polaris-vpn 0.3.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -5,23 +5,21 @@ 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
8
+ ## [0.5.0] - 2026-07-12
9
+
10
+ ### Added
9
11
 
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.
12
+ - **Server Health Dashboard**: `polaris status --full` now queries GeoIP data, tests ping latency directly to the server, and retrieves live WireGuard rx/tx data limits.
13
+ - **Auto-Updater**: Introduced `polaris update` and background update notifications via `update-notifier`.
13
14
 
14
- ## [0.3.0] - 2026-06-28
15
+ ## [0.4.0] - 2026-07-12
15
16
 
16
17
  ### 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
-
24
- ## [0.2.0] - 2026-06-28
18
+
19
+ - **Live Bandwidth Monitor**: Use `polaris monitor` to see real-time up/down speeds, data usage, and peer activity for WireGuard tunnels.
20
+ - **Local REST API**: Use `polaris server start` to run a local API on `127.0.0.1:7070` for external integrations and status queries.
21
+
22
+ ## [0.3.1] - 2026-06-28
25
23
 
26
24
  ### Added
27
25
  - Companion `polaris-server` dynamic HTTP/TLS forwarding proxy on port 8443.
package/README.md CHANGED
@@ -45,11 +45,14 @@ polaris stop
45
45
  | --- | --- |
46
46
  | `polaris start --server user@host [--port 1080]` | Start the encrypted SSH tunnel. |
47
47
  | `polaris stop` | Stop the active tunnel. |
48
- | `polaris status` | Show current tunnel status, IP, and uptime. |
48
+ | `polaris status [--full]` | Show current tunnel status. `--full` shows GeoIP, ping latency, and WG stats. |
49
+ | `polaris monitor` | Live bandwidth monitor for WireGuard tunnels. |
49
50
  | `polaris check` | Run a 3-point privacy check (IP, DNS leak, IPv6 leak). |
50
51
  | `polaris add <alias> --server user@host` | Save a server profile for quick access. |
51
52
  | `polaris list` | List all saved server profiles. |
52
53
  | `polaris use <alias>` | Set a saved profile as the active default. |
54
+ | `polaris server start` | Start a local REST API on `127.0.0.1:7070`. |
55
+ | `polaris update` | Update `polaris-vpn` to the latest version via npm. |
53
56
 
54
57
  > All commands support the `--json` flag for machine-readable output.
55
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polaris-vpn",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Your True North in Digital Privacy. A self-hosted VPN CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,12 +37,14 @@
37
37
  "commander": "^15.0.0",
38
38
  "conf": "^15.1.0",
39
39
  "dns2": "^3.0.0",
40
+ "express": "^5.2.1",
40
41
  "node-fetch": "^3.3.2",
41
42
  "node-notifier": "^10.0.1",
42
43
  "ora": "^9.4.1",
43
44
  "qrcode-terminal": "^0.12.0",
44
45
  "socks-proxy-agent": "^10.1.0",
45
46
  "ssh2": "^1.17.0",
47
+ "update-notifier": "^7.3.1",
46
48
  "wireguard-tools": "^0.1.0"
47
49
  }
48
50
  }
package/src/cli.js CHANGED
@@ -3,12 +3,17 @@ import { Command } from 'commander';
3
3
  import { readFileSync } from 'fs';
4
4
  import { fileURLToPath } from 'url';
5
5
  import path from 'path';
6
- import { printBanner, printError } from './utils/display.js';
6
+ import updateNotifier from 'update-notifier';
7
+ import { spawnSync } from 'child_process';
8
+ import { printBanner, printError, printSuccess } from './utils/display.js';
9
+ import chalk from 'chalk';
7
10
 
8
11
  const __filename = fileURLToPath(import.meta.url);
9
12
  const __dirname = path.dirname(__filename);
10
13
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
11
14
 
15
+ updateNotifier({ pkg }).notify();
16
+
12
17
  const program = new Command();
13
18
 
14
19
  program
@@ -20,6 +25,20 @@ program
20
25
  // Helper to check if we should print the banner
