Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .cargo/config.toml

This file was deleted.

17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Optional shell/CI overrides. Cargo defaults to the same production values.
# Run `set -a; source .env; set +a` after copying this file to .env.
DHTTP_BOOTSTRAP_URL=https://bootstrap.genmeta.net:20002
DHTTP_CA_SERVICE=https://api.genmeta.net
DHTTP_NAME_SERVICE=https://ddns.genmeta.net
DHTTP_MDNS_SERVICE_DOMAIN="_dhttp.local"
# Keep this value in sync with dhttp/root.crt.
DHTTP_ROOT_CA_PEM="-----BEGIN CERTIFICATE-----
MIICVzCCAd2gAwIBAgIUe8kwBACY6f+MAzdCBVPmq4p+CiswCgYIKoZIzj0EAwMw
WTELMAkGA1UEBhMCQ04xETAPBgNVBAgMCEhvbmdLb25nMRwwGgYDVQQKDBNHZW5t
ZXRhIEVDQyBSb290IENBMRkwFwYDVQQDDBByb290Lmdlbm1ldGEubmV0MB4XDTI2
MDcxMzEzMDQyOFoXDTQ2MDcxMzEzMDQyOFowWTELMAkGA1UEBhMCQ04xETAPBgNV
BAgMCEhvbmdLb25nMRwwGgYDVQQKDBNHZW5tZXRhIEVDQyBSb290IENBMRkwFwYD
VQQDDBByb290Lmdlbm1ldGEubmV0MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEO+zm
ZYL0LaqTKf7mW4tnRWeNop1p8f2ZsexhAl23GHkHwLjCihhQzBCZ8VMRPAdVcEIS
XcGY/U6+Z1IAYCRG0tdsUCXHXxzvDY0I9FZqZw1Xo94gkHnNe7mTu/jCQg3Xo2Yw
ZDAdBgNVHQ4EFgQUq1SsSWDnp0G5v5/hWi9CC7eWDTwwHwYDVR0jBBgwFoAUq1Ss
SWDnp0G5v5/hWi9CC7eWDTwwEgYDVR0TAQH/BAgwBgEB/wIBATAOBgNVHQ8BAf8E
BAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwK9GqxdmRHJw7iB0z/b/WgzBv2jb7OmFS
uVPA+6ZNApjYXCZUOVQFC60KUUV7yW53AjEA5lLrdXxdGNSIuLe1h/A+v/vRrYtt
132Jzh+LkKBHdC1wcvDKjk2ZQG5WySly6VMp
-----END CERTIFICATE-----"
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/dhttp/root.crt text eol=lf
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["f
dhttp-identity = "0.3.0"
dhttp-home = { path = "home", version = "0.5.0" }
dquic = { version = "0.7.1", default-features = false }
ddns = { package = "dyns", version = "0.7.1", features = [
ddns = { package = "dyns", git = "https://github.com/genmeta/ddns.git", rev = "332cf51093572a28364226ef7682e0d95812a6ef", features = [
"resolvers",
"publishers",
"h3",
Expand Down
5 changes: 5 additions & 0 deletions dhttp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,14 @@ ddns = { workspace = true }
h3x = { workspace = true }

[build-dependencies]
rustls-pemfile = "2"
url = "2"
x509-parser.workspace = true

[dev-dependencies]
dhttp-access = { workspace = true, features = ["http", "orm"] }
rcgen = "0.14"
rustls-pemfile = "2"
tokio = { workspace = true, features = ["macros", "rt"] }
url = "2"
x509-parser.workspace = true
174 changes: 174 additions & 0 deletions dhttp/bootstrap_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
use std::{error::Error, fmt};

use rustls_pemfile::{Item, read_one_from_slice};

pub const DEFAULT_BOOTSTRAP_URL: &str = "https://bootstrap.genmeta.net:20002";
pub const DEFAULT_ROOT_CA_PEM: &str = include_str!("root.crt");

pub fn env_or_default(name: &str, default: &str) -> String {
std::env::var(name).unwrap_or_else(|_| default.to_owned())
}

pub fn bootstrap_authority(value: &str) -> Result<String, String> {
let url = url::Url::parse(value).map_err(|error| error.to_string())?;
if url.scheme() != "https" {
return Err("scheme must be https".to_owned());
}
if url.username() != "" || url.password().is_some() {
return Err("credentials are not allowed".to_owned());
}
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
return Err("path, query, and fragment are not allowed".to_owned());
}

let host = url
.host_str()
.ok_or_else(|| "host is required".to_owned())?;
let port = url
.port()
.ok_or_else(|| "an explicit port is required".to_owned())?;
if matches!(url.host(), Some(url::Host::Ipv6(_))) {
Ok(format!("[{host}]:{port}"))
} else {
Ok(format!("{host}:{port}"))
}
}

#[derive(Debug)]
pub enum RootCaError {
DecodePem(rustls_pemfile::Error),
MissingCertificate,
UnexpectedPemItem,
MultipleCertificates,
InvalidX509(String),
TrailingDerData,
}

impl fmt::Display for RootCaError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DecodePem(error) => write!(formatter, "failed to decode PEM: {error:?}"),
Self::MissingCertificate => formatter.write_str("missing PEM CERTIFICATE block"),
Self::UnexpectedPemItem => {
formatter.write_str("PEM input contains a non-certificate item")
}
Self::MultipleCertificates => {
formatter.write_str("PEM input contains multiple certificates")
}
Self::InvalidX509(error) => {
write!(formatter, "certificate is not valid X.509 DER: {error}")
}
Self::TrailingDerData => formatter.write_str("certificate contains trailing DER data"),
}
}
}

impl Error for RootCaError {}

pub fn parse_root_ca_der(pem: &str) -> Result<Vec<u8>, RootCaError> {
let mut remainder = pem.as_bytes();
let mut certificate = None;

while let Some((item, next)) = read_one_from_slice(remainder).map_err(RootCaError::DecodePem)? {
remainder = next;
let Item::X509Certificate(item) = item else {
return Err(RootCaError::UnexpectedPemItem);
};
if certificate.replace(item).is_some() {
return Err(RootCaError::MultipleCertificates);
}
}

let certificate = certificate.ok_or(RootCaError::MissingCertificate)?;
let (remainder, _) = x509_parser::parse_x509_certificate(certificate.as_ref())
.map_err(|error| RootCaError::InvalidX509(error.to_string()))?;
if !remainder.is_empty() {
return Err(RootCaError::TrailingDerData);
}

Ok(certificate.as_ref().to_vec())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn missing_bootstrap_env_uses_genmeta_production_default() {
let name = format!("__DHTTP_MISSING_BOOTSTRAP_{}", std::process::id());

assert_eq!(
env_or_default(&name, DEFAULT_BOOTSTRAP_URL),
"https://bootstrap.genmeta.net:20002"
);
}

#[test]
fn bootstrap_url_produces_stun_authority() {
assert_eq!(
bootstrap_authority("https://bootstrap.genmeta.net:20002").as_deref(),
Ok("bootstrap.genmeta.net:20002")
);
}

#[test]
fn bootstrap_url_requires_https_and_explicit_port() {
assert!(bootstrap_authority("http://bootstrap.genmeta.net:20002").is_err());
assert!(bootstrap_authority("https://bootstrap.genmeta.net").is_err());
}

#[test]
fn default_root_ca_is_decoded_to_der() {
let der = parse_root_ca_der(DEFAULT_ROOT_CA_PEM).unwrap();

assert_eq!(der.first(), Some(&0x30));
assert!(!der.starts_with(b"-----BEGIN CERTIFICATE-----"));
}

#[test]
fn escaped_newlines_are_not_accepted_as_pem() {
let escaped = DEFAULT_ROOT_CA_PEM.replace('\n', "\\n");

assert!(parse_root_ca_der(&escaped).is_err());
}

#[test]
fn crlf_root_ca_is_accepted_by_the_pem_parser() {
let expected = parse_root_ca_der(DEFAULT_ROOT_CA_PEM).unwrap();
let crlf = DEFAULT_ROOT_CA_PEM
.replace("\r\n", "\n")
.replace('\n', "\r\n");

assert_eq!(parse_root_ca_der(&crlf).unwrap(), expected);
}

#[test]
fn malformed_x509_certificate_is_rejected() {
let pem = "-----BEGIN CERTIFICATE-----\nYm9keQ==\n-----END CERTIFICATE-----\n";

assert!(matches!(
parse_root_ca_der(pem),
Err(RootCaError::InvalidX509(_))
));
}

#[test]
fn multiple_certificates_are_rejected() {
let pem = format!("{DEFAULT_ROOT_CA_PEM}{DEFAULT_ROOT_CA_PEM}");

assert!(matches!(
parse_root_ca_der(&pem),
Err(RootCaError::MultipleCertificates)
));
}

#[test]
fn non_certificate_pem_item_is_rejected() {
let pem = DEFAULT_ROOT_CA_PEM.replace("CERTIFICATE", "PRIVATE KEY");

assert!(matches!(
parse_root_ca_der(&pem),
Err(RootCaError::UnexpectedPemItem)
));
}
}
128 changes: 19 additions & 109 deletions dhttp/build.rs
Original file line number Diff line number Diff line change
@@ -1,37 +1,19 @@
mod bootstrap_config;

use std::{env, fs, path::PathBuf};

const ROOT_CA_ENV: &str = "DHTTP_ROOT_CA";
const BOOTSTRAP_URL_ENV: &str = "DHTTP_BOOTSTRAP_URL";
use bootstrap_config::{
DEFAULT_BOOTSTRAP_URL, DEFAULT_ROOT_CA_PEM, bootstrap_authority, env_or_default,
parse_root_ca_der,
};

const DEFAULT_BOOTSTRAP_URL: &str = "https://bootstrap.genmeta.net:20002";
const DEFAULT_ROOT_CA_PEM: &str = "\
-----BEGIN CERTIFICATE-----\n\
MIICVzCCAd2gAwIBAgIUe8kwBACY6f+MAzdCBVPmq4p+CiswCgYIKoZIzj0EAwMw\n\
WTELMAkGA1UEBhMCQ04xETAPBgNVBAgMCEhvbmdLb25nMRwwGgYDVQQKDBNHZW5t\n\
ZXRhIEVDQyBSb290IENBMRkwFwYDVQQDDBByb290Lmdlbm1ldGEubmV0MB4XDTI2\n\
MDcxMzEzMDQyOFoXDTQ2MDcxMzEzMDQyOFowWTELMAkGA1UEBhMCQ04xETAPBgNV\n\
BAgMCEhvbmdLb25nMRwwGgYDVQQKDBNHZW5tZXRhIEVDQyBSb290IENBMRkwFwYD\n\
VQQDDBByb290Lmdlbm1ldGEubmV0MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEO+zm\n\
ZYL0LaqTKf7mW4tnRWeNop1p8f2ZsexhAl23GHkHwLjCihhQzBCZ8VMRPAdVcEIS\n\
XcGY/U6+Z1IAYCRG0tdsUCXHXxzvDY0I9FZqZw1Xo94gkHnNe7mTu/jCQg3Xo2Yw\n\
ZDAdBgNVHQ4EFgQUq1SsSWDnp0G5v5/hWi9CC7eWDTwwHwYDVR0jBBgwFoAUq1Ss\n\
SWDnp0G5v5/hWi9CC7eWDTwwEgYDVR0TAQH/BAgwBgEB/wIBATAOBgNVHQ8BAf8E\n\
BAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwK9GqxdmRHJw7iB0z/b/WgzBv2jb7OmFS\n\
uVPA+6ZNApjYXCZUOVQFC60KUUV7yW53AjEA5lLrdXxdGNSIuLe1h/A+v/vRrYtt\n\
132Jzh+LkKBHdC1wcvDKjk2ZQG5WySly6VMp\n\
-----END CERTIFICATE-----\n";
const ROOT_CA_PEM_ENV: &str = "DHTTP_ROOT_CA_PEM";
const BOOTSTRAP_URL_ENV: &str = "DHTTP_BOOTSTRAP_URL";

fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by cargo"));

let root_ca_dest = out_dir.join("root.crt");
let root_ca = root_ca_bytes();
fs::write(&root_ca_dest, &root_ca).unwrap_or_else(|error| {
panic!(
"failed to write generated DHTTP root CA to {}: {error}",
root_ca_dest.display()
)
});
let root_ca_der = root_ca_der();

let bootstrap_url = env_or_default(BOOTSTRAP_URL_ENV, DEFAULT_BOOTSTRAP_URL);
let bootstrap_authority = bootstrap_authority(&bootstrap_url).unwrap_or_else(|error| {
Expand All @@ -41,95 +23,23 @@ fn main() {
"// @generated by build.rs; do not edit.\n\
pub const DHTTP_BOOTSTRAP_URL: &str = {bootstrap_url:?};\n\
pub const DHTTP_BOOTSTRAP_AUTHORITY: &str = {bootstrap_authority:?};\n\
pub const DHTTP_ROOT_CA: &[u8] = &{root_ca:?};\n"
pub const DHTTP_ROOT_CA_DER: &[u8] = &{root_ca_der:?};\n"
);
fs::write(out_dir.join("bootstrap.rs"), bootstrap)
.expect("failed to write generated DHTTP bootstrap constants");

println!("cargo::rerun-if-env-changed={ROOT_CA_ENV}");
println!("cargo::rerun-if-env-changed={ROOT_CA_PEM_ENV}");
println!("cargo::rerun-if-env-changed={BOOTSTRAP_URL_ENV}");
if let Some(root_ca) = optional_env_path(ROOT_CA_ENV) {
println!("cargo::rerun-if-changed={}", root_ca.display());
}
}

fn env_or_default(name: &str, default: &str) -> String {
env::var(name).unwrap_or_else(|_| default.to_owned())
}

fn bootstrap_authority(value: &str) -> Result<String, String> {
let url = url::Url::parse(value).map_err(|error| error.to_string())?;
if url.scheme() != "https" {
return Err("scheme must be https".to_owned());
}
if url.username() != "" || url.password().is_some() {
return Err("credentials are not allowed".to_owned());
}
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
return Err("path, query, and fragment are not allowed".to_owned());
}

let host = url
.host_str()
.ok_or_else(|| "host is required".to_owned())?;
let port = url
.port()
.ok_or_else(|| "an explicit port is required".to_owned())?;
if matches!(url.host(), Some(url::Host::Ipv6(_))) {
Ok(format!("[{host}]:{port}"))
} else {
Ok(format!("{host}:{port}"))
}
}

fn optional_env_path(name: &str) -> Option<PathBuf> {
env::var_os(name).map(PathBuf::from)
}

fn root_ca_bytes() -> Vec<u8> {
let Some(root_ca) = optional_env_path(ROOT_CA_ENV) else {
return DEFAULT_ROOT_CA_PEM.as_bytes().to_vec();
fn root_ca_der() -> Vec<u8> {
let pem = match env::var(ROOT_CA_PEM_ENV) {
Ok(pem) => pem,
Err(env::VarError::NotPresent) => DEFAULT_ROOT_CA_PEM.to_owned(),
Err(env::VarError::NotUnicode(_)) => {
panic!("{ROOT_CA_PEM_ENV} must contain UTF-8 PEM text")
}
};

fs::read(&root_ca).unwrap_or_else(|error| {
panic!(
"failed to read DHTTP root CA from {}: {error}",
root_ca.display()
)
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn missing_bootstrap_env_uses_genmeta_production_default() {
let name = format!("__DHTTP_MISSING_BOOTSTRAP_{}", std::process::id());

assert_eq!(
env_or_default(&name, DEFAULT_BOOTSTRAP_URL),
"https://bootstrap.genmeta.net:20002"
);
}

#[test]
fn bootstrap_url_produces_stun_authority() {
assert_eq!(
bootstrap_authority("https://bootstrap.genmeta.net:20002").as_deref(),
Ok("bootstrap.genmeta.net:20002")
);
}

#[test]
fn bootstrap_url_requires_https_and_explicit_port() {
assert!(bootstrap_authority("http://bootstrap.genmeta.net:20002").is_err());
assert!(bootstrap_authority("https://bootstrap.genmeta.net").is_err());
}

#[test]
fn default_root_ca_is_pem_certificate() {
assert!(DEFAULT_ROOT_CA_PEM.starts_with("-----BEGIN CERTIFICATE-----"));
assert!(DEFAULT_ROOT_CA_PEM.ends_with("-----END CERTIFICATE-----\n"));
}
parse_root_ca_der(&pem).unwrap_or_else(|error| panic!("invalid {ROOT_CA_PEM_ENV}: {error}"))
}
15 changes: 15 additions & 0 deletions dhttp/root.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICVzCCAd2gAwIBAgIUe8kwBACY6f+MAzdCBVPmq4p+CiswCgYIKoZIzj0EAwMw
WTELMAkGA1UEBhMCQ04xETAPBgNVBAgMCEhvbmdLb25nMRwwGgYDVQQKDBNHZW5t
ZXRhIEVDQyBSb290IENBMRkwFwYDVQQDDBByb290Lmdlbm1ldGEubmV0MB4XDTI2
MDcxMzEzMDQyOFoXDTQ2MDcxMzEzMDQyOFowWTELMAkGA1UEBhMCQ04xETAPBgNV
BAgMCEhvbmdLb25nMRwwGgYDVQQKDBNHZW5tZXRhIEVDQyBSb290IENBMRkwFwYD
VQQDDBByb290Lmdlbm1ldGEubmV0MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEO+zm
ZYL0LaqTKf7mW4tnRWeNop1p8f2ZsexhAl23GHkHwLjCihhQzBCZ8VMRPAdVcEIS
XcGY/U6+Z1IAYCRG0tdsUCXHXxzvDY0I9FZqZw1Xo94gkHnNe7mTu/jCQg3Xo2Yw
ZDAdBgNVHQ4EFgQUq1SsSWDnp0G5v5/hWi9CC7eWDTwwHwYDVR0jBBgwFoAUq1Ss
SWDnp0G5v5/hWi9CC7eWDTwwEgYDVR0TAQH/BAgwBgEB/wIBATAOBgNVHQ8BAf8E
BAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwK9GqxdmRHJw7iB0z/b/WgzBv2jb7OmFS
uVPA+6ZNApjYXCZUOVQFC60KUUV7yW53AjEA5lLrdXxdGNSIuLe1h/A+v/vRrYtt
132Jzh+LkKBHdC1wcvDKjk2ZQG5WySly6VMp
-----END CERTIFICATE-----
Loading
Loading