diff --git a/src/components/provider-gateway/Dockerfile b/src/components/provider-gateway/Dockerfile index 9f694b4f..e0eaa04b 100644 --- a/src/components/provider-gateway/Dockerfile +++ b/src/components/provider-gateway/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1-alpine +FROM oven/bun:1.2.22-alpine ARG ALPINE_REPOSITORY= RUN if [ -n "$ALPINE_REPOSITORY" ]; then printf '%s\n' "$ALPINE_REPOSITORY/main" "$ALPINE_REPOSITORY/community" > /etc/apk/repositories; fi RUN apk add --no-cache bash docker-cli docker-cli-compose openssh-client diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index 5be45f1d..03e58105 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { createConnection, type Socket } from "node:net"; import { @@ -1974,6 +1975,7 @@ function startHostSshSession(message: CoreHostSshOpenMessage): void { } try { const command = typeof message.command === "string" && message.command.length > 0 ? message.command : null; + const commandDigest = command === null ? null : createHash("sha256").update(command).digest("hex").slice(0, 16); const allocateTty = typeof message.tty === "boolean" ? message.tty : command === null; const remoteScript = hostSshRemoteScript(command, message.cwd ?? null, allocateTty, message.cols, message.rows); const proc = Bun.spawn(["ssh", ...hostSshArgs(remoteScript, allocateTty)], { @@ -2009,7 +2011,16 @@ function startHostSshSession(message: CoreHostSshOpenMessage): void { hostSshSessions.delete(message.sessionId); releaseSshDataChannel(message.dataChannelId, message.sessionId); }); - logger("info", "host_ssh_session_started", { sessionId: message.sessionId, hasCommand: command !== null, tty: allocateTty, cwd: message.cwd ?? null, transport: "tcp-pool", dataChannelId: message.dataChannelId }); + logger("info", "host_ssh_session_started", { + sessionId: message.sessionId, + hasCommand: command !== null, + commandBytes: command === null ? 0 : Buffer.byteLength(command), + commandDigest, + tty: allocateTty, + cwd: message.cwd ?? null, + transport: "tcp-pool", + dataChannelId: message.dataChannelId, + }); } catch (error) { sendHostSshError(message.sessionId, error); releaseSshDataChannel(message.dataChannelId, message.sessionId); @@ -2155,6 +2166,21 @@ function upgradePlan(taskId: string): Record { ` echo "rollback_to_last_known_good restored old gateway container name=$old_name image=$old_image reason=$reason" >&2`, `}`, ].join("\n"); + const runtimeDependencyGuardScript = [ + `validate_gateway_runtime_dependencies() {`, + ` container="$1"`, + ` phase="$2"`, + ` docker exec "$container" /bin/sh -lc 'set -eu`, + `test -x /bin/sh`, + `test -x /usr/bin/ssh`, + `/usr/bin/ssh -V >/tmp/unidesk-provider-ssh-version 2>&1 || { cat /tmp/unidesk-provider-ssh-version >&2; exit 41; }`, + `command -v docker >/dev/null`, + `docker version --format "{{.Client.Version}}" >/dev/null`, + `test -e /lib/libz.so.1 -o -e /usr/lib/libz.so.1`, + `command -v bun >/dev/null' || { echo "$phase provider-gateway runtime dependency guard failed" >&2; return 1; }`, + ` echo "$phase provider-gateway runtime dependency guard passed: container=$container"`, + `}`, + ].join("\n"); const script = [ "set -eu", `cd ${shellQuote(workspace)}`, @@ -2174,6 +2200,7 @@ function upgradePlan(taskId: string): Record { `first_network=""`, `network_arg=""`, rollbackToLastKnownGoodScript, + runtimeDependencyGuardScript, `if [ -n "$old_ids" ]; then docker update --restart always $old_ids >/dev/null 2>&1 || true; fi`, `if [ -z "$first_old" ]; then echo "no existing provider-gateway compose container found; cannot perform safe in-place upgrade" >&2; exit 1; fi`, `old_name=$(docker inspect --format '{{.Name}}' "$first_old")`, @@ -2211,6 +2238,7 @@ function upgradePlan(taskId: string): Record { `candidate_restart=$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' ${shellQuote(candidateName)})`, `candidate_pid_mode=$(docker inspect --format '{{.HostConfig.PidMode}}' ${shellQuote(candidateName)})`, `if [ "$candidate_pid_mode" != "host" ]; then echo "candidate runtime guard failed: restart=$candidate_restart pid=$candidate_pid_mode" >&2; docker rm -f ${shellQuote(candidateName)} >/dev/null 2>&1 || true; rm -f "$candidate_env_file"; exit 1; fi`, + `validate_gateway_runtime_dependencies ${shellQuote(candidateName)} candidate || { docker logs ${shellQuote(candidateName)} >&2 || true; docker rm -f ${shellQuote(candidateName)} >/dev/null 2>&1 || true; rm -f "$candidate_env_file"; exit 1; }`, `if [ -n "$old_ids" ]; then docker rm -f $old_ids; fi`, `docker rm -f ${shellQuote(candidateName)} >/dev/null 2>&1 || true`, composeUpCommand.map(shellQuote).join(" "), @@ -2221,6 +2249,7 @@ function upgradePlan(taskId: string): Record { `final_restart=$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$final_container")`, `final_pid_mode=$(docker inspect --format '{{.HostConfig.PidMode}}' "$final_container")`, `if [ "$final_restart" != "always" ] || [ "$final_pid_mode" != "host" ]; then echo "final provider-gateway runtime guard failed: restart=$final_restart pid=$final_pid_mode" >&2; rollback_to_last_known_good "final-runtime-guard-failed" || true; rm -f "$candidate_env_file"; exit 1; fi`, + `if ! validate_gateway_runtime_dependencies "$final_container" final; then rollback_to_last_known_good "final-runtime-dependency-guard-failed" || true; rm -f "$candidate_env_file"; exit 1; fi`, `final_attempt=0`, `final_validated=0`, `while [ "$final_attempt" -lt "${finalValidationAttempts}" ]; do final_logs=$(docker logs "$final_container" 2>&1 || true); final_has_open=0; final_has_ack=0; final_has_ok=0; case "$final_logs" in *${shellQuote(validationNeedleOpen)}*) final_has_open=1;; esac; case "$final_logs" in *${shellQuote(validationNeedleAck)}*) final_has_ack=1;; esac; case "$final_logs" in *${shellQuote(validationNeedleOk)}*) final_has_ok=1;; esac; if [ "$final_has_open" = "1" ] && [ "$final_has_ack" = "1" ] && [ "$final_has_ok" = "1" ]; then final_validated=1; break; fi; final_running=$(docker inspect --format '{{.State.Running}}' "$final_container" 2>/dev/null || true); if [ "$final_running" != "true" ]; then break; fi; final_attempt=$((final_attempt + 1)); sleep 2; done`,