feat: add egress proxy traffic sampling
This commit is contained in:
@@ -3,10 +3,12 @@ import { rootPath } from "./config";
|
||||
import { runCommand } from "./command";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import { egressBenchmarkCompactResult, egressBenchmarkDryRun, egressBenchmarkStartScript, egressBenchmarkStatusScript, type EgressBenchmarkSpec } from "./egress-proxy-benchmark";
|
||||
import { egressProxyTrafficCompactResult, egressProxyTrafficScript, type EgressProxyTrafficSpec } from "./egress-proxy-traffic";
|
||||
import { readSub2ApiConfig } from "./platform-infra/config";
|
||||
import { resolveTarget } from "./platform-infra/manifest";
|
||||
|
||||
type BenchmarkAction = "benchmark" | "benchmark-status" | "benchmark-logs";
|
||||
type EgressProxyAction = BenchmarkAction | "traffic";
|
||||
|
||||
interface BenchmarkOptions {
|
||||
action: BenchmarkAction;
|
||||
@@ -20,8 +22,22 @@ interface BenchmarkOptions {
|
||||
tailLines: number;
|
||||
}
|
||||
|
||||
interface TrafficOptions {
|
||||
action: "traffic";
|
||||
targetId: string;
|
||||
sampleSeconds: number;
|
||||
timeoutSeconds: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
type EgressProxyOptions = BenchmarkOptions | TrafficOptions;
|
||||
|
||||
export async function runPlatformInfraEgressProxyCommand(_config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const options = parseBenchmarkOptions(args);
|
||||
const options = parseEgressProxyOptions(args);
|
||||
if (options.action === "traffic") {
|
||||
const spec = platformTrafficSpec(options);
|
||||
return proxyTraffic(spec, options);
|
||||
}
|
||||
const spec = platformBenchmarkSpec(options);
|
||||
if (options.action === "benchmark-status" || options.action === "benchmark-logs") return benchmarkStatus(spec, options);
|
||||
if (options.dryRun) {
|
||||
@@ -48,6 +64,27 @@ export async function runPlatformInfraEgressProxyCommand(_config: UniDeskConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function proxyTraffic(spec: EgressProxyTrafficSpec, options: TrafficOptions): RenderedCliResult {
|
||||
const result = runTrans(spec.route, egressProxyTrafficScript(spec, options.sampleSeconds), options.timeoutSeconds);
|
||||
const parsed = parseJson(result.stdout);
|
||||
const data = typeof parsed === "object" && parsed !== null
|
||||
? parsed as Record<string, unknown>
|
||||
: { ok: false, reason: "traffic-output-unparseable", stdoutPreview: result.stdout.slice(0, 2000) };
|
||||
return renderTraffic({
|
||||
...data,
|
||||
ok: result.exitCode === 0 && data.ok !== false,
|
||||
command: "platform-infra egress-proxy traffic",
|
||||
mode: "traffic",
|
||||
target: spec.targetId,
|
||||
namespace: spec.namespace,
|
||||
serviceName: spec.serviceName,
|
||||
servicePort: spec.port,
|
||||
sampleSecondsRequested: options.sampleSeconds,
|
||||
limit: options.limit,
|
||||
result: egressProxyTrafficCompactResult(result),
|
||||
});
|
||||
}
|
||||
|
||||
function benchmarkStatus(spec: EgressBenchmarkSpec, options: BenchmarkOptions): RenderedCliResult {
|
||||
const result = runTrans(spec.route, egressBenchmarkStatusScript(spec, options.tailLines), options.timeoutSeconds);
|
||||
const parsed = parseJson(result.stdout);
|
||||
@@ -88,14 +125,44 @@ function platformBenchmarkSpec(options: BenchmarkOptions): EgressBenchmarkSpec {
|
||||
};
|
||||
}
|
||||
|
||||
function parseBenchmarkOptions(args: string[]): BenchmarkOptions {
|
||||
function platformTrafficSpec(options: TrafficOptions): EgressProxyTrafficSpec {
|
||||
const sub2api = readSub2ApiConfig();
|
||||
const target = resolveTarget(sub2api, options.targetId);
|
||||
const proxy = target.egressProxy;
|
||||
if (proxy === null || !proxy.enabled) throw new Error(`target ${target.id} has no enabled egressProxy`);
|
||||
return {
|
||||
scope: "platform-infra",
|
||||
targetId: target.id,
|
||||
route: target.route,
|
||||
namespace: target.namespace,
|
||||
deploymentName: proxy.deploymentName,
|
||||
serviceName: proxy.serviceName,
|
||||
port: proxy.listenPort,
|
||||
sourceType: proxy.sourceType,
|
||||
sourceRef: proxy.sourceRef,
|
||||
sourceConfigRef: proxy.sourceConfigRef,
|
||||
sourceFingerprint: proxy.sourceFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEgressProxyOptions(args: string[]): EgressProxyOptions {
|
||||
const actionRaw = args[0] ?? "benchmark";
|
||||
if (actionRaw !== "benchmark" && actionRaw !== "benchmark-status" && actionRaw !== "benchmark-logs") {
|
||||
throw new Error("platform-infra egress-proxy usage: benchmark|benchmark-status|benchmark-logs --target D601|D518 --profile no-mirror [--dry-run|--confirm]");
|
||||
if (!isEgressProxyAction(actionRaw)) {
|
||||
throw new Error("platform-infra egress-proxy usage: benchmark|benchmark-status|benchmark-logs|traffic --target D601|D518 [--profile no-mirror] [--sample-seconds N]");
|
||||
}
|
||||
const action = actionRaw;
|
||||
const rest = args.slice(1);
|
||||
const targetId = option(rest, "--target") ?? "D601";
|
||||
if (action === "traffic") {
|
||||
const sampleSeconds = positiveIntOption(rest, "--sample-seconds", 5, 45);
|
||||
return {
|
||||
action,
|
||||
targetId,
|
||||
sampleSeconds,
|
||||
timeoutSeconds: positiveIntOption(rest, "--timeout-seconds", Math.min(60, sampleSeconds + 20), 60),
|
||||
limit: positiveIntOption(rest, "--limit", 50, 200),
|
||||
};
|
||||
}
|
||||
const profileRaw = option(rest, "--profile") ?? "no-mirror";
|
||||
if (profileRaw !== "no-mirror") throw new Error("--profile currently supports no-mirror");
|
||||
const confirm = rest.includes("--confirm");
|
||||
@@ -114,6 +181,10 @@ function parseBenchmarkOptions(args: string[]): BenchmarkOptions {
|
||||
};
|
||||
}
|
||||
|
||||
function isEgressProxyAction(value: string): value is EgressProxyAction {
|
||||
return value === "benchmark" || value === "benchmark-status" || value === "benchmark-logs" || value === "traffic";
|
||||
}
|
||||
|
||||
function runTrans(route: string, script: string, timeoutSeconds: number) {
|
||||
return runCommand(["/root/.local/bin/trans", route, "sh", "--", script], rootPath(), { timeoutMs: timeoutSeconds * 1000 });
|
||||
}
|
||||
@@ -186,6 +257,68 @@ function renderBenchmark(result: Record<string, unknown>): RenderedCliResult {
|
||||
return { ok: result.ok !== false, command: text(result.command, "platform-infra egress-proxy benchmark"), renderedText: lines.join("\n"), contentType: "text/plain" };
|
||||
}
|
||||
|
||||
function renderTraffic(result: Record<string, unknown>): RenderedCliResult {
|
||||
const totals = record(result.totals);
|
||||
const source = record(result.source);
|
||||
const next = record(result.next);
|
||||
const clients = arrayRecords(result.clients).slice(0, Number(result.limit ?? 50));
|
||||
const allClientCount = arrayRecords(result.clients).length;
|
||||
const ok = result.ok !== false;
|
||||
const target = text(result.target);
|
||||
const reason = text(result.reason, ok ? "ok" : "failed");
|
||||
const rows = clients.map((client) => {
|
||||
const destinations = arrayRecords(client.topDestinations)
|
||||
.map((item) => `${text(item.destination)}(${text(item.samples)})`)
|
||||
.join(", ");
|
||||
return [
|
||||
text(client.client),
|
||||
text(client.activeConnections, "0"),
|
||||
rate(client.totalBps),
|
||||
rate(client.uploadBps),
|
||||
rate(client.downloadBps),
|
||||
bytes(client.windowTotalBytes),
|
||||
bytes(client.activeTotalBytes),
|
||||
destinations || "-",
|
||||
];
|
||||
});
|
||||
const lines = [
|
||||
"PLATFORM-INFRA EGRESS-PROXY TRAFFIC",
|
||||
"",
|
||||
...table(["TARGET", "STATUS", "POD", "DURATION", "SNAPSHOTS", "ACTIVE"], [[
|
||||
target,
|
||||
ok ? "ok" : reason,
|
||||
text(result.pod),
|
||||
`${numberText(result.sampleSecondsActual, result.sampleSecondsRequested)}s`,
|
||||
text(result.snapshots, "0"),
|
||||
text(totals.activeConnections, "0"),
|
||||
]]),
|
||||
"",
|
||||
"TOTALS",
|
||||
...table(["SCOPE", "RATE", "WINDOW", "ACTIVE/CUMULATIVE"], [
|
||||
["per-client", rate(totals.clientTotalBps), bytes(totals.clientWindowTotalBytes), bytes(totals.clientActiveTotalBytes)],
|
||||
["process", rate(totals.processWindowBps), bytes(totals.processWindowTotalBytes), bytes(totals.processTotalBytes)],
|
||||
]),
|
||||
"",
|
||||
rows.length === 0 ? "CLIENTS\n-" : [
|
||||
"CLIENTS",
|
||||
...table(["CLIENT", "CONNS", "TOTAL/s", "UP/s", "DOWN/s", "WINDOW", "ACTIVE_TOTAL", "TOP_DESTINATIONS"], rows),
|
||||
].join("\n"),
|
||||
allClientCount > clients.length ? `\nDisclosure: ${allClientCount - clients.length} more clients hidden; rerun with --limit ${Math.min(200, allClientCount)}.` : "",
|
||||
"",
|
||||
"SOURCE",
|
||||
` target=${target} namespace=${text(result.namespace)} service=${text(result.serviceName)}:${text(result.servicePort)} controller=${text(result.controller)}`,
|
||||
` sourceType=${text(source.sourceType)} sourceRef=${text(source.sourceRef)} sourceFingerprint=${text(source.sourceFingerprint)} valuesPrinted=false`,
|
||||
"",
|
||||
"NEXT",
|
||||
` ${text(next.apply, "")}`,
|
||||
` ${text(next.status, `bun scripts/cli.ts platform-infra sub2api status --target ${target} --full`)}`,
|
||||
` bun scripts/cli.ts platform-infra egress-proxy traffic --target ${target} --sample-seconds 15`,
|
||||
"",
|
||||
"Disclosure: proxy API is read through pod loopback only. Per-client cumulative bytes are for the sample window; process cumulative is since proxy process start when reported by sing-box.",
|
||||
].filter((line) => line !== " ");
|
||||
return { ok, command: "platform-infra egress-proxy traffic", renderedText: lines.join("\n"), contentType: "text/plain" };
|
||||
}
|
||||
|
||||
function table(headers: string[], rows: string[][]): string[] {
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)));
|
||||
const render = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
||||
@@ -196,7 +329,34 @@ function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function arrayRecords(value: unknown): Array<Record<string, unknown>> {
|
||||
return Array.isArray(value) ? value.map(record) : [];
|
||||
}
|
||||
|
||||
function text(value: unknown, fallback = "-"): string {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function numberText(value: unknown, fallback: unknown): string {
|
||||
const number = typeof value === "number" && Number.isFinite(value) ? value : Number(fallback);
|
||||
if (!Number.isFinite(number)) return text(value);
|
||||
return number.toFixed(number >= 10 ? 0 : 1).replace(/\.0$/u, "");
|
||||
}
|
||||
|
||||
function bytes(value: unknown): string {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) return "0 B";
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let scaled = number;
|
||||
let unit = 0;
|
||||
while (scaled >= 1024 && unit < units.length - 1) {
|
||||
scaled /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${scaled >= 10 || unit === 0 ? scaled.toFixed(0) : scaled.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function rate(value: unknown): string {
|
||||
return `${bytes(value)}/s`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user