@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 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
- io.writeStdout(`Open ${server.origin}/?token=${encodeURIComponent(serveOptions.auth.token)}\n`);
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)));
@@ -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;
@@ -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
- if (signalProcess) {
58
- signalProcess(target, 'SIGTERM');
60
+ try {
61
+ if (signalProcess) {
62
+ signalProcess(target, 'SIGTERM');
63
+ }
64
+ else {
65
+ defaultSignalProcess(target, 'SIGTERM', options);
66
+ }
59
67
  }
60
- else {
61
- defaultSignalProcess(target, 'SIGTERM', options);
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
- if (signalProcess) {
69
- signalProcess(target, 'SIGKILL');
79
+ try {
80
+ if (signalProcess) {
81
+ signalProcess(target, 'SIGKILL');
82
+ }
83
+ else {
84
+ defaultSignalProcess(target, 'SIGKILL', options);
85
+ }
70
86
  }
71
- else {
72
- defaultSignalProcess(target, 'SIGKILL', options);
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(),
@@ -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 minIterations = Math.max(0, Math.trunc(numberOrDefault(source.min_iterations, 0)));
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 fetchWithTimeout(fetchImpl, url, 20_000);
180
- if (response.ok === false || Number(response.status ?? 200) >= 400) {
181
- throw new Error(`HTTP ${Number(response.status ?? 0)}`);
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 && attempt >= minIterations) {
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: 'success',
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: String(report.run_status ?? 'success') === 'failed' ? 'failed' : 'success',
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: 'success',
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 extractResults = [];
360
- for (const [sourceName, source] of enabledSources(profile)) {
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 }, { cwd: projectRoot, env: runtimeStageEnv });
364
- extractResults.push(result);
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;
@@ -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 SENSITIVE_KEYS = new Set([
9
- 'key',
10
- 'token',
11
- 'api_token',
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
- if (SENSITIVE_KEYS.has(parentKey.toLowerCase())) {
23
- return value ? '<redacted>' : '';
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(body ?? await fs.readFile(filePath));
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.replace('<script type="module" src="./app.js"></script>', '<script src="/web-adapter.js"></script>\n <script type="module" src="./app.js"></script>');
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 === `Bearer ${auth.token}`) {
98
+ if (authorization.startsWith('Bearer ') && timingSafeEqualText(authorization.slice(7), auth.token)) {
100
99
  return true;
101
100
  }
102
- return url.searchParams.get('token') === auth.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
- if (url.pathname.startsWith('/api/') && !isAuthorized(request, url, options.auth)) {
126
- writeJson(response, 401, { ok: false, error: 'unauthorized' });
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') {
@@ -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
- : { enabled: true, token: token || (context.randomToken ?? defaultRandomToken)() }
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
  }
@@ -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 redactIfSet(value) {
16
- return String(value ?? '').trim() ? '<redacted>' : '';
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 !== 'stopping') {
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
- const job = await startDetachedRun({
152
- projectRoot: options.projectRoot,
153
- skipDeploy: Boolean(runOptions.skipDeploy),
154
- skipVerify: Boolean(runOptions.skipVerify),
155
- resumeLatest: Boolean(runOptions.resumeLatest),
156
- outputFormat: 'jsonl'
157
- }, {
158
- env: options.env,
159
- cwd: options.projectRoot
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.env,
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 stopped = await stopManagedJob(options.projectRoot, activeJobId, { env: options.env });
195
- unsubscribeLogs?.();
196
- runState = 'idle';
197
- publish({ type: 'server_state', run_state: 'idle' });
198
- return { ok: true, requested: true, job_id: stopped.job_id ?? activeJobId, status: stopped.status ?? 'stopped' };
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
- const token = params.get('token') || window.localStorage.getItem('autovpn.server.token') || '';
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
- async function request(path, options = {}) {
18
- const headers = { ...(options.headers || {}) };
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 (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
21
- const response = await fetch(path, { ...options, headers });
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
  }
@@ -1037,12 +1037,29 @@ async function stopPipeline() {
1037
1037
  renderAll();
1038
1038
  appendLog(messages.pipelineStopping);
1039
1039
 
1040
- const result = await window.vpnAutomation.stopPipeline();
1041
- if (!result?.ok) {
1042
- state.runState = 'running';
1043
- touchUpdate();
1044
- renderAll();
1045
- appendLog(messages.stopUnavailable);
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
- touchUpdate();
1131
- renderRuntimeOnly({ chrome: false });
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
 
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.1.5.4',
8
+ sidebarVersion: 'v.1.6.0',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmingliu/autovpn",
3
- "version": "1.5.4",
3
+ "version": "1.6.0",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {