Skip to content

feat: transparent routing through agent tunnel#1741

Draft
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 4 commits intomasterfrom
feat/quic-tunnel-2-routing
Draft

feat: transparent routing through agent tunnel#1741
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 4 commits intomasterfrom
feat/quic-tunnel-2-routing

Conversation

@irvingoujAtDevolution
Copy link
Copy Markdown
Contributor

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

  • Routing pipeline: explicit agent_id → subnet match → domain suffix (longest wins) → direct
  • Integrated into all proxy paths: RDP (clean path), SSH, VNC, ARD, KDC proxy
  • ServerTransport enum (Tcp/Quic) in rd_clean_path.rs for RDP tunnel support
  • 7 routing unit tests

PR stack

  1. Protocol + Tunnel Core (feat: initial implementation of QUIC agent tunnel #1738)
  2. Transparent Routing (this PR)
  3. Auth + Webapp
  4. Deployment + Installer

🤖 Generated with Claude Code

@irvingoujAtDevolution
Copy link
Copy Markdown
Contributor Author

⚠️ Not ready to merge — depends on #1738. Will rebase and mark ready once #1738 is merged.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ServerTransport enum (to preserve Send).

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.

Comment thread crates/agent-tunnel/src/routing.rs Outdated
Comment thread devolutions-gateway/src/generic_client.rs Outdated
Comment thread devolutions-gateway/src/api/fwd.rs
Comment thread devolutions-gateway/src/rdp_proxy.rs
Comment thread devolutions-gateway/src/rd_clean_path.rs
Comment thread devolutions-gateway/src/agent_tunnel/routing.rs Outdated
Comment thread devolutions-gateway/src/agent_tunnel/registry.rs
Comment thread crates/agent-tunnel-proto/src/control.rs
Comment thread devolutions-gateway/src/api/kdc_proxy.rs Outdated
Base automatically changed from feat/quic-tunnel-1-core to master April 21, 2026 16:44
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>
`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` ✅
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants