feat(net): derive cluster API target from netmap by hostname

Adds an optional `cluster_api_host` field to VpnConfig. When set, the
daemon resolves it against the netmap's peer list once the first
netmap arrives and uses that peer's tailnet IP as the proxy backend,
overriding the static `cluster_api_addr`. Falls back to the static
addr if the hostname doesn't match any peer.

The resolver tries hostname first, then peer name (FQDN), then a
prefix match against name. Picks v4 over v6 from the peer's address
list.

- sunbeam-net/src/config.rs: new `cluster_api_host: Option<String>`
- sunbeam-net/src/daemon/lifecycle.rs: resolve_peer_ip helper +
  resolution at proxy bind time
- sunbeam-net/tests/integration.rs: pass cluster_api_host: None in
  the existing VpnConfig literals
- src/config.rs: new context field `vpn-cluster-host`
- src/vpn_cmds.rs: thread it from context → VpnConfig
This commit is contained in:
2026-04-07 15:00:30 +01:00
parent 27a6f4377c
commit e934eb45dc
5 changed files with 76 additions and 5 deletions

View File

@@ -65,6 +65,13 @@ pub struct Context {
/// Stored in plain text — keep this file readable only by the user.
#[serde(default, rename = "vpn-auth-key", skip_serializing_if = "String::is_empty")]
pub vpn_auth_key: String,
/// Hostname of the cluster API server peer to look up in the netmap.
/// When set, the VPN daemon resolves this against the netmap's peer
/// list and proxies k8s API traffic to that peer's tailnet IP. When
/// empty, falls back to a static fallback address.
#[serde(default, rename = "vpn-cluster-host", skip_serializing_if = "String::is_empty")]
pub vpn_cluster_host: String,
}
// ---------------------------------------------------------------------------

View File

@@ -171,11 +171,19 @@ async fn run_daemon_foreground() -> Result<()> {
state_dir: state_dir.clone(),
// Bind the local k8s proxy on 16579 — far enough away from common
// conflicts (6443 = kube API, 16443 = sienna's SSH tunnel) that we
// shouldn't collide on dev machines. TODO: make this configurable
// and discoverable via IPC.
// shouldn't collide on dev machines. TODO: make this configurable.
proxy_bind: "127.0.0.1:16579".parse().expect("static addr"),
// Static fallback if the netmap doesn't have the named host.
cluster_api_addr: "100.64.0.1".parse().expect("static addr"),
cluster_api_port: 6443,
// If the user set vpn-cluster-host in their context config, the
// daemon resolves it from the netmap and uses that peer's
// tailnet IP for the proxy backend.
cluster_api_host: if ctx.vpn_cluster_host.is_empty() {
None
} else {
Some(ctx.vpn_cluster_host.clone())
},
control_socket: state_dir.join("daemon.sock"),
hostname,
server_public_key: None,