@push.rocks/smartvpn 2.0.0 → 2.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.
@@ -22,7 +22,7 @@ locked, feature-inactive packages are covered by these supplemental MIT notices:
22
22
  - [valuable 0.1.1](./notices/valuable-license.txt), from
23
23
  [its pinned upstream LICENSE](https://github.com/tokio-rs/valuable/blob/9efc29b6e58cef28f6566a47aa7e142a55fead77/LICENSE).
24
24
 
25
- Together these cover all 278 locked registry packages, conservatively including
25
+ Together these cover all 278 locked external packages, conservatively including
26
26
  build-only and target-inactive dependencies. Listing a package does not mean it
27
27
  is linked into either Linux executable. MIT is preferred where offered as an
28
28
  alternative; other selected terms include Apache-2.0, BSD, ISC, Unicode-3.0,
@@ -30,6 +30,12 @@ CDLA-Permissive-2.0 and the existing tun 0.7.22 dependency's WTFPL.
30
30
  The exact package versions, declared license expressions and reviewed file hashes
31
31
  are recorded in [inventory.json](./notices/inventory.json).
32
32
 
33
+ The managed kernel inventory uses the published `netlink-proto` 0.13.1 fork at
34
+ `bcbbfbba7485857c3b1f9fd5f6d99d597a334ab0` from
35
+ [push.rocks/netlink-proto](https://code.foss.global/push.rocks/netlink-proto).
36
+ Its exact MIT notice is included in the generated inventory and supplemental
37
+ route notices; the original 0.13.0 attribution is retained there as well.
38
+
33
39
  The [additional MIT source attributions](./notices/mit-source-attributions.txt)
34
40
  retain cesu8 1.1.0's Rust Project/Eric Kidd source headers and siphasher 1.0.2's
35
41
  Rust Project/Frank Denis `COPYING` attribution, together with the selected MIT terms.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartvpn',
6
- version: '2.0.0',
6
+ version: '2.2.0',
7
7
  description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
8
8
  }
@@ -3,8 +3,9 @@ import * as paths from './smartvpn.paths.js';
3
3
  import type {
4
4
  TVpnTransportOptions,
5
5
  IVpnTransportSocket,
6
+ IVpnManagedRuntimeInvalidation,
6
7
  } from './smartvpn.interfaces.js';
7
- import type { TCommandMap } from '@push.rocks/smartrust';
8
+ import type { TCommandMap, IRustBridgeDisconnectedEvent } from '@push.rocks/smartrust';
8
9
 
9
10
  /**
10
11
  * Shared bridge wrapper around smartrust RustBridge.
@@ -19,25 +20,45 @@ export class VpnBridge<TCommands extends TCommandMap> extends plugins.events.Eve
19
20
  transport: TVpnTransportOptions;
20
21
  mode: 'client' | 'server';
21
22
  binaryName?: string;
23
+ networkNamespaceFd?: number;
22
24
  }) {
23
25
  super();
24
26
 
27
+ const namespaceFd = options.networkNamespaceFd;
28
+ if (namespaceFd !== undefined) {
29
+ if (plugins.os.platform() !== 'linux' || options.mode !== 'client' || options.transport.transport !== 'stdio') {
30
+ throw new Error('VpnBridge networkNamespaceFd requires a Linux stdio client');
31
+ }
32
+ if (!Number.isInteger(namespaceFd) || namespaceFd < 0 || namespaceFd > 0x7fff_ffff) {
33
+ throw new Error('VpnBridge networkNamespaceFd must be a non-negative native descriptor');
34
+ }
35
+ }
25
36
  const binaryName = options.binaryName || 'smartvpn_daemon';
26
- this.transportOptions = options.transport;
37
+ if (options.transport.transport === 'stdio' && options.transport.binaryPath !== undefined
38
+ && (typeof options.transport.binaryPath !== 'string' || !plugins.path.isAbsolute(options.transport.binaryPath))) {
39
+ throw new Error('VpnBridge binaryPath must be an absolute executable path');
40
+ }
41
+ // Keep the validated process transport stable even if the caller edits its options.
42
+ this.transportOptions = { ...options.transport };
27
43
  this.mode = options.mode;
28
44
 
29
45
  this.bridge = new plugins.smartrust.RustBridge<TCommands>({
30
46
  binaryName,
31
47
  binaryPath: options.transport.transport === 'stdio'
32
- ? paths.getBinaryPath(binaryName)
48
+ ? options.transport.binaryPath ?? paths.getBinaryPath(binaryName)
33
49
  : undefined,
34
50
  localPaths: [],
35
51
  searchSystemPath: false,
36
- cliArgs: ['--management', '--mode', this.mode],
52
+ cliArgs: ['--management', '--mode', this.mode,
53
+ ...(namespaceFd === undefined ? [] : ['--network-namespace-fd', '3'])],
54
+ inheritedFileDescriptors: namespaceFd === undefined ? undefined : [namespaceFd],
37
55
  maxPayloadSize: 10 * 1024 * 1024, // 10 MB
38
56
  });
39
57
 
40
58
  // Forward events from inner bridge
59
+ this.bridge.on('disconnected', (event: IRustBridgeDisconnectedEvent) => {
60
+ this.emit('disconnected', event);
61
+ });
41
62
  this.bridge.on('exit', (code: number | null, signal: string | null) => {
42
63
  this.emit('exit', code, signal);
43
64
  });
@@ -50,6 +71,9 @@ export class VpnBridge<TCommands extends TCommandMap> extends plugins.events.Eve
50
71
 
51
72
  // Forward management events from the daemon
52
73
  // smartrust emits 'management:<eventName>' for unsolicited events
74
+ this.bridge.on('management:managed-runtime-invalidated', (data: IVpnManagedRuntimeInvalidation) => {
75
+ this.emit('managed-runtime-invalidated', data);
76
+ });
53
77
  this.bridge.on('management:status', (data: any) => {
54
78
  this.emit('status', data);
55
79
  });
@@ -7,6 +7,9 @@ import type {
7
7
  IVpnStatistics,
8
8
  IVpnConnectionQuality,
9
9
  IVpnMtuInfo,
10
+ IVpnConnectionResult,
11
+ IVpnManagedRuntime,
12
+ IVpnManagedRuntimeInvalidation,
10
13
  TVpnClientCommands,
11
14
  } from './smartvpn.interfaces.js';
12
15
 
@@ -16,6 +19,9 @@ import type {
16
19
  export class VpnClient extends plugins.events.EventEmitter {
17
20
  private bridge: VpnBridge<TVpnClientCommands>;
18
21
  private options: IVpnClientOptions;
22
+ private bridgeEpoch = 0;
23
+ private managedGeneration?: string;
24
+ private lastInvalidatedGeneration?: string;
19
25
 
20
26
  constructor(options: IVpnClientOptions) {
21
27
  super();
@@ -23,18 +29,46 @@ export class VpnClient extends plugins.events.EventEmitter {
23
29
  this.bridge = new VpnBridge<TVpnClientCommands>({
24
30
  transport: options.transport,
25
31
  mode: 'client',
32
+ networkNamespaceFd: options.networkNamespaceFd,
26
33
  });
27
34
 
28
35
  // Forward bridge events
36
+ this.bridge.on('disconnected', () => {
37
+ this.invalidateBridge('Client transport disconnected');
38
+ });
29
39
  this.bridge.on('exit', (code: number | null, signal: string | null) => {
40
+ this.invalidateBridge('Client bridge exited');
30
41
  this.emit('exit', { code, signal });
31
42
  });
32
43
  this.bridge.on('reconnected', () => {
44
+ this.invalidateBridge('Client bridge reconnected');
33
45
  this.emit('reconnected');
34
46
  });
35
47
  this.bridge.on('stderr', (line: string) => {
36
48
  this.emit('stderr', line);
37
49
  });
50
+ this.bridge.on('managed-runtime-invalidated', (event: IVpnManagedRuntimeInvalidation) => {
51
+ this.invalidateGeneration(event);
52
+ });
53
+ }
54
+
55
+ private invalidateGeneration(event: IVpnManagedRuntimeInvalidation): void {
56
+ if (this.lastInvalidatedGeneration === event.generation) return;
57
+ this.lastInvalidatedGeneration = event.generation;
58
+ if (this.managedGeneration === event.generation) this.managedGeneration = undefined;
59
+ this.emit('managed-runtime-invalidated', event);
60
+ }
61
+
62
+ private invalidateBridge(reason: string): void {
63
+ this.bridgeEpoch++;
64
+ if (this.managedGeneration) this.invalidateGeneration({ generation: this.managedGeneration, reason });
65
+ }
66
+
67
+ private checkObservation(runtime: IVpnManagedRuntime, epoch: number, generation?: string): void {
68
+ if (epoch !== this.bridgeEpoch || runtime.generation === this.lastInvalidatedGeneration
69
+ || (generation !== undefined && runtime.generation !== generation)) {
70
+ throw new Error('Managed runtime retired during the operation');
71
+ }
38
72
  }
39
73
 
40
74
  /**
@@ -47,12 +81,30 @@ export class VpnClient extends plugins.events.EventEmitter {
47
81
  /**
48
82
  * Connect to the VPN server using the provided config.
49
83
  */
50
- public async connect(config?: IVpnClientConfig): Promise<{ assignedIp: string }> {
84
+ public async connect(config?: IVpnClientConfig): Promise<IVpnConnectionResult> {
51
85
  const cfg = config || this.options.config;
52
86
  if (!cfg) {
53
87
  throw new Error('VpnClient.connect: no config provided');
54
88
  }
55
- return this.bridge.sendCommand('connect', { config: cfg });
89
+ const epoch = this.bridgeEpoch;
90
+ const result = await this.bridge.sendCommand('connect', { config: cfg });
91
+ if (result.managedRuntime) {
92
+ this.checkObservation(result.managedRuntime, epoch);
93
+ this.managedGeneration = result.managedRuntime.generation;
94
+ }
95
+ return result;
96
+ }
97
+
98
+ /** Inspect the retained native TUN, its exact IPv4 /32 and split routes.
99
+ * Requires the connection's generation and fails after retirement/reconnect. */
100
+ public async inspectManagedRuntime(generation: string): Promise<IVpnManagedRuntime> {
101
+ if (typeof generation !== 'string' || !/^[a-f0-9]{32}$/.test(generation)) {
102
+ throw new Error('Managed runtime generation must be a native 128-bit identifier');
103
+ }
104
+ const epoch = this.bridgeEpoch;
105
+ const result = await this.bridge.sendCommand('inspectManagedRuntime', { generation });
106
+ this.checkObservation(result, epoch, generation);
107
+ return result;
56
108
  }
57
109
 
58
110
  /**
@@ -95,6 +147,7 @@ export class VpnClient extends plugins.events.EventEmitter {
95
147
  * Stop the daemon bridge and wait for confirmed ownership release.
96
148
  */
97
149
  public async stop(): Promise<void> {
150
+ this.invalidateBridge('Client stop requested');
98
151
  await this.bridge.stop();
99
152
  }
100
153
 
@@ -4,6 +4,9 @@
4
4
 
5
5
  export interface IVpnTransportStdio {
6
6
  transport: 'stdio';
7
+ /** Exact caller-verified executable. Overrides environment/package lookup;
8
+ * invalid paths fail without falling through to another executable. */
9
+ binaryPath?: string;
7
10
  }
8
11
 
9
12
  export interface IVpnTransportSocket {
@@ -88,6 +91,10 @@ export type IVpnClientConfig = TVpnClientConfig;
88
91
  export interface IVpnClientOptions {
89
92
  transport: TVpnTransportOptions;
90
93
  config?: IVpnClientConfig;
94
+ /** Linux stdio only. Caller-owned network namespace FD, retained through every spawn.
95
+ * The complete native client enters before threads, sockets, TUN or readiness.
96
+ * The caller owns namespace creation, underlay routing, DNS and packet policy. */
97
+ networkNamespaceFd?: number;
91
98
  }
92
99
 
93
100
  // ============================================================================
@@ -484,7 +491,8 @@ export interface IWgPeerInfo {
484
491
  // ============================================================================
485
492
 
486
493
  export type TVpnClientCommands = {
487
- connect: { params: { config: IVpnClientConfig }; result: { assignedIp: string } };
494
+ connect: { params: { config: IVpnClientConfig }; result: IVpnConnectionResult };
495
+ inspectManagedRuntime: { params: { generation: string }; result: IVpnManagedRuntime };
488
496
  disconnect: { params: Record<string, never>; result: void };
489
497
  getStatus: { params: Record<string, never>; result: IVpnStatus };
490
498
  getStatistics: { params: Record<string, never>; result: IVpnStatistics };
@@ -591,6 +599,39 @@ export interface IManagedNetworkBinding {
591
599
  nodeId: string;
592
600
  }
593
601
 
602
+ export interface IVpnConnectionResult {
603
+ assignedIp: string;
604
+ /** Present only for an authenticated native managed connection with a real TUN. */
605
+ managedRuntime?: IVpnManagedRuntime;
606
+ }
607
+
608
+ /** A fresh observation of a retained native lifetime, never a transferable or
609
+ * restorable capability. The caller retains VpnClient and listens for invalidation.
610
+ * IPv6, namespace-wide policy/rules, underlay and firewall remain caller-owned. */
611
+ export interface IVpnManagedRuntime {
612
+ schemaVersion: 1;
613
+ generation: string;
614
+ namespace: { device: string; inode: string };
615
+ link: {
616
+ interfaceIndex: number;
617
+ interfaceName: string;
618
+ interfaceKind: 'tun';
619
+ linkIndex: number;
620
+ mtu: number;
621
+ address: string;
622
+ prefixLength: 32;
623
+ };
624
+ remoteIp: string;
625
+ assignment: IManagedNetworkAssignment;
626
+ }
627
+
628
+ /** Stop using this generation immediately. This event is not a joined cleanup,
629
+ * conntrack drain, or allocation-reuse acknowledgement. */
630
+ export interface IVpnManagedRuntimeInvalidation {
631
+ generation: string;
632
+ reason: string;
633
+ }
634
+
594
635
  export interface IManagedNodeConfig {
595
636
  /** Caller-owned control address with /32; local workload prefixes are not hub routes. */
596
637
  address: string;
@@ -628,6 +669,7 @@ export interface IVpnServiceUnit {
628
669
  // ============================================================================
629
670
 
630
671
  export interface IVpnEventMap {
672
+ 'managed-runtime-invalidated': IVpnManagedRuntimeInvalidation;
631
673
  'status': IVpnStatus;
632
674
  'error': { message: string; code?: string };
633
675
  'client-connected': IVpnClientInfo;
package/readme.hints.md DELETED
@@ -1,74 +0,0 @@
1
- # smartvpn hints
2
-
3
- ## Musl distribution and qualification checkpoint (2026-09-07)
4
-
5
- - Rust 1.95.0 produces locked, remapped Linux amd64/arm64 musl artifacts. Both
6
- passed ELF architecture/static-link/no-glibc checks. ARM64 management startup
7
- and confirmed termination passed under QEMU, not privileged ARM64 hardware.
8
- - The exact amd64 SHA-256 `9dae4722a783f8de4ce2c5066eff1bf5d6ebde48750aaf3e96ad4e0374e153a3`
9
- passed the dedicated serve.zone/testing KVM scenario: real WS/Noise + Linux
10
- managed TUN split routes, directed allow/deny, reconnect, natural revocation,
11
- partial route-conflict rollback, empty restart and child termination. Cleanup
12
- joined, removed owned namespaces, and preserved VM DNS and default routes.
13
- The VM is shut off. WSS/private CA, IPv6 workload traffic, privileged ARM64,
14
- standalone kernel modes and Pallet/Cloudly production integration remain unqualified.
15
- - Verification passed 109 TypeScript tests, all source/test type checks, 255 musl
16
- Rust unit tests, three Rust integrations, and the separate 181-second WireGuard
17
- expiry test. Build/test invocations must remain sequential because tsrust
18
- replaces dist_rust. Cargo tests using their separate target tree may run alongside.
19
- - Release tooling was committed separately. The complete Cargo/compiler/runtime
20
- notice index is `third-party-notices.md`, with hashed inputs and all 278 locked
21
- registry packages in `notices/inventory.json`. Tests enforce coverage and payload
22
- inclusion. Redistribution of the unchanged tun 0.7.22/WTFPL dependency was
23
- explicitly approved on 2026-09-07; this is not a production deployment approval.
24
-
25
- The authority notes below retain earlier development checkpoints. Qualification
26
- claims above supersede their historical managed-TUN test gaps, not the remaining
27
- standalone-kernel or production-integration limitations.
28
-
29
- ## Authority prerequisites (2026-09-07)
30
-
31
- - The Rust managed_network compiler validates complete schema-v1 declarations before active effects. Managed ClientRegistry retains the lifetime binding, applied snapshot and exact pending candidate/pool/affected set. Admissible stamps quarantine affected nodes under the native/WG admission gate; raw entry/stamp reads are not admission. Server-owned serialized reconcile joins cancelled sessions outside the gate. With WG, the loop prepares replacements then swaps peer crypto, candidate registry and exact pool synchronously under the registry gate; failures retain quarantine and only exact-target retry. Without WG the same commit runs in the tracked apply owner. Equal canonical replay preserves sessions; per-node effective authority includes directed grants, not only route unions.
32
- - Public reconcileManagedNetwork/getManagedNetworkStatus/getManagedNodeProjection IPC is typed on both sides. Managed mode has no hub host-network effects and no standalone writer. Unix apply releases the outer VpnServer mutex before awaiting; status from a second connection remains available, and dropping a requester does not cancel the tracked operation. Regressions cover actual native/WG workload relay and MTUs, denied domains, unchanged owners, failed preparation/retry, direct raw-WG writer rejection, address/key reuse, disconnected IPC and whole-server drain.
33
- - Native managed assignment validates bounded canonical owned/remote prefixes and reports live authority/lifetime/revision through client status. Native Linux managed_tunnel uses the published rtnetlink API, exclusive ACKed route adds and a kernel-allocated nonpersistent /32 TUN. The netlink future is polled inline; no detached shell/route task or route-conflict reuse. Closing the owned FD removes associated routes, as documented at https://docs.kernel.org/networking/tuntap.html. Actual WS/QUIC peer addresses guard against capturing the hub connection. Pure preflight tests run without host effects; privileged rollback/default-route/DNS qualification remains outstanding. Exact upstream notices for the unmodified Linux netlink crates ship under assets/**/* and passed license review.
34
- - Registry candidates are validated before any index mutation. Updates operate on a detached copy, reject ID/key/IP conflicts and malformed expiry/IPv4 assignments, and preserve the original record and indexes on failure. Malformed expiry also fails closed when checked outside the registry.
35
- - Create/update settings now deserialize a complete detached candidate instead of silently clearing malformed security, filtering bad tags or truncating integers. Unknown/server-owned mutation fields reject explicitly; omitted fields are preserved, null clears optional settings, and a security object replaces the previous complete object. Shared ACL parsing preserves IPv4 exact/CIDR/wildcard/range syntax for both validation and matching. Startup validates preloaded client settings, connection block lists and destination policy before host effects. Rust and real daemon IPC regressions cover rejected updates/creates, allocation preservation, policy typos and null/VLAN invariants. This is input validation, not live-session or queued-packet revocation.
36
- - ClientEntry, ClientSecurity and DestinationPolicyConfig omit absent optional fields when serializing, matching the public TypeScript output types; explicit null remains accepted for patch clearing. Registry admission consumes deprecated tags into serverDefinedClientTags and rejects both-populated aliases, so clearing and reimport cannot resurrect stale tags. No recursive JSON output shim is used.
37
- - Native Noise admission captures a process-local record incarnation/revision and checks it again after the handshake response send, before local connection admission. Every successful registry mutation changes the revision; remove/recreate gets a new incarnation, even with identical keys. Invalid updates preserve the stamp. Registry guards are released before network sends, including unauthorized responses. Real Noise plus a controlled send barrier covers disable, disable/re-enable, remove/recreate, key rotation, expiry/policy edits and failed edits; WS/QUIC share this handler.
38
- - Rate-limit unit tests use controlled Instant values through the same private consumption/refill path as production. The previous immediate-empty assertion was invalid at 1 MB/s: one microsecond refills a byte, and it failed under build load. Production still samples Instant::now; no test clock, sleeps or scheduler assumptions enter the runtime.
39
- - IP pools require canonical network CIDRs and usable ranges; explicit reservations may be outside the dynamic range but not outside the subnet or on its network/gateway/broadcast addresses. Allocation is bounded, including a fully reserved pool and `/0`. `release_owned` compares an opaque reservation owner before release; callers that outlive records must use incarnation tokens, not reusable client IDs.
40
- - Runtime address reservations now belong to registry incarnations, not reusable IDs or connections. Startup reserves all explicit registry and raw WG addresses before sorted dynamic allocation. Native admission verifies and uses the registry assignment; disconnect and registered WG peer removal retain it. Record removal disables/cancels, joins transports, reconciles the exact WG revision, then releases only the matching reservation. Failed create rollback rejects a changed revision, and failed rotation never restores old keys.
41
- - LiveClients/ClientSession/SessionGuard owns one volatile generation across WS/QUIC and integrated WG. Admission counts the inline protocol lifetime before publishing its route. Cancellation closes task admission; reconnect joins predecessors outside registry/ownership locks. Protocol I/O and crypto drop before the guard token, and WG relays belong to both session and server trackers. Exact Arc identity fences stale route cleanup and merged WG returns. Raw WG control IDs use the full public key and a separate internal namespace; ambiguous control names reject without cancelling either owner.
42
- - Native/WG idle expiry and registry mutations drain transport owners. WG disconnect resets BoringTun with a nonreused 24-bit peer index and retains configured peer settings. The WG loop interrupts blocked forwarding when a WG owner needs retirement; stale notifications from completed native takeovers must not drop the new WG session's first packet. Integrated UDP sends are nonblocking/best-effort, so send-buffer pressure cannot block other peers' revocation.
43
- - Registered tunnel packets require exact-length, checksummed IPv4 headers and their authenticated assigned source. Outer connection ACLs and inner destination ACLs are separate. Hybrid forwarding uses the immutable session's useHostIp setting, not a packet-source lookup. Raw WG IPv6 remains kernel/testing only, never reinterpreted by IPv4 socket/bridge engines.
44
- - Regressions cover address collision/reuse, joined native and real-UDP WG mutations, WS/QUIC socket takeover, cross-transport takeover with never-sent old ciphertext, idle expiry, stale cleanup/returns, unpolled descendants, raw peer reservations, source spoofing, malformed packets and failed reconciliation ownership. No publication or serve.zone production adoption is implied by this transport checkpoint.
45
- - AuthenticatedPacket now carries ClientSession plus a tracked queued-work lifetime through socket/bridge queues. NatDispatcher selects a separate session-owned smoltcp stack; queued work, flow tasks/readers and replies no longer resolve a mutable registry/IP route. A reproduced registry-lock fail-open is removed: destination policy and PROXY metadata use the authenticated owner. Bridge writes observe current-client cancellation and broadcast shared retirement, so an unrelated blocked TAP write cannot indefinitely hold another client's queued stop token.
46
- - NatFlow Arc identity fences TCP/UDP tuple reuse and owns a cancellation tracker plus a capacity lease. Each flow task/reader is enrolled with flow, client and server owners. Late task/message references retain the lease. Limits are 256 engines, 1,024 flow incarnations, and 128 flows/client by default (security.maxConnections may override the per-client limit). Packet/message queues are bounded. The existing smoltcp build has no fragment reassembly; IPv4 fragments, invalid TCP/UDP headers/checksums and bad lengths reject before flow allocation.
47
- - A reproduced UDP demultiplexing defect sent different source-port flows through the first socket bound to their shared destination. There is now one smoltcp UDP listener per destination in each client stack; received endpoint metadata selects the exact flow. External UDP sockets are connected to their target, filtering unrelated senders. Tests cover real multi-client UDP/replies, same-target source-port isolation, full TCP packet handshake plus real bidirectional stream traffic, server/client revocation, stale tuple messages, queue drain, capacity retention and PROXY identity under registry lock.
48
- - Server MTU enforcement now treats config as inner IP bytes (576..65472), rejects invalid input before startup effects, and checks authenticated ingress plus native/WG return encryption. A configured 1200 previously became 1121, and 1500-byte ingress previously passed a 1420-byte limit; both regressions were reproduced. Eligible DF packets get rate-limited IPv4 fragmentation-needed feedback from the owned gateway/bridge IP through the exact session. Malformed/error/fragment/multicast/broadcast packets do not provoke feedback; unavailable feedback never permits oversize. Real Noise and UDP/WG tests cover feedback, exact-limit admission and oversized return rejection. Counters distinguish drops from queued (not confirmed-sent) ICMP. This is not fragmentation/reassembly or outer path-MTU discovery.
49
- - Native client MTU regressions reproduced acceptance of a 1200-byte packet despite a local 1100-byte ceiling, and fabricated getMtuInfo values both connected and disconnected. A required authenticated ClientHello now carries the local ceiling; the server pins the negotiated minimum on ClientSession and its smoltcp stack. Typed assignments include the actual subnet, reject missing/invalid values before TUN setup and remove guessed /24/MTU defaults. Real WS/QUIC tests cover negotiated return admission; real Noise plus unprivileged packet-I/O fixtures cover both client directions, exact-limit traffic, owned IPv4 feedback and retirement. The TCP NAT fixture exchanges 4KB while asserting every return packet respects the smaller session ceiling. Nullable MTU reporting describes actual native runtime ownership, not outer-path estimates; failed or short TUN feedback writes never count as sent. Both native endpoints must upgrade together.
50
- - WireGuard's public flat config previously failed Rust deserialization with missing privateKey; the owning Rust input now consumes wgPrivateKey/serverPublicKey/wgAddress/wgEndpoint and requires nonempty wgAllowedIps, with no nested-peer adapter. Actual address/prefix/keys/MTU/AllowedIPs validate before TUN effects; route CIDRs normalize host bits, the configured prefix supplies netmask, and hostname resolution selects a matching UDP family. Explicit testing mode owns no TUN/routes; WG's existing default remains tun. Real UDP/BoringTun client-loop tests cover a complete handshake, bidirectional MTU admission, malformed/checksum and AllowedIPs source/destination rejection, exact-limit traffic, owned IPv4 feedback, short/blocked device writes, EOF retirement and actual UDP-port release. MTU IPC reports live local ownership, resets on reconnect, and remains null when inactive. Standard WireGuard framing and first-authorized-packet connection semantics are unchanged; connectedSince is now RFC3339.
51
- - Managed snapshots now own hub policy and transport handover. Standalone kernel cleanup/partial-setup rollback and privileged managed host-network qualification remain outstanding. Raw WG IPv6 remains kernel/testing-only; oversized IPv6 drops without inventing an owned IPv6 router for feedback. No checkpoint retracts traffic already handed to an external network/kernel or proves production managed-network readiness.
52
- - Return-queue baseline reproduced plaintext remaining available after session.stop acknowledged with no running consumer. ReturnQueues registers bounded per-session and shared WG queues with one LiveClients owner; queued ReturnPacket payloads retain destination and optional source join tokens. Either generation's cancellation purges every matching queue without consumer progress; dequeued writes observe both cancellation sources. Foreign runtime queues/relay origins reject. Native cancellation after encryption retires the destination stream because Noise's implicit nonce and partial frame cannot safely be continued; a real Noise blocked-write/reconnect test covers it. Real UDP/WG shared relay, multi-thread enqueue/revoke, either-owner purge/join and backpressure regressions cover the foundation now used by managed relay.
53
- - BoringTun 0.7's encapsulate copies plaintext into a private queue when the current crypto session is absent; its timer removes that session after 180 seconds, before connection expiry. A regression reproduced revoked source plaintext escaping that queue after rekey. Integrated WG returns now check the public time_since_last_handshake Option under the same exclusive synchronous crypto owner: None drops/counts the application packet and requests a handshake without submitting plaintext. Some reflects the exact current-session slot used by encapsulate, not an age heuristic. The deterministic key-reset regression exercises rekey and proves the drained source cannot emit later while fresh destination returns still work. Explicit wall-clock qualification: `cargo test --manifest-path rust/Cargo.toml --lib return_crypto_timer_expiry_cannot_queue_plaintext_past_source_revocation -- --ignored`; it takes 181 seconds because BoringTun owns its clock. No dependency patch, timer override or networking privilege is used.
54
- - Native client disconnect previously acknowledged a five-second timeout while a deliberately blocked packet write remained alive; the real packet-loop regression reproduced it. Native and WG clients now retain RuntimeTasks ownership and task handles. Disconnect cancels blocked I/O and awaits destruction; native route finalization stays outside packet cancellation, and native monitors retire on natural exit or panic through an exit guard. Task panic is reported as failure, not successful disconnect. Failed native/WG connection attempts reset false startup state. Tests cover blocked native packet I/O plus real keepalive destruction, native failed-connect retry, natural exit/panic, owned WG UDP socket release/panic, and actual WS/QUIC disconnect/reconnect. Full privileged client TUN/route qualification remains outstanding.
55
- - Native and WireGuard client configs now form a discriminated union. Native configs require Noise keys; WireGuard configs require their own key/address/endpoint. The published `IVpnClientConfig` name remains available as the same union, while generated native bundles retain their narrower required-key type.
56
- - Native unit tests are in the Cargo library target: `cargo test --lib` runs them. `cargo test --bin smartvpn_daemon` runs zero unit tests and is not a substitute.
57
- - Facades consume published Smartrust 2.0's confirmed termination instead of fire-and-forget kill or a local timeout-as-success. Await all stop calls; server teardown drains startup and policy-health work, retains failed cleanup ownership, and requires a remote stop acknowledgement before removing socket-managed TUN policy. Whole-server packet task drain is now implemented below; per-client revocation and kernel-resource rollback still require qualification before publication as managed-network support.
58
- - Rust server work shares an admission-fenced RuntimeTasks owner using tokio-util TaskTracker/CancellationToken. Stop cancels and drains listeners, pre-authentication/packet handlers, WG return relays, NAT flow tasks and their readers before clearing forwarding state; QUIC completes protocol shutdown. Loopback regressions check WS/QUIC/WG listener teardown and real bidirectional TCP/UDP socket traffic through the NAT bridge tasks. These are lifecycle tests, not complete authenticated packet-to-NAT policy tests. Startup waits for listener readiness and merges preloaded WG definitions before readiness, rejecting conflicting assignments and excluding disabled/expired registered keys. Existing kernel route/NAT cleanup helpers still swallow command failures, and partial host-network setup needs transactional cleanup; no privileged network test or production adoption has occurred.
59
- - Run build and test commands sequentially: the current tsrust replaces `dist_rust` during every build, so overlapping builds can race test daemon discovery. A pre-ready child failure during overlapping verification was not attributable after stderr was discarded; focused and full sequential reruns passed. The facades now forward `stderr` for callers to capture diagnostics without automatic logging.
60
-
61
- ## Static Rust Binaries
62
-
63
- - The Rust binaries in `dist_rust/` use explicit `linux_amd64_musl` and
64
- `linux_arm64_musl` targets. tsrust 1.11 verifies static linking and writes
65
- hash-bound provenance sidecars. The runtime chooses these exact filenames;
66
- explicit SMARTVPN_RUST_BINARY selections never fall through to discovery.
67
- - Target-specific Cargo CC settings require genuine musl headers for ring and
68
- mimalloc. Final linking uses native/cross GCC drivers with Rust's self-contained
69
- musl libraries; standalone musl-gcc specs add PT_INTERP even for static PIE and
70
- are unsuitable as that final linker. Do not add repository-wide crt-static flags
71
- that also affect untargeted host proc-macros.
72
- - `pnpm run test:rust` runs the Rust unit tests; plain `cargo test` in `rust/` works as well.
73
- - Verify linkage with `file` and `readelf` against the `_musl` artifacts. Regression
74
- tests reject PT_INTERP, DT_NEEDED, wrong machine identifiers and glibc signatures.
package/readme.plan.md DELETED
@@ -1,253 +0,0 @@
1
- # PROXY Protocol v2 Support for SmartVPN WebSocket Transport
2
-
3
- ## Context
4
-
5
- SmartVPN's WebSocket transport is designed to sit behind reverse proxies (Cloudflare, HAProxy, SmartProxy). The recently added ACL engine has `ipAllowList`/`ipBlockList` per client, but without PROXY protocol support the server only sees the proxy's IP — not the real client's. This makes source-IP ACLs useless behind a proxy.
6
-
7
- PROXY protocol v2 solves this by letting the proxy prepend a binary header with the real client IP/port before the WebSocket upgrade.
8
-
9
- ---
10
-
11
- ## Design
12
-
13
- ### Two-Phase ACL with Real Client IP
14
-
15
- ```
16
- TCP accept → Read PP v2 header → Extract real IP
17
-
18
- ├─ Phase 1 (pre-handshake): Check server-level connectionIpBlockList → reject early
19
-
20
- ├─ WebSocket upgrade → Noise IK handshake → Client identity known
21
-
22
- └─ Phase 2 (post-handshake): Check per-client ipAllowList/ipBlockList → reject if denied
23
- ```
24
-
25
- - **Phase 1**: Server-wide block list (`connectionIpBlockList` on `IVpnServerConfig`). Rejects before any crypto work. Protects server resources.
26
- - **Phase 2**: Per-client ACL from `IClientSecurity.ipAllowList`/`ipBlockList`. Applied after the Noise IK handshake identifies the client.
27
-
28
- ### No New Dependencies
29
-
30
- PROXY protocol v2 is a fixed-format binary header (16-byte signature + variable address block). Manual parsing (~80 lines) follows the same pattern as `codec.rs`. No crate needed.
31
-
32
- ### Scope: WebSocket Only
33
-
34
- - **WebSocket**: Needs PP v2 (sits behind reverse proxies)
35
- - **QUIC**: Direct UDP, just use `conn.remote_address()`
36
- - **WireGuard**: Direct UDP, uses boringtun peer tracking
37
-
38
- ---
39
-
40
- ## Implementation
41
-
42
- ### Phase 1: New Rust module `proxy_protocol.rs`
43
-
44
- **New file: `rust/src/proxy_protocol.rs`**
45
-
46
- PP v2 binary format:
47
- ```
48
- Bytes 0-11: Signature \x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A
49
- Byte 12: Version (high nibble = 0x2) | Command (low nibble: 0x0=LOCAL, 0x1=PROXY)
50
- Byte 13: Address family | Protocol (0x11 = IPv4/TCP, 0x21 = IPv6/TCP)
51
- Bytes 14-15: Address data length (big-endian u16)
52
- Bytes 16+: IPv4: 4 src_ip + 4 dst_ip + 2 src_port + 2 dst_port (12 bytes)
53
- IPv6: 16 src_ip + 16 dst_ip + 2 src_port + 2 dst_port (36 bytes)
54
- ```
55
-
56
- ```rust
57
- pub struct ProxyHeader {
58
- pub src_addr: SocketAddr,
59
- pub dst_addr: SocketAddr,
60
- pub is_local: bool, // LOCAL command = health check probe
61
- }
62
-
63
- /// Read and parse a PROXY protocol v2 header from a TCP stream.
64
- /// Reads exactly the header bytes — the stream is clean for WS upgrade after.
65
- pub async fn read_proxy_header(stream: &mut TcpStream) -> Result<ProxyHeader>
66
- ```
67
-
68
- - 5-second timeout on header read (constant `PROXY_HEADER_TIMEOUT`)
69
- - Validates 12-byte signature, version nibble, command type
70
- - Parses IPv4 and IPv6 address blocks
71
- - LOCAL command returns `is_local: true` (caller closes connection gracefully)
72
- - Unit tests: valid IPv4/IPv6 headers, LOCAL command, invalid signature, truncated data
73
-
74
- **Modify: `rust/src/lib.rs`** — add `pub mod proxy_protocol;`
75
-
76
- ### Phase 2: Server config + client info fields
77
-
78
- **File: `rust/src/server.rs` — `ServerConfig`**
79
-
80
- Add:
81
- ```rust
82
- /// Enable PROXY protocol v2 parsing on WebSocket connections.
83
- /// SECURITY: Must be false when accepting direct client connections.
84
- pub proxy_protocol: Option<bool>,
85
- /// Server-level IP block list — applied at TCP accept time, before Noise handshake.
86
- pub connection_ip_block_list: Option<Vec<String>>,
87
- ```
88
-
89
- **File: `rust/src/server.rs` — `ClientInfo`**
90
-
91
- Add:
92
- ```rust
93
- /// Real client IP:port (from PROXY protocol header or direct TCP connection).
94
- pub remote_addr: Option<String>,
95
- ```
96
-
97
- ### Phase 3: ACL helper
98
-
99
- **File: `rust/src/acl.rs`**
100
-
101
- Add a public function for the server-level pre-handshake check:
102
- ```rust
103
- /// Check whether a connection source IP is in a block list.
104
- pub fn is_connection_blocked(ip: Ipv4Addr, block_list: &[String]) -> bool {
105
- ip_matches_any(ip, block_list)
106
- }
107
- ```
108
-
109
- (Keeps `ip_matches_any` private; exposes only the specific check needed.)
110
-
111
- ### Phase 4: WebSocket listener integration
112
-
113
- **File: `rust/src/server.rs` — `run_ws_listener()`**
114
-
115
- Between `listener.accept()` and `transport::accept_connection()`:
116
-
117
- ```rust
118
- // Determine real client address
119
- let remote_addr = if state.config.proxy_protocol.unwrap_or(false) {
120
- match proxy_protocol::read_proxy_header(&mut tcp_stream).await {
121
- Ok(header) if header.is_local => {
122
- // Health check probe — close gracefully
123
- return;
124
- }
125
- Ok(header) => {
126
- info!("PP v2: real client {} -> {}", header.src_addr, header.dst_addr);
127
- Some(header.src_addr)
128
- }
129
- Err(e) => {
130
- warn!("PP v2 parse failed from {}: {}", tcp_addr, e);
131
- return; // Drop connection
132
- }
133
- }
134
- } else {
135
- Some(tcp_addr) // Direct connection — use TCP SocketAddr
136
- };
137
-
138
- // Pre-handshake server-level block list check
139
- if let (Some(ref block_list), Some(ref addr)) = (&state.config.connection_ip_block_list, &remote_addr) {
140
- if let std::net::IpAddr::V4(v4) = addr.ip() {
141
- if acl::is_connection_blocked(v4, block_list) {
142
- warn!("Connection blocked by server IP block list: {}", addr);
143
- return;
144
- }
145
- }
146
- }
147
-
148
- // Then proceed with WS upgrade + handle_client_connection as before
149
- ```
150
-
151
- Key correctness note: `read_proxy_header` reads *exactly* the PP header bytes via `read_exact`. The `TcpStream` is then in a clean state for the WS HTTP upgrade. No buffered wrapper needed.
152
-
153
- ### Phase 5: Update `handle_client_connection` signature
154
-
155
- **File: `rust/src/server.rs`**
156
-
157
- Change signature:
158
- ```rust
159
- async fn handle_client_connection(
160
- state: Arc<ServerState>,
161
- mut sink: Box<dyn TransportSink>,
162
- mut stream: Box<dyn TransportStream>,
163
- remote_addr: Option<std::net::SocketAddr>, // NEW
164
- ) -> Result<()>
165
- ```
166
-
167
- After Noise IK handshake + registry lookup (where `client_security` is available), add connection-level per-client ACL:
168
-
169
- ```rust
170
- if let (Some(ref sec), Some(addr)) = (&client_security, &remote_addr) {
171
- if let std::net::IpAddr::V4(v4) = addr.ip() {
172
- if acl::is_connection_blocked(v4, sec.ip_block_list.as_deref().unwrap_or(&[])) {
173
- anyhow::bail!("Client {} connection denied: source IP {} blocked", registered_client_id, addr);
174
- }
175
- if let Some(ref allow) = sec.ip_allow_list {
176
- if !allow.is_empty() && !acl::is_ip_allowed(v4, allow) {
177
- anyhow::bail!("Client {} connection denied: source IP {} not in allow list", registered_client_id, addr);
178
- }
179
- }
180
- }
181
- }
182
- ```
183
-
184
- Populate `remote_addr` when building `ClientInfo`:
185
- ```rust
186
- remote_addr: remote_addr.map(|a| a.to_string()),
187
- ```
188
-
189
- ### Phase 6: QUIC listener — pass remote addr through
190
-
191
- **File: `rust/src/server.rs` — `run_quic_listener()`**
192
-
193
- QUIC doesn't use PROXY protocol. Just pass `conn.remote_address()` through:
194
- ```rust
195
- let remote = conn.remote_address();
196
- // ...
197
- handle_client_connection(state, Box::new(sink), Box::new(stream), Some(remote)).await
198
- ```
199
-
200
- ### Phase 7: TypeScript interface updates
201
-
202
- **File: `ts/smartvpn.interfaces.ts`**
203
-
204
- Add to `IVpnServerConfig`:
205
- ```typescript
206
- /** Enable PROXY protocol v2 on incoming WebSocket connections.
207
- * Required when behind a reverse proxy that sends PP v2 headers. */
208
- proxyProtocol?: boolean;
209
- /** Server-level IP block list — applied at TCP accept time, before Noise handshake. */
210
- connectionIpBlockList?: string[];
211
- ```
212
-
213
- Add to `IVpnClientInfo`:
214
- ```typescript
215
- /** Real client IP:port (from PROXY protocol or direct TCP). */
216
- remoteAddr?: string;
217
- ```
218
-
219
- ### Phase 8: Tests
220
-
221
- **Rust unit tests in `proxy_protocol.rs`:**
222
- - `parse_valid_ipv4_header` — construct a valid PP v2 header with known IPs, verify parsed correctly
223
- - `parse_valid_ipv6_header` — same for IPv6
224
- - `parse_local_command` — health check probe returns `is_local: true`
225
- - `reject_invalid_signature` — random bytes rejected
226
- - `reject_truncated_header` — short reads fail gracefully
227
- - `reject_v1_header` — PROXY v1 text format rejected (we only support v2)
228
-
229
- **Rust unit tests in `acl.rs`:**
230
- - `is_connection_blocked` with various IP patterns
231
-
232
- **TypeScript tests:**
233
- - Config validation accepts `proxyProtocol: true` + `connectionIpBlockList`
234
-
235
- ---
236
-
237
- ## Key Files to Modify
238
-
239
- | File | Changes |
240
- |------|---------|
241
- | `rust/src/proxy_protocol.rs` | **NEW** — PP v2 parser + tests |
242
- | `rust/src/lib.rs` | Add `pub mod proxy_protocol;` |
243
- | `rust/src/server.rs` | `ServerConfig` + `ClientInfo` fields, `run_ws_listener` PP integration, `handle_client_connection` signature + connection ACL, `run_quic_listener` pass-through |
244
- | `rust/src/acl.rs` | Add `is_connection_blocked` public function |
245
- | `ts/smartvpn.interfaces.ts` | `proxyProtocol`, `connectionIpBlockList`, `remoteAddr` |
246
-
247
- ---
248
-
249
- ## Verification
250
-
251
- 1. `cargo test` — all existing 121 tests + new PP parser tests pass
252
- 2. `pnpm test` — all 79 TS tests pass (no PP in test setup, just config validation)
253
- 3. Manual: `socat` or test harness to send a PP v2 header before WS upgrade, verify server logs real IP