@singbox-iac/cli 0.1.6 → 0.1.7
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/dist/cli/commands/apply.js +21 -9
- package/dist/cli/commands/apply.js.map +1 -1
- package/dist/cli/commands/history.d.ts +2 -0
- package/dist/cli/commands/history.js +21 -0
- package/dist/cli/commands/history.js.map +1 -0
- package/dist/cli/commands/rollback.d.ts +2 -0
- package/dist/cli/commands/rollback.js +38 -0
- package/dist/cli/commands/rollback.js.map +1 -0
- package/dist/cli/commands/verify.js +72 -20
- package/dist/cli/commands/verify.js.map +1 -1
- package/dist/cli/index.js +6 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/domain/dns-plan.d.ts +27 -0
- package/dist/domain/dns-plan.js +2 -0
- package/dist/domain/dns-plan.js.map +1 -0
- package/dist/domain/intent.d.ts +61 -0
- package/dist/domain/intent.js +2 -0
- package/dist/domain/intent.js.map +1 -0
- package/dist/domain/transaction.d.ts +12 -0
- package/dist/domain/transaction.js +2 -0
- package/dist/domain/transaction.js.map +1 -0
- package/dist/domain/verification-plan.d.ts +34 -0
- package/dist/domain/verification-plan.js +2 -0
- package/dist/domain/verification-plan.js.map +1 -0
- package/dist/modules/authoring/index.d.ts +2 -0
- package/dist/modules/authoring/index.js +32 -17
- package/dist/modules/authoring/index.js.map +1 -1
- package/dist/modules/build/index.d.ts +3 -0
- package/dist/modules/build/index.js +8 -1
- package/dist/modules/build/index.js.map +1 -1
- package/dist/modules/compiler/index.d.ts +2 -2
- package/dist/modules/compiler/index.js +38 -53
- package/dist/modules/compiler/index.js.map +1 -1
- package/dist/modules/dns-plan/index.d.ts +10 -0
- package/dist/modules/dns-plan/index.js +104 -0
- package/dist/modules/dns-plan/index.js.map +1 -0
- package/dist/modules/intent/index.d.ts +7 -0
- package/dist/modules/intent/index.js +101 -0
- package/dist/modules/intent/index.js.map +1 -0
- package/dist/modules/transactions/index.d.ts +20 -0
- package/dist/modules/transactions/index.js +150 -0
- package/dist/modules/transactions/index.js.map +1 -0
- package/dist/modules/update/index.d.ts +1 -0
- package/dist/modules/update/index.js +22 -6
- package/dist/modules/update/index.js.map +1 -1
- package/dist/modules/verification/index.d.ts +42 -0
- package/dist/modules/verification/index.js +274 -0
- package/dist/modules/verification/index.js.map +1 -1
- package/dist/modules/verification-plan/index.d.ts +9 -0
- package/dist/modules/verification-plan/index.js +117 -0
- package/dist/modules/verification-plan/index.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { BuilderConfig } from "../../config/schema.js";
|
|
2
|
+
import type { ApplyTransaction } from "../../domain/transaction.js";
|
|
3
|
+
export interface ApplyWithTransactionInput {
|
|
4
|
+
readonly config: BuilderConfig;
|
|
5
|
+
readonly generatedPath: string;
|
|
6
|
+
readonly livePath: string;
|
|
7
|
+
readonly backupPath: string;
|
|
8
|
+
readonly verificationSummary: Record<string, unknown>;
|
|
9
|
+
readonly retainSnapshots?: number;
|
|
10
|
+
readonly apply: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface RollbackInput {
|
|
13
|
+
readonly config: BuilderConfig;
|
|
14
|
+
readonly livePath?: string;
|
|
15
|
+
readonly afterRestore?: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function applyWithTransaction(input: ApplyWithTransactionInput): Promise<ApplyTransaction>;
|
|
18
|
+
export declare function listTransactionHistory(config: BuilderConfig): Promise<readonly ApplyTransaction[]>;
|
|
19
|
+
export declare function rollbackToPrevious(input: RollbackInput): Promise<ApplyTransaction>;
|
|
20
|
+
export declare function resolveTransactionHistoryPath(config: BuilderConfig): string;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export async function applyWithTransaction(input) {
|
|
5
|
+
const txId = createTxId();
|
|
6
|
+
const historyPath = resolveTransactionHistoryPath(input.config);
|
|
7
|
+
const snapshotsDir = resolveSnapshotsDir(input.config);
|
|
8
|
+
await mkdir(path.dirname(historyPath), { recursive: true });
|
|
9
|
+
await mkdir(snapshotsDir, { recursive: true });
|
|
10
|
+
const liveExists = await pathExists(input.livePath);
|
|
11
|
+
const snapshotPath = liveExists ? path.join(snapshotsDir, `${txId}.json`) : undefined;
|
|
12
|
+
if (snapshotPath) {
|
|
13
|
+
await copyFile(input.livePath, snapshotPath);
|
|
14
|
+
}
|
|
15
|
+
if (input.backupPath && liveExists) {
|
|
16
|
+
await mkdir(path.dirname(input.backupPath), { recursive: true });
|
|
17
|
+
await copyFile(input.livePath, input.backupPath);
|
|
18
|
+
}
|
|
19
|
+
const pending = {
|
|
20
|
+
txId,
|
|
21
|
+
generatedPath: input.generatedPath,
|
|
22
|
+
livePath: input.livePath,
|
|
23
|
+
backupPath: input.backupPath,
|
|
24
|
+
...(snapshotPath ? { snapshotPath } : {}),
|
|
25
|
+
startedAt: new Date().toISOString(),
|
|
26
|
+
status: "pending",
|
|
27
|
+
verificationSummary: input.verificationSummary,
|
|
28
|
+
};
|
|
29
|
+
await appendTransaction(historyPath, pending);
|
|
30
|
+
try {
|
|
31
|
+
await input.apply();
|
|
32
|
+
const applied = {
|
|
33
|
+
...pending,
|
|
34
|
+
completedAt: new Date().toISOString(),
|
|
35
|
+
status: "applied",
|
|
36
|
+
};
|
|
37
|
+
await replaceLatestTransaction(historyPath, applied);
|
|
38
|
+
await trimSnapshots(historyPath, snapshotsDir, input.retainSnapshots ?? 5);
|
|
39
|
+
return applied;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (snapshotPath && (await pathExists(snapshotPath))) {
|
|
43
|
+
await copyFile(snapshotPath, input.livePath);
|
|
44
|
+
const rolledBack = {
|
|
45
|
+
...pending,
|
|
46
|
+
completedAt: new Date().toISOString(),
|
|
47
|
+
status: "rolled-back",
|
|
48
|
+
error: error instanceof Error ? error.message : String(error),
|
|
49
|
+
};
|
|
50
|
+
await replaceLatestTransaction(historyPath, rolledBack);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const failed = {
|
|
54
|
+
...pending,
|
|
55
|
+
completedAt: new Date().toISOString(),
|
|
56
|
+
status: "failed",
|
|
57
|
+
error: error instanceof Error ? error.message : String(error),
|
|
58
|
+
};
|
|
59
|
+
await replaceLatestTransaction(historyPath, failed);
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function listTransactionHistory(config) {
|
|
65
|
+
return (await loadTransactionLog(resolveTransactionHistoryPath(config))).entries;
|
|
66
|
+
}
|
|
67
|
+
export async function rollbackToPrevious(input) {
|
|
68
|
+
const config = input.config;
|
|
69
|
+
const livePath = input.livePath ?? config.output.livePath;
|
|
70
|
+
const historyPath = resolveTransactionHistoryPath(config);
|
|
71
|
+
const log = await loadTransactionLog(historyPath);
|
|
72
|
+
const previous = log.entries.find((entry) => entry.status === "applied" && entry.snapshotPath);
|
|
73
|
+
if (!previous?.snapshotPath) {
|
|
74
|
+
throw new Error("No previous snapshot is available for rollback.");
|
|
75
|
+
}
|
|
76
|
+
if (!(await pathExists(previous.snapshotPath))) {
|
|
77
|
+
throw new Error(`The previous snapshot is missing: ${previous.snapshotPath}`);
|
|
78
|
+
}
|
|
79
|
+
await mkdir(path.dirname(livePath), { recursive: true });
|
|
80
|
+
await copyFile(previous.snapshotPath, livePath);
|
|
81
|
+
if (input.afterRestore) {
|
|
82
|
+
await input.afterRestore();
|
|
83
|
+
}
|
|
84
|
+
const tx = {
|
|
85
|
+
txId: createTxId(),
|
|
86
|
+
generatedPath: previous.snapshotPath,
|
|
87
|
+
livePath,
|
|
88
|
+
backupPath: config.output.backupPath,
|
|
89
|
+
snapshotPath: previous.snapshotPath,
|
|
90
|
+
startedAt: new Date().toISOString(),
|
|
91
|
+
completedAt: new Date().toISOString(),
|
|
92
|
+
status: "rolled-back",
|
|
93
|
+
verificationSummary: {
|
|
94
|
+
rollbackTo: previous.txId,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
await appendTransaction(historyPath, tx);
|
|
98
|
+
return tx;
|
|
99
|
+
}
|
|
100
|
+
export function resolveTransactionHistoryPath(config) {
|
|
101
|
+
return path.join(path.dirname(config.output.stagingPath), "transactions.json");
|
|
102
|
+
}
|
|
103
|
+
function resolveSnapshotsDir(config) {
|
|
104
|
+
return path.join(path.dirname(config.output.stagingPath), "snapshots");
|
|
105
|
+
}
|
|
106
|
+
async function loadTransactionLog(historyPath) {
|
|
107
|
+
if (!(await pathExists(historyPath))) {
|
|
108
|
+
return { version: 1, entries: [] };
|
|
109
|
+
}
|
|
110
|
+
return JSON.parse(await readFile(historyPath, "utf8"));
|
|
111
|
+
}
|
|
112
|
+
async function appendTransaction(historyPath, entry) {
|
|
113
|
+
const log = await loadTransactionLog(historyPath);
|
|
114
|
+
await writeFile(historyPath, `${JSON.stringify({ version: 1, entries: [entry, ...log.entries] }, null, 2)}\n`, "utf8");
|
|
115
|
+
}
|
|
116
|
+
async function replaceLatestTransaction(historyPath, entry) {
|
|
117
|
+
const log = await loadTransactionLog(historyPath);
|
|
118
|
+
const [, ...rest] = log.entries;
|
|
119
|
+
await writeFile(historyPath, `${JSON.stringify({ version: 1, entries: [entry, ...rest] }, null, 2)}\n`, "utf8");
|
|
120
|
+
}
|
|
121
|
+
async function trimSnapshots(historyPath, snapshotsDir, retainSnapshots) {
|
|
122
|
+
const log = await loadTransactionLog(historyPath);
|
|
123
|
+
const keep = new Set(log.entries
|
|
124
|
+
.filter((entry) => entry.snapshotPath)
|
|
125
|
+
.slice(0, retainSnapshots)
|
|
126
|
+
.map((entry) => entry.snapshotPath));
|
|
127
|
+
if (!(await pathExists(snapshotsDir))) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const files = await readdir(snapshotsDir);
|
|
131
|
+
await Promise.all(files.map(async (fileName) => {
|
|
132
|
+
const filePath = path.join(snapshotsDir, fileName);
|
|
133
|
+
if (!keep.has(filePath)) {
|
|
134
|
+
await rm(filePath, { force: true });
|
|
135
|
+
}
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
async function pathExists(filePath) {
|
|
139
|
+
try {
|
|
140
|
+
await access(filePath, constants.F_OK);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function createTxId() {
|
|
148
|
+
return `tx-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/modules/transactions/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildConfigArtifact } from "../build/index.js";
|
|
2
2
|
import { applyConfig } from "../manager/index.js";
|
|
3
3
|
import { shouldAutoReloadRuntime } from "../manager/index.js";
|
|
4
|
+
import { applyWithTransaction } from "../transactions/index.js";
|
|
4
5
|
import { assertVerificationReportPassed, verifyConfigRoutes, } from "../verification/index.js";
|
|
5
6
|
export async function runUpdate(input) {
|
|
6
7
|
const build = await buildConfigArtifact({
|
|
@@ -22,13 +23,27 @@ export async function runUpdate(input) {
|
|
|
22
23
|
const livePath = input.livePath ?? input.config.output.livePath;
|
|
23
24
|
const backupPath = input.backupPath ?? input.config.output.backupPath;
|
|
24
25
|
const reloaded = input.reload ?? (await shouldAutoReloadRuntime(input.config.runtime.reload));
|
|
25
|
-
await
|
|
26
|
-
|
|
26
|
+
const transaction = await applyWithTransaction({
|
|
27
|
+
config: input.config,
|
|
28
|
+
generatedPath: build.outputPath,
|
|
27
29
|
livePath,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
backupPath,
|
|
31
|
+
verificationSummary: verification
|
|
32
|
+
? {
|
|
33
|
+
routeScenariosPassed: verification.scenarios.filter((scenario) => scenario.passed).length,
|
|
34
|
+
routeScenariosTotal: verification.scenarios.length,
|
|
35
|
+
}
|
|
36
|
+
: { routeScenariosSkipped: true },
|
|
37
|
+
apply: async () => {
|
|
38
|
+
await applyConfig({
|
|
39
|
+
stagingPath: build.outputPath,
|
|
40
|
+
livePath,
|
|
41
|
+
...(backupPath ? { backupPath } : {}),
|
|
42
|
+
...(input.singBoxBinary ? { singBoxBinary: input.singBoxBinary } : {}),
|
|
43
|
+
reload: reloaded,
|
|
44
|
+
runtime: input.config.runtime.reload,
|
|
45
|
+
});
|
|
46
|
+
},
|
|
32
47
|
});
|
|
33
48
|
return {
|
|
34
49
|
build,
|
|
@@ -36,6 +51,7 @@ export async function runUpdate(input) {
|
|
|
36
51
|
livePath,
|
|
37
52
|
...(backupPath ? { backupPath } : {}),
|
|
38
53
|
reloaded,
|
|
54
|
+
transactionId: transaction.txId,
|
|
39
55
|
};
|
|
40
56
|
}
|
|
41
57
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/modules/update/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,EAEL,8BAA8B,EAC9B,kBAAkB,GACnB,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/modules/update/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { BuilderConfig } from "../../config/schema.js";
|
|
2
|
+
import type { DNSPlan } from "../../domain/dns-plan.js";
|
|
3
|
+
import type { VerificationPlan } from "../../domain/verification-plan.js";
|
|
2
4
|
type JsonObject = Record<string, unknown>;
|
|
3
5
|
export interface VerifyConfigRoutesInput {
|
|
4
6
|
readonly configPath: string;
|
|
@@ -40,6 +42,37 @@ export interface VisibleChromeLaunch {
|
|
|
40
42
|
readonly urls: readonly string[];
|
|
41
43
|
readonly userDataDir: string;
|
|
42
44
|
}
|
|
45
|
+
export interface DnsVerificationResult {
|
|
46
|
+
readonly domain: string;
|
|
47
|
+
readonly passed: boolean;
|
|
48
|
+
readonly expectedMode: "fake-ip" | "real-ip";
|
|
49
|
+
readonly actualMode: "fake-ip" | "real-ip";
|
|
50
|
+
readonly resolver: string;
|
|
51
|
+
}
|
|
52
|
+
export interface AppVerificationResult {
|
|
53
|
+
readonly app: string;
|
|
54
|
+
readonly passed: boolean;
|
|
55
|
+
readonly expectedInbound: "in-proxifier" | "in-default";
|
|
56
|
+
readonly expectedOutboundGroup: string;
|
|
57
|
+
readonly details: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ProtocolVerificationResult {
|
|
60
|
+
readonly target: string;
|
|
61
|
+
readonly passed: boolean;
|
|
62
|
+
readonly expectTCPOnly: boolean;
|
|
63
|
+
readonly details: string;
|
|
64
|
+
}
|
|
65
|
+
export interface EgressVerificationResult {
|
|
66
|
+
readonly id: string;
|
|
67
|
+
readonly target: string;
|
|
68
|
+
readonly inbound: "in-mixed" | "in-proxifier";
|
|
69
|
+
readonly expectedOutboundGroup: string;
|
|
70
|
+
readonly passed: boolean;
|
|
71
|
+
readonly ip?: string;
|
|
72
|
+
readonly country?: string;
|
|
73
|
+
readonly asn?: string;
|
|
74
|
+
readonly details: string;
|
|
75
|
+
}
|
|
43
76
|
export declare function assertVerificationReportPassed(report: VerificationReport): void;
|
|
44
77
|
interface PreparedVerificationConfig {
|
|
45
78
|
readonly config: JsonObject;
|
|
@@ -68,6 +101,15 @@ interface RequestRunResult {
|
|
|
68
101
|
readonly timedOut: boolean;
|
|
69
102
|
}
|
|
70
103
|
export declare function verifyConfigRoutes(input: VerifyConfigRoutesInput): Promise<VerificationReport>;
|
|
104
|
+
export declare function verifyDnsPlan(plan: VerificationPlan, dnsPlan: DNSPlan): readonly DnsVerificationResult[];
|
|
105
|
+
export declare function verifyAppPlan(plan: VerificationPlan, config: JsonObject): readonly AppVerificationResult[];
|
|
106
|
+
export declare function verifyProtocolPlan(plan: VerificationPlan, config: JsonObject): readonly ProtocolVerificationResult[];
|
|
107
|
+
export declare function verifyEgressPlan(input: {
|
|
108
|
+
readonly configPath: string;
|
|
109
|
+
readonly checks: VerificationPlan["egressChecks"];
|
|
110
|
+
readonly singBoxBinary?: string;
|
|
111
|
+
readonly requestBinary?: string;
|
|
112
|
+
}): Promise<readonly EgressVerificationResult[]>;
|
|
71
113
|
export declare function isRouteLevelProxySuccess(scenario: Pick<RuntimeScenario, "inboundTag">, requestResult: RequestRunResult): boolean;
|
|
72
114
|
export declare function resolveChromeBinary(explicitPath?: string): Promise<string>;
|
|
73
115
|
export declare function openVisibleChromeWindows(input: {
|
|
@@ -77,6 +77,161 @@ export async function verifyConfigRoutes(input) {
|
|
|
77
77
|
await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 2_000))]);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
+
export function verifyDnsPlan(plan, dnsPlan) {
|
|
81
|
+
return plan.dnsChecks.map((check) => ({
|
|
82
|
+
domain: check.domain,
|
|
83
|
+
passed: dnsPlan.mode === check.expectedMode,
|
|
84
|
+
expectedMode: check.expectedMode,
|
|
85
|
+
actualMode: dnsPlan.mode,
|
|
86
|
+
resolver: check.expectedResolver ?? dnsPlan.defaultResolvers[0] ?? "unknown",
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
export function verifyAppPlan(plan, config) {
|
|
90
|
+
const route = asObject(config.route, "Config is missing route.");
|
|
91
|
+
const rules = ensureArray(route.rules, "Route is missing rules.");
|
|
92
|
+
const proxifierProtected = rules.some((rule) => Array.isArray(rule.inbound) &&
|
|
93
|
+
rule.inbound.includes("in-proxifier") &&
|
|
94
|
+
rule.action === "route" &&
|
|
95
|
+
rule.outbound === "Process-Proxy");
|
|
96
|
+
return plan.appChecks.map((check) => ({
|
|
97
|
+
app: check.app,
|
|
98
|
+
passed: check.expectedInbound === "in-proxifier" ? proxifierProtected : true,
|
|
99
|
+
expectedInbound: check.expectedInbound,
|
|
100
|
+
expectedOutboundGroup: check.expectedOutboundGroup,
|
|
101
|
+
details: check.expectedInbound === "in-proxifier"
|
|
102
|
+
? "Protected in-proxifier route is present."
|
|
103
|
+
: "No special app inbound is required.",
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
export function verifyProtocolPlan(plan, config) {
|
|
107
|
+
const route = asObject(config.route, "Config is missing route.");
|
|
108
|
+
const rules = ensureArray(route.rules, "Route is missing rules.");
|
|
109
|
+
const quicReject = rules.some((rule) => rule.network === "udp" && rule.port === 443 && rule.action === "reject");
|
|
110
|
+
return plan.protocolChecks.map((check) => ({
|
|
111
|
+
target: check.target,
|
|
112
|
+
passed: check.expectTCPOnly ? quicReject : true,
|
|
113
|
+
expectTCPOnly: check.expectTCPOnly === true,
|
|
114
|
+
details: check.expectTCPOnly === true
|
|
115
|
+
? quicReject
|
|
116
|
+
? "UDP 443 reject is present, so TCP-only fallback is enforced."
|
|
117
|
+
: "UDP 443 reject is missing."
|
|
118
|
+
: "No protocol restriction requested.",
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
export async function verifyEgressPlan(input) {
|
|
122
|
+
if (input.checks.length === 0) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const singBoxBinary = await resolveSingBoxBinary(input.singBoxBinary);
|
|
126
|
+
const requestBinary = input.requestBinary ?? (await resolveCurlBinary());
|
|
127
|
+
const baseConfig = JSON.parse(await readFile(input.configPath, "utf8"));
|
|
128
|
+
const egressGeoServiceUrls = [
|
|
129
|
+
"https://api.ip.sb/geoip",
|
|
130
|
+
"https://ipinfo.io/json",
|
|
131
|
+
"https://ifconfig.co/json",
|
|
132
|
+
];
|
|
133
|
+
const egressIpServiceUrls = [
|
|
134
|
+
"https://api.ipify.org?format=json",
|
|
135
|
+
"https://api64.ipify.org?format=json",
|
|
136
|
+
"https://ifconfig.me/all.json",
|
|
137
|
+
"https://icanhazip.com/",
|
|
138
|
+
];
|
|
139
|
+
const results = [];
|
|
140
|
+
for (const check of input.checks) {
|
|
141
|
+
const prepared = await prepareVerificationConfig(baseConfig);
|
|
142
|
+
const route = asObject(prepared.config.route, "Config is missing route.");
|
|
143
|
+
const rules = ensureArray(route.rules, "Route is missing rules.");
|
|
144
|
+
rules.unshift({
|
|
145
|
+
domain_suffix: [...egressGeoServiceUrls, ...egressIpServiceUrls].map((url) => new URL(url).hostname),
|
|
146
|
+
action: "route",
|
|
147
|
+
outbound: check.expectedOutboundGroup,
|
|
148
|
+
});
|
|
149
|
+
const runDir = await mkdtemp(path.join(tmpdir(), "singbox-iac-egress-"));
|
|
150
|
+
const verifyConfigPath = path.join(runDir, "egress.config.json");
|
|
151
|
+
const logPath = path.join(runDir, "egress.log");
|
|
152
|
+
await writeFile(verifyConfigPath, `${JSON.stringify(prepared.config, null, 2)}\n`, "utf8");
|
|
153
|
+
await checkConfig({ configPath: verifyConfigPath, singBoxBinary });
|
|
154
|
+
const logBuffer = { text: "" };
|
|
155
|
+
const appender = async (chunk) => {
|
|
156
|
+
const text = chunk.toString();
|
|
157
|
+
logBuffer.text += text;
|
|
158
|
+
await writeFile(logPath, text, { encoding: "utf8", flag: "a" });
|
|
159
|
+
};
|
|
160
|
+
const child = spawn(singBoxBinary, ["run", "-c", verifyConfigPath], {
|
|
161
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
+
});
|
|
163
|
+
child.stdout.on("data", (chunk) => void appender(chunk));
|
|
164
|
+
child.stderr.on("data", (chunk) => void appender(chunk));
|
|
165
|
+
const exitPromise = new Promise((resolve, reject) => {
|
|
166
|
+
child.on("error", reject);
|
|
167
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
168
|
+
});
|
|
169
|
+
try {
|
|
170
|
+
await waitForLog(logBuffer, /sing-box started/, 15_000, "Timed out waiting for sing-box startup during egress verification.");
|
|
171
|
+
const proxyPort = check.inbound === "in-proxifier" ? prepared.proxifierPort : prepared.mixedPort;
|
|
172
|
+
let payload = await probeEgressPayload({
|
|
173
|
+
requestBinary,
|
|
174
|
+
proxyPort,
|
|
175
|
+
targets: [check.target, ...egressGeoServiceUrls.filter((url) => url !== check.target)],
|
|
176
|
+
});
|
|
177
|
+
let ip = extractIpAddress(payload);
|
|
178
|
+
let country = normalizeCountryCode(payload);
|
|
179
|
+
let asn = normalizeAsn(payload);
|
|
180
|
+
if (!ip || !country || !asn) {
|
|
181
|
+
const ipPayload = await probeEgressPayload({
|
|
182
|
+
requestBinary,
|
|
183
|
+
proxyPort,
|
|
184
|
+
targets: egressIpServiceUrls,
|
|
185
|
+
});
|
|
186
|
+
payload = { ...payload, ...ipPayload };
|
|
187
|
+
ip = extractIpAddress(payload);
|
|
188
|
+
country = normalizeCountryCode(payload);
|
|
189
|
+
asn = normalizeAsn(payload);
|
|
190
|
+
}
|
|
191
|
+
if (ip && (!country || !asn)) {
|
|
192
|
+
const geoPayload = await probeEgressPayload({
|
|
193
|
+
requestBinary,
|
|
194
|
+
targets: [`https://api.ip.sb/geoip/${ip}`, `https://ipinfo.io/${ip}/json`],
|
|
195
|
+
});
|
|
196
|
+
payload = { ...payload, ...geoPayload, ip };
|
|
197
|
+
country = normalizeCountryCode(payload);
|
|
198
|
+
asn = normalizeAsn(payload);
|
|
199
|
+
}
|
|
200
|
+
const expectedCountrySatisfied = !check.expectedCountry || check.expectedCountry.length === 0
|
|
201
|
+
? true
|
|
202
|
+
: country !== undefined && check.expectedCountry.includes(country);
|
|
203
|
+
const expectedAsnSatisfied = !check.expectedASN || check.expectedASN.length === 0
|
|
204
|
+
? true
|
|
205
|
+
: asn !== undefined && check.expectedASN.some((expected) => asn.includes(expected));
|
|
206
|
+
results.push({
|
|
207
|
+
id: check.id,
|
|
208
|
+
target: check.target,
|
|
209
|
+
inbound: check.inbound,
|
|
210
|
+
expectedOutboundGroup: check.expectedOutboundGroup,
|
|
211
|
+
passed: expectedCountrySatisfied && expectedAsnSatisfied,
|
|
212
|
+
...(ip ? { ip } : {}),
|
|
213
|
+
...(country ? { country } : {}),
|
|
214
|
+
...(asn ? { asn } : {}),
|
|
215
|
+
details: `Exit via ${check.expectedOutboundGroup} returned ${JSON.stringify(payload)}`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
results.push({
|
|
220
|
+
id: check.id,
|
|
221
|
+
target: check.target,
|
|
222
|
+
inbound: check.inbound,
|
|
223
|
+
expectedOutboundGroup: check.expectedOutboundGroup,
|
|
224
|
+
passed: false,
|
|
225
|
+
details: error instanceof Error ? error.message : String(error),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
child.kill("SIGINT");
|
|
230
|
+
await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 2_000))]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return results;
|
|
234
|
+
}
|
|
80
235
|
async function verifyRuntimeScenario(scenario, logBuffer, requestBinary) {
|
|
81
236
|
const hostname = new URL(scenario.url).hostname;
|
|
82
237
|
const expectedLog = scenario.expectedOutboundTag === "direct"
|
|
@@ -437,6 +592,56 @@ async function runProxyRequestScenario(input) {
|
|
|
437
592
|
});
|
|
438
593
|
});
|
|
439
594
|
}
|
|
595
|
+
async function runProxyJsonRequestScenario(input) {
|
|
596
|
+
return runJsonRequestScenario(input);
|
|
597
|
+
}
|
|
598
|
+
async function runJsonRequestScenario(input) {
|
|
599
|
+
const args = [
|
|
600
|
+
"--silent",
|
|
601
|
+
"--show-error",
|
|
602
|
+
"--location",
|
|
603
|
+
"--max-time",
|
|
604
|
+
"12",
|
|
605
|
+
"--connect-timeout",
|
|
606
|
+
"5",
|
|
607
|
+
input.url,
|
|
608
|
+
];
|
|
609
|
+
if (typeof input.proxyPort === "number") {
|
|
610
|
+
args.splice(args.length - 1, 0, "--proxy", `http://127.0.0.1:${input.proxyPort}`);
|
|
611
|
+
}
|
|
612
|
+
return new Promise((resolve, reject) => {
|
|
613
|
+
const child = spawn(input.requestBinary, args, {
|
|
614
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
615
|
+
});
|
|
616
|
+
let stdout = "";
|
|
617
|
+
let stderr = "";
|
|
618
|
+
let timedOut = false;
|
|
619
|
+
const timeout = setTimeout(() => {
|
|
620
|
+
timedOut = true;
|
|
621
|
+
child.kill("SIGKILL");
|
|
622
|
+
}, 12_000);
|
|
623
|
+
child.stdout.on("data", (chunk) => {
|
|
624
|
+
stdout += chunk.toString();
|
|
625
|
+
});
|
|
626
|
+
child.stderr.on("data", (chunk) => {
|
|
627
|
+
stderr += chunk.toString();
|
|
628
|
+
});
|
|
629
|
+
child.on("error", (error) => {
|
|
630
|
+
clearTimeout(timeout);
|
|
631
|
+
reject(error);
|
|
632
|
+
});
|
|
633
|
+
child.on("close", (code) => {
|
|
634
|
+
clearTimeout(timeout);
|
|
635
|
+
resolve({
|
|
636
|
+
exitCode: code ?? 0,
|
|
637
|
+
stdout,
|
|
638
|
+
stderr,
|
|
639
|
+
timedOut,
|
|
640
|
+
body: stdout,
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
}
|
|
440
645
|
function detectRequestFailure(result) {
|
|
441
646
|
if (result.exitCode !== 0 && !result.timedOut) {
|
|
442
647
|
return `Proxy request exited with code ${result.exitCode}.\n${`${result.stdout}\n${result.stderr}`.trim()}`;
|
|
@@ -446,6 +651,75 @@ function detectRequestFailure(result) {
|
|
|
446
651
|
}
|
|
447
652
|
return undefined;
|
|
448
653
|
}
|
|
654
|
+
function parseEgressPayload(body) {
|
|
655
|
+
const trimmed = body.trim();
|
|
656
|
+
if (trimmed.length === 0) {
|
|
657
|
+
throw new Error("Egress probe returned an empty body.");
|
|
658
|
+
}
|
|
659
|
+
if (isIpAddress(trimmed)) {
|
|
660
|
+
return { ip: trimmed };
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
return JSON.parse(trimmed);
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
throw new Error(`Unable to parse egress response JSON:\n${trimmed}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async function probeEgressPayload(input) {
|
|
670
|
+
const errors = [];
|
|
671
|
+
for (const target of input.targets) {
|
|
672
|
+
const result = await runJsonRequestScenario({
|
|
673
|
+
requestBinary: input.requestBinary,
|
|
674
|
+
url: target,
|
|
675
|
+
...(typeof input.proxyPort === "number" ? { proxyPort: input.proxyPort } : {}),
|
|
676
|
+
});
|
|
677
|
+
if (result.exitCode !== 0 || result.timedOut) {
|
|
678
|
+
errors.push(`${target}: ${detectRequestFailure(result) ?? "request failed"}`);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
return parseEgressPayload(result.body);
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
errors.push(`${target}: ${error instanceof Error ? error.message : String(error)}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
throw new Error(`All egress probes failed.\n${errors.join("\n")}`);
|
|
689
|
+
}
|
|
690
|
+
function extractIpAddress(payload) {
|
|
691
|
+
const candidates = [payload.ip, payload.query, payload.address];
|
|
692
|
+
for (const candidate of candidates) {
|
|
693
|
+
if (typeof candidate === "string" && isIpAddress(candidate.trim())) {
|
|
694
|
+
return candidate.trim();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
function normalizeCountryCode(payload) {
|
|
700
|
+
if (typeof payload.country_code === "string") {
|
|
701
|
+
return payload.country_code.toUpperCase();
|
|
702
|
+
}
|
|
703
|
+
if (typeof payload.country === "string" && payload.country.length <= 3) {
|
|
704
|
+
return payload.country.toUpperCase();
|
|
705
|
+
}
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
function isIpAddress(value) {
|
|
709
|
+
return /^[\dA-Fa-f:.]+$/.test(value);
|
|
710
|
+
}
|
|
711
|
+
function normalizeAsn(payload) {
|
|
712
|
+
if (typeof payload.asn === "number") {
|
|
713
|
+
return `AS${payload.asn}`;
|
|
714
|
+
}
|
|
715
|
+
if (typeof payload.asn === "string") {
|
|
716
|
+
return payload.asn.startsWith("AS") ? payload.asn : `AS${payload.asn}`;
|
|
717
|
+
}
|
|
718
|
+
if (typeof payload.org === "string" && payload.org.length > 0) {
|
|
719
|
+
return payload.org;
|
|
720
|
+
}
|
|
721
|
+
return undefined;
|
|
722
|
+
}
|
|
449
723
|
async function waitForScenarioLogs(buffer, offset, patterns, timeoutMs) {
|
|
450
724
|
const startedAt = Date.now();
|
|
451
725
|
while (Date.now() - startedAt < timeoutMs) {
|