Skip to content

Commit 832830c

Browse files
feat: deployment, installer, and E2E tests for agent tunnel
Docker setup, MSI installer dialogs, and black-box E2E test that walks the full user flow from UI enrollment to SSH through tunnel. - Dockerfile + docker-compose for gateway + SSH test container - MSI installer: AgentTunnelDialog for enrollment string input - E2E test: enroll agent via UI → start agent → verify ONLINE → SSH - .gitignore updates for test artifacts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f49cfd5 commit 832830c

17 files changed

Lines changed: 1025 additions & 0 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

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"]

docker-compose.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# documentation: https://docs.devolutions.net/gateway/standalone/
2+
# slogan: Protocol-aware relay server with QUIC agent tunnel for private network access (RDP, SSH, VNC, Telnet, ARD)
3+
# tags: devolutions,gateway,rdp,ssh,vnc,telnet,remote-access,relay,quic,agent-tunnel
4+
# port: 7171
5+
6+
services:
7+
gateway:
8+
image: irvingou/devolutions-gateway:quic-tunnel-v6
9+
# To build from source instead of using the pre-built image, comment out
10+
# "image:" above and uncomment the line below:
11+
# build: .
12+
environment:
13+
# -- Coolify Magic Variables --
14+
# SERVICE_FQDN_GATEWAY_7171 tells Traefik to route traffic to port 7171 inside the container.
15+
- SERVICE_FQDN_GATEWAY_7171
16+
# Auto-generated credentials — visible in Coolify's environment variables UI.
17+
- WEB_APP_USERNAME=${SERVICE_USER_GATEWAY}
18+
- WEB_APP_PASSWORD=${SERVICE_PASSWORD_GATEWAY}
19+
20+
# -- Gateway Standalone Configuration --
21+
# Enable the built-in web application (admin UI + web-based remote access).
22+
- WEB_APP_ENABLED=true
23+
# Internal scheme is HTTP; Coolify's Traefik reverse proxy terminates TLS.
24+
- WEB_SCHEME=http
25+
# Tell the gateway that clients reach it over HTTPS (via Traefik).
26+
- EXTERNAL_WEB_SCHEME=https
27+
# Session recording storage path inside the container.
28+
- RECORDING_PATH=/recordings
29+
# Logging verbosity: Default, Debug, Tls, All, Quiet
30+
- VERBOSITY_PROFILE=${VERBOSITY_PROFILE:-Debug}
31+
32+
# -- QUIC Agent Tunnel --
33+
# Enable QUIC listener for agent-based private network routing.
34+
- AGENT_TUNNEL_ENABLED=true
35+
# QUIC listener port (UDP). Agents connect to this port.
36+
- AGENT_TUNNEL_PORT=${AGENT_TUNNEL_PORT:-4433}
37+
38+
volumes:
39+
# Persist session recordings across redeployments.
40+
- gateway-recordings:/recordings
41+
# Persist gateway configuration (provisioner keys, config files, agent certs).
42+
- gateway-config:/tmp/devolutions-gateway
43+
44+
healthcheck:
45+
test: ["CMD", "curl", "-sf", "http://localhost:7171/jet/health"]
46+
interval: 30s
47+
timeout: 10s
48+
retries: 5
49+
start_period: 15s
50+
51+
ports:
52+
# QUIC agent tunnel — UDP, must bypass Traefik, exposed directly on host
53+
- "${AGENT_TUNNEL_PORT:-4433}:${AGENT_TUNNEL_PORT:-4433}/udp"
54+
55+
volumes:
56+
gateway-recordings:
57+
gateway-config:

package/AgentWindowsManaged/Actions/AgentActions.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,18 @@ internal static class AgentActions
258258
Condition = Condition.NOT_BeingRemoved & new Condition("(UILevel >= 3 OR WIXSHARP_MANAGED_UI_HANDLE <> \"\")")
259259
};
260260

261+
private static readonly ElevatedManagedAction enrollAgentTunnel = new(
262+
new Id($"CA.{nameof(enrollAgentTunnel)}"),
263+
CustomActions.EnrollAgentTunnel,
264+
Return.check,
265+
When.Before, Step.StartServices,
266+
Condition.NOT_BeingRemoved,
267+
Sequence.InstallExecuteSequence)
268+
{
269+
Execute = Execute.deferred,
270+
Impersonate = false,
271+
};
272+
261273
private static readonly ElevatedManagedAction registerExplorerCommand = new(
262274
CustomActions.RegisterExplorerCommand
263275
)
@@ -330,6 +342,7 @@ private static string UseProperties(IEnumerable<IWixProperty> properties)
330342
getInstallDirFromRegistry,
331343
setArpInstallLocation,
332344
configureFeatures,
345+
enrollAgentTunnel,
333346
createProgramDataDirectory,
334347
setProgramDataDirectoryPermissions,
335348
createProgramDataPedmDirectories,

