Files
pikasTech-unidesk/scripts/src/platform-infra/pk01-public-exposure.ts
T
2026-06-30 09:25:59 +00:00

784 lines
31 KiB
TypeScript

// 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<Sub2ApiEgressProxyConfig["preferredOutbound"], null>): 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<Sub2ApiEgressProxyConfig["preferredOutbound"], null>): { 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<string, number> = {};
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<string, unknown>, 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<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (child !== undefined) output[key] = stripUndefined(child);
}
return output;
}
return value;
}
export function redactSubscriptionUri(uri: string): string {
return uri.replace(/\/\/[^@]+@/u, "//<redacted>@").replace(/password=[^&#]+/giu, "password=<redacted>").replace(/pbk=[^&#]+/giu, "pbk=<redacted>");
}
export async function applyPk01PublicExposure(config: UniDeskConfig, target: Sub2ApiTargetConfig): Promise<Record<string, unknown>> {
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<Record<string, unknown>> {
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<name>[^\\n]+)\\n(?P<body>.*?)\\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<Record<string, unknown>> {
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<Record<string, unknown>> {
const observations: Array<Record<string, unknown>> = [];
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<string, unknown> }> {
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
`;
}