@jiayunxie/aerial 0.1.11 → 0.2.1
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.md +25 -1
- package/docs/usage.md +24 -1
- package/package.json +9 -4
- package/src/auth.js +4 -3
- package/src/cli.js +199 -98
- package/src/config.js +22 -1
- package/src/copilot.js +18 -12
- package/src/http-utils.js +9 -0
- package/src/model-selection.js +7 -32
- package/src/model-utils.js +20 -0
- package/src/probe.js +7 -24
- package/src/prompt-utils.js +8 -0
- package/src/proxy-config.js +51 -0
- package/src/responses-websocket.js +4 -2
- package/src/service.js +305 -214
- package/src/setup-selection.js +2 -9
- package/src/setup.js +41 -22
- package/src/socks5-bridge.js +189 -0
- package/src/upstream-fetch.js +354 -0
package/src/setup-selection.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { parseNumberChoice } from "./prompt-utils.js";
|
|
3
4
|
|
|
4
5
|
export const EFFORT_VALUES = Object.freeze(["low", "medium", "high", "xhigh"]);
|
|
5
6
|
export const DEFAULT_EFFORT = "medium";
|
|
@@ -21,14 +22,6 @@ export function assertValidEffort(raw) {
|
|
|
21
22
|
return normalized;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
function parseChoice(value, max, defaultIndex) {
|
|
25
|
-
const trimmed = String(value || "").trim();
|
|
26
|
-
if (!trimmed) return defaultIndex;
|
|
27
|
-
if (!/^\d+$/.test(trimmed)) return undefined;
|
|
28
|
-
const n = Number(trimmed);
|
|
29
|
-
return n >= 1 && n <= max ? n - 1 : undefined;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
25
|
export async function chooseSetupEffort({
|
|
33
26
|
target,
|
|
34
27
|
explicitEffort,
|
|
@@ -53,7 +46,7 @@ export async function chooseSetupEffort({
|
|
|
53
46
|
}
|
|
54
47
|
while (true) {
|
|
55
48
|
const answer = await rl.question(`Choose effort [1-${EFFORT_VALUES.length}, default ${defaultIndex + 1} = ${DEFAULT_EFFORT}]: `);
|
|
56
|
-
const selectedIndex =
|
|
49
|
+
const selectedIndex = parseNumberChoice(answer, { max: EFFORT_VALUES.length, defaultIndex });
|
|
57
50
|
if (selectedIndex !== undefined) {
|
|
58
51
|
return { effort: EFFORT_VALUES[selectedIndex], source: "prompt", displayed: true };
|
|
59
52
|
}
|
package/src/setup.js
CHANGED
|
@@ -259,6 +259,43 @@ export function claudeStatus() {
|
|
|
259
259
|
return { target: "claude", state, file, backups, model, baseUrl, effort };
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
function validateTomlBackup(content) {
|
|
263
|
+
try {
|
|
264
|
+
parseToml(content.toString("utf8"));
|
|
265
|
+
} catch (err) {
|
|
266
|
+
throw new Error(`Restore aborted: backup is not valid TOML (${err.message}). Live file left unchanged.`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function validateJsonBackup(content) {
|
|
271
|
+
try {
|
|
272
|
+
JSON.parse(content.toString("utf8"));
|
|
273
|
+
} catch (err) {
|
|
274
|
+
throw new Error(`Restore aborted: backup is not valid JSON (${err.message}). Live file left unchanged.`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const CLIENTS = Object.freeze({
|
|
279
|
+
codex: Object.freeze({
|
|
280
|
+
target: "codex",
|
|
281
|
+
file: codexConfigFile,
|
|
282
|
+
status: codexStatus,
|
|
283
|
+
validateBackup: validateTomlBackup
|
|
284
|
+
}),
|
|
285
|
+
claude: Object.freeze({
|
|
286
|
+
target: "claude",
|
|
287
|
+
file: claudeSettingsFile,
|
|
288
|
+
status: claudeStatus,
|
|
289
|
+
validateBackup: validateJsonBackup
|
|
290
|
+
})
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
function clientDescriptor(target) {
|
|
294
|
+
const descriptor = CLIENTS[target];
|
|
295
|
+
if (!descriptor) throw new Error(`Unknown restore target: ${target}. Use codex, claude, or all.`);
|
|
296
|
+
return descriptor;
|
|
297
|
+
}
|
|
298
|
+
|
|
262
299
|
export function setupStatus() {
|
|
263
300
|
const config = loadConfig();
|
|
264
301
|
const apiKeyFile = apiKeyPath();
|
|
@@ -274,17 +311,12 @@ export function setupStatus() {
|
|
|
274
311
|
return { file: githubTokenFile, exists: source !== "missing", source };
|
|
275
312
|
})()
|
|
276
313
|
},
|
|
277
|
-
clients:
|
|
278
|
-
codex: codexStatus(),
|
|
279
|
-
claude: claudeStatus()
|
|
280
|
-
}
|
|
314
|
+
clients: Object.fromEntries(Object.entries(CLIENTS).map(([target, client]) => [target, client.status()]))
|
|
281
315
|
};
|
|
282
316
|
}
|
|
283
317
|
|
|
284
318
|
function clientFile(target) {
|
|
285
|
-
|
|
286
|
-
if (target === "claude") return claudeSettingsFile();
|
|
287
|
-
throw new Error(`Unknown restore target: ${target}. Use codex, claude, or all.`);
|
|
319
|
+
return clientDescriptor(target).file();
|
|
288
320
|
}
|
|
289
321
|
|
|
290
322
|
function resolveWritePath(file) {
|
|
@@ -297,20 +329,7 @@ function resolveWritePath(file) {
|
|
|
297
329
|
}
|
|
298
330
|
|
|
299
331
|
function validateBackupContent(target, content) {
|
|
300
|
-
|
|
301
|
-
if (target === "codex") {
|
|
302
|
-
try {
|
|
303
|
-
parseToml(text);
|
|
304
|
-
} catch (err) {
|
|
305
|
-
throw new Error(`Restore aborted: backup is not valid TOML (${err.message}). Live file left unchanged.`);
|
|
306
|
-
}
|
|
307
|
-
} else if (target === "claude") {
|
|
308
|
-
try {
|
|
309
|
-
JSON.parse(text);
|
|
310
|
-
} catch (err) {
|
|
311
|
-
throw new Error(`Restore aborted: backup is not valid JSON (${err.message}). Live file left unchanged.`);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
332
|
+
clientDescriptor(target).validateBackup(content);
|
|
314
333
|
}
|
|
315
334
|
|
|
316
335
|
function resolveRestoreMode(writePath, backupPath, targetExisted) {
|
|
@@ -369,7 +388,7 @@ export function restoreClient(target, { now = () => new Date() } = {}) {
|
|
|
369
388
|
|
|
370
389
|
export function restoreAllClients(opts) {
|
|
371
390
|
const results = {};
|
|
372
|
-
for (const target of
|
|
391
|
+
for (const target of Object.keys(CLIENTS)) {
|
|
373
392
|
try {
|
|
374
393
|
results[target] = restoreClient(target, opts);
|
|
375
394
|
} catch (err) {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { SocksClient } from "socks";
|
|
3
|
+
import { isSocksProxyEndpoint, normalizeProxyEndpoint } from "./proxy-config.js";
|
|
4
|
+
|
|
5
|
+
const MAX_CONNECT_HEADER_BYTES = 8192;
|
|
6
|
+
const SOCKET_TIMEOUT_MS = 10000;
|
|
7
|
+
const bridgeCache = new Map();
|
|
8
|
+
|
|
9
|
+
function stripIpv6Brackets(host) {
|
|
10
|
+
return String(host || "").replace(/^\[/, "").replace(/\]$/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseSocksEndpoint(endpoint) {
|
|
14
|
+
const normalized = normalizeProxyEndpoint(endpoint);
|
|
15
|
+
if (!isSocksProxyEndpoint(normalized)) throw new Error("invalid SOCKS5 proxy endpoint");
|
|
16
|
+
const url = new URL(normalized);
|
|
17
|
+
const port = Number(url.port || 1080);
|
|
18
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
19
|
+
throw new Error("invalid SOCKS5 proxy port");
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
host: stripIpv6Brackets(url.hostname),
|
|
23
|
+
port,
|
|
24
|
+
type: 5,
|
|
25
|
+
userId: url.username ? decodeURIComponent(url.username) : undefined,
|
|
26
|
+
password: url.password ? decodeURIComponent(url.password) : undefined
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseConnectAuthority(authority) {
|
|
31
|
+
const text = String(authority || "").trim();
|
|
32
|
+
let host;
|
|
33
|
+
let portText;
|
|
34
|
+
if (text.startsWith("[")) {
|
|
35
|
+
const end = text.indexOf("]");
|
|
36
|
+
if (end < 0 || text[end + 1] !== ":") return undefined;
|
|
37
|
+
host = text.slice(1, end);
|
|
38
|
+
portText = text.slice(end + 2);
|
|
39
|
+
} else {
|
|
40
|
+
const colon = text.lastIndexOf(":");
|
|
41
|
+
if (colon <= 0) return undefined;
|
|
42
|
+
host = text.slice(0, colon);
|
|
43
|
+
portText = text.slice(colon + 1);
|
|
44
|
+
if (host.includes(":")) return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (!host || !/^\d+$/.test(portText)) return undefined;
|
|
47
|
+
const port = Number(portText);
|
|
48
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return undefined;
|
|
49
|
+
return { host, port };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseConnectRequest(buffer) {
|
|
53
|
+
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
54
|
+
if (headerEnd < 0) return undefined;
|
|
55
|
+
const header = buffer.subarray(0, headerEnd).toString("latin1");
|
|
56
|
+
const [requestLine] = header.split("\r\n");
|
|
57
|
+
const match = /^CONNECT\s+(\S+)\s+HTTP\/1\.[01]$/i.exec(requestLine || "");
|
|
58
|
+
const target = match ? parseConnectAuthority(match[1]) : undefined;
|
|
59
|
+
if (!target) return { error: "bad_request" };
|
|
60
|
+
return {
|
|
61
|
+
target,
|
|
62
|
+
rest: buffer.subarray(headerEnd + 4)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeHttpError(socket, status, reason) {
|
|
67
|
+
if (socket.destroyed) return;
|
|
68
|
+
socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function destroyBoth(left, right) {
|
|
72
|
+
try { left.destroy(); } catch {}
|
|
73
|
+
try { right.destroy(); } catch {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function connectViaSocks(proxy, target) {
|
|
77
|
+
const result = await SocksClient.createConnection({
|
|
78
|
+
command: "connect",
|
|
79
|
+
proxy,
|
|
80
|
+
destination: target,
|
|
81
|
+
timeout: SOCKET_TIMEOUT_MS
|
|
82
|
+
});
|
|
83
|
+
return result.socket;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleClient(client, proxy) {
|
|
87
|
+
let buffer = Buffer.alloc(0);
|
|
88
|
+
client.setTimeout(SOCKET_TIMEOUT_MS, () => writeHttpError(client, 408, "Request Timeout"));
|
|
89
|
+
|
|
90
|
+
const onData = async (chunk) => {
|
|
91
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
92
|
+
if (buffer.length > MAX_CONNECT_HEADER_BYTES) {
|
|
93
|
+
client.removeListener("data", onData);
|
|
94
|
+
writeHttpError(client, 400, "Bad Request");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const parsed = parseConnectRequest(buffer);
|
|
99
|
+
if (!parsed) return;
|
|
100
|
+
client.removeListener("data", onData);
|
|
101
|
+
client.setTimeout(0);
|
|
102
|
+
if (parsed.error) {
|
|
103
|
+
writeHttpError(client, 400, "Bad Request");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
client.pause();
|
|
108
|
+
let upstream;
|
|
109
|
+
try {
|
|
110
|
+
upstream = await connectViaSocks(proxy, parsed.target);
|
|
111
|
+
} catch {
|
|
112
|
+
writeHttpError(client, 502, "Bad Gateway");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (client.destroyed) {
|
|
116
|
+
upstream.destroy();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
upstream.on("error", () => destroyBoth(client, upstream));
|
|
121
|
+
client.on("error", () => destroyBoth(client, upstream));
|
|
122
|
+
upstream.on("close", () => client.destroy());
|
|
123
|
+
client.on("close", () => upstream.destroy());
|
|
124
|
+
|
|
125
|
+
client.write("HTTP/1.1 200 Connection Established\r\nProxy-agent: Aerial\r\n\r\n");
|
|
126
|
+
if (parsed.rest.length) upstream.write(parsed.rest);
|
|
127
|
+
client.pipe(upstream);
|
|
128
|
+
upstream.pipe(client);
|
|
129
|
+
client.resume();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
client.on("data", onData);
|
|
133
|
+
client.on("error", () => client.destroy());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function listen(server) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const onError = (err) => {
|
|
139
|
+
server.off("listening", onListening);
|
|
140
|
+
reject(err);
|
|
141
|
+
};
|
|
142
|
+
const onListening = () => {
|
|
143
|
+
server.off("error", onError);
|
|
144
|
+
resolve();
|
|
145
|
+
};
|
|
146
|
+
server.once("error", onError);
|
|
147
|
+
server.once("listening", onListening);
|
|
148
|
+
server.listen(0, "127.0.0.1");
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function createBridge(endpoint) {
|
|
153
|
+
const normalized = normalizeProxyEndpoint(endpoint);
|
|
154
|
+
const proxy = parseSocksEndpoint(normalized);
|
|
155
|
+
const server = net.createServer((client) => handleClient(client, proxy));
|
|
156
|
+
await listen(server);
|
|
157
|
+
server.unref();
|
|
158
|
+
const address = server.address();
|
|
159
|
+
return {
|
|
160
|
+
socksEndpoint: normalized,
|
|
161
|
+
endpoint: `http://127.0.0.1:${address.port}`,
|
|
162
|
+
host: "127.0.0.1",
|
|
163
|
+
port: address.port,
|
|
164
|
+
close: () => new Promise((resolve, reject) => {
|
|
165
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
166
|
+
})
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function startSocks5Bridge(endpoint) {
|
|
171
|
+
const normalized = normalizeProxyEndpoint(endpoint);
|
|
172
|
+
if (!isSocksProxyEndpoint(normalized)) throw new Error("invalid SOCKS5 proxy endpoint");
|
|
173
|
+
if (!bridgeCache.has(normalized)) {
|
|
174
|
+
const promise = createBridge(normalized).catch((err) => {
|
|
175
|
+
bridgeCache.delete(normalized);
|
|
176
|
+
throw err;
|
|
177
|
+
});
|
|
178
|
+
bridgeCache.set(normalized, promise);
|
|
179
|
+
}
|
|
180
|
+
return bridgeCache.get(normalized);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function _closeSocks5BridgesForTests() {
|
|
184
|
+
const bridges = await Promise.allSettled([...bridgeCache.values()]);
|
|
185
|
+
bridgeCache.clear();
|
|
186
|
+
await Promise.all(bridges
|
|
187
|
+
.filter((result) => result.status === "fulfilled")
|
|
188
|
+
.map((result) => result.value.close().catch(() => {})));
|
|
189
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { ProxyAgent } from "undici";
|
|
3
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
4
|
+
import { PROXY_MODE_AUTO, PROXY_MODE_DISABLED, isSocksProxyEndpoint, normalizeProxyEndpoint, normalizeProxyMode } from "./proxy-config.js";
|
|
5
|
+
import { startSocks5Bridge, _closeSocks5BridgesForTests } from "./socks5-bridge.js";
|
|
6
|
+
|
|
7
|
+
const VALIDATION_URL = "https://api.github.com/rate_limit";
|
|
8
|
+
const EGRESS_URL = "https://ipinfo.io/json";
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
10
|
+
const MAX_PAC_BYTES = 1024 * 1024;
|
|
11
|
+
const COMMON_LOCAL_HTTP_PROXY_PORTS = Object.freeze([
|
|
12
|
+
1087,
|
|
13
|
+
7890,
|
|
14
|
+
7897,
|
|
15
|
+
7899,
|
|
16
|
+
6152,
|
|
17
|
+
10808,
|
|
18
|
+
10809
|
|
19
|
+
]);
|
|
20
|
+
const COMMON_LOCAL_SOCKS_PROXY_PORTS = Object.freeze([
|
|
21
|
+
1086,
|
|
22
|
+
1080,
|
|
23
|
+
7891,
|
|
24
|
+
10808
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const dispatcherCache = new Map();
|
|
28
|
+
|
|
29
|
+
function defaultRunCommand(file, args, opts = {}) {
|
|
30
|
+
const result = spawnSync(file, args, {
|
|
31
|
+
stdio: "pipe",
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
timeout: opts.timeout || 3000,
|
|
34
|
+
windowsHide: true
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
status: result.status,
|
|
38
|
+
stdout: result.stdout || "",
|
|
39
|
+
stderr: result.stderr || "",
|
|
40
|
+
error: result.error
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function endpointFromHostPort({ scheme = "http", host, port }) {
|
|
45
|
+
const hostname = String(host || "").trim();
|
|
46
|
+
const n = Number(String(port || "").trim());
|
|
47
|
+
if (!hostname || !Number.isInteger(n) || n < 1 || n > 65535) return undefined;
|
|
48
|
+
const hostPart = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
|
|
49
|
+
return normalizeProxyEndpoint(`${scheme}://${hostPart}:${n}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function addCandidate(map, candidate) {
|
|
53
|
+
const endpoint = normalizeProxyEndpoint(candidate?.endpoint);
|
|
54
|
+
if (!endpoint || map.has(endpoint)) return;
|
|
55
|
+
map.set(endpoint, {
|
|
56
|
+
endpoint,
|
|
57
|
+
source: candidate?.source || "discovered",
|
|
58
|
+
priority: Number.isFinite(candidate?.priority) ? candidate.priority : map.size
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseScutilValueMap(text) {
|
|
63
|
+
const values = {};
|
|
64
|
+
for (const line of String(text || "").split(/\r?\n/)) {
|
|
65
|
+
const match = /^\s*([^:]+?)\s*:\s*(.*?)\s*$/.exec(line);
|
|
66
|
+
if (!match) continue;
|
|
67
|
+
values[match[1].trim()] = match[2].trim();
|
|
68
|
+
}
|
|
69
|
+
return values;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function scutilEnabled(values, key) {
|
|
73
|
+
return values[key] === "1" || values[key]?.toLowerCase?.() === "yes";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseScutilProxyOutput(text) {
|
|
77
|
+
const values = parseScutilValueMap(text);
|
|
78
|
+
const candidates = [];
|
|
79
|
+
if (scutilEnabled(values, "HTTPEnable")) {
|
|
80
|
+
const endpoint = endpointFromHostPort({ host: values.HTTPProxy, port: values.HTTPPort });
|
|
81
|
+
if (endpoint) candidates.push({ endpoint, source: "macos-http-proxy" });
|
|
82
|
+
}
|
|
83
|
+
if (scutilEnabled(values, "HTTPSEnable")) {
|
|
84
|
+
const endpoint = endpointFromHostPort({ host: values.HTTPSProxy, port: values.HTTPSPort });
|
|
85
|
+
if (endpoint) candidates.push({ endpoint, source: "macos-https-proxy" });
|
|
86
|
+
}
|
|
87
|
+
if (scutilEnabled(values, "SOCKSEnable")) {
|
|
88
|
+
const endpoint = endpointFromHostPort({ scheme: "socks5", host: values.SOCKSProxy, port: values.SOCKSPort });
|
|
89
|
+
if (endpoint) candidates.push({ endpoint, source: "macos-socks-proxy" });
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
values,
|
|
93
|
+
candidates,
|
|
94
|
+
pacUrl: scutilEnabled(values, "ProxyAutoConfigEnable") ? values.ProxyAutoConfigURLString : undefined
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function parsePacProxyCandidates(text, { source = "pac" } = {}) {
|
|
99
|
+
const out = [];
|
|
100
|
+
const re = /\b(PROXY|HTTPS|SOCKS5?|SOCKS)\s+([A-Za-z0-9_.-]+|\[[0-9A-Fa-f:.]+\]):(\d{1,5})\b/g;
|
|
101
|
+
let match;
|
|
102
|
+
while ((match = re.exec(String(text || ""))) !== null) {
|
|
103
|
+
const directive = match[1].toUpperCase();
|
|
104
|
+
const scheme = directive === "HTTPS" ? "https" : directive.startsWith("SOCKS") ? "socks5" : "http";
|
|
105
|
+
const endpoint = endpointFromHostPort({ scheme, host: match[2], port: match[3] });
|
|
106
|
+
if (endpoint) out.push({ endpoint, source });
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function proxyEndpointFromEnv(name) {
|
|
112
|
+
const endpoint = normalizeProxyEndpoint(process.env[name]);
|
|
113
|
+
return endpoint ? { endpoint, source: name } : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function responseTextWithLimit(response, maxBytes) {
|
|
117
|
+
const length = Number(response.headers.get("content-length"));
|
|
118
|
+
if (Number.isFinite(length) && length > maxBytes) throw new Error("PAC file is too large");
|
|
119
|
+
if (!response.body?.getReader) {
|
|
120
|
+
const text = await response.text();
|
|
121
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes) throw new Error("PAC file is too large");
|
|
122
|
+
return text;
|
|
123
|
+
}
|
|
124
|
+
const reader = response.body.getReader();
|
|
125
|
+
const decoder = new TextDecoder();
|
|
126
|
+
let text = "";
|
|
127
|
+
let bytes = 0;
|
|
128
|
+
while (true) {
|
|
129
|
+
const { value, done } = await reader.read();
|
|
130
|
+
if (done) break;
|
|
131
|
+
bytes += value.byteLength;
|
|
132
|
+
if (bytes > maxBytes) {
|
|
133
|
+
try { await reader.cancel(); } catch {}
|
|
134
|
+
throw new Error("PAC file is too large");
|
|
135
|
+
}
|
|
136
|
+
text += decoder.decode(value, { stream: true });
|
|
137
|
+
}
|
|
138
|
+
return text + decoder.decode();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function pacCandidatesFromUrl(pacUrl, { fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, maxPacBytes = MAX_PAC_BYTES } = {}) {
|
|
142
|
+
if (!pacUrl) return [];
|
|
143
|
+
try {
|
|
144
|
+
const response = await fetchImpl(pacUrl, { signal: AbortSignal.timeout(timeoutMs) });
|
|
145
|
+
if (!response.ok) return [];
|
|
146
|
+
const text = await responseTextWithLimit(response, maxPacBytes);
|
|
147
|
+
return parsePacProxyCandidates(text, { source: `macos-pac:${pacUrl}` });
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function discoverProxyCandidates({
|
|
154
|
+
config = loadConfig(),
|
|
155
|
+
fetchImpl = globalThis.fetch,
|
|
156
|
+
runCommand = defaultRunCommand,
|
|
157
|
+
platform = process.platform,
|
|
158
|
+
commonPorts = COMMON_LOCAL_HTTP_PROXY_PORTS,
|
|
159
|
+
commonSocksPorts = COMMON_LOCAL_SOCKS_PROXY_PORTS,
|
|
160
|
+
maxPacBytes = MAX_PAC_BYTES
|
|
161
|
+
} = {}) {
|
|
162
|
+
const candidates = new Map();
|
|
163
|
+
|
|
164
|
+
for (const envName of ["AERIAL_UPSTREAM_PROXY", "ALL_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "all_proxy", "https_proxy", "http_proxy"]) {
|
|
165
|
+
addCandidate(candidates, proxyEndpointFromEnv(envName));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
addCandidate(candidates, {
|
|
169
|
+
endpoint: config.upstreamProxyEndpoint,
|
|
170
|
+
source: config.upstreamProxySource || "configured"
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (platform === "darwin") {
|
|
174
|
+
const scutil = runCommand("scutil", ["--proxy"], { timeout: 3000 });
|
|
175
|
+
if (scutil.status === 0) {
|
|
176
|
+
const parsed = parseScutilProxyOutput(scutil.stdout);
|
|
177
|
+
for (const candidate of parsed.candidates) addCandidate(candidates, candidate);
|
|
178
|
+
const pacCandidates = await pacCandidatesFromUrl(parsed.pacUrl, { fetchImpl, maxPacBytes });
|
|
179
|
+
for (const candidate of pacCandidates) addCandidate(candidates, candidate);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const port of commonSocksPorts) {
|
|
184
|
+
addCandidate(candidates, {
|
|
185
|
+
endpoint: `socks5://127.0.0.1:${port}`,
|
|
186
|
+
source: "common-local-socks-port"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const port of commonPorts) {
|
|
191
|
+
addCandidate(candidates, {
|
|
192
|
+
endpoint: `http://127.0.0.1:${port}`,
|
|
193
|
+
source: "common-local-port"
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return [...candidates.values()].sort((a, b) => a.priority - b.priority);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function agentEndpointForProxyEndpoint(endpoint) {
|
|
201
|
+
const normalized = normalizeProxyEndpoint(endpoint);
|
|
202
|
+
if (!normalized) return undefined;
|
|
203
|
+
if (!isSocksProxyEndpoint(normalized)) return normalized;
|
|
204
|
+
const bridge = await startSocks5Bridge(normalized);
|
|
205
|
+
return bridge.endpoint;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function dispatcherForEndpoint(endpoint) {
|
|
209
|
+
const agentEndpoint = await agentEndpointForProxyEndpoint(endpoint);
|
|
210
|
+
if (!agentEndpoint) return undefined;
|
|
211
|
+
if (!dispatcherCache.has(agentEndpoint)) dispatcherCache.set(agentEndpoint, new ProxyAgent(agentEndpoint));
|
|
212
|
+
return dispatcherCache.get(agentEndpoint);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function upstreamProxyState(config = loadConfig()) {
|
|
216
|
+
const envProxy = proxyEndpointFromEnv("AERIAL_UPSTREAM_PROXY");
|
|
217
|
+
if (envProxy) {
|
|
218
|
+
return {
|
|
219
|
+
mode: "env",
|
|
220
|
+
enabled: true,
|
|
221
|
+
endpoint: envProxy.endpoint,
|
|
222
|
+
source: envProxy.source
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const mode = normalizeProxyMode(config.upstreamProxyMode);
|
|
226
|
+
const endpoint = normalizeProxyEndpoint(config.upstreamProxyEndpoint);
|
|
227
|
+
const enabled = mode === PROXY_MODE_AUTO && Boolean(endpoint);
|
|
228
|
+
return {
|
|
229
|
+
mode,
|
|
230
|
+
enabled,
|
|
231
|
+
endpoint: enabled ? endpoint : undefined,
|
|
232
|
+
source: enabled ? (config.upstreamProxySource || "configured") : undefined
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function upstreamDispatcher(config = loadConfig()) {
|
|
237
|
+
const state = upstreamProxyState(config);
|
|
238
|
+
return state.enabled ? await dispatcherForEndpoint(state.endpoint) : undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function fetchWithProxyEndpoint(url, init = {}, endpoint, fetchImpl = globalThis.fetch) {
|
|
242
|
+
const dispatcher = await dispatcherForEndpoint(endpoint);
|
|
243
|
+
if (!dispatcher) return fetchImpl(url, init);
|
|
244
|
+
return fetchImpl(url, { ...init, dispatcher });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function upstreamFetch(url, init = {}) {
|
|
248
|
+
const state = upstreamProxyState();
|
|
249
|
+
if (!state.enabled) return globalThis.fetch(url, init);
|
|
250
|
+
return fetchWithProxyEndpoint(url, init, state.endpoint);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function validateProxyEndpoint(endpoint, {
|
|
254
|
+
fetchImpl = globalThis.fetch,
|
|
255
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
256
|
+
} = {}) {
|
|
257
|
+
const normalized = normalizeProxyEndpoint(endpoint);
|
|
258
|
+
if (!normalized) return { ok: false, error: "invalid proxy endpoint" };
|
|
259
|
+
try {
|
|
260
|
+
const response = await fetchWithProxyEndpoint(VALIDATION_URL, {
|
|
261
|
+
headers: { "user-agent": "Aerial/0.1" },
|
|
262
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
263
|
+
}, normalized, fetchImpl);
|
|
264
|
+
if (!response.ok) return { ok: false, status: response.status, error: `http ${response.status}` };
|
|
265
|
+
let payload;
|
|
266
|
+
try {
|
|
267
|
+
payload = await response.json();
|
|
268
|
+
} catch {
|
|
269
|
+
payload = undefined;
|
|
270
|
+
}
|
|
271
|
+
if (payload?.resources && typeof payload.resources === "object") {
|
|
272
|
+
return { ok: true, status: response.status };
|
|
273
|
+
}
|
|
274
|
+
if (payload?.rate && typeof payload.rate === "object") {
|
|
275
|
+
return { ok: true, status: response.status };
|
|
276
|
+
}
|
|
277
|
+
return { ok: false, status: response.status, error: "GitHub validation response did not match rate_limit JSON" };
|
|
278
|
+
} catch (err) {
|
|
279
|
+
return { ok: false, error: err.message || String(err) };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function selectWorkingProxyCandidate(candidates, opts = {}) {
|
|
284
|
+
const list = Array.isArray(candidates) ? candidates : [];
|
|
285
|
+
const validated = await Promise.all(list.map(async (candidate) => ({
|
|
286
|
+
...candidate,
|
|
287
|
+
validation: await validateProxyEndpoint(candidate.endpoint, opts)
|
|
288
|
+
})));
|
|
289
|
+
return validated.find((candidate) => candidate.validation.ok) || undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function enableUpstreamProxy(opts = {}) {
|
|
293
|
+
const config = opts.config || loadConfig();
|
|
294
|
+
const candidates = opts.candidates || await discoverProxyCandidates({ ...opts, config });
|
|
295
|
+
const selected = await selectWorkingProxyCandidate(candidates, opts);
|
|
296
|
+
if (!selected) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
candidates,
|
|
300
|
+
error: candidates.length
|
|
301
|
+
? "No discovered HTTP(S) or SOCKS5 proxy could reach GitHub."
|
|
302
|
+
: "No HTTP(S) or SOCKS5 proxy candidates were found."
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const next = {
|
|
306
|
+
...config,
|
|
307
|
+
upstreamProxyMode: PROXY_MODE_AUTO,
|
|
308
|
+
upstreamProxyEndpoint: selected.endpoint,
|
|
309
|
+
upstreamProxySource: selected.source
|
|
310
|
+
};
|
|
311
|
+
saveConfig(next);
|
|
312
|
+
return { ok: true, selected, candidates };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function disableUpstreamProxy(config = loadConfig()) {
|
|
316
|
+
const next = {
|
|
317
|
+
...config,
|
|
318
|
+
upstreamProxyMode: PROXY_MODE_DISABLED,
|
|
319
|
+
upstreamProxyEndpoint: undefined,
|
|
320
|
+
upstreamProxySource: undefined
|
|
321
|
+
};
|
|
322
|
+
saveConfig(next);
|
|
323
|
+
return next;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function probeEgress({
|
|
327
|
+
endpoint,
|
|
328
|
+
fetchImpl = globalThis.fetch,
|
|
329
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
330
|
+
} = {}) {
|
|
331
|
+
try {
|
|
332
|
+
const response = await fetchWithProxyEndpoint(EGRESS_URL, {
|
|
333
|
+
headers: { "user-agent": "Aerial/0.1" },
|
|
334
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
335
|
+
}, endpoint, fetchImpl);
|
|
336
|
+
if (!response.ok) return { ok: false, status: response.status, error: `http ${response.status}` };
|
|
337
|
+
const payload = await response.json();
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
ip: payload.ip,
|
|
341
|
+
city: payload.city,
|
|
342
|
+
region: payload.region,
|
|
343
|
+
country: payload.country,
|
|
344
|
+
org: payload.org
|
|
345
|
+
};
|
|
346
|
+
} catch (err) {
|
|
347
|
+
return { ok: false, error: err.message || String(err) };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function _clearProxyDispatcherCacheForTests() {
|
|
352
|
+
dispatcherCache.clear();
|
|
353
|
+
return _closeSocks5BridgesForTests();
|
|
354
|
+
}
|