@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,70 @@
1
+ /**
2
+ * In-process DNS server — answers A and PTR queries for `<name>.<domain>`
3
+ * (default `.decent`) out of the daemon's IPAM, forwards everything
4
+ * else to the system upstream.
5
+ *
6
+ * Why bake this into the daemon: every node already pulls dora's
7
+ * roster every 60s, so the records are in memory. Spinning up a
8
+ * separate dnsmasq / CoreDNS + a glue script that regenerates a
9
+ * hosts file is more moving parts than `dgram.createSocket("udp4")`
10
+ * plus a hundred lines of DNS wire parsing. Same pattern Tailscale
11
+ * uses for MagicDNS.
12
+ *
13
+ * Wire format: stub implementation of RFC 1035 — enough to answer
14
+ * A / AAAA / PTR for our zone and forward (just the question
15
+ * section) for everything else. We don't recurse; we proxy the raw
16
+ * query to the upstream and ferry the reply back. Compression
17
+ * pointers in incoming queries are handled by the parser; outgoing
18
+ * answers use literal labels (slightly larger packets, but inside
19
+ * the 512-byte UDP DNS limit for our short names).
20
+ */
21
+ import type { Ipam } from "../ipam/ipam.js";
22
+ export interface DnsServerOptions {
23
+ ipam: Ipam;
24
+ /** Bare TLD label, e.g. "decent" — queries for `<name>.decent` resolve
25
+ * from IPAM. */
26
+ domain: string;
27
+ /** UDP port to listen on. 5353 is the conventional unprivileged
28
+ * choice (mDNS uses it too but at a different multicast address;
29
+ * unicast on 127.0.0.1 doesn't collide). 53 would need root. */
30
+ port: number;
31
+ /** Address to bind. Defaults to 127.0.0.1; pass the TUN ip if you
32
+ * want other peers on the virtual LAN to query this resolver. */
33
+ bindAddress?: string;
34
+ /** Upstream resolver for queries outside our zone. Defaults to
35
+ * Node's `dns.lookup` (which uses the system resolver). */
36
+ upstream?: string;
37
+ }
38
+ export declare class DnsServer {
39
+ private opts;
40
+ private logger;
41
+ private sock?;
42
+ /** When a forwarded query is in flight, key = txid string,
43
+ * value = the original requester. */
44
+ private pendingForwards;
45
+ private forwardSock?;
46
+ /** Actual port we ended up bound to (may differ from opts.port if
47
+ * we fell back due to EADDRINUSE). Exposed via getBoundPort() so
48
+ * `agentnet dns install` can write the correct port into the
49
+ * OS resolver config. */
50
+ private boundPort;
51
+ constructor(opts: DnsServerOptions);
52
+ start(): Promise<void>;
53
+ /** Actual UDP port the listener bound to (matches opts.port unless
54
+ * the fallback sweep had to pick something else). */
55
+ getBoundPort(): number;
56
+ stop(): Promise<void>;
57
+ /**
58
+ * Parse the incoming DNS query, answer A/PTR records from IPAM for
59
+ * our zone, forward everything else to the upstream resolver.
60
+ */
61
+ private handleQuery;
62
+ private answerA;
63
+ private answerPtr;
64
+ private forwardQuery;
65
+ private handleForwardReply;
66
+ private pickUpstream;
67
+ private replyError;
68
+ private sendReply;
69
+ getDomain(): string;
70
+ }
@@ -0,0 +1,393 @@
1
+ /**
2
+ * In-process DNS server — answers A and PTR queries for `<name>.<domain>`
3
+ * (default `.decent`) out of the daemon's IPAM, forwards everything
4
+ * else to the system upstream.
5
+ *
6
+ * Why bake this into the daemon: every node already pulls dora's
7
+ * roster every 60s, so the records are in memory. Spinning up a
8
+ * separate dnsmasq / CoreDNS + a glue script that regenerates a
9
+ * hosts file is more moving parts than `dgram.createSocket("udp4")`
10
+ * plus a hundred lines of DNS wire parsing. Same pattern Tailscale
11
+ * uses for MagicDNS.
12
+ *
13
+ * Wire format: stub implementation of RFC 1035 — enough to answer
14
+ * A / AAAA / PTR for our zone and forward (just the question
15
+ * section) for everything else. We don't recurse; we proxy the raw
16
+ * query to the upstream and ferry the reply back. Compression
17
+ * pointers in incoming queries are handled by the parser; outgoing
18
+ * answers use literal labels (slightly larger packets, but inside
19
+ * the 512-byte UDP DNS limit for our short names).
20
+ */
21
+ import { createSocket } from "dgram";
22
+ import { lookup } from "dns/promises";
23
+ import { Logger } from "../utils/logger.js";
24
+ const QTYPE_A = 1;
25
+ const QTYPE_PTR = 12;
26
+ const QTYPE_AAAA = 28;
27
+ const QCLASS_IN = 1;
28
+ const RCODE_NOERROR = 0;
29
+ const RCODE_NXDOMAIN = 3;
30
+ const RCODE_SERVFAIL = 2;
31
+ const RCODE_NOTIMPL = 4;
32
+ export class DnsServer {
33
+ opts;
34
+ logger;
35
+ sock;
36
+ /** When a forwarded query is in flight, key = txid string,
37
+ * value = the original requester. */
38
+ pendingForwards = new Map();
39
+ forwardSock;
40
+ /** Actual port we ended up bound to (may differ from opts.port if
41
+ * we fell back due to EADDRINUSE). Exposed via getBoundPort() so
42
+ * `agentnet dns install` can write the correct port into the
43
+ * OS resolver config. */
44
+ boundPort = 0;
45
+ constructor(opts) {
46
+ this.opts = opts;
47
+ this.logger = new Logger({ prefix: "DNS" });
48
+ }
49
+ async start() {
50
+ // Outbound socket for forwarded queries. Bind ephemerally; we
51
+ // multiplex by txid -> requester.
52
+ this.forwardSock = createSocket("udp4");
53
+ this.forwardSock.on("message", (msg) => this.handleForwardReply(msg));
54
+ // Try the configured port first; if it's taken (macOS
55
+ // mDNSResponder holds :5353 system-wide on every interface,
56
+ // openclaw-gateway sometimes too) sweep upward until we find
57
+ // a free slot. Anything up to +9 is fair game — operator can
58
+ // see the actual port in the daemon log and `agentnet diag`
59
+ // reads the live socket so `dns install` writes the right
60
+ // value into the OS resolver config.
61
+ const startPort = this.opts.port;
62
+ const bindAddr = this.opts.bindAddress ?? "127.0.0.1";
63
+ let lastErr;
64
+ for (let attempt = 0; attempt < 10; attempt++) {
65
+ const port = startPort + attempt;
66
+ const sock = createSocket("udp4");
67
+ try {
68
+ await new Promise((resolve, reject) => {
69
+ const onErr = (err) => {
70
+ sock.off("listening", onListening);
71
+ reject(err);
72
+ };
73
+ const onListening = () => {
74
+ sock.off("error", onErr);
75
+ resolve();
76
+ };
77
+ sock.once("error", onErr);
78
+ sock.once("listening", onListening);
79
+ sock.bind(port, bindAddr);
80
+ });
81
+ // Success
82
+ sock.on("error", (err) => this.logger.warn(`socket error: ${err.message}`));
83
+ sock.on("message", (msg, rinfo) => {
84
+ this.handleQuery(msg, rinfo.address, rinfo.port).catch((err) => {
85
+ this.logger.debug(`handleQuery: ${err}`);
86
+ });
87
+ });
88
+ this.sock = sock;
89
+ this.boundPort = port;
90
+ if (port !== startPort) {
91
+ this.logger.warn(`Port ${startPort} was busy on ${bindAddr}; fell back to ${port}. ` +
92
+ `Update OS resolver config via 'agentnet dns install' to pick up the new port.`);
93
+ }
94
+ this.logger.info(`Listening on ${bindAddr}:${port} for *.${this.opts.domain}`);
95
+ return;
96
+ }
97
+ catch (err) {
98
+ lastErr = err instanceof Error ? err : new Error(String(err));
99
+ sock.close();
100
+ if (!/EADDRINUSE/i.test(lastErr.message))
101
+ break;
102
+ }
103
+ }
104
+ throw lastErr ?? new Error("DnsServer.start: could not bind any port");
105
+ }
106
+ /** Actual UDP port the listener bound to (matches opts.port unless
107
+ * the fallback sweep had to pick something else). */
108
+ getBoundPort() {
109
+ return this.boundPort;
110
+ }
111
+ async stop() {
112
+ if (this.sock) {
113
+ await new Promise((r) => this.sock.close(() => r()));
114
+ this.sock = undefined;
115
+ }
116
+ if (this.forwardSock) {
117
+ await new Promise((r) => this.forwardSock.close(() => r()));
118
+ this.forwardSock = undefined;
119
+ }
120
+ }
121
+ /**
122
+ * Parse the incoming DNS query, answer A/PTR records from IPAM for
123
+ * our zone, forward everything else to the upstream resolver.
124
+ */
125
+ async handleQuery(msg, clientAddr, clientPort) {
126
+ if (msg.length < 12)
127
+ return;
128
+ const txid = msg.readUInt16BE(0);
129
+ const flags = msg.readUInt16BE(2);
130
+ const qdcount = msg.readUInt16BE(4);
131
+ if (qdcount !== 1) {
132
+ // Multi-question queries are rare and we don't support them.
133
+ this.replyError(txid, msg, clientAddr, clientPort, RCODE_NOTIMPL);
134
+ return;
135
+ }
136
+ // Skip the header (12 bytes), parse the QNAME.
137
+ const parsed = parseName(msg, 12);
138
+ if (!parsed) {
139
+ this.replyError(txid, msg, clientAddr, clientPort, RCODE_SERVFAIL);
140
+ return;
141
+ }
142
+ const { name, nextOffset } = parsed;
143
+ if (nextOffset + 4 > msg.length) {
144
+ this.replyError(txid, msg, clientAddr, clientPort, RCODE_SERVFAIL);
145
+ return;
146
+ }
147
+ const qtype = msg.readUInt16BE(nextOffset);
148
+ const qclass = msg.readUInt16BE(nextOffset + 2);
149
+ if (qclass !== QCLASS_IN) {
150
+ this.replyError(txid, msg, clientAddr, clientPort, RCODE_NOTIMPL);
151
+ return;
152
+ }
153
+ const lower = name.toLowerCase();
154
+ const dotDomain = "." + this.opts.domain.toLowerCase();
155
+ const inZone = lower === this.opts.domain.toLowerCase() || lower.endsWith(dotDomain);
156
+ if (inZone && (qtype === QTYPE_A || qtype === QTYPE_AAAA)) {
157
+ this.answerA(msg, txid, flags, name, qtype, nextOffset + 4, clientAddr, clientPort);
158
+ return;
159
+ }
160
+ if (qtype === QTYPE_PTR && lower.endsWith(".in-addr.arpa")) {
161
+ this.answerPtr(msg, txid, flags, name, lower, nextOffset + 4, clientAddr, clientPort);
162
+ return;
163
+ }
164
+ // Not ours — proxy to the upstream. We DON'T use Node's
165
+ // dns.lookup() because that returns a single result, no SOA, no
166
+ // TTL — instead we forward the raw wire query to a real DNS
167
+ // server and ferry the reply back. The upstream is whatever the
168
+ // host's /etc/resolv.conf says, accessed via the dns module's
169
+ // getServers() so we always honor the OS config.
170
+ await this.forwardQuery(msg, txid, clientAddr, clientPort);
171
+ }
172
+ answerA(query, txid, qflags, name, qtype, questionEnd, clientAddr, clientPort) {
173
+ const shortName = name
174
+ .slice(0, name.length - this.opts.domain.length - (name.endsWith("." + this.opts.domain) ? 1 : 0));
175
+ const record = this.opts.ipam.resolveName(shortName);
176
+ if (!record) {
177
+ this.sendReply(buildHeader(txid, qflags, RCODE_NXDOMAIN, 0), query, questionEnd, [], clientAddr, clientPort);
178
+ return;
179
+ }
180
+ // For AAAA we return NOERROR with no answers — clients will fall
181
+ // back to A. Returning NXDOMAIN here would cause some resolvers
182
+ // to give up on the name entirely.
183
+ if (qtype === QTYPE_AAAA) {
184
+ this.sendReply(buildHeader(txid, qflags, RCODE_NOERROR, 0), query, questionEnd, [], clientAddr, clientPort);
185
+ return;
186
+ }
187
+ const rdata = ipv4ToBytes(record);
188
+ if (!rdata) {
189
+ this.sendReply(buildHeader(txid, qflags, RCODE_SERVFAIL, 0), query, questionEnd, [], clientAddr, clientPort);
190
+ return;
191
+ }
192
+ const answer = encodeAnswer(name, QTYPE_A, QCLASS_IN, 60, rdata);
193
+ this.sendReply(buildHeader(txid, qflags, RCODE_NOERROR, 1), query, questionEnd, [answer], clientAddr, clientPort);
194
+ }
195
+ answerPtr(query, txid, qflags, name, lower, questionEnd, clientAddr, clientPort) {
196
+ // X.Y.Z.W.in-addr.arpa -> W.Z.Y.X
197
+ const ipReversed = lower.slice(0, lower.length - ".in-addr.arpa".length);
198
+ const parts = ipReversed.split(".");
199
+ if (parts.length !== 4) {
200
+ this.sendReply(buildHeader(txid, qflags, RCODE_NXDOMAIN, 0), query, questionEnd, [], clientAddr, clientPort);
201
+ return;
202
+ }
203
+ const ip = [parts[3], parts[2], parts[1], parts[0]].join(".");
204
+ const record = this.opts.ipam.resolveIp(ip);
205
+ if (!record) {
206
+ this.sendReply(buildHeader(txid, qflags, RCODE_NXDOMAIN, 0), query, questionEnd, [], clientAddr, clientPort);
207
+ return;
208
+ }
209
+ const fqdn = `${record.name}.${this.opts.domain}`;
210
+ const rdata = encodeName(fqdn);
211
+ const answer = encodeAnswer(name, QTYPE_PTR, QCLASS_IN, 60, rdata);
212
+ this.sendReply(buildHeader(txid, qflags, RCODE_NOERROR, 1), query, questionEnd, [answer], clientAddr, clientPort);
213
+ }
214
+ async forwardQuery(msg, txid, clientAddr, clientPort) {
215
+ const upstream = await this.pickUpstream();
216
+ if (!upstream) {
217
+ this.replyError(txid, msg, clientAddr, clientPort, RCODE_SERVFAIL);
218
+ return;
219
+ }
220
+ const key = `${txid}|${clientAddr}|${clientPort}`;
221
+ this.pendingForwards.set(key, { addr: clientAddr, port: clientPort, originalId: txid });
222
+ // Use the txid as-is — the upstream echoes it back, we match on it.
223
+ this.forwardSock.send(msg, upstream.port, upstream.host, (err) => {
224
+ if (err) {
225
+ this.pendingForwards.delete(key);
226
+ this.logger.debug(`forward send: ${err.message}`);
227
+ this.replyError(txid, msg, clientAddr, clientPort, RCODE_SERVFAIL);
228
+ }
229
+ });
230
+ // Expire stale entries after 5s.
231
+ setTimeout(() => this.pendingForwards.delete(key), 5000).unref();
232
+ }
233
+ handleForwardReply(msg) {
234
+ if (msg.length < 2)
235
+ return;
236
+ const txid = msg.readUInt16BE(0);
237
+ // Find any matching pending entry (txid is the only key the
238
+ // upstream preserves; with two simultaneous clients using the
239
+ // same id we'd collide — rare for our scale).
240
+ for (const [key, pending] of this.pendingForwards) {
241
+ if (pending.originalId === txid) {
242
+ this.pendingForwards.delete(key);
243
+ this.sock?.send(msg, pending.port, pending.addr, () => undefined);
244
+ return;
245
+ }
246
+ }
247
+ }
248
+ async pickUpstream() {
249
+ if (this.opts.upstream) {
250
+ return { host: this.opts.upstream, port: 53 };
251
+ }
252
+ // Fallback: ask Node's resolver for its servers. We don't pin
253
+ // these on startup so changes to /etc/resolv.conf propagate.
254
+ try {
255
+ const { Resolver } = await import("dns/promises");
256
+ const resolver = new Resolver();
257
+ const servers = resolver.getServers();
258
+ // Skip 127.0.0.x — would loop right back to us via systemd-
259
+ // resolved on Linux setups.
260
+ const external = servers.find((s) => !s.startsWith("127."));
261
+ if (external)
262
+ return { host: external, port: 53 };
263
+ }
264
+ catch {
265
+ // ignore
266
+ }
267
+ return { host: "1.1.1.1", port: 53 };
268
+ }
269
+ replyError(txid, query, clientAddr, clientPort, rcode) {
270
+ if (!this.sock)
271
+ return;
272
+ const flags = query.length >= 4 ? query.readUInt16BE(2) : 0;
273
+ const header = buildHeader(txid, flags, rcode, 0);
274
+ this.sock.send(header, clientPort, clientAddr, () => undefined);
275
+ }
276
+ sendReply(header, query, questionEnd, answers, clientAddr, clientPort) {
277
+ if (!this.sock)
278
+ return;
279
+ // Echo the question section so the client knows which query
280
+ // this answers (per RFC 1035).
281
+ const question = query.slice(12, questionEnd);
282
+ const reply = Buffer.concat([header, question, ...answers]);
283
+ this.sock.send(reply, clientPort, clientAddr, () => undefined);
284
+ }
285
+ // For test/debug — make sure the IPAM bound at construction time
286
+ // is still the one being used (it's a reference, but explicit
287
+ // is safer if the daemon ever needs to swap it).
288
+ getDomain() {
289
+ return this.opts.domain;
290
+ }
291
+ }
292
+ /**
293
+ * Parse a DNS-encoded name starting at `offset`. Supports compression
294
+ * pointers (RFC 1035 §4.1.4). Returns the dot-joined ASCII name and
295
+ * the byte offset just past the terminator (or the pointer-back
296
+ * label, whichever ends the name).
297
+ */
298
+ function parseName(buf, offset) {
299
+ const labels = [];
300
+ let cursor = offset;
301
+ let jumped = false;
302
+ let nextAfter = -1;
303
+ let safety = 0;
304
+ while (true) {
305
+ if (safety++ > 64)
306
+ return null;
307
+ if (cursor >= buf.length)
308
+ return null;
309
+ const len = buf[cursor];
310
+ if (len === 0) {
311
+ cursor += 1;
312
+ if (!jumped)
313
+ nextAfter = cursor;
314
+ break;
315
+ }
316
+ if ((len & 0xc0) === 0xc0) {
317
+ if (cursor + 1 >= buf.length)
318
+ return null;
319
+ const pointer = ((len & 0x3f) << 8) | buf[cursor + 1];
320
+ if (!jumped)
321
+ nextAfter = cursor + 2;
322
+ cursor = pointer;
323
+ jumped = true;
324
+ continue;
325
+ }
326
+ cursor += 1;
327
+ if (cursor + len > buf.length)
328
+ return null;
329
+ labels.push(buf.slice(cursor, cursor + len).toString("ascii"));
330
+ cursor += len;
331
+ }
332
+ return { name: labels.join("."), nextOffset: nextAfter };
333
+ }
334
+ function encodeName(name) {
335
+ const parts = name.split(".").filter((p) => p.length > 0);
336
+ let total = 1; // trailing zero
337
+ for (const p of parts)
338
+ total += 1 + p.length;
339
+ const buf = Buffer.alloc(total);
340
+ let off = 0;
341
+ for (const p of parts) {
342
+ buf[off++] = p.length;
343
+ buf.write(p, off, "ascii");
344
+ off += p.length;
345
+ }
346
+ buf[off] = 0;
347
+ return buf;
348
+ }
349
+ function buildHeader(txid, qflagsRaw, rcode, ancount) {
350
+ const buf = Buffer.alloc(12);
351
+ buf.writeUInt16BE(txid, 0);
352
+ // QR=1 (response), Opcode from query, AA=1, RD echoed, RA=1, RCODE.
353
+ const opcode = (qflagsRaw >> 11) & 0x0f;
354
+ const rd = (qflagsRaw >> 8) & 1;
355
+ const flags = (1 << 15) | (opcode << 11) | (1 << 10) | (rd << 8) | (1 << 7) | (rcode & 0x0f);
356
+ buf.writeUInt16BE(flags, 2);
357
+ buf.writeUInt16BE(1, 4); // qdcount
358
+ buf.writeUInt16BE(ancount, 6);
359
+ return buf;
360
+ }
361
+ function encodeAnswer(name, type, klass, ttl, rdata) {
362
+ const nameBuf = encodeName(name);
363
+ const buf = Buffer.alloc(nameBuf.length + 10 + rdata.length);
364
+ nameBuf.copy(buf, 0);
365
+ let off = nameBuf.length;
366
+ buf.writeUInt16BE(type, off);
367
+ off += 2;
368
+ buf.writeUInt16BE(klass, off);
369
+ off += 2;
370
+ buf.writeUInt32BE(ttl, off);
371
+ off += 4;
372
+ buf.writeUInt16BE(rdata.length, off);
373
+ off += 2;
374
+ rdata.copy(buf, off);
375
+ return buf;
376
+ }
377
+ function ipv4ToBytes(ip) {
378
+ const parts = ip.split(".");
379
+ if (parts.length !== 4)
380
+ return null;
381
+ const buf = Buffer.alloc(4);
382
+ for (let i = 0; i < 4; i++) {
383
+ const n = parseInt(parts[i], 10);
384
+ if (Number.isNaN(n) || n < 0 || n > 255)
385
+ return null;
386
+ buf[i] = n;
387
+ }
388
+ return buf;
389
+ }
390
+ // Silence the unused-import warning while preserving the import as
391
+ // a hint that we *could* drop to Node's high-level resolver if the
392
+ // raw-forward path ever proves too fragile.
393
+ void lookup;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Dora integration — registers this node with a dora (DHCP-style) server
3
+ * and pulls the roster of known peers into the in-memory IPAM.
4
+ *
5
+ * Replaces the manual ipam.yaml dance once an operator points the config
6
+ * at a dora server's userid. The yaml file is still loaded first as a
7
+ * fallback; whatever the dora server says wins on conflict.
8
+ *
9
+ * Failure mode: if no configured dora server answers within the timeout,
10
+ * we log a warning and fall through to whatever IP/IPAM was loaded from
11
+ * disk. Decentlan is fully functional without dora — it's just that
12
+ * peers need to manually share ipam.yaml entries.
13
+ */
14
+ import type { PeerManager } from "../carrier/peer-manager.js";
15
+ import type { Ipam } from "../ipam/ipam.js";
16
+ import type { DoraConfig } from "../types.js";
17
+ export interface DoraIntegrationOptions {
18
+ config: DoraConfig;
19
+ peerManager: PeerManager;
20
+ ipam: Ipam;
21
+ /** Our own user-facing node name (becomes the `name` field in dora). */
22
+ nodeName: string;
23
+ /** Preferred virtual IP from local config — sent as `requestedIp` so a
24
+ * restart keeps the same address when possible. */
25
+ preferredIp?: string;
26
+ }
27
+ export declare class DoraIntegration {
28
+ private opts;
29
+ private client;
30
+ private logger;
31
+ private refreshTimer?;
32
+ private retryBootstrapTimer?;
33
+ /** The IP dora handed us. Falls back to preferredIp on registry failure. */
34
+ private allocatedIp;
35
+ /** Userids we've already attempted to friend this session — keeps the
36
+ * 60s roster refresh from spamming sendFriendRequest. */
37
+ private friendRequested;
38
+ /** Set once we've successfully registered. The retry-bootstrap loop
39
+ * uses this to know when to stop trying. */
40
+ private registered;
41
+ constructor(opts: DoraIntegrationOptions);
42
+ /**
43
+ * Register self with dora and pull the roster. Idempotent; safe to
44
+ * retry. Returns the IP dora allocated (or preferredIp if all servers
45
+ * were unreachable).
46
+ */
47
+ bootstrap(): Promise<string>;
48
+ /**
49
+ * Re-attempt the register flow every 30 seconds until it succeeds.
50
+ * Without this, a daemon that started before its dora server came
51
+ * online never makes it into the roster — peers can't auto-friend
52
+ * us, and auto-IP allocation is lost. bootstrap() re-derives its
53
+ * own myUserid so we don't need to thread it through.
54
+ */
55
+ private scheduleBootstrapRetry;
56
+ /**
57
+ * Wrap a single register call with up to 3 retries spaced 2s apart.
58
+ * The first call often races the underlying Carrier session even
59
+ * after waitForFriendConnected resolves — the SDK has a transient
60
+ * gap between the "online" event and the in-session crypto channel
61
+ * being ready for the next outgoing sendText. A short retry burst
62
+ * hides that without needing express fallback.
63
+ */
64
+ private tryRegister;
65
+ stop(): void;
66
+ getAllocatedIp(): string | undefined;
67
+ private refreshRoster;
68
+ /**
69
+ * Stamp dora records into the in-memory IPAM. We DON'T persist to
70
+ * ipam.yaml — that file remains operator-managed. Dora is treated as
71
+ * a live overlay that's refreshed on every restart.
72
+ *
73
+ * Conflict policy: dora wins. If ipam.yaml had a peer with a stale IP,
74
+ * the dora record overwrites it (assignPeer dedupes by name + carrierId).
75
+ */
76
+ private mergeRosterIntoIpam;
77
+ /**
78
+ * Send an outbound friend request to a roster entry if we haven't
79
+ * already (and aren't already friends). Idempotent at multiple
80
+ * levels: in-process guard via `friendRequested`, SDK-level
81
+ * short-circuit on `acceptedAt`, and recipient-side auto-accept.
82
+ *
83
+ * Address is optional in the protocol for backward-compat with
84
+ * older dora records; without it, we can't initiate the request
85
+ * (the SDK needs the nospam token embedded in the address). The
86
+ * caller logs a one-time warning so the operator knows to either
87
+ * re-register the peer or fall back to a manual friend-request.
88
+ */
89
+ private maybeFriend;
90
+ }