Skip to content

Commit 766160c

Browse files
authored
fix: refresh netmap error (#38)
* fix: refresh netmap error Signed-off-by: kerthcet <kerthcet@gmail.com> * fix the interval time Signed-off-by: kerthcet <kerthcet@gmail.com> * address comments Signed-off-by: kerthcet <kerthcet@gmail.com> * fix test error Signed-off-by: kerthcet <kerthcet@gmail.com> --------- Signed-off-by: kerthcet <kerthcet@gmail.com>
1 parent 136febd commit 766160c

3 files changed

Lines changed: 155 additions & 29 deletions

File tree

sandd/src/main.rs

Lines changed: 125 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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...");

server/src/lib.rs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ use std::sync::Arc;
1515
use std::time::Duration;
1616
use tokio::runtime::Runtime;
1717
use tokio::sync::oneshot;
18-
use tracing_subscriber;
1918
use uuid::Uuid;
2019

2120
use sandd_protocol::Message;
@@ -106,7 +105,7 @@ impl Server {
106105

107106
// Setup tunnel
108107
runtime.block_on(async {
109-
setup_tunnel_controller(&config)
108+
setup_tunnel_controller(&config, verbose)
110109
.await
111110
.map_err(|e| PyRuntimeError::new_err(format!("Tunnel setup failed: {}", e)))
112111
})?;
@@ -765,8 +764,8 @@ pub struct PyStats {
765764
}
766765

767766
/// Setup tunnel for controller
768-
async fn setup_tunnel_controller(config: &TunnelConfig) -> anyhow::Result<()> {
769-
use std::process::Command;
767+
async fn setup_tunnel_controller(config: &TunnelConfig, verbose: bool) -> anyhow::Result<()> {
768+
use std::process::{Command, Stdio};
770769

771770
// Check if tailscale is installed by trying to run it
772771
let tailscale_check = Command::new("tailscale").arg("version").output();
@@ -780,12 +779,22 @@ async fn setup_tunnel_controller(config: &TunnelConfig) -> anyhow::Result<()> {
780779

781780
tracing::info!("Starting tailscaled...");
782781

783-
// Start tailscaled in background (if not already running)
784-
let _tailscaled = Command::new("tailscaled")
782+
// Start tailscaled in the background. The SAME `verbose` flag that gates sandd's own
783+
// logging also gates tailscaled's routine chatter: when off, we pass --verbose=-1 to
784+
// silence its per-packet magicsock/netmap/health lines and discard its STDOUT, so it
785+
// doesn't flood a `kubectl exec` REPL. STDERR is deliberately KEPT: --verbose=-1
786+
// already mutes the routine noise there, but a fatal startup failure (bad flag,
787+
// permission denied, or another tailscaled holding the state lock) is reported on
788+
// stderr and would otherwise be lost — `tailscale up` below only says it can't reach
789+
// the daemon, never WHY it exited. Keeping stderr makes those failures diagnosable.
790+
let mut tailscaled = Command::new("tailscaled");
791+
tailscaled
785792
.arg("--tun=userspace-networking")
786-
.arg("--state=/var/lib/tailscale/tailscaled.state")
787-
.spawn()
788-
.context("Failed to start tailscaled")?;
793+
.arg("--state=/var/lib/tailscale/tailscaled.state");
794+
if !verbose {
795+
tailscaled.arg("--verbose=-1").stdout(Stdio::null());
796+
}
797+
let _tailscaled = tailscaled.spawn().context("Failed to start tailscaled")?;
789798

790799
// Give tailscaled time to start
791800
tokio::time::sleep(Duration::from_secs(2)).await;

server/src/server.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,21 @@ async fn stats_handler(State(registry): State<Arc<DaemonRegistry>>) -> impl Into
294294
}
295295

296296
async fn heartbeat_monitor(registry: Arc<DaemonRegistry>) {
297-
let mut interval = tokio::time::interval(Duration::from_secs(30));
297+
// Tick every 5s so an ungraceful death (instance hard-killed, network yanked —
298+
// no Close frame, so the immediate remove() on disconnect never fires) is noticed
299+
// within ~5s of crossing the threshold, not up to a full tick later.
300+
let mut interval = tokio::time::interval(Duration::from_secs(5));
298301
loop {
299302
interval.tick().await;
300303

301-
let removed = registry.cleanup_stale(90); // 90 second timeout
304+
// 30s threshold against a 5s daemon heartbeat interval = ~6 missed beats before
305+
// reaping. That margin is deliberate: mesh churn (DERP peer reconfig, netmap
306+
// propagation) can stall heartbeats for tens of seconds WITHOUT the daemon being
307+
// dead, and reaping a daemon whose socket is still open orphans it (its later
308+
// heartbeats hit no registry entry and are ignored until the socket truly
309+
// breaks). Detection is ~30-35s vs the old ~90-120s; clean disconnects are still
310+
// removed instantly on Close (see the remove() on the disconnect path above).
311+
let removed = registry.cleanup_stale(30);
302312
if removed > 0 {
303313
warn!("Cleaned up {} stale daemon connections", removed);
304314
}

0 commit comments

Comments
 (0)