@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.
- package/LICENSE +31 -0
- package/README.md +296 -0
- package/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/dist/acl/acl-engine.d.ts +43 -0
- package/dist/acl/acl-engine.js +189 -0
- package/dist/acl/audit.d.ts +70 -0
- package/dist/acl/audit.js +144 -0
- package/dist/acl/index.d.ts +4 -0
- package/dist/acl/index.js +3 -0
- package/dist/acl/policy.d.ts +31 -0
- package/dist/acl/policy.js +102 -0
- package/dist/acl/types.d.ts +18 -0
- package/dist/acl/types.js +4 -0
- package/dist/carrier/frame.d.ts +18 -0
- package/dist/carrier/frame.js +66 -0
- package/dist/carrier/index.d.ts +5 -0
- package/dist/carrier/index.js +4 -0
- package/dist/carrier/packet-session.d.ts +32 -0
- package/dist/carrier/packet-session.js +151 -0
- package/dist/carrier/peer-manager.d.ts +113 -0
- package/dist/carrier/peer-manager.js +392 -0
- package/dist/carrier/types.d.ts +10 -0
- package/dist/carrier/types.js +11 -0
- package/dist/cli/commands.d.ts +223 -0
- package/dist/cli/commands.js +932 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.js +196 -0
- package/dist/config/loader.d.ts +10 -0
- package/dist/config/loader.js +152 -0
- package/dist/daemon/index.d.ts +1 -0
- package/dist/daemon/index.js +1 -0
- package/dist/daemon/ipc.d.ts +60 -0
- package/dist/daemon/ipc.js +144 -0
- package/dist/daemon/server.d.ts +63 -0
- package/dist/daemon/server.js +510 -0
- package/dist/dns/index.d.ts +1 -0
- package/dist/dns/index.js +1 -0
- package/dist/dns/resolver.d.ts +44 -0
- package/dist/dns/resolver.js +82 -0
- package/dist/dns/server.d.ts +70 -0
- package/dist/dns/server.js +393 -0
- package/dist/dora/dora-integration.d.ts +90 -0
- package/dist/dora/dora-integration.js +325 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +15 -0
- package/dist/ipam/index.d.ts +1 -0
- package/dist/ipam/index.js +1 -0
- package/dist/ipam/ipam.d.ts +99 -0
- package/dist/ipam/ipam.js +254 -0
- package/dist/proxy/connect-proxy.d.ts +78 -0
- package/dist/proxy/connect-proxy.js +204 -0
- package/dist/router/index.d.ts +5 -0
- package/dist/router/index.js +4 -0
- package/dist/router/ip-parser.d.ts +36 -0
- package/dist/router/ip-parser.js +127 -0
- package/dist/router/packet-router.d.ts +49 -0
- package/dist/router/packet-router.js +251 -0
- package/dist/router/session-manager.d.ts +50 -0
- package/dist/router/session-manager.js +138 -0
- package/dist/router/types.d.ts +21 -0
- package/dist/router/types.js +6 -0
- package/dist/tun/index.d.ts +3 -0
- package/dist/tun/index.js +2 -0
- package/dist/tun/route-manager.d.ts +59 -0
- package/dist/tun/route-manager.js +353 -0
- package/dist/tun/tun-device.d.ts +45 -0
- package/dist/tun/tun-device.js +265 -0
- package/dist/tun/types.d.ts +28 -0
- package/dist/tun/types.js +4 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.js +4 -0
- package/dist/utils/logger.d.ts +20 -0
- package/dist/utils/logger.js +43 -0
- package/docs/CONFIGURATION.md +197 -0
- package/docs/INSTALL.md +145 -0
- 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,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,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
|
+
}
|