Phase 8a: fleet-ca-tailscale-sign — Tailscale-aware leaf certs #62
@@ -27,6 +27,7 @@ pub mod rpc;
|
|||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
pub mod tailscale;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
pub mod wal;
|
pub mod wal;
|
||||||
pub mod wal_mutation;
|
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`,
|
/// Sign a leaf cert and write PEM files (`node.crt`, `node.key`,
|
||||||
/// `ca.crt`) into `out_dir`. Used by `fleet-ca sign`.
|
/// `ca.crt`) into `out_dir`. Used by `fleet-ca sign`.
|
||||||
pub fn sign_leaf_to_pem(&self, node_name: &str, out_dir: &Path) -> Result<()> {
|
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() {
|
if node_name.is_empty() {
|
||||||
bail!("node name cannot be empty when signing a leaf");
|
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)
|
std::fs::create_dir_all(out_dir)
|
||||||
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
|
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
|
||||||
|
|
||||||
@@ -317,10 +334,29 @@ impl FleetCa {
|
|||||||
fn mint_leaf(
|
fn mint_leaf(
|
||||||
&self,
|
&self,
|
||||||
node_name: &str,
|
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)> {
|
) -> Result<(rcgen::KeyPair, rcgen::Certificate)> {
|
||||||
let leaf_key = rcgen::KeyPair::generate().context("generating leaf key")?;
|
let leaf_key = rcgen::KeyPair::generate().context("generating leaf key")?;
|
||||||
let mut leaf_params = rcgen::CertificateParams::new(vec![node_name.to_string()])
|
let mut sans = vec![node_name.to_string()];
|
||||||
.context("building leaf params")?;
|
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
|
leaf_params
|
||||||
.distinguished_name
|
.distinguished_name
|
||||||
.push(rcgen::DnType::CommonName, node_name);
|
.push(rcgen::DnType::CommonName, node_name);
|
||||||
|
|||||||
+71
-1
@@ -129,6 +129,25 @@ enum Cmd {
|
|||||||
/// `ca.crt` (public), `node.crt` (public), `node.key` (private,
|
/// `ca.crt` (public), `node.crt` (public), `node.key` (private,
|
||||||
/// 0o600) into `--out-dir`. Copy those three files to the target
|
/// 0o600) into `--out-dir`. Copy those three files to the target
|
||||||
/// node and point `[cluster.tls]` at them.
|
/// 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 {
|
FleetCaSign {
|
||||||
/// Directory holding the CA (`ca.crt` + `ca.key`) — the same
|
/// Directory holding the CA (`ca.crt` + `ca.key`) — the same
|
||||||
/// dir passed to `fleet-ca init`.
|
/// dir passed to `fleet-ca init`.
|
||||||
@@ -265,6 +284,11 @@ async fn main() -> Result<()> {
|
|||||||
node,
|
node,
|
||||||
out_dir,
|
out_dir,
|
||||||
} => return cmd_fleet_ca_sign(ca_dir, 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,
|
rpc_addr,
|
||||||
tls_dir,
|
tls_dir,
|
||||||
} => cmd_cluster_peer_status(&peer, rpc_addr, &tls_dir).await?,
|
} => 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.
|
// Handled by the config-independent short-circuit above.
|
||||||
unreachable!("fleet-ca commands short-circuit before config load");
|
unreachable!("fleet-ca commands short-circuit before config load");
|
||||||
}
|
}
|
||||||
@@ -935,6 +959,52 @@ fn cmd_fleet_ca_sign(
|
|||||||
Ok(())
|
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 ─────────────────────────────────────────────────────────────
|
// ── cluster ping ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Round-trip a `ping` payload to `peer` over the QUIC RPC transport
|
/// Round-trip a `ping` payload to `peer` over the QUIC RPC transport
|
||||||
|
|||||||
Reference in New Issue
Block a user