diff --git a/docs/live-vless-verification.md b/docs/live-vless-verification.md new file mode 100644 index 0000000..c761882 --- /dev/null +++ b/docs/live-vless-verification.md @@ -0,0 +1,40 @@ +# Authorized live VLESS verification + +This test is deliberately separate from secretless PR CI. `pnpm verify` never invokes it, and the +release workflow never receives its credentials. Never print or record the subscription URL, node +address, UUID, authentication material, or observed IP address. + +## Reproduce + +Install the repository-pinned official Mihomo asset with `egresskit runtime install`, then provide +the resulting executable path, an authorized HTTPS subscription, and an HTTPS JSON target whose +response has an `ip` field: + +```sh +export EGRESSKIT_MIHOMO_BINARY=/private/path/to/mihomo +export EGRESSKIT_LIVE_SUBSCRIPTION_URL='set-in-your-secret-store' +export EGRESSKIT_LIVE_TARGET_URL='https://your-authorized-ip-echo.example/json' +pnpm test:live:vless +``` + +The runner uses an isolated temporary state directory, generated one-run admin and proxy tokens, +the real `egressd` and `egresskit` entry points, and the EgressKit proxy as the single egress +endpoint. It imports the remote subscription, lets official Mihomo check and apply it, makes a +direct target request, then makes the same request through an authenticated CONNECT tunnel. It +reports only four independent outcomes: local implementation, Mihomo acceptance, target +reachability, and real exit verification. Real exit verification passes only when the target +returns a valid proxy-observed IP distinct from the direct IP. Temporary state is removed. + +## Evidence: 2026-09-08 + +- EgressKit commit under test: `4e97483` (the merged Issue #22 baseline). +- Platform: Darwin 25.6.0 arm64; Node.js v26.8.1. +- Official runtime: Mihomo Meta v1.19.30, Darwin arm64; repository-pinned SHA-256 verified. +- Configuration category: remote Mihomo YAML containing 170 VLESS nodes using TCP transport. +- Local implementation: passed; daemon started and the subscription operation completed. +- Mihomo acceptance: passed; the official runtime checked and applied the generated configuration. +- Target reachability: passed through the authenticated EgressKit CONNECT endpoint. +- Real exit verification: passed; the target observed a valid IP distinct from the direct exit. + +No subscription URL, node secret, node endpoint, UUID, token, direct IP, or observed exit IP is +stored in this evidence. diff --git a/docs/live-vless-verification.test.mjs b/docs/live-vless-verification.test.mjs new file mode 100644 index 0000000..9214698 --- /dev/null +++ b/docs/live-vless-verification.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const root = new URL("../", import.meta.url); + +test("authorized VLESS verification is documented and isolated from secretless CI", async () => { + const [documentation, packageJson, workflow] = await Promise.all([ + readFile(new URL("live-vless-verification.md", import.meta.url), "utf8"), + readFile(new URL("package.json", root), "utf8").then(JSON.parse), + readFile(new URL(".github/workflows/release.yml", root), "utf8"), + ]); + + assert.match(documentation, /EGRESSKIT_LIVE_SUBSCRIPTION_URL/); + assert.match(documentation, /local implementation/i); + assert.match(documentation, /Mihomo acceptance/i); + assert.match(documentation, /target reachability/i); + assert.match(documentation, /real exit verification/i); + assert.match(documentation, /never (?:print|record).*subscription/i); + assert.equal(packageJson.scripts["test:live:vless"], "node scripts/live-vless.mjs"); + assert.doesNotMatch(packageJson.scripts.verify, /test:live:vless/); + assert.doesNotMatch(workflow, /EGRESSKIT_LIVE_SUBSCRIPTION_URL|test:live:vless/); +}); + +test("the checked-in evidence is reproducible metadata without node secrets", async () => { + const documentation = await readFile( + new URL("live-vless-verification.md", import.meta.url), + "utf8", + ); + + assert.match(documentation, /2026-09-08/); + assert.match(documentation, /Mihomo Meta v1\.19\.30/); + assert.match(documentation, /Darwin 25\.6\.0 arm64/); + assert.match(documentation, /170 VLESS nodes/); + assert.doesNotMatch(documentation, /\/sub\/[a-z0-9]+/i); + assert.doesNotMatch(documentation, /uuid\s*[:=]/i); +}); diff --git a/package.json b/package.json index 33ddcfb..3f0432c 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,10 @@ "check": "pnpm typecheck", "lint": "turbo run lint", "lint:fix": "turbo run lint:fix", - "lint:root": "biome check package.json pnpm-workspace.yaml turbo.json biome.json tsconfig.base.json .github/workflows/release.test.mjs docker/Dockerfile.test.mjs docs/deployment.test.mjs release", + "lint:root": "biome check package.json pnpm-workspace.yaml turbo.json biome.json tsconfig.base.json .github/workflows/release.test.mjs docker/Dockerfile.test.mjs docs/*.test.mjs release scripts", "prepare": "husky", - "test": "pnpm --filter @egresskit/egressd test && node --test .github/workflows/*.test.mjs apps/egressd/*.test.mjs docker/*.test.mjs docs/*.test.mjs release/*.test.mjs", + "test": "pnpm --filter @egresskit/egressd test && node --test .github/workflows/*.test.mjs apps/egressd/*.test.mjs docker/*.test.mjs docs/*.test.mjs release/*.test.mjs scripts/*.test.mjs", + "test:live:vless": "node scripts/live-vless.mjs", "typecheck": "turbo run typecheck", "verify": "pnpm lint && pnpm typecheck && pnpm test" }, diff --git a/scripts/live-vless.mjs b/scripts/live-vless.mjs new file mode 100644 index 0000000..22166a8 --- /dev/null +++ b/scripts/live-vless.mjs @@ -0,0 +1,189 @@ +import { spawn, spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { isIP } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +export function cleanChildEnvironment(environment) { + return Object.fromEntries( + Object.entries(environment).filter(([name]) => !name.startsWith("EGRESSKIT_")), + ); +} + +export function validateTargetUrl(value) { + const target = new URL(value); + if (target.protocol !== "https:") throw new Error("live target must use HTTPS"); + return target; +} + +export function isVerifiedExit(direct, observed) { + return isIP(direct) !== 0 && isIP(observed) !== 0 && direct !== observed; +} + +function required(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required for the authorized live test`); + return value; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 1024 * 1024, + timeout: 60_000, + ...options, + }); + if (result.status !== 0) throw new Error(`${command} failed during the live test`); + return result.stdout; +} + +function waitForStart(child) { + return new Promise((resolve, reject) => { + let buffered = ""; + const timeout = setTimeout(() => reject(new Error("egressd startup timed out")), 10_000); + const fail = () => { + clearTimeout(timeout); + reject(new Error("egressd exited before startup")); + }; + child.once("error", fail); + child.once("exit", fail); + child.stderr.resume(); + child.stdout.on("data", (chunk) => { + buffered += chunk; + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + for (const line of lines) { + if (!line.includes('"egressd.started"')) continue; + try { + const address = JSON.parse(line); + if (!Number.isInteger(address.port)) throw new Error("invalid port"); + clearTimeout(timeout); + child.off("error", fail); + child.off("exit", fail); + resolve(address); + } catch { + clearTimeout(timeout); + reject(new Error("egressd emitted an invalid startup event")); + } + return; + } + }); + }); +} + +async function stopDaemon(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolve) => child.once("exit", resolve)); + if (child.exitCode !== null || child.signalCode !== null) return; + if (!child.kill("SIGTERM")) return; + const stopped = await Promise.race([ + exited.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 3_000)), + ]); + if (!stopped && child.exitCode === null) { + child.kill("SIGKILL"); + await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 1_000))]); + } +} + +function parseObservedIp(output) { + try { + const value = JSON.parse(output).ip; + return typeof value === "string" ? value : undefined; + } catch { + throw new Error("target returned invalid JSON"); + } +} + +export async function main() { + const subscriptionUrl = required("EGRESSKIT_LIVE_SUBSCRIPTION_URL"); + const targetUrl = validateTargetUrl(required("EGRESSKIT_LIVE_TARGET_URL")).href; + const mihomoBinary = required("EGRESSKIT_MIHOMO_BINARY"); + const stateDirectory = await mkdtemp(join(tmpdir(), "egresskit-live-vless-")); + const adminToken = randomBytes(24).toString("hex"); + const proxyToken = randomBytes(24).toString("hex"); + const childEnvironment = cleanChildEnvironment(process.env); + let daemon; + let cancelled = false; + const interrupt = () => { + cancelled = true; + daemon?.kill("SIGTERM"); + }; + const ensureNotCancelled = () => { + if (cancelled) throw new Error("live test interrupted"); + }; + process.once("SIGINT", interrupt); + process.once("SIGTERM", interrupt); + + try { + run("pnpm", ["--filter", "@egresskit/egressd", "build"], { env: childEnvironment }); + ensureNotCancelled(); + daemon = spawn("node", ["apps/egressd/dist/cli.js"], { + env: { + ...childEnvironment, + EGRESSKIT_ADMIN_TOKEN: adminToken, + EGRESSKIT_MIHOMO_BINARY: mihomoBinary, + EGRESSKIT_PORT: "0", + EGRESSKIT_PROXY_TOKEN: proxyToken, + EGRESSKIT_STATE_DIRECTORY: stateDirectory, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + const address = await waitForStart(daemon); + ensureNotCancelled(); + run("node", ["apps/egressd/dist/control-cli-bin.js", "subscription", "add", "--redact"], { + env: { + ...childEnvironment, + EGRESSKIT_ADMIN_TOKEN: adminToken, + EGRESSKIT_STATE_DIRECTORY: stateDirectory, + }, + input: subscriptionUrl, + }); + ensureNotCancelled(); + const curlBase = ["--fail", "--silent", "--show-error", "--max-time", "30"]; + const direct = parseObservedIp( + run("curl", [...curlBase, targetUrl], { env: childEnvironment }), + ); + ensureNotCancelled(); + const observed = parseObservedIp( + run( + "curl", + [ + ...curlBase, + "--noproxy", + "", + "--proxy", + `http://127.0.0.1:${address.port}`, + "--proxy-user", + `rotate:${proxyToken}`, + targetUrl, + ], + { env: childEnvironment }, + ), + ); + ensureNotCancelled(); + if (!isVerifiedExit(direct, observed)) { + throw new Error("the target did not observe a distinct valid proxy exit"); + } + process.stdout.write( + `${JSON.stringify({ localImplementation: "passed", mihomoAcceptance: "passed", targetReachability: "passed", realExitVerification: "passed" })}\n`, + ); + } finally { + process.off("SIGINT", interrupt); + process.off("SIGTERM", interrupt); + try { + await stopDaemon(daemon); + } finally { + await rm(stateDirectory, { force: true, recursive: true }); + } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : "live test failed"}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/live-vless.test.mjs b/scripts/live-vless.test.mjs new file mode 100644 index 0000000..d141d9a --- /dev/null +++ b/scripts/live-vless.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { cleanChildEnvironment, isVerifiedExit, validateTargetUrl } from "./live-vless.mjs"; + +test("live target must be HTTPS and both observed values must be distinct IP addresses", () => { + assert.throws(() => validateTargetUrl("http://target.example/json"), /HTTPS/); + assert.equal(validateTargetUrl("https://target.example/json").protocol, "https:"); + assert.equal(isVerifiedExit("direct", "proxy"), false); + assert.equal(isVerifiedExit("192.0.2.1", "192.0.2.1"), false); + assert.equal(isVerifiedExit("192.0.2.1", "2001:db8::1"), true); +}); + +test("live secrets are not inherited by daemon, CLI, Mihomo, or curl", () => { + assert.deepEqual( + cleanChildEnvironment({ + EGRESSKIT_LIVE_SUBSCRIPTION_URL: "secret", + EGRESSKIT_LIVE_TARGET_URL: "https://target.example", + EGRESSKIT_MIHOMO_HTTP_LISTENER: "http://127.0.0.1:9999", + EGRESSKIT_CONTROL_SOCKET: "/wrong/socket", + PATH: "/bin", + }), + { PATH: "/bin" }, + ); +});