diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5507cea --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +[env] +DHTTP_BOOTSTRAP_URL = "https://bootstrap.genmeta.net:20002" +DHTTP_H3_DNS_SERVER = "https://ddns.genmeta.net:4433" +DHTTP_MDNS_SERVICE = "_dhttp.local" diff --git a/.env.example b/.env.example index 5713e04..32b28bb 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,5 @@ -# Copy this file to .env and load it in your shell or CI before building. -# Pishoo does not load .env files automatically. - -DHTTP_BOOTSTRAP_URL=https://bootstrap.genmeta.net +# Optional shell/CI overrides. Cargo defaults to the same production values. +DHTTP_BOOTSTRAP_URL=https://bootstrap.genmeta.net:20002 +DHTTP_CERT_SERVER_URL=https://api.genmeta.net:4433 DHTTP_H3_DNS_SERVER=https://ddns.genmeta.net:4433 -DHTTP_MDNS_SERVICE="_dhttp.local" -DHTTP_ROOT_CA_PEM="-----BEGIN CERTIFICATE----- -MIICVTCCAdqgAwIBAgIUcBI2Xq2ZS+SYgLrPjqN0eqCf5a8wCgYIKoZIzj0EAwMw -WTELMAkGA1UEBhMCQ04xETAPBgNVBAgMCEhvbmdLb25nMRwwGgYDVQQKDBNHZW5t -ZXRhIEVDQyBSb290IENBMRkwFwYDVQQDDBByb290Lmdlbm1ldGEubmV0MB4XDTI1 -MDQyMTA1MDEwNFoXDTQ1MDQxNjA1MDEwNFowWTELMAkGA1UEBhMCQ04xETAPBgNV -BAgMCEhvbmdLb25nMRwwGgYDVQQKDBNHZW5tZXRhIEVDQyBSb290IENBMRkwFwYD -VQQDDBByb290Lmdlbm1ldGEubmV0MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEA81Y -m6nFz+c58mDquLk2KhZufUejy6js+Ru9AsrRyFIHCHbTQSOlkK+QXZMqRBkBSA+n -Gy0mnkf0zeXj9NFqTYveIrgDtXv/WoD3eadAyD8CAu5O0/XsyQGaP/bS+sEeo2Mw -YTAdBgNVHQ4EFgQUXgbKWqQlEhGkIdqW+slZ0wTtCS8wHwYDVR0jBBgwFoAUXgbK -WqQlEhGkIdqW+slZ0wTtCS8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC -AQYwCgYIKoZIzj0EAwMDaQAwZgIxALCdmweGeSRn+B5IblH1emNjT2kw3CKO8l1g -jYBag7kqGK8ZGjwx9xpC5QMn3hw8qQIxAKKnzCktBfssKJ5HMEHuWCg0rw/FHmpu -PrFCruzsQiLcBa+GAP2O7Qbl+XAlK+MNiA== ------END CERTIFICATE-----" +DHTTP_MDNS_SERVICE=_dhttp.local diff --git a/Cargo.toml b/Cargo.toml index 1acd3a3..11e02f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["dhttp", "identity", "home", "api", "access", "log"] [workspace.package] -version = "0.6.0-beta.5" +version = "0.6.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/genmeta/dhttp" @@ -41,10 +41,10 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["f # Keep same-repository workspace members as path dependencies, except staged # dhttp-identity releases, which resolve through crates.io to avoid duplicate # identity types with h3x/ddns in the formal release graph. -dhttp-identity = "0.3.0-beta.1" -dhttp-home = { path = "home", version = "0.5.0-beta.1" } -dquic = { version = "0.7.0-beta.4", default-features = false } -ddns = { package = "dyns", version = "0.7.0-beta.2", features = [ +dhttp-identity = "0.3.0" +dhttp-home = { path = "home", version = "0.5.0" } +dquic = { version = "0.7.0", default-features = false } +ddns = { package = "dyns", version = "0.7.0", features = [ "resolvers", "publishers", "h3", @@ -52,9 +52,9 @@ ddns = { package = "dyns", version = "0.7.0-beta.2", features = [ "mdns", "dquic-network", ] } -h3x = { version = "0.6.0-beta.4", features = [ +h3x = { version = "0.6.0", features = [ "dquic", ] } -dhttp = { path = "dhttp", version = "0.6.0-beta.5" } -dhttp-access = { path = "access", version = "0.4.0-beta.2" } -dhttp-log = { path = "log", version = "0.1.0-beta.1" } +dhttp = { path = "dhttp", version = "0.6.0" } +dhttp-access = { path = "access", version = "0.4.0" } +dhttp-log = { path = "log", version = "0.1.0" } diff --git a/access/Cargo.toml b/access/Cargo.toml index b7ce8ef..f15ca3e 100644 --- a/access/Cargo.toml +++ b/access/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-access" description = "Identity-aware access control primitives for DHttp" -version = "0.4.0-beta.2" +version = "0.4.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/api/js/index.d.ts b/api/js/index.d.ts index 9ba268f..7575c79 100644 --- a/api/js/index.d.ts +++ b/api/js/index.d.ts @@ -12,7 +12,7 @@ export type FetchHandler = (request: DhttpRequest) => Response | Promise void | Promise; -export type CertificateChainKind = "primary" | "secondary"; +export type CertificateChainKind = "client" | "client and server"; export interface CertificateChainKey { sequence: number; diff --git a/api/package-lock.json b/api/package-lock.json index 3be1460..01f49aa 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@genmeta/dhttp", - "version": "0.6.0-beta.5", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@genmeta/dhttp", - "version": "0.6.0-beta.5", + "version": "0.6.0", "devDependencies": { "@napi-rs/cli": "^3.3.5" } diff --git a/api/package.json b/api/package.json index 10c6b1a..a8bf8a3 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@genmeta/dhttp", - "version": "0.6.0-beta.5", + "version": "0.6.0", "description": "The True Internet", "license": "Apache-2.0", "homepage": "https://dhttp.net/", diff --git a/api/src/certificate.rs b/api/src/certificate.rs index 18a4b4a..fbcb34e 100644 --- a/api/src/certificate.rs +++ b/api/src/certificate.rs @@ -17,7 +17,7 @@ impl From for DhttpSubjectKeyIden value: value.to_string(), chain: CertificateChainKey { sequence: value.chain().sequence().get(), - kind: value.chain().kind().as_str().to_owned(), + kind: value.chain().usage().as_str().to_owned(), }, owner_hash: value.owner_hash().as_str().to_owned(), } diff --git a/dhttp/Cargo.toml b/dhttp/Cargo.toml index 0ffbe94..8e8e21c 100644 --- a/dhttp/Cargo.toml +++ b/dhttp/Cargo.toml @@ -38,6 +38,9 @@ dquic = { workspace = true } ddns = { workspace = true } h3x = { workspace = true } +[build-dependencies] +url = "2" + [dev-dependencies] dhttp-access = { workspace = true, features = ["http", "orm"] } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/dhttp/build.rs b/dhttp/build.rs index 88a2131..4fdcd85 100644 --- a/dhttp/build.rs +++ b/dhttp/build.rs @@ -1,28 +1,24 @@ use std::{env, fs, path::PathBuf}; const ROOT_CA_ENV: &str = "DHTTP_ROOT_CA"; -const STUN_SERVER_ENV: &str = "DHTTP_STUN_SERVER"; +const BOOTSTRAP_URL_ENV: &str = "DHTTP_BOOTSTRAP_URL"; -const DEFAULT_STUN_SERVER: &str = "stun.dhttp.example.net"; +const DEFAULT_BOOTSTRAP_URL: &str = "https://bootstrap.genmeta.net:20002"; const DEFAULT_ROOT_CA_PEM: &str = "\ -----BEGIN CERTIFICATE-----\n\ -MIIDKTCCAhGgAwIBAgIUHNScq6R2U5QYUzxkEkNDaOJt4yMwDQYJKoZIhvcNAQEL\n\ -BQAwHDEaMBgGA1UEAwwRZGh0dHAuZXhhbXBsZS5uZXQwHhcNMjYwNjA0MTE0NjI1\n\ -WhcNMzYwNjAxMTE0NjI1WjAcMRowGAYDVQQDDBFkaHR0cC5leGFtcGxlLm5ldDCC\n\ -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANCHw/okfV4RDIN5ws0IMGi1\n\ -c1TTQAklaBynw8neIM+ZniGLOluRLvYATY4xla/ayjYdzXmel+4hdaG5pN6Ggdgm\n\ -PhIj9hpsNKOjjkBHtdnDg/Dfc/L+ElRDPMNosvVN2L/G4yDBso5SGJUlFE7Fww3x\n\ -izrTCCbR4lofwZAnUZAtvnX3KGgvgRtg0VdgvpWJ1JOnwQYm1qU6ljFG+72418SW\n\ -Htn2A8SRGSO1Im1W1QMY4OJtAVwfi/XwwN9ifWWPwcl4OGV/MXBLUQRF4gzgFC+1\n\ -8EG5jKCZtoJcY3FY+LF1DMkdbmE6TOddycev0HWNBPQtSMsxTo9aYrApKnOk5ekC\n\ -AwEAAaNjMGEwHQYDVR0OBBYEFDe4aYWivTEYa1hkBy7WaZOVY/S6MB8GA1UdIwQY\n\ -MBaAFDe4aYWivTEYa1hkBy7WaZOVY/S6MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P\n\ -AQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IBAQAEpkndNISW40WGLJa9QVYcBAyP\n\ -Ou+vdoRuch+/jeIyM4Lau2yBYu1nyneLmVpJGTCFHxcTrYCmUqJKnkWbHPXoDY27\n\ -MPGih8GkiIW057JUbcyktKZqzzmsAoCDz6kLp76BpJiLViiSctufWKeHt8Rm/n66\n\ -dI2XmJcJxJ9Xs+QkziinZbiUKQxiDBTgkTT4kxhMESlb4iV9YNpIvW4m8fqCaxtl\n\ -ECyJBmqcGIp3bEYchL4hs8I3jbT3VXykjCK/FU/wLWSCIMzhsV4U7JxI0xKGziPU\n\ -p83DC7/YpVF7avt05Mwb0n3RhKSmDMLnfWs61zTDQu0T/IYBbr+PvVMsw+W4\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"; fn main() { @@ -37,17 +33,21 @@ fn main() { ) }); - let stun_server = env_or_default(STUN_SERVER_ENV, DEFAULT_STUN_SERVER); + let bootstrap_url = env_or_default(BOOTSTRAP_URL_ENV, DEFAULT_BOOTSTRAP_URL); + let bootstrap_authority = bootstrap_authority(&bootstrap_url).unwrap_or_else(|error| { + panic!("invalid {BOOTSTRAP_URL_ENV} value {bootstrap_url:?}: {error}") + }); let bootstrap = format!( "// @generated by build.rs; do not edit.\n\ - pub const DHTTP_STUN_SERVER: &str = {stun_server:?};\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" ); 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={STUN_SERVER_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()); } @@ -57,6 +57,31 @@ fn env_or_default(name: &str, default: &str) -> String { env::var(name).unwrap_or_else(|_| default.to_owned()) } +fn bootstrap_authority(value: &str) -> Result { + 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 { env::var_os(name).map(PathBuf::from) } @@ -79,17 +104,31 @@ mod tests { use super::*; #[test] - fn missing_stun_env_uses_dhttp_example_net_placeholder() { - let name = format!("__DHTTP_MISSING_STUN_{}", std::process::id()); + fn missing_bootstrap_env_uses_genmeta_production_default() { + let name = format!("__DHTTP_MISSING_BOOTSTRAP_{}", std::process::id()); assert_eq!( - env_or_default(&name, DEFAULT_STUN_SERVER), - "stun.dhttp.example.net" + env_or_default(&name, DEFAULT_BOOTSTRAP_URL), + "https://bootstrap.genmeta.net:20002" ); } #[test] - fn placeholder_root_ca_is_pem_certificate() { + 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")); } diff --git a/dhttp/src/ddns.rs b/dhttp/src/ddns.rs index 6c64720..bf03773 100644 --- a/dhttp/src/ddns.rs +++ b/dhttp/src/ddns.rs @@ -1,6 +1,6 @@ //! Re-export of the ddns crate APIs used by DHTTP. -use std::{future::Future, sync::Arc}; +use std::{fmt, future::Future, sync::Arc}; use snafu::ResultExt; @@ -22,6 +22,8 @@ pub type ArcResolver = Arc; /// Publisher trait object used by DHTTP DNS construction. pub type ArcPublisher = Arc; +const DHTTP_DNS_SUFFIX: &str = "dhttp.net"; + #[derive(Clone)] enum DhttpDnsOp { Dns(resolvers::DnsScheme), @@ -110,6 +112,29 @@ impl DhttpDnsPlan { type DeferredEndpointResolver = resolvers::deferred::DeferredResolver; type EndpointH3Client = Arc>; +#[derive(Debug)] +struct StunResolverRouter { + dhttp: ArcResolvers, + external: ArcResolvers, +} + +impl fmt::Display for StunResolverRouter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("STUN DNS Router") + } +} + +impl Resolve for StunResolverRouter { + fn lookup<'a>(&'a self, name: &'a str) -> crate::dquic::resolver::ResolveFuture<'a> { + let resolvers = if uses_h3_dns(name) { + &self.dhttp + } else { + &self.external + }; + Resolve::lookup(resolvers.as_ref(), name) + } +} + #[derive(Clone)] struct EndpointH3Clients { resolver: EndpointH3Client, @@ -225,16 +250,20 @@ async fn network_stun_resolver_from_plan( h3_dns_server: Arc, ) -> Result { let operations = dns_plan.effective_ops(); - let h3_resolver = if uses_h3(&operations) { + let (h3_resolver, external_resolvers) = if uses_h3(&operations) { let h3_underlay = network_h3_underlay(&operations, network.clone(), bind.clone()).await?; let h3_quic = - dedicated_network_h3_client_quic(network.clone(), bind.clone(), h3_underlay).await; - Some(Arc::new(h3_resolver_for_network( - h3_dns_server.as_ref(), - h3_quic, - )?)) + dedicated_network_h3_client_quic(network.clone(), bind.clone(), h3_underlay.clone()) + .await; + ( + Some(Arc::new(h3_resolver_for_network( + h3_dns_server.as_ref(), + h3_quic, + )?)), + Some(h3_underlay), + ) } else { - None + (None, None) }; let mut builder = resolvers::Resolvers::builder(); @@ -263,7 +292,16 @@ async fn network_stun_resolver_from_plan( } } - network_resolver_chain(builder.build()) + let dhttp_resolvers = network_resolver_chain(builder.build())?; + let Some(external_resolvers) = external_resolvers else { + return Ok(dhttp_resolvers); + }; + + let router: ArcResolver = Arc::new(StunResolverRouter { + dhttp: dhttp_resolvers, + external: external_resolvers, + }); + network_resolver_chain(resolvers::Resolvers::new().with(router)) } async fn endpoint_dns_from_quic( @@ -303,7 +341,7 @@ async fn endpoint_dns_from_quic( } DhttpDnsOp::Dns(resolvers::DnsScheme::Http) => { let http = Arc::new( - resolvers::HttpResolver::new(resolvers::DHTTP_HTTP_DNS_SERVER) + resolvers::HttpResolver::new(crate::endpoint::BOOTSTRAP_URL) .expect("BUG: DHTTP HTTP DNS server is a valid URL"), ); resolver_builder = resolver_builder.candidate_resolver(http.clone()); @@ -407,10 +445,10 @@ async fn network_h3_underlay( operations: &[DhttpDnsOp], network: Arc, bind: Arc>, -) -> Result { +) -> Result { let resolvers = non_h3_resolvers(operations, network, bind).await; - network_arc_resolver_chain(resolvers) + network_resolver_chain(resolvers) } async fn non_h3_resolvers( @@ -494,22 +532,39 @@ fn network_resolver_chain( } } -fn network_arc_resolver_chain( - resolvers: resolvers::Resolvers, -) -> Result { - if resolvers.iter().next().is_none() { - build_dhttp_network_with_dns_error::EmptyResolverSnafu.fail() - } else { - Ok(Arc::new(resolvers)) - } -} - fn uses_h3(operations: &[DhttpDnsOp]) -> bool { operations .iter() .any(|operation| matches!(operation, DhttpDnsOp::Dns(resolvers::DnsScheme::H3))) } +fn uses_h3_dns(name: &str) -> bool { + let host = match name.rsplit_once(':') { + Some((host, digits)) + if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) => + { + host + } + _ => name, + }; + let host = host.strip_suffix('.').unwrap_or(host); + + if rustls::pki_types::DnsName::try_from(host).is_err() { + return false; + } + + if host.eq_ignore_ascii_case(DHTTP_DNS_SUFFIX) { + return true; + } + + let Some(suffix_start) = host.len().checked_sub(DHTTP_DNS_SUFFIX.len()) else { + return false; + }; + suffix_start > 0 + && host.as_bytes().get(suffix_start - 1) == Some(&b'.') + && host.as_bytes()[suffix_start..].eq_ignore_ascii_case(DHTTP_DNS_SUFFIX.as_bytes()) +} + fn has_custom_resolver(operations: &[DhttpDnsOp]) -> bool { operations .iter() @@ -578,6 +633,59 @@ mod tests { } } + #[test] + fn h3_dns_is_limited_to_dhttp_names() { + for name in [ + "dhttp.net", + "DHTTP.NET.", + "node.dhttp.net", + "deep.node.dhttp.net:443", + "deep.node.dhttp.net.:7", + ] { + assert!(uses_h3_dns(name), "expected H3 DNS for {name}"); + } + + for name in [ + "nat.genmeta.net:20004", + "ddns.genmeta.net:443", + "notdhttp.net", + "dhttp.net.example", + "127.0.0.1:443", + "[::1]:443", + "dhttp.net:service", + "bad..name.dhttp.net", + ] { + assert!(!uses_h3_dns(name), "unexpected H3 DNS for {name}"); + } + } + + #[tokio::test] + async fn stun_resolver_router_selects_branch_from_lookup_name() { + let dhttp_calls = Arc::new(AtomicUsize::new(0)); + let external_calls = Arc::new(AtomicUsize::new(0)); + let dhttp = Arc::new(resolvers::Resolvers::new().with(Arc::new(CountingResolver { + calls: dhttp_calls.clone(), + }))); + let external = Arc::new(resolvers::Resolvers::new().with(Arc::new(CountingResolver { + calls: external_calls.clone(), + }))); + let router = StunResolverRouter { dhttp, external }; + + let _dhttp_records = router + .lookup("node.dhttp.net:443") + .await + .expect("dhttp STUN name should use dhttp resolvers"); + assert_eq!(dhttp_calls.load(Ordering::SeqCst), 1); + assert_eq!(external_calls.load(Ordering::SeqCst), 0); + + let _external_records = router + .lookup("nat.genmeta.net:20004") + .await + .expect("external STUN name should use external resolvers"); + assert_eq!(dhttp_calls.load(Ordering::SeqCst), 1); + assert_eq!(external_calls.load(Ordering::SeqCst), 1); + } + #[test] fn dhttp_dns_plan_defaults_only_when_empty() { let empty = DhttpDnsPlan::new(); diff --git a/dhttp/src/endpoint.rs b/dhttp/src/endpoint.rs index f7fd9a4..498d2cf 100644 --- a/dhttp/src/endpoint.rs +++ b/dhttp/src/endpoint.rs @@ -88,12 +88,11 @@ pub enum CreateEndpointPublicationLoopError { AnonymousEndpoint, } -/// Default STUN bootstrap name for NAT traversal. -/// -/// DDNS resolution of this name returns the actual socket addresses and ports -/// from endpoint `E` records; the bootstrap value itself is a logical lookup -/// name, not a raw `host:port` transport authority. -pub const STUN_SERVER: &str = crate::bootstrap::DHTTP_STUN_SERVER; +/// Default bootstrap service URL. +pub const BOOTSTRAP_URL: &str = crate::bootstrap::DHTTP_BOOTSTRAP_URL; + +/// Bootstrap authority passed to the NAT traversal layer. +pub const BOOTSTRAP_AUTHORITY: &str = crate::bootstrap::DHTTP_BOOTSTRAP_AUTHORITY; fn normalize_bind(bind: Arc>) -> Arc> { if bind.is_empty() { @@ -588,7 +587,7 @@ impl crate::h3x::quic::Connect for Endpoint { mod tests { use super::*; use crate::{ - ddns::resolvers::{DnsScheme, H3Resolver, Resolvers}, + ddns::resolvers::{DnsScheme, Resolvers}, dquic::Network, network::DeferredStunResolver, }; @@ -616,16 +615,17 @@ mod tests { } #[test] - fn stun_server_comes_from_compile_time_environment() { - if let Some(expected) = option_env!("DHTTP_STUN_SERVER") { - assert_eq!(STUN_SERVER, expected); + fn bootstrap_url_comes_from_compile_time_environment() { + if let Some(expected) = option_env!("DHTTP_BOOTSTRAP_URL") { + assert_eq!(BOOTSTRAP_URL, expected); } } #[test] - fn stun_server_placeholder_is_plain_name_when_compile_time_env_is_absent() { - if option_env!("DHTTP_STUN_SERVER").is_none() { - assert_eq!(STUN_SERVER, "stun.dhttp.example.net"); + fn bootstrap_production_default_is_used_when_compile_time_env_is_absent() { + if option_env!("DHTTP_BOOTSTRAP_URL").is_none() { + assert_eq!(BOOTSTRAP_URL, "https://bootstrap.genmeta.net:20002"); + assert_eq!(BOOTSTRAP_AUTHORITY, "bootstrap.genmeta.net:20002"); } } @@ -1115,59 +1115,24 @@ mod tests { } #[tokio::test] - async fn owned_default_network_stun_resolver_keeps_h3_resolver_alive_through_weak_edge() { - let endpoint = Endpoint::builder().build().await.unwrap(); - let stun_resolver = endpoint.network().network().quic().stun_resolver(); - let deferred_any: &dyn Any = stun_resolver.as_ref(); - let deferred = deferred_any - .downcast_ref::() - .expect("owned network uses a deferred STUN resolver"); - let weak_resolver = deferred - .get() - .expect("deferred STUN resolver is initialized"); - let actual = weak_resolver - .upgrade() - .expect("DhttpNetwork keeps the STUN resolver target alive"); - - assert!(actual.iter().any(|resolver| { - let resolver_any: &dyn Any = resolver.as_ref(); - resolver_any.is::>() - })); - } - - #[tokio::test] - async fn h3_only_endpoint_stun_resolver_uses_h3_final_resolver_not_system_final_resolver() { - let endpoint = Endpoint::builder() - .dns(DnsScheme::H3) + async fn owned_network_keeps_stun_resolver_alive_through_weak_edge() { + let network = DhttpNetwork::builder() + .stun_server(Some(Arc::from("node.dhttp.net"))) .build() .await .unwrap(); + let endpoint = Endpoint::builder().network(network).build().await.unwrap(); let stun_resolver = endpoint.network().network().quic().stun_resolver(); let deferred_any: &dyn Any = stun_resolver.as_ref(); let deferred = deferred_any .downcast_ref::() - .expect("h3-only endpoint-owned network uses deferred STUN resolver"); + .expect("owned network uses a deferred STUN resolver"); let weak_resolver = deferred .get() .expect("deferred STUN resolver is initialized"); - let actual = weak_resolver + let _actual = weak_resolver .upgrade() .expect("DhttpNetwork keeps the STUN resolver target alive"); - let resolver_names = actual - .iter() - .map(|resolver| resolver.to_string()) - .collect::>(); - - assert!( - resolver_names - .iter() - .any(|name| name.starts_with("H3 DNS Resolver(")) - ); - assert!( - !resolver_names - .iter() - .any(|name| name == "System DNS Resolver") - ); } #[tokio::test] diff --git a/dhttp/src/network.rs b/dhttp/src/network.rs index 9c628b2..659da06 100644 --- a/dhttp/src/network.rs +++ b/dhttp/src/network.rs @@ -108,8 +108,8 @@ impl DhttpNetwork { #[builder(default = Arc::new(QuicRouter::new()))] quic_router: Arc, #[builder(default = Arc::new(LocalEndpoints::new()))] local_endpoints: Arc, ) -> Result { - let stun_server = - stun_server.unwrap_or_else(|| Some(Arc::::from(crate::endpoint::STUN_SERVER))); + let stun_server = stun_server + .unwrap_or_else(|| Some(Arc::::from(crate::endpoint::BOOTSTRAP_AUTHORITY))); if let Some(stun_resolver) = stun_resolver { let network = Network::builder() @@ -226,7 +226,7 @@ mod tests { assert_eq!( dhttp_network.network().quic().stun_server().as_deref(), - Some(crate::endpoint::STUN_SERVER) + Some(crate::endpoint::BOOTSTRAP_AUTHORITY) ); } @@ -312,9 +312,10 @@ mod tests { } #[tokio::test] - async fn h3_only_network_stun_resolver_uses_h3_without_system_final_resolver() { + async fn h3_only_network_installs_routed_stun_resolver() { let dhttp_network = DhttpNetwork::builder() .dns(DnsScheme::H3) + .stun_server(Some(Arc::from("node.dhttp.net"))) .build() .await .expect("h3-only network should build"); @@ -335,16 +336,7 @@ mod tests { .map(|resolver| resolver.to_string()) .collect::>(); - assert!( - resolver_names - .iter() - .any(|name| name.starts_with("H3 DNS Resolver(")) - ); - assert!( - !resolver_names - .iter() - .any(|name| name == "System DNS Resolver") - ); + assert_eq!(resolver_names, vec!["STUN DNS Router"]); } #[tokio::test] diff --git a/home/Cargo.toml b/home/Cargo.toml index cc41830..5762afe 100644 --- a/home/Cargo.toml +++ b/home/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-home" description = "Local identity home and profile management for DHttp" -version = "0.5.0-beta.1" +version = "0.5.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/identity/Cargo.toml b/identity/Cargo.toml index 0e8c190..fdea346 100644 --- a/identity/Cargo.toml +++ b/identity/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-identity" description = "Identity primitives for DHttp" -version = "0.3.0-beta.2" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/identity/src/certificate.rs b/identity/src/certificate.rs index 3b380f9..1ce0a25 100644 --- a/identity/src/certificate.rs +++ b/identity/src/certificate.rs @@ -93,8 +93,8 @@ impl CertificateUsage { pub fn kind_flag(self) -> &'static str { match self { - Self::ClientOnly => "0", - Self::ClientAndServer => "1", + Self::ClientOnly => "1", + Self::ClientAndServer => "0", } } } @@ -227,8 +227,8 @@ impl FromStr for DhttpSubjectKeyIdentifier { let sequence = CertificateSequence::try_from(sequence) .context(invalid_dhttp_subject_key_identifier::SequenceRangeSnafu)?; let usage = match usage { - "0" => CertificateUsage::ClientOnly, - "1" => CertificateUsage::ClientAndServer, + "0" => CertificateUsage::ClientAndServer, + "1" => CertificateUsage::ClientOnly, _ => return invalid_dhttp_subject_key_identifier::KindFlagSnafu.fail(), }; let owner_hash = OwnerHash::try_from(owner_hash) @@ -309,6 +309,12 @@ mod tests { assert_eq!(secondary.to_string(), "client:2"); } + #[test] + fn certificate_usage_preserves_certserver_kind_flags() { + assert_eq!(CertificateUsage::ClientAndServer.kind_flag(), "0"); + assert_eq!(CertificateUsage::ClientOnly.kind_flag(), "1"); + } + #[test] fn rejects_out_of_range_subject_key_identifier_sequence() { let error = format!("{}:0:{OWNER_HASH}", i32::MAX as u64 + 1) @@ -324,14 +330,14 @@ mod tests { #[test] fn parses_canonical_dhttp_subject_key_identifier() { let ski = DhttpSubjectKeyIdentifier::try_from_subject_key_identifier_bytes( - format!("7:1:{OWNER_HASH}").as_bytes(), + format!("7:0:{OWNER_HASH}").as_bytes(), ) .unwrap(); assert_eq!(ski.chain().sequence().get(), 7); assert_eq!(ski.chain().usage(), CertificateUsage::ClientAndServer); assert_eq!(ski.owner_hash().as_str(), OWNER_HASH); - assert_eq!(ski.to_string(), format!("7:1:{OWNER_HASH}")); + assert_eq!(ski.to_string(), format!("7:0:{OWNER_HASH}")); } #[test] diff --git a/identity/src/identity.rs b/identity/src/identity.rs index 861daf0..be40d09 100644 --- a/identity/src/identity.rs +++ b/identity/src/identity.rs @@ -572,7 +572,7 @@ mod tests { let dhttp = identity .dhttp_subject_key_identifier() .expect("extract dhttp ski"); - assert_eq!(dhttp.chain().usage(), CertificateUsage::ClientOnly); + assert_eq!(dhttp.chain().usage(), CertificateUsage::ClientAndServer); assert_eq!(dhttp.chain().sequence().get(), 0); } diff --git a/log/Cargo.toml b/log/Cargo.toml index 9ec0922..03fe045 100644 --- a/log/Cargo.toml +++ b/log/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-log" description = "Typed DHTTP domain log records and formatting" -version = "0.1.0-beta.1" +version = "0.1.0" edition = "2024" license.workspace = true repository.workspace = true diff --git a/log/src/access/formatter.rs b/log/src/access/formatter.rs index ec3b0f5..a5fb43c 100644 --- a/log/src/access/formatter.rs +++ b/log/src/access/formatter.rs @@ -15,9 +15,30 @@ pub struct DefaultAccessFormatter; impl DefaultAccessFormatter { /// Formats one access record using the built-in compact V1 representation. pub fn format(record: &AccessLogRecord) -> Result { + Self::format_with_client(record, FormattedClient::Address) + } + + /// Formats one access record using the authenticated client identity as the + /// remote host and omitting the transport address. + pub fn format_with_client_identity( + record: &AccessLogRecord, + client_identity: Option<&str>, + ) -> Result { + Self::format_with_client(record, FormattedClient::Identity(client_identity)) + } + + fn format_with_client( + record: &AccessLogRecord, + client: FormattedClient<'_>, + ) -> Result { let convention = CompactConvention::default(); let mut builder = RecordBuilder::new(); - builder.element(&convention, &record.client)?; + match client { + FormattedClient::Address => builder.element(&convention, &record.client)?, + FormattedClient::Identity(identity) => { + builder.element(&convention, &Optional(identity.map(Text)))?; + } + } builder.literal(b" ")?; builder.element(&convention, &MissingField)?; builder.literal(b" ")?; @@ -38,6 +59,11 @@ impl DefaultAccessFormatter { } } +enum FormattedClient<'a> { + Address, + Identity(Option<&'a str>), +} + struct MissingField; impl FormatElement for MissingField { diff --git a/log/tests/access.rs b/log/tests/access.rs index 6803a8f..ee2a172 100644 --- a/log/tests/access.rs +++ b/log/tests/access.rs @@ -53,6 +53,27 @@ fn default_access_format_is_combined_without_reserved_columns() { assert!(!line.as_bytes().windows(5).any(|bytes| bytes == b"token")); } +#[test] +fn authenticated_client_identity_replaces_the_transport_address() { + let line = + DefaultAccessFormatter::format_with_client_identity(&access_fixture(), Some("reimu.pilot")) + .unwrap(); + + assert_eq!( + line.as_bytes(), + b"reimu.pilot - - [13/Jul/2026:16:20:31 +0800] \"GET /assets/a.css HTTP/3\" 200 1432 \"https://example.test/\" \"ExampleAgent/1.0\"\n" + ); +} + +#[test] +fn identity_format_omits_the_transport_address_for_anonymous_clients() { + let line = + DefaultAccessFormatter::format_with_client_identity(&access_fixture(), None).unwrap(); + + assert!(line.as_bytes().starts_with(b"- - - [")); + assert!(!line.as_bytes().starts_with(b"192.0.2.10")); +} + #[test] fn request_header_values_are_built_from_the_named_allowlist_only() { let mut headers = HeaderMap::new(); diff --git a/log/tests/certificate.rs b/log/tests/certificate.rs index f75fd91..bc92465 100644 --- a/log/tests/certificate.rs +++ b/log/tests/certificate.rs @@ -1,5 +1,7 @@ use chrono::{FixedOffset, TimeZone}; -use dhttp_identity::certificate::{CertificateChainKey, CertificateChainKind, CertificateSequence}; +use dhttp_identity::certificate::{ + CertificateChainKey, CertificateSequence, CertificateUsage as IdentityCertificateUsage, +}; use dhttp_log::cert::{ CertificateAction, CertificateIssuer, CertificateLogRecord, CertificateUsage, DefaultCertificateFormatter, Sha256Fingerprint, @@ -26,7 +28,7 @@ fn timestamp( fn chain() -> CertificateChainKey { CertificateChainKey::new( CertificateSequence::try_from(0_u32).unwrap(), - CertificateChainKind::Primary, + IdentityCertificateUsage::ClientAndServer, ) } @@ -86,7 +88,7 @@ fn default_certificate_line_formats_sha256_fingerprint_and_existing_chain_key() assert_eq!( line.as_bytes(), - b"[13/Jul/2026:08:20:31 +0000] APPLY \"Genmeta Tech Limited\" \"client only\" primary:0 [13/Jul/2027:08:20:30 +0000] \"sha256:0000000000000000000000000000000000000000000000000000000000000000\"\n" + b"[13/Jul/2026:08:20:31 +0000] APPLY \"Genmeta Tech Limited\" \"client only\" client and server:0 [13/Jul/2027:08:20:30 +0000] \"sha256:0000000000000000000000000000000000000000000000000000000000000000\"\n" ); }