Files
cli/src/vpn_cmds.rs

278 lines
9.9 KiB
Rust
Raw Normal View History

//! `sunbeam connect` / `sunbeam disconnect` / `sunbeam vpn ...`
//!
feat(cli): background the VPN daemon with re-exec + clean shutdown `sunbeam connect` now fork-execs itself with a hidden `__vpn-daemon` subcommand instead of running the daemon in-process. The user-facing command spawns the child detached (stdio → log file, setsid for no controlling TTY), polls the IPC socket until the daemon reaches Running, prints a one-line status, and exits. The user gets back to their shell immediately. - src/cli.rs: `Connect { foreground }` instead of unit. Add hidden `__vpn-daemon` Verb that the spawned child runs. - src/vpn_cmds.rs: split into spawn_background_daemon (default path) and run_daemon_foreground (used by both `connect --foreground` and `__vpn-daemon`). Detached child uses pre_exec(setsid) and inherits --context from the parent so it resolves the same VPN config. Refuses to start if a daemon is already running on the control socket; cleans up stale socket files. Switches the proxy bind from 16443 (sienna's existing SSH tunnel uses it) to 16579. - sunbeam-net/src/daemon/lifecycle: add a SocketGuard RAII type so the IPC control socket is unlinked when the daemon exits, regardless of shutdown path. Otherwise `vpn status` after a clean disconnect would see a stale socket and report an error. End-to-end smoke test against the docker stack: $ sunbeam connect ==> VPN daemon spawned (pid 90072, ...) Connected (100.64.0.154, fd7a:115c:a1e0::9a) — 2 peers visible $ sunbeam vpn status VPN: running addresses: 100.64.0.154, fd7a:115c:a1e0::9a peers: 2 derp home: region 0 $ sunbeam disconnect ==> Asking VPN daemon to stop... Daemon acknowledged shutdown. $ sunbeam vpn status VPN: not running
2026-04-07 14:57:15 +01:00
//! `sunbeam connect` re-execs the current binary with a hidden
//! `__vpn-daemon` subcommand and detaches it (stdio → /dev/null + a log
//! file). The detached child runs the actual `sunbeam-net` daemon and
//! listens on the IPC control socket. The user-facing process polls the
//! socket until the daemon reaches Running, prints status, and exits.
//!
//! This shape avoids forking from inside the tokio runtime.
use crate::config::active_context;
use crate::error::{Result, SunbeamError};
use crate::output::{ok, step, warn};
use std::path::PathBuf;
feat(cli): background the VPN daemon with re-exec + clean shutdown `sunbeam connect` now fork-execs itself with a hidden `__vpn-daemon` subcommand instead of running the daemon in-process. The user-facing command spawns the child detached (stdio → log file, setsid for no controlling TTY), polls the IPC socket until the daemon reaches Running, prints a one-line status, and exits. The user gets back to their shell immediately. - src/cli.rs: `Connect { foreground }` instead of unit. Add hidden `__vpn-daemon` Verb that the spawned child runs. - src/vpn_cmds.rs: split into spawn_background_daemon (default path) and run_daemon_foreground (used by both `connect --foreground` and `__vpn-daemon`). Detached child uses pre_exec(setsid) and inherits --context from the parent so it resolves the same VPN config. Refuses to start if a daemon is already running on the control socket; cleans up stale socket files. Switches the proxy bind from 16443 (sienna's existing SSH tunnel uses it) to 16579. - sunbeam-net/src/daemon/lifecycle: add a SocketGuard RAII type so the IPC control socket is unlinked when the daemon exits, regardless of shutdown path. Otherwise `vpn status` after a clean disconnect would see a stale socket and report an error. End-to-end smoke test against the docker stack: $ sunbeam connect ==> VPN daemon spawned (pid 90072, ...) Connected (100.64.0.154, fd7a:115c:a1e0::9a) — 2 peers visible $ sunbeam vpn status VPN: running addresses: 100.64.0.154, fd7a:115c:a1e0::9a peers: 2 derp home: region 0 $ sunbeam disconnect ==> Asking VPN daemon to stop... Daemon acknowledged shutdown. $ sunbeam vpn status VPN: not running
2026-04-07 14:57:15 +01:00
/// Run `sunbeam connect`.
///
/// Default mode spawns a backgrounded daemon and returns once it reaches
/// Running. With `--foreground`, runs the daemon in-process and blocks
/// until SIGINT or SIGTERM.
pub async fn cmd_connect(foreground: bool) -> Result<()> {
let ctx = active_context();
if ctx.vpn_url.is_empty() {
return Err(SunbeamError::Other(
feat(cli): background the VPN daemon with re-exec + clean shutdown `sunbeam connect` now fork-execs itself with a hidden `__vpn-daemon` subcommand instead of running the daemon in-process. The user-facing command spawns the child detached (stdio → log file, setsid for no controlling TTY), polls the IPC socket until the daemon reaches Running, prints a one-line status, and exits. The user gets back to their shell immediately. - src/cli.rs: `Connect { foreground }` instead of unit. Add hidden `__vpn-daemon` Verb that the spawned child runs. - src/vpn_cmds.rs: split into spawn_background_daemon (default path) and run_daemon_foreground (used by both `connect --foreground` and `__vpn-daemon`). Detached child uses pre_exec(setsid) and inherits --context from the parent so it resolves the same VPN config. Refuses to start if a daemon is already running on the control socket; cleans up stale socket files. Switches the proxy bind from 16443 (sienna's existing SSH tunnel uses it) to 16579. - sunbeam-net/src/daemon/lifecycle: add a SocketGuard RAII type so the IPC control socket is unlinked when the daemon exits, regardless of shutdown path. Otherwise `vpn status` after a clean disconnect would see a stale socket and report an error. End-to-end smoke test against the docker stack: $ sunbeam connect ==> VPN daemon spawned (pid 90072, ...) Connected (100.64.0.154, fd7a:115c:a1e0::9a) — 2 peers visible $ sunbeam vpn status VPN: running addresses: 100.64.0.154, fd7a:115c:a1e0::9a peers: 2 derp home: region 0 $ sunbeam disconnect ==> Asking VPN daemon to stop... Daemon acknowledged shutdown. $ sunbeam vpn status VPN: not running
2026-04-07 14:57:15 +01:00
"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| {
feat(cli): background the VPN daemon with re-exec + clean shutdown `sunbeam connect` now fork-execs itself with a hidden `__vpn-daemon` subcommand instead of running the daemon in-process. The user-facing command spawns the child detached (stdio → log file, setsid for no controlling TTY), polls the IPC socket until the daemon reaches Running, prints a one-line status, and exits. The user gets back to their shell immediately. - src/cli.rs: `Connect { foreground }` instead of unit. Add hidden `__vpn-daemon` Verb that the spawned child runs. - src/vpn_cmds.rs: split into spawn_background_daemon (default path) and run_daemon_foreground (used by both `connect --foreground` and `__vpn-daemon`). Detached child uses pre_exec(setsid) and inherits --context from the parent so it resolves the same VPN config. Refuses to start if a daemon is already running on the control socket; cleans up stale socket files. Switches the proxy bind from 16443 (sienna's existing SSH tunnel uses it) to 16579. - sunbeam-net/src/daemon/lifecycle: add a SocketGuard RAII type so the IPC control socket is unlinked when the daemon exits, regardless of shutdown path. Otherwise `vpn status` after a clean disconnect would see a stale socket and report an error. End-to-end smoke test against the docker stack: $ sunbeam connect ==> VPN daemon spawned (pid 90072, ...) Connected (100.64.0.154, fd7a:115c:a1e0::9a) — 2 peers visible $ sunbeam vpn status VPN: running addresses: 100.64.0.154, fd7a:115c:a1e0::9a peers: 2 derp home: region 0 $ sunbeam disconnect ==> Asking VPN daemon to stop... Daemon acknowledged shutdown. $ sunbeam vpn status VPN: not running
2026-04-07 14:57:15 +01:00
SunbeamError::Other(format!("create vpn state dir {}: {e}", state_dir.display()))
})?;
if foreground {
return run_daemon_foreground().await;
}
spawn_background_daemon(&state_dir).await
}
/// Spawn a detached daemon child and wait for it to reach Running.
async fn spawn_background_daemon(state_dir: &std::path::Path) -> Result<()> {
// Refuse to start a second daemon if one is already running.
let socket = state_dir.join("daemon.sock");
let probe = sunbeam_net::IpcClient::new(&socket);
if probe.socket_exists() {
if let Ok(status) = probe.status().await {
warn(&format!(
"VPN daemon already running ({status}). Use `sunbeam disconnect` first."
));
return Ok(());
}
// Stale socket — clean it up so the new daemon can rebind.
let _ = std::fs::remove_file(&socket);
}
// Re-exec ourselves with the hidden subcommand.
let exe = std::env::current_exe()
.map_err(|e| SunbeamError::Other(format!("locate current_exe: {e}")))?;
let log_path = state_dir.join("daemon.log");
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.map_err(|e| SunbeamError::Other(format!("open daemon log: {e}")))?;
let log_err = log
.try_clone()
.map_err(|e| SunbeamError::Other(format!("dup daemon log fd: {e}")))?;
let mut cmd = std::process::Command::new(&exe);
// --context is a top-level flag, must precede the subcommand.
let cfg = crate::config::load_config();
if !cfg.current_context.is_empty() {
cmd.arg("--context").arg(&cfg.current_context);
}
cmd.arg("__vpn-daemon");
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(log))
.stderr(std::process::Stdio::from(log_err));
// Detach from the controlling terminal so closing the parent shell
// doesn't SIGHUP the daemon.
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
// Become a session leader so the child has no controlling TTY.
libc::setsid();
Ok(())
});
}
let child = cmd
.spawn()
.map_err(|e| SunbeamError::Other(format!("spawn daemon: {e}")))?;
step(&format!(
"VPN daemon spawned (pid {}, logs at {})",
child.id(),
log_path.display()
));
// Poll the IPC socket until the daemon reaches Running.
let client = sunbeam_net::IpcClient::new(&socket);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
if std::time::Instant::now() > deadline {
warn(
"VPN daemon did not reach Running state within 30s — \
check the daemon log for details",
);
return Ok(());
}
if !client.socket_exists() {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
continue;
}
match client.status().await {
Ok(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
));
return Ok(());
}
Ok(sunbeam_net::DaemonStatus::Error { message }) => {
return Err(SunbeamError::Other(format!("VPN daemon error: {message}")));
}
// Still starting / connecting / registering — keep polling.
Ok(_) | Err(_) => {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
}
}
}
/// The hidden `__vpn-daemon` subcommand entry point.
pub async fn cmd_vpn_daemon() -> Result<()> {
run_daemon_foreground().await
}
/// Build VpnConfig from the active context, start the daemon, and block
/// until SIGINT/SIGTERM or an IPC `Stop` request brings it down.
async fn run_daemon_foreground() -> Result<()> {
let ctx = active_context();
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()))
})?;
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(),
feat(cli): background the VPN daemon with re-exec + clean shutdown `sunbeam connect` now fork-execs itself with a hidden `__vpn-daemon` subcommand instead of running the daemon in-process. The user-facing command spawns the child detached (stdio → log file, setsid for no controlling TTY), polls the IPC socket until the daemon reaches Running, prints a one-line status, and exits. The user gets back to their shell immediately. - src/cli.rs: `Connect { foreground }` instead of unit. Add hidden `__vpn-daemon` Verb that the spawned child runs. - src/vpn_cmds.rs: split into spawn_background_daemon (default path) and run_daemon_foreground (used by both `connect --foreground` and `__vpn-daemon`). Detached child uses pre_exec(setsid) and inherits --context from the parent so it resolves the same VPN config. Refuses to start if a daemon is already running on the control socket; cleans up stale socket files. Switches the proxy bind from 16443 (sienna's existing SSH tunnel uses it) to 16579. - sunbeam-net/src/daemon/lifecycle: add a SocketGuard RAII type so the IPC control socket is unlinked when the daemon exits, regardless of shutdown path. Otherwise `vpn status` after a clean disconnect would see a stale socket and report an error. End-to-end smoke test against the docker stack: $ sunbeam connect ==> VPN daemon spawned (pid 90072, ...) Connected (100.64.0.154, fd7a:115c:a1e0::9a) — 2 peers visible $ sunbeam vpn status VPN: running addresses: 100.64.0.154, fd7a:115c:a1e0::9a peers: 2 derp home: region 0 $ sunbeam disconnect ==> Asking VPN daemon to stop... Daemon acknowledged shutdown. $ sunbeam vpn status VPN: not running
2026-04-07 14:57:15 +01:00
// 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.
proxy_bind: "127.0.0.1:16579".parse().expect("static addr"),
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}")))?;
feat(cli): background the VPN daemon with re-exec + clean shutdown `sunbeam connect` now fork-execs itself with a hidden `__vpn-daemon` subcommand instead of running the daemon in-process. The user-facing command spawns the child detached (stdio → log file, setsid for no controlling TTY), polls the IPC socket until the daemon reaches Running, prints a one-line status, and exits. The user gets back to their shell immediately. - src/cli.rs: `Connect { foreground }` instead of unit. Add hidden `__vpn-daemon` Verb that the spawned child runs. - src/vpn_cmds.rs: split into spawn_background_daemon (default path) and run_daemon_foreground (used by both `connect --foreground` and `__vpn-daemon`). Detached child uses pre_exec(setsid) and inherits --context from the parent so it resolves the same VPN config. Refuses to start if a daemon is already running on the control socket; cleans up stale socket files. Switches the proxy bind from 16443 (sienna's existing SSH tunnel uses it) to 16579. - sunbeam-net/src/daemon/lifecycle: add a SocketGuard RAII type so the IPC control socket is unlinked when the daemon exits, regardless of shutdown path. Otherwise `vpn status` after a clean disconnect would see a stale socket and report an error. End-to-end smoke test against the docker stack: $ sunbeam connect ==> VPN daemon spawned (pid 90072, ...) Connected (100.64.0.154, fd7a:115c:a1e0::9a) — 2 peers visible $ sunbeam vpn status VPN: running addresses: 100.64.0.154, fd7a:115c:a1e0::9a peers: 2 derp home: region 0 $ sunbeam disconnect ==> Asking VPN daemon to stop... Daemon acknowledged shutdown. $ sunbeam vpn status VPN: not running
2026-04-07 14:57:15 +01:00
// Wait for either Ctrl-C, SIGTERM, or the daemon stopping itself
// (e.g. via an IPC `Stop` request).
let ctrl_c = tokio::signal::ctrl_c();
tokio::pin!(ctrl_c);
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.map_err(|e| SunbeamError::Other(format!("install SIGTERM handler: {e}")))?;
loop {
tokio::select! {
biased;
_ = &mut ctrl_c => {
step("Interrupt — disconnecting...");
break;
}
feat(cli): background the VPN daemon with re-exec + clean shutdown `sunbeam connect` now fork-execs itself with a hidden `__vpn-daemon` subcommand instead of running the daemon in-process. The user-facing command spawns the child detached (stdio → log file, setsid for no controlling TTY), polls the IPC socket until the daemon reaches Running, prints a one-line status, and exits. The user gets back to their shell immediately. - src/cli.rs: `Connect { foreground }` instead of unit. Add hidden `__vpn-daemon` Verb that the spawned child runs. - src/vpn_cmds.rs: split into spawn_background_daemon (default path) and run_daemon_foreground (used by both `connect --foreground` and `__vpn-daemon`). Detached child uses pre_exec(setsid) and inherits --context from the parent so it resolves the same VPN config. Refuses to start if a daemon is already running on the control socket; cleans up stale socket files. Switches the proxy bind from 16443 (sienna's existing SSH tunnel uses it) to 16579. - sunbeam-net/src/daemon/lifecycle: add a SocketGuard RAII type so the IPC control socket is unlinked when the daemon exits, regardless of shutdown path. Otherwise `vpn status` after a clean disconnect would see a stale socket and report an error. End-to-end smoke test against the docker stack: $ sunbeam connect ==> VPN daemon spawned (pid 90072, ...) Connected (100.64.0.154, fd7a:115c:a1e0::9a) — 2 peers visible $ sunbeam vpn status VPN: running addresses: 100.64.0.154, fd7a:115c:a1e0::9a peers: 2 derp home: region 0 $ sunbeam disconnect ==> Asking VPN daemon to stop... Daemon acknowledged shutdown. $ sunbeam vpn status VPN: not running
2026-04-07 14:57:15 +01:00
_ = sigterm.recv() => {
step("SIGTERM — disconnecting...");
break;
}
feat(cli): background the VPN daemon with re-exec + clean shutdown `sunbeam connect` now fork-execs itself with a hidden `__vpn-daemon` subcommand instead of running the daemon in-process. The user-facing command spawns the child detached (stdio → log file, setsid for no controlling TTY), polls the IPC socket until the daemon reaches Running, prints a one-line status, and exits. The user gets back to their shell immediately. - src/cli.rs: `Connect { foreground }` instead of unit. Add hidden `__vpn-daemon` Verb that the spawned child runs. - src/vpn_cmds.rs: split into spawn_background_daemon (default path) and run_daemon_foreground (used by both `connect --foreground` and `__vpn-daemon`). Detached child uses pre_exec(setsid) and inherits --context from the parent so it resolves the same VPN config. Refuses to start if a daemon is already running on the control socket; cleans up stale socket files. Switches the proxy bind from 16443 (sienna's existing SSH tunnel uses it) to 16579. - sunbeam-net/src/daemon/lifecycle: add a SocketGuard RAII type so the IPC control socket is unlinked when the daemon exits, regardless of shutdown path. Otherwise `vpn status` after a clean disconnect would see a stale socket and report an error. End-to-end smoke test against the docker stack: $ sunbeam connect ==> VPN daemon spawned (pid 90072, ...) Connected (100.64.0.154, fd7a:115c:a1e0::9a) — 2 peers visible $ sunbeam vpn status VPN: running addresses: 100.64.0.154, fd7a:115c:a1e0::9a peers: 2 derp home: region 0 $ sunbeam disconnect ==> Asking VPN daemon to stop... Daemon acknowledged shutdown. $ sunbeam vpn status VPN: not running
2026-04-07 14:57:15 +01:00
_ = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
if matches!(handle.current_status(), sunbeam_net::DaemonStatus::Stopped) {
break;
}
}
}
}
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 socket = vpn_state_dir()?.join("daemon.sock");
let client = sunbeam_net::IpcClient::new(&socket);
if !client.socket_exists() {
return Err(SunbeamError::Other(
"no running VPN daemon (control socket missing)".into(),
));
}
step("Asking VPN daemon to stop...");
client
.stop()
.await
.map_err(|e| SunbeamError::Other(format!("IPC stop: {e}")))?;
ok("Daemon acknowledged shutdown.");
Ok(())
}
/// Run `sunbeam vpn status` — query a running daemon's status via IPC.
pub async fn cmd_vpn_status() -> Result<()> {
let socket = vpn_state_dir()?.join("daemon.sock");
let client = sunbeam_net::IpcClient::new(&socket);
if !client.socket_exists() {
println!("VPN: not running");
return Ok(());
}
match client.status().await {
Ok(sunbeam_net::DaemonStatus::Running { addresses, peer_count, derp_home }) => {
let addrs: Vec<String> = addresses.iter().map(|a| a.to_string()).collect();
println!("VPN: running");
println!(" addresses: {}", addrs.join(", "));
println!(" peers: {peer_count}");
if let Some(region) = derp_home {
println!(" derp home: region {region}");
}
}
Ok(other) => {
println!("VPN: {other}");
}
Err(e) => {
// Socket exists but daemon isn't actually responding — common
// when the daemon crashed and left a stale socket file behind.
println!("VPN: stale socket at {} ({e})", socket.display());
}
}
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"))
}