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
This commit is contained in:
2026-04-07 14:57:15 +01:00
parent 7019937f6f
commit 27a6f4377c
5 changed files with 208 additions and 52 deletions

View File

@@ -42,6 +42,11 @@ async fn run_daemon_loop(
status: Arc<RwLock<DaemonStatus>>,
shutdown: tokio_util::sync::CancellationToken,
) -> crate::Result<()> {
// Make sure the IPC control socket is cleaned up no matter how the
// daemon exits — otherwise `sunbeam vpn status` after a clean shutdown
// would see a stale socket file and report "stale socket".
let _socket_guard = SocketGuard::new(config.control_socket.clone());
let keys = crate::keys::NodeKeys::load_or_generate(&config.state_dir)?;
let mut attempt: u32 = 0;
let max_backoff = Duration::from_secs(60);
@@ -86,6 +91,26 @@ enum SessionExit {
Disconnected,
}
/// RAII guard that removes a Unix socket file when dropped. Used by
/// `run_daemon_loop` to make sure the IPC control socket is cleaned up
/// when the daemon exits, regardless of whether shutdown was triggered
/// via DaemonHandle, IPC Stop, signal, or panic.
struct SocketGuard {
path: std::path::PathBuf,
}
impl SocketGuard {
fn new(path: std::path::PathBuf) -> Self {
Self { path }
}
}
impl Drop for SocketGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
/// Run a single VPN session. Returns when the session ends (error or shutdown).
async fn run_session(
config: &VpnConfig,