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:
62
Cargo.lock
generated
62
Cargo.lock
generated
@@ -1471,7 +1471,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2463,7 +2463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2586,6 +2586,24 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
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]]
|
[[package]]
|
||||||
name = "object"
|
name = "object"
|
||||||
version = "0.37.3"
|
version = "0.37.3"
|
||||||
@@ -4158,7 +4176,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
"whoami",
|
"whoami 1.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4197,7 +4215,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
"whoami",
|
"whoami 1.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4334,6 +4352,7 @@ dependencies = [
|
|||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"hostname",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"k8s-openapi",
|
"k8s-openapi",
|
||||||
"kube",
|
"kube",
|
||||||
@@ -4351,6 +4370,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"sunbeam-net",
|
||||||
"sunbeam-sdk",
|
"sunbeam-sdk",
|
||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -4364,6 +4384,7 @@ dependencies = [
|
|||||||
"wfe-core",
|
"wfe-core",
|
||||||
"wfe-sqlite",
|
"wfe-sqlite",
|
||||||
"wfe-yaml",
|
"wfe-yaml",
|
||||||
|
"whoami 2.1.1",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5028,6 +5049,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
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]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.2+wasi-0.2.9"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
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]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.114"
|
version = "0.2.114"
|
||||||
@@ -5272,7 +5311,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libredox",
|
"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]]
|
[[package]]
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ members = ["sunbeam-sdk", "sunbeam-net"]
|
|||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# Workspace
|
||||||
|
sunbeam-sdk = { path = "sunbeam-sdk" }
|
||||||
|
sunbeam-net = { path = "sunbeam-net" }
|
||||||
|
|
||||||
# Core
|
# Core
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["full"] }
|
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-sqlite = { version = "1.6.3", registry = "sunbeam" }
|
||||||
wfe-yaml = { version = "1.6.3", registry = "sunbeam" }
|
wfe-yaml = { version = "1.6.3", registry = "sunbeam" }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
hostname = "0.4.2"
|
||||||
|
whoami = "2.1.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
|||||||
146
src/cli.rs
146
src/cli.rs
@@ -149,6 +149,42 @@ pub enum Verb {
|
|||||||
action: crate::workflows::cmd::WorkflowAction,
|
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.
|
/// Self-update from latest mainline commit.
|
||||||
Update,
|
Update,
|
||||||
|
|
||||||
@@ -156,6 +192,21 @@ pub enum Verb {
|
|||||||
Version,
|
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)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum AuthAction {
|
pub enum AuthAction {
|
||||||
/// Log in to both SSO and Gitea.
|
/// Log in to both SSO and Gitea.
|
||||||
@@ -861,6 +912,72 @@ mod tests {
|
|||||||
let result = Cli::try_parse_from(&["sunbeam", "workflow", "status"]);
|
let result = Cli::try_parse_from(&["sunbeam", "workflow", "status"]);
|
||||||
assert!(result.is_err());
|
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.
|
/// 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
|
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::Update) => crate::update::cmd_update().await,
|
||||||
|
|
||||||
Some(Verb::Version) => {
|
Some(Verb::Version) => {
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ pub struct Context {
|
|||||||
/// ACME email for cert-manager.
|
/// ACME email for cert-manager.
|
||||||
#[serde(default, rename = "acme-email")]
|
#[serde(default, rename = "acme-email")]
|
||||||
pub acme_email: String,
|
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(),
|
ssh_host: config.production_host.clone(),
|
||||||
infra_dir: config.infra_directory.clone(),
|
infra_dir: config.infra_directory.clone(),
|
||||||
acme_email: config.acme_email.clone(),
|
acme_email: config.acme_email.clone(),
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if config.current_context.is_empty() {
|
if config.current_context.is_empty() {
|
||||||
@@ -369,6 +382,7 @@ mod tests {
|
|||||||
ssh_host: "sienna@server.sunbeam.pt".to_string(),
|
ssh_host: "sienna@server.sunbeam.pt".to_string(),
|
||||||
infra_dir: "/home/infra".to_string(),
|
infra_dir: "/home/infra".to_string(),
|
||||||
acme_email: "ops@sunbeam.pt".to_string(),
|
acme_email: "ops@sunbeam.pt".to_string(),
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ mod openbao;
|
|||||||
mod output;
|
mod output;
|
||||||
mod pm;
|
mod pm;
|
||||||
mod secrets;
|
mod secrets;
|
||||||
|
mod service_cmds;
|
||||||
mod services;
|
mod services;
|
||||||
mod tools;
|
mod tools;
|
||||||
mod update;
|
mod update;
|
||||||
mod users;
|
mod users;
|
||||||
|
mod vpn_cmds;
|
||||||
mod workflows;
|
mod workflows;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|||||||
146
src/vpn_cmds.rs
Normal file
146
src/vpn_cmds.rs
Normal 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"))
|
||||||
|
}
|
||||||
@@ -162,6 +162,7 @@ mod tests {
|
|||||||
ssh_host: String::new(),
|
ssh_host: String::new(),
|
||||||
infra_dir: "/home/user/infra".to_string(),
|
infra_dir: "/home/user/infra".to_string(),
|
||||||
acme_email: "admin@local.dev".to_string(),
|
acme_email: "admin@local.dev".to_string(),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let sc = StepContext::from_config(&ctx, "local");
|
let sc = StepContext::from_config(&ctx, "local");
|
||||||
assert_eq!(sc.domain, "local.dev");
|
assert_eq!(sc.domain, "local.dev");
|
||||||
@@ -180,6 +181,7 @@ mod tests {
|
|||||||
ssh_host: "sienna@62.210.145.138".to_string(),
|
ssh_host: "sienna@62.210.145.138".to_string(),
|
||||||
infra_dir: "/srv/infra".to_string(),
|
infra_dir: "/srv/infra".to_string(),
|
||||||
acme_email: "ops@sunbeam.pt".to_string(),
|
acme_email: "ops@sunbeam.pt".to_string(),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let sc = StepContext::from_config(&ctx, "production");
|
let sc = StepContext::from_config(&ctx, "production");
|
||||||
assert!(sc.is_production);
|
assert!(sc.is_production);
|
||||||
|
|||||||
Reference in New Issue
Block a user