diff --git a/crates/agent-tunnel/src/cert.rs b/crates/agent-tunnel/src/cert.rs index 188364703..996f5908b 100644 --- a/crates/agent-tunnel/src/cert.rs +++ b/crates/agent-tunnel/src/cert.rs @@ -128,6 +128,50 @@ pub struct SignedAgentCert { pub ca_cert_pem: String, } +/// Provenance of the server key used to sign a (re)issued server certificate. +/// +/// Tracked so that [`CaManager::ensure_server_cert`] always persists a freshly +/// generated key to disk *before* the new cert document is written. If we only +/// gated the write on `!key_path.exists()` (the previous behaviour), then for +/// statuses like `ExpiringSoon` / `Unreadable` — where the key file may still +/// exist but the keypair we just generated is brand new — the cert would be +/// signed with a key that never gets persisted, producing a cert/key mismatch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyOrigin { + LoadedFromDisk, + FreshlyGenerated, +} + +/// Atomically write `contents` to `path`: write to a sibling temp file, then +/// rename over the target. Prevents a crash mid-write from leaving the target +/// truncated or partially written. +fn write_atomically(path: &Utf8Path, contents: &[u8]) -> anyhow::Result<()> { + let parent = path + .parent() + .with_context(|| format!("target path has no parent directory: {path}"))?; + if !parent.as_str().is_empty() { + std::fs::create_dir_all(parent).with_context(|| format!("create directory {parent}"))?; + } + let file_name = path + .file_name() + .with_context(|| format!("target path has no file name: {path}"))?; + let tmp_path = parent.join(format!(".{file_name}.tmp.{}", Uuid::new_v4())); + + // Best-effort cleanup if anything fails after the temp file is created. + let write_result = std::fs::write(&tmp_path, contents); + if let Err(e) = write_result { + let _ = std::fs::remove_file(&tmp_path); + return Err(anyhow::Error::new(e).context(format!("write temp file {tmp_path}"))); + } + + if let Err(e) = std::fs::rename(&tmp_path, path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(anyhow::Error::new(e).context(format!("rename {tmp_path} -> {path}"))); + } + + Ok(()) +} + impl CaManager { /// Load an existing CA from disk, or generate a new one. pub fn load_or_generate(data_dir: &Utf8Path) -> anyhow::Result> { @@ -250,34 +294,71 @@ impl CaManager { /// Ensure a server certificate exists for the QUIC listener (signed by our CA). /// + /// `advertised_names` is the authoritative list of names/IPs the agent tunnel + /// is reachable as. Each entry is added to the cert SAN — DNS literals are + /// inserted as `SanType::DnsName`, IP literals (parseable by [`std::net::IpAddr`]) + /// as `SanType::IpAddress`. When the on-disk cert's SAN set differs from the + /// expected SAN set, the cert is regenerated reusing the existing keypair so + /// the SPKI pin captured at enrollment stays stable. + /// /// Returns `(cert_path, key_path)` on disk. - pub fn ensure_server_cert(&self, hostname: &str) -> anyhow::Result<(Utf8PathBuf, Utf8PathBuf)> { + pub fn ensure_server_cert(&self, advertised_names: &[&str]) -> anyhow::Result<(Utf8PathBuf, Utf8PathBuf)> { let cert_path = self.data_dir.join(SERVER_CERT_FILENAME); let key_path = self.data_dir.join(SERVER_KEY_FILENAME); - match check_server_cert(&cert_path, &key_path, hostname) { - ServerCertStatus::Valid => { - info!(%cert_path, "Using existing agent tunnel server certificate"); + if advertised_names.is_empty() { + anyhow::bail!("at least one advertised name is required to generate the agent tunnel server certificate"); + } + + // Compute the expected SAN set (canonical, deduped). + let expected_sans = build_san_set(advertised_names)?; + + let status = check_server_cert(&cert_path, &key_path, &expected_sans); + + // Pick the primary name for cert CN / log messages: the first advertised + // name. Stable across regenerations so log lines remain greppable. + let primary_name = advertised_names[0]; + + // The keypair is preserved across SAN regenerations to keep the SPKI pin + // stable for already-enrolled agents. Only the cert document changes. + // Generate a fresh key only if the existing key is missing/unreadable. + // + // `key_origin` tracks whether `server_key_pair` was loaded from disk or + // freshly generated. When freshly generated we MUST persist the new key + // (atomically) *before* writing the cert, otherwise a crash between the + // two writes — or the cert-only write path used previously — would + // leave a cert/key mismatch on disk and break Gateway TLS. + let (server_key_pair, key_origin) = match (status, std::fs::read_to_string(&key_path)) { + (ServerCertStatus::Valid, _) => { + info!(%cert_path, ?expected_sans, "Using existing agent tunnel server certificate"); return Ok((cert_path, key_path)); } - status => { - info!(%cert_path, ?status, "Generating server certificate"); + (ServerCertStatus::SanMismatch { on_disk_sans }, Ok(key_pem)) => { + info!( + %cert_path, + ?on_disk_sans, + new_sans = ?expected_sans, + "Agent tunnel server cert SAN set changed; regenerating with the existing keypair", + ); + let kp = KeyPair::from_pem(&key_pem).context("parse existing server key pair from PEM")?; + (kp, KeyOrigin::LoadedFromDisk) } - } - - info!(%hostname, "Generating agent tunnel server certificate"); - - let server_key_pair = - KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).context("generate server key pair")?; + (other_status, _) => { + info!(%cert_path, ?other_status, "Generating new server certificate keypair"); + let kp = + KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).context("generate server key pair")?; + (kp, KeyOrigin::FreshlyGenerated) + } + }; let mut server_params = CertificateParams::default(); - server_params.distinguished_name.push(DnType::CommonName, hostname); + server_params.distinguished_name.push(DnType::CommonName, primary_name); server_params .distinguished_name .push(DnType::OrganizationName, CA_ORG_NAME); - server_params - .subject_alt_names - .push(SanType::DnsName(hostname.try_into().context("DNS SAN")?)); + for san in &expected_sans { + server_params.subject_alt_names.push(san_entry(san)?); + } server_params .extended_key_usages .push(ExtendedKeyUsagePurpose::ServerAuth); @@ -291,18 +372,35 @@ impl CaManager { .signed_by(&server_key_pair, &ca_cert, &self.ca_key_pair) .context("sign server certificate with CA")?; - std::fs::write(&cert_path, server_cert.pem()).with_context(|| format!("write server cert to {cert_path}"))?; - std::fs::write(&key_path, server_key_pair.serialize_pem()) - .with_context(|| format!("write server key to {key_path}"))?; + let server_cert_pem = server_cert.pem(); + let fingerprint = cert_fingerprint_from_pem(&server_cert_pem).unwrap_or_else(|_| "".to_owned()); + + // Order matters: when the key was freshly generated, persist it BEFORE + // the cert so we never end up with a new cert on disk paired with an + // old/missing key. Use write-to-temp + rename for atomicity so a crash + // mid-write cannot leave a truncated key on disk. + if matches!(key_origin, KeyOrigin::FreshlyGenerated) { + write_atomically(&key_path, server_key_pair.serialize_pem().as_bytes()) + .with_context(|| format!("write server key to {key_path}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) - .with_context(|| format!("set permissions on {key_path}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("set permissions on {key_path}"))?; + } } - info!(%cert_path, %hostname, "Server certificate generated and saved"); + write_atomically(&cert_path, server_cert_pem.as_bytes()) + .with_context(|| format!("write server cert to {cert_path}"))?; + + info!( + %cert_path, + primary_name, + sans = ?expected_sans, + %fingerprint, + "Agent tunnel server certificate generated and saved", + ); Ok((cert_path, key_path)) } @@ -321,13 +419,13 @@ impl CaManager { /// /// The server certificate is signed by our CA; clients must present a certificate /// also signed by our CA (mutual TLS). - pub fn build_server_tls_config(&self, hostname: &str) -> anyhow::Result { + pub fn build_server_tls_config(&self, advertised_names: &[&str]) -> anyhow::Result { use rustls::pki_types::PrivateKeyDer; // Ensure rustls crypto provider is installed (ring). let _ = rustls::crypto::ring::default_provider().install_default(); - let (server_cert_path, server_key_path) = self.ensure_server_cert(hostname)?; + let (server_cert_path, server_key_path) = self.ensure_server_cert(advertised_names)?; // Load server certificate. let server_cert_pem = @@ -375,8 +473,8 @@ impl CaManager { /// Compute the SPKI SHA-256 hash of the server certificate. /// /// Loads the cert from disk. Only called during enrollment (infrequent). - pub fn server_spki_sha256(&self, hostname: &str) -> anyhow::Result { - let (server_cert_path, _) = self.ensure_server_cert(hostname)?; + pub fn server_spki_sha256(&self, advertised_names: &[&str]) -> anyhow::Result { + let (server_cert_path, _) = self.ensure_server_cert(advertised_names)?; let pem_str = std::fs::read_to_string(&server_cert_path) .with_context(|| format!("read server cert from {server_cert_path}"))?; let der = cert_pem_to_der(&pem_str).context("parse server cert PEM")?; @@ -384,6 +482,94 @@ impl CaManager { } } +/// Canonical SAN identifier kept inside the manager. +/// +/// `Dns` names are lower-cased; `Ip` values are normalized via [`std::net::IpAddr`] +/// so `10.10.0.7` and `10.10.000.7` collapse to the same identifier and IPv6 +/// literals compare in canonical form. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) enum SanIdent { + Dns(String), + Ip(std::net::IpAddr), +} + +impl std::fmt::Display for SanIdent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SanIdent::Dns(name) => f.write_str(name), + SanIdent::Ip(ip) => write!(f, "{ip}"), + } + } +} + +/// Build a deduped, canonical SAN set from the raw advertised names. +/// +/// Strings that parse as `IpAddr` become `SanIdent::Ip` (with canonical formatting); +/// everything else becomes `SanIdent::Dns` lower-cased. +pub(crate) fn build_san_set(advertised_names: &[&str]) -> anyhow::Result> { + let mut seen = std::collections::BTreeSet::new(); + let mut out = Vec::new(); + for raw in advertised_names { + let trimmed = raw.trim(); + if trimmed.is_empty() { + anyhow::bail!("advertised name cannot be empty"); + } + let ident = if let Ok(ip) = trimmed.parse::() { + SanIdent::Ip(ip) + } else { + SanIdent::Dns(trimmed.to_ascii_lowercase()) + }; + if seen.insert(ident.clone()) { + out.push(ident); + } + } + Ok(out) +} + +/// Build the rcgen `SanType` for a canonical SAN identifier. +fn san_entry(san: &SanIdent) -> anyhow::Result { + match san { + SanIdent::Dns(name) => Ok(SanType::DnsName(name.clone().try_into().context("DNS SAN")?)), + SanIdent::Ip(ip) => Ok(SanType::IpAddress(*ip)), + } +} + +/// Extract the SAN set from a parsed certificate as a canonical `Vec`. +fn extract_san_set(cert: &Cert) -> Vec { + let mut sans = Vec::new(); + for ext in cert.extensions() { + if let ExtensionView::SubjectAltName(names) = ext.extn_value() { + for name in &names.0 { + match name { + GeneralName::DnsName(dns) => sans.push(SanIdent::Dns(dns.as_utf8().to_ascii_lowercase())), + GeneralName::IpAddress(bytes) => { + // X.509 IP SAN encodes IPv4 as 4 bytes, IPv6 as 16 bytes. + if let Some(ip) = ip_from_san_bytes(bytes) { + sans.push(SanIdent::Ip(ip)); + } + } + _ => {} + } + } + } + } + sans +} + +fn ip_from_san_bytes(bytes: &[u8]) -> Option { + match bytes.len() { + 4 => { + let octets: [u8; 4] = bytes.try_into().ok()?; + Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(octets))) + } + 16 => { + let octets: [u8; 16] = bytes.try_into().ok()?; + Some(std::net::IpAddr::V6(std::net::Ipv6Addr::from(octets))) + } + _ => None, + } +} + /// SHA-256 hash of a DER certificate's Subject Public Key Info (hex string). pub fn spki_sha256_from_der(der_bytes: &[u8]) -> anyhow::Result { let cert = Cert::from_der(der_bytes).context("parse certificate for SPKI")?; @@ -445,21 +631,22 @@ pub fn extract_agent_id_from_der(der_bytes: &[u8]) -> anyhow::Result { // --------------------------------------------------------------------------- /// Why an existing server certificate cannot be reused. -#[derive(Debug)] +#[derive(Debug, Clone)] enum ServerCertStatus { - /// Certificate is valid and matches the configured hostname. + /// Certificate is valid and the SAN set matches the configured advertised names. Valid, /// Certificate or key file does not exist yet. NotFound, /// Certificate expires within 7 days. ExpiringSoon, - /// Certificate's DNS SAN does not match the configured hostname. - HostnameMismatch, + /// Certificate's SAN set does not match the configured advertised names. + /// The existing keypair can be reused; only the cert document is regenerated. + SanMismatch { on_disk_sans: Vec }, /// Certificate file is corrupt or unparseable. Unreadable, } -fn check_server_cert(cert_path: &Utf8Path, key_path: &Utf8Path, hostname: &str) -> ServerCertStatus { +fn check_server_cert(cert_path: &Utf8Path, key_path: &Utf8Path, expected_sans: &[SanIdent]) -> ServerCertStatus { if !cert_path.exists() || !key_path.exists() { return ServerCertStatus::NotFound; } @@ -483,20 +670,15 @@ fn check_server_cert(cert_path: &Utf8Path, key_path: &Utf8Path, hostname: &str) return ServerCertStatus::ExpiringSoon; } - // Hostname: reject if DNS SAN doesn't match the configured hostname. - let san_matches = cert.extensions().iter().any(|ext| { - if let ExtensionView::SubjectAltName(names) = ext.extn_value() { - names - .0 - .iter() - .any(|name| matches!(name, GeneralName::DnsName(h) if h.as_utf8() == hostname)) - } else { - false - } - }); - - if !san_matches { - return ServerCertStatus::HostnameMismatch; + // SAN set match: order-insensitive, canonical comparison. + let mut on_disk_sans = extract_san_set(&cert); + let mut on_disk_sorted = on_disk_sans.clone(); + on_disk_sorted.sort(); + let mut expected_sorted = expected_sans.to_vec(); + expected_sorted.sort(); + if on_disk_sorted != expected_sorted { + on_disk_sans.sort(); + return ServerCertStatus::SanMismatch { on_disk_sans }; } ServerCertStatus::Valid @@ -555,7 +737,7 @@ mod tests { // Server certificate. let (server_cert_path, server_key_path) = ca - .ensure_server_cert("test-gateway.local") + .ensure_server_cert(&["test-gateway.local"]) .expect("server cert should succeed"); assert!(server_cert_path.exists()); assert!(server_key_path.exists()); @@ -675,6 +857,198 @@ mod tests { assert!(msg.contains("no CERTIFICATE blocks"), "got: {msg}"); } + // --------------------------------------------------------------------- + // SAN regen idempotence — same SAN set must not rotate the keypair, a + // different SAN set must regenerate the cert but keep the keypair so the + // SPKI pin held by already-enrolled agents stays stable. + // --------------------------------------------------------------------- + + #[test] + fn ensure_server_cert_is_idempotent_when_san_set_matches() { + let temp_dir = std::env::temp_dir().join(format!("dgw-san-idem-{}", Uuid::new_v4())); + let data_dir = Utf8PathBuf::from_path_buf(temp_dir.clone()).expect("temp path UTF-8"); + let ca = CaManager::load_or_generate(&data_dir).expect("CA"); + + let names = ["gateway.corp.example.com", "10.10.0.7"]; + let (_cert_path, key_path) = ca.ensure_server_cert(&names).expect("first issue"); + + let key_pem_before = std::fs::read_to_string(&key_path).expect("read key after first issue"); + + // Re-running with the same set must be a no-op (same key, same cert content). + let (cert_path_2, key_path_2) = ca.ensure_server_cert(&names).expect("second issue"); + assert_eq!(cert_path_2, _cert_path); + assert_eq!(key_path_2, key_path); + + let key_pem_after = std::fs::read_to_string(&key_path_2).expect("read key after second issue"); + assert_eq!(key_pem_before, key_pem_after, "keypair must not rotate when SAN set unchanged"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn ensure_server_cert_regenerates_on_san_change_keeping_keypair() { + let temp_dir = std::env::temp_dir().join(format!("dgw-san-change-{}", Uuid::new_v4())); + let data_dir = Utf8PathBuf::from_path_buf(temp_dir.clone()).expect("temp path UTF-8"); + let ca = CaManager::load_or_generate(&data_dir).expect("CA"); + + let names_before = ["gateway.corp.example.com"]; + let (cert_path, key_path) = ca.ensure_server_cert(&names_before).expect("first issue"); + let key_pem_before = std::fs::read_to_string(&key_path).expect("read key"); + let cert_pem_before = std::fs::read_to_string(&cert_path).expect("read cert"); + + // Now configure a different SAN set: add an IP literal and a public DNS name. + let names_after = ["gateway.corp.example.com", "10.10.0.7", "agw.public.example.com"]; + ca.ensure_server_cert(&names_after).expect("regen with new SAN set"); + + let key_pem_after = std::fs::read_to_string(&key_path).expect("read key after regen"); + let cert_pem_after = std::fs::read_to_string(&cert_path).expect("read cert after regen"); + + assert_eq!( + key_pem_before, key_pem_after, + "keypair must be reused when only the SAN set changes — SPKI pin stays stable" + ); + assert_ne!( + cert_pem_before, cert_pem_after, + "cert document must be reissued when the SAN set changes" + ); + + // Confirm the new cert actually contains the new SANs in canonical form. + let der = cert_pem_to_der(&cert_pem_after).expect("parse new cert PEM"); + let cert = Cert::from_der(&der).expect("parse new cert DER"); + let mut sans = extract_san_set(&cert); + sans.sort(); + let mut expected = build_san_set(&names_after).expect("build expected SAN set"); + expected.sort(); + assert_eq!(sans, expected); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn build_san_set_normalizes_and_dedups() { + // Mixed case DNS, alternate IP formatting, duplicate entries. + let names = [ + "Gateway.Corp.Example.com", + "gateway.corp.example.com", + "10.10.0.7", + "fd00::7", + "fd00::0007", // same IPv6 in alternate form + ]; + let sans = build_san_set(&names).expect("build SAN set"); + + // Expected canonical: lowered DNS, canonical IP strings, deduped. + let expected: Vec = vec![ + SanIdent::Dns("gateway.corp.example.com".to_owned()), + SanIdent::Ip("10.10.0.7".parse().unwrap()), + SanIdent::Ip("fd00::7".parse().unwrap()), + ]; + assert_eq!(sans, expected); + } + + #[test] + fn build_san_set_rejects_empty_entry() { + let err = build_san_set(&[" "]).expect_err("empty advertised name should fail"); + let msg = format!("{err:#}"); + assert!(msg.contains("empty"), "got: {msg}"); + } + + #[test] + fn ensure_server_cert_regenerates_key_atomically_when_cert_expiring_soon() { + // Regression for the cert/key mismatch bug: when the cert is expiring + // soon (or unreadable / missing) but a stale key file is still on disk + // from a previous run, the regeneration path used to sign the new cert + // with a freshly generated keypair while skipping the key write — + // leaving cert and key out of sync. The fix tracks key provenance and + // always persists a freshly generated key BEFORE writing the cert. + // + // This test backdates the cert's validity by overwriting cert+key on + // disk with a deliberately near-expiry pair, then calls + // `ensure_server_cert` and asserts the on-disk key is the one inside + // the new cert (i.e. they match SPKI-wise). + + let temp_dir = std::env::temp_dir().join(format!("dgw-expiring-{}", Uuid::new_v4())); + let data_dir = Utf8PathBuf::from_path_buf(temp_dir.clone()).expect("temp path UTF-8"); + let ca = CaManager::load_or_generate(&data_dir).expect("CA"); + + let names = ["gateway.corp.example.com"]; + let (cert_path, key_path) = ca.ensure_server_cert(&names).expect("first issue"); + + // Mint a replacement (cert, key) pair where the cert is already + // expiring (within the 7-day window) but the key on disk is a stale + // keypair unrelated to whatever the regen path will generate. The + // point: force the code into the `ExpiringSoon` branch with a key + // file present on disk, and verify regeneration writes a matching key. + let stale_key = + KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("stale keypair"); + let mut expiring_params = CertificateParams::default(); + expiring_params + .distinguished_name + .push(DnType::CommonName, names[0]); + expiring_params + .subject_alt_names + .push(SanType::DnsName(names[0].try_into().unwrap())); + expiring_params.not_before = time::OffsetDateTime::now_utc() - Duration::from_secs(60 * SECS_PER_DAY); + // Inside the 7-day "ExpiringSoon" threshold. + expiring_params.not_after = time::OffsetDateTime::now_utc() + Duration::from_secs(SECS_PER_DAY); + let ca_cert_for_sign = ca.reconstruct_ca_cert().expect("reconstruct CA"); + let expiring_cert = expiring_params + .signed_by(&stale_key, &ca_cert_for_sign, &ca.ca_key_pair) + .expect("sign expiring cert"); + std::fs::write(&cert_path, expiring_cert.pem()).expect("overwrite cert with expiring one"); + std::fs::write(&key_path, stale_key.serialize_pem()).expect("overwrite key with stale one"); + + // Sanity: confirm the status check classifies this as ExpiringSoon. + let expected_sans = build_san_set(&names).expect("expected sans"); + let status = check_server_cert(&cert_path, &key_path, &expected_sans); + assert!( + matches!(status, ServerCertStatus::ExpiringSoon), + "precondition: expected ExpiringSoon, got {status:?}" + ); + + // Trigger regeneration. + ca.ensure_server_cert(&names).expect("regen after expiring"); + + // The on-disk key must match the public key embedded in the new cert. + let new_cert_pem = std::fs::read_to_string(&cert_path).expect("read regenerated cert"); + let new_key_pem = std::fs::read_to_string(&key_path).expect("read regenerated key"); + + let new_cert_der = cert_pem_to_der(&new_cert_pem).expect("parse new cert PEM"); + let cert_spki = spki_sha256_from_der(&new_cert_der).expect("cert SPKI"); + + // Build the SPKI of the persisted key by self-signing a throwaway cert + // with it (rcgen doesn't expose the SPKI of a KeyPair directly). + let persisted_key = KeyPair::from_pem(&new_key_pem).expect("parse persisted key"); + let mut probe_params = CertificateParams::default(); + probe_params + .distinguished_name + .push(DnType::CommonName, "probe"); + let probe_cert = probe_params.self_signed(&persisted_key).expect("self-sign probe"); + let probe_der = cert_pem_to_der(&probe_cert.pem()).expect("probe DER"); + let probe_spki = spki_sha256_from_der(&probe_der).expect("probe SPKI"); + + assert_eq!( + cert_spki, probe_spki, + "on-disk key must match the public key embedded in the regenerated cert" + ); + + // And confirm the key is no longer the stale one we wrote earlier. + let mut stale_probe_params = CertificateParams::default(); + stale_probe_params + .distinguished_name + .push(DnType::CommonName, "stale-probe"); + let stale_probe_cert = stale_probe_params + .self_signed(&stale_key) + .expect("self-sign stale probe"); + let stale_probe_der = cert_pem_to_der(&stale_probe_cert.pem()).expect("stale probe DER"); + let stale_spki = spki_sha256_from_der(&stale_probe_der).expect("stale SPKI"); + assert_ne!( + cert_spki, stale_spki, + "regenerated cert must NOT use the stale on-disk key" + ); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + #[test] fn read_cert_chain_rejects_wrong_label() { let (_, leaf_pem) = make_cert_pair(); diff --git a/crates/agent-tunnel/src/listener.rs b/crates/agent-tunnel/src/listener.rs index 820ac6f49..f49c92976 100644 --- a/crates/agent-tunnel/src/listener.rs +++ b/crates/agent-tunnel/src/listener.rs @@ -112,10 +112,10 @@ impl AgentTunnelListener { pub async fn bind( listen_addr: SocketAddr, ca_manager: Arc, - hostname: &str, + advertised_names: &[&str], ) -> anyhow::Result<(Self, AgentTunnelHandle)> { let tls_config = ca_manager - .build_server_tls_config(hostname) + .build_server_tls_config(advertised_names) .context("build server TLS config")?; let quic_server_config = quinn::crypto::rustls::QuicServerConfig::try_from(Arc::new(tls_config)) diff --git a/devolutions-gateway/src/api/diagnostics.rs b/devolutions-gateway/src/api/diagnostics.rs index 2bc44a273..77e4be0fd 100644 --- a/devolutions-gateway/src/api/diagnostics.rs +++ b/devolutions-gateway/src/api/diagnostics.rs @@ -32,6 +32,36 @@ pub(crate) struct ConfigDiagnostic { version: &'static str, /// Listeners configured on this instance listeners: Vec, + /// Agent tunnel configuration summary (`null` when feature is disabled or absent in config). + #[serde(skip_serializing_if = "Option::is_none")] + agent_tunnel: Option, +} + +/// Agent tunnel diagnostic surface. +/// +/// DVLS reads this when building the "Generate enrollment string" dropdown so +/// admins pick a name the Gateway is actually advertising. Same auth scope as +/// the rest of `/jet/diagnostics/configuration`. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Serialize)] +pub(crate) struct AgentTunnelDiagnostic { + /// Whether the agent tunnel listener is enabled. + enabled: bool, + /// UDP port the agent tunnel QUIC listener is bound to. + listen_port: u16, + /// Names or IPs this Gateway is reachable as for agent tunnel enrollment. + advertised_names: Vec, +} + +/// One advertised name entry, with an optional display label for DVLS UI. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Serialize)] +pub(crate) struct AdvertisedNameDiagnostic { + /// Host or IP literal (canonical form). + name: String, + /// Optional display label. + #[serde(skip_serializing_if = "Option::is_none")] + label: Option, } impl From<&Conf> for ConfigDiagnostic { @@ -75,11 +105,30 @@ impl From<&Conf> for ConfigDiagnostic { } } + let agent_tunnel = if conf.agent_tunnel.enabled { + Some(AgentTunnelDiagnostic { + enabled: conf.agent_tunnel.enabled, + listen_port: conf.agent_tunnel.listen_port, + advertised_names: conf + .agent_tunnel + .advertised_names + .iter() + .map(|n| AdvertisedNameDiagnostic { + name: n.name().to_owned(), + label: n.label().map(str::to_owned), + }) + .collect(), + }) + } else { + None + }; + ConfigDiagnostic { id: conf.id, listeners, version: env!("CARGO_PKG_VERSION"), hostname: conf.hostname.clone(), + agent_tunnel, } } } diff --git a/devolutions-gateway/src/api/tunnel.rs b/devolutions-gateway/src/api/tunnel.rs index fee203c58..6c47caa25 100644 --- a/devolutions-gateway/src/api/tunnel.rs +++ b/devolutions-gateway/src/api/tunnel.rs @@ -1,5 +1,6 @@ use axum::extract::{Path, State}; -use axum::http::HeaderMap; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -8,40 +9,68 @@ use crate::DgwState; use crate::extract::{AgentManagementReadAccess, AgentManagementWriteAccess}; use crate::http::HttpError; -/// Validate a Bearer token as an enrollment JWT signed by the provisioner key. +/// Validate the enrollment JWT and return its decoded claims on success. /// -/// Returns `true` if the token is a well-formed JWT whose signature verifies -/// against `provisioner_key`, whose `exp` has not passed, and whose `scope` -/// is `AgentEnroll` (or `Wildcard`). Returns `false` for any failure. -/// -/// The enrollment JWT carries extra claims (`jet_gw_url`, `jet_agent_name`) -/// that the *agent* reads locally from its own copy of the token — the Gateway -/// does not consume them here, it only authenticates the bearer. -fn validate_enrollment_jwt(token: &str, provisioner_key: &picky::key::PublicKey) -> bool { +/// Same validation rules as [`validate_enrollment_jwt`]; on failure returns +/// `None`. The caller does not need to distinguish between failure modes — +/// the unauthenticated request is rejected at the HTTP layer with a generic +/// "invalid enrollment token" message regardless. +fn validate_enrollment_jwt_claims( + token: &str, + provisioner_key: &picky::key::PublicKey, +) -> Option { use picky::jose::jws::RawJws; use picky::jose::jwt::{JwtDate, JwtSig, JwtValidator}; use crate::token::{AccessScope, EnrollmentTokenClaims}; - let Ok(raw_jws) = RawJws::decode(token) else { - return false; - }; - - let Ok(jwt) = raw_jws.verify(provisioner_key).map(JwtSig::from) else { - return false; - }; + let raw_jws = RawJws::decode(token).ok()?; + let jwt = raw_jws.verify(provisioner_key).map(JwtSig::from).ok()?; let now = JwtDate::new_with_leeway(time::OffsetDateTime::now_utc().unix_timestamp(), 60); let validator = JwtValidator::strict(now); - let Ok(validated) = jwt.validate::(&validator) else { - return false; - }; + let validated = jwt.validate::(&validator).ok()?; - matches!( + if !matches!( validated.state.claims.scope, AccessScope::AgentEnroll | AccessScope::Wildcard - ) + ) { + return None; + } + + Some(validated.state.claims) +} + +/// Canonicalize the host portion of `jet_gw_url` for comparison against +/// `AgentTunnel.AdvertisedNames`. +/// +/// - IP literals (IPv4 or IPv6) are parsed via `std::net::IpAddr` so +/// alternate textual forms collapse to the canonical one. +/// - IPv6 brackets (e.g. `[fd00::7]`) are stripped before parsing — `url::Url` +/// surfaces IPv6 hosts with brackets already included. +/// - DNS names are lower-cased (DNS is case-insensitive). +fn normalize_host(host: &str) -> String { + let trimmed = host.trim(); + // Strip surrounding brackets for IPv6 literals before parsing. + let unbracketed = trimmed + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(trimmed); + if let Ok(ip) = unbracketed.parse::() { + ip.to_string() + } else { + trimmed.to_ascii_lowercase() + } +} + +/// Parse `jet_gw_url` and return the normalized host portion. +/// +/// Returns `None` when the URL is unparseable or has no host component. +fn enrollment_host(jet_gw_url: &str) -> Option { + let url = url::Url::parse(jet_gw_url).ok()?; + let host = url.host_str()?; + Some(normalize_host(host)) } #[derive(Deserialize)] @@ -66,12 +95,59 @@ pub struct EnrollResponse { /// PEM-encoded gateway CA certificate (for server verification). pub gateway_ca_cert_pem: String, /// QUIC endpoint to connect to (`host:port`). + /// + /// Computed from the enrollment URL host (the host the agent actually used) + /// plus the agent tunnel listen port. Kept for backward compatibility with + /// older agents; new agents should prefer `quic_port` plus the host they + /// already enrolled through. pub quic_endpoint: String, + /// UDP port the agent tunnel QUIC listener is bound to. + /// + /// New field (compat bridge in the enrollment response). New agents should + /// dial `(enrollment URL host, quic_port)` rather than parsing `quic_endpoint`. + pub quic_port: u16, /// SHA-256 hash of the server certificate's SPKI (hex-encoded). /// Used by the agent to pin the server's public key. pub server_spki_sha256: String, } +/// Structured 400 body returned when the enrollment URL host is not in +/// `AgentTunnel.AdvertisedNames`. +/// +/// The agent CLI propagates this verbatim to stderr so the installer dialog +/// and Windows event log can surface the operator-facing help text. +#[derive(Serialize)] +struct EnrollmentRejection { + /// Stable identifier for this error class. Always + /// `"enrollment_host_not_advertised"` for this rejection. + error: &'static str, + /// One-sentence description of what is wrong. + message: String, + /// One paragraph telling the operator how to recover. + help: String, +} + +impl EnrollmentRejection { + fn host_not_advertised(jwt_host: &str, allowed: &[String]) -> Self { + let allowed_repr = serde_json::to_string(allowed).unwrap_or_else(|_| "[]".to_owned()); + Self { + error: "enrollment_host_not_advertised", + message: format!( + "The Gateway is not advertised as '{jwt_host}'. Allowed advertised names: {allowed_repr}.", + ), + help: format!( + "Either (a) regenerate the enrollment string in DVLS using one of the names listed above, \ + or (b) ask the Gateway operator to add '{jwt_host}' to AgentTunnel.AdvertisedNames in \ + gateway.json and restart the Gateway." + ), + } + } + + fn into_response(self) -> Response { + (StatusCode::BAD_REQUEST, Json(self)).into_response() + } +} + pub fn make_router(state: DgwState) -> Router { Router::new() .route("/enroll", axum::routing::post(enroll_agent)) @@ -100,10 +176,10 @@ async fn enroll_agent( csr_pem, agent_hostname, }): Json, -) -> Result, HttpError> { +) -> Result, Response> { // Validate agent name: 1-255 printable ASCII characters. if agent_name.is_empty() || 255 < agent_name.len() || agent_name.bytes().any(|b| !(0x20..=0x7E).contains(&b)) { - return Err(HttpError::bad_request().msg("agent name must be 1-255 printable ASCII characters")); + return Err(http_err(HttpError::bad_request().msg("agent name must be 1-255 printable ASCII characters"))); } let conf = conf_handle.get_conf(); @@ -112,42 +188,78 @@ async fn enroll_agent( let auth_header = headers .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) - .ok_or_else(|| HttpError::unauthorized().msg("missing Authorization header"))?; + .ok_or_else(|| http_err(HttpError::unauthorized().msg("missing Authorization header")))?; let provided_token = auth_header .strip_prefix("Bearer ") - .ok_or_else(|| HttpError::unauthorized().msg("expected Bearer token"))?; + .ok_or_else(|| http_err(HttpError::unauthorized().msg("expected Bearer token")))?; let handle = agent_tunnel_handle .as_ref() - .ok_or_else(|| HttpError::not_found().msg("agent enrollment is not configured"))?; - - if !validate_enrollment_jwt(provided_token, &conf.provisioner_public_key) { - return Err(HttpError::forbidden().msg("invalid enrollment token")); + .ok_or_else(|| http_err(HttpError::not_found().msg("agent enrollment is not configured")))?; + + let claims = validate_enrollment_jwt_claims(provided_token, &conf.provisioner_public_key) + .ok_or_else(|| http_err(HttpError::forbidden().msg("invalid enrollment token")))?; + + // Parse the URL the agent enrolled through. The agent will dial QUIC at + // this host:listen_port pair, so the gateway must (a) have a server cert + // SAN matching this host and (b) explicitly advertise it as a valid name. + let jwt_host = enrollment_host(&claims.jet_gw_url).ok_or_else(|| { + http_err(HttpError::bad_request().msg("enrollment JWT jet_gw_url is missing or has no host component")) + })?; + + // Build the canonical normalized list of advertised names. + let advertised: Vec = conf + .agent_tunnel + .advertised_names + .iter() + .map(|n| normalize_host(n.name())) + .collect(); + + if !advertised.iter().any(|name| name == &jwt_host) { + warn!( + jwt_host = %jwt_host, + ?advertised, + %agent_id, + "Rejecting enrollment: jet_gw_url host is not in AgentTunnel.AdvertisedNames", + ); + return Err(EnrollmentRejection::host_not_advertised(&jwt_host, &advertised).into_response()); } // Reject duplicate agent IDs to prevent identity shadowing. if handle.registry().get(&agent_id).await.is_some() { - return Err( - crate::http::HttpErrorBuilder::new(axum::http::StatusCode::CONFLICT).msg("agent ID already registered") - ); + return Err(http_err( + crate::http::HttpErrorBuilder::new(StatusCode::CONFLICT).msg("agent ID already registered"), + )); } let signed = handle .ca_manager() .sign_agent_csr(agent_id, &agent_name, &csr_pem, agent_hostname.as_deref()) - .map_err(HttpError::bad_request().with_msg("invalid CSR").err())?; + .map_err(|e| http_err(HttpError::bad_request().with_msg("invalid CSR").build(e)))?; + + // Compute `quic_endpoint` from the host the agent already used to reach the + // gateway, NOT from `conf.hostname` — that was the old footgun. + let listen_port = conf.agent_tunnel.listen_port; + let quic_endpoint = format_endpoint(&jwt_host, listen_port); - let quic_endpoint = format!("{}:{}", conf.hostname, conf.agent_tunnel.listen_port); + let advertised_names: Vec<&str> = conf + .agent_tunnel + .advertised_names + .iter() + .map(|n| n.name()) + .collect(); let server_spki_sha256 = handle .ca_manager() - .server_spki_sha256(&conf.hostname) - .map_err(HttpError::internal().with_msg("compute server SPKI").err())?; + .server_spki_sha256(&advertised_names) + .map_err(|e| http_err(HttpError::internal().with_msg("compute server SPKI").build(e)))?; info!( %agent_id, agent_name = %agent_name, + %jwt_host, + quic_port = listen_port, "Agent enrolled successfully", ); @@ -156,10 +268,29 @@ async fn enroll_agent( client_cert_pem: signed.client_cert_pem, gateway_ca_cert_pem: signed.ca_cert_pem, quic_endpoint, + quic_port: listen_port, server_spki_sha256, })) } +/// Wrap an `HttpError` as an Axum `Response` so the enrollment handler can +/// also return structured 400 bodies for host validation failures. +fn http_err(error: HttpError) -> Response { + error.into_response() +} + +/// Format a `host:port` endpoint with proper bracketing for IPv6 literals. +/// +/// Kept here for the gateway's `quic_endpoint` compatibility field. The agent +/// uses an equivalent helper on its side. +fn format_endpoint(host: &str, port: u16) -> String { + if host.parse::().is_ok() { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + /// List connected agents and their status. async fn list_agents( State(DgwState { @@ -204,7 +335,7 @@ async fn delete_agent( }): State, _access: AgentManagementWriteAccess, Path(agent_id): Path, -) -> Result { +) -> Result { let handle = agent_tunnel_handle .as_ref() .ok_or_else(|| HttpError::not_found().msg("agent tunnel not configured"))?; @@ -217,7 +348,7 @@ async fn delete_agent( info!(%agent_id, "Agent deleted via API"); - Ok(axum::http::StatusCode::NO_CONTENT) + Ok(StatusCode::NO_CONTENT) } #[cfg(test)] @@ -228,7 +359,12 @@ mod tests { use serde_json::json; use uuid::Uuid; - use super::validate_enrollment_jwt; + use super::validate_enrollment_jwt_claims; + + /// Thin wrapper preserving the old assertion ergonomics. + fn validate_enrollment_jwt(token: &str, key: &PublicKey) -> bool { + validate_enrollment_jwt_claims(token, key).is_some() + } fn keypair() -> (PrivateKey, PublicKey) { let private_key = PrivateKey::generate_rsa(2048).expect("generate RSA private key"); @@ -356,4 +492,126 @@ mod tests { assert!(!validate_enrollment_jwt("", &pub_key)); assert!(!validate_enrollment_jwt("only.two", &pub_key)); } + + // ---- Host normalization & enrollment URL parsing ------------------------- + + #[test] + fn normalize_host_lowercases_dns() { + assert_eq!(super::normalize_host("Gateway.Example.COM"), "gateway.example.com"); + assert_eq!(super::normalize_host(" HOST "), "host"); + } + + #[test] + fn normalize_host_canonicalizes_ipv4() { + // Different textual forms of 10.10.0.7 collapse onto canonical form via IpAddr::parse. + assert_eq!(super::normalize_host("10.10.0.7"), "10.10.0.7"); + } + + #[test] + fn normalize_host_canonicalizes_ipv6() { + // Verbose IPv6 collapses to the canonical compressed form. + assert_eq!(super::normalize_host("fd00:0000:0000:0000:0000:0000:0000:0007"), "fd00::7"); + assert_eq!(super::normalize_host("FD00::7"), "fd00::7"); + } + + #[test] + fn enrollment_host_extracts_normalized_host() { + assert_eq!( + super::enrollment_host("https://Gateway.Example.COM:7171").as_deref(), + Some("gateway.example.com"), + ); + assert_eq!(super::enrollment_host("http://10.10.0.7:7777").as_deref(), Some("10.10.0.7")); + // url::Url surfaces IPv6 hosts without brackets. + assert_eq!(super::enrollment_host("https://[fd00::7]:7171").as_deref(), Some("fd00::7")); + } + + #[test] + fn enrollment_host_rejects_no_host() { + // No scheme means url::Url cannot parse this as an absolute URL. + assert!(super::enrollment_host("not-a-url").is_none()); + } + + // ---- format_endpoint with IPv6 bracketing -------------------------------- + + #[test] + fn format_endpoint_dns() { + assert_eq!(super::format_endpoint("gateway.example.com", 4433), "gateway.example.com:4433"); + } + + #[test] + fn format_endpoint_ipv4() { + assert_eq!(super::format_endpoint("10.10.0.7", 4433), "10.10.0.7:4433"); + } + + #[test] + fn format_endpoint_ipv6_is_bracketed() { + assert_eq!(super::format_endpoint("fd00::7", 4433), "[fd00::7]:4433"); + } + + // ---- AdvertisedName serde ------------------------------------------------ + + #[test] + fn advertised_name_deserializes_bare_string() { + let value: crate::config::dto::AdvertisedName = + serde_json::from_str("\"gateway.corp.example.com\"").expect("parse bare"); + assert_eq!(value.name(), "gateway.corp.example.com"); + assert_eq!(value.label(), None); + } + + #[test] + fn advertised_name_deserializes_labeled_object() { + let value: crate::config::dto::AdvertisedName = + serde_json::from_str(r#"{ "Name": "10.10.0.7", "Label": "Customer LAN" }"#).expect("parse labeled"); + assert_eq!(value.name(), "10.10.0.7"); + assert_eq!(value.label(), Some("Customer LAN")); + } + + #[test] + fn advertised_name_accepts_lowercase_keys_too() { + let value: crate::config::dto::AdvertisedName = + serde_json::from_str(r#"{ "name": "host", "label": "lab" }"#).expect("parse lowercase keys"); + assert_eq!(value.name(), "host"); + assert_eq!(value.label(), Some("lab")); + } + + #[test] + fn advertised_name_roundtrips_bare_form_when_no_label() { + let value = crate::config::dto::AdvertisedName::Bare("gateway.corp.example.com".to_owned()); + let json = serde_json::to_string(&value).expect("serialize bare"); + assert_eq!(json, "\"gateway.corp.example.com\""); + } + + // ---- EnrollmentRejection JSON body shape -------------------------------- + + #[test] + fn enrollment_rejection_body_carries_error_message_help_triple() { + let rejection = super::EnrollmentRejection::host_not_advertised( + "evil.example.com", + &["gateway.corp.example.com".to_owned(), "10.10.0.7".to_owned()], + ); + let json = serde_json::to_value(&rejection).expect("serialize rejection"); + assert_eq!(json["error"], serde_json::json!("enrollment_host_not_advertised")); + let message = json["message"].as_str().expect("message string"); + assert!(message.contains("evil.example.com"), "{message}"); + assert!(message.contains("gateway.corp.example.com"), "{message}"); + assert!(message.contains("10.10.0.7"), "{message}"); + let help = json["help"].as_str().expect("help string"); + assert!(help.contains("AdvertisedNames"), "{help}"); + assert!(help.contains("regenerate the enrollment string"), "{help}"); + } + + // ---- AdvertisedName serde, continued ------------------------------------ + + #[test] + fn advertised_name_serializes_labeled_as_object() { + let value = crate::config::dto::AdvertisedName::Labeled { + name: "10.10.0.7".to_owned(), + label: Some("Customer LAN".to_owned()), + }; + let json = serde_json::to_string(&value).expect("serialize labeled"); + // Object with both Name + Label. + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["Name"], serde_json::json!("10.10.0.7")); + assert_eq!(parsed["Label"], serde_json::json!("Customer LAN")); + } } diff --git a/devolutions-gateway/src/config.rs b/devolutions-gateway/src/config.rs index adadbd2ce..7d51a3505 100644 --- a/devolutions-gateway/src/config.rs +++ b/devolutions-gateway/src/config.rs @@ -897,6 +897,18 @@ impl Conf { ); } + // Build the migrated agent_tunnel config before moving `hostname` into the result. + let agent_tunnel = { + let mut agent_tunnel = conf_file.agent_tunnel.clone().unwrap_or_default(); + // Migration shim: if `AdvertisedNames` is not configured, fall back + // to the legacy single-hostname behaviour by advertising the + // Gateway's `hostname`. + if agent_tunnel.advertised_names.is_empty() { + agent_tunnel.advertised_names = vec![dto::AdvertisedName::Bare(hostname.clone())]; + } + agent_tunnel + }; + Ok(Conf { id: conf_file.id, hostname, @@ -928,7 +940,7 @@ impl Conf { .as_ref() .map(AiGatewayConf::from_dto) .unwrap_or_default(), - agent_tunnel: conf_file.agent_tunnel.clone().unwrap_or_default(), + agent_tunnel, proxy: conf_file.proxy.clone().unwrap_or_default(), debug: conf_file.debug.clone().unwrap_or_default(), }) @@ -1941,6 +1953,14 @@ pub mod dto { /// UDP port for the QUIC listener (default: 4433) #[serde(default = "AgentTunnelConf::default_listen_port")] pub listen_port: u16, + /// Names or IPs this Gateway is reachable as for the agent tunnel. + /// + /// Each entry is either a bare string or `{ "name": "...", "label": "..." }`. + /// All entries are added to the agent tunnel server certificate's SAN list, + /// and only enrollment requests whose `jet_gw_url` host matches one of them + /// are accepted. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub advertised_names: Vec, } impl AgentTunnelConf { @@ -1954,6 +1974,69 @@ pub mod dto { Self { enabled: false, listen_port: Self::default_listen_port(), + advertised_names: Vec::new(), + } + } + } + + /// An advertised name (FQDN or IP literal) under which the agent tunnel + /// Gateway accepts enrollments and which is included in the server cert SAN. + /// + /// Accepts two equivalent JSON shapes via `#[serde(untagged)]`: + /// - A bare string: `"gateway.corp.example.com"` + /// - An object with an optional display label: + /// `{ "name": "gateway.corp.example.com", "label": "HQ FQDN" }` + /// + /// The label is purely informational and surfaced by DVLS UI. The Gateway + /// itself only uses `name` for SAN generation and host validation. + #[derive(PartialEq, Eq, Debug, Clone, Deserialize)] + #[serde(untagged)] + pub enum AdvertisedName { + /// Bare string form: just a host or IP literal. + Bare(String), + /// Object form with a name and an optional display label. + Labeled { + #[serde(rename = "Name", alias = "name")] + name: String, + #[serde(rename = "Label", alias = "label", default, skip_serializing_if = "Option::is_none")] + label: Option, + }, + } + + impl AdvertisedName { + /// Return the name portion (host or IP literal) for SAN/host matching. + pub fn name(&self) -> &str { + match self { + AdvertisedName::Bare(name) => name.as_str(), + AdvertisedName::Labeled { name, .. } => name.as_str(), + } + } + + /// Return the optional display label (informational only). + pub fn label(&self) -> Option<&str> { + match self { + AdvertisedName::Bare(_) => None, + AdvertisedName::Labeled { label, .. } => label.as_deref(), + } + } + } + + /// Serialize as a bare string when no label is set, otherwise as an object. + impl serde::Serialize for AdvertisedName { + fn serialize(&self, serializer: S) -> Result { + match self { + AdvertisedName::Bare(name) => serializer.serialize_str(name), + AdvertisedName::Labeled { name, label: None } => serializer.serialize_str(name), + AdvertisedName::Labeled { + name, + label: Some(label), + } => { + use serde::ser::SerializeMap as _; + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("Name", name)?; + map.serialize_entry("Label", label)?; + map.end() + } } } } diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 5ec30f40e..585bebf65 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -46,6 +46,8 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; PubKeyFormat, Subscriber, crate::api::diagnostics::ConfigDiagnostic, + crate::api::diagnostics::AgentTunnelDiagnostic, + crate::api::diagnostics::AdvertisedNameDiagnostic, crate::api::diagnostics::ClockDiagnostic, SubProvisionerKey, ConfigPatch, diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index e847e1d94..b8d13d7bd 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -277,7 +277,12 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { // Initialize agent tunnel if configured. let agent_tunnel_handle = if conf.agent_tunnel.enabled { let data_dir = config::get_data_dir(); - let hostname = &conf.hostname; + let advertised_names: Vec<&str> = conf + .agent_tunnel + .advertised_names + .iter() + .map(|n| n.name()) + .collect(); let ca_manager = agent_tunnel::cert::CaManager::load_or_generate(&data_dir) .context("failed to initialize agent tunnel CA")?; @@ -290,7 +295,7 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { let listen_addr = std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, conf.agent_tunnel.listen_port)); let (listener, handle) = - agent_tunnel::AgentTunnelListener::bind(listen_addr, Arc::clone(&ca_manager), hostname) + agent_tunnel::AgentTunnelListener::bind(listen_addr, Arc::clone(&ca_manager), &advertised_names) .await .context("failed to bind agent tunnel listener")?;