Phase 8a: fleet-ca-tailscale-sign — Tailscale-aware leaf certs
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 25s
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user