@decentnetwork/lan 0.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.
Files changed (79) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +296 -0
  3. package/bin/tun-helper-darwin-amd64 +0 -0
  4. package/bin/tun-helper-darwin-arm64 +0 -0
  5. package/bin/tun-helper-linux-amd64 +0 -0
  6. package/bin/tun-helper-linux-arm64 +0 -0
  7. package/dist/acl/acl-engine.d.ts +43 -0
  8. package/dist/acl/acl-engine.js +189 -0
  9. package/dist/acl/audit.d.ts +70 -0
  10. package/dist/acl/audit.js +144 -0
  11. package/dist/acl/index.d.ts +4 -0
  12. package/dist/acl/index.js +3 -0
  13. package/dist/acl/policy.d.ts +31 -0
  14. package/dist/acl/policy.js +102 -0
  15. package/dist/acl/types.d.ts +18 -0
  16. package/dist/acl/types.js +4 -0
  17. package/dist/carrier/frame.d.ts +18 -0
  18. package/dist/carrier/frame.js +66 -0
  19. package/dist/carrier/index.d.ts +5 -0
  20. package/dist/carrier/index.js +4 -0
  21. package/dist/carrier/packet-session.d.ts +32 -0
  22. package/dist/carrier/packet-session.js +151 -0
  23. package/dist/carrier/peer-manager.d.ts +113 -0
  24. package/dist/carrier/peer-manager.js +392 -0
  25. package/dist/carrier/types.d.ts +10 -0
  26. package/dist/carrier/types.js +11 -0
  27. package/dist/cli/commands.d.ts +223 -0
  28. package/dist/cli/commands.js +932 -0
  29. package/dist/cli/index.d.ts +7 -0
  30. package/dist/cli/index.js +196 -0
  31. package/dist/config/loader.d.ts +10 -0
  32. package/dist/config/loader.js +152 -0
  33. package/dist/daemon/index.d.ts +1 -0
  34. package/dist/daemon/index.js +1 -0
  35. package/dist/daemon/ipc.d.ts +60 -0
  36. package/dist/daemon/ipc.js +144 -0
  37. package/dist/daemon/server.d.ts +63 -0
  38. package/dist/daemon/server.js +510 -0
  39. package/dist/dns/index.d.ts +1 -0
  40. package/dist/dns/index.js +1 -0
  41. package/dist/dns/resolver.d.ts +44 -0
  42. package/dist/dns/resolver.js +82 -0
  43. package/dist/dns/server.d.ts +70 -0
  44. package/dist/dns/server.js +393 -0
  45. package/dist/dora/dora-integration.d.ts +90 -0
  46. package/dist/dora/dora-integration.js +325 -0
  47. package/dist/index.d.ts +13 -0
  48. package/dist/index.js +15 -0
  49. package/dist/ipam/index.d.ts +1 -0
  50. package/dist/ipam/index.js +1 -0
  51. package/dist/ipam/ipam.d.ts +99 -0
  52. package/dist/ipam/ipam.js +254 -0
  53. package/dist/proxy/connect-proxy.d.ts +78 -0
  54. package/dist/proxy/connect-proxy.js +204 -0
  55. package/dist/router/index.d.ts +5 -0
  56. package/dist/router/index.js +4 -0
  57. package/dist/router/ip-parser.d.ts +36 -0
  58. package/dist/router/ip-parser.js +127 -0
  59. package/dist/router/packet-router.d.ts +49 -0
  60. package/dist/router/packet-router.js +251 -0
  61. package/dist/router/session-manager.d.ts +50 -0
  62. package/dist/router/session-manager.js +138 -0
  63. package/dist/router/types.d.ts +21 -0
  64. package/dist/router/types.js +6 -0
  65. package/dist/tun/index.d.ts +3 -0
  66. package/dist/tun/index.js +2 -0
  67. package/dist/tun/route-manager.d.ts +59 -0
  68. package/dist/tun/route-manager.js +353 -0
  69. package/dist/tun/tun-device.d.ts +45 -0
  70. package/dist/tun/tun-device.js +265 -0
  71. package/dist/tun/types.d.ts +28 -0
  72. package/dist/tun/types.js +4 -0
  73. package/dist/types.d.ts +176 -0
  74. package/dist/types.js +4 -0
  75. package/dist/utils/logger.d.ts +20 -0
  76. package/dist/utils/logger.js +43 -0
  77. package/docs/CONFIGURATION.md +197 -0
  78. package/docs/INSTALL.md +145 -0
  79. package/package.json +93 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Audit logger for ACL decisions and connection events
