Phase 8a: fleet-ca-tailscale-sign — Tailscale-aware leaf certs #62

Merged
osobh merged 1 commits from phase-8a-tailscale-ca-sign into main 2026-07-14 18:09:02 +00:00
4 changed files with 306 additions and 4 deletions
+1
View File
@@ -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;
+195
View File
@@ -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()]);
}
}
+39 -3
View File
@@ -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
View File
@@ -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