Skip to content

Commit 137a51f

Browse files
chore(agent-tunnel): address review feedback on PR
* `upstream::ConnectedUpstream::server_addr` for tunneled targets now reports the target IP:port (or `0.0.0.0:<port>` for a hostname target) instead of `0.0.0.0:0`. Logs / PCAP filenames / session info surface a meaningful peer address again. * `RoutePlan` and its methods are now `pub(crate)`; they were never meant to leak outside the upstream module. * `RoutingDecision::ExplicitAgentNotFound` is logged and degraded to Direct rather than panicking via `unreachable!` — the routing crate could grow new branches and we should not crash the gateway on a contract drift. * `/jet/tunnel/enrollment-string` now returns `400` when neither `quic_host` is provided nor a parseable host can be extracted from `api_base_url`. The previous silent fallback to `conf.hostname` was a Docker/K8s footgun (often a container ID the agent cannot dial), and the token store insert is now performed only after that check so a 400 leaves no orphan token. * `AgentManagementReadAccess` and `AgentManagementWriteAccess` reject with an error message that names the accepted scopes, easing integration debugging.
1 parent fbb0532 commit 137a51f

3 files changed

Lines changed: 50 additions & 22 deletions

File tree

devolutions-gateway/src/api/tunnel.rs

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -304,25 +304,33 @@ async fn create_agent_enrollment_string(
304304

305305
let lifetime_secs = req.lifetime.unwrap_or(3600);
306306

307-
// Generate a one-time enrollment token stored server-side.
308-
let enrollment_token = Uuid::new_v4().to_string();
309-
handle
310-
.enrollment_token_store()
311-
.insert(enrollment_token.clone(), req.name.clone(), Some(lifetime_secs))
312-
.await;
313-
314-
// Determine QUIC host: explicit override > extract from api_base_url > gateway hostname config.
315-
// The gateway hostname config is often a container ID in Docker, so we prefer
316-
// extracting the host from the api_base_url which the caller already knows is reachable.
307+
// Determine QUIC host: explicit override > host extracted from api_base_url.
308+
// We deliberately do NOT fall back to `conf.hostname`: in Docker/K8s that is
309+
// typically a container ID or pod name not resolvable by the agent, so a
310+
// silent fallback would emit an enrollment string the agent cannot use.
311+
// Force the caller to either supply `quic_host` or pass an `api_base_url`
312+
// we can parse a host out of.
317313
let quic_host = match req.quic_host.as_deref().filter(|h| !h.is_empty()) {
318314
Some(host) => host.to_owned(),
319315
None => url::Url::parse(&req.api_base_url)
320316
.ok()
321317
.and_then(|u| u.host_str().map(ToOwned::to_owned))
322-
.unwrap_or_else(|| conf.hostname.clone()),
318+
.ok_or_else(|| {
319+
HttpError::bad_request().msg(
320+
"could not derive QUIC host: api_base_url has no host component, pass `quic_host` explicitly",
321+
)
322+
})?,
323323
};
324324
let quic_endpoint = format!("{quic_host}:{}", conf.agent_tunnel.listen_port);
325325

326+
// Generate a one-time enrollment token stored server-side. Done after the
327+
// quic_host validation so a 400 response does not pollute the store.
328+
let enrollment_token = Uuid::new_v4().to_string();
329+
handle
330+
.enrollment_token_store()
331+
.insert(enrollment_token.clone(), req.name.clone(), Some(lifetime_secs))
332+
.await;
333+
326334
// Build the enrollment payload.
327335
let payload = serde_json::json!({
328336
"version": 1,

devolutions-gateway/src/extract.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,9 @@ where
434434
| AccessScope::AgentRead
435435
| AccessScope::DiagnosticsRead
436436
| AccessScope::ConfigWrite => Ok(Self),
437-
_ => Err(HttpError::forbidden().msg("invalid scope for agent management read")),
437+
_ => Err(HttpError::forbidden().msg(
438+
"invalid scope for agent management read (require one of: gateway.agent.read, gateway.diagnostics.read, gateway.config.write, *)",
439+
)),
438440
},
439441
_ => Err(HttpError::forbidden().msg("scope token required for agent management read")),
440442
}
@@ -466,7 +468,9 @@ where
466468
match claims {
467469
AccessTokenClaims::Scope(scope) => match scope.scope {
468470
AccessScope::Wildcard | AccessScope::AgentEnroll | AccessScope::ConfigWrite => Ok(Self),
469-
_ => Err(HttpError::forbidden().msg("invalid scope for agent management write")),
471+
_ => Err(HttpError::forbidden().msg(
472+
"invalid scope for agent management write (require one of: gateway.agent.enroll, gateway.config.write, *)",
473+
)),
470474
},
471475
_ => Err(HttpError::forbidden().msg("scope token required for agent management write")),
472476
}

devolutions-gateway/src/upstream.rs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,11 @@ pub enum UpstreamMode {
135135
/// or an agent-tunnel stream; a TLS wrap has not yet been applied.
136136
pub struct ConnectedUpstream {
137137
pub leg: UpstreamLeg,
138-
/// Remote peer address. For agent-tunnel routing this is a placeholder
139-
/// (`0.0.0.0:0`) because the real TCP peer lives on the agent side.
138+
/// Remote peer address. For direct routing this is the resolved TCP peer.
139+
/// For agent-tunnel routing the real TCP peer lives on the agent side, so
140+
/// we surface the target IP:port (when the target is an IP literal) or
141+
/// `0.0.0.0:<target_port>` (when the target is a hostname the gateway
142+
/// never resolves) — both are more useful in logs/PCAP than a true zero.
140143
pub server_addr: SocketAddr,
141144
pub selected_target: TargetAddr,
142145
}
@@ -154,7 +157,7 @@ pub struct PreparedUpstream {
154157
// ---------------------------------------------------------------------------
155158

156159
/// A routing decision for a single target.
157-
pub enum RoutePlan<'a> {
160+
pub(crate) enum RoutePlan<'a> {
158161
Direct(&'a TargetAddr),
159162
ViaAgent {
160163
target: &'a TargetAddr,
@@ -166,7 +169,7 @@ impl<'a> RoutePlan<'a> {
166169
/// Pick how to reach `target`:
167170
/// - Explicit `jet_agent_id` → route via that agent (or error if missing).
168171
/// - Otherwise registry subnet/domain match → best candidates, else Direct.
169-
pub async fn resolve(
172+
pub(crate) async fn resolve(
170173
handle: Option<&AgentTunnelHandle>,
171174
explicit_agent_id: Option<Uuid>,
172175
target: &'a TargetAddr,
@@ -197,8 +200,15 @@ impl<'a> RoutePlan<'a> {
197200
match resolve_route(handle.registry(), None, target.host()).await {
198201
RoutingDecision::ViaAgent(candidates) => Ok(Self::ViaAgent { target, candidates }),
199202
RoutingDecision::Direct => Ok(Self::Direct(target)),
200-
RoutingDecision::ExplicitAgentNotFound(_) => {
201-
unreachable!("explicit agent IDs are handled before route resolution")
203+
RoutingDecision::ExplicitAgentNotFound(agent_id) => {
204+
// resolve_route only returns this when an explicit agent_id is passed
205+
// in; we pass None above. Treat as a soft failure rather than panic
206+
// so a future change in the routing crate cannot crash the gateway.
207+
warn!(
208+
%agent_id,
209+
"routing crate returned ExplicitAgentNotFound for an implicit lookup; falling back to direct"
210+
);
211+
Ok(Self::Direct(target))
202212
}
203213
}
204214
}
@@ -207,7 +217,7 @@ impl<'a> RoutePlan<'a> {
207217
///
208218
/// For `Direct`, does a TCP connect. For `ViaAgent`, tries each candidate
209219
/// in order until one succeeds.
210-
pub async fn execute(self, handle: Option<&AgentTunnelHandle>, session_id: Uuid) -> Result<ConnectedUpstream> {
220+
pub(crate) async fn execute(self, handle: Option<&AgentTunnelHandle>, session_id: Uuid) -> Result<ConnectedUpstream> {
211221
match self {
212222
Self::Direct(target) => {
213223
trace!(%target, "Connecting to target directly");
@@ -239,8 +249,14 @@ impl<'a> RoutePlan<'a> {
239249
.await
240250
{
241251
Ok(stream) => {
242-
// Real TCP peer lives on the agent; expose a placeholder.
243-
let server_addr: SocketAddr = "0.0.0.0:0".parse().expect("valid placeholder");
252+
// The TCP peer lives on the agent side; surface the target
253+
// IP:port for logs/PCAP when the target is a literal IP, or
254+
// 0.0.0.0:<port> when it's a hostname the gateway never
255+
// resolved itself. Either is more useful than 0.0.0.0:0.
256+
let server_addr = match target.host_ip() {
257+
Some(ip) => SocketAddr::new(ip, target.port()),
258+
None => SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, target.port())),
259+
};
244260

245261
return Ok(ConnectedUpstream {
246262
leg: UpstreamLeg::Tunnel(stream),

0 commit comments

Comments
 (0)