feat(fls): DNS records from /dns/hosts, as Java's server answers them
Java's DNS server reads its records from the text file /dns/hosts on every query. dnsHosts.ts ports that reading (DNSServer.leseRecordListe, ResourceRecord, Dateisystem.holeDatei) and keeps in the model the records Java answers: type exactly A, MX or NS, and a name Java accepted. The file stays in the file system as it is. Both oracles read 76 hand-picked hosts files (dns-hosts.cases.txt): the Java oracle through DNSServer.holeResourceRecords (DnsHostsCases), the Swift oracle through TopologyDNSHostsFileCodec (--dns-hosts). The Java view matches Java on every case; the model matches Swift except for 33 named cases in twelve reasons: Java splits at single spaces and LF only, has no comments or "IN", turns a TTL that is no int into 3600, keeps duplicates, and empties names with a leading digit. The Swift dump now carries each server's records; the corpus comparison covers them on 16 files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
// 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); where the two differ, Java decides:
|
||||
// - 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 Java int silently becomes 3600;
|
||||
// - a name Java does not accept becomes the empty name, and the record
|
||||
// still counts - it just never answers, like any record whose type is
|
||||
// not exactly A, MX or NS (lower-case "a" included);
|
||||
// - duplicates stay.
|
||||
|
||||
import type { DnsResourceRecord } from '@mdg-filius/core';
|
||||
import { javaIpCheck, javaParseInt } from './javaIp.js';
|
||||
import type { FlsFileTree } from './systemSoftware.js';
|
||||
|
||||
/** A record as Java's ResourceRecord holds it (the getters). */
|
||||
export interface JavaResourceRecord {
|
||||
readonly domainName: string;
|
||||
readonly type: string;
|
||||
readonly ttl: number;
|
||||
readonly rdata: string;
|
||||
}
|
||||
|
||||
export const DNS_HOSTS_PATH = '/dns/hosts';
|
||||
|
||||
// EingabenUeberpruefung.musterDomain, including its quirks: labels start with
|
||||
// a letter, labels after the first may hold a backslash, and the empty
|
||||
// string is a valid domain.
|
||||
const JAVA_DOMAIN = /^([a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?)*)?\.?$/;
|
||||
// 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]))$/;
|
||||
|
||||
/** `String.trim()`: every UTF-16 unit up to U+0020 off both ends. */
|
||||
function javaTrim(text: string): string {
|
||||
return text.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The content of /dns/hosts as `Dateisystem.holeDatei` finds it: the first
|
||||
* node in preorder whose path equals it ignoring case, and only if that node
|
||||
* is a file. Undefined when there is none; a file without content reads as
|
||||
* empty (Java would fail on it).
|
||||
*/
|
||||
export function javaHostsFileContent(root: FlsFileTree | undefined): string | undefined {
|
||||
if (root === undefined) return undefined;
|
||||
const found = firstInPreorder(root, '', (path) => javaEqualsIgnoreCase(path, DNS_HOSTS_PATH));
|
||||
return found?.kind === 'file' ? (found.content ?? '') : undefined;
|
||||
}
|
||||
|
||||
/** Preorder below the root; a node's path is "/" plus each name after the root's. */
|
||||
function firstInPreorder(node: FlsFileTree, path: string, matches: (path: string) => boolean): FlsFileTree | undefined {
|
||||
for (const child of node.children) {
|
||||
const childPath = `${path}/${child.kind === 'other' ? child.className : String(child.name)}`;
|
||||
if (matches(childPath)) return child;
|
||||
const found = firstInPreorder(child, childPath, matches);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `String.equalsIgnoreCase` against an ASCII path, unit by unit: equal after
|
||||
* Character.toUpperCase, so the long s (U+017F) is an "s". Java also
|
||||
* compares the lower case of both; for the letters of /dns/hosts that adds
|
||||
* no unit (measured over the BMP).
|
||||
*/
|
||||
function javaEqualsIgnoreCase(text: string, asciiPath: string): boolean {
|
||||
if (text.length !== asciiPath.length) return false;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
if (text[index]!.toUpperCase() !== asciiPath[index]!.toUpperCase()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `DNSServer.leseRecordListe` on the content of /dns/hosts. */
|
||||
export function readJavaDnsHosts(content: string): JavaResourceRecord[] {
|
||||
const records: JavaResourceRecord[] = [];
|
||||
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 = javaResourceRecord(line);
|
||||
if (isValidJavaRecord(record)) records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/** `new ResourceRecord(line)`: up to four space-separated fields. */
|
||||
function javaResourceRecord(line: string): JavaResourceRecord {
|
||||
const fields = line.split(' ').filter((field) => field !== '').map(javaTrim);
|
||||
let domainName = '';
|
||||
let type = 'A';
|
||||
let ttl = 3600;
|
||||
let rdata = '';
|
||||
if (fields[0] !== undefined && JAVA_DOMAIN.test(fields[0])) {
|
||||
domainName = fields[0].endsWith('.') ? fields[0] : `${fields[0]}.`;
|
||||
}
|
||||
if (fields[1] !== undefined) type = fields[1];
|
||||
if (fields[2] !== undefined) ttl = javaParseInt(fields[2]) ?? ttl;
|
||||
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 isValidJavaRecord(record: JavaResourceRecord): boolean {
|
||||
switch (record.type) {
|
||||
case 'A':
|
||||
return JAVA_DOMAIN.test(record.domainName) && JAVA_IP_ADDRESS.test(record.rdata);
|
||||
case 'MX':
|
||||
case 'NS':
|
||||
return JAVA_DOMAIN.test(record.domainName) && JAVA_DOMAIN.test(record.rdata);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The records Java answers with, in the model's form: Java looks them up by
|
||||
* type (exactly "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 JavaResourceRecord[], 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;
|
||||
}
|
||||
@@ -33,7 +33,10 @@
|
||||
// - a web server's files stay in /webserver (its document root), where Swift
|
||||
// moves them to /www; and no program state is written into a host's files
|
||||
// (Swift keeps Gnutella and e-mail state there as hidden JSON files).
|
||||
// Not here yet: DNS records (from /dns/hosts) and e-mail accounts.
|
||||
// - a DNS server's records are what Java's server answers from /dns/hosts
|
||||
// (see dnsHosts.ts); Swift reads the file its own way — tabs, comments,
|
||||
// "IN", names with a leading digit — and drops Java's duplicates.
|
||||
// Not here yet: e-mail accounts.
|
||||
|
||||
import {
|
||||
canonicalMacAddress,
|
||||
@@ -88,6 +91,7 @@ import {
|
||||
type TopologyNode,
|
||||
type VirtualFileContent,
|
||||
} from '@mdg-filius/core';
|
||||
import { dnsRecordsOf, javaHostsFileContent, readJavaDnsHosts } from './dnsHosts.js';
|
||||
import type { FlsApplicationClass, FlsFileTree, FlsFirewall } from './systemSoftware.js';
|
||||
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
|
||||
|
||||
@@ -415,11 +419,15 @@ function registerPrograms(source: FlsNode, node: TopologyNode, system: SystemCon
|
||||
const forwarder = trimmedOrEmpty(source.interfaces[0]?.dns);
|
||||
const primary = trimmedOrEmpty(source.interfaces[0]?.ip);
|
||||
const forwarding = recursive && forwarder !== '' && forwarder !== primary;
|
||||
const hosts = javaHostsFileContent(source.system.fileSystem);
|
||||
const records = dnsRecordsOf(readJavaDnsHosts(hosts ?? ''), (message) =>
|
||||
warnings.push(`node '${node.displayName}': ${message}`),
|
||||
);
|
||||
system.dnsServerConfigurations.set(
|
||||
node.id,
|
||||
forwarding
|
||||
? { recursiveResolutionEnabled: recursive, forwardingServerIPAddress: forwarder }
|
||||
: { recursiveResolutionEnabled: recursive },
|
||||
? { recursiveResolutionEnabled: recursive, forwardingServerIPAddress: forwarder, records }
|
||||
: { recursiveResolutionEnabled: recursive, records },
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Public surface of the .fls codec.
|
||||
export * from './archive.js';
|
||||
export * from './dnsHosts.js';
|
||||
export * from './importProject.js';
|
||||
export * from './javaIp.js';
|
||||
export * from './javabeans.js';
|
||||
|
||||
Reference in New Issue
Block a user