@swimmingliu/autovpn 1.5.5 → 1.6.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/bin/autovpn.mjs +0 -0
- package/dist/cli/main.js +9 -2
- package/dist/cli/native-commands.js +2 -0
- package/dist/jobs/commands.js +35 -0
- package/dist/jobs/process.js +26 -8
- package/dist/jobs/read.js +4 -0
- package/dist/pipeline/extract.js +153 -6
- package/dist/pipeline/orchestrator.js +18 -8
- package/dist/server/http.js +64 -30
- package/dist/server/options.js +38 -4
- package/dist/server/runtime.js +269 -33
- package/dist/server/web-adapter.js +124 -6
- package/dist/web/renderer/app.js +187 -38
- package/dist/web/renderer/i18n.js +4 -2
- package/dist/web/renderer/views.js +9 -1
- package/package.json +1 -1
package/bin/autovpn.mjs
CHANGED
|
File without changes
|
package/dist/cli/main.js
CHANGED
|
@@ -207,7 +207,8 @@ export async function runCliShell(argv, options = {}) {
|
|
|
207
207
|
const serverFactory = options.createServer ?? createAutoVpnServer;
|
|
208
208
|
const runtime = createServerRuntime({
|
|
209
209
|
projectRoot: serveOptions.projectRoot,
|
|
210
|
-
env
|
|
210
|
+
env,
|
|
211
|
+
proxy: serveOptions.proxy
|
|
211
212
|
});
|
|
212
213
|
const server = await serverFactory({
|
|
213
214
|
...serveOptions,
|
|
@@ -217,7 +218,13 @@ export async function runCliShell(argv, options = {}) {
|
|
|
217
218
|
});
|
|
218
219
|
io.writeStdout(`AutoVPN server listening on ${server.origin}\n`);
|
|
219
220
|
if (serveOptions.auth.enabled) {
|
|
220
|
-
|
|
221
|
+
if (serveOptions.auth.password) {
|
|
222
|
+
io.writeStdout(`Open ${server.origin}/\n`);
|
|
223
|
+
io.writeStdout(`Password: ${serveOptions.auth.password}\n`);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
io.writeStdout(`Open ${server.origin}/?token=${encodeURIComponent(serveOptions.auth.token)}\n`);
|
|
227
|
+
}
|
|
221
228
|
}
|
|
222
229
|
else {
|
|
223
230
|
io.writeStderr('autovpn: warning: server authentication is disabled\n');
|
|
@@ -160,6 +160,8 @@ export async function runNativeCommand(argv, context) {
|
|
|
160
160
|
resumeLatest: true,
|
|
161
161
|
skipDeploy: Boolean(sourceOptions.skip_deploy),
|
|
162
162
|
skipVerify: Boolean(sourceOptions.skip_verify),
|
|
163
|
+
useProxy: Boolean(sourceOptions.use_proxy),
|
|
164
|
+
proxyUrl: String(sourceOptions.proxy_url ?? ''),
|
|
163
165
|
outputFormat: outputFormat(argv)
|
|
164
166
|
}, jobOptions(context));
|
|
165
167
|
context.io.writeStdout(jsonLine(publicStartedPayload(job)));
|
package/dist/jobs/commands.js
CHANGED
|
@@ -45,6 +45,32 @@ function spawnDetached(command, args, job, options) {
|
|
|
45
45
|
fs.closeSync(stderrFd);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
function markArtifactStopped(job) {
|
|
49
|
+
const artifactDir = String(job.artifact_dir ?? '');
|
|
50
|
+
if (!artifactDir) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const reportPath = path.join(artifactDir, 'pipeline_report.json');
|
|
54
|
+
if (!fs.existsSync(reportPath)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
59
|
+
const stageStatus = { ...(report.stage_status ?? {}) };
|
|
60
|
+
for (const [stage, status] of Object.entries(stageStatus)) {
|
|
61
|
+
if (status === 'running') {
|
|
62
|
+
stageStatus[stage] = 'failed';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
report.stage_status = stageStatus;
|
|
66
|
+
report.run_status = 'stopped';
|
|
67
|
+
report.error = report.error || 'Stopped by user';
|
|
68
|
+
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Best-effort cleanup so stopping a job never fails because its report is corrupt.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
48
74
|
export async function startDetachedRun(command, options = {}) {
|
|
49
75
|
const outputFormat = command.outputFormat ?? 'jsonl';
|
|
50
76
|
const jobStore = createJobStore(command.projectRoot, options);
|
|
@@ -59,6 +85,8 @@ export async function startDetachedRun(command, options = {}) {
|
|
|
59
85
|
resume_latest: Boolean(command.resumeLatest),
|
|
60
86
|
skip_deploy: Boolean(command.skipDeploy),
|
|
61
87
|
skip_verify: Boolean(command.skipVerify),
|
|
88
|
+
use_proxy: Boolean(command.useProxy),
|
|
89
|
+
proxy_url: command.proxyUrl ?? '',
|
|
62
90
|
output_format: outputFormat
|
|
63
91
|
}
|
|
64
92
|
});
|
|
@@ -66,6 +94,12 @@ export async function startDetachedRun(command, options = {}) {
|
|
|
66
94
|
pushFlag(runArgs, command.resumeLatest, '--resume-latest');
|
|
67
95
|
pushFlag(runArgs, command.skipDeploy, '--skip-deploy');
|
|
68
96
|
pushFlag(runArgs, command.skipVerify, '--skip-verify');
|
|
97
|
+
if (command.useProxy) {
|
|
98
|
+
runArgs.push('--proxy');
|
|
99
|
+
if (command.proxyUrl) {
|
|
100
|
+
runArgs.push(command.proxyUrl);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
69
103
|
job.command = [resolved.command, ...resolved.args, ...runArgs];
|
|
70
104
|
const child = spawnDetached(resolved.command, [...resolved.args, ...runArgs], job, options);
|
|
71
105
|
job.pid = Number(child.pid ?? 0);
|
|
@@ -148,6 +182,7 @@ export async function stopManagedJob(projectRoot, jobId, options = {}) {
|
|
|
148
182
|
throw new Error(`refusing to stop pid ${pid}: command does not match AutoVPN job`);
|
|
149
183
|
}
|
|
150
184
|
await terminateProcessGroup(pid, options);
|
|
185
|
+
markArtifactStopped(job);
|
|
151
186
|
job.status = 'stopped';
|
|
152
187
|
job.finished_at = options.now?.() ?? new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
|
|
153
188
|
job.exit_code = 1;
|
package/dist/jobs/process.js
CHANGED
|
@@ -37,6 +37,9 @@ function defaultSignalProcess(target, signal, options) {
|
|
|
37
37
|
}
|
|
38
38
|
process.kill(target, signal);
|
|
39
39
|
}
|
|
40
|
+
function isNoSuchProcess(error) {
|
|
41
|
+
return error?.code === 'ESRCH';
|
|
42
|
+
}
|
|
40
43
|
function defaultSleep(ms) {
|
|
41
44
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
45
|
}
|
|
@@ -54,22 +57,37 @@ export async function terminateProcessGroup(pid, options = {}) {
|
|
|
54
57
|
const target = signalTarget(pid, platform);
|
|
55
58
|
if (!isAlive(pid))
|
|
56
59
|
return;
|
|
57
|
-
|
|
58
|
-
signalProcess
|
|
60
|
+
try {
|
|
61
|
+
if (signalProcess) {
|
|
62
|
+
signalProcess(target, 'SIGTERM');
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
defaultSignalProcess(target, 'SIGTERM', options);
|
|
66
|
+
}
|
|
59
67
|
}
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (isNoSuchProcess(error)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
62
73
|
}
|
|
63
74
|
const deadline = Date.now() + timeoutMs;
|
|
64
75
|
while (Date.now() < deadline && isAlive(pid)) {
|
|
65
76
|
await sleep(100);
|
|
66
77
|
}
|
|
67
78
|
if (isAlive(pid)) {
|
|
68
|
-
|
|
69
|
-
signalProcess
|
|
79
|
+
try {
|
|
80
|
+
if (signalProcess) {
|
|
81
|
+
signalProcess(target, 'SIGKILL');
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
defaultSignalProcess(target, 'SIGKILL', options);
|
|
85
|
+
}
|
|
70
86
|
}
|
|
71
|
-
|
|
72
|
-
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (!isNoSuchProcess(error)) {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
73
91
|
}
|
|
74
92
|
}
|
|
75
93
|
}
|
package/dist/jobs/read.js
CHANGED
|
@@ -55,6 +55,10 @@ function reconcileFromPipelineReport(job) {
|
|
|
55
55
|
if (!['success', 'failed', 'stopped'].includes(runStatus)) {
|
|
56
56
|
return undefined;
|
|
57
57
|
}
|
|
58
|
+
const stageStatus = (report.stage_status ?? {});
|
|
59
|
+
if (Object.values(stageStatus).some((status) => String(status) === 'running')) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
58
62
|
return {
|
|
59
63
|
status: runStatus,
|
|
60
64
|
finished_at: job.finished_at || nowIso(),
|
package/dist/pipeline/extract.js
CHANGED
|
@@ -142,10 +142,121 @@ async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
|
|
|
142
142
|
clearTimeout(timer);
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
+
function isEnabled(value) {
|
|
146
|
+
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
|
|
147
|
+
}
|
|
148
|
+
function resolveUpstreamProxyUrl(env) {
|
|
149
|
+
if (!isEnabled(env.VPN_AUTOMATION_USE_UPSTREAM_PROXY)) {
|
|
150
|
+
return '';
|
|
151
|
+
}
|
|
152
|
+
const value = String(env.VPN_AUTOMATION_UPSTREAM_PROXY ?? 'http://127.0.0.1:7897').trim();
|
|
153
|
+
return ['', 'off', 'none', 'false', '0'].includes(value.toLowerCase()) ? '' : value;
|
|
154
|
+
}
|
|
155
|
+
function isTlsFailure(error) {
|
|
156
|
+
const text = error instanceof Error
|
|
157
|
+
? `${error.name}: ${error.message}`.toLowerCase()
|
|
158
|
+
: String(error).toLowerCase();
|
|
159
|
+
if (text.includes('ssl') || text.includes('tls') || text.includes('certificate')) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
163
|
+
if (cause) {
|
|
164
|
+
return isTlsFailure(cause);
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
function defaultCurlFetch(url, proxyUrl, spawn = defaultSpawn) {
|
|
169
|
+
const args = [
|
|
170
|
+
'--fail',
|
|
171
|
+
'--silent',
|
|
172
|
+
'--show-error',
|
|
173
|
+
'--location',
|
|
174
|
+
'--max-time',
|
|
175
|
+
'20',
|
|
176
|
+
'--connect-timeout',
|
|
177
|
+
'10',
|
|
178
|
+
'--insecure',
|
|
179
|
+
'--http1.1',
|
|
180
|
+
'--config',
|
|
181
|
+
'-'
|
|
182
|
+
];
|
|
183
|
+
if (proxyUrl) {
|
|
184
|
+
args.push('--proxy', proxyUrl);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
args.push('--noproxy', '*');
|
|
188
|
+
}
|
|
189
|
+
const child = spawn('curl', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
190
|
+
let stdout = '';
|
|
191
|
+
let stderr = '';
|
|
192
|
+
child.stdout?.on('data', (chunk) => {
|
|
193
|
+
stdout += String(chunk);
|
|
194
|
+
});
|
|
195
|
+
child.stderr?.on('data', (chunk) => {
|
|
196
|
+
stderr += String(chunk);
|
|
197
|
+
});
|
|
198
|
+
const completion = new Promise((resolve, reject) => {
|
|
199
|
+
child.on('error', reject);
|
|
200
|
+
child.on('close', (code) => {
|
|
201
|
+
if (code === 0) {
|
|
202
|
+
resolve(stdout);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
reject(new Error((stderr || stdout || 'curl TLS fallback failed').trim()));
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
child.stdin?.write(`url = ${JSON.stringify(url)}\n`);
|
|
209
|
+
child.stdin?.end();
|
|
210
|
+
return completion;
|
|
211
|
+
}
|
|
212
|
+
function emitExtractEvent(options, type, payload) {
|
|
213
|
+
options.eventCallback?.(type, payload);
|
|
214
|
+
}
|
|
215
|
+
async function fetchSourceText(input, url, attempt, fetchImpl, options, upstreamProxy) {
|
|
216
|
+
try {
|
|
217
|
+
const response = await fetchWithTimeout(fetchImpl, url, 20_000);
|
|
218
|
+
if (response.ok === false || Number(response.status ?? 200) >= 400) {
|
|
219
|
+
throw new Error(`HTTP ${Number(response.status ?? 0)}`);
|
|
220
|
+
}
|
|
221
|
+
const text = await response.text();
|
|
222
|
+
emitExtractEvent(options, 'extract_request_result', {
|
|
223
|
+
source_name: input.source_name,
|
|
224
|
+
iteration: attempt,
|
|
225
|
+
success: true,
|
|
226
|
+
via: 'direct'
|
|
227
|
+
});
|
|
228
|
+
return { text, via: 'direct' };
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
const shouldRetry = Boolean(upstreamProxy) || isTlsFailure(error);
|
|
232
|
+
emitExtractEvent(options, 'extract_request_result', {
|
|
233
|
+
source_name: input.source_name,
|
|
234
|
+
iteration: attempt,
|
|
235
|
+
success: false,
|
|
236
|
+
via: 'direct',
|
|
237
|
+
error: error instanceof Error ? `${error.constructor.name}: ${error.message}` : String(error),
|
|
238
|
+
will_retry: shouldRetry
|
|
239
|
+
});
|
|
240
|
+
if (!shouldRetry) {
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
const curlFetch = options.curlFetch ?? ((targetUrl, proxyUrl) => defaultCurlFetch(targetUrl, proxyUrl, options.spawn));
|
|
244
|
+
const text = await curlFetch(url, upstreamProxy);
|
|
245
|
+
const via = upstreamProxy ? 'upstream_proxy_curl_tls_fallback' : 'direct_curl_tls_fallback';
|
|
246
|
+
emitExtractEvent(options, 'extract_request_result', {
|
|
247
|
+
source_name: input.source_name,
|
|
248
|
+
iteration: attempt,
|
|
249
|
+
success: true,
|
|
250
|
+
via
|
|
251
|
+
});
|
|
252
|
+
return { text, via };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
145
255
|
async function fetchSourceLinksInNode(input, options) {
|
|
146
256
|
const source = input.source;
|
|
147
257
|
const maxIterations = Math.max(0, Math.trunc(numberOrDefault(source.max_iterations, 0)));
|
|
148
|
-
const
|
|
258
|
+
const configuredMinIterations = Math.max(0, Math.trunc(numberOrDefault(source.min_iterations, 0)));
|
|
259
|
+
const minIterations = configuredMinIterations > maxIterations ? 0 : configuredMinIterations;
|
|
149
260
|
const plateauLimit = Math.max(1, Math.trunc(numberOrDefault(source.plateau_limit, 1)));
|
|
150
261
|
const failureLimit = Math.max(1, Math.trunc(numberOrDefault(source.failure_limit, 1)));
|
|
151
262
|
const maxRuntimeSeconds = Math.max(0, numberOrDefault(source.max_runtime_seconds, 0));
|
|
@@ -169,6 +280,13 @@ async function fetchSourceLinksInNode(input, options) {
|
|
|
169
280
|
let successes = 0;
|
|
170
281
|
let failures = 0;
|
|
171
282
|
const startedAt = Date.now();
|
|
283
|
+
const upstreamProxy = resolveUpstreamProxyUrl(options.env ?? process.env);
|
|
284
|
+
emitExtractEvent(options, 'extract_source_started', {
|
|
285
|
+
source_name: input.source_name,
|
|
286
|
+
requested_iterations: maxIterations,
|
|
287
|
+
min_iterations: minIterations,
|
|
288
|
+
resume_from_iteration: startIteration
|
|
289
|
+
});
|
|
172
290
|
for (let iteration = startIteration - 1; iteration < maxIterations; iteration += 1) {
|
|
173
291
|
const attempt = iteration + 1;
|
|
174
292
|
if (maxRuntimeSeconds > 0 && attempt > minIterations && (Date.now() - startedAt) / 1000 >= maxRuntimeSeconds) {
|
|
@@ -176,11 +294,25 @@ async function fetchSourceLinksInNode(input, options) {
|
|
|
176
294
|
}
|
|
177
295
|
try {
|
|
178
296
|
const url = buildRuntimeSourceUrl(source, iteration);
|
|
179
|
-
const response = await
|
|
180
|
-
|
|
181
|
-
|
|
297
|
+
const response = await fetchSourceText(input, url, attempt, fetchImpl, options, upstreamProxy);
|
|
298
|
+
let plaintext = '';
|
|
299
|
+
try {
|
|
300
|
+
plaintext = decryptPayload(response.text.trim(), source.key);
|
|
301
|
+
emitExtractEvent(options, 'extract_decrypt_result', {
|
|
302
|
+
source_name: input.source_name,
|
|
303
|
+
iteration: attempt,
|
|
304
|
+
success: true
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch (decryptError) {
|
|
308
|
+
emitExtractEvent(options, 'extract_decrypt_result', {
|
|
309
|
+
source_name: input.source_name,
|
|
310
|
+
iteration: attempt,
|
|
311
|
+
success: false,
|
|
312
|
+
error: decryptError instanceof Error ? `${decryptError.constructor.name}: ${decryptError.message}` : String(decryptError)
|
|
313
|
+
});
|
|
314
|
+
throw decryptError;
|
|
182
315
|
}
|
|
183
|
-
const plaintext = decryptPayload((await response.text()).trim(), source.key);
|
|
184
316
|
const extracted = extractLinksFromPlaintext(input.source_name, plaintext);
|
|
185
317
|
successes += 1;
|
|
186
318
|
failures = 0;
|
|
@@ -193,6 +325,14 @@ async function fetchSourceLinksInNode(input, options) {
|
|
|
193
325
|
links.push(link);
|
|
194
326
|
newItems += 1;
|
|
195
327
|
}
|
|
328
|
+
emitExtractEvent(options, 'extract_iteration', {
|
|
329
|
+
source_name: input.source_name,
|
|
330
|
+
iteration: attempt,
|
|
331
|
+
requested_iterations: maxIterations,
|
|
332
|
+
new_items: newItems,
|
|
333
|
+
extracted_links: extracted.length,
|
|
334
|
+
total_links: links.length
|
|
335
|
+
});
|
|
196
336
|
plateau = newItems === 0 ? plateau + 1 : 0;
|
|
197
337
|
if (plateau >= plateauLimit && attempt >= minIterations) {
|
|
198
338
|
break;
|
|
@@ -200,7 +340,7 @@ async function fetchSourceLinksInNode(input, options) {
|
|
|
200
340
|
}
|
|
201
341
|
catch (error) {
|
|
202
342
|
failures += 1;
|
|
203
|
-
if (failures >= failureLimit
|
|
343
|
+
if (failures >= failureLimit) {
|
|
204
344
|
break;
|
|
205
345
|
}
|
|
206
346
|
if (attempt >= maxIterations) {
|
|
@@ -208,6 +348,13 @@ async function fetchSourceLinksInNode(input, options) {
|
|
|
208
348
|
}
|
|
209
349
|
}
|
|
210
350
|
}
|
|
351
|
+
emitExtractEvent(options, 'extract_source_completed', {
|
|
352
|
+
source_name: input.source_name,
|
|
353
|
+
requested_iterations: maxIterations,
|
|
354
|
+
successful_iterations: successes,
|
|
355
|
+
failed_iterations: failures,
|
|
356
|
+
raw_links: links.length
|
|
357
|
+
});
|
|
211
358
|
return {
|
|
212
359
|
source_name: input.source_name,
|
|
213
360
|
requested_iterations: maxIterations,
|
|
@@ -169,7 +169,7 @@ async function seedRetryArtifact(sourceArtifactDir, retryArtifactDir, stage, ret
|
|
|
169
169
|
source_counts: { ...(sourceReport.source_counts ?? {}) },
|
|
170
170
|
deployment: { ...(sourceReport.deployment ?? {}) },
|
|
171
171
|
retry_context: retryContext,
|
|
172
|
-
run_status: '
|
|
172
|
+
run_status: 'running',
|
|
173
173
|
error: ''
|
|
174
174
|
};
|
|
175
175
|
await copyIfExists(path.join(sourceArtifactDir, 'vpn_node_raw.txt'), path.join(retryArtifactDir, 'vpn_node_raw.txt'));
|
|
@@ -205,7 +205,9 @@ function pipelineSummaryFromReport(artifactDir, report) {
|
|
|
205
205
|
source_counts: { ...(report.source_counts ?? {}) },
|
|
206
206
|
deployment: { ...(report.deployment ?? {}) },
|
|
207
207
|
retry_context: { ...(report.retry_context ?? {}) },
|
|
208
|
-
run_status:
|
|
208
|
+
run_status: ['running', 'success', 'failed', 'stopped'].includes(String(report.run_status ?? ''))
|
|
209
|
+
? String(report.run_status)
|
|
210
|
+
: 'running',
|
|
209
211
|
error: String(report.error ?? '')
|
|
210
212
|
};
|
|
211
213
|
}
|
|
@@ -328,7 +330,7 @@ export async function runNodePipeline(options, context = {}) {
|
|
|
328
330
|
source_counts: {},
|
|
329
331
|
deployment: {},
|
|
330
332
|
retry_context: {},
|
|
331
|
-
run_status: '
|
|
333
|
+
run_status: 'running',
|
|
332
334
|
error: ''
|
|
333
335
|
};
|
|
334
336
|
const emit = (type, payload = {}) => {
|
|
@@ -356,19 +358,26 @@ export async function runNodePipeline(options, context = {}) {
|
|
|
356
358
|
await setStage('doctor', 'success');
|
|
357
359
|
const profile = await readProfile(projectRoot, env);
|
|
358
360
|
await setStage('extract', 'running');
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
+
const sourcesToRun = enabledSources(profile);
|
|
362
|
+
const extractResults = await Promise.all(sourcesToRun.map(async ([sourceName, source]) => {
|
|
361
363
|
const result = context.stages?.extract
|
|
362
364
|
? await context.stages.extract({ source_name: sourceName, source })
|
|
363
|
-
: await fetchSourceLinksWithBackend({ source_name: sourceName, source }, {
|
|
364
|
-
|
|
365
|
+
: await fetchSourceLinksWithBackend({ source_name: sourceName, source }, {
|
|
366
|
+
cwd: projectRoot,
|
|
367
|
+
env: runtimeStageEnv,
|
|
368
|
+
eventCallback: (type, payload) => emit(type, payload)
|
|
369
|
+
});
|
|
365
370
|
summary.source_counts[result.source_name] = {
|
|
366
371
|
raw_links: result.links.length,
|
|
367
372
|
successful_iterations: result.successful_iterations,
|
|
368
373
|
failed_iterations: result.failed_iterations
|
|
369
374
|
};
|
|
370
|
-
|
|
375
|
+
return result;
|
|
376
|
+
}));
|
|
371
377
|
const rawLinks = extractResults.flatMap((result) => result.links);
|
|
378
|
+
if (sourcesToRun.length > 0 && rawLinks.length === 0 && extractResults.some((result) => result.requested_iterations > 0 || result.failed_iterations > 0)) {
|
|
379
|
+
throw new Error('No links extracted from configured sources');
|
|
380
|
+
}
|
|
372
381
|
summary.counts.raw_links = rawLinks.length;
|
|
373
382
|
await writeLines(artifactDir, 'vpn_node_raw.txt', rawLinks);
|
|
374
383
|
await setStage('extract', 'success');
|
|
@@ -478,6 +487,7 @@ export async function runNodePipeline(options, context = {}) {
|
|
|
478
487
|
emit('run_failed', { error: summary.error });
|
|
479
488
|
throw error;
|
|
480
489
|
}
|
|
490
|
+
summary.run_status = 'success';
|
|
481
491
|
await writeReport();
|
|
482
492
|
emit('summary', summary);
|
|
483
493
|
return summary;
|
package/dist/server/http.js
CHANGED
|
@@ -1,43 +1,38 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath, URL } from 'node:url';
|
|
5
6
|
import QRCode from 'qrcode';
|
|
6
7
|
import { renderWebAdapterScript } from './web-adapter.js';
|
|
7
8
|
import { redactText } from '../runtime/redaction.js';
|
|
8
|
-
const
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
12
|
-
'cloudflare_api_token',
|
|
13
|
-
'cloudflare_global_key',
|
|
14
|
-
'subscription_url',
|
|
15
|
-
'verify_subscription_url',
|
|
16
|
-
'secret_query',
|
|
17
|
-
'pages_secret_admin',
|
|
18
|
-
'share_project_sub_value'
|
|
9
|
+
const LABELED_SECRET_KEYS = new Map([
|
|
10
|
+
['cloudflare_api_token', '<Cloudflare Token>'],
|
|
11
|
+
['cloudflare_global_key', '<Cloudflare Token>'],
|
|
12
|
+
['pages_secret_admin', '<Pages Secret ADMIN>']
|
|
19
13
|
]);
|
|
20
|
-
function redactPayload(value, parentKey = '') {
|
|
14
|
+
function redactPayload(value, parentKey = '', mode = 'full') {
|
|
21
15
|
if (typeof value === 'string') {
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
const label = LABELED_SECRET_KEYS.get(parentKey.toLowerCase());
|
|
17
|
+
if (label) {
|
|
18
|
+
return value ? label : '';
|
|
24
19
|
}
|
|
25
|
-
return redactText(value);
|
|
20
|
+
return mode === 'config' ? value : redactText(value);
|
|
26
21
|
}
|
|
27
22
|
if (value === null || ['number', 'boolean'].includes(typeof value)) {
|
|
28
23
|
return value;
|
|
29
24
|
}
|
|
30
25
|
if (Array.isArray(value)) {
|
|
31
|
-
return value.map((item) => redactPayload(item, parentKey));
|
|
26
|
+
return value.map((item) => redactPayload(item, parentKey, mode));
|
|
32
27
|
}
|
|
33
28
|
if (typeof value === 'object') {
|
|
34
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactPayload(item, key)]));
|
|
29
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactPayload(item, key, mode)]));
|
|
35
30
|
}
|
|
36
31
|
return null;
|
|
37
32
|
}
|
|
38
|
-
function writeJson(response, statusCode, payload) {
|
|
33
|
+
function writeJson(response, statusCode, payload, mode = 'full') {
|
|
39
34
|
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
40
|
-
response.end(JSON.stringify(redactPayload(payload)));
|
|
35
|
+
response.end(JSON.stringify(redactPayload(payload, '', mode)));
|
|
41
36
|
}
|
|
42
37
|
function contentType(filePath) {
|
|
43
38
|
if (filePath.endsWith('.html'))
|
|
@@ -56,8 +51,9 @@ function rendererRoot() {
|
|
|
56
51
|
return path.resolve(fileURLToPath(new URL('../web/renderer', import.meta.url)));
|
|
57
52
|
}
|
|
58
53
|
async function writeStaticFile(response, statusCode, filePath, body) {
|
|
54
|
+
const payload = body ?? await fs.readFile(filePath);
|
|
59
55
|
response.writeHead(statusCode, { 'Content-Type': contentType(filePath) });
|
|
60
|
-
response.end(
|
|
56
|
+
response.end(payload);
|
|
61
57
|
}
|
|
62
58
|
async function serveRendererIndex(response) {
|
|
63
59
|
const indexPath = path.join(rendererRoot(), 'index.html');
|
|
@@ -72,10 +68,6 @@ async function serveRendererAsset(url, response) {
|
|
|
72
68
|
await serveRendererIndex(response);
|
|
73
69
|
return true;
|
|
74
70
|
}
|
|
75
|
-
if (url.pathname === '/web-adapter.js') {
|
|
76
|
-
await writeStaticFile(response, 200, 'web-adapter.js', renderWebAdapterScript());
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
71
|
const decodedPath = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
|
|
80
72
|
if (!decodedPath || decodedPath.includes('..') || path.isAbsolute(decodedPath)) {
|
|
81
73
|
return false;
|
|
@@ -93,15 +85,23 @@ async function serveRendererAsset(url, response) {
|
|
|
93
85
|
return false;
|
|
94
86
|
}
|
|
95
87
|
}
|
|
88
|
+
function timingSafeEqualText(left, right) {
|
|
89
|
+
const leftBuffer = Buffer.from(left);
|
|
90
|
+
const rightBuffer = Buffer.from(right);
|
|
91
|
+
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
|
92
|
+
}
|
|
96
93
|
function isAuthorized(request, url, auth) {
|
|
97
94
|
if (!auth.enabled) {
|
|
98
95
|
return true;
|
|
99
96
|
}
|
|
100
97
|
const authorization = request.headers.authorization ?? '';
|
|
101
|
-
if (authorization
|
|
98
|
+
if (authorization.startsWith('Bearer ') && timingSafeEqualText(authorization.slice(7), auth.token)) {
|
|
102
99
|
return true;
|
|
103
100
|
}
|
|
104
|
-
return url.searchParams.get('token')
|
|
101
|
+
return timingSafeEqualText(url.searchParams.get('token') ?? '', auth.token);
|
|
102
|
+
}
|
|
103
|
+
function clientIp(request) {
|
|
104
|
+
return request.socket.remoteAddress || 'unknown';
|
|
105
105
|
}
|
|
106
106
|
async function readJsonBody(request) {
|
|
107
107
|
const chunks = [];
|
|
@@ -122,13 +122,47 @@ async function readJsonBody(request) {
|
|
|
122
122
|
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
123
123
|
}
|
|
124
124
|
export async function createAutoVpnServer(options) {
|
|
125
|
+
const authFailures = new Map();
|
|
125
126
|
const server = http.createServer(async (request, response) => {
|
|
126
127
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `${options.host}:${options.port}`}`);
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
const ip = clientIp(request);
|
|
129
|
+
const authState = authFailures.get(ip);
|
|
130
|
+
if (authState?.banned) {
|
|
131
|
+
writeJson(response, 403, { ok: false, error: 'ip_banned' });
|
|
129
132
|
return;
|
|
130
133
|
}
|
|
131
134
|
try {
|
|
135
|
+
if (request.method === 'POST' && url.pathname === '/api/auth/login') {
|
|
136
|
+
const password = String((await readJsonBody(request)).password ?? '');
|
|
137
|
+
if (!options.auth.enabled || !options.auth.password || timingSafeEqualText(password, options.auth.password)) {
|
|
138
|
+
authFailures.delete(ip);
|
|
139
|
+
writeJson(response, 200, { ok: true, token: options.auth.token });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const nextFailures = (authState?.failures ?? 0) + 1;
|
|
143
|
+
if (nextFailures >= options.auth.maxAttempts) {
|
|
144
|
+
authFailures.set(ip, { failures: nextFailures, banned: true });
|
|
145
|
+
writeJson(response, 403, { ok: false, error: 'ip_banned' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
authFailures.set(ip, { failures: nextFailures, banned: false });
|
|
149
|
+
writeJson(response, 401, {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: 'invalid_password',
|
|
152
|
+
attemptsRemaining: options.auth.maxAttempts - nextFailures
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (request.method === 'GET' && url.pathname === '/web-adapter.js') {
|
|
157
|
+
await writeStaticFile(response, 200, 'web-adapter.js', renderWebAdapterScript({
|
|
158
|
+
passwordEnabled: Boolean(options.auth.enabled && options.auth.password)
|
|
159
|
+
}));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (url.pathname.startsWith('/api/') && !isAuthorized(request, url, options.auth)) {
|
|
163
|
+
writeJson(response, 401, { ok: false, error: 'unauthorized' });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
132
166
|
if (request.method === 'GET' && url.pathname === '/api/health') {
|
|
133
167
|
writeJson(response, 200, {
|
|
134
168
|
status: 'ok',
|
|
@@ -139,12 +173,12 @@ export async function createAutoVpnServer(options) {
|
|
|
139
173
|
return;
|
|
140
174
|
}
|
|
141
175
|
if (request.method === 'GET' && url.pathname === '/api/state') {
|
|
142
|
-
writeJson(response, 200, await options.runtime.loadState());
|
|
176
|
+
writeJson(response, 200, await options.runtime.loadState(), 'config');
|
|
143
177
|
return;
|
|
144
178
|
}
|
|
145
179
|
if (request.method === 'POST' && url.pathname === '/api/profile') {
|
|
146
180
|
const body = await readJsonBody(request);
|
|
147
|
-
writeJson(response, 200, await options.runtime.saveProfile?.(body) ?? { ok: false, error: 'profile_save_unavailable' });
|
|
181
|
+
writeJson(response, 200, await options.runtime.saveProfile?.(body) ?? { ok: false, error: 'profile_save_unavailable' }, 'config');
|
|
148
182
|
return;
|
|
149
183
|
}
|
|
150
184
|
if (request.method === 'POST' && url.pathname === '/api/qr') {
|
package/dist/server/options.js
CHANGED
|
@@ -11,6 +11,23 @@ function isLoopbackHost(host) {
|
|
|
11
11
|
function defaultRandomToken() {
|
|
12
12
|
return crypto.randomBytes(18).toString('base64url');
|
|
13
13
|
}
|
|
14
|
+
function defaultRandomPassword() {
|
|
15
|
+
return crypto.randomBytes(9).toString('base64url');
|
|
16
|
+
}
|
|
17
|
+
const DEFAULT_PROXY_URL = 'http://127.0.0.1:7897';
|
|
18
|
+
function optionalFlagValue(argv, flag) {
|
|
19
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
20
|
+
const value = argv[index];
|
|
21
|
+
if (value.startsWith(`${flag}=`)) {
|
|
22
|
+
return value.slice(flag.length + 1);
|
|
23
|
+
}
|
|
24
|
+
if (value === flag) {
|
|
25
|
+
const next = argv[index + 1] ?? '';
|
|
26
|
+
return next && !next.startsWith('--') ? next : '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
14
31
|
export function parseServeOptions(argv, context) {
|
|
15
32
|
const host = readOptionValue(argv, '--host') ?? context.env.AUTOVPN_SERVER_HOST ?? '127.0.0.1';
|
|
16
33
|
const portText = readOptionValue(argv, '--port') ?? context.env.AUTOVPN_SERVER_PORT ?? '8765';
|
|
@@ -19,16 +36,33 @@ export function parseServeOptions(argv, context) {
|
|
|
19
36
|
throw new CliUsageError('serve --port must be an integer from 1 to 65535');
|
|
20
37
|
}
|
|
21
38
|
const token = readOptionValue(argv, '--token') ?? context.env.AUTOVPN_SERVER_TOKEN ?? '';
|
|
39
|
+
const password = readOptionValue(argv, '--password')
|
|
40
|
+
?? context.env.AUTOVPN_SERVER_PASSWORD
|
|
41
|
+
?? (context.randomPassword ?? defaultRandomPassword)();
|
|
42
|
+
const maxAttemptsText = readOptionValue(argv, '--max-auth-attempts') ?? context.env.AUTOVPN_SERVER_MAX_AUTH_ATTEMPTS ?? '5';
|
|
43
|
+
const maxAttempts = Number(maxAttemptsText);
|
|
44
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 100) {
|
|
45
|
+
throw new CliUsageError('serve --max-auth-attempts must be an integer from 1 to 100');
|
|
46
|
+
}
|
|
22
47
|
const noAuth = hasFlag(argv, '--no-auth');
|
|
23
|
-
if (!isLoopbackHost(host) && !token && !noAuth) {
|
|
24
|
-
throw new CliUsageError('serve requires --token or --no-auth when binding to non-loopback host');
|
|
48
|
+
if (!isLoopbackHost(host) && !token && !password && !noAuth) {
|
|
49
|
+
throw new CliUsageError('serve requires --token, --password, or --no-auth when binding to non-loopback host');
|
|
25
50
|
}
|
|
26
51
|
return {
|
|
27
52
|
host,
|
|
28
53
|
port,
|
|
29
54
|
projectRoot: path.resolve(resolveProjectRoot(argv, context.cwd)),
|
|
55
|
+
proxy: {
|
|
56
|
+
enabled: hasFlag(argv, '--proxy') || argv.some((value) => value.startsWith('--proxy=')),
|
|
57
|
+
url: optionalFlagValue(argv, '--proxy') || DEFAULT_PROXY_URL
|
|
58
|
+
},
|
|
30
59
|
auth: noAuth
|
|
31
|
-
? { enabled: false, token: '' }
|
|
32
|
-
: {
|
|
60
|
+
? { enabled: false, token: '', password: '', maxAttempts }
|
|
61
|
+
: {
|
|
62
|
+
enabled: true,
|
|
63
|
+
token: token || (context.randomToken ?? defaultRandomToken)(),
|
|
64
|
+
password,
|
|
65
|
+
maxAttempts
|
|
66
|
+
}
|
|
33
67
|
};
|
|
34
68
|
}
|