-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathdns.mjs
More file actions
3178 lines (2968 loc) · 140 KB
/
Copy pathdns.mjs
File metadata and controls
3178 lines (2968 loc) · 140 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Moshpit names on the machine, not just in the browser.
//
// The registry speaks HTTP, not DNS: pit.moshcode.sh answers
// /api/moshpit/resolve?name=… and nothing is listening on port 53. That is why
// `curl https://california.oranges/` fails on a VPS while the TronBrowser
// extension can reach the same name — the extension redirects tabs, which is
// not resolution, and nothing outside a browser benefits from it.
//
// So this is a bridge: a tiny DNS server that answers A queries for Moshpit
// TLDs out of the registry's HTTP API, plus the resolver config that routes
// just those TLDs to it. Everything else on the machine keeps using the normal
// nameserver — the bridge is authoritative for claimed Moshpit TLDs and
// deliberately silent about anything else.
//
// The wire codec is pure and separate from the socket so the whole protocol is
// testable without binding a port.
import dgram from "node:dgram";
import { isIP, connect as netConnect } from "node:net";
import { Resolver } from "node:dns/promises";
export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh";
export const DEFAULT_PARKING_HOST = "moshcoding.com";
export const DEFAULT_PORT = 5354;
export const DEFAULT_HOST = "127.0.0.1";
// Where the pinned-TLS proxy listens. Not configurable from DNS: an A record
// cannot carry a port, so the proxy has to be on 443 for a browser to reach it
// at all — its installer moves it there for exactly this reason.
export const PROXY_PORT = 443;
export function parseDnsPort(input) {
const raw = String(input ?? "").trim();
if (!/^\d+$/.test(raw)) return null;
const port = Number(raw);
return Number.isSafeInteger(port) && port >= 1 && port <= 65535 ? port : null;
}
// Short, because a name's target can change the moment its owner points it
// somewhere. A stale A record is the one failure mode users cannot debug.
export const DEFAULT_TTL = 30;
export const TYPE_A = 1;
export const TYPE_AAAA = 28;
export const TYPE_CNAME = 5;
export const TYPE_MX = 15;
export const TYPE_TXT = 16;
const CLASS_IN = 1;
/**
* The question types answered out of the registry's record set, mapped to the
* name the registry calls them.
*
* Address questions are not in here. They are answered from `target`, which the
* registry keeps in step with the address records and which every build of this
* bridge has read since before records existed — routing them through here
* would change how a name already resolving today gets its answer, to arrive at
* the same address.
*/
export const RECORD_TYPES = new Map([
[TYPE_CNAME, "CNAME"],
[TYPE_MX, "MX"],
[TYPE_TXT, "TXT"],
]);
/**
* What fits in a UDP answer without EDNS.
*
* 512 bytes is the floor every resolver accepts. Beyond it a datagram may be
* dropped by a middlebox rather than delivered short, so the reply is trimmed
* to what fits and marked truncated instead of being sent oversized and lost.
*/
export const UDP_SAFE_BYTES = 512;
const RCODE_OK = 0;
const RCODE_SERVFAIL = 2;
const RCODE_REFUSED = 5;
const RCODE_NXDOMAIN = 3;
/* ---------------------------------------------------------------- wire codec */
/** Encode a hostname as DNS labels. */
export function encodeName(name) {
const labels = String(name).replace(/\.$/, "").split(".").filter(Boolean);
const parts = labels.map((l) => {
const b = Buffer.from(l, "ascii");
if (b.length > 63) throw new Error(`label too long: ${l}`);
return Buffer.concat([Buffer.from([b.length]), b]);
});
return Buffer.concat([...parts, Buffer.from([0])]);
}
/**
* Read a QNAME starting at `offset`. Returns { name, offset } where offset is
* the first byte AFTER the name. Compression pointers are rejected rather than
* followed: they cannot legally appear in a question, and quietly accepting
* them in a parser that only reads questions invites a pointer loop.
*/
export function decodeName(buf, offset) {
const labels = [];
let i = offset;
for (;;) {
if (i >= buf.length) throw new Error("truncated name");
const len = buf[i];
if (len === 0) return { name: labels.join("."), offset: i + 1 };
if ((len & 0xc0) === 0xc0) throw new Error("compression pointer in question");
i += 1;
if (i + len > buf.length) throw new Error("truncated label");
labels.push(buf.toString("ascii", i, i + len));
i += len;
}
}
/** Parse a query. Returns null for anything we should not try to answer. */
export function parseQuery(buf) {
if (!Buffer.isBuffer(buf) || buf.length < 12) return null;
const flags = buf.readUInt16BE(2);
if (flags & 0x8000) return null; // a response, not a query
if (buf.readUInt16BE(4) !== 1) return null; // exactly one question
let name;
let offset;
try {
({ name, offset } = decodeName(buf, 12));
} catch {
return null;
}
if (offset + 4 > buf.length) return null;
return {
id: buf.readUInt16BE(0),
recursionDesired: !!(flags & 0x0100),
name: name.toLowerCase(),
type: buf.readUInt16BE(offset),
class: buf.readUInt16BE(offset + 2),
questionEnd: offset + 4,
};
}
function header(id, { rcode, answers, recursionDesired }) {
const buf = Buffer.alloc(12);
buf.writeUInt16BE(id, 0);
// QR=1 (response), AA=1 (we are authoritative for the TLDs we serve), RD
// echoed back per RFC 1035, RA=0 — we do not offer recursion for anything.
buf.writeUInt16BE(0x8400 | (recursionDesired ? 0x0100 : 0) | rcode, 2);
buf.writeUInt16BE(1, 4); // QDCOUNT — the question is echoed
buf.writeUInt16BE(answers, 6);
return buf;
}
function ipv4(address) {
const parts = String(address).split(".");
if (parts.length !== 4) return null;
const bytes = parts.map((p) => Number(p));
if (bytes.some((b) => !Number.isInteger(b) || b < 0 || b > 255)) return null;
return Buffer.from(bytes);
}
/**
* 16 bytes of AAAA rdata.
*
* `isIP` has already ruled on the grammar, so the work here is expanding what
* the text form is allowed to leave out: the `::` run of zero groups, and the
* trailing dotted-quad an IPv4-mapped address is written with.
*/
function ipv6(address) {
const raw = String(address).trim().toLowerCase().replace(/^\[|\]$/g, "");
if (isIP(raw) !== 6) return null;
let text = raw;
const mapped = text.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) {
const octets = mapped[2].split(".").map(Number);
text = `${mapped[1]}${(((octets[0] << 8) | octets[1]) >>> 0).toString(16)}:${(((octets[2] << 8) | octets[3]) >>> 0).toString(16)}`;
}
const [head, tail] = text.split("::");
const left = head ? head.split(":").filter(Boolean) : [];
const right = tail ? tail.split(":").filter(Boolean) : [];
const groups = text.includes("::")
? [...left, ...Array(8 - left.length - right.length).fill("0"), ...right]
: left;
if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
const buf = Buffer.alloc(16);
groups.forEach((group, i) => buf.writeUInt16BE(parseInt(group, 16), i * 2));
return buf;
}
/**
* TXT rdata: one or more length-prefixed strings.
*
* Split at 255 bytes because that is the largest a single DNS character-string
* can be, and long TXT values are normal rather than exceptional — a DKIM key
* does not fit in one and is always carried as several. A client joins them
* back together, so the split is invisible above the wire.
*
* Split on bytes, not characters: a multi-byte character straddling the
* boundary would be cut in half and neither piece would decode.
*/
export function rdataTxt(value) {
const bytes = Buffer.from(String(value), "utf8");
if (!bytes.length) return Buffer.from([0]);
const chunks = [];
for (let i = 0; i < bytes.length; i += 255) {
const chunk = bytes.subarray(i, i + 255);
chunks.push(Buffer.concat([Buffer.from([chunk.length]), chunk]));
}
return Buffer.concat(chunks);
}
/** MX rdata: a 16-bit preference, then the exchange as labels. */
export function rdataMx(priority, value) {
const preference = Buffer.alloc(2);
preference.writeUInt16BE(Math.min(65_535, Math.max(0, Number(priority) || 0)), 0);
return Buffer.concat([preference, encodeName(value)]);
}
/**
* The rdata for one record from the registry, or null when it cannot be
* encoded.
*
* Null rather than a throw: one malformed record must not take down the answer
* for the ones beside it that are fine. The registry validates on the way in,
* so this is the second line — it is reading data over HTTP from a service that
* may be a different version than this bridge.
*/
export function encodeRdata(record) {
try {
if (record?.type === "TXT") return rdataTxt(record.value);
if (record?.type === "MX") return rdataMx(record.priority, record.value);
if (record?.type === "CNAME") return encodeName(record.value);
if (record?.type === "AAAA") return ipv6(record.value);
if (record?.type === "A") return ipv4(record.value);
} catch {
return null;
}
return null;
}
const TYPE_NUMBERS = new Map([["A", TYPE_A], ["CNAME", TYPE_CNAME], ["MX", TYPE_MX],
["TXT", TYPE_TXT], ["AAAA", TYPE_AAAA]]);
/**
* A response carrying whole records rather than a bare address.
*
* Answers are fitted to `limit` and TC is set only if something was left out.
* Dropping every answer the way capResponse does is right for a relayed reply
* that cannot be re-cut, but here the answers are ours: a name with nine MX
* records should hand back the seven that fit and say it was truncated, not
* nothing at all — this bridge speaks UDP only, so a client that retries over
* TCP finds no one listening.
*
* `exists` carries the same NODATA/NXDOMAIN distinction buildResponse draws: a
* name with no TXT record still exists, and answering NXDOMAIN would deny it
* for every other type at once.
*/
export function buildRecordResponse(query, buf, records = [], { ttl = DEFAULT_TTL, exists = true, limit = UDP_SAFE_BYTES } = {}) {
const question = buf.subarray(12, query.questionEnd);
const encoded = [];
let dropped = false;
let size = 12 + question.length;
for (const record of records) {
const rdata = encodeRdata(record);
const type = TYPE_NUMBERS.get(record?.type);
if (!rdata || !type) continue;
const answer = Buffer.alloc(12);
answer.writeUInt16BE(0xc00c, 0); // the question's name, by pointer
answer.writeUInt16BE(type, 2);
answer.writeUInt16BE(CLASS_IN, 4);
// The record's own TTL when it has one. An owner who set 60 on an address
// that moves meant it, and overriding it with the bridge's default would
// quietly hold the old answer for longer than they asked.
answer.writeUInt32BE(Number.isFinite(record.ttl) ? Math.max(0, Math.floor(record.ttl)) : ttl, 6);
answer.writeUInt16BE(rdata.length, 10);
if (size + answer.length + rdata.length > limit) { dropped = true; continue; }
size += answer.length + rdata.length;
encoded.push(answer, rdata);
}
const answers = encoded.length / 2;
const head = header(query.id, {
rcode: answers || exists ? RCODE_OK : RCODE_NXDOMAIN,
answers,
recursionDesired: query.recursionDesired,
});
if (dropped) head.writeUInt16BE(head.readUInt16BE(2) | 0x0200, 2); // TC
return Buffer.concat([head, question, ...encoded]);
}
/**
* A CNAME answer, plus the leaf addresses when we could find them.
*
* Two owner names appear in one message: the question's name owns the CNAME,
* and the CNAME's target owns the addresses. Only the first can use the 0xc00c
* pointer — it is the only name already in the message — so the target is
* written out in full for each leaf. Uncompressed is legal, and a handful of
* spare bytes is a fair price for not hand-rolling a compression table.
*/
export function buildChainResponse(query, buf, { cname, addresses = [], ttl = DEFAULT_TTL } = {}) {
const question = buf.subarray(12, query.questionEnd);
const wantsV6 = query.type === TYPE_AAAA;
const target = encodeName(cname);
const head = Buffer.alloc(12);
head.writeUInt16BE(0xc00c, 0); // the question's name, by pointer
head.writeUInt16BE(TYPE_CNAME, 2);
head.writeUInt16BE(CLASS_IN, 4);
head.writeUInt32BE(ttl, 6);
head.writeUInt16BE(target.length, 10);
const parts = [head, target];
let answers = 1;
for (const address of addresses) {
const rdata = wantsV6 ? ipv6(address) : ipv4(address);
if (!rdata) continue;
const leaf = Buffer.alloc(10);
leaf.writeUInt16BE(wantsV6 ? TYPE_AAAA : TYPE_A, 0);
leaf.writeUInt16BE(CLASS_IN, 2);
leaf.writeUInt32BE(ttl, 4);
leaf.writeUInt16BE(rdata.length, 8);
parts.push(target, leaf, rdata);
answers += 1;
}
return Buffer.concat([
header(query.id, { rcode: RCODE_OK, answers, recursionDesired: query.recursionDesired }),
question,
...parts,
]);
}
/**
* Build an address-record response for the family the query asked for.
*
* Three outcomes, and the difference between the last two is the whole reason
* this is not a one-liner. NXDOMAIN says the name does not exist, and a
* resolver is entitled to apply that to every record type at once. A name
* pointed at an IPv6 address *does* exist — it just has no A record — so the A
* query every browser sends alongside the AAAA one has to come back NOERROR
* with no answers. Answering NXDOMAIN there teaches the resolver the name is
* gone and takes the AAAA lookup down with it.
*
* `exists` is that distinction on its own. Holding an address implies the name
* exists, so it defaults to exactly that, but the reverse does not hold: a name
* can exist and have no address to hand back — because the question was for a
* type this bridge does not serve, or because the target is a hostname rather
* than an address. Those are NODATA, not NXDOMAIN.
*/
export function buildResponse(query, buf, address, ttl = DEFAULT_TTL, exists = Boolean(address)) {
const question = buf.subarray(12, query.questionEnd);
const wantsV6 = query.type === TYPE_AAAA;
const rdata = address ? (wantsV6 ? ipv6(address) : ipv4(address)) : null;
if (!rdata) {
return Buffer.concat([
header(query.id, {
// The name is here, we just have nothing to say for this question: NODATA.
rcode: exists ? RCODE_OK : RCODE_NXDOMAIN,
answers: 0,
recursionDesired: query.recursionDesired,
}),
question,
]);
}
const answer = Buffer.alloc(12);
answer.writeUInt16BE(0xc00c, 0); // pointer to the question's name
answer.writeUInt16BE(wantsV6 ? TYPE_AAAA : TYPE_A, 2);
answer.writeUInt16BE(CLASS_IN, 4);
answer.writeUInt32BE(ttl, 6);
answer.writeUInt16BE(rdata.length, 10);
return Buffer.concat([
header(query.id, { rcode: RCODE_OK, answers: 1, recursionDesired: query.recursionDesired }),
question,
answer,
rdata,
]);
}
/* ------------------------------------------------------------------ registry */
/**
* Names the registry can hold: exactly one label and one TLD, or a third label
* under such a name — including `*` as the whole leftmost label, the wildcard
* an owner publishes for everything under their name.
*/
export function parseRegistryName(hostname) {
const host = String(hostname || "").trim().toLowerCase().replace(/\.$/, "");
if (!host || host.includes(":")) return null;
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null;
const parts = host.split(".");
if (parts.length !== 2 && parts.length !== 3) return null;
// Letters and digits only, matching the registry. A dash is the cheapest way
// to mint a look-alike of an ending someone else holds, and in a namespace
// one level deep and first come first served there is nowhere to retreat to.
// Keeping the rule here identical to the registry's matters more than the
// rule itself: a name this bridge accepts and the registry rejects resolves
// to a page that says it does not exist.
const LABEL = /^[a-z0-9]{1,63}$/;
if (parts.length === 3) {
const [sub, label, tld] = parts;
// `*` is a label only whole and only leftmost — `f*.chovy.hacker` and
// `foo.*.hacker` are not names the registry can be asked about.
if (sub !== "*" && !LABEL.test(sub)) return null;
if (!LABEL.test(label) || !LABEL.test(tld)) return null;
return { sub, label, tld };
}
const [label, tld] = parts;
if (!LABEL.test(label) || !LABEL.test(tld)) return null;
return { label, tld };
}
/** The TLDs currently claimed in the Pit — what we route to this resolver. */
/** The registry's own ceiling on one page. Asking for more just gets this. */
const TLD_PAGE = 1000;
/**
* Every ending, paged.
*
* This used to take the first response and stop, which is a silent truncation:
* the registry answers 200 by default and says so in `total`, but a list of 200
* looks exactly like a complete list of 200. `.eggs` sat past that line, so
* `dns install` wrote a config that quietly did not route it and the name did
* not resolve — the failure looked like DNS, three layers from the cause.
*
* Paged to exhaustion against `total`, with the page count bounded so a
* registry that misreports it cannot spin here forever.
*/
export async function fetchTlds({ registryBase = DEFAULT_REGISTRY_BASE, fetchImpl = fetch } = {}) {
const base = `${registryBase.replace(/\/+$/, "")}/api/moshpit/tlds`;
const seen = [];
let offset = 0;
let total = null;
// A page that comes back empty ends it too, so a `total` that overstates the
// rows on hand cannot loop.
for (let page = 0; page < 64; page++) {
const res = await fetchImpl(`${base}?limit=${TLD_PAGE}&offset=${offset}`);
if (!res.ok) throw new Error(`registry returned ${res.status}`);
const json = await res.json();
const rows = json?.tlds || [];
if (!rows.length) break;
seen.push(...rows);
offset += rows.length;
if (total === null && Number.isFinite(Number(json?.total))) total = Number(json.total);
// No `total` at all means an older registry that cannot page — take what it
// gave rather than walking off the end of it.
if (total === null || offset >= total) break;
}
return seen
.map((t) => (typeof t === "string" ? t : t?.tld))
.filter((t) => typeof t === "string" && t)
.map((t) => t.toLowerCase())
.sort();
}
/**
* What address a Moshpit name should resolve to.
*
* Three outcomes, and the middle one is the whole point of parking: a claimed
* name with no target is NOT an error, it is a name waiting to be pointed
* somewhere. Handing back the parking host means `curl california.oranges`
* reaches a page that explains itself instead of failing to resolve.
*
* A third-level name adds a fourth: it exists only through its parent or a
* wildcard the parent published, so missing both is NXDOMAIN — there is
* nothing to park it to.
*/
export async function resolveName(
name,
{ registryBase = DEFAULT_REGISTRY_BASE, fetchImpl = fetch, timeoutMs = 4000, records = false } = {},
) {
const parsed = parseRegistryName(name);
if (!parsed) return { status: "not-a-name", target: null };
const full = `${parsed.sub ? `${parsed.sub}.` : ""}${parsed.label}.${parsed.tld}`;
try {
// `&records=1` only when the question needs the whole set. Every address
// lookup on the machine comes through here, and the registry does a second
// query to answer it — a browser opening a page must not pay for records it
// will never read.
//
// The timeout is per ask rather than per call: the wildcard fallback below
// is a second request, and a budget shared with the first would give it
// whatever was left over — sometimes nothing.
const ask = async (asked) => {
const url = `${registryBase.replace(/\/+$/, "")}/api/moshpit/resolve?name=${encodeURIComponent(
asked,
)}${records ? "&records=1" : ""}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetchImpl(url, { signal: controller.signal });
if (!res.ok) return { status: "unreachable", target: null };
const json = await res.json();
const claimed =
typeof json?.name_registered === "boolean" ? json.name_registered : json?.registered;
if (typeof claimed !== "boolean") return { status: "unreachable", target: null };
// The `records` key appears only when it was asked for. Every caller that
// wants an address deep-compares this shape, and an empty array they never
// requested is a difference they would have to be taught to ignore.
const found = records ? { records: Array.isArray(json.records) ? json.records : [] } : {};
const target = typeof json.target === "string" && json.target ? json.target : null;
if (target) return { status: "live", target, ...found };
return { status: "parked", target: null, registered: claimed, ...found };
} finally {
clearTimeout(timer);
}
};
let result = await ask(full);
// A third-level name the registry does not hold may still be covered by a
// wildcard its owner published. The registry applies that match itself;
// asking for the literal `*.parent` is the fallback for one old enough to
// only know the wildcard as a name of its own. A bare label keeps parking
// on a miss — a sub-name has nothing to park to, so missing everywhere is
// NXDOMAIN. The answer keeps the asked name either way: the wire codec
// writes the question's name into every owner field, as a wildcard answer
// should.
const missed = (r) => r.status === "parked" && r.registered === false;
if (parsed.sub && missed(result)) {
if (parsed.sub !== "*") result = await ask(`*.${parsed.label}.${parsed.tld}`);
if (missed(result)) {
return { status: "nxdomain", target: null, ...(records ? { records: [] } : {}) };
}
}
return result;
} catch {
return { status: "unreachable", target: null };
}
}
/**
* The records of one type a name publishes, and whether the name is here.
*
* Both halves matter and they are not the same question: a name with no MX
* record still exists, so the answer is NODATA, while a name nobody holds is
* NXDOMAIN. Collapsing them would let a missing MX deny the name's address too.
*/
export async function answerRecords(name, options = {}) {
const { type } = options;
const result = await resolveName(name, { ...options, records: true });
const exists = result.status === "live" || result.status === "parked";
if (!exists || !type) return { exists, records: [] };
return { exists, records: (result.records || []).filter((r) => r?.type === type) };
}
/* -------------------------------------------------------------------- server */
/**
* What to say about a name: whether it is here at all, and the address to
* answer with when there is one.
*
* Kept separate from the socket so the policy is testable on its own. A name we
* could not look up is not here rather than parked: a registry outage must not
* silently redirect every name on the machine to a parking page.
*
* `wantsAddress` is false for the questions this bridge does not serve (TXT, MX,
* HTTPS/SVCB). Those still need to know the name is here, because saying
* NXDOMAIN to one question denies the name for every other one too.
*/
export async function answerPolicy(name, options = {}) {
const { parkingAddress, wantsAddress = true } = options;
const result = await resolveName(name, options);
const exists = result.status === "live" || result.status === "parked";
if (!exists || !wantsAddress) return { exists, address: null };
if (result.status === "live") return { exists, address: targetAddress(result.target) };
return { exists, address: parkingAddress || null };
}
/**
* The address to answer with, or null when there is none.
*/
export async function answerFor(name, options = {}) {
const { address } = await answerPolicy(name, options);
return address;
}
/**
* The bare address inside a stored target, or null when there isn't one.
*
* Targets are typed by hand and come back from the registry as `2606:...`,
* `[2606:...]:8080`, `example.com`, or with a scheme still attached. A record
* carries an address and nothing else, so the port is dropped here — a name
* whose target names a non-default port cannot be served by the resolver path
* at all, because there is no way to say "port 8080" in an A or AAAA record and
* the browser will go to 80 regardless. A hostname target is null here because
* an A record cannot hold one; `targetHostname` is the other half of the answer.
*/
export function targetAddress(target) {
const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
if (!raw) return null;
const bracketed = raw.match(/^\[([0-9a-f:.]+)\](?::\d+)?$/i);
const host = bracketed ? bracketed[1] : raw;
if (isIP(host)) return host;
const at = host.lastIndexOf(":");
if (at > 0 && /^\d+$/.test(host.slice(at + 1))) {
const bare = host.slice(0, at);
if (isIP(bare) === 4) return bare;
}
return null;
}
/**
* The bare hostname inside a stored target, or null when there isn't one.
*
* The other half of `targetAddress`. Most names in the registry are pointed at
* a host, not an address — `seo.rank` targets `dev.profullstack.com` — and
* refusing to say so was the bug that made every such name look unregistered.
* A CNAME expresses exactly this and costs us no clearnet DNS: the client
* chases it, which is what a CNAME is for.
*
* A target naming a port is null on purpose. No CNAME can carry `:8080`, and
* sending the client to port 80 of the right host is a worse answer than
* admitting there is nothing here to say.
*/
export function targetHostname(target) {
const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
if (!raw || targetAddress(raw)) return null;
// A colon is a port or a malformed v6 literal; a slash is a path. Neither
// survives the trip into an owner name, so neither is guessed at.
if (raw.includes(":") || raw.includes("/")) return null;
const host = raw.toLowerCase().replace(/\.$/, "");
const label = "[a-z0-9]([a-z0-9-]*[a-z0-9])?";
return new RegExp(`^${label}(\\.${label})+$`).test(host) ? host : null;
}
/**
* Is an address question on this name worth a second look for a CNAME?
*
* True when the name is here and has no address to give. A CNAME is the one
* thing that can still answer such a question, and finding out costs another
* round trip to the registry — so it is asked only on the path that would
* otherwise return nothing at all, never on a name that already has an address.
*/
export function mayHaveCname({ exists, address }) {
return Boolean(exists) && !address;
}
/**
* Everything an address question needs, from a single registry lookup.
*
* The old path asked two separate questions — `answerPolicy` for the target,
* then `answerRecords` for a CNAME — and between them dropped the two cases
* that cover most of the registry. A published A/AAAA record was never
* consulted at all (addresses came only from `target`), and a hostname target
* produced nothing. Both surfaced as an authoritative NOERROR with no answers,
* which a client is entitled to treat as final: the name looked dead while the
* registry held a perfectly good answer for it.
*
* The cheap question is asked first and usually ends it: a name pointed at a
* bare address needs no record set, and every page load on the machine comes
* through here. Only a name that has nothing to say yet is worth the second
* round trip — which is the same bargain the old path struck for CNAMEs, held
* to here so the common case did not get slower in exchange for being right.
*/
/**
* Is something actually listening where we are about to send every name?
*
* The guard that makes proxy mode safe to offer at all. Pointing every live
* Moshpit name at a loopback address is exactly as good as the thing behind it:
* with a proxy there, all of them work in a stock client; with nothing there,
* all of them break at once, and the resolver looks healthy while doing it —
* `dig` answers 127.0.0.1 and every connection is refused.
*
* So this is checked before the mode is allowed on, and rechecked rather than
* remembered: a proxy that dies after the resolver started is the same outage
* as one that was never running.
*/
export function proxyReachable(address, port = 443, { connect = null, timeoutMs = 1500 } = {}) {
return new Promise((resolve) => {
let socket;
const done = (ok) => {
try { socket?.destroy(); } catch { /* already gone */ }
resolve(ok);
};
try {
const net = connect || netConnect;
socket = net({ host: address, port });
// Deliberately not unref'd. This timer is the only thing that guarantees
// the promise settles at all, and an unref'd one does not hold the loop
// open — so a connect that stalls without keeping a handle alive let the
// process reach an idle event loop with this still pending, which node
// reports as a cancelled await rather than the `false` the caller needs.
// It cannot outlive the probe: both settle paths clear it.
const timer = setTimeout(() => done(false), timeoutMs);
socket.once("connect", () => { clearTimeout(timer); done(true); });
socket.once("error", () => { clearTimeout(timer); done(false); });
} catch {
resolve(false);
}
});
}
/** The root moshpit-proxy signs with. Its leaves are how the proxy is recognised. */
export const PROXY_ROOT_CN = "Moshpit Local CA";
/**
* Is the thing on that address *our proxy*, or merely something on port 443?
*
* `proxyReachable` answers the second question, and on one common class of
* machine the two answers differ in the worst possible way. An origin runs
* nginx on `0.0.0.0:443`, which covers loopback — so a bare connect succeeds,
* proxy mode is turned on, and every live Moshpit name on the machine is
* pointed at a web server that knows nothing about them. That is not a
* certificate problem, it is every name on the machine serving the wrong site
* at once, and the connect probe cannot see it coming.
*
* So this asks the question that actually distinguishes them: complete a TLS
* handshake and look at who issued the certificate. The proxy mints a leaf per
* name from the root it generated on this machine, so the issuer is that root.
* Anything else — nginx with the origin's own self-signed certificate, some
* unrelated service — is issued by something else and is refused.
*
* `rejectUnauthorized` is off deliberately, and it is not a hole: nothing is
* sent, the peer certificate is read rather than trusted, and the only thing
* accepted from it is the issuer name. Verifying properly would require the
* root to already be installed, which is a step that has not happened yet at
* the point this runs.
*/
export async function proxyServes(address, name, {
port = PROXY_PORT,
timeoutMs = 2500,
tlsConnect = null,
} = {}) {
const connectImpl = tlsConnect || (await import("node:tls")).connect;
return new Promise((resolve) => {
let socket;
const done = (result) => {
try { socket?.destroy(); } catch { /* already gone */ }
resolve(result);
};
try {
socket = connectImpl({
host: address,
port,
servername: name,
rejectUnauthorized: false,
// The proxy forces http/1.1; offering nothing keeps this a pure
// handshake rather than a protocol negotiation that could be declined.
ALPNProtocols: ["http/1.1"],
});
// Not unref'd, for the reason proxyReachable spells out: this timer is the
// only guarantee the promise settles.
const timer = setTimeout(() => done({ ok: false, why: "timed out" }), timeoutMs);
socket.once("secureConnect", () => {
clearTimeout(timer);
const cert = socket.getPeerCertificate?.() || {};
const issuer = cert.issuer?.CN || "";
if (issuer === PROXY_ROOT_CN) return done({ ok: true, issuer });
done({
ok: false,
issuer,
// Named as what it means rather than what was seen: "issuer is
// chovy.hacker" is a fact, "something else owns 443" is the reason
// proxy mode must stay off.
why: issuer
? `something other than the proxy owns ${address}:${port} — it served a certificate issued by ${JSON.stringify(issuer)}`
: `something other than the proxy owns ${address}:${port}`,
});
});
socket.once("error", (err) => {
clearTimeout(timer);
done({ ok: false, why: err?.code || err?.message || "connection failed" });
});
} catch (err) {
resolve({ ok: false, why: err?.message || "connection failed" });
}
});
}
/**
* Which loopback addresses have the proxy behind them, if any.
*
* Both families are asked because answering one of them wrongly is an outage:
* a v6-only answer for a v4-only listener is a refused connection that reads as
* the site being down. `addressAnswer` handles the asymmetry; this just reports
* what is actually there.
*/
export async function findLocalProxy(name, { candidates = ["127.0.0.1", "::1"], ...options } = {}) {
const reachable = [];
let why = null;
for (const address of candidates) {
const result = await proxyServes(address, name, options);
if (result.ok) reachable.push(address);
// Keep the most informative refusal: "something else owns 443" is worth
// saying out loud, where "ECONNREFUSED" just means no proxy is installed.
else if (result.issuer && !why) why = result.why;
}
return {
found: reachable.length > 0,
why,
address: {
v4: reachable.find((a) => isIP(a) === 4) || null,
v6: reachable.find((a) => isIP(a) === 6) || null,
},
};
}
export async function addressAnswer(name, options = {}) {
const { parkingAddress, wantsV6 = false, proxyAddress = null } = options;
const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra });
const result = await resolveName(name, options);
const exists = result.status === "live" || result.status === "parked";
if (!exists) return { exists: false, kind: "nxdomain", records: [], address: null, cname: null };
// Parking is checked before anything the registry published: a parked name's
// whole job is to reach the page explaining that it is for sale. A third-level
// name is never for sale — it exists only through a wildcard its parent
// published — so "parked" there means the wildcard has no target, and the
// records it published are the answer.
//
// It is also checked before the proxy, deliberately. A parked name has no
// origin and no published pin, so handing it to a proxy whose entire job is
// to verify one would turn "this name is for sale" into a TLS error.
if (result.status === "parked" && !parseRegistryName(name)?.sub) {
return parkingAddress ? plan("address", { address: parkingAddress }) : plan("nodata");
}
// Every live name answers the local proxy, whatever the registry says its
// target is — that is the point. The proxy reads the SNI, checks the origin's
// key against the registry pin, and re-signs with a root this machine
// generated, which is the only way a stock client can be told the result: no
// CA will ever sign for a Moshpit name.
//
// Answering the origin instead is what left the proxy running on loopback
// with nothing ever routed to it, so every name arrived at a stock client as
// a self-signed certificate no matter what was installed.
if (proxyAddress) {
const forFamily = wantsV6 ? proxyAddress.v6 : proxyAddress.v4;
// A proxy that only speaks one family is NODATA for the other, not a
// fabricated address: answering ::1 for a v4-only listener is a connection
// refused that looks like the site is down.
return forFamily ? plan("address", { address: forFamily, proxied: true }) : plan("nodata");
}
const address = targetAddress(result.target);
if (address) return plan("address", { address });
const full = await resolveName(name, { ...options, records: true });
const of = (type) => (full.records || []).filter((r) => r?.type === type);
// An address the owner published beats a CNAME to somewhere that holds one:
// it is the more specific statement, and it saves the client a lookup.
const published = of(wantsV6 ? "AAAA" : "A");
if (published.length) return plan("records", { records: published });
const cnames = of("CNAME");
if (cnames.length) return plan("records", { records: cnames });
const host = targetHostname(result.target);
return host ? plan("chain", { cname: host }) : plan("nodata");
}
/**
* The addresses a clearnet hostname holds, for finishing a CNAME chain.
*
* A bare CNAME is a legal answer and a useless one here. This bridge sets RA=0
* — it offers no recursion — so a stub that receives a dangling CNAME has been
* told, in the same breath, that nobody will chase it. systemd-resolved reports
* that as a name with no address, which is indistinguishable from broken.
*
* Best-effort by design: the chain is a courtesy on top of a CNAME that is
* already correct, so an upstream that is slow or silent costs the extra
* records, never the answer.
*/
export async function resolveChain(hostname, { upstreams = [], wantsV6 = false, timeoutMs = 2000 } = {}) {
const servers = upstreams.map(resolverServer).filter(Boolean);
if (!hostname || !servers.length) return [];
try {
const resolver = new Resolver({ timeout: timeoutMs, tries: 1 });
resolver.setServers(servers);
const found = await (wantsV6 ? resolver.resolve6(hostname) : resolver.resolve4(hostname));
return Array.isArray(found) ? found : [];
} catch {
return [];
}
}
/** An upstream in `1.2.3.4#5353` form, as node's resolver wants to read it. */
function resolverServer(upstream) {
const [address, portText] = String(upstream).split("#");
const family = isIP(address);
if (!family) return null;
const port = Number(portText) || 53;
return port === 53 ? address : `${family === 6 ? `[${address}]` : address}:${port}`;
}
/**
* Start the bridge. Returns { port, address, close() }.
*
* `parkingAddress` is resolved once by the caller (an A record must carry an
* IP, not a name) and passed in, so the server itself never does clearnet DNS.
*/
/* ------------------------------------------------------------------ abuse */
// An open forwarding resolver is a DDoS amplifier before it is anything else.
// The attack does not need a botnet: one host spoofs a victim's source address,
// sends a small query, and the resolver mails the large answer to the victim.
// Scanners find open resolvers within hours of them being reachable.
//
// That shape defeats most defences worth having. The source address is a lie,
// so blocking "the client" punishes the victim; there is no session to
// fingerprint and no user agent to read. What is left is limiting how much
// amplification any single query can buy, and bounding what one source can
// extract before we stop answering it.
/** The question type that exists to be abused. */
export const TYPE_ANY = 255;
/**
* A query we will not answer, or null when it is fine.
*
* ANY asks for every record a name has and is the classic amplification lever:
* a 30-byte question for a multi-kilobyte answer. Real clients stopped needing
* it years ago, and RFC 8482 blesses refusing it outright.
*/
export function refusalReason(query) {
if (!query) return null;
if (query.type === TYPE_ANY) return "ANY is refused — RFC 8482";
return null;
}
/**
* What counts as "the same client" for the purposes of banning one.
*
* IPv6 is grouped by /64 and this is the whole reason the function exists. A
* single v6 address is free to change: any host worth banning has a /64 at
* minimum and often a /48, so a ban on one address is defeated by incrementing
* it. fail2ban rules written per-address in a v4 world quietly stop working
* when the traffic arrives over v6, and the failure is silent — the bans look
* like they are being applied, and the abuse continues.
*
* IPv4 is the address itself. Widening to /24 would be the equivalent move,
* but v4 is scarce enough to be shared: a /24 routinely spans unrelated
* customers behind carrier NAT, so grouping there punishes the neighbours of
* an abuser rather than the abuser.
*/
export function clientKey(address) {
const raw = String(address ?? "").trim().toLowerCase();
if (!raw) return "";
// A v4-mapped v6 address is a v4 client arriving on a dual-stack socket.
const mapped = raw.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return mapped[1];
if (isIP(raw) !== 6) return raw;
// Expand to the first four groups — the /64 — without a full parse.
const [head, tail = ""] = raw.split("::");
const left = head ? head.split(":").filter(Boolean) : [];
const right = tail ? tail.split(":").filter(Boolean) : [];
const groups = raw.includes("::")
? [...left, ...Array(Math.max(0, 8 - left.length - right.length)).fill("0"), ...right]
: left;
if (groups.length < 4) return raw;
return `${groups.slice(0, 4).map((g) => parseInt(g, 16).toString(16)).join(":")}::/64`;
}
/**
* fail2ban for a resolver: repeat offenders wait exponentially longer.
*
* A flat rate limit is a toll an attacker simply pays — they lose nothing by
* being refused, and come straight back. Backoff changes the economics: each
* time a source earns another strike its ban doubles, so a persistent source
* spends most of its time banned while a client that misbehaves once is
* inconvenienced for a minute.
*
* Strikes decay after a clean spell, so a bad afternoon does not follow a
* client forever — without that, the ceiling is permanent and the first
* mistake is unforgivable.
*
* Memory is bounded for the same reason the rate limiter's is: the key space
* is attacker-controlled, so an unbounded map is the vulnerability rather than
* the mitigation.
*/
export function createBanList({
baseMs = 60_000,
factor = 2,
maxMs = 24 * 60 * 60 * 1000,
forgetMs = 60 * 60 * 1000,
maxClients = 10_000,
now = () => Date.now(),
} = {}) {
const records = new Map();
const touch = (key, record) => {
records.delete(key);
if (records.size >= maxClients) {
const oldest = records.keys().next().value;
if (oldest !== undefined) records.delete(oldest);
}
records.set(key, record);
};
return {
/** Record an offence and return the ban it earned. */
strike(key) {