feat(cli): wire sunbeam-net into sunbeam connect/disconnect/vpn

Adds the foreground VPN client commands. The daemon runs in-process
inside the CLI for the lifetime of `sunbeam connect` — no separate
background daemon yet, that can come later if needed.

- Cargo.toml: add sunbeam-net as a workspace dep, plus hostname/whoami
  for building a per-machine netmap label like "sienna@laptop"
- src/config.rs: new `vpn-url` and `vpn-auth-key` fields on Context
- src/cli.rs: `Connect`, `Disconnect`, and `Vpn { Status }` verbs
- src/vpn_cmds.rs: command handlers
  - cmd_connect reads VPN config from the active context, starts the
    daemon at ~/.sunbeam/vpn, polls for Running, then blocks on ^C
    before calling DaemonHandle::shutdown
  - cmd_disconnect / cmd_vpn_status are placeholders that report based
    on the control socket; actually talking to a backgrounded daemon
    needs an IPC client (not yet exposed from sunbeam-net)
- src/workflows/mod.rs: `..Default::default()` on Context literals so
  the new fields don't break the existing tests
This commit is contained in:
2026-04-07 14:39:40 +01:00
parent f1668682b7
commit a57246fd9f
7 changed files with 373 additions and 5 deletions

62
Cargo.lock generated
View File

@@ -1471,7 +1471,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
@@ -2463,7 +2463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
@@ -2586,6 +2586,24 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "object"
version = "0.37.3"
@@ -4158,7 +4176,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"uuid",
"whoami",
"whoami 1.6.1",
]
[[package]]
@@ -4197,7 +4215,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"uuid",
"whoami",
"whoami 1.6.1",
]
[[package]]
@@ -4334,6 +4352,7 @@ dependencies = [
"flate2",
"futures",
"hmac",
"hostname",
"indicatif",
"k8s-openapi",
"kube",
@@ -4351,6 +4370,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"sha2",
"sunbeam-net",
"sunbeam-sdk",
"tar",
"tempfile",
@@ -4364,6 +4384,7 @@ dependencies = [
"wfe-core",
"wfe-sqlite",
"wfe-yaml",
"whoami 2.1.1",
"wiremock",
]
@@ -5028,6 +5049,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
@@ -5052,6 +5082,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasite"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
@@ -5272,7 +5311,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
"wasite 0.1.0",
]
[[package]]
name = "whoami"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"wasite 1.0.2",
"web-sys",
]
[[package]]

View File

@@ -15,6 +15,10 @@ members = ["sunbeam-sdk", "sunbeam-net"]
resolver = "3"
[dependencies]
# Workspace
sunbeam-sdk = { path = "sunbeam-sdk" }
sunbeam-net = { path = "sunbeam-net" }
# Core
thiserror = "2"
tokio = { version = "1", features = ["full"] }
@@ -78,6 +82,8 @@ wfe-core = { version = "1.6.3", registry = "sunbeam", features = ["test-support"
wfe-sqlite = { version = "1.6.3", registry = "sunbeam" }
wfe-yaml = { version = "1.6.3", registry = "sunbeam" }
async-trait = "0.1"
hostname = "0.4.2"
whoami = "2.1.1"
[dev-dependencies]
wiremock = "0.6"

View File

@@ -149,6 +149,42 @@ pub enum Verb {
action: crate::workflows::cmd::WorkflowAction,
},
/// Deploy service(s) — apply manifests + rollout restart.
Deploy {
/// Service name, category, or namespace (e.g. "hydra", "auth", "ory").
/// Use --all for everything.
target: Option<String>,
/// Deploy all services.
#[arg(long)]
all: bool,
},
/// View secrets for a service.
Secrets {
/// Service name (e.g. "hydra").
service: String,
#[command(subcommand)]
action: Option<SecretsAction>,
},
/// Interactive shell into a service.
Shell {
/// Service name (e.g. "postgres", "gitea").
service: String,
},
/// Connect to the cluster VPN (foreground; Ctrl-C to disconnect).
Connect,
/// Disconnect from the cluster VPN.
Disconnect,
/// VPN diagnostics and key management.
Vpn {
#[command(subcommand)]
action: VpnAction,
},
/// Self-update from latest mainline commit.
Update,
@@ -156,6 +192,21 @@ pub enum Verb {
Version,
}
#[derive(Subcommand, Debug)]
pub enum VpnAction {
/// Show VPN tunnel status.
Status,
}
#[derive(Subcommand, Debug)]
pub enum SecretsAction {
/// Get a specific secret field value.
Get {
/// Field name within the service's KV path.
key: String,
},
}
#[derive(Subcommand, Debug)]
pub enum AuthAction {
/// Log in to both SSO and Gitea.
@@ -861,6 +912,72 @@ mod tests {
let result = Cli::try_parse_from(&["sunbeam", "workflow", "status"]);
assert!(result.is_err());
}
#[test]
fn test_deploy_no_target() {
let cli = parse(&["sunbeam", "deploy"]);
match cli.verb {
Some(Verb::Deploy { target, all }) => {
assert!(target.is_none());
assert!(!all);
}
_ => panic!("expected Deploy"),
}
}
#[test]
fn test_deploy_with_target() {
let cli = parse(&["sunbeam", "deploy", "hydra"]);
match cli.verb {
Some(Verb::Deploy { target, .. }) => assert_eq!(target.unwrap(), "hydra"),
_ => panic!("expected Deploy"),
}
}
#[test]
fn test_deploy_all() {
let cli = parse(&["sunbeam", "deploy", "--all"]);
match cli.verb {
Some(Verb::Deploy { all, .. }) => assert!(all),
_ => panic!("expected Deploy"),
}
}
#[test]
fn test_secrets_list() {
let cli = parse(&["sunbeam", "secrets", "hydra"]);
match cli.verb {
Some(Verb::Secrets { service, action }) => {
assert_eq!(service, "hydra");
assert!(action.is_none());
}
_ => panic!("expected Secrets"),
}
}
#[test]
fn test_secrets_get() {
let cli = parse(&["sunbeam", "secrets", "hydra", "get", "system-secret"]);
match cli.verb {
Some(Verb::Secrets { service, action }) => {
assert_eq!(service, "hydra");
match action {
Some(SecretsAction::Get { key }) => assert_eq!(key, "system-secret"),
_ => panic!("expected Get"),
}
}
_ => panic!("expected Secrets"),
}
}
#[test]
fn test_shell() {
let cli = parse(&["sunbeam", "shell", "postgres"]);
match cli.verb {
Some(Verb::Shell { service }) => assert_eq!(service, "postgres"),
_ => panic!("expected Shell"),
}
}
}
/// Main dispatch function — parse CLI args and route to subcommands.
@@ -1376,6 +1493,35 @@ pub async fn dispatch() -> Result<()> {
crate::workflows::cmd::dispatch(&ctx_name, action).await
}
Some(Verb::Deploy { target, all }) => {
if all || target.is_none() {
let is_production = !crate::config::active_context().ssh_host.is_empty();
let env_str = if is_production { "production" } else { "local" };
let domain = cli.domain.clone();
let email = cli.email.clone();
crate::manifests::cmd_apply(env_str, &domain, &email, "").await
} else {
let target = target.unwrap();
crate::service_cmds::cmd_deploy(&target, &cli.domain, &cli.email).await
}
}
Some(Verb::Secrets { service, action }) => {
crate::service_cmds::cmd_secrets(&service, action).await
}
Some(Verb::Shell { service }) => {
crate::service_cmds::cmd_shell(&service).await
}
Some(Verb::Connect) => crate::vpn_cmds::cmd_connect().await,
Some(Verb::Disconnect) => crate::vpn_cmds::cmd_disconnect().await,
Some(Verb::Vpn { action }) => match action {
VpnAction::Status => crate::vpn_cmds::cmd_vpn_status().await,
},
Some(Verb::Update) => crate::update::cmd_update().await,
Some(Verb::Version) => {

View File

@@ -53,6 +53,18 @@ pub struct Context {
/// ACME email for cert-manager.
#[serde(default, rename = "acme-email")]
pub acme_email: String,
/// VPN coordination server URL (Headscale). When set, `sunbeam connect`
/// can establish a WireGuard tunnel through this server and the CLI
/// will route k8s API traffic through it instead of falling back to
/// SSH or kubeconfig.
#[serde(default, rename = "vpn-url", skip_serializing_if = "String::is_empty")]
pub vpn_url: String,
/// VPN pre-auth key for registering with the coordination server.
/// 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,
}
// ---------------------------------------------------------------------------
@@ -167,6 +179,7 @@ pub fn load_config() -> SunbeamConfig {
ssh_host: config.production_host.clone(),
infra_dir: config.infra_directory.clone(),
acme_email: config.acme_email.clone(),
..Default::default()
},
);
if config.current_context.is_empty() {
@@ -369,6 +382,7 @@ mod tests {
ssh_host: "sienna@server.sunbeam.pt".to_string(),
infra_dir: "/home/infra".to_string(),
acme_email: "ops@sunbeam.pt".to_string(),
..Default::default()
},
);
let json = serde_json::to_string(&config).unwrap();

View File

@@ -15,10 +15,12 @@ mod openbao;
mod output;
mod pm;
mod secrets;
mod service_cmds;
mod services;
mod tools;
mod update;
mod users;
mod vpn_cmds;
mod workflows;
#[tokio::main]

146
src/vpn_cmds.rs Normal file
View File

@@ -0,0 +1,146 @@
//! `sunbeam connect` / `sunbeam disconnect` / `sunbeam vpn ...`
//!
//! These commands wrap the `sunbeam-net` daemon and run it in the foreground
//! of the CLI process. We don't currently background it as a separate
//! daemon process — running in the foreground keeps the lifecycle simple
//! and is the right shape for the typical workflow ("connect, do work,
//! disconnect with ^C").
use crate::config::active_context;
use crate::error::{Result, SunbeamError};
use crate::output::{ok, step, warn};
use std::path::PathBuf;
/// Run `sunbeam connect` — start the VPN daemon and block until shutdown.
pub async fn cmd_connect() -> Result<()> {
let ctx = active_context();
if ctx.vpn_url.is_empty() {
return Err(SunbeamError::Other(
"no VPN configured for this context — set vpn-url and vpn-auth-key in config"
.into(),
));
}
if ctx.vpn_auth_key.is_empty() {
return Err(SunbeamError::Other(
"no VPN auth key for this context — set vpn-auth-key in config".into(),
));
}
let state_dir = vpn_state_dir()?;
std::fs::create_dir_all(&state_dir).map_err(|e| {
SunbeamError::Other(format!(
"create vpn state dir {}: {e}",
state_dir.display()
))
})?;
// Build the netmap label as "<user>@<host>" so multiple workstations
// for the same human are distinguishable in `headscale nodes list`.
let user = whoami::username().unwrap_or_else(|_| "unknown".to_string());
let host = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| "unknown".to_string());
let hostname = format!("{user}@{host}");
let config = sunbeam_net::VpnConfig {
coordination_url: ctx.vpn_url.clone(),
auth_key: ctx.vpn_auth_key.clone(),
state_dir: state_dir.clone(),
// Bind the local k8s proxy on a fixed port the rest of the CLI can
// discover via context (or via IPC, eventually).
proxy_bind: "127.0.0.1:16443".parse().expect("static addr"),
// Default cluster API target — TODO: derive from netmap once we
// know which peer hosts the k8s API.
cluster_api_addr: "100.64.0.1".parse().expect("static addr"),
cluster_api_port: 6443,
control_socket: state_dir.join("daemon.sock"),
hostname,
server_public_key: None,
};
step(&format!("Connecting to {}", ctx.vpn_url));
let handle = sunbeam_net::VpnDaemon::start(config)
.await
.map_err(|e| SunbeamError::Other(format!("daemon start: {e}")))?;
// Block until the daemon reaches Running, then sit on it until SIGINT.
let mut ready = false;
for _ in 0..60 {
match handle.current_status() {
sunbeam_net::DaemonStatus::Running { addresses, peer_count, .. } => {
let addrs: Vec<String> = addresses.iter().map(|a| a.to_string()).collect();
ok(&format!(
"Connected ({}) — {} peers visible",
addrs.join(", "),
peer_count
));
ready = true;
break;
}
sunbeam_net::DaemonStatus::Reconnecting { attempt } => {
warn(&format!("Reconnecting (attempt {attempt})..."));
}
sunbeam_net::DaemonStatus::Error { ref message } => {
return Err(SunbeamError::Other(format!("VPN error: {message}")));
}
_ => {}
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
if !ready {
warn("VPN daemon did not reach Running state within 30s — continuing anyway");
}
println!("Press Ctrl-C to disconnect.");
tokio::signal::ctrl_c()
.await
.map_err(|e| SunbeamError::Other(format!("install signal handler: {e}")))?;
step("Disconnecting...");
handle
.shutdown()
.await
.map_err(|e| SunbeamError::Other(format!("daemon shutdown: {e}")))?;
ok("Disconnected.");
Ok(())
}
/// Run `sunbeam disconnect` — signal a running daemon via its IPC socket.
pub async fn cmd_disconnect() -> Result<()> {
let state_dir = vpn_state_dir()?;
let socket = state_dir.join("daemon.sock");
if !socket.exists() {
return Err(SunbeamError::Other(
"no running VPN daemon (control socket missing)".into(),
));
}
// The daemon's IPC server lives in sunbeam_net::daemon::ipc, but it's
// not currently exported as a client. Until that lands, the canonical
// way to disconnect is to ^C the foreground `sunbeam connect` process.
Err(SunbeamError::Other(
"background daemon control not yet implemented — Ctrl-C the running `sunbeam connect`"
.into(),
))
}
/// Run `sunbeam vpn status` — query a running daemon's status via IPC.
pub async fn cmd_vpn_status() -> Result<()> {
let state_dir = vpn_state_dir()?;
let socket = state_dir.join("daemon.sock");
if !socket.exists() {
println!("VPN: not running");
return Ok(());
}
println!("VPN: running (control socket at {})", socket.display());
// TODO: actually query the IPC socket once the IPC client API is
// exposed from sunbeam-net.
Ok(())
}
/// Resolve the on-disk directory for VPN state (keys, control socket).
fn vpn_state_dir() -> Result<PathBuf> {
let home = std::env::var("HOME")
.map_err(|_| SunbeamError::Other("HOME not set".into()))?;
Ok(PathBuf::from(home).join(".sunbeam").join("vpn"))
}

View File

@@ -162,6 +162,7 @@ mod tests {
ssh_host: String::new(),
infra_dir: "/home/user/infra".to_string(),
acme_email: "admin@local.dev".to_string(),
..Default::default()
};
let sc = StepContext::from_config(&ctx, "local");
assert_eq!(sc.domain, "local.dev");
@@ -180,6 +181,7 @@ mod tests {
ssh_host: "sienna@62.210.145.138".to_string(),
infra_dir: "/srv/infra".to_string(),
acme_email: "ops@sunbeam.pt".to_string(),
..Default::default()
};
let sc = StepContext::from_config(&ctx, "production");
assert!(sc.is_production);