1use 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
26pub 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
57fn 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 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 #[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}