2026-04-07 14:39:40 +01:00
|
|
|
//! `sunbeam connect` / `sunbeam disconnect` / `sunbeam vpn ...`
|
|
|
|
|
//!
|
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.
|
2026-04-07 14:39:40 +01:00
|
|
|
|
|
|
|
|
use crate::config::active_context;
|
|
|
|
|
use crate::error::{Result, SunbeamError};
|
|
|
|
|
use crate::output::{ok, step, warn};
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
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<()> {
|
2026-04-07 14:39:40 +01:00
|
|
|
let ctx = active_context();
|
|
|
|
|
if ctx.vpn_url.is_empty() {
|
|
|
|
|
return Err(SunbeamError::Other(
|
2026-04-07 14:57:15 +01:00
|
|
|
"no VPN configured for this context — set vpn-url and vpn-auth-key in config".into(),
|
2026-04-07 14:39:40 +01:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
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| {
|
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()))
|
2026-04-07 14:39:40 +01:00
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
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(),
|
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
|
2026-04-07 15:00:30 +01:00
|
|
|
// shouldn't collide on dev machines. TODO: make this configurable.
|
2026-04-07 14:57:15 +01:00
|
|
|
proxy_bind: "127.0.0.1:16579".parse().expect("static addr"),
|
2026-04-07 15:00:30 +01:00
|
|
|
// Static fallback if the netmap doesn't have the named host.
|
2026-04-07 14:39:40 +01:00
|
|
|
cluster_api_addr: "100.64.0.1".parse().expect("static addr"),
|
|
|
|
|
cluster_api_port: 6443,
|
2026-04-07 15:00:30 +01:00
|
|
|
// 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())
|
|
|
|
|
},
|
2026-04-07 14:39:40 +01:00
|
|
|
control_socket: state_dir.join("daemon.sock"),
|
|
|
|
|
hostname,
|
|
|
|
|
server_public_key: None,
|
2026-04-07 15:32:44 +01:00
|
|
|
derp_tls_insecure: ctx.vpn_tls_insecure,
|
2026-04-07 14:39:40 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
step(&format!("Connecting to {}", ctx.vpn_url));
|
|
|
|
|
let handle = sunbeam_net::VpnDaemon::start(config)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| SunbeamError::Other(format!("daemon start: {e}")))?;
|
|
|
|
|
|
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...");
|
2026-04-07 14:39:40 +01:00
|
|
|
break;
|
|
|
|
|
}
|
2026-04-07 14:57:15 +01:00
|
|
|
_ = sigterm.recv() => {
|
|
|
|
|
step("SIGTERM — disconnecting...");
|
|
|
|
|
break;
|
2026-04-07 14:39:40 +01:00
|
|
|
}
|
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;
|
|
|
|
|
}
|
2026-04-07 14:39:40 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:32:44 +01:00
|
|
|
/// Run `sunbeam vpn create-key` — call Headscale's REST API to mint a
|
|
|
|
|
/// new pre-auth key for onboarding a new client.
|
|
|
|
|
///
|
|
|
|
|
/// Reads `vpn-url` and `vpn-api-key` from the active context. The user
|
|
|
|
|
/// must have generated a Headscale API key out-of-band (typically via
|
|
|
|
|
/// `headscale apikeys create` on the cluster) and stored it in the
|
|
|
|
|
/// context config.
|
|
|
|
|
pub async fn cmd_vpn_create_key(
|
|
|
|
|
user: &str,
|
|
|
|
|
reusable: bool,
|
|
|
|
|
ephemeral: bool,
|
|
|
|
|
expiration: &str,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let ctx = active_context();
|
|
|
|
|
if ctx.vpn_url.is_empty() {
|
|
|
|
|
return Err(SunbeamError::Other(
|
|
|
|
|
"no vpn-url configured for this context".into(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if ctx.vpn_api_key.is_empty() {
|
|
|
|
|
return Err(SunbeamError::Other(
|
|
|
|
|
"no vpn-api-key configured — generate one with \
|
|
|
|
|
`kubectl exec -n vpn deploy/headscale -- headscale apikeys create` \
|
|
|
|
|
and add it to your context as vpn-api-key"
|
|
|
|
|
.into(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Headscale's REST API mirrors its gRPC schema. Body fields use
|
|
|
|
|
// snake_case in the JSON request.
|
|
|
|
|
let body = serde_json::json!({
|
|
|
|
|
"user": user,
|
|
|
|
|
"reusable": reusable,
|
|
|
|
|
"ephemeral": ephemeral,
|
|
|
|
|
"expiration": expiration_to_rfc3339(expiration)?,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let endpoint = format!("{}/api/v1/preauthkey", ctx.vpn_url.trim_end_matches('/'));
|
|
|
|
|
let client = reqwest::Client::builder()
|
|
|
|
|
.danger_accept_invalid_certs(ctx.vpn_tls_insecure)
|
|
|
|
|
.build()
|
|
|
|
|
.map_err(|e| SunbeamError::Other(format!("build http client: {e}")))?;
|
|
|
|
|
|
|
|
|
|
step(&format!("Creating pre-auth key on {}", ctx.vpn_url));
|
|
|
|
|
let resp = client
|
|
|
|
|
.post(&endpoint)
|
|
|
|
|
.bearer_auth(&ctx.vpn_api_key)
|
|
|
|
|
.json(&body)
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| SunbeamError::Other(format!("POST {endpoint}: {e}")))?;
|
|
|
|
|
|
|
|
|
|
let status = resp.status();
|
|
|
|
|
let text = resp
|
|
|
|
|
.text()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| SunbeamError::Other(format!("read response body: {e}")))?;
|
|
|
|
|
|
|
|
|
|
if !status.is_success() {
|
|
|
|
|
return Err(SunbeamError::Other(format!(
|
|
|
|
|
"headscale returned {status}: {text}"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Response shape: {"preAuthKey": {"key": "...", "user": "...", "reusable": ..., ...}}
|
|
|
|
|
let parsed: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
|
|
|
|
|
SunbeamError::Other(format!("parse headscale response: {e}\nbody: {text}"))
|
|
|
|
|
})?;
|
|
|
|
|
let key = parsed
|
|
|
|
|
.get("preAuthKey")
|
|
|
|
|
.and_then(|p| p.get("key"))
|
|
|
|
|
.and_then(|k| k.as_str())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
SunbeamError::Other(format!("no preAuthKey.key in response: {text}"))
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
ok(&format!("Pre-auth key for user '{user}':"));
|
|
|
|
|
println!("{key}");
|
|
|
|
|
println!();
|
|
|
|
|
println!("Add it to a context with:");
|
|
|
|
|
println!(" sunbeam config set --context <ctx> vpn-auth-key {key}");
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convert a human-friendly duration ("30d", "1h", "2w") into the RFC3339
|
|
|
|
|
/// timestamp Headscale expects in pre-auth key requests.
|
|
|
|
|
fn expiration_to_rfc3339(s: &str) -> Result<String> {
|
|
|
|
|
let s = s.trim();
|
|
|
|
|
if s.is_empty() {
|
|
|
|
|
return Err(SunbeamError::Other("empty expiration".into()));
|
|
|
|
|
}
|
|
|
|
|
let (num, unit) = s.split_at(s.len() - 1);
|
|
|
|
|
let n: u64 = num
|
|
|
|
|
.parse()
|
|
|
|
|
.map_err(|_| SunbeamError::Other(format!("bad expiration '{s}': expected like '30d'")))?;
|
|
|
|
|
let secs = match unit {
|
|
|
|
|
"s" => n,
|
|
|
|
|
"m" => n * 60,
|
|
|
|
|
"h" => n * 3600,
|
|
|
|
|
"d" => n * 86_400,
|
|
|
|
|
"w" => n * 86_400 * 7,
|
|
|
|
|
other => {
|
|
|
|
|
return Err(SunbeamError::Other(format!(
|
|
|
|
|
"bad expiration unit '{other}': expected s/m/h/d/w"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let when = std::time::SystemTime::now()
|
|
|
|
|
.checked_add(std::time::Duration::from_secs(secs))
|
|
|
|
|
.ok_or_else(|| SunbeamError::Other("expiration overflow".into()))?
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
.map_err(|e| SunbeamError::Other(format!("system time: {e}")))?
|
|
|
|
|
.as_secs();
|
|
|
|
|
// Format as RFC3339 manually using the same proleptic Gregorian
|
|
|
|
|
// approach as elsewhere in this crate. Headscale parses Go's
|
|
|
|
|
// time.RFC3339, which is `YYYY-MM-DDTHH:MM:SSZ`.
|
|
|
|
|
Ok(unix_to_rfc3339(when as i64))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convert a unix timestamp (seconds since epoch) to an RFC3339 string.
|
|
|
|
|
fn unix_to_rfc3339(secs: i64) -> String {
|
|
|
|
|
let days = secs.div_euclid(86_400);
|
|
|
|
|
let day_secs = secs.rem_euclid(86_400);
|
|
|
|
|
let h = day_secs / 3600;
|
|
|
|
|
let m = (day_secs % 3600) / 60;
|
|
|
|
|
let s = day_secs % 60;
|
|
|
|
|
let (year, month, day) = days_to_ymd(days);
|
|
|
|
|
format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Days since 1970-01-01 → (year, month, day). Proleptic Gregorian.
|
|
|
|
|
fn days_to_ymd(mut days: i64) -> (i32, u32, u32) {
|
|
|
|
|
let mut y: i32 = 1970;
|
|
|
|
|
loop {
|
|
|
|
|
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
|
|
|
|
|
let year_days: i64 = if leap { 366 } else { 365 };
|
|
|
|
|
if days < year_days {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
days -= year_days;
|
|
|
|
|
y += 1;
|
|
|
|
|
}
|
|
|
|
|
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
|
|
|
|
|
let mdays = [
|
|
|
|
|
31u32,
|
|
|
|
|
if leap { 29 } else { 28 },
|
|
|
|
|
31,
|
|
|
|
|
30,
|
|
|
|
|
31,
|
|
|
|
|
30,
|
|
|
|
|
31,
|
|
|
|
|
31,
|
|
|
|
|
30,
|
|
|
|
|
31,
|
|
|
|
|
30,
|
|
|
|
|
31,
|
|
|
|
|
];
|
|
|
|
|
let mut mo = 1u32;
|
|
|
|
|
let mut days_u = days as u32;
|
|
|
|
|
for md in mdays {
|
|
|
|
|
if days_u < md {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
days_u -= md;
|
|
|
|
|
mo += 1;
|
|
|
|
|
}
|
|
|
|
|
(y, mo, days_u + 1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 14:39:40 +01:00
|
|
|
/// 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"))
|
|
|
|
|
}
|