fix: 声明 7681 Webterm PTY 修复部署
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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, "-")],
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user