784 lines
31 KiB
TypeScript
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
|
|
`;
|
|
}
|