// 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; }