Skip to content

Commit f16f14a

Browse files
feat(dgw): decouple agent tunnel SAN from network endpoint
Splits the Gateway's cryptographic identity (the SAN written to the agent tunnel server certificate) from its network reachability (the host the agent dials). A single `conf.hostname` was previously overloaded as both, which broke deployments where the gateway is reachable by several names depending on the agent's network position (internal FQDN, IP literal, public DNS). `AgentTunnel.AdvertisedNames` is the authoritative list of names/IPs the gateway accepts enrollments through, and is the SAN set written into `agent-tunnel-server-cert.pem`. Each entry is either a bare string or `{ "Name": "...", "Label": "..." }` for DVLS UI grouping. When absent, the config defaults to `[conf.hostname]` so existing deployments are unaffected. At gateway boot the server cert is regenerated whenever the on-disk SAN set differs from the configured advertised names. The existing server keypair is reused so the SPKI pin held by already-enrolled agents stays stable; only the cert document is reissued. The new cert fingerprint is logged. `/jet/tunnel/enroll` now parses the JWT's `jet_gw_url`, normalises the host (DNS lowercased, IP literals canonicalised), and rejects with HTTP 400 + `{ error, message, help }` body when the host is not in `AgentTunnel.AdvertisedNames`. The enrollment response now carries both `quic_endpoint` (legacy, computed from the validated JWT host and the listen port) and `quic_port` (new); a follow-up release will drop `quic_endpoint`. `/jet/diagnostics/configuration` now exposes an `agent_tunnel` field with `enabled`, `listen_port`, and the advertised name list (with optional labels). DVLS reads this to build the "Generate enrollment string" dropdown. Issue: DGW-Agent-Tunnel-Identity
1 parent 45de0e6 commit f16f14a

7 files changed

Lines changed: 706 additions & 89 deletions

File tree

crates/agent-tunnel/src/cert.rs

Lines changed: 264 additions & 44 deletions
Large diffs are not rendered by default.

crates/agent-tunnel/src/listener.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,10 @@ impl AgentTunnelListener {
112112
pub async fn bind(
113113
listen_addr: SocketAddr,
114114
ca_manager: Arc<CaManager>,
115-
hostname: &str,
115+
advertised_names: &[&str],
116116
) -> anyhow::Result<(Self, AgentTunnelHandle)> {
117117
let tls_config = ca_manager
118-
.build_server_tls_config(hostname)
118+
.build_server_tls_config(advertised_names)
119119
.context("build server TLS config")?;
120120

121121
let quic_server_config = quinn::crypto::rustls::QuicServerConfig::try_from(Arc::new(tls_config))

devolutions-gateway/src/api/diagnostics.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,36 @@ pub(crate) struct ConfigDiagnostic {
3232
version: &'static str,
3333
/// Listeners configured on this instance
3434
listeners: Vec<ListenerUrls>,
35+
/// Agent tunnel configuration summary (`null` when feature is disabled or absent in config).
36+
#[serde(skip_serializing_if = "Option::is_none")]
37+
agent_tunnel: Option<AgentTunnelDiagnostic>,
38+
}
39+
40+
/// Agent tunnel diagnostic surface.
41+
///
42+
/// DVLS reads this when building the "Generate enrollment string" dropdown so
43+
/// admins pick a name the Gateway is actually advertising. Same auth scope as
44+
/// the rest of `/jet/diagnostics/configuration`.
45+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
46+
#[derive(Serialize)]
47+
pub(crate) struct AgentTunnelDiagnostic {
48+
/// Whether the agent tunnel listener is enabled.
49+
enabled: bool,
50+
/// UDP port the agent tunnel QUIC listener is bound to.
51+
listen_port: u16,
52+
/// Names or IPs this Gateway is reachable as for agent tunnel enrollment.
53+
advertised_names: Vec<AdvertisedNameDiagnostic>,
54+
}
55+
56+
/// One advertised name entry, with an optional display label for DVLS UI.
57+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
58+
#[derive(Serialize)]
59+
pub(crate) struct AdvertisedNameDiagnostic {
60+
/// Host or IP literal (canonical form).
61+
name: String,
62+
/// Optional display label.
63+
#[serde(skip_serializing_if = "Option::is_none")]
64+
label: Option<String>,
3565
}
3666

3767
impl From<&Conf> for ConfigDiagnostic {
@@ -75,11 +105,30 @@ impl From<&Conf> for ConfigDiagnostic {
75105
}
76106
}
77107

108+
let agent_tunnel = if conf.agent_tunnel.enabled {
109+
Some(AgentTunnelDiagnostic {
110+
enabled: conf.agent_tunnel.enabled,
111+
listen_port: conf.agent_tunnel.listen_port,
112+
advertised_names: conf
113+
.agent_tunnel
114+
.advertised_names
115+
.iter()
116+
.map(|n| AdvertisedNameDiagnostic {
117+
name: n.name().to_owned(),
118+
label: n.label().map(str::to_owned),
119+
})
120+
.collect(),
121+
})
122+
} else {
123+
None
124+
};
125+
78126
ConfigDiagnostic {
79127
id: conf.id,
80128
listeners,
81129
version: env!("CARGO_PKG_VERSION"),
82130
hostname: conf.hostname.clone(),
131+
agent_tunnel,
83132
}
84133
}
85134
}

0 commit comments

Comments
 (0)