Phase 8b: LAN-first probe with tailnet fallback #63
@@ -524,6 +524,61 @@ impl QuicClient {
|
|||||||
.with_context(|| format!("completing handshake with {addr}"))
|
.with_context(|| format!("completing handshake with {addr}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 8b (2026-07-14): LAN-first probe with a tailnet
|
||||||
|
/// fallback. Attempts `lan` (when given) under a tight budget;
|
||||||
|
/// if the handshake doesn't complete in `lan_probe`, falls
|
||||||
|
/// back to `tailscale` (when given) under a longer budget.
|
||||||
|
///
|
||||||
|
/// Rationale: on the fleet's 1G LAN a handshake typically
|
||||||
|
/// finishes in single-digit ms. Tailscale (WireGuard over WAN
|
||||||
|
/// for roaming clients) can take 100-500 ms. A short LAN
|
||||||
|
/// probe lets in-office nodes take the fast path without
|
||||||
|
/// starving roaming nodes when LAN isn't reachable.
|
||||||
|
///
|
||||||
|
/// If both address slots are `None` the call errors
|
||||||
|
/// immediately rather than hanging.
|
||||||
|
pub async fn connect_lan_first(
|
||||||
|
&self,
|
||||||
|
expected_server_name: &str,
|
||||||
|
lan: Option<SocketAddr>,
|
||||||
|
tailscale: Option<SocketAddr>,
|
||||||
|
lan_probe: std::time::Duration,
|
||||||
|
) -> Result<(quinn::Connection, ConnectRoute)> {
|
||||||
|
if let Some(lan_addr) = lan {
|
||||||
|
match tokio::time::timeout(
|
||||||
|
lan_probe,
|
||||||
|
self.connect(lan_addr, expected_server_name),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(conn)) => return Ok((conn, ConnectRoute::Lan(lan_addr))),
|
||||||
|
Ok(Err(e)) => tracing::debug!(
|
||||||
|
peer = expected_server_name,
|
||||||
|
lan = %lan_addr,
|
||||||
|
error = %e,
|
||||||
|
"LAN dial failed; trying tailnet if configured"
|
||||||
|
),
|
||||||
|
Err(_) => tracing::debug!(
|
||||||
|
peer = expected_server_name,
|
||||||
|
lan = %lan_addr,
|
||||||
|
"LAN probe hit deadline; falling back to tailnet"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ts_addr) = tailscale {
|
||||||
|
let conn = self
|
||||||
|
.connect(ts_addr, expected_server_name)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("tailnet fallback to {ts_addr}"))?;
|
||||||
|
return Ok((conn, ConnectRoute::Tailscale(ts_addr)));
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"no reachable address for {expected_server_name}: LAN {} + tailnet {} both failed or absent",
|
||||||
|
lan.map(|a| a.to_string()).unwrap_or_else(|| "-".into()),
|
||||||
|
tailscale.map(|a| a.to_string()).unwrap_or_else(|| "-".into())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Graceful shutdown.
|
/// Graceful shutdown.
|
||||||
pub async fn shutdown(&self) {
|
pub async fn shutdown(&self) {
|
||||||
self.endpoint
|
self.endpoint
|
||||||
@@ -532,6 +587,16 @@ impl QuicClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 8b (2026-07-14): which route won the LAN-first probe.
|
||||||
|
/// Returned from [`QuicClient::connect_lan_first`] so operators
|
||||||
|
/// (and telemetry) can see which side of the network was chosen
|
||||||
|
/// per connection.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ConnectRoute {
|
||||||
|
Lan(SocketAddr),
|
||||||
|
Tailscale(SocketAddr),
|
||||||
|
}
|
||||||
|
|
||||||
/// Open a bidi stream on `conn`, send `payload`, read back the peer's
|
/// Open a bidi stream on `conn`, send `payload`, read back the peer's
|
||||||
/// response (bounded by [`MAX_MESSAGE_BYTES`]). This is the client
|
/// response (bounded by [`MAX_MESSAGE_BYTES`]). This is the client
|
||||||
/// side of the ping RPC.
|
/// side of the ping RPC.
|
||||||
@@ -695,6 +760,98 @@ mod tests {
|
|||||||
accept_task.abort();
|
accept_task.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_lan_first_takes_lan_when_reachable() {
|
||||||
|
// Live LAN server, valid tailscale would just be a decoy —
|
||||||
|
// we should never dial it. Assert the returned route.
|
||||||
|
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
|
||||||
|
let server = QuicServer::bind(loopback(0), id_b).unwrap();
|
||||||
|
let server_addr = server.local_addr().unwrap();
|
||||||
|
let accept_task = tokio::spawn(async move {
|
||||||
|
if let Some(res) = server.accept().await {
|
||||||
|
let conn = res.expect("accept");
|
||||||
|
let _ = ping_handler_loop(conn).await;
|
||||||
|
}
|
||||||
|
server.shutdown().await;
|
||||||
|
});
|
||||||
|
let client = QuicClient::new(loopback(0), id_a).unwrap();
|
||||||
|
// Fake tailscale addr = a port nothing binds. Must not be
|
||||||
|
// dialed since LAN succeeded first.
|
||||||
|
let fake_ts: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||||
|
let (conn, route) = client
|
||||||
|
.connect_lan_first(
|
||||||
|
"b",
|
||||||
|
Some(server_addr),
|
||||||
|
Some(fake_ts),
|
||||||
|
Duration::from_secs(2),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("connect_lan_first");
|
||||||
|
assert!(matches!(route, ConnectRoute::Lan(a) if a == server_addr));
|
||||||
|
let response = ping(&conn, b"hi").await.unwrap();
|
||||||
|
assert_eq!(response, b"pong:hi");
|
||||||
|
conn.close(VarInt::from_u32(0), b"done");
|
||||||
|
client.shutdown().await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
accept_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_lan_first_falls_through_to_tailscale_on_lan_deadline() {
|
||||||
|
// LAN addr is a black hole (drops SYNs). Probe budget = 100ms.
|
||||||
|
// Tailscale addr = real server. Must fall through and pick
|
||||||
|
// the tailscale route.
|
||||||
|
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
|
||||||
|
let server = QuicServer::bind(loopback(0), id_b).unwrap();
|
||||||
|
let server_addr = server.local_addr().unwrap();
|
||||||
|
let accept_task = tokio::spawn(async move {
|
||||||
|
if let Some(res) = server.accept().await {
|
||||||
|
let conn = res.expect("accept");
|
||||||
|
let _ = ping_handler_loop(conn).await;
|
||||||
|
}
|
||||||
|
server.shutdown().await;
|
||||||
|
});
|
||||||
|
// 240.x.x.x is RFC1112 unroutable — SYN just times out.
|
||||||
|
// We don't need a live server; the deadline must fire on
|
||||||
|
// its own.
|
||||||
|
let black_hole: SocketAddr = "240.0.0.1:1".parse().unwrap();
|
||||||
|
let client = QuicClient::new(loopback(0), id_a).unwrap();
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let (conn, route) = client
|
||||||
|
.connect_lan_first(
|
||||||
|
"b",
|
||||||
|
Some(black_hole),
|
||||||
|
Some(server_addr),
|
||||||
|
Duration::from_millis(150),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("connect_lan_first");
|
||||||
|
assert!(matches!(route, ConnectRoute::Tailscale(a) if a == server_addr));
|
||||||
|
// Sanity: we shouldn't have waited far beyond the probe
|
||||||
|
// budget before starting the tailscale attempt.
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(3),
|
||||||
|
"took too long: {:?}",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
conn.close(VarInt::from_u32(0), b"done");
|
||||||
|
client.shutdown().await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
accept_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_lan_first_errors_when_both_addrs_absent() {
|
||||||
|
let (id_a, _id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap();
|
||||||
|
let client = QuicClient::new(loopback(0), id_a).unwrap();
|
||||||
|
let err = client
|
||||||
|
.connect_lan_first("b", None, None, Duration::from_millis(50))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("no reachable address"));
|
||||||
|
client.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn client_rejects_peer_with_wrong_ca() {
|
async fn client_rejects_peer_with_wrong_ca() {
|
||||||
// Two CAs, A and X. A pair (id_a, id_b) share CA_A; a rogue id_x
|
// Two CAs, A and X. A pair (id_a, id_b) share CA_A; a rogue id_x
|
||||||
|
|||||||
Reference in New Issue
Block a user