@@ -133,14 +133,29 @@ async fn main() -> Result<()> {
133133 info ! ( "Labels: {:?}" , labels) ;
134134 }
135135
136- // Handle tunnel mode
137136 if args. tunnel {
138137 info ! ( "Tunnel mode enabled" ) ;
139- setup_tunnel ( & args) . await ?;
140138 }
141139
142- // Main connection loop with reconnection
140+ // Main connection loop with reconnection.
143141 loop {
142+ // In tunnel mode, (re)establish the mesh on EVERY iteration before dialing
143+ // the controller. setup_tunnel is idempotent (see its body): first pass it
144+ // starts tailscaled + joins; on a reconnect it re-runs `tailscale up`, which
145+ // re-registers the node if headscale reaped it. Without this, a reaped daemon
146+ // loops forever dialing the controller through a dead tunnel and never rejoins
147+ // (container stays Running, node stays gone from headscale). On failure, log
148+ // and fall through to the backoff sleep rather than crash — a transient mesh
149+ // failure must not kill a long-lived daemon.
150+ if args. tunnel {
151+ if let Err ( e) = setup_tunnel ( & args) . await {
152+ error ! ( "Failed to (re)establish tunnel: {}; retrying" , e) ;
153+ warn ! ( "Reconnecting in {} seconds..." , args. reconnect_interval) ;
154+ tokio:: time:: sleep ( Duration :: from_secs ( args. reconnect_interval ) ) . await ;
155+ continue ;
156+ }
157+ }
158+
144159 match connect_and_serve (
145160 & args. server_url ,
146161 & daemon_id,
@@ -793,30 +808,49 @@ async fn setup_tunnel(args: &Args) -> Result<()> {
793808 ) ) ;
794809 }
795810
796- info ! ( "Starting tailscaled..." ) ;
811+ // setup_tunnel is IDEMPOTENT: main()'s reconnect loop calls it before every
812+ // connect attempt so a dropped/reaped node re-establishes the mesh (not just the
813+ // WebSocket). tailscaled is a long-lived singleton — spawning a second one would
814+ // collide on the SOCKS5 port and the state lock — so we only start it if it isn't
815+ // already up. `tailscale up`, by contrast, ALWAYS re-runs: it is idempotent when
816+ // already connected (cheap no-op) and is exactly what re-activates the node with
817+ // headscale after an ephemeral reap. This is the fix for "container Running but
818+ // node gone from headscale": before, tailscale up ran once at startup only, so a
819+ // reaped daemon looped forever dialing the controller through a dead tunnel and
820+ // 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 ) ;
797826
798- // Start tailscaled in background.
799- //
800- // --socks5-server is what makes tunnel mode actually work: with
801- // --tun=userspace-networking there is no TUN device and thus no kernel route
802- // to the tailnet (100.64.0.0/10), so a plain socket to the controller's mesh
803- // address always fails. The SOCKS5 proxy is the entry point INTO tailscaled's
804- // userspace network stack; connect_and_serve dials the controller through it
805- // (see TUNNEL_SOCKS_PROXY) so the WebSocket rides the mesh. Bound to localhost
806- // so only this container's daemon can use it.
807- let _tailscaled = Command :: new ( "tailscaled" )
808- . arg ( "--tun=userspace-networking" )
809- . arg ( format ! ( "--socks5-server={}" , TUNNEL_SOCKS_PROXY ) )
810- . arg ( "--state=/var/lib/tailscale/tailscaled.state" )
811- . spawn ( )
812- . context ( "Failed to start tailscaled" ) ?;
813-
814- // Give tailscaled time to start
815- tokio:: time:: sleep ( Duration :: from_secs ( 2 ) ) . await ;
827+ if tailscaled_running {
828+ info ! ( "tailscaled already running; re-joining mesh" ) ;
829+ } else {
830+ info ! ( "Starting tailscaled..." ) ;
831+ // --socks5-server is what makes tunnel mode actually work: with
832+ // --tun=userspace-networking there is no TUN device and thus no kernel route
833+ // to the tailnet (100.64.0.0/10), so a plain socket to the controller's mesh
834+ // address always fails. The SOCKS5 proxy is the entry point INTO tailscaled's
835+ // userspace network stack; connect_and_serve dials the controller through it
836+ // (see TUNNEL_SOCKS_PROXY) so the WebSocket rides the mesh. Bound to localhost
837+ // so only this container's daemon can use it.
838+ Command :: new ( "tailscaled" )
839+ . arg ( "--tun=userspace-networking" )
840+ . arg ( format ! ( "--socks5-server={}" , TUNNEL_SOCKS_PROXY ) )
841+ . arg ( "--state=/var/lib/tailscale/tailscaled.state" )
842+ . spawn ( )
843+ . context ( "Failed to start tailscaled" ) ?;
844+
845+ // Give tailscaled time to start
846+ tokio:: time:: sleep ( Duration :: from_secs ( 2 ) ) . await ;
847+ }
816848
817849 info ! ( "Joining mesh network..." ) ;
818850
819- // Join mesh
851+ // Join mesh. Always run (even when tailscaled was already up): if the node was
852+ // reaped by headscale this re-registers it; if it's still a valid member this is
853+ // an idempotent no-op.
820854 let output = Command :: new ( "tailscale" )
821855 . arg ( "up" )
822856 . arg ( format ! ( "--authkey={}" , authkey) )
0 commit comments