feat: transparent routing through agent tunnel#1741
Draft
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 4 commits intomasterfrom
Draft
feat: transparent routing through agent tunnel#1741irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 4 commits intomasterfrom
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 4 commits intomasterfrom
Conversation
This was referenced Apr 7, 2026
Contributor
Author
8638365 to
ad3d3a0
Compare
76acc25 to
b70e278
Compare
Copilot started reviewing on behalf of
irvingouj@Devolutions (irvingoujAtDevolution)
April 14, 2026 16:49
View session
Contributor
There was a problem hiding this comment.
Pull request overview
Adds transparent target-based routing through the QUIC agent tunnel so the gateway can automatically forward connections via an enrolled agent when the destination matches advertised subnets/domains.
Changes:
- Introduces a shared agent-tunnel routing pipeline (
resolve_route/try_route) and wires it into forwarding (WS TCP/TLS), RDP clean path, and KDC proxy. - Extends route advertisements to support IPv4+IPv6 subnets and normalized domain suffix matching (longest domain suffix wins).
- Updates RDP clean-path server connection logic to support both TCP and QUIC transports via a concrete
ServerTransportenum (to preserveSend).
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| devolutions-gateway/src/rdp_proxy.rs | Updates Kerberos send function signature usage for CredSSP network requests. |
| devolutions-gateway/src/rd_clean_path.rs | Splits clean-path into authorization vs connect; adds TCP/QUIC ServerTransport for server side. |
| devolutions-gateway/src/proxy.rs | Tightens transport bounds to require Send for both sides. |
| devolutions-gateway/src/generic_client.rs | Integrates agent-tunnel routing into generic TCP forwarding path. |
| devolutions-gateway/src/api/rdp.rs | Plumbs agent_tunnel_handle into the RDP handler path. |
| devolutions-gateway/src/api/kdc_proxy.rs | Adds optional agent-tunnel routing to KDC proxy send path and generalizes reply reading. |
| devolutions-gateway/src/api/fwd.rs | Plumbs agent_tunnel_handle into WS forwarder and routes via tunnel when matched. |
| devolutions-gateway/src/agent_tunnel/routing.rs | New shared routing pipeline + unit tests. |
| devolutions-gateway/src/agent_tunnel/registry.rs | Adds target matching helpers and agent lookup by subnet/domain specificity; moves to IpNetwork. |
| devolutions-gateway/src/agent_tunnel/mod.rs | Exposes new routing module. |
| devolutions-agent/src/tunnel_helpers.rs | Extends tunnel target parsing/resolution to support IPv6 and IpNetwork. |
| devolutions-agent/src/tunnel.rs | Switches advertised subnets to IpNetwork and domains to normalized DomainName. |
| crates/agent-tunnel-proto/src/stream.rs | Refactors framing helpers placement and control stream split types. |
| crates/agent-tunnel-proto/src/lib.rs | Re-exports DomainName. |
| crates/agent-tunnel-proto/src/control.rs | Introduces DomainName and changes subnet advertisement type to IpNetwork (IPv4+IPv6). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
b70e278 to
80aed20
Compare
80aed20 to
f323f30
Compare
Builds on #1738 (core infrastructure). Follow-up PRs will add the Windows/Linux installer integration, gateway webapp agent management UI, Docker deployment, and Playwright E2E harness. Transparent routing: - `crates/agent-tunnel/src/routing.rs`: `RoutingDecision` pipeline — explicit `jet_agent_id` from the JWT → subnet match → domain suffix match (longest wins) → direct connect. Single `try_route` entry point consumed by all gateway proxy paths. - `crates/agent-tunnel/src/registry.rs`: `find_agents_for(host)` + `RouteAdvertisementState::matches_target()` do the lookup in one spot; offline agents are skipped. - Gateway proxy integration: `api/fwd.rs`, `api/kdc_proxy.rs`, `api/rdp.rs`, `rd_clean_path.rs`, `generic_client.rs`, `rdp_proxy.rs` all call `try_route` before falling through to direct TCP. - Tests: `agent-tunnel/src/integration_test.rs` (2 full-stack QUIC E2E), `tests/agent_tunnel_registry.rs` (13), `tests/agent_tunnel_ routing.rs` (8). Agent-side certificate renewal: - `enrollment.rs`: `is_cert_expiring(cert_path, threshold_days)` and `generate_csr_from_existing_key(key_path, agent_name)` — the key never changes across renewals, the gateway just signs a new cert with the same public key. - `tunnel.rs`: on connect, if the cert is within 15 days of expiry, the agent sends a `CertRenewalRequest` control message with a new CSR, waits for `CertRenewalResponse::Success`, writes the renewed cert and CA, and reconnects. - `agent-tunnel/src/listener.rs`: gateway-side handler signs the CSR via `CaManager::sign_agent_csr` and returns the new cert chain. (Stub replaced: master's handler emitted a debug log and dropped the message.) QUIC endpoint override: - `enrollment.rs`: new `quic_endpoint_override: Option<String>` parameter on `enroll_agent` — if set, overrides the endpoint returned by the enroll API. Needed because the gateway's `quic_endpoint` is derived from `conf.hostname`, which in a containerized deployment is often the container ID (not routable from outside). - `main.rs`: new `--quic-endpoint` CLI flag and `jet_quic_endpoint` JWT claim; precedence is CLI flag > JWT claim > enroll API response. Agent-side routing primitives: - `tunnel_helpers.rs`: `Target::Ip` / `Target::Domain` enum parsed from the gateway's `ConnectRequest::target`, `resolve_target` (domain → DNS), `connect_to_target` (happy-eyeballs). Tests: 22 agent-tunnel lib + 3 proto version + 24 proto control + 11 proto session + 13 registry + 8 routing integration + 64 gateway lib, all green. Zero clippy warnings; nightly fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
f323f30 to
3c49f7f
Compare
`run_single_connection` previously returned `Ok(())` on both graceful
shutdown and successful cert renewal. The outer reconnect loop treated
`Ok(())` as "task done forever", so after a renewal the agent exited
and never reconnected with the new cert.
Split the return with `ConnectionOutcome::{Shutdown, CertRenewed}`;
renewal now reconnects immediately (bypassing backoff), shutdown still
exits the task. Also wrap the `CertRenewalResponse` recv in a 30s
timeout so a stalled gateway cannot hang the agent indefinitely.
- routing.rs: when `explicit_agent_id` is set but the gateway has no
tunnel handle, return `Err` instead of silently falling back to a
direct connect. A token that names a specific `jet_agent_id` is
declaring a required network boundary; silent fallback would bypass
it.
- api/fwd.rs, generic_client.rs, rd_clean_path.rs, api/kdc_proxy.rs:
use `TargetAddr::as_addr()` (which brackets IPv6) instead of
`format!("{host}:{port}")` or `to_string()` (which includes scheme).
Fixes two real bugs: IPv6 targets were malformed (`::1:443` vs
`[::1]:443`), and kdc_proxy was passing `tcp://host:88` to the
tunnel target parser — which only accepts bare `host:port`.
- rdp_proxy.rs: add a `TODO(agent-tunnel)` documenting that CredSSP
Kerberos network requests cannot currently traverse the agent
tunnel because `send_network_request` hardcodes `None` for the
handle. Edge case (KDC behind a NAT'd site only reachable via an
enrolled agent); plumbing the handle through `RdpProxy` is a
follow-up.
- tests/agent_tunnel_routing.rs: replace a flaky `thread::sleep(10ms)`
(Windows timer resolution is ~16 ms) with an explicit
`set_received_at_for_test` helper. Adds two new tests for the new
explicit-agent-without-handle error path.
- registry.rs: expose `set_received_at_for_test` for the above.
- agent-tunnel-proto/control.rs: fix a stale doc comment that claimed
`subnets` is IPv4+IPv6 (it is IPv4-only; `Vec<Ipv4Network>`).
irvingouj@Devolutions (irvingoujAtDevolution)
added a commit
that referenced
this pull request
Apr 22, 2026
- routing.rs: when `explicit_agent_id` is set but the gateway has no
tunnel handle, return `Err` instead of silently falling back to a
direct connect. A token that names a specific `jet_agent_id` is
declaring a required network boundary; silent fallback would bypass
it.
- api/fwd.rs, generic_client.rs, rd_clean_path.rs, api/kdc_proxy.rs:
use `TargetAddr::as_addr()` (which brackets IPv6) instead of
`format!("{host}:{port}")` or `to_string()` (which includes scheme).
Fixes two real bugs: IPv6 targets were malformed (`::1:443` vs
`[::1]:443`), and kdc_proxy was passing `tcp://host:88` to the
tunnel target parser — which only accepts bare `host:port`.
- rdp_proxy.rs: add a `TODO(agent-tunnel)` documenting that CredSSP
Kerberos network requests cannot currently traverse the agent
tunnel because `send_network_request` hardcodes `None` for the
handle. Edge case (KDC behind a NAT'd site only reachable via an
enrolled agent); plumbing the handle through `RdpProxy` is a
follow-up.
- tests/agent_tunnel_routing.rs: replace a flaky `thread::sleep(10ms)`
(Windows timer resolution is ~16 ms) with an explicit
`set_received_at_for_test` helper. Adds two new tests for the new
explicit-agent-without-handle error path.
- registry.rs: expose `set_received_at_for_test` for the above.
- agent-tunnel-proto/control.rs: fix a stale doc comment that claimed
`subnets` is IPv4+IPv6 (it is IPv4-only; `Vec<Ipv4Network>`).
…nale
CI fix:
- enrollment.rs: replace `time::OffsetDateTime::now_utc()` with
`std::time::SystemTime::now()` — the `time` crate is Windows-only in
devolutions-agent's Cargo.toml, so the previous code broke the Linux
lint and test jobs. No dependency added; one fewer path too.
Test relocation:
- `crates/agent-tunnel/src/integration_test.rs` (QUIC E2E +
domain-routing E2E) moved to
`devolutions-gateway/tests/agent_tunnel_integration.rs`. Integration
tests belong in the gateway's dedicated `tests/` folder, not inside
a library crate's `src/`. Imports swapped from `super::cert` /
`super::listener` to `agent_tunnel::cert` / `agent_tunnel::`.
- `devolutions-agent/src/main.rs` inline `mod tests { ... }` block
extracted to `devolutions-agent/src/cli_tests.rs`. Kept as a child
module (`#[cfg(test)] mod cli_tests;`) because the tests use the
private `UpCommand` / `parse_up_command_args` — the alternative
(move to `tests/` folder) would require exposing binary internals
or a lib+bin split.
Dev-dep cleanup on agent-tunnel:
- Drop `rustls-pemfile` and `tempfile` from `[dev-dependencies]` —
both were only used by the integration test that just moved out.
- Trim `tokio` dev-dep to `["macros"]` (drop `"net"` — no TcpListener
usage in the remaining src tests).
- `rustls` stays in `[dependencies]` — it is unavoidable: Quinn's
QUIC server TLS config is built on `rustls::ServerConfig`. PR1's
cleanup dropped `rustls-pemfile` and `x509-parser` (replaced with
picky); `rustls` itself is the TLS stack, not something we pull in
for PEM parsing.
`jet_quic_endpoint` rationale:
- Expand the terse "often a container ID in Docker" comment into a
full explanation on `EnrollmentJwtClaims::jet_quic_endpoint` of why
the override exists at all: a running process has no way to
self-discover its externally-reachable address, so the enroll API's
self-reported `conf.hostname:port` is routinely wrong in
Docker/K8s, NAT, split-horizon DNS, and HA-behind-LB deployments.
Only the operator who designed the network knows the right value,
so it is encoded into the JWT at mint time. The CLI flag takes
precedence for last-minute corrections without re-issuing a JWT.
- `enroll_agent`'s docstring and the inline comment in the override
branch now defer to the claim doc instead of re-stating the Docker
example.
Verification:
- `cargo check --workspace --all-targets` ✅
- `cargo clippy -p agent-tunnel -p agent-tunnel-proto -p devolutions-agent -p devolutions-gateway --all-targets` ✅ 0 warn
- Tests: 55 lib (agent-tunnel 20 / proto 3 / agent 27 / agent bin 5)
+ 25 gateway integration (e2e 2 / registry 13 / routing 10) = 80
total, all green.
- `cargo +nightly fmt --check` ✅
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Transparent routing through QUIC agent tunnel (PR 2 of 4, stacked on #1738).
When a connection target matches an agent's advertised subnets or domains, the gateway automatically routes through the QUIC tunnel instead of connecting directly.
Depends on: #1738 (must merge first)
Changes
ServerTransportenum (Tcp/Quic) inrd_clean_path.rsfor RDP tunnel supportPR stack
🤖 Generated with Claude Code