@@ -196,9 +196,11 @@ async fn main() -> Result<()> {
196196 info ! ( "Connection closed, reconnecting" ) ;
197197 stale_netmap = false ;
198198 }
199- // A dial/handshake error means we never reached the controller — the
200- // likely cause is a stale netmap pointing at the controller's old IP, so
201- // force a full refresh before the next attempt.
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.
202204 Err ( e) => {
203205 error ! ( "Connection error: {}" , e) ;
204206 stale_netmap = true ;
@@ -265,7 +267,9 @@ async fn connect_and_serve(
265267 . await
266268 . context ( "tunnel: WebSocket handshake over SOCKS5 failed" ) ?;
267269 log_negotiated_protocol ( & response) ;
268- 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+ ) ) ;
269273 }
270274
271275 let ( ws_stream, response) = match tokio_tungstenite:: connect_async ( request) . await {
@@ -276,7 +280,25 @@ async fn connect_and_serve(
276280 }
277281 } ;
278282 log_negotiated_protocol ( & response) ;
279- 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+ }
280302}
281303
282304/// Log the WebSocket subprotocol the server negotiated (shared by both transports).
@@ -844,19 +866,31 @@ async fn setup_tunnel(args: &Args, force_refresh: bool) -> Result<()> {
844866 // reaped daemon looped forever dialing the controller through a dead tunnel and
845867 // never re-registered.
846868 //
847- // Readiness is probed by connecting to the SOCKS5 port, NOT by `tailscale status`.
848- // connect_and_serve dials the controller THROUGH that proxy, so the listener being
849- // up is the exact invariant that matters. A bare `tailscale status` check would
850- // pass for ANY running tailscaled — including a system/sidecar one started without
851- // --socks5-server — and we'd then skip our spawn, leaving the proxy absent so every
852- // connect fails and the loop never recovers. Probing the port instead means: proxy
853- // reachable => our tunnel is truly up, skip; not reachable => (re)start our own
854- // tailscaled with the SOCKS listener, even if some other tailscaled exists.
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.
855883 let socks_reachable = tokio:: net:: TcpStream :: connect ( TUNNEL_SOCKS_PROXY )
856884 . await
857885 . is_ok ( ) ;
858-
859- if socks_reachable {
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 {
860894 info ! ( "tailscaled SOCKS5 proxy already listening on {}; re-joining mesh" , TUNNEL_SOCKS_PROXY ) ;
861895 } else {
862896 info ! ( "Starting tailscaled..." ) ;
@@ -880,9 +914,20 @@ async fn setup_tunnel(args: &Args, force_refresh: bool) -> Result<()> {
880914 // fail with a clear, actionable error instead of falling through to an opaque
881915 // "failed to reach controller through SOCKS5 proxy" on every connect. main()'s
882916 // loop then retries setup_tunnel after its backoff, so a slow start recovers.
917+ // Ready only when the port is reachable AND `tailscale status` succeeds — the
918+ // same two-part check as above. A bare port connect would accept a foreign
919+ // process squatting on 1055; requiring status confirms it is OUR tailscaled that
920+ // came up, so we never fall through to a `tailscale up` that can't work.
883921 let mut ready = false ;
884922 for _ in 0 ..20 {
885- if tokio:: net:: TcpStream :: connect ( TUNNEL_SOCKS_PROXY ) . await . is_ok ( ) {
923+ let socks_up = tokio:: net:: TcpStream :: connect ( TUNNEL_SOCKS_PROXY ) . await . is_ok ( ) ;
924+ if socks_up
925+ && Command :: new ( "tailscale" )
926+ . arg ( "status" )
927+ . output ( )
928+ . map ( |o| o. status . success ( ) )
929+ . unwrap_or ( false )
930+ {
886931 ready = true ;
887932 break ;
888933 }
@@ -891,8 +936,8 @@ async fn setup_tunnel(args: &Args, force_refresh: bool) -> Result<()> {
891936 if !ready {
892937 return Err ( anyhow:: anyhow!(
893938 "tailscaled SOCKS5 proxy never came up on {} after starting tailscaled \
894- (is another tailscaled holding /var/lib/tailscale/tailscaled.state , or is \
895- the port in use ?)",
939+ (is another process holding the port , or another tailscaled holding \
940+ /var/lib/tailscale/tailscaled.state ?)",
896941 TUNNEL_SOCKS_PROXY
897942 ) ) ;
898943 }
0 commit comments