Files
cli/src/vpn_cmds.rs

288 lines
10 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.
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
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,
feat(net): TLS support for HTTPS coordination URLs and DERP relays Production Headscale terminates TLS for both the control plane (via the TS2021 HTTP CONNECT upgrade endpoint) and the embedded DERP relay. Without TLS, the daemon could only talk to plain-HTTP test stacks. - New crate::tls module: shared TlsMode (Verify | InsecureSkipVerify) + tls_wrap helper. webpki roots in Verify mode; an explicit ServerCertVerifier that accepts any cert in InsecureSkipVerify (test-only). - Cargo.toml: add tokio-rustls, webpki-roots, rustls-pemfile. - noise/handshake: perform_handshake is now generic over the underlying stream and takes an explicit `host_header` argument instead of using `peer_addr`. Lets callers pass either a TcpStream or a TLS-wrapped stream. - noise/stream: NoiseStream<S> is generic over the underlying transport with `S = TcpStream` as the default. The AsyncRead+AsyncWrite impls forward to whatever S provides. - control/client: ControlClient::connect detects `https://` in coordination_url and TLS-wraps the TCP stream before the Noise handshake. fetch_server_key now also TLS-wraps when needed. Both honor the new derp_tls_insecure config flag (which is misnamed but controls all TLS verification, not just DERP). - derp/client: DerpClient::connect_with_tls accepts a TlsMode and uses the shared tls::tls_wrap helper instead of duplicating it. The client struct's inner Framed is now generic over a Box<dyn DerpTransport> so it can hold either a plain or TLS-wrapped stream. - daemon/lifecycle: derive the DERP URL scheme from coordination_url (https → https) and pass derp_tls_insecure through. - config.rs: new `derp_tls_insecure: bool` field on VpnConfig. - src/vpn_cmds.rs: pass `derp_tls_insecure: false` for production. Two bug fixes found while wiring this up: - proxy/engine: bridge_connection used to set remote_done on any smoltcp recv error, including the transient InvalidState that smoltcp returns while a TCP socket is still in SynSent. That meant the engine gave up on the connection before the WG handshake even finished. Distinguish "not ready yet" (returns Ok(0)) from "actually closed" (returns Err) inside tcp_recv, and only mark remote_done on the latter. - proxy/engine: the connection's "done" condition required local_read_done, but most clients (curl, kubectl) keep their write side open until they read EOF. The engine never closed its local TCP, so clients sat in read_to_end forever. Drop the connection as soon as the remote side has finished and we've drained its buffer to the local socket — the local TcpStream drop closes the socket and the client sees EOF.
2026-04-07 15:28:44 +01:00
// Production deployments use proper TLS — leave verification on.
derp_tls_insecure: false,
};
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"))
}