diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index b1cb181d..ab3eb1eb 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -62,3 +62,51 @@ targets: caddyConfigPath: /etc/caddy/Caddyfile caddyServiceName: caddy responseHeaderTimeoutSeconds: 600 + + - id: local-7681 + enabled: true + runtime: + mode: host-docker + workDir: /root/webterm/.worktree/fix-pty-fallback-7681 + composePath: /root/webterm/.worktree/fix-pty-fallback-7681/.state/runtime/docker-compose.7681.yaml + composeProject: webterm-7681 + serviceName: web-terminal + containerName: web-terminal + image: webterm-web-terminal-7681:latest + envFile: /root/webterm/.env + hostPort: 7681 + containerPort: 7681 + privileged: true + pid: host + sourceMounts: + - source: /root/webterm/.worktree/fix-pty-fallback-7681/scripts/host-shell.sh + target: /usr/local/bin/host-shell + readOnly: true + environment: + HOST: 0.0.0.0 + PORT: "7681" + SESSION_COOKIE_NAME: webterm_session_7681 + SESSION_MAX_AGE_SECONDS: "604800" + SERVER_PROVIDER_ID: remote-server + SERVER_PROVIDER_NAME: Remote Server + TERMINAL_SHELL: /usr/local/bin/host-shell + TERMINAL_CWD: /root + TERMINAL_REMOTE_CWD: /root/unidesk + TERMINAL_START_COMMAND: mycx + TERMINAL_START_FALLBACK_SHELL: "true" + TERMINAL_HISTORY_BYTES: "2097152" + TERMINAL_SNAPSHOT_BYTES: "0" + TERMINAL_SNAPSHOT_POLICY: tail + BROWSER_SNAPSHOT_WRITE_CHUNK_BYTES: "8192" + BROWSER_MANUAL_REDRAW_MODE: double + BROWSER_MANUAL_REDRAW_DELAY_MS: "60" + OUTPUT_BATCH_MS: "8" + OUTPUT_BATCH_BYTES: "32768" + BROWSER_KEEPALIVE_ENABLED: "true" + BROWSER_RESUME_PROBE_ENABLED: "false" + BROWSER_KEEPALIVE_INTERVAL_MS: "30000" + BROWSER_STALE_TIMEOUT_MS: "180000" + PROTOCOL_METRICS_ENABLED: "true" + PROTOCOL_METRICS_WINDOW: "400" + publicExposure: + enabled: false diff --git a/scripts/src/platform-infra-webterm.ts b/scripts/src/platform-infra-webterm.ts index 495e366e..847ac263 100644 --- a/scripts/src/platform-infra-webterm.ts +++ b/scripts/src/platform-infra-webterm.ts @@ -24,6 +24,21 @@ interface WebtermConfig { targets: WebtermTarget[]; } +interface EnabledWebtermPublicExposure { + enabled: true; + publicBaseUrl: string; + dns: { hostname: string; expectedA: string; resolvers: string[] }; + upstream: { host: string; port: number }; + pk01: { + route: string; + caddyConfigPath: string; + caddyServiceName: string; + responseHeaderTimeoutSeconds: number; + }; +} + +type WebtermPublicExposure = { enabled: false } | EnabledWebtermPublicExposure; + interface WebtermTarget { id: string; enabled: boolean; @@ -43,18 +58,7 @@ interface WebtermTarget { sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>; environment: Record; }; - publicExposure: { - enabled: boolean; - publicBaseUrl: string; - dns: { hostname: string; expectedA: string; resolvers: string[] }; - upstream: { host: string; port: number }; - pk01: { - route: string; - caddyConfigPath: string; - caddyServiceName: string; - responseHeaderTimeoutSeconds: number; - }; - }; + publicExposure: WebtermPublicExposure; } export async function runWebtermCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { @@ -132,6 +136,7 @@ function plan(targetId: string | null): Record { async function apply(config: UniDeskConfig, targetId: string | null, confirm: boolean): Promise> { const target = resolveTarget(targetId); + const exposure = target.publicExposure; const compose = renderCompose(target); const policy = policyChecks(target, compose); const failedPolicy = policy.filter((item) => item.ok === false); @@ -163,20 +168,20 @@ async function apply(config: UniDeskConfig, targetId: string | null, confirm: bo encoding: "utf8", }); const localProbe = localHttpProbeWithRetry(target.runtime.hostPort, "/login", 10, 500); - const caddy = target.publicExposure.enabled + const caddy = exposure.enabled ? await applyPk01CaddySiteBlock( config, serviceName, - caddyExposure(target), + caddyExposure(exposure), renderWebtermCaddySiteBlock({ - hostname: target.publicExposure.dns.hostname, - upstream: `${target.publicExposure.upstream.host}:${target.publicExposure.upstream.port}`, - responseHeaderTimeoutSeconds: target.publicExposure.pk01.responseHeaderTimeoutSeconds, + hostname: exposure.dns.hostname, + upstream: `${exposure.upstream.host}:${exposure.upstream.port}`, + responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds, }), caddyManagedBlockMarkers(serviceName), ) : { ok: true, action: "not-enabled" }; - const publicProbe = target.publicExposure.enabled ? publicHttpProbeWithRetry(target.publicExposure.publicBaseUrl, "/login", 6, 1000) : { ok: true, skipped: true }; + const publicProbe = exposure.enabled ? publicHttpProbeWithRetry(exposure.publicBaseUrl, "/login", 6, 1000) : { ok: true, skipped: true }; return { ok: composeResult.status === 0 && localProbe.ok === true && caddy.ok !== false && publicProbe.ok === true, mode: "confirmed", @@ -220,11 +225,12 @@ function status(targetId: string | null): Record { function validate(targetId: string | null): Record { const target = resolveTarget(targetId); - const dns = dnsProbe(target.publicExposure.dns.hostname); + const exposure = target.publicExposure; + const dns = exposure.enabled ? dnsProbe(exposure.dns.hostname) : { ok: true, skipped: true }; const localProbe = localHttpProbe(target.runtime.hostPort, "/login"); - const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true }; + const publicProbe = exposure.enabled ? publicHttpProbe(exposure.publicBaseUrl, "/login") : { ok: true, skipped: true }; return { - ok: localProbe.ok === true && publicProbe.ok === true && dns.addresses.includes(target.publicExposure.dns.expectedA), + ok: localProbe.ok === true && publicProbe.ok === true && (exposure.enabled ? dns.addresses.includes(exposure.dns.expectedA) : true), target: targetSummary(target), dns, localProbe, @@ -247,9 +253,6 @@ function readWebtermConfig(): WebtermConfig { function parseTarget(record: Record, path: string): WebtermTarget { const runtime = recordField(record, "runtime", path); const exposure = recordField(record, "publicExposure", path); - const dns = recordField(exposure, "dns", `${path}.publicExposure`); - const upstream = recordField(exposure, "upstream", `${path}.publicExposure`); - const pk01 = recordField(exposure, "pk01", `${path}.publicExposure`); const environment = parseEnvironment(recordField(runtime, "environment", `${path}.runtime`), `${path}.runtime.environment`); const sourceMounts = parseSourceMounts(runtime.sourceMounts, `${path}.runtime.sourceMounts`); const target: WebtermTarget = { @@ -271,29 +274,10 @@ function parseTarget(record: Record, path: string): WebtermTarg sourceMounts, environment, }, - publicExposure: { - enabled: booleanField(exposure, "enabled", `${path}.publicExposure`), - publicBaseUrl: stringField(exposure, "publicBaseUrl", `${path}.publicExposure`), - dns: { - hostname: stringField(dns, "hostname", `${path}.publicExposure.dns`), - expectedA: stringField(dns, "expectedA", `${path}.publicExposure.dns`), - resolvers: stringArray(dns.resolvers, `${path}.publicExposure.dns.resolvers`), - }, - upstream: { - host: stringField(upstream, "host", `${path}.publicExposure.upstream`), - port: portField(upstream, "port", `${path}.publicExposure.upstream`), - }, - pk01: { - route: stringField(pk01, "route", `${path}.publicExposure.pk01`), - caddyConfigPath: stringField(pk01, "caddyConfigPath", `${path}.publicExposure.pk01`), - caddyServiceName: stringField(pk01, "caddyServiceName", `${path}.publicExposure.pk01`), - responseHeaderTimeoutSeconds: integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.publicExposure.pk01`), - }, - }, + publicExposure: parsePublicExposure(exposure, `${path}.publicExposure`), }; if (target.runtime.hostPort === 7682) throw new Error(`${path}.runtime.hostPort must not reuse existing 7682`); if (target.runtime.containerName === "web-terminal-7682") throw new Error(`${path}.runtime.containerName must not reuse web-terminal-7682`); - if (!target.publicExposure.publicBaseUrl.startsWith("https://")) throw new Error(`${path}.publicExposure.publicBaseUrl must be https`); return target; } @@ -333,23 +317,30 @@ ${envLines} } function policyChecks(target: WebtermTarget, compose: string): Array & { ok: boolean }> { + const exposure = target.publicExposure; + const terminalShell = target.runtime.environment.TERMINAL_SHELL; + const shellMount = target.runtime.sourceMounts.find((mount) => mount.target === terminalShell); return [ { name: "enabled", ok: target.enabled, detail: "target must be enabled" }, { name: "no-7682-host-port", ok: target.runtime.hostPort !== 7682, detail: "existing 7682 service must not be reused" }, { name: "no-7682-container", ok: target.runtime.containerName !== "web-terminal-7682", detail: "existing 7682 container must not be reused" }, - { name: "compose-path-7683", ok: target.runtime.composePath.includes("7683"), detail: target.runtime.composePath }, - { name: "public-https", ok: target.publicExposure.publicBaseUrl.startsWith("https://"), detail: target.publicExposure.publicBaseUrl }, - { name: "compose-renders-7683", ok: compose.includes(`"${target.runtime.hostPort}:${target.runtime.containerPort}"`), detail: `${target.runtime.hostPort}:${target.runtime.containerPort}` }, - { name: "source-mounts-present", ok: target.runtime.sourceMounts.length >= 3, detail: `mounts=${target.runtime.sourceMounts.length}` }, + { name: "compose-path-target-port", ok: target.runtime.composePath.includes(String(target.runtime.hostPort)), detail: target.runtime.composePath }, + { name: "public-https", ok: !exposure.enabled || exposure.publicBaseUrl.startsWith("https://"), skipped: !exposure.enabled, detail: exposure.enabled ? exposure.publicBaseUrl : "public exposure disabled" }, + { name: "compose-renders-port", ok: compose.includes(`"${target.runtime.hostPort}:${target.runtime.containerPort}"`), detail: `${target.runtime.hostPort}:${target.runtime.containerPort}` }, + { + name: "terminal-shell-mounted-read-only", + ok: typeof terminalShell === "string" && shellMount?.readOnly === true, + detail: typeof terminalShell === "string" ? `${terminalShell}:${shellMount?.readOnly === true ? "ro" : "missing-or-writable"}` : "TERMINAL_SHELL missing", + }, ]; } -function caddyExposure(target: WebtermTarget): Parameters[2] { +function caddyExposure(exposure: EnabledWebtermPublicExposure): Parameters[2] { return { - enabled: target.publicExposure.enabled, - dns: { hostname: target.publicExposure.dns.hostname }, - frpc: { remotePort: target.publicExposure.upstream.port }, - pk01: target.publicExposure.pk01, + enabled: true, + dns: { hostname: exposure.dns.hostname }, + frpc: { remotePort: exposure.upstream.port }, + pk01: exposure.pk01, }; } @@ -385,8 +376,9 @@ function targetSummary(target: WebtermTarget): Record { } function exposureSummary(target: WebtermTarget): Record { + if (!target.publicExposure.enabled) return { enabled: false, skipped: true }; return { - enabled: target.publicExposure.enabled, + enabled: true, publicBaseUrl: target.publicExposure.publicBaseUrl, hostname: target.publicExposure.dns.hostname, expectedA: target.publicExposure.dns.expectedA, @@ -481,6 +473,35 @@ function parseSourceMounts(value: unknown, path: string): WebtermTarget["runtime }); } +function parsePublicExposure(record: Record, path: string): WebtermPublicExposure { + const enabled = booleanField(record, "enabled", path); + if (!enabled) return { enabled: false }; + const dns = recordField(record, "dns", path); + const upstream = recordField(record, "upstream", path); + const pk01 = recordField(record, "pk01", path); + const publicBaseUrl = stringField(record, "publicBaseUrl", path); + if (!publicBaseUrl.startsWith("https://")) throw new Error(`${path}.publicBaseUrl must be https`); + return { + enabled: true, + publicBaseUrl, + dns: { + hostname: stringField(dns, "hostname", `${path}.dns`), + expectedA: stringField(dns, "expectedA", `${path}.dns`), + resolvers: stringArray(dns.resolvers, `${path}.dns.resolvers`), + }, + upstream: { + host: stringField(upstream, "host", `${path}.upstream`), + port: portField(upstream, "port", `${path}.upstream`), + }, + pk01: { + route: stringField(pk01, "route", `${path}.pk01`), + caddyConfigPath: stringField(pk01, "caddyConfigPath", `${path}.pk01`), + caddyServiceName: stringField(pk01, "caddyServiceName", `${path}.pk01`), + responseHeaderTimeoutSeconds: integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.pk01`), + }, + }; +} + function parseLiteral(value: string, expected: T, path: string): T { if (value !== expected) throw new Error(`${path} must be ${expected}`); return expected; @@ -589,7 +610,7 @@ function renderValidate(result: Record): RenderedCliResult { const lines = [ "PLATFORM-INFRA WEBTERM VALIDATE", ...table(["CHECK", "OK", "DETAIL"], [ - ["dns", bool(dns.ok), values(dns.addresses).map(String).join(",") || "-"], + ["dns", bool(dns.ok), dns.skipped === true ? "skipped" : values(dns.addresses).map(String).join(",") || "-"], ["local", bool(localProbe.ok), str(localProbe.url, "-")], ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], ]),