@singbox-iac/cli 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-en.md +47 -1
- package/README.md +47 -1
- package/dist/cli/commands/diagnose.js +19 -5
- package/dist/cli/commands/diagnose.js.map +1 -1
- package/dist/cli/commands/reload.js +13 -1
- package/dist/cli/commands/reload.js.map +1 -1
- package/dist/cli/commands/restart.js +25 -5
- package/dist/cli/commands/restart.js.map +1 -1
- package/dist/cli/commands/start.js +70 -12
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/commands/status.js +5 -4
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/stop.js +15 -4
- package/dist/cli/commands/stop.js.map +1 -1
- package/dist/cli/commands/verify.js +19 -1
- package/dist/cli/commands/verify.js.map +1 -1
- package/dist/config/load-config.js +24 -1
- package/dist/config/load-config.js.map +1 -1
- package/dist/modules/authoring/index.js +21 -1
- package/dist/modules/authoring/index.js.map +1 -1
- package/dist/modules/desktop-runtime/index.d.ts +25 -0
- package/dist/modules/desktop-runtime/index.js +119 -0
- package/dist/modules/desktop-runtime/index.js.map +1 -1
- package/dist/modules/diagnostics/index.js +83 -8
- package/dist/modules/diagnostics/index.js.map +1 -1
- package/dist/modules/doctor/index.js +41 -0
- package/dist/modules/doctor/index.js.map +1 -1
- package/dist/modules/manager/index.js +15 -5
- package/dist/modules/manager/index.js.map +1 -1
- package/dist/modules/natural-language/index.d.ts +1 -0
- package/dist/modules/natural-language/index.js +83 -35
- package/dist/modules/natural-language/index.js.map +1 -1
- package/dist/modules/runtime-watchdog/index.d.ts +10 -0
- package/dist/modules/runtime-watchdog/index.js +33 -0
- package/dist/modules/runtime-watchdog/index.js.map +1 -1
- package/dist/modules/status/index.d.ts +4 -0
- package/dist/modules/status/index.js +55 -3
- package/dist/modules/status/index.js.map +1 -1
- package/dist/modules/verification/index.d.ts +22 -0
- package/dist/modules/verification/index.js +449 -8
- package/dist/modules/verification/index.js.map +1 -1
- package/dist/modules/verification-plan/index.js +4 -3
- package/dist/modules/verification-plan/index.js.map +1 -1
- package/docs/natural-language-authoring.md +6 -0
- package/docs/runtime-modes.md +23 -7
- package/docs/runtime-on-macos.md +48 -9
- package/docs/sing-box-config-primer.md +10 -1
- package/package.json +1 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { access } from "node:fs/promises";
|
|
4
|
-
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
5
5
|
import net from "node:net";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
|
+
import { isProcessRoot } from "../desktop-runtime/index.js";
|
|
8
9
|
import { checkConfig, resolveSingBoxBinary } from "../manager/index.js";
|
|
9
10
|
import { resolveChromeDependency } from "../runtime-dependencies/index.js";
|
|
10
11
|
export function assertVerificationReportPassed(report) {
|
|
@@ -16,6 +17,7 @@ export function assertVerificationReportPassed(report) {
|
|
|
16
17
|
.map((scenario) => `- ${scenario.name}\n url: ${scenario.url}\n details: ${scenario.details}`)
|
|
17
18
|
.join("\n")}`);
|
|
18
19
|
}
|
|
20
|
+
const nativeProcessProbeUrl = "https://example.com/";
|
|
19
21
|
export async function verifyConfigRoutes(input) {
|
|
20
22
|
const singBoxBinary = await resolveSingBoxBinary(input.singBoxBinary);
|
|
21
23
|
const requestBinary = await resolveCurlBinary();
|
|
@@ -98,6 +100,120 @@ export function verifyAppPlan(plan, config) {
|
|
|
98
100
|
details: describeProcessAwareRouteResult(rules, check),
|
|
99
101
|
}));
|
|
100
102
|
}
|
|
103
|
+
export async function verifyNativeProcessE2E(input) {
|
|
104
|
+
const nativeChecks = input.plan.appChecks.filter((check) => check.expectedCaptureBackend === "native-process");
|
|
105
|
+
if (nativeChecks.length === 0) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
if (!isProcessRoot()) {
|
|
109
|
+
return nativeChecks.map((check) => ({
|
|
110
|
+
app: check.app,
|
|
111
|
+
status: "SKIP",
|
|
112
|
+
expectedOutboundGroup: check.expectedOutboundGroup,
|
|
113
|
+
details: "Native-process e2e requires root. Re-run `sudo singbox-iac verify app -c <builder.config.yaml>`.",
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
const singBoxBinary = await resolveSingBoxBinary(input.singBoxBinary);
|
|
117
|
+
const cCompilerBinary = await resolveCCompilerBinary();
|
|
118
|
+
const rawConfig = JSON.parse(await readFile(input.configPath, "utf8"));
|
|
119
|
+
const results = [];
|
|
120
|
+
for (const check of nativeChecks) {
|
|
121
|
+
const prepared = await prepareVerificationConfig(rawConfig);
|
|
122
|
+
const conflictingOutbound = chooseConflictingOutbound(prepared.config, check.expectedOutboundGroup);
|
|
123
|
+
if (!conflictingOutbound) {
|
|
124
|
+
results.push({
|
|
125
|
+
app: check.app,
|
|
126
|
+
status: "SKIP",
|
|
127
|
+
expectedOutboundGroup: check.expectedOutboundGroup,
|
|
128
|
+
details: "No conflicting fallback outbound is available in the compiled config for native-process e2e.",
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
injectNativeProcessProbeRule(prepared.config, conflictingOutbound);
|
|
133
|
+
const processName = selectNativeProcessProbeName(prepared.config, check.expectedOutboundGroup);
|
|
134
|
+
if (!processName) {
|
|
135
|
+
results.push({
|
|
136
|
+
app: check.app,
|
|
137
|
+
status: "SKIP",
|
|
138
|
+
expectedOutboundGroup: check.expectedOutboundGroup,
|
|
139
|
+
details: "No concrete process_name matcher was available for native-process e2e. Add a plain processName matcher to the bundle or explicit process policy.",
|
|
140
|
+
});
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const runDir = await mkdtemp(path.join(tmpdir(), "singbox-iac-native-process-"));
|
|
144
|
+
const verifyConfigPath = path.join(runDir, "native-process.config.json");
|
|
145
|
+
const logPath = path.join(runDir, "native-process.log");
|
|
146
|
+
const probeBinaryPath = path.join(runDir, processName);
|
|
147
|
+
await compileNativeProcessProbe({
|
|
148
|
+
runDir,
|
|
149
|
+
compilerBinary: cCompilerBinary,
|
|
150
|
+
probeBinaryPath,
|
|
151
|
+
});
|
|
152
|
+
injectNativeProcessPathProbeRule(prepared.config, probeBinaryPath, check.expectedOutboundGroup);
|
|
153
|
+
await writeFile(verifyConfigPath, `${JSON.stringify(prepared.config, null, 2)}\n`, "utf8");
|
|
154
|
+
await checkConfig({ configPath: verifyConfigPath, singBoxBinary });
|
|
155
|
+
const logBuffer = { text: "" };
|
|
156
|
+
const appender = async (chunk) => {
|
|
157
|
+
const text = chunk.toString();
|
|
158
|
+
logBuffer.text += text;
|
|
159
|
+
await writeFile(logPath, text, { encoding: "utf8", flag: "a" });
|
|
160
|
+
};
|
|
161
|
+
const child = spawn(singBoxBinary, ["run", "-c", verifyConfigPath], {
|
|
162
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
163
|
+
});
|
|
164
|
+
child.stdout.on("data", (chunk) => void appender(chunk));
|
|
165
|
+
child.stderr.on("data", (chunk) => void appender(chunk));
|
|
166
|
+
const exitPromise = new Promise((resolve, reject) => {
|
|
167
|
+
child.on("error", reject);
|
|
168
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
await waitForLog(logBuffer, /sing-box started/, 15_000, "Timed out waiting for sing-box startup during native-process verification.");
|
|
172
|
+
const scenario = await verifyNativeProcessRuntimeScenario({
|
|
173
|
+
id: `native-process-${slugifyProcessName(processName)}`,
|
|
174
|
+
name: `native-process e2e for ${check.app}`,
|
|
175
|
+
url: nativeProcessProbeUrl,
|
|
176
|
+
inboundTag: "in-tun",
|
|
177
|
+
proxyPort: prepared.mixedPort,
|
|
178
|
+
expectedOutboundGroup: check.expectedOutboundGroup,
|
|
179
|
+
processName,
|
|
180
|
+
probeBinaryPath,
|
|
181
|
+
}, logBuffer);
|
|
182
|
+
results.push({
|
|
183
|
+
app: check.app,
|
|
184
|
+
status: scenario.passed ? "PASS" : "FAIL",
|
|
185
|
+
expectedOutboundGroup: check.expectedOutboundGroup,
|
|
186
|
+
processName,
|
|
187
|
+
details: scenario.passed
|
|
188
|
+
? `Probe binary=${probeBinaryPath}; compiled process_name=${processName}; conflicting fallback=${conflictingOutbound}. ${scenario.details}`
|
|
189
|
+
: `Probe binary=${probeBinaryPath}; compiled process_name=${processName}; conflicting fallback=${conflictingOutbound}. ${scenario.details}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
results.push({
|
|
194
|
+
app: check.app,
|
|
195
|
+
status: "FAIL",
|
|
196
|
+
expectedOutboundGroup: check.expectedOutboundGroup,
|
|
197
|
+
processName,
|
|
198
|
+
details: error instanceof Error ? error.message : String(error),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
child.kill("SIGINT");
|
|
203
|
+
await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 2_000))]);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return results;
|
|
207
|
+
}
|
|
208
|
+
export function assertNativeProcessE2EPassed(results) {
|
|
209
|
+
const failures = results.filter((result) => result.status === "FAIL");
|
|
210
|
+
if (failures.length === 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
throw new Error(`Native-process e2e failed:\n${failures
|
|
214
|
+
.map((result) => `- ${result.app}${result.processName ? ` (${result.processName})` : ""}: ${result.details}`)
|
|
215
|
+
.join("\n")}`);
|
|
216
|
+
}
|
|
101
217
|
function hasExpectedProcessAwareRoute(rules, check) {
|
|
102
218
|
switch (check.expectedCaptureBackend) {
|
|
103
219
|
case "fallback":
|
|
@@ -318,6 +434,52 @@ async function verifyRuntimeScenario(scenario, logBuffer, requestBinary) {
|
|
|
318
434
|
expectedOutboundTag: scenario.expectedOutboundTag,
|
|
319
435
|
};
|
|
320
436
|
}
|
|
437
|
+
async function verifyNativeProcessRuntimeScenario(scenario, logBuffer) {
|
|
438
|
+
const hostname = new URL(scenario.url).hostname;
|
|
439
|
+
const inboundLog = new RegExp(`inbound/mixed\\[${escapeRegExp(scenario.inboundTag)}\\]: inbound connection to ${escapeRegExp(hostname)}:443`);
|
|
440
|
+
const routeLogAlternatives = [
|
|
441
|
+
new RegExp(`router: match\\[\\d+\\].*process_path[^\\n]*${escapeRegExp(scenario.probeBinaryPath)}[^\\n]*=> route\\(${escapeRegExp(scenario.expectedOutboundGroup)}\\)`),
|
|
442
|
+
new RegExp(`router: match\\[\\d+\\].*process_path[^\\n]*${escapeRegExp(path.basename(scenario.probeBinaryPath))}[^\\n]*=> route\\(${escapeRegExp(scenario.expectedOutboundGroup)}\\)`),
|
|
443
|
+
new RegExp(`router: match\\[\\d+\\].*process_name[^\\n]*${escapeRegExp(scenario.processName)}[^\\n]*=> route\\(${escapeRegExp(scenario.expectedOutboundGroup)}\\)`),
|
|
444
|
+
];
|
|
445
|
+
let lastFailure = `Expected native-process logs were not observed for ${hostname} within the timeout.`;
|
|
446
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
447
|
+
const offset = logBuffer.text.length;
|
|
448
|
+
const requestResult = await runNativeProcessProbeScenario({
|
|
449
|
+
probeBinaryPath: scenario.probeBinaryPath,
|
|
450
|
+
proxyPort: scenario.proxyPort,
|
|
451
|
+
targetHost: hostname,
|
|
452
|
+
targetPort: 443,
|
|
453
|
+
});
|
|
454
|
+
const excerpt = await waitForScenarioLogsWithAlternatives(logBuffer, offset, [inboundLog], routeLogAlternatives, 20_000);
|
|
455
|
+
const requestFailure = detectRequestFailure(requestResult);
|
|
456
|
+
if (excerpt !== undefined) {
|
|
457
|
+
return {
|
|
458
|
+
name: scenario.name,
|
|
459
|
+
passed: true,
|
|
460
|
+
details: requestFailure === undefined
|
|
461
|
+
? excerpt.trim()
|
|
462
|
+
: `${excerpt.trim()}\nRequest note: ${requestFailure}`,
|
|
463
|
+
url: scenario.url,
|
|
464
|
+
inboundTag: scenario.inboundTag,
|
|
465
|
+
expectedOutboundTag: scenario.expectedOutboundGroup,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
lastFailure =
|
|
469
|
+
requestFailure ?? `Expected native-process logs were not observed for ${hostname} within the timeout.`;
|
|
470
|
+
if (attempt < 2) {
|
|
471
|
+
await sleep(500);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
name: scenario.name,
|
|
476
|
+
passed: false,
|
|
477
|
+
details: lastFailure,
|
|
478
|
+
url: scenario.url,
|
|
479
|
+
inboundTag: scenario.inboundTag,
|
|
480
|
+
expectedOutboundTag: scenario.expectedOutboundGroup,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
321
483
|
export function isRouteLevelProxySuccess(scenario, requestResult) {
|
|
322
484
|
if (scenario.inboundTag !== "in-proxifier") {
|
|
323
485
|
return false;
|
|
@@ -385,6 +547,22 @@ export async function resolveCurlBinary(explicitPath) {
|
|
|
385
547
|
}
|
|
386
548
|
throw new Error("Unable to find a usable curl binary for route verification.");
|
|
387
549
|
}
|
|
550
|
+
export async function resolveCCompilerBinary(explicitPath) {
|
|
551
|
+
const candidates = [
|
|
552
|
+
explicitPath,
|
|
553
|
+
process.env.CC,
|
|
554
|
+
"/usr/bin/cc",
|
|
555
|
+
"/usr/bin/clang",
|
|
556
|
+
...resolvePathCandidates("cc"),
|
|
557
|
+
...resolvePathCandidates("clang"),
|
|
558
|
+
].filter((candidate) => typeof candidate === "string" && candidate.length > 0);
|
|
559
|
+
for (const candidate of candidates) {
|
|
560
|
+
if (await isExecutable(candidate)) {
|
|
561
|
+
return candidate;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
throw new Error("Unable to find a usable C compiler for native-process verification.");
|
|
565
|
+
}
|
|
388
566
|
export async function prepareVerificationConfig(config) {
|
|
389
567
|
const cloned = structuredClone(config);
|
|
390
568
|
const mixedPort = await findAvailablePort();
|
|
@@ -431,9 +609,16 @@ export async function prepareVerificationConfig(config) {
|
|
|
431
609
|
tag: "dns-local-verify",
|
|
432
610
|
},
|
|
433
611
|
];
|
|
612
|
+
dns.rules = [];
|
|
434
613
|
dns.final = "dns-local-verify";
|
|
614
|
+
dns.reverse_mapping = false;
|
|
435
615
|
const route = asObject(cloned.route, "Config is missing route.");
|
|
436
616
|
route.default_domain_resolver = "dns-local-verify";
|
|
617
|
+
if (typeof cloned.experimental === "object" && cloned.experimental !== null) {
|
|
618
|
+
const experimental = asObject(cloned.experimental, "Config has an invalid experimental block.");
|
|
619
|
+
const { cache_file: _cacheFile, ...remaining } = experimental;
|
|
620
|
+
cloned.experimental = Object.keys(remaining).length === 0 ? undefined : remaining;
|
|
621
|
+
}
|
|
437
622
|
const outbounds = ensureArray(cloned.outbounds, "Config is missing outbounds.");
|
|
438
623
|
const globalIndex = outbounds.findIndex((outbound) => outbound.tag === "Global");
|
|
439
624
|
if (globalIndex >= 0) {
|
|
@@ -455,6 +640,59 @@ export async function prepareVerificationConfig(config) {
|
|
|
455
640
|
proxifierPort,
|
|
456
641
|
};
|
|
457
642
|
}
|
|
643
|
+
export function chooseConflictingOutbound(config, expectedOutboundGroup) {
|
|
644
|
+
const outbounds = ensureArray(config.outbounds, "Config is missing outbounds.");
|
|
645
|
+
const preferred = ["direct", "Global", "AI-Out", "Dev-Common-Out", "Stitch-Out", "Process-Proxy"];
|
|
646
|
+
for (const tag of preferred) {
|
|
647
|
+
if (tag === expectedOutboundGroup) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (outbounds.some((outbound) => outbound.tag === tag)) {
|
|
651
|
+
return tag;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return outbounds
|
|
655
|
+
.map((outbound) => (typeof outbound.tag === "string" ? outbound.tag : undefined))
|
|
656
|
+
.find((tag) => typeof tag === "string" && tag !== expectedOutboundGroup);
|
|
657
|
+
}
|
|
658
|
+
export function injectNativeProcessProbeRule(config, conflictingOutbound) {
|
|
659
|
+
const route = asObject(config.route, "Config is missing route.");
|
|
660
|
+
const rules = ensureArray(route.rules, "Route is missing rules.");
|
|
661
|
+
const probeHost = new URL(nativeProcessProbeUrl).hostname;
|
|
662
|
+
const insertionIndex = findLastProcessAwareRuleIndex(rules) + 1;
|
|
663
|
+
rules.splice(insertionIndex, 0, {
|
|
664
|
+
domain: [probeHost],
|
|
665
|
+
action: "route",
|
|
666
|
+
outbound: conflictingOutbound,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
export function injectNativeProcessPathProbeRule(config, probeBinaryPath, expectedOutboundGroup) {
|
|
670
|
+
const route = asObject(config.route, "Config is missing route.");
|
|
671
|
+
const rules = ensureArray(route.rules, "Route is missing rules.");
|
|
672
|
+
const insertionIndex = findLastProcessAwareRuleIndex(rules) + 1;
|
|
673
|
+
rules.splice(insertionIndex, 0, {
|
|
674
|
+
process_path: [probeBinaryPath],
|
|
675
|
+
action: "route",
|
|
676
|
+
outbound: expectedOutboundGroup,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
export function selectNativeProcessProbeName(config, expectedOutboundGroup) {
|
|
680
|
+
const route = asObject(config.route, "Config is missing route.");
|
|
681
|
+
const rules = ensureArray(route.rules, "Route is missing rules.");
|
|
682
|
+
for (const rule of rules) {
|
|
683
|
+
if (rule.outbound !== expectedOutboundGroup || !Array.isArray(rule.process_name)) {
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const candidate = rule.process_name.find((value) => typeof value === "string" &&
|
|
687
|
+
value.length > 0 &&
|
|
688
|
+
!value.includes("/") &&
|
|
689
|
+
!value.includes("*"));
|
|
690
|
+
if (candidate) {
|
|
691
|
+
return candidate;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return undefined;
|
|
695
|
+
}
|
|
458
696
|
export function validateConfigInvariants(config) {
|
|
459
697
|
const route = asObject(config.route, "Config is missing route.");
|
|
460
698
|
const rules = ensureArray(route.rules, "Route is missing rules.");
|
|
@@ -526,6 +764,16 @@ export function validateConfigInvariants(config) {
|
|
|
526
764
|
.join(", ")}`));
|
|
527
765
|
return checks;
|
|
528
766
|
}
|
|
767
|
+
function findLastProcessAwareRuleIndex(rules) {
|
|
768
|
+
return rules.reduce((lastIndex, rule, index) => Array.isArray(rule.process_name) ||
|
|
769
|
+
Array.isArray(rule.process_path) ||
|
|
770
|
+
Array.isArray(rule.process_path_regex)
|
|
771
|
+
? index
|
|
772
|
+
: lastIndex, -1);
|
|
773
|
+
}
|
|
774
|
+
function slugifyProcessName(processName) {
|
|
775
|
+
return processName.replaceAll(/[^A-Za-z0-9._-]+/g, "-");
|
|
776
|
+
}
|
|
529
777
|
export function resolveDefaultLeafOutboundTag(config, tag) {
|
|
530
778
|
const outbounds = ensureArray(config.outbounds, "Config is missing outbounds.");
|
|
531
779
|
const byTag = new Map();
|
|
@@ -562,20 +810,44 @@ export function resolveDefaultLeafOutboundTag(config, tag) {
|
|
|
562
810
|
}
|
|
563
811
|
}
|
|
564
812
|
function buildRuntimeScenarios(config, mixedPort, proxifierPort, configuredScenarios) {
|
|
565
|
-
const sourceScenarios = configuredScenarios && configuredScenarios.length > 0
|
|
813
|
+
const sourceScenarios = normalizeConfiguredVerificationScenarios(config, configuredScenarios && configuredScenarios.length > 0
|
|
566
814
|
? configuredScenarios
|
|
567
|
-
: defaultConfiguredScenarios;
|
|
568
|
-
|
|
815
|
+
: defaultConfiguredScenarios);
|
|
816
|
+
const fallbackScenarios = sourceScenarios.length > 0
|
|
817
|
+
? sourceScenarios
|
|
818
|
+
: normalizeConfiguredVerificationScenarios(config, defaultConfiguredScenarios);
|
|
819
|
+
return fallbackScenarios.map((scenario) => ({
|
|
569
820
|
id: scenario.id,
|
|
570
821
|
name: scenario.name,
|
|
571
822
|
url: scenario.url,
|
|
572
823
|
inboundTag: scenario.inbound,
|
|
573
824
|
proxyPort: scenario.inbound === "in-proxifier" ? proxifierPort : mixedPort,
|
|
574
|
-
expectedOutboundTag:
|
|
575
|
-
? scenario.expectedOutbound
|
|
576
|
-
: resolveDefaultLeafOutboundTag(config, scenario.expectedOutbound),
|
|
825
|
+
expectedOutboundTag: resolveExpectedVerificationOutboundTag(config, scenario.expectedOutbound),
|
|
577
826
|
}));
|
|
578
827
|
}
|
|
828
|
+
export function resolveExpectedVerificationOutboundTag(config, expectedOutboundTag) {
|
|
829
|
+
return expectedOutboundTag === "direct" || expectedOutboundTag === "block"
|
|
830
|
+
? expectedOutboundTag
|
|
831
|
+
: resolveDefaultLeafOutboundTag(config, expectedOutboundTag);
|
|
832
|
+
}
|
|
833
|
+
export function normalizeConfiguredVerificationScenarios(config, scenarios) {
|
|
834
|
+
const availableInbounds = new Set(ensureArray(config.inbounds, "Config is missing inbounds.")
|
|
835
|
+
.map((inbound) => inbound.tag)
|
|
836
|
+
.filter((tag) => typeof tag === "string" && tag.length > 0));
|
|
837
|
+
const hasTun = availableInbounds.has("in-tun");
|
|
838
|
+
const hasMixed = availableInbounds.has("in-mixed");
|
|
839
|
+
const hasProxifier = availableInbounds.has("in-proxifier");
|
|
840
|
+
const prefersTunFallback = hasTun && !hasMixed && !hasProxifier;
|
|
841
|
+
return scenarios.flatMap((scenario) => {
|
|
842
|
+
if (availableInbounds.has(scenario.inbound)) {
|
|
843
|
+
return [scenario];
|
|
844
|
+
}
|
|
845
|
+
if (scenario.inbound === "in-mixed" && (hasTun || prefersTunFallback)) {
|
|
846
|
+
return [{ ...scenario, inbound: "in-tun" }];
|
|
847
|
+
}
|
|
848
|
+
return [];
|
|
849
|
+
});
|
|
850
|
+
}
|
|
579
851
|
const defaultConfiguredScenarios = [
|
|
580
852
|
{
|
|
581
853
|
id: "stitch",
|
|
@@ -632,11 +904,49 @@ async function runProxyRequestScenario(input) {
|
|
|
632
904
|
"5",
|
|
633
905
|
"--proxy",
|
|
634
906
|
`http://127.0.0.1:${input.proxyPort}`,
|
|
907
|
+
"--noproxy",
|
|
908
|
+
"",
|
|
635
909
|
input.url,
|
|
636
910
|
];
|
|
637
911
|
return new Promise((resolve, reject) => {
|
|
638
912
|
const child = spawn(input.requestBinary, args, {
|
|
639
913
|
stdio: ["ignore", "pipe", "pipe"],
|
|
914
|
+
env: buildVerificationRequestEnv(),
|
|
915
|
+
});
|
|
916
|
+
let stdout = "";
|
|
917
|
+
let stderr = "";
|
|
918
|
+
let timedOut = false;
|
|
919
|
+
const timeout = setTimeout(() => {
|
|
920
|
+
timedOut = true;
|
|
921
|
+
child.kill("SIGKILL");
|
|
922
|
+
}, 12_000);
|
|
923
|
+
child.stdout.on("data", (chunk) => {
|
|
924
|
+
stdout += chunk.toString();
|
|
925
|
+
});
|
|
926
|
+
child.stderr.on("data", (chunk) => {
|
|
927
|
+
stderr += chunk.toString();
|
|
928
|
+
});
|
|
929
|
+
child.on("error", (error) => {
|
|
930
|
+
clearTimeout(timeout);
|
|
931
|
+
reject(error);
|
|
932
|
+
});
|
|
933
|
+
child.on("close", (code) => {
|
|
934
|
+
clearTimeout(timeout);
|
|
935
|
+
resolve({
|
|
936
|
+
exitCode: code ?? 0,
|
|
937
|
+
stdout,
|
|
938
|
+
stderr,
|
|
939
|
+
timedOut,
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
async function runNativeProcessProbeScenario(input) {
|
|
945
|
+
const args = ["127.0.0.1", String(input.proxyPort), input.targetHost, String(input.targetPort)];
|
|
946
|
+
return new Promise((resolve, reject) => {
|
|
947
|
+
const child = spawn(input.probeBinaryPath, args, {
|
|
948
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
949
|
+
env: buildVerificationRequestEnv(),
|
|
640
950
|
});
|
|
641
951
|
let stdout = "";
|
|
642
952
|
let stderr = "";
|
|
@@ -681,11 +991,12 @@ async function runJsonRequestScenario(input) {
|
|
|
681
991
|
input.url,
|
|
682
992
|
];
|
|
683
993
|
if (typeof input.proxyPort === "number") {
|
|
684
|
-
args.splice(args.length - 1, 0, "--proxy", `http://127.0.0.1:${input.proxyPort}
|
|
994
|
+
args.splice(args.length - 1, 0, "--proxy", `http://127.0.0.1:${input.proxyPort}`, "--noproxy", "");
|
|
685
995
|
}
|
|
686
996
|
return new Promise((resolve, reject) => {
|
|
687
997
|
const child = spawn(input.requestBinary, args, {
|
|
688
998
|
stdio: ["ignore", "pipe", "pipe"],
|
|
999
|
+
env: buildVerificationRequestEnv(),
|
|
689
1000
|
});
|
|
690
1001
|
let stdout = "";
|
|
691
1002
|
let stderr = "";
|
|
@@ -805,6 +1116,18 @@ async function waitForScenarioLogs(buffer, offset, patterns, timeoutMs) {
|
|
|
805
1116
|
}
|
|
806
1117
|
return undefined;
|
|
807
1118
|
}
|
|
1119
|
+
async function waitForScenarioLogsWithAlternatives(buffer, offset, requiredPatterns, alternativePatterns, timeoutMs) {
|
|
1120
|
+
const startedAt = Date.now();
|
|
1121
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
1122
|
+
const excerpt = buffer.text.slice(offset);
|
|
1123
|
+
if (requiredPatterns.every((pattern) => pattern.test(excerpt)) &&
|
|
1124
|
+
alternativePatterns.some((pattern) => pattern.test(excerpt))) {
|
|
1125
|
+
return excerpt;
|
|
1126
|
+
}
|
|
1127
|
+
await sleep(250);
|
|
1128
|
+
}
|
|
1129
|
+
return undefined;
|
|
1130
|
+
}
|
|
808
1131
|
async function waitForLog(buffer, pattern, timeoutMs, errorMessage) {
|
|
809
1132
|
const startedAt = Date.now();
|
|
810
1133
|
while (Date.now() - startedAt < timeoutMs) {
|
|
@@ -855,6 +1178,124 @@ function makeCheck(name, passed, details) {
|
|
|
855
1178
|
function escapeRegExp(input) {
|
|
856
1179
|
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
857
1180
|
}
|
|
1181
|
+
export function buildVerificationRequestEnv(baseEnv = process.env) {
|
|
1182
|
+
const stripped = {};
|
|
1183
|
+
for (const [key, value] of Object.entries(baseEnv)) {
|
|
1184
|
+
if (/^(http_proxy|https_proxy|all_proxy|no_proxy)$/i.test(key)) {
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
stripped[key] = value;
|
|
1188
|
+
}
|
|
1189
|
+
return stripped;
|
|
1190
|
+
}
|
|
1191
|
+
async function compileNativeProcessProbe(input) {
|
|
1192
|
+
const sourcePath = path.join(input.runDir, "native-process-probe.c");
|
|
1193
|
+
await writeFile(sourcePath, buildNativeProcessProbeSource(), "utf8");
|
|
1194
|
+
await new Promise((resolve, reject) => {
|
|
1195
|
+
const child = spawn(input.compilerBinary, ["-O2", "-Wall", "-Wextra", "-o", input.probeBinaryPath, sourcePath], {
|
|
1196
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1197
|
+
});
|
|
1198
|
+
let stderr = "";
|
|
1199
|
+
child.stdout.on("data", () => {
|
|
1200
|
+
// ignore compiler stdout
|
|
1201
|
+
});
|
|
1202
|
+
child.stderr.on("data", (chunk) => {
|
|
1203
|
+
stderr += chunk.toString();
|
|
1204
|
+
});
|
|
1205
|
+
child.on("error", reject);
|
|
1206
|
+
child.on("close", (code) => {
|
|
1207
|
+
if ((code ?? 0) === 0) {
|
|
1208
|
+
resolve();
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
reject(new Error(`Failed to compile native-process probe with ${input.compilerBinary}.\n${stderr.trim()}`));
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
await chmod(input.probeBinaryPath, 0o755);
|
|
1215
|
+
}
|
|
1216
|
+
function buildNativeProcessProbeSource() {
|
|
1217
|
+
return `#include <arpa/inet.h>
|
|
1218
|
+
#include <errno.h>
|
|
1219
|
+
#include <netinet/in.h>
|
|
1220
|
+
#include <stdio.h>
|
|
1221
|
+
#include <stdlib.h>
|
|
1222
|
+
#include <stdint.h>
|
|
1223
|
+
#include <string.h>
|
|
1224
|
+
#include <sys/socket.h>
|
|
1225
|
+
#include <sys/time.h>
|
|
1226
|
+
#include <unistd.h>
|
|
1227
|
+
|
|
1228
|
+
int main(int argc, char **argv) {
|
|
1229
|
+
if (argc != 5) {
|
|
1230
|
+
fprintf(stderr, "usage: %s <proxy-host> <proxy-port> <target-host> <target-port>\\n", argv[0]);
|
|
1231
|
+
return 2;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const char *proxyHost = argv[1];
|
|
1235
|
+
const char *proxyPort = argv[2];
|
|
1236
|
+
const char *targetHost = argv[3];
|
|
1237
|
+
const char *targetPort = argv[4];
|
|
1238
|
+
|
|
1239
|
+
int socketFd = socket(AF_INET, SOCK_STREAM, 0);
|
|
1240
|
+
if (socketFd < 0) {
|
|
1241
|
+
perror("socket");
|
|
1242
|
+
return 3;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
struct timeval timeout;
|
|
1246
|
+
timeout.tv_sec = 3;
|
|
1247
|
+
timeout.tv_usec = 0;
|
|
1248
|
+
setsockopt(socketFd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
|
1249
|
+
setsockopt(socketFd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
|
1250
|
+
|
|
1251
|
+
struct sockaddr_in proxyAddress;
|
|
1252
|
+
memset(&proxyAddress, 0, sizeof(proxyAddress));
|
|
1253
|
+
proxyAddress.sin_family = AF_INET;
|
|
1254
|
+
proxyAddress.sin_port = htons((uint16_t) atoi(proxyPort));
|
|
1255
|
+
|
|
1256
|
+
if (inet_pton(AF_INET, proxyHost, &proxyAddress.sin_addr) != 1) {
|
|
1257
|
+
fprintf(stderr, "invalid proxy host: %s\\n", proxyHost);
|
|
1258
|
+
close(socketFd);
|
|
1259
|
+
return 4;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (connect(socketFd, (struct sockaddr *) &proxyAddress, sizeof(proxyAddress)) != 0) {
|
|
1263
|
+
perror("connect");
|
|
1264
|
+
close(socketFd);
|
|
1265
|
+
return 5;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
char request[1024];
|
|
1269
|
+
int written = snprintf(
|
|
1270
|
+
request,
|
|
1271
|
+
sizeof(request),
|
|
1272
|
+
"CONNECT %s:%s HTTP/1.1\\r\\nHost: %s:%s\\r\\nProxy-Connection: Keep-Alive\\r\\n\\r\\n",
|
|
1273
|
+
targetHost,
|
|
1274
|
+
targetPort,
|
|
1275
|
+
targetHost,
|
|
1276
|
+
targetPort
|
|
1277
|
+
);
|
|
1278
|
+
if (written < 0 || written >= (int) sizeof(request)) {
|
|
1279
|
+
fprintf(stderr, "failed to encode request\\n");
|
|
1280
|
+
close(socketFd);
|
|
1281
|
+
return 6;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
ssize_t sent = send(socketFd, request, (size_t) written, 0);
|
|
1285
|
+
if (sent < 0) {
|
|
1286
|
+
perror("send");
|
|
1287
|
+
close(socketFd);
|
|
1288
|
+
return 7;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
char response[512];
|
|
1292
|
+
recv(socketFd, response, sizeof(response) - 1, 0);
|
|
1293
|
+
usleep(250000);
|
|
1294
|
+
close(socketFd);
|
|
1295
|
+
return 0;
|
|
1296
|
+
}
|
|
1297
|
+
`;
|
|
1298
|
+
}
|
|
858
1299
|
async function isExecutable(filePath) {
|
|
859
1300
|
try {
|
|
860
1301
|
await access(filePath, constants.X_OK);
|