feat(cli): sunbeam vpn create-key + vpn-tls-insecure config flag
`sunbeam vpn create-key` calls Headscale's REST API at
`/api/v1/preauthkey` 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 generates the API key once via `headscale apikeys create`
on the cluster and stores it in their context config.
Flags:
- --user <name> Headscale user the key belongs to
- --reusable allow multiple registrations with the same key
- --ephemeral auto-delete the node when its map stream drops
- --expiration <dur> human-friendly lifetime ("30d", "1h", "2w")
Also adds a `vpn-tls-insecure` context flag that controls TLS
verification across the whole VPN integration: it's now used by both
the daemon (for the Noise control connection + DERP relay) and the
new create-key REST client. Test stacks with self-signed certs set
this to true; production stacks leave it false.
Verified end-to-end against the docker test stack:
$ sunbeam vpn create-key --user test --reusable --expiration 1h
==> Creating pre-auth key on https://localhost:8443
Pre-auth key for user 'test':
ebcd77f51bf30ef373c9070382b834859935797a90c2647f
Add it to a context with:
sunbeam config set --context <ctx> vpn-auth-key ebcd77f5...
This commit is contained in:
22
src/cli.rs
22
src/cli.rs
@@ -206,6 +206,22 @@ pub enum Verb {
|
|||||||
pub enum VpnAction {
|
pub enum VpnAction {
|
||||||
/// Show VPN tunnel status.
|
/// Show VPN tunnel status.
|
||||||
Status,
|
Status,
|
||||||
|
/// Create a new pre-auth key for onboarding a new client.
|
||||||
|
CreateKey {
|
||||||
|
/// Headscale user the key belongs to (default: from CLI user).
|
||||||
|
#[arg(long, default_value = "sunbeam")]
|
||||||
|
user: String,
|
||||||
|
/// Make the key reusable across multiple registrations.
|
||||||
|
#[arg(long)]
|
||||||
|
reusable: bool,
|
||||||
|
/// Mark the key (and any node registered with it) ephemeral —
|
||||||
|
/// Headscale auto-deletes the node when its map stream drops.
|
||||||
|
#[arg(long)]
|
||||||
|
ephemeral: bool,
|
||||||
|
/// Key lifetime, in human-readable form (e.g. "1h", "30d").
|
||||||
|
#[arg(long, default_value = "30d")]
|
||||||
|
expiration: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
@@ -1532,6 +1548,12 @@ pub async fn dispatch() -> Result<()> {
|
|||||||
|
|
||||||
Some(Verb::Vpn { action }) => match action {
|
Some(Verb::Vpn { action }) => match action {
|
||||||
VpnAction::Status => crate::vpn_cmds::cmd_vpn_status().await,
|
VpnAction::Status => crate::vpn_cmds::cmd_vpn_status().await,
|
||||||
|
VpnAction::CreateKey {
|
||||||
|
user,
|
||||||
|
reusable,
|
||||||
|
ephemeral,
|
||||||
|
expiration,
|
||||||
|
} => crate::vpn_cmds::cmd_vpn_create_key(&user, reusable, ephemeral, &expiration).await,
|
||||||
},
|
},
|
||||||
|
|
||||||
Some(Verb::VpnDaemon) => crate::vpn_cmds::cmd_vpn_daemon().await,
|
Some(Verb::VpnDaemon) => crate::vpn_cmds::cmd_vpn_daemon().await,
|
||||||
|
|||||||
@@ -72,6 +72,23 @@ pub struct Context {
|
|||||||
/// empty, falls back to a static fallback address.
|
/// empty, falls back to a static fallback address.
|
||||||
#[serde(default, rename = "vpn-cluster-host", skip_serializing_if = "String::is_empty")]
|
#[serde(default, rename = "vpn-cluster-host", skip_serializing_if = "String::is_empty")]
|
||||||
pub vpn_cluster_host: String,
|
pub vpn_cluster_host: String,
|
||||||
|
|
||||||
|
/// Headscale API key for `sunbeam vpn create-key` and other admin
|
||||||
|
/// commands. Generated once via `headscale apikeys create`. Stored
|
||||||
|
/// in plain text — keep this file readable only by the user.
|
||||||
|
#[serde(default, rename = "vpn-api-key", skip_serializing_if = "String::is_empty")]
|
||||||
|
pub vpn_api_key: String,
|
||||||
|
|
||||||
|
/// Skip TLS certificate verification when talking to the VPN
|
||||||
|
/// coordination server (control plane, DERP relay, REST API).
|
||||||
|
/// Only set this for test stacks with self-signed certs — leave
|
||||||
|
/// false for production.
|
||||||
|
#[serde(default, rename = "vpn-tls-insecure", skip_serializing_if = "is_false")]
|
||||||
|
pub vpn_tls_insecure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_false(b: &bool) -> bool {
|
||||||
|
!*b
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
172
src/vpn_cmds.rs
172
src/vpn_cmds.rs
@@ -187,8 +187,7 @@ async fn run_daemon_foreground() -> Result<()> {
|
|||||||
control_socket: state_dir.join("daemon.sock"),
|
control_socket: state_dir.join("daemon.sock"),
|
||||||
hostname,
|
hostname,
|
||||||
server_public_key: None,
|
server_public_key: None,
|
||||||
// Production deployments use proper TLS — leave verification on.
|
derp_tls_insecure: ctx.vpn_tls_insecure,
|
||||||
derp_tls_insecure: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
step(&format!("Connecting to {}", ctx.vpn_url));
|
step(&format!("Connecting to {}", ctx.vpn_url));
|
||||||
@@ -279,6 +278,175 @@ pub async fn cmd_vpn_status() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the on-disk directory for VPN state (keys, control socket).
|
/// Resolve the on-disk directory for VPN state (keys, control socket).
|
||||||
fn vpn_state_dir() -> Result<PathBuf> {
|
fn vpn_state_dir() -> Result<PathBuf> {
|
||||||
let home = std::env::var("HOME")
|
let home = std::env::var("HOME")
|
||||||
|
|||||||
Reference in New Issue
Block a user