// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. pk01-public-exposure module for scripts/src/platform-infra.ts. // Moved mechanically from scripts/src/platform-infra.ts:2226-2702 for #903. import { createHash, randomBytes } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, join } from "node:path"; import type { UniDeskConfig } from "../config"; import { rootPath } from "../config"; import { startJob } from "../jobs"; import type { RenderedCliResult } from "../output"; import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "../pk01-caddy"; import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } from "../platform-infra-public-service"; import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; import { sub2apiCaddyManagedMarker } from "./entry"; import type { EgressProxySubscriptionCandidateSummary, EgressProxySubscriptionDiagnostics, Sub2ApiEgressProxyConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry"; import { status } from "./actions"; import { publicExposureUpstream, renderPk01CaddyService, renderPk01Caddyfile } from "./apply-script"; import { asRecordOrNull, boolField } from "./utils"; export type SubscriptionNode = | { kind: "vless-reality"; uri: string; uuid: string; host: string; port: number; sni: string; flow: string | null; fingerprint: string | null; publicKey: string; shortId: string | null } | { kind: "hysteria2"; uri: string; password: string; host: string; port: number; sni: string | null; obfsPassword: string | null; insecure: boolean }; export function selectSubscriptionNode(nodes: string[], preferred: Exclude): SubscriptionNode { const analyzed = analyzeEgressProxySubscription(nodes, preferred); if (analyzed.selected === null) throw new Error(analyzed.diagnostics.error ?? `egressProxy preferredOutbound=${preferred} is absent from subscription; no fallback outbound was selected`); return analyzed.selected; } export function analyzeEgressProxySubscription(nodes: string[], preferred: Exclude): { diagnostics: EgressProxySubscriptionDiagnostics; selected: SubscriptionNode | null } { const parsed = nodes .map((uri, index) => ({ uri, sourceLine: index, node: parseSubscriptionNode(uri) })) .filter((entry): entry is { uri: string; sourceLine: number; node: SubscriptionNode } => entry.node !== null); const selectedEntry = parsed.find((entry) => entry.node.kind === preferred) ?? null; const byKind: Record = {}; for (const entry of parsed) byKind[entry.node.kind] = (byKind[entry.node.kind] ?? 0) + 1; const candidates = parsed.map((entry): EgressProxySubscriptionCandidateSummary => { const fingerprint = fingerprintSecretValues({ candidate: entry.uri }, ["candidate"]); return { sourceLine: entry.sourceLine, kind: entry.node.kind, fingerprint, paramKeys: subscriptionUriParamKeys(entry.uri), selected: selectedEntry !== null && entry.sourceLine === selectedEntry.sourceLine, }; }); const selectedFingerprint = selectedEntry === null ? null : fingerprintSecretValues({ candidate: selectedEntry.uri }, ["candidate"]); const diagnostics: EgressProxySubscriptionDiagnostics = { ok: selectedEntry !== null, sourceType: "subscription-url", preferredOutbound: preferred, candidateCount: nodes.length, supportedCount: parsed.length, unsupportedCount: nodes.length - parsed.length, byKind, selectedOutbound: selectedEntry?.node.kind ?? null, selectedFingerprint, candidates, valuesPrinted: false, }; if (selectedEntry === null) { diagnostics.error = `egressProxy preferredOutbound=${preferred} is absent from subscription; no fallback outbound was selected`; } return { diagnostics, selected: selectedEntry?.node ?? null }; } export function subscriptionUriParamKeys(uri: string): string[] { try { return Array.from(new URL(uri).searchParams.keys()).sort(); } catch { return []; } } export function parseSubscriptionNode(uri: string): SubscriptionNode | null { let url: URL; try { url = new URL(uri); } catch { return null; } if (url.protocol === "vless:") { const params = url.searchParams; if (params.get("security") !== "reality") return null; const publicKey = params.get("pbk"); if (publicKey === null || publicKey.length === 0) return null; return { kind: "vless-reality", uri, uuid: decodeURIComponent(url.username), host: requiredUrlHost(url, uri), port: requiredUrlPort(url, uri), sni: params.get("sni") ?? "", flow: params.get("flow"), fingerprint: params.get("fp"), publicKey, shortId: params.get("sid"), }; } if (url.protocol === "hysteria2:" || url.protocol === "hy2:") { const params = url.searchParams; return { kind: "hysteria2", uri, password: decodeURIComponent(url.username), host: requiredUrlHost(url, uri), port: requiredUrlPort(url, uri), sni: params.get("sni"), obfsPassword: params.get("obfs-password"), insecure: params.get("insecure") === "1" || params.get("insecure") === "true", }; } return null; } export function requiredUrlHost(url: URL, uri: string): string { if (url.hostname.length === 0) throw new Error(`VPN subscription node is missing host: ${redactSubscriptionUri(uri)}`); return url.hostname; } export function requiredUrlPort(url: URL, uri: string): number { const port = Number(url.port); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`VPN subscription node is missing a valid port: ${redactSubscriptionUri(uri)}`); return port; } export function renderSingBoxConfig(node: SubscriptionNode, proxy: Sub2ApiEgressProxyConfig): string { const outbound = node.kind === "vless-reality" ? { type: "vless", tag: "master-vpn", server: node.host, server_port: node.port, uuid: node.uuid, flow: node.flow ?? undefined, tls: { enabled: true, server_name: node.sni, utls: { enabled: true, fingerprint: node.fingerprint ?? "chrome" }, reality: { enabled: true, public_key: node.publicKey, short_id: node.shortId ?? "" }, }, } : { type: "hysteria2", tag: "master-vpn", server: node.host, server_port: node.port, password: node.password, obfs: node.obfsPassword === null ? undefined : { type: "salamander", password: node.obfsPassword }, tls: { enabled: true, server_name: node.sni ?? node.host, insecure: node.insecure }, }; return renderSingBoxProxyConfig(outbound, proxy); } export function renderSingBoxMasterShadowsocksConfig(proxy: Sub2ApiEgressProxyConfig, password: string): string { const shadowsocks = proxy.masterShadowsocks; if (shadowsocks === null) throw new Error("masterShadowsocks config is required"); return renderSingBoxProxyConfig({ type: "shadowsocks", tag: "master-vpn", server: shadowsocks.serverHost, server_port: shadowsocks.serverPort, method: shadowsocks.method, password, }, proxy); } export function renderSingBoxProxyConfig(outbound: Record, proxy: Sub2ApiEgressProxyConfig): string { const config = stripUndefined({ log: { level: "info", timestamp: true }, experimental: { clash_api: { external_controller: "127.0.0.1:9090", }, }, inbounds: [ { type: "mixed", tag: "mixed-in", listen: "0.0.0.0", listen_port: proxy.listenPort, }, ], outbounds: [ outbound, { type: "direct", tag: "direct" }, { type: "block", tag: "block" }, ], route: { rules: [ { ip_is_private: true, outbound: "direct" }, { domain_suffix: ["cluster.local", "svc"], outbound: "direct" }, ], final: "master-vpn", }, }); return `${JSON.stringify(config, null, 2)}\n`; } export function stripUndefined(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripUndefined); if (value !== null && typeof value === "object") { const output: Record = {}; for (const [key, child] of Object.entries(value as Record)) { if (child !== undefined) output[key] = stripUndefined(child); } return output; } return value; } export function redactSubscriptionUri(uri: string): string { return uri.replace(/\/\/[^@]+@/u, "//@").replace(/password=[^&#]+/giu, "password=").replace(/pbk=[^&#]+/giu, "pbk="); } export async function applyPk01PublicExposure(config: UniDeskConfig, target: Sub2ApiTargetConfig): Promise> { const exposure = target.publicExposure; if (exposure === null) return { ok: true, action: "not-configured" }; if (!exposure.enabled) return await removePk01PublicExposure(config, target, exposure); const start = await startPk01PublicExposureJob(config, target, exposure); if (!start.ok || typeof start.remoteJobId !== "string") { return { ok: false, action: "platform-infra-sub2api-pk01-public-exposure", route: exposure.pk01.route, mode: "remote-job-start-failed", remoteJob: start, }; } const waited = await waitPk01PublicExposureJob(config, exposure, start.remoteJobId); const terminal = asRecordOrNull(waited.terminal); const summary = asRecordOrNull(terminal?.summary); return { ok: waited.ok === true && boolField(summary, "ok", false), action: "platform-infra-sub2api-pk01-public-exposure", route: exposure.pk01.route, mode: "remote-job", summary, remoteJob: { start, waited, }, }; } export async function removePk01PublicExposure(config: UniDeskConfig, target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): Promise> { const marker = sub2apiCaddyManagedMarker(target); const script = ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT config_path=${shQuote(exposure.pk01.caddyConfigPath)} service_name=${shQuote(exposure.pk01.caddyServiceName)} marker=${shQuote(marker)} hostname=${shQuote(exposure.dns.hostname)} next="$tmp/Caddyfile.next" update_out="$tmp/update.out" update_err="$tmp/update.err" fmt_out="$tmp/fmt.out" fmt_err="$tmp/fmt.err" validate_out="$tmp/validate.out" validate_err="$tmp/validate.err" install_out="$tmp/install.out" install_err="$tmp/install.err" reload_out="$tmp/reload.out" reload_err="$tmp/reload.err" if ! [ -f "$config_path" ]; then python3 - "$config_path" "$marker" "$hostname" <<'PY' import json import sys print(json.dumps({ "ok": True, "action": "caddy-config-missing-noop", "configPath": sys.argv[1], "marker": sys.argv[2], "hostname": sys.argv[3], "valuesPrinted": False, }, ensure_ascii=False, indent=2)) PY exit 0 fi sudo python3 - "$config_path" "$next" "$marker" >"$update_out" 2>"$update_err" <<'PY' import pathlib import re import sys config_path = pathlib.Path(sys.argv[1]) next_path = pathlib.Path(sys.argv[2]) marker = sys.argv[3] text = config_path.read_text(encoding="utf-8") pattern = re.compile(r"(?ms)^# BEGIN unidesk managed (?P[^\\n]+)\\n(?P.*?)\\n# END unidesk managed (?P=name)\\n*") removed = False def replace(match): global removed if match.group("name") != marker: return match.group(0) removed = True return "" next_text = pattern.sub(replace, text) next_text = re.sub(r"\\n{3,}", "\\n\\n", next_text).rstrip() + "\\n" next_path.write_text(next_text, encoding="utf-8") print("removed" if removed else "absent") PY update_rc=$? if [ "$update_rc" -eq 0 ]; then sudo caddy fmt --overwrite "$next" >"$fmt_out" 2>"$fmt_err" fmt_rc=$? else : >"$fmt_out"; : >"$fmt_err"; fmt_rc=1 fi if [ "$fmt_rc" -eq 0 ]; then sudo caddy validate --config "$next" >"$validate_out" 2>"$validate_err" validate_rc=$? else : >"$validate_out"; : >"$validate_err"; validate_rc=1 fi if [ "$validate_rc" -eq 0 ]; then sudo install -m 0644 "$next" "$config_path" >"$install_out" 2>"$install_err" install_rc=$? else : >"$install_out"; : >"$install_err"; install_rc=1 fi if [ "$install_rc" -eq 0 ]; then sudo systemctl reload "$service_name" >"$reload_out" 2>"$reload_err" || sudo systemctl restart "$service_name" >>"$reload_out" 2>>"$reload_err" reload_rc=$? else : >"$reload_out"; : >"$reload_err"; reload_rc=1 fi python3 - "$update_rc" "$fmt_rc" "$validate_rc" "$install_rc" "$reload_rc" "$config_path" "$marker" "$hostname" "$update_out" "$update_err" "$fmt_out" "$fmt_err" "$validate_out" "$validate_err" "$install_out" "$install_err" "$reload_out" "$reload_err" <<'PY' import json import sys update_rc, fmt_rc, validate_rc, install_rc, reload_rc = [int(value) for value in sys.argv[1:6]] config_path, marker, hostname = sys.argv[6:9] paths = sys.argv[9:] def text(path, limit=3000): try: return open(path, encoding="utf-8", errors="replace").read()[-limit:] except FileNotFoundError: return "" update_stdout = text(paths[0], 1000).strip() payload = { "ok": update_rc == 0 and fmt_rc == 0 and validate_rc == 0 and install_rc == 0 and reload_rc == 0, "action": "removed-managed-block" if update_stdout == "removed" else "managed-block-absent", "target": "${target.id}", "publicBaseUrl": "${exposure.publicBaseUrl}", "hostname": hostname, "configPath": config_path, "managedBlock": {"marker": marker}, "steps": { "update": {"exitCode": update_rc, "stdout": update_stdout, "stderr": text(paths[1])}, "fmt": {"exitCode": fmt_rc, "stdout": text(paths[2]), "stderr": text(paths[3])}, "validate": {"exitCode": validate_rc, "stdout": text(paths[4]), "stderr": text(paths[5])}, "install": {"exitCode": install_rc, "stdout": text(paths[6]), "stderr": text(paths[7])}, "reload": {"exitCode": reload_rc, "stdout": text(paths[8]), "stderr": text(paths[9])}, }, "valuesPrinted": False, } print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY `; const result = await capture(config, exposure.pk01.route, ["sh"], script); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-sub2api-pk01-public-exposure", route: exposure.pk01.route, mode: "remove-disabled-managed-block", summary: parsed ?? null, capture: compactCapture(result, { full: result.exitCode !== 0 }), }; } export async function startPk01PublicExposureJob(config: UniDeskConfig, target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): Promise> { const jobId = `sub2api-pk01-exposure-${new Date().toISOString().replace(/[^0-9A-Za-z]/gu, "")}-${randomBytes(3).toString("hex")}`; const payload = pk01PublicExposureScript(target, exposure); const payloadB64 = Buffer.from(payload, "utf8").toString("base64"); const script = ` set -eu job_id=${shQuote(jobId)} base="$HOME/.unidesk/platform-infra/sub2api-pk01-exposure/jobs" job_dir="$base/$job_id" mkdir -p "$job_dir" payload="$job_dir/payload.sh" runner="$job_dir/runner.sh" stdout="$job_dir/stdout.log" stderr="$job_dir/stderr.log" state="$job_dir/state.json" printf '%s' '${payloadB64}' | base64 -d >"$payload" chmod 0700 "$payload" cat >"$runner" <<'RUNNER' #!/bin/sh set +e job_dir="$1" payload="$job_dir/payload.sh" stdout="$job_dir/stdout.log" stderr="$job_dir/stderr.log" state="$job_dir/state.json" write_state() { status="$1" stage="$2" exit_code="$3" python3 - "$state" "$status" "$stage" "$exit_code" <<'PY' import datetime import json import sys path, status, stage, exit_code = sys.argv[1:5] payload = { "ok": status == "succeeded", "status": status, "stage": stage, "updatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), "exitCode": None if exit_code == "" else int(exit_code), } if status in ("succeeded", "failed"): payload["finishedAt"] = payload["updatedAt"] with open(path, "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False) PY } write_state running start "" sh "$payload" >"$stdout" 2>"$stderr" rc=$? if [ "$rc" -eq 0 ]; then write_state succeeded complete "$rc" else write_state failed complete "$rc" fi exit "$rc" RUNNER chmod 0700 "$runner" python3 - "$state" <<'PY' import datetime import json import sys payload = { "ok": False, "status": "queued", "stage": "queued", "updatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), "exitCode": None, } with open(sys.argv[1], "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False) PY nohup "$runner" "$job_dir" >/dev/null 2>&1 & printf '%s' "$!" >"$job_dir/pid" python3 - "$job_id" "$job_dir" <<'PY' import json import os import sys job_id, job_dir = sys.argv[1:3] payload = { "ok": True, "remoteJobId": job_id, "statePath": os.path.join(job_dir, "state.json"), "stdoutPath": os.path.join(job_dir, "stdout.log"), "stderrPath": os.path.join(job_dir, "stderr.log"), "valuesPrinted": False, } print(json.dumps(payload, ensure_ascii=False, indent=2)) PY `; const result = await capture(config, exposure.pk01.route, ["sh"], script); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), remoteJobId: typeof parsed?.remoteJobId === "string" ? parsed.remoteJobId : null, parsed, capture: compactCapture(result, { full: result.exitCode !== 0 }), }; } export async function waitPk01PublicExposureJob(config: UniDeskConfig, exposure: Sub2ApiPublicExposureConfig, remoteJobId: string): Promise> { const observations: Array> = []; const maxPolls = 120; let lastProgressKey: string | null = null; for (let attempt = 0; attempt < maxPolls; attempt += 1) { const observation = await readPk01PublicExposureJob(config, exposure, remoteJobId); observations.push(observation.summary); const statusValue = observation.status; const stage = typeof observation.summary.stage === "string" ? observation.summary.stage : null; const progressKey = `${statusValue ?? "unknown"}:${stage ?? "unknown"}`; if (attempt === 0 || progressKey !== lastProgressKey || attempt % 6 === 0 || statusValue === "succeeded" || statusValue === "failed") { lastProgressKey = progressKey; console.error(JSON.stringify({ event: "platform-infra.sub2api.pk01-exposure.progress", at: new Date().toISOString(), remoteJobId, attempt: attempt + 1, status: statusValue, stage, exitCode: observation.summary.exitCode ?? null, })); } if (statusValue === "succeeded" || statusValue === "failed") { return { ok: statusValue === "succeeded" && boolField(asRecordOrNull(observation.summary.summary), "ok", false), attempts: attempt + 1, terminal: observation.summary, recent: observations.slice(-10), }; } await Bun.sleep(5000); } return { ok: false, attempts: maxPolls, terminal: null, recent: observations.slice(-10), error: "PK01 public exposure job did not finish before local polling budget", }; } export async function readPk01PublicExposureJob(config: UniDeskConfig, exposure: Sub2ApiPublicExposureConfig, remoteJobId: string): Promise<{ status: string | null; summary: Record }> { const safeId = remoteJobId.replace(/[^A-Za-z0-9_.-]/gu, ""); const script = ` set +e job_dir="$HOME/.unidesk/platform-infra/sub2api-pk01-exposure/jobs/${safeId}" state="$job_dir/state.json" stdout="$job_dir/stdout.log" stderr="$job_dir/stderr.log" if [ -f "$state" ]; then cat "$state"; else printf '{"status":"missing","stage":"missing","exitCode":null}'; fi printf '\\n---STDOUT---\\n' if [ -f "$stdout" ]; then tail -200 "$stdout"; fi printf '\\n---STDERR---\\n' if [ -f "$stderr" ]; then tail -120 "$stderr"; fi printf '\\n---DOWNLOAD---\\n' download_part="$HOME/.cache/unidesk/platform-infra/caddy-linux-amd64.part" download_cache="$HOME/.cache/unidesk/platform-infra/caddy-linux-amd64" if [ -f "$download_part" ]; then stat -c 'part_bytes=%s part_mtime=%y' "$download_part"; fi if [ -f "$download_cache" ]; then stat -c 'cache_bytes=%s cache_mtime=%y' "$download_cache"; fi `; const result = await capture(config, exposure.pk01.route, ["sh"], script); const [stateText, rest = ""] = result.stdout.split("\n---STDOUT---\n"); const [stdoutAndMaybeStderr = "", downloadProgress = ""] = rest.split("\n---DOWNLOAD---\n"); const [stdoutTail = "", stderrTail = ""] = stdoutAndMaybeStderr.split("\n---STDERR---\n"); const parsed = parseJsonOutput(stateText) ?? {}; const statusValue = typeof parsed.status === "string" ? parsed.status : null; const summary = parseJsonOutput(stdoutTail); return { status: statusValue, summary: { ok: result.exitCode === 0, remoteJobId, status: statusValue, stage: parsed.stage ?? null, updatedAt: parsed.updatedAt ?? null, finishedAt: parsed.finishedAt ?? null, exitCode: parsed.exitCode ?? null, summary, stdoutTail: stdoutTail.slice(-8000), stderrTail: stderrTail.slice(-4000), downloadProgress: downloadProgress.slice(-1000), capture: compactCapture(result, { full: result.exitCode !== 0 }), }, }; } export function pk01PublicExposureScript(target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): string { const caddyfile = renderPk01Caddyfile(target, exposure); const serviceUnit = renderPk01CaddyService(exposure); const exposureMode = exposure.mode === "pk01-local" ? "pk01-caddy-local-docker" : "pk01-caddy-frp-direct"; const dataPath = exposure.mode === "pk01-local" ? `client -> PK01 Caddy -> ${publicExposureUpstream(target, exposure)} -> PK01 Docker Sub2API` : `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`; const frpSummary = exposure.mode === "frp" && exposure.frpc !== null ? { server: `${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}`, remotePort: exposure.frpc.remotePort, proxyName: exposure.frpc.proxyName, } : null; const frpSummaryPython = frpSummary === null ? "None" : `json.loads(${JSON.stringify(JSON.stringify(frpSummary))})`; const caddyfileB64 = Buffer.from(caddyfile, "utf8").toString("base64"); const serviceUnitB64 = Buffer.from(serviceUnit, "utf8").toString("base64"); const caddyDownloadProxyArgs = exposure.pk01.caddyDownloadProxyUrl === null ? "" : `--proxy ${shQuote(exposure.pk01.caddyDownloadProxyUrl)}`; const caddyDownloadProxyUrl = exposure.pk01.caddyDownloadProxyUrl ?? ""; return ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT caddyfile="$tmp/Caddyfile" service_unit="$tmp/${exposure.pk01.caddyServiceName}.service" printf '%s' '${caddyfileB64}' | base64 -d >"$caddyfile" printf '%s' '${serviceUnitB64}' | base64 -d >"$service_unit" download_out="$tmp/download.out" download_err="$tmp/download.err" install_out="$tmp/install.out" install_err="$tmp/install.err" validate_out="$tmp/validate.out" validate_err="$tmp/validate.err" pikanode_out="$tmp/pikanode.out" pikanode_err="$tmp/pikanode.err" caddy_out="$tmp/caddy.out" caddy_err="$tmp/caddy.err" probe_out="$tmp/probe.out" probe_err="$tmp/probe.err" download_action="kept-existing" download_proxy_url=${shQuote(caddyDownloadProxyUrl)} download_proxy_mode="direct" if [ -n "$download_proxy_url" ]; then download_proxy_mode="curl-proxy"; fi download_rc=0 if ! [ -x ${shQuote(exposure.pk01.caddyBinaryPath)} ]; then cache_dir="$HOME/.cache/unidesk/platform-infra" cache_path="$cache_dir/caddy-linux-amd64" cache_part="$cache_path.part" mkdir -p "$cache_dir" if [ -x "$cache_path" ]; then download_action="used-cache" "$cache_path" version >"$download_out" 2>"$download_err" || true else download_action="downloaded" resume_args= if [ -s "$cache_part" ]; then resume_args="-C -"; fi curl -fL --connect-timeout 20 --retry 5 --retry-delay 3 --max-time 600 ${caddyDownloadProxyArgs} $resume_args ${shQuote(exposure.pk01.caddyDownloadUrl)} -o "$cache_part" >"$download_out" 2>"$download_err" download_rc=$? if [ "$download_rc" -eq 33 ]; then rm -f "$cache_part" curl -fL --connect-timeout 20 --retry 5 --retry-delay 3 --max-time 600 ${caddyDownloadProxyArgs} ${shQuote(exposure.pk01.caddyDownloadUrl)} -o "$cache_part" >>"$download_out" 2>>"$download_err" download_rc=$? fi if [ "$download_rc" -eq 0 ]; then mv "$cache_part" "$cache_path" chmod 0755 "$cache_path" fi fi if [ "$download_rc" -eq 0 ]; then sudo install -m 0755 "$cache_path" ${shQuote(exposure.pk01.caddyBinaryPath)} >>"$download_out" 2>>"$download_err" download_rc=$? fi else rm -f "$HOME/.cache/unidesk/platform-infra/caddy-linux-amd64.part" ${shQuote(exposure.pk01.caddyBinaryPath)} version >"$download_out" 2>"$download_err" || true fi install_rc=1 if [ "$download_rc" -eq 0 ]; then sudo mkdir -p "$(dirname ${shQuote(exposure.pk01.caddyConfigPath)})" ${shQuote(exposure.pk01.caddyStorageDir)} /etc/systemd/system merged_caddyfile="$tmp/Caddyfile.merged" sudo python3 - "$caddyfile" ${shQuote(exposure.pk01.caddyConfigPath)} "$merged_caddyfile" ${shQuote(exposure.dns.hostname)} >"$install_out" 2>"$install_err" <<'PY' ${pk01CaddyMergeManagedBlocksPython()} PY install_rc=$? if [ "$install_rc" -eq 0 ]; then sudo install -m 0644 "$merged_caddyfile" ${shQuote(exposure.pk01.caddyConfigPath)} >>"$install_out" 2>>"$install_err" install_rc=$? fi if [ "$install_rc" -eq 0 ]; then sudo install -m 0644 "$service_unit" /etc/systemd/system/${exposure.pk01.caddyServiceName}.service >>"$install_out" 2>>"$install_err" install_rc=$? fi fi validate_rc=1 if [ "$install_rc" -eq 0 ]; then sudo ${shQuote(exposure.pk01.caddyBinaryPath)} validate --config ${shQuote(exposure.pk01.caddyConfigPath)} >"$validate_out" 2>"$validate_err" validate_rc=$? else : >"$validate_out" : >"$validate_err" fi pikanode_rc=1 if [ "$validate_rc" -eq 0 ]; then if docker inspect ${shQuote(exposure.pk01.pikanodeContainerName)} >/dev/null 2>&1; then docker rm -f ${shQuote(exposure.pk01.pikanodeContainerName)} >"$pikanode_out" 2>"$pikanode_err" pikanode_rc=$? else : >"$pikanode_out" : >"$pikanode_err" pikanode_rc=0 fi if [ "$pikanode_rc" -eq 0 ]; then docker run -d --name ${shQuote(exposure.pk01.pikanodeContainerName)} \\ --restart=always \\ -p 127.0.0.1:${exposure.pk01.pikanodeHttpHostPort}:8888 \\ -p 22000-22099:22000-22099 \\ -p 7500:7500 \\ -v ${shQuote(exposure.pk01.pikanodeRoot)}:/usr/src/app \\ -v /etc/letsencrypt:/etc/letsencrypt:ro \\ -w /usr/src/app \\ ${shQuote(exposure.pk01.pikanodeImage)} \\ sh init.sh >>"$pikanode_out" 2>>"$pikanode_err" pikanode_rc=$? if [ "$pikanode_rc" -ne 0 ]; then printf '%s\\n' '[rollback] restarting pikanode with previous public 80/443 mapping' >>"$pikanode_err" docker rm -f ${shQuote(exposure.pk01.pikanodeContainerName)} >>"$pikanode_out" 2>>"$pikanode_err" || true docker run -d --name ${shQuote(exposure.pk01.pikanodeContainerName)} \\ --restart=always \\ -p 80:8888 \\ -p 22000-22099:22000-22099 \\ -p 443:443 \\ -p 7500:7500 \\ -v ${shQuote(exposure.pk01.pikanodeRoot)}:/usr/src/app \\ -v /etc/letsencrypt:/etc/letsencrypt:ro \\ -w /usr/src/app \\ ${shQuote(exposure.pk01.pikanodeImage)} \\ sh init.sh >>"$pikanode_out" 2>>"$pikanode_err" || true fi fi else : >"$pikanode_out" : >"$pikanode_err" fi caddy_rc=1 if [ "$pikanode_rc" -eq 0 ]; then sudo systemctl daemon-reload >"$caddy_out" 2>"$caddy_err" sudo systemctl enable --now ${shQuote(exposure.pk01.caddyServiceName)} >>"$caddy_out" 2>>"$caddy_err" caddy_rc=$? if [ "$caddy_rc" -eq 0 ]; then sudo systemctl reload ${shQuote(exposure.pk01.caddyServiceName)} >>"$caddy_out" 2>>"$caddy_err" || sudo systemctl restart ${shQuote(exposure.pk01.caddyServiceName)} >>"$caddy_out" 2>>"$caddy_err" caddy_rc=$? fi fi probe_rc=1 if [ "$caddy_rc" -eq 0 ]; then { printf '[pikanode]\\n' curl -kfsS --max-time 10 --resolve pikapython.com:443:127.0.0.1 https://pikapython.com/ -o /dev/null -w 'status=%{http_code}\\n' printf '[api-health]\\n' curl -kfsS --max-time 10 --resolve ${exposure.dns.hostname}:443:127.0.0.1 ${exposure.publicBaseUrl}/health -o /dev/null -w 'status=%{http_code}\\n' || true } >"$probe_out" 2>"$probe_err" probe_rc=0 else : >"$probe_out" : >"$probe_err" fi python3 - "$download_rc" "$install_rc" "$validate_rc" "$pikanode_rc" "$caddy_rc" "$probe_rc" "$download_action" "$download_proxy_mode" "$download_proxy_url" "$download_out" "$download_err" "$install_out" "$install_err" "$validate_out" "$validate_err" "$pikanode_out" "$pikanode_err" "$caddy_out" "$caddy_err" "$probe_out" "$probe_err" <<'PY' import json import sys download_rc, install_rc, validate_rc, pikanode_rc, caddy_rc, probe_rc = [int(value) for value in sys.argv[1:7]] download_action = sys.argv[7] download_proxy_mode = sys.argv[8] download_proxy_url = sys.argv[9] or None paths = sys.argv[10:] def text(path, limit=4000): try: return open(path, encoding="utf-8", errors="replace").read()[-limit:] except FileNotFoundError: return "" payload = { "ok": download_rc == 0 and install_rc == 0 and validate_rc == 0 and pikanode_rc == 0 and caddy_rc == 0, "target": "${target.id}", "mode": ${JSON.stringify(exposureMode)}, "publicBaseUrl": "${exposure.publicBaseUrl}", "hostname": "${exposure.dns.hostname}", "expectedA": "${exposure.dns.expectedA}", "dataPath": ${JSON.stringify(dataPath)}, "pikanodeRole": "pikapython.com upstream only; ${exposure.dns.hostname} does not pass through pikanode Express", "caddy": { "binaryPath": "${exposure.pk01.caddyBinaryPath}", "configPath": "${exposure.pk01.caddyConfigPath}", "serviceName": "${exposure.pk01.caddyServiceName}", "downloadProxy": {"mode": download_proxy_mode, "url": download_proxy_url or None}, }, "frp": ${frpSummaryPython}, "steps": { "downloadCaddy": {"exitCode": download_rc, "action": download_action, "stdout": text(paths[0]), "stderr": text(paths[1])}, "installConfig": {"exitCode": install_rc, "stdout": text(paths[2]), "stderr": text(paths[3])}, "validateCaddyConfig": {"exitCode": validate_rc, "stdout": text(paths[4]), "stderr": text(paths[5])}, "restartPikanodeLoopback": {"exitCode": pikanode_rc, "stdout": text(paths[6]), "stderr": text(paths[7])}, "startCaddy": {"exitCode": caddy_rc, "stdout": text(paths[8]), "stderr": text(paths[9])}, "localProbe": {"exitCode": probe_rc, "stdout": text(paths[10]), "stderr": text(paths[11])}, }, "valuesPrinted": False, } print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY `; }