package/AgentWindowsManaged/Actions/CustomActions.cs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,100 @@ static ActionResult ToggleAgentFeature(Session session, string feature, bool ena
290290
}
291291
}
292292

293+
[CustomAction]
294+
public static ActionResult EnrollAgentTunnel(Session session)
295+
{
296+
string enrollmentString = session.Property(AgentProperties.AgentTunnelEnrollmentString);
297+
string subnetsRaw = session.Property(AgentProperties.AgentTunnelAdvertiseSubnets);
298+
299+
if (string.IsNullOrWhiteSpace(enrollmentString))
300+
{
301+
session.Log("Agent tunnel enrollment string not provided, skipping tunnel setup");
302+
return ActionResult.Success;
303+
}
304+
305+
try
306+
{
307+
// Parse enrollment string to extract gateway URL, token, and name.
308+
// Format: dgw-enroll:v1:<base64 JSON payload>
309+
const string prefix = "dgw-enroll:v1:";
310+
if (!enrollmentString.StartsWith(prefix))
311+
{
312+
session.Log("Invalid enrollment string prefix");
313+
return ActionResult.Failure;
314+
}
315+
316+
string base64 = enrollmentString.Substring(prefix.Length);
317+
byte[] decoded = Convert.FromBase64String(base64.Replace('-', '+').Replace('_', '/'));
318+
string json = System.Text.Encoding.UTF8.GetString(decoded);
319+
320+
var payload = JsonConvert.DeserializeObject<Dictionary<string, object>>(json);
321+
string apiBaseUrl = payload["api_base_url"]?.ToString();
322+
string enrollmentToken = payload["enrollment_token"]?.ToString();
323+
string agentName = payload.ContainsKey("name") && payload["name"] != null
324+
? payload["name"].ToString()
325+
: Environment.MachineName;
326+
327+
if (string.IsNullOrEmpty(agentName))
328+
{
329+
agentName = Environment.MachineName;
330+
}
331+
332+
// Build CLI arguments for: devolutions-agent.exe enroll <url> <token> <name> <config> [subnets]
333+
string configPath = Path.Combine(ProgramDataDirectory, "agent.json");
334+
string installDir = session.Property(AgentProperties.InstallDir);
335+
string exePath = Path.Combine(installDir, Includes.EXECUTABLE_NAME);
336+
337+
string subnetsArg = string.IsNullOrWhiteSpace(subnetsRaw) ? "" : subnetsRaw.Trim();
338+
339+
string arguments = $"enroll \"{apiBaseUrl}\" \"{enrollmentToken}\" \"{agentName}\" \"{configPath}\"";
340+
if (!string.IsNullOrEmpty(subnetsArg))
341+
{
342+
arguments += $" \"{subnetsArg}\"";
343+
}
344+
345+
session.Log($"Running enrollment: {exePath} {arguments.Replace(enrollmentToken, "***")}");
346+
347+
ProcessStartInfo startInfo = new ProcessStartInfo(exePath, arguments)
348+
{
349+
UseShellExecute = false,
350+
RedirectStandardOutput = true,
351+
RedirectStandardError = true,
352+
CreateNoWindow = true,
353+
WorkingDirectory = ProgramDataDirectory,
354+
};
355+
356+
using Process process = Process.Start(startInfo);
357+
string stdout = process.StandardOutput.ReadToEnd();
358+
string stderr = process.StandardError.ReadToEnd();
359+
process.WaitForExit(60_000); // 60 second timeout
360+
361+
if (!string.IsNullOrEmpty(stdout))
362+
{
363+
session.Log($"enrollment stdout: {stdout}");
364+
}
365+
366+
if (!string.IsNullOrEmpty(stderr))
367+
{
368+
session.Log($"enrollment stderr: {stderr}");
369+
}
370+
371+
if (process.ExitCode != 0)
372+
{
373+
session.Log($"Enrollment failed with exit code {process.ExitCode}");
374+
return ActionResult.Failure;
375+
}
376+
377+
session.Log("Agent tunnel enrollment completed successfully");
378+
return ActionResult.Success;
379+
}
380+
catch (Exception e)
381+
{
382+
session.Log($"Agent tunnel enrollment failed: {e}");
383+
return ActionResult.Failure;
384+
}
385+
}
386+
293387
[CustomAction]
294388
public static ActionResult ConfigureFeatures(Session session)
295389
{

0 commit comments

Comments
 (0)