Skip to content

Commit f323f30

Browse files
feat(dgw): agent tunnel routing, cert renewal, Docker deployment
Builds on #1738 (core infrastructure) to make the agent tunnel production-ready for a DVLS-driven deployment. Not yet: agent management webapp UI (follow-up PR) and Playwright E2E harness (follow-up PR). 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 Docker 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). Deployment: - `Dockerfile`: multi-stage build for the gateway. Produces an image that can run behind a DVLS-managed reverse proxy. - `docker-compose.yml`: gateway + network setup for local dev. - `.dockerignore` + `.gitignore` updates. 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>
1 parent d89dd0c commit f323f30

25 files changed

Lines changed: 1669 additions & 151 deletions

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
**/target
2+
**/node_modules
23
.git/
34
.dockerignore
45
Dockerfile

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,11 @@ dist/
1313
# Downloaded build dependencies
1414
tun2socks.exe
1515
wintun.dll
16+
PROTOCOL.md
17+
TECHNICAL_SPEC.md
18+
19+
# E2E test artifacts
20+
tests/e2e/test-results/
21+
tests/e2e/node_modules/
22+
# Dev artifacts
23+
devolutions-agent-linux

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Dockerfile

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# =============================================================================
2+
# Devolutions Gateway — Source build for Coolify
3+
# =============================================================================
4+
# Multi-stage build:
5+
# 1. rust-builder — compile the gateway binary from source
6+
# 2. official-image — extract libxmf and PowerShell module from official image
7+
# 3. runtime — assemble the final image
8+
#
9+
# Both the gateway binary AND the webapp are built from THIS repo's source.
10+
# The webapp must be pre-built locally (pnpm build:gateway) because some
11+
# dependencies (@devolutions/icons) require private registry authentication.
12+
# The libxmf.so and PowerShell module come from the official published image.
13+
# =============================================================================
14+
15+
# Global ARG — must be before any FROM to be usable in FROM lines
16+
ARG GATEWAY_VERSION=latest
17+
18+
# ---------------------------------------------------------------------------
19+
# Stage 1: Rust builder
20+
# ---------------------------------------------------------------------------
21+
FROM rust:1.90-bookworm AS rust-builder
22+
23+
WORKDIR /src
24+
25+
# Install build dependencies (cmake required by quiche/BoringSSL, go required by quiche)
26+
RUN apt-get update && apt-get install -y --no-install-recommends \
27+
cmake \
28+
golang-go \
29+
nasm \
30+
&& rm -rf /var/lib/apt/lists/*
31+
32+
# Copy manifests first for better layer caching
33+
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
34+
COPY crates crates
35+
COPY devolutions-gateway devolutions-gateway
36+
COPY devolutions-agent devolutions-agent
37+
COPY devolutions-session devolutions-session
38+
COPY jetsocat jetsocat
39+
COPY testsuite testsuite
40+
COPY tools tools
41+
COPY fuzz fuzz
42+
43+
# Build only the gateway binary in release mode
44+
RUN cargo build --release --package devolutions-gateway \
45+
&& cp target/release/devolutions-gateway /usr/local/bin/devolutions-gateway
46+
47+
# ---------------------------------------------------------------------------
48+
# Stage 2: Extract libxmf + PowerShell module from the official image
49+
# ---------------------------------------------------------------------------
50+
FROM devolutions/devolutions-gateway:${GATEWAY_VERSION} AS official-image
51+
52+
# ---------------------------------------------------------------------------
53+
# Stage 3: Runtime
54+
# ---------------------------------------------------------------------------
55+
FROM debian:bookworm-slim
56+
57+
LABEL maintainer="Devolutions Inc."
58+
LABEL description="Devolutions Gateway — built from source with QUIC agent tunnel"
59+
60+
# Install PowerShell and runtime dependencies
61+
RUN apt-get update \
62+
&& apt-get install -y --no-install-recommends wget ca-certificates openssl curl \
63+
&& ARCH=$(dpkg --print-architecture) \
64+
&& if [ "$ARCH" = "arm64" ]; then \
65+
PWSH_VERSION=7.4.6 \
66+
&& wget -q "https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell-${PWSH_VERSION}-linux-arm64.tar.gz" \
67+
&& mkdir -p /opt/microsoft/powershell/7 \
68+
&& tar -xzf "powershell-${PWSH_VERSION}-linux-arm64.tar.gz" -C /opt/microsoft/powershell/7 \
69+
&& chmod +x /opt/microsoft/powershell/7/pwsh \
70+
&& ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \
71+
&& rm "powershell-${PWSH_VERSION}-linux-arm64.tar.gz"; \
72+
else \
73+
wget -q https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb -O packages-microsoft-prod.deb \
74+
&& dpkg -i packages-microsoft-prod.deb \
75+
&& rm packages-microsoft-prod.deb \
76+
&& apt-get update \
77+
&& apt-get install -y --no-install-recommends powershell; \
78+
fi \
79+
&& rm -rf /var/lib/apt/lists/*
80+
81+
ENV XDG_CACHE_HOME="/tmp/.cache"
82+
ENV XDG_DATA_HOME="/tmp/.local/share"
83+
ENV POWERSHELL_TELEMETRY_OPTOUT="1"
84+
85+
ENV DGATEWAY_CONFIG_PATH="/tmp/devolutions-gateway"
86+
RUN mkdir -p "$DGATEWAY_CONFIG_PATH"
87+
88+
WORKDIR /opt/devolutions/gateway
89+
90+
ENV DGATEWAY_EXECUTABLE_PATH="/opt/devolutions/gateway/devolutions-gateway"
91+
ENV DGATEWAY_LIB_XMF_PATH="/opt/devolutions/gateway/libxmf.so"
92+
ENV DGATEWAY_WEBAPP_PATH="/opt/devolutions/gateway/webapp"
93+
94+
# Gateway binary — built from THIS repo's source code
95+
COPY --from=rust-builder /usr/local/bin/devolutions-gateway $DGATEWAY_EXECUTABLE_PATH
96+
97+
# Webapp — pre-built locally (pnpm build:gateway), output in webapp/dist/gateway-ui/
98+
COPY webapp/dist/gateway-ui/ /opt/devolutions/gateway/webapp/client/
99+
100+
# libxmf — from official image (native library, not built from source)
101+
COPY --from=official-image /opt/devolutions/gateway/libxmf.so $DGATEWAY_LIB_XMF_PATH
102+
103+
# PowerShell module — from official image (includes pre-compiled .NET DLLs)
104+
COPY --from=official-image /opt/microsoft/powershell/7/Modules/DevolutionsGateway /opt/microsoft/powershell/7/Modules/DevolutionsGateway
105+
106+
# Entrypoint script from this repo's source
107+
COPY package/Linux/entrypoint.ps1 /usr/local/bin/entrypoint.ps1
108+
RUN chmod +x /usr/local/bin/entrypoint.ps1
109+
110+
EXPOSE 7171
111+
EXPOSE 8181
112+
EXPOSE 4433/udp
113+
114+
HEALTHCHECK --interval=30s --timeout=10s --retries=5 --start-period=15s \
115+
CMD curl -sf http://localhost:7171/jet/health || exit 1
116+
117+
ENTRYPOINT ["pwsh", "-File", "/usr/local/bin/entrypoint.ps1"]

crates/agent-tunnel/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,6 @@ quinn = "0.11"
4343

4444
[dev-dependencies]
4545
base64 = "0.22"
46-
tokio = { version = "1.45", features = ["macros"] }
46+
tempfile = "3"
47+
rustls-pemfile = "2.2"
48+
tokio = { version = "1.45", features = ["macros", "net"] }

crates/agent-tunnel/src/cert.rs

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::time::Duration;
88

99
use anyhow::{Context as _, bail};
1010
use camino::{Utf8Path, Utf8PathBuf};
11-
use picky::pem::parse_pem;
11+
use picky::pem::{PemError, parse_pem, read_pem};
1212
use picky::x509::Cert;
1313
use picky_asn1_x509::{ExtensionView, GeneralName};
1414
use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType};
@@ -32,29 +32,28 @@ fn cert_pem_to_der(pem_str: &str) -> anyhow::Result<Vec<u8>> {
3232
/// Parse one or more PEM-encoded certificates into `rustls` certificate types.
3333
///
3434
/// A PEM file can carry multiple concatenated CERTIFICATE blocks (chain). We
35-
/// iterate block-by-block with [`parse_pem`], check each label, and wrap the
36-
/// DER bytes in [`rustls_pki_types::CertificateDer`] — the only type the
37-
/// rustls/quinn TLS builders accept.
35+
/// use [`read_pem`] in a loop — each call consumes one block; `HeaderNotFound`
36+
/// signals "no more blocks left", which is the termination condition. Each
37+
/// block's label is verified, then the DER bytes are wrapped in
38+
/// [`rustls_pki_types::CertificateDer`] — the type the rustls/quinn TLS
39+
/// builders accept.
3840
fn read_cert_chain(pem_str: &str) -> anyhow::Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
41+
use std::io::BufReader;
42+
43+
let mut reader = BufReader::new(pem_str.as_bytes());
3944
let mut chain = Vec::new();
40-
let mut remaining = pem_str;
41-
while let Some(start) = remaining.find("-----BEGIN ") {
42-
let block_end = remaining[start..]
43-
.find("-----END ")
44-
.and_then(|e| {
45-
remaining[start + e..]
46-
.find("-----\n")
47-
.map(|n| start + e + n + "-----\n".len())
48-
})
49-
.context("malformed PEM block (no END tag)")?;
50-
51-
let block = &remaining[start..block_end];
52-
let pem = parse_pem(block).context("parse PEM block")?;
53-
if pem.label() != PEM_LABEL_CERTIFICATE {
54-
bail!("expected {PEM_LABEL_CERTIFICATE} PEM, got {}", pem.label());
45+
46+
loop {
47+
match read_pem(&mut reader) {
48+
Ok(pem) => {
49+
if pem.label() != PEM_LABEL_CERTIFICATE {
50+
bail!("expected {PEM_LABEL_CERTIFICATE} PEM, got {}", pem.label());
51+
}
52+
chain.push(rustls::pki_types::CertificateDer::from(pem.data().to_vec()));
53+
}
54+
Err(PemError::HeaderNotFound) => break,
55+
Err(e) => return Err(anyhow::Error::new(e).context("parse PEM block")),
5556
}
56-
chain.push(rustls::pki_types::CertificateDer::from(pem.data().to_vec()));
57-
remaining = &remaining[block_end..];
5857
}
5958

6059
if chain.is_empty() {
@@ -87,7 +86,7 @@ const SERVER_CERT_FILENAME: &str = "agent-tunnel-server-cert.pem";
8786
const SERVER_KEY_FILENAME: &str = "agent-tunnel-server-key.pem";
8887
const CA_VALIDITY_DAYS: u32 = 3650; // ~10 years
8988
const SERVER_CERT_VALIDITY_DAYS: u32 = 365; // 1 year
90-
const AGENT_CERT_VALIDITY_DAYS: u32 = 365; // 1 year
89+
const AGENT_CERT_VALIDITY_DAYS: u32 = 30; // 30 days (short-lived, auto-renewed)
9190

9291
const SECS_PER_DAY: u64 = 86_400;
9392
const CA_COMMON_NAME: &str = "Devolutions Gateway Agent Tunnel CA";

0 commit comments

Comments
 (0)