Phase 8c: cluster-peer-status + cluster-repair support --tailscale-addr #64

Merged
osobh merged 1 commits from phase-8c-cli-tailnet-fallback into main 2026-07-14 18:18:41 +00:00
+63 -9
View File
@@ -171,6 +171,20 @@ enum Cmd {
/// Peer's RPC socket. Typically gossip_port + 1.
#[arg(long)]
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
/// (`ca.crt` + `node.crt` + `node.key` from `fleet-ca sign`).
#[arg(long)]
@@ -247,6 +261,14 @@ enum Cmd {
/// Peer's RPC socket. Typically gossip_port + 1.
#[arg(long)]
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.
#[arg(long)]
tls_dir: PathBuf,
@@ -331,9 +353,11 @@ async fn main() -> Result<()> {
Cmd::ClusterRepair {
peer,
rpc_addr,
tailscale_addr,
lan_probe_ms,
tls_dir,
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 {
gitea_url,
gitea_token,
@@ -346,8 +370,10 @@ async fn main() -> Result<()> {
Cmd::ClusterPeerStatus {
peer,
rpc_addr,
tailscale_addr,
lan_probe_ms,
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 { .. } => {
// Handled by the config-independent short-circuit above.
unreachable!("fleet-ca commands short-circuit before config load");
@@ -520,12 +546,14 @@ async fn cmd_cluster_repair(
cfg: &Config,
peer: &str,
rpc_addr: SocketAddr,
tailscale_addr: Option<SocketAddr>,
lan_probe_ms: u64,
tls_dir: &std::path::Path,
dry_run: bool,
) -> Result<()> {
use cluster::blob::BlobStore;
use cluster::rpc::{call_get_chunk, call_has_chunk};
use cluster::transport::{NodeIdentity, QuicClient};
use cluster::transport::{ConnectRoute, NodeIdentity, QuicClient};
let root = cfg
.cluster
@@ -581,14 +609,24 @@ async fn cmd_cluster_repair(
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)
.with_context(|| format!("loading identity from {}", tls_dir.display()))?;
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?;
let conn = client
.connect(rpc_addr, peer)
let (conn, route) = client
.connect_lan_first(
peer,
Some(rpc_addr),
tailscale_addr,
std::time::Duration::from_millis(lan_probe_ms),
)
.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
// 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(
peer: &str,
rpc_addr: SocketAddr,
tailscale_addr: Option<SocketAddr>,
lan_probe_ms: u64,
tls_dir: &std::path::Path,
) -> Result<()> {
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)
.with_context(|| format!("loading identity from {}", tls_dir.display()))?;
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?;
println!(