polaris-vpn 0.1.0 → 0.2.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 +11 -0
- package/package.json +5 -2
- package/src/cli.js +50 -3
- package/src/commands/check.js +27 -19
- package/src/commands/dns.js +85 -0
- package/src/commands/servers.js +29 -35
- package/src/commands/start.js +58 -22
- package/src/commands/status.js +6 -4
- package/src/commands/stop.js +12 -13
- package/src/core/dns-service.js +46 -0
- package/src/core/profile-service.js +30 -0
- package/src/core/tunnel-service.js +39 -0
- package/src/net/dns-resolver.js +72 -0
- package/src/net/dns-runner.js +6 -0
- package/src/net/{ip.js → ip-check.js} +1 -3
- package/src/server/server.js +109 -0
- package/src/tunnel/ssh.js +3 -81
- package/src/tunnel/tls-runner.js +85 -0
- package/src/tunnel/tls.js +131 -0
- package/src/utils/config.js +18 -0
- package/src/utils/daemon.js +68 -0
- /package/src/{ui → utils}/display.js +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ 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.2.0] - 2026-06-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Companion `polaris-server` dynamic HTTP/TLS forwarding proxy on port 8443.
|
|
12
|
+
- local DNS-over-HTTPS (DoH) resolver command namespace (`polaris dns start/stop/status`) on port 5354.
|
|
13
|
+
- TLS tunnel client option (`polaris start --mode tls`).
|
|
14
|
+
- Auto-reconnect with exponential backoff & keepalive monitoring.
|
|
15
|
+
- Desktop alerts/notifications via `node-notifier`.
|
|
16
|
+
- Refactored codebase to highly modular MVC structure with centralized configurations and daemon manager.
|
|
17
|
+
- Static runner scripts to ensure full compatibility with global NPM installation permissions.
|
|
18
|
+
|
|
8
19
|
## [0.1.0] - 2026-06-27
|
|
9
20
|
|
|
10
21
|
### Added
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polaris-vpn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Your True North in Digital Privacy. A self-hosted VPN CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"polaris": "src/cli.js"
|
|
7
|
+
"polaris": "src/cli.js",
|
|
8
|
+
"polaris-server": "src/server/server.js"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"src",
|
|
@@ -35,7 +36,9 @@
|
|
|
35
36
|
"cli-table3": "^0.6.5",
|
|
36
37
|
"commander": "^15.0.0",
|
|
37
38
|
"conf": "^15.1.0",
|
|
39
|
+
"dns2": "^3.0.0",
|
|
38
40
|
"node-fetch": "^3.3.2",
|
|
41
|
+
"node-notifier": "^10.0.1",
|
|
39
42
|
"ora": "^9.4.1",
|
|
40
43
|
"socks-proxy-agent": "^10.1.0"
|
|
41
44
|
}
|
package/src/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ 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 './
|
|
6
|
+
import { printBanner, printError } from './utils/display.js';
|
|
7
7
|
|
|
8
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
9
|
const __dirname = path.dirname(__filename);
|
|
@@ -22,9 +22,10 @@ const willPrintJson = () => process.argv.includes('--json');
|
|
|
22
22
|
|
|
23
23
|
program
|
|
24
24
|
.command('start')
|
|
25
|
-
.description('Start the encrypted SSH tunnel')
|
|
26
|
-
.option('-s, --server <user@host>', 'SSH server to connect to')
|
|
25
|
+
.description('Start the encrypted SSH or TLS tunnel')
|
|
26
|
+
.option('-s, --server <user@host>', 'SSH/TLS 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
29
|
.action(async (options, cmd) => {
|
|
29
30
|
if (!cmd.optsWithGlobals().json) printBanner();
|
|
30
31
|
try {
|
|
@@ -121,6 +122,52 @@ program
|
|
|
121
122
|
}
|
|
122
123
|
});
|
|
123
124
|
|
|
125
|
+
const dnsCmd = program.command('dns').description('Manage local DNS-over-HTTPS resolver');
|
|
126
|
+
|
|
127
|
+
dnsCmd
|
|
128
|
+
.command('start')
|
|
129
|
+
.description('Start the local DoH resolver')
|
|
130
|
+
.option('-p, --port <number>', 'Local port to bind', '5354')
|
|
131
|
+
.option('-u, --upstream <provider>', 'Upstream DoH provider (cloudflare, google, or custom URL)', 'cloudflare')
|
|
132
|
+
.action(async (options, cmd) => {
|
|
133
|
+
if (!cmd.optsWithGlobals().json) printBanner();
|
|
134
|
+
try {
|
|
135
|
+
const { dnsStart } = await import('./commands/dns.js');
|
|
136
|
+
await dnsStart(cmd.optsWithGlobals());
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (!cmd.optsWithGlobals().json) printError('Command failed', err);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
dnsCmd
|
|
144
|
+
.command('stop')
|
|
145
|
+
.description('Stop the local DoH resolver')
|
|
146
|
+
.action(async (options, cmd) => {
|
|
147
|
+
if (!cmd.optsWithGlobals().json) printBanner();
|
|
148
|
+
try {
|
|
149
|
+
const { dnsStop } = await import('./commands/dns.js');
|
|
150
|
+
await dnsStop(cmd.optsWithGlobals());
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (!cmd.optsWithGlobals().json) printError('Command failed', err);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
dnsCmd
|
|
158
|
+
.command('status')
|
|
159
|
+
.description('Show local DoH resolver status')
|
|
160
|
+
.action(async (options, cmd) => {
|
|
161
|
+
if (!cmd.optsWithGlobals().json) printBanner();
|
|
162
|
+
try {
|
|
163
|
+
const { dnsStatus } = await import('./commands/dns.js');
|
|
164
|
+
await dnsStatus(cmd.optsWithGlobals());
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (!cmd.optsWithGlobals().json) printError('Command failed', err);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
124
171
|
program.parseAsync(process.argv).catch(err => {
|
|
125
172
|
if (!willPrintJson()) printError('Fatal error', err);
|
|
126
173
|
process.exit(1);
|
package/src/commands/check.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { getActiveTunnel } from '../core/tunnel-service.js';
|
|
3
|
+
import { getDnsStatus } from '../core/dns-service.js';
|
|
4
|
+
import { getPublicIp, getProxiedIp, checkDns, checkIpv6Leak } from '../net/ip-check.js';
|
|
5
|
+
import { createTable, createSpinner, printError } from '../utils/display.js';
|
|
5
6
|
|
|
6
7
|
export default async (options) => {
|
|
7
8
|
const isJson = options.json;
|
|
8
|
-
const info =
|
|
9
|
+
const info = getActiveTunnel();
|
|
10
|
+
const dnsStatus = getDnsStatus();
|
|
9
11
|
|
|
10
12
|
if (!isJson) {
|
|
11
13
|
console.log(chalk.cyan.bold('\nRunning Privacy Check...\n'));
|
|
@@ -22,7 +24,7 @@ export default async (options) => {
|
|
|
22
24
|
try {
|
|
23
25
|
const tunnelIp = await getProxiedIp(info.port);
|
|
24
26
|
results.ip = true;
|
|
25
|
-
details.ip = `Tunnel active: ${tunnelIp}`;
|
|
27
|
+
details.ip = `Tunnel active (${(info.mode || 'ssh').toUpperCase()}): ${tunnelIp}`;
|
|
26
28
|
} catch (e) {
|
|
27
29
|
results.ip = false;
|
|
28
30
|
details.ip = `Tunnel process running but proxy failed: ${e.message}`;
|
|
@@ -40,15 +42,14 @@ export default async (options) => {
|
|
|
40
42
|
results.dns = false;
|
|
41
43
|
details.dns = 'DNS resolution failed';
|
|
42
44
|
} else {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
details.dns = `Using system DNS: ${servers}`;
|
|
45
|
+
if (dnsStatus) {
|
|
46
|
+
results.dns = true;
|
|
47
|
+
details.dns = `DoH Resolver Active: 127.0.0.1:${dnsStatus.port} (Upstream: ${dnsStatus.upstream})`;
|
|
48
|
+
} else {
|
|
49
|
+
const servers = dnsResult.servers.join(', ');
|
|
50
|
+
results.dns = false;
|
|
51
|
+
details.dns = `Using system/ISP DNS: ${servers} (Potential DNS Leak)`;
|
|
52
|
+
}
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
// 3. IPv6 Leak Check
|
|
@@ -65,7 +66,12 @@ export default async (options) => {
|
|
|
65
66
|
if (spinner) spinner.stop();
|
|
66
67
|
|
|
67
68
|
if (isJson) {
|
|
68
|
-
console.log(JSON.stringify({
|
|
69
|
+
console.log(JSON.stringify({
|
|
70
|
+
results,
|
|
71
|
+
details,
|
|
72
|
+
tunnelMode: info ? (info.mode || 'ssh') : 'none',
|
|
73
|
+
dnsResolver: dnsStatus ? 'doh' : 'system'
|
|
74
|
+
}));
|
|
69
75
|
} else {
|
|
70
76
|
const table = createTable(['Check', 'Status', 'Details']);
|
|
71
77
|
|
|
@@ -73,9 +79,9 @@ export default async (options) => {
|
|
|
73
79
|
|
|
74
80
|
table.push(
|
|
75
81
|
['IP Address', formatResult(results.ip), details.ip],
|
|
76
|
-
|
|
77
|
-
['
|
|
78
|
-
['
|
|
82
|
+
['DNS Leak', dnsStatus ? chalk.green('✓ PASS') : chalk.yellow('⚠ WARN'), details.dns],
|
|
83
|
+
['IPv6 Leak', formatResult(results.ipv6), details.ipv6],
|
|
84
|
+
['WebRTC Leak', chalk.cyan('ⓘ INFO'), 'WebRTC can bypass proxies. Check browserleaks.com/webrtc']
|
|
79
85
|
);
|
|
80
86
|
|
|
81
87
|
console.log(table.toString());
|
|
@@ -85,7 +91,9 @@ export default async (options) => {
|
|
|
85
91
|
console.log(chalk.yellow('Note: Your traffic is currently exposed. Run "polaris start" to connect.'));
|
|
86
92
|
} else {
|
|
87
93
|
console.log(chalk.cyan('Note: SOCKS5 proxies only tunnel apps configured to use them (like your browser).'));
|
|
88
|
-
|
|
94
|
+
if (!dnsStatus) {
|
|
95
|
+
console.log(chalk.yellow(' Run "polaris dns start" to prevent DNS queries from leaking to your ISP.'));
|
|
96
|
+
}
|
|
89
97
|
}
|
|
90
98
|
console.log();
|
|
91
99
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { printError, printSuccess, printInfo, printWarning } from '../utils/display.js';
|
|
3
|
+
import { startDnsResolver, stopDnsResolver, getDnsStatus } from '../core/dns-service.js';
|
|
4
|
+
|
|
5
|
+
export const dnsStart = async (options) => {
|
|
6
|
+
const port = parseInt(options.port || '5354', 10);
|
|
7
|
+
const upstreamMap = {
|
|
8
|
+
'cloudflare': 'https://cloudflare-dns.com/dns-query',
|
|
9
|
+
'google': 'https://dns.google/resolve'
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const provider = options.upstream || 'cloudflare';
|
|
13
|
+
const upstream = upstreamMap[provider.toLowerCase()] || provider;
|
|
14
|
+
const isJson = options.json;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const res = startDnsResolver(port, upstream);
|
|
18
|
+
if (isJson) {
|
|
19
|
+
console.log(JSON.stringify({ success: true, ...res }));
|
|
20
|
+
} else {
|
|
21
|
+
printSuccess(`DNS-over-HTTPS resolver started on 127.0.0.1:${port}`);
|
|
22
|
+
printInfo(`Forwarding to: ${upstream}`);
|
|
23
|
+
}
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (isJson) {
|
|
26
|
+
console.log(JSON.stringify({ error: err.message }));
|
|
27
|
+
} else {
|
|
28
|
+
printError('Failed to start DNS resolver', err);
|
|
29
|
+
}
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const dnsStop = async (options) => {
|
|
35
|
+
const isJson = options.json;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const res = stopDnsResolver();
|
|
39
|
+
if (!res) {
|
|
40
|
+
if (isJson) {
|
|
41
|
+
console.log(JSON.stringify({ error: 'DNS resolver is not running' }));
|
|
42
|
+
} else {
|
|
43
|
+
printWarning('DNS resolver is not running.');
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isJson) {
|
|
49
|
+
console.log(JSON.stringify({ success: true }));
|
|
50
|
+
} else {
|
|
51
|
+
printSuccess('DNS-over-HTTPS resolver stopped.');
|
|
52
|
+
}
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (isJson) {
|
|
55
|
+
console.log(JSON.stringify({ error: err.message }));
|
|
56
|
+
} else {
|
|
57
|
+
printError('Failed to stop DNS resolver', err);
|
|
58
|
+
}
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const dnsStatus = async (options) => {
|
|
64
|
+
const isJson = options.json;
|
|
65
|
+
const status = getDnsStatus();
|
|
66
|
+
|
|
67
|
+
if (!status) {
|
|
68
|
+
if (isJson) {
|
|
69
|
+
console.log(JSON.stringify({ running: false }));
|
|
70
|
+
} else {
|
|
71
|
+
console.log(`${chalk.red('✗')} DNS Resolver: ${chalk.red('Stopped')}`);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (isJson) {
|
|
77
|
+
console.log(JSON.stringify({ running: true, ...status }));
|
|
78
|
+
} else {
|
|
79
|
+
console.log(`${chalk.green('✓')} DNS Resolver: ${chalk.green('Running')}`);
|
|
80
|
+
console.log(` ${chalk.dim('PID :')} ${status.pid}`);
|
|
81
|
+
console.log(` ${chalk.dim('Port :')} ${status.port}`);
|
|
82
|
+
console.log(` ${chalk.dim('Upstream:')} ${status.upstream}`);
|
|
83
|
+
console.log(` ${chalk.dim('Uptime :')} ${Math.floor((Date.now() - new Date(status.startTime)) / 1000)}s`);
|
|
84
|
+
}
|
|
85
|
+
};
|
package/src/commands/servers.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import Conf from 'conf';
|
|
2
|
-
import { printSuccess, printError, printInfo, createTable } from '../ui/display.js';
|
|
3
1
|
import chalk from 'chalk';
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import { printSuccess, printError, printInfo, createTable } from '../utils/display.js';
|
|
3
|
+
import { addProfile, getProfiles, setActiveProfile } from '../core/profile-service.js';
|
|
6
4
|
|
|
7
5
|
export const addServer = async (alias, options) => {
|
|
8
6
|
const isJson = options.json;
|
|
@@ -18,31 +16,30 @@ export const addServer = async (alias, options) => {
|
|
|
18
16
|
return;
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
19
|
+
try {
|
|
20
|
+
const res = addProfile(alias, server);
|
|
21
|
+
if (isJson) {
|
|
22
|
+
console.log(JSON.stringify({ success: true, ...res }));
|
|
23
|
+
} else {
|
|
24
|
+
printSuccess(`Saved profile '${alias}' -> ${server}`);
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
if (isJson) {
|
|
28
|
+
console.log(JSON.stringify({ error: err.message }));
|
|
29
|
+
} else {
|
|
30
|
+
printError('Failed to add profile', err);
|
|
31
|
+
}
|
|
32
|
+
process.exitCode = 1;
|
|
34
33
|
}
|
|
35
34
|
};
|
|
36
35
|
|
|
37
36
|
export const listServers = async (options) => {
|
|
38
37
|
const isJson = options.json;
|
|
39
|
-
const profiles =
|
|
40
|
-
const activeAlias = config.get('activeServer');
|
|
41
|
-
|
|
38
|
+
const { profiles, active } = getProfiles();
|
|
42
39
|
const aliases = Object.keys(profiles);
|
|
43
40
|
|
|
44
41
|
if (isJson) {
|
|
45
|
-
console.log(JSON.stringify({ servers: profiles, active
|
|
42
|
+
console.log(JSON.stringify({ servers: profiles, active }));
|
|
46
43
|
return;
|
|
47
44
|
}
|
|
48
45
|
|
|
@@ -54,7 +51,7 @@ export const listServers = async (options) => {
|
|
|
54
51
|
const table = createTable(['Alias', 'Server', 'Active']);
|
|
55
52
|
|
|
56
53
|
for (const alias of aliases) {
|
|
57
|
-
const isActive = alias ===
|
|
54
|
+
const isActive = alias === active;
|
|
58
55
|
table.push([
|
|
59
56
|
isActive ? chalk.green.bold(alias) : alias,
|
|
60
57
|
profiles[alias],
|
|
@@ -67,23 +64,20 @@ export const listServers = async (options) => {
|
|
|
67
64
|
|
|
68
65
|
export const useServer = async (alias, options) => {
|
|
69
66
|
const isJson = options.json;
|
|
70
|
-
const profiles = config.get('servers', {});
|
|
71
67
|
|
|
72
|
-
|
|
68
|
+
try {
|
|
69
|
+
const res = setActiveProfile(alias);
|
|
70
|
+
if (isJson) {
|
|
71
|
+
console.log(JSON.stringify({ success: true, active: alias, server: res.server }));
|
|
72
|
+
} else {
|
|
73
|
+
printSuccess(`Set '${alias}' as the active profile.`);
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
73
76
|
if (isJson) {
|
|
74
|
-
console.log(JSON.stringify({ error:
|
|
77
|
+
console.log(JSON.stringify({ error: err.message }));
|
|
75
78
|
} else {
|
|
76
|
-
printError(
|
|
79
|
+
printError(err.message);
|
|
77
80
|
}
|
|
78
81
|
process.exitCode = 1;
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
config.set('activeServer', alias);
|
|
83
|
-
|
|
84
|
-
if (isJson) {
|
|
85
|
-
console.log(JSON.stringify({ success: true, active: alias, server: profiles[alias] }));
|
|
86
|
-
} else {
|
|
87
|
-
printSuccess(`Set '${alias}' as the active profile.`);
|
|
88
82
|
}
|
|
89
83
|
};
|
package/src/commands/start.js
CHANGED
|
@@ -1,22 +1,43 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import
|
|
3
|
-
import { printError, printSuccess,
|
|
4
|
-
import { getPublicIp, getProxiedIp } from '../net/ip.js';
|
|
5
|
-
import {
|
|
2
|
+
import tls from 'tls';
|
|
3
|
+
import { printError, printSuccess, createSpinner } from '../utils/display.js';
|
|
4
|
+
import { getPublicIp, getProxiedIp } from '../net/ip-check.js';
|
|
5
|
+
import { getProfiles } from '../core/profile-service.js';
|
|
6
|
+
import { getActiveTunnel, startTunnel } from '../core/tunnel-service.js';
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const detectTlsServer = (host, port = 8443) => {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const socket = tls.connect({
|
|
11
|
+
host,
|
|
12
|
+
port,
|
|
13
|
+
rejectUnauthorized: false,
|
|
14
|
+
timeout: 3000
|
|
15
|
+
}, () => {
|
|
16
|
+
socket.destroy();
|
|
17
|
+
resolve(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
socket.on('error', () => {
|
|
21
|
+
resolve(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
socket.on('timeout', () => {
|
|
25
|
+
socket.destroy();
|
|
26
|
+
resolve(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
};
|
|
8
30
|
|
|
9
31
|
export default async (options) => {
|
|
10
32
|
const isJson = options.json;
|
|
11
33
|
let server = options.server;
|
|
12
34
|
const port = parseInt(options.port || '1080', 10);
|
|
35
|
+
const requestedMode = options.mode || 'auto';
|
|
13
36
|
|
|
14
37
|
if (!server) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (activeAlias && profiles[activeAlias]) {
|
|
19
|
-
server = profiles[activeAlias];
|
|
38
|
+
const { profiles, active } = getProfiles();
|
|
39
|
+
if (active && profiles[active]) {
|
|
40
|
+
server = profiles[active];
|
|
20
41
|
} else {
|
|
21
42
|
if (isJson) {
|
|
22
43
|
console.log(JSON.stringify({ error: 'No server specified' }));
|
|
@@ -29,7 +50,7 @@ export default async (options) => {
|
|
|
29
50
|
}
|
|
30
51
|
|
|
31
52
|
// Check if already running
|
|
32
|
-
const info =
|
|
53
|
+
const info = getActiveTunnel();
|
|
33
54
|
if (info) {
|
|
34
55
|
if (isJson) {
|
|
35
56
|
console.log(JSON.stringify({ error: 'Tunnel already running', pid: info.pid }));
|
|
@@ -47,18 +68,33 @@ export default async (options) => {
|
|
|
47
68
|
oldIp = await getPublicIp();
|
|
48
69
|
if (!isJson) {
|
|
49
70
|
spinner.succeed(`Current IP: ${chalk.cyan(oldIp)}`);
|
|
50
|
-
spinner.text = 'Starting SSH SOCKS5 tunnel...';
|
|
51
|
-
spinner.start();
|
|
52
71
|
}
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
|
|
73
|
+
const hostPart = server.includes('@') ? server.split('@')[1] : server;
|
|
74
|
+
let actualMode = requestedMode;
|
|
75
|
+
|
|
76
|
+
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';
|
|
84
|
+
} else {
|
|
85
|
+
actualMode = 'ssh';
|
|
86
|
+
if (!isJson) {
|
|
87
|
+
spinner.info('TLS server not detected. Falling back to SSH mode...');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
if (!isJson) {
|
|
58
|
-
spinner.text =
|
|
93
|
+
spinner.text = `Starting ${actualMode.toUpperCase()} tunnel...`;
|
|
94
|
+
spinner.start();
|
|
59
95
|
}
|
|
60
|
-
|
|
61
|
-
await
|
|
96
|
+
|
|
97
|
+
const res = await startTunnel(server, port, actualMode);
|
|
62
98
|
|
|
63
99
|
if (!isJson) {
|
|
64
100
|
spinner.text = 'Verifying IP through proxy...';
|
|
@@ -68,7 +104,7 @@ export default async (options) => {
|
|
|
68
104
|
|
|
69
105
|
if (!isJson) {
|
|
70
106
|
spinner.stop();
|
|
71
|
-
printSuccess(`Tunnel established successfully to ${server}`);
|
|
107
|
+
printSuccess(`Tunnel established successfully to ${server} (${actualMode.toUpperCase()} mode)`);
|
|
72
108
|
console.log(`\n ${chalk.dim('Old IP:')} ${chalk.red(oldIp)}`);
|
|
73
109
|
console.log(` ${chalk.dim('New IP:')} ${chalk.green(newIp)}`);
|
|
74
110
|
console.log(` ${chalk.dim('Proxy :')} ${chalk.cyan(`socks5://127.0.0.1:${port}`)}`);
|
|
@@ -76,7 +112,7 @@ export default async (options) => {
|
|
|
76
112
|
console.log(chalk.dim('\nTunnel is running in the background. Leave this terminal or close it, it will stay alive.'));
|
|
77
113
|
console.log(chalk.dim('Run "polaris status" to check, or "polaris stop" to end.'));
|
|
78
114
|
} else {
|
|
79
|
-
console.log(JSON.stringify({ success: true, oldIp, newIp, proxy: `socks5://127.0.0.1:${port}`, pid }));
|
|
115
|
+
console.log(JSON.stringify({ success: true, oldIp, newIp, proxy: `socks5://127.0.0.1:${port}`, pid: res.pid, mode: actualMode }));
|
|
80
116
|
}
|
|
81
117
|
|
|
82
118
|
} catch (err) {
|
package/src/commands/status.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getProxiedIp
|
|
3
|
-
import { createTable, createSpinner, printError } from '../
|
|
1
|
+
import { getActiveTunnel } from '../core/tunnel-service.js';
|
|
2
|
+
import { getProxiedIp } from '../net/ip-check.js';
|
|
3
|
+
import { createTable, createSpinner, printError } from '../utils/display.js';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
|
|
6
6
|
export default async (options) => {
|
|
7
7
|
const isJson = options.json;
|
|
8
|
-
const info =
|
|
8
|
+
const info = getActiveTunnel();
|
|
9
9
|
|
|
10
10
|
if (!info) {
|
|
11
11
|
if (isJson) {
|
|
@@ -30,6 +30,7 @@ export default async (options) => {
|
|
|
30
30
|
server: info.server,
|
|
31
31
|
port: info.port,
|
|
32
32
|
pid: info.pid,
|
|
33
|
+
mode: info.mode || 'ssh',
|
|
33
34
|
ip: currentIp,
|
|
34
35
|
uptimeMs
|
|
35
36
|
}));
|
|
@@ -41,6 +42,7 @@ export default async (options) => {
|
|
|
41
42
|
|
|
42
43
|
table.push(
|
|
43
44
|
['Status', chalk.green('Connected')],
|
|
45
|
+
['Mode', (info.mode || 'ssh').toUpperCase()],
|
|
44
46
|
['Server', info.server],
|
|
45
47
|
['Proxy', `socks5://127.0.0.1:${info.port}`],
|
|
46
48
|
['Current IP', chalk.cyan(currentIp)],
|
package/src/commands/stop.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { printSuccess, printInfo, printError } from '../
|
|
1
|
+
import { stopActiveTunnel } from '../core/tunnel-service.js';
|
|
2
|
+
import { printSuccess, printInfo, printError } from '../utils/display.js';
|
|
3
3
|
|
|
4
4
|
export default async (options) => {
|
|
5
5
|
const isJson = options.json;
|
|
6
|
-
const info = getTunnelInfo();
|
|
7
|
-
|
|
8
|
-
if (!info) {
|
|
9
|
-
if (isJson) {
|
|
10
|
-
console.log(JSON.stringify({ success: true, message: 'No active tunnel found.' }));
|
|
11
|
-
} else {
|
|
12
|
-
printInfo('No active tunnel found.');
|
|
13
|
-
}
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
6
|
|
|
17
7
|
try {
|
|
18
|
-
|
|
8
|
+
const info = stopActiveTunnel();
|
|
9
|
+
if (!info) {
|
|
10
|
+
if (isJson) {
|
|
11
|
+
console.log(JSON.stringify({ success: true, message: 'No active tunnel found.' }));
|
|
12
|
+
} else {
|
|
13
|
+
printInfo('No active tunnel found.');
|
|
14
|
+
}
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
19
18
|
if (isJson) {
|
|
20
19
|
console.log(JSON.stringify({ success: true, message: 'Tunnel stopped successfully.' }));
|
|
21
20
|
} else {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { spawnDaemon, loadDaemonState, clearDaemonState, killPid, saveDaemonState } from '../utils/daemon.js';
|
|
4
|
+
import { DNS_PID_FILE, DNS_CONFIG_FILE, ensureDir } from '../utils/config.js';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
export const getDnsStatus = () => {
|
|
10
|
+
return loadDaemonState(DNS_PID_FILE, DNS_CONFIG_FILE);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const startDnsResolver = (port = 5354, upstream = 'https://cloudflare-dns.com/dns-query') => {
|
|
14
|
+
const active = getDnsStatus();
|
|
15
|
+
if (active) {
|
|
16
|
+
throw new Error(`DNS resolver is already running (PID: ${active.pid})`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
ensureDir();
|
|
20
|
+
|
|
21
|
+
// Point to static runner relative to this file
|
|
22
|
+
const serverScript = path.resolve(__dirname, '..', 'net', 'dns-runner.js');
|
|
23
|
+
|
|
24
|
+
const pid = spawnDaemon(serverScript, [], {
|
|
25
|
+
POLARIS_DNS_PORT: String(port),
|
|
26
|
+
POLARIS_DNS_UPSTREAM: upstream
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
saveDaemonState(DNS_PID_FILE, DNS_CONFIG_FILE, {
|
|
30
|
+
pid,
|
|
31
|
+
port,
|
|
32
|
+
upstream,
|
|
33
|
+
startTime: new Date().toISOString()
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return { pid, port, upstream };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const stopDnsResolver = () => {
|
|
40
|
+
const active = getDnsStatus();
|
|
41
|
+
if (active) {
|
|
42
|
+
killPid(active.pid);
|
|
43
|
+
}
|
|
44
|
+
clearDaemonState(DNS_PID_FILE, DNS_CONFIG_FILE);
|
|
45
|
+
return active;
|
|
46
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { store } from '../utils/config.js';
|
|
2
|
+
|
|
3
|
+
export const addProfile = (alias, server) => {
|
|
4
|
+
if (!server) {
|
|
5
|
+
throw new Error('Server address is required');
|
|
6
|
+
}
|
|
7
|
+
const profiles = store.get('servers', {});
|
|
8
|
+
profiles[alias] = server;
|
|
9
|
+
store.set('servers', profiles);
|
|
10
|
+
|
|
11
|
+
if (!store.get('activeServer')) {
|
|
12
|
+
store.set('activeServer', alias);
|
|
13
|
+
}
|
|
14
|
+
return { alias, server };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const getProfiles = () => {
|
|
18
|
+
const profiles = store.get('servers', {});
|
|
19
|
+
const active = store.get('activeServer');
|
|
20
|
+
return { profiles, active };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const setActiveProfile = (alias) => {
|
|
24
|
+
const profiles = store.get('servers', {});
|
|
25
|
+
if (!profiles[alias]) {
|
|
26
|
+
throw new Error(`Profile '${alias}' not found`);
|
|
27
|
+
}
|
|
28
|
+
store.set('activeServer', alias);
|
|
29
|
+
return { alias, server: profiles[alias] };
|
|
30
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { spawnSSH, waitForSocks } from '../tunnel/ssh.js';
|
|
2
|
+
import { startTlsBackground } from '../tunnel/tls.js';
|
|
3
|
+
import { loadDaemonState, clearDaemonState, killPid, saveDaemonState } from '../utils/daemon.js';
|
|
4
|
+
import { TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE, ensureDir } from '../utils/config.js';
|
|
5
|
+
|
|
6
|
+
export const getActiveTunnel = () => {
|
|
7
|
+
return loadDaemonState(TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const stopActiveTunnel = () => {
|
|
11
|
+
const info = getActiveTunnel();
|
|
12
|
+
if (info) {
|
|
13
|
+
killPid(info.pid);
|
|
14
|
+
}
|
|
15
|
+
clearDaemonState(TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE);
|
|
16
|
+
return info;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const startTunnel = async (server, port, mode = 'ssh') => {
|
|
20
|
+
const hostPart = server.includes('@') ? server.split('@')[1] : server;
|
|
21
|
+
ensureDir();
|
|
22
|
+
|
|
23
|
+
let pid;
|
|
24
|
+
if (mode === 'tls') {
|
|
25
|
+
pid = startTlsBackground(port, hostPart, 8443);
|
|
26
|
+
} else {
|
|
27
|
+
pid = spawnSSH(server, port);
|
|
28
|
+
saveDaemonState(TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE, {
|
|
29
|
+
pid,
|
|
30
|
+
server,
|
|
31
|
+
port,
|
|
32
|
+
mode: 'ssh',
|
|
33
|
+
startTime: new Date().toISOString()
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await waitForSocks(port);
|
|
38
|
+
return { pid, server, port, mode };
|
|
39
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import dns2 from 'dns2';
|
|
2
|
+
import fetch from 'node-fetch';
|
|
3
|
+
|
|
4
|
+
export const resolveViaDoh = async (name, typeNum, upstreamUrl) => {
|
|
5
|
+
const typeMap = {
|
|
6
|
+
1: 'A',
|
|
7
|
+
28: 'AAAA',
|
|
8
|
+
15: 'MX',
|
|
9
|
+
16: 'TXT',
|
|
10
|
+
5: 'CNAME',
|
|
11
|
+
2: 'NS',
|
|
12
|
+
6: 'SOA',
|
|
13
|
+
12: 'PTR'
|
|
14
|
+
};
|
|
15
|
+
const typeName = typeMap[typeNum] || 'A';
|
|
16
|
+
|
|
17
|
+
const url = `${upstreamUrl}?name=${encodeURIComponent(name)}&type=${typeName}`;
|
|
18
|
+
const response = await fetch(url, {
|
|
19
|
+
headers: { 'accept': 'application/dns-json' }
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error(`Upstream returned ${response.status}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return await response.json();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const startDnsServerInstance = (port = 5354, upstream = 'https://cloudflare-dns.com/dns-query') => {
|
|
30
|
+
const { Packet } = dns2;
|
|
31
|
+
|
|
32
|
+
const server = dns2.createServer({
|
|
33
|
+
udp: true,
|
|
34
|
+
handle: async (request, send) => {
|
|
35
|
+
const response = Packet.createResponseFromRequest(request);
|
|
36
|
+
|
|
37
|
+
for (const question of request.questions) {
|
|
38
|
+
try {
|
|
39
|
+
const dohRes = await resolveViaDoh(question.name, question.type, upstream);
|
|
40
|
+
if (dohRes.Answer) {
|
|
41
|
+
for (const ans of dohRes.Answer) {
|
|
42
|
+
response.answers.push({
|
|
43
|
+
name: ans.name,
|
|
44
|
+
type: ans.type,
|
|
45
|
+
class: Packet.CLASS.IN,
|
|
46
|
+
ttl: ans.TTL || 300,
|
|
47
|
+
address: ans.data
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// Ignore resolution failure on single record
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
send(response);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
server.listen({ udp: port }).then(() => {
|
|
61
|
+
console.log(`Polaris DNS-over-HTTPS local resolver listening on 127.0.0.1:${port}`);
|
|
62
|
+
console.log(`Using upstream: ${upstream}`);
|
|
63
|
+
}).catch((err) => {
|
|
64
|
+
console.error('DNS server failed to start:', err);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
server.on('error', (err) => {
|
|
68
|
+
console.error('DNS server error:', err);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return server;
|
|
72
|
+
};
|
|
@@ -20,7 +20,6 @@ export const getProxiedIp = async (port) => {
|
|
|
20
20
|
return data.ip;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
// Simple DNS leak check by resolving via system dns
|
|
24
23
|
export const checkDns = async () => {
|
|
25
24
|
try {
|
|
26
25
|
const servers = dns.getServers();
|
|
@@ -31,7 +30,6 @@ export const checkDns = async () => {
|
|
|
31
30
|
}
|
|
32
31
|
};
|
|
33
32
|
|
|
34
|
-
// Returns IPv6 address if leaked, null otherwise
|
|
35
33
|
export const checkIpv6Leak = async () => {
|
|
36
34
|
try {
|
|
37
35
|
const controller = new AbortController();
|
|
@@ -45,6 +43,6 @@ export const checkIpv6Leak = async () => {
|
|
|
45
43
|
}
|
|
46
44
|
return null;
|
|
47
45
|
} catch (err) {
|
|
48
|
-
return null;
|
|
46
|
+
return null;
|
|
49
47
|
}
|
|
50
48
|
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import tls from 'tls';
|
|
3
|
+
import net from 'net';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
|
|
9
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'polaris-server');
|
|
10
|
+
const KEY_FILE = path.join(CONFIG_DIR, 'server.key');
|
|
11
|
+
const CERT_FILE = path.join(CONFIG_DIR, 'server.crt');
|
|
12
|
+
|
|
13
|
+
const ensureConfigDir = () => {
|
|
14
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
15
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const generateCerts = () => {
|
|
20
|
+
ensureConfigDir();
|
|
21
|
+
if (fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
console.log('Generating self-signed TLS certificates...');
|
|
25
|
+
try {
|
|
26
|
+
execSync(
|
|
27
|
+
`openssl req -x509 -newkey rsa:2048 -nodes -sha256 -keyout "${KEY_FILE}" -out "${CERT_FILE}" -days 365 -subj "/CN=polaris"`,
|
|
28
|
+
{ stdio: 'ignore' }
|
|
29
|
+
);
|
|
30
|
+
console.log('Certificates generated successfully.');
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error('Failed to generate TLS certificates using openssl CLI:', err.message);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const startServer = (port = 8443) => {
|
|
38
|
+
generateCerts();
|
|
39
|
+
|
|
40
|
+
const options = {
|
|
41
|
+
key: fs.readFileSync(KEY_FILE),
|
|
42
|
+
cert: fs.readFileSync(CERT_FILE),
|
|
43
|
+
minVersion: 'TLSv1.3'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const server = tls.createServer(options, (cleartextStream) => {
|
|
47
|
+
let buffer = '';
|
|
48
|
+
|
|
49
|
+
const onData = (chunk) => {
|
|
50
|
+
buffer += chunk.toString('utf8');
|
|
51
|
+
const lineEnd = buffer.indexOf('\n');
|
|
52
|
+
if (lineEnd !== -1) {
|
|
53
|
+
const line = buffer.substring(0, lineEnd).trim();
|
|
54
|
+
buffer = buffer.substring(lineEnd + 1);
|
|
55
|
+
|
|
56
|
+
cleartextStream.removeListener('data', onData);
|
|
57
|
+
|
|
58
|
+
const match = line.match(/^CONNECT\s+(.+):(\d+)$/);
|
|
59
|
+
if (!match) {
|
|
60
|
+
cleartextStream.write('ERR Invalid command\n');
|
|
61
|
+
cleartextStream.end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const host = match[1];
|
|
66
|
+
const targetPort = parseInt(match[2], 10);
|
|
67
|
+
|
|
68
|
+
const targetSocket = net.connect(targetPort, host, () => {
|
|
69
|
+
cleartextStream.write('OK\n');
|
|
70
|
+
if (buffer.length > 0) {
|
|
71
|
+
targetSocket.write(buffer);
|
|
72
|
+
}
|
|
73
|
+
cleartextStream.pipe(targetSocket);
|
|
74
|
+
targetSocket.pipe(cleartextStream);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
targetSocket.on('error', (err) => {
|
|
78
|
+
try {
|
|
79
|
+
cleartextStream.write(`ERR ${err.message}\n`);
|
|
80
|
+
cleartextStream.end();
|
|
81
|
+
} catch (e) {}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
cleartextStream.on('error', () => {
|
|
85
|
+
targetSocket.destroy();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
cleartextStream.on('data', onData);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
server.listen(port, () => {
|
|
94
|
+
console.log(`Polaris TLS Server listening on port ${port}`);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
server.on('error', (err) => {
|
|
98
|
+
console.error('Server error:', err);
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const args = process.argv.slice(2);
|
|
103
|
+
const portOptIdx = args.indexOf('--port');
|
|
104
|
+
let port = 8443;
|
|
105
|
+
if (portOptIdx !== -1 && args[portOptIdx + 1]) {
|
|
106
|
+
port = parseInt(args[portOptIdx + 1], 10);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
startServer(port);
|
package/src/tunnel/ssh.js
CHANGED
|
@@ -1,27 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import net from 'net';
|
|
3
|
-
import fs from 'fs';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import os from 'os';
|
|
6
|
-
|
|
7
|
-
const CONFIG_DIR = path.join(os.homedir(), '.config', 'polaris');
|
|
8
|
-
const PID_FILE = path.join(CONFIG_DIR, 'tunnel.pid');
|
|
9
|
-
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
10
|
-
|
|
11
|
-
const ensureDir = () => {
|
|
12
|
-
if (!fs.existsSync(CONFIG_DIR)) {
|
|
13
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
14
|
-
}
|
|
15
|
-
};
|
|
16
3
|
|
|
17
4
|
export const spawnSSH = (server, port) => {
|
|
18
|
-
// We want to run SSH as a detached child process so it survives terminal close.
|
|
19
|
-
// -D port: Dynamic SOCKS5 forwarding
|
|
20
|
-
// -N: Do not execute a remote command
|
|
21
|
-
// -o StrictHostKeyChecking=no: Don't prompt for host key verification
|
|
22
|
-
// -o ServerAliveInterval=30: Keep connection alive
|
|
23
|
-
// -o ExitOnForwardFailure=yes: Exit if port forwarding fails
|
|
24
|
-
|
|
25
5
|
const args = [
|
|
26
6
|
'-D', String(port),
|
|
27
7
|
'-N',
|
|
@@ -33,15 +13,13 @@ export const spawnSSH = (server, port) => {
|
|
|
33
13
|
|
|
34
14
|
const child = spawn('ssh', args, {
|
|
35
15
|
detached: true,
|
|
36
|
-
stdio: 'ignore'
|
|
16
|
+
stdio: 'ignore'
|
|
37
17
|
});
|
|
38
18
|
|
|
39
|
-
child.unref();
|
|
40
|
-
|
|
19
|
+
child.unref();
|
|
41
20
|
return child.pid;
|
|
42
21
|
};
|
|
43
22
|
|
|
44
|
-
// Poll the local port until it accepts connections
|
|
45
23
|
export const waitForSocks = async (port, timeoutMs = 30000) => {
|
|
46
24
|
const start = Date.now();
|
|
47
25
|
|
|
@@ -49,7 +27,6 @@ export const waitForSocks = async (port, timeoutMs = 30000) => {
|
|
|
49
27
|
try {
|
|
50
28
|
await new Promise((resolve, reject) => {
|
|
51
29
|
const socket = new net.Socket();
|
|
52
|
-
|
|
53
30
|
socket.setTimeout(1000);
|
|
54
31
|
|
|
55
32
|
socket.on('connect', () => {
|
|
@@ -69,66 +46,11 @@ export const waitForSocks = async (port, timeoutMs = 30000) => {
|
|
|
69
46
|
|
|
70
47
|
socket.connect(port, '127.0.0.1');
|
|
71
48
|
});
|
|
72
|
-
return true;
|
|
49
|
+
return true;
|
|
73
50
|
} catch (err) {
|
|
74
|
-
// Wait 500ms before retrying
|
|
75
51
|
await new Promise(r => setTimeout(r, 500));
|
|
76
52
|
}
|
|
77
53
|
}
|
|
78
54
|
|
|
79
55
|
throw new Error(`Timed out waiting for SOCKS5 proxy on port ${port} to become ready`);
|
|
80
56
|
};
|
|
81
|
-
|
|
82
|
-
export const saveTunnelInfo = (pid, server, port) => {
|
|
83
|
-
ensureDir();
|
|
84
|
-
fs.writeFileSync(PID_FILE, String(pid), 'utf-8');
|
|
85
|
-
|
|
86
|
-
const config = {
|
|
87
|
-
server,
|
|
88
|
-
port,
|
|
89
|
-
startTime: new Date().toISOString()
|
|
90
|
-
};
|
|
91
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
export const getTunnelInfo = () => {
|
|
95
|
-
if (!fs.existsSync(PID_FILE) || !fs.existsSync(CONFIG_FILE)) {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
|
101
|
-
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
102
|
-
|
|
103
|
-
// Check if process actually exists
|
|
104
|
-
try {
|
|
105
|
-
process.kill(pid, 0); // test signal 0 to check if alive
|
|
106
|
-
} catch (e) {
|
|
107
|
-
if (e.code === 'ESRCH') {
|
|
108
|
-
// Process doesn't exist, tunnel is dead, cleanup
|
|
109
|
-
clearTunnelInfo();
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return { pid, ...config };
|
|
115
|
-
} catch (err) {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
export const stopTunnel = () => {
|
|
121
|
-
const info = getTunnelInfo();
|
|
122
|
-
if (info) {
|
|
123
|
-
try {
|
|
124
|
-
process.kill(info.pid, 'SIGTERM');
|
|
125
|
-
} catch (e) {
|
|
126
|
-
// Ignore errors if process already dead
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
clearTunnelInfo();
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
export const clearTunnelInfo = () => {
|
|
133
|
-
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
|
134
|
-
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createSocksToTlsProxy } from './tls.js';
|
|
2
|
+
import tls from 'tls';
|
|
3
|
+
import notifier from 'node-notifier';
|
|
4
|
+
|
|
5
|
+
const localPort = parseInt(process.env.POLARIS_LOCAL_PORT || '1080', 10);
|
|
6
|
+
const remoteServer = process.env.POLARIS_REMOTE_SERVER;
|
|
7
|
+
const remotePort = parseInt(process.env.POLARIS_REMOTE_PORT || '8443', 10);
|
|
8
|
+
|
|
9
|
+
let proxyServer = createSocksToTlsProxy(localPort, remoteServer, remotePort);
|
|
10
|
+
|
|
11
|
+
// Keepalive pings & Auto-reconnect
|
|
12
|
+
const checkConnection = async () => {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const socket = tls.connect({
|
|
15
|
+
host: remoteServer,
|
|
16
|
+
port: remotePort,
|
|
17
|
+
rejectUnauthorized: false,
|
|
18
|
+
timeout: 5000
|
|
19
|
+
}, () => {
|
|
20
|
+
socket.destroy();
|
|
21
|
+
resolve(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
socket.on('error', () => {
|
|
25
|
+
socket.destroy();
|
|
26
|
+
resolve(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
socket.on('timeout', () => {
|
|
30
|
+
socket.destroy();
|
|
31
|
+
resolve(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const reconnect = async (attempt = 1) => {
|
|
37
|
+
if (attempt > 5) {
|
|
38
|
+
notifier.notify({
|
|
39
|
+
title: 'Polaris VPN',
|
|
40
|
+
message: 'Failed to reconnect after 5 attempts. Tunnel stopped.'
|
|
41
|
+
});
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
notifier.notify({
|
|
46
|
+
title: 'Polaris VPN',
|
|
47
|
+
message: `Connection lost. Reconnecting (attempt ${attempt}/5)...`
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
proxyServer.close();
|
|
52
|
+
} catch (e) {}
|
|
53
|
+
|
|
54
|
+
// Wait with exponential backoff
|
|
55
|
+
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
proxyServer = createSocksToTlsProxy(localPort, remoteServer, remotePort);
|
|
59
|
+
const alive = await checkConnection();
|
|
60
|
+
if (alive) {
|
|
61
|
+
notifier.notify({
|
|
62
|
+
title: 'Polaris VPN',
|
|
63
|
+
message: 'Reconnected successfully.'
|
|
64
|
+
});
|
|
65
|
+
startInterval();
|
|
66
|
+
} else {
|
|
67
|
+
reconnect(attempt + 1);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
reconnect(attempt + 1);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let intervalId;
|
|
75
|
+
const startInterval = () => {
|
|
76
|
+
intervalId = setInterval(async () => {
|
|
77
|
+
const isAlive = await checkConnection();
|
|
78
|
+
if (!isAlive) {
|
|
79
|
+
clearInterval(intervalId);
|
|
80
|
+
reconnect();
|
|
81
|
+
}
|
|
82
|
+
}, 30000);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
startInterval();
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import net from 'net';
|
|
2
|
+
import tls from 'tls';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { spawnDaemon, saveDaemonState } from '../utils/daemon.js';
|
|
6
|
+
import { TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE, ensureDir } from '../utils/config.js';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
|
|
11
|
+
// SOCKS5 to TLS proxy server instance logic
|
|
12
|
+
export const createSocksToTlsProxy = (localPort, remoteServer, remotePort = 8443) => {
|
|
13
|
+
const server = net.createServer((clientSocket) => {
|
|
14
|
+
let stage = 0; // 0: Greeting, 1: Request, 2: Connected
|
|
15
|
+
|
|
16
|
+
clientSocket.on('data', (data) => {
|
|
17
|
+
if (stage === 0) {
|
|
18
|
+
if (data[0] !== 0x05) {
|
|
19
|
+
clientSocket.destroy();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
clientSocket.write(Buffer.from([0x05, 0x00]));
|
|
23
|
+
stage = 1;
|
|
24
|
+
} else if (stage === 1) {
|
|
25
|
+
if (data[0] !== 0x05 || data[1] !== 0x01) {
|
|
26
|
+
clientSocket.write(Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
27
|
+
clientSocket.destroy();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const atyp = data[3];
|
|
32
|
+
let host = '';
|
|
33
|
+
let offset = 4;
|
|
34
|
+
|
|
35
|
+
if (atyp === 0x01) {
|
|
36
|
+
host = `${data[4]}.${data[5]}.${data[6]}.${data[7]}`;
|
|
37
|
+
offset = 8;
|
|
38
|
+
} else if (atyp === 0x03) {
|
|
39
|
+
const len = data[4];
|
|
40
|
+
host = data.toString('utf8', 5, 5 + len);
|
|
41
|
+
offset = 5 + len;
|
|
42
|
+
} else if (atyp === 0x04) {
|
|
43
|
+
host = data.slice(4, 20).toString('hex');
|
|
44
|
+
offset = 20;
|
|
45
|
+
} else {
|
|
46
|
+
clientSocket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
47
|
+
clientSocket.destroy();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const port = data.readUInt16BE(offset);
|
|
52
|
+
|
|
53
|
+
// Connect to remote TLS server
|
|
54
|
+
const tlsSocket = tls.connect({
|
|
55
|
+
host: remoteServer,
|
|
56
|
+
port: remotePort,
|
|
57
|
+
rejectUnauthorized: false,
|
|
58
|
+
minVersion: 'TLSv1.3'
|
|
59
|
+
}, () => {
|
|
60
|
+
tlsSocket.write(`CONNECT ${host}:${port}\n`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let responseBuffer = '';
|
|
64
|
+
const onTlsData = (chunk) => {
|
|
65
|
+
responseBuffer += chunk.toString('utf8');
|
|
66
|
+
const lineEnd = responseBuffer.indexOf('\n');
|
|
67
|
+
if (lineEnd !== -1) {
|
|
68
|
+
const line = responseBuffer.substring(0, lineEnd).trim();
|
|
69
|
+
tlsSocket.removeListener('data', onTlsData);
|
|
70
|
+
|
|
71
|
+
if (line === 'OK') {
|
|
72
|
+
clientSocket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
73
|
+
stage = 2;
|
|
74
|
+
|
|
75
|
+
const rest = responseBuffer.substring(lineEnd + 1);
|
|
76
|
+
if (rest.length > 0) {
|
|
77
|
+
clientSocket.write(Buffer.from(rest, 'utf8'));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
clientSocket.pipe(tlsSocket);
|
|
81
|
+
tlsSocket.pipe(clientSocket);
|
|
82
|
+
} else {
|
|
83
|
+
clientSocket.write(Buffer.from([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
84
|
+
clientSocket.destroy();
|
|
85
|
+
tlsSocket.destroy();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
tlsSocket.on('data', onTlsData);
|
|
91
|
+
|
|
92
|
+
tlsSocket.on('error', () => {
|
|
93
|
+
try {
|
|
94
|
+
clientSocket.write(Buffer.from([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
95
|
+
clientSocket.destroy();
|
|
96
|
+
} catch (e) {}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
clientSocket.on('error', () => {
|
|
100
|
+
tlsSocket.destroy();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
server.listen(localPort, '127.0.0.1');
|
|
107
|
+
return server;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Spawn background proxy with keepalive/reconnect checks
|
|
111
|
+
export const startTlsBackground = (localPort, remoteServer, remotePort = 8443) => {
|
|
112
|
+
ensureDir();
|
|
113
|
+
|
|
114
|
+
const runnerScript = path.join(__dirname, 'tls-runner.js');
|
|
115
|
+
|
|
116
|
+
const pid = spawnDaemon(runnerScript, [], {
|
|
117
|
+
POLARIS_LOCAL_PORT: String(localPort),
|
|
118
|
+
POLARIS_REMOTE_SERVER: remoteServer,
|
|
119
|
+
POLARIS_REMOTE_PORT: String(remotePort)
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
saveDaemonState(TUNNEL_PID_FILE, TUNNEL_CONFIG_FILE, {
|
|
123
|
+
pid,
|
|
124
|
+
server: remoteServer,
|
|
125
|
+
port: localPort,
|
|
126
|
+
mode: 'tls',
|
|
127
|
+
startTime: new Date().toISOString()
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return pid;
|
|
131
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import Conf from 'conf';
|
|
5
|
+
|
|
6
|
+
export const CONFIG_DIR = path.join(os.homedir(), '.config', 'polaris');
|
|
7
|
+
export const TUNNEL_PID_FILE = path.join(CONFIG_DIR, 'tunnel.pid');
|
|
8
|
+
export const TUNNEL_CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
|
+
export const DNS_PID_FILE = path.join(CONFIG_DIR, 'dns.pid');
|
|
10
|
+
export const DNS_CONFIG_FILE = path.join(CONFIG_DIR, 'dns.json');
|
|
11
|
+
|
|
12
|
+
export const ensureDir = (dir = CONFIG_DIR) => {
|
|
13
|
+
if (!fs.existsSync(dir)) {
|
|
14
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const store = new Conf({ projectName: 'polaris' });
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
|
|
4
|
+
export const isPidAlive = (pid) => {
|
|
5
|
+
if (!pid) return false;
|
|
6
|
+
try {
|
|
7
|
+
process.kill(pid, 0);
|
|
8
|
+
return true;
|
|
9
|
+
} catch (e) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const killPid = (pid, signal = 'SIGTERM') => {
|
|
15
|
+
if (!pid) return;
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, signal);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
// Ignore if already dead
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const spawnDaemon = (scriptPath, args = [], env = {}) => {
|
|
24
|
+
const child = spawn('node', [scriptPath, ...args], {
|
|
25
|
+
detached: true,
|
|
26
|
+
stdio: 'ignore',
|
|
27
|
+
env: {
|
|
28
|
+
...process.env,
|
|
29
|
+
...env
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
child.unref();
|
|
33
|
+
return child.pid;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const saveDaemonState = (pidFile, configFile, data) => {
|
|
37
|
+
fs.writeFileSync(pidFile, String(data.pid), 'utf-8');
|
|
38
|
+
fs.writeFileSync(configFile, JSON.stringify(data, null, 2), 'utf-8');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const loadDaemonState = (pidFile, configFile) => {
|
|
42
|
+
if (!fs.existsSync(pidFile) || !fs.existsSync(configFile)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
48
|
+
const config = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
|
|
49
|
+
|
|
50
|
+
if (!isPidAlive(pid)) {
|
|
51
|
+
clearDaemonState(pidFile, configFile);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { pid, ...config };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const clearDaemonState = (pidFile, configFile) => {
|
|
62
|
+
try {
|
|
63
|
+
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
|
|
64
|
+
} catch (e) {}
|
|
65
|
+
try {
|
|
66
|
+
if (fs.existsSync(configFile)) fs.unlinkSync(configFile);
|
|
67
|
+
} catch (e) {}
|
|
68
|
+
};
|
|
File without changes
|