@swimmingliu/autovpn 1.5.4 → 1.6.0
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 +67 -31
- package/dist/server/options.js +38 -4
- package/dist/server/runtime.js +78 -33
- package/dist/server/web-adapter.js +124 -6
- package/dist/web/renderer/app.js +109 -9
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/styles.css +8 -0
- package/dist/web/renderer/views.js +1 -0
- 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,13 +51,16 @@ 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');
|
|
64
60
|
const html = await fs.readFile(indexPath, 'utf8');
|
|
65
|
-
const injected = html
|
|
61
|
+
const injected = html
|
|
62
|
+
.replace('<body data-page="dashboard">', '<body data-page="dashboard" data-runtime="web">')
|
|
63
|
+
.replace('<script type="module" src="./app.js"></script>', '<script src="/web-adapter.js"></script>\n <script type="module" src="./app.js"></script>');
|
|
66
64
|
await writeStaticFile(response, 200, indexPath, injected);
|
|
67
65
|
}
|
|
68
66
|
async function serveRendererAsset(url, response) {
|
|
@@ -70,10 +68,6 @@ async function serveRendererAsset(url, response) {
|
|
|
70
68
|
await serveRendererIndex(response);
|
|
71
69
|
return true;
|
|
72
70
|
}
|
|
73
|
-
if (url.pathname === '/web-adapter.js') {
|
|
74
|
-
await writeStaticFile(response, 200, 'web-adapter.js', renderWebAdapterScript());
|
|
75
|
-
return true;
|
|
76
|
-
}
|
|
77
71
|
const decodedPath = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
|
|
78
72
|
if (!decodedPath || decodedPath.includes('..') || path.isAbsolute(decodedPath)) {
|
|
79
73
|
return false;
|
|
@@ -91,15 +85,23 @@ async function serveRendererAsset(url, response) {
|
|
|
91
85
|
return false;
|
|
92
86
|
}
|
|
93
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
|
+
}
|
|
94
93
|
function isAuthorized(request, url, auth) {
|
|
95
94
|
if (!auth.enabled) {
|
|
96
95
|
return true;
|
|
97
96
|
}
|
|
98
97
|
const authorization = request.headers.authorization ?? '';
|
|
99
|
-
if (authorization
|
|
98
|
+
if (authorization.startsWith('Bearer ') && timingSafeEqualText(authorization.slice(7), auth.token)) {
|
|
100
99
|
return true;
|
|
101
100
|
}
|
|
102
|
-
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';
|
|
103
105
|
}
|
|
104
106
|
async function readJsonBody(request) {
|
|
105
107
|
const chunks = [];
|
|
@@ -120,13 +122,47 @@ async function readJsonBody(request) {
|
|
|
120
122
|
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
121
123
|
}
|
|
122
124
|
export async function createAutoVpnServer(options) {
|
|
125
|
+
const authFailures = new Map();
|
|
123
126
|
const server = http.createServer(async (request, response) => {
|
|
124
127
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `${options.host}:${options.port}`}`);
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
const ip = clientIp(request);
|
|
129
|
+
const authState = authFailures.get(ip);
|
|
130
|
+
if (authState?.banned) {
|
|
131
|
+
writeJson(response, 403, { ok: false, error: 'ip_banned' });
|
|
127
132
|
return;
|
|
128
133
|
}
|
|
129
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
|
+
}
|
|
130
166
|
if (request.method === 'GET' && url.pathname === '/api/health') {
|
|
131
167
|
writeJson(response, 200, {
|
|
132
168
|
status: 'ok',
|
|
@@ -137,12 +173,12 @@ export async function createAutoVpnServer(options) {
|
|
|
137
173
|
return;
|
|
138
174
|
}
|
|
139
175
|
if (request.method === 'GET' && url.pathname === '/api/state') {
|
|
140
|
-
writeJson(response, 200, await options.runtime.loadState());
|
|
176
|
+
writeJson(response, 200, await options.runtime.loadState(), 'config');
|
|
141
177
|
return;
|
|
142
178
|
}
|
|
143
179
|
if (request.method === 'POST' && url.pathname === '/api/profile') {
|
|
144
180
|
const body = await readJsonBody(request);
|
|
145
|
-
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');
|
|
146
182
|
return;
|
|
147
183
|
}
|
|
148
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
|
}
|
package/dist/server/runtime.js
CHANGED
|
@@ -6,26 +6,19 @@ import { followLog as defaultFollowLog } from '../jobs/logs.js';
|
|
|
6
6
|
const DEPLOY_SECRET_KEYS = new Set([
|
|
7
7
|
'cloudflare_api_token',
|
|
8
8
|
'cloudflare_global_key',
|
|
9
|
-
'pages_secret_admin'
|
|
10
|
-
'subscription_url',
|
|
11
|
-
'verify_subscription_url',
|
|
12
|
-
'secret_query',
|
|
13
|
-
'share_project_sub_value'
|
|
9
|
+
'pages_secret_admin'
|
|
14
10
|
]);
|
|
15
|
-
function
|
|
16
|
-
return
|
|
11
|
+
function redactedLabel(key) {
|
|
12
|
+
return key === 'pages_secret_admin' ? '<Pages Secret ADMIN>' : '<Cloudflare Token>';
|
|
13
|
+
}
|
|
14
|
+
function redactIfSet(key, value) {
|
|
15
|
+
return String(value ?? '').trim() ? redactedLabel(key) : '';
|
|
17
16
|
}
|
|
18
17
|
export function sanitizeProfileForServer(profile) {
|
|
19
18
|
const safe = structuredClone(profile ?? {});
|
|
20
|
-
for (const source of Object.values((safe.sources ?? {}))) {
|
|
21
|
-
if (source && typeof source === 'object') {
|
|
22
|
-
source.url = redactIfSet(source.url);
|
|
23
|
-
source.key = redactIfSet(source.key);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
19
|
for (const [key, value] of Object.entries((safe.deploy ?? {}))) {
|
|
27
20
|
if (DEPLOY_SECRET_KEYS.has(key)) {
|
|
28
|
-
safe.deploy[key] = redactIfSet(value);
|
|
21
|
+
safe.deploy[key] = redactIfSet(key, value);
|
|
29
22
|
}
|
|
30
23
|
}
|
|
31
24
|
return safe;
|
|
@@ -46,7 +39,7 @@ function preserveRedactedSecrets(incoming, current) {
|
|
|
46
39
|
const deploy = (merged.deploy ?? {});
|
|
47
40
|
const currentDeploy = (current.deploy ?? {});
|
|
48
41
|
for (const key of DEPLOY_SECRET_KEYS) {
|
|
49
|
-
if (deploy[key] === '<redacted>') {
|
|
42
|
+
if (deploy[key] === '<redacted>' || deploy[key] === redactedLabel(key)) {
|
|
50
43
|
deploy[key] = currentDeploy[key] ?? '';
|
|
51
44
|
}
|
|
52
45
|
}
|
|
@@ -77,6 +70,35 @@ function parseJsonLine(line) {
|
|
|
77
70
|
return { type: 'log', message: trimmed };
|
|
78
71
|
}
|
|
79
72
|
}
|
|
73
|
+
function eventRunState(event) {
|
|
74
|
+
if (event.type === 'run_failed') {
|
|
75
|
+
return 'failed';
|
|
76
|
+
}
|
|
77
|
+
if (event.type === 'summary') {
|
|
78
|
+
const status = String(event.run_status ?? '');
|
|
79
|
+
if (status === 'failed') {
|
|
80
|
+
return 'failed';
|
|
81
|
+
}
|
|
82
|
+
if (status === 'success') {
|
|
83
|
+
return 'success';
|
|
84
|
+
}
|
|
85
|
+
if (status === 'stopped') {
|
|
86
|
+
return 'idle';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
function runEnv(options) {
|
|
92
|
+
if (!options.proxy?.enabled) {
|
|
93
|
+
return options.env;
|
|
94
|
+
}
|
|
95
|
+
const proxyUrl = String(options.proxy.url ?? '').trim() || 'http://127.0.0.1:7897';
|
|
96
|
+
return {
|
|
97
|
+
...(options.env ?? process.env),
|
|
98
|
+
VPN_AUTOMATION_USE_UPSTREAM_PROXY: '1',
|
|
99
|
+
VPN_AUTOMATION_UPSTREAM_PROXY: proxyUrl
|
|
100
|
+
};
|
|
101
|
+
}
|
|
80
102
|
export function createServerRuntime(options) {
|
|
81
103
|
let runState = 'idle';
|
|
82
104
|
let activeJobId = '';
|
|
@@ -106,11 +128,15 @@ export function createServerRuntime(options) {
|
|
|
106
128
|
for (const line of String(chunk).split(/\r?\n/)) {
|
|
107
129
|
const event = parseJsonLine(line);
|
|
108
130
|
if (event) {
|
|
131
|
+
const nextRunState = eventRunState(event);
|
|
132
|
+
if (nextRunState) {
|
|
133
|
+
runState = nextRunState;
|
|
134
|
+
}
|
|
109
135
|
publish(event);
|
|
110
136
|
}
|
|
111
137
|
}
|
|
112
138
|
}
|
|
113
|
-
if (!cancelled && runState
|
|
139
|
+
if (!cancelled && runState === 'running') {
|
|
114
140
|
runState = 'success';
|
|
115
141
|
publish({ type: 'server_state', run_state: runState });
|
|
116
142
|
}
|
|
@@ -148,16 +174,25 @@ export function createServerRuntime(options) {
|
|
|
148
174
|
return { ok: false, error: 'run_already_active' };
|
|
149
175
|
}
|
|
150
176
|
runState = 'running';
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
177
|
+
let job;
|
|
178
|
+
try {
|
|
179
|
+
job = await startDetachedRun({
|
|
180
|
+
projectRoot: options.projectRoot,
|
|
181
|
+
skipDeploy: Boolean(runOptions.skipDeploy),
|
|
182
|
+
skipVerify: Boolean(runOptions.skipVerify),
|
|
183
|
+
resumeLatest: Boolean(runOptions.resumeLatest),
|
|
184
|
+
outputFormat: 'jsonl'
|
|
185
|
+
}, {
|
|
186
|
+
env: runEnv(options),
|
|
187
|
+
cwd: options.projectRoot
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
runState = 'failed';
|
|
192
|
+
activeJobId = '';
|
|
193
|
+
publish({ type: 'server_state', run_state: runState });
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
161
196
|
activeJobId = String(job.job_id ?? '');
|
|
162
197
|
followJob(activeJobId);
|
|
163
198
|
return { ok: true, runId: activeJobId, job_id: activeJobId, status: job.status ?? 'running' };
|
|
@@ -178,7 +213,7 @@ export function createServerRuntime(options) {
|
|
|
178
213
|
stage,
|
|
179
214
|
outputFormat: 'jsonl'
|
|
180
215
|
}, {
|
|
181
|
-
env: options
|
|
216
|
+
env: runEnv(options),
|
|
182
217
|
cwd: options.projectRoot
|
|
183
218
|
});
|
|
184
219
|
activeJobId = String(job.job_id ?? '');
|
|
@@ -187,15 +222,25 @@ export function createServerRuntime(options) {
|
|
|
187
222
|
},
|
|
188
223
|
async stopRun() {
|
|
189
224
|
if (runState !== 'running' || !activeJobId) {
|
|
190
|
-
return { ok: true, requested: false };
|
|
225
|
+
return { ok: true, requested: false, run_state: runState };
|
|
191
226
|
}
|
|
192
227
|
runState = 'stopping';
|
|
193
228
|
publish({ type: 'server_state', run_state: runState });
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
229
|
+
const jobId = activeJobId;
|
|
230
|
+
try {
|
|
231
|
+
const stopped = await stopManagedJob(options.projectRoot, jobId, { env: options.env });
|
|
232
|
+
unsubscribeLogs?.();
|
|
233
|
+
runState = 'idle';
|
|
234
|
+
activeJobId = '';
|
|
235
|
+
publish({ type: 'server_state', run_state: 'idle' });
|
|
236
|
+
return { ok: true, requested: true, job_id: stopped.job_id ?? jobId, status: stopped.status ?? 'stopped', stopped: true };
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
unsubscribeLogs?.();
|
|
240
|
+
runState = 'failed';
|
|
241
|
+
publish({ type: 'server_state', run_state: 'failed' });
|
|
242
|
+
return { ok: false, requested: true, run_state: 'failed', error: error instanceof Error ? error.message : String(error) };
|
|
243
|
+
}
|
|
199
244
|
},
|
|
200
245
|
subscribe(handler) {
|
|
201
246
|
subscribers.add(handler);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
export function renderWebAdapterScript() {
|
|
1
|
+
export function renderWebAdapterScript(options = {}) {
|
|
2
2
|
return `
|
|
3
3
|
(() => {
|
|
4
|
+
const passwordEnabled = ${JSON.stringify(Boolean(options.passwordEnabled))};
|
|
4
5
|
const params = new URLSearchParams(window.location.search);
|
|
5
|
-
|
|
6
|
+
let token = params.get('token') || window.localStorage.getItem('autovpn.server.token') || '';
|
|
6
7
|
if (token) {
|
|
7
8
|
window.localStorage.setItem('autovpn.server.token', token);
|
|
8
9
|
}
|
|
@@ -14,12 +15,129 @@ export function renderWebAdapterScript() {
|
|
|
14
15
|
return url.pathname + url.search;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
function ensureLoginStyles() {
|
|
19
|
+
if (document.getElementById('autovpn-server-login-style')) return;
|
|
20
|
+
const style = document.createElement('style');
|
|
21
|
+
style.id = 'autovpn-server-login-style';
|
|
22
|
+
style.textContent = [
|
|
23
|
+
'.server-login-page{position:fixed;inset:0;z-index:9999;display:grid;place-items:center;padding:28px;background:radial-gradient(circle at top left,rgba(91,92,226,.08),transparent 24%),linear-gradient(180deg,#fbfcff 0%,var(--bg,#f5f7ff) 100%);font-family:Inter,"SF Pro Display","PingFang SC","Microsoft YaHei",system-ui,sans-serif;color:var(--text,#1e2746);}',
|
|
24
|
+
'.server-login-panel{width:min(440px,calc(100vw - 40px));display:grid;gap:22px;padding:30px;border:1px solid var(--border,#dfe5f5);border-radius:20px;background:rgba(255,255,255,.96);box-shadow:var(--shadow,0 20px 44px rgba(29,39,71,.1));}',
|
|
25
|
+
'.server-login-brand{display:flex;align-items:center;gap:14px;}',
|
|
26
|
+
'.server-login-logo{width:54px;height:54px;object-fit:contain;border-radius:18px;}',
|
|
27
|
+
'.server-login-title{margin:0;font-size:28px;font-weight:850;letter-spacing:0;}',
|
|
28
|
+
'.server-login-copy{margin:5px 0 0;color:var(--text-soft,#6d7794);line-height:1.5;}',
|
|
29
|
+
'.server-login-form{display:grid;gap:14px;}',
|
|
30
|
+
'.server-login-form input{width:100%;min-height:48px;border:1px solid var(--border,#dfe5f5);border-radius:14px;background:#fff;color:var(--text,#1e2746);padding:0 14px;outline:none;}',
|
|
31
|
+
'.server-login-form input:focus{border-color:rgba(91,92,226,.45);box-shadow:0 0 0 4px rgba(91,92,226,.12);}',
|
|
32
|
+
'.server-login-form button{min-height:48px;border:0;border-radius:14px;background:var(--accent,#5b5ce2);color:#fff;font-weight:800;cursor:pointer;box-shadow:0 12px 28px rgba(91,92,226,.22);}',
|
|
33
|
+
'.server-login-form button:disabled{background:var(--border-strong,#cfd7ef);box-shadow:none;cursor:not-allowed;}',
|
|
34
|
+
'.server-login-error{min-height:22px;margin:0;color:var(--danger,#f05b69);font-weight:700;}'
|
|
35
|
+
].join('');
|
|
36
|
+
document.head.append(style);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loginMessage(payload) {
|
|
40
|
+
if (payload?.error === 'ip_banned') return '密码错误次数过多,此 IP 已被封禁。';
|
|
41
|
+
if (payload?.error === 'invalid_password') {
|
|
42
|
+
const remaining = Number(payload.attemptsRemaining ?? 0);
|
|
43
|
+
return '密码错误,剩余 ' + remaining + ' 次尝试。';
|
|
44
|
+
}
|
|
45
|
+
return '登录失败,请重试。';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function showLoginPage(message = '', banned = false) {
|
|
49
|
+
ensureLoginStyles();
|
|
50
|
+
let root = document.querySelector('[data-server-login]');
|
|
51
|
+
if (!root) {
|
|
52
|
+
root = document.createElement('section');
|
|
53
|
+
root.className = 'server-login-page';
|
|
54
|
+
root.setAttribute('data-server-login', '');
|
|
55
|
+
root.innerHTML = [
|
|
56
|
+
'<div class="server-login-panel">',
|
|
57
|
+
'<div class="server-login-brand">',
|
|
58
|
+
'<img class="server-login-logo" src="./assets/vpn-auto-logo-v2-minimal.svg" alt="" aria-hidden="true" />',
|
|
59
|
+
'<div>',
|
|
60
|
+
'<h1 class="server-login-title">AutoVPN</h1>',
|
|
61
|
+
'<p class="server-login-copy">输入 serve 启动时打印的密码继续访问。</p>',
|
|
62
|
+
'</div>',
|
|
63
|
+
'</div>',
|
|
64
|
+
'<form class="server-login-form" data-server-login-form>',
|
|
65
|
+
'<input data-server-password type="password" autocomplete="current-password" placeholder="密码" />',
|
|
66
|
+
'<button data-server-login-submit type="submit">登录</button>',
|
|
67
|
+
'<p class="server-login-error" data-server-login-error aria-live="polite"></p>',
|
|
68
|
+
'</form>',
|
|
69
|
+
'</div>'
|
|
70
|
+
].join('');
|
|
71
|
+
document.body.append(root);
|
|
72
|
+
}
|
|
73
|
+
const error = root.querySelector('[data-server-login-error]');
|
|
74
|
+
const submit = root.querySelector('[data-server-login-submit]');
|
|
75
|
+
const input = root.querySelector('[data-server-password]');
|
|
76
|
+
if (error) error.textContent = message;
|
|
77
|
+
if (submit) submit.disabled = Boolean(banned);
|
|
78
|
+
if (input) {
|
|
79
|
+
input.disabled = Boolean(banned);
|
|
80
|
+
if (!banned) input.focus();
|
|
81
|
+
}
|
|
82
|
+
return root;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hideLoginPage() {
|
|
86
|
+
document.querySelector('[data-server-login]')?.remove();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function submitPassword(password) {
|
|
90
|
+
const response = await fetch('/api/auth/login', {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({ password })
|
|
94
|
+
});
|
|
95
|
+
const payload = await response.json().catch(() => ({}));
|
|
96
|
+
if (!response.ok || !payload.token) {
|
|
97
|
+
return { ok: false, status: response.status, payload };
|
|
98
|
+
}
|
|
99
|
+
token = String(payload.token);
|
|
100
|
+
window.localStorage.setItem('autovpn.server.token', token);
|
|
101
|
+
hideLoginPage();
|
|
102
|
+
return { ok: true, token };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loginWithPassword(message = '') {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const root = showLoginPage(message);
|
|
108
|
+
const form = root.querySelector('[data-server-login-form]');
|
|
109
|
+
const input = root.querySelector('[data-server-password]');
|
|
110
|
+
form.onsubmit = async (event) => {
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
const result = await submitPassword(input?.value || '');
|
|
113
|
+
if (result.ok) {
|
|
114
|
+
resolve(result.token);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const banned = result.payload?.error === 'ip_banned' || result.status === 403;
|
|
118
|
+
showLoginPage(loginMessage(result.payload), banned);
|
|
119
|
+
if (banned) {
|
|
120
|
+
reject(new Error('ip_banned'));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function request(path, requestOptions = {}) {
|
|
127
|
+
if (passwordEnabled && !token) {
|
|
128
|
+
await loginWithPassword();
|
|
129
|
+
}
|
|
130
|
+
const headers = { ...(requestOptions.headers || {}) };
|
|
19
131
|
if (token) headers.Authorization = 'Bearer ' + token;
|
|
20
|
-
if (
|
|
21
|
-
const response = await fetch(path, { ...
|
|
132
|
+
if (requestOptions.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
|
133
|
+
const response = await fetch(path, { ...requestOptions, headers });
|
|
22
134
|
const payload = await response.json().catch(() => ({}));
|
|
135
|
+
if (response.status === 401 && passwordEnabled) {
|
|
136
|
+
window.localStorage.removeItem('autovpn.server.token');
|
|
137
|
+
token = '';
|
|
138
|
+
await loginWithPassword('登录已过期,请重新输入密码。');
|
|
139
|
+
return request(path, requestOptions);
|
|
140
|
+
}
|
|
23
141
|
if (!response.ok) {
|
|
24
142
|
throw new Error(payload.error || 'request_failed');
|
|
25
143
|
}
|
package/dist/web/renderer/app.js
CHANGED
|
@@ -1037,12 +1037,29 @@ async function stopPipeline() {
|
|
|
1037
1037
|
renderAll();
|
|
1038
1038
|
appendLog(messages.pipelineStopping);
|
|
1039
1039
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1040
|
+
try {
|
|
1041
|
+
const result = await window.vpnAutomation.stopPipeline();
|
|
1042
|
+
if (!result?.ok) {
|
|
1043
|
+
finishRun({ ok: false, error: result?.error || messages.stopUnavailable });
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
if (result.requested === false) {
|
|
1047
|
+
if (result.run_state === 'failed') {
|
|
1048
|
+
finishRun({ ok: false, error: result.error || messages.stopUnavailable });
|
|
1049
|
+
} else {
|
|
1050
|
+
state.runState = 'idle';
|
|
1051
|
+
state.runStartedAt = null;
|
|
1052
|
+
touchUpdate();
|
|
1053
|
+
renderAll();
|
|
1054
|
+
appendLog(messages.stopUnavailable);
|
|
1055
|
+
}
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (result.stopped || result.status === 'stopped') {
|
|
1059
|
+
finishRun({ stopped: true });
|
|
1060
|
+
}
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
finishRun({ ok: false, error: error.message });
|
|
1046
1063
|
}
|
|
1047
1064
|
}
|
|
1048
1065
|
|
|
@@ -1081,6 +1098,31 @@ function finishRun(result = {}) {
|
|
|
1081
1098
|
}
|
|
1082
1099
|
|
|
1083
1100
|
function handlePipelineEvent(event) {
|
|
1101
|
+
if (event.type === 'server_state') {
|
|
1102
|
+
const nextRunState = String(event.run_state ?? '');
|
|
1103
|
+
if (['idle', 'running', 'stopping', 'failed', 'success'].includes(nextRunState)) {
|
|
1104
|
+
state.runState = nextRunState === 'success' ? 'idle' : nextRunState;
|
|
1105
|
+
if (['idle', 'failed', 'success'].includes(nextRunState)) {
|
|
1106
|
+
state.runStartedAt = null;
|
|
1107
|
+
}
|
|
1108
|
+
if (nextRunState === 'failed') {
|
|
1109
|
+
state.runResult = 'failed';
|
|
1110
|
+
} else if (nextRunState === 'success') {
|
|
1111
|
+
state.runResult = 'success';
|
|
1112
|
+
}
|
|
1113
|
+
touchUpdate();
|
|
1114
|
+
renderAll();
|
|
1115
|
+
}
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (event.type === 'run_failed') {
|
|
1120
|
+
if (state.runState !== 'idle' || state.runResult !== 'failed') {
|
|
1121
|
+
finishRun({ ok: false, error: event.error });
|
|
1122
|
+
}
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1084
1126
|
if (event.type === 'log') {
|
|
1085
1127
|
appendLog(event.message);
|
|
1086
1128
|
return;
|
|
@@ -1117,18 +1159,76 @@ function handlePipelineEvent(event) {
|
|
|
1117
1159
|
}
|
|
1118
1160
|
state.artifactDir = event.artifact_dir ?? '';
|
|
1119
1161
|
state.selectedRetryArtifactDir = state.artifactDir || state.selectedRetryArtifactDir;
|
|
1162
|
+
appendLog(`[summary] artifacts: ${event.artifact_dir}`);
|
|
1163
|
+
const runStatus = String(event.run_status ?? '');
|
|
1164
|
+
if (runStatus === 'failed') {
|
|
1165
|
+
finishRun({ ok: false, error: event.error });
|
|
1166
|
+
hydrateArtifactPreview();
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
if (runStatus === 'success') {
|
|
1170
|
+
finishRun({ ok: true, code: 0 });
|
|
1171
|
+
hydrateArtifactPreview();
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
if (runStatus === 'stopped') {
|
|
1175
|
+
finishRun({ stopped: true });
|
|
1176
|
+
hydrateArtifactPreview();
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1120
1179
|
touchUpdate();
|
|
1121
1180
|
renderAll();
|
|
1122
|
-
appendLog(`[summary] artifacts: ${event.artifact_dir}`);
|
|
1123
1181
|
hydrateArtifactPreview();
|
|
1124
1182
|
void hydrateRetryArtifacts();
|
|
1125
1183
|
return;
|
|
1126
1184
|
}
|
|
1127
1185
|
|
|
1186
|
+
if (event.type === 'extract_source_started') {
|
|
1187
|
+
appendLog(`[extract] ${event.source_name} 开始提取,最多 ${event.requested_iterations ?? 0} 次,最少 ${event.min_iterations ?? 0} 次`, {
|
|
1188
|
+
kind: 'stage',
|
|
1189
|
+
stage: 'extract',
|
|
1190
|
+
level: 'info'
|
|
1191
|
+
});
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (event.type === 'extract_request_result') {
|
|
1196
|
+
const status = event.success ? '成功' : '失败';
|
|
1197
|
+
const retry = event.will_retry ? ',将重试' : '';
|
|
1198
|
+
appendLog(`[extract] ${event.source_name} #${event.iteration} ${event.via} ${status}${retry}`, {
|
|
1199
|
+
kind: 'stage',
|
|
1200
|
+
stage: 'extract',
|
|
1201
|
+
level: event.success ? 'info' : 'warning'
|
|
1202
|
+
});
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (event.type === 'extract_decrypt_result' && !event.success) {
|
|
1207
|
+
appendLog(`[extract] ${event.source_name} #${event.iteration} 解密失败`, {
|
|
1208
|
+
kind: 'stage',
|
|
1209
|
+
stage: 'extract',
|
|
1210
|
+
level: 'error'
|
|
1211
|
+
});
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (event.type === 'extract_source_completed') {
|
|
1216
|
+
updateExtractMetrics({ source_name: event.source_name, total_links: event.raw_links });
|
|
1217
|
+
appendLog(`[extract] ${event.source_name} 完成,成功 ${event.successful_iterations ?? 0} 次,失败 ${event.failed_iterations ?? 0} 次,原始节点 ${event.raw_links ?? 0} 个`, {
|
|
1218
|
+
kind: 'stage',
|
|
1219
|
+
stage: 'extract',
|
|
1220
|
+
level: 'info'
|
|
1221
|
+
});
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1128
1225
|
if (event.type === 'extract_iteration') {
|
|
1129
1226
|
updateExtractMetrics(event);
|
|
1130
|
-
|
|
1131
|
-
|
|
1227
|
+
appendLog(`[extract] ${event.source_name} #${event.iteration ?? 0} 新增 ${event.new_items ?? 0} 个,本次解析 ${event.extracted_links ?? 0} 个,累计 ${event.total_links ?? 0} 个`, {
|
|
1228
|
+
kind: 'stage',
|
|
1229
|
+
stage: 'extract',
|
|
1230
|
+
level: 'info'
|
|
1231
|
+
});
|
|
1132
1232
|
return;
|
|
1133
1233
|
}
|
|
1134
1234
|
|
|
@@ -94,6 +94,10 @@ textarea:focus {
|
|
|
94
94
|
min-height: 0;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
body[data-runtime="web"] .app-frame {
|
|
98
|
+
grid-template-rows: minmax(0, 1fr);
|
|
99
|
+
}
|
|
100
|
+
|
|
97
101
|
.window-titlebar {
|
|
98
102
|
-webkit-app-region: drag;
|
|
99
103
|
user-select: none;
|
|
@@ -102,6 +106,10 @@ textarea:focus {
|
|
|
102
106
|
backdrop-filter: blur(16px);
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
body[data-runtime="web"] .window-titlebar {
|
|
110
|
+
display: none;
|
|
111
|
+
}
|
|
112
|
+
|
|
105
113
|
.app-content-shell {
|
|
106
114
|
min-height: 0;
|
|
107
115
|
overflow: hidden;
|
|
@@ -1005,6 +1005,7 @@ export function applySourceIterationDraft(sources = {}, draft = {}) {
|
|
|
1005
1005
|
{
|
|
1006
1006
|
...source,
|
|
1007
1007
|
max_iterations: maxIterations,
|
|
1008
|
+
min_iterations: Math.min(coercePositiveInteger(source.min_iterations, 0), maxIterations),
|
|
1008
1009
|
area_min: Math.min(areaMin, areaMax),
|
|
1009
1010
|
area_max: Math.max(areaMin, areaMax)
|
|
1010
1011
|
}
|