Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
12658b5
feat(dgw): agent tunnel transparent routing + cert renewal
irvingoujAtDevolution Apr 21, 2026
31428f1
fix(agent): reconnect after successful cert renewal
irvingoujAtDevolution Apr 21, 2026
5234472
fix(agent-tunnel): address Copilot review feedback on PR #1741
irvingoujAtDevolution Apr 22, 2026
1bd37cf
chore(agent-tunnel): fix CI, relocate tests, elaborate override ratio…
irvingoujAtDevolution Apr 22, 2026
f1bee2b
refactor(agent-tunnel): remove gateway QUIC endpoint self-report
irvingoujAtDevolution Apr 22, 2026
82ed6ee
refactor(dgw): simplify forwarded upstream routing
irvingoujAtDevolution Apr 22, 2026
d53deed
refactor(dgw): extract upstream routing into shared module
irvingoujAtDevolution Apr 23, 2026
fd412b8
feat(agent-tunnel): admin-facing /jet/tunnel/enrollment-string endpoint
irvingoujAtDevolution Apr 23, 2026
3ae55cf
feat(utils): add GatewayAgentEnroll / GatewayAgentRead scopes + bump …
irvingoujAtDevolution Apr 23, 2026
11f5b5f
fix(agent-tunnel): bind QUIC sockets dual-stack
irvingoujAtDevolution Apr 26, 2026
627ddbd
fix(agent-tunnel): explicitly clear IPV6_V6ONLY on the listener
irvingoujAtDevolution Apr 27, 2026
7a1bac7
chore(agent-tunnel): address review feedback on PR
irvingoujAtDevolution Apr 27, 2026
0472017
chore(upstream): log routing decision for implicit lookups
irvingoujAtDevolution Apr 27, 2026
9f3321b
style: cargo fmt
irvingoujAtDevolution Apr 27, 2026
a1e8a22
fix(agent-tunnel): address Copilot follow-up review
irvingoujAtDevolution Apr 27, 2026
a405f2e
fix(fwd): restore per-mode log message string
irvingoujAtDevolution Apr 27, 2026
da3f54b
chore(utils): release Devolutions.Gateway.Utils 2025.10.2
irvingoujAtDevolution Apr 27, 2026
b7c000e
refactor(agent-tunnel): drop server-side enrollment-string mint
irvingoujAtDevolution Apr 27, 2026
3922d40
refactor(token): collapse two enrollment scopes into agent.enroll
irvingoujAtDevolution Apr 27, 2026
da1d3a5
fix(agent-tunnel): periodic cert renewal + KDC scheme guard
irvingoujAtDevolution Apr 27, 2026
9cc7dcf
fix(agent-tunnel): address Copilot review on agent enrollment PR
irvingoujAtDevolution Apr 27, 2026
975d997
chore(utils): release Devolutions.Gateway.Utils 2026.4.27
irvingoujAtDevolution Apr 27, 2026
d44d350
feat(agent): --advertise-domains CLI flag + enroll.nu demo helper
irvingoujAtDevolution Apr 27, 2026
3423516
fix(enroll.nu): drop PowerShell-style line continuation
irvingoujAtDevolution Apr 27, 2026
c89ffa3
fix(enroll.nu): default agent name to 'demo-agent'
irvingoujAtDevolution Apr 27, 2026
2c2a148
chore(agent-tunnel): address maintainer review feedback
irvingoujAtDevolution Apr 28, 2026
02833ca
revert(agent-tunnel): back out dual-stack QUIC bind from this PR
irvingoujAtDevolution Apr 28, 2026
92ae558
refactor(pr2): trim cert renewal and JWT enrollment refactor
irvingoujAtDevolution Apr 29, 2026
e7477b9
refactor(pr2): drop residual D content from agent CLI
irvingoujAtDevolution Apr 29, 2026
8c9a4ac
refactor(pr2): drop cert.rs read_cert_chain refactor
irvingoujAtDevolution Apr 29, 2026
a2b246b
Revert "refactor(pr2): drop cert.rs read_cert_chain refactor"
irvingoujAtDevolution Apr 29, 2026
6c27fb8
refactor(pr2): split cert.rs refactor and agent-tunnel tests into own…
irvingoujAtDevolution Apr 29, 2026
552f7da
test(agent-tunnel): add e2e + registry + routing test suite
irvingoujAtDevolution Apr 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 20 additions & 21 deletions crates/agent-tunnel/src/cert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::time::Duration;

