1use std::{
2 iter,
3 path::{Path, PathBuf},
4};
5
6use futures::{Stream, StreamExt, stream};
7use rustls::pki_types::{CertificateDer, PrivateKeyDer};
8use snafu::{IntoError, ResultExt, Snafu};
9use tokio::{
10 fs::{self, ReadDir},
11 io::{self, AsyncWriteExt},
12};
13use x509_parser::prelude::Pem;
14
15use dhttp_identity::{identity::Identity, name::DhttpName};
16
17use crate::{DhttpHome, identity::IdentityProfile};
18
19pub const SSL_DIR_NAME: &str = "ssl";
20pub const CERT_FILE_NAME: &str = "fullchain.crt";
21pub const KEY_FILE_NAME: &str = "privkey.pem";
22
23#[derive(Snafu, Debug)]
24#[snafu(module)]
25pub enum ResolveIdentityProfileError {
26 #[snafu(display("failed to inspect exact identity profile path {}", path.display()))]
27 ExactMetadata { path: PathBuf, source: io::Error },
28 #[snafu(display("failed to inspect wildcard identity profile path {}", path.display()))]
29 WildcardMetadata { path: PathBuf, source: io::Error },
30 #[snafu(display("exact identity profile path does not exist: {}", path.display()))]
31 ExactNotFound { path: PathBuf },
32 #[snafu(display("wildcard identity profile path does not exist: {}", path.display()))]
33 WildcardNotFound { path: PathBuf },
34 #[snafu(display(
35 "identity profile does not exist at exact path {} or wildcard path {}",
36 exact.display(),
37 wildcard.display()
38 ))]
39 NotFound { exact: PathBuf, wildcard: PathBuf },
40}
41
42#[derive(Snafu, Debug)]
43#[snafu(module)]
44pub enum LoadCertsError {
45 #[snafu(display("failed to read certificate file {}", path.display()))]
46 Read { path: PathBuf, source: io::Error },
47 #[snafu(display("failed to parse pem block in {}", path.display()))]
48 Pem {
49 path: PathBuf,
50 source: x509_parser::error::PEMError,
51 },
52}
53
54#[derive(Snafu, Debug)]
55#[snafu(module)]
56pub enum LoadKeyError {
57 #[snafu(display("failed to inspect private key file {}", path.display()))]
58 Metadata { path: PathBuf, source: io::Error },
59 #[snafu(display("failed to read private key file {}", path.display()))]
60 Read { path: PathBuf, source: io::Error },
61 #[snafu(display(
62 "private key file permissions are too open at {} (current {current:o}, expected to be 400)",
63 path.display()
64 ))]
65 PermissionsTooOpen { path: PathBuf, current: u32 },
66 #[snafu(display("failed to parse private key file {}", path.display()))]
67 Parse {
68 path: PathBuf,
69 source: rustls::pki_types::pem::Error,
70 },
71}
72
73#[derive(Snafu, Debug)]
74#[snafu(module)]
75pub enum LoadIdentityError {
76 #[snafu(display("failed to load identity certificates at {}", path.display()))]
77 LoadCerts {
78 path: PathBuf,
79 source: LoadCertsError,
80 },
81
82 #[snafu(display("failed to load identity private key at {}", path.display()))]
83 LoadKey { path: PathBuf, source: LoadKeyError },
84}
85
86#[derive(Snafu, Debug)]
87#[snafu(module)]
88pub enum SaveIdentityError {
89 #[snafu(display("failed to create identity directory at {}", path.display()))]
90 CreateIdentityDir { path: PathBuf, source: io::Error },
91 #[snafu(display("failed to get metadata for path {}", path.display()))]
92 Metadata { path: PathBuf, source: io::Error },
93 #[snafu(display("failed to delete old file at {}", path.display()))]
94 Delete { path: PathBuf, source: io::Error },
95 #[snafu(display("failed to create file at {}", path.display()))]
96 Create { path: PathBuf, source: io::Error },
97 #[snafu(display("failed to write to file at {}", path.display()))]
98 Write { path: PathBuf, source: io::Error },
99}
100
101#[derive(Snafu, Debug)]
102#[snafu(module)]
103pub enum ListIdentityProfilesError {
104 #[snafu(display("failed to list identity profiles in directory {}", path.display()))]
105 ReadDir { path: PathBuf, source: io::Error },
106 #[snafu(display("failed to read filetype of {}", path.display()))]
107 ReadFty { path: PathBuf, source: io::Error },
108}
109
110impl IdentityProfile {
111 pub fn ssl_dir(&self) -> PathBuf {
112 self.join(SSL_DIR_NAME)
113 }
114
115 pub async fn load_certs(&self) -> Result<Vec<CertificateDer<'static>>, LoadCertsError> {
116 let certs_path = self.ssl_dir().join(CERT_FILE_NAME);
117 let mut data = std::io::Cursor::new(fs::read(certs_path.as_path()).await.context(
118 load_certs_error::ReadSnafu {
119 path: certs_path.clone(),
120 },
121 )?);
122 let (end_entity_pem, _read) = Pem::read(&mut data).context(load_certs_error::PemSnafu {
123 path: certs_path.clone(),
124 })?;
125 let mut certs = vec![CertificateDer::from(end_entity_pem.contents)];
126 loop {
127 match Pem::read(&mut data) {
128 Ok((pem, _read)) => {
129 certs.push(CertificateDer::from(pem.contents));
130 }
131 Err(x509_parser::error::PEMError::MissingHeader) => break,
132 result => {
133 _ = result.context(load_certs_error::PemSnafu {
134 path: certs_path.clone(),
135 })?;
136 }
137 }
138 }
139
140 Ok(certs)
141 }
142
143 pub async fn load_key(&self) -> Result<PrivateKeyDer<'static>, LoadKeyError> {
144 let key_path = self.ssl_dir().join(KEY_FILE_NAME);
145 #[cfg(unix)]
146 {
147 use std::os::unix::fs::MetadataExt;
148
149 use snafu::ensure;
150 let metadata =
151 fs::metadata(key_path.as_path())
152 .await
153 .context(load_key_error::MetadataSnafu {
154 path: key_path.clone(),
155 })?;
156 let permissions = metadata.mode() & 0o777;
157 ensure!(
158 permissions == 0o400,
159 load_key_error::PermissionsTooOpenSnafu {
160 path: key_path.clone(),
161 current: permissions
162 }
163 )
164 }
165
166 let data = fs::read(key_path.as_path())
167 .await
168 .context(load_key_error::ReadSnafu {
169 path: key_path.clone(),
170 })?;
171 rustls::pki_types::pem::PemObject::from_pem_slice(&data).context(
172 load_key_error::ParseSnafu {
173 path: key_path.clone(),
174 },
175 )
176 }
177
178 pub async fn load_identity(&self) -> Result<Identity, LoadIdentityError> {
180 let certs_path = self.ssl_dir().join(CERT_FILE_NAME);
181 let certs = self
182 .load_certs()
183 .await
184 .context(load_identity_error::LoadCertsSnafu { path: certs_path })?;
185
186 let key_path = self.ssl_dir().join(KEY_FILE_NAME);
187 let key = self
188 .load_key()
189 .await
190 .context(load_identity_error::LoadKeySnafu { path: key_path })?;
191
192 Ok(Identity::new(self.name.clone().into_name(), certs, key))
193 }
194
195 pub async fn save_identity(&self, cert: &[u8], key: &[u8]) -> Result<(), SaveIdentityError> {
196 let ssl_dir = self.ssl_dir();
197 fs::create_dir_all(ssl_dir.as_path()).await.context(
198 save_identity_error::CreateIdentityDirSnafu {
199 path: ssl_dir.clone(),
200 },
201 )?;
202
203 let mut open_options = fs::OpenOptions::new();
204 open_options.create_new(true).write(true);
205 #[cfg(unix)]
206 open_options.mode(0o400);
207
208 let path = ssl_dir.join(CERT_FILE_NAME);
209 if let Err(error) = fs::remove_file(path.as_path()).await
210 && error.kind() != io::ErrorKind::NotFound
211 {
212 return Err(save_identity_error::DeleteSnafu { path }.into_error(error));
213 }
214 open_options
215 .open(path.as_path())
216 .await
217 .context(save_identity_error::CreateSnafu { path: path.clone() })?
218 .write_all(cert)
219 .await
220 .context(save_identity_error::WriteSnafu { path: path.clone() })?;
221
222 let path = ssl_dir.join(KEY_FILE_NAME);
223 if let Err(error) = fs::remove_file(path.as_path()).await
224 && error.kind() != io::ErrorKind::NotFound
225 {
226 return Err(save_identity_error::DeleteSnafu { path }.into_error(error));
227 }
228 open_options
229 .open(path.as_path())
230 .await
231 .context(save_identity_error::CreateSnafu { path: path.clone() })?
232 .write_all(key)
233 .await
234 .context(save_identity_error::WriteSnafu { path: path.clone() })?;
235
236 Ok(())
237 }
238}
239
240impl DhttpHome {
241 pub async fn resolve_identity_profile_exactly(
243 &self,
244 name: DhttpName<'_>,
245 ) -> Result<IdentityProfile, ResolveIdentityProfileError> {
246 let profile_path = self.join_identity_name(name.clone());
247 match fs::metadata(profile_path.as_path()).await {
248 Ok(_) => Ok(IdentityProfile {
249 path: profile_path,
250 name: name.to_owned(),
251 }),
252 Err(error) if error.kind() == io::ErrorKind::NotFound => {
253 resolve_identity_profile_error::ExactNotFoundSnafu { path: profile_path }.fail()
254 }
255 Err(error) => Err(error)
256 .context(resolve_identity_profile_error::ExactMetadataSnafu { path: profile_path }),
257 }
258 }
259
260 pub async fn resolve_identity_profile_wildcard(
262 &self,
263 name: DhttpName<'_>,
264 ) -> Result<IdentityProfile, ResolveIdentityProfileError> {
265 let wildcard_name = name.to_wildcard();
266 let profile_path = self.join_identity_name(wildcard_name.clone());
267 match fs::metadata(profile_path.as_path()).await {
268 Ok(_) => Ok(IdentityProfile {
269 path: profile_path,
270 name: wildcard_name,
271 }),
272 Err(error) if error.kind() == io::ErrorKind::NotFound => {
273 resolve_identity_profile_error::WildcardNotFoundSnafu { path: profile_path }.fail()
274 }
275 Err(error) => {
276 Err(error).context(resolve_identity_profile_error::WildcardMetadataSnafu {
277 path: profile_path,
278 })
279 }
280 }
281 }
282
283 pub async fn resolve_identity_profile(
285 &self,
286 name: DhttpName<'_>,
287 ) -> Result<IdentityProfile, ResolveIdentityProfileError> {
288 match self.resolve_identity_profile_exactly(name.clone()).await {
289 Ok(profile) => Ok(profile),
290 Err(ResolveIdentityProfileError::ExactNotFound { path: exact }) => {
291 match self.resolve_identity_profile_wildcard(name).await {
292 Ok(profile) => Ok(profile),
293 Err(ResolveIdentityProfileError::WildcardNotFound { path: wildcard }) => {
294 resolve_identity_profile_error::NotFoundSnafu { exact, wildcard }.fail()
295 }
296 Err(error) => Err(error),
297 }
298 }
299 Err(error) => Err(error),
300 }
301 }
302
303 pub fn identity_profile_names(
306 &self,
307 ) -> impl Stream<Item = Result<DhttpName<'static>, ListIdentityProfilesError>> {
308 use list_identity_profiles_error::*;
309 async fn next_name(
310 read_dir: &mut ReadDir,
311 path: &Path,
312 ) -> Result<Option<DhttpName<'static>>, ListIdentityProfilesError> {
313 loop {
314 let Some(e) = read_dir.next_entry().await.context(ReadDirSnafu { path })? else {
315 return Ok(None);
316 };
317 if let (entry_path, name) = (e.path(), e.file_name())
318 && e.file_type()
319 .await
320 .context(ReadFtySnafu {
321 path: entry_path.clone(),
322 })?
323 .is_dir()
324 && let Ok(name) = name.to_string_lossy().as_ref().parse::<DhttpName>()
325 && fs::metadata(entry_path.join(SSL_DIR_NAME)).await.is_ok()
326 {
327 return Ok(Some(name));
328 }
329 }
330 }
331
332 let path = self.as_path();
333 stream::once(fs::read_dir(path)).flat_map(move |result| {
334 match result.context(ReadDirSnafu { path }) {
335 Err(error) => stream::iter(iter::once(Err(error))).right_stream(),
336 Ok(read_dir) => stream::unfold(read_dir, move |mut read_dir| async move {
337 match next_name(&mut read_dir, path).await {
338 Ok(Some(name)) => Some((Ok(name), read_dir)),
339 Ok(None) => None,
340 Err(e) => Some((Err(e), read_dir)),
341 }
342 })
343 .left_stream(),
344 }
345 })
346 }
347
348 pub async fn identity_profile_exists_exactly(&self, name: DhttpName<'_>) -> bool {
349 self.resolve_identity_profile_exactly(name).await.is_ok()
350 }
351
352 pub async fn identity_profile_exists_wildcard(&self, name: DhttpName<'_>) -> bool {
353 self.resolve_identity_profile_wildcard(name).await.is_ok()
354 }
355
356 pub async fn identity_profile_exists(&self, name: DhttpName<'_>) -> bool {
357 self.resolve_identity_profile(name).await.is_ok()
358 }
359}
360
361#[cfg(feature = "settings")]
362mod settings_integration {
363 use snafu::{OptionExt, ResultExt, Snafu};
364
365 use super::ResolveIdentityProfileError;
366 use crate::{
367 DhttpHome,
368 identity::{
369 IdentityProfile,
370 settings::{DhttpSettingsFile, FileLineCol, LoadDhttpSettingsError},
371 },
372 };
373
374 #[derive(Snafu, Debug)]
375 #[snafu(module, display(
376 "failed to resolve default identity profile{}",
377 location.as_ref().map_or(String::new(), |loc| format!(" at {loc}"))
378 ))]
379 pub struct ResolveDefaultIdentityFromSettingsError {
380 location: Option<FileLineCol>,
381 source: ResolveIdentityProfileError,
382 }
383
384 #[derive(Debug, Snafu)]
385 #[snafu(module)]
386 pub enum ResolveDefaultIdentityProfileError {
387 #[snafu(transparent)]
388 LoadSettings { source: LoadDhttpSettingsError },
389 #[snafu(display("no default identity configured"))]
390 NoDefaultIdentity,
391 #[snafu(transparent)]
392 Resolve {
393 source: ResolveDefaultIdentityFromSettingsError,
394 },
395 }
396
397 impl DhttpSettingsFile {
398 pub async fn resolve_default_identity_profile(
401 &self,
402 home: &DhttpHome,
403 ) -> Option<Result<IdentityProfile, ResolveDefaultIdentityFromSettingsError>> {
404 let name = self.settings().default.name.as_ref()?;
405
406 Some(
407 home.resolve_identity_profile(name.as_ref().clone())
408 .await
409 .context(
410 resolve_default_identity_from_settings_error::ResolveDefaultIdentityFromSettingsSnafu {
411 location: self.locate(name.span().start),
412 },
413 ),
414 )
415 }
416 }
417
418 impl DhttpHome {
419 pub async fn resolve_default_identity_profile(
421 &self,
422 ) -> Result<IdentityProfile, ResolveDefaultIdentityProfileError> {
423 Ok(self
424 .load_settings()
425 .await?
426 .resolve_default_identity_profile(self)
427 .await
428 .context(resolve_default_identity_profile_error::NoDefaultIdentitySnafu)??)
429 }
430 }
431}
432
433#[cfg(feature = "settings")]
434pub use settings_integration::*;
435
436#[cfg(test)]
437mod tests {
438 use std::{
439 fs,
440 path::PathBuf,
441 time::{SystemTime, UNIX_EPOCH},
442 };
443
444 use super::*;
445
446 struct TempDir {
447 path: PathBuf,
448 }
449
450 impl TempDir {
451 fn new(name: &str) -> Self {
452 let stamp = SystemTime::now()
453 .duration_since(UNIX_EPOCH)
454 .expect("system clock should be after unix epoch")
455 .as_nanos();
456 let path = std::env::temp_dir()
457 .join(format!("dhttp-home-{name}-{}-{stamp}", std::process::id()));
458 fs::create_dir_all(&path).expect("test temp dir should be creatable");
459 Self { path }
460 }
461
462 fn path(&self) -> &std::path::Path {
463 &self.path
464 }
465 }
466
467 impl Drop for TempDir {
468 fn drop(&mut self) {
469 let _ = fs::remove_dir_all(&self.path);
470 }
471 }
472
473 #[tokio::test]
474 async fn missing_certificate_reports_certificate_path() {
475 let temp = TempDir::new("missing-certificate");
476 let profile = IdentityProfile::try_from(temp.path().join("reimu.pilot")).unwrap();
477
478 let error = profile.load_certs().await.unwrap_err();
479
480 match error {
481 LoadCertsError::Read { path, .. } => {
482 assert_eq!(path, profile.ssl_dir().join(CERT_FILE_NAME));
483 }
484 other => panic!("expected certificate read error, got {other:?}"),
485 }
486 }
487
488 #[tokio::test]
489 async fn missing_key_reports_key_metadata_path() {
490 let temp = TempDir::new("missing-key");
491 let profile = IdentityProfile::try_from(temp.path().join("reimu.pilot")).unwrap();
492
493 let error = profile.load_key().await.unwrap_err();
494
495 match error {
496 LoadKeyError::Metadata { path, .. } => {
497 assert_eq!(path, profile.ssl_dir().join(KEY_FILE_NAME));
498 }
499 other => panic!("expected key metadata error, got {other:?}"),
500 }
501 }
502
503 #[tokio::test]
504 async fn missing_identity_profile_reports_exact_and_wildcard_paths() {
505 let temp = TempDir::new("missing-identity-profile");
506 let home = DhttpHome::new(temp.path().to_path_buf());
507 let name = "reimu.pilot".parse().unwrap();
508
509 let error = home.resolve_identity_profile(name).await.unwrap_err();
510
511 match error {
512 ResolveIdentityProfileError::NotFound { exact, wildcard } => {
513 assert_eq!(exact, temp.path().join("reimu.pilot"));
514 assert_eq!(wildcard, temp.path().join("*.pilot"));
515 }
516 other => panic!("expected not-found error, got {other:?}"),
517 }
518 }
519}