3
+ * Append-only JSONL file format
4
+ */
5
+ import { appendFileSync, readFileSync, mkdirSync, existsSync } from "fs";
6
+ import { dirname } from "path";
7
+ import { Logger } from "../utils/logger.js";
8
+ export class AuditLog {
9
+ filePath;
10
+ logger;
11
+ buffer = [];
12
+ bufferLimit = 100; // Keep last N entries in memory for fast reads
13
+ constructor(filePath) {
14
+ this.filePath = filePath;
15
+ this.logger = new Logger({ prefix: "AuditLog" });
16
+ // Ensure parent dir exists
17
+ const dir = dirname(filePath);
18
+ mkdirSync(dir, { recursive: true });
19
+ }
20
+ /**
21
+ * Log an access attempt (allowed or denied)
22
+ */
23
+ logAccess(opts) {
24
+ this.write({
25
+ timestamp: Date.now(),
26
+ type: "access",
27
+ srcPubkey: opts.srcPubkey,
28
+ srcName: opts.srcName,
29
+ dstIp: opts.dstIp,
30
+ dstPort: opts.dstPort,
31
+ proto: opts.proto,
32
+ allowed: opts.allowed,
33
+ reason: opts.reason,
34
+ });
35
+ }
36
+ /**
37
+ * Log a grant event
38
+ */
39
+ logGrant(opts) {
40
+ this.write({
41
+ timestamp: Date.now(),
42
+ type: "grant",
43
+ srcName: opts.peer,
44
+ reason: `granted: tcp ${opts.ports.join(",")}${opts.expiresAt ? ` until ${new Date(opts.expiresAt).toISOString()}` : ""}${opts.purpose ? ` (${opts.purpose})` : ""}`,
45
+ });
46
+ }
47
+ /**
48
+ * Log a revoke event
49
+ */
50
+ logRevoke(peer, reason) {
51
+ this.write({
52
+ timestamp: Date.now(),
53
+ type: "revoke",
54
+ srcName: peer,
55
+ reason: reason || "revoked",
56
+ });
57
+ }
58
+ /**
59
+ * Log a connection event
60
+ */
61
+ logConnection(opts) {
62
+ this.write({
63
+ timestamp: Date.now(),
64
+ type: opts.type,
65
+ srcPubkey: opts.srcPubkey,
66
+ });
67
+ }
68
+ /**
69
+ * Log a CONNECT proxy tunnel opening.
70
+ */
71
+ logProxyOpen(opts) {
72
+ this.write({
73
+ timestamp: Date.now(),
74
+ type: "proxy_open",
75
+ srcName: opts.srcName,
76
+ dstIp: opts.srcIp,
77
+ proxyTarget: opts.target,
78
+ });
79
+ }
80
+ /**
81
+ * Log a CONNECT proxy tunnel closing.
82
+ */
83
+ logProxyClose(opts) {
84
+ this.write({
85
+ timestamp: Date.now(),
86
+ type: "proxy_close",
87
+ srcName: opts.srcName,
88
+ dstIp: opts.srcIp,
89
+ proxyTarget: opts.target,
90
+ bytesTransferred: opts.bytesTransferred,
91
+ });
92
+ }
93
+ /**
94
+ * Read recent entries (from buffer + tail of file)
95
+ */
96
+ readRecent(limit = 50) {
97
+ if (this.buffer.length >= limit) {
98
+ return this.buffer.slice(-limit);
99
+ }
100
+ // Read full file if buffer is short
101
+ if (!existsSync(this.filePath)) {
102
+ return this.buffer.slice();
103
+ }
104
+ try {
105
+ const content = readFileSync(this.filePath, "utf-8");
106
+ const lines = content.trim().split("\n").filter(Boolean);
107
+ const entries = [];
108
+ for (const line of lines.slice(-limit)) {
109
+ try {
110
+ entries.push(JSON.parse(line));
111
+ }
112
+ catch {
113
+ // Skip malformed lines
114
+ }
115
+ }
116
+ return entries;
117
+ }
118
+ catch (error) {
119
+ this.logger.error(`Failed to read audit log: ${error}`);
120
+ return this.buffer.slice();
121
+ }
122
+ }
123
+ /**
124
+ * Get all entries since a timestamp
125
+ */
126
+ readSince(timestamp) {
127
+ const entries = this.readRecent(10000); // Effectively "all recent"
128
+ return entries.filter((e) => e.timestamp >= timestamp);
129
+ }
130
+ write(entry) {
131
+ // Add to in-memory buffer
132
+ this.buffer.push(entry);
133
+ if (this.buffer.length > this.bufferLimit) {
134
+ this.buffer = this.buffer.slice(-this.bufferLimit);
135
+ }
136
+ // Append to file (JSONL)
137
+ try {
138
+ appendFileSync(this.filePath, JSON.stringify(entry) + "\n", "utf-8");
139
+ }
140
+ catch (error) {
141
+ this.logger.error(`Failed to write audit entry: ${error}`);
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,4 @@
1
+ export { AclEngine, type AclEngineOptions } from "./acl-engine.js";
2
+ export { Policy } from "./policy.js";
3
+ export { AuditLog } from "./audit.js";
4
+ export type { AccessRequest, AccessResult } from "./types.js";
@@ -0,0 +1,3 @@
1
+ export { AclEngine } from "./acl-engine.js";
2
+ export { Policy } from "./policy.js";
3
+ export { AuditLog } from "./audit.js";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Policy file loader and manager
3
+ * Handles loading/saving ACL rules from YAML
4
+ */
5
+ import type { PolicyConfig, AclRule } from "../types.js";
6
+ export declare class Policy {
7
+ private config;
8
+ private filePath;
9
+ private logger;
10
+ constructor(config: PolicyConfig, filePath: string);
11
+ static load(filePath: string): Promise<Policy>;
12
+ static loadOrCreate(filePath: string): Promise<Policy>;
13
+ save(): Promise<void>;
14
+ getRules(): AclRule[];
15
+ getDefaultAction(): "allow" | "deny";
16
+ getRulesForPeer(peerIdentifier: string): AclRule[];
17
+ /**
18
+ * Add or replace a rule for a peer.
19
+ * If existing rule for same peer + direction, replaces it.
20
+ */
21
+ addRule(rule: AclRule): void;
22
+ /**
23
+ * Remove all rules for a peer.
24
+ */
25
+ removePeer(peerIdentifier: string): boolean;
26
+ /**
27
+ * Remove expired rules.
28
+ */
29
+ cleanupExpired(): number;
30
+ getConfig(): PolicyConfig;
31
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Policy file loader and manager
3
+ * Handles loading/saving ACL rules from YAML
4
+ */
5
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
6
+ import { dirname } from "path";
7
+ import yaml from "js-yaml";
8
+ import { Logger } from "../utils/logger.js";
9
+ export class Policy {
10
+ config;
11
+ filePath;
12
+ logger;
13
+ constructor(config, filePath) {
14
+ this.config = config;
15
+ this.filePath = filePath;
16
+ this.logger = new Logger({ prefix: "Policy" });
17
+ }
18
+ static async load(filePath) {
19
+ try {
20
+ const content = readFileSync(filePath, "utf-8");
21
+ const config = yaml.load(content);
22
+ return new Policy(config, filePath);
23
+ }
24
+ catch (error) {
25
+ if (error instanceof Error && error.message.includes("ENOENT")) {
26
+ throw new Error(`Policy file not found: ${filePath}`);
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+ static async loadOrCreate(filePath) {
32
+ try {
33
+ return await Policy.load(filePath);
34
+ }
35
+ catch {
36
+ // Default to "allow" rather than "deny". Friends in the IPAM are
37
+ // already vetted (you accepted their friend request and assigned
38
+ // them a virtual IP), so further per-port gatekeeping mirrors what
39
+ // Tailscale does for tailnet members: trust the relationship, let
40
+ // the OS firewall + service-binding decide what's actually exposed.
41
+ // Operators who want strict per-port control can flip this to
42
+ // "deny" in policy.yaml and add explicit grants.
43
+ const config = {
44
+ defaultAction: "allow",
45
+ rules: [],
46
+ };
47
+ const policy = new Policy(config, filePath);
48
+ await policy.save();
49
+ return policy;
50
+ }
51
+ }
52
+ async save() {
53
+ const dir = dirname(this.filePath);
54
+ mkdirSync(dir, { recursive: true });
55
+ const content = yaml.dump(this.config, { lineWidth: -1 });
56
+ writeFileSync(this.filePath, content, "utf-8");
57
+ this.logger.info(`Policy saved to ${this.filePath}`);
58
+ }
59
+ getRules() {
60
+ return this.config.rules;
61
+ }
62
+ getDefaultAction() {
63
+ return this.config.defaultAction;
64
+ }
65
+ getRulesForPeer(peerIdentifier) {
66
+ return this.config.rules.filter((r) => r.peer === peerIdentifier);
67
+ }
68
+ /**
69
+ * Add or replace a rule for a peer.
70
+ * If existing rule for same peer + direction, replaces it.
71
+ */
72
+ addRule(rule) {
73
+ const direction = rule.direction || "inbound";
74
+ this.config.rules = this.config.rules.filter((r) => !(r.peer === rule.peer && (r.direction || "inbound") === direction));
75
+ this.config.rules.push(rule);
76
+ this.logger.info(`Added rule for peer ${rule.peer} (${direction})`);
77
+ }
78
+ /**
79
+ * Remove all rules for a peer.
80
+ */
81
+ removePeer(peerIdentifier) {
82
+ const initialLength = this.config.rules.length;
83
+ this.config.rules = this.config.rules.filter((r) => r.peer !== peerIdentifier);
84
+ const removed = this.config.rules.length < initialLength;
85
+ if (removed) {
86
+ this.logger.info(`Removed rules for peer ${peerIdentifier}`);
87
+ }
88
+ return removed;
89
+ }
90
+ /**
91
+ * Remove expired rules.
92
+ */
93
+ cleanupExpired() {
94
+ const now = Date.now();
95
+ const initialLength = this.config.rules.length;
96
+ this.config.rules = this.config.rules.filter((r) => !r.expiresAt || r.expiresAt > now);
97
+ return initialLength - this.config.rules.length;
98
+ }
99
+ getConfig() {
100
+ return this.config;
101
+ }
102
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * ACL & Policy types — re-exported from main types.ts
3
+ */
4
+ export type { ProtocolType, AclRule, AclPermission, PolicyConfig, AuditEntry, } from "../types.js";
5
+ export interface AccessRequest {
6
+ srcPubkey: string;
7
+ dstIp: string;
8
+ dstPort: number;
9
+ srcPort?: number;
10
+ proto: "tcp" | "udp";
11
+ direction: "inbound" | "outbound";
12
+ now?: Date;
13
+ }
14
+ export interface AccessResult {
15
+ allowed: boolean;
16
+ matchedRule?: string;
17
+ reason: string;
18
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * ACL & Policy types — re-exported from main types.ts
3
+ */
4
+ export {};
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Packet framing for Carrier transport
3
+ *
4
+ * Frame format:
5
+ * [1 byte: magic 0xAA]
6
+ * [2 bytes: length (big-endian, includes payload only)]
7
+ * [2 bytes: session ID (big-endian)]
8
+ * [1 byte: opcode]
9
+ * [N bytes: payload]
10
+ */
11
+ import type { PacketFrame } from "./types.js";
12
+ export declare class FrameCodec {
13
+ static encode(packet: Uint8Array, sessionId: number, opcode?: number): Uint8Array;
14
+ static decode(data: Uint8Array): PacketFrame | null;
15
+ static isHandshakeRequest(opcode: number): boolean;
16
+ static isHandshakeAck(opcode: number): boolean;
17
+ static isData(opcode: number): boolean;
18
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Packet framing for Carrier transport
3
+ *
4
+ * Frame format:
5
+ * [1 byte: magic 0xAA]
6
+ * [2 bytes: length (big-endian, includes payload only)]
7
+ * [2 bytes: session ID (big-endian)]
8
+ * [1 byte: opcode]
9
+ * [N bytes: payload]
10
+ */
11
+ import { FRAME_MAGIC, FRAME_HEADER_SIZE, FRAME_OPCODE_DATA, } from "./types.js";
12
+ export class FrameCodec {
13
+ static encode(packet, sessionId, opcode = FRAME_OPCODE_DATA) {
14
+ const payloadLength = packet.length;
15
+ const frameSize = FRAME_HEADER_SIZE + payloadLength;
16
+ const frame = new Uint8Array(frameSize);
17
+ let offset = 0;
18
+ // Magic byte
19
+ frame[offset++] = FRAME_MAGIC;
20
+ // Length (2 bytes, big-endian)
21
+ frame[offset++] = (payloadLength >> 8) & 0xff;
22
+ frame[offset++] = payloadLength & 0xff;
23
+ // Session ID (2 bytes, big-endian)
24
+ frame[offset++] = (sessionId >> 8) & 0xff;
25
+ frame[offset++] = sessionId & 0xff;
26
+ // Opcode
27
+ frame[offset++] = opcode;
28
+ // Payload
29
+ frame.set(packet, offset);
30
+ return frame;
31
+ }
32
+ static decode(data) {
33
+ if (data.length < FRAME_HEADER_SIZE) {
34
+ return null;
35
+ }
36
+ let offset = 0;
37
+ // Check magic
38
+ if (data[offset++] !== FRAME_MAGIC) {
39
+ return null;
40
+ }
41
+ // Length
42
+ const length = (data[offset] << 8) | data[offset + 1];
43
+ offset += 2;
44
+ // Session ID
45
+ const sessionId = (data[offset] << 8) | data[offset + 1];
46
+ offset += 2;
47
+ // Opcode
48
+ const opcode = data[offset++];
49
+ // Validate frame size
50
+ if (data.length < FRAME_HEADER_SIZE + length) {
51
+ return null;
52
+ }
53
+ // Extract payload
54
+ const payload = data.slice(offset, offset + length);
55
+ return { sessionId, opcode, payload };
56
+ }
57
+ static isHandshakeRequest(opcode) {
58
+ return opcode === 0x01;
59
+ }
60
+ static isHandshakeAck(opcode) {
61
+ return opcode === 0x02;
62
+ }
63
+ static isData(opcode) {
64
+ return opcode === FRAME_OPCODE_DATA;
65
+ }
66
+ }
@@ -0,0 +1,5 @@
1
+ export { PeerManager, type PeerManagerOptions } from "./peer-manager.js";
2
+ export { PacketSession, type PacketSessionOptions } from "./packet-session.js";
3
+ export { FrameCodec } from "./frame.js";
4
+ export type { PeerIdentity, RemotePeer, FriendConnectionEvent, PacketFrame, } from "./types.js";
5
+ export { FRAME_OPCODE_HANDSHAKE_REQ, FRAME_OPCODE_HANDSHAKE_ACK, FRAME_OPCODE_DATA, FRAME_MAGIC, FRAME_HEADER_SIZE, } from "./types.js";
@@ -0,0 +1,4 @@
1
+ export { PeerManager } from "./peer-manager.js";
2
+ export { PacketSession } from "./packet-session.js";
3
+ export { FrameCodec } from "./frame.js";
4
+ export { FRAME_OPCODE_HANDSHAKE_REQ, FRAME_OPCODE_HANDSHAKE_ACK, FRAME_OPCODE_DATA, FRAME_MAGIC, FRAME_HEADER_SIZE, } from "./types.js";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Bidirectional packet session over Carrier friend connection
3
+ */
4
+ import { EventEmitter } from "events";
5
+ import type { PacketFrame } from "./types.js";
6
+ export interface PacketSessionOptions {
7
+ peerId: string;
8
+ sessionId: number;
9
+ sendText: (text: string) => Promise<void>;
10
+ onClose?: () => void;
11
+ }
12
+ export declare class PacketSession extends EventEmitter {
13
+ private peerId;
14
+ private sessionId;
15
+ private sendText;
16
+ private isActive;
17
+ private handshakeTimeout;
18
+ private handshakePromise;
19
+ constructor(opts: PacketSessionOptions);
20
+ /**
21
+ * Mark session as active (responder side, after sending HANDSHAKE_ACK).
22
+ * Used by PeerManager when accepting incoming connections.
23
+ */
24
+ acceptIncoming(): void;
25
+ handshake(): Promise<void>;
26
+ send(packet: Uint8Array): Promise<void>;
27
+ handleIncomingFrame(frame: PacketFrame): void;
28
+ close(): void;
29
+ getPeerId(): string;
30
+ getSessionId(): number;
31
+ isConnected(): boolean;
32
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Bidirectional packet session over Carrier friend connection
3
+ */
4
+ import { EventEmitter } from "events";
5
+ import { FrameCodec } from "./frame.js";
6
+ import { FRAME_OPCODE_HANDSHAKE_REQ, FRAME_OPCODE_DATA, } from "./types.js";
7
+ export class PacketSession extends EventEmitter {
8
+ peerId;
9
+ sessionId;
10
+ sendText;
11
+ isActive = false;
12
+ handshakeTimeout = null;
13
+ handshakePromise = null;
14
+ constructor(opts) {
15
+ super();
16
+ this.peerId = opts.peerId;
17
+ this.sessionId = opts.sessionId;
18
+ this.sendText = opts.sendText;
19
+ this.on("close", () => {
20
+ if (opts.onClose) {
21
+ opts.onClose();
22
+ }
23
+ });
24
+ }
25
+ /**
26
+ * Mark session as active (responder side, after sending HANDSHAKE_ACK).
27
+ * Used by PeerManager when accepting incoming connections.
28
+ */
29
+ acceptIncoming() {
30
+ this.isActive = true;
31
+ this.emit("session-active");
32
+ }
33
+ async handshake() {
34
+ if (this.isActive) {
35
+ return;
36
+ }
37
+ if (this.handshakePromise) {
38
+ return this.handshakePromise;
39
+ }
40
+ this.handshakePromise = new Promise((resolve, reject) => {
41
+ // Send handshake request
42
+ const frame = FrameCodec.encode(new Uint8Array(0), this.sessionId, FRAME_OPCODE_HANDSHAKE_REQ);
43
+ const frameBase64 = Buffer.from(frame).toString("base64");
44
+ // 30s default — Carrier express relay can add several seconds of latency
45
+ // for cross-region peers. Configurable via AGENTNET_HANDSHAKE_TIMEOUT_MS.
46
+ const timeoutMs = parseInt(process.env.AGENTNET_HANDSHAKE_TIMEOUT_MS || "30000", 10);
47
+ this.handshakeTimeout = setTimeout(() => {
48
+ reject(new Error(`Handshake timeout (${timeoutMs}ms) for peer ${this.peerId}`));
49
+ }, timeoutMs);
50
+ this.once("handshake-ack", () => {
51
+ if (this.handshakeTimeout) {
52
+ clearTimeout(this.handshakeTimeout);
53
+ }
54
+ this.isActive = true;
55
+ this.handshakePromise = null;
56
+ resolve();
57
+ });
58
+ // If the session is closed mid-handshake (typical cause: the
59
+ // peer-manager re-issues a session cleanup on a friend-status
60
+ // flip), reject the in-flight promise so callers like
61
+ // sessionManager.openSession don't hang waiting on a session
62
+ // whose listeners are about to be removed.
63
+ this.once("handshake-aborted", () => {
64
+ if (this.handshakeTimeout) {
65
+ clearTimeout(this.handshakeTimeout);
66
+ }
67
+ this.handshakePromise = null;
68
+ reject(new Error(`Handshake aborted for peer ${this.peerId}`));
69
+ });
70
+ this.sendText(frameBase64).catch((error) => {
71
+ if (this.handshakeTimeout) {
72
+ clearTimeout(this.handshakeTimeout);
73
+ }
74
+ this.handshakePromise = null;
75
+ reject(error);
76
+ });
77
+ });
78
+ return this.handshakePromise;
79
+ }
80
+ async send(packet) {
81
+ if (!this.isActive) {
82
+ throw new Error("Packet session not active. Call handshake() first.");
83
+ }
84
+ const frame = FrameCodec.encode(packet, this.sessionId, FRAME_OPCODE_DATA);
85
+ const frameBase64 = Buffer.from(frame).toString("base64");
86
+ try {
87
+ await this.sendText(frameBase64);
88
+ }
89
+ catch (error) {
90
+ // Don't tear down the whole session for a single failed sendText —
91
+ // doing so triggers SessionManager to open a fresh session on the
92
+ // next packet, which sends another HANDSHAKE_REQ, which makes the
93
+ // peer churn through new sessionIds (old=N→new=N+1 in their logs).
94
+ // TCP retransmit covers per-packet loss; only kill the session on
95
+ // events that prove the underlying friend session is gone.
96
+ throw error;
97
+ }
98
+ }
99
+ handleIncomingFrame(frame) {
100
+ // Adopt the peer's sessionId rather than rejecting on mismatch.
101
+ //
102
+ // Background: each side picks its own sessionId when it first opens a
103
+ // PacketSession. After a Carrier re-pair the two sides can end up with
104
+ // different sessionIds for what is "morally" the same connection, and
105
+ // a strict equality check silently drops every DATA frame — exactly
106
+ // what we observed when SSH packets reached the peer's onText callback
107
+ // but never made it to the TUN. SessionManager keeps at most one
108
+ // PacketSession per peer (keyed by dstIp / pubkey), so the sessionId
109
+ // here is purely advisory; treat the peer's id as authoritative.
110
+ if (frame.sessionId !== this.sessionId) {
111
+ this.sessionId = frame.sessionId;
112
+ }
113
+ if (FrameCodec.isHandshakeRequest(frame.opcode)) {
114
+ this.emit("handshake-req");
115
+ }
116
+ else if (FrameCodec.isHandshakeAck(frame.opcode)) {
117
+ this.emit("handshake-ack");
118
+ }
119
+ else if (FrameCodec.isData(frame.opcode)) {
120
+ this.emit("packet", frame.payload);
121
+ }
122
+ }
123
+ close() {
124
+ if (this.handshakeTimeout) {
125
+ clearTimeout(this.handshakeTimeout);
126
+ this.handshakeTimeout = null;
127
+ }
128
+ // If a handshake is in flight, we MUST reject its promise — once
129
+ // we removeAllListeners() below, the `once("handshake-ack")`
130
+ // wiring is gone and no incoming ACK can resolve it. Without
131
+ // this, callers awaiting handshake() (sessionManager.openSession)
132
+ // hang forever; symptom is "activeSessions=0, packetsForwarded=0"
133
+ // and pings silently time out even though the friend is online.
134
+ if (this.handshakePromise) {
135
+ this.emit("handshake-aborted");
136
+ this.handshakePromise = null;
137
+ }
138
+ this.isActive = false;
139
+ this.emit("close");
140
+ this.removeAllListeners();
141
+ }
142
+ getPeerId() {
143
+ return this.peerId;
144
+ }
145
+ getSessionId() {
146
+ return this.sessionId;
147
+ }
148
+ isConnected() {
149
+ return this.isActive;
150
+ }
151
+ }