From 754789c43c65ec493386c4a790460b433f8153a6 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:22:30 +0800 Subject: [PATCH] fix: route D601 PK01 postgres through master relay (#967) Co-authored-by: Codex --- docker-compose.yml | 24 +++ scripts/cli.ts | 4 +- scripts/runtime/pk01-postgres-relay.js | 147 ++++++++++++++++++ scripts/src/docker.ts | 35 ++++- scripts/src/help.ts | 4 +- .../k3sctl-adapter/k3s/code-queue.k8s.yaml | 2 +- 6 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 scripts/runtime/pk01-postgres-relay.js diff --git a/docker-compose.yml b/docker-compose.yml index 6ae21825..1cf835c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -475,6 +475,30 @@ services: - "host.docker.internal:host-gateway" logging: *unidesk-log-rotation + pk01-postgres-relay: + image: unidesk_provider-gateway + container_name: unidesk-pk01-postgres-relay + restart: unless-stopped + network_mode: "host" + environment: + PK01_POSTGRES_RELAY_LISTEN_HOST: "${UNIDESK_PK01_POSTGRES_RELAY_LISTEN_HOST:-0.0.0.0}" + PK01_POSTGRES_RELAY_LISTEN_PORT: "${UNIDESK_PK01_POSTGRES_RELAY_LISTEN_PORT:-15433}" + PK01_POSTGRES_RELAY_TARGET_HOST: "${UNIDESK_PK01_POSTGRES_RELAY_TARGET_HOST:-82.156.23.220}" + PK01_POSTGRES_RELAY_TARGET_PORT: "${UNIDESK_PK01_POSTGRES_RELAY_TARGET_PORT:-5432}" + PK01_POSTGRES_RELAY_IDLE_TIMEOUT_MS: "${UNIDESK_PK01_POSTGRES_RELAY_IDLE_TIMEOUT_MS:-300000}" + PK01_POSTGRES_RELAY_ALLOWED_SOURCE_CIDRS: "${UNIDESK_PK01_POSTGRES_RELAY_ALLOWED_SOURCE_CIDRS:-127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}" + LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_DAY:-manual}/${UNIDESK_LOG_PREFIX:-unidesk}_pk01-postgres-relay.jsonl" + command: ["bun", "/workspace/scripts/runtime/pk01-postgres-relay.js"] + volumes: + - .:/workspace:ro + - ${UNIDESK_LOG_DIR:-./.state/logs}:/var/log/unidesk + healthcheck: + test: ["CMD", "bun", "/workspace/scripts/runtime/pk01-postgres-relay.js", "--healthcheck"] + interval: 10s + timeout: 3s + retries: 6 + logging: *unidesk-log-rotation + volumes: unidesk_pgdata_10gb: name: unidesk_pgdata_10gb diff --git a/scripts/cli.ts b/scripts/cli.ts index 0fcb995c..b00fd020 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -1,6 +1,6 @@ import { readConfig } from "./src/config"; import { debugDispatch, debugHealth, debugSshPool, debugTask, isDebugDispatchCommand, type DebugDispatchCommand } from "./src/debug"; -import { isRebuildableService, rebuildService, restartService, stackLogs, stackStatus, startStack, stopStack, unsupportedRebuildService, unsupportedRestartService } from "./src/docker"; +import { isRebuildableService, isRestartableService, rebuildService, restartService, stackLogs, stackStatus, startStack, stopStack, unsupportedRebuildService, unsupportedRestartService } from "./src/docker"; import { emitError, emitJson, emitText, isRenderedCliResult } from "./src/output"; import { cancelJob, jobWithTail, listJobs, listJobsSummary, readJob, renderJobStatusSummary, runJob } from "./src/jobs"; import { checkHelp, parseCheckOptions, runChecks, runRecoveryGuardrailsCheck } from "./src/check"; @@ -488,7 +488,7 @@ async function main(): Promise { return; } if (sub === "restart") { - if (!isRebuildableService(third)) { + if (!isRestartableService(third)) { const result = unsupportedRestartService(third); emitJson(commandName, result, false); process.exitCode = 1; diff --git a/scripts/runtime/pk01-postgres-relay.js b/scripts/runtime/pk01-postgres-relay.js new file mode 100644 index 00000000..0b432f34 --- /dev/null +++ b/scripts/runtime/pk01-postgres-relay.js @@ -0,0 +1,147 @@ +const net = require("node:net"); +const fs = require("node:fs"); + +function readInt(name, fallback) { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { + throw new Error(`${name} must be a TCP port number`); + } + return parsed; +} + +const listenHost = process.env.PK01_POSTGRES_RELAY_LISTEN_HOST || "0.0.0.0"; +const listenPort = readInt("PK01_POSTGRES_RELAY_LISTEN_PORT", 15433); +const targetHost = process.env.PK01_POSTGRES_RELAY_TARGET_HOST || "82.156.23.220"; +const targetPort = readInt("PK01_POSTGRES_RELAY_TARGET_PORT", 5432); +const idleTimeoutMs = Number(process.env.PK01_POSTGRES_RELAY_IDLE_TIMEOUT_MS || 300000); +const allowedSourceCidrs = (process.env.PK01_POSTGRES_RELAY_ALLOWED_SOURCE_CIDRS || "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +const logFile = process.env.LOG_FILE || ""; + +function log(level, message, data = {}) { + const record = JSON.stringify({ + ts: new Date().toISOString(), + service: "pk01-postgres-relay", + level, + message, + data, + }); + console.log(record); + if (logFile) { + fs.appendFile(logFile, `${record}\n`, () => {}); + } +} + +function exitWithError(error) { + log("error", "fatal", { error: error instanceof Error ? error.message : String(error) }); + process.exit(1); +} + +function ipv4ToInt(value) { + const parts = value.split("."); + if (parts.length !== 4) return null; + let result = 0; + for (const part of parts) { + if (!/^\d+$/u.test(part)) return null; + const octet = Number(part); + if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null; + result = ((result << 8) | octet) >>> 0; + } + return result >>> 0; +} + +function normalizeRemoteAddress(value) { + if (!value) return ""; + if (value.startsWith("::ffff:")) return value.slice("::ffff:".length); + return value; +} + +function sourceAllowed(remoteAddress) { + const normalized = normalizeRemoteAddress(remoteAddress); + if (normalized === "::1") return true; + const ip = ipv4ToInt(normalized); + if (ip === null) return false; + return allowedSourceCidrs.some((cidr) => { + const [baseRaw, prefixRaw = "32"] = cidr.split("/"); + const base = ipv4ToInt(baseRaw); + const prefix = Number(prefixRaw); + if (base === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false; + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return (ip & mask) === (base & mask); + }); +} + +async function runHealthcheck() { + await new Promise((resolve, reject) => { + const socket = net.connect({ host: "127.0.0.1", port: listenPort }, resolve); + socket.setTimeout(2000); + socket.on("timeout", () => reject(new Error("healthcheck timeout"))); + socket.on("error", reject); + socket.on("connect", () => socket.destroy()); + }); +} + +if (process.argv.includes("--healthcheck")) { + runHealthcheck().then(() => process.exit(0), exitWithError); +} else { + let nextConnectionId = 0; + const server = net.createServer((client) => { + const connectionId = ++nextConnectionId; + const remoteAddress = normalizeRemoteAddress(client.remoteAddress || ""); + if (!sourceAllowed(remoteAddress)) { + log("warn", "source_rejected", { connectionId, remoteAddress, allowedSourceCidrs }); + client.destroy(); + return; + } + const upstream = net.connect({ host: targetHost, port: targetPort }); + client.setNoDelay(true); + upstream.setNoDelay(true); + client.setTimeout(idleTimeoutMs); + upstream.setTimeout(idleTimeoutMs); + + let closed = false; + const closeBoth = (reason) => { + if (closed) return; + closed = true; + client.destroy(); + upstream.destroy(); + log("info", "closed", { connectionId, reason }); + }; + + upstream.on("connect", () => { + log("info", "connected", { connectionId, target: `${targetHost}:${targetPort}` }); + }); + client.on("timeout", () => closeBoth("client_idle_timeout")); + upstream.on("timeout", () => closeBoth("upstream_idle_timeout")); + client.on("error", (error) => closeBoth(`client_error:${error.message}`)); + upstream.on("error", (error) => closeBoth(`upstream_error:${error.message}`)); + client.on("close", () => closeBoth("client_closed")); + upstream.on("close", () => closeBoth("upstream_closed")); + + client.pipe(upstream); + upstream.pipe(client); + }); + + server.on("error", exitWithError); + server.listen(listenPort, listenHost, () => { + log("info", "listening", { + listen: `${listenHost}:${listenPort}`, + target: `${targetHost}:${targetPort}`, + networkMode: "host", + allowedSourceCidrs, + }); + }); + + process.on("SIGTERM", () => { + log("info", "shutdown_requested", { signal: "SIGTERM" }); + server.close(() => process.exit(0)); + }); + process.on("SIGINT", () => { + log("info", "shutdown_requested", { signal: "SIGINT" }); + server.close(() => process.exit(0)); + }); +} diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index 3879c601..9dfeb1c7 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -44,12 +44,18 @@ export interface DeprecatedHostComposeEntry { } const rebuildableServices = ["backend-core", "frontend", "dev-frontend-proxy", "provider-gateway", "todo-note", "project-manager", "baidu-netdisk", "oa-event-flow", "code-queue-mgr"] as const; +const restartableServices = [...rebuildableServices, "pk01-postgres-relay"] as const; export type RebuildableService = typeof rebuildableServices[number]; +export type RestartableService = typeof restartableServices[number]; export function isRebuildableService(value: string | undefined): value is RebuildableService { return rebuildableServices.some((service) => service === value); } +export function isRestartableService(value: string | undefined): value is RestartableService { + return restartableServices.some((service) => service === value); +} + export function unsupportedRebuildService(value: string | undefined): Record { const service = value ?? null; const classifications: Record> = { @@ -83,6 +89,12 @@ export function unsupportedRebuildService(value: string | undefined): Record { const databaseBindHost = runtimeValue("UNIDESK_DATABASE_BIND_HOST") || "127.0.0.1"; const oaEventFlowBindHost = runtimeValue("UNIDESK_OA_EVENT_FLOW_BIND_HOST") || "127.0.0.1"; const oaEventFlowPort = Number(runtimeValue("UNIDESK_OA_EVENT_FLOW_PORT") || "4255"); + const pk01PostgresRelayListenHost = runtimeValue("UNIDESK_PK01_POSTGRES_RELAY_LISTEN_HOST") || "0.0.0.0"; + const pk01PostgresRelayPort = Number(runtimeValue("UNIDESK_PK01_POSTGRES_RELAY_LISTEN_PORT") || "15433"); const coreHealth = dockerExecJson("unidesk-backend-core", "/health"); const overview = dockerExecJson("unidesk-backend-core", "/api/overview"); return { @@ -676,6 +697,14 @@ export async function stackStatus(config: UniDeskConfig): Promise { listening: isPortListening(oaEventFlowPort), expected: oaEventFlowBindHost === "127.0.0.1" ? "local-only" : "restricted-to-code-queue-provider", }, + { + name: "pk01-postgres-relay", + bindHost: pk01PostgresRelayListenHost, + hostPort: pk01PostgresRelayPort, + containerPort: pk01PostgresRelayPort, + listening: isPortListening(pk01PostgresRelayPort), + expected: "source-acl-private-or-local-only", + }, ], internalPorts: [ { name: "backend-core", containerPort: config.network.core.containerPort, hostPort: null }, @@ -684,6 +713,7 @@ export async function stackStatus(config: UniDeskConfig): Promise { { name: "project-manager", containerPort: 4233, hostPort: null }, { name: "baidu-netdisk", containerPort: 4244, hostPort: null }, { name: "oa-event-flow", containerPort: 4255, hostPort: null }, + { name: "pk01-postgres-relay", containerPort: pk01PostgresRelayPort, hostPort: pk01PostgresRelayPort }, ], containers, health: { @@ -693,6 +723,7 @@ export async function stackStatus(config: UniDeskConfig): Promise { providerIngress: await probe(`http://127.0.0.1:${config.network.providerIngress.port}/health`), providerDataPortListening: isPortListening(config.network.providerData.port), database: dockerExec(config, "unidesk-database", ["pg_isready", "-U", config.database.user, "-d", config.database.name]), + pk01PostgresRelay: dockerExec(config, "unidesk-pk01-postgres-relay", ["bun", "/workspace/scripts/runtime/pk01-postgres-relay.js", "--healthcheck"]), overview, }, urls: { @@ -781,7 +812,7 @@ export function stackLogs(config: UniDeskConfig, tailBytes: number): unknown { const truncated = sizeBytes > tailBytes; return { path, name: basename(path), sizeBytes, tailBytes, truncated, tail: tailFile(path, tailBytes) }; }); - const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-dev-frontend-proxy", "unidesk-provider-gateway-main", "todo-note-backend", "project-manager-backend", "baidu-netdisk-backend", "oa-event-flow-backend"]; + const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-dev-frontend-proxy", "unidesk-provider-gateway-main", "unidesk-pk01-postgres-relay", "todo-note-backend", "project-manager-backend", "baidu-netdisk-backend", "oa-event-flow-backend"]; const docker = containerNames.map((name) => { const result = runCommand(["docker", "logs", "--tail", "40", name], repoRoot); return { diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 317aaac1..36577439 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -20,7 +20,7 @@ export function rootHelp(): unknown { { command: "server cleanup plan [--min-age-hours N] [--limit N]", description: "Dry-run Docker image cleanup plan only: list active/protected images, stale candidates older than the default 24h threshold, risk, estimated reclaim, and manual review commands without deleting anything." }, { command: "gc plan|run|db-trace|policy|remote [--confirm] [--logs-keep-days N] [--include-browser-cache]", description: "One-time main-server or remote provider disk relief and low-risk anti-bloat policy for logs, journald, allowlisted /tmp artifacts, scoped core dumps and explicit trace telemetry retention; plan is read-only and run requires --confirm." }, { command: "server rebuild ", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." }, - { command: "server restart ", description: "No-build single-service Compose restart for reviewed main-server maintenance recovery; returns an async job and validates the recreated container." }, + { command: "server restart ", description: "No-build single-service Compose restart for reviewed main-server maintenance recovery; returns an async job and validates the recreated container." }, { command: "provider attach [--master-server URL] [--up] [--force] | provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." }, { command: "trans [operation args...] (alias of ssh ...)", description: "Open a Host SSH / WSL SSH maintenance session; provider WebSocket carries control and host.ssh.tcp-pool carries stdin/stdout/stderr data." }, { command: "trans gh:/owner/repo[/pr|/issue][/number[/1]] ls|cat|rg|apply-patch", description: "Treat GitHub PRs/issues as virtual text directories; `issue ls --search/--state` covers filtered reads, `cat|rg` reads first-floor body text, and `apply-patch` updates `body.md` through UniDesk gh plus apply-patch v2." }, @@ -118,7 +118,7 @@ export function serverHelp(action: string | undefined = undefined): unknown { logs: "bun scripts/cli.ts server logs [--tail-bytes N]", cleanup: "bun scripts/cli.ts server cleanup plan [--min-age-hours N] [--limit N]", rebuild: "bun scripts/cli.ts server rebuild ", - restart: "bun scripts/cli.ts server restart ", + restart: "bun scripts/cli.ts server restart ", }, cleanupPlan: { dryRunOnly: true, diff --git a/src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml b/src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml index 46fb5d8e..49cb3fa8 100644 --- a/src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml +++ b/src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml @@ -919,7 +919,7 @@ spec: - name: TCP_EGRESS_HTTP_PROXY value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789" - name: TCP_EGRESS_ROUTES - value: "postgres=15432=74.48.78.17:15432,pk01-postgres=25432=82.156.23.220:5432,oa-event-flow=4255=74.48.78.17:4255" + value: "postgres=15432=74.48.78.17:15432,pk01-postgres=25432=74.48.78.17:15433,oa-event-flow=4255=74.48.78.17:4255" - name: TCP_EGRESS_HEALTH_PORT value: "18080" - name: TCP_EGRESS_CONNECT_ATTEMPTS