Design proposal for decoupling Gateway's cryptographic identity (server cert SAN) from its network reachability (the endpoint agents dial).
Audience: gateway/agent/installer maintainers + DVLS-side maintainers.
The PR (#1789) introduces an agent tunnel where:
- DVLS mints an enrollment JWT with
jet_gw_urlclaim - Agent POSTs CSR to
<jet_gw_url>/jet/tunnel/enroll - Gateway signs the CSR with its internal
agent-tunnel-ca, returns:client_cert_pem,gateway_ca_cert_pem,server_spki_sha256quic_endpoint:format!("{}:{}", conf.hostname, conf.agent_tunnel.listen_port)
- Agent stores everything in
agent.jsonand connects QUIC to that endpoint
A single conf.hostname field on the gateway side is overloaded as both:
- Cryptographic identity — the SAN written into
agent-tunnel-server-cert.pem - Network advertisement — the hostname returned to agents as their dial target
These two responsibilities are coupled in code but uncoupled in reality.
In any realistic deployment a single gateway is reachable through multiple distinct names depending on the agent's network position:
- HQ-internal FQDN (
gateway.corp.example.com) - LAN/lab IP literal (
10.10.0.7) - External public DNS (
agw.public.example.com)
Today the gateway picks one (conf.hostname) and forces every agent to use it,
regardless of how the agent was told to reach the gateway by the admin.
- Gateway config:
Hostname = "it-help-gw.ad.it-help.ninja" - Enrollment JWT:
jet_gw_url = "http://10.10.0.7:7777"(chosen because the test VM cannot resolve internal AD DNS) - Agent enrolls successfully via IP
- Gateway response:
quic_endpoint = "it-help-gw.ad.it-help.ninja:4433" - Agent service tries to QUIC-dial that hostname, DNS resolution fails forever, silent reconnect loop
The installer reports success because agent.exe up exits 0 (it writes
config; it does not validate the resulting tunnel).
The admin had no leverage: they correctly used an IP because the agent's network couldn't see the gateway by name. The gateway then overrode that choice and asserted a name the agent's network had never heard of. The admin's intent ("reach me at 10.10.0.7") was lost the moment enrollment moved past the HTTP request.
If we "fix" by switching Hostname to the IP, we break the AD-internal use
case where agents do use the FQDN. The fields fight each other because they
should not be the same field.
| Question | Answer |
|---|---|
| Single gateway reachable via multiple names? | Very common |
| Multiple DVLS instances? | 99% single, but design must not assume |
| Agent roams between gateways? | No, one gateway per agent for life |
| Enrollment token reuse? | Reusable until token expiry for this iteration; TODO: decide single-use enforcement later |
| Reinstall reuses identity? | No, always new agent_id |
Add a new config block:
"AgentTunnel": {
"Enabled": true,
"ListenPort": 4433,
"AdvertisedNames": [
"gateway.corp.example.com",
"10.10.0.7",
"agw.public.example.com"
]
}AdvertisedNames accepts either a bare string or an object with a display
label, deserialized via serde #[serde(untagged)]:
"AdvertisedNames": [
"10.10.0.7",
{ "name": "gateway.corp.example.com", "label": "HQ FQDN" },
{ "name": "agw.public.example.com", "label": "Public DNS" }
]The label is purely informational, surfaced by DVLS UI when offering the
admin a choice. The Gateway itself only uses name for SAN generation and
host validation.
AdvertisedNames is the authoritative list of names/IPs this gateway is
reachable as for the agent-tunnel use case. The gateway:
- Signs
agent-tunnel-server-cert.pemwith all of them in SAN (DnsName entries for FQDNs, IpAddr entries for literals — rcgen handles both) - Regenerates the cert at startup whenever the SAN set on disk differs from
the current config list (allow admin to add/remove names without a manual
cert reset). SAN-only regeneration must reuse the existing
agent-tunnel-server-key.pemso the server SPKI pin remains stable for already enrolled agents. Generate a new server key only when the key is missing/corrupt; that is an SPKI rotation event and existing agents must be re-enrolled. - Exposes
AdvertisedNamesby extending the existing diagnostics endpoint (/jet/diagnostics/configuration) rather than adding a new route. The response gains anagent_tunnelfield carryingenabled,listen_port, andadvertised_names. Same auth scope, no new public surface. DVLS reads this single endpoint for all gateway introspection.
The legacy conf.hostname is no longer used for the agent tunnel cert. It
remains usable elsewhere (or we deprecate it in a follow-up).
Today:
let quic_endpoint = format!("{}:{}", conf.hostname, conf.agent_tunnel.listen_port);For one compatibility window, return both the legacy full endpoint and the new port-only field:
pub struct EnrollResponse {
pub agent_id: Uuid,
pub client_cert_pem: String,
pub gateway_ca_cert_pem: String,
pub quic_endpoint: String, // legacy: "<jet_gw_url.host>:<quic_port>"
pub quic_port: u16,
pub server_spki_sha256: String,
}quic_endpoint must be computed from the normalized jet_gw_url host and the
agent tunnel listen port, not from conf.hostname.
Old agents keep using quic_endpoint and still benefit from the fix.
New agents prefer quic_port plus the enrollment URL host.
After one release, remove quic_endpoint in a schema cleanup PR.
The long-term model is: the gateway tells the agent which port to dial, not which host. The host the agent uses is whichever host the agent already chose to enroll through — that's the host the admin intentionally configured for that agent's network.
In devolutions-agent:
// JWT carries jet_gw_url, e.g. "https://10.10.0.7:7777"
let enrollment_url = Url::parse(&claims.jet_gw_url)?;
let host = enrollment_url.host_str().context("missing host in jet_gw_url")?;
// Response carries quic_port; helper handles DNS, IPv4, and bracketed IPv6.
let gateway_endpoint = format_endpoint(host, quic_port)?;This goes into agent.json as Tunnel.GatewayEndpoint. On runtime the agent:
- Resolves
host(which the admin already verified is resolvable from this agent's network, by virtue of the enrollment URL working) - QUIC-dials it
- TLS handshake uses
hostas SNI; the server cert SAN list includeshost(because admin put it inAdvertisedNames); validation passes - SPKI pinning still applies on top
Endpoint formatting must not be raw format!("{host}:{port}").
It must handle:
| host kind | endpoint |
|---|---|
| DNS | gateway.example.com:4433 |
| IPv4 | 10.10.0.7:4433 |
| IPv6 | [fd00::7]:4433 |
Host comparison and SAN generation must normalize DNS names case-insensitively and parse IP literals as IP addresses rather than DNS names.
Before signing the CSR, gateway parses the JWT's jet_gw_url and rejects
the request if the host portion is not in AdvertisedNames. This fails fast
with a clear error message instead of producing a cert/endpoint pair the
agent cannot use.
Response on rejection (HTTP 400):
{
"error": "enrollment_host_not_advertised",
"message": "The Gateway is not advertised as 'evil.example.com'. Allowed advertised names: [\"gateway.corp.example.com\", \"10.10.0.7\"].",
"help": "Either (a) regenerate the enrollment string in DVLS using one of the names listed above, or (b) ask the Gateway operator to add 'evil.example.com' to AgentTunnel.AdvertisedNames in gateway.json and restart the Gateway."
}The HTTP body is consumed by the agent CLI and re-emitted to stderr verbatim so the message reaches the installer dialog and Windows event log.
Do not add a gateway-side enrollment/JTI store in this pass. Enrollment JWTs remain reusable until their normal expiry.
Strict single-use enrollment is still desirable, but it should be handled as a follow-up decision rather than bundled into the endpoint identity fix. The preferred owner is DVLS because DVLS issues the enrollment JWT and presents the enrollment string to the admin. If Gateway later needs to enforce replay prevention independently, the design can revisit a bounded consumed-JTI store as an explicit statefulness tradeoff.
Add agent.exe verify-tunnel --timeout <secs>. It:
- Reads
agent.json - Performs one QUIC handshake
- Sends one
RouteAdvertisemessage and waits for ack - Exit code 0 = tunnel works
- Non-zero = exits with stderr describing the failure point AND a one-line next-step the operator can act on without reading source
In CA.EnrollAgentTunnel, after up returns success, call verify-tunnel
with a hardcoded 10s timeout. If it fails, ActionResult.Failure +
InstallMessage.Error + MSI rollback. The installer's "success" now means
the tunnel is up, not just that a cert exists.
The 10s timeout is not configurable in this iteration. No MSI property to tune it, no escape hatch to skip verification. If real deployments later need a longer budget for slow customer networks, expose a property then — not pre-emptively.
verify-tunnel's stderr is a single line of JSON carrying the error
triple, written as the last line before exit:
{"kind":"dns_resolution_failed","detail":"Could not resolve 'gateway.corp' from this machine","next_step":"This agent's network cannot resolve 'gateway.corp'. ..."}
The installer CA reads stderr, parses the JSON, and feeds kind, detail,
next_step into the MSI error dialog. Agent log file and Windows Event Log
record the same object plus underlying stack.
Drop the Gateway URL override field from the installer dialog — with this design the JWT is the single source of truth for the agent-facing URL, and overriding it server-side would defeat the whole point.
Every failure path must emit a structured triple: kind, detail, next_step. The installer dialog and Windows Event Log show all three; the agent log file shows them plus the underlying stack.
| kind | when it fires | detail (variable) | next_step (the help text) |
|---|---|---|---|
enrollment_host_not_advertised |
Gateway rejects enrollment at HTTP layer (Section 4) | "Gateway advertises: [...]. JWT used host: X" | "Regenerate the enrollment string in DVLS using one of the advertised names, or add 'X' to AgentTunnel.AdvertisedNames on the Gateway." |
dns_resolution_failed |
QUIC dial step, OS returns NXDOMAIN / no such host | "Could not resolve 'X' from this machine" | "This agent's network cannot resolve 'X'. Either generate an enrollment string with a name this machine can resolve (e.g. an IP literal that the Gateway also advertises), or add a DNS entry / hosts file mapping for 'X'." |
udp_unreachable |
DNS resolves but UDP socket cannot send / no QUIC initial response in N seconds | "Resolved X -> A.B.C.D; UDP/ blocked or no listener" | "Verify Gateway is running and UDP is open between this agent and the Gateway. Check Windows Firewall, corporate firewall, NAT, and SophosNTP / EDR network filters on both ends." |
tls_san_mismatch |
QUIC TLS handshake fails because server cert SAN does not include the dial host | "Connecting as 'X' but server cert SAN is [...]" | "Gateway operator must add 'X' to AgentTunnel.AdvertisedNames in gateway.json and restart the Gateway. The server certificate will be regenerated with X in SAN." |
tls_spki_pin_mismatch |
TLS chain validates but SPKI does not match the value captured at enrollment | "Pinned SPKI ; server presented SPKI " | "The Gateway's agent-tunnel keypair changed since this agent enrolled (server key regenerated, gateway reinstalled, or man-in-the-middle). Re-enroll this agent by uninstalling and reinstalling with a fresh enrollment string." |
quic_handshake_timeout |
TLS got far enough to start but no Finished message within timeout | "Handshake stalled at " | "Network path likely drops UDP mid-flow (path MTU, broken NAT, deep packet inspection). Try a different network egress, lower QUIC MTU, or disable EDR network inspection for the Gateway endpoint." |
route_advertise_timeout |
Tunnel up but Gateway did not ack RouteAdvertise within timeout | "QUIC connected, no advertise ack in s" | "Gateway is running an older or incompatible build; ensure Gateway version supports the agent tunnel feature. Check Gateway logs for RouteAdvertise handling errors." |
enrollment_token_expired |
JWT exp claim is in the past | "exp: , now: " | "Generate a new enrollment string in DVLS. Default token lifetime is short; coordinate enrollment with the installer run." |
enrollment_token_signature_invalid |
JWT signature does not verify against provisioner.pem | "verification error: <axum/jwt error>" | "The Gateway's provisioner.pem does not match the DVLS instance that signed this enrollment string. Verify DVLS is configured with the same Gateway entry, and that provisioner.pem on the Gateway corresponds to the provisioner.key DVLS is using." |
unexpected_error |
A failure path has not yet been classified | "Unexpected failure during ; correlation_id=; log=" | "Collect the agent log and Gateway log using the correlation ID, then file a support issue. This is a product bug if it reaches the operator." |
- Installer dialog: shows
kindas the error title,detailas the subtitle,next_stepas the body. One Copy-to-Clipboard button copies all three plus the timestamp and agent ID (if assigned). - Windows Event Log: source = "DevolutionsAgent"; one event per failure with the structured fields as named properties so it's parseable by monitoring tools.
- Agent service log file (
agent.<date>.log): full triple plus underlying stack and the request/response payloads (redacted). - DVLS Agent list view: when an agent shows offline, the per-row tooltip
shows the most recent
kind+next_stepso the admin sees the actionable hint without leaving the UI.
- No bare "unknown error", "internal error", or other context-free catch-all
messages reach the operator. The fallback is
unexpected_error, and it must includedetail,next_step, correlation ID, and log location. - No stack traces in the operator-facing surface. Stacks live in agent log files only.
- No URLs to docs as the sole answer. The
next_stepmust be self-contained for the common case. Docs links are additive.
- When admin adds a Gateway entry, DVLS fetches
AdvertisedNamesfrom/jet/diagnostics/agent-tunneland stores them as a cache - When generating an enrollment string, DVLS refreshes
AdvertisedNamesfrom the Gateway before presenting choices. A stale cached list must not be the only source for new enrollment strings. - "Generate enrollment string" UI presents
AdvertisedNamesas a dropdown instead of a free-text URL field - Agent list view queries the gateway's
/jet/tunnel/agentsfor live status rather than maintaining a separate DVLS-side mirror
- Existing deployments with
conf.hostname = "x"and noAdvertisedNames: defaultAdvertisedNames = [conf.hostname]so single-name setups keep working without changes - Existing agent.json files with the old
GatewayEndpointstring remain valid; nothing to migrate - Existing enrollment tokens remain reusable until expiry. Strict single-use replay prevention is a TODO and is not part of this change.
| # | Question | Decision |
|---|---|---|
| 1 | Cert regen trigger | Silent at startup. Log previous SAN, new SAN, new cert fingerprint. |
| 2 | Verify-tunnel timeout | Hardcoded 10s. No MSI property. No skip-verify escape hatch. |
| 3 | AdvertisedNames discovery | Extend /jet/diagnostics/configuration with an agent_tunnel field. Same scope. No new endpoint. |
| 4 | Error triple transport | Single-line JSON on stderr. Installer CA parses and surfaces fields into InstallMessage.Error. |
| 5 | Compat bridge | EnrollResponse returns both quic_endpoint (legacy, computed from jet_gw_url.host) and quic_port (new). Remove quic_endpoint in a follow-up release. |
| 6 | AdvertisedNames schema | Accept string or {name, label} object via serde untagged. Label is informational, surfaced by DVLS UI. |
- Single-use enrollment enforcement. Tokens reusable until expiry for this iteration. Future decision: DVLS, Gateway, or both as owner.
- Gateway farm / load-balanced gateway HA. Agent tunnel assumes one agent enrolls to one gateway for life. A shared FQDN across multiple gateway backends behind a load balancer is not supported in this iteration. An agent enrolled through such an LB may bind to a single backend via session affinity, but cross-gateway agent discovery is not part of this design. Document this in admin docs.
- Configurable verify-tunnel timeout / skip-verify escape hatch. Add later if real deployments demand it; not pre-emptively.
Each PR ships independently. PR 1 alone fixes the SAN mismatch; subsequent PRs add the polish.
Scope (all in devolutions-gateway):
- Add
AgentTunnelConf.advertised_names: Vec<AdvertisedName>with serde untagged string-or-object support. - Migration shim: when absent, default to
vec![conf.hostname.clone()]so existing deployments keep working. - At gateway boot: compare on-disk
agent-tunnel-server-cert.pemSAN list against config. If different, regenerate cert (reusing existing keypair) with all advertised names as multi-SAN. Log old SAN, new SAN, new cert fingerprint. EnrollResponse: addquic_port: u16. Computequic_endpointfrom the validatedjet_gw_url.host+ agent tunnel listen port (not fromconf.hostname).- Enrollment handler: parse
jet_gw_url, normalize host (DNS lowercased, IPs parsed), reject with HTTP 400 + structured{error, message, help}body when host is not inAdvertisedNames. - Extend
/jet/diagnostics/configurationresponse withagent_tunnel: { enabled, listen_port, advertised_names: [{ name, label }] }.
Verification:
- Unit tests for SAN regen idempotence, host normalization, host validation.
- Integration test: configure gateway with
AdvertisedNames = [name1, name2]; enroll via name1; verify cert SAN contains both; reject enrollment via name3.
Scope (all in devolutions-agent):
- Parse
jet_gw_urlhost from JWT. format_endpoint(host, port)helper handling DNS / IPv4 / bracketed IPv6.- Prefer
quic_portfrom response when available; fall back to parsingquic_endpointfor backward compatibility against older gateways. - Write
agent.json::Tunnel.GatewayEndpointfrom the new logic.
Verification:
- Unit tests for endpoint formatting (IPv4, IPv6, DNS).
- End-to-end: agent enrolls via IP literal, QUIC dials same IP, TLS SAN validates against multi-SAN cert from PR 1.
Scope (split across devolutions-agent and dgw-pr-installer/package/AgentWindowsManaged):
- New
agent.exe verify-tunnel --timeout <secs>subcommand. One QUIC handshake + one RouteAdvertise round-trip. Emits single-line JSON triple on stderr; exit code 0 on success, non-zero on failure. - Error catalog implementation (kinds from Section 6 of this doc).
CA.EnrollAgentTunnelcallsverify-tunnelafterup. Parses stderr JSON; on failure,ActionResult.Failure+session.Message(Error, ...).- Drop Gateway URL override field from
AgentTunnelDialog(and the associatedAGENT_TUNNEL_GATEWAY_URLProperty declaration). - Windows Event Log writer in agent service for the same triples (source:
DevolutionsAgent, structured named properties).
Verification:
- Manual install with bad enrollment (DNS unresolvable) → installer
dialog shows
next_step, MSI rollbacks. - Manual install with good enrollment → tunnel up, installer reports success, agent appears in gateway agents list.
Scope (DVLS Web + DVLS server):
- Gateway entry editor: on save, call gateway's
/jet/diagnostics/configuration, storeagent_tunnel.advertised_namesin the gateway record. - "Generate enrollment string" UI: dropdown of advertised names with labels, no free-text URL box. Refresh from gateway before generation.
- Agent list view: query gateway's
/jet/tunnel/agentsfor live status instead of mirroring locally. Tooltip shows latestkind+next_stepfor offline agents.
Verification:
- Add a new advertised name in gateway.json → DVLS sees it after manual refresh + on next "Generate" click.
- Generate string → install agent → DVLS list shows agent online within 30 seconds.
Out of scope for the identity refactor. Tracked as follow-ups.
- Trust chain: provisioner key still lives only in DVLS; gateway has only the public half. Agent-tunnel CA still lives only in gateway.
- Cert pinning: SPKI pin still applies on top of SAN check.
- One-gateway-per-agent invariant.
- Reinstall semantics (always a new agent_id).
I reviewed this against the local knowledge base before commenting, especially D:\AGENT_KNOWLEDGE_BASE\integrations\dvls-to-gateway-agent-tunnel.md, D:\AGENT_KNOWLEDGE_BASE\integrations\gateway-quic-tunnel-pr-split.md, D:\AGENT_KNOWLEDGE_BASE\integrations\how-they-fit-together.md, D:\AGENT_KNOWLEDGE_BASE\projects\devolutions-gateway.md, D:\AGENT_KNOWLEDGE_BASE\projects\DVLS.md, and D:\AGENT_KNOWLEDGE_BASE\notes\tokens-and-claims.md.
My short take is: the core design is right, but I would ship it with a compatibility bridge and be careful not to undo the current stateless DVLS-signed enrollment direction.
The important product problem is not certificate generation. The product problem is that the installer can say "success" while the tunnel is dead because the agent was handed an endpoint it cannot resolve. For IT teams and MSPs, that failure mode is expensive because it appears after deployment, often on a remote customer network, and it turns a clean RMM or MSI rollout into a support ticket. Fixing this makes Agent Tunnel feel like a real deployment feature instead of a lab feature.
The business value is strong. MSPs live in split-DNS, NAT, VPN, customer-site, and segmented-network reality. They need to enroll agents from whatever name or IP works at that site, then let RDM and DVLS route RDP, SSH, KDC, and other Gateway traffic through that agent without opening inbound firewall holes to every target. This feature reduces customer network friction, makes private-network onboarding more repeatable, and gives Devolutions a cleaner story for managed remote access into customer environments.
The expected user workflow should be simple. An admin configures the Gateway with the names or IPs that agents may legitimately use. DVLS shows those choices when generating the enrollment string. The admin chooses the endpoint that matches the target network, then deploys the Agent MSI through RMM, GPO, Intune, or manual install. The installer enrolls, verifies one real tunnel handshake, and only reports success if the tunnel can actually advertise routes. After that, help desk users and administrators should not have to think about the tunnel when launching sessions from RDM or DVLS Web.
I strongly agree with splitting cryptographic identity from network reachability.
conf.hostname should not be both the SAN source and the agent dial target.
The multi-SAN AdvertisedNames model is the right primitive because the Gateway can be known as an internal FQDN, a public DNS name, and a site-local IP at the same time.
The config name might be worth refining to something like AgentTunnel.AdvertisedHosts or AgentTunnel.ReachableNames, but the concept is correct.
I also agree that the agent should derive the QUIC host from the enrollment URL host.
If the agent successfully reached jet_gw_url during enrollment, that host is the best available evidence of what works from the agent's network.
The gateway should return the QUIC port, not override the host with conf.hostname.
Implementation must handle IP literals and IPv6 bracket formatting carefully, because 10.10.0.7:4433 and [fd00::7]:4433 need different endpoint formatting.
The main compatibility risk is the enrollment response schema.
The 2026-05-21 KB snapshot says the current merged direction has the agent reading jet_gw_url from the enrollment JWT and still receiving quic_endpoint from the enrollment response.
I would not hard-break that response unless every dependent artifact is moved in one coordinated PR set.
For one release, I would accept both shapes or return both quic_endpoint and quic_port, with the new agent preferring quic_port plus the enrollment URL host.
That keeps older agents and installer builds from failing during staged rollout.
I agree with validating the enrollment URL host against AdvertisedNames.
That validation should happen as early as possible and produce an operator-grade error, not a generic enrollment failure.
DNS names should compare case-insensitively, IPs should be parsed and normalized, and the implementation should avoid accepting an arbitrary redirected host just because the HTTP request reached the gateway.
The security property should be: the agent may only enroll through a host or IP the Gateway operator intentionally advertised for agent tunnel use.
The verify-tunnel installer step is a must-have, not a nice-to-have.
Without it, we still have a gap between "configuration was written" and "the customer can route a session".
A 10 second default is reasonable, but the MSI property should be overrideable for slow customer networks.
The error should identify the failing phase: DNS, UDP reachability, TLS SAN validation, SPKI pinning, QUIC handshake, route advertise, or timeout.
I am more cautious about the consumed-JTI SQLite table, and the current decision is to defer it. Single-use enrollment is good security, but the KB says the architecture intentionally moved to stateless DVLS-signed JWT enrollment and removed gateway-side enrollment token storage. For this iteration, enrollment tokens can remain reusable until expiry. If strict single-use is required later, the cleanest owner is the issuer, which is DVLS, because DVLS is generating the enrollment string and presenting it to the admin. If the Gateway must enforce replay prevention anyway, the table is acceptable as a bounded fallback, but it should be called out as a deliberate tradeoff against stateless enrollment rather than a small implementation detail.
The DVLS UI should not be a free-text URL box for normal users.
The dropdown from AdvertisedNames is the right default because it prevents typos and keeps the cert SAN list, gateway validation, and admin intent aligned.
For MSP usability, each advertised name should probably have an optional display label such as "Customer LAN", "Public DNS", or "Lab subnet".
Most IT operators think in site and network names first, not in certificate SAN mechanics.
Certificate regeneration at startup is acceptable if it is explicit in logs and diagnostics. I would log the previous SAN set, new SAN set, and new server certificate fingerprint. DVLS should also be able to detect drift by querying diagnostics, because otherwise an admin can generate an enrollment string using a stale cached name after the Gateway config changed. This is especially important for MSPs managing multiple customer gateways.
The load balancer and gateway-farm question remains the biggest unresolved edge. The design correctly acknowledges that a shared FQDN can route an enrolled agent to the wrong gateway if registration state is per-gateway. We should not accidentally imply HA support for agent tunnel until there is sticky routing, shared agent registry state, or a documented farm ownership model. For now, the UI and docs should describe this as one agent enrolled to one gateway, with load-balanced gateway farms out of scope.
My recommended implementation order would be:
- Add
AgentTunnel.AdvertisedNames, multi-SAN server cert generation, diagnostics exposure, and enrollment-host validation. - Change new agents to derive the QUIC host from
jet_gw_urland consumequic_port, while keeping response compatibility for old agents during rollout. - Add
agent.exe verify-tunneland wire the MSI to fail install when verification fails. - Update DVLS to present advertised names as labeled choices when generating enrollment strings.
- Revisit strict single-use enforcement and gateway-farm behavior as explicit TODO follow-up decisions.
Bottom line: I would move forward with this design. It solves a real deployment blocker, lines up with how IT professionals and MSPs actually operate, and turns enrollment from "certs were written" into "the tunnel is reachable and usable". The only part I would not take blindly is the gateway-side JTI store, because it partially reverses the stateless enrollment architecture that the current PR stack just landed.