diff --git a/.dockerignore b/.dockerignore index bf06978ba..e87eb566d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ **/target +**/node_modules .git/ .dockerignore Dockerfile diff --git a/.gitignore b/.gitignore index 809ffc7a2..32b3b0747 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,11 @@ dist/ # Downloaded build dependencies tun2socks.exe wintun.dll +PROTOCOL.md +TECHNICAL_SPEC.md + +# E2E test artifacts +tests/e2e/test-results/ +tests/e2e/node_modules/ +# Dev artifacts +devolutions-agent-linux diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..75091691f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,117 @@ +# ============================================================================= +# Devolutions Gateway — Source build for Coolify +# ============================================================================= +# Multi-stage build: +# 1. rust-builder — compile the gateway binary from source +# 2. official-image — extract libxmf and PowerShell module from official image +# 3. runtime — assemble the final image +# +# Both the gateway binary AND the webapp are built from THIS repo's source. +# The webapp must be pre-built locally (pnpm build:gateway) because some +# dependencies (@devolutions/icons) require private registry authentication. +# The libxmf.so and PowerShell module come from the official published image. +# ============================================================================= + +# Global ARG — must be before any FROM to be usable in FROM lines +ARG GATEWAY_VERSION=latest + +# --------------------------------------------------------------------------- +# Stage 1: Rust builder +# --------------------------------------------------------------------------- +FROM rust:1.90-bookworm AS rust-builder + +WORKDIR /src + +# Install build dependencies (cmake required by quiche/BoringSSL, go required by quiche) +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake \ + golang-go \ + nasm \ + && rm -rf /var/lib/apt/lists/* + +# Copy manifests first for better layer caching +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY crates crates +COPY devolutions-gateway devolutions-gateway +COPY devolutions-agent devolutions-agent +COPY devolutions-session devolutions-session +COPY jetsocat jetsocat +COPY testsuite testsuite +COPY tools tools +COPY fuzz fuzz + +# Build only the gateway binary in release mode +RUN cargo build --release --package devolutions-gateway \ + && cp target/release/devolutions-gateway /usr/local/bin/devolutions-gateway + +# --------------------------------------------------------------------------- +# Stage 2: Extract libxmf + PowerShell module from the official image +# --------------------------------------------------------------------------- +FROM devolutions/devolutions-gateway:${GATEWAY_VERSION} AS official-image + +# --------------------------------------------------------------------------- +# Stage 3: Runtime +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim + +LABEL maintainer="Devolutions Inc." +LABEL description="Devolutions Gateway — built from source with QUIC agent tunnel" + +# Install PowerShell and runtime dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends wget ca-certificates openssl curl \ + && ARCH=$(dpkg --print-architecture) \ + && if [ "$ARCH" = "arm64" ]; then \ + PWSH_VERSION=7.4.6 \ + && wget -q "https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell-${PWSH_VERSION}-linux-arm64.tar.gz" \ + && mkdir -p /opt/microsoft/powershell/7 \ + && tar -xzf "powershell-${PWSH_VERSION}-linux-arm64.tar.gz" -C /opt/microsoft/powershell/7 \ + && chmod +x /opt/microsoft/powershell/7/pwsh \ + && ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \ + && rm "powershell-${PWSH_VERSION}-linux-arm64.tar.gz"; \ + else \ + wget -q https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb -O packages-microsoft-prod.deb \ + && dpkg -i packages-microsoft-prod.deb \ + && rm packages-microsoft-prod.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends powershell; \ + fi \ + && rm -rf /var/lib/apt/lists/* + +ENV XDG_CACHE_HOME="/tmp/.cache" +ENV XDG_DATA_HOME="/tmp/.local/share" +ENV POWERSHELL_TELEMETRY_OPTOUT="1" + +ENV DGATEWAY_CONFIG_PATH="/tmp/devolutions-gateway" +RUN mkdir -p "$DGATEWAY_CONFIG_PATH" + +WORKDIR /opt/devolutions/gateway + +ENV DGATEWAY_EXECUTABLE_PATH="/opt/devolutions/gateway/devolutions-gateway" +ENV DGATEWAY_LIB_XMF_PATH="/opt/devolutions/gateway/libxmf.so" +ENV DGATEWAY_WEBAPP_PATH="/opt/devolutions/gateway/webapp" + +# Gateway binary — built from THIS repo's source code +COPY --from=rust-builder /usr/local/bin/devolutions-gateway $DGATEWAY_EXECUTABLE_PATH + +# Webapp — pre-built locally (pnpm build:gateway), output in webapp/dist/gateway-ui/ +COPY webapp/dist/gateway-ui/ /opt/devolutions/gateway/webapp/client/ + +# libxmf — from official image (native library, not built from source) +COPY --from=official-image /opt/devolutions/gateway/libxmf.so $DGATEWAY_LIB_XMF_PATH + +# PowerShell module — from official image (includes pre-compiled .NET DLLs) +COPY --from=official-image /opt/microsoft/powershell/7/Modules/DevolutionsGateway /opt/microsoft/powershell/7/Modules/DevolutionsGateway + +# Entrypoint script from this repo's source +COPY package/Linux/entrypoint.ps1 /usr/local/bin/entrypoint.ps1 +RUN chmod +x /usr/local/bin/entrypoint.ps1 + +EXPOSE 7171 +EXPOSE 8181 +EXPOSE 4433/udp + +HEALTHCHECK --interval=30s --timeout=10s --retries=5 --start-period=15s \ + CMD curl -sf http://localhost:7171/jet/health || exit 1 + +ENTRYPOINT ["pwsh", "-File", "/usr/local/bin/entrypoint.ps1"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..0c16a0289 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +# documentation: https://docs.devolutions.net/gateway/standalone/ +# slogan: Protocol-aware relay server with QUIC agent tunnel for private network access (RDP, SSH, VNC, Telnet, ARD) +# tags: devolutions,gateway,rdp,ssh,vnc,telnet,remote-access,relay,quic,agent-tunnel +# port: 7171 + +services: + gateway: + image: irvingou/devolutions-gateway:quic-tunnel-v6 + # To build from source instead of using the pre-built image, comment out + # "image:" above and uncomment the line below: + # build: . + environment: + # -- Coolify Magic Variables -- + # SERVICE_FQDN_GATEWAY_7171 tells Traefik to route traffic to port 7171 inside the container. + - SERVICE_FQDN_GATEWAY_7171 + # Auto-generated credentials — visible in Coolify's environment variables UI. + - WEB_APP_USERNAME=${SERVICE_USER_GATEWAY} + - WEB_APP_PASSWORD=${SERVICE_PASSWORD_GATEWAY} + + # -- Gateway Standalone Configuration -- + # Enable the built-in web application (admin UI + web-based remote access). + - WEB_APP_ENABLED=true + # Internal scheme is HTTP; Coolify's Traefik reverse proxy terminates TLS. + - WEB_SCHEME=http + # Tell the gateway that clients reach it over HTTPS (via Traefik). + - EXTERNAL_WEB_SCHEME=https + # Session recording storage path inside the container. + - RECORDING_PATH=/recordings + # Logging verbosity: Default, Debug, Tls, All, Quiet + - VERBOSITY_PROFILE=${VERBOSITY_PROFILE:-Debug} + + # -- QUIC Agent Tunnel -- + # Enable QUIC listener for agent-based private network routing. + - AGENT_TUNNEL_ENABLED=true + # QUIC listener port (UDP). Agents connect to this port. + - AGENT_TUNNEL_PORT=${AGENT_TUNNEL_PORT:-4433} + + volumes: + # Persist session recordings across redeployments. + - gateway-recordings:/recordings + # Persist gateway configuration (provisioner keys, config files, agent certs). + - gateway-config:/tmp/devolutions-gateway + + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:7171/jet/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 15s + + ports: + # QUIC agent tunnel — UDP, must bypass Traefik, exposed directly on host + - "${AGENT_TUNNEL_PORT:-4433}:${AGENT_TUNNEL_PORT:-4433}/udp" + +volumes: + gateway-recordings: + gateway-config: diff --git a/package/AgentWindowsManaged/Actions/AgentActions.cs b/package/AgentWindowsManaged/Actions/AgentActions.cs index 407b8bebf..b3d9779e4 100644 --- a/package/AgentWindowsManaged/Actions/AgentActions.cs +++ b/package/AgentWindowsManaged/Actions/AgentActions.cs @@ -257,6 +257,18 @@ internal static class AgentActions When = When.Before }; + private static readonly ElevatedManagedAction enrollAgentTunnel = new( + new Id($"CA.{nameof(enrollAgentTunnel)}"), + CustomActions.EnrollAgentTunnel, + Return.check, + When.Before, Step.StartServices, + Condition.NOT_BeingRemoved, + Sequence.InstallExecuteSequence) + { + Execute = Execute.deferred, + Impersonate = false, + }; + private static readonly ElevatedManagedAction registerExplorerCommand = new( CustomActions.RegisterExplorerCommand ) @@ -329,6 +341,7 @@ private static string UseProperties(IEnumerable properties) getInstallDirFromRegistry, setArpInstallLocation, configureFeatures, + enrollAgentTunnel, createProgramDataDirectory, setProgramDataDirectoryPermissions, createProgramDataPedmDirectories, diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index 10d3c4185..f9fcb8e66 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -290,6 +290,100 @@ static ActionResult ToggleAgentFeature(Session session, string feature, bool ena } } + [CustomAction] + public static ActionResult EnrollAgentTunnel(Session session) + { + string enrollmentString = session.Property(AgentProperties.AgentTunnelEnrollmentString); + string subnetsRaw = session.Property(AgentProperties.AgentTunnelAdvertiseSubnets); + + if (string.IsNullOrWhiteSpace(enrollmentString)) + { + session.Log("Agent tunnel enrollment string not provided, skipping tunnel setup"); + return ActionResult.Success; + } + + try + { + // Parse enrollment string to extract gateway URL, token, and name. + // Format: dgw-enroll:v1: + const string prefix = "dgw-enroll:v1:"; + if (!enrollmentString.StartsWith(prefix)) + { + session.Log("Invalid enrollment string prefix"); + return ActionResult.Failure; + } + + string base64 = enrollmentString.Substring(prefix.Length); + byte[] decoded = Convert.FromBase64String(base64.Replace('-', '+').Replace('_', '/')); + string json = System.Text.Encoding.UTF8.GetString(decoded); + + var payload = JsonConvert.DeserializeObject>(json); + string apiBaseUrl = payload["api_base_url"]?.ToString(); + string enrollmentToken = payload["enrollment_token"]?.ToString(); + string agentName = payload.ContainsKey("name") && payload["name"] != null + ? payload["name"].ToString() + : Environment.MachineName; + + if (string.IsNullOrEmpty(agentName)) + { + agentName = Environment.MachineName; + } + + // Build CLI arguments for: devolutions-agent.exe enroll [subnets] + string configPath = Path.Combine(ProgramDataDirectory, "agent.json"); + string installDir = session.Property(AgentProperties.InstallDir); + string exePath = Path.Combine(installDir, Includes.EXECUTABLE_NAME); + + string subnetsArg = string.IsNullOrWhiteSpace(subnetsRaw) ? "" : subnetsRaw.Trim(); + + string arguments = $"enroll \"{apiBaseUrl}\" \"{enrollmentToken}\" \"{agentName}\" \"{configPath}\""; + if (!string.IsNullOrEmpty(subnetsArg)) + { + arguments += $" \"{subnetsArg}\""; + } + + session.Log($"Running enrollment: {exePath} {arguments.Replace(enrollmentToken, "***")}"); + + ProcessStartInfo startInfo = new ProcessStartInfo(exePath, arguments) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = ProgramDataDirectory, + }; + + using Process process = Process.Start(startInfo); + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(60_000); // 60 second timeout + + if (!string.IsNullOrEmpty(stdout)) + { + session.Log($"enrollment stdout: {stdout}"); + } + + if (!string.IsNullOrEmpty(stderr)) + { + session.Log($"enrollment stderr: {stderr}"); + } + + if (process.ExitCode != 0) + { + session.Log($"Enrollment failed with exit code {process.ExitCode}"); + return ActionResult.Failure; + } + + session.Log("Agent tunnel enrollment completed successfully"); + return ActionResult.Success; + } + catch (Exception e) + { + session.Log($"Agent tunnel enrollment failed: {e}"); + return ActionResult.Failure; + } + } + [CustomAction] public static ActionResult ConfigureFeatures(Session session) { diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs new file mode 100644 index 000000000..cbfc45aa5 --- /dev/null +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.Designer.cs @@ -0,0 +1,324 @@ +using WixSharp; +using WixSharp.UI.Forms; + +namespace WixSharpSetup.Dialogs +{ + partial class AgentTunnelDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.middlePanel = new System.Windows.Forms.Panel(); + this.tableLayoutPanel2 = new System.Windows.Forms.TableLayoutPanel(); + this.labelEnrollmentString = new System.Windows.Forms.Label(); + this.enrollmentString = new System.Windows.Forms.TextBox(); + this.labelSubnets = new System.Windows.Forms.Label(); + this.advertiseSubnets = new System.Windows.Forms.TextBox(); + this.labelSubnetsHint = new System.Windows.Forms.Label(); + this.topBorder = new System.Windows.Forms.Panel(); + this.topPanel = new System.Windows.Forms.Panel(); + this.label2 = new System.Windows.Forms.Label(); + this.label1 = new System.Windows.Forms.Label(); + this.banner = new System.Windows.Forms.PictureBox(); + this.bottomPanel = new System.Windows.Forms.Panel(); + this.tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel(); + this.back = new System.Windows.Forms.Button(); + this.next = new System.Windows.Forms.Button(); + this.cancel = new System.Windows.Forms.Button(); + this.border1 = new System.Windows.Forms.Panel(); + this.middlePanel.SuspendLayout(); + this.tableLayoutPanel2.SuspendLayout(); + this.topPanel.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.banner)).BeginInit(); + this.bottomPanel.SuspendLayout(); + this.tableLayoutPanel1.SuspendLayout(); + this.SuspendLayout(); + // + // middlePanel + // + this.middlePanel.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.middlePanel.Controls.Add(this.tableLayoutPanel2); + this.middlePanel.Location = new System.Drawing.Point(22, 75); + this.middlePanel.Name = "middlePanel"; + this.middlePanel.Size = new System.Drawing.Size(449, 225); + this.middlePanel.TabIndex = 0; + // + // tableLayoutPanel2 + // + this.tableLayoutPanel2.ColumnCount = 1; + this.tableLayoutPanel2.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + this.tableLayoutPanel2.Controls.Add(this.labelEnrollmentString, 0, 0); + this.tableLayoutPanel2.Controls.Add(this.enrollmentString, 0, 1); + this.tableLayoutPanel2.Controls.Add(this.labelSubnets, 0, 2); + this.tableLayoutPanel2.Controls.Add(this.advertiseSubnets, 0, 3); + this.tableLayoutPanel2.Controls.Add(this.labelSubnetsHint, 0, 4); + this.tableLayoutPanel2.Dock = System.Windows.Forms.DockStyle.Fill; + this.tableLayoutPanel2.Location = new System.Drawing.Point(0, 0); + this.tableLayoutPanel2.Name = "tableLayoutPanel2"; + this.tableLayoutPanel2.RowCount = 5; + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + this.tableLayoutPanel2.Size = new System.Drawing.Size(449, 225); + this.tableLayoutPanel2.TabIndex = 0; + // + // labelEnrollmentString + // + this.labelEnrollmentString.AutoSize = true; + this.labelEnrollmentString.BackColor = System.Drawing.Color.Transparent; + this.labelEnrollmentString.Location = new System.Drawing.Point(3, 3); + this.labelEnrollmentString.Margin = new System.Windows.Forms.Padding(3); + this.labelEnrollmentString.Name = "labelEnrollmentString"; + this.labelEnrollmentString.Size = new System.Drawing.Size(200, 13); + this.labelEnrollmentString.TabIndex = 0; + this.labelEnrollmentString.Text = "[AgentTunnelDlgEnrollmentStringLabel]"; + // + // enrollmentString + // + this.enrollmentString.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.enrollmentString.Location = new System.Drawing.Point(3, 22); + this.enrollmentString.Multiline = true; + this.enrollmentString.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.enrollmentString.Name = "enrollmentString"; + this.enrollmentString.Size = new System.Drawing.Size(443, 60); + this.enrollmentString.TabIndex = 1; + // + // labelSubnets + // + this.labelSubnets.AutoSize = true; + this.labelSubnets.BackColor = System.Drawing.Color.Transparent; + this.labelSubnets.Location = new System.Drawing.Point(3, 93); + this.labelSubnets.Margin = new System.Windows.Forms.Padding(3, 8, 3, 3); + this.labelSubnets.Name = "labelSubnets"; + this.labelSubnets.Size = new System.Drawing.Size(200, 13); + this.labelSubnets.TabIndex = 2; + this.labelSubnets.Text = "[AgentTunnelDlgSubnetsLabel]"; + // + // advertiseSubnets + // + this.advertiseSubnets.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.advertiseSubnets.Location = new System.Drawing.Point(3, 112); + this.advertiseSubnets.Name = "advertiseSubnets"; + this.advertiseSubnets.Size = new System.Drawing.Size(443, 20); + this.advertiseSubnets.TabIndex = 3; + // + // labelSubnetsHint + // + this.labelSubnetsHint.AutoSize = true; + this.labelSubnetsHint.BackColor = System.Drawing.Color.Transparent; + this.labelSubnetsHint.ForeColor = System.Drawing.SystemColors.GrayText; + this.labelSubnetsHint.Location = new System.Drawing.Point(3, 138); + this.labelSubnetsHint.Margin = new System.Windows.Forms.Padding(3, 3, 3, 3); + this.labelSubnetsHint.Name = "labelSubnetsHint"; + this.labelSubnetsHint.Size = new System.Drawing.Size(300, 13); + this.labelSubnetsHint.TabIndex = 4; + this.labelSubnetsHint.Text = "[AgentTunnelDlgSubnetsHint]"; + // + // topBorder + // + this.topBorder.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.topBorder.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.topBorder.Location = new System.Drawing.Point(0, 58); + this.topBorder.Name = "topBorder"; + this.topBorder.Size = new System.Drawing.Size(494, 1); + this.topBorder.TabIndex = 15; + // + // topPanel + // + this.topPanel.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.topPanel.BackColor = System.Drawing.SystemColors.Control; + this.topPanel.Controls.Add(this.label2); + this.topPanel.Controls.Add(this.label1); + this.topPanel.Controls.Add(this.banner); + this.topPanel.Location = new System.Drawing.Point(0, 0); + this.topPanel.Name = "topPanel"; + this.topPanel.Size = new System.Drawing.Size(494, 58); + this.topPanel.TabIndex = 10; + // + // label2 + // + this.label2.AutoEllipsis = true; + this.label2.BackColor = System.Drawing.Color.Transparent; + this.label2.ForeColor = System.Drawing.SystemColors.HighlightText; + this.label2.Location = new System.Drawing.Point(18, 31); + this.label2.Name = "label2"; + this.label2.Size = new System.Drawing.Size(409, 24); + this.label2.TabIndex = 1; + this.label2.Text = "[AgentTunnelDlgDescription]"; + // + // label1 + // + this.label1.AutoSize = true; + this.label1.BackColor = System.Drawing.Color.Transparent; + this.label1.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.label1.ForeColor = System.Drawing.SystemColors.HighlightText; + this.label1.Location = new System.Drawing.Point(11, 8); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(109, 13); + this.label1.TabIndex = 1; + this.label1.Text = "[AgentTunnelDlgTitle]"; + // + // banner + // + this.banner.BackColor = System.Drawing.Color.White; + this.banner.Location = new System.Drawing.Point(0, 0); + this.banner.Name = "banner"; + this.banner.Size = new System.Drawing.Size(494, 58); + this.banner.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.banner.TabIndex = 0; + this.banner.TabStop = false; + // + // bottomPanel + // + this.bottomPanel.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.bottomPanel.BackColor = System.Drawing.SystemColors.Control; + this.bottomPanel.Controls.Add(this.tableLayoutPanel1); + this.bottomPanel.Controls.Add(this.border1); + this.bottomPanel.Location = new System.Drawing.Point(0, 312); + this.bottomPanel.Name = "bottomPanel"; + this.bottomPanel.Size = new System.Drawing.Size(494, 49); + this.bottomPanel.TabIndex = 9; + // + // tableLayoutPanel1 + // + this.tableLayoutPanel1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right))); + this.tableLayoutPanel1.ColumnCount = 5; + this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 14F)); + this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + this.tableLayoutPanel1.Controls.Add(this.back, 1, 0); + this.tableLayoutPanel1.Controls.Add(this.next, 2, 0); + this.tableLayoutPanel1.Controls.Add(this.cancel, 4, 0); + this.tableLayoutPanel1.Location = new System.Drawing.Point(0, 3); + this.tableLayoutPanel1.Name = "tableLayoutPanel1"; + this.tableLayoutPanel1.RowCount = 1; + this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + this.tableLayoutPanel1.Size = new System.Drawing.Size(493, 43); + this.tableLayoutPanel1.TabIndex = 8; + // + // back + // + this.back.Anchor = System.Windows.Forms.AnchorStyles.Right; + this.back.AutoSize = true; + this.back.Location = new System.Drawing.Point(224, 10); + this.back.MinimumSize = new System.Drawing.Size(75, 0); + this.back.Name = "back"; + this.back.Size = new System.Drawing.Size(77, 23); + this.back.TabIndex = 1; + this.back.Text = "[WixUIBack]"; + this.back.UseVisualStyleBackColor = true; + this.back.Click += new System.EventHandler(this.Back_Click); + // + // next + // + this.next.Anchor = System.Windows.Forms.AnchorStyles.Right; + this.next.AutoSize = true; + this.next.Location = new System.Drawing.Point(307, 10); + this.next.MinimumSize = new System.Drawing.Size(75, 0); + this.next.Name = "next"; + this.next.Size = new System.Drawing.Size(77, 23); + this.next.TabIndex = 0; + this.next.Text = "[WixUINext]"; + this.next.UseVisualStyleBackColor = true; + this.next.Click += new System.EventHandler(this.Next_Click); + // + // cancel + // + this.cancel.Anchor = System.Windows.Forms.AnchorStyles.Right; + this.cancel.AutoSize = true; + this.cancel.Location = new System.Drawing.Point(404, 10); + this.cancel.MinimumSize = new System.Drawing.Size(75, 0); + this.cancel.Name = "cancel"; + this.cancel.Size = new System.Drawing.Size(86, 23); + this.cancel.TabIndex = 2; + this.cancel.Text = "[WixUICancel]"; + this.cancel.UseVisualStyleBackColor = true; + this.cancel.Click += new System.EventHandler(this.Cancel_Click); + // + // border1 + // + this.border1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.border1.Dock = System.Windows.Forms.DockStyle.Top; + this.border1.Location = new System.Drawing.Point(0, 0); + this.border1.Name = "border1"; + this.border1.Size = new System.Drawing.Size(494, 1); + this.border1.TabIndex = 14; + // + // AgentTunnelDialog + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.ClientSize = new System.Drawing.Size(494, 361); + this.Controls.Add(this.middlePanel); + this.Controls.Add(this.topBorder); + this.Controls.Add(this.topPanel); + this.Controls.Add(this.bottomPanel); + this.Name = "AgentTunnelDialog"; + this.Load += new System.EventHandler(this.OnLoad); + this.middlePanel.ResumeLayout(false); + this.tableLayoutPanel2.ResumeLayout(false); + this.tableLayoutPanel2.PerformLayout(); + this.topPanel.ResumeLayout(false); + this.topPanel.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)(this.banner)).EndInit(); + this.bottomPanel.ResumeLayout(false); + this.tableLayoutPanel1.ResumeLayout(false); + this.tableLayoutPanel1.PerformLayout(); + this.ResumeLayout(false); + } + + #endregion + + private System.Windows.Forms.PictureBox banner; + private System.Windows.Forms.Panel topPanel; + private System.Windows.Forms.Label label2; + private System.Windows.Forms.Label label1; + private System.Windows.Forms.Panel bottomPanel; + private System.Windows.Forms.Panel border1; + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1; + private System.Windows.Forms.Button back; + private System.Windows.Forms.Button next; + private System.Windows.Forms.Button cancel; + private System.Windows.Forms.Panel topBorder; + private System.Windows.Forms.Panel middlePanel; + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel2; + private System.Windows.Forms.Label labelEnrollmentString; + private System.Windows.Forms.TextBox enrollmentString; + private System.Windows.Forms.Label labelSubnets; + private System.Windows.Forms.TextBox advertiseSubnets; + private System.Windows.Forms.Label labelSubnetsHint; + } +} diff --git a/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs new file mode 100644 index 000000000..495c70e7d --- /dev/null +++ b/package/AgentWindowsManaged/Dialogs/AgentTunnelDialog.cs @@ -0,0 +1,65 @@ +using DevolutionsAgent.Dialogs; +using DevolutionsAgent.Properties; + +using System; +using System.Windows.Forms; + +using WixSharp; + +namespace WixSharpSetup.Dialogs; + +public partial class AgentTunnelDialog : AgentDialog +{ + public AgentTunnelDialog() + { + InitializeComponent(); + label1.MakeTransparentOn(banner); + label2.MakeTransparentOn(banner); + } + + public override bool ToProperties() + { + Runtime.Session[AgentProperties.AgentTunnelEnrollmentString] = enrollmentString.Text.Trim(); + Runtime.Session[AgentProperties.AgentTunnelAdvertiseSubnets] = advertiseSubnets.Text.Trim(); + + return true; + } + + public override void OnLoad(object sender, EventArgs e) + { + banner.Image = Runtime.Session.GetResourceBitmap("WixUI_Bmp_Banner"); + + enrollmentString.Text = Runtime.Session.Property(AgentProperties.AgentTunnelEnrollmentString) ?? ""; + advertiseSubnets.Text = Runtime.Session.Property(AgentProperties.AgentTunnelAdvertiseSubnets) ?? ""; + + base.OnLoad(sender, e); + } + + public override bool DoValidate() + { + // Tunnel is optional — if enrollment string is empty, skip tunnel setup entirely. + if (string.IsNullOrWhiteSpace(enrollmentString.Text)) + { + return true; + } + + string text = enrollmentString.Text.Trim(); + + if (!text.StartsWith("dgw-enroll:v1:")) + { + ShowValidationErrorString("Invalid enrollment string. Expected format: dgw-enroll:v1:"); + return false; + } + + return true; + } + + // ReSharper disable once RedundantOverriddenMember + protected override void Back_Click(object sender, EventArgs e) => base.Back_Click(sender, e); + + // ReSharper disable once RedundantOverriddenMember + protected override void Next_Click(object sender, EventArgs e) => base.Next_Click(sender, e); + + // ReSharper disable once RedundantOverriddenMember + protected override void Cancel_Click(object sender, EventArgs e) => base.Cancel_Click(sender, e); +} diff --git a/package/AgentWindowsManaged/Dialogs/Wizard.cs b/package/AgentWindowsManaged/Dialogs/Wizard.cs index df1f71f78..eb89d0efc 100644 --- a/package/AgentWindowsManaged/Dialogs/Wizard.cs +++ b/package/AgentWindowsManaged/Dialogs/Wizard.cs @@ -21,6 +21,7 @@ static Wizard() { typeof(WelcomeDialog), typeof(FeaturesDialog), + typeof(AgentTunnelDialog), typeof(InstallDirDialog), }; diff --git a/package/AgentWindowsManaged/Properties/AgentProperties.cs b/package/AgentWindowsManaged/Properties/AgentProperties.cs index 1010fb969..9f7c1761a 100644 --- a/package/AgentWindowsManaged/Properties/AgentProperties.cs +++ b/package/AgentWindowsManaged/Properties/AgentProperties.cs @@ -16,6 +16,16 @@ internal partial class AgentProperties /// public static string InstallDir = "INSTALLDIR"; + /// + /// Agent tunnel enrollment string (dgw-enroll:v1:...) + /// + public static string AgentTunnelEnrollmentString = "AGENT_TUNNEL_ENROLLMENT_STRING"; + + /// + /// Comma-separated subnets to advertise (e.g., "10.10.0.0/24, 192.168.1.0/24") + /// + public static string AgentTunnelAdvertiseSubnets = "AGENT_TUNNEL_ADVERTISE_SUBNETS"; + public AgentProperties(ISession runtimeSession) { this.runtimeSession = runtimeSession; diff --git a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl index ba52af25b..14a08c453 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl @@ -57,4 +57,12 @@ If it appears minimized then active it from the taskbar. Ready to update [ProductName] Welcome to the [ProductName] 20[ProductVersion] Setup Wizard + + Agent Tunnel Configuration + Configure the agent tunnel to connect to a Devolutions Gateway. Leave blank to skip. + Enrollment String (from Gateway web interface): + Advertise Subnets: + Comma-separated CIDR notation, e.g. 10.10.0.0/24, 192.168.1.0/24. Leave blank for auto-detection. + Install [ProductName] to: + Change... diff --git a/package/Linux/entrypoint.ps1 b/package/Linux/entrypoint.ps1 index 6418bb11e..427456a2c 100644 --- a/package/Linux/entrypoint.ps1 +++ b/package/Linux/entrypoint.ps1 @@ -211,5 +211,31 @@ if ($WebScheme -eq 'https' -and Remove-Item @($TlsCertificateFile, $TlsPrivateKeyFile) -ErrorAction SilentlyContinue | Out-Null } +# -- QUIC Agent Tunnel -- +$AgentTunnelEnabled = $false +if ($Env:AGENT_TUNNEL_ENABLED) { + try { + $AgentTunnelEnabled = [bool]::Parse($Env:AGENT_TUNNEL_ENABLED) + } catch { + $AgentTunnelEnabled = $false + } +} + +if ($AgentTunnelEnabled) { + $TunnelPort = if ($Env:AGENT_TUNNEL_PORT) { [int]$Env:AGENT_TUNNEL_PORT } else { 4433 } + + # Patch gateway.json with agent tunnel config + $ConfigFile = "$Env:DGATEWAY_CONFIG_PATH/gateway.json" + if (Test-Path $ConfigFile) { + $json = Get-Content $ConfigFile -Raw | ConvertFrom-Json + $json | Add-Member -NotePropertyName 'AgentTunnel' -NotePropertyValue @{ + Enabled = $true + ListenPort = $TunnelPort + } -Force + $json | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile + Write-Host "QUIC Agent Tunnel enabled on UDP port $TunnelPort" + } +} + & "$Env:DGATEWAY_EXECUTABLE_PATH" [System.Environment]::ExitCode = $LASTEXITCODE diff --git a/tests/e2e/docker-compose.e2e.yml b/tests/e2e/docker-compose.e2e.yml new file mode 100644 index 000000000..d2691e87d --- /dev/null +++ b/tests/e2e/docker-compose.e2e.yml @@ -0,0 +1,22 @@ +services: + ssh-target: + image: lscr.io/linuxserver/openssh-server:latest + environment: + - PUID=1000 + - PGID=1000 + - USER_NAME=testuser + - USER_PASSWORD=testpass + - PASSWORD_ACCESS=true + - LISTEN_PORT=22 + ports: + - "22:22" + networks: + agent-net: + ipv4_address: 10.55.0.2 + +networks: + agent-net: + driver: bridge + ipam: + config: + - subnet: 10.55.0.0/24 diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 000000000..6a4669570 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,94 @@ +{ + "name": "agent-tunnel-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agent-tunnel-e2e", + "devDependencies": { + "@playwright/test": "^1.52.0", + "@types/node": "^25.5.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 000000000..22450fee4 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "agent-tunnel-e2e", + "private": true, + "scripts": { + "test": "npx playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.52.0", + "@types/node": "^25.5.2" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 000000000..981e750af --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + timeout: 60_000, + retries: 0, + use: { + baseURL: 'http://localhost:4200', + headless: false, + screenshot: 'only-on-failure', + }, +}); diff --git a/tests/e2e/ssh-e2e.spec.ts b/tests/e2e/ssh-e2e.spec.ts new file mode 100644 index 000000000..f5afe5d57 --- /dev/null +++ b/tests/e2e/ssh-e2e.spec.ts @@ -0,0 +1,162 @@ +import { test, expect } from '@playwright/test'; +import { execSync, spawn } from 'child_process'; + +// Black-box E2E: walk the full user flow from enrollment to SSH session. +// No pre-existing config, no API shortcuts — everything through the webapp UI. + +const AGENT_BIN = 'D:\\devolutions-gateway-quic-agent-tunnel\\target\\release\\devolutions-agent.exe'; +const AGENT_CONFIG_DIR = 'C:\\ProgramData\\Devolutions\\Agent-E2E-BlackBox'; + +test('Full user flow: enroll agent from UI, SSH through tunnel', async ({ page }) => { + test.setTimeout(180_000); + + // Clean up any previous agent config. + try { execSync(`rmdir /s /q "${AGENT_CONFIG_DIR}"`, { stdio: 'ignore' }); } catch {} + execSync(`mkdir "${AGENT_CONFIG_DIR}"`, { stdio: 'ignore' }); + + // --- Step 1: Navigate to Agents page --- + await page.goto('/jet/webapp/client'); + await page.waitForLoadState('networkidle'); + await page.locator('text=Agents').first().click(); + await page.waitForTimeout(2000); + + // --- Step 2: Click Enroll Agent, fill form, generate enrollment string --- + const enrollBtn = page.locator('button:has-text("Enroll"), button:has-text("enrollment")').first(); + await enrollBtn.click(); + await page.waitForTimeout(1000); + + // Fill the enrollment form fields. + const gatewayUrlInput = page.locator('input[placeholder*="Gateway"], input[placeholder*="URL"], #apiBaseUrl').first(); + if (await gatewayUrlInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await gatewayUrlInput.clear(); + await gatewayUrlInput.fill('http://127.0.0.1:7272'); + } + + // Set QUIC host to 127.0.0.1 (avoid localhost → IPv6 resolution). + const quicHostInput = page.locator('input[placeholder*="QUIC"], input[placeholder*="Host"], #quicHost').first(); + if (await quicHostInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await quicHostInput.clear(); + await quicHostInput.fill('127.0.0.1'); + } + + // Click Generate. + await page.locator('button:has-text("Generate")').first().click(); + await page.waitForTimeout(3000); + + // --- Step 3: Extract the enrollment string from the page --- + await page.screenshot({ path: 'test-results/01-enrollment-generated.png' }); + + const enrollmentText = await page.locator('code, .enrollment-string-block, pre').first().innerText(); + expect(enrollmentText).toContain('dgw-enroll:v1:'); + const enrollmentString = enrollmentText.trim(); + console.log('Enrollment string:', enrollmentString.slice(0, 60) + '...'); + + // --- Step 4: Enroll agent, then start it (two-step: enroll writes config, run starts tunnel) --- + // Step 4a: Enroll (writes certs + config to default path). + try { + const enrollOutput = execSync( + `"${AGENT_BIN}" up --enrollment-string "${enrollmentString}" --name e2e-blackbox-agent --advertise-subnets 127.0.0.0/8`, + { stdio: 'pipe', timeout: 30000 }, + ); + console.log('Enroll output:', enrollOutput.toString()); + } catch (e: any) { + console.error('Enroll failed:', e.stderr?.toString() || e.message); + throw e; + } + + // Step 4b: Start agent (reads config from default path, connects to gateway). + const agentProc = spawn(AGENT_BIN, ['run'], { + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + + let agentStderr = ''; + agentProc.stderr?.on('data', (d: Buffer) => { agentStderr += d.toString(); }); + agentProc.stdout?.on('data', (d: Buffer) => { console.log('[agent]', d.toString().trim()); }); + + // Wait for agent to connect. + await page.waitForTimeout(12000); + + console.log('Agent stderr:', agentStderr.slice(0, 500)); + console.log('Agent alive:', !agentProc.killed, 'pid:', agentProc.pid); + expect(agentProc.pid).toBeTruthy(); + + // --- Step 5: Close enrollment modal, refresh Agents page, verify agent appears --- + // Navigate away and back to refresh the agent list. + await page.goto('/jet/webapp/client'); + await page.waitForLoadState('networkidle'); + await page.locator('text=Agents').first().click(); + await page.waitForTimeout(3000); + // Click Refresh if available. + const refreshBtn = page.locator('button:has-text("Refresh")').first(); + if (await refreshBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await refreshBtn.click(); + await page.waitForTimeout(3000); + } + + const agentsPageText = await page.locator('body').innerText(); + await page.screenshot({ path: 'test-results/02-agent-connected.png' }); + console.log('Agents page:', agentsPageText.slice(0, 300)); + expect(agentsPageText).toContain('ONLINE'); + + // --- Step 6: Create SSH session through the tunnel --- + await page.goto('/jet/webapp/client'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + // Select SSH protocol. + await page.locator('#protocol').click(); + await page.locator('p-select-option span:text-is("SSH"), li:has-text("SSH")').first().click(); + await page.waitForTimeout(500); + + // Fill SSH form — Docker container on 127.0.0.1:22 (testuser/testpass). + await page.locator('p-autocomplete input[role="combobox"]').first().fill('127.0.0.1'); + await page.locator('#username').fill('testuser'); + await page.locator('input[placeholder="Enter Password"], p-password input').first().fill('testpass'); + + // Connect. + await page.locator('button:has-text("Connect Session")').click(); + + // --- Step 7: Wait for terminal, accept host key, verify commands --- + const terminal = page.locator('.xterm'); + await terminal.waitFor({ state: 'visible', timeout: 30000 }); + await page.waitForTimeout(3000); + await terminal.click(); + + const xtermRows = page.locator('.xterm-rows'); + let text = await xtermRows.innerText(); + + // Accept host key if prompted. + if (text.includes('yes/no')) { + await page.keyboard.type('yes'); + await page.keyboard.press('Enter'); + } + + // Wait for shell prompt. + for (let i = 0; i < 20; i++) { + await page.waitForTimeout(1000); + text = await xtermRows.innerText(); + if (text.includes('$') || text.includes('#')) break; + } + + await page.screenshot({ path: 'test-results/03-ssh-connected.png' }); + expect(text).toMatch(/[$#]/); + + // Run ls. + await page.keyboard.type('ls'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(2000); + + // Run echo hello. + await page.keyboard.type('echo "hello"'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(2000); + + text = await xtermRows.innerText(); + await page.screenshot({ path: 'test-results/04-commands-executed.png' }); + console.log('Terminal output:', text.slice(-200)); + expect(text).toContain('hello'); + + // --- Cleanup --- + agentProc.kill(); +});