Phase 8a: fleet-ca-tailscale-sign — Tailscale-aware leaf certs
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 25s

First slice of Phase 8 (roaming client identity). Adds a helper
that mints a leaf cert whose SANs include this node's Tailscale
identity — MagicDNS name (laptop.taila4f562.ts.net) + all tailnet
IPs — alongside the primary node name.

Closes the "how does a laptop join the fleet without hand-editing
SANs" gap: on a machine that's on Tailscale, one command produces
a leaf that peers can dial by MagicDNS from anywhere on the
tailnet.

New CLI:
  claw-store fleet-ca-tailscale-sign \
    --ca-dir /etc/claw-store/ca \
    [--node <name>]           # defaults to Tailscale HostName
    --out-dir /etc/claw-store/tls

Reads identity by shelling to `tailscale status --json` (already
present on any node that's on the tailnet; no extra dep). If
tailscale isn't running or installed, exits cleanly with a real
error.

New module cluster::tailscale:
* TailscaleSelf { magicdns_name, tailscale_ips, short_hostname }
* read_self() — runs the CLI, returns identity
* parse_status() — pure decoder, unit-tested
* suggested_sans() — MagicDNS + IPs ordered for the CA sign flow

FleetCa additions:
* sign_leaf_to_pem_with_sans(node_name, extra_sans, out_dir) —
  Sans-extended variant of sign_leaf_to_pem. Empty entries dropped.
  Existing sign_leaf_to_pem now delegates with empty extras (100%
  backward compat).
* mint_leaf_with_sans — internal shared helper.

+5 tests: parse full identity, parse missing MagicDNS, error on
no Self record, suggested_sans ordering, suggested_sans skips
missing MagicDNS.

372 tests pass (+5). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.

Next Phase 8 slices: (a) tailnet-preferring peer probe with a
config-selectable auth mode, (b) documented "roaming client"
config template.
This commit is contained in:
Omar Sobh
2026-07-14 11:08:57 -07:00
parent 3af6390316
commit 98036f2597
4 changed files with 306 additions and 4 deletions
+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);