diff --git a/README.md b/README.md index d0e9ce9..53eb614 100644 --- a/README.md +++ b/README.md @@ -185,4 +185,5 @@ We welcome any kind of contributions, feedback, and suggestions! See [DEVELOP.md ## License -Apache-2.0 — see [LICENSE](./LICENSE). +Apache-2.0 — see [LICENSE](./LICENSE). Third-party components (e.g. Tailscale, used by +tunnel mode) are listed in [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..7f5ccbf --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,15 @@ +# Third-party notices + +SandD is licensed under Apache-2.0 (see [LICENSE](./LICENSE)). It relies on the +following third-party software, which is licensed separately. + +## Tailscale + +Tunnel mode uses the [Tailscale](https://github.com/tailscale/tailscale) client +(`tailscale`/`tailscaled`), © Tailscale Inc., licensed under +[BSD-3-Clause](https://github.com/tailscale/tailscale/blob/main/LICENSE). + +SandD invokes it as a separate process and fetches it at runtime (the install script +and tunnel Dockerfiles pull the official binaries) — it is not linked as a library or +included in SandD's source. If you build and redistribute an image with Tailscale +baked in, retain its copyright notice per BSD-3-Clause. diff --git a/sandd/src/main.rs b/sandd/src/main.rs index 7f4858a..824b52e 100644 --- a/sandd/src/main.rs +++ b/sandd/src/main.rs @@ -133,14 +133,29 @@ async fn main() -> Result<()> { info!("Labels: {:?}", labels); } - // Handle tunnel mode if args.tunnel { info!("Tunnel mode enabled"); - setup_tunnel(&args).await?; } - // Main connection loop with reconnection + // Main connection loop with reconnection. loop { + // In tunnel mode, (re)establish the mesh on EVERY iteration before dialing + // the controller. setup_tunnel is idempotent (see its body): first pass it + // starts tailscaled + joins; on a reconnect it re-runs `tailscale up`, which + // re-registers the node if headscale reaped it. Without this, a reaped daemon + // loops forever dialing the controller through a dead tunnel and never rejoins + // (container stays Running, node stays gone from headscale). On failure, log + // and fall through to the backoff sleep rather than crash — a transient mesh + // failure must not kill a long-lived daemon. + if args.tunnel { + if let Err(e) = setup_tunnel(&args).await { + error!("Failed to (re)establish tunnel: {}; retrying", e); + warn!("Reconnecting in {} seconds...", args.reconnect_interval); + tokio::time::sleep(Duration::from_secs(args.reconnect_interval)).await; + continue; + } + } + match connect_and_serve( &args.server_url, &daemon_id, @@ -793,30 +808,49 @@ async fn setup_tunnel(args: &Args) -> Result<()> { )); } - info!("Starting tailscaled..."); + // setup_tunnel is IDEMPOTENT: main()'s reconnect loop calls it before every + // connect attempt so a dropped/reaped node re-establishes the mesh (not just the + // WebSocket). tailscaled is a long-lived singleton — spawning a second one would + // collide on the SOCKS5 port and the state lock — so we only start it if it isn't + // already up. `tailscale up`, by contrast, ALWAYS re-runs: it is idempotent when + // already connected (cheap no-op) and is exactly what re-activates the node with + // headscale after an ephemeral reap. This is the fix for "container Running but + // node gone from headscale": before, tailscale up ran once at startup only, so a + // reaped daemon looped forever dialing the controller through a dead tunnel and + // never re-registered. + let tailscaled_running = Command::new("tailscale") + .arg("status") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); - // Start tailscaled in background. - // - // --socks5-server is what makes tunnel mode actually work: with - // --tun=userspace-networking there is no TUN device and thus no kernel route - // to the tailnet (100.64.0.0/10), so a plain socket to the controller's mesh - // address always fails. The SOCKS5 proxy is the entry point INTO tailscaled's - // userspace network stack; connect_and_serve dials the controller through it - // (see TUNNEL_SOCKS_PROXY) so the WebSocket rides the mesh. Bound to localhost - // so only this container's daemon can use it. - let _tailscaled = Command::new("tailscaled") - .arg("--tun=userspace-networking") - .arg(format!("--socks5-server={}", TUNNEL_SOCKS_PROXY)) - .arg("--state=/var/lib/tailscale/tailscaled.state") - .spawn() - .context("Failed to start tailscaled")?; - - // Give tailscaled time to start - tokio::time::sleep(Duration::from_secs(2)).await; + if tailscaled_running { + info!("tailscaled already running; re-joining mesh"); + } else { + info!("Starting tailscaled..."); + // --socks5-server is what makes tunnel mode actually work: with + // --tun=userspace-networking there is no TUN device and thus no kernel route + // to the tailnet (100.64.0.0/10), so a plain socket to the controller's mesh + // address always fails. The SOCKS5 proxy is the entry point INTO tailscaled's + // userspace network stack; connect_and_serve dials the controller through it + // (see TUNNEL_SOCKS_PROXY) so the WebSocket rides the mesh. Bound to localhost + // so only this container's daemon can use it. + Command::new("tailscaled") + .arg("--tun=userspace-networking") + .arg(format!("--socks5-server={}", TUNNEL_SOCKS_PROXY)) + .arg("--state=/var/lib/tailscale/tailscaled.state") + .spawn() + .context("Failed to start tailscaled")?; + + // Give tailscaled time to start + tokio::time::sleep(Duration::from_secs(2)).await; + } info!("Joining mesh network..."); - // Join mesh + // Join mesh. Always run (even when tailscaled was already up): if the node was + // reaped by headscale this re-registers it; if it's still a valid member this is + // an idempotent no-op. let output = Command::new("tailscale") .arg("up") .arg(format!("--authkey={}", authkey))