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 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.2.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
- "socks-proxy-agent": "^10.1.0"
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 TLS tunnel')
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
+ };
@@ -1,9 +1,15 @@
1
1
  import chalk from 'chalk';
2
2
  import tls from 'tls';
3
- import { printError, printSuccess, createSpinner } from '../utils/display.js';
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 (PID: ${info.pid}) connected to ${info.server}. Run "polaris stop" first.`);
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 (!isJson) {
78
- spinner.text = 'Checking server for TLS support...';
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.info('TLS server not detected. Falling back to SSH mode...');
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
- const newIp = await getProxiedIp(port);
104
-
105
- if (!isJson) {
106
- spinner.stop();
107
- printSuccess(`Tunnel established successfully to ${server} (${actualMode.toUpperCase()} mode)`);
108
- console.log(`\n ${chalk.dim('Old IP:')} ${chalk.red(oldIp)}`);
109
- console.log(` ${chalk.dim('New IP:')} ${chalk.green(newIp)}`);
110
- console.log(` ${chalk.dim('Proxy :')} ${chalk.cyan(`socks5://127.0.0.1:${port}`)}`);
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
- console.log(chalk.dim('\nTunnel is running in the background. Leave this terminal or close it, it will stay alive.'));
113
- console.log(chalk.dim('Run "polaris status" to check, or "polaris stop" to end.'));
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
- console.log(JSON.stringify({ success: true, oldIp, newIp, proxy: `socks5://127.0.0.1:${port}`, pid: res.pid, mode: actualMode }));
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) {
@@ -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
- return loadDaemonState(TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE);
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
- killPid(info.pid);
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 {
@@ -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
+ };