diff --git a/Cargo.lock b/Cargo.lock index 6e79fbaa..56e4ff39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 7b13f877..f051168e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/cli.rs b/src/cli.rs index 0f6fbf93..b1a3ff01 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, + /// Deploy all services. + #[arg(long)] + all: bool, + }, + + /// View secrets for a service. + Secrets { + /// Service name (e.g. "hydra"). + service: String, + #[command(subcommand)] + action: Option, + }, + + /// 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) => { diff --git a/src/config.rs b/src/config.rs index 3c3efd3f..d52e9f6d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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(); diff --git a/src/main.rs b/src/main.rs index b0a1fd91..a3220d1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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] diff --git a/src/vpn_cmds.rs b/src/vpn_cmds.rs new file mode 100644 index 00000000..5ecadb19 --- /dev/null +++ b/src/vpn_cmds.rs @@ -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 "@" 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 = 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 { + let home = std::env::var("HOME") + .map_err(|_| SunbeamError::Other("HOME not set".into()))?; + Ok(PathBuf::from(home).join(".sunbeam").join("vpn")) +} diff --git a/src/workflows/mod.rs b/src/workflows/mod.rs index 5bc1e52c..f94b0b59 100644 --- a/src/workflows/mod.rs +++ b/src/workflows/mod.rs @@ -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);