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:
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"))
|
||||
}
|
||||
Reference in New Issue
Block a user