@push.rocks/smartvpn 1.22.0 → 2.1.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/assets/third-party-licenses.md +157 -0
- package/dist_rust/{smartvpn_daemon_linux_amd64 → smartvpn_daemon_linux_amd64_musl} +0 -0
- package/dist_rust/smartvpn_daemon_linux_amd64_musl.tsrust-build.json +14 -0
- package/dist_rust/{smartvpn_daemon_linux_arm64 → smartvpn_daemon_linux_arm64_musl} +0 -0
- package/dist_rust/smartvpn_daemon_linux_arm64_musl.tsrust-build.json +14 -0
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/smartvpn.classes.vpnbridge.d.ts +4 -3
- package/dist_ts/smartvpn.classes.vpnbridge.js +27 -47
- package/dist_ts/smartvpn.classes.vpnclient.d.ts +5 -4
- package/dist_ts/smartvpn.classes.vpnclient.js +10 -5
- package/dist_ts/smartvpn.classes.vpnconfig.d.ts +1 -0
- package/dist_ts/smartvpn.classes.vpnconfig.js +52 -14
- package/dist_ts/smartvpn.classes.vpnserver.d.ts +17 -4
- package/dist_ts/smartvpn.classes.vpnserver.js +80 -40
- package/dist_ts/smartvpn.interfaces.d.ts +155 -16
- package/dist_ts/smartvpn.paths.d.ts +2 -0
- package/dist_ts/smartvpn.paths.js +14 -1
- package/notices/aho-corasick-license.txt +21 -0
- package/notices/cargo-dependencies.html +5202 -0
- package/notices/compiler-builtins-license.txt +275 -0
- package/notices/defmt-license.txt +25 -0
- package/notices/inventory.json +1427 -0
- package/notices/llvm-libunwind-license.txt +311 -0
- package/notices/mit-source-attributions.txt +31 -0
- package/notices/musl-copyright.txt +193 -0
- package/notices/proc-macro-error2-license.txt +21 -0
- package/notices/rust-standard-library.html +8266 -0
- package/notices/valuable-license.txt +25 -0
- package/package.json +15 -7
- package/readme.md +344 -5
- package/third-party-notices.md +81 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/smartvpn.classes.vpnbridge.ts +27 -52
- package/ts/smartvpn.classes.vpnclient.ts +10 -5
- package/ts/smartvpn.classes.vpnconfig.ts +49 -13
- package/ts/smartvpn.classes.vpnserver.ts +94 -40
- package/ts/smartvpn.interfaces.ts +159 -16
- package/ts/smartvpn.paths.ts +19 -0
- package/readme.hints.md +0 -8
- package/readme.plan.md +0 -253
|
@@ -6,49 +6,6 @@ import type {
|
|
|
6
6
|
} from './smartvpn.interfaces.js';
|
|
7
7
|
import type { TCommandMap } from '@push.rocks/smartrust';
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Get the package root directory.
|
|
11
|
-
*/
|
|
12
|
-
function getPackageRoot(): string {
|
|
13
|
-
const thisDir = plugins.path.dirname(plugins.url.fileURLToPath(import.meta.url));
|
|
14
|
-
return plugins.path.resolve(thisDir, '..');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Map Node.js platform/arch to tsrust's platform suffix.
|
|
19
|
-
*/
|
|
20
|
-
function getTsrustPlatformSuffix(): string | null {
|
|
21
|
-
const archMap: Record<string, string> = { x64: 'amd64', arm64: 'arm64' };
|
|
22
|
-
const osMap: Record<string, string> = { linux: 'linux', darwin: 'macos' };
|
|
23
|
-
const os = osMap[process.platform];
|
|
24
|
-
const arch = archMap[process.arch];
|
|
25
|
-
if (os && arch) {
|
|
26
|
-
return `${os}_${arch}`;
|
|
27
|
-
}
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Build local search paths for the smartvpn daemon binary.
|
|
33
|
-
*/
|
|
34
|
-
function buildLocalPaths(binaryName: string): string[] {
|
|
35
|
-
const packageRoot = getPackageRoot();
|
|
36
|
-
const suffix = getTsrustPlatformSuffix();
|
|
37
|
-
const paths: string[] = [];
|
|
38
|
-
|
|
39
|
-
// dist_rust/ (tsrust cross-compiled output)
|
|
40
|
-
if (suffix) {
|
|
41
|
-
paths.push(plugins.path.join(packageRoot, 'dist_rust', `${binaryName}_${suffix}`));
|
|
42
|
-
}
|
|
43
|
-
paths.push(plugins.path.join(packageRoot, 'dist_rust', binaryName));
|
|
44
|
-
|
|
45
|
-
// Local dev build paths
|
|
46
|
-
paths.push(plugins.path.resolve(process.cwd(), 'rust', 'target', 'release', binaryName));
|
|
47
|
-
paths.push(plugins.path.resolve(process.cwd(), 'rust', 'target', 'debug', binaryName));
|
|
48
|
-
|
|
49
|
-
return paths;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
9
|
/**
|
|
53
10
|
* Shared bridge wrapper around smartrust RustBridge.
|
|
54
11
|
* Supports stdio mode (dev: spawn child process) and socket mode (production: connect to running daemon).
|
|
@@ -62,19 +19,34 @@ export class VpnBridge<TCommands extends TCommandMap> extends plugins.events.Eve
|
|
|
62
19
|
transport: TVpnTransportOptions;
|
|
63
20
|
mode: 'client' | 'server';
|
|
64
21
|
binaryName?: string;
|
|
22
|
+
networkNamespaceFd?: number;
|
|
65
23
|
}) {
|
|
66
24
|
super();
|
|
67
25
|
|
|
26
|
+
const namespaceFd = options.networkNamespaceFd;
|
|
27
|
+
if (namespaceFd !== undefined) {
|
|
28
|
+
if (plugins.os.platform() !== 'linux' || options.mode !== 'client' || options.transport.transport !== 'stdio') {
|
|
29
|
+
throw new Error('VpnBridge networkNamespaceFd requires a Linux stdio client');
|
|
30
|
+
}
|
|
31
|
+
if (!Number.isInteger(namespaceFd) || namespaceFd < 0 || namespaceFd > 0x7fff_ffff) {
|
|
32
|
+
throw new Error('VpnBridge networkNamespaceFd must be a non-negative native descriptor');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
68
35
|
const binaryName = options.binaryName || 'smartvpn_daemon';
|
|
69
|
-
|
|
36
|
+
// Keep the validated process transport stable even if the caller edits its options.
|
|
37
|
+
this.transportOptions = { ...options.transport };
|
|
70
38
|
this.mode = options.mode;
|
|
71
39
|
|
|
72
40
|
this.bridge = new plugins.smartrust.RustBridge<TCommands>({
|
|
73
41
|
binaryName,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
42
|
+
binaryPath: options.transport.transport === 'stdio'
|
|
43
|
+
? paths.getBinaryPath(binaryName)
|
|
44
|
+
: undefined,
|
|
45
|
+
localPaths: [],
|
|
46
|
+
searchSystemPath: false,
|
|
47
|
+
cliArgs: ['--management', '--mode', this.mode,
|
|
48
|
+
...(namespaceFd === undefined ? [] : ['--network-namespace-fd', '3'])],
|
|
49
|
+
inheritedFileDescriptors: namespaceFd === undefined ? undefined : [namespaceFd],
|
|
78
50
|
maxPayloadSize: 10 * 1024 * 1024, // 10 MB
|
|
79
51
|
});
|
|
80
52
|
|
|
@@ -85,6 +57,9 @@ export class VpnBridge<TCommands extends TCommandMap> extends plugins.events.Eve
|
|
|
85
57
|
this.bridge.on('reconnected', () => {
|
|
86
58
|
this.emit('reconnected');
|
|
87
59
|
});
|
|
60
|
+
this.bridge.on('stderr', (line: string) => {
|
|
61
|
+
this.emit('stderr', line);
|
|
62
|
+
});
|
|
88
63
|
|
|
89
64
|
// Forward management events from the daemon
|
|
90
65
|
// smartrust emits 'management:<eventName>' for unsolicited events
|
|
@@ -126,11 +101,11 @@ export class VpnBridge<TCommands extends TCommandMap> extends plugins.events.Eve
|
|
|
126
101
|
}
|
|
127
102
|
|
|
128
103
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
104
|
+
* Confirm closure of the owned transport. Socket mode leaves the remote daemon alive;
|
|
105
|
+
* stdio mode waits for child exit and stdio closure. Failure remains retryable upstream.
|
|
131
106
|
*/
|
|
132
|
-
public stop(): void {
|
|
133
|
-
this.bridge.
|
|
107
|
+
public async stop(): Promise<void> {
|
|
108
|
+
await this.bridge.terminate();
|
|
134
109
|
}
|
|
135
110
|
|
|
136
111
|
/**
|
|
@@ -23,6 +23,7 @@ export class VpnClient extends plugins.events.EventEmitter {
|
|
|
23
23
|
this.bridge = new VpnBridge<TVpnClientCommands>({
|
|
24
24
|
transport: options.transport,
|
|
25
25
|
mode: 'client',
|
|
26
|
+
networkNamespaceFd: options.networkNamespaceFd,
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
// Forward bridge events
|
|
@@ -32,6 +33,9 @@ export class VpnClient extends plugins.events.EventEmitter {
|
|
|
32
33
|
this.bridge.on('reconnected', () => {
|
|
33
34
|
this.emit('reconnected');
|
|
34
35
|
});
|
|
36
|
+
this.bridge.on('stderr', (line: string) => {
|
|
37
|
+
this.emit('stderr', line);
|
|
38
|
+
});
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/**
|
|
@@ -81,17 +85,18 @@ export class VpnClient extends plugins.events.EventEmitter {
|
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/**
|
|
84
|
-
* Get MTU information
|
|
88
|
+
* Get active runtime MTU information, or null when disconnected.
|
|
89
|
+
* Unknown outer-path measurements remain null.
|
|
85
90
|
*/
|
|
86
|
-
public async getMtuInfo(): Promise<IVpnMtuInfo> {
|
|
91
|
+
public async getMtuInfo(): Promise<IVpnMtuInfo | null> {
|
|
87
92
|
return this.bridge.sendCommand('getMtuInfo', {} as Record<string, never>);
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/**
|
|
91
|
-
* Stop the daemon bridge.
|
|
96
|
+
* Stop the daemon bridge and wait for confirmed ownership release.
|
|
92
97
|
*/
|
|
93
|
-
public stop(): void {
|
|
94
|
-
this.bridge.stop();
|
|
98
|
+
public async stop(): Promise<void> {
|
|
99
|
+
await this.bridge.stop();
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
/**
|
|
@@ -8,6 +8,12 @@ import type {
|
|
|
8
8
|
* VPN configuration loader, saver, and validator.
|
|
9
9
|
*/
|
|
10
10
|
export class VpnConfig {
|
|
11
|
+
private static validateMtu(mtu?: number): void {
|
|
12
|
+
if (mtu !== undefined && (!Number.isInteger(mtu) || mtu < 576 || mtu > 65472)) {
|
|
13
|
+
throw new Error('VpnConfig: mtu must be an integer between 576 and 65472 inner IP bytes');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* Validate a client config object. Throws on invalid config.
|
|
13
19
|
*/
|
|
@@ -31,14 +37,26 @@ export class VpnConfig {
|
|
|
31
37
|
if (config.wgPresharedKey) {
|
|
32
38
|
VpnConfig.validateBase64Key(config.wgPresharedKey, 'wgPresharedKey');
|
|
33
39
|
}
|
|
34
|
-
if (config.wgAllowedIps) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
if (!Array.isArray(config.wgAllowedIps) || config.wgAllowedIps.length === 0) {
|
|
41
|
+
throw new Error('VpnConfig: wgAllowedIps must be a nonempty array for WireGuard transport');
|
|
42
|
+
}
|
|
43
|
+
for (const cidr of config.wgAllowedIps) {
|
|
44
|
+
if (!VpnConfig.isValidCidr(cidr)) {
|
|
45
|
+
throw new Error(`VpnConfig: invalid allowedIp CIDR: ${cidr}`);
|
|
39
46
|
}
|
|
40
47
|
}
|
|
48
|
+
if (!VpnConfig.isValidIp(config.wgAddress)) {
|
|
49
|
+
throw new Error('VpnConfig: wgAddress must be an IPv4 address');
|
|
50
|
+
}
|
|
51
|
+
if (config.wgAddressPrefix !== undefined && (!Number.isInteger(config.wgAddressPrefix) || config.wgAddressPrefix < 0 || config.wgAddressPrefix > 32)) {
|
|
52
|
+
throw new Error('VpnConfig: wgAddressPrefix must be an integer from 0 through 32');
|
|
53
|
+
}
|
|
41
54
|
} else {
|
|
55
|
+
if (config.managedNetwork && (!config.managedNetwork.authorityId || !config.managedNetwork.nodeId
|
|
56
|
+
|| config.managedNetwork.authorityId.trim() !== config.managedNetwork.authorityId
|
|
57
|
+
|| config.managedNetwork.nodeId.trim() !== config.managedNetwork.nodeId)) {
|
|
58
|
+
throw new Error('VpnConfig: managedNetwork requires an explicit authorityId and nodeId');
|
|
59
|
+
}
|
|
42
60
|
if (!config.serverUrl) {
|
|
43
61
|
throw new Error('VpnConfig: serverUrl is required');
|
|
44
62
|
}
|
|
@@ -61,9 +79,7 @@ export class VpnConfig {
|
|
|
61
79
|
}
|
|
62
80
|
VpnConfig.validateBase64Key(config.clientPublicKey, 'clientPublicKey');
|
|
63
81
|
}
|
|
64
|
-
|
|
65
|
-
throw new Error('VpnConfig: mtu must be between 576 and 65535');
|
|
66
|
-
}
|
|
82
|
+
VpnConfig.validateMtu(config.mtu);
|
|
67
83
|
if (config.keepaliveIntervalSecs !== undefined && config.keepaliveIntervalSecs < 1) {
|
|
68
84
|
throw new Error('VpnConfig: keepaliveIntervalSecs must be >= 1');
|
|
69
85
|
}
|
|
@@ -80,16 +96,38 @@ export class VpnConfig {
|
|
|
80
96
|
* Validate a server config object. Throws on invalid config.
|
|
81
97
|
*/
|
|
82
98
|
public static validateServerConfig(config: IVpnServerConfig): void {
|
|
99
|
+
if (config.forwardingMode === 'managed') {
|
|
100
|
+
if (!config.managedAuthorityId || config.managedAuthorityId.trim() !== config.managedAuthorityId) {
|
|
101
|
+
throw new Error('VpnConfig: managed mode requires managedAuthorityId');
|
|
102
|
+
}
|
|
103
|
+
if (config.clients?.length || config.wgPeers?.length || config.enableNat === true
|
|
104
|
+
|| config.destinationPolicy !== undefined || config.clientAllowedIPs !== undefined || config.dns !== undefined
|
|
105
|
+
|| config.bridgeLanSubnet !== undefined || config.bridgePhysicalInterface !== undefined
|
|
106
|
+
|| config.bridgeIpRangeStart !== undefined || config.bridgeIpRangeEnd !== undefined
|
|
107
|
+
|| config.socketForwardProxyProtocol === true || config.socketForwardProxyProtocolSource !== undefined
|
|
108
|
+
|| config.socketForwardProxyProtocolVpnMetadata === true) {
|
|
109
|
+
throw new Error('VpnConfig: managed mode does not accept standalone clients, peers, routing, DNS, NAT or bridge configuration');
|
|
110
|
+
}
|
|
111
|
+
if (config.wgPrivateKey && ['all', 'wireguard'].includes(config.transportMode ?? 'all') && !config.serverEndpoint) {
|
|
112
|
+
throw new Error('VpnConfig: managed WireGuard requires an explicit serverEndpoint');
|
|
113
|
+
}
|
|
114
|
+
} else if (config.managedAuthorityId !== undefined) {
|
|
115
|
+
throw new Error('VpnConfig: managedAuthorityId requires managed mode');
|
|
116
|
+
}
|
|
117
|
+
if (config.wgPrivateKey) { VpnConfig.validateBase64Key(config.wgPrivateKey, 'wgPrivateKey'); }
|
|
83
118
|
if (config.transportMode === 'wireguard') {
|
|
84
119
|
// WireGuard server validation
|
|
85
120
|
if (!config.privateKey) {
|
|
86
121
|
throw new Error('VpnConfig: privateKey is required');
|
|
87
122
|
}
|
|
88
123
|
VpnConfig.validateBase64Key(config.privateKey, 'privateKey');
|
|
89
|
-
if (
|
|
124
|
+
if (config.forwardingMode === 'managed' && !config.wgPrivateKey) {
|
|
125
|
+
throw new Error('VpnConfig: managed WireGuard requires wgPrivateKey');
|
|
126
|
+
}
|
|
127
|
+
if (config.forwardingMode !== 'managed' && (!config.wgPeers || config.wgPeers.length === 0)) {
|
|
90
128
|
throw new Error('VpnConfig: at least one wgPeers entry is required for WireGuard mode');
|
|
91
129
|
}
|
|
92
|
-
for (const peer of config.wgPeers) {
|
|
130
|
+
for (const peer of config.wgPeers ?? []) {
|
|
93
131
|
if (!peer.publicKey) {
|
|
94
132
|
throw new Error('VpnConfig: peer publicKey is required');
|
|
95
133
|
}
|
|
@@ -138,9 +176,7 @@ export class VpnConfig {
|
|
|
138
176
|
}
|
|
139
177
|
}
|
|
140
178
|
}
|
|
141
|
-
|
|
142
|
-
throw new Error('VpnConfig: mtu must be between 576 and 65535');
|
|
143
|
-
}
|
|
179
|
+
VpnConfig.validateMtu(config.mtu);
|
|
144
180
|
if (config.keepaliveIntervalSecs !== undefined && config.keepaliveIntervalSecs < 1) {
|
|
145
181
|
throw new Error('VpnConfig: keepaliveIntervalSecs must be >= 1');
|
|
146
182
|
}
|
|
@@ -11,9 +11,14 @@ import type {
|
|
|
11
11
|
IWgPeerConfig,
|
|
12
12
|
IWgPeerInfo,
|
|
13
13
|
IClientEntry,
|
|
14
|
+
IClientCreateOptions,
|
|
15
|
+
TClientUpdateOptions,
|
|
14
16
|
IClientConfigBundle,
|
|
15
17
|
IDestinationPolicy,
|
|
16
18
|
TVpnServerCommands,
|
|
19
|
+
IManagedNetworkSnapshot,
|
|
20
|
+
IManagedNetworkStatus,
|
|
21
|
+
IManagedNodeProjection,
|
|
17
22
|
} from './smartvpn.interfaces.js';
|
|
18
23
|
|
|
19
24
|
/**
|
|
@@ -26,6 +31,10 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|
|
26
31
|
private nftHealthInterval?: ReturnType<typeof setInterval>;
|
|
27
32
|
private nftSubnet?: string;
|
|
28
33
|
private nftPolicy?: IDestinationPolicy;
|
|
34
|
+
private nftHealthCheck?: Promise<void>;
|
|
35
|
+
private startOperation?: Promise<void>;
|
|
36
|
+
private stopOperation?: Promise<void>;
|
|
37
|
+
private remotePolicyStopAcknowledged = false;
|
|
29
38
|
|
|
30
39
|
constructor(options: IVpnServerOptions) {
|
|
31
40
|
super();
|
|
@@ -42,12 +51,28 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|
|
42
51
|
this.bridge.on('reconnected', () => {
|
|
43
52
|
this.emit('reconnected');
|
|
44
53
|
});
|
|
54
|
+
this.bridge.on('stderr', (line: string) => {
|
|
55
|
+
this.emit('stderr', line);
|
|
56
|
+
});
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
/**
|
|
48
60
|
* Start the daemon bridge (spawn or connect).
|
|
49
61
|
*/
|
|
50
62
|
public async start(config?: IVpnServerConfig): Promise<void> {
|
|
63
|
+
if (this.startOperation || this.stopOperation || this.nft) {
|
|
64
|
+
throw new Error('VpnServer: finish the current lifecycle operation and cleanup before starting');
|
|
65
|
+
}
|
|
66
|
+
const operation = this.startOwnedResources(config);
|
|
67
|
+
this.startOperation = operation;
|
|
68
|
+
try {
|
|
69
|
+
await operation;
|
|
70
|
+
} finally {
|
|
71
|
+
this.startOperation = undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async startOwnedResources(config?: IVpnServerConfig): Promise<void> {
|
|
51
76
|
const started = await this.bridge.start();
|
|
52
77
|
if (!started) {
|
|
53
78
|
throw new Error('VpnServer: failed to start daemon bridge');
|
|
@@ -77,6 +102,20 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|
|
77
102
|
return this.bridge.sendCommand('getStatus', {} as Record<string, never>);
|
|
78
103
|
}
|
|
79
104
|
|
|
105
|
+
/** Apply a complete caller-owned snapshot. An IPC timeout does not cancel its owner.
|
|
106
|
+
* Inspect status and replay the exact target after uncertainty or a pending failure. */
|
|
107
|
+
public async reconcileManagedNetwork(snapshot: IManagedNetworkSnapshot): Promise<IManagedNetworkStatus> {
|
|
108
|
+
return this.bridge.sendCommand('reconcileManagedNetwork', { snapshot });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public async getManagedNetworkStatus(): Promise<IManagedNetworkStatus> {
|
|
112
|
+
return this.bridge.sendCommand('getManagedNetworkStatus', {});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public async getManagedNodeProjection(nodeId: string): Promise<IManagedNodeProjection> {
|
|
116
|
+
return this.bridge.sendCommand('getManagedNodeProjection', { nodeId });
|
|
117
|
+
}
|
|
118
|
+
|
|
80
119
|
/**
|
|
81
120
|
* Get server statistics.
|
|
82
121
|
*/
|
|
@@ -170,7 +209,7 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|
|
170
209
|
* Create a new client. Generates keypairs, assigns IP, returns full config bundle.
|
|
171
210
|
* The secrets (private keys) are only returned at creation time.
|
|
172
211
|
*/
|
|
173
|
-
public async createClient(opts:
|
|
212
|
+
public async createClient(opts: IClientCreateOptions): Promise<IClientConfigBundle> {
|
|
174
213
|
return this.bridge.sendCommand('createClient', { client: opts });
|
|
175
214
|
}
|
|
176
215
|
|
|
@@ -199,7 +238,7 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|
|
199
238
|
/**
|
|
200
239
|
* Update a registered client's fields (ACLs, tags, description, etc.).
|
|
201
240
|
*/
|
|
202
|
-
public async updateClient(clientId: string, update:
|
|
241
|
+
public async updateClient(clientId: string, update: TClientUpdateOptions): Promise<void> {
|
|
203
242
|
await this.bridge.sendCommand('updateClient', { clientId, update });
|
|
204
243
|
}
|
|
205
244
|
|
|
@@ -246,6 +285,7 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|
|
246
285
|
* Also starts a 60-second health check interval to re-apply if rules are removed externally.
|
|
247
286
|
*/
|
|
248
287
|
private async setupTunDestinationPolicy(subnet: string, policy: IDestinationPolicy): Promise<void> {
|
|
288
|
+
this.remotePolicyStopAcknowledged = false;
|
|
249
289
|
this.nftSubnet = subnet;
|
|
250
290
|
this.nftPolicy = policy;
|
|
251
291
|
this.nft = new plugins.smartnftables.SmartNftables({
|
|
@@ -257,24 +297,32 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|
|
257
297
|
await this.applyDestinationPolicyRules();
|
|
258
298
|
|
|
259
299
|
// Health check: re-apply rules if they disappear
|
|
260
|
-
this.nftHealthInterval = setInterval(
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.warn('[smartvpn] nftables rules missing, re-applying destination policy');
|
|
266
|
-
this.nft = new plugins.smartnftables.SmartNftables({
|
|
267
|
-
tableName: 'smartvpn_tun',
|
|
268
|
-
});
|
|
269
|
-
await this.nft.initialize();
|
|
270
|
-
await this.applyDestinationPolicyRules();
|
|
271
|
-
}
|
|
272
|
-
} catch (err) {
|
|
273
|
-
console.warn(`[smartvpn] nftables health check failed: ${err}`);
|
|
274
|
-
}
|
|
300
|
+
this.nftHealthInterval = setInterval(() => {
|
|
301
|
+
if (this.nftHealthCheck || this.stopOperation) return;
|
|
302
|
+
this.nftHealthCheck = this.checkTunDestinationPolicy().finally(() => {
|
|
303
|
+
this.nftHealthCheck = undefined;
|
|
304
|
+
});
|
|
275
305
|
}, 60_000);
|
|
276
306
|
}
|
|
277
307
|
|
|
308
|
+
private async checkTunDestinationPolicy(): Promise<void> {
|
|
309
|
+
if (!this.nft) return;
|
|
310
|
+
try {
|
|
311
|
+
const exists = await this.nft.tableExists();
|
|
312
|
+
if (!exists) {
|
|
313
|
+
console.warn('[smartvpn] nftables rules missing, re-applying destination policy');
|
|
314
|
+
this.nft = new plugins.smartnftables.SmartNftables({
|
|
315
|
+
tableName: 'smartvpn_tun',
|
|
316
|
+
dryRun: process.getuid?.() !== 0,
|
|
317
|
+
});
|
|
318
|
+
await this.nft.initialize();
|
|
319
|
+
await this.applyDestinationPolicyRules();
|
|
320
|
+
}
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.warn(`[smartvpn] nftables health check failed: ${err}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
278
326
|
/**
|
|
279
327
|
* Apply destination policy as nftables rules.
|
|
280
328
|
* Order: blockList (drop) → allowList (accept) → default action.
|
|
@@ -331,37 +379,43 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|
|
331
379
|
}
|
|
332
380
|
|
|
333
381
|
/**
|
|
334
|
-
*
|
|
382
|
+
* Drain startup, confirm daemon closure, then drain policy work and clean up.
|
|
383
|
+
* Failure preserves ownership so callers can retry; no timeout implies success.
|
|
335
384
|
*/
|
|
336
|
-
public
|
|
337
|
-
|
|
385
|
+
public stop(): Promise<void> {
|
|
386
|
+
if (this.stopOperation) return this.stopOperation;
|
|
387
|
+
const operation = this.stopOwnedResources();
|
|
388
|
+
this.stopOperation = operation;
|
|
389
|
+
void operation.then(
|
|
390
|
+
() => { this.stopOperation = undefined; },
|
|
391
|
+
() => { this.stopOperation = undefined; },
|
|
392
|
+
);
|
|
393
|
+
return operation;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private async stopOwnedResources(): Promise<void> {
|
|
397
|
+
// A failed start is still reported to its caller; stop must clean its partial resources.
|
|
398
|
+
await this.startOperation?.catch(() => undefined);
|
|
338
399
|
if (this.nftHealthInterval) {
|
|
339
400
|
clearInterval(this.nftHealthInterval);
|
|
340
401
|
this.nftHealthInterval = undefined;
|
|
341
402
|
}
|
|
403
|
+
// A socket disconnect cannot stop a remote packet worker. If this facade
|
|
404
|
+
// installed its host policy, stop that server before releasing enforcement.
|
|
405
|
+
if (this.nft && this.options.transport.transport === 'socket' && !this.remotePolicyStopAcknowledged) {
|
|
406
|
+
await this.stopServer();
|
|
407
|
+
this.remotePolicyStopAcknowledged = true;
|
|
408
|
+
}
|
|
409
|
+
// In stdio mode, keep policy enforced until the owned worker has exited.
|
|
410
|
+
await this.bridge.stop();
|
|
411
|
+
await this.nftHealthCheck;
|
|
342
412
|
if (this.nft) {
|
|
343
|
-
|
|
344
|
-
await this.nft.cleanup();
|
|
345
|
-
} catch (e) {
|
|
346
|
-
console.warn(`[smartvpn] nftables cleanup failed: ${e}`);
|
|
347
|
-
}
|
|
413
|
+
await this.nft.cleanup();
|
|
348
414
|
this.nft = undefined;
|
|
349
415
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (!this.bridge.running) {
|
|
354
|
-
resolve();
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
const timeout = setTimeout(() => resolve(), 5000);
|
|
358
|
-
this.bridge.once('exit', () => {
|
|
359
|
-
clearTimeout(timeout);
|
|
360
|
-
resolve();
|
|
361
|
-
});
|
|
362
|
-
});
|
|
363
|
-
this.bridge.stop();
|
|
364
|
-
await exitPromise;
|
|
416
|
+
this.nftSubnet = undefined;
|
|
417
|
+
this.nftPolicy = undefined;
|
|
418
|
+
this.remotePolicyStopAcknowledged = false;
|
|
365
419
|
}
|
|
366
420
|
|
|
367
421
|
/**
|