use anyhow::{Context as _, bail};
use camino::{Utf8Path, Utf8PathBuf};
use picky::pem::parse_pem;
use picky::pem::{PemError, parse_pem, read_pem};
use picky::x509::Cert;
use picky_asn1_x509::{ExtensionView, GeneralName};
use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType};
Expand All @@ -32,29 +32,28 @@ fn cert_pem_to_der(pem_str: &str) -> anyhow::Result<Vec<u8>> {
/// Parse one or more PEM-encoded certificates into `rustls` certificate types.
///
/// A PEM file can carry multiple concatenated CERTIFICATE blocks (chain). We
/// iterate block-by-block with [`parse_pem`], check each label, and wrap the
/// DER bytes in [`rustls_pki_types::CertificateDer`] — the only type the
/// rustls/quinn TLS builders accept.
/// use [`read_pem`] in a loop — each call consumes one block; `HeaderNotFound`
/// signals "no more blocks left", which is the termination condition. Each
/// block's label is verified, then the DER bytes are wrapped in
/// [`rustls_pki_types::CertificateDer`] — the type the rustls/quinn TLS
/// builders accept.
fn read_cert_chain(pem_str: &str) -> anyhow::Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
use std::io::BufReader;

let mut reader = BufReader::new(pem_str.as_bytes());
let mut chain = Vec::new();
let mut remaining = pem_str;
while let Some(start) = remaining.find("-----BEGIN ") {
let block_end = remaining[start..]
.find("-----END ")
.and_then(|e| {
remaining[start + e..]
.find("-----\n")
.map(|n| start + e + n + "-----\n".len())
})
.context("malformed PEM block (no END tag)")?;

let block = &remaining[start..block_end];
let pem = parse_pem(block).context("parse PEM block")?;
if pem.label() != PEM_LABEL_CERTIFICATE {
bail!("expected {PEM_LABEL_CERTIFICATE} PEM, got {}", pem.label());

loop {
match read_pem(&mut reader) {
Ok(pem) => {
if pem.label() != PEM_LABEL_CERTIFICATE {
bail!("expected {PEM_LABEL_CERTIFICATE} PEM, got {}", pem.label());
}
chain.push(rustls::pki_types::CertificateDer::from(pem.data().to_vec()));
}
Err(PemError::HeaderNotFound) => break,
Err(e) => return Err(anyhow::Error::new(e).context("parse PEM block")),
}
chain.push(rustls::pki_types::CertificateDer::from(pem.data().to_vec()));
remaining = &remaining[block_end..];
}

if chain.is_empty() {
Expand Down
1 change: 1 addition & 0 deletions crates/agent-tunnel/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod cert;
pub mod enrollment_store;
pub mod listener;
pub mod registry;
pub mod routing;
pub mod stream;

pub use enrollment_store::EnrollmentTokenStore;
Expand Down
13 changes: 8 additions & 5 deletions crates/agent-tunnel/src/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ impl AgentTunnelListener {
let handle = AgentTunnelHandle {
registry: Arc::clone(&registry),
agent_connections: Arc::clone(&agent_connections),
ca_manager,
ca_manager: Arc::clone(&ca_manager),
enrollment_token_store,
};

Expand All @@ -165,6 +165,11 @@ impl AgentTunnelListener {

Ok((listener, handle))
}

/// Returns the local address the QUIC endpoint is bound to.
pub fn local_addr(&self) -> SocketAddr {
self.endpoint.local_addr().expect("endpoint has local addr")
}
}

#[async_trait]
Expand Down Expand Up @@ -197,9 +202,7 @@ impl devolutions_gateway_task::Task for AgentTunnelListener {
let registry = Arc::clone(&self.registry);
let agent_connections = Arc::clone(&self.agent_connections);

conn_handles.spawn(
run_agent_connection(registry, agent_connections, incoming),
);
conn_handles.spawn(run_agent_connection(registry, agent_connections, incoming));
}

// Reap completed connection tasks to prevent unbounded growth.
Expand Down Expand Up @@ -248,7 +251,7 @@ async fn run_agent_connection(

info!(%agent_id, %agent_name, %peer_addr, "Agent authenticated via mTLS");

let peer = Arc::new(AgentPeer::new(agent_id, agent_name, fingerprint));
let peer = Arc::new(AgentPeer::new(agent_id, agent_name.clone(), fingerprint));
registry.register(Arc::clone(&peer)).await;
agent_connections.write().await.insert(agent_id, conn.clone());

Expand Down
90 changes: 90 additions & 0 deletions crates/agent-tunnel/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use serde::Serialize;
use tokio::sync::RwLock as TokioRwLock;
use uuid::Uuid;

use crate::routing::RouteTarget;

/// Duration after which an agent is considered offline if no heartbeat has been received.
pub const AGENT_OFFLINE_TIMEOUT: Duration = Duration::from_secs(90);

Expand All @@ -32,6 +34,33 @@ pub struct RouteAdvertisementState {
pub updated_at: SystemTime,
}

impl RouteAdvertisementState {
/// Match this route set against a parsed target host.
///
/// Returns a specificity score if matched, or `None` if no match.
/// IP subnet matches return `usize::MAX` (always highest priority).
/// Domain matches return the matched domain length (longer = more specific).
pub fn matches_target(&self, target: &RouteTarget) -> Option<usize> {
use std::net::IpAddr;

match target {
// Only IPv4 subnets are currently tracked; only match IPv4 target IPs.
RouteTarget::Ip(IpAddr::V4(ipv4)) => self
.subnets
.iter()
.any(|subnet| subnet.contains(*ipv4))
.then_some(usize::MAX),
RouteTarget::Ip(IpAddr::V6(_)) => None,
RouteTarget::Hostname(hostname) => self
.domains
.iter()
.filter(|adv| adv.domain.matches_hostname(hostname.as_str()))
.map(|adv| adv.domain.as_str().len())
.max(),
}
}
}

impl Default for RouteAdvertisementState {
fn default() -> Self {
let now = SystemTime::now();
Expand Down Expand Up @@ -79,6 +108,34 @@ impl AgentPeer {
self.last_seen.store(now_ms, Ordering::Release);
}

/// Set `last_seen` to an explicit timestamp (milliseconds since UNIX epoch).
///
/// Test-only API — the `_for_test` suffix is the project's signal that
/// production code must not call this. Used by integration tests in
/// `devolutions-gateway/tests/agent_tunnel_*` to force an agent into the
/// "offline" state without waiting for the real timeout to elapse;
/// production code should use [`touch`](Self::touch) instead. We deliberately
/// keep this `pub` instead of `cfg(test)` because the consumer is a
/// different crate's tests, and `cfg(test)` is only set within the
/// declaring crate's own test build.
#[doc(hidden)]
pub fn set_last_seen_for_test(&self, last_seen_ms: u64) {
self.last_seen.store(last_seen_ms, Ordering::Release);
}

/// Overwrite `received_at` on the current route state.
///
/// Test-only API. Intended for tests that need to assert ordering by
/// arrival time without relying on wall-clock `thread::sleep` — which is
/// flaky on platforms with coarse timer resolution (e.g. Windows ~16 ms).
/// See the note on [`set_last_seen_for_test`](Self::set_last_seen_for_test)
/// for why this is `pub` rather than `cfg(test)`.
#[doc(hidden)]
pub fn set_received_at_for_test(&self, received_at: SystemTime) {
let mut state = self.route_state.write();
state.received_at = received_at;
}

/// Returns the last-seen timestamp as milliseconds since UNIX epoch.
pub fn last_seen_ms(&self) -> u64 {
self.last_seen.load(Ordering::Acquire)
Expand Down Expand Up @@ -223,6 +280,39 @@ impl AgentRegistry {
pub async fn agent_infos(&self) -> Vec<AgentInfo> {
self.agents.read().await.values().map(AgentInfo::from).collect()
}

/// Find all online agents that can route to the given parsed target host.
///
/// For IP targets: matches against advertised subnets.
/// For domain targets: uses longest suffix match (more specific domain wins).
///
/// Results with equal specificity are sorted by `received_at` descending (most recent first).
pub async fn find_agents_for(&self, target: &RouteTarget) -> Vec<Arc<AgentPeer>> {
let mut best_specificity: usize = 0;
let mut candidates: Vec<(SystemTime, Arc<AgentPeer>)> = Vec::new();

let agents = self.agents.read().await;
for agent in agents.values() {
if !agent.is_online(AGENT_OFFLINE_TIMEOUT) {
continue;
}

let route_state = agent.route_state();

if let Some(specificity) = route_state.matches_target(target) {
if specificity > best_specificity {
best_specificity = specificity;
candidates.clear();
candidates.push((route_state.received_at, Arc::clone(agent)));
} else if specificity == best_specificity {
candidates.push((route_state.received_at, Arc::clone(agent)));
}
}
}

candidates.sort_by(|a, b| b.0.cmp(&a.0));
candidates.into_iter().map(|(_, agent)| agent).collect()
}
}

impl Default for AgentRegistry {
Expand Down
Loading
Loading