fix: 声明 7681 Webterm PTY 修复部署

This commit is contained in:
Codex
2026-07-10 19:08:06 +02:00
parent d745da7176
commit f3a302119e
2 changed files with 124 additions and 55 deletions
+48
View File
@@ -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
+76 -55
View File
@@ -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<string, string>;
};
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<Record<string, unknown> | RenderedCliResult> {
@@ -132,6 +136,7 @@ function plan(targetId: string | null): Record<string, unknown> {
async function apply(config: UniDeskConfig, targetId: string | null, confirm: boolean): Promise<Record<string, unknown>> {
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<string, unknown> {
function validate(targetId: string | null): Record<string, unknown> {
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<string, unknown>, 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<string, unknown>, 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<Record<string, unknown> & { 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<typeof applyPk01CaddySiteBlock>[2] {
function caddyExposure(exposure: EnabledWebtermPublicExposure): Parameters<typeof applyPk01CaddySiteBlock>[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<string, unknown> {
}
function exposureSummary(target: WebtermTarget): Record<string, unknown> {
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<string, unknown>, 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<T extends string>(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<string, unknown>): 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, "-")],
]),