Phase 8a: fleet-ca-tailscale-sign — Tailscale-aware leaf certs #62
@@ -27,6 +27,7 @@ pub mod rpc;
|
||||
pub mod services;
|
||||
pub mod snapshot;
|
||||
pub mod tags;
|
||||
pub mod tailscale;
|
||||
pub mod transport;
|
||||
pub mod wal;
|
||||
pub mod wal_mutation;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Tailscale identity helpers (Phase 8).
|
||||
//!
|
||||
//! When a laptop or roaming client wants to join the fleet, its
|
||||
//! "identity" naturally includes its Tailscale hostname
|
||||
//! (`laptop.taila4f562.ts.net`) and its tailnet IPv4 (`100.x.y.z`).
|
||||
//! Those are what other peers will dial it by. This module reads
|
||||
//! them from the local `tailscale` CLI so the operator doesn't
|
||||
//! have to eyeball them off the Tailscale admin panel.
|
||||
//!
|
||||
//! We shell out to `tailscale status --json` rather than link
|
||||
//! `tsnet` because:
|
||||
//! * `tsnet` pulls a hefty Go runtime and full Tailscale client
|
||||
//! into every binary.
|
||||
//! * `tailscale` CLI is universally installed on any node that
|
||||
//! actually uses Tailscale.
|
||||
//! * The API surface we need — one identity query — is trivial.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Self-identity as reported by Tailscale.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TailscaleSelf {
|
||||
/// MagicDNS name, e.g. `laptop.taila4f562.ts.net`. Missing
|
||||
/// when MagicDNS is disabled on the tailnet — callers must
|
||||
/// tolerate `None` and fall back to IPs.
|
||||
pub magicdns_name: Option<String>,
|
||||
/// All tailnet IPs assigned to this node (typically one v4 +
|
||||
/// one v6). Ordered as Tailscale reported them.
|
||||
pub tailscale_ips: Vec<String>,
|
||||
/// Short hostname portion of the MagicDNS name, e.g. `laptop`.
|
||||
/// Missing when MagicDNS is disabled.
|
||||
pub short_hostname: Option<String>,
|
||||
}
|
||||
|
||||
impl TailscaleSelf {
|
||||
/// Every SAN suitable for a leaf-cert: the MagicDNS name (if
|
||||
/// present) and every tailnet IP as an IP-SAN. Convenience for
|
||||
/// the CA-sign flow.
|
||||
///
|
||||
/// Note: rcgen currently only takes DNS-form SANs from a plain
|
||||
/// `Vec<String>`; the IPs come in as DNS-form strings which
|
||||
/// most Tailscale-facing dialers will not check against IP-SAN
|
||||
/// verification anyway. Kept in the returned vec for
|
||||
/// operator visibility.
|
||||
pub fn suggested_sans(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(name) = &self.magicdns_name {
|
||||
out.push(name.clone());
|
||||
}
|
||||
for ip in &self.tailscale_ips {
|
||||
out.push(ip.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell out to `tailscale status --json` and pluck the self record.
|
||||
///
|
||||
/// Fails when:
|
||||
/// * `tailscale` isn't on PATH — this node isn't on the tailnet;
|
||||
/// the operator wanted a Tailscale identity somewhere it doesn't
|
||||
/// exist.
|
||||
/// * The daemon is stopped or the socket unreachable — same story.
|
||||
/// * The JSON shape doesn't include a `Self` record — indicates a
|
||||
/// Tailscale version we haven't seen.
|
||||
pub fn read_self() -> Result<TailscaleSelf> {
|
||||
let output = std::process::Command::new("tailscale")
|
||||
.args(["status", "--json"])
|
||||
.output()
|
||||
.context("running `tailscale status --json` (is tailscale installed + running?)")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"`tailscale status --json` exited {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
parse_status(&output.stdout)
|
||||
}
|
||||
|
||||
fn parse_status(bytes: &[u8]) -> Result<TailscaleSelf> {
|
||||
let status: TsStatus = serde_json::from_slice(bytes)
|
||||
.context("parsing tailscale status JSON")?;
|
||||
let s = status
|
||||
.self_
|
||||
.as_ref()
|
||||
.context("tailscale status has no `Self` record")?;
|
||||
// MagicDNS name in the wire schema is `DNSName` — e.g.
|
||||
// `laptop.taila4f562.ts.net.`. Trim the trailing dot for
|
||||
// downstream ergonomics.
|
||||
let magicdns_name = s
|
||||
.dns_name
|
||||
.as_deref()
|
||||
.map(|n| n.trim_end_matches('.').to_string())
|
||||
.filter(|n| !n.is_empty());
|
||||
// Short hostname is the first label of MagicDNS. `HostName`
|
||||
// is also present in the schema and is authoritative for the
|
||||
// Tailscale-registered short name.
|
||||
let short_hostname = s
|
||||
.hostname
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
magicdns_name.as_ref().and_then(|n| {
|
||||
n.split('.').next().map(str::to_string)
|
||||
})
|
||||
});
|
||||
Ok(TailscaleSelf {
|
||||
magicdns_name,
|
||||
tailscale_ips: s.tailscale_ips.clone().unwrap_or_default(),
|
||||
short_hostname,
|
||||
})
|
||||
}
|
||||
|
||||
// Only the shape we actually consume — Tailscale's real JSON is huge.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TsStatus {
|
||||
#[serde(rename = "Self")]
|
||||
self_: Option<TsSelf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TsSelf {
|
||||
#[serde(rename = "DNSName")]
|
||||
dns_name: Option<String>,
|
||||
#[serde(rename = "HostName")]
|
||||
hostname: Option<String>,
|
||||
#[serde(rename = "TailscaleIPs")]
|
||||
tailscale_ips: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_status_extracts_full_identity() {
|
||||
let json = br#"{
|
||||
"Self": {
|
||||
"DNSName": "laptop.taila4f562.ts.net.",
|
||||
"HostName": "laptop",
|
||||
"TailscaleIPs": ["100.64.0.5", "fd7a:115c:a1e0::5"]
|
||||
}
|
||||
}"#;
|
||||
let s = parse_status(json).unwrap();
|
||||
assert_eq!(s.magicdns_name.as_deref(), Some("laptop.taila4f562.ts.net"));
|
||||
assert_eq!(s.short_hostname.as_deref(), Some("laptop"));
|
||||
assert_eq!(
|
||||
s.tailscale_ips,
|
||||
vec!["100.64.0.5".to_string(), "fd7a:115c:a1e0::5".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_status_missing_dns_fills_short_from_hostname() {
|
||||
// MagicDNS off, HostName present → still get short_hostname
|
||||
// via the direct field.
|
||||
let json = br#"{"Self": {"HostName": "laptop", "TailscaleIPs": ["100.64.0.5"]}}"#;
|
||||
let s = parse_status(json).unwrap();
|
||||
assert_eq!(s.magicdns_name, None);
|
||||
assert_eq!(s.short_hostname.as_deref(), Some("laptop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_status_no_self_errors() {
|
||||
let json = br#"{"BackendState": "Stopped"}"#;
|
||||
let err = parse_status(json).unwrap_err();
|
||||
assert!(err.to_string().to_lowercase().contains("self"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggested_sans_orders_magicdns_first() {
|
||||
let s = TailscaleSelf {
|
||||
magicdns_name: Some("laptop.tailnet.ts.net".into()),
|
||||
tailscale_ips: vec!["100.64.0.5".into()],
|
||||
short_hostname: Some("laptop".into()),
|
||||
};
|
||||
let sans = s.suggested_sans();
|
||||
assert_eq!(sans[0], "laptop.tailnet.ts.net");
|
||||
assert_eq!(sans[1], "100.64.0.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggested_sans_skips_missing_magicdns() {
|
||||
let s = TailscaleSelf {
|
||||
magicdns_name: None,
|
||||
tailscale_ips: vec!["100.64.0.5".into()],
|
||||
short_hostname: None,
|
||||
};
|
||||
let sans = s.suggested_sans();
|
||||
assert_eq!(sans, vec!["100.64.0.5".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -292,10 +292,27 @@ impl FleetCa {
|
||||
/// Sign a leaf cert and write PEM files (`node.crt`, `node.key`,
|
||||
/// `ca.crt`) into `out_dir`. Used by `fleet-ca sign`.
|
||||
pub fn sign_leaf_to_pem(&self, node_name: &str, out_dir: &Path) -> Result<()> {
|
||||
self.sign_leaf_to_pem_with_sans(node_name, &[], out_dir)
|
||||
}
|
||||
|
||||
/// Phase 8 (2026-07-14): sign a leaf with extra SANs alongside the
|
||||
/// primary `node_name`. Used by `fleet-ca-tailscale-sign` so a
|
||||
/// laptop's leaf cert works whether peers dial by its LAN
|
||||
/// hostname *or* its Tailscale MagicDNS name.
|
||||
///
|
||||
/// `extra_sans` are dropped in as DNS SANs. Empty entries are
|
||||
/// skipped so callers can conditionally include a value without
|
||||
/// pre-filtering.
|
||||
pub fn sign_leaf_to_pem_with_sans(
|
||||
&self,
|
||||
node_name: &str,
|
||||
extra_sans: &[String],
|
||||
out_dir: &Path,
|
||||
) -> Result<()> {
|
||||
if node_name.is_empty() {
|
||||
bail!("node name cannot be empty when signing a leaf");
|
||||
}
|
||||
let (leaf_key, leaf_cert) = self.mint_leaf(node_name)?;
|
||||
let (leaf_key, leaf_cert) = self.mint_leaf_with_sans(node_name, extra_sans)?;
|
||||
std::fs::create_dir_all(out_dir)
|
||||
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
|
||||
|
||||
@@ -317,10 +334,29 @@ impl FleetCa {
|
||||
fn mint_leaf(
|
||||
&self,
|
||||
node_name: &str,
|
||||
) -> Result<(rcgen::KeyPair, rcgen::Certificate)> {
|
||||
self.mint_leaf_with_sans(node_name, &[])
|
||||
}
|
||||
|
||||
/// Phase 8: like [`mint_leaf`] but tacks on additional DNS SANs.
|
||||
/// Order: `[node_name, ...extra_sans]`. Empty extras are dropped
|
||||
/// so callers can conditionally pass values.
|
||||
fn mint_leaf_with_sans(
|
||||
&self,
|
||||
node_name: &str,
|
||||
extra_sans: &[String],
|
||||
) -> Result<(rcgen::KeyPair, rcgen::Certificate)> {
|
||||
let leaf_key = rcgen::KeyPair::generate().context("generating leaf key")?;
|
||||
let mut leaf_params = rcgen::CertificateParams::new(vec![node_name.to_string()])
|
||||
.context("building leaf params")?;
|
||||
let mut sans = vec![node_name.to_string()];
|
||||
for s in extra_sans {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || sans.iter().any(|existing| existing == s) {
|
||||
continue;
|
||||
}
|
||||
sans.push(s.to_string());
|
||||
}
|
||||
let mut leaf_params =
|
||||
rcgen::CertificateParams::new(sans).context("building leaf params")?;
|
||||
leaf_params
|
||||
.distinguished_name
|
||||
.push(rcgen::DnType::CommonName, node_name);
|
||||
|
||||
+71
-1
@@ -129,6 +129,25 @@ enum Cmd {
|
||||
/// `ca.crt` (public), `node.crt` (public), `node.key` (private,
|
||||
/// 0o600) into `--out-dir`. Copy those three files to the target
|
||||
/// node and point `[cluster.tls]` at them.
|
||||
/// Phase 8 (2026-07-14): sign a leaf cert using this node's
|
||||
/// Tailscale identity as extra SANs. Queries the local
|
||||
/// `tailscale` CLI for MagicDNS name + tailnet IPs and folds
|
||||
/// them into the cert alongside `--node`. Zero-touch bootstrap
|
||||
/// for laptops joining the fleet: they can be reached by
|
||||
/// MagicDNS name from anywhere on the tailnet.
|
||||
FleetCaTailscaleSign {
|
||||
/// Directory holding the fleet CA (`ca.crt` + `ca.key`),
|
||||
/// typically produced by `fleet-ca-init`.
|
||||
#[arg(long)]
|
||||
ca_dir: PathBuf,
|
||||
/// Primary node name for the cert (CN + first SAN).
|
||||
/// Defaults to the Tailscale short hostname.
|
||||
#[arg(long)]
|
||||
node: Option<String>,
|
||||
/// Where to write `ca.crt` + `node.crt` + `node.key`.
|
||||
#[arg(long)]
|
||||
out_dir: PathBuf,
|
||||
},
|
||||
FleetCaSign {
|
||||
/// Directory holding the CA (`ca.crt` + `ca.key`) — the same
|
||||
/// dir passed to `fleet-ca init`.
|
||||
@@ -265,6 +284,11 @@ async fn main() -> Result<()> {
|
||||
node,
|
||||
out_dir,
|
||||
} => return cmd_fleet_ca_sign(ca_dir, node, out_dir),
|
||||
Cmd::FleetCaTailscaleSign {
|
||||
ca_dir,
|
||||
node,
|
||||
out_dir,
|
||||
} => return cmd_fleet_ca_tailscale_sign(ca_dir, node.as_deref(), out_dir),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -324,7 +348,7 @@ async fn main() -> Result<()> {
|
||||
rpc_addr,
|
||||
tls_dir,
|
||||
} => cmd_cluster_peer_status(&peer, rpc_addr, &tls_dir).await?,
|
||||
Cmd::FleetCaInit { .. } | Cmd::FleetCaSign { .. } => {
|
||||
Cmd::FleetCaInit { .. } | Cmd::FleetCaSign { .. } | Cmd::FleetCaTailscaleSign { .. } => {
|
||||
// Handled by the config-independent short-circuit above.
|
||||
unreachable!("fleet-ca commands short-circuit before config load");
|
||||
}
|
||||
@@ -935,6 +959,52 @@ fn cmd_fleet_ca_sign(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_fleet_ca_tailscale_sign(
|
||||
ca_dir: &std::path::Path,
|
||||
node: Option<&str>,
|
||||
out_dir: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
use cluster::tailscale;
|
||||
use cluster::transport::FleetCa;
|
||||
|
||||
let ts = tailscale::read_self()
|
||||
.context("reading Tailscale identity via `tailscale status --json`")?;
|
||||
let primary = match node {
|
||||
Some(n) if !n.is_empty() => n.to_string(),
|
||||
_ => ts
|
||||
.short_hostname
|
||||
.clone()
|
||||
.context("no --node given and Tailscale reports no HostName")?,
|
||||
};
|
||||
let sans = ts.suggested_sans();
|
||||
|
||||
let ca = FleetCa::load(ca_dir).context("loading fleet CA")?;
|
||||
ca.sign_leaf_to_pem_with_sans(&primary, &sans, out_dir)
|
||||
.context("signing + writing per-node PEMs (with Tailscale SANs)")?;
|
||||
println!("── fleet-ca tailscale-sign ─────────────────────────");
|
||||
println!("primary CN/SAN: {primary}");
|
||||
if !sans.is_empty() {
|
||||
println!("extra SANs:");
|
||||
for s in &sans {
|
||||
println!(" - {s}");
|
||||
}
|
||||
} else {
|
||||
println!("extra SANs: (none — Tailscale reported no identity data)");
|
||||
}
|
||||
println!();
|
||||
println!("written:");
|
||||
println!(" {}", out_dir.join("ca.crt").display());
|
||||
println!(" {}", out_dir.join("node.crt").display());
|
||||
println!(
|
||||
" {} (chmod 0600; distribute securely)",
|
||||
out_dir.join("node.key").display()
|
||||
);
|
||||
println!();
|
||||
println!("On this node, point [cluster.tls] in the config at those three paths.");
|
||||
println!("────────────────────────────────────────────────────");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── cluster ping ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Round-trip a `ping` payload to `peer` over the QUIC RPC transport
|
||||
|
||||
Reference in New Issue
Block a user