@@ -80,7 +80,7 @@ struct Args {
8080 reconnect_interval : u64 ,
8181
8282 /// Heartbeat interval in seconds
83- #[ arg( long, default_value = "10 " ) ]
83+ #[ arg( long, default_value = "5 " ) ]
8484 heartbeat_interval : u64 ,
8585
8686 /// Labels in key=value format (e.g., --label env=prod --label region=us-west)
@@ -137,6 +137,11 @@ async fn main() -> Result<()> {
137137 info ! ( "Tunnel mode enabled" ) ;
138138 }
139139
140+ // Set when the previous attempt could not REACH the controller (dial through the
141+ // SOCKS5 proxy failed), as opposed to a clean mid-session drop. It signals the
142+ // next setup_tunnel to force a full netmap refresh — see below.
143+ let mut stale_netmap = false ;
144+
140145 // Main connection loop with reconnection.
141146 loop {
142147 // In tunnel mode, (re)establish the mesh on EVERY iteration before dialing
@@ -147,8 +152,18 @@ async fn main() -> Result<()> {
147152 // (container stays Running, node stays gone from headscale). On failure, log
148153 // and fall through to the backoff sleep rather than crash — a transient mesh
149154 // failure must not kill a long-lived daemon.
155+ //
156+ // stale_netmap forces a FULL netmap refresh (tailscale down/up) this pass. It
157+ // is set only after a dial FAILURE below: the controller is ephemeral and gets
158+ // a NEW mesh IP on every restart, and headscale (v0.23) does not reliably push
159+ // that new peer to already-connected daemons. So the daemon keeps resolving the
160+ // controller's MagicDNS name to the DEAD old IP and every dial fails — for as
161+ // long as it takes some unrelated event to jog headscale into re-sending the
162+ // map (observed: ~11 min). A plain `tailscale up` while already connected is a
163+ // no-op that does NOT re-fetch the map; bouncing the control session does, so
164+ // the next dial resolves to the controller's current IP and connects in seconds.
150165 if args. tunnel {
151- if let Err ( e) = setup_tunnel ( & args) . await {
166+ if let Err ( e) = setup_tunnel ( & args, stale_netmap ) . await {
152167 error ! ( "Failed to (re)establish tunnel: {}; retrying" , e) ;
153168 warn ! ( "Reconnecting in {} seconds..." , args. reconnect_interval) ;
154169 tokio:: time:: sleep ( Duration :: from_secs ( args. reconnect_interval ) ) . await ;
@@ -175,9 +190,21 @@ async fn main() -> Result<()> {
175190 // The specific reason (server Close, socket error, stream end,
176191 // registration failure) is already logged at the break site inside
177192 // serve(); avoid claiming "gracefully" here since Disconnected also
178- // covers error paths. main() only needs to know: reconnect.
179- Ok ( ServeOutcome :: Disconnected ) => info ! ( "Connection closed, reconnecting" ) ,
180- Err ( e) => error ! ( "Connection error: {}" , e) ,
193+ // covers error paths. main() only needs to know: reconnect. A clean drop
194+ // means the map WAS fine (we had a live session), so don't force a refresh.
195+ Ok ( ServeOutcome :: Disconnected ) => {
196+ info ! ( "Connection closed, reconnecting" ) ;
197+ stale_netmap = false ;
198+ }
199+ // connect_and_serve only returns Err when the connection was never
200+ // ESTABLISHED (request build, SOCKS dial, or WebSocket handshake failed) —
201+ // post-handshake serve() errors are folded into Disconnected above. So we
202+ // never reached the controller; the likely cause is a stale netmap pointing
203+ // at its old IP, so force a full refresh before the next attempt.
204+ Err ( e) => {
205+ error ! ( "Connection error: {}" , e) ;
206+ stale_netmap = true ;
207+ }
181208 }
182209
183210 warn ! ( "Reconnecting in {} seconds..." , args. reconnect_interval) ;
@@ -240,7 +267,9 @@ async fn connect_and_serve(
240267 . await
241268 . context ( "tunnel: WebSocket handshake over SOCKS5 failed" ) ?;
242269 log_negotiated_protocol ( & response) ;
243- return serve ( ws_stream, daemon_id, heartbeat_interval, labels, shutdown_signal ( ) ) . await ;
270+ return Ok ( session_outcome (
271+ serve ( ws_stream, daemon_id, heartbeat_interval, labels, shutdown_signal ( ) ) . await ,
272+ ) ) ;
244273 }
245274
246275 let ( ws_stream, response) = match tokio_tungstenite:: connect_async ( request) . await {
@@ -251,7 +280,25 @@ async fn connect_and_serve(
251280 }
252281 } ;
253282 log_negotiated_protocol ( & response) ;
254- serve ( ws_stream, daemon_id, heartbeat_interval, labels, shutdown_signal ( ) ) . await
283+ Ok ( session_outcome (
284+ serve ( ws_stream, daemon_id, heartbeat_interval, labels, shutdown_signal ( ) ) . await ,
285+ ) )
286+ }
287+
288+ /// Collapse a serve() result into a ServeOutcome for the POST-handshake path. Once the
289+ /// WebSocket is up the mesh path is proven good, so a serve() error is a post-connect
290+ /// failure (registration send, serde, socket reset mid-session) — NOT an unreachable
291+ /// controller. Map it to Disconnected (logged) so main() reconnects WITHOUT forcing a
292+ /// netmap refresh; that keeps an Err from connect_and_serve meaning only "failed to
293+ /// establish the connection", which is exactly the condition stale_netmap keys off of.
294+ fn session_outcome ( result : Result < ServeOutcome > ) -> ServeOutcome {
295+ match result {
296+ Ok ( outcome) => outcome,
297+ Err ( e) => {
298+ error ! ( "Session error after connect: {}; reconnecting" , e) ;
299+ ServeOutcome :: Disconnected
300+ }
301+ }
255302}
256303
257304/// Log the WebSocket subprotocol the server negotiated (shared by both transports).
@@ -784,7 +831,7 @@ where
784831 Ok ( ( ) )
785832}
786833
787- async fn setup_tunnel ( args : & Args ) -> Result < ( ) > {
834+ async fn setup_tunnel ( args : & Args , force_refresh : bool ) -> Result < ( ) > {
788835 use std:: process:: Command ;
789836
790837 // Validate required arguments
@@ -818,14 +865,33 @@ async fn setup_tunnel(args: &Args) -> Result<()> {
818865 // node gone from headscale": before, tailscale up ran once at startup only, so a
819866 // reaped daemon looped forever dialing the controller through a dead tunnel and
820867 // never re-registered.
821- let tailscaled_running = Command :: new ( "tailscale" )
822- . arg ( "status" )
823- . output ( )
824- . map ( |o| o. status . success ( ) )
825- . unwrap_or ( false ) ;
826-
827- if tailscaled_running {
828- info ! ( "tailscaled already running; re-joining mesh" ) ;
868+ //
869+ // Readiness needs BOTH checks — each covers the other's blind spot:
870+ // 1. The SOCKS5 port is reachable. connect_and_serve dials the controller THROUGH
871+ // this proxy, so the listener being up is the exact invariant that matters. But
872+ // a raw connect is a false positive if ANY process squats on 127.0.0.1:1055 —
873+ // we'd skip our spawn and then `tailscale up` fails/retries forever against a
874+ // proxy that isn't tailscaled's.
875+ // 2. `tailscale status` succeeds. This confirms a functioning tailscaled is
876+ // actually running (not a squatter, not a half-dead daemon). Alone it is also
877+ // insufficient: it passes for ANY tailscaled — including a system/sidecar one
878+ // started WITHOUT --socks5-server — so the proxy could still be absent.
879+ // Together they mean: proxy reachable AND owned by a live tailscaled => our tunnel is
880+ // truly up, skip. Otherwise (re)start our own tailscaled with the SOCKS listener; if
881+ // a foreign process holds the port, our spawn can't bind it and the poll below fails
882+ // with a clear error rather than looping silently.
883+ let socks_reachable = tokio:: net:: TcpStream :: connect ( TUNNEL_SOCKS_PROXY )
884+ . await
885+ . is_ok ( ) ;
886+ let tailscaled_healthy = socks_reachable
887+ && Command :: new ( "tailscale" )
888+ . arg ( "status" )
889+ . output ( )
890+ . map ( |o| o. status . success ( ) )
891+ . unwrap_or ( false ) ;
892+
893+ if tailscaled_healthy {
894+ info ! ( "tailscaled SOCKS5 proxy already listening on {}; re-joining mesh" , TUNNEL_SOCKS_PROXY ) ;
829895 } else {
830896 info ! ( "Starting tailscaled..." ) ;
831897 // --socks5-server is what makes tunnel mode actually work: with
@@ -842,8 +908,49 @@ async fn setup_tunnel(args: &Args) -> Result<()> {
842908 . spawn ( )
843909 . context ( "Failed to start tailscaled" ) ?;
844910
845- // Give tailscaled time to start
846- tokio:: time:: sleep ( Duration :: from_secs ( 2 ) ) . await ;
911+ // Wait for the SOCKS5 listener to actually come up rather than sleeping a
912+ // fixed interval and hoping. If it never binds — e.g. a foreign tailscaled
913+ // already holds the state lock so our spawn exited, or the port is taken —
914+ // fail with a clear, actionable error instead of falling through to an opaque
915+ // "failed to reach controller through SOCKS5 proxy" on every connect. main()'s
916+ // loop then retries setup_tunnel after its backoff, so a slow start recovers.
917+ //
918+ // Probe the PORT only here — NOT `tailscale status`. We have just spawned
919+ // tailscaled but have not yet run `tailscale up` (that happens below), so the
920+ // node is still logged out and `tailscale status` would exit non-zero: gating on
921+ // it would be circular (status needs `up`, `up` needs us past this poll) and wedge
922+ // the daemon forever at "Active daemons: 0". The proxy being served IS the
923+ // readiness signal for a freshly-started tailscaled; the `tailscale up` that
924+ // follows surfaces any real join failure. (The skip-gate above additionally
925+ // checks status, which is valid there because a prior iteration already ran up.)
926+ let mut ready = false ;
927+ for _ in 0 ..20 {
928+ if tokio:: net:: TcpStream :: connect ( TUNNEL_SOCKS_PROXY ) . await . is_ok ( ) {
929+ ready = true ;
930+ break ;
931+ }
932+ tokio:: time:: sleep ( Duration :: from_millis ( 500 ) ) . await ;
933+ }
934+ if !ready {
935+ return Err ( anyhow:: anyhow!(
936+ "tailscaled SOCKS5 proxy never came up on {} after starting tailscaled \
937+ (is another tailscaled holding /var/lib/tailscale/tailscaled.state, or is \
938+ the port in use?)",
939+ TUNNEL_SOCKS_PROXY
940+ ) ) ;
941+ }
942+ }
943+
944+ // Force a full netmap refresh when the last attempt couldn't reach the controller
945+ // (see the stale_netmap comment in main). `tailscale up` on an already-connected
946+ // node is a no-op that reuses the CACHED netmap — so it keeps resolving the
947+ // controller's MagicDNS name to its old, dead IP. Bringing the node DOWN first
948+ // drops the control session; the `tailscale up` that follows re-polls headscale and
949+ // pulls a fresh map that includes the controller's current IP. Best-effort: a
950+ // failed `down` (e.g. already down) must not abort the re-join below.
951+ if force_refresh {
952+ info ! ( "Forcing netmap refresh (tailscale down) after unreachable controller" ) ;
953+ let _ = Command :: new ( "tailscale" ) . arg ( "down" ) . output ( ) ;
847954 }
848955
849956 info ! ( "Joining mesh network..." ) ;
0 commit comments