@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,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon Server — wires together all components
|
|
3
|
+
*/
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
6
|
+
import { PeerManager } from "../carrier/peer-manager.js";
|
|
7
|
+
import { TunDevice } from "../tun/tun-device.js";
|
|
8
|
+
import { RouteManager } from "../tun/route-manager.js";
|
|
9
|
+
import { Ipam } from "../ipam/ipam.js";
|
|
10
|
+
import { DnsResolver } from "../dns/resolver.js";
|
|
11
|
+
import { Policy } from "../acl/policy.js";
|
|
12
|
+
import { AuditLog } from "../acl/audit.js";
|
|
13
|
+
import { AclEngine } from "../acl/acl-engine.js";
|
|
14
|
+
import { PacketRouter } from "../router/packet-router.js";
|
|
15
|
+
import { ConnectProxy } from "../proxy/connect-proxy.js";
|
|
16
|
+
import { DoraIntegration } from "../dora/dora-integration.js";
|
|
17
|
+
import { DnsServer } from "../dns/server.js";
|
|
18
|
+
import { IpcServer, ipcSocketPath } from "./ipc.js";
|
|
19
|
+
import { Logger } from "../utils/logger.js";
|
|
20
|
+
export class DaemonServer {
|
|
21
|
+
config;
|
|
22
|
+
useMockTun;
|
|
23
|
+
logger;
|
|
24
|
+
// Components
|
|
25
|
+
peerManager;
|
|
26
|
+
tunDevice;
|
|
27
|
+
routeManager;
|
|
28
|
+
ipam;
|
|
29
|
+
dnsResolver;
|
|
30
|
+
policy;
|
|
31
|
+
auditLog;
|
|
32
|
+
aclEngine;
|
|
33
|
+
packetRouter;
|
|
34
|
+
connectProxy;
|
|
35
|
+
doraIntegration;
|
|
36
|
+
ipcServer;
|
|
37
|
+
dnsServer;
|
|
38
|
+
startedAt = 0;
|
|
39
|
+
isRunning = false;
|
|
40
|
+
pidFile;
|
|
41
|
+
constructor(opts) {
|
|
42
|
+
this.config = opts.config;
|
|
43
|
+
this.useMockTun = opts.useMockTun ?? true; // Default to mock; override for production
|
|
44
|
+
this.logger = new Logger({ prefix: "Daemon" });
|
|
45
|
+
}
|
|
46
|
+
async start() {
|
|
47
|
+
if (this.isRunning) {
|
|
48
|
+
throw new Error("Daemon already running");
|
|
49
|
+
}
|
|
50
|
+
// Reject a second instance for the same identity. Two daemons using
|
|
51
|
+
// the same keypair race on Carrier session establishment, fight over
|
|
52
|
+
// the TUN interface, and produce a stream of "Replacing stale session"
|
|
53
|
+
// messages with no useful outcome. Use a pidfile next to the keypair
|
|
54
|
+
// so the check is keyed by identity, not by config dir.
|
|
55
|
+
this.pidFile = resolve(this.config.carrier.dataDir, "daemon.pid");
|
|
56
|
+
if (existsSync(this.pidFile)) {
|
|
57
|
+
const oldPid = parseInt(readFileSync(this.pidFile, "utf-8").trim(), 10);
|
|
58
|
+
if (oldPid && oldPid !== process.pid && isProcessAlive(oldPid)) {
|
|
59
|
+
throw new Error(`Another decentlan daemon (pid ${oldPid}) is already running for this identity ` +
|
|
60
|
+
`(${this.pidFile}). Stop it first, or remove the pidfile if you're sure it's stale.`);
|
|
61
|
+
}
|
|
62
|
+
// Stale pidfile (process gone) — clear it.
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(this.pidFile, String(process.pid), "utf-8");
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
this.logger.warn(`Could not write pidfile ${this.pidFile}: ${err}`);
|
|
69
|
+
}
|
|
70
|
+
this.logger.info(`Starting daemon (node: ${this.config.node.name})`);
|
|
71
|
+
this.startedAt = Date.now();
|
|
72
|
+
try {
|
|
73
|
+
// 1. Load IPAM
|
|
74
|
+
this.ipam = await Ipam.loadOrCreate(this.config.paths.ipamFile, this.config.node.namespace);
|
|
75
|
+
this.logger.info(`IPAM loaded: ${this.ipam.getPeers().length} peers`);
|
|
76
|
+
// 2. Load DNS resolver
|
|
77
|
+
this.dnsResolver = new DnsResolver(this.ipam, this.config.network.dnsDomain);
|
|
78
|
+
// 3. Load policy + audit
|
|
79
|
+
this.policy = await Policy.loadOrCreate(this.config.paths.policyFile);
|
|
80
|
+
this.auditLog = new AuditLog(this.config.paths.auditLog);
|
|
81
|
+
// 4. ACL engine
|
|
82
|
+
this.aclEngine = new AclEngine({
|
|
83
|
+
policy: this.policy,
|
|
84
|
+
ipam: this.ipam,
|
|
85
|
+
auditLog: this.auditLog,
|
|
86
|
+
});
|
|
87
|
+
// 5. Carrier peer manager — started BEFORE TUN so that an
|
|
88
|
+
// optional dora registration can decide our IP.
|
|
89
|
+
const keyFile = resolve(this.config.carrier.dataDir, "keypair.json");
|
|
90
|
+
this.peerManager = new PeerManager();
|
|
91
|
+
// Daemon does NOT use express nodes. Express is the offline-message
|
|
92
|
+
// store-and-forward relay — appropriate for friend-request delivery
|
|
93
|
+
// (peer might be offline) but not for live packet forwarding (peer
|
|
94
|
+
// must be online; we drop packets via SessionManager.isFriendOnline).
|
|
95
|
+
// Passing empty array prevents the SDK from falling back to express.
|
|
96
|
+
await this.peerManager.create({
|
|
97
|
+
keyFile,
|
|
98
|
+
bootstrapNodes: this.config.carrier.bootstrapNodes,
|
|
99
|
+
expressNodes: [],
|
|
100
|
+
});
|
|
101
|
+
await this.peerManager.start();
|
|
102
|
+
this.logger.info(`Identity: ${this.peerManager.getAddress()}`);
|
|
103
|
+
// Start IPC as soon as the peer is up. The CLI uses it to drive
|
|
104
|
+
// operations that need the daemon's Carrier identity (e.g.
|
|
105
|
+
// friend-request) without spawning a competing Peer instance.
|
|
106
|
+
this.ipcServer = new IpcServer(ipcSocketPath(this.config.carrier.dataDir), {
|
|
107
|
+
friendRequest: async (address, hello) => {
|
|
108
|
+
await this.peerManager.sendFriendRequest(address, hello);
|
|
109
|
+
},
|
|
110
|
+
diag: async () => {
|
|
111
|
+
// Snapshot of everything an operator needs to debug why
|
|
112
|
+
// packets aren't moving: forwarding counters, friend
|
|
113
|
+
// connectivity, IPAM-resolved peers, dora-allocated IP.
|
|
114
|
+
const router = this.packetRouter;
|
|
115
|
+
const stats = router ? router.getStats() : null;
|
|
116
|
+
const peers = this.peerManager?.getFriends() ?? [];
|
|
117
|
+
const ipamPeers = (this.ipam?.getPeers() ?? []).map((p) => ({
|
|
118
|
+
name: p.name,
|
|
119
|
+
virtualIp: p.virtualIp,
|
|
120
|
+
carrierId: p.carrierId,
|
|
121
|
+
}));
|
|
122
|
+
return {
|
|
123
|
+
identity: this.peerManager?.getIdentity(),
|
|
124
|
+
tun: this.tunDevice?.getConfig(),
|
|
125
|
+
allocatedIp: this.doraIntegration?.getAllocatedIp() ?? this.config.network.ip,
|
|
126
|
+
dns: this.dnsServer
|
|
127
|
+
? { domain: this.dnsServer.getDomain(), port: this.dnsServer.getBoundPort() }
|
|
128
|
+
: null,
|
|
129
|
+
stats,
|
|
130
|
+
friends: peers.map((f) => ({
|
|
131
|
+
pubkey: f.pubkey,
|
|
132
|
+
carrierId: f.carrierId,
|
|
133
|
+
name: f.name,
|
|
134
|
+
status: f.status,
|
|
135
|
+
})),
|
|
136
|
+
ipam: ipamPeers,
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
await this.ipcServer.start();
|
|
141
|
+
// 6. Optional dora (DHCP-style) registration. When enabled and the
|
|
142
|
+
// server is reachable, dora hands us a virtual IP and tells us
|
|
143
|
+
// who else is on the network — eliminating the manual ipam.yaml
|
|
144
|
+
// sync between operators. On any failure we fall through to the
|
|
145
|
+
// IP already loaded from config + ipam.yaml.
|
|
146
|
+
let tunIp = this.config.network.ip;
|
|
147
|
+
if (this.config.dora?.enabled && (this.config.dora.userids?.length ?? 0) > 0) {
|
|
148
|
+
// Need to be on the Carrier network before dora can talk to its
|
|
149
|
+
// server. Wait synchronously here — without joinNetwork the
|
|
150
|
+
// first sendText will fail with "no transport".
|
|
151
|
+
await this.peerManager.joinNetwork();
|
|
152
|
+
await this.peerManager.announceSelf(15000).catch((err) => {
|
|
153
|
+
this.logger.warn(`announceSelf during dora bootstrap failed: ${err}`);
|
|
154
|
+
});
|
|
155
|
+
this.doraIntegration = new DoraIntegration({
|
|
156
|
+
config: this.config.dora,
|
|
157
|
+
peerManager: this.peerManager,
|
|
158
|
+
ipam: this.ipam,
|
|
159
|
+
nodeName: this.config.node.name,
|
|
160
|
+
preferredIp: this.config.network.ip,
|
|
161
|
+
});
|
|
162
|
+
const doraIp = await this.doraIntegration.bootstrap();
|
|
163
|
+
if (doraIp && doraIp !== tunIp) {
|
|
164
|
+
this.logger.info(`Dora-allocated IP ${doraIp} (was ${tunIp})`);
|
|
165
|
+
tunIp = doraIp;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// 7. Setup TUN device + routing using the (possibly dora-allocated) IP.
|
|
169
|
+
this.routeManager = new RouteManager();
|
|
170
|
+
this.tunDevice = new TunDevice({
|
|
171
|
+
config: {
|
|
172
|
+
name: this.config.network.interface,
|
|
173
|
+
ip: tunIp,
|
|
174
|
+
subnet: this.config.network.subnet,
|
|
175
|
+
},
|
|
176
|
+
mockMode: this.useMockTun,
|
|
177
|
+
});
|
|
178
|
+
// In real mode: helper creates the TUN, then we configure IP + route on it.
|
|
179
|
+
// In mock mode: just open the mock device.
|
|
180
|
+
await this.tunDevice.open();
|
|
181
|
+
if (!this.useMockTun) {
|
|
182
|
+
// Use the actual device name from the helper (e.g. utun12 on macOS,
|
|
183
|
+
// agentnet0 on Linux) — different from the configured name.
|
|
184
|
+
const actualName = this.tunDevice.getInterface();
|
|
185
|
+
await this.routeManager.configureTun({
|
|
186
|
+
...this.tunDevice.getConfig(),
|
|
187
|
+
name: actualName,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
// Live friend-request handling. The daemon stays up and accepts
|
|
191
|
+
// (or just logs) incoming requests — no more `friend-accept --wait`
|
|
192
|
+
// ceremony where the operator has to stop the daemon. Default is
|
|
193
|
+
// auto-accept; the Carrier friend store IS the trust boundary, and
|
|
194
|
+
// requests only reach a peer whose address was deliberately shared.
|
|
195
|
+
const autoAcceptFriends = this.config.friends?.autoAccept ?? true;
|
|
196
|
+
this.peerManager.on("friend-request", (req) => {
|
|
197
|
+
const who = `${req.name || "(unnamed)"} ${req.pubkey.slice(0, 16)}...`;
|
|
198
|
+
const hello = req.hello ? ` hello="${req.hello.slice(0, 60)}"` : "";
|
|
199
|
+
if (autoAcceptFriends) {
|
|
200
|
+
this.logger.info(`Friend request from ${who}${hello} — auto-accepting`);
|
|
201
|
+
this.peerManager?.acceptFriendRequest(req.pubkey).catch((err) => {
|
|
202
|
+
this.logger.warn(`Auto-accept failed for ${who}: ${err}`);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
this.logger.info(`Friend request from ${who}${hello} — NOT auto-accepting ` +
|
|
207
|
+
`(friends.autoAccept=false; accept manually later)`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
// NOTE: auto-IPAM (hash-derived virtual IP per userid) was tried and
|
|
211
|
+
// reverted — it produced IPs like 10.86.175.35 that conflicted with
|
|
212
|
+
// peers' own config.network.ip and silently broke the return path
|
|
213
|
+
// (the original peer kept routing for its configured IP, not the
|
|
214
|
+
// hash-derived one). A proper fix needs peers to *announce* their
|
|
215
|
+
// chosen IP over Carrier; until that's built, IPAM stays manual.
|
|
216
|
+
// Network bootstrap runs in the background so we don't block startup.
|
|
217
|
+
// Steps: joinNetwork -> announceSelf -> waitForFriendConnected(...) for
|
|
218
|
+
// each known IPAM peer. The waitForFriendConnected call is what tells
|
|
219
|
+
// the SDK to actively pursue UDP holepunching for that friend.
|
|
220
|
+
//
|
|
221
|
+
// When dora is enabled, joinNetwork + announceSelf already ran
|
|
222
|
+
// synchronously during dora bootstrap; skip the duplicate calls.
|
|
223
|
+
const networkAlreadyJoined = !!this.doraIntegration;
|
|
224
|
+
(async () => {
|
|
225
|
+
try {
|
|
226
|
+
if (!networkAlreadyJoined) {
|
|
227
|
+
await this.peerManager.joinNetwork();
|
|
228
|
+
this.logger.info("Announcing self on DHT");
|
|
229
|
+
await this.peerManager.announceSelf(15000);
|
|
230
|
+
this.logger.info("Self-announce complete");
|
|
231
|
+
}
|
|
232
|
+
// Trigger friend connection for every known peer in IPAM —
|
|
233
|
+
// but skip our own entry (operators ship a single canonical
|
|
234
|
+
// IPAM file to every node, so self appears in the list).
|
|
235
|
+
// Probing yourself loops forever logging "self is OFFLINE".
|
|
236
|
+
const myUserid = this.peerManager.getPubkey();
|
|
237
|
+
const peers = this.ipam.getPeers().filter((p) => p.carrierId !== myUserid);
|
|
238
|
+
if (peers.length > 0) {
|
|
239
|
+
this.logger.info(`Probing ${peers.length} IPAM peer(s) for online status...`);
|
|
240
|
+
for (const p of peers) {
|
|
241
|
+
this.kickSessionForever(p.carrierId, p.name);
|
|
242
|
+
this.probeFriendForever(p.carrierId, p.name, p.virtualIp);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
this.logger.warn("Network bootstrap failed:", err);
|
|
248
|
+
}
|
|
249
|
+
})();
|
|
250
|
+
// 7. Packet router
|
|
251
|
+
this.packetRouter = new PacketRouter({
|
|
252
|
+
tunDevice: this.tunDevice,
|
|
253
|
+
peerManager: this.peerManager,
|
|
254
|
+
ipam: this.ipam,
|
|
255
|
+
acl: this.aclEngine,
|
|
256
|
+
});
|
|
257
|
+
await this.packetRouter.start();
|
|
258
|
+
// 7b. In-process DNS server. Answers A/PTR for *.{dnsDomain}
|
|
259
|
+
// (default .decent) from the same IPAM dora populates; forwards
|
|
260
|
+
// everything else to the system upstream.
|
|
261
|
+
//
|
|
262
|
+
// Bind address: 0.0.0.0 (all interfaces). A socket bound to a
|
|
263
|
+
// specific interface IP only sees packets that arrive on that
|
|
264
|
+
// interface; on macOS the kernel routes 10.86.1.11 → lo0 for
|
|
265
|
+
// self-queries, so /etc/resolver/<domain> pointed at the TUN
|
|
266
|
+
// IP would NXDOMAIN locally. Binding on 0.0.0.0 makes the
|
|
267
|
+
// resolver work both for local queries (via 127.0.0.1 / TUN
|
|
268
|
+
// IP from the same host) and for peers on the virtual LAN.
|
|
269
|
+
// Auto-fallback in DnsServer dodges port collisions
|
|
270
|
+
// (mDNSResponder, Bonjour, openclaw-gateway all hold :5353 on
|
|
271
|
+
// 0.0.0.0; we land on 5354 in practice).
|
|
272
|
+
if (!this.useMockTun) {
|
|
273
|
+
this.dnsServer = new DnsServer({
|
|
274
|
+
ipam: this.ipam,
|
|
275
|
+
domain: this.config.network.dnsDomain,
|
|
276
|
+
port: this.config.network.dnsPort,
|
|
277
|
+
bindAddress: "0.0.0.0",
|
|
278
|
+
});
|
|
279
|
+
try {
|
|
280
|
+
await this.dnsServer.start();
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
this.logger.warn(`DNS server failed to start on 0.0.0.0:${this.config.network.dnsPort}: ${err instanceof Error ? err.message : err}`);
|
|
284
|
+
this.dnsServer = undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// 8. Optional CONNECT proxy. Only spawned when the operator has
|
|
288
|
+
// explicitly enabled it via `agentnet proxy enable`. Binds to the
|
|
289
|
+
// virtual IP so reach is gated by the existing per-peer ACL — we
|
|
290
|
+
// don't add a second auth layer at the HTTP level.
|
|
291
|
+
if (this.config.proxy?.enabled) {
|
|
292
|
+
if (this.useMockTun) {
|
|
293
|
+
this.logger.warn("Proxy enabled but daemon is in mock-TUN mode; skipping proxy listener");
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
this.connectProxy = new ConnectProxy({
|
|
297
|
+
bindIp: this.config.network.ip,
|
|
298
|
+
port: this.config.proxy.port,
|
|
299
|
+
allowHosts: this.config.proxy.allowHosts ?? [],
|
|
300
|
+
allowConnectPorts: this.config.proxy.allowConnectPorts,
|
|
301
|
+
resolvePeerName: (srcIp) => this.ipam.resolveIp(srcIp)?.name,
|
|
302
|
+
onTunnelOpen: ({ src, srcName, target }) => {
|
|
303
|
+
this.auditLog.logProxyOpen({ srcIp: src, srcName, target });
|
|
304
|
+
},
|
|
305
|
+
onTunnelClose: ({ src, srcName, target, bytesTransferred }) => {
|
|
306
|
+
this.auditLog.logProxyClose({ srcIp: src, srcName, target, bytesTransferred });
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
await this.connectProxy.start();
|
|
310
|
+
this.logger.info(`Proxy listening on ${this.config.network.ip}:${this.config.proxy.port}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
this.isRunning = true;
|
|
314
|
+
this.logger.info("Daemon started successfully");
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
this.logger.error("Failed to start daemon:", error);
|
|
318
|
+
await this.cleanup();
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async stop() {
|
|
323
|
+
if (!this.isRunning)
|
|
324
|
+
return;
|
|
325
|
+
this.logger.info("Stopping daemon");
|
|
326
|
+
this.isRunning = false;
|
|
327
|
+
await this.cleanup();
|
|
328
|
+
this.logger.info("Daemon stopped");
|
|
329
|
+
}
|
|
330
|
+
getStatus() {
|
|
331
|
+
return {
|
|
332
|
+
isRunning: this.isRunning,
|
|
333
|
+
uptime: this.isRunning ? Date.now() - this.startedAt : 0,
|
|
334
|
+
version: "0.1.0",
|
|
335
|
+
identity: this.peerManager?.getIdentity(),
|
|
336
|
+
tunInterface: this.tunDevice?.getConfig(),
|
|
337
|
+
peers: this.peerManager?.getFriends() || [],
|
|
338
|
+
activeSessions: this.packetRouter?.getStats().activeSessions || 0,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Periodically poke the SDK to (re-)initiate a session with the friend.
|
|
343
|
+
* This works around the asymmetric friendOnline issue: without this,
|
|
344
|
+
* one side may never spontaneously try to talk to the other and the
|
|
345
|
+
* friend stays perpetually offline.
|
|
346
|
+
*/
|
|
347
|
+
async kickSessionForever(carrierId, name) {
|
|
348
|
+
while (this.isRunning) {
|
|
349
|
+
try {
|
|
350
|
+
await this.peerManager.kickSessionEstablishment(carrierId);
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
this.logger.debug(`kickSessionEstablishment ${name}: ${err}`);
|
|
354
|
+
}
|
|
355
|
+
// 8s cadence — fast enough to retry across cookie/handshake
|
|
356
|
+
// round-trips, slow enough to avoid hammering the SDK
|
|
357
|
+
await new Promise((r) => setTimeout(r, 8000));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Repeatedly call waitForFriendConnected for the given peer until the
|
|
362
|
+
* daemon is stopped. Logs ONLINE/OFFLINE transitions.
|
|
363
|
+
*/
|
|
364
|
+
async probeFriendForever(carrierId, name, virtualIp) {
|
|
365
|
+
let lastState = "unknown";
|
|
366
|
+
while (this.isRunning) {
|
|
367
|
+
try {
|
|
368
|
+
const online = await this.peerManager.waitForFriendConnected(carrierId, 60000);
|
|
369
|
+
const state = online ? "online" : "offline";
|
|
370
|
+
if (state !== lastState) {
|
|
371
|
+
if (online) {
|
|
372
|
+
this.logger.info(`Peer ${name} (${virtualIp}) is ONLINE`);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
this.logger.info(`Peer ${name} (${virtualIp}) is OFFLINE — will keep probing`);
|
|
376
|
+
}
|
|
377
|
+
lastState = state;
|
|
378
|
+
}
|
|
379
|
+
if (!online) {
|
|
380
|
+
// Brief pause before re-probing to avoid tight loop
|
|
381
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
// Once online, poll status less aggressively
|
|
385
|
+
await new Promise((r) => setTimeout(r, 30000));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
this.logger.debug(`probeFriendForever ${name}: ${err}`);
|
|
390
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Component accessors for CLI
|
|
395
|
+
getPeerManager() {
|
|
396
|
+
return this.peerManager;
|
|
397
|
+
}
|
|
398
|
+
getIpam() {
|
|
399
|
+
return this.ipam;
|
|
400
|
+
}
|
|
401
|
+
getDnsResolver() {
|
|
402
|
+
return this.dnsResolver;
|
|
403
|
+
}
|
|
404
|
+
getAclEngine() {
|
|
405
|
+
return this.aclEngine;
|
|
406
|
+
}
|
|
407
|
+
getAuditLog() {
|
|
408
|
+
return this.auditLog;
|
|
409
|
+
}
|
|
410
|
+
getPacketRouter() {
|
|
411
|
+
return this.packetRouter;
|
|
412
|
+
}
|
|
413
|
+
getConnectProxy() {
|
|
414
|
+
return this.connectProxy;
|
|
415
|
+
}
|
|
416
|
+
getDoraIntegration() {
|
|
417
|
+
return this.doraIntegration;
|
|
418
|
+
}
|
|
419
|
+
async cleanup() {
|
|
420
|
+
try {
|
|
421
|
+
if (this.dnsServer) {
|
|
422
|
+
await this.dnsServer.stop();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch (e) {
|
|
426
|
+
this.logger.warn("Error stopping DNS server:", e);
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
if (this.ipcServer) {
|
|
430
|
+
await this.ipcServer.stop();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch (e) {
|
|
434
|
+
this.logger.warn("Error stopping IPC server:", e);
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
if (this.doraIntegration) {
|
|
438
|
+
this.doraIntegration.stop();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
catch (e) {
|
|
442
|
+
this.logger.warn("Error stopping dora integration:", e);
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
if (this.connectProxy) {
|
|
446
|
+
await this.connectProxy.stop();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch (e) {
|
|
450
|
+
this.logger.warn("Error stopping proxy:", e);
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
if (this.packetRouter) {
|
|
454
|
+
await this.packetRouter.stop();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch (e) {
|
|
458
|
+
this.logger.warn("Error stopping router:", e);
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
if (this.peerManager) {
|
|
462
|
+
await this.peerManager.stop();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
this.logger.warn("Error stopping peer:", e);
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
if (this.tunDevice) {
|
|
470
|
+
await this.tunDevice.close();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
this.logger.warn("Error closing TUN:", e);
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
if (this.routeManager && !this.useMockTun) {
|
|
478
|
+
// Use the configured name on Linux (where we created the device),
|
|
479
|
+
// or the actual utun name on macOS (no-op cleanup since helper exit
|
|
480
|
+
// handles it). Pass the TUN IP so macOS cleanup also removes
|
|
481
|
+
// the lo0 self-alias.
|
|
482
|
+
const ifname = this.tunDevice?.getInterface() || this.config.network.interface;
|
|
483
|
+
const ip = this.doraIntegration?.getAllocatedIp() ?? this.config.network.ip;
|
|
484
|
+
await this.routeManager.cleanup(ifname, this.config.network.subnet, ip);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch (e) {
|
|
488
|
+
this.logger.warn("Error cleaning routes:", e);
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
if (this.pidFile && existsSync(this.pidFile)) {
|
|
492
|
+
unlinkSync(this.pidFile);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// best-effort; a stale pidfile will be detected on next start
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function isProcessAlive(pid) {
|
|
501
|
+
try {
|
|
502
|
+
// Signal 0 doesn't actually send anything — it only performs the
|
|
503
|
+
// permission/existence check. Throws ESRCH if the process is gone.
|
|
504
|
+
process.kill(pid, 0);
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DnsResolver } from "./resolver.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DnsResolver } from "./resolver.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local DNS resolver for AgentNet hostnames
|
|
3
|
+
* Resolves names like "partner-openclaw.agentnet" to virtual IPs
|
|
4
|
+
*/
|
|
5
|
+
import type { Ipam } from "../ipam/ipam.js";
|
|
6
|
+
import type { IpamRecord } from "../types.js";
|
|
7
|
+
export declare class DnsResolver {
|
|
8
|
+
private ipam;
|
|
9
|
+
private logger;
|
|
10
|
+
private domain;
|
|
11
|
+
constructor(ipam: Ipam, domain?: string);
|
|
12
|
+
/**
|
|
13
|
+
* Resolve hostname to IP
|
|
14
|
+
* Supports:
|
|
15
|
+
* - "partner-openclaw.agentnet" -> "10.86.12.34"
|
|
16
|
+
* - "10.86.12.34.agentnet" -> "10.86.12.34" (reverse lookup)
|
|
17
|
+
* - Carrier ID as hex string
|
|
18
|
+
*/
|
|
19
|
+
resolve(hostname: string): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve IP to IpamRecord
|
|
22
|
+
*/
|
|
23
|
+
resolveToRecord(ip: string): IpamRecord | null;
|
|
24
|
+
/**
|
|
25
|
+
* Check if hostname is in our domain
|
|
26
|
+
*/
|
|
27
|
+
isInDomain(hostname: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Get all peer entries for listing
|
|
30
|
+
*/
|
|
31
|
+
getEntries(): Array<{
|
|
32
|
+
hostname: string;
|
|
33
|
+
ip: string;
|
|
34
|
+
carrierId: string;
|
|
35
|
+
}>;
|
|
36
|
+
/**
|
|
37
|
+
* Format a hostname
|
|
38
|
+
*/
|
|
39
|
+
formatHostname(peerName: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Get domain name
|
|
42
|
+
*/
|
|
43
|
+
getDomain(): string;
|
|
44
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local DNS resolver for AgentNet hostnames
|
|
3
|
+
* Resolves names like "partner-openclaw.agentnet" to virtual IPs
|
|
4
|
+
*/
|
|
5
|
+
import { Logger } from "../utils/logger.js";
|
|
6
|
+
export class DnsResolver {
|
|
7
|
+
ipam;
|
|
8
|
+
logger;
|
|
9
|
+
domain; // e.g., "agentnet"
|
|
10
|
+
constructor(ipam, domain = "agentnet") {
|
|
11
|
+
this.ipam = ipam;
|
|
12
|
+
this.domain = domain;
|
|
13
|
+
this.logger = new Logger({ prefix: "DnsResolver" });
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve hostname to IP
|
|
17
|
+
* Supports:
|
|
18
|
+
* - "partner-openclaw.agentnet" -> "10.86.12.34"
|
|
19
|
+
* - "10.86.12.34.agentnet" -> "10.86.12.34" (reverse lookup)
|
|
20
|
+
* - Carrier ID as hex string
|
|
21
|
+
*/
|
|
22
|
+
resolve(hostname) {
|
|
23
|
+
// Remove .agentnet suffix if present
|
|
24
|
+
const name = hostname.replace(new RegExp(`\\.${this.domain}$`), "");
|
|
25
|
+
// Try to resolve by peer name
|
|
26
|
+
const ipByName = this.ipam.resolveName(name);
|
|
27
|
+
if (ipByName) {
|
|
28
|
+
this.logger.debug(`Resolved ${hostname} -> ${ipByName}`);
|
|
29
|
+
return ipByName;
|
|
30
|
+
}
|
|
31
|
+
// Try to resolve by IP directly (reverse lookup)
|
|
32
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) {
|
|
33
|
+
const record = this.ipam.resolveIp(name);
|
|
34
|
+
if (record) {
|
|
35
|
+
this.logger.debug(`Resolved IP ${name} -> ${record.virtualIp}`);
|
|
36
|
+
return record.virtualIp;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Try to resolve by Carrier ID
|
|
40
|
+
const record = this.ipam.resolveCarrierId(name);
|
|
41
|
+
if (record) {
|
|
42
|
+
this.logger.debug(`Resolved Carrier ID ${name} -> ${record.virtualIp}`);
|
|
43
|
+
return record.virtualIp;
|
|
44
|
+
}
|
|
45
|
+
this.logger.debug(`Failed to resolve: ${hostname}`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve IP to IpamRecord
|
|
50
|
+
*/
|
|
51
|
+
resolveToRecord(ip) {
|
|
52
|
+
return this.ipam.resolveIp(ip);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Check if hostname is in our domain
|
|
56
|
+
*/
|
|
57
|
+
isInDomain(hostname) {
|
|
58
|
+
return hostname.endsWith(`.${this.domain}`) || !hostname.includes(".");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get all peer entries for listing
|
|
62
|
+
*/
|
|
63
|
+
getEntries() {
|
|
64
|
+
return this.ipam.getPeers().map((peer) => ({
|
|
65
|
+
hostname: `${peer.name}.${this.domain}`,
|
|
66
|
+
ip: peer.virtualIp,
|
|
67
|
+
carrierId: peer.carrierId,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Format a hostname
|
|
72
|
+
*/
|
|
73
|
+
formatHostname(peerName) {
|
|
74
|
+
return `${peerName}.${this.domain}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get domain name
|
|
78
|
+
*/
|
|
79
|
+
getDomain() {
|
|
80
|
+
return this.domain;
|
|
81
|
+
}
|
|
82
|
+
}
|