2026-04-07 14:39:40 +01:00
|
|
|
//! `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<()> {
|
2026-04-07 14:46:47 +01:00
|
|
|
let socket = vpn_state_dir()?.join("daemon.sock");
|
|
|
|
|
let client = sunbeam_net::IpcClient::new(&socket);
|
|
|
|
|
if !client.socket_exists() {
|
2026-04-07 14:39:40 +01:00
|
|
|
return Err(SunbeamError::Other(
|
|
|
|
|
"no running VPN daemon (control socket missing)".into(),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-04-07 14:46:47 +01:00
|
|
|
step("Asking VPN daemon to stop...");
|
|
|
|
|
client
|
|
|
|
|
.stop()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| SunbeamError::Other(format!("IPC stop: {e}")))?;
|
|
|
|
|
ok("Daemon acknowledged shutdown.");
|
|
|
|
|
Ok(())
|
2026-04-07 14:39:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run `sunbeam vpn status` — query a running daemon's status via IPC.
|
|
|
|
|
pub async fn cmd_vpn_status() -> Result<()> {
|
2026-04-07 14:46:47 +01:00
|
|
|
let socket = vpn_state_dir()?.join("daemon.sock");
|
|
|
|
|
let client = sunbeam_net::IpcClient::new(&socket);
|
|
|
|
|
if !client.socket_exists() {
|
2026-04-07 14:39:40 +01:00
|
|
|
println!("VPN: not running");
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
2026-04-07 14:46:47 +01:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-07 14:39:40 +01:00
|
|
|
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"))
|
|
|
|
|
}
|