1174 lines
50 KiB
TypeScript
1174 lines
50 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname } from "node:path";
|
|
import type { UniDeskConfig } from "./config";
|
|
import { rootPath } from "./config";
|
|
import { runCommand, type CommandResult } from "./command";
|
|
import { resolveEgressProxySourceRef, type MasterShadowsocksSourceSpec } from "./egress-proxy-sources";
|
|
import type { RenderedCliResult } from "./output";
|
|
import { shQuote } from "./platform-infra-public-service";
|
|
import { fingerprintSecretValues, readEnvSourceFile, requiredEnvValue } from "./secrets";
|
|
import { transHostProxyEnvSummary } from "./trans-host-proxy";
|
|
|
|
const configPath = "config/platform-infra/host-proxy.yaml";
|
|
|
|
interface HostProxyConfig {
|
|
defaults: { targetId: string };
|
|
server: HostProxyServer;
|
|
sources: Record<string, HostProxySource>;
|
|
targets: Record<string, HostProxyTarget>;
|
|
}
|
|
|
|
interface HostProxyServer {
|
|
id: string;
|
|
enabled: boolean;
|
|
benchmarkRef: string;
|
|
implementationRef: string;
|
|
sourceConfigRef: string;
|
|
sourceRef: string;
|
|
sourceKey: string;
|
|
sourceFingerprint: string;
|
|
masterShadowsocks: MasterShadowsocksSourceSpec;
|
|
composeFile: string;
|
|
serviceName: string;
|
|
containerName: string;
|
|
image: string;
|
|
configPath: string;
|
|
listenHost: string;
|
|
listenPort: number;
|
|
health: { mode: "tcp"; host: string; port: number };
|
|
fingerprint: string;
|
|
}
|
|
|
|
interface HostProxySource {
|
|
id: string;
|
|
sourceType: "benchmark-validated-master-shadowsocks";
|
|
serverRef: string;
|
|
benchmarkRef: string;
|
|
implementationRef: string;
|
|
sourceConfigRef: string;
|
|
sourceRef: string;
|
|
sourceKey: string;
|
|
sourceFingerprint: string;
|
|
masterShadowsocks: MasterShadowsocksSourceSpec;
|
|
client: HostProxyClient;
|
|
proxyUrl: string;
|
|
externalProbeUrl: string;
|
|
fingerprint: string;
|
|
}
|
|
|
|
interface HostProxyClient {
|
|
mode: "trans-static-binary";
|
|
upstreamUrl: string;
|
|
version: string;
|
|
archiveCachePath: string;
|
|
archiveSha256: string;
|
|
archiveInstallPath: string;
|
|
binaryMember: string;
|
|
binaryCachePath: string;
|
|
binarySha256: string;
|
|
installPath: string;
|
|
configPath: string;
|
|
unitPath: string;
|
|
serviceName: string;
|
|
listenHost: string;
|
|
listenPort: number;
|
|
podAccess: HostProxyPodAccess | null;
|
|
clashApiListen: string;
|
|
healthUrl: string;
|
|
}
|
|
|
|
interface HostProxyPodAccess {
|
|
enabled: boolean;
|
|
listenHost: string;
|
|
listenPort: number;
|
|
proxyUrl: string;
|
|
}
|
|
|
|
interface HostProxyTarget {
|
|
id: string;
|
|
route: string;
|
|
enabled: boolean;
|
|
sourceRef: string;
|
|
source: HostProxySource;
|
|
env: {
|
|
httpProxy: string;
|
|
httpsProxy: string;
|
|
allProxy: string;
|
|
noProxy: string[];
|
|
};
|
|
files: {
|
|
envFile: string;
|
|
profile: string;
|
|
apt: string;
|
|
dockerSystemdDropIn: string;
|
|
k3sSystemdDropIn: string;
|
|
};
|
|
apply: {
|
|
reloadSystemd: boolean;
|
|
restartDocker: boolean;
|
|
restartK3s: boolean;
|
|
};
|
|
}
|
|
|
|
interface HostProxySecretMaterial {
|
|
serverConfigJson: string;
|
|
clientConfigJson: string;
|
|
fingerprint: string;
|
|
sourcePath: string;
|
|
valuesPrinted: false;
|
|
}
|
|
|
|
type HostProxyAction = "plan" | "apply" | "status";
|
|
|
|
interface HostProxyOptions {
|
|
action: HostProxyAction;
|
|
targetId: string;
|
|
confirm: boolean;
|
|
dryRun: boolean;
|
|
}
|
|
|
|
export async function runPlatformInfraHostProxyCommand(_config: UniDeskConfig, args: string[]): Promise<RenderedCliResult | Record<string, unknown>> {
|
|
const options = parseOptions(args);
|
|
const config = readHostProxyConfig();
|
|
const target = resolveTarget(config, options.targetId);
|
|
if (options.action === "plan") return renderPlan(plan(config.server, target));
|
|
if (options.action === "status") return renderStatus(status(config.server, target));
|
|
if (!options.confirm || options.dryRun) {
|
|
return renderPlan({
|
|
...plan(config.server, target),
|
|
mode: "dry-run",
|
|
mutation: false,
|
|
next: {
|
|
apply: `bun scripts/cli.ts platform-infra egress-proxy host apply --target ${target.id} --confirm`,
|
|
status: `bun scripts/cli.ts platform-infra egress-proxy host status --target ${target.id}`,
|
|
},
|
|
});
|
|
}
|
|
return renderApply(apply(config.server, target));
|
|
}
|
|
|
|
function parseOptions(args: string[]): HostProxyOptions {
|
|
const actionRaw = args[0] ?? "plan";
|
|
if (actionRaw !== "plan" && actionRaw !== "apply" && actionRaw !== "status") {
|
|
throw new Error("platform-infra egress-proxy host usage: host plan|apply|status --target JD01 [--dry-run|--confirm]");
|
|
}
|
|
const targetId = option(args, "--target");
|
|
const confirm = args.includes("--confirm");
|
|
const dryRun = args.includes("--dry-run") || !confirm;
|
|
if (confirm && args.includes("--dry-run")) throw new Error("host apply accepts only one of --confirm or --dry-run");
|
|
return { action: actionRaw, targetId: targetId ?? readHostProxyConfig().defaults.targetId, confirm, dryRun };
|
|
}
|
|
|
|
function readHostProxyConfig(): HostProxyConfig {
|
|
const root = record(Bun.YAML.parse(readFileSync(rootPath(configPath), "utf8")), configPath);
|
|
if (root.kind !== "platform-infra-host-proxy") throw new Error(`${configPath}.kind must be platform-infra-host-proxy`);
|
|
const defaultsRaw = record(root.defaults, `${configPath}.defaults`);
|
|
const server = parseServer(record(root.server, `${configPath}.server`), `${configPath}.server`);
|
|
const sources = Object.fromEntries(Object.entries(record(root.sources, `${configPath}.sources`)).map(([id, value]) => {
|
|
const source = parseSource(id, record(value, `${configPath}.sources.${id}`));
|
|
if (source.serverRef !== `server.${server.id}`) throw new Error(`${configPath}.sources.${id}.serverRef must be server.${server.id}`);
|
|
return [id, source];
|
|
}));
|
|
const targets: Record<string, HostProxyTarget> = {};
|
|
for (const [id, value] of Object.entries(record(root.targets, `${configPath}.targets`))) {
|
|
targets[id] = parseTarget(id, record(value, `${configPath}.targets.${id}`), sources);
|
|
}
|
|
return {
|
|
defaults: { targetId: stringField(defaultsRaw, "targetId", `${configPath}.defaults`) },
|
|
server,
|
|
sources,
|
|
targets,
|
|
};
|
|
}
|
|
|
|
function parseServer(raw: Record<string, unknown>, label: string): HostProxyServer {
|
|
const sourceConfigRef = stringField(raw, "sourceConfigRef", label);
|
|
const resolved = resolveEgressProxySourceRef(sourceConfigRef, `${label}.sourceConfigRef`);
|
|
if (resolved.sourceType !== "master-shadowsocks" || resolved.masterShadowsocks === null) {
|
|
throw new Error(`${label}.sourceConfigRef must reference a master-shadowsocks source`);
|
|
}
|
|
const healthRaw = record(raw.health, `${label}.health`);
|
|
const server = {
|
|
id: stringField(raw, "id", label),
|
|
enabled: booleanField(raw, "enabled", label),
|
|
benchmarkRef: stringField(raw, "benchmarkRef", label),
|
|
implementationRef: stringField(raw, "implementationRef", label),
|
|
sourceConfigRef,
|
|
sourceRef: resolved.sourceRef,
|
|
sourceKey: resolved.sourceKey,
|
|
sourceFingerprint: resolved.fingerprint,
|
|
masterShadowsocks: resolved.masterShadowsocks,
|
|
composeFile: repoYamlPathField(raw, "composeFile", label),
|
|
serviceName: stringField(raw, "serviceName", label),
|
|
containerName: stringField(raw, "containerName", label),
|
|
image: imageField(raw, "image", label),
|
|
configPath: absolutePathField(raw, "configPath", label),
|
|
listenHost: hostField(raw, "listenHost", label),
|
|
listenPort: portField(raw, "listenPort", label),
|
|
health: {
|
|
mode: enumField(healthRaw, "mode", `${label}.health`, ["tcp"] as const),
|
|
host: hostField(healthRaw, "host", `${label}.health`),
|
|
port: portField(healthRaw, "port", `${label}.health`),
|
|
},
|
|
fingerprint: "",
|
|
};
|
|
return {
|
|
...server,
|
|
fingerprint: fingerprint({
|
|
benchmarkRef: server.benchmarkRef,
|
|
implementationRef: server.implementationRef,
|
|
sourceConfigRef,
|
|
sourceFingerprint: server.sourceFingerprint,
|
|
composeFile: server.composeFile,
|
|
serviceName: server.serviceName,
|
|
containerName: server.containerName,
|
|
configPath: server.configPath,
|
|
listenHost: server.listenHost,
|
|
listenPort: server.listenPort,
|
|
health: server.health,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function parseSource(id: string, raw: Record<string, unknown>): HostProxySource {
|
|
const sourceType = enumField(raw, "sourceType", `${configPath}.sources.${id}`, ["benchmark-validated-master-shadowsocks"] as const);
|
|
const sourceConfigRef = stringField(raw, "sourceConfigRef", `${configPath}.sources.${id}`);
|
|
const resolved = resolveEgressProxySourceRef(sourceConfigRef, `${configPath}.sources.${id}.sourceConfigRef`);
|
|
if (resolved.sourceType !== "master-shadowsocks" || resolved.masterShadowsocks === null) {
|
|
throw new Error(`${configPath}.sources.${id}.sourceConfigRef must reference a master-shadowsocks source`);
|
|
}
|
|
const client = clientSpec(record(raw.client, `${configPath}.sources.${id}.client`), `${configPath}.sources.${id}.client`);
|
|
const proxyUrl = urlField(raw, "proxyUrl", `${configPath}.sources.${id}`);
|
|
const expectedProxyUrl = `http://${client.listenHost}:${client.listenPort}`;
|
|
if (proxyUrl !== expectedProxyUrl) throw new Error(`${configPath}.sources.${id}.proxyUrl must be ${expectedProxyUrl}`);
|
|
const source = {
|
|
id,
|
|
sourceType,
|
|
serverRef: stringField(raw, "serverRef", `${configPath}.sources.${id}`),
|
|
benchmarkRef: stringField(raw, "benchmarkRef", `${configPath}.sources.${id}`),
|
|
implementationRef: stringField(raw, "implementationRef", `${configPath}.sources.${id}`),
|
|
sourceConfigRef,
|
|
sourceRef: resolved.sourceRef,
|
|
sourceKey: resolved.sourceKey,
|
|
sourceFingerprint: resolved.fingerprint,
|
|
masterShadowsocks: resolved.masterShadowsocks,
|
|
client,
|
|
proxyUrl,
|
|
externalProbeUrl: urlField(raw, "externalProbeUrl", `${configPath}.sources.${id}`),
|
|
fingerprint: "",
|
|
};
|
|
return {
|
|
...source,
|
|
fingerprint: fingerprint({
|
|
sourceType,
|
|
benchmarkRef: source.benchmarkRef,
|
|
implementationRef: source.implementationRef,
|
|
sourceConfigRef,
|
|
sourceFingerprint: source.sourceFingerprint,
|
|
client,
|
|
proxyUrl,
|
|
externalProbeUrl: source.externalProbeUrl,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function clientSpec(raw: Record<string, unknown>, label: string): HostProxyClient {
|
|
const mode = enumField(raw, "mode", label, ["trans-static-binary"] as const);
|
|
const podAccess = raw.podAccess === undefined ? null : podAccessSpec(record(raw.podAccess, `${label}.podAccess`), `${label}.podAccess`);
|
|
const client = {
|
|
mode,
|
|
upstreamUrl: httpsUrlField(raw, "upstreamUrl", label),
|
|
version: stringField(raw, "version", label),
|
|
archiveCachePath: repoStatePathField(raw, "archiveCachePath", label),
|
|
archiveSha256: sha256Field(raw, "archiveSha256", label),
|
|
archiveInstallPath: absolutePathField(raw, "archiveInstallPath", label),
|
|
binaryMember: relativePathField(raw, "binaryMember", label),
|
|
binaryCachePath: repoStatePathField(raw, "binaryCachePath", label),
|
|
binarySha256: sha256Field(raw, "binarySha256", label),
|
|
installPath: absolutePathField(raw, "installPath", label),
|
|
configPath: absolutePathField(raw, "configPath", label),
|
|
unitPath: absolutePathField(raw, "unitPath", label),
|
|
serviceName: stringField(raw, "serviceName", label),
|
|
listenHost: hostField(raw, "listenHost", label),
|
|
listenPort: portField(raw, "listenPort", label),
|
|
podAccess,
|
|
clashApiListen: listenAddressField(raw, "clashApiListen", label),
|
|
healthUrl: urlField(raw, "healthUrl", label),
|
|
};
|
|
if (client.listenHost !== "127.0.0.1") throw new Error(`${label}.listenHost must stay 127.0.0.1 for host-local bootstrap proxy`);
|
|
if (client.unitPath !== `/etc/systemd/system/${client.serviceName}.service`) throw new Error(`${label}.unitPath must match serviceName`);
|
|
return client;
|
|
}
|
|
|
|
function podAccessSpec(raw: Record<string, unknown>, label: string): HostProxyPodAccess {
|
|
const enabled = booleanField(raw, "enabled", label);
|
|
const listenHost = hostField(raw, "listenHost", label);
|
|
const listenPort = portField(raw, "listenPort", label);
|
|
const proxyUrl = urlField(raw, "proxyUrl", label);
|
|
const expectedProxyUrl = `http://${listenHost}:${listenPort}`;
|
|
if (proxyUrl !== expectedProxyUrl) throw new Error(`${label}.proxyUrl must be ${expectedProxyUrl}`);
|
|
if (listenHost === "0.0.0.0" || listenHost === "127.0.0.1") {
|
|
throw new Error(`${label}.listenHost must be a pod-reachable private host address, not ${listenHost}`);
|
|
}
|
|
if (!privateIpv4(listenHost)) throw new Error(`${label}.listenHost must be a private IPv4 address`);
|
|
return { enabled, listenHost, listenPort, proxyUrl };
|
|
}
|
|
|
|
function parseTarget(id: string, raw: Record<string, unknown>, sources: Record<string, HostProxySource>): HostProxyTarget {
|
|
const sourceRef = stringField(raw, "sourceRef", `${configPath}.targets.${id}`);
|
|
if (!sourceRef.startsWith("sources.")) throw new Error(`${configPath}.targets.${id}.sourceRef must use sources.<id>`);
|
|
const sourceId = sourceRef.slice("sources.".length);
|
|
const source = sources[sourceId];
|
|
if (source === undefined) throw new Error(`${configPath}.targets.${id}.sourceRef points to missing source ${sourceId}`);
|
|
const envRaw = record(raw.env, `${configPath}.targets.${id}.env`);
|
|
const filesRaw = record(raw.files, `${configPath}.targets.${id}.files`);
|
|
const applyRaw = record(raw.apply, `${configPath}.targets.${id}.apply`);
|
|
const noProxy = stringArrayField(envRaw, "noProxy", `${configPath}.targets.${id}.env`);
|
|
if (!noProxy.includes("hyueapi.com") || !noProxy.includes(".hyueapi.com")) {
|
|
throw new Error(`${configPath}.targets.${id}.env.noProxy must preserve hyueapi.com and .hyueapi.com`);
|
|
}
|
|
const target: HostProxyTarget = {
|
|
id,
|
|
route: routeField(raw, "route", `${configPath}.targets.${id}`),
|
|
enabled: booleanField(raw, "enabled", `${configPath}.targets.${id}`),
|
|
sourceRef,
|
|
source,
|
|
env: {
|
|
httpProxy: urlField(envRaw, "httpProxy", `${configPath}.targets.${id}.env`),
|
|
httpsProxy: urlField(envRaw, "httpsProxy", `${configPath}.targets.${id}.env`),
|
|
allProxy: urlField(envRaw, "allProxy", `${configPath}.targets.${id}.env`),
|
|
noProxy,
|
|
},
|
|
files: {
|
|
envFile: absolutePathField(filesRaw, "envFile", `${configPath}.targets.${id}.files`),
|
|
profile: absolutePathField(filesRaw, "profile", `${configPath}.targets.${id}.files`),
|
|
apt: absolutePathField(filesRaw, "apt", `${configPath}.targets.${id}.files`),
|
|
dockerSystemdDropIn: absolutePathField(filesRaw, "dockerSystemdDropIn", `${configPath}.targets.${id}.files`),
|
|
k3sSystemdDropIn: absolutePathField(filesRaw, "k3sSystemdDropIn", `${configPath}.targets.${id}.files`),
|
|
},
|
|
apply: {
|
|
reloadSystemd: booleanField(applyRaw, "reloadSystemd", `${configPath}.targets.${id}.apply`),
|
|
restartDocker: booleanField(applyRaw, "restartDocker", `${configPath}.targets.${id}.apply`),
|
|
restartK3s: booleanField(applyRaw, "restartK3s", `${configPath}.targets.${id}.apply`),
|
|
},
|
|
};
|
|
if (target.env.httpProxy !== source.proxyUrl || target.env.httpsProxy !== source.proxyUrl || target.env.allProxy !== source.proxyUrl) {
|
|
throw new Error(`${configPath}.targets.${id}.env proxy URLs must match ${sourceRef}.proxyUrl`);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function resolveTarget(config: HostProxyConfig, targetId: string): HostProxyTarget {
|
|
const target = config.targets[targetId];
|
|
if (target === undefined) throw new Error(`${configPath}.targets.${targetId} is not defined`);
|
|
return target;
|
|
}
|
|
|
|
function plan(server: HostProxyServer, target: HostProxyTarget): Record<string, unknown> {
|
|
const client = target.source.client;
|
|
return {
|
|
ok: true,
|
|
command: "platform-infra egress-proxy host plan",
|
|
mode: "plan",
|
|
mutation: false,
|
|
server: serverSummary(server),
|
|
target: targetSummary(target),
|
|
artifact: artifactSummary(client),
|
|
files: target.files,
|
|
apply: target.apply,
|
|
env: { proxyUrl: target.env.httpProxy, noProxyCount: target.env.noProxy.length, noProxyIncludesHyueapi: true },
|
|
valuesPrinted: false,
|
|
next: {
|
|
dryRun: `bun scripts/cli.ts platform-infra egress-proxy host apply --target ${target.id} --dry-run`,
|
|
apply: `bun scripts/cli.ts platform-infra egress-proxy host apply --target ${target.id} --confirm`,
|
|
status: `bun scripts/cli.ts platform-infra egress-proxy host status --target ${target.id}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function apply(server: HostProxyServer, target: HostProxyTarget): Record<string, unknown> {
|
|
const material = proxySecretMaterial(server, target.source);
|
|
const serverApply = applyMasterProxyServer(server, material);
|
|
const artifact = prepareClientArtifact(target.source.client);
|
|
const remotePrepare = runTrans(target.route, remotePrepareScript(target.source.client), 30);
|
|
const remoteArchive = remotePrepare.exitCode === 0
|
|
? runTrans(target.route, remoteArchiveStatusScript(target.source.client), 30)
|
|
: remotePrepare;
|
|
const remoteArchiveParsed = record(parseJson(remoteArchive.stdout) ?? {});
|
|
const upload = remotePrepare.exitCode === 0 && remoteArchive.exitCode === 0 && remoteArchiveParsed.archiveSha256Ok === true
|
|
? skippedCommandResult(["bun", "scripts/ssh-cli.ts", "ssh", target.route, "upload", rootPath(target.source.client.archiveCachePath), target.source.client.archiveInstallPath], "remote archive already matches YAML sha256")
|
|
: remotePrepare.exitCode === 0
|
|
? runTransUpload(target.route, rootPath(target.source.client.archiveCachePath), target.source.client.archiveInstallPath, 1_800_000)
|
|
: remotePrepare;
|
|
const remoteApply = upload.exitCode === 0
|
|
? runTrans(target.route, remoteApplyScript(target, material.clientConfigJson), 60)
|
|
: upload;
|
|
const parsed = parseJson(remoteApply.stdout);
|
|
return {
|
|
ok: serverApply.ok === true && artifact.ok === true && remotePrepare.exitCode === 0 && upload.exitCode === 0 && remoteApply.exitCode === 0 && record(parsed).ok !== false,
|
|
command: "platform-infra egress-proxy host apply",
|
|
mode: "apply",
|
|
mutation: serverApply.ok === true && remoteApply.exitCode === 0,
|
|
server: serverSummary(server),
|
|
serverApply,
|
|
target: targetSummary(target),
|
|
artifact,
|
|
distribution: {
|
|
mode: target.source.client.mode,
|
|
artifact: "archive",
|
|
prepare: compactLocalResult(remotePrepare),
|
|
remoteArchive: compactLocalResult(remoteArchive),
|
|
upload: compactLocalResult(upload),
|
|
},
|
|
remote: parsed ?? { stdoutPreview: remoteApply.stdout.slice(0, 2000) },
|
|
stderrTail: remoteApply.stderr.slice(-2000),
|
|
valuesPrinted: false,
|
|
next: { status: `bun scripts/cli.ts platform-infra egress-proxy host status --target ${target.id}` },
|
|
};
|
|
}
|
|
|
|
function status(server: HostProxyServer, target: HostProxyTarget): Record<string, unknown> {
|
|
const serverStatus = masterProxyServerStatus(server);
|
|
const result = runTrans(target.route, remoteStatusScript(target), 45);
|
|
const parsed = parseJson(result.stdout);
|
|
return {
|
|
ok: serverStatus.ok === true && result.exitCode === 0 && record(parsed).ok !== false,
|
|
command: "platform-infra egress-proxy host status",
|
|
mode: "status",
|
|
mutation: false,
|
|
server: serverSummary(server),
|
|
serverStatus,
|
|
target: targetSummary(target),
|
|
remote: parsed ?? { stdoutPreview: result.stdout.slice(0, 2000) },
|
|
stderrTail: result.stderr.slice(-2000),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function proxySecretMaterial(server: HostProxyServer, source: HostProxySource): HostProxySecretMaterial {
|
|
const envSource = readEnvSourceFile({
|
|
root: rootPath(".state", "secrets"),
|
|
sourceRef: source.sourceRef,
|
|
missingMessage: (sourcePath) => `host proxy requires ${sourcePath} with ${source.sourceKey}`,
|
|
});
|
|
const password = requiredEnvValue(envSource.values, source.sourceKey, source.sourceRef);
|
|
const serverConfigJson = renderMasterShadowsocksServerConfig(server, password);
|
|
const clientConfigJson = renderSingBoxClientConfig(source, password);
|
|
return {
|
|
serverConfigJson,
|
|
clientConfigJson,
|
|
sourcePath: envSource.sourcePathRedacted,
|
|
fingerprint: fingerprintSecretValues({ password, serverConfigJson, clientConfigJson }, ["password", "serverConfigJson", "clientConfigJson"]),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function renderMasterShadowsocksServerConfig(server: HostProxyServer, password: string): string {
|
|
return `${JSON.stringify({
|
|
server: server.listenHost,
|
|
server_port: server.listenPort,
|
|
password,
|
|
method: server.masterShadowsocks.method,
|
|
timeout: 300,
|
|
mode: "tcp_only",
|
|
}, null, 2)}\n`;
|
|
}
|
|
|
|
function renderSingBoxClientConfig(source: HostProxySource, password: string): string {
|
|
const client = source.client;
|
|
const inbounds = [
|
|
{ type: "mixed", tag: "mixed-host-local", listen: client.listenHost, listen_port: client.listenPort },
|
|
...(client.podAccess?.enabled
|
|
? [{ type: "mixed", tag: "mixed-pod-access", listen: client.podAccess.listenHost, listen_port: client.podAccess.listenPort }]
|
|
: []),
|
|
];
|
|
return `${JSON.stringify({
|
|
log: { level: "info", timestamp: true },
|
|
experimental: { clash_api: { external_controller: client.clashApiListen } },
|
|
inbounds,
|
|
outbounds: [
|
|
{
|
|
type: "shadowsocks",
|
|
tag: "master-vpn",
|
|
server: source.masterShadowsocks.serverHost,
|
|
server_port: source.masterShadowsocks.serverPort,
|
|
method: source.masterShadowsocks.method,
|
|
password,
|
|
},
|
|
{ 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",
|
|
},
|
|
}, null, 2)}\n`;
|
|
}
|
|
|
|
function applyMasterProxyServer(server: HostProxyServer, material: HostProxySecretMaterial): Record<string, unknown> {
|
|
mkdirSync(dirname(server.configPath), { recursive: true });
|
|
writeFileSync(server.configPath, material.serverConfigJson, "utf8");
|
|
chmodSync(server.configPath, 0o600);
|
|
const compose = runCommand(["docker", "compose", "-f", rootPath(server.composeFile), "up", "-d", server.serviceName], rootPath(), { timeoutMs: 60_000 });
|
|
const health = masterProxyTcpHealth(server);
|
|
const inspect = runCommand(["docker", "inspect", server.containerName, "--format", "{{.State.Status}} {{.State.Running}} {{.Config.Image}}"], rootPath(), { timeoutMs: 15_000 });
|
|
const existingHealthy = compose.exitCode !== 0
|
|
&& /container name .* already in use|Conflict\. The container name/i.test(compose.stderr)
|
|
&& inspect.exitCode === 0
|
|
&& /\brunning true\b/.test(inspect.stdout.trim())
|
|
&& health.ok === true;
|
|
return {
|
|
ok: server.enabled && (compose.exitCode === 0 || existingHealthy) && health.ok,
|
|
enabled: server.enabled,
|
|
benchmarkRef: server.benchmarkRef,
|
|
implementationRef: server.implementationRef,
|
|
sourceConfigRef: server.sourceConfigRef,
|
|
sourceFingerprint: server.sourceFingerprint,
|
|
materialFingerprint: material.fingerprint,
|
|
compose: compactLocalResult(compose),
|
|
existingHealthy,
|
|
inspect: compactLocalResult(inspect),
|
|
health,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function masterProxyServerStatus(server: HostProxyServer): Record<string, unknown> {
|
|
const inspect = runCommand(["docker", "inspect", server.containerName, "--format", "{{.State.Status}} {{.State.Running}} {{.Config.Image}}"], rootPath(), { timeoutMs: 15_000 });
|
|
const health = masterProxyTcpHealth(server);
|
|
return {
|
|
ok: server.enabled && inspect.exitCode === 0 && health.ok,
|
|
enabled: server.enabled,
|
|
benchmarkRef: server.benchmarkRef,
|
|
implementationRef: server.implementationRef,
|
|
sourceConfigRef: server.sourceConfigRef,
|
|
sourceFingerprint: server.sourceFingerprint,
|
|
container: inspect.exitCode === 0 ? inspect.stdout.trim() : "missing",
|
|
inspect: compactLocalResult(inspect),
|
|
health,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function prepareClientArtifact(client: HostProxyClient): Record<string, unknown> {
|
|
const binaryPath = rootPath(client.binaryCachePath);
|
|
const archivePath = rootPath(client.archiveCachePath);
|
|
let archiveAction = "existing";
|
|
let binaryAction = "existing";
|
|
if (!existsSync(binaryPath) || sha256File(binaryPath) !== client.binarySha256) {
|
|
if (!existsSync(archivePath) || sha256File(archivePath) !== client.archiveSha256) {
|
|
mkdirSync(dirname(archivePath), { recursive: true });
|
|
const download = runCommand(["curl", "-fL", "--connect-timeout", "15", "--max-time", "240", "-o", archivePath, client.upstreamUrl], rootPath(), { timeoutMs: 300_000 });
|
|
if (download.exitCode !== 0) return { ok: false, action: "download", result: compactLocalResult(download), valuesPrinted: false };
|
|
archiveAction = "downloaded";
|
|
}
|
|
if (sha256File(archivePath) !== client.archiveSha256) {
|
|
return { ok: false, action: archiveAction, reason: "archive-sha256-mismatch", path: client.archiveCachePath, valuesPrinted: false };
|
|
}
|
|
mkdirSync(dirname(binaryPath), { recursive: true });
|
|
const extract = runCommand(["bash", "-lc", `tar -xOf ${shQuote(archivePath)} ${shQuote(client.binaryMember)} > ${shQuote(binaryPath)} && chmod 0755 ${shQuote(binaryPath)}`], rootPath(), { timeoutMs: 120_000 });
|
|
if (extract.exitCode !== 0) return { ok: false, action: "extract", result: compactLocalResult(extract), valuesPrinted: false };
|
|
binaryAction = "extracted";
|
|
}
|
|
const binarySha = sha256File(binaryPath);
|
|
return {
|
|
ok: binarySha === client.binarySha256,
|
|
mode: client.mode,
|
|
version: client.version,
|
|
archiveCachePath: client.archiveCachePath,
|
|
binaryCachePath: client.binaryCachePath,
|
|
archiveAction,
|
|
binaryAction,
|
|
binarySha256: binarySha,
|
|
installPath: client.installPath,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function remotePrepareScript(client: HostProxyClient): string {
|
|
return `
|
|
set -eu
|
|
mkdir -p ${shQuote(dirname(client.installPath))} ${shQuote(dirname(client.configPath))} ${shQuote(dirname(client.unitPath))} ${shQuote(dirname(client.archiveInstallPath))}
|
|
`;
|
|
}
|
|
|
|
function remoteArchiveStatusScript(client: HostProxyClient): string {
|
|
return `
|
|
set -eu
|
|
archive_sha="$(sha256sum ${shQuote(client.archiveInstallPath)} 2>/dev/null | awk '{print $1}' || true)"
|
|
python3 - "$archive_sha" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
archive_sha = sys.argv[1]
|
|
payload = {
|
|
"ok": True,
|
|
"archivePresent": bool(archive_sha),
|
|
"archiveSha256Ok": archive_sha == "${client.archiveSha256}",
|
|
"archiveSha256Prefix": archive_sha[:12] if archive_sha else "",
|
|
"valuesPrinted": False,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function remoteApplyScript(target: HostProxyTarget, clientConfigJson: string): string {
|
|
const client = target.source.client;
|
|
const envFile = renderEnvFile(target);
|
|
const profile = renderProfile(target);
|
|
const apt = renderApt(target);
|
|
const dockerDropIn = renderSystemdDropIn(target.files.envFile);
|
|
const k3sDropIn = renderSystemdDropIn(target.files.envFile);
|
|
const unit = renderClientUnit(client);
|
|
const daemonReload = target.apply.reloadSystemd ? "systemctl daemon-reload >/dev/null 2>&1 || true" : ": # reloadSystemd disabled by YAML";
|
|
const restartDocker = target.apply.restartDocker
|
|
? "nohup sh -c 'sleep 1; systemctl restart docker >/tmp/unidesk-host-proxy-docker-restart.out 2>/tmp/unidesk-host-proxy-docker-restart.err || true' >/dev/null 2>&1 &"
|
|
: ": # restartDocker disabled by YAML";
|
|
const restartK3s = target.apply.restartK3s
|
|
? "nohup sh -c 'sleep 1; systemctl restart k3s >/tmp/unidesk-host-proxy-k3s-restart.out 2>/tmp/unidesk-host-proxy-k3s-restart.err || true' >/dev/null 2>&1 &"
|
|
: ": # restartK3s disabled by YAML";
|
|
return `
|
|
set -eu
|
|
write_b64() {
|
|
path="$1"
|
|
mode="$2"
|
|
data="$3"
|
|
mkdir -p "$(dirname "$path")"
|
|
tmp="$path.tmp.$$"
|
|
printf '%s' "$data" | base64 -d >"$tmp"
|
|
chmod "$mode" "$tmp"
|
|
mv "$tmp" "$path"
|
|
}
|
|
write_b64 ${shQuote(client.configPath)} 0600 ${shQuote(b64(clientConfigJson))}
|
|
write_b64 ${shQuote(client.unitPath)} 0644 ${shQuote(b64(unit))}
|
|
write_b64 ${shQuote(target.files.envFile)} 0644 ${shQuote(b64(envFile))}
|
|
write_b64 ${shQuote(target.files.profile)} 0644 ${shQuote(b64(profile))}
|
|
write_b64 ${shQuote(target.files.apt)} 0644 ${shQuote(b64(apt))}
|
|
write_b64 ${shQuote(target.files.dockerSystemdDropIn)} 0644 ${shQuote(b64(dockerDropIn))}
|
|
write_b64 ${shQuote(target.files.k3sSystemdDropIn)} 0644 ${shQuote(b64(k3sDropIn))}
|
|
archive_sha="$(sha256sum ${shQuote(client.archiveInstallPath)} 2>/dev/null | awk '{print $1}' || true)"
|
|
if [ "$archive_sha" != ${shQuote(client.archiveSha256)} ]; then
|
|
printf 'archive sha256 mismatch\\n' >&2
|
|
exit 23
|
|
fi
|
|
tmp_bin="${client.installPath}.tmp.$$"
|
|
tar -xOf ${shQuote(client.archiveInstallPath)} ${shQuote(client.binaryMember)} >"$tmp_bin"
|
|
chmod 0755 "$tmp_bin"
|
|
binary_sha="$(sha256sum "$tmp_bin" | awk '{print $1}')"
|
|
if [ "$binary_sha" != ${shQuote(client.binarySha256)} ]; then
|
|
rm -f "$tmp_bin"
|
|
printf 'binary sha256 mismatch\\n' >&2
|
|
exit 24
|
|
fi
|
|
mv -f "$tmp_bin" ${shQuote(client.installPath)}
|
|
chmod 0755 ${shQuote(client.installPath)}
|
|
${daemonReload}
|
|
systemctl enable --now ${shQuote(client.serviceName)} >/tmp/unidesk-host-proxy-systemctl.out 2>/tmp/unidesk-host-proxy-systemctl.err || true
|
|
systemctl restart ${shQuote(client.serviceName)} >/tmp/unidesk-host-proxy-restart.out 2>/tmp/unidesk-host-proxy-restart.err || true
|
|
sleep 2
|
|
${remoteStatusProbeShell(target)}
|
|
${restartDocker}
|
|
${restartK3s}
|
|
`;
|
|
}
|
|
|
|
function remoteStatusScript(target: HostProxyTarget): string {
|
|
return `
|
|
set -eu
|
|
${remoteStatusProbeShell(target)}
|
|
`;
|
|
}
|
|
|
|
function remoteStatusProbeShell(target: HostProxyTarget): string {
|
|
const client = target.source.client;
|
|
const podAccess = client.podAccess;
|
|
const podAccessShell = podAccess?.enabled
|
|
? `
|
|
pod_probe_rc=0
|
|
pod_probe_status="$(HTTP_PROXY=${shQuote(podAccess.proxyUrl)} HTTPS_PROXY=${shQuote(podAccess.proxyUrl)} ALL_PROXY=${shQuote(podAccess.proxyUrl)} NO_PROXY=${shQuote(target.env.noProxy.join(","))} curl -fsS -o /dev/null -w '%{http_code}' --max-time 20 ${shQuote(target.source.externalProbeUrl)} 2>/tmp/unidesk-host-proxy-pod-probe.err)" || pod_probe_rc=$?
|
|
`
|
|
: `
|
|
pod_probe_rc=0
|
|
pod_probe_status="skipped"
|
|
`;
|
|
return `
|
|
exists() { [ -f "$1" ] && printf present || printf missing; }
|
|
contains() { grep -F "$2" "$1" >/dev/null 2>&1 && printf true || printf false; }
|
|
service_active="$(systemctl is-active ${shQuote(client.serviceName)} 2>/dev/null || true)"
|
|
binary_sha="$(sha256sum ${shQuote(client.installPath)} 2>/dev/null | awk '{print $1}' || true)"
|
|
archive_sha="$(sha256sum ${shQuote(client.archiveInstallPath)} 2>/dev/null | awk '{print $1}' || true)"
|
|
health_rc=0
|
|
curl -fsS --max-time 5 ${shQuote(client.healthUrl)} >/tmp/unidesk-host-proxy-health.out 2>/tmp/unidesk-host-proxy-health.err || health_rc=$?
|
|
probe_rc=0
|
|
probe_status="$(HTTP_PROXY=${shQuote(target.env.httpProxy)} HTTPS_PROXY=${shQuote(target.env.httpsProxy)} ALL_PROXY=${shQuote(target.env.allProxy)} NO_PROXY=${shQuote(target.env.noProxy.join(","))} curl -fsS -o /dev/null -w '%{http_code}' --max-time 20 ${shQuote(target.source.externalProbeUrl)} 2>/tmp/unidesk-host-proxy-probe.err)" || probe_rc=$?
|
|
${podAccessShell}
|
|
python3 - "$health_rc" "$probe_rc" "$probe_status" "$(exists ${shQuote(target.files.envFile)})" "$(exists ${shQuote(target.files.profile)})" "$(exists ${shQuote(target.files.apt)})" "$(exists ${shQuote(target.files.dockerSystemdDropIn)})" "$(exists ${shQuote(target.files.k3sSystemdDropIn)})" "$(contains ${shQuote(target.files.envFile)} hyueapi.com)" "$(contains ${shQuote(target.files.envFile)} .hyueapi.com)" "$service_active" "$binary_sha" "$archive_sha" ${shQuote(podAccess?.enabled ? "true" : "false")} "$pod_probe_rc" "$pod_probe_status" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
health_rc = int(sys.argv[1])
|
|
probe_rc = int(sys.argv[2])
|
|
service_active = sys.argv[11]
|
|
binary_sha = sys.argv[12]
|
|
archive_sha = sys.argv[13]
|
|
pod_access_enabled = sys.argv[14] == "true"
|
|
pod_probe_rc = int(sys.argv[15])
|
|
payload = {
|
|
"ok": health_rc == 0 and probe_rc == 0 and (not pod_access_enabled or pod_probe_rc == 0) and service_active == "active" and binary_sha == "${client.binarySha256}" and archive_sha == "${client.archiveSha256}" and all(value == "present" for value in sys.argv[4:9]) and sys.argv[9] == "true" and sys.argv[10] == "true",
|
|
"target": "${target.id}",
|
|
"route": "${target.route}",
|
|
"sourceRef": "${target.sourceRef}",
|
|
"sourceFingerprint": "${target.source.fingerprint}",
|
|
"client": {
|
|
"mode": "${client.mode}",
|
|
"service": "${client.serviceName}",
|
|
"serviceActive": service_active,
|
|
"binarySha256Ok": binary_sha == "${client.binarySha256}",
|
|
"archiveSha256Ok": archive_sha == "${client.archiveSha256}",
|
|
"listen": "${client.listenHost}:${client.listenPort}",
|
|
"podAccess": {
|
|
"enabled": pod_access_enabled,
|
|
"listen": "${podAccess?.enabled ? `${podAccess.listenHost}:${podAccess.listenPort}` : ""}",
|
|
"proxyUrl": "${podAccess?.enabled ? podAccess.proxyUrl : ""}",
|
|
},
|
|
},
|
|
"files": {
|
|
"envFile": sys.argv[4],
|
|
"profile": sys.argv[5],
|
|
"apt": sys.argv[6],
|
|
"dockerSystemdDropIn": sys.argv[7],
|
|
"k3sSystemdDropIn": sys.argv[8],
|
|
},
|
|
"noProxy": {"hyueapi": sys.argv[9] == "true", "wildcardHyueapi": sys.argv[10] == "true"},
|
|
"health": {"exitCode": health_rc, "url": "${client.healthUrl}"},
|
|
"externalProbe": {"exitCode": probe_rc, "status": sys.argv[3], "url": "${target.source.externalProbeUrl}"},
|
|
"podAccessProbe": {"enabled": pod_access_enabled, "exitCode": pod_probe_rc, "status": sys.argv[16], "url": "${target.source.externalProbeUrl}"},
|
|
"valuesPrinted": False,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function renderClientUnit(client: HostProxyClient): string {
|
|
return [
|
|
"[Unit]",
|
|
"Description=UniDesk host egress proxy client",
|
|
"After=network-online.target",
|
|
"Wants=network-online.target",
|
|
"",
|
|
"[Service]",
|
|
"Type=simple",
|
|
`ExecStart=${client.installPath} run -c ${client.configPath}`,
|
|
"Restart=always",
|
|
"RestartSec=5",
|
|
"LimitNOFILE=1048576",
|
|
"NoNewPrivileges=true",
|
|
"",
|
|
"[Install]",
|
|
"WantedBy=multi-user.target",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function renderEnvFile(target: HostProxyTarget): string {
|
|
const noProxy = target.env.noProxy.join(",");
|
|
return [
|
|
"# Generated by: bun scripts/cli.ts platform-infra egress-proxy host apply",
|
|
"# Source: config/platform-infra/host-proxy.yaml",
|
|
`HTTP_PROXY=${target.env.httpProxy}`,
|
|
`HTTPS_PROXY=${target.env.httpsProxy}`,
|
|
`ALL_PROXY=${target.env.allProxy}`,
|
|
`NO_PROXY=${noProxy}`,
|
|
`http_proxy=${target.env.httpProxy}`,
|
|
`https_proxy=${target.env.httpsProxy}`,
|
|
`all_proxy=${target.env.allProxy}`,
|
|
`no_proxy=${noProxy}`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function renderProfile(target: HostProxyTarget): string {
|
|
return renderEnvFile(target)
|
|
.split("\n")
|
|
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
|
.map((line) => `export ${line}`)
|
|
.join("\n") + "\n";
|
|
}
|
|
|
|
function renderApt(target: HostProxyTarget): string {
|
|
return [
|
|
`Acquire::http::Proxy "${target.env.httpProxy}";`,
|
|
`Acquire::https::Proxy "${target.env.httpsProxy}";`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function renderSystemdDropIn(envFile: string): string {
|
|
return ["[Service]", `EnvironmentFile=-${envFile}`, ""].join("\n");
|
|
}
|
|
|
|
function serverSummary(server: HostProxyServer): Record<string, unknown> {
|
|
return {
|
|
id: server.id,
|
|
enabled: server.enabled,
|
|
benchmarkRef: server.benchmarkRef,
|
|
implementationRef: server.implementationRef,
|
|
sourceConfigRef: server.sourceConfigRef,
|
|
sourceFingerprint: server.sourceFingerprint,
|
|
composeFile: server.composeFile,
|
|
serviceName: server.serviceName,
|
|
containerName: server.containerName,
|
|
image: server.image,
|
|
listen: `${server.listenHost}:${server.listenPort}`,
|
|
health: `${server.health.host}:${server.health.port}`,
|
|
fingerprint: server.fingerprint,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function targetSummary(target: HostProxyTarget): Record<string, unknown> {
|
|
const client = target.source.client;
|
|
return {
|
|
id: target.id,
|
|
route: target.route,
|
|
enabled: target.enabled,
|
|
sourceRef: target.sourceRef,
|
|
sourceType: target.source.sourceType,
|
|
sourceFingerprint: target.source.fingerprint,
|
|
proxyUrl: target.source.proxyUrl,
|
|
benchmarkRef: target.source.benchmarkRef,
|
|
implementationRef: target.source.implementationRef,
|
|
client: {
|
|
mode: client.mode,
|
|
version: client.version,
|
|
installPath: client.installPath,
|
|
configPath: client.configPath,
|
|
unitPath: client.unitPath,
|
|
serviceName: client.serviceName,
|
|
listen: `${client.listenHost}:${client.listenPort}`,
|
|
},
|
|
trans: {
|
|
hostProxyEnv: transHostProxyEnvSummary(target.id),
|
|
},
|
|
};
|
|
}
|
|
|
|
function artifactSummary(client: HostProxyClient): Record<string, unknown> {
|
|
return {
|
|
mode: client.mode,
|
|
version: client.version,
|
|
upstreamUrl: client.upstreamUrl,
|
|
archiveCachePath: client.archiveCachePath,
|
|
archiveSha256: client.archiveSha256,
|
|
archiveInstallPath: client.archiveInstallPath,
|
|
binaryCachePath: client.binaryCachePath,
|
|
binarySha256: client.binarySha256,
|
|
installPath: client.installPath,
|
|
};
|
|
}
|
|
|
|
function masterProxyTcpHealth(server: HostProxyServer): Record<string, unknown> {
|
|
const result = runCommand(["bash", "-lc", `timeout 5 bash -lc ${shQuote(`</dev/tcp/${server.health.host}/${server.health.port}`)}`], rootPath(), { timeoutMs: 8_000 });
|
|
return { ok: result.exitCode === 0, mode: server.health.mode, host: server.health.host, port: server.health.port, result: compactLocalResult(result) };
|
|
}
|
|
|
|
function runTrans(route: string, script: string, timeoutSeconds: number): CommandResult {
|
|
return runCommand(["bun", "scripts/ssh-cli.ts", "ssh", route, "bash", "--", script], rootPath(), { timeoutMs: timeoutSeconds * 1000 });
|
|
}
|
|
|
|
function runTransUpload(route: string, localPath: string, remotePath: string, timeoutMs: number): CommandResult {
|
|
return runCommand(["bun", "scripts/ssh-cli.ts", "ssh", route, "upload", localPath, remotePath], rootPath(), { timeoutMs });
|
|
}
|
|
|
|
function skippedCommandResult(command: string[], reason: string): CommandResult {
|
|
return {
|
|
command,
|
|
cwd: rootPath(),
|
|
exitCode: 0,
|
|
stdout: JSON.stringify({ ok: true, skipped: true, reason }, null, 2),
|
|
stderr: "",
|
|
signal: null,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
|
|
function sha256File(path: string): string {
|
|
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
}
|
|
|
|
function compactLocalResult(result: CommandResult): Record<string, unknown> {
|
|
return {
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
stdoutTail: result.stdout.slice(-1000),
|
|
stderrTail: result.stderr.slice(-1000),
|
|
};
|
|
}
|
|
|
|
function renderPlan(result: Record<string, unknown>): RenderedCliResult {
|
|
const server = record(result.server);
|
|
const target = record(result.target);
|
|
const env = record(result.env);
|
|
const artifact = record(result.artifact);
|
|
const next = record(result.next);
|
|
const lines = [
|
|
"PLATFORM-INFRA HOST PROXY PLAN",
|
|
...table(["SERVER", "LISTEN", "BENCHMARK", "SOURCE"], [[text(server.id), text(server.listen), text(server.benchmarkRef), text(server.sourceConfigRef)]]),
|
|
"",
|
|
...table(["TARGET", "ROUTE", "SOURCE", "PROXY"], [[text(target.id), text(target.route), text(target.sourceRef), text(target.proxyUrl)]]),
|
|
"",
|
|
...table(["CLIENT_MODE", "VERSION", "INSTALL", "SHA256"], [[text(artifact.mode), text(artifact.version), text(artifact.installPath), shortSha(text(artifact.binarySha256))]]),
|
|
"",
|
|
...table(["PROXY", "NO_PROXY_COUNT", "HYUEAPI", "VALUES"], [[text(env.proxyUrl), text(env.noProxyCount), "preserved", "printed=false"]]),
|
|
"",
|
|
...transHostProxyEnvTable(target),
|
|
"",
|
|
"NEXT",
|
|
` dry-run: ${text(next.dryRun, "")}`,
|
|
` apply: ${text(next.apply, "")}`,
|
|
` status: ${text(next.status, "")}`,
|
|
"",
|
|
"Disclosure: Secret material and generated proxy config are not printed.",
|
|
];
|
|
return { ok: result.ok !== false, command: text(result.command, "platform-infra egress-proxy host plan"), renderedText: lines.join("\n"), contentType: "text/plain" };
|
|
}
|
|
|
|
function renderApply(result: Record<string, unknown>): RenderedCliResult {
|
|
const server = record(result.server);
|
|
const serverApply = record(result.serverApply);
|
|
const serverHealth = record(serverApply.health);
|
|
const target = record(result.target);
|
|
const artifact = record(result.artifact);
|
|
const distribution = record(result.distribution);
|
|
const remote = record(result.remote);
|
|
const client = record(remote.client);
|
|
const podProbe = record(remote.podAccessProbe);
|
|
const probe = record(remote.externalProbe);
|
|
const lines = [
|
|
"PLATFORM-INFRA HOST PROXY APPLY",
|
|
...table(["SERVER", "OK", "LISTEN", "BENCHMARK"], [[text(server.id), text(serverApply.ok), text(server.listen), text(server.benchmarkRef)]]),
|
|
"",
|
|
...table(["SERVER_HEALTH", "CLIENT_BINARY", "DISTRIBUTION"], [[text(serverHealth.ok), text(artifact.binaryAction), text(distribution.mode)]]),
|
|
"",
|
|
...table(["TARGET", "ROUTE", "OK", "CLIENT"], [[text(target.id), text(target.route), result.ok === false ? "false" : "true", text(client.service)]]),
|
|
"",
|
|
...table(["SERVICE_ACTIVE", "BINARY_SHA", "HOST_PROBE", "POD_PROBE", "VALUES"], [[text(client.serviceActive), text(client.binarySha256Ok), text(probe.status), text(podProbe.status, "skipped"), "printed=false"]]),
|
|
"",
|
|
...transHostProxyEnvTable(target),
|
|
"",
|
|
`NEXT\n ${text(record(result.next).status, "")}`,
|
|
];
|
|
return { ok: result.ok !== false, command: "platform-infra egress-proxy host apply", renderedText: lines.join("\n"), contentType: "text/plain" };
|
|
}
|
|
|
|
function renderStatus(result: Record<string, unknown>): RenderedCliResult {
|
|
const server = record(result.server);
|
|
const serverStatus = record(result.serverStatus);
|
|
const target = record(result.target);
|
|
const remote = record(result.remote);
|
|
const client = record(remote.client);
|
|
const files = record(remote.files);
|
|
const noProxy = record(remote.noProxy);
|
|
const health = record(remote.health);
|
|
const probe = record(remote.externalProbe);
|
|
const podProbe = record(remote.podAccessProbe);
|
|
const lines = [
|
|
"PLATFORM-INFRA HOST PROXY STATUS",
|
|
...table(["SERVER", "OK", "LISTEN", "CONTAINER"], [[text(server.id), text(serverStatus.ok), text(server.listen), text(serverStatus.container)]]),
|
|
"",
|
|
...table(["TARGET", "ROUTE", "OK", "CLIENT"], [[text(target.id), text(target.route), result.ok === false ? "false" : "true", text(client.service)]]),
|
|
"",
|
|
...table(["SERVICE_ACTIVE", "BINARY_SHA", "HEALTH_EXIT", "HOST_PROBE", "POD_PROBE"], [[text(client.serviceActive), text(client.binarySha256Ok), text(health.exitCode), text(probe.status), text(podProbe.status, "skipped")]]),
|
|
"",
|
|
...table(["ENV", "PROFILE", "APT", "DOCKER_DROPIN", "K3S_DROPIN"], [[text(files.envFile), text(files.profile), text(files.apt), text(files.dockerSystemdDropIn), text(files.k3sSystemdDropIn)]]),
|
|
"",
|
|
...table(["NO_PROXY_HYUEAPI", "NO_PROXY_WILDCARD", "VALUES"], [[text(noProxy.hyueapi), text(noProxy.wildcardHyueapi), "printed=false"]]),
|
|
"",
|
|
...transHostProxyEnvTable(target),
|
|
"",
|
|
"Disclosure: file content, Secret values and generated proxy config are not printed.",
|
|
];
|
|
return { ok: result.ok !== false, command: "platform-infra egress-proxy host status", renderedText: lines.join("\n"), contentType: "text/plain" };
|
|
}
|
|
|
|
function transHostProxyEnvTable(target: Record<string, unknown>): string[] {
|
|
const trans = record(target.trans);
|
|
const rule = record(trans.hostProxyEnv);
|
|
return table(
|
|
["TRANS_PROXY_ENV", "ENV_REF", "APPLY_TO", "ENV_FILE"],
|
|
[[text(rule.enabled, "false"), text(rule.envFileRef, ""), text(rule.applyTo, "disabled"), text(rule.envFile, "")]],
|
|
);
|
|
}
|
|
|
|
function option(args: string[], name: string): string | null {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) return null;
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function parseJson(value: string): unknown {
|
|
const trimmed = value.trim();
|
|
if (trimmed.length === 0) return null;
|
|
try { return JSON.parse(trimmed) as unknown; } catch {
|
|
const start = trimmed.indexOf("{");
|
|
const end = trimmed.lastIndexOf("}");
|
|
if (start >= 0 && end > start) {
|
|
try { return JSON.parse(trimmed.slice(start, end + 1)) as unknown; } catch {}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function record(value: unknown, _label = "value"): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = obj[key];
|
|
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${label}.${key} must be a non-empty string`);
|
|
return value.trim();
|
|
}
|
|
|
|
function routeField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = stringField(obj, key, label);
|
|
if (!/^[A-Za-z0-9:_./-]+$/u.test(value) || value.includes("..")) throw new Error(`${label}.${key} has an unsupported route format`);
|
|
return value;
|
|
}
|
|
|
|
function repoYamlPathField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = stringField(obj, key, label);
|
|
if (value.startsWith("/") || value.includes("..") || !value.startsWith("config/") || !value.endsWith(".yaml")) {
|
|
throw new Error(`${label}.${key} must be a repo-relative config/*.yaml path without ..`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function repoStatePathField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = stringField(obj, key, label);
|
|
if (value.startsWith("/") || value.includes("..") || !value.startsWith(".state/")) {
|
|
throw new Error(`${label}.${key} must be a repo-relative .state/ path without ..`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function relativePathField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = stringField(obj, key, label);
|
|
if (value.startsWith("/") || value.includes("..")) throw new Error(`${label}.${key} must be a relative path without ..`);
|
|
return value;
|
|
}
|
|
|
|
function absolutePathField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = stringField(obj, key, label);
|
|
if (!value.startsWith("/") || value.includes("..")) throw new Error(`${label}.${key} must be an absolute path without ..`);
|
|
return value;
|
|
}
|
|
|
|
function booleanField(obj: Record<string, unknown>, key: string, label: string): boolean {
|
|
const value = obj[key];
|
|
if (typeof value !== "boolean") throw new Error(`${label}.${key} must be a boolean`);
|
|
return value;
|
|
}
|
|
|
|
function stringArrayField(obj: Record<string, unknown>, key: string, label: string): string[] {
|
|
const value = obj[key];
|
|
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim().length > 0)) {
|
|
throw new Error(`${label}.${key} must be an array of non-empty strings`);
|
|
}
|
|
return value.map((item) => item.trim());
|
|
}
|
|
|
|
function enumField<const T extends readonly string[]>(obj: Record<string, unknown>, key: string, label: string, values: T): T[number] {
|
|
const value = stringField(obj, key, label);
|
|
if (!(values as readonly string[]).includes(value)) throw new Error(`${label}.${key} must be one of ${values.join(", ")}`);
|
|
return value as T[number];
|
|
}
|
|
|
|
function urlField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = stringField(obj, key, label);
|
|
const url = new URL(value);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${label}.${key} must use http:// or https://`);
|
|
if (url.username || url.password || url.hash) throw new Error(`${label}.${key} must not include credentials or hash`);
|
|
return value;
|
|
}
|
|
|
|
function httpsUrlField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = urlField(obj, key, label);
|
|
if (!value.startsWith("https://")) throw new Error(`${label}.${key} must use https://`);
|
|
return value;
|
|
}
|
|
|
|
function hostField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = stringField(obj, key, label);
|
|
if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${label}.${key} has an unsupported host/listen format`);
|
|
return value;
|
|
}
|
|
|
|
function privateIpv4(value: string): boolean {
|
|
const parts = value.split(".").map((part) => Number(part));
|
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
return parts[0] === 10
|
|
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
|
|| (parts[0] === 192 && parts[1] === 168);
|
|
}
|
|
|
|
function portField(obj: Record<string, unknown>, key: string, label: string): number {
|
|
const value = obj[key];
|
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535) throw new Error(`${label}.${key} must be a TCP port`);
|
|
return value;
|
|
}
|
|
|
|
function imageField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = stringField(obj, key, label);
|
|
if (!/^[A-Za-z0-9._:/@+-]+$/u.test(value) || value.includes("..")) throw new Error(`${label}.${key} has an unsupported image reference format`);
|
|
return value;
|
|
}
|
|
|
|
function listenAddressField(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = stringField(obj, key, label);
|
|
const [host, portRaw, extra] = value.split(":");
|
|
if (extra !== undefined || host === undefined || portRaw === undefined || host.length === 0) throw new Error(`${label}.${key} must be host:port`);
|
|
const port = Number(portRaw);
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(host) || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${label}.${key} must be host:port`);
|
|
return value;
|
|
}
|
|
|
|
function sha256Field(obj: Record<string, unknown>, key: string, label: string): string {
|
|
const value = stringField(obj, key, label).toLowerCase();
|
|
if (!/^[0-9a-f]{64}$/u.test(value)) throw new Error(`${label}.${key} must be a sha256 hex digest`);
|
|
return value;
|
|
}
|
|
|
|
function b64(value: string): string {
|
|
return Buffer.from(value, "utf8").toString("base64");
|
|
}
|
|
|
|
function fingerprint(value: unknown): string {
|
|
return `sha256:${createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16)}`;
|
|
}
|
|
|
|
function text(value: unknown, fallback = "-"): string {
|
|
if (typeof value === "string") return value;
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
return fallback;
|
|
}
|
|
|
|
function shortSha(value: string): string {
|
|
return /^[0-9a-f]{64}$/u.test(value) ? value.slice(0, 12) : value;
|
|
}
|
|
|
|
function table(headers: string[], rows: string[][]): string[] {
|
|
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
|
|
const format = (row: string[]) => row.map((cell, index) => (cell ?? "").padEnd(widths[index] ?? 0)).join(" ").trimEnd();
|
|
return [format(headers), format(headers.map((header, index) => "-".repeat(widths[index] ?? header.length))), ...rows.map(format)];
|
|
}
|