21
26
  const willPrintJson = () => process.argv.includes('--json');
22
27
 
28
+ program
29
+ .command('update')
30
+ .description('Update polaris-vpn to the latest version')
31
+ .action(() => {
32
+ console.log(chalk.cyan('Updating polaris-vpn...'));
33
+ const res = spawnSync('npm', ['install', '-g', 'polaris-vpn@latest'], { stdio: 'inherit' });
34
+ if (res.status === 0) {
35
+ printSuccess('Successfully updated to the latest version!');
36
+ } else {
37
+ printError('Failed to update polaris-vpn.');
38
+ process.exitCode = 1;
39
+ }
40
+ });
41
+
23
42
  program
24
43
  .command('start')
25
44
  .description('Start the encrypted SSH, TLS or WireGuard tunnel')
@@ -54,6 +73,7 @@ program
54
73
  program
55
74
  .command('status')
56
75
  .description('Show current tunnel status, IP, and uptime')
76
+ .option('--full', 'Show GeoIP, latency, and full data usage stats')
57
77
  .action(async (options, cmd) => {
58
78
  if (!cmd.optsWithGlobals().json) printBanner();
59
79
  try {
@@ -269,6 +289,25 @@ configCmd
269
289
  }
270
290
  });
271
291
 
292
+ program
293
+ .command('server')
294
+ .description('Manage the local REST API server')
295
+ .command('start')
296
+ .description('Start the local REST API server')
297
+ .option('-p, --port <port>', 'Port to listen on', '7070')
298
+ .action(async (options) => {
299
+ const { serverStart } = await import('./commands/server.js');
300
+ await serverStart({ ...options, json: willPrintJson() });
301
+ });
302
+
303
+ program
304
+ .command('monitor')
305
+ .description('Live bandwidth monitor for WireGuard/AmneziaWG tunnels')
306
+ .action(async (options, cmd) => {
307
+ const run = (await import('./commands/monitor.js')).default;
308
+ await run(cmd.optsWithGlobals());
309
+ });
310
+
272
311
  program.parseAsync(process.argv).catch(err => {
273
312
  if (!willPrintJson()) printError('Fatal error', err);
274
313
  process.exit(1);
@@ -0,0 +1,120 @@
1
+ import chalk from 'chalk';
2
+ import { getActiveTunnel } from '../core/tunnel-service.js';
3
+ import { createTable, printError } from '../utils/display.js';
4
+ import { spawnSync } from 'child_process';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { CONFIG_DIR } from '../utils/config.js';
8
+
9
+ const getWgStats = () => {
10
+ const confPathAwg = path.join(CONFIG_DIR, 'wg', 'awg0.conf');
11
+ const showCmd = fs.existsSync(confPathAwg) ? 'awg' : 'wg';
12
+
13
+ const dumpRes = spawnSync('sudo', [showCmd, 'show', 'all', 'dump'], { encoding: 'utf-8' });
14
+ if (dumpRes.status !== 0) return null;
15
+
16
+ const lines = dumpRes.stdout.trim().split('\n');
17
+ if (lines.length <= 1) return null;
18
+
19
+ const peers = [];
20
+ let totalRx = 0;
21
+ let totalTx = 0;
22
+
23
+ for (let i = 1; i < lines.length; i++) {
24
+ const peerInfo = lines[i].split('\t');
25
+ const rx = parseInt(peerInfo[6], 10) || 0;
26
+ const tx = parseInt(peerInfo[7], 10) || 0;
27
+ totalRx += rx;
28
+ totalTx += tx;
29
+ peers.push({
30
+ pubKey: peerInfo[1].substring(0, 8) + '...',
31
+ endpoint: peerInfo[4],
32
+ rx,
33
+ tx
34
+ });
35
+ }
36
+
37
+ return { peers, totalRx, totalTx };
38
+ };
39
+
40
+ const formatBytes = (bytes) => {
41
+ if (bytes === 0) return '0 B';
42
+ const k = 1024;
43
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
44
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
45
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
46
+ };
47
+
48
+ export default async (options) => {
49
+ const isJson = options.json;
50
+ const info = getActiveTunnel();
51
+
52
+ if (!info || info.mode !== 'wireguard') {
53
+ if (isJson) {
54
+ console.log(JSON.stringify({ error: 'WireGuard tunnel is not active' }));
55
+ } else {
56
+ printError('Monitor requires an active WireGuard or AmneziaWG tunnel.');
57
+ }
58
+ process.exitCode = 1;
59
+ return;
60
+ }
61
+
62
+ if (isJson) {
63
+ console.log(JSON.stringify(getWgStats()));
64
+ return;
65
+ }
66
+
67
+ let prevRx = 0;
68
+ let prevTx = 0;
69
+ let hasInit = false;
70
+
71
+ console.clear();
72
+ console.log(chalk.cyan.bold('\n● Polaris Live Bandwidth Monitor\n'));
73
+ console.log(chalk.dim('Press Ctrl+C to exit.\n'));
74
+
75
+ setInterval(() => {
76
+ const stats = getWgStats();
77
+ if (!stats) return;
78
+
79
+ if (!hasInit) {
80
+ prevRx = stats.totalRx;
81
+ prevTx = stats.totalTx;
82
+ hasInit = true;
83
+ return;
84
+ }
85
+
86
+ const rxSpeed = stats.totalRx - prevRx;
87
+ const txSpeed = stats.totalTx - prevTx;
88
+
89
+ prevRx = stats.totalRx;
90
+ prevTx = stats.totalTx;
91
+
92
+ console.clear();
93
+ console.log(chalk.cyan.bold('\n● Polaris Live Bandwidth Monitor\n'));
94
+ console.log(chalk.dim('Press Ctrl+C to exit.\n'));
95
+
96
+ const table = createTable(['Metric', 'Value']);
97
+ table.push(
98
+ ['Download Speed', chalk.green(`${formatBytes(rxSpeed)}/s`)],
99
+ ['Upload Speed', chalk.green(`${formatBytes(txSpeed)}/s`)],
100
+ ['Total Data Received', formatBytes(stats.totalRx)],
101
+ ['Total Data Sent', formatBytes(stats.totalTx)]
102
+ );
103
+ console.log(table.toString());
104
+
105
+ if (stats.peers.length > 0) {
106
+ console.log(chalk.bold('\nActive Peers:'));
107
+ const peerTable = createTable(['Peer', 'Endpoint', 'Received', 'Sent']);
108
+ for (const peer of stats.peers) {
109
+ peerTable.push([
110
+ peer.pubKey,
111
+ peer.endpoint,
112
+ formatBytes(peer.rx),
113
+ formatBytes(peer.tx)
114
+ ]);
115
+ }
116
+ console.log(peerTable.toString());
117
+ }
118
+
119
+ }, 1000);
120
+ };
@@ -0,0 +1,35 @@
1
+ import { printSuccess, printError, printInfo } from '../utils/display.js';
2
+ import { startApiServer } from '../server/api.js';
3
+
4
+ export const serverStart = async (options) => {
5
+ const isJson = options.json;
6
+
7
+ try {
8
+ if (!isJson) {
9
+ printInfo('Starting local REST API server...');
10
+ }
11
+
12
+ const port = parseInt(options.port || '7070', 10);
13
+ await startApiServer(port);
14
+
15
+ if (isJson) {
16
+ console.log(JSON.stringify({ success: true, url: `http://127.0.0.1:${port}` }));
17
+ } else {
18
+ printSuccess(`Local API running at http://127.0.0.1:${port}`);
19
+ console.log(`
20
+ Endpoints:
21
+ GET /status — Get tunnel state
22
+ POST /connect — Start tunnel { "server": "user@host", "mode": "auto" }
23
+ POST /disconnect — Stop active tunnel
24
+ GET /peers — List WireGuard peers (requires active deploy)
25
+ `);
26
+ }
27
+ } catch (err) {
28
+ if (isJson) {
29
+ console.log(JSON.stringify({ error: err.message }));
30
+ } else {
31
+ printError('Failed to start local API', err);
32
+ }
33
+ process.exitCode = 1;
34
+ }
35
+ };
@@ -2,9 +2,64 @@ import { getActiveTunnel } from '../core/tunnel-service.js';
2
2
  import { getProxiedIp } from '../net/ip-check.js';
3
3
  import { createTable, createSpinner, printError } from '../utils/display.js';
4
4
  import chalk from 'chalk';
5
+ import fetch from 'node-fetch';
6
+ import { spawnSync } from 'child_process';
7
+ import os from 'os';
8
+
9
+ const pingServer = (ip) => {
10
+ const isWin = os.platform() === 'win32';
11
+ const args = isWin ? ['-n', '1', '-w', '2000', ip] : ['-c', '1', '-W', '2', ip];
12
+ const res = spawnSync('ping', args, { encoding: 'utf-8' });
13
+ if (res.status === 0) {
14
+ if (isWin) {
15
+ const match = res.stdout.match(/Average = (\d+)ms/);
16
+ if (match) return `${match[1]} ms`;
17
+ } else {
18
+ const match = res.stdout.match(/time=([\d.]+)\s*ms/);
19
+ if (match) return `${match[1]} ms`;
20
+ }
21
+ }
22
+ return 'Timeout';
23
+ };
24
+
25
+ const getGeoIp = async (ip) => {
26
+ try {
27
+ const res = await fetch(`http://ip-api.com/json/${ip}`);
28
+ const data = await res.json();
29
+ if (data.status === 'success') {
30
+ return `${data.city}, ${data.country} (${data.isp})`;
31
+ }
32
+ } catch (err) {}
33
+ return 'Unknown';
34
+ };
35
+
36
+ const formatBytes = (bytes) => {
37
+ if (bytes === 0) return '0 B';
38
+ const k = 1024;
39
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
40
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
41
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
42
+ };
43
+
44
+ const getWgStats = () => {
45
+ const dumpRes = spawnSync('sudo', ['wg', 'show', 'all', 'dump'], { encoding: 'utf-8' });
46
+ if (dumpRes.status !== 0) return null;
47
+ const lines = dumpRes.stdout.trim().split('\n');
48
+ if (lines.length <= 1) return null;
49
+
50
+ let totalRx = 0;
51
+ let totalTx = 0;
52
+ for (let i = 1; i < lines.length; i++) {
53
+ const peerInfo = lines[i].split('\t');
54
+ totalRx += parseInt(peerInfo[6], 10) || 0;
55
+ totalTx += parseInt(peerInfo[7], 10) || 0;
56
+ }
57
+ return { totalRx, totalTx };
58
+ };
5
59
 
6
60
  export default async (options) => {
7
61
  const isJson = options.json;
62
+ const isFull = options.full;
8
63
  const info = getActiveTunnel();
9
64
 
10
65
  if (!info) {
@@ -23,9 +78,33 @@ export default async (options) => {
23
78
  currentIp = await getProxiedIp(info.port);
24
79
  if (spinner) spinner.stop();
25
80
 
81
+ let geo = 'N/A';
82
+ let latency = 'N/A';
83
+ let dataUsage = 'N/A';
84
+
85
+ if (isFull) {
86
+ if (spinner) {
87
+ spinner.text = 'Fetching full health data...';
88
+ spinner.start();
89
+ }
90
+
91
+ const serverIp = info.server.split('@').pop();
92
+ latency = pingServer(serverIp);
93
+ geo = await getGeoIp(currentIp);
94
+
95
+ if (info.mode === 'wireguard') {
96
+ const stats = getWgStats();
97
+ if (stats) {
98
+ dataUsage = `${formatBytes(stats.totalRx)} / ${formatBytes(stats.totalTx)}`;
99
+ }
100
+ }
101
+
102
+ if (spinner) spinner.stop();
103
+ }
104
+
26
105
  if (isJson) {
27
106
  const uptimeMs = Date.now() - new Date(info.startTime).getTime();
28
- console.log(JSON.stringify({
107
+ const payload = {
29
108
  status: 'up',
30
109
  server: info.server,
31
110
  port: info.port,
@@ -33,7 +112,13 @@ export default async (options) => {
33
112
  mode: info.mode || 'ssh',
34
113
  ip: currentIp,
35
114
  uptimeMs
36
- }));
115
+ };
116
+ if (isFull) {
117
+ payload.geo = geo;
118
+ payload.latency = latency;
119
+ payload.dataUsage = dataUsage;
120
+ }
121
+ console.log(JSON.stringify(payload));
37
122
  } else {
38
123
  console.log(chalk.green.bold('\n● Tunnel is UP\n'));
39
124
 
@@ -49,6 +134,14 @@ export default async (options) => {
49
134
  ['Uptime', `${uptimeMin} minutes`],
50
135
  ['PID', info.pid.toString()]
51
136
  );
137
+
138
+ if (isFull) {
139
+ table.push(
140
+ ['Geo Location', geo],
141
+ ['Latency (Ping)', latency],
142
+ ['Data (Rx/Tx)', dataUsage]
143
+ );
144
+ }
52
145
 
53
146
  console.log(table.toString());
54
147
  console.log('');
@@ -0,0 +1,77 @@
1
+ import express from 'express';
2
+ import { getActiveTunnel, startTunnel, stopActiveTunnel } from '../core/tunnel-service.js';
3
+ import { listPeers } from '../core/peer-service.js';
4
+ import { getProfiles } from '../core/profile-service.js';
5
+
6
+ export const startApiServer = (port = 7070) => {
7
+ const app = express();
8
+ app.use(express.json());
9
+
10
+ app.get('/status', (req, res) => {
11
+ try {
12
+ const info = getActiveTunnel();
13
+ if (!info) {
14
+ return res.json({ status: 'disconnected' });
15
+ }
16
+ res.json({ status: 'connected', ...info });
17
+ } catch (err) {
18
+ res.status(500).json({ error: err.message });
19
+ }
20
+ });
21
+
22
+ app.post('/connect', async (req, res) => {
23
+ try {
24
+ const { server, mode, proxyPort } = req.body;
25
+ let targetServer = server;
26
+
27
+ if (!targetServer) {
28
+ const { profiles, active } = getProfiles();
29
+ if (active && profiles[active]) {
30
+ targetServer = profiles[active];
31
+ } else {
32
+ return res.status(400).json({ error: 'No server specified and no active profile found.' });
33
+ }
34
+ }
35
+
36
+ const info = getActiveTunnel();
37
+ if (info) {
38
+ return res.status(400).json({ error: 'Tunnel is already active.', current: info });
39
+ }
40
+
41
+ const pPort = proxyPort || 1080;
42
+ const tMode = mode || 'auto';
43
+
44
+ const result = await startTunnel(targetServer, pPort, tMode, true);
45
+ res.json({ status: 'connected', ...result });
46
+ } catch (err) {
47
+ res.status(500).json({ error: err.message });
48
+ }
49
+ });
50
+
51
+ app.post('/disconnect', (req, res) => {
52
+ try {
53
+ const info = stopActiveTunnel(true);
54
+ if (!info) {
55
+ return res.json({ status: 'disconnected', message: 'No active tunnel.' });
56
+ }
57
+ res.json({ status: 'disconnected', message: 'Tunnel stopped successfully.' });
58
+ } catch (err) {
59
+ res.status(500).json({ error: err.message });
60
+ }
61
+ });
62
+
63
+ app.get('/peers', async (req, res) => {
64
+ try {
65
+ const peers = await listPeers();
66
+ res.json({ success: true, peers });
67
+ } catch (err) {
68
+ res.status(500).json({ error: err.message });
69
+ }
70
+ });
71
+
72
+ return new Promise((resolve, reject) => {
73
+ const server = app.listen(port, '127.0.0.1', () => {
74
+ resolve(server);
75
+ }).on('error', reject);
76
+ });
77
+ };