Files
mdg-filius/packages/fls/src/dnsHosts.ts
T
julian.kiedaischandClaude Opus 5.5 a4036f12a3 feat(core): RIP after Java's code
Where the Swift template departs from Java's filius.software.rip, Java's
code rules: a learned route gets 16 hops at the router's next beacon after
it expired (RIPTable.check), not when it expires; messages count from any
source port; each router draws its pauses from a random of its own (Java:
an unseeded Random per router), which the core seeds from the scenario and
the router's id (ripSeed), so that the Java oracle can give it the same.

A route of hop count 0 to each own network, the first beacon after a
second, then every 25.2 to 35.2 s; a message per interface without the
routes through it; a new route or a longer one from the same router sends
at once; forwarding by the table alone, fewest hops before longest mask.
Messages are read with Java's parser (javaIpCheck, javaParseInt, moved from
fls to core for it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:29:48 +00:00

142 lines
5.8 KiB
TypeScript

// Java's DNS server keeps its records in the text file /dns/hosts and reads
// them on every query (DNSServer.leseRecordListe, ResourceRecord(String)).
// Measured on test/dns-hosts.cases.txt against Java (dns-hosts.java.json)
// and Swift (dns-hosts.swift.json). Java's format decides where the two
// differ:
// - fields are split at single spaces only: a tab or NBSP joins two fields;
// - there are no comments, no "IN" and no zone-file order;
// - a TTL that is not a number silently becomes 3600;
// - a name that is not a domain name becomes the empty name, and the record
// still counts - it just never answers, like one of another type;
// - duplicates stay.
// Java's errors are not copied (each named in dnsHosts.test.ts): a label may
// start with a digit and holds no backslash (RFC 1123 - Java's pattern has
// both wrong; see isDomainName), the type A, MX or NS is read in any case
// (RFC 4343), and a negative TTL is invalid (RFC 2181).
import { javaIpCheck, javaParseInt, type DnsResourceRecord } from '@mdg-filius/core';
import { javaFileContent } from './javaFileSystem.js';
import { javaTrim } from './javaString.js';
import type { FlsFileTree } from './systemSoftware.js';
/** A record of /dns/hosts, as Java's ResourceRecord holds it (the getters). */
export interface HostsRecord {
readonly domainName: string;
readonly type: string;
readonly ttl: number;
readonly rdata: string;
}
export const DNS_HOSTS_PATH = '/dns/hosts';
// Labels of letters, digits and inner hyphens, an optional final dot; the
// empty string counts too.
const DOMAIN_LABELS = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)?\.?$/;
/**
* A domain name as EingabenUeberpruefung.musterDomain means it (the empty
* string included). Java's pattern wants every label to start with a letter
* and lets a backslash into the labels after the first; instead a label may
* start with a digit (RFC 1123), but the last one must not be all digits
* (RFC 3696), so that no IPv4 address passes for a name.
*/
export function isDomainName(text: string): boolean {
if (!DOMAIN_LABELS.test(text)) return false;
const labels = text.split('.').filter((label) => label !== '');
return labels.length === 0 || !/^[0-9]+$/.test(labels[labels.length - 1]!);
}
// EingabenUeberpruefung.musterIpAdresse.
const JAVA_IP_ADDRESS =
/^(0*([1-9]?[0-9]?|1[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/;
/** The content of /dns/hosts as Java's DNS server finds it (see javaFileSystem.ts). */
export function javaHostsFileContent(root: FlsFileTree | undefined): string | undefined {
return javaFileContent(root, DNS_HOSTS_PATH);
}
const ANSWERED_TYPES = new Set(['A', 'MX', 'NS']);
/** `DNSServer.leseRecordListe` on the content of /dns/hosts. */
export function readDnsHosts(content: string): HostsRecord[] {
const records: HostsRecord[] = [];
for (const token of content.split('\n')) {
const line = javaTrim(token);
// line.split(" ", 5).length < 4: fewer than three single spaces.
if (line === '' || line.split(' ').length < 4) continue;
const record = hostsRecord(line);
if (isValidRecord(record)) records.push(record);
}
return records;
}
/** `new ResourceRecord(line)`: up to four space-separated fields. */
function hostsRecord(line: string): HostsRecord {
const fields = line.split(' ').filter((field) => field !== '').map(javaTrim);
let domainName = '';
let type = 'A';
let ttl = 3600;
let rdata = '';
if (fields[0] !== undefined && isDomainName(fields[0])) {
domainName = fields[0].endsWith('.') ? fields[0] : `${fields[0]}.`;
}
if (fields[1] !== undefined) {
const upper = fields[1].replace(/[a-z]/g, (letter) => letter.toUpperCase());
type = ANSWERED_TYPES.has(upper) ? upper : fields[1];
}
if (fields[2] !== undefined) {
const parsed = javaParseInt(fields[2]);
if (parsed !== undefined && parsed >= 0) ttl = parsed;
}
if (fields[3] !== undefined) {
// Java tests ".*\." (no line terminator before the dot); a target with
// one fails the domain check anyway, so endsWith says the same.
const needsDot = (type === 'NS' || type === 'MX') && !fields[3].endsWith('.');
rdata = needsDot ? `${fields[3]}.` : fields[3];
}
return { domainName, type, ttl, rdata };
}
function isValidRecord(record: HostsRecord): boolean {
switch (record.type) {
case 'A':
return isDomainName(record.domainName) && JAVA_IP_ADDRESS.test(record.rdata);
case 'MX':
case 'NS':
return isDomainName(record.domainName) && isDomainName(record.rdata);
default:
return true;
}
}
/**
* The records Java answers with, in the model's form: Java looks them up by
* type (A, MX, NS) and by name ignoring case, so a record with the empty
* name or another type never answers and is left out. The file itself stays
* as it is.
*/
export function dnsRecordsOf(records: readonly HostsRecord[], warn: (message: string) => void): DnsResourceRecord[] {
const answered: DnsResourceRecord[] = [];
for (const record of records) {
if (record.domainName === '') continue;
const name = record.domainName === '.' ? '.' : record.domainName.slice(0, -1).toLowerCase();
switch (record.type) {
case 'A': {
const address = javaIpCheck(record.rdata);
if (address === null) {
warn(`DNS record '${name} A ${record.rdata}' has no usable IPv4 address, left out (it stays in ${DNS_HOSTS_PATH})`);
continue;
}
answered.push({ name, type: 'A', ttlSeconds: record.ttl, target: address });
break;
}
case 'MX':
case 'NS':
answered.push({ name, type: record.type, ttlSeconds: record.ttl, target: record.rdata.toLowerCase() });
break;
default:
break;
}
}
return answered;
}