1use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use rcgen::{CertificateParams, DnType, DistinguishedName, KeyPair, SanType};
15use tokio_rustls::TlsAcceptor;
16use tokio_rustls::rustls::ServerConfig;
17use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
18
19pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
20
21pub fn default_cert_dir() -> PathBuf {
23 let base = std::env::var_os("XDG_CONFIG_HOME")
24 .map(PathBuf::from)
25 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
26 .unwrap_or_else(|| PathBuf::from("."));
27 base.join("fbi-proxy").join("certs")
28}
29
30pub fn cert_pem_path(domain: &str, cert_dir: &Path) -> PathBuf {
34 let slug = if domain.is_empty() { "localhost" } else { domain };
35 cert_dir.join(format!("{slug}.pem"))
36}
37
38pub fn is_trusted(cert_path: &Path) -> bool {
43 #[cfg(target_os = "macos")]
44 {
45 std::process::Command::new("security")
46 .args(["verify-cert", "-c"])
47 .arg(cert_path)
48 .stdout(std::process::Stdio::null())
49 .stderr(std::process::Stdio::null())
50 .status()
51 .map(|s| s.success())
52 .unwrap_or(false)
53 }
54 #[cfg(not(target_os = "macos"))]
55 {
56 let _ = cert_path;
57 false
58 }
59}
60
61pub fn install_to_system_trust(cert_path: &Path) -> Result<bool, BoxError> {
69 if is_trusted(cert_path) {
70 return Ok(false);
71 }
72
73 #[cfg(target_os = "macos")]
74 {
75 log::info!("installing {} to System.keychain", cert_path.display());
76 let status = std::process::Command::new("security")
77 .args([
78 "add-trusted-cert",
79 "-d",
80 "-r",
81 "trustRoot",
82 "-k",
83 "/Library/Keychains/System.keychain",
84 ])
85 .arg(cert_path)
86 .status()?;
87 if !status.success() {
88 return Err(format!(
89 "security add-trusted-cert failed (exit {:?}); needs root (sudo)",
90 status.code(),
91 )
92 .into());
93 }
94 Ok(true)
95 }
96 #[cfg(not(target_os = "macos"))]
97 {
98 let _ = cert_path;
99 log::warn!("auto-trust-install: only macOS supported in this build");
100 Ok(false)
101 }
102}
103
104pub fn build_acceptor(domain: &str, cert_dir: &Path) -> Result<TlsAcceptor, BoxError> {
112 let (cert_pem, key_pem) = load_or_generate(domain, cert_dir)?;
113
114 let cert_chain: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
115 .collect::<Result<Vec<_>, _>>()?;
116 let key = PrivateKeyDer::from_pem_slice(key_pem.as_bytes())?;
117
118 let config = ServerConfig::builder()
119 .with_no_client_auth()
120 .with_single_cert(cert_chain, key)?;
121
122 Ok(TlsAcceptor::from(Arc::new(config)))
123}
124
125fn load_or_generate(domain: &str, cert_dir: &Path) -> Result<(String, String), BoxError> {
126 let slug = if domain.is_empty() { "localhost" } else { domain };
127 let cert_path = cert_dir.join(format!("{slug}.pem"));
128 let key_path = cert_dir.join(format!("{slug}.key"));
129
130 if cert_path.exists() && key_path.exists() {
131 let cert = std::fs::read_to_string(&cert_path)?;
132 let key = std::fs::read_to_string(&key_path)?;
133 return Ok((cert, key));
134 }
135
136 let (cert_pem, key_pem) = generate_self_signed(domain)?;
137 std::fs::create_dir_all(cert_dir)?;
138 std::fs::write(&cert_path, &cert_pem)?;
139 write_private(&key_path, key_pem.as_bytes())?;
141
142 Ok((cert_pem, key_pem))
143}
144
145#[cfg(unix)]
146fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
147 use std::io::Write;
148 use std::os::unix::fs::OpenOptionsExt;
149 let mut f = std::fs::OpenOptions::new()
150 .write(true)
151 .create(true)
152 .truncate(true)
153 .mode(0o600)
154 .open(path)?;
155 f.write_all(bytes)?;
156 Ok(())
157}
158
159#[cfg(not(unix))]
160fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
161 std::fs::write(path, bytes)
162}
163
164pub fn generate_self_signed(domain: &str) -> Result<(String, String), BoxError> {
168 let mut sans: Vec<SanType> = Vec::new();
169 if domain.is_empty() || domain == "localhost" {
170 sans.push(SanType::DnsName("localhost".try_into()?));
171 sans.push(SanType::IpAddress("127.0.0.1".parse()?));
172 } else {
173 sans.push(SanType::DnsName(domain.try_into()?));
174 sans.push(SanType::DnsName(format!("*.{domain}").try_into()?));
175 }
176
177 let mut params = CertificateParams::default();
178 params.subject_alt_names = sans;
179
180 let mut dn = DistinguishedName::new();
183 dn.push(DnType::OrganizationName, "fbi-proxy (self-signed)");
184 params.distinguished_name = dn;
185
186 let now = time::OffsetDateTime::now_utc();
187 params.not_before = now - time::Duration::days(1);
188 params.not_after = now + time::Duration::days(365);
189
190 let key_pair = KeyPair::generate()?;
191 let cert = params.self_signed(&key_pair)?;
192
193 Ok((cert.pem(), key_pair.serialize_pem()))
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn generates_pem_with_domain_san() {
202 let (cert, key) = generate_self_signed("fbi.com").unwrap();
203 assert!(cert.contains("BEGIN CERTIFICATE"));
204 assert!(key.contains("BEGIN PRIVATE KEY"));
205
206 let der = CertificateDer::pem_slice_iter(cert.as_bytes())
208 .next()
209 .unwrap()
210 .unwrap();
211 let key_der = PrivateKeyDer::from_pem_slice(key.as_bytes()).unwrap();
215 let config = ServerConfig::builder()
216 .with_no_client_auth()
217 .with_single_cert(vec![der], key_der);
218 assert!(config.is_ok(), "rustls should accept generated cert+key");
219 }
220
221 #[test]
222 fn generates_for_localhost_fallback() {
223 let (cert, _key) = generate_self_signed("").unwrap();
224 assert!(cert.contains("BEGIN CERTIFICATE"));
225 }
226
227 #[test]
228 fn load_or_generate_round_trips_persisted_certs() {
229 let tmp = std::env::temp_dir().join(format!(
230 "fbi-tls-test-{}",
231 std::process::id()
232 ));
233 let _ = std::fs::remove_dir_all(&tmp);
234
235 let (cert1, key1) = load_or_generate("test.dev", &tmp).unwrap();
236 let (cert2, key2) = load_or_generate("test.dev", &tmp).unwrap();
238 assert_eq!(cert1, cert2);
239 assert_eq!(key1, key2);
240
241 let _ = std::fs::remove_dir_all(&tmp);
242 }
243}