@swimmingliu/autovpn 1.5.5 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/autovpn.mjs 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,8 +51,9 @@ function rendererRoot() {
56
51
  return path.resolve(fileURLToPath(new URL('../web/renderer', import.meta.url)));
57
52
  }
58
53
  async function writeStaticFile(response, statusCode, filePath, body) {
54
+ const payload = body ?? await fs.readFile(filePath);
59
55
  response.writeHead(statusCode, { 'Content-Type': contentType(filePath) });
60
- response.end(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');
@@ -72,10 +68,6 @@ async function serveRendererAsset(url, response) {
72
68
  await serveRendererIndex(response);
73
69
  return true;
74
70
  }
75
- if (url.pathname === '/web-adapter.js') {
76
- await writeStaticFile(response, 200, 'web-adapter.js', renderWebAdapterScript());
77
- return true;
78
- }
79
71
  const decodedPath = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
80
72
  if (!decodedPath || decodedPath.includes('..') || path.isAbsolute(decodedPath)) {
81
73
  return false;
@@ -93,15 +85,23 @@ async function serveRendererAsset(url, response) {
93
85
  return false;
94
86
  }
95
87
  }
88
+ function timingSafeEqualText(left, right) {
89
+ const leftBuffer = Buffer.from(left);
90
+ const rightBuffer = Buffer.from(right);
91
+ return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
92
+ }
96
93
  function isAuthorized(request, url, auth) {
97
94
  if (!auth.enabled) {
98
95
  return true;
99
96
  }
100
97
  const authorization = request.headers.authorization ?? '';
101
- if (authorization === `Bearer ${auth.token}`) {
98
+ if (authorization.startsWith('Bearer ') && timingSafeEqualText(authorization.slice(7), auth.token)) {
102
99
  return true;
103
100
  }
104
- 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';
105
105
  }
106
106
  async function readJsonBody(request) {
107
107
  const chunks = [];
@@ -122,13 +122,47 @@ async function readJsonBody(request) {
122
122
  return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
123
123
  }
124
124
  export async function createAutoVpnServer(options) {
125
+ const authFailures = new Map();
125
126
  const server = http.createServer(async (request, response) => {
126
127
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `${options.host}:${options.port}`}`);
127
- if (url.pathname.startsWith('/api/') && !isAuthorized(request, url, options.auth)) {
128
- 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' });
129
132
  return;
130
133
  }
131
134
  try {
135
+ if (request.method === 'POST' && url.pathname === '/api/auth/login') {
136
+ const password = String((await readJsonBody(request)).password ?? '');
137
+ if (!options.auth.enabled || !options.auth.password || timingSafeEqualText(password, options.auth.password)) {
138
+ authFailures.delete(ip);
139
+ writeJson(response, 200, { ok: true, token: options.auth.token });
140
+ return;
141
+ }
142
+ const nextFailures = (authState?.failures ?? 0) + 1;
143
+ if (nextFailures >= options.auth.maxAttempts) {
144
+ authFailures.set(ip, { failures: nextFailures, banned: true });
145
+ writeJson(response, 403, { ok: false, error: 'ip_banned' });
146
+ return;
147
+ }
148
+ authFailures.set(ip, { failures: nextFailures, banned: false });
149
+ writeJson(response, 401, {
150
+ ok: false,
151
+ error: 'invalid_password',
152
+ attemptsRemaining: options.auth.maxAttempts - nextFailures
153
+ });
154
+ return;
155
+ }
156
+ if (request.method === 'GET' && url.pathname === '/web-adapter.js') {
157
+ await writeStaticFile(response, 200, 'web-adapter.js', renderWebAdapterScript({
158
+ passwordEnabled: Boolean(options.auth.enabled && options.auth.password)
159
+ }));
160
+ return;
161
+ }
162
+ if (url.pathname.startsWith('/api/') && !isAuthorized(request, url, options.auth)) {
163
+ writeJson(response, 401, { ok: false, error: 'unauthorized' });
164
+ return;
165
+ }
132
166
  if (request.method === 'GET' && url.pathname === '/api/health') {
133
167
  writeJson(response, 200, {
134
168
  status: 'ok',
@@ -139,12 +173,12 @@ export async function createAutoVpnServer(options) {
139
173
  return;
140
174
  }
141
175
  if (request.method === 'GET' && url.pathname === '/api/state') {
142
- writeJson(response, 200, await options.runtime.loadState());
176
+ writeJson(response, 200, await options.runtime.loadState(), 'config');
143
177
  return;
144
178
  }
145
179
  if (request.method === 'POST' && url.pathname === '/api/profile') {
146
180
  const body = await readJsonBody(request);
147
- writeJson(response, 200, await options.runtime.saveProfile?.(body) ?? { ok: false, error: 'profile_save_unavailable' });
181
+ writeJson(response, 200, await options.runtime.saveProfile?.(body) ?? { ok: false, error: 'profile_save_unavailable' }, 'config');
148
182
  return;
149
183
  }
150
184
  if (request.method === 'POST' && url.pathname === '/api/qr') {
@@ -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
  }