Phase 8c: cluster-peer-status + cluster-repair support --tailscale-addr
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 11s

Wires the operator CLIs to the Phase 8b connect_lan_first primitive.
Roaming ops (laptop on LTE, coffee-shop wifi) can now pass a
tailnet address alongside the usual --rpc-addr and get the
LAN-first-with-fallback behavior automatically.

New flags on both cluster-peer-status and cluster-repair:
* --tailscale-addr <addr>   — optional tailnet RPC socket. When
                              set, --rpc-addr is tried first with
                              a short deadline, then this on
                              failure/timeout.
* --lan-probe-ms <ms>       — LAN probe deadline. Default 200
                              matches the arch doc.

Zero flag → byte-identical to pre-8c behavior (single-addr dial).
Both flags → chosen route printed in the output header so
operators can see whether LAN or tailnet won.

No new tests: this is thin glue over connect_lan_first, which
already has its own unit coverage. Smoke test live on tank
against architect (LAN), and against fake unroutable + real
tailnet exercises both branches.
This commit is contained in:
Omar Sobh
2026-07-14 11:18:35 -07:00
parent ef60a7984e
commit 38652c5886
+63 -9
View File
@@ -171,6 +171,20 @@ enum Cmd {
/// Peer's RPC socket. Typically gossip_port + 1. /// Peer's RPC socket. Typically gossip_port + 1.
#[arg(long)] #[arg(long)]
rpc_addr: SocketAddr, rpc_addr: SocketAddr,
/// Phase 8c (2026-07-14): optional Tailscale RPC socket.
/// When set, `--rpc-addr` is tried first with a short
/// deadline (`--lan-probe-ms`); on failure or timeout we
/// fall through to this tailnet address. Roaming ops
/// (laptop on LTE, in-flight wifi) get connectivity
/// without hand-editing addresses per environment.
#[arg(long)]
tailscale_addr: Option<SocketAddr>,
/// LAN probe deadline in milliseconds. Only used when
/// `--tailscale-addr` is set. Default 200ms matches the
/// arch doc — long enough for a live LAN handshake, short
/// enough that roaming clients don't stall.
#[arg(long, default_value_t = 200)]
lan_probe_ms: u64,
/// Directory holding this node's mTLS material /// Directory holding this node's mTLS material
/// (`ca.crt` + `node.crt` + `node.key` from `fleet-ca sign`). /// (`ca.crt` + `node.crt` + `node.key` from `fleet-ca sign`).
#[arg(long)] #[arg(long)]
@@ -247,6 +261,14 @@ enum Cmd {
/// Peer's RPC socket. Typically gossip_port + 1. /// Peer's RPC socket. Typically gossip_port + 1.
#[arg(long)] #[arg(long)]
rpc_addr: SocketAddr, rpc_addr: SocketAddr,
/// Phase 8c (2026-07-14): optional Tailscale RPC socket
/// for LAN-first-with-fallback routing. See
/// `cluster-peer-status` for details.
#[arg(long)]
tailscale_addr: Option<SocketAddr>,
/// LAN probe deadline (ms) when `--tailscale-addr` is set.
#[arg(long, default_value_t = 200)]
lan_probe_ms: u64,
/// Directory holding this node's mTLS material. /// Directory holding this node's mTLS material.
#[arg(long)] #[arg(long)]
tls_dir: PathBuf, tls_dir: PathBuf,
@@ -331,9 +353,11 @@ async fn main() -> Result<()> {
Cmd::ClusterRepair { Cmd::ClusterRepair {
peer, peer,
rpc_addr, rpc_addr,
tailscale_addr,
lan_probe_ms,
tls_dir, tls_dir,
dry_run, dry_run,
} => cmd_cluster_repair(&cfg, &peer, rpc_addr, &tls_dir, dry_run).await?, } => cmd_cluster_repair(&cfg, &peer, rpc_addr, tailscale_addr, lan_probe_ms, &tls_dir, dry_run).await?,
Cmd::ClusterRefSweep { Cmd::ClusterRefSweep {
gitea_url, gitea_url,
gitea_token, gitea_token,
@@ -346,8 +370,10 @@ async fn main() -> Result<()> {
Cmd::ClusterPeerStatus { Cmd::ClusterPeerStatus {
peer, peer,
rpc_addr, rpc_addr,
tailscale_addr,
lan_probe_ms,
tls_dir, tls_dir,
} => cmd_cluster_peer_status(&peer, rpc_addr, &tls_dir).await?, } => cmd_cluster_peer_status(&peer, rpc_addr, tailscale_addr, lan_probe_ms, &tls_dir).await?,
Cmd::FleetCaInit { .. } | Cmd::FleetCaSign { .. } | Cmd::FleetCaTailscaleSign { .. } => { Cmd::FleetCaInit { .. } | Cmd::FleetCaSign { .. } | Cmd::FleetCaTailscaleSign { .. } => {
// Handled by the config-independent short-circuit above. // Handled by the config-independent short-circuit above.
unreachable!("fleet-ca commands short-circuit before config load"); unreachable!("fleet-ca commands short-circuit before config load");
@@ -520,12 +546,14 @@ async fn cmd_cluster_repair(
cfg: &Config, cfg: &Config,
peer: &str, peer: &str,
rpc_addr: SocketAddr, rpc_addr: SocketAddr,
tailscale_addr: Option<SocketAddr>,
lan_probe_ms: u64,
tls_dir: &std::path::Path, tls_dir: &std::path::Path,
dry_run: bool, dry_run: bool,
) -> Result<()> { ) -> Result<()> {
use cluster::blob::BlobStore; use cluster::blob::BlobStore;
use cluster::rpc::{call_get_chunk, call_has_chunk}; use cluster::rpc::{call_get_chunk, call_has_chunk};
use cluster::transport::{NodeIdentity, QuicClient}; use cluster::transport::{ConnectRoute, NodeIdentity, QuicClient};
let root = cfg let root = cfg
.cluster .cluster
@@ -581,14 +609,24 @@ async fn cmd_cluster_repair(
return Ok(()); return Ok(());
} }
// Phase 2: connect to the peer. // Phase 2: connect to the peer. Phase 8c: LAN-first with
// optional tailnet fallback when the operator supplied one.
let identity = NodeIdentity::from_pem_dir(tls_dir) let identity = NodeIdentity::from_pem_dir(tls_dir)
.with_context(|| format!("loading identity from {}", tls_dir.display()))?; .with_context(|| format!("loading identity from {}", tls_dir.display()))?;
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?; let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?;
let conn = client let (conn, route) = client
.connect(rpc_addr, peer) .connect_lan_first(
peer,
Some(rpc_addr),
tailscale_addr,
std::time::Duration::from_millis(lan_probe_ms),
)
.await .await
.with_context(|| format!("connecting to {peer} at {rpc_addr}"))?; .with_context(|| format!("connecting to {peer}"))?;
match route {
ConnectRoute::Lan(a) => println!("route: LAN ({a})"),
ConnectRoute::Tailscale(a) => println!("route: tailnet ({a})"),
}
// Phase 3: repair. Fetcher probes HasChunk first (cheap) so a // Phase 3: repair. Fetcher probes HasChunk first (cheap) so a
// peer that lacks the chunk is one round-trip, not a full pull // peer that lacks the chunk is one round-trip, not a full pull
@@ -835,15 +873,31 @@ async fn cmd_cluster_ref_sweep(
async fn cmd_cluster_peer_status( async fn cmd_cluster_peer_status(
peer: &str, peer: &str,
rpc_addr: SocketAddr, rpc_addr: SocketAddr,
tailscale_addr: Option<SocketAddr>,
lan_probe_ms: u64,
tls_dir: &std::path::Path, tls_dir: &std::path::Path,
) -> Result<()> { ) -> Result<()> {
use cluster::rpc::call_peer_status; use cluster::rpc::call_peer_status;
use cluster::transport::{NodeIdentity, QuicClient}; use cluster::transport::{ConnectRoute, NodeIdentity, QuicClient};
let identity = NodeIdentity::from_pem_dir(tls_dir) let identity = NodeIdentity::from_pem_dir(tls_dir)
.with_context(|| format!("loading identity from {}", tls_dir.display()))?; .with_context(|| format!("loading identity from {}", tls_dir.display()))?;
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?; let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?;
let conn = client.connect(rpc_addr, peer).await?; // Phase 8c: LAN-first with optional tailnet fallback. When the
// caller didn't pass --tailscale-addr the behavior is
// byte-identical to the pre-8c path (single-addr dial).
let (conn, route) = client
.connect_lan_first(
peer,
Some(rpc_addr),
tailscale_addr,
std::time::Duration::from_millis(lan_probe_ms),
)
.await?;
match route {
ConnectRoute::Lan(a) => println!("route: LAN ({a})"),
ConnectRoute::Tailscale(a) => println!("route: tailnet ({a})"),
}
let status = call_peer_status(&conn).await?; let status = call_peer_status(&conn).await?;
println!( println!(