@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,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
|
+
}
|