nixfleet_agent/
comms.rs

1//! mTLS HTTP client construction for the control plane.
2//!
3//! Exposes `build_client` only — used by `main.rs` for the bootstrap +
4//! enroll path and by the runtime workers via the `Client` they receive
5//! at spawn.
6//!
7//! Event-stream POSTs (RFC-0005 §4.2) live in
8//! `runtime/workers/longpoll.rs` and the outbound-queue drainer; they
9//! construct their own request shapes against
10//! `nixfleet_proto::agent_wire` directly.
11//!
12//! The `read_client_key_as_pem` helper survives because the SSH
13//! host-key → PKCS#8 conversion footgun it documents remains
14//! load-bearing on agent first-boot.
15
16use std::path::Path;
17use std::time::Duration;
18
19use anyhow::{Context, Result};
20use reqwest::{Certificate, Client, Identity};
21
22const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
23
24const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
25
26/// TLS-only mode (None cert/key) supported but production always wires both.
27pub fn build_client(
28    ca_cert: Option<&Path>,
29    client_cert: Option<&Path>,
30    client_key: Option<&Path>,
31) -> Result<Client> {
32    let mut builder = Client::builder()
33        .use_rustls_tls()
34        .connect_timeout(CONNECT_TIMEOUT)
35        .timeout(REQUEST_TIMEOUT);
36
37    if let Some(ca_path) = ca_cert {
38        let pem = std::fs::read(ca_path)
39            .with_context(|| format!("read CA cert {}", ca_path.display()))?;
40        let cert = Certificate::from_pem(&pem).context("parse CA cert PEM")?;
41        builder = builder.add_root_certificate(cert);
42    }
43
44    if let (Some(cert), Some(key)) = (client_cert, client_key) {
45        let mut pem =
46            std::fs::read(cert).with_context(|| format!("read client cert {}", cert.display()))?;
47        let key_pem = read_client_key_as_pem(key)
48            .with_context(|| format!("read client key {}", key.display()))?;
49        pem.extend_from_slice(key_pem.as_bytes());
50        let identity = Identity::from_pem(&pem).context("parse client identity PEM")?;
51        builder = builder.identity(identity);
52    }
53
54    builder.build().context("build reqwest client")
55}
56
57/// Return PEM bytes that `reqwest::Identity::from_pem` accepts. FOOTGUN:
58/// the agent's client key is the host SSH key, which is OpenSSH format -
59/// neither reqwest nor rustls parses it. We extract the 32-byte ed25519
60/// seed and re-emit as PKCS#8 PEM. PEM inputs pass through unchanged.
61fn read_client_key_as_pem(path: &Path) -> Result<String> {
62    let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
63    if raw.contains("-----BEGIN OPENSSH PRIVATE KEY-----") {
64        let private = ssh_key::PrivateKey::from_openssh(&raw)
65            .with_context(|| format!("parse OpenSSH key at {}", path.display()))?;
66        let seed = match private.key_data() {
67            ssh_key::private::KeypairData::Ed25519(kp) => kp.private.to_bytes(),
68            other => anyhow::bail!(
69                "ssh host key at {} is not ed25519 (algorithm: {:?})",
70                path.display(),
71                other.algorithm(),
72            ),
73        };
74        Ok(nixfleet_proto::host_key::ed25519_pkcs8_pem_from_seed(&seed))
75    } else {
76        Ok(raw)
77    }
78}
79
80#[cfg(test)]
81mod read_client_key_tests {
82    use super::*;
83    use ed25519_dalek::SigningKey;
84    use rand::RngCore;
85    use ssh_key::{LineEnding, PrivateKey};
86
87    fn write_test_ssh_host_key(dir: &std::path::Path) -> std::path::PathBuf {
88        let mut seed = [0u8; 32];
89        rand::rng().fill_bytes(&mut seed);
90        let sk = SigningKey::from_bytes(&seed);
91        let kp = ssh_key::private::Ed25519Keypair {
92            public: ssh_key::public::Ed25519PublicKey(sk.verifying_key().to_bytes()),
93            private: ssh_key::private::Ed25519PrivateKey::from_bytes(&sk.to_bytes()),
94        };
95        let pk = PrivateKey::new(ssh_key::private::KeypairData::Ed25519(kp), "test-host")
96            .expect("PrivateKey::new");
97        let pem = pk.to_openssh(LineEnding::LF).expect("openssh PEM");
98        let path = dir.join("ssh_host_ed25519_key");
99        std::fs::write(&path, pem.as_bytes()).expect("write key");
100        path
101    }
102
103    #[test]
104    fn openssh_input_converts_to_pkcs8() {
105        let dir = tempfile::tempdir().expect("tempdir");
106        let path = write_test_ssh_host_key(dir.path());
107        let pem = read_client_key_as_pem(&path).expect("convert");
108        assert!(
109            pem.starts_with("-----BEGIN PRIVATE KEY-----"),
110            "expected PKCS#8 PEM, got: {}",
111            pem.lines().next().unwrap_or(""),
112        );
113        assert!(pem.contains("-----END PRIVATE KEY-----"));
114    }
115
116    #[test]
117    fn pem_input_passes_through() {
118        // Legacy PEM keys must be returned unchanged so reqwest sees the
119        // exact bytes the operator deployed.
120        let dir = tempfile::tempdir().expect("tempdir");
121        let path = dir.path().join("agent.key");
122        let pem_input = "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n";
123        std::fs::write(&path, pem_input).expect("write");
124        let got = read_client_key_as_pem(&path).expect("read");
125        assert_eq!(got, pem_input);
126    }
127
128    /// Pubkey round-trip: protects against accidental seed swap during
129    /// OpenSSH -> PKCS#8 conversion.
130    #[test]
131    fn openssh_to_pkcs8_pubkey_round_trips() {
132        let dir = tempfile::tempdir().expect("tempdir");
133        let path = write_test_ssh_host_key(dir.path());
134
135        let raw = std::fs::read_to_string(&path).expect("read");
136        let priv_key = PrivateKey::from_openssh(&raw).expect("parse");
137        let expected_pubkey = match priv_key.key_data() {
138            ssh_key::private::KeypairData::Ed25519(kp) => kp.public.0,
139            _ => panic!("not ed25519"),
140        };
141
142        let pkcs8_pem = read_client_key_as_pem(&path).expect("convert");
143        let key = rcgen::KeyPair::from_pem(&pkcs8_pem).expect("rcgen parse");
144        let mut got = [0u8; 32];
145        got.copy_from_slice(key.public_key_raw());
146
147        assert_eq!(got, expected_pubkey);
148    }
149}