-
Notifications
You must be signed in to change notification settings - Fork 26
feat(agent): transparent routing through agent tunnel #1741
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
1ad988c
feat(dgw): agent tunnel transparent routing + cert renewal
irvingoujAtDevolution 9abb9fb
fix(agent): reconnect after successful cert renewal
irvingoujAtDevolution f928a36
fix(agent-tunnel): address Copilot review feedback on PR #1741
irvingoujAtDevolution b6c500d
chore(agent-tunnel): fix CI, relocate tests, elaborate override ratio…
irvingoujAtDevolution f3cff9d
refactor(agent-tunnel): remove gateway QUIC endpoint self-report
irvingoujAtDevolution 418b925
refactor(dgw): simplify forwarded upstream routing
irvingoujAtDevolution 6c23728
refactor(dgw): extract upstream routing into shared module
irvingoujAtDevolution f6b6229
feat(agent-tunnel): admin-facing /jet/tunnel/enrollment-string endpoint
irvingoujAtDevolution ac929d2
feat(utils): add GatewayAgentEnroll / GatewayAgentRead scopes + bump …
irvingoujAtDevolution a370e1e
chore(agent-tunnel): address review feedback on PR
irvingoujAtDevolution f338617
chore(upstream): log routing decision for implicit lookups
irvingoujAtDevolution b011432
style: cargo fmt
irvingoujAtDevolution e65f199
fix(agent-tunnel): address Copilot follow-up review
irvingoujAtDevolution fd80bf6
fix(fwd): restore per-mode log message string
irvingoujAtDevolution 6ed4220
chore(utils): release Devolutions.Gateway.Utils 2025.10.2
irvingoujAtDevolution 4e52c64
refactor(agent-tunnel): drop server-side enrollment-string mint
irvingoujAtDevolution 88c2539
refactor(token): collapse two enrollment scopes into agent.enroll
irvingoujAtDevolution 2982a9c
fix(agent-tunnel): periodic cert renewal + KDC scheme guard
irvingoujAtDevolution 37e3d37
fix(agent-tunnel): address Copilot review on agent enrollment PR
irvingoujAtDevolution 0bf8efa
chore(utils): release Devolutions.Gateway.Utils 2026.4.27
irvingoujAtDevolution b763ab7
feat(agent): --advertise-domains CLI flag + enroll.nu demo helper
irvingoujAtDevolution e3cfe35
fix(enroll.nu): drop PowerShell-style line continuation
irvingoujAtDevolution 22b1663
fix(enroll.nu): default agent name to 'demo-agent'
irvingoujAtDevolution b513422
chore(agent-tunnel): address maintainer review feedback
irvingoujAtDevolution d112b91
refactor(pr2): trim cert renewal and JWT enrollment refactor
irvingoujAtDevolution 69ef61e
refactor(pr2): drop residual D content from agent CLI
irvingoujAtDevolution 1188fbe
refactor(pr2): split cert.rs refactor and agent-tunnel tests into own…
irvingoujAtDevolution a2b2ff5
refactor(pr2): defer KDC tunnel routing to DGW-384
irvingoujAtDevolution f410bd9
chore(agent-tunnel): gate _for_test helpers behind test-utils feature
irvingoujAtDevolution File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
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
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
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| //! Shared routing pipeline for agent tunnel. | ||
| //! | ||
| //! Consumed by the upstream connection paths (forwarding, RDP clean path, | ||
| //! generic client) to ensure consistent routing behavior and error messages. | ||
|
|
||
| use std::net::IpAddr; | ||
| use std::sync::Arc; | ||
|
|
||
| use agent_tunnel_proto::DomainName; | ||
| use anyhow::{Result, anyhow}; | ||
| use uuid::Uuid; | ||
|
|
||
| use super::listener::AgentTunnelHandle; | ||
| use super::registry::{AgentPeer, AgentRegistry}; | ||
| use super::stream::TunnelStream; | ||
|
|
||
| /// A parsed target host used for route matching. | ||
| /// | ||
| /// Routing cares only about the host identity, not the port or scheme used by | ||
| /// the eventual connection attempt. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub enum RouteTarget { | ||
| Ip(IpAddr), | ||
| Hostname(DomainName), | ||
| } | ||
|
|
||
| impl RouteTarget { | ||
| pub fn ip(ip: IpAddr) -> Self { | ||
| Self::Ip(ip) | ||
| } | ||
|
|
||
| pub fn hostname(hostname: impl Into<String>) -> Self { | ||
| Self::Hostname(DomainName::new(hostname)) | ||
| } | ||
| } | ||
|
|
||
| impl std::fmt::Display for RouteTarget { | ||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| match self { | ||
| Self::Ip(ip) => ip.fmt(f), | ||
| Self::Hostname(hostname) => hostname.fmt(f), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Result of the routing pipeline. | ||
| /// | ||
| /// Each variant carries enough context for the caller to produce an actionable error message. | ||
| #[derive(Debug)] | ||
| pub enum RoutingDecision { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Routing Decisions: |
||
| /// Route through these agent candidates (try in order, first success wins). | ||
| ViaAgent(Vec<Arc<AgentPeer>>), | ||
| /// Explicit agent_id was specified but not found in registry. | ||
| ExplicitAgentNotFound(Uuid), | ||
| /// No agent matched — caller should attempt direct connection. | ||
| Direct, | ||
| } | ||
|
|
||
| /// Determines how to route a connection to the given target. | ||
| /// | ||
| /// Pipeline (in order of priority): | ||
| /// 1. Explicit agent_id (from JWT) → route to that agent | ||
| /// 2. Target match (IP subnet or domain suffix) → best match wins | ||
| /// 3. No match → direct connection | ||
| pub async fn resolve_route( | ||
| registry: &AgentRegistry, | ||
| explicit_agent_id: Option<Uuid>, | ||
| target: &RouteTarget, | ||
| ) -> RoutingDecision { | ||
| // Step 1: Explicit agent ID (from JWT) | ||
| if let Some(id) = explicit_agent_id { | ||
| return match registry.get(&id).await { | ||
| Some(agent) => RoutingDecision::ViaAgent(vec![agent]), | ||
| None => RoutingDecision::ExplicitAgentNotFound(id), | ||
| }; | ||
| } | ||
|
|
||
| // Step 2: Match target against all agents (IP subnet or domain suffix) | ||
| let agents = registry.find_agents_for(target).await; | ||
|
|
||
| if agents.is_empty() { | ||
| RoutingDecision::Direct | ||
| } else { | ||
| RoutingDecision::ViaAgent(agents) | ||
| } | ||
| } | ||
|
|
||
| /// Attempt to route a connection via the agent tunnel. | ||
| /// | ||
| /// Returns `Ok(Some(stream))` if routed through an agent, `Ok(None)` if the caller | ||
| /// should fall through to direct connect, or `Err` if an explicit agent was specified | ||
| /// but not found (or all candidates failed). | ||
| pub async fn try_route( | ||
| handle: Option<&AgentTunnelHandle>, | ||
| explicit_agent_id: Option<Uuid>, | ||
| target: &RouteTarget, | ||
| session_id: Uuid, | ||
| target_addr: &str, | ||
| ) -> Result<Option<(TunnelStream, Arc<AgentPeer>)>> { | ||
| let Some(handle) = handle else { | ||
| // An explicit `jet_agent_id` claim means the token requires routing via that | ||
| // specific agent; silently falling back to a direct connect would bypass the | ||
| // intended network boundary. Reject instead. | ||
| return match explicit_agent_id { | ||
| Some(id) => Err(anyhow!( | ||
| "agent {id} specified in token requires agent tunnel routing, but no tunnel handle is configured" | ||
| )), | ||
| None => Ok(None), | ||
| }; | ||
| }; | ||
|
|
||
| match resolve_route(handle.registry(), explicit_agent_id, target).await { | ||
| RoutingDecision::ExplicitAgentNotFound(id) => { | ||
| Err(anyhow!("agent {id} specified in token not found in registry")) | ||
| } | ||
| RoutingDecision::Direct => Ok(None), | ||
| RoutingDecision::ViaAgent(candidates) => { | ||
| let result = route_and_connect(handle, &candidates, session_id, target_addr).await?; | ||
| Ok(Some(result)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Try connecting to target through agent candidates (try-fail-retry). | ||
| /// | ||
| /// Returns the connected `TunnelStream` and the agent that succeeded. | ||
| /// | ||
| /// Callers must handle `RoutingDecision::ExplicitAgentNotFound` and | ||
| /// `RoutingDecision::Direct` before calling this function. | ||
| pub async fn route_and_connect( | ||
| handle: &AgentTunnelHandle, | ||
| candidates: &[Arc<AgentPeer>], | ||
| session_id: Uuid, | ||
| target: &str, | ||
| ) -> Result<(TunnelStream, Arc<AgentPeer>)> { | ||
| if candidates.is_empty() { | ||
| return Err(anyhow!("route_and_connect called with empty candidates")); | ||
| } | ||
|
|
||
| let mut last_error = None; | ||
|
|
||
| for agent in candidates { | ||
| info!( | ||
| agent_id = %agent.agent_id, | ||
| agent_name = %agent.name, | ||
| %target, | ||
| "Routing via agent tunnel" | ||
| ); | ||
|
|
||
| match handle.connect_via_agent(agent.agent_id, session_id, target).await { | ||
| Ok(stream) => { | ||
| info!( | ||
| agent_id = %agent.agent_id, | ||
| agent_name = %agent.name, | ||
| %target, | ||
| "Agent tunnel connection established" | ||
| ); | ||
| return Ok((stream, Arc::clone(agent))); | ||
| } | ||
| Err(error) => { | ||
| warn!( | ||
| agent_id = %agent.agent_id, | ||
| agent_name = %agent.name, | ||
| %target, | ||
| error = format!("{error:#}"), | ||
| "Agent tunnel connection failed, trying next candidate" | ||
| ); | ||
| last_error = Some(error); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| let agent_names: Vec<&str> = candidates.iter().map(|a| a.name.as_str()).collect(); | ||
| let last_err_msg = last_error.as_ref().map(|e| format!("{e:#}")).unwrap_or_default(); | ||
|
|
||
| error!( | ||
| agent_count = candidates.len(), | ||
| %target, | ||
| agents = ?agent_names, | ||
| last_error = %last_err_msg, | ||
| "All agent tunnel candidates failed" | ||
| ); | ||
|
|
||
| Err(last_error.unwrap_or_else(|| { | ||
| anyhow!( | ||
| "All {} agents matching target '{}' failed to connect. Agents tried: [{}]", | ||
| candidates.len(), | ||
| target, | ||
| agent_names.join(", "), | ||
| ) | ||
| })) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.