polaris-vpn 0.2.0 → 0.3.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/CHANGELOG.md +16 -0
- package/package.json +5 -2
- package/src/cli.js +104 -3
- package/src/commands/deploy.js +57 -0
- package/src/commands/peer.js +121 -0
- package/src/commands/start.js +64 -27
- package/src/commands/stop.js +1 -1
- package/src/core/deploy-service.js +150 -0
- package/src/core/peer-service.js +238 -0
- package/src/core/tunnel-service.js +53 -5
- package/src/tunnel/wg.js +59 -0
- package/src/utils/kill-switch.js +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.1] - 2026-06-28
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Gracefully handled interactive `sudo` password prompts during WireGuard configuration when running in `--json` mode.
|
|
12
|
+
- Improved robust error handling for `wg-quick` start/stop routines.
|
|
13
|
+
|
|
14
|
+
## [0.3.0] - 2026-06-28
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- Automated system-wide VPN deployment command (`polaris deploy --server user@host`) for Ubuntu 22.04.
|
|
18
|
+
- Peer command namespace (`polaris peer add/list/remove/qr`) to manage multiple WireGuard client connections.
|
|
19
|
+
- Terminal QR code generation (`polaris peer qr <name>`) using `qrcode-terminal` for mobile client pairing.
|
|
20
|
+
- OS-level Network Kill Switch (`polaris config set kill-switch true`) for Linux (`iptables`) and macOS (`pfctl`) interfaces.
|
|
21
|
+
- Pure JavaScript base64 Curve25519/X25519 key derivation helper using standard Node.js `crypto` (no local binary `wg` CLI command dependency).
|
|
22
|
+
- Integrated smart auto-detection mode fallback sequence (`wireguard` -> `tls` -> `ssh`) for client tunnel startup.
|
|
23
|
+
|
|
8
24
|
## [0.2.0] - 2026-06-28
|
|
9
25
|
|
|
10
26
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polaris-vpn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Your True North in Digital Privacy. A self-hosted VPN CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"node-fetch": "^3.3.2",
|
|
41
41
|
"node-notifier": "^10.0.1",
|
|
42
42
|
"ora": "^9.4.1",
|
|
43
|
-
"
|
|
43
|
+
"qrcode-terminal": "^0.12.0",
|
|
44
|
+
"socks-proxy-agent": "^10.1.0",
|
|
45
|
+
"ssh2": "^1.17.0",
|
|
46
|
+
"wireguard-tools": "^0.1.0"
|
|
44
47
|
}
|
|
45
48
|
}
|
package/src/cli.js
CHANGED
|
@@ -22,10 +22,10 @@ const willPrintJson = () => process.argv.includes('--json');
|
|
|
22
22
|
|
|
23
23
|
program
|
|
24
24
|
.command('start')
|
|
25
|
-
.description('Start the encrypted SSH or
|
|
26
|
-
.option('-s, --server <user@host>', 'SSH/TLS server to connect to')
|
|
25
|
+
.description('Start the encrypted SSH, TLS or WireGuard tunnel')
|
|
26
|
+
.option('-s, --server <user@host>', 'SSH/TLS/WireGuard server to connect to')
|
|
27
27
|
.option('-p, --port <number>', 'Local SOCKS5 port to bind', '1080')
|
|
28
|
-
.option('-m, --mode <type>', 'Tunnel mode: ssh, tls or auto', 'auto')
|
|
28
|
+
.option('-m, --mode <type>', 'Tunnel mode: ssh, tls, wireguard or auto', 'auto')
|
|
29
29
|
.action(async (options, cmd) => {
|
|
30
30
|
if (!cmd.optsWithGlobals().json) printBanner();
|
|
31
31
|
try {
|
|
@@ -168,6 +168,107 @@ dnsCmd
|
|
|
168
168
|
}
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
program
|
|
172
|
+
.command('deploy')
|
|
173
|
+
.description('Provision a fresh Ubuntu server as a WireGuard server')
|
|
174
|
+
.requiredOption('-s, --server <user@host>', 'SSH server to configure')
|
|
175
|
+
.option('-i, --identity <path>', 'SSH private key file path')
|
|
176
|
+
.option('-p, --password <password>', 'SSH password (alternative to identity)')
|
|
177
|
+
.action(async (options, cmd) => {
|
|
178
|
+
if (!cmd.optsWithGlobals().json) printBanner();
|
|
179
|
+
try {
|
|
180
|
+
const run = (await import('./commands/deploy.js')).default;
|
|
181
|
+
await run(cmd.optsWithGlobals());
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (!cmd.optsWithGlobals().json) printError('Command failed', err);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const peerCmd = program.command('peer').description('Manage WireGuard client peers');
|
|
189
|
+
|
|
190
|
+
peerCmd
|
|
191
|
+
.command('add <name>')
|
|
192
|
+
.description('Generate and add a new peer config')
|
|
193
|
+
.action(async (name, options, cmd) => {
|
|
194
|
+
if (!cmd.optsWithGlobals().json) printBanner();
|
|
195
|
+
try {
|
|
196
|
+
const { peerAdd } = await import('./commands/peer.js');
|
|
197
|
+
await peerAdd(name, cmd.optsWithGlobals());
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (!cmd.optsWithGlobals().json) printError('Command failed', err);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
peerCmd
|
|
205
|
+
.command('list')
|
|
206
|
+
.description('List all active peer configurations')
|
|
207
|
+
.action(async (options, cmd) => {
|
|
208
|
+
if (!cmd.optsWithGlobals().json) printBanner();
|
|
209
|
+
try {
|
|
210
|
+
const { peerList } = await import('./commands/peer.js');
|
|
211
|
+
await peerList(cmd.optsWithGlobals());
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (!cmd.optsWithGlobals().json) printError('Command failed', err);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
peerCmd
|
|
219
|
+
.command('remove <name>')
|
|
220
|
+
.description('Revoke a peer from the server')
|
|
221
|
+
.action(async (name, options, cmd) => {
|
|
222
|
+
if (!cmd.optsWithGlobals().json) printBanner();
|
|
223
|
+
try {
|
|
224
|
+
const { peerRemove } = await import('./commands/peer.js');
|
|
225
|
+
await peerRemove(name, cmd.optsWithGlobals());
|
|
226
|
+
} catch (err) {
|
|
227
|
+
if (!cmd.optsWithGlobals().json) printError('Command failed', err);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
peerCmd
|
|
233
|
+
.command('qr <name>')
|
|
234
|
+
.description('Show peer configuration QR code')
|
|
235
|
+
.action(async (name, options, cmd) => {
|
|
236
|
+
try {
|
|
237
|
+
const { peerQr } = await import('./commands/peer.js');
|
|
238
|
+
await peerQr(name, cmd.optsWithGlobals());
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (!cmd.optsWithGlobals().json) printError('Command failed', err);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const configCmd = program.command('config').description('Configure polaris settings');
|
|
246
|
+
configCmd
|
|
247
|
+
.command('set <key> <value>')
|
|
248
|
+
.description('Set a configuration setting (e.g. kill-switch true)')
|
|
249
|
+
.action(async (key, value, options, cmd) => {
|
|
250
|
+
const isJson = cmd.optsWithGlobals().json;
|
|
251
|
+
if (!isJson) printBanner();
|
|
252
|
+
try {
|
|
253
|
+
if (key === 'kill-switch') {
|
|
254
|
+
const val = value === 'true';
|
|
255
|
+
const { setKillSwitchConfig } = await import('./utils/kill-switch.js');
|
|
256
|
+
setKillSwitchConfig(val);
|
|
257
|
+
const { printSuccess } = await import('./utils/display.js');
|
|
258
|
+
if (!isJson) {
|
|
259
|
+
printSuccess(`Set config 'kill-switch' to ${val}`);
|
|
260
|
+
} else {
|
|
261
|
+
console.log(JSON.stringify({ success: true, key, value: val }));
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
throw new Error(`Unknown config key '${key}'`);
|
|
265
|
+
}
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (!isJson) printError('Command failed', err);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
171
272
|
program.parseAsync(process.argv).catch(err => {
|
|
172
273
|
if (!willPrintJson()) printError('Fatal error', err);
|
|
173
274
|
process.exit(1);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { printSuccess, printError, createSpinner, printInfo } from '../utils/display.js';
|
|
3
|
+
import { deployServer } from '../core/deploy-service.js';
|
|
4
|
+
import startCommand from './start.js';
|
|
5
|
+
|
|
6
|
+
export default async (options) => {
|
|
7
|
+
const isJson = options.json;
|
|
8
|
+
const server = options.server;
|
|
9
|
+
|
|
10
|
+
if (!server) {
|
|
11
|
+
if (isJson) {
|
|
12
|
+
console.log(JSON.stringify({ error: 'Missing --server argument' }));
|
|
13
|
+
} else {
|
|
14
|
+
printError('You must specify a server with --server <user@host>');
|
|
15
|
+
}
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const spinner = isJson ? null : createSpinner('Initiating server deployment...').start();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const res = await deployServer(server, {
|
|
24
|
+
privateKey: options.identity,
|
|
25
|
+
password: options.password,
|
|
26
|
+
onProgress: (msg) => {
|
|
27
|
+
if (spinner) spinner.text = msg;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (spinner) {
|
|
32
|
+
spinner.succeed('Server provisioning completed successfully!');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (isJson) {
|
|
36
|
+
console.log(JSON.stringify({ success: true, ...res }));
|
|
37
|
+
} else {
|
|
38
|
+
printSuccess(`WireGuard configured on remote VPS ${server}`);
|
|
39
|
+
printInfo(`Local Client Config: ${res.clientConfPath}`);
|
|
40
|
+
printInfo(`Client Public Key : ${res.clientPublicKey}`);
|
|
41
|
+
printInfo(`Server Public Key : ${res.serverPublicKey}`);
|
|
42
|
+
console.log(chalk.cyan('\nStarting WireGuard tunnel connection locally...\n'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Automatically trigger local connection
|
|
46
|
+
await startCommand({ mode: 'wireguard', json: isJson });
|
|
47
|
+
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (spinner) spinner.fail('Deployment failed');
|
|
50
|
+
if (isJson) {
|
|
51
|
+
console.log(JSON.stringify({ error: err.message }));
|
|
52
|
+
} else {
|
|
53
|
+
printError('Deployment failed', err);
|
|
54
|
+
}
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import qrcode from 'qrcode-terminal';
|
|
4
|
+
import { printSuccess, printError, createSpinner, printInfo, createTable } from '../utils/display.js';
|
|
5
|
+
import { addPeer, listPeers, removePeer, getLocalPeerConfPath } from '../core/peer-service.js';
|
|
6
|
+
|
|
7
|
+
export const peerAdd = async (name, options) => {
|
|
8
|
+
const isJson = options.json;
|
|
9
|
+
const spinner = isJson ? null : createSpinner(`Creating peer '${name}' on server...`).start();
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const res = await addPeer(name);
|
|
13
|
+
if (spinner) spinner.succeed(`Peer '${name}' created successfully!`);
|
|
14
|
+
|
|
15
|
+
if (isJson) {
|
|
16
|
+
console.log(JSON.stringify({ success: true, ...res }));
|
|
17
|
+
} else {
|
|
18
|
+
printSuccess(`Saved local peer profile: ${res.confPath}`);
|
|
19
|
+
printInfo(`IP Assigned: ${res.ip}`);
|
|
20
|
+
printInfo(`Public Key : ${res.publicKey}`);
|
|
21
|
+
console.log(chalk.cyan('\nScan this QR code with the WireGuard app on your mobile device:\n'));
|
|
22
|
+
|
|
23
|
+
const confString = fs.readFileSync(res.confPath, 'utf-8');
|
|
24
|
+
qrcode.generate(confString, { small: true });
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
if (spinner) spinner.fail('Failed to add peer');
|
|
28
|
+
if (isJson) {
|
|
29
|
+
console.log(JSON.stringify({ error: err.message }));
|
|
30
|
+
} else {
|
|
31
|
+
printError('Failed to add peer', err);
|
|
32
|
+
}
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const peerList = async (options) => {
|
|
38
|
+
const isJson = options.json;
|
|
39
|
+
const spinner = isJson ? null : createSpinner('Fetching peer list...').start();
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const peers = await listPeers();
|
|
43
|
+
if (spinner) spinner.stop();
|
|
44
|
+
|
|
45
|
+
if (isJson) {
|
|
46
|
+
console.log(JSON.stringify({ success: true, peers }));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (peers.length === 0) {
|
|
51
|
+
printInfo('No peers configured on server.');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const table = createTable(['Name', 'Assigned IP', 'Latest Handshake', 'Transfer Received/Sent']);
|
|
56
|
+
|
|
57
|
+
for (const p of peers) {
|
|
58
|
+
table.push([
|
|
59
|
+
chalk.green.bold(p.name),
|
|
60
|
+
p.ip,
|
|
61
|
+
p.handshake,
|
|
62
|
+
`${p.transferRx} / ${p.transferTx}`
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log('\n' + table.toString() + '\n');
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (spinner) spinner.stop();
|
|
69
|
+
if (isJson) {
|
|
70
|
+
console.log(JSON.stringify({ error: err.message }));
|
|
71
|
+
} else {
|
|
72
|
+
printError('Failed to list peers', err);
|
|
73
|
+
}
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const peerRemove = async (name, options) => {
|
|
79
|
+
const isJson = options.json;
|
|
80
|
+
const spinner = isJson ? null : createSpinner(`Revoking peer '${name}'...`).start();
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await removePeer(name);
|
|
84
|
+
if (spinner) spinner.succeed(`Peer '${name}' revoked from server.`);
|
|
85
|
+
|
|
86
|
+
if (isJson) {
|
|
87
|
+
console.log(JSON.stringify({ success: true }));
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (spinner) spinner.fail('Failed to remove peer');
|
|
91
|
+
if (isJson) {
|
|
92
|
+
console.log(JSON.stringify({ error: err.message }));
|
|
93
|
+
} else {
|
|
94
|
+
printError('Failed to remove peer', err);
|
|
95
|
+
}
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const peerQr = async (name, options) => {
|
|
101
|
+
const isJson = options.json;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const confPath = getLocalPeerConfPath(name);
|
|
105
|
+
const confString = fs.readFileSync(confPath, 'utf-8');
|
|
106
|
+
|
|
107
|
+
if (isJson) {
|
|
108
|
+
console.log(JSON.stringify({ success: true, config: confString }));
|
|
109
|
+
} else {
|
|
110
|
+
console.log(chalk.cyan(`\nQR code for peer configuration '${name}':\n`));
|
|
111
|
+
qrcode.generate(confString, { small: true });
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (isJson) {
|
|
115
|
+
console.log(JSON.stringify({ error: err.message }));
|
|
116
|
+
} else {
|
|
117
|
+
printError('Failed to retrieve QR code', err);
|
|
118
|
+
}
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
}
|
|
121
|
+
};
|
package/src/commands/start.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import tls from 'tls';
|
|
3
|
-
import
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
6
|
+
import { printError, printSuccess, createSpinner, printInfo } from '../utils/display.js';
|
|
4
7
|
import { getPublicIp, getProxiedIp } from '../net/ip-check.js';
|
|
5
8
|
import { getProfiles } from '../core/profile-service.js';
|
|
6
9
|
import { getActiveTunnel, startTunnel } from '../core/tunnel-service.js';
|
|
10
|
+
import { CONFIG_DIR } from '../utils/config.js';
|
|
11
|
+
|
|
12
|
+
const WG_CONF = path.join(CONFIG_DIR, 'wg', 'wg0.conf');
|
|
7
13
|
|
|
8
14
|
const detectTlsServer = (host, port = 8443) => {
|
|
9
15
|
return new Promise((resolve) => {
|
|
@@ -28,6 +34,15 @@ const detectTlsServer = (host, port = 8443) => {
|
|
|
28
34
|
});
|
|
29
35
|
};
|
|
30
36
|
|
|
37
|
+
const hasWgQuick = () => {
|
|
38
|
+
try {
|
|
39
|
+
const res = spawnSync('which', ['wg-quick'], { encoding: 'utf-8' });
|
|
40
|
+
return res.status === 0;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
31
46
|
export default async (options) => {
|
|
32
47
|
const isJson = options.json;
|
|
33
48
|
let server = options.server;
|
|
@@ -55,7 +70,7 @@ export default async (options) => {
|
|
|
55
70
|
if (isJson) {
|
|
56
71
|
console.log(JSON.stringify({ error: 'Tunnel already running', pid: info.pid }));
|
|
57
72
|
} else {
|
|
58
|
-
printError(`Tunnel is already running
|
|
73
|
+
printError(`Tunnel is already running connected to ${info.server}. Run "polaris stop" first.`);
|
|
59
74
|
}
|
|
60
75
|
process.exitCode = 1;
|
|
61
76
|
return;
|
|
@@ -74,17 +89,21 @@ export default async (options) => {
|
|
|
74
89
|
let actualMode = requestedMode;
|
|
75
90
|
|
|
76
91
|
if (requestedMode === 'auto') {
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
spinner.start();
|
|
80
|
-
}
|
|
81
|
-
const hasTls = await detectTlsServer(hostPart, 8443);
|
|
82
|
-
if (hasTls) {
|
|
83
|
-
actualMode = 'tls';
|
|
92
|
+
if (fs.existsSync(WG_CONF) && hasWgQuick()) {
|
|
93
|
+
actualMode = 'wireguard';
|
|
84
94
|
} else {
|
|
85
|
-
actualMode = 'ssh';
|
|
86
95
|
if (!isJson) {
|
|
87
|
-
spinner.
|
|
96
|
+
spinner.text = 'Checking server for TLS support...';
|
|
97
|
+
spinner.start();
|
|
98
|
+
}
|
|
99
|
+
const hasTls = await detectTlsServer(hostPart, 8443);
|
|
100
|
+
if (hasTls) {
|
|
101
|
+
actualMode = 'tls';
|
|
102
|
+
} else {
|
|
103
|
+
actualMode = 'ssh';
|
|
104
|
+
if (!isJson) {
|
|
105
|
+
spinner.info('TLS server not detected. Falling back to SSH mode...');
|
|
106
|
+
}
|
|
88
107
|
}
|
|
89
108
|
}
|
|
90
109
|
}
|
|
@@ -94,25 +113,43 @@ export default async (options) => {
|
|
|
94
113
|
spinner.start();
|
|
95
114
|
}
|
|
96
115
|
|
|
97
|
-
const res = await startTunnel(server, port, actualMode);
|
|
98
|
-
|
|
99
|
-
if (!isJson) {
|
|
100
|
-
spinner.text = 'Verifying IP through proxy...';
|
|
101
|
-
}
|
|
116
|
+
const res = await startTunnel(server, port, actualMode, isJson);
|
|
102
117
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
if (actualMode === 'wireguard') {
|
|
119
|
+
if (!isJson) {
|
|
120
|
+
spinner.text = 'Waiting for interface to configure...';
|
|
121
|
+
}
|
|
122
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
123
|
+
|
|
124
|
+
if (!isJson) {
|
|
125
|
+
spinner.text = 'Verifying system-wide IP...';
|
|
126
|
+
}
|
|
127
|
+
const newIp = await getPublicIp();
|
|
111
128
|
|
|
112
|
-
|
|
113
|
-
|
|
129
|
+
if (!isJson) {
|
|
130
|
+
spinner.stop();
|
|
131
|
+
printSuccess(`WireGuard full tunnel established successfully to ${server}`);
|
|
132
|
+
console.log(`\n ${chalk.dim('Old IP:')} ${chalk.red(oldIp)}`);
|
|
133
|
+
console.log(` ${chalk.dim('New IP:')} ${chalk.green(newIp)}`);
|
|
134
|
+
console.log(` ${chalk.dim('Routing:')} System-wide (All OS traffic)`);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(JSON.stringify({ success: true, oldIp, newIp, mode: 'wireguard', pid: res.pid }));
|
|
137
|
+
}
|
|
114
138
|
} else {
|
|
115
|
-
|
|
139
|
+
if (!isJson) {
|
|
140
|
+
spinner.text = 'Verifying IP through proxy...';
|
|
141
|
+
}
|
|
142
|
+
const newIp = await getProxiedIp(port);
|
|
143
|
+
|
|
144
|
+
if (!isJson) {
|
|
145
|
+
spinner.stop();
|
|
146
|
+
printSuccess(`Tunnel established successfully to ${server} (${actualMode.toUpperCase()} mode)`);
|
|
147
|
+
console.log(`\n ${chalk.dim('Old IP:')} ${chalk.red(oldIp)}`);
|
|
148
|
+
console.log(` ${chalk.dim('New IP:')} ${chalk.green(newIp)}`);
|
|
149
|
+
console.log(` ${chalk.dim('Proxy :')} ${chalk.cyan(`socks5://127.0.0.1:${port}`)}`);
|
|
150
|
+
} else {
|
|
151
|
+
console.log(JSON.stringify({ success: true, oldIp, newIp, proxy: `socks5://127.0.0.1:${port}`, pid: res.pid, mode: actualMode }));
|
|
152
|
+
}
|
|
116
153
|
}
|
|
117
154
|
|
|
118
155
|
} catch (err) {
|
package/src/commands/stop.js
CHANGED
|
@@ -5,7 +5,7 @@ export default async (options) => {
|
|
|
5
5
|
const isJson = options.json;
|
|
6
6
|
|
|
7
7
|
try {
|
|
8
|
-
const info = stopActiveTunnel();
|
|
8
|
+
const info = stopActiveTunnel(isJson);
|
|
9
9
|
if (!info) {
|
|
10
10
|
if (isJson) {
|
|
11
11
|
console.log(JSON.stringify({ success: true, message: 'No active tunnel found.' }));
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Client } from 'ssh2';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { generateKeyPair } from '../tunnel/wg.js';
|
|
6
|
+
import { ensureDir, CONFIG_DIR } from '../utils/config.js';
|
|
7
|
+
|
|
8
|
+
const getDefaultPrivateKey = () => {
|
|
9
|
+
const keys = ['id_rsa', 'id_ed25519', 'id_ecdsa', 'id_dsa'];
|
|
10
|
+
for (const k of keys) {
|
|
11
|
+
const p = path.join(os.homedir(), '.ssh', k);
|
|
12
|
+
if (fs.existsSync(p)) {
|
|
13
|
+
return fs.readFileSync(p);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const sshExec = (client, command) => {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
client.exec(command, (err, stream) => {
|
|
22
|
+
if (err) return reject(err);
|
|
23
|
+
let stdout = '';
|
|
24
|
+
let stderr = '';
|
|
25
|
+
stream.on('close', (code) => {
|
|
26
|
+
resolve({ code, stdout, stderr });
|
|
27
|
+
}).on('data', (data) => {
|
|
28
|
+
stdout += data.toString('utf8');
|
|
29
|
+
}).stderr.on('data', (data) => {
|
|
30
|
+
stderr += data.toString('utf8');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const deployServer = async (serverStr, options = {}) => {
|
|
37
|
+
const parts = serverStr.split('@');
|
|
38
|
+
const username = parts.length > 1 ? parts[0] : 'ubuntu';
|
|
39
|
+
const host = parts.length > 1 ? parts[1] : parts[0];
|
|
40
|
+
|
|
41
|
+
const privateKey = options.privateKey
|
|
42
|
+
? fs.readFileSync(options.privateKey)
|
|
43
|
+
: getDefaultPrivateKey();
|
|
44
|
+
|
|
45
|
+
if (!privateKey && !options.password) {
|
|
46
|
+
throw new Error('No SSH authentication method found. Please configure default SSH keys or specify key path/password.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 1. Generate local keys
|
|
50
|
+
const serverKeys = generateKeyPair();
|
|
51
|
+
const clientKeys = generateKeyPair();
|
|
52
|
+
|
|
53
|
+
const conn = new Client();
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
conn.on('ready', async () => {
|
|
57
|
+
try {
|
|
58
|
+
const onProgress = options.onProgress || (() => {});
|
|
59
|
+
|
|
60
|
+
// Step 1: Install packages
|
|
61
|
+
onProgress('Installing WireGuard and UFW on remote VPS...');
|
|
62
|
+
let res = await sshExec(conn, 'sudo apt-get update -y && sudo apt-get install -y wireguard ufw');
|
|
63
|
+
if (res.code !== 0) throw new Error(`Installation failed: ${res.stderr}`);
|
|
64
|
+
|
|
65
|
+
// Step 2: Enable packet forwarding
|
|
66
|
+
onProgress('Enabling IPv4 forwarding...');
|
|
67
|
+
res = await sshExec(conn, 'sudo sysctl -w net.ipv4.ip_forward=1 && echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf');
|
|
68
|
+
if (res.code !== 0) throw new Error(`IPv4 forwarding setup failed: ${res.stderr}`);
|
|
69
|
+
|
|
70
|
+
// Step 3: Detect default interface
|
|
71
|
+
onProgress('Detecting default interface...');
|
|
72
|
+
res = await sshExec(conn, "ip route show default | awk '/default/ {print $5}'");
|
|
73
|
+
const ethInterface = res.stdout.trim() || 'eth0';
|
|
74
|
+
|
|
75
|
+
// Step 4: Write server config
|
|
76
|
+
onProgress('Configuring WireGuard server wg0...');
|
|
77
|
+
const serverConf = `[Interface]
|
|
78
|
+
PrivateKey = ${serverKeys.privateKey}
|
|
79
|
+
Address = 10.0.0.1/24
|
|
80
|
+
ListenPort = 51820
|
|
81
|
+
PostUp = ufw route allow in on wg0; iptables -t nat -A POSTROUTING -o ${ethInterface} -j MASQUERADE
|
|
82
|
+
PostDown = ufw route delete allow in on wg0; iptables -t nat -D POSTROUTING -o ${ethInterface} -j MASQUERADE
|
|
83
|
+
|
|
84
|
+
[Peer]
|
|
85
|
+
PublicKey = ${clientKeys.publicKey}
|
|
86
|
+
AllowedIPs = 10.0.0.2/32
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
res = await sshExec(conn, `cat << 'EOF' > /tmp/wg0.conf\n${serverConf}\nEOF\nsudo mv /tmp/wg0.conf /etc/wireguard/wg0.conf && sudo chmod 600 /etc/wireguard/wg0.conf`);
|
|
90
|
+
if (res.code !== 0) throw new Error(`Server config write failed: ${res.stderr}`);
|
|
91
|
+
|
|
92
|
+
// Step 5: Start service
|
|
93
|
+
onProgress('Starting WireGuard interface...');
|
|
94
|
+
res = await sshExec(conn, 'sudo systemctl stop wg-quick@wg0 || true && sudo systemctl start wg-quick@wg0 && sudo systemctl enable wg-quick@wg0');
|
|
95
|
+
if (res.code !== 0) throw new Error(`Starting WireGuard failed: ${res.stderr}`);
|
|
96
|
+
|
|
97
|
+
// Step 6: Configure UFW firewall
|
|
98
|
+
onProgress('Configuring UFW firewall...');
|
|
99
|
+
res = await sshExec(conn, 'sudo ufw allow 51820/udp && sudo ufw allow 22/tcp && echo "y" | sudo ufw enable');
|
|
100
|
+
if (res.code !== 0) throw new Error(`Firewall setup failed: ${res.stderr}`);
|
|
101
|
+
|
|
102
|
+
// Step 7: Write client config locally
|
|
103
|
+
onProgress('Saving local client configuration...');
|
|
104
|
+
const clientConf = `[Interface]
|
|
105
|
+
PrivateKey = ${clientKeys.privateKey}
|
|
106
|
+
Address = 10.0.0.2/24
|
|
107
|
+
DNS = 1.1.1.1
|
|
108
|
+
|
|
109
|
+
[Peer]
|
|
110
|
+
PublicKey = ${serverKeys.publicKey}
|
|
111
|
+
Endpoint = ${host}:51820
|
|
112
|
+
AllowedIPs = 0.0.0.0/0
|
|
113
|
+
PersistentKeepalive = 25
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
const wgDir = path.join(CONFIG_DIR, 'wg');
|
|
117
|
+
ensureDir(wgDir);
|
|
118
|
+
|
|
119
|
+
// Save client config
|
|
120
|
+
const clientConfPath = path.join(wgDir, 'wg0.conf');
|
|
121
|
+
fs.writeFileSync(clientConfPath, clientConf, 'utf-8');
|
|
122
|
+
|
|
123
|
+
// Also save provisioning info to config
|
|
124
|
+
fs.writeFileSync(path.join(wgDir, 'deploy.json'), JSON.stringify({
|
|
125
|
+
server: serverStr,
|
|
126
|
+
serverPublicKey: serverKeys.publicKey,
|
|
127
|
+
clientPublicKey: clientKeys.publicKey,
|
|
128
|
+
interface: ethInterface,
|
|
129
|
+
timestamp: new Date().toISOString()
|
|
130
|
+
}, null, 2), 'utf-8');
|
|
131
|
+
|
|
132
|
+
conn.end();
|
|
133
|
+
resolve({ clientConfPath, clientPublicKey: clientKeys.publicKey, serverPublicKey: serverKeys.publicKey });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
conn.end();
|
|
136
|
+
reject(err);
|
|
137
|
+
}
|
|
138
|
+
}).on('error', (err) => {
|
|
139
|
+
reject(err);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
conn.connect({
|
|
143
|
+
host,
|
|
144
|
+
port: options.port || 22,
|
|
145
|
+
username,
|
|
146
|
+
privateKey,
|
|
147
|
+
password: options.password
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { Client } from 'ssh2';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { generateKeyPair } from '../tunnel/wg.js';
|
|
6
|
+
import { CONFIG_DIR, ensureDir } from '../utils/config.js';
|
|
7
|
+
|
|
8
|
+
const DEPLOY_JSON = path.join(CONFIG_DIR, 'wg', 'deploy.json');
|
|
9
|
+
|
|
10
|
+
const getDeployInfo = () => {
|
|
11
|
+
if (!fs.existsSync(DEPLOY_JSON)) {
|
|
12
|
+
throw new Error('No deployment found. Please deploy a server first using "polaris deploy".');
|
|
13
|
+
}
|
|
14
|
+
return JSON.parse(fs.readFileSync(DEPLOY_JSON, 'utf-8'));
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const getDefaultPrivateKey = () => {
|
|
18
|
+
const keys = ['id_rsa', 'id_ed25519', 'id_ecdsa', 'id_dsa'];
|
|
19
|
+
for (const k of keys) {
|
|
20
|
+
const p = path.join(os.homedir(), '.ssh', k);
|
|
21
|
+
if (fs.existsSync(p)) {
|
|
22
|
+
return fs.readFileSync(p);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const sshConnect = (info) => {
|
|
29
|
+
const parts = info.server.split('@');
|
|
30
|
+
const username = parts.length > 1 ? parts[0] : 'ubuntu';
|
|
31
|
+
const host = parts.length > 1 ? parts[1] : parts[0];
|
|
32
|
+
|
|
33
|
+
const privateKey = getDefaultPrivateKey();
|
|
34
|
+
|
|
35
|
+
const conn = new Client();
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
conn.on('ready', () => resolve(conn))
|
|
38
|
+
.on('error', (err) => reject(err));
|
|
39
|
+
conn.connect({
|
|
40
|
+
host,
|
|
41
|
+
port: 22,
|
|
42
|
+
username,
|
|
43
|
+
privateKey
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const sshExec = (client, command) => {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
client.exec(command, (err, stream) => {
|
|
51
|
+
if (err) return reject(err);
|
|
52
|
+
let stdout = '';
|
|
53
|
+
let stderr = '';
|
|
54
|
+
stream.on('close', (code) => {
|
|
55
|
+
resolve({ code, stdout, stderr });
|
|
56
|
+
}).on('data', (data) => {
|
|
57
|
+
stdout += data.toString('utf8');
|
|
58
|
+
}).stderr.on('data', (data) => {
|
|
59
|
+
stderr += data.toString('utf8');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const addPeer = async (name) => {
|
|
66
|
+
const info = getDeployInfo();
|
|
67
|
+
const conn = await sshConnect(info);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// 1. Read remote wg0.conf
|
|
71
|
+
const catRes = await sshExec(conn, 'sudo cat /etc/wireguard/wg0.conf');
|
|
72
|
+
if (catRes.code !== 0) throw new Error(`Failed to read server config: ${catRes.stderr}`);
|
|
73
|
+
const configContent = catRes.stdout;
|
|
74
|
+
|
|
75
|
+
// Check if name already exists
|
|
76
|
+
if (configContent.includes(`# Name: ${name}\n`) || configContent.includes(`# Name: ${name}\r\n`)) {
|
|
77
|
+
throw new Error(`Peer with name '${name}' already exists.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Parse IPs to find next free IP
|
|
81
|
+
const ipMatches = [...configContent.matchAll(/AllowedIPs\s*=\s*10\.0\.0\.(\d+)\/32/g)];
|
|
82
|
+
let nextIpIndex = 2;
|
|
83
|
+
if (ipMatches.length > 0) {
|
|
84
|
+
const indices = ipMatches.map(m => parseInt(m[1], 10));
|
|
85
|
+
nextIpIndex = Math.max(...indices) + 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (nextIpIndex >= 254) {
|
|
89
|
+
throw new Error('IP range exhausted for subnet 10.0.0.0/24');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const peerIp = `10.0.0.${nextIpIndex}`;
|
|
93
|
+
const peerKeys = generateKeyPair();
|
|
94
|
+
|
|
95
|
+
// 2. Append peer to remote wg0.conf
|
|
96
|
+
const peerConfigBlock = `\n[Peer]
|
|
97
|
+
# Name: ${name}
|
|
98
|
+
PublicKey = ${peerKeys.publicKey}
|
|
99
|
+
AllowedIPs = ${peerIp}/32
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
const writeCmd = `echo "${peerConfigBlock.replace(/"/g, '\\"')}" | sudo tee -a /etc/wireguard/wg0.conf`;
|
|
103
|
+
let res = await sshExec(conn, writeCmd);
|
|
104
|
+
if (res.code !== 0) throw new Error(`Failed to append remote peer: ${res.stderr}`);
|
|
105
|
+
|
|
106
|
+
// Sync remote wireguard
|
|
107
|
+
await sshExec(conn, 'sudo systemctl restart wg-quick@wg0');
|
|
108
|
+
|
|
109
|
+
// 3. Save peer config locally
|
|
110
|
+
const clientConf = `[Interface]
|
|
111
|
+
PrivateKey = ${peerKeys.privateKey}
|
|
112
|
+
Address = ${peerIp}/24
|
|
113
|
+
DNS = 1.1.1.1
|
|
114
|
+
|
|
115
|
+
[Peer]
|
|
116
|
+
PublicKey = ${info.serverPublicKey}
|
|
117
|
+
Endpoint = ${info.server.split('@')[1]}:51820
|
|
118
|
+
AllowedIPs = 0.0.0.0/0
|
|
119
|
+
PersistentKeepalive = 25
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
const peersDir = path.join(CONFIG_DIR, 'wg', 'peers');
|
|
123
|
+
ensureDir(peersDir);
|
|
124
|
+
const peerConfPath = path.join(peersDir, `${name}.conf`);
|
|
125
|
+
fs.writeFileSync(peerConfPath, clientConf, 'utf-8');
|
|
126
|
+
|
|
127
|
+
conn.end();
|
|
128
|
+
return { name, ip: peerIp, publicKey: peerKeys.publicKey, confPath: peerConfPath };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
conn.end();
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const listPeers = async () => {
|
|
136
|
+
const info = getDeployInfo();
|
|
137
|
+
const conn = await sshConnect(info);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
// 1. Read remote configuration to parse names
|
|
141
|
+
const catRes = await sshExec(conn, 'sudo cat /etc/wireguard/wg0.conf');
|
|
142
|
+
if (catRes.code !== 0) throw new Error('Failed to read remote config');
|
|
143
|
+
const content = catRes.stdout;
|
|
144
|
+
|
|
145
|
+
// Parse blocks
|
|
146
|
+
const lines = content.split('\n');
|
|
147
|
+
const peersMap = {}; // publicKey -> name
|
|
148
|
+
let currentName = 'Default Client';
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < lines.length; i++) {
|
|
151
|
+
const line = lines[i].trim();
|
|
152
|
+
if (line.startsWith('# Name:')) {
|
|
153
|
+
currentName = line.substring(7).trim();
|
|
154
|
+
}
|
|
155
|
+
if (line.startsWith('PublicKey')) {
|
|
156
|
+
const pk = line.split('=')[1].trim();
|
|
157
|
+
peersMap[pk] = currentName;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 2. Fetch wg status
|
|
162
|
+
const showRes = await sshExec(conn, 'sudo wg show wg0 dump');
|
|
163
|
+
if (showRes.code !== 0) throw new Error('Failed to run wg show');
|
|
164
|
+
|
|
165
|
+
const dumpLines = showRes.stdout.trim().split('\n');
|
|
166
|
+
const list = [];
|
|
167
|
+
|
|
168
|
+
// First line is server key info, subsequent lines are peers
|
|
169
|
+
for (let i = 1; i < dumpLines.length; i++) {
|
|
170
|
+
const parts = dumpLines[i].split('\t');
|
|
171
|
+
if (parts.length < 8) continue;
|
|
172
|
+
const [publicKey, , endpoint, allowedIps, latestHandshake, transferRx, transferTx] = parts;
|
|
173
|
+
|
|
174
|
+
list.push({
|
|
175
|
+
name: peersMap[publicKey] || 'Unknown',
|
|
176
|
+
publicKey,
|
|
177
|
+
endpoint: endpoint === '(none)' ? 'Disconnected' : endpoint,
|
|
178
|
+
ip: allowedIps,
|
|
179
|
+
handshake: parseInt(latestHandshake, 10) === 0 ? 'Never' : `${Math.floor(Date.now() / 1000 - parseInt(latestHandshake, 10))}s ago`,
|
|
180
|
+
transferRx: `${Math.round(parseInt(transferRx, 10) / 1024 / 1024 * 100) / 100} MB`,
|
|
181
|
+
transferTx: `${Math.round(parseInt(transferTx, 10) / 1024 / 1024 * 100) / 100} MB`
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
conn.end();
|
|
186
|
+
return list;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
conn.end();
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const removePeer = async (name) => {
|
|
194
|
+
const info = getDeployInfo();
|
|
195
|
+
const conn = await sshConnect(info);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const catRes = await sshExec(conn, 'sudo cat /etc/wireguard/wg0.conf');
|
|
199
|
+
if (catRes.code !== 0) throw new Error('Failed to read remote config');
|
|
200
|
+
const content = catRes.stdout;
|
|
201
|
+
|
|
202
|
+
if (!content.includes(`# Name: ${name}`)) {
|
|
203
|
+
throw new Error(`Peer '${name}' not found on server.`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Strip peer block from remote config
|
|
207
|
+
const blocks = content.split('[Peer]');
|
|
208
|
+
const filteredBlocks = blocks.filter(b => !b.includes(`# Name: ${name}`));
|
|
209
|
+
const newContent = filteredBlocks.join('[Peer]');
|
|
210
|
+
|
|
211
|
+
// Save remote config back
|
|
212
|
+
const writeRes = await sshExec(conn, `cat << 'EOF' > /tmp/wg0.conf\n${newContent}\nEOF\nsudo mv /tmp/wg0.conf /etc/wireguard/wg0.conf && sudo chmod 600 /etc/wireguard/wg0.conf`);
|
|
213
|
+
if (writeRes.code !== 0) throw new Error('Failed to update remote config');
|
|
214
|
+
|
|
215
|
+
// Sync remote wireguard
|
|
216
|
+
await sshExec(conn, 'sudo systemctl restart wg-quick@wg0');
|
|
217
|
+
|
|
218
|
+
// Remove local peer config if it exists
|
|
219
|
+
const peerConfPath = path.join(CONFIG_DIR, 'wg', 'peers', `${name}.conf`);
|
|
220
|
+
if (fs.existsSync(peerConfPath)) {
|
|
221
|
+
fs.unlinkSync(peerConfPath);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
conn.end();
|
|
225
|
+
return true;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
conn.end();
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export const getLocalPeerConfPath = (name) => {
|
|
233
|
+
const peerConfPath = path.join(CONFIG_DIR, 'wg', 'peers', `${name}.conf`);
|
|
234
|
+
if (!fs.existsSync(peerConfPath)) {
|
|
235
|
+
throw new Error(`Local config for peer '${name}' not found.`);
|
|
236
|
+
}
|
|
237
|
+
return peerConfPath;
|
|
238
|
+
};
|
|
@@ -1,26 +1,74 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
1
4
|
import { spawnSSH, waitForSocks } from '../tunnel/ssh.js';
|
|
2
5
|
import { startTlsBackground } from '../tunnel/tls.js';
|
|
6
|
+
import { startWgTunnel, stopWgTunnel } from '../tunnel/wg.js';
|
|
7
|
+
import { enableKillSwitch, disableKillSwitch } from '../utils/kill-switch.js';
|
|
3
8
|
import { loadDaemonState, clearDaemonState, killPid, saveDaemonState } from '../utils/daemon.js';
|
|
4
|
-
import { TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE, ensureDir } from '../utils/config.js';
|
|
9
|
+
import { TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE, CONFIG_DIR, ensureDir } from '../utils/config.js';
|
|
10
|
+
|
|
11
|
+
const WG_CONF = path.join(CONFIG_DIR, 'wg', 'wg0.conf');
|
|
5
12
|
|
|
6
13
|
export const getActiveTunnel = () => {
|
|
7
|
-
|
|
14
|
+
const info = loadDaemonState(TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE);
|
|
15
|
+
if (!info) return null;
|
|
16
|
+
|
|
17
|
+
// For WireGuard, PID file tracks start process, but we verify interface status
|
|
18
|
+
if (info.mode === 'wireguard') {
|
|
19
|
+
try {
|
|
20
|
+
const showRes = spawnSync('sudo', ['wg', 'show'], { encoding: 'utf-8' });
|
|
21
|
+
if (showRes.status !== 0 || !showRes.stdout.includes('interface:')) {
|
|
22
|
+
clearDaemonState(TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
clearDaemonState(TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return info;
|
|
8
31
|
};
|
|
9
32
|
|
|
10
|
-
export const stopActiveTunnel = () => {
|
|
33
|
+
export const stopActiveTunnel = (isJson = false) => {
|
|
11
34
|
const info = getActiveTunnel();
|
|
12
35
|
if (info) {
|
|
13
|
-
|
|
36
|
+
if (info.mode === 'wireguard') {
|
|
37
|
+
stopWgTunnel(WG_CONF, isJson);
|
|
38
|
+
disableKillSwitch(info.server);
|
|
39
|
+
} else {
|
|
40
|
+
killPid(info.pid);
|
|
41
|
+
}
|
|
14
42
|
}
|
|
15
43
|
clearDaemonState(TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE);
|
|
16
44
|
return info;
|
|
17
45
|
};
|
|
18
46
|
|
|
19
|
-
export const startTunnel = async (server, port, mode = 'ssh') => {
|
|
47
|
+
export const startTunnel = async (server, port, mode = 'ssh', isJson = false) => {
|
|
20
48
|
const hostPart = server.includes('@') ? server.split('@')[1] : server;
|
|
21
49
|
ensureDir();
|
|
22
50
|
|
|
23
51
|
let pid;
|
|
52
|
+
if (mode === 'wireguard') {
|
|
53
|
+
if (!fs.existsSync(WG_CONF)) {
|
|
54
|
+
throw new Error(`WireGuard client configuration not found at ${WG_CONF}. Please provision the server first with "polaris deploy".`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pid = startWgTunnel(WG_CONF, isJson);
|
|
58
|
+
|
|
59
|
+
// Save state before enableKillSwitch in case sudo prompt needs to block
|
|
60
|
+
saveDaemonState(TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE, {
|
|
61
|
+
pid,
|
|
62
|
+
server,
|
|
63
|
+
port: 0,
|
|
64
|
+
mode: 'wireguard',
|
|
65
|
+
startTime: new Date().toISOString()
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
enableKillSwitch(server);
|
|
69
|
+
return { pid, server, port: 0, mode: 'wireguard' };
|
|
70
|
+
}
|
|
71
|
+
|
|
24
72
|
if (mode === 'tls') {
|
|
25
73
|
pid = startTlsBackground(port, hostPart, 8443);
|
|
26
74
|
} else {
|
package/src/tunnel/wg.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawnSync, spawn } from 'child_process';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
|
|
4
|
+
// Pure JS Curve25519/X25519 key generator matching WireGuard format
|
|
5
|
+
export const generateKeyPair = () => {
|
|
6
|
+
const rawPriv = crypto.randomBytes(32);
|
|
7
|
+
// Apply Curve25519 private key clamping:
|
|
8
|
+
// - clear the lowest three bits of the first byte
|
|
9
|
+
// - clear the highest bit of the last byte
|
|
10
|
+
// - set the second highest bit of the last byte
|
|
11
|
+
rawPriv[0] &= 248;
|
|
12
|
+
rawPriv[31] &= 127;
|
|
13
|
+
rawPriv[31] |= 64;
|
|
14
|
+
|
|
15
|
+
const keyDer = Buffer.concat([
|
|
16
|
+
Buffer.from('302e020100300506032b656e04220420', 'hex'),
|
|
17
|
+
rawPriv
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const priv = crypto.createPrivateKey({ key: keyDer, format: 'der', type: 'pkcs8' });
|
|
21
|
+
const pub = crypto.createPublicKey(priv);
|
|
22
|
+
const pubRaw = pub.export({ format: 'der', type: 'spki' }).slice(-32);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
privateKey: rawPriv.toString('base64'),
|
|
26
|
+
publicKey: pubRaw.toString('base64')
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const startWgTunnel = (confPath, isJson = false) => {
|
|
31
|
+
const sudoCheck = spawnSync('sudo', ['-n', 'true']);
|
|
32
|
+
if (sudoCheck.status !== 0 && isJson) {
|
|
33
|
+
throw new Error('Sudo privileges required. Please run with "sudo polaris start ..." for JSON mode.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const res = spawnSync('sudo', ['wg-quick', 'up', confPath], {
|
|
37
|
+
stdio: isJson ? 'ignore' : 'inherit'
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (res.status !== 0) {
|
|
41
|
+
throw new Error(`wg-quick up failed with code ${res.status}`);
|
|
42
|
+
}
|
|
43
|
+
return process.pid;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const stopWgTunnel = (confPath, isJson = false) => {
|
|
47
|
+
const sudoCheck = spawnSync('sudo', ['-n', 'true']);
|
|
48
|
+
if (sudoCheck.status !== 0 && isJson) {
|
|
49
|
+
throw new Error('Sudo privileges required. Please run with "sudo polaris stop" for JSON mode.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const res = spawnSync('sudo', ['wg-quick', 'down', confPath], {
|
|
53
|
+
stdio: isJson ? 'ignore' : 'inherit'
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (res.status !== 0) {
|
|
57
|
+
throw new Error(`wg-quick down failed with code ${res.status}`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { store } from './config.js';
|
|
5
|
+
|
|
6
|
+
export const isKillSwitchConfigured = () => {
|
|
7
|
+
return store.get('kill-switch', false);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const setKillSwitchConfig = (enabled) => {
|
|
11
|
+
store.set('kill-switch', enabled);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const enableKillSwitch = (serverIp) => {
|
|
15
|
+
if (!isKillSwitchConfigured()) return;
|
|
16
|
+
|
|
17
|
+
const platform = os.platform();
|
|
18
|
+
const hostIp = serverIp.includes('@') ? serverIp.split('@')[1] : serverIp;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
if (platform === 'linux') {
|
|
22
|
+
// 1. Loopback
|
|
23
|
+
execSync('sudo iptables -C OUTPUT -o lo -j ACCEPT || sudo iptables -A OUTPUT -o lo -j ACCEPT', { stdio: 'ignore' });
|
|
24
|
+
// 2. WireGuard interface (default wg0)
|
|
25
|
+
execSync('sudo iptables -C OUTPUT -o wg0 -j ACCEPT || sudo iptables -A OUTPUT -o wg0 -j ACCEPT', { stdio: 'ignore' });
|
|
26
|
+
// 3. Connect to the VPS server
|
|
27
|
+
execSync(`sudo iptables -C OUTPUT -d ${hostIp} -j ACCEPT || sudo iptables -A OUTPUT -d ${hostIp} -j ACCEPT`, { stdio: 'ignore' });
|
|
28
|
+
// 4. Drop other outbound connections
|
|
29
|
+
execSync('sudo iptables -C OUTPUT -j REJECT || sudo iptables -A OUTPUT -j REJECT', { stdio: 'ignore' });
|
|
30
|
+
} else if (platform === 'darwin') {
|
|
31
|
+
// macOS Packet Filter (PF) setup
|
|
32
|
+
const pfRules = `
|
|
33
|
+
block out all
|
|
34
|
+
pass out on lo0 all
|
|
35
|
+
pass out on utun all
|
|
36
|
+
pass out proto {tcp, udp} to ${hostIp}
|
|
37
|
+
`;
|
|
38
|
+
fs.writeFileSync('/tmp/polaris_pf.conf', pfRules, 'utf-8');
|
|
39
|
+
execSync('sudo pfctl -ef /tmp/polaris_pf.conf', { stdio: 'ignore' });
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error('Failed to enable kill switch rules:', err.message);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const disableKillSwitch = (serverIp) => {
|
|
47
|
+
const platform = os.platform();
|
|
48
|
+
const hostIp = serverIp.includes('@') ? serverIp.split('@')[1] : serverIp;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (platform === 'linux') {
|
|
52
|
+
execSync('sudo iptables -D OUTPUT -j REJECT', { stdio: 'ignore' });
|
|
53
|
+
execSync(`sudo iptables -D OUTPUT -d ${hostIp} -j ACCEPT`, { stdio: 'ignore' });
|
|
54
|
+
execSync('sudo iptables -D OUTPUT -o wg0 -j ACCEPT', { stdio: 'ignore' });
|
|
55
|
+
execSync('sudo iptables -D OUTPUT -o lo -j ACCEPT', { stdio: 'ignore' });
|
|
56
|
+
} else if (platform === 'darwin') {
|
|
57
|
+
execSync('sudo pfctl -d', { stdio: 'ignore' });
|
|
58
|
+
// Reload default system rules
|
|
59
|
+
if (fs.existsSync('/etc/pf.conf')) {
|
|
60
|
+
execSync('sudo pfctl -f /etc/pf.conf', { stdio: 'ignore' });
|
|
61
|
+
}
|
|
62
|
+
if (fs.existsSync('/tmp/polaris_pf.conf')) {
|
|
63
|
+
fs.unlinkSync('/tmp/polaris_pf.conf');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Ignore cleanup errors
|
|
68
|
+
}
|
|
69
|
+
};
|