@wenathlan/argan 2.0.170 → 2.0.172

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 (2) hide show
  1. package/dns.ts +1121 -0
  2. package/package.json +40 -31
package/dns.ts ADDED
@@ -0,0 +1,1121 @@
1
+ // # dns.ts — the one keyword file of the dns zone domain: the
2
+ // consolidation law of the root (one keyword file per domain) merged the
3
+ // four wave files of the name resolution lane into this seat — the
4
+ // DNSSEC validation math (the former dnssec.ts body below), the planned
5
+ // change set engine between two versions of a zone (zonediff.ts), the
6
+ // clean URL bar of the SPA (cleanurl.ts) and the gateway protocol mesh
7
+ // (protocols.ts) — every export preserved verbatim, zero logic changes,
8
+ // the wire surfaces untouched (PROTOCOLS: 231 rows across the fifteen
9
+ // family groups, the dns family carrying the argan home transports).
10
+ // The consolidation notes: the four sources share no duplicated helpers
11
+ // and no same-name declarations — the dedupe ride and the group-suffix
12
+ // ride of the law are both empty here — so every section keeps its own
13
+ // private helpers verbatim (parseZoneTimestamp / decodeBase64 /
14
+ // ownerLabelCount of the dnssec section, NAMEFIELDS / TTLUNITS / parseTtl
15
+ // / isTtlToken / absolutize / normalizeData / tokenize / groupRecords /
16
+ // targetSet / pickPrimary of the zonediff section, HASH_ROUTE / BASENAME
17
+ // / TRACKERS / isTracker of the cleanurl section), and the two argan
18
+ // imports the sources carried (nextSerial + RecordSetRow of zonediff,
19
+ // BadgeTone of protocols) fold into the single argan.ts import below.
20
+
21
+ import { type BadgeTone, nextSerial, type RecordSetRow } from "./argan.ts";
22
+
23
+ /* ════════════════════════════════════════════════════════════════════ */
24
+ /* SECTION: dnssec.ts — the DNSSEC validation math (the first seat). */
25
+ /* ════════════════════════════════════════════════════════════════════ */
26
+
27
+ // # dnssec — the DNSSEC validation math of the family, housed at the argan root.
28
+ // Library-grade module shipped inside the published packages (npm, maven with the Spring
29
+ // Boot rider): it owns the domain types and pure, generic helpers only — zero consumer
30
+ // data. The scope here is the clock and structure half of the validation chain (the
31
+ // discipline dnssec-js models with its DatePeriod): RRSIG presentation parsing, the
32
+ // RFC 4034 Appendix B key tag, the signature validity window with skew, the RRset/RRSIG
33
+ // TTL equality rule and the DS→DNSKEY structural match over the IANA registries. The
34
+ // heavyweight half — the cryptographic signature verification itself — belongs to the
35
+ // crypto layer and is deliberately out of scope; every verdict here names the exact
36
+ // failed check through typed codes, traceable from the resolver answer back to the row.
37
+
38
+ /** The IANA DNSSEC algorithm numbers the validator answers for (RFC 8624 set). */
39
+ export const DNSSECALGORITHMS: ReadonlyMap<number, string> = new Map([
40
+ [5, "RSASHA1"],
41
+ [7, "RSASHA1NSEC3SHA1"],
42
+ [8, "RSASHA256"],
43
+ [10, "RSASHA512"],
44
+ [13, "ECDSAP256SHA256"],
45
+ [14, "ECDSAP384SHA384"],
46
+ [15, "ED25519"],
47
+ [16, "ED448"],
48
+ ]);
49
+
50
+ /** The IANA DS digest type numbers the validator answers for. */
51
+ export const DNSSECDIGESTTYPES: ReadonlyMap<number, string> = new Map([
52
+ [1, "SHA-1"],
53
+ [2, "SHA-256"],
54
+ [4, "SHA-384"],
55
+ ]);
56
+
57
+ /** One parsed RRSIG record (the presentation fields of RFC 4034 §3.1). */
58
+ export interface RrSig {
59
+ /** the record type the signature covers, uppercase ("A", "DNSKEY") */
60
+ typeCovered: string;
61
+ algorithm: number;
62
+ /** the label count the signer used (wildcard owner names drop the leftmost `*`) */
63
+ labels: number;
64
+ /** the ttl the signed rrset originally carried */
65
+ originalTtl: number;
66
+ /** signature expiration, epoch ms (inclusive) */
67
+ expiration: number;
68
+ /** signature inception, epoch ms (inclusive) */
69
+ inception: number;
70
+ keyTag: number;
71
+ /** the signer zone name, lowercase, trailing dot kept */
72
+ signerName: string;
73
+ /** the signature itself, presentation base64 (unverified here) */
74
+ signature: string;
75
+ }
76
+
77
+ /** One parsed DNSKEY record (the presentation fields of RFC 4034 §2.1). */
78
+ export interface DnsKey {
79
+ flags: number;
80
+ protocol: number;
81
+ algorithm: number;
82
+ /** the public key, presentation base64 */
83
+ publicKey: string;
84
+ }
85
+
86
+ /** One parsed DS record (the presentation fields of RFC 4036 §5.1.4). */
87
+ export interface DsRecord {
88
+ keyTag: number;
89
+ algorithm: number;
90
+ digestType: number;
91
+ /** the digest, presentation hex (uncompared here) */
92
+ digest: string;
93
+ }
94
+
95
+ /** One verdict of the structural validators: ok, or the typed code of the failed check. */
96
+ export type DnssecVerdict = { ok: true } | { ok: false; code: DnssecFailureCode; detail: string };
97
+
98
+ /** The typed codes every failed check answers with — traceable from the answer to the row. */
99
+ export type DnssecFailureCode =
100
+ | "expired-signature"
101
+ | "not-yet-valid-signature"
102
+ | "ttl-mismatch"
103
+ | "ttl-exceeds-original"
104
+ | "label-count-mismatch"
105
+ | "unknown-algorithm"
106
+ | "unknown-digest-type"
107
+ | "key-tag-mismatch"
108
+ | "algorithm-mismatch"
109
+ | "not-a-zone-key"
110
+ | "protocol-mismatch";
111
+
112
+ /** Error raised by the presentation parsers for lines they cannot read. */
113
+ export class DnssecValidationError extends Error {
114
+ constructor(message: string, options?: { cause?: unknown }) {
115
+ super(message, options);
116
+ this.name = "DnssecValidationError";
117
+ }
118
+ }
119
+
120
+ /** Parses a zone-file UTC timestamp (YYYYMMDDHHmmSS) into epoch ms. */
121
+ function parseZoneTimestamp(token: string): number {
122
+ if (!/^\d{14}$/.test(token)) throw new DnssecValidationError(`dnssec: unreadable timestamp "${token}"`);
123
+ const year = Number.parseInt(token.slice(0, 4), 10);
124
+ const month = Number.parseInt(token.slice(4, 6), 10);
125
+ const day = Number.parseInt(token.slice(6, 8), 10);
126
+ const hour = Number.parseInt(token.slice(8, 10), 10);
127
+ const minute = Number.parseInt(token.slice(10, 12), 10);
128
+ const second = Number.parseInt(token.slice(12, 14), 10);
129
+ return Date.UTC(year, month - 1, day, hour, minute, second);
130
+ }
131
+
132
+ /**
133
+ * Parses one RRSIG record from its presentation format:
134
+ * `<typeCovered> <algorithm> <labels> <originalTtl> <expiration> <inception> <keyTag> <signer> <signature>`.
135
+ * Dates are zone-file UTC (YYYYMMDDHHmmSS). Throws `DnssecValidationError` on bad rows.
136
+ *
137
+ * @param line the presentation line (the rdata, with or without the leading "RRSIG").
138
+ * @returns the parsed signature.
139
+ */
140
+ export function parseRrSig(line: string): RrSig {
141
+ const fields = line.trim().split(/\s+/);
142
+ if (fields[0]?.toUpperCase() === "RRSIG") fields.shift();
143
+ if (fields.length < 9) throw new DnssecValidationError(`dnssec: RRSIG needs 9 fields, got ${fields.length}`);
144
+ const algorithm = Number.parseInt(fields[1], 10);
145
+ const labels = Number.parseInt(fields[2], 10);
146
+ const originalTtl = Number.parseInt(fields[3], 10);
147
+ const expiration = parseZoneTimestamp(fields[4]);
148
+ const inception = parseZoneTimestamp(fields[5]);
149
+ const keyTag = Number.parseInt(fields[6], 10);
150
+ if (!Number.isFinite(algorithm) || algorithm <= 0)
151
+ throw new DnssecValidationError(`dnssec: bad algorithm "${fields[1]}"`);
152
+ if (!Number.isFinite(labels) || labels < 0) throw new DnssecValidationError(`dnssec: bad label count "${fields[2]}"`);
153
+ if (!Number.isFinite(originalTtl) || originalTtl < 0)
154
+ throw new DnssecValidationError(`dnssec: bad original ttl "${fields[3]}"`);
155
+ if (expiration < inception) throw new DnssecValidationError("dnssec: signature expires before it starts");
156
+ if (!Number.isFinite(keyTag) || keyTag < 0 || keyTag > 65535)
157
+ throw new DnssecValidationError(`dnssec: bad key tag "${fields[6]}"`);
158
+ return {
159
+ typeCovered: fields[0].toUpperCase(),
160
+ algorithm,
161
+ labels,
162
+ originalTtl,
163
+ expiration,
164
+ inception,
165
+ keyTag,
166
+ signerName: `${fields[7].toLowerCase()}${fields[7].endsWith(".") ? "" : "."}`,
167
+ signature: fields[8],
168
+ };
169
+ }
170
+
171
+ /** Decodes presentation base64 into bytes — dependency-free, node and browser. */
172
+ function decodeBase64(value: string): Uint8Array {
173
+ const clean = value.replace(/[^A-Za-z0-9+/]/g, "");
174
+ const padded = clean.padEnd(Math.ceil(clean.length / 4) * 4, "=");
175
+ const bytes: number[] = [];
176
+ let buffer = 0;
177
+ let bits = 0;
178
+ for (const char of padded) {
179
+ if (char === "=") break;
180
+ const index = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".indexOf(char);
181
+ if (index === -1) throw new DnssecValidationError(`dnssec: bad base64 character "${char}"`);
182
+ buffer = (buffer << 6) | index;
183
+ bits += 6;
184
+ if (bits >= 8) {
185
+ bits -= 8;
186
+ bytes.push((buffer >> bits) & 0xff);
187
+ }
188
+ }
189
+ return Uint8Array.from(bytes);
190
+ }
191
+
192
+ /** Builds the DNSKEY rdata wire format (flags, protocol, algorithm, public key). */
193
+ export function dnsKeyRdata(key: DnsKey): Uint8Array {
194
+ const publicKey = decodeBase64(key.publicKey);
195
+ const rdata = new Uint8Array(4 + publicKey.length);
196
+ rdata[0] = (key.flags >> 8) & 0xff;
197
+ rdata[1] = key.flags & 0xff;
198
+ rdata[2] = key.protocol;
199
+ rdata[3] = key.algorithm;
200
+ rdata.set(publicKey, 4);
201
+ return rdata;
202
+ }
203
+
204
+ /**
205
+ * Computes the DNSSEC key tag of a DNSKEY record (RFC 4034 Appendix B): a 16-bit
206
+ * ones'-complement checksum over the rdata wire format.
207
+ *
208
+ * @param key the DNSKEY record.
209
+ * @returns the key tag (0-65535).
210
+ */
211
+ export function keyTag(key: DnsKey): number {
212
+ const rdata = dnsKeyRdata(key);
213
+ let accumulator = 0;
214
+ for (let index = 0; index < rdata.length; index++) {
215
+ accumulator += index & 1 ? rdata[index] : rdata[index] << 8;
216
+ accumulator += (accumulator >> 16) & 0xffff;
217
+ }
218
+ return accumulator & 0xffff;
219
+ }
220
+
221
+ /**
222
+ * Validates the RRSIG validity window against the reference clock (RFC 4035 §5.3.1):
223
+ * inception ≤ now ≤ expiration, with an explicit skew for resolver clock drift.
224
+ *
225
+ * @param sig the parsed signature.
226
+ * @param now the reference clock in epoch ms (defaults to the real clock).
227
+ * @param skewMs the tolerated clock drift in ms (default 0 — resolvers answer for themselves).
228
+ * @returns the verdict with `expired-signature` or `not-yet-valid-signature` on failure.
229
+ */
230
+ export function validateSignatureWindow(sig: RrSig, now: number = Date.now(), skewMs = 0): DnssecVerdict {
231
+ if (now - skewMs > sig.expiration) {
232
+ return { ok: false, code: "expired-signature", detail: `expired at ${new Date(sig.expiration).toISOString()}` };
233
+ }
234
+ if (now + skewMs < sig.inception) {
235
+ return { ok: false, code: "not-yet-valid-signature", detail: `starts at ${new Date(sig.inception).toISOString()}` };
236
+ }
237
+ return { ok: true };
238
+ }
239
+
240
+ /**
241
+ * Validates the RRSIG ttl against the RRset it covers (RFC 4035 §5.3.2): the
242
+ * signature ttl must equal the rrset ttl and never exceed the original ttl the
243
+ * signer recorded.
244
+ *
245
+ * @param sigTtl the ttl the RRSIG record itself carries.
246
+ * @param sig the parsed signature.
247
+ * @param rrsetTtl the ttl of the covered rrset.
248
+ * @returns the verdict with `ttl-mismatch` or `ttl-exceeds-original` on failure.
249
+ */
250
+ export function validateSignatureTtl(sig: RrSig, sigTtl: number, rrsetTtl: number): DnssecVerdict {
251
+ if (sigTtl !== rrsetTtl) {
252
+ return { ok: false, code: "ttl-mismatch", detail: `rrsig ttl ${sigTtl} differs from rrset ttl ${rrsetTtl}` };
253
+ }
254
+ if (sigTtl > sig.originalTtl) {
255
+ return { ok: false, code: "ttl-exceeds-original", detail: `ttl ${sigTtl} exceeds the original ${sig.originalTtl}` };
256
+ }
257
+ return { ok: true };
258
+ }
259
+
260
+ /** Counts the labels of an owner name (empty trailing root label excluded). */
261
+ function ownerLabelCount(name: string): number {
262
+ const trimmed = name.trim().toLowerCase();
263
+ if (trimmed === "" || trimmed === ".") return 0;
264
+ return trimmed.replace(/\.$/, "").split(".").length;
265
+ }
266
+
267
+ /**
268
+ * Validates the RRSIG label count against the owner name it signs (RFC 4035 §5.3.2):
269
+ * the count must match the owner's labels, with a leading wildcard dropped on both
270
+ * sides (`*.example.com.` signs as `example.com.` = 2 labels).
271
+ *
272
+ * @param sig the parsed signature.
273
+ * @param ownerName the rrset owner name (absolute, trailing dot).
274
+ * @returns the verdict with `label-count-mismatch` on failure.
275
+ */
276
+ export function validateSignatureLabels(sig: RrSig, ownerName: string): DnssecVerdict {
277
+ const labels = ownerLabelCount(ownerName);
278
+ const isWildcard = ownerName.trim().toLowerCase().startsWith("*.");
279
+ const signedLabels = isWildcard ? labels - 1 : labels;
280
+ if (sig.labels !== signedLabels) {
281
+ return {
282
+ ok: false,
283
+ code: "label-count-mismatch",
284
+ detail: `rrsig covers ${sig.labels} labels, owner carries ${signedLabels}`,
285
+ };
286
+ }
287
+ return { ok: true };
288
+ }
289
+
290
+ /**
291
+ * Validates the structural match between a DS record and the DNSKEY it refers to
292
+ * (RFC 4035 §5.2): the key tag and the algorithm must agree, the digest type must
293
+ * be one of the IANA registry and the key must be a zone key answering on
294
+ * protocol 3. The digest comparison itself belongs to the crypto layer.
295
+ *
296
+ * @param ds the delegation signer record.
297
+ * @param key the child DNSKEY record.
298
+ * @returns the verdict with the typed code of the failed check.
299
+ */
300
+ export function validateDsMatch(ds: DsRecord, key: DnsKey): DnssecVerdict {
301
+ if (!DNSSECALGORITHMS.has(ds.algorithm)) {
302
+ return {
303
+ ok: false,
304
+ code: "unknown-algorithm",
305
+ detail: `algorithm ${ds.algorithm} is not in the RFC 8624 registry`,
306
+ };
307
+ }
308
+ if (!DNSSECDIGESTTYPES.has(ds.digestType)) {
309
+ return {
310
+ ok: false,
311
+ code: "unknown-digest-type",
312
+ detail: `digest type ${ds.digestType} is not in the IANA registry`,
313
+ };
314
+ }
315
+ if (ds.algorithm !== key.algorithm) {
316
+ return {
317
+ ok: false,
318
+ code: "algorithm-mismatch",
319
+ detail: `ds algorithm ${ds.algorithm} differs from dnskey ${key.algorithm}`,
320
+ };
321
+ }
322
+ if (ds.keyTag !== keyTag(key)) {
323
+ return { ok: false, code: "key-tag-mismatch", detail: `ds names tag ${ds.keyTag}, dnskey computes ${keyTag(key)}` };
324
+ }
325
+ const ZONEKEYFLAG = 0x0100;
326
+ if ((key.flags & ZONEKEYFLAG) === 0) {
327
+ return { ok: false, code: "not-a-zone-key", detail: `dnskey flags ${key.flags} miss the zone key bit` };
328
+ }
329
+ if (key.protocol !== 3) {
330
+ return { ok: false, code: "protocol-mismatch", detail: `dnskey protocol ${key.protocol} is not 3` };
331
+ }
332
+ return { ok: true };
333
+ }
334
+
335
+ /**
336
+ * Validates one structural link of the chain of trust: the DS of the parent zone
337
+ * against a DNSKEY of the child, and (when the child key is the secure entry
338
+ * point, flags 257) the key tag the chain walks through. The signature math is
339
+ * out of scope by design; this answers "does the chain structurally hold".
340
+ *
341
+ * @param ds the parent's DS record for the child zone.
342
+ * @param key the child DNSKEY candidate.
343
+ * @returns the verdict over the whole structural link.
344
+ */
345
+ export function validateChainLink(ds: DsRecord, key: DnsKey): DnssecVerdict {
346
+ const dsVerdict = validateDsMatch(ds, key);
347
+ if (!dsVerdict.ok) return dsVerdict;
348
+ return { ok: true };
349
+ }
350
+
351
+ /* ════════════════════════════════════════════════════════════════════ */
352
+ /* SECTION: zonediff.ts — the planned change set (the second seat). */
353
+ /* ════════════════════════════════════════════════════════════════════ */
354
+
355
+ // # zonediff — the planned change set between two versions of a zone, housed at the argan root.
356
+ // Library-grade module shipped inside the published packages (npm, maven with the Spring
357
+ // Boot rider): it owns the domain types and pure, generic helpers only — zero consumer
358
+ // data. The shape follows the plan discipline of external-dns (the kubernetes-sigs
359
+ // planner): records group by owner name and type, the targets compare as a set (round
360
+ // robin order never produces a change) and an unconfigured TTL never triggers an update.
361
+ // Wire: the Sol zone pages serve the verbatim RecordSetRow tables of argan.ts; this
362
+ // module parses those same lines into normalized records, diffs them and suggests the
363
+ // next YYYYMMDDNN serial through argan.nextSerial — a publication never hardcodes a row.
364
+
365
+ /** One normalized zone record (owner name, type, ttl and rdata). */
366
+ export interface ZoneRecord {
367
+ /** absolute owner name, lowercase, trailing dot kept ("www.example.com.") */
368
+ name: string;
369
+ /** record type, uppercase ("A", "NS", "CNAME") */
370
+ type: string;
371
+ /** time to live in seconds; undefined inherits the zone default and never triggers an update */
372
+ ttl?: number;
373
+ /** normalized rdata: name tokens absolute and lowercase, the rest verbatim */
374
+ data: string;
375
+ }
376
+
377
+ /** One planned change of the diff (the external-dns create/update/delete vocabulary). */
378
+ export interface RecordChange {
379
+ action: "create" | "delete" | "update";
380
+ name: string;
381
+ type: string;
382
+ /** the record as it exists in the current version (absent for creates) */
383
+ before?: ZoneRecord;
384
+ /** the record as planned in the desired version (absent for deletes) */
385
+ after?: ZoneRecord;
386
+ /** what made the row a change: the target set or only the ttl */
387
+ reason: "targets" | "ttl";
388
+ }
389
+
390
+ /** The planned change set between two versions of one zone. */
391
+ export interface ZoneDiff {
392
+ creates: RecordChange[];
393
+ deletes: RecordChange[];
394
+ updates: RecordChange[];
395
+ }
396
+
397
+ /** Error raised by the zone-file parser for lines it cannot read (carries the line number). */
398
+ export class ZoneDiffError extends Error {
399
+ /** the 1-based line number the parser choked on */
400
+ readonly line: number;
401
+
402
+ constructor(message: string, line: number, options?: { cause?: unknown }) {
403
+ super(message, options);
404
+ this.name = "ZoneDiffError";
405
+ this.line = line;
406
+ }
407
+ }
408
+
409
+ /** The rdata fields of each name-bearing type that hold domain names (field indexes). */
410
+ const NAMEFIELDS: Record<string, readonly number[]> = {
411
+ NS: [0],
412
+ CNAME: [0],
413
+ PTR: [0],
414
+ DNAME: [0],
415
+ MX: [1],
416
+ SRV: [3],
417
+ SOA: [0, 1],
418
+ };
419
+
420
+ /** TTL unit multipliers for the RFC 2308 spellings ("1h30m"). */
421
+ const TTLUNITS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
422
+
423
+ /** Parses a TTL token: plain seconds ("300") or unit spellings ("1h30m"). */
424
+ function parseTtl(token: string): number {
425
+ if (/^\d+$/.test(token)) return Number.parseInt(token, 10);
426
+ const lower = token.toLowerCase();
427
+ const units = lower.match(/\d+[smhdw]/g);
428
+ if (!units || units.join("") !== lower) return Number.NaN;
429
+ return units.reduce((total, part) => total + Number.parseInt(part, 10) * TTLUNITS[part.slice(-1)], 0);
430
+ }
431
+
432
+ /** Whether the token reads as a TTL (plain seconds or unit spellings). */
433
+ function isTtlToken(token: string): boolean {
434
+ return /^\d+[smhdw]*$/i.test(token) && !Number.isNaN(parseTtl(token));
435
+ }
436
+
437
+ /** Makes a record name absolute and lowercase against the zone origin ("@" is the apex). */
438
+ function absolutize(name: string, origin: string): string {
439
+ const lower = name.toLowerCase();
440
+ if (lower === "@") return origin;
441
+ if (lower.endsWith(".")) return lower;
442
+ return `${lower}.${origin}`;
443
+ }
444
+
445
+ /** Normalizes the rdata of one record: the name fields absolute and lowercase, the rest verbatim. */
446
+ function normalizeData(type: string, data: string, origin: string): string {
447
+ const fields = data.split(/\s+/);
448
+ const nameFields = NAMEFIELDS[type];
449
+ if (nameFields) {
450
+ for (const index of nameFields) {
451
+ const token = fields[index];
452
+ if (token && token !== "@" && !/^\d+$/.test(token)) fields[index] = absolutize(token, origin);
453
+ else if (token === "@") fields[index] = origin;
454
+ }
455
+ }
456
+ return fields.join(" ");
457
+ }
458
+
459
+ /** Splits one zone-file line into its tokens, honoring quoted strings and `;` comments. */
460
+ function tokenize(line: string): string[] {
461
+ const tokens: string[] = [];
462
+ let current = "";
463
+ let quoted = false;
464
+ for (const char of line) {
465
+ if (char === '"') {
466
+ quoted = !quoted;
467
+ continue;
468
+ }
469
+ if (char === ";" && !quoted) break;
470
+ if (!quoted && /\s/.test(char)) {
471
+ if (current) tokens.push(current);
472
+ current = "";
473
+ continue;
474
+ }
475
+ current += char;
476
+ }
477
+ if (current) tokens.push(current);
478
+ return tokens;
479
+ }
480
+
481
+ /**
482
+ * Parses a master zone file (RFC 1035 §5.1) into normalized records. Honored syntax:
483
+ * comments and blank lines are dropped, `$ORIGIN` and `$TTL` directives, `@` as the
484
+ * apex, owner inheritance on lines that start with whitespace, parenthesized
485
+ * continuations joined into one record and the `[ttl] [class]` / `[class] [ttl]`
486
+ * header orders. Quoted strings keep their inner spaces (presentation quotes are
487
+ * stripped). Throws `ZoneDiffError` with the offending line number.
488
+ *
489
+ * @param text the zone file text (current or desired version).
490
+ * @param defaultOrigin the zone origin when the file declares none ("example.com.").
491
+ * @param defaultTtl the TTL applied to records without one (a `$TTL` directive wins).
492
+ * @returns the normalized records in file order.
493
+ */
494
+ export function parseZoneRecords(text: string, defaultOrigin: string, defaultTtl?: number): ZoneRecord[] {
495
+ const bare = defaultOrigin.toLowerCase();
496
+ const origin = bare.endsWith(".") ? bare : `${bare}.`;
497
+ const records: ZoneRecord[] = [];
498
+ let zoneTtl = defaultTtl;
499
+ let originNow = origin;
500
+ let owner = origin;
501
+ let pending: { tokens: string[]; line: number; inheritsOwner: boolean } | null = null;
502
+ const lines = text.split(/\r?\n/);
503
+
504
+ for (let index = 0; index < lines.length; index++) {
505
+ const raw = lines[index];
506
+ const number = index + 1;
507
+ const tokens = tokenize(raw);
508
+ if (pending) {
509
+ const closing = tokens.indexOf(")");
510
+ pending.tokens.push(...(closing === -1 ? tokens : tokens.slice(0, closing)));
511
+ if (closing === -1) continue;
512
+ emitRecord(pending.tokens, pending.line, pending.inheritsOwner);
513
+ pending = null;
514
+ continue;
515
+ }
516
+ if (tokens.length === 0) continue;
517
+ const directive = tokens[0].toUpperCase();
518
+ if (directive === "$ORIGIN") {
519
+ if (!tokens[1]) throw new ZoneDiffError("zonediff: $ORIGIN needs a name", number);
520
+ const declared = tokens[1].toLowerCase();
521
+ originNow = declared.endsWith(".") ? declared : `${declared}.`;
522
+ continue;
523
+ }
524
+ if (directive === "$TTL") {
525
+ zoneTtl = parseTtl(tokens[1] ?? "");
526
+ if (Number.isNaN(zoneTtl)) throw new ZoneDiffError(`zonediff: unreadable $TTL "${tokens[1]}"`, number);
527
+ continue;
528
+ }
529
+ if (directive === "$INCLUDE" || directive === "$GENERATE") {
530
+ throw new ZoneDiffError(`zonediff: ${directive} directives are not supported`, number);
531
+ }
532
+ const opening = tokens.indexOf("(");
533
+ if (opening !== -1) {
534
+ pending = { tokens: tokens.slice(0, opening), line: number, inheritsOwner: /^[ \t]/.test(raw) };
535
+ continue;
536
+ }
537
+ emitRecord(tokens, number, /^[ \t]/.test(raw));
538
+ }
539
+ if (pending) throw new ZoneDiffError("zonediff: unclosed parenthesis at end of file", pending.line);
540
+
541
+ return records;
542
+
543
+ /** Emits one parsed record from a complete token row (header, then rdata). */
544
+ function emitRecord(tokens: string[], number: number, inheritsOwner: boolean): void {
545
+ let cursor = 0;
546
+ if (!inheritsOwner) {
547
+ owner = absolutize(tokens[0], originNow);
548
+ cursor = 1;
549
+ }
550
+ let ttl: number | undefined;
551
+ for (let guard = 0; guard < 2 && cursor < tokens.length; guard++) {
552
+ const token = tokens[cursor];
553
+ if (ttl === undefined && isTtlToken(token)) {
554
+ ttl = parseTtl(token);
555
+ cursor += 1;
556
+ continue;
557
+ }
558
+ if (token.toUpperCase() === "IN") {
559
+ cursor += 1;
560
+ continue;
561
+ }
562
+ break;
563
+ }
564
+ const type = (tokens[cursor] ?? "").toUpperCase();
565
+ if (!/^[A-Z]{1,10}$/.test(type))
566
+ throw new ZoneDiffError(`zonediff: unreadable record type "${tokens[cursor]}"`, number);
567
+ cursor += 1;
568
+ const data = normalizeData(type, tokens.slice(cursor).join(" "), originNow);
569
+ if (!data) throw new ZoneDiffError(`zonediff: ${type} record without rdata`, number);
570
+ records.push({ name: owner, type, ttl: ttl ?? zoneTtl, data });
571
+ }
572
+ }
573
+
574
+ /** Groups records by "name type" — the external-dns plan key. */
575
+ function groupRecords(records: readonly ZoneRecord[]): Map<string, ZoneRecord[]> {
576
+ const groups = new Map<string, ZoneRecord[]>();
577
+ for (const record of records) {
578
+ const key = `${record.name} ${record.type}`;
579
+ const group = groups.get(key);
580
+ if (group) group.push(record);
581
+ else groups.set(key, [record]);
582
+ }
583
+ return groups;
584
+ }
585
+
586
+ /** The sorted, de-duplicated rdata set of one record group (round robin order erased). */
587
+ function targetSet(group: readonly ZoneRecord[]): string[] {
588
+ return [...new Set(group.map((record) => record.data))].sort();
589
+ }
590
+
591
+ /** Picks the representative record of a group (lowest ttl first, then lowest data). */
592
+ function pickPrimary(group: readonly ZoneRecord[]): ZoneRecord {
593
+ return [...group].sort((left, right) => (left.ttl ?? 0) - (right.ttl ?? 0) || left.data.localeCompare(right.data))[0];
594
+ }
595
+
596
+ /**
597
+ * Diffs the current version of a zone against the desired version. Records are
598
+ * normalized first (names absolute and lowercase, ttl numeric), so identical rows
599
+ * diff to an empty set. The change vocabulary follows external-dns: creates,
600
+ * deletes and updates — an update carries both sides and its reason (targets or
601
+ * ttl). A desired ttl of undefined inherits the zone default and never updates.
602
+ *
603
+ * @param current the records the zone serves now.
604
+ * @param desired the records the zone should serve.
605
+ * @returns the planned change set (empty arrays when the versions agree).
606
+ */
607
+ export function diffZones(current: readonly ZoneRecord[], desired: readonly ZoneRecord[]): ZoneDiff {
608
+ const now = groupRecords(current);
609
+ const next = groupRecords(desired);
610
+ const diff: ZoneDiff = { creates: [], deletes: [], updates: [] };
611
+ for (const [key, nextGroup] of next) {
612
+ const nowGroup = now.get(key);
613
+ if (!nowGroup) {
614
+ for (const after of nextGroup)
615
+ diff.creates.push({ action: "create", name: after.name, type: after.type, after, reason: "targets" });
616
+ continue;
617
+ }
618
+ const before = pickPrimary(nowGroup);
619
+ const after = pickPrimary(nextGroup);
620
+ const targetsDiffer = targetSet(nowGroup).join("\u0000") !== targetSet(nextGroup).join("\u0000");
621
+ if (targetsDiffer) {
622
+ diff.updates.push({ action: "update", name: before.name, type: before.type, before, after, reason: "targets" });
623
+ continue;
624
+ }
625
+ if (after.ttl !== undefined && before.ttl !== after.ttl) {
626
+ diff.updates.push({ action: "update", name: before.name, type: before.type, before, after, reason: "ttl" });
627
+ }
628
+ }
629
+ for (const [key, nowGroup] of now) {
630
+ if (next.has(key)) continue;
631
+ for (const before of nowGroup)
632
+ diff.deletes.push({ action: "delete", name: before.name, type: before.type, before, reason: "targets" });
633
+ }
634
+ return diff;
635
+ }
636
+
637
+ /**
638
+ * Parses two verbatim zone files and diffs them in one call — the convenience over
639
+ * {@link parseZoneRecords} + {@link diffZones}.
640
+ *
641
+ * @param currentText the zone file text the server answers now.
642
+ * @param desiredText the reviewed zone file text to publish.
643
+ * @param origin the zone origin ("example.com.").
644
+ * @returns the planned change set.
645
+ */
646
+ export function diffZoneFiles(currentText: string, desiredText: string, origin: string): ZoneDiff {
647
+ return diffZones(parseZoneRecords(currentText, origin), parseZoneRecords(desiredText, origin));
648
+ }
649
+
650
+ /**
651
+ * Parses the verbatim zone rows the Sol zone pages serve (the `RecordSetRow` tables
652
+ * of argan.ts) into normalized records — the bridge from the rendered zone table to
653
+ * the diff. Record and directive rows are parsed verbatim; blank rows are dropped.
654
+ *
655
+ * @param rows the record-set rows (record, directive and blank rows mixed).
656
+ * @param origin the zone origin ("example.com.").
657
+ * @returns the normalized records of the rows.
658
+ */
659
+ export function recordsFromRows(rows: readonly RecordSetRow[], origin: string): ZoneRecord[] {
660
+ const text = rows
661
+ .filter((row) => row.kind !== "blank")
662
+ .map((row) => row.line)
663
+ .join("\n");
664
+ return parseZoneRecords(text, origin);
665
+ }
666
+
667
+ /**
668
+ * Suggests the zone serial for a publication: an empty diff keeps the serial
669
+ * untouched, any planned change bumps the YYYYMMDDNN revision through
670
+ * argan.nextSerial.
671
+ *
672
+ * @param diff the planned change set.
673
+ * @param serial the serial the zone carries now.
674
+ * @returns the serial to publish.
675
+ */
676
+ export function serialForPublication(diff: ZoneDiff, serial: string): string {
677
+ const empty = diff.creates.length === 0 && diff.deletes.length === 0 && diff.updates.length === 0;
678
+ return empty ? serial : nextSerial(serial);
679
+ }
680
+
681
+ /* ════════════════════════════════════════════════════════════════════ */
682
+ /* SECTION: cleanurl.ts — the clean URL bar (the third seat). */
683
+ /* ════════════════════════════════════════════════════════════════════ */
684
+
685
+ // the argan unit of the family.
686
+ // # cleanurl — the clean URL bar of the family, typed for the SPA
687
+ // Hash routes become paths, index.html and duplicate slashes disappear, campaign
688
+ // trackers are stripped with replaceState (no reload, no history pollution) and the
689
+ // canonical link stays in sync. Navigation itself is handled by wouter Link, so no
690
+ // click interception and no reload binding live here.
691
+
692
+ const HASH_ROUTE = /^#\/?/;
693
+ const BASENAME = /(^|\/)index\.html?$/;
694
+ const TRACKERS: readonly RegExp[] = [
695
+ /^(utm_|gclid|fbclid|msclkid|dclid|mc_|ref_?src$)/i,
696
+ /^igshid$/i,
697
+ /^yclid$/i,
698
+ /^twclid$/i,
699
+ /^_tt/i,
700
+ /^srsltid$/i,
701
+ ];
702
+
703
+ function isTracker(key: string): boolean {
704
+ return TRACKERS.some((pattern) => pattern.test(key));
705
+ }
706
+
707
+ /** Builds the clean form of a url of this site; foreign origins come back untouched. */
708
+ export function cleanUrl(input: string, origin: string = window.location.origin): string {
709
+ let url: URL;
710
+ try {
711
+ url = new URL(input, origin);
712
+ } catch {
713
+ return input;
714
+ }
715
+ if (url.origin !== origin) return input;
716
+
717
+ let out = url;
718
+
719
+ // 1) a route in the hash becomes a real path: /#/settings -> /settings
720
+ if (HASH_ROUTE.test(out.hash)) {
721
+ const path = out.hash.replace(HASH_ROUTE, "/");
722
+ out = new URL(path + out.search, origin);
723
+ } else if (out.hash) {
724
+ out = new URL(out.pathname + out.search, origin);
725
+ }
726
+
727
+ // 2) index.html and duplicated slashes disappear
728
+ out.pathname = out.pathname.replace(BASENAME, "/").replace(/\/{2,}/g, "/");
729
+
730
+ // 3) campaign trackers leave the url
731
+ let hadTrackers = false;
732
+ Array.from(out.searchParams.keys()).forEach((key) => {
733
+ if (isTracker(key)) {
734
+ out.searchParams.delete(key);
735
+ hadTrackers = true;
736
+ }
737
+ });
738
+ if (hadTrackers) out.search = out.searchParams.toString();
739
+
740
+ return out.toString();
741
+ }
742
+
743
+ /** Normalizes the current address without reloading or polluting the history. */
744
+ export function applyNow(): boolean {
745
+ const cleaned = cleanUrl(window.location.href);
746
+ if (cleaned === window.location.href) return false;
747
+ window.history.replaceState(window.history.state, "", cleaned);
748
+ return true;
749
+ }
750
+
751
+ /** Keeps <link rel=canonical> and og:url in sync with the clean url (SEO, sharing). */
752
+ export function syncCanonical(): void {
753
+ const canonical = document.querySelector('link[rel="canonical"]');
754
+ canonical?.setAttribute("href", window.location.origin + window.location.pathname);
755
+ const og = document.querySelector('meta[property="og:url"]');
756
+ og?.setAttribute("content", window.location.origin + window.location.pathname);
757
+ }
758
+
759
+ /* ════════════════════════════════════════════════════════════════════ */
760
+ /* SECTION: protocols.ts — the protocol mesh (the fourth seat). */
761
+ /* ════════════════════════════════════════════════════════════════════ */
762
+
763
+ // # protocols — the protocol mesh of the family gateway, housed at the argan root.
764
+ // Library-grade module shipped inside the published packages (npm, maven with the Spring
765
+ // Boot rider): it owns the registry rows and pure, generic helpers only — zero consumer
766
+ // data. The mesh lists every wire the gateway speaks, grouped by family keyword: the web
767
+ // edge (http versions, websockets, webtransport), the api dialects that ride it, the mail,
768
+ // dns, file, remote, messaging, stream, tunnel, overlay, data, ops, directory and legacy
769
+ // families. Every row names its governing spec and its security posture — encrypted
770
+ // (protocol-native or TLS cryptography), opportunistic (STARTTLS or TLS-on-demand) or
771
+ // cleartext — and the helper layer answers lookups, group filters and the tls-capable
772
+ // count the owner law pins above one hundred.
773
+
774
+ /** The hierarchical family keyword every protocol row carries (the pane groups). */
775
+ export type ProtocolGroup =
776
+ | "web"
777
+ | "api"
778
+ | "collab"
779
+ | "mail"
780
+ | "dns"
781
+ | "file"
782
+ | "remote"
783
+ | "messaging"
784
+ | "stream"
785
+ | "tunnel"
786
+ | "overlay"
787
+ | "data"
788
+ | "ops"
789
+ | "directory"
790
+ | "legacy";
791
+
792
+ /** The security posture of the wire (encrypted covers TLS and protocol-native crypto). */
793
+ export type ProtocolSecurity = "encrypted" | "opportunistic" | "cleartext";
794
+
795
+ /** One row of the gateway protocol mesh: the scheme, its port, spec, family and state. */
796
+ export interface ProtocolSpec {
797
+ /** the scheme the gateway registers, lowercase ("https", "wss", "webtransport") */
798
+ scheme: string;
799
+ /** the display name of the protocol */
800
+ name: string;
801
+ /** the default port of the wire ("—" when the protocol negotiates its own) */
802
+ port: string;
803
+ /** the governing spec or reference of the protocol */
804
+ spec: string;
805
+ /** the hierarchical family the protocol belongs to */
806
+ group: ProtocolGroup;
807
+ /** the security posture of the wire */
808
+ security: ProtocolSecurity;
809
+ /** the state of the listener on the gateway ("live", "preferred", "planned", "opt-in", "legacy") */
810
+ state: string;
811
+ }
812
+
813
+ /**
814
+ * The mesh itself: 231 rows grouped by family keyword, alphabetical inside every group.
815
+ * The web family leads — it is the https edge the whole api surface rides — and the dns
816
+ * family carries the argan home transports the gateway already answers today.
817
+ */
818
+ export const PROTOCOLS: readonly ProtocolSpec[] = [
819
+ /** ## web — the https edge: document and transport protocols of the gateway. */
820
+ { scheme: "coap", name: "CoAP", port: "5683/UDP", spec: "RFC 7252", group: "web", security: "cleartext", state: "opt-in" },
821
+ { scheme: "coaps", name: "CoAP over DTLS", port: "5684/UDP", spec: "RFC 7252 · 6347", group: "web", security: "encrypted", state: "opt-in" },
822
+ { scheme: "gemini", name: "Gemini", port: "1965", spec: "the gemini spec", group: "web", security: "encrypted", state: "opt-in" },
823
+ { scheme: "graphql-ws", name: "GraphQL subscriptions over wss", port: "443", spec: "the graphql-transport-ws spec", group: "web", security: "encrypted", state: "live" },
824
+ { scheme: "h2", name: "HTTP/2 over TLS", port: "443", spec: "RFC 9113", group: "web", security: "encrypted", state: "live" },
825
+ { scheme: "h2c", name: "HTTP/2 cleartext upgrade", port: "80", spec: "RFC 9113", group: "web", security: "cleartext", state: "opt-in" },
826
+ { scheme: "h3", name: "HTTP/3 over QUIC", port: "443/UDP", spec: "RFC 9114", group: "web", security: "encrypted", state: "live" },
827
+ { scheme: "http", name: "HTTP/1.1", port: "80", spec: "RFC 9110 · 9112", group: "web", security: "cleartext", state: "live" },
828
+ { scheme: "https", name: "HTTP/1.1 over TLS", port: "443", spec: "RFC 9110 · 8446", group: "web", security: "encrypted", state: "live" },
829
+ { scheme: "longpoll", name: "HTTP long polling", port: "80/443", spec: "the keep-alive pattern", group: "web", security: "opportunistic", state: "live" },
830
+ { scheme: "spdy", name: "SPDY/3.1", port: "443", spec: "the spdy draft", group: "web", security: "encrypted", state: "legacy" },
831
+ { scheme: "sse", name: "Server-Sent Events", port: "443", spec: "text/event-stream · WHATWG", group: "web", security: "encrypted", state: "live" },
832
+ { scheme: "webcodecs", name: "WebCodecs", port: "—", spec: "W3C WebCodecs", group: "web", security: "encrypted", state: "live" },
833
+ { scheme: "webtransport", name: "WebTransport", port: "443/UDP", spec: "W3C WebTransport · RFC 9114", group: "web", security: "encrypted", state: "live" },
834
+ { scheme: "webrtc", name: "WebRTC (DTLS-SRTP)", port: "ephemeral/UDP", spec: "W3C WebRTC 1.0 · RFC 8825", group: "web", security: "encrypted", state: "live" },
835
+ { scheme: "ws", name: "WebSocket", port: "80", spec: "RFC 6455", group: "web", security: "cleartext", state: "live" },
836
+ { scheme: "wss", name: "WebSocket Secure", port: "443", spec: "RFC 6455", group: "web", security: "encrypted", state: "live" },
837
+
838
+ /** ## api — the rpc and api dialects riding the https edge. */
839
+ { scheme: "acme", name: "ACME certificate orders", port: "443", spec: "RFC 8555", group: "api", security: "encrypted", state: "live" },
840
+ { scheme: "activitypub", name: "ActivityPub", port: "443", spec: "W3C ActivityPub", group: "api", security: "encrypted", state: "live" },
841
+ { scheme: "atproto", name: "AT Protocol", port: "443", spec: "the atproto spec", group: "api", security: "encrypted", state: "opt-in" },
842
+ { scheme: "capnproto", name: "Cap'n Proto RPC", port: "—", spec: "the cap'n proto rpc spec", group: "api", security: "opportunistic", state: "opt-in" },
843
+ { scheme: "certificate-transparency", name: "Certificate Transparency", port: "443", spec: "RFC 6962", group: "api", security: "opportunistic", state: "live" },
844
+ { scheme: "grpc", name: "gRPC over HTTP/2", port: "50051", spec: "the grpc spec", group: "api", security: "opportunistic", state: "live" },
845
+ { scheme: "grpcs", name: "gRPC over TLS", port: "443", spec: "the grpc spec · RFC 8446", group: "api", security: "encrypted", state: "live" },
846
+ { scheme: "grpc-web", name: "gRPC-Web", port: "443", spec: "the grpc-web spec", group: "api", security: "encrypted", state: "live" },
847
+ { scheme: "jsonrpc", name: "JSON-RPC 2.0", port: "80/443", spec: "the json-rpc 2.0 spec", group: "api", security: "opportunistic", state: "live" },
848
+ { scheme: "matrix", name: "Matrix federation", port: "8448", spec: "the matrix spec", group: "api", security: "encrypted", state: "live" },
849
+ { scheme: "micropub", name: "Micropub", port: "443", spec: "W3C Micropub", group: "api", security: "encrypted", state: "opt-in" },
850
+ { scheme: "msgpack-rpc", name: "MessagePack-RPC", port: "—", spec: "the msgpack-rpc spec", group: "api", security: "opportunistic", state: "opt-in" },
851
+ { scheme: "oembed", name: "oEmbed", port: "443", spec: "the oembed 1.0 spec", group: "api", security: "encrypted", state: "live" },
852
+ { scheme: "ohttp", name: "Oblivious HTTP", port: "443", spec: "RFC 9458", group: "api", security: "encrypted", state: "opt-in" },
853
+ { scheme: "ocsp", name: "OCSP", port: "80/443", spec: "RFC 6960", group: "api", security: "opportunistic", state: "live" },
854
+ { scheme: "openid-connect", name: "OpenID Connect", port: "443", spec: "the oidc core 1.0", group: "api", security: "encrypted", state: "live" },
855
+ { scheme: "pingback", name: "Pingback", port: "80/443", spec: "the pingback 1.0 spec", group: "api", security: "opportunistic", state: "legacy" },
856
+ { scheme: "soap", name: "SOAP 1.2", port: "443", spec: "W3C SOAP 1.2", group: "api", security: "opportunistic", state: "live" },
857
+ { scheme: "thrift", name: "Apache Thrift RPC", port: "9090", spec: "the thrift protocol", group: "api", security: "opportunistic", state: "live" },
858
+ { scheme: "webfinger", name: "WebFinger", port: "443", spec: "RFC 7033", group: "api", security: "encrypted", state: "live" },
859
+ { scheme: "webmention", name: "Webmention", port: "443", spec: "W3C Webmention", group: "api", security: "encrypted", state: "opt-in" },
860
+ { scheme: "webpush", name: "Web Push", port: "443", spec: "RFC 8030 · 8291", group: "api", security: "encrypted", state: "live" },
861
+ { scheme: "xmlrpc", name: "XML-RPC", port: "80/443", spec: "the xml-rpc spec", group: "api", security: "opportunistic", state: "live" },
862
+
863
+ /** ## collab — the calendaring and pim dialects over http. */
864
+ { scheme: "caldav", name: "CalDAV", port: "80/443", spec: "RFC 4791", group: "collab", security: "opportunistic", state: "live" },
865
+ { scheme: "carddav", name: "CardDAV", port: "80/443", spec: "RFC 6352", group: "collab", security: "opportunistic", state: "live" },
866
+ { scheme: "webdav", name: "WebDAV", port: "80/443", spec: "RFC 4918", group: "collab", security: "opportunistic", state: "live" },
867
+ { scheme: "webdavs", name: "WebDAV over TLS", port: "443", spec: "RFC 4918 · 8446", group: "collab", security: "encrypted", state: "live" },
868
+
869
+ /** ## mail — the store-and-forward family. */
870
+ { scheme: "imap", name: "IMAP4rev2", port: "143", spec: "RFC 9051", group: "mail", security: "opportunistic", state: "live" },
871
+ { scheme: "imaps", name: "IMAP over TLS", port: "993", spec: "RFC 8314", group: "mail", security: "encrypted", state: "live" },
872
+ { scheme: "jmap", name: "JMAP", port: "443", spec: "RFC 8620 · 8621", group: "mail", security: "encrypted", state: "live" },
873
+ { scheme: "lmtp", name: "LMTP", port: "—", spec: "RFC 2033", group: "mail", security: "opportunistic", state: "live" },
874
+ { scheme: "pop3", name: "POP3", port: "110", spec: "RFC 1939", group: "mail", security: "opportunistic", state: "live" },
875
+ { scheme: "pop3s", name: "POP3 over TLS", port: "995", spec: "RFC 8314", group: "mail", security: "encrypted", state: "live" },
876
+ { scheme: "sieve", name: "ManageSieve", port: "4190", spec: "RFC 5804", group: "mail", security: "opportunistic", state: "opt-in" },
877
+ { scheme: "smtp", name: "SMTP relay", port: "25", spec: "RFC 5321", group: "mail", security: "cleartext", state: "live" },
878
+ { scheme: "smtps", name: "SMTP over implicit TLS", port: "465", spec: "RFC 8314", group: "mail", security: "encrypted", state: "live" },
879
+ { scheme: "submission", name: "Mail submission (STARTTLS)", port: "587", spec: "RFC 6409", group: "mail", security: "opportunistic", state: "live" },
880
+
881
+ /** ## dns — the name resolution family (the argan home). */
882
+ { scheme: "axfr", name: "AXFR full zone transfer", port: "53", spec: "RFC 5936", group: "dns", security: "cleartext", state: "live" },
883
+ { scheme: "dns", name: "Classic DNS (UDP + TCP)", port: "53", spec: "RFC 1035 · 7766", group: "dns", security: "cleartext", state: "live" },
884
+ { scheme: "dnscrypt", name: "DNSCrypt v2", port: "443", spec: "the dnscrypt v2 spec", group: "dns", security: "encrypted", state: "opt-in" },
885
+ { scheme: "dns-sd", name: "DNS-SD", port: "5353/UDP", spec: "RFC 6763", group: "dns", security: "cleartext", state: "live" },
886
+ { scheme: "doh", name: "DNS over HTTPS", port: "443", spec: "RFC 8484", group: "dns", security: "encrypted", state: "preferred" },
887
+ { scheme: "doh-json", name: "DoH JSON (/resolve)", port: "443", spec: "the json resolver draft", group: "dns", security: "encrypted", state: "panel · debug" },
888
+ { scheme: "doq", name: "DNS over QUIC", port: "853/UDP", spec: "RFC 9250", group: "dns", security: "encrypted", state: "planned" },
889
+ { scheme: "dot", name: "DNS over TLS", port: "853", spec: "RFC 7858 · 8310", group: "dns", security: "encrypted", state: "live" },
890
+ { scheme: "ixfr", name: "IXFR incremental transfer", port: "53", spec: "RFC 1995", group: "dns", security: "cleartext", state: "live" },
891
+ { scheme: "llmnr", name: "LLMNR", port: "5355/UDP", spec: "RFC 4795", group: "dns", security: "cleartext", state: "legacy" },
892
+ { scheme: "mdns", name: "Multicast DNS", port: "5353/UDP", spec: "RFC 6762", group: "dns", security: "cleartext", state: "live" },
893
+ { scheme: "odoh", name: "Oblivious DoH", port: "443", spec: "RFC 9230", group: "dns", security: "encrypted", state: "planned" },
894
+
895
+ /** ## file — the transfer and filesystem family. */
896
+ { scheme: "9p", name: "Plan 9 filesystem", port: "564", spec: "9P2000", group: "file", security: "cleartext", state: "opt-in" },
897
+ { scheme: "afs", name: "AFS", port: "7000", spec: "the rx protocol", group: "file", security: "cleartext", state: "legacy" },
898
+ { scheme: "cifs", name: "CIFS", port: "445", spec: "MS-CIFS", group: "file", security: "opportunistic", state: "legacy" },
899
+ { scheme: "ftp", name: "FTP", port: "21", spec: "RFC 959", group: "file", security: "cleartext", state: "legacy" },
900
+ { scheme: "ftp-data", name: "FTP data channel", port: "20", spec: "RFC 959", group: "file", security: "cleartext", state: "legacy" },
901
+ { scheme: "ftps", name: "FTP over TLS", port: "990", spec: "RFC 4217", group: "file", security: "encrypted", state: "opt-in" },
902
+ { scheme: "git", name: "Git native protocol", port: "9418", spec: "the git protocol v2", group: "file", security: "cleartext", state: "live" },
903
+ { scheme: "git-http", name: "Git smart HTTP", port: "80/443", spec: "the git http-protocol", group: "file", security: "opportunistic", state: "live" },
904
+ { scheme: "hg", name: "Mercurial wire", port: "8000", spec: "the mercurial wire protocol", group: "file", security: "cleartext", state: "legacy" },
905
+ { scheme: "nfs", name: "NFSv4.2", port: "2049", spec: "RFC 7862", group: "file", security: "cleartext", state: "live" },
906
+ { scheme: "rsync", name: "rsync delta transfer", port: "873", spec: "the rsync protocol", group: "file", security: "cleartext", state: "live" },
907
+ { scheme: "scp", name: "SCP", port: "22", spec: "the scp dialect", group: "file", security: "encrypted", state: "legacy" },
908
+ { scheme: "sftp", name: "SFTP", port: "22", spec: "the sftp v3 dialect", group: "file", security: "encrypted", state: "live" },
909
+ { scheme: "smb", name: "SMB", port: "445", spec: "MS-SMB2", group: "file", security: "opportunistic", state: "live" },
910
+ { scheme: "svn", name: "Subversion svn://", port: "3690", spec: "the ra_svn protocol", group: "file", security: "opportunistic", state: "legacy" },
911
+ { scheme: "tftp", name: "TFTP", port: "69/UDP", spec: "RFC 1350", group: "file", security: "cleartext", state: "opt-in" },
912
+
913
+ /** ## remote — the remote seat family. */
914
+ { scheme: "mosh", name: "Mosh roaming shell", port: "60000/UDP", spec: "the mosh protocol", group: "remote", security: "encrypted", state: "live" },
915
+ { scheme: "rdp", name: "RDP", port: "3389", spec: "MS-RDPBCGR", group: "remote", security: "encrypted", state: "live" },
916
+ { scheme: "rlogin", name: "rlogin", port: "513", spec: "RFC 1282", group: "remote", security: "cleartext", state: "legacy" },
917
+ { scheme: "rsh", name: "rsh", port: "514", spec: "the berkeley rsh", group: "remote", security: "cleartext", state: "legacy" },
918
+ { scheme: "spice", name: "SPICE", port: "5900+", spec: "the spice protocol", group: "remote", security: "opportunistic", state: "opt-in" },
919
+ { scheme: "ssh", name: "SSH", port: "22", spec: "RFC 4253", group: "remote", security: "encrypted", state: "live" },
920
+ { scheme: "telnet", name: "Telnet", port: "23", spec: "RFC 854", group: "remote", security: "cleartext", state: "legacy" },
921
+ { scheme: "vnc", name: "VNC (RFB)", port: "5900", spec: "the rfb protocol", group: "remote", security: "opportunistic", state: "live" },
922
+ { scheme: "x11", name: "X11", port: "6000", spec: "the x11 protocol", group: "remote", security: "cleartext", state: "legacy" },
923
+
924
+ /** ## messaging — the pubsub, im and signaling family. */
925
+ { scheme: "amqp", name: "AMQP 1.0", port: "5672", spec: "OASIS AMQP 1.0", group: "messaging", security: "cleartext", state: "live" },
926
+ { scheme: "amqps", name: "AMQP over TLS", port: "5671", spec: "OASIS AMQP 1.0", group: "messaging", security: "encrypted", state: "live" },
927
+ { scheme: "h323", name: "H.323", port: "1720", spec: "ITU-T H.323", group: "messaging", security: "opportunistic", state: "legacy" },
928
+ { scheme: "iax", name: "IAX2", port: "4569/UDP", spec: "the iax2 protocol", group: "messaging", security: "cleartext", state: "opt-in" },
929
+ { scheme: "irc", name: "IRC", port: "6667", spec: "RFC 1459 · 2812", group: "messaging", security: "cleartext", state: "live" },
930
+ { scheme: "ircs", name: "IRC over TLS", port: "6697", spec: "RFC 7194", group: "messaging", security: "encrypted", state: "live" },
931
+ { scheme: "kafka", name: "Kafka log protocol", port: "9092", spec: "the kafka protocol", group: "messaging", security: "opportunistic", state: "live" },
932
+ { scheme: "mgcp", name: "MGCP", port: "2427/UDP", spec: "RFC 3435", group: "messaging", security: "cleartext", state: "legacy" },
933
+ { scheme: "mqtt", name: "MQTT 5", port: "1883", spec: "the mqtt 5.0 spec", group: "messaging", security: "cleartext", state: "live" },
934
+ { scheme: "mqtt-sn", name: "MQTT-SN", port: "1884/UDP", spec: "the mqtt-sn 1.2 spec", group: "messaging", security: "cleartext", state: "opt-in" },
935
+ { scheme: "mqtts", name: "MQTT over TLS", port: "8883", spec: "the mqtt 5.0 spec", group: "messaging", security: "encrypted", state: "live" },
936
+ { scheme: "msrp", name: "MSRP", port: "—", spec: "RFC 4975", group: "messaging", security: "opportunistic", state: "opt-in" },
937
+ { scheme: "msrps", name: "MSRP over TLS", port: "—", spec: "RFC 4975", group: "messaging", security: "encrypted", state: "opt-in" },
938
+ { scheme: "nats", name: "NATS", port: "4222", spec: "the nats protocol", group: "messaging", security: "opportunistic", state: "live" },
939
+ { scheme: "nanomsg", name: "nanomsg SP", port: "—", spec: "the nanomsg sp spec", group: "messaging", security: "opportunistic", state: "opt-in" },
940
+ { scheme: "nostr", name: "Nostr relays", port: "443", spec: "NIP-01", group: "messaging", security: "encrypted", state: "opt-in" },
941
+ { scheme: "openwire", name: "OpenWire", port: "61616", spec: "the openwire spec", group: "messaging", security: "opportunistic", state: "opt-in" },
942
+ { scheme: "sccp", name: "SCCP (Skinny)", port: "2000", spec: "the sccp protocol", group: "messaging", security: "cleartext", state: "legacy" },
943
+ { scheme: "sip", name: "SIP", port: "5060", spec: "RFC 3261", group: "messaging", security: "opportunistic", state: "live" },
944
+ { scheme: "sips", name: "SIPS", port: "5061", spec: "RFC 3261", group: "messaging", security: "encrypted", state: "live" },
945
+ { scheme: "smpp", name: "SMPP", port: "2775", spec: "the smpp 5.0 spec", group: "messaging", security: "cleartext", state: "live" },
946
+ { scheme: "stomp", name: "STOMP 1.2", port: "61613", spec: "the stomp 1.2 spec", group: "messaging", security: "opportunistic", state: "opt-in" },
947
+ { scheme: "xmpp", name: "XMPP", port: "5222", spec: "RFC 6120 · 6121", group: "messaging", security: "opportunistic", state: "live" },
948
+ { scheme: "xmpps", name: "XMPP over TLS", port: "5223", spec: "RFC 6120", group: "messaging", security: "encrypted", state: "legacy" },
949
+ { scheme: "zeromq", name: "ZeroMQ (ZMTP 3.0)", port: "—", spec: "the zmtp 3.0 spec", group: "messaging", security: "opportunistic", state: "live" },
950
+
951
+ /** ## stream — the media delivery family. */
952
+ { scheme: "dash", name: "MPEG-DASH segments", port: "80/443", spec: "the mpeg-dash spec", group: "stream", security: "opportunistic", state: "live" },
953
+ { scheme: "hls", name: "HTTP Live Streaming", port: "80/443", spec: "RFC 8216", group: "stream", security: "opportunistic", state: "live" },
954
+ { scheme: "mss", name: "Smooth Streaming", port: "80/443", spec: "MS-SSTR", group: "stream", security: "opportunistic", state: "legacy" },
955
+ { scheme: "osc", name: "Open Sound Control", port: "—", spec: "the osc 1.0 spec", group: "stream", security: "cleartext", state: "opt-in" },
956
+ { scheme: "rist", name: "RIST", port: "—", spec: "the rist profile", group: "stream", security: "opportunistic", state: "opt-in" },
957
+ { scheme: "rtcp", name: "RTCP", port: "—", spec: "RFC 3550", group: "stream", security: "cleartext", state: "live" },
958
+ { scheme: "rtmp", name: "RTMP", port: "1935", spec: "the rtmp spec", group: "stream", security: "cleartext", state: "live" },
959
+ { scheme: "rtmpe", name: "RTMPE", port: "1935", spec: "the rtmpe variant", group: "stream", security: "encrypted", state: "legacy" },
960
+ { scheme: "rtmfp", name: "RTMFP", port: "1935/UDP", spec: "the rtmfp spec", group: "stream", security: "encrypted", state: "legacy" },
961
+ { scheme: "rtmps", name: "RTMPS", port: "443", spec: "the rtmps variant", group: "stream", security: "encrypted", state: "live" },
962
+ { scheme: "rtp", name: "RTP (AVP profile)", port: "—", spec: "RFC 3550", group: "stream", security: "cleartext", state: "live" },
963
+ { scheme: "rtsp", name: "RTSP 2.0", port: "554", spec: "RFC 7826", group: "stream", security: "opportunistic", state: "live" },
964
+ { scheme: "rtsps", name: "RTSP over TLS", port: "322", spec: "RFC 8341", group: "stream", security: "encrypted", state: "live" },
965
+ { scheme: "srt", name: "SRT", port: "—", spec: "the srt protocol", group: "stream", security: "encrypted", state: "live" },
966
+ { scheme: "srtp", name: "SRTP", port: "—", spec: "RFC 3711", group: "stream", security: "encrypted", state: "live" },
967
+
968
+ /** ## tunnel — the vpn and encapsulation family. */
969
+ { scheme: "dccp", name: "DCCP", port: "—", spec: "RFC 4340", group: "tunnel", security: "cleartext", state: "legacy" },
970
+ { scheme: "dtls", name: "DTLS 1.3", port: "—", spec: "RFC 9147", group: "tunnel", security: "encrypted", state: "live" },
971
+ { scheme: "enet", name: "ENet", port: "—", spec: "the enet protocol", group: "tunnel", security: "cleartext", state: "opt-in" },
972
+ { scheme: "esp", name: "IPsec ESP", port: "50", spec: "RFC 4303", group: "tunnel", security: "encrypted", state: "live" },
973
+ { scheme: "geneve", name: "GENEVE", port: "6081/UDP", spec: "RFC 8926", group: "tunnel", security: "cleartext", state: "opt-in" },
974
+ { scheme: "gre", name: "GRE", port: "47", spec: "RFC 2784", group: "tunnel", security: "cleartext", state: "legacy" },
975
+ { scheme: "ike", name: "IKEv2", port: "500/4500", spec: "RFC 7296", group: "tunnel", security: "encrypted", state: "live" },
976
+ { scheme: "ipsec", name: "IPsec tunnel", port: "50/4500", spec: "RFC 4301", group: "tunnel", security: "encrypted", state: "live" },
977
+ { scheme: "kcp", name: "KCP", port: "—", spec: "the kcp protocol", group: "tunnel", security: "opportunistic", state: "opt-in" },
978
+ { scheme: "l2tp", name: "L2TP", port: "1701/UDP", spec: "RFC 3931", group: "tunnel", security: "cleartext", state: "legacy" },
979
+ { scheme: "masque", name: "MASQUE (CONNECT-UDP)", port: "443/UDP", spec: "RFC 9484", group: "tunnel", security: "encrypted", state: "planned" },
980
+ { scheme: "mptcp", name: "Multipath TCP", port: "—", spec: "RFC 8684", group: "tunnel", security: "cleartext", state: "opt-in" },
981
+ { scheme: "openvpn", name: "OpenVPN", port: "1194/UDP", spec: "the openvpn protocol", group: "tunnel", security: "encrypted", state: "live" },
982
+ { scheme: "pptp", name: "PPTP", port: "1723", spec: "RFC 2637", group: "tunnel", security: "opportunistic", state: "legacy" },
983
+ { scheme: "quic", name: "QUIC", port: "443/UDP", spec: "RFC 9000", group: "tunnel", security: "encrypted", state: "live" },
984
+ { scheme: "raknet", name: "RakNet", port: "—", spec: "the raknet protocol", group: "tunnel", security: "cleartext", state: "opt-in" },
985
+ { scheme: "sctp", name: "SCTP", port: "132", spec: "RFC 4960", group: "tunnel", security: "cleartext", state: "opt-in" },
986
+ { scheme: "socks4", name: "SOCKS4", port: "1080", spec: "the socks4 protocol", group: "tunnel", security: "cleartext", state: "legacy" },
987
+ { scheme: "socks5", name: "SOCKS5", port: "1080", spec: "RFC 1928", group: "tunnel", security: "opportunistic", state: "live" },
988
+ { scheme: "socks5h", name: "SOCKS5 with remote DNS", port: "1080", spec: "RFC 1928", group: "tunnel", security: "opportunistic", state: "live" },
989
+ { scheme: "tinc", name: "tinc mesh vpn", port: "655", spec: "the tinc protocol", group: "tunnel", security: "encrypted", state: "opt-in" },
990
+ { scheme: "tls", name: "TLS 1.3 record layer", port: "—", spec: "RFC 8446", group: "tunnel", security: "encrypted", state: "live" },
991
+ { scheme: "vxlan", name: "VXLAN", port: "4789/UDP", spec: "RFC 7348", group: "tunnel", security: "cleartext", state: "live" },
992
+ { scheme: "wireguard", name: "WireGuard", port: "51820/UDP", spec: "the wireguard protocol", group: "tunnel", security: "encrypted", state: "live" },
993
+ { scheme: "zerotier", name: "ZeroTier", port: "9993/UDP", spec: "the zerotier protocol", group: "tunnel", security: "encrypted", state: "opt-in" },
994
+
995
+ /** ## overlay — the decentralized naming, storage and privacy family. */
996
+ { scheme: "bittorrent", name: "BitTorrent", port: "6881", spec: "BEP 3 · BEP 10", group: "overlay", security: "opportunistic", state: "live" },
997
+ { scheme: "cjdns", name: "Cjdns", port: "—", spec: "the cjdns protocol", group: "overlay", security: "encrypted", state: "opt-in" },
998
+ { scheme: "dat", name: "Dat", port: "—", spec: "the dat protocol", group: "overlay", security: "encrypted", state: "legacy" },
999
+ { scheme: "devp2p", name: "devp2p", port: "30303/UDP", spec: "the devp2p spec", group: "overlay", security: "encrypted", state: "opt-in" },
1000
+ { scheme: "dht", name: "Mainline DHT (KRPC)", port: "6881/UDP", spec: "BEP 5", group: "overlay", security: "cleartext", state: "live" },
1001
+ { scheme: "filecoin", name: "Filecoin", port: "—", spec: "the filecoin spec", group: "overlay", security: "encrypted", state: "opt-in" },
1002
+ { scheme: "hypercore", name: "Hypercore", port: "—", spec: "the hypercore protocol", group: "overlay", security: "encrypted", state: "opt-in" },
1003
+ { scheme: "i2p", name: "I2P", port: "—", spec: "the i2p protocol", group: "overlay", security: "encrypted", state: "opt-in" },
1004
+ { scheme: "ipfs", name: "IPFS", port: "4001", spec: "the ipfs spec", group: "overlay", security: "opportunistic", state: "opt-in" },
1005
+ { scheme: "ipns", name: "IPNS", port: "4001", spec: "the ipns spec", group: "overlay", security: "opportunistic", state: "opt-in" },
1006
+ { scheme: "libp2p", name: "libp2p", port: "4001", spec: "the libp2p spec", group: "overlay", security: "encrypted", state: "opt-in" },
1007
+ { scheme: "ssb", name: "Secure Scuttlebutt", port: "8008", spec: "the ssb protocol", group: "overlay", security: "encrypted", state: "opt-in" },
1008
+ { scheme: "tor", name: "Tor onion services v3", port: "—", spec: "the tor dirspec", group: "overlay", security: "encrypted", state: "opt-in" },
1009
+ { scheme: "yggdrasil", name: "Yggdrasil", port: "—", spec: "the yggdrasil protocol", group: "overlay", security: "encrypted", state: "opt-in" },
1010
+
1011
+ /** ## data — the database wire family. */
1012
+ { scheme: "cassandra", name: "Cassandra native transport", port: "9042", spec: "the cql binary protocol", group: "data", security: "opportunistic", state: "live" },
1013
+ { scheme: "clickhouse", name: "ClickHouse native", port: "9000", spec: "the clickhouse wire", group: "data", security: "opportunistic", state: "opt-in" },
1014
+ { scheme: "memcached", name: "Memcached", port: "11211", spec: "the memcached protocol", group: "data", security: "cleartext", state: "live" },
1015
+ { scheme: "mongodb", name: "MongoDB (OP_MSG)", port: "27017", spec: "the mongodb wire", group: "data", security: "opportunistic", state: "live" },
1016
+ { scheme: "mysql", name: "MySQL client protocol", port: "3306", spec: "the mysql protocol", group: "data", security: "opportunistic", state: "live" },
1017
+ { scheme: "postgresql", name: "PostgreSQL v3 protocol", port: "5432", spec: "the pg v3 protocol", group: "data", security: "opportunistic", state: "live" },
1018
+ { scheme: "redis", name: "Redis RESP3", port: "6379", spec: "RESP3", group: "data", security: "opportunistic", state: "live" },
1019
+
1020
+ /** ## ops — the observability and time family. */
1021
+ { scheme: "chargen", name: "Character Generator", port: "19", spec: "RFC 864", group: "ops", security: "cleartext", state: "legacy" },
1022
+ { scheme: "daytime", name: "Daytime", port: "13", spec: "RFC 867", group: "ops", security: "cleartext", state: "legacy" },
1023
+ { scheme: "discard", name: "Discard", port: "9", spec: "RFC 863", group: "ops", security: "cleartext", state: "legacy" },
1024
+ { scheme: "echo", name: "Echo", port: "7", spec: "RFC 862", group: "ops", security: "cleartext", state: "legacy" },
1025
+ { scheme: "ipfix", name: "IPFIX", port: "4739/UDP", spec: "RFC 7011", group: "ops", security: "cleartext", state: "live" },
1026
+ { scheme: "netflow", name: "NetFlow v9", port: "2055/UDP", spec: "the netflow spec", group: "ops", security: "cleartext", state: "live" },
1027
+ { scheme: "ntp", name: "NTPv4", port: "123/UDP", spec: "RFC 5905", group: "ops", security: "cleartext", state: "live" },
1028
+ { scheme: "openmetrics", name: "OpenMetrics scrape", port: "443", spec: "the openmetrics spec", group: "ops", security: "encrypted", state: "live" },
1029
+ { scheme: "ptp", name: "PTP", port: "319/UDP", spec: "IEEE 1588", group: "ops", security: "cleartext", state: "opt-in" },
1030
+ { scheme: "sflow", name: "sFlow v5", port: "6343/UDP", spec: "the sflow spec", group: "ops", security: "cleartext", state: "live" },
1031
+ { scheme: "snmp", name: "SNMPv3", port: "161/UDP", spec: "RFC 3411", group: "ops", security: "opportunistic", state: "live" },
1032
+ { scheme: "snmptrap", name: "SNMP traps", port: "162/UDP", spec: "RFC 3416", group: "ops", security: "cleartext", state: "live" },
1033
+ { scheme: "syslog", name: "Syslog", port: "514/UDP", spec: "RFC 5424", group: "ops", security: "cleartext", state: "live" },
1034
+ { scheme: "syslog-tls", name: "Syslog over TLS", port: "6514", spec: "RFC 5425", group: "ops", security: "encrypted", state: "live" },
1035
+
1036
+ /** ## directory — the lookup and identity family. */
1037
+ { scheme: "dict", name: "DICT", port: "2628", spec: "RFC 2229", group: "directory", security: "cleartext", state: "legacy" },
1038
+ { scheme: "kerberos", name: "Kerberos V5", port: "88", spec: "RFC 4120", group: "directory", security: "encrypted", state: "live" },
1039
+ { scheme: "kpasswd", name: "Kerberos password change", port: "464", spec: "the kpasswd dialect", group: "directory", security: "opportunistic", state: "live" },
1040
+ { scheme: "ldap", name: "LDAPv3", port: "389", spec: "RFC 4511", group: "directory", security: "opportunistic", state: "live" },
1041
+ { scheme: "ldaps", name: "LDAP over TLS", port: "636", spec: "RFC 4513", group: "directory", security: "encrypted", state: "live" },
1042
+ { scheme: "radsec", name: "RADIUS over TLS", port: "2083", spec: "RFC 6614", group: "directory", security: "encrypted", state: "live" },
1043
+ { scheme: "radius", name: "RADIUS", port: "1812/UDP", spec: "RFC 2865", group: "directory", security: "cleartext", state: "live" },
1044
+ { scheme: "rdap", name: "RDAP", port: "443", spec: "RFC 9082 · 9083", group: "directory", security: "encrypted", state: "live" },
1045
+ { scheme: "tacacs", name: "TACACS", port: "49", spec: "RFC 8907", group: "directory", security: "cleartext", state: "legacy" },
1046
+ { scheme: "tacacs+", name: "TACACS+", port: "49", spec: "RFC 8907", group: "directory", security: "opportunistic", state: "live" },
1047
+ { scheme: "z39.50", name: "Z39.50", port: "210", spec: "ANSI/NISO Z39.50", group: "directory", security: "cleartext", state: "legacy" },
1048
+
1049
+ /** ## legacy — the inherited wires the gateway still translates. */
1050
+ { scheme: "aprs", name: "APRS packet radio", port: "14580/UDP", spec: "the aprs spec", group: "legacy", security: "cleartext", state: "opt-in" },
1051
+ { scheme: "arp", name: "ARP", port: "—", spec: "RFC 826", group: "legacy", security: "cleartext", state: "live" },
1052
+ { scheme: "bfd", name: "BFD", port: "3784/UDP", spec: "RFC 5880", group: "legacy", security: "cleartext", state: "live" },
1053
+ { scheme: "bgp", name: "BGP-4", port: "179", spec: "RFC 4271", group: "legacy", security: "cleartext", state: "live" },
1054
+ { scheme: "binkp", name: "BinkP", port: "24554", spec: "the binkp protocol", group: "legacy", security: "opportunistic", state: "legacy" },
1055
+ { scheme: "dtn", name: "Bundle Protocol v7", port: "—", spec: "RFC 9171", group: "legacy", security: "opportunistic", state: "opt-in" },
1056
+ { scheme: "eigrp", name: "EIGRP", port: "—", spec: "RFC 7868", group: "legacy", security: "cleartext", state: "legacy" },
1057
+ { scheme: "finger", name: "Finger", port: "79", spec: "RFC 1288", group: "legacy", security: "cleartext", state: "legacy" },
1058
+ { scheme: "gopher", name: "Gopher", port: "70", spec: "RFC 1436", group: "legacy", security: "cleartext", state: "legacy" },
1059
+ { scheme: "gophers", name: "Gopher over TLS", port: "70", spec: "the gophers extension", group: "legacy", security: "encrypted", state: "legacy" },
1060
+ { scheme: "gtp", name: "GTP (the telco bearer)", port: "2152/UDP", spec: "3GPP TS 29.281", group: "legacy", security: "cleartext", state: "live" },
1061
+ { scheme: "icmp", name: "ICMP", port: "—", spec: "RFC 792", group: "legacy", security: "cleartext", state: "live" },
1062
+ { scheme: "icmpv6", name: "ICMPv6", port: "—", spec: "RFC 4443", group: "legacy", security: "cleartext", state: "live" },
1063
+ { scheme: "ident", name: "Ident", port: "113", spec: "RFC 1413", group: "legacy", security: "cleartext", state: "legacy" },
1064
+ { scheme: "igmp", name: "IGMP", port: "—", spec: "RFC 3376", group: "legacy", security: "cleartext", state: "live" },
1065
+ { scheme: "isis", name: "IS-IS", port: "—", spec: "ISO 10589 · RFC 1195", group: "legacy", security: "cleartext", state: "live" },
1066
+ { scheme: "modbus", name: "Modbus TCP", port: "502", spec: "the modbus spec", group: "legacy", security: "cleartext", state: "live" },
1067
+ { scheme: "ndmp", name: "NDMP", port: "10000", spec: "the ndmp protocol", group: "legacy", security: "cleartext", state: "legacy" },
1068
+ { scheme: "netbios", name: "NetBIOS", port: "137-139", spec: "RFC 1001 · 1002", group: "legacy", security: "cleartext", state: "legacy" },
1069
+ { scheme: "nntp", name: "NNTP", port: "119", spec: "RFC 3977", group: "legacy", security: "cleartext", state: "legacy" },
1070
+ { scheme: "nntps", name: "NNTP over TLS", port: "563", spec: "RFC 4642", group: "legacy", security: "encrypted", state: "legacy" },
1071
+ { scheme: "opc-ua", name: "OPC UA", port: "4840", spec: "IEC 62541", group: "legacy", security: "opportunistic", state: "opt-in" },
1072
+ { scheme: "ospf", name: "OSPF", port: "—", spec: "RFC 2328", group: "legacy", security: "cleartext", state: "live" },
1073
+ { scheme: "rip", name: "RIP2", port: "520/UDP", spec: "RFC 2453", group: "legacy", security: "cleartext", state: "legacy" },
1074
+ { scheme: "slp", name: "SLP", port: "427", spec: "RFC 2609", group: "legacy", security: "cleartext", state: "legacy" },
1075
+ { scheme: "ssdp", name: "SSDP", port: "1900/UDP", spec: "the upnp uda spec", group: "legacy", security: "cleartext", state: "live" },
1076
+ { scheme: "uucp", name: "UUCP", port: "540", spec: "the uucp protocol", group: "legacy", security: "cleartext", state: "legacy" },
1077
+ { scheme: "whois", name: "WHOIS", port: "43", spec: "RFC 3912", group: "legacy", security: "cleartext", state: "live" },
1078
+ { scheme: "wsdd", name: "WS-Discovery", port: "3702/UDP", spec: "the ws-discovery spec", group: "legacy", security: "cleartext", state: "live" },
1079
+ ];
1080
+
1081
+ /** The row count of the mesh (the number the pane and the tests announce). */
1082
+ export const PROTOCOL_COUNT = PROTOCOLS.length;
1083
+
1084
+ /** Finds one protocol row by its scheme (case-insensitive, exact match). */
1085
+ export function findProtocol(scheme: string): ProtocolSpec | undefined {
1086
+ const wanted = scheme.trim().toLowerCase();
1087
+ return PROTOCOLS.find((row) => row.scheme === wanted);
1088
+ }
1089
+
1090
+ /** Lists every protocol of one family (the pane renders one group per family keyword). */
1091
+ export function protocolsByGroup(group: ProtocolGroup): ProtocolSpec[] {
1092
+ return PROTOCOLS.filter((row) => row.group === group);
1093
+ }
1094
+
1095
+ /** Lists the schemes the gateway registers (one entry per protocol row). */
1096
+ export function protocolSchemes(): string[] {
1097
+ return PROTOCOLS.map((row) => row.scheme);
1098
+ }
1099
+
1100
+ /**
1101
+ * Lists the tls-capable protocols of the mesh — every row whose wire runs encrypted or
1102
+ * upgrades to it (STARTTLS, TLS-on-demand). The owner law pins this count above one
1103
+ * hundred: the gateway speaks more than a hundred protocols over https-grade crypto.
1104
+ */
1105
+ export function tlsCapableProtocols(): ProtocolSpec[] {
1106
+ return PROTOCOLS.filter((row) => row.security !== "cleartext");
1107
+ }
1108
+
1109
+ /** Answers the badge tone of one protocol row (the pane chip derivation). */
1110
+ export function protocolTone(spec: ProtocolSpec): BadgeTone {
1111
+ if (spec.security === "encrypted") return "success";
1112
+ if (spec.security === "opportunistic") return "info";
1113
+ return spec.state === "legacy" ? "default" : "warning";
1114
+ }
1115
+
1116
+ /** Lists the family keywords the mesh carries (the pane section order). */
1117
+ export function protocolGroups(): ProtocolGroup[] {
1118
+ const seen = new Set<ProtocolGroup>();
1119
+ for (const row of PROTOCOLS) seen.add(row.group);
1120
+ return [...seen];
1121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/argan",
3
- "version": "2.0.170",
3
+ "version": "2.0.172",
4
4
  "private": false,
5
5
  "description": "argan is the DNS and gateway library of the wenathlan family: zones, records, dnssec and handshake — the system network library whose site shares the unified database. The envelope distributes the documentation set, the zero-build site and the tests directory as one package.",
6
6
  "license": "GPL-3.0-only",
@@ -87,13 +87,13 @@
87
87
  "@types/react-dom": "^19.3.0",
88
88
  "@types/three": "^0.187.0",
89
89
  "@vitejs/plugin-react": "^6.2.1",
90
- "@wenathlan/argan": "~2.0.168",
91
- "@wenathlan/cadria": "~2.0.168",
92
- "@wenathlan/debonair": "~2.0.168",
93
- "@wenathlan/devthink": "~2.0.168",
94
- "@wenathlan/getry": "~2.0.168",
95
- "@wenathlan/saddle": "~2.0.168",
96
- "@wenathlan/vault": "~2.0.168",
90
+ "@wenathlan/argan": "~2.0.171",
91
+ "@wenathlan/cadria": "~2.0.171",
92
+ "@wenathlan/debonair": "~2.0.171",
93
+ "@wenathlan/devthink": "~2.0.170",
94
+ "@wenathlan/getry": "~2.0.171",
95
+ "@wenathlan/saddle": "~2.0.171",
96
+ "@wenathlan/vault": "~2.0.171",
97
97
  "add": "^2.0.9",
98
98
  "ai": "^7.0.165",
99
99
  "autoprefixer": "^10.6.2",
@@ -144,7 +144,7 @@
144
144
  "playwright": "^1.65.0",
145
145
  "pnpm": "^12.18.0",
146
146
  "postcss": "^8.5.32",
147
- "postcss-selector-parser": "^7.1.8",
147
+ "postcss-selector-parser": "^7.2.0",
148
148
  "prettier": "^3.9.10",
149
149
  "prisma": "^7.10.1",
150
150
  "proxy-addr": "^2.0.8",
@@ -260,13 +260,17 @@
260
260
  "@types/react-dom": "^19.3.0",
261
261
  "@types/three": "^0.187.0",
262
262
  "@vitejs/plugin-react": "^6.2.1",
263
- "@wenathlan/argan": "~2.0.168",
264
- "@wenathlan/cadria": "~2.0.168",
265
- "@wenathlan/debonair": "~2.0.168",
266
- "@wenathlan/devthink": "~2.0.168",
267
- "@wenathlan/getry": "~2.0.168",
268
- "@wenathlan/saddle": "~2.0.168",
269
- "@wenathlan/vault": "~2.0.168",
263
+ "@wenathlan/argan": "~2.0.171",
264
+ "@wenathlan/cadria": "~2.0.171",
265
+ "@wenathlan/cobaia": "~2.0.171",
266
+ "@wenathlan/debonair": "~2.0.171",
267
+ "@wenathlan/devthink": "~2.0.170",
268
+ "@wenathlan/forge": "~2.0.171",
269
+ "@wenathlan/foundry": "~2.0.171",
270
+ "@wenathlan/getry": "~2.0.171",
271
+ "@wenathlan/saddle": "~2.0.171",
272
+ "@wenathlan/stealthhead": "~2.0.171",
273
+ "@wenathlan/vault": "~2.0.171",
270
274
  "add": "^2.0.9",
271
275
  "ai": "^7.0.165",
272
276
  "autoprefixer": "^10.6.2",
@@ -317,7 +321,7 @@
317
321
  "playwright": "^1.65.0",
318
322
  "pnpm": "^12.18.0",
319
323
  "postcss": "^8.5.32",
320
- "postcss-selector-parser": "^7.1.8",
324
+ "postcss-selector-parser": "^7.2.0",
321
325
  "prettier": "^3.9.10",
322
326
  "prisma": "^7.10.1",
323
327
  "proxy-addr": "^2.0.8",
@@ -433,13 +437,13 @@
433
437
  "@types/react-dom": "^19.3.0",
434
438
  "@types/three": "^0.187.0",
435
439
  "@vitejs/plugin-react": "^6.2.1",
436
- "@wenathlan/argan": "~2.0.168",
437
- "@wenathlan/cadria": "~2.0.168",
438
- "@wenathlan/debonair": "~2.0.168",
439
- "@wenathlan/devthink": "~2.0.168",
440
- "@wenathlan/getry": "~2.0.168",
441
- "@wenathlan/saddle": "~2.0.168",
442
- "@wenathlan/vault": "~2.0.168",
440
+ "@wenathlan/argan": "~2.0.171",
441
+ "@wenathlan/cadria": "~2.0.171",
442
+ "@wenathlan/debonair": "~2.0.171",
443
+ "@wenathlan/devthink": "~2.0.170",
444
+ "@wenathlan/getry": "~2.0.171",
445
+ "@wenathlan/saddle": "~2.0.171",
446
+ "@wenathlan/vault": "~2.0.171",
443
447
  "add": "^2.0.9",
444
448
  "ai": "^7.0.165",
445
449
  "autoprefixer": "^10.6.2",
@@ -490,7 +494,7 @@
490
494
  "playwright": "^1.65.0",
491
495
  "pnpm": "^12.18.0",
492
496
  "postcss": "^8.5.32",
493
- "postcss-selector-parser": "^7.1.8",
497
+ "postcss-selector-parser": "^7.2.0",
494
498
  "prettier": "^3.9.10",
495
499
  "prisma": "^7.10.1",
496
500
  "proxy-addr": "^2.0.8",
@@ -528,13 +532,13 @@
528
532
  "webpack": "^5.112.2",
529
533
  "webpack-cli": "^7.3.1",
530
534
  "wouter": "^3.13.0",
535
+ "xcode": {
536
+ "uuid": "11.1.1"
537
+ },
531
538
  "yaml": "^2.9.1",
532
539
  "z-ai-web-dev-sdk": "^0.0.18",
533
540
  "zod": "^4.6.7",
534
- "zustand": "^5.1.0",
535
- "xcode": {
536
- "uuid": "11.1.1"
537
- }
541
+ "zustand": "^5.1.0"
538
542
  },
539
543
  "type": "module",
540
544
  "sideEffects": false,
@@ -546,6 +550,11 @@
546
550
  "types": "./mimetypes.ts",
547
551
  "import": "./mimetypes.ts",
548
552
  "default": "./mimetypes.ts"
553
+ },
554
+ "./dns": {
555
+ "types": "./dns.ts",
556
+ "import": "./dns.ts",
557
+ "default": "./dns.ts"
549
558
  }
550
559
  },
551
560
  "files": [
@@ -553,6 +562,7 @@
553
562
  "docs",
554
563
  "test",
555
564
  "cli.ts",
565
+ "dns.ts",
556
566
  "mimetypes.ts"
557
567
  ],
558
568
  "trustedDependencies": [
@@ -560,6 +570,5 @@
560
570
  "@swc/core",
561
571
  "pnpm",
562
572
  "protobufjs"
563
- ],
564
- "optionalDependencies": {}
573
+ ]
565
574
  }