@nodepisss/vpn_tool 1.0.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/README.md +10 -0
- package/package.json +30 -0
- package/vpn-tool.js +871 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nodepisss/vpn_tool",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Command-line client for the VPN Control API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vpn-tool": "./vpn-tool.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./vpn-tool.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node ./vpn-tool.js",
|
|
12
|
+
"test": "node ./vpn-tool.js --help"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/hb044082-alt/vpn_tool.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/hb044082-alt/vpn_tool/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/hb044082-alt/vpn_tool#readme",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"axios": "^1.8.0",
|
|
24
|
+
"commander": "^14.0.0"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|
package/vpn-tool.js
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import readline from 'node:readline';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
|
|
10
|
+
const API_BASE = 'https://vpncontrol-b2m4cn5q.manus.space';
|
|
11
|
+
const APP_NAME = 'vpn-tool';
|
|
12
|
+
const VERSION = '1.0.0';
|
|
13
|
+
|
|
14
|
+
const AUTH_FILE = 'auth.json';
|
|
15
|
+
const LOCK_FILE = 'pin.lock';
|
|
16
|
+
|
|
17
|
+
const MAX_PIN_ATTEMPTS = 5;
|
|
18
|
+
const LOCKOUT_SECONDS = 8 * 60 * 60;
|
|
19
|
+
|
|
20
|
+
function fail(message, code = 1) {
|
|
21
|
+
process.stderr.write(`error: ${message}\n`);
|
|
22
|
+
process.exit(code);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getAppDir(dir) {
|
|
26
|
+
if (!dir || dir.trim() === '') {
|
|
27
|
+
fail(
|
|
28
|
+
'missing required option: --dir <path>\n' +
|
|
29
|
+
'Example: vpn-tool --dir /path/to/data auth register'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return path.resolve(dir);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateExistingDir(dir) {
|
|
37
|
+
try {
|
|
38
|
+
const stats = fs.statSync(dir);
|
|
39
|
+
|
|
40
|
+
if (!stats.isDirectory()) {
|
|
41
|
+
fail(`path exists but is not a directory: ${dir}`);
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err?.code === 'ENOENT') {
|
|
45
|
+
process.stderr.write(
|
|
46
|
+
`Directory not found: ${dir} (ENOENT)\n`
|
|
47
|
+
);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fail(`unable to access directory: ${dir}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getExistingDir(options) {
|
|
56
|
+
const dir = getAppDir(options.dir);
|
|
57
|
+
validateExistingDir(dir);
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function authFile(dir) {
|
|
62
|
+
return path.join(dir, AUTH_FILE);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function lockFile(dir) {
|
|
66
|
+
return path.join(dir, LOCK_FILE);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function output(data, json) {
|
|
70
|
+
if (json || typeof data === 'object') {
|
|
71
|
+
process.stdout.write(
|
|
72
|
+
JSON.stringify(data, null, 2) + '\n'
|
|
73
|
+
);
|
|
74
|
+
} else {
|
|
75
|
+
process.stdout.write(String(data) + '\n');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function prompt(queryText) {
|
|
80
|
+
const rl = readline.createInterface({
|
|
81
|
+
input: process.stdin,
|
|
82
|
+
output: process.stdout
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
rl.question(queryText, (answer) => {
|
|
87
|
+
rl.close();
|
|
88
|
+
resolve(answer.trim());
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function promptPin(queryText) {
|
|
94
|
+
return prompt(queryText);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readJsonFile(file) {
|
|
98
|
+
if (!fs.existsSync(file)) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
104
|
+
|
|
105
|
+
if (!raw.trim()) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return JSON.parse(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeJsonFile(file, data) {
|
|
116
|
+
const dir = path.dirname(file);
|
|
117
|
+
|
|
118
|
+
validateExistingDir(dir);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
fs.writeFileSync(
|
|
122
|
+
file,
|
|
123
|
+
JSON.stringify(data, null, 2) + '\n',
|
|
124
|
+
{
|
|
125
|
+
encoding: 'utf8',
|
|
126
|
+
mode: 0o600
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
fs.chmodSync(file, 0o600);
|
|
132
|
+
} catch {
|
|
133
|
+
// Ignore unsupported chmod.
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
fail(`unable to write ${file}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function checkLockout(dir) {
|
|
141
|
+
const lock = readJsonFile(lockFile(dir));
|
|
142
|
+
|
|
143
|
+
if (!lock) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const lockedUntil = Number(lock.locked_until || 0);
|
|
148
|
+
|
|
149
|
+
if (lockedUntil <= 0) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const now = Math.floor(Date.now() / 1000);
|
|
154
|
+
|
|
155
|
+
if (now < lockedUntil) {
|
|
156
|
+
const remaining = lockedUntil - now;
|
|
157
|
+
const hours = Math.floor(remaining / 3600);
|
|
158
|
+
const minutes = Math.floor((remaining % 3600) / 60);
|
|
159
|
+
|
|
160
|
+
fail(
|
|
161
|
+
`pin.lockout: too many failed attempts. ` +
|
|
162
|
+
`Try again in ${hours} hours` +
|
|
163
|
+
`${minutes > 0 ? ` ${minutes} minutes` : ''}.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const file = lockFile(dir);
|
|
168
|
+
|
|
169
|
+
if (fs.existsSync(file)) {
|
|
170
|
+
fs.unlinkSync(file);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function failedPin(dir) {
|
|
175
|
+
const file = lockFile(dir);
|
|
176
|
+
|
|
177
|
+
const lock = readJsonFile(file) || {
|
|
178
|
+
attempts: 0,
|
|
179
|
+
locked_until: 0
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const attempts = Number(lock.attempts || 0) + 1;
|
|
183
|
+
const now = Math.floor(Date.now() / 1000);
|
|
184
|
+
|
|
185
|
+
if (attempts >= MAX_PIN_ATTEMPTS) {
|
|
186
|
+
writeJsonFile(file, {
|
|
187
|
+
attempts,
|
|
188
|
+
locked_until: now + LOCKOUT_SECONDS,
|
|
189
|
+
locked_at: new Date().toISOString(),
|
|
190
|
+
lockout_seconds: LOCKOUT_SECONDS
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
fail(
|
|
194
|
+
'pin.lockout: too many failed attempts. ' +
|
|
195
|
+
'Try again in 8 hours.'
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
writeJsonFile(file, {
|
|
200
|
+
attempts,
|
|
201
|
+
locked_until: 0
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
fail('invalid PIN');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function loadAuth(dir) {
|
|
208
|
+
return readJsonFile(authFile(dir));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function loggedIn(dir) {
|
|
212
|
+
const auth = loadAuth(dir);
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
auth !== null &&
|
|
216
|
+
Boolean(auth.logged_in)
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function requireLogin(dir) {
|
|
221
|
+
if (!loggedIn(dir)) {
|
|
222
|
+
fail(
|
|
223
|
+
'not logged in. ' +
|
|
224
|
+
'Try: vpn-tool --dir <path> auth login'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function registerPin(dir, replace = false) {
|
|
230
|
+
validateExistingDir(dir);
|
|
231
|
+
|
|
232
|
+
const authData = loadAuth(dir);
|
|
233
|
+
|
|
234
|
+
if (authData && !replace) {
|
|
235
|
+
fail(
|
|
236
|
+
'PIN already registered. ' +
|
|
237
|
+
'Use "auth reset" to change it.'
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
while (true) {
|
|
242
|
+
const pin = await promptPin('Enter new PIN: ');
|
|
243
|
+
|
|
244
|
+
if (pin.length < 4) {
|
|
245
|
+
process.stderr.write(
|
|
246
|
+
'error: PIN must contain at least 4 characters.\n'
|
|
247
|
+
);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const confirm = await promptPin('Confirm new PIN: ');
|
|
252
|
+
|
|
253
|
+
if (pin !== confirm) {
|
|
254
|
+
process.stderr.write(
|
|
255
|
+
'error: PINs do not match.\n'
|
|
256
|
+
);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const existing = loadAuth(dir);
|
|
261
|
+
|
|
262
|
+
const pinHash = crypto
|
|
263
|
+
.createHash('sha256')
|
|
264
|
+
.update(pin)
|
|
265
|
+
.digest('hex');
|
|
266
|
+
|
|
267
|
+
const newAuth = {
|
|
268
|
+
pin_hash: pinHash,
|
|
269
|
+
logged_in: false,
|
|
270
|
+
created_at:
|
|
271
|
+
existing?.created_at ||
|
|
272
|
+
new Date().toISOString(),
|
|
273
|
+
updated_at: new Date().toISOString()
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
writeJsonFile(authFile(dir), newAuth);
|
|
277
|
+
|
|
278
|
+
const lockF = lockFile(dir);
|
|
279
|
+
|
|
280
|
+
if (fs.existsSync(lockF)) {
|
|
281
|
+
fs.unlinkSync(lockF);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function verifyPin(dir, pin) {
|
|
289
|
+
checkLockout(dir);
|
|
290
|
+
|
|
291
|
+
const auth = loadAuth(dir);
|
|
292
|
+
|
|
293
|
+
if (!auth || !auth.pin_hash) {
|
|
294
|
+
fail(
|
|
295
|
+
'no PIN registered. ' +
|
|
296
|
+
'Try: vpn-tool --dir <path> auth register'
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const pinHash = crypto
|
|
301
|
+
.createHash('sha256')
|
|
302
|
+
.update(pin)
|
|
303
|
+
.digest('hex');
|
|
304
|
+
|
|
305
|
+
if (pinHash !== auth.pin_hash) {
|
|
306
|
+
failedPin(dir);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const lockF = lockFile(dir);
|
|
310
|
+
|
|
311
|
+
if (fs.existsSync(lockF)) {
|
|
312
|
+
fs.unlinkSync(lockF);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/*
|
|
319
|
+
* Axios API client.
|
|
320
|
+
*/
|
|
321
|
+
const api = axios.create({
|
|
322
|
+
baseURL: API_BASE,
|
|
323
|
+
timeout: 30000,
|
|
324
|
+
headers: {
|
|
325
|
+
Accept: 'application/json'
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
async function apiRequest(method, endpoint, body = null) {
|
|
330
|
+
try {
|
|
331
|
+
const response = await api.request({
|
|
332
|
+
method: method.toLowerCase(),
|
|
333
|
+
url: endpoint,
|
|
334
|
+
...(body !== null ? { data: body } : {})
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return response.data;
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (err.response) {
|
|
340
|
+
const data = err.response.data;
|
|
341
|
+
|
|
342
|
+
let message = `HTTP ${err.response.status}`;
|
|
343
|
+
|
|
344
|
+
if (
|
|
345
|
+
typeof data === 'object' &&
|
|
346
|
+
data !== null
|
|
347
|
+
) {
|
|
348
|
+
message =
|
|
349
|
+
data.error ||
|
|
350
|
+
data.message ||
|
|
351
|
+
message;
|
|
352
|
+
|
|
353
|
+
if (data.details) {
|
|
354
|
+
message += `: ${data.details}`;
|
|
355
|
+
}
|
|
356
|
+
} else if (typeof data === 'string' && data.trim()) {
|
|
357
|
+
message = data;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
fail(message);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (err.code === 'ECONNABORTED') {
|
|
364
|
+
fail('API request timed out');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
fail(
|
|
368
|
+
`API request failed: ${err.message}`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const program = new Command();
|
|
374
|
+
|
|
375
|
+
program
|
|
376
|
+
.name(APP_NAME)
|
|
377
|
+
.version(VERSION)
|
|
378
|
+
.option('--json', 'JSON output')
|
|
379
|
+
.option('--output <format>', 'Output format')
|
|
380
|
+
.requiredOption(
|
|
381
|
+
'--dir <path>',
|
|
382
|
+
'Existing local data directory'
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
function isJson(options, globalOpts) {
|
|
386
|
+
return (
|
|
387
|
+
options.json ||
|
|
388
|
+
globalOpts.json ||
|
|
389
|
+
options.output === 'json' ||
|
|
390
|
+
globalOpts.output === 'json'
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
program
|
|
395
|
+
.command('countries')
|
|
396
|
+
.description('List supported countries')
|
|
397
|
+
.action(async (_, cmd) => {
|
|
398
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
399
|
+
const dir = getExistingDir(globalOpts);
|
|
400
|
+
|
|
401
|
+
requireLogin(dir);
|
|
402
|
+
|
|
403
|
+
const data = await apiRequest(
|
|
404
|
+
'GET',
|
|
405
|
+
'/api/countries'
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
output(
|
|
409
|
+
data,
|
|
410
|
+
isJson(cmd.opts(), globalOpts)
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
program
|
|
415
|
+
.command('connect')
|
|
416
|
+
.description('Connect to country')
|
|
417
|
+
.option(
|
|
418
|
+
'--country <code2>',
|
|
419
|
+
'Country code'
|
|
420
|
+
)
|
|
421
|
+
.action(async (opts, cmd) => {
|
|
422
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
423
|
+
const dir = getExistingDir(globalOpts);
|
|
424
|
+
|
|
425
|
+
requireLogin(dir);
|
|
426
|
+
|
|
427
|
+
let country = opts.country;
|
|
428
|
+
|
|
429
|
+
if (!country || country.trim() === '') {
|
|
430
|
+
country = await prompt(
|
|
431
|
+
'Enter country code (example: DE or JP): '
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
country = country.toUpperCase().trim();
|
|
436
|
+
|
|
437
|
+
if (!/^[A-Z]{2}$/.test(country)) {
|
|
438
|
+
fail(
|
|
439
|
+
'countryCode must be a two-letter code such as DE or JP'
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const data = await apiRequest(
|
|
444
|
+
'POST',
|
|
445
|
+
'/api/vpn/connect',
|
|
446
|
+
{
|
|
447
|
+
countryCode: country
|
|
448
|
+
}
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
output(
|
|
452
|
+
data,
|
|
453
|
+
isJson(opts, globalOpts)
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
program
|
|
458
|
+
.command('status')
|
|
459
|
+
.description('Show VPN status')
|
|
460
|
+
.action(async (_, cmd) => {
|
|
461
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
462
|
+
const dir = getExistingDir(globalOpts);
|
|
463
|
+
|
|
464
|
+
requireLogin(dir);
|
|
465
|
+
|
|
466
|
+
const data = await apiRequest(
|
|
467
|
+
'GET',
|
|
468
|
+
'/api/vpn/status'
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
output(
|
|
472
|
+
data,
|
|
473
|
+
isJson(cmd.opts(), globalOpts)
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
program
|
|
478
|
+
.command('disconnect')
|
|
479
|
+
.description('Disconnect VPN')
|
|
480
|
+
.action(async (_, cmd) => {
|
|
481
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
482
|
+
const dir = getExistingDir(globalOpts);
|
|
483
|
+
|
|
484
|
+
requireLogin(dir);
|
|
485
|
+
|
|
486
|
+
const data = await apiRequest(
|
|
487
|
+
'POST',
|
|
488
|
+
'/api/vpn/disconnect'
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
output(
|
|
492
|
+
data,
|
|
493
|
+
isJson(cmd.opts(), globalOpts)
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
program
|
|
498
|
+
.command('ip')
|
|
499
|
+
.description('Return assigned/generated IPs')
|
|
500
|
+
.option(
|
|
501
|
+
'--count <number>',
|
|
502
|
+
'Number of IPs',
|
|
503
|
+
'10'
|
|
504
|
+
)
|
|
505
|
+
.action(async (opts, cmd) => {
|
|
506
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
507
|
+
const dir = getExistingDir(globalOpts);
|
|
508
|
+
|
|
509
|
+
requireLogin(dir);
|
|
510
|
+
|
|
511
|
+
const count = opts.count;
|
|
512
|
+
|
|
513
|
+
if (
|
|
514
|
+
!/^\d+$/.test(count) ||
|
|
515
|
+
parseInt(count, 10) < 1
|
|
516
|
+
) {
|
|
517
|
+
fail('--count must be a positive integer');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const data = await apiRequest(
|
|
521
|
+
'GET',
|
|
522
|
+
`/api/vpn/ip?count=${encodeURIComponent(count)}`
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
output(
|
|
526
|
+
data,
|
|
527
|
+
isJson(opts, globalOpts)
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
program
|
|
532
|
+
.command('ips')
|
|
533
|
+
.description('Generate simulated VPN IPs')
|
|
534
|
+
.option(
|
|
535
|
+
'--count <number>',
|
|
536
|
+
'Number of IPs',
|
|
537
|
+
'10'
|
|
538
|
+
)
|
|
539
|
+
.action(async (opts, cmd) => {
|
|
540
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
541
|
+
const dir = getExistingDir(globalOpts);
|
|
542
|
+
|
|
543
|
+
requireLogin(dir);
|
|
544
|
+
|
|
545
|
+
const count = opts.count;
|
|
546
|
+
|
|
547
|
+
if (
|
|
548
|
+
!/^\d+$/.test(count) ||
|
|
549
|
+
parseInt(count, 10) < 1
|
|
550
|
+
) {
|
|
551
|
+
fail('--count must be a positive integer');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const data = await apiRequest(
|
|
555
|
+
'GET',
|
|
556
|
+
`/api/vpn/ips?count=${encodeURIComponent(count)}`
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
output(
|
|
560
|
+
data,
|
|
561
|
+
isJson(opts, globalOpts)
|
|
562
|
+
);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
program
|
|
566
|
+
.command('test')
|
|
567
|
+
.description('Test sleep endpoint')
|
|
568
|
+
.option(
|
|
569
|
+
'--ms <number>',
|
|
570
|
+
'Milliseconds',
|
|
571
|
+
'1000'
|
|
572
|
+
)
|
|
573
|
+
.action(async (opts, cmd) => {
|
|
574
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
575
|
+
const dir = getExistingDir(globalOpts);
|
|
576
|
+
|
|
577
|
+
requireLogin(dir);
|
|
578
|
+
|
|
579
|
+
const ms = opts.ms;
|
|
580
|
+
|
|
581
|
+
if (
|
|
582
|
+
!/^\d+$/.test(ms) ||
|
|
583
|
+
parseInt(ms, 10) < 0
|
|
584
|
+
) {
|
|
585
|
+
fail('--ms must be a non-negative integer');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const data = await apiRequest(
|
|
589
|
+
'GET',
|
|
590
|
+
`/api/test/sleep?ms=${encodeURIComponent(ms)}`
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
output(
|
|
594
|
+
data,
|
|
595
|
+
isJson(opts, globalOpts)
|
|
596
|
+
);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const auth = program
|
|
600
|
+
.command('auth')
|
|
601
|
+
.description('Authentication management');
|
|
602
|
+
|
|
603
|
+
auth
|
|
604
|
+
.command('register')
|
|
605
|
+
.description('Register PIN')
|
|
606
|
+
.action(async (_, cmd) => {
|
|
607
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
608
|
+
const dir = getExistingDir(globalOpts);
|
|
609
|
+
|
|
610
|
+
await registerPin(dir);
|
|
611
|
+
|
|
612
|
+
const data = {
|
|
613
|
+
success: true,
|
|
614
|
+
registered: true,
|
|
615
|
+
logged_in: false
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
if (isJson(cmd.opts(), globalOpts)) {
|
|
619
|
+
output(data, true);
|
|
620
|
+
} else {
|
|
621
|
+
process.stdout.write(
|
|
622
|
+
'PIN registered successfully.\n'
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
auth
|
|
628
|
+
.command('login')
|
|
629
|
+
.description('Login')
|
|
630
|
+
.action(async (_, cmd) => {
|
|
631
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
632
|
+
const dir = getExistingDir(globalOpts);
|
|
633
|
+
|
|
634
|
+
checkLockout(dir);
|
|
635
|
+
|
|
636
|
+
if (!readJsonFile(authFile(dir))) {
|
|
637
|
+
fail(
|
|
638
|
+
'no PIN registered. ' +
|
|
639
|
+
'Try: vpn-tool --dir <path> auth register'
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const pin = await promptPin('Enter PIN: ');
|
|
644
|
+
|
|
645
|
+
await verifyPin(dir, pin);
|
|
646
|
+
|
|
647
|
+
const authData = loadAuth(dir);
|
|
648
|
+
|
|
649
|
+
authData.logged_in = true;
|
|
650
|
+
authData.last_login = new Date().toISOString();
|
|
651
|
+
|
|
652
|
+
writeJsonFile(
|
|
653
|
+
authFile(dir),
|
|
654
|
+
authData
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
const data = {
|
|
658
|
+
success: true,
|
|
659
|
+
logged_in: true
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
if (isJson(cmd.opts(), globalOpts)) {
|
|
663
|
+
output(data, true);
|
|
664
|
+
} else {
|
|
665
|
+
process.stdout.write(
|
|
666
|
+
'Login successful.\n'
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
auth
|
|
672
|
+
.command('status')
|
|
673
|
+
.description('Authentication status')
|
|
674
|
+
.action(async (_, cmd) => {
|
|
675
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
676
|
+
const dir = getExistingDir(globalOpts);
|
|
677
|
+
|
|
678
|
+
checkLockout(dir);
|
|
679
|
+
|
|
680
|
+
const authData = loadAuth(dir);
|
|
681
|
+
const lock = readJsonFile(lockFile(dir));
|
|
682
|
+
|
|
683
|
+
const data = {
|
|
684
|
+
registered: authData !== null,
|
|
685
|
+
logged_in: loggedIn(dir),
|
|
686
|
+
directory: dir,
|
|
687
|
+
lockout: {
|
|
688
|
+
locked: Boolean(
|
|
689
|
+
lock &&
|
|
690
|
+
lock.locked_until &&
|
|
691
|
+
Number(lock.locked_until) >
|
|
692
|
+
Math.floor(Date.now() / 1000)
|
|
693
|
+
),
|
|
694
|
+
attempts: Number(
|
|
695
|
+
lock?.attempts || 0
|
|
696
|
+
)
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
if (authData) {
|
|
701
|
+
data.created_at =
|
|
702
|
+
authData.created_at || null;
|
|
703
|
+
|
|
704
|
+
data.last_login =
|
|
705
|
+
authData.last_login || null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
output(
|
|
709
|
+
data,
|
|
710
|
+
isJson(cmd.opts(), globalOpts)
|
|
711
|
+
);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
auth
|
|
715
|
+
.command('logout')
|
|
716
|
+
.description('Logout')
|
|
717
|
+
.action(async (_, cmd) => {
|
|
718
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
719
|
+
const dir = getExistingDir(globalOpts);
|
|
720
|
+
|
|
721
|
+
if (!readJsonFile(authFile(dir))) {
|
|
722
|
+
fail('not logged in');
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const authData = loadAuth(dir);
|
|
726
|
+
|
|
727
|
+
authData.logged_in = false;
|
|
728
|
+
authData.last_logout = new Date().toISOString();
|
|
729
|
+
|
|
730
|
+
writeJsonFile(
|
|
731
|
+
authFile(dir),
|
|
732
|
+
authData
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
const data = {
|
|
736
|
+
success: true,
|
|
737
|
+
logged_in: false
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
if (isJson(cmd.opts(), globalOpts)) {
|
|
741
|
+
output(data, true);
|
|
742
|
+
} else {
|
|
743
|
+
process.stdout.write(
|
|
744
|
+
'Logged out successfully.\n'
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
auth
|
|
750
|
+
.command('reset')
|
|
751
|
+
.description('Change PIN')
|
|
752
|
+
.action(async (_, cmd) => {
|
|
753
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
754
|
+
const dir = getExistingDir(globalOpts);
|
|
755
|
+
|
|
756
|
+
checkLockout(dir);
|
|
757
|
+
|
|
758
|
+
if (!readJsonFile(authFile(dir))) {
|
|
759
|
+
fail(
|
|
760
|
+
'no PIN registered. ' +
|
|
761
|
+
'Try: vpn-tool --dir <path> auth register'
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const oldPin = await promptPin(
|
|
766
|
+
'Enter current PIN: '
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
await verifyPin(dir, oldPin);
|
|
770
|
+
|
|
771
|
+
await registerPin(dir, true);
|
|
772
|
+
|
|
773
|
+
const data = {
|
|
774
|
+
success: true,
|
|
775
|
+
message: 'PIN reset successfully'
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
if (isJson(cmd.opts(), globalOpts)) {
|
|
779
|
+
output(data, true);
|
|
780
|
+
} else {
|
|
781
|
+
process.stdout.write(
|
|
782
|
+
'PIN reset successfully.\n'
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
auth
|
|
788
|
+
.command('remove')
|
|
789
|
+
.description('Remove PIN')
|
|
790
|
+
.action(async (_, cmd) => {
|
|
791
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
792
|
+
const dir = getExistingDir(globalOpts);
|
|
793
|
+
|
|
794
|
+
checkLockout(dir);
|
|
795
|
+
|
|
796
|
+
if (!readJsonFile(authFile(dir))) {
|
|
797
|
+
fail('no stored PIN found');
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const pin = await promptPin(
|
|
801
|
+
'Enter PIN to confirm removal: '
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
await verifyPin(dir, pin);
|
|
805
|
+
|
|
806
|
+
const af = authFile(dir);
|
|
807
|
+
|
|
808
|
+
if (fs.existsSync(af)) {
|
|
809
|
+
fs.unlinkSync(af);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const lf = lockFile(dir);
|
|
813
|
+
|
|
814
|
+
if (fs.existsSync(lf)) {
|
|
815
|
+
fs.unlinkSync(lf);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const data = {
|
|
819
|
+
success: true,
|
|
820
|
+
registered: false
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
if (isJson(cmd.opts(), globalOpts)) {
|
|
824
|
+
output(data, true);
|
|
825
|
+
} else {
|
|
826
|
+
process.stdout.write(
|
|
827
|
+
'Stored PIN removed.\n'
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
auth
|
|
833
|
+
.command('list')
|
|
834
|
+
.description('List stored PIN metadata')
|
|
835
|
+
.action(async (_, cmd) => {
|
|
836
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
837
|
+
const dir = getExistingDir(globalOpts);
|
|
838
|
+
|
|
839
|
+
const authData = loadAuth(dir);
|
|
840
|
+
|
|
841
|
+
if (!authData) {
|
|
842
|
+
output(
|
|
843
|
+
{
|
|
844
|
+
stored: false,
|
|
845
|
+
pins: []
|
|
846
|
+
},
|
|
847
|
+
isJson(cmd.opts(), globalOpts)
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
output(
|
|
854
|
+
{
|
|
855
|
+
stored: true,
|
|
856
|
+
pins: [
|
|
857
|
+
{
|
|
858
|
+
created_at:
|
|
859
|
+
authData.created_at || null,
|
|
860
|
+
logged_in:
|
|
861
|
+
Boolean(authData.logged_in),
|
|
862
|
+
last_login:
|
|
863
|
+
authData.last_login || null
|
|
864
|
+
}
|
|
865
|
+
]
|
|
866
|
+
},
|
|
867
|
+
isJson(cmd.opts(), globalOpts)
|
|
868
|
+
);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
program.parse(process.argv);
|