fix: 通过共享 Caddy 暴露 VPN 订阅

This commit is contained in:
Codex
2026-07-15 12:00:01 +02:00
parent 73c69ce70b
commit b1c75703fd
3 changed files with 91 additions and 109 deletions
+10 -13
View File
@@ -347,6 +347,11 @@ publicEdge:
node: NC01
route: NC01
publicAddress: 152.53.229.148
isolation:
excludedRoutes:
- PK01
preservedUdpPorts:
- 443
caddy:
image: caddy:2.10.2-alpine
workDir: /opt/unidesk/pikaoa-edge
@@ -370,6 +375,11 @@ publicEdge:
hostname: oa-dev.hwpod.com
upstream: 127.0.0.1:32080
healthPath: /healthz
subscription:
hostname: proxy.hwpod.com
upstream: https://127.0.0.1:8443
upstreamTlsServerName: 152-53-229-148.nip.io
healthPath: /subscribe
tcpPortRelease:
settingsPath: /root/vpn-server/config/settings.yaml
workDir: /root/vpn-server
@@ -377,19 +387,6 @@ publicEdge:
fromPort: 443
toPort: 8447
applyCommand: npm run apply
legacyExposure:
pk01:
route: PK01
caddyConfigPath: /etc/caddy/Caddyfile
caddyServiceName: caddy
managedBlock: pikaoa
hostname: oa.pikapython.com
frpc:
route: NC01:k3s
namespace: pikaoa
deploymentName: pikaoa-frpc
retirementBlocking: false
delivery:
targets:
NC01:
+4 -2
View File
@@ -143,10 +143,12 @@
- 公网边缘由 `config/pikaoa.yaml#publicEdge` 唯一声明:
- `oa.hwpod.com` 反向代理生产 NodePort
- `oa-dev.hwpod.com` 反向代理测试 NodePort
- `proxy.hwpod.com/subscribe` 通过共享 Caddy 转发到 VPN 订阅的 HTTPS/8443
- NC01 Caddy 使用标准 TCP 80/443
- Hysteria 继续使用 UDP 443,不受 TCP listener 变更影响;
- Xray Reality 使用 YAML 声明的独立 TCP 端口,不再占用 TCP 443
- 旧 `oa.pikapython.com` PK01 Caddy 托管块必须退役。
- `publicEdge.isolation.excludedRoutes` 必须排除 `PK01`,受控入口不得连接或 reload PK01
- `publicEdge.isolation.preservedUdpPorts` 声明并验证 VPN/host-proxy 依赖的 UDP 监听。
- 公网边缘受控入口是:
```bash
@@ -155,7 +157,7 @@
bun scripts/cli.ts pikaoa public-edge status
```
- `public-edge apply` 只按 managed marker 删除 PikaOA 旧边缘块;不得覆盖 PK01 其他 Caddy 站点
- `public-edge apply` 只修改 NC01 的 VPN TCP 端口与共享 Caddy,不访问 PK01
- 旧 FRPC 残留只产生 `blocking=false` warning,不阻塞新域名上线。
- 生产初始化模式是 `fresh-database-only`,不实现旧数据迁移、兼容或回滚。
- 版本、审计和配置一致性异常只产生 `blocking=false` warning,不作为 MVP 业务门禁。
+77 -94
View File
@@ -21,12 +21,17 @@ interface EdgeSite {
id: string;
hostname: string;
upstream: string;
upstreamTlsServerName?: string;
healthPath: string;
}
interface EdgeSpec {
route: string;
publicAddress: string;
isolation: {
excludedRoutes: string[];
preservedUdpPorts: number[];
};
caddy: {
image: string;
workDir: string;
@@ -49,10 +54,6 @@ interface EdgeSpec {
toPort: number;
applyCommand: string;
};
legacyExposure: {
pk01: { route: string; caddyConfigPath: string; caddyServiceName: string; managedBlock: string; hostname: string };
frpc: { route: string; namespace: string; deploymentName: string; retirementBlocking: boolean };
};
}
export function pikaoaPublicEdgeHelp(): Record<string, unknown> {
@@ -67,8 +68,8 @@ export function pikaoaPublicEdgeHelp(): Record<string, unknown> {
source: "config/pikaoa.yaml#publicEdge",
guarantees: [
"TCP 80/443、站点、upstream、Caddy 和 TCP 端口释放全部来自 owning YAML。",
"apply 保留 UDP 443;只迁移 YAML 声明的 TCP 服务端口。",
"旧 PK01 Caddy 托管块按 marker 精确删除;FRPC 退役仅为 non-blocking warning。",
"apply 保留 YAML 声明的 UDP 端口;只迁移 YAML 声明的 TCP 服务端口。",
"apply/status 只访问 publicEdge.target.route,不访问 YAML 明确排除的 PK01。",
],
};
}
@@ -114,15 +115,17 @@ function readSpec(path: string): EdgeSpec {
const root = readYamlRecord(path, "pikaoa-platform-delivery");
const edge = record(root.publicEdge, "publicEdge");
const target = record(edge.target, "publicEdge.target");
const isolation = record(edge.isolation, "publicEdge.isolation");
const caddy = record(edge.caddy, "publicEdge.caddy");
const sites = record(edge.sites, "publicEdge.sites");
const tcp = record(edge.tcpPortRelease, "publicEdge.tcpPortRelease");
const legacy = record(edge.legacyExposure, "publicEdge.legacyExposure");
const pk01 = record(legacy.pk01, "publicEdge.legacyExposure.pk01");
const frpc = record(legacy.frpc, "publicEdge.legacyExposure.frpc");
return {
const spec: EdgeSpec = {
route: string(target.route, "publicEdge.target.route"),
publicAddress: string(target.publicAddress, "publicEdge.target.publicAddress"),
isolation: {
excludedRoutes: stringList(isolation.excludedRoutes, "publicEdge.isolation.excludedRoutes"),
preservedUdpPorts: integerList(isolation.preservedUdpPorts, "publicEdge.isolation.preservedUdpPorts"),
},
caddy: {
image: string(caddy.image, "publicEdge.caddy.image"),
workDir: string(caddy.workDir, "publicEdge.caddy.workDir"),
@@ -138,7 +141,12 @@ function readSpec(path: string): EdgeSpec {
},
sites: Object.entries(sites).map(([id, value]) => {
const site = record(value, `publicEdge.sites.${id}`);
return { id, hostname: hostname(site.hostname, `publicEdge.sites.${id}.hostname`), upstream: string(site.upstream, `publicEdge.sites.${id}.upstream`), healthPath: httpPath(site.healthPath, `publicEdge.sites.${id}.healthPath`) };
const upstream = string(site.upstream, `publicEdge.sites.${id}.upstream`);
const upstreamTlsServerName = optionalHostname(site.upstreamTlsServerName, `publicEdge.sites.${id}.upstreamTlsServerName`);
if (upstreamTlsServerName !== undefined && !upstream.startsWith("https://")) {
throw inputError(`publicEdge.sites.${id}.upstreamTlsServerName 只允许用于 HTTPS upstream`, "invalid-config", `publicEdge.sites.${id}.upstreamTlsServerName`);
}
return { id, hostname: hostname(site.hostname, `publicEdge.sites.${id}.hostname`), upstream, upstreamTlsServerName, healthPath: httpPath(site.healthPath, `publicEdge.sites.${id}.healthPath`) };
}),
tcpPortRelease: {
settingsPath: string(tcp.settingsPath, "publicEdge.tcpPortRelease.settingsPath"),
@@ -148,22 +156,11 @@ function readSpec(path: string): EdgeSpec {
toPort: integer(tcp.toPort, "publicEdge.tcpPortRelease.toPort"),
applyCommand: string(tcp.applyCommand, "publicEdge.tcpPortRelease.applyCommand"),
},
legacyExposure: {
pk01: {
route: string(pk01.route, "publicEdge.legacyExposure.pk01.route"),
caddyConfigPath: string(pk01.caddyConfigPath, "publicEdge.legacyExposure.pk01.caddyConfigPath"),
caddyServiceName: string(pk01.caddyServiceName, "publicEdge.legacyExposure.pk01.caddyServiceName"),
managedBlock: string(pk01.managedBlock, "publicEdge.legacyExposure.pk01.managedBlock"),
hostname: hostname(pk01.hostname, "publicEdge.legacyExposure.pk01.hostname"),
},
frpc: {
route: string(frpc.route, "publicEdge.legacyExposure.frpc.route"),
namespace: string(frpc.namespace, "publicEdge.legacyExposure.frpc.namespace"),
deploymentName: string(frpc.deploymentName, "publicEdge.legacyExposure.frpc.deploymentName"),
retirementBlocking: boolean(frpc.retirementBlocking, "publicEdge.legacyExposure.frpc.retirementBlocking"),
},
},
};
if (spec.isolation.excludedRoutes.includes(spec.route)) {
throw inputError(`publicEdge.target.route 不能命中 publicEdge.isolation.excludedRoutes`, "excluded-target", "publicEdge.target.route");
}
return spec;
}
function plan(spec: EdgeSpec, configPath: string): Record<string, unknown> {
@@ -172,10 +169,10 @@ function plan(spec: EdgeSpec, configPath: string): Record<string, unknown> {
mutation: false,
configRef: `${configPath}#publicEdge`,
target: { route: spec.route, publicAddress: spec.publicAddress },
listeners: { tcp: [spec.caddy.httpPort, spec.caddy.httpsPort], udpPreserved: [spec.caddy.httpsPort] },
sites: spec.sites,
listeners: { tcp: [spec.caddy.httpPort, spec.caddy.httpsPort], udpPreserved: spec.isolation.preservedUdpPorts },
excludedRoutes: spec.isolation.excludedRoutes,
sites: spec.sites.map((site) => ({ ...site, publicUrl: `https://${site.hostname}${site.healthPath}` })),
tcpPortRelease: { service: spec.tcpPortRelease.service, fromPort: spec.tcpPortRelease.fromPort, toPort: spec.tcpPortRelease.toPort },
legacyExposure: { hostname: spec.legacyExposure.pk01.hostname, managedBlock: spec.legacyExposure.pk01.managedBlock, frpcRetirementBlocking: false },
renderedFingerprint: sha256Fingerprint(`${renderCaddyfile(spec)}\n${renderCompose(spec)}`),
valuesPrinted: false,
};
@@ -184,20 +181,13 @@ function plan(spec: EdgeSpec, configPath: string): Record<string, unknown> {
async function apply(config: UniDeskConfig, spec: EdgeSpec): Promise<Record<string, unknown>> {
const edgeResult = await capture(config, spec.route, ["sh"], edgeApplyScript(spec), { runtimeTimeoutMs: 240_000 });
const edge = parseJsonOutput(edgeResult.stdout) ?? { ok: false, capture: compactCapture(edgeResult, { full: true }) };
if (edgeResult.exitCode !== 0 || edge.ok === false) return { ok: false, mutation: true, edge, valuesPrinted: false };
const legacyResult = await capture(config, spec.legacyExposure.pk01.route, ["sh"], legacyRemovalScript(spec), { runtimeTimeoutMs: 60_000 });
const legacy = parseJsonOutput(legacyResult.stdout) ?? { ok: false, capture: compactCapture(legacyResult, { full: true }) };
const frpcResult = await capture(config, spec.legacyExposure.frpc.route, ["kubectl", "get", "deployment", "-n", spec.legacyExposure.frpc.namespace, spec.legacyExposure.frpc.deploymentName, "-o", "name"], "");
const warnings = frpcResult.exitCode === 0 ? [{ code: "legacy-frpc-still-present", blocking: false, object: frpcResult.stdout.trim() }] : [];
return { ok: legacyResult.exitCode === 0 && legacy.ok !== false, mutation: true, edge, legacy, warnings, valuesPrinted: false };
return { ok: edgeResult.exitCode === 0 && edge.ok !== false, mutation: true, edge, excludedRoutes: spec.isolation.excludedRoutes, valuesPrinted: false };
}
async function status(config: UniDeskConfig, spec: EdgeSpec): Promise<Record<string, unknown>> {
const edgeResult = await capture(config, spec.route, ["sh"], edgeStatusScript(spec));
const legacyResult = await capture(config, spec.legacyExposure.pk01.route, ["sh"], legacyStatusScript(spec));
const edge = parseJsonOutput(edgeResult.stdout) ?? { ok: false, capture: compactCapture(edgeResult, { full: true }) };
const legacy = parseJsonOutput(legacyResult.stdout) ?? { ok: false, capture: compactCapture(legacyResult, { full: true }) };
return { ok: edgeResult.exitCode === 0 && legacyResult.exitCode === 0 && edge.ok !== false && legacy.ok !== false, mutation: false, edge, legacy, valuesPrinted: false };
return { ok: edgeResult.exitCode === 0 && edge.ok !== false, mutation: false, edge, excludedRoutes: spec.isolation.excludedRoutes, valuesPrinted: false };
}
function edgeApplyScript(spec: EdgeSpec): string {
@@ -246,13 +236,38 @@ else
: >"$tmp/vpn.out"; : >"$tmp/vpn.err"; vpn_rc=1
fi
mkdir -p ${shQuote(spec.caddy.workDir)} ${shQuote(spec.caddy.dataDir)} ${shQuote(spec.caddy.configDir)}
printf '%s' '${caddyfile}' | base64 -d >${shQuote(spec.caddy.caddyfilePath)}
printf '%s' '${compose}' | base64 -d >${shQuote(spec.caddy.composePath)}
if [ "$vpn_rc" -eq 0 ]; then
docker compose -f ${shQuote(spec.caddy.composePath)} up -d >"$tmp/caddy.out" 2>"$tmp/caddy.err"
caddy_rc=$?
if [ -f ${shQuote(spec.caddy.caddyfilePath)} ]; then
cp ${shQuote(spec.caddy.caddyfilePath)} "$tmp/Caddyfile.old"
old_caddyfile=true
else
old_caddyfile=false
fi
printf '%s' '${caddyfile}' | base64 -d >${shQuote(spec.caddy.caddyfilePath)}
docker run --rm -v ${shQuote(`${spec.caddy.caddyfilePath}:/etc/caddy/Caddyfile:ro`)} ${shQuote(spec.caddy.image)} validate --config /etc/caddy/Caddyfile --adapter caddyfile >"$tmp/caddy-validate.out" 2>"$tmp/caddy-validate.err"
validate_rc=$?
if [ "$validate_rc" -eq 0 ]; then
docker compose -f ${shQuote(spec.caddy.composePath)} up -d >"$tmp/caddy.out" 2>"$tmp/caddy.err"
compose_rc=$?
else
compose_rc=1
fi
if [ "$compose_rc" -eq 0 ]; then
docker exec ${shQuote(spec.caddy.containerName)} caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >"$tmp/caddy-reload.out" 2>"$tmp/caddy-reload.err"
reload_rc=$?
else
reload_rc=1
fi
if [ "$validate_rc" -ne 0 ] && [ "$old_caddyfile" = true ]; then
cp "$tmp/Caddyfile.old" ${shQuote(spec.caddy.caddyfilePath)}
elif [ "$reload_rc" -ne 0 ] && [ "$old_caddyfile" = true ]; then
cp "$tmp/Caddyfile.old" ${shQuote(spec.caddy.caddyfilePath)}
docker compose -f ${shQuote(spec.caddy.composePath)} up -d --force-recreate >"$tmp/caddy-rollback.out" 2>"$tmp/caddy-rollback.err"
fi
if [ "$validate_rc" -eq 0 ] && [ "$compose_rc" -eq 0 ] && [ "$reload_rc" -eq 0 ]; then caddy_rc=0; else caddy_rc=1; fi
else
: >"$tmp/caddy.out"; : >"$tmp/caddy.err"; caddy_rc=1
: >"$tmp/caddy.out"; : >"$tmp/caddy.err"; validate_rc=1; compose_rc=1; reload_rc=1; caddy_rc=1
fi
sleep 2
probe_rc=0
@@ -260,14 +275,16 @@ for item in ${spec.sites.map((site) => shQuote(`${site.hostname}|${site.healthPa
host="${'${item%%|*}'}"; path="/${'${item#*|}'}"; path="/${'${path#/}'}"
curl --noproxy '*' -fsS --connect-timeout 5 --max-time 30 --resolve "$host:${spec.caddy.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null 2>"$tmp/probe-$host.err" || probe_rc=1
done
python3 - "$port_rc" "$vpn_rc" "$caddy_rc" "$probe_rc" "$tmp/port.json" <<'PY'
udp_rc=0
${spec.isolation.preservedUdpPorts.map((port) => `ss -lun | grep -Eq '[:.]${port}[[:space:]]' || udp_rc=1`).join("\n")}
python3 - "$port_rc" "$vpn_rc" "$caddy_rc" "$probe_rc" "$udp_rc" "$validate_rc" "$compose_rc" "$reload_rc" "$tmp/port.json" <<'PY'
import json, sys
rcs = [int(x) for x in sys.argv[1:5]]
rcs = [int(x) for x in sys.argv[1:6]]
try:
port = json.load(open(sys.argv[5], encoding="utf-8"))
port = json.load(open(sys.argv[9], encoding="utf-8"))
except Exception:
port = {"ok": False}
print(json.dumps({"ok": all(x == 0 for x in rcs), "steps": {"tcpPortRelease": {"exitCode": rcs[0], **port}, "vpnApply": {"exitCode": rcs[1], "outputRedacted": True}, "caddyApply": {"exitCode": rcs[2]}, "siteProbes": {"exitCode": rcs[3]}}, "valuesPrinted": False}))
print(json.dumps({"ok": all(x == 0 for x in rcs), "steps": {"tcpPortRelease": {"exitCode": rcs[0], **port}, "vpnApply": {"exitCode": rcs[1], "outputRedacted": True}, "caddyApply": {"exitCode": rcs[2], "validateExitCode": int(sys.argv[6]), "composeExitCode": int(sys.argv[7]), "reloadExitCode": int(sys.argv[8])}, "siteProbes": {"exitCode": rcs[3]}, "preservedUdpListeners": {"exitCode": rcs[4]}}, "valuesPrinted": False}))
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
PY
`;
@@ -283,58 +300,20 @@ for item in ${spec.sites.map((site) => shQuote(`${site.hostname}|${site.healthPa
curl --noproxy '*' -fsS --connect-timeout 5 --max-time 20 --resolve "$host:${spec.caddy.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null || sites_rc=1
done
xray_rc=0; ss -ltn | grep -Eq '[:.]${spec.tcpPortRelease.toPort}[[:space:]]' || xray_rc=1
python3 - "$container_rc" "$ports_rc" "$sites_rc" "$xray_rc" <<'PY'
udp_rc=0
${spec.isolation.preservedUdpPorts.map((port) => `ss -lun | grep -Eq '[:.]${port}[[:space:]]' || udp_rc=1`).join("\n")}
python3 - "$container_rc" "$ports_rc" "$sites_rc" "$xray_rc" "$udp_rc" <<'PY'
import json, sys
rcs = [int(x) for x in sys.argv[1:]]
print(json.dumps({"ok": all(x == 0 for x in rcs), "container": {"exitCode": rcs[0]}, "tcpListeners": {"exitCode": rcs[1]}, "siteProbes": {"exitCode": rcs[2]}, "releasedService": {"exitCode": rcs[3]}, "valuesPrinted": False}))
print(json.dumps({"ok": all(x == 0 for x in rcs), "container": {"exitCode": rcs[0]}, "tcpListeners": {"exitCode": rcs[1]}, "siteProbes": {"exitCode": rcs[2]}, "releasedService": {"exitCode": rcs[3]}, "preservedUdpListeners": {"exitCode": rcs[4]}, "valuesPrinted": False}))
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
PY
`;
}
function legacyRemovalScript(spec: EdgeSpec): string {
const legacy = spec.legacyExposure.pk01;
return `set -u
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
python3 - ${shQuote(legacy.caddyConfigPath)} ${shQuote(legacy.managedBlock)} "$tmp/Caddyfile" <<'PY'
import re, sys
path, marker, out = sys.argv[1:]
text = open(path, encoding="utf-8").read()
pattern = re.compile(rf"(?ms)^# BEGIN unidesk managed {re.escape(marker)}\\n.*?^# END unidesk managed {re.escape(marker)}\\n?")
next_text, count = pattern.subn("", text)
open(out, "w", encoding="utf-8").write(next_text)
print(count)
PY
remove_rc=$?
if [ "$remove_rc" -eq 0 ]; then caddy fmt --overwrite "$tmp/Caddyfile" >/dev/null 2>"$tmp/fmt.err"; fmt_rc=$?; else fmt_rc=1; fi
if [ "$fmt_rc" -eq 0 ]; then caddy validate --config "$tmp/Caddyfile" >/dev/null 2>"$tmp/validate.err"; validate_rc=$?; else validate_rc=1; fi
if [ "$validate_rc" -eq 0 ]; then install -m 0644 "$tmp/Caddyfile" ${shQuote(legacy.caddyConfigPath)}; install_rc=$?; else install_rc=1; fi
if [ "$install_rc" -eq 0 ]; then systemctl reload ${shQuote(legacy.caddyServiceName)}; reload_rc=$?; else reload_rc=1; fi
python3 - "$remove_rc" "$fmt_rc" "$validate_rc" "$install_rc" "$reload_rc" <<'PY'
import json, sys
rcs = [int(x) for x in sys.argv[1:]]
print(json.dumps({"ok": all(x == 0 for x in rcs), "managedBlockRemoved": all(x == 0 for x in rcs), "steps": rcs, "valuesPrinted": False}))
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
PY
`;
}
function legacyStatusScript(spec: EdgeSpec): string {
const legacy = spec.legacyExposure.pk01;
return `set -u
if grep -Fq ${shQuote(`# BEGIN unidesk managed ${legacy.managedBlock}`)} ${shQuote(legacy.caddyConfigPath)}; then present=true; rc=1; else present=false; rc=0; fi
python3 - "$present" "$rc" <<'PY'
import json, sys
present = sys.argv[1] == "true"; rc = int(sys.argv[2])
print(json.dumps({"ok": rc == 0, "legacyManagedBlockPresent": present, "valuesPrinted": False}))
raise SystemExit(rc)
PY
`;
}
function renderCaddyfile(spec: EdgeSpec): string {
const global = `{\n servers {\n protocols ${spec.caddy.protocols.join(" ")}\n }\n}\n`;
return global + spec.sites.map((site) => `${site.hostname} {\n reverse_proxy ${site.upstream} {\n transport http {\n response_header_timeout ${spec.caddy.responseHeaderTimeoutSeconds}s\n }\n }\n}`).join("\n\n") + "\n";
return global + spec.sites.map((site) => `${site.hostname} {\n reverse_proxy ${site.upstream} {\n transport http {\n response_header_timeout ${spec.caddy.responseHeaderTimeoutSeconds}s${site.upstreamTlsServerName === undefined ? "" : `\n tls_server_name ${site.upstreamTlsServerName}`}\n }\n }\n}`).join("\n\n") + "\n";
}
function renderCompose(spec: EdgeSpec): string {
@@ -356,22 +335,26 @@ function integer(value: unknown, path: string): number {
return value as number;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw inputError(`${path} 必须是布尔值`, "invalid-config", path);
return value;
}
function stringList(value: unknown, path: string): string[] {
if (!Array.isArray(value) || value.length === 0) throw inputError(`${path} 必须是非空字符串数组`, "invalid-config", path);
return value.map((item, index) => string(item, `${path}[${index}]`));
}
function integerList(value: unknown, path: string): number[] {
if (!Array.isArray(value) || value.length === 0) throw inputError(`${path} 必须是非空整数数组`, "invalid-config", path);
return value.map((item, index) => integer(item, `${path}[${index}]`));
}
function hostname(value: unknown, path: string): string {
const result = string(value, path);
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(result)) throw inputError(`${path} hostname`, "invalid-config", path);
return result;
}
function optionalHostname(value: unknown, path: string): string | undefined {
return value === undefined ? undefined : hostname(value, path);
}
function httpPath(value: unknown, path: string): string {
const result = string(value, path);
if (!result.startsWith("/")) throw inputError(`${path} 必须以 / 开头`, "invalid-config", path);