feat: F4.6g2 the DNS server window, its records being /dns/hosts
Oracle DnsWindowRun uses Java's DNS server window as a person does and asks a computer's resolver after each step. The core's DNS server is a program its window changes: on and off (its socket on port 53), recursive resolution, and records that are /dns/hosts, read on every query as Java's server reads them (the reader moves from fls to the core). The snapshot carries every installed DNS server with whether it runs. The room takes the window's actions, sends its view and writes the server back after the run; the editor shows Java's window with its three tabs. Not taken from Java: a cell edit spilling into records of another type, an add losing what another program wrote into the file, an empty mail domain, a removal hitting the first record of the name. A table cell no longer takes the room's value over what is being typed (the web server's too). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -22,12 +22,14 @@ export * from './runtime/apps/program.js';
|
||||
export * from './runtime/apps/installation.js';
|
||||
export * from './runtime/apps/server.js';
|
||||
export * from './runtime/apps/echo.js';
|
||||
export * from './runtime/apps/dnsServer.js';
|
||||
export * from './runtime/apps/web.js';
|
||||
export * from './runtime/apps/routerPages.js';
|
||||
export * from './runtime/apps/firewallSettings.js';
|
||||
export * from './runtime/apps/emailServer.js';
|
||||
export * from './runtime/apps/emailClient.js';
|
||||
export * from './runtime/apps/filePrograms.js';
|
||||
export * from './runtime/dnsHosts.js';
|
||||
export * from './runtime/apps/gnutella.js';
|
||||
export * from './runtime/apps/terminal.js';
|
||||
export * from './runtime/apps/terminalTexts.js';
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// FILIUS' DNS server as its window changes it (DNSServer,
|
||||
// GUIApplicationDNSServerWindow, DNSConfigTable; measured with
|
||||
// tools/java-oracle's DnsWindowRun): switched on and off (its socket on port
|
||||
// 53), recursive resolution, and the records — which are /dns/hosts, read on
|
||||
// every query as Java's server reads them, so that a record another program
|
||||
// wrote into the file answers at once. A server without the file (a core
|
||||
// scenario) answers from its settings until its window writes one.
|
||||
//
|
||||
// Departures from Java (dnsWindow.test.ts names each):
|
||||
// - a record added keeps what another program wrote into the file meanwhile
|
||||
// (Java writes back the list it read before: the file's new lines are lost);
|
||||
// - a cell changed changes that record only (Java counts only the records of
|
||||
// the table's type and, past the one changed, writes the value into each
|
||||
// record of another type up to the next of its own: editing an MX record's
|
||||
// server turned the following A record's address into that name);
|
||||
// - a row removed is that record (Java removes the first of the name: with
|
||||
// two of one name, the other);
|
||||
// - a record needs a name (Java's pattern takes the empty one: ". MX 3600 x.").
|
||||
|
||||
import type { DnsRecordType, DnsResourceRecord, DnsServerConfiguration } from '../config.js';
|
||||
import { DNS_HOSTS_PATH, dnsHostsText, dnsRecordsOf, hostsRecordsOf, isHostsDomainName, newHostsRecord, readDnsHosts, type HostsRecord } from '../dnsHosts.js';
|
||||
import type { VirtualFileSystem } from '../../model/virtualFileSystem.js';
|
||||
import { javaIpCheck } from '../../util/javaIp.js';
|
||||
import { IP_ADDRESS_PATTERN } from './web.js';
|
||||
|
||||
/** What the server gets from the engine: its files (none yet, or made to write), and its socket opened and closed. */
|
||||
export interface DnsServerHost {
|
||||
readonly files: () => VirtualFileSystem | undefined;
|
||||
readonly filesToWrite: () => VirtualFileSystem;
|
||||
readonly open: () => void;
|
||||
readonly close: () => void;
|
||||
}
|
||||
|
||||
/** EingabenUeberpruefung.musterIpAdresse with IP.ipCheck, as the window checks an address. */
|
||||
const isAddress = (text: string) => IP_ADDRESS_PATTERN.test(text) && javaIpCheck(text) !== null;
|
||||
|
||||
/** The window's check of a record's two parts: a name, and an address (A) or a name (MX, NS). */
|
||||
export function validDnsRecord(type: DnsRecordType, domainName: string, rdata: string): boolean {
|
||||
return domainName !== '' && isHostsDomainName(domainName) && (type === 'A' ? isAddress(rdata) : rdata !== '' && isHostsDomainName(rdata));
|
||||
}
|
||||
|
||||
export class DnsServer {
|
||||
private active: boolean;
|
||||
private recursive: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly host: DnsServerHost,
|
||||
private readonly configuration: DnsServerConfiguration,
|
||||
isActive: boolean,
|
||||
) {
|
||||
this.active = isActive;
|
||||
this.recursive = configuration.recursiveResolutionEnabled;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
get recursiveResolutionEnabled(): boolean {
|
||||
return this.recursive;
|
||||
}
|
||||
|
||||
/** The start button: the socket on port 53 opened or closed. */
|
||||
setActive(active: boolean): void {
|
||||
// Once more alike: the port is taken by its own socket, or there is none to close.
|
||||
this.active = active;
|
||||
if (active) this.host.open();
|
||||
else this.host.close();
|
||||
}
|
||||
|
||||
setRecursiveResolutionEnabled(enabled: boolean): void {
|
||||
this.recursive = enabled;
|
||||
}
|
||||
|
||||
private file(): string | undefined {
|
||||
const files = this.host.files();
|
||||
return files !== undefined && files.contains(DNS_HOSTS_PATH) && files.entry(DNS_HOSTS_PATH).content.kind === 'text' ? files.textFile(DNS_HOSTS_PATH) : undefined;
|
||||
}
|
||||
|
||||
/** The records as the window lists them: the valid lines of /dns/hosts (holeResourceRecords), else the settings'. */
|
||||
get records(): HostsRecord[] {
|
||||
const text = this.file();
|
||||
return text === undefined ? hostsRecordsOf(this.configuration.records) : readDnsHosts(text);
|
||||
}
|
||||
|
||||
/** The records a query is answered from. */
|
||||
get answering(): DnsResourceRecord[] {
|
||||
const text = this.file();
|
||||
return text === undefined ? [...this.configuration.records] : dnsRecordsOf(readDnsHosts(text), () => {});
|
||||
}
|
||||
|
||||
/** The records of a type in the order of the file, each with its place among all (a row of the window's table). */
|
||||
rows(type: DnsRecordType): { readonly record: HostsRecord; readonly index: number }[] {
|
||||
return this.records.flatMap((record, index) => (record.type === type ? [{ record, index }] : []));
|
||||
}
|
||||
|
||||
private write(records: readonly HostsRecord[]): void {
|
||||
const files = this.host.filesToWrite();
|
||||
if (!files.contains('/dns')) files.createDirectory('/dns', true);
|
||||
files.writeTextFile(DNS_HOSTS_PATH, dnsHostsText(records));
|
||||
}
|
||||
|
||||
/** "Hinzufügen" (hinzuRecord): at the end of the file as it is now; false when the window's check fails. */
|
||||
addRecord(type: DnsRecordType, domainName: string, rdata: string): boolean {
|
||||
if (!validDnsRecord(type, domainName, rdata)) return false;
|
||||
this.write([...this.records, newHostsRecord(domainName, type, rdata)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** "Auswahl entfernen": the table's row of the type; false when there is none. */
|
||||
removeRecord(type: DnsRecordType, row: number): boolean {
|
||||
const target = this.rows(type)[row];
|
||||
if (target === undefined) return false;
|
||||
this.write(this.records.filter((_, index) => index !== target.index));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A cell of the type's table written (changeSingleEntry): the name (column 0) or the data (column 1) of that record only; false when the value fails the check. */
|
||||
changeRecord(type: DnsRecordType, row: number, column: 0 | 1, value: string): boolean {
|
||||
const target = this.rows(type)[row];
|
||||
if (target === undefined) return false;
|
||||
const trimmed = value.trim();
|
||||
const domainName = column === 0 ? trimmed : target.record.domainName;
|
||||
const rdata = column === 1 ? trimmed : target.record.rdata;
|
||||
if (!validDnsRecord(type, domainName, rdata)) return false;
|
||||
const changed = { ...newHostsRecord(domainName, type, rdata), ttl: target.record.ttl };
|
||||
this.write(this.records.map((record, index) => (index === target.index ? changed : record)));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Java's DNS server keeps its records in the text file /dns/hosts and reads
|
||||
// them on every query (DNSServer.leseRecordListe, ResourceRecord(String)) —
|
||||
// so does the engine's (moved here from packages/fls, which reads the file
|
||||
// of a project with it, in F4.6g2).
|
||||
// Measured on packages/fls/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 isHostsDomainName), the type A, MX or NS is read in any case
|
||||
// (RFC 4343), and a negative TTL is invalid (RFC 2181).
|
||||
|
||||
import type { DnsRecordType, DnsResourceRecord } from './config.js';
|
||||
import { javaIpCheck, javaParseInt } from '../util/javaIp.js';
|
||||
import { javaTrim } from './http.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 isHostsDomainName(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]))$/;
|
||||
|
||||
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 && isHostsDomainName(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 isHostsDomainName(record.domainName) && JAVA_IP_ADDRESS.test(record.rdata);
|
||||
case 'MX':
|
||||
case 'NS':
|
||||
return isHostsDomainName(record.domainName) && isHostsDomainName(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new record as Java's window makes it (new ResourceRecord(name, type,
|
||||
* rdata)): the name with its final dot, an MX's or NS's target too, TTL 3600.
|
||||
*/
|
||||
export function newHostsRecord(domainName: string, type: DnsRecordType, rdata: string): HostsRecord {
|
||||
const dotted = (text: string) => (text.endsWith('.') ? text : `${text}.`);
|
||||
return { domainName: dotted(domainName), type, ttl: 3600, rdata: type === 'A' ? rdata : dotted(rdata) };
|
||||
}
|
||||
|
||||
/** The records as DNSServer.schreibeRecordListe writes them: ResourceRecord.toString, a line each. */
|
||||
export function dnsHostsText(records: readonly HostsRecord[]): string {
|
||||
return records.map((record) => `${record.domainName} ${record.type} ${record.ttl} ${record.rdata}\n`).join('');
|
||||
}
|
||||
|
||||
/** A model's records as lines of /dns/hosts (for a server without the file). */
|
||||
export function hostsRecordsOf(records: readonly DnsResourceRecord[]): HostsRecord[] {
|
||||
return records.filter((record) => record.type === 'A' || record.type === 'MX' || record.type === 'NS').map((record) => newHostsRecord(record.name, record.type, record.target));
|
||||
}
|
||||
@@ -112,11 +112,12 @@ import { PacketCapture, PRODUCTION_CAPTURE_RETENTION, type CaptureRetentionPolic
|
||||
import { ICMP_TYPE_CODE } from './captureRows.js';
|
||||
import { JavaCompatibleRandom } from './random.js';
|
||||
import { routeTableRows, type RouteRow } from './routeTable.js';
|
||||
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
|
||||
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type DnsServerSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
|
||||
import { installationFiles } from './apps/installation.js';
|
||||
import { RuntimeSpeed } from './speed.js';
|
||||
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
|
||||
import { EchoServer, SimpleClient } from './apps/echo.js';
|
||||
import { DnsServer } from './apps/dnsServer.js';
|
||||
import { WebBrowser, WebServer, type WebServerPage } from './apps/web.js';
|
||||
import { EmailClient, type EmailClientFolders } from './apps/emailClient.js';
|
||||
import { EmailServer } from './apps/emailServer.js';
|
||||
@@ -934,6 +935,7 @@ export class NetworkRuntimeEngine {
|
||||
/** The programs, by node (initializePrograms). */
|
||||
private readonly echoServersByNodeId = new Map<Id, EchoServer>();
|
||||
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
|
||||
private readonly dnsServersByNodeId = new Map<Id, DnsServer>();
|
||||
private readonly webServersByNodeId = new Map<Id, WebServer>();
|
||||
private readonly webBrowsersByNodeId = new Map<Id, WebBrowser>();
|
||||
private readonly emailServersByNodeId = new Map<Id, EmailServer>();
|
||||
@@ -1151,6 +1153,11 @@ export class NetworkRuntimeEngine {
|
||||
}
|
||||
|
||||
/** The node's generic client while the simulation runs, if it has one installed. */
|
||||
/** A node's DNS server while the simulation runs (installed, running or not). */
|
||||
dnsServer(nodeId: Id): DnsServer | undefined {
|
||||
return this.dnsServersByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
simpleClient(nodeId: Id): SimpleClient | undefined {
|
||||
return this.simpleClientsByNodeId.get(nodeId);
|
||||
}
|
||||
@@ -1381,6 +1388,8 @@ export class NetworkRuntimeEngine {
|
||||
firewallConfigurationsByNodeId: program === 'firewall' ? copied(snapshot.firewallConfigurationsByNodeId, source.firewallConfigurationsByNodeId) : snapshot.firewallConfigurationsByNodeId,
|
||||
});
|
||||
if (running !== undefined && this.hasProgram(nodeId, running)) this.startProgram(nodeId, running);
|
||||
const dns = program === 'dnsServer' ? state.topologySnapshot.dnsServerConfigurationsByNodeId.get(nodeId) : undefined;
|
||||
if (dns !== undefined) this.startDnsServer(nodeId, dns);
|
||||
this.applicationThreads.resume();
|
||||
}
|
||||
|
||||
@@ -1432,6 +1441,8 @@ export class NetworkRuntimeEngine {
|
||||
state.topologySnapshot = withSnapshot(snapshot, { gnutellaByNodeId: without(snapshot.gnutellaByNodeId) });
|
||||
break;
|
||||
case 'dnsServer':
|
||||
this.dnsServersByNodeId.get(nodeId)?.setActive(false);
|
||||
this.dnsServersByNodeId.delete(nodeId);
|
||||
state.topologySnapshot = withSnapshot(snapshot, { dnsServerConfigurationsByNodeId: without(snapshot.dnsServerConfigurationsByNodeId) });
|
||||
break;
|
||||
case 'firewall':
|
||||
@@ -2005,14 +2016,34 @@ export class NetworkRuntimeEngine {
|
||||
|
||||
// ── DNS (after Java's DNSServer and Resolver) ────────────────────────────
|
||||
|
||||
/** Every DNS server switched on listens on port 53. */
|
||||
/** Each DNS server installed; the ones switched on listen on port 53. */
|
||||
private initializeDnsState(): void {
|
||||
const state = this.stateValue;
|
||||
state.dnsServerSocketIdsByNodeId.clear();
|
||||
for (const [nodeId, configuration] of state.topologySnapshot.dnsServerConfigurationsByNodeId) {
|
||||
const socketId = this.bindUdpSocket(nodeId, { localPort: DNS_PORT });
|
||||
if (socketId !== undefined) state.dnsServerSocketIdsByNodeId.set(nodeId, socketId);
|
||||
}
|
||||
this.dnsServersByNodeId.clear();
|
||||
for (const [nodeId, configuration] of state.topologySnapshot.dnsServerConfigurationsByNodeId) this.startDnsServer(nodeId, configuration);
|
||||
}
|
||||
|
||||
private startDnsServer(nodeId: Id, configuration: DnsServerSnapshot): void {
|
||||
const server = new DnsServer(
|
||||
{
|
||||
files: () => this.stateValue.topologySnapshot.fileSystemsByNodeId.get(nodeId),
|
||||
filesToWrite: () => this.nodeFiles(nodeId),
|
||||
open: () => {
|
||||
const socketId = this.bindUdpSocket(nodeId, { localPort: DNS_PORT });
|
||||
if (socketId !== undefined) this.stateValue.dnsServerSocketIdsByNodeId.set(nodeId, socketId);
|
||||
},
|
||||
close: () => {
|
||||
const socketId = this.stateValue.dnsServerSocketIdsByNodeId.get(nodeId);
|
||||
if (socketId !== undefined) this.closeSocket(socketId);
|
||||
this.stateValue.dnsServerSocketIdsByNodeId.delete(nodeId);
|
||||
},
|
||||
},
|
||||
configuration,
|
||||
false,
|
||||
);
|
||||
this.dnsServersByNodeId.set(nodeId, server);
|
||||
server.setActive(configuration.isActive !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2064,10 +2095,10 @@ export class NetworkRuntimeEngine {
|
||||
* (the NS record of the question's zone and its address).
|
||||
*/
|
||||
private answerDnsQuestion(nodeId: Id, question: DnsQuestion, done: (answers: DnsMessageRecord[]) => void): void {
|
||||
const configuration = this.stateValue.topologySnapshot.dnsServerConfigurationsByNodeId.get(nodeId);
|
||||
const records = (configuration?.records ?? []).map(messageRecord);
|
||||
const server = this.dnsServersByNodeId.get(nodeId);
|
||||
const records = (server?.answering ?? []).map(messageRecord);
|
||||
const local = localDnsAnswers(question, records);
|
||||
if (local.length > 0 || configuration?.recursiveResolutionEnabled !== true) {
|
||||
if (local.length > 0 || server?.recursiveResolutionEnabled !== true) {
|
||||
done(local.length > 0 ? local : dnsReferral(question, records));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ export interface LinkSnapshot {
|
||||
readonly targetPortId: Id;
|
||||
}
|
||||
|
||||
/** A DNS server as the simulation starts it: its settings, and whether it runs (when not said, it does). */
|
||||
export type DnsServerSnapshot = DnsServerConfiguration & { readonly isActive?: boolean };
|
||||
|
||||
export interface TopologySnapshot {
|
||||
/** Sorted by id (Swift `uuidString` order). */
|
||||
readonly nodes: readonly NodeSnapshot[];
|
||||
@@ -87,7 +90,7 @@ export interface TopologySnapshot {
|
||||
readonly dhcpClientConfigurationsByNodeId: ReadonlyMap<Id, DhcpClientConfiguration>;
|
||||
readonly dhcpServerConfigurationsByNodeId: ReadonlyMap<Id, DhcpServerConfiguration>;
|
||||
/** The DNS servers switched on (Java: aktiv), by node. */
|
||||
readonly dnsServerConfigurationsByNodeId: ReadonlyMap<Id, DnsServerConfiguration>;
|
||||
readonly dnsServerConfigurationsByNodeId: ReadonlyMap<Id, DnsServerSnapshot>;
|
||||
readonly firewallConfigurationsByNodeId: ReadonlyMap<Id, FirewallConfiguration>;
|
||||
/** A home router's port forwarding (Java GatewayFirmware.staticNAT), by node. */
|
||||
readonly portForwardingRowsByNodeId: ReadonlyMap<Id, readonly GatewayPortForwardingRow[]>;
|
||||
@@ -125,7 +128,7 @@ export interface TopologySnapshotInput {
|
||||
ripEnabledByNodeId?: ReadonlyMap<Id, boolean>;
|
||||
dhcpClientConfigurationsByNodeId?: ReadonlyMap<Id, DhcpClientConfiguration>;
|
||||
dhcpServerConfigurationsByNodeId?: ReadonlyMap<Id, DhcpServerConfiguration>;
|
||||
dnsServerConfigurationsByNodeId?: ReadonlyMap<Id, DnsServerConfiguration>;
|
||||
dnsServerConfigurationsByNodeId?: ReadonlyMap<Id, DnsServerSnapshot>;
|
||||
firewallConfigurationsByNodeId?: ReadonlyMap<Id, FirewallConfiguration>;
|
||||
portForwardingRowsByNodeId?: ReadonlyMap<Id, readonly GatewayPortForwardingRow[]>;
|
||||
switchConfigurationsByNodeId?: ReadonlyMap<Id, SwitchConfiguration>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
// The DNS server as Java's window changes it, against Java's
|
||||
// (dnsWindow.java.json, DnsWindowRun) on mail-lan's DNS server: Java's steps
|
||||
// played on the engine's server — the tables, /dns/hosts, which names the
|
||||
// first computer's resolver gets, the button and the box — and where Java
|
||||
// errs, what the server does instead (apps/dnsServer.ts names each): an
|
||||
// empty name refused, a cell changing its record only, a record added
|
||||
// keeping what another program wrote into the file.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NetworkRuntimeEngine, validDnsRecord, VirtualFileSystem, withSnapshot, type DnsRecordType, type Steps } from '../src/index.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
interface JavaStep {
|
||||
readonly step: string;
|
||||
readonly button: string;
|
||||
readonly recursive: boolean;
|
||||
readonly a: readonly (readonly string[])[];
|
||||
readonly mx: readonly (readonly string[])[];
|
||||
readonly ns: readonly (readonly string[])[];
|
||||
readonly file: string;
|
||||
readonly dialogs: readonly string[];
|
||||
readonly resolved?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const java = JSON.parse(readFileSync(join(here, 'dnsWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] };
|
||||
const scenario = JSON.parse(readFileSync(join(here, 'scenarios/mail-lan.scenario.json'), 'utf8')) as Scenario;
|
||||
const CLIENT = '00000000-0000-0000-0000-000000000201';
|
||||
const SERVER = '00000000-0000-0000-0000-000000000202';
|
||||
|
||||
function setUp() {
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||||
const files = new VirtualFileSystem();
|
||||
engine.handle({ type: 'start', snapshot: withSnapshot(snapshotOf(scenario), { fileSystemsByNodeId: new Map([[SERVER, files]]) }), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||
const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds });
|
||||
/** What the first computer's resolver gets for a name, as Java's oracle writes it. */
|
||||
const resolve = (name: string): string => {
|
||||
let answer = '(no answer)';
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
const result = yield* engine.resolveDnsAddressSteps(CLIENT, name);
|
||||
answer = result.type === 'timedOut' ? 'TimeOutException' : String(result.address ?? null);
|
||||
})(),
|
||||
);
|
||||
run(30_000);
|
||||
return answer;
|
||||
};
|
||||
return { engine, server: engine.dnsServer(SERVER)!, files, run, resolve };
|
||||
}
|
||||
|
||||
describe("the DNS server's window, against Java's", () => {
|
||||
it("plays Java's steps: the same where Java is right, as named where it errs", () => {
|
||||
const { server, files, resolve } = setUp();
|
||||
const rows = (type: DnsRecordType) => server.rows(type).map(({ record }) => [record.domainName, record.rdata]);
|
||||
const file = () => (files.contains('/dns/hosts') ? files.textFile('/dns/hosts') : undefined);
|
||||
// Where Java's file holds what Java got wrong, the file as it should be (named below at each step).
|
||||
const fixes: ((text: string) => string)[] = [];
|
||||
const ours = (text: string) => fixes.reduce((each, fix) => fix(each), text);
|
||||
const shown = (step: JavaStep) => {
|
||||
const expected = {
|
||||
button: step.button,
|
||||
recursive: step.recursive,
|
||||
a: ours(step.file) === step.file ? step.a : rowsOf(ours(step.file), 'A'),
|
||||
mx: rowsOf(ours(step.file), 'MX'),
|
||||
ns: step.ns,
|
||||
file: ours(step.file),
|
||||
};
|
||||
const actual = { button: server.isActive ? 'Beenden' : 'Starten', recursive: server.recursiveResolutionEnabled, a: rows('A'), mx: rows('MX'), ns: rows('NS'), file: file() ?? ours(step.file) };
|
||||
return { expected, actual };
|
||||
};
|
||||
const at = (name: string) => java.steps.find((step) => step.step === name)!;
|
||||
const check = (name: string, resolved: Readonly<Record<string, string>> | undefined = at(name).resolved) => {
|
||||
const { expected, actual } = shown(at(name));
|
||||
expect(actual, name).toEqual(expected);
|
||||
if (resolved !== undefined) expect(Object.fromEntries(Object.keys(resolved).map((each) => [each, resolve(each)])), name).toEqual(resolved);
|
||||
};
|
||||
|
||||
check('open');
|
||||
expect([server.addRecord('A', 'kein name!', '192.168.0.30'), server.addRecord('A', 'neu.schule.de', '192.168.0.300')]).toEqual([false, false]);
|
||||
expect(at('A: add an invalid name').dialogs).toHaveLength(1);
|
||||
check('A: add an invalid address');
|
||||
expect(server.addRecord('A', 'neu.schule.de', '10.0.0.1')).toBe(true);
|
||||
check('A: add neu.schule.de');
|
||||
expect(server.addRecord('A', 'neu.schule.de', '10.0.0.2')).toBe(true);
|
||||
check('A: add neu.schule.de again');
|
||||
expect(server.addRecord('MX', 'schule.de', 'mail.schule.de')).toBe(true);
|
||||
check('MX: add schule.de');
|
||||
// An empty name is refused (Java makes ". MX 3600 x.").
|
||||
expect(server.addRecord('MX', '', 'x')).toBe(false);
|
||||
fixes.push((text) => text.replace('. MX 3600 x.\n', ''));
|
||||
check('MX: add with no domain');
|
||||
expect(server.addRecord('NS', 'schule.de', 'ns.schule.de')).toBe(true);
|
||||
check('NS: add schule.de');
|
||||
|
||||
expect(server.changeRecord('A', 0, 0, 'web.filius.de')).toBe(true);
|
||||
check("A: edit row 0's name to web.filius.de");
|
||||
expect(server.changeRecord('A', 1, 1, '999.1.1.1')).toBe(false);
|
||||
check("A: edit row 1's address to 999.1.1.1");
|
||||
expect(server.changeRecord('A', 2, 1, '192.168.0.99')).toBe(true);
|
||||
check("A: edit row 2's address");
|
||||
// The MX record's server changes that record only (Java also writes it into the A record after it).
|
||||
expect(server.changeRecord('MX', 0, 1, 'post.filius.de')).toBe(true);
|
||||
fixes.push((text) => text.replace('mail.filius.de. A 3600 post.filius.de\n', 'mail.filius.de. A 3600 192.168.0.30\n'));
|
||||
check("MX: edit row 0's server");
|
||||
// Row 5 is mail.andere.de; Java's file loses mail.filius.de too, which its mistake had made invalid.
|
||||
expect(server.removeRecord('A', 5)).toBe(true);
|
||||
fixes.push((text) => text.replace('filius.de. MX 3600 post.filius.de.\n', 'filius.de. MX 3600 post.filius.de.\nmail.filius.de. A 3600 192.168.0.30\n'));
|
||||
check('A: remove row 5');
|
||||
|
||||
server.setRecursiveResolutionEnabled(true);
|
||||
check('recursive on');
|
||||
server.setActive(false);
|
||||
check('button once');
|
||||
server.setActive(true);
|
||||
check('button twice');
|
||||
|
||||
// Another program writes /dns/hosts: answered at once; a record added keeps it (Java's add loses it).
|
||||
files.writeTextFile('/dns/hosts', `${files.textFile('/dns/hosts')}extern.schule.de. A 3600 10.0.0.9\n`);
|
||||
check('file changed by another program');
|
||||
expect(server.addRecord('A', 'danach.schule.de', '10.0.0.3')).toBe(true);
|
||||
fixes.push((text) => text.replace('danach.schule.de.', 'extern.schule.de. A 3600 10.0.0.9\ndanach.schule.de.'));
|
||||
check('A: add after the file changed', { ...at('A: add after the file changed').resolved, 'extern.schule.de': '10.0.0.9' });
|
||||
expect(server.removeRecord('A', 0)).toBe(true);
|
||||
check('A: remove row 0 after the file changed', { ...at('A: remove row 0 after the file changed').resolved, 'extern.schule.de': '10.0.0.9' });
|
||||
});
|
||||
});
|
||||
|
||||
/** The rows of a type the window shows for a file. */
|
||||
function rowsOf(text: string, type: DnsRecordType): string[][] {
|
||||
return text
|
||||
.split('\n')
|
||||
.filter((line) => line.split(' ')[1] === type)
|
||||
.map((line) => [line.split(' ')[0]!, line.split(' ')[3]!]);
|
||||
}
|
||||
|
||||
describe('the DNS server beyond the steps', () => {
|
||||
it('removes the row chosen of two of one name (Java the first of the name), and changes the row of its type', () => {
|
||||
const { server, files } = setUp();
|
||||
server.addRecord('A', 'zwei.de', '10.0.0.1');
|
||||
server.addRecord('A', 'zwei.de', '10.0.0.2');
|
||||
const twins = () => server.rows('A').filter(({ record }) => record.domainName === 'zwei.de.');
|
||||
expect(twins().map(({ record }) => record.rdata)).toEqual(['10.0.0.1', '10.0.0.2']);
|
||||
const second = server.rows('A').findIndex(({ record }) => record.rdata === '10.0.0.2');
|
||||
expect(server.removeRecord('A', second)).toBe(true);
|
||||
expect(twins().map(({ record }) => record.rdata)).toEqual(['10.0.0.1']);
|
||||
expect([server.removeRecord('A', 99), server.changeRecord('NS', 0, 0, 'x.de')]).toEqual([false, false]);
|
||||
// A record's name and target in any form the file takes: the dots added, the TTL kept.
|
||||
files.writeTextFile('/dns/hosts', 'alt.de. A 60 10.0.0.5\n');
|
||||
expect(server.changeRecord('A', 0, 0, ' neu.de ')).toBe(true);
|
||||
expect(files.textFile('/dns/hosts')).toBe('neu.de. A 60 10.0.0.5\n');
|
||||
});
|
||||
|
||||
it("answers from its settings until it has a file, and its window's checks are Java's patterns without their errors", () => {
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||||
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||
const server = engine.dnsServer(SERVER)!;
|
||||
expect(server.rows('A').map(({ record }) => record.domainName)[0]).toBe('www.filius.de.');
|
||||
expect(server.answering.map((record) => record.name)[0]).toBe('www.filius.de');
|
||||
expect(engine.state.topologySnapshot.fileSystemsByNodeId.has(SERVER)).toBe(false);
|
||||
server.addRecord('NS', 'schule.de', 'ns.schule.de');
|
||||
expect(engine.state.topologySnapshot.fileSystemsByNodeId.get(SERVER)!.textFile('/dns/hosts').split('\n').at(-2)).toBe('schule.de. NS 3600 ns.schule.de.');
|
||||
expect(
|
||||
[
|
||||
['A', 'a.de', '1.2.3.4'],
|
||||
['A', '', '1.2.3.4'],
|
||||
['A', 'a.de', ''],
|
||||
['A', 'a.de', '1.2.3'],
|
||||
['A', '9a.de', '1.2.3.4'],
|
||||
['A', 'a.123', '1.2.3.4'],
|
||||
['MX', 'a.de', 'mail.a.de'],
|
||||
['MX', 'a.de', ''],
|
||||
['NS', 'a.de', '1.2.3.4'],
|
||||
// Java's pattern and IP.ipCheck both: each alone takes one of these.
|
||||
['A', 'a.de', '1.2.3.4.'],
|
||||
['A', 'a.de', '.1.2.3'],
|
||||
['A', 'a.de', '01.2.3.4'],
|
||||
].map(([type, name, data]) => validDnsRecord(type as DnsRecordType, name!, data!)),
|
||||
).toEqual([true, false, false, false, true, false, true, false, false, false, false, true]);
|
||||
// A folder named /dns/hosts is no file: the settings' records.
|
||||
const files = engine.state.topologySnapshot.fileSystemsByNodeId.get(SERVER)!;
|
||||
files.deleteItem('/dns/hosts');
|
||||
files.createDirectory('/dns/hosts');
|
||||
expect(server.records.map((record) => record.domainName)[0]).toBe('www.filius.de.');
|
||||
});
|
||||
|
||||
it('runs a server switched off only once it is switched on, and an installed one switched off', () => {
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||||
const snapshot = snapshotOf(scenario);
|
||||
const configuration = snapshot.dnsServerConfigurationsByNodeId.get(SERVER)!;
|
||||
engine.handle({
|
||||
type: 'start',
|
||||
snapshot: withSnapshot(snapshot, { dnsServerConfigurationsByNodeId: new Map([[SERVER, { ...configuration, isActive: false }]]) }),
|
||||
seed: scenario.seed,
|
||||
initialTimeMilliseconds: 0,
|
||||
});
|
||||
const server = engine.dnsServer(SERVER)!;
|
||||
expect([server.isActive, engine.state.dnsServerSocketIdsByNodeId.has(SERVER)]).toEqual([false, false]);
|
||||
server.setActive(true);
|
||||
server.setActive(true);
|
||||
expect([server.isActive, engine.state.dnsServerSocketIdsByNodeId.has(SERVER)]).toEqual([true, true]);
|
||||
engine.removeProgram(SERVER, 'dnsServer');
|
||||
expect([engine.dnsServer(SERVER), engine.state.dnsServerSocketIdsByNodeId.has(SERVER)]).toEqual([undefined, false]);
|
||||
engine.installProgram(SERVER, 'dnsServer', withSnapshot(snapshot, { dnsServerConfigurationsByNodeId: new Map([[SERVER, { ...configuration, isActive: false }]]) }));
|
||||
expect(engine.dnsServer(SERVER)?.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// The DNS server on a computer's desktop in Chromium (F4.6g2), on FILIUS'
|
||||
// example email_komplett: Java's button and box, the address field with the
|
||||
// computer's own address, a record refused with Java's valid example, one
|
||||
// added, a cell written and one refused, a row removed, the MX tab, the
|
||||
// server stopped; and another computer's command line resolving the new name.
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
|
||||
|
||||
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = await serveEditor();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
async function desktopOf(page: Page, name: string) {
|
||||
const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id;
|
||||
const at = await onDevice(page, id);
|
||||
await page.mouse.click(at.x, at.y);
|
||||
return page.locator(`section[data-desktop="${id}"]`);
|
||||
}
|
||||
|
||||
test('keeps the records as Java’s DNS window does, and the server answers with them', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await openEditor(page, server.base, 'email_komplett.fls');
|
||||
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
|
||||
const desktop = await desktopOf(page, 'DNS-Server');
|
||||
await desktop.locator('[data-program="dnsServer"]').click();
|
||||
const window = desktop.locator('[data-program-window="dnsServer"]');
|
||||
await expect(window.locator('.program-titlebar h3')).toHaveText('DNS-Server');
|
||||
await expect(window.getByRole('button', { name: 'Beenden' })).toBeVisible();
|
||||
await expect(window.getByRole('tab')).toHaveText(['Adressen (A)', 'Mailaustausch (MX)', 'Nameserver (NS)']);
|
||||
const name = window.getByRole('textbox', { name: 'Host-/Domainname:' });
|
||||
const address = window.getByRole('textbox', { name: 'IP-Adresse:' });
|
||||
await expect(address).toHaveValue('141.99.50.230');
|
||||
const rows = window.getByRole('table', { name: 'Einträge' }).locator('tbody tr');
|
||||
const before = await rows.count();
|
||||
|
||||
// Not taken: Java's valid example.
|
||||
await name.fill('kein name!');
|
||||
await expect(name).toHaveAttribute('aria-invalid', 'true');
|
||||
await window.getByRole('button', { name: 'Hinzufügen' }).click();
|
||||
await expect(window.getByRole('alert')).toHaveText('Eingaben sind ungültig! Valides Beispiel: \n> IP-Adresse: 192.168.0.10\n> Domainname: www.filius.de');
|
||||
await name.fill('neu.schule.de');
|
||||
await address.fill('10.0.0.1');
|
||||
await window.getByRole('button', { name: 'Hinzufügen' }).click();
|
||||
await expect(rows).toHaveCount(before + 1);
|
||||
await expect([await name.inputValue(), await address.inputValue()]).toEqual(['', '']);
|
||||
const last = rows.last();
|
||||
await expect(last.locator('input').first()).toHaveValue('neu.schule.de.');
|
||||
|
||||
// A cell written; one the check does not take is marked and goes back.
|
||||
const cell = last.locator('input').nth(1);
|
||||
await cell.fill('10.0.0.2');
|
||||
await cell.press('Enter');
|
||||
await expect(cell).toHaveValue('10.0.0.2');
|
||||
await cell.fill('10.0.0');
|
||||
await expect(cell).toHaveAttribute('aria-invalid', 'true');
|
||||
await cell.blur();
|
||||
await expect(cell).toHaveValue('10.0.0.2');
|
||||
|
||||
// A computer whose DNS server it is resolves the new name (the desktop closed, which covered it).
|
||||
await desktop.locator('.desktop-close').click();
|
||||
const web = await desktopOf(page, 'Web-Server');
|
||||
await web.locator('[data-program="installation"]').click();
|
||||
const installation = web.locator('[data-program-window="installation"]');
|
||||
await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: 'Befehlszeile' }).click();
|
||||
await installation.getByRole('button', { name: 'Installieren' }).click();
|
||||
await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
|
||||
await web.locator('[data-program="commandPrompt"]').click();
|
||||
const input = web.locator('input[name="terminalInput"]');
|
||||
await input.fill('host neu.schule.de');
|
||||
await input.press('Enter');
|
||||
await expect(web.locator('.terminal-text')).toContainText('10.0.0.2', { timeout: 30_000 });
|
||||
await web.locator('.desktop-close').click();
|
||||
await desktopOf(page, 'DNS-Server');
|
||||
await desktop.locator('[data-program="dnsServer"]').click();
|
||||
|
||||
// A row chosen and removed; the MX tab; the box and the button.
|
||||
await rows.last().locator('td').first().dispatchEvent('pointerdown');
|
||||
await window.getByRole('button', { name: 'Auswahl entfernen' }).click();
|
||||
await expect(rows).toHaveCount(before);
|
||||
await window.getByRole('tab', { name: 'Mailaustausch (MX)' }).click();
|
||||
await window.getByRole('textbox', { name: 'Maildomain:' }).fill('schule.de');
|
||||
await window.getByRole('textbox', { name: 'Domainname Mailserver:' }).fill('mail.schule.de');
|
||||
await window.getByRole('button', { name: 'Hinzufügen' }).click();
|
||||
await expect(rows.last().locator('input').nth(1)).toHaveValue('mail.schule.de.');
|
||||
await window.getByRole('checkbox', { name: 'Aktiviere rekursive Domain-Auflösung' }).click();
|
||||
await expect(window.getByRole('checkbox', { name: 'Aktiviere rekursive Domain-Auflösung' })).toBeChecked();
|
||||
await window.getByRole('checkbox', { name: 'Aktiviere rekursive Domain-Auflösung' }).click();
|
||||
await expect(window.getByRole('checkbox', { name: 'Aktiviere rekursive Domain-Auflösung' })).not.toBeChecked();
|
||||
await window.getByRole('button', { name: 'Beenden' }).click();
|
||||
await expect(window.getByRole('button', { name: 'Starten' })).toBeVisible();
|
||||
});
|
||||
@@ -14,7 +14,8 @@
|
||||
// leaves it; so far the command line (F4.6b), the web browser and the web
|
||||
// server (F4.6d), the e-mail program and server (F4.6e), the text editor
|
||||
// (F4.6f2), the file explorer and the image viewer (F4.6f3), the echo
|
||||
// server and the generic client (F4.6g1) have one, the others follow. The browser's window takes its title from its page, the
|
||||
// server and the generic client (F4.6g1), the DNS server (F4.6g2) have one,
|
||||
// the others follow. The browser's window takes its title from its page, the
|
||||
// text editor's from its open file, the image viewer's from its picture, as
|
||||
// Java's do.
|
||||
|
||||
@@ -39,6 +40,7 @@ import { TerminalPanel } from './TerminalPanel.js';
|
||||
import { TextEditorPanel, useTextEditorWindow } from './TextEditorPanel.js';
|
||||
import { FileExplorerPanel } from './FileExplorerPanel.js';
|
||||
import { EchoServerPanel, SimpleClientPanel } from './EchoPanels.js';
|
||||
import { DnsServerPanel } from './DnsServerPanel.js';
|
||||
import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js';
|
||||
import { WebServerPanel } from './WebServerPanel.js';
|
||||
import './desktop.css';
|
||||
@@ -69,7 +71,7 @@ export interface DesktopWindowProps {
|
||||
export type DesktopWindowName = DesktopProgram | 'installation';
|
||||
|
||||
/** The programs whose windows are built so far. */
|
||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient']);
|
||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient', 'dnsServer']);
|
||||
|
||||
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
|
||||
function useDesktopView(doc: Y.Doc, id: string) {
|
||||
@@ -186,6 +188,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
{shown === 'imageViewer' ? <ImageViewerPanel doc={doc} node={id} /> : null}
|
||||
{shown === 'echoServer' ? <EchoServerPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'simpleClient' ? <SimpleClientPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'dnsServer' ? <DnsServerPanel node={id} simulation={simulation} /> : null}
|
||||
</ProgramFrame>
|
||||
)}
|
||||
{info ? (
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// A computer's DNS server, after Java's GUIApplicationDNSServerWindow
|
||||
// (measured with tools/java-oracle's DnsWindowRun): the start button
|
||||
// ("Starten"/"Beenden") and the box for recursive resolution above, three
|
||||
// tabs — addresses (A), mail exchange (MX), name servers (NS) — each with
|
||||
// its two fields (the address starts as the computer's own, as Java's), red
|
||||
// while Java's patterns do not take them, "Hinzufügen" (Java's message when
|
||||
// they do not: the valid example), "Auswahl entfernen" and the table, whose
|
||||
// cells are written when they are left. The server is the room's: the panel
|
||||
// shows its view and hands over what is done; what the server does better
|
||||
// than Java is in its core (apps/dnsServer.ts).
|
||||
//
|
||||
// Better than Java: a cell that its check does not take is marked and goes
|
||||
// back when it is left (Java's goes back without a word).
|
||||
|
||||
import { useEffect, useState, type KeyboardEvent } from 'react';
|
||||
import { validDnsRecord, type DnsRecordType } from '@mdg-filius/core';
|
||||
import type { DnsServerView } from '@mdg-filius/protocol';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import type { Simulation } from '../simulation/useSimulation.js';
|
||||
|
||||
/** Each tab's words: its title, its fields' labels, its columns, Java's message for what it does not take. */
|
||||
const TABS: Readonly<Record<DnsRecordType, { readonly tab: string; readonly fields: readonly [string, string]; readonly columns: readonly [string, string]; readonly invalid: string }>> = {
|
||||
A: { tab: 'dnsServer.tabA', fields: ['dnsServer.aName', 'dnsServer.aAddress'], columns: ['dnsServer.aNameColumn', 'dnsServer.aAddressColumn'], invalid: 'dnsServer.invalidA' },
|
||||
MX: { tab: 'dnsServer.tabMX', fields: ['dnsServer.mxDomain', 'dnsServer.mxServer'], columns: ['dnsServer.mxDomainColumn', 'dnsServer.mxServerColumn'], invalid: 'dnsServer.invalidMX' },
|
||||
NS: { tab: 'dnsServer.tabNS', fields: ['dnsServer.nsDomain', 'dnsServer.nsServer'], columns: ['dnsServer.nsDomainColumn', 'dnsServer.nsServerColumn'], invalid: 'dnsServer.invalidNS' },
|
||||
};
|
||||
|
||||
/** Whether a field's text is what the tab takes there (its other part taken as valid). */
|
||||
function fieldValid(type: DnsRecordType, column: 0 | 1, text: string): boolean {
|
||||
if (text === '') return true;
|
||||
return column === 0 ? validDnsRecord(type, text, type === 'A' ? '1.1.1.1' : 'a.de') : validDnsRecord(type, 'a.de', text);
|
||||
}
|
||||
|
||||
/** A cell of the table, written when it is left or Enter is pressed and it changed; one its check does not take goes back. */
|
||||
function RecordCell({
|
||||
type,
|
||||
column,
|
||||
value,
|
||||
label,
|
||||
onCommit,
|
||||
}: {
|
||||
readonly type: DnsRecordType;
|
||||
readonly column: 0 | 1;
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly onCommit: (value: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [editing, setEditing] = useState(false);
|
||||
// The server's value, but not over what is being typed since the last Enter, Escape or leaving (it may come just after a cell written).
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(value);
|
||||
}, [value, editing]);
|
||||
const valid = draft !== '' && fieldValid(type, column, draft.trim());
|
||||
const commit = () => {
|
||||
setEditing(false);
|
||||
if (draft !== value && valid) onCommit(draft);
|
||||
else setDraft(value);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') commit();
|
||||
if (event.key === 'Escape') {
|
||||
setEditing(false);
|
||||
setDraft(value);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<input
|
||||
value={draft}
|
||||
aria-label={label}
|
||||
aria-invalid={valid ? undefined : 'true'}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setEditing(true);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DnsServerPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
|
||||
const t = useTranslate();
|
||||
const topic = `app:${node}/dnsServer` as const;
|
||||
const { watch, act } = simulation;
|
||||
useEffect(() => watch([topic, `node:${node}`]), [watch, topic, node]);
|
||||
const view = simulation.views.get(topic) as DnsServerView | null | undefined;
|
||||
const ownAddress = simulation.nodes.get(node)?.ipAddress ?? '';
|
||||
const [tab, setTab] = useState<DnsRecordType>('A');
|
||||
// The address field starts as the computer's own (Java's window takes it when it opens): null until it is typed into or emptied.
|
||||
const [fields, setFields] = useState<Record<DnsRecordType, readonly [string, string | null]>>({ A: ['', null], MX: ['', ''], NS: ['', ''] });
|
||||
const [chosen, setChosen] = useState<number | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
if (view === null || view === undefined) return <div className="web-server" />;
|
||||
const words = TABS[tab];
|
||||
const shownFields = (type: DnsRecordType): readonly [string, string] => [fields[type][0], fields[type][1] ?? ownAddress];
|
||||
const [first, second] = shownFields(tab);
|
||||
const rows = view.rows[tab];
|
||||
|
||||
const add = () => {
|
||||
if (!validDnsRecord(tab, first.trim(), second.trim())) {
|
||||
setNotice(t(words.invalid));
|
||||
return;
|
||||
}
|
||||
act(node, 'dnsServer', 'addRecord', { type: tab, name: first.trim(), data: second.trim() });
|
||||
setFields({ ...fields, [tab]: ['', ''] });
|
||||
setNotice(null);
|
||||
};
|
||||
const remove = () => {
|
||||
if (chosen === null || chosen >= rows.length) return;
|
||||
act(node, 'dnsServer', 'removeRecord', { type: tab, row: chosen });
|
||||
setChosen(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="web-server dns-server">
|
||||
<div className="web-server-bar">
|
||||
<button type="button" onClick={() => act(node, 'dnsServer', 'setActive', { active: !view.active })}>
|
||||
{t(view.active ? 'dnsServer.stop' : 'dnsServer.start')}
|
||||
</button>
|
||||
<label>
|
||||
<input type="checkbox" checked={view.recursive} onChange={(event) => act(node, 'dnsServer', 'setRecursive', { enabled: event.target.checked })} />
|
||||
{t('dnsServer.recursive')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="mail-tabs" role="tablist">
|
||||
{(['A', 'MX', 'NS'] as const).map((each) => (
|
||||
<button
|
||||
key={each}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === each}
|
||||
onClick={() => {
|
||||
setTab(each);
|
||||
setChosen(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
>
|
||||
{t(TABS[each].tab)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dns-server-page" role="tabpanel" aria-label={t(words.tab)}>
|
||||
{([0, 1] as const).map((column) => (
|
||||
<label key={column} className="simple-client-field">
|
||||
<span>{t(words.fields[column])}</span>
|
||||
<input
|
||||
value={shownFields(tab)[column]}
|
||||
aria-invalid={fieldValid(tab, column, shownFields(tab)[column].trim()) ? undefined : 'true'}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
const next: [string, string] = [...shownFields(tab)];
|
||||
next[column] = event.target.value;
|
||||
setFields({ ...fields, [tab]: next });
|
||||
setNotice(null);
|
||||
}}
|
||||
onKeyDown={(event) => event.key === 'Enter' && add()}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div className="web-server-bar">
|
||||
<button type="button" onClick={add}>
|
||||
{t('dnsServer.add')}
|
||||
</button>
|
||||
<button type="button" disabled={chosen === null || chosen >= rows.length} onClick={remove}>
|
||||
{t('dnsServer.remove')}
|
||||
</button>
|
||||
</div>
|
||||
{notice === null ? null : (
|
||||
<p className="mail-notice dns-server-notice" role="alert">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<table className="dns-server-table" aria-label={t('dnsServer.tables')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t(words.columns[0])}</th>
|
||||
<th>{t(words.columns[1])}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([name, data], row) => (
|
||||
<tr key={row} aria-selected={row === chosen} onPointerDown={() => setChosen(row)}>
|
||||
{([name, data] as const).map((value, column) => (
|
||||
<td key={column}>
|
||||
<RecordCell
|
||||
type={tab}
|
||||
column={column as 0 | 1}
|
||||
value={value}
|
||||
label={`${t(words.columns[column]!)} ${t('dnsServer.row', { row: row + 1 })}`}
|
||||
onCommit={(written) => act(node, 'dnsServer', 'changeRecord', { type: tab, row, column, value: written })}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,15 +17,37 @@ const NO_LINES: readonly LogLine<ServerLogEntry>[] = [];
|
||||
/** A cell of the table, written to the server when it is left or Enter is pressed and it changed. */
|
||||
function HostCell({ value, label, onCommit }: { readonly value: string; readonly label: string; readonly onCommit: (value: string) => void }) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => setDraft(value), [value]);
|
||||
const [editing, setEditing] = useState(false);
|
||||
// The server's value, but not over what is being typed since the last Enter, Escape or leaving (it may come just after a cell written).
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(value);
|
||||
}, [value, editing]);
|
||||
const commit = () => {
|
||||
setEditing(false);
|
||||
if (draft !== value) onCommit(draft);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') commit();
|
||||
if (event.key === 'Escape') setDraft(value);
|
||||
if (event.key === 'Escape') {
|
||||
setEditing(false);
|
||||
setDraft(value);
|
||||
}
|
||||
};
|
||||
return <input value={draft} aria-label={label} autoCapitalize="off" autoCorrect="off" spellCheck={false} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={onKeyDown} />;
|
||||
return (
|
||||
<input
|
||||
value={draft}
|
||||
aria-label={label}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setEditing(true);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebServerPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
|
||||
|
||||
@@ -1083,3 +1083,60 @@
|
||||
padding: 4px 6px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* The DNS server (GUIApplicationDNSServerWindow): button and box, three tabs
|
||||
* of two fields, two buttons and a table whose cells are written in place. */
|
||||
.dns-server-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dns-server input:not([type='checkbox']) {
|
||||
min-height: 32px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
font: 16px Dialog, sans-serif;
|
||||
}
|
||||
|
||||
.dns-server [aria-invalid='true'] {
|
||||
border-color: #ff0000;
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
.dns-server-notice {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.dns-server-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.dns-server-table th {
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid #a0a0a0;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.dns-server-table td {
|
||||
padding: 2px;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
.dns-server-table td input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.dns-server-table tr[aria-selected='true'] td,
|
||||
.dns-server-table tr[aria-selected='true'] input {
|
||||
background: #b8cfe5;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,31 @@
|
||||
"dhcp.static": "Statische Adresszuweisung",
|
||||
"dhcp.subnetMask": "Netzmaske",
|
||||
"dhcp.upperBound": "Adress-Obergrenze",
|
||||
"dnsServer.aAddress": "IP-Adresse:",
|
||||
"dnsServer.aAddressColumn": "IP-Adresse",
|
||||
"dnsServer.aName": "Host-/Domainname:",
|
||||
"dnsServer.aNameColumn": "Host-/Domainname",
|
||||
"dnsServer.add": "Hinzufügen",
|
||||
"dnsServer.invalidA": "Eingaben sind ungültig! Valides Beispiel: \n> IP-Adresse: 192.168.0.10\n> Domainname: www.filius.de",
|
||||
"dnsServer.invalidMX": "Eingaben sind ungültig! Valides Beispiel: \n> Maildomain: filius.de\n> Mailserver: mail.filius.de",
|
||||
"dnsServer.invalidNS": "Eingaben sind ungültig! Valides Beispiel: \n> Domainname: filius.de\n> Nameserver: dns.filius.de",
|
||||
"dnsServer.mxDomain": "Maildomain:",
|
||||
"dnsServer.mxDomainColumn": "Maildomain",
|
||||
"dnsServer.mxServer": "Domainname Mailserver:",
|
||||
"dnsServer.mxServerColumn": "Domainname Mailserver",
|
||||
"dnsServer.nsDomain": "Domain:",
|
||||
"dnsServer.nsDomainColumn": "Domain",
|
||||
"dnsServer.nsServer": "Nameserver:",
|
||||
"dnsServer.nsServerColumn": "Nameserver",
|
||||
"dnsServer.recursive": "Aktiviere rekursive Domain-Auflösung",
|
||||
"dnsServer.remove": "Auswahl entfernen",
|
||||
"dnsServer.row": "Zeile {row}",
|
||||
"dnsServer.start": "Starten",
|
||||
"dnsServer.stop": "Beenden",
|
||||
"dnsServer.tabA": "Adressen (A)",
|
||||
"dnsServer.tabMX": "Mailaustausch (MX)",
|
||||
"dnsServer.tabNS": "Nameserver (NS)",
|
||||
"dnsServer.tables": "Einträge",
|
||||
"echoServer.log": "Ausgabe",
|
||||
"echoServer.port": "Port:",
|
||||
"echoServer.start": "Starten",
|
||||
|
||||
@@ -59,6 +59,31 @@
|
||||
"dhcp.static": "Static Address Assignment",
|
||||
"dhcp.subnetMask": "Netmask",
|
||||
"dhcp.upperBound": "Upper bound of address",
|
||||
"dnsServer.aAddress": "IP address:",
|
||||
"dnsServer.aAddressColumn": "IP address",
|
||||
"dnsServer.aName": "Host/domain name:",
|
||||
"dnsServer.aNameColumn": "Host/domain name",
|
||||
"dnsServer.add": "Add",
|
||||
"dnsServer.invalidA": "Input invalid! Valid example: \n> IP address: 192.168.0.10\n> Domain name: www.filius.de",
|
||||
"dnsServer.invalidMX": "Input invalid! Valid example: \n> Mail domain: filius.de\n> Mail server: mail.filius.de!",
|
||||
"dnsServer.invalidNS": "Input invalid! Valid example: \n> Domain name: filius.de\n> Name server: dns.filius.de",
|
||||
"dnsServer.mxDomain": "Mail domain:",
|
||||
"dnsServer.mxDomainColumn": "Mail domain",
|
||||
"dnsServer.mxServer": "Mail server domain name:",
|
||||
"dnsServer.mxServerColumn": "Mail server domain name",
|
||||
"dnsServer.nsDomain": "Domain:",
|
||||
"dnsServer.nsDomainColumn": "Domain",
|
||||
"dnsServer.nsServer": "Name server:",
|
||||
"dnsServer.nsServerColumn": "Name server",
|
||||
"dnsServer.recursive": "Enable recursive domain resolution",
|
||||
"dnsServer.remove": "Remove entry",
|
||||
"dnsServer.row": "Row {row}",
|
||||
"dnsServer.start": "Start",
|
||||
"dnsServer.stop": "Stop",
|
||||
"dnsServer.tabA": "Address (A)",
|
||||
"dnsServer.tabMX": "Mail exchange (MX)",
|
||||
"dnsServer.tabNS": "Name server (NS)",
|
||||
"dnsServer.tables": "Records",
|
||||
"echoServer.log": "Output",
|
||||
"echoServer.port": "Port:",
|
||||
"echoServer.start": "Start",
|
||||
|
||||
@@ -61,6 +61,26 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'simpleClient.failure.unresolved': 'sw_socket_msg1',
|
||||
'simpleClient.failure.timedOut': 'sw_tcpsocket_msg2',
|
||||
'simpleClient.failure.failed': 'sw_tcpsocket_msg3',
|
||||
'dnsServer.start': 'dnsserver_msg1',
|
||||
'dnsServer.stop': 'dnsserver_msg14',
|
||||
'dnsServer.tabA': 'dnsserver_msg2',
|
||||
'dnsServer.tabMX': 'dnsserver_msg3',
|
||||
'dnsServer.tabNS': 'dnsserver_msg15',
|
||||
'dnsServer.aName': 'dnsserver_msg4',
|
||||
'dnsServer.aAddress': 'dnsserver_msg5',
|
||||
'dnsServer.add': 'dnsserver_msg6',
|
||||
'dnsServer.remove': 'dnsserver_msg7',
|
||||
'dnsServer.aNameColumn': 'dnsserver_msg8',
|
||||
'dnsServer.aAddressColumn': 'dnsserver_msg9',
|
||||
'dnsServer.mxDomain': 'dnsserver_msg10',
|
||||
'dnsServer.mxServer': 'dnsserver_msg11',
|
||||
'dnsServer.mxDomainColumn': 'dnsserver_msg12',
|
||||
'dnsServer.mxServerColumn': 'dnsserver_msg13',
|
||||
'dnsServer.nsDomain': 'dnsserver_msg16',
|
||||
'dnsServer.nsServer': 'dnsserver_msg17',
|
||||
'dnsServer.nsDomainColumn': 'dnsserver_msg18',
|
||||
'dnsServer.nsServerColumn': 'dnsserver_msg19',
|
||||
'dnsServer.recursive': 'dnsserver_msg20',
|
||||
'textEditor.new': 'texteditor_msg3',
|
||||
'textEditor.open': 'texteditor_msg4',
|
||||
'textEditor.save': 'texteditor_msg5',
|
||||
@@ -253,6 +273,10 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
|
||||
'router.prompt': ['guicontainer_msg1', (java) => java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')],
|
||||
// A label above its list, without the colon of Java's label beside it.
|
||||
'sheet.network': ['jhostkonfiguration_msg15', (java) => java.replace(/:$/, '')],
|
||||
// Java's message dialog breaks its lines with \n.
|
||||
'dnsServer.invalidA': ['dnsserver_msg21', (java) => java.replaceAll('\\n', '\n')],
|
||||
'dnsServer.invalidNS': ['dnsserver_msg22', (java) => java.replaceAll('\\n', '\n')],
|
||||
'dnsServer.invalidMX': ['dnsserver_msg23', (java) => java.replaceAll('\\n', '\n')],
|
||||
// Java writes "Eintrag Löschen".
|
||||
'sheet.deleteEntry': ['jvermittlungsrechnerkonfiguration_msg13', (java) => java.replace('Löschen', 'löschen')],
|
||||
// Java's word, then what it joins or which card.
|
||||
|
||||
@@ -1,141 +1,17 @@
|
||||
// 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).
|
||||
// Java's DNS server keeps its records in the text file /dns/hosts; reading
|
||||
// it lives in the core (runtime/dnsHosts.ts), whose engine answers from the
|
||||
// file as Java's server does. Here: finding the file in a project's tree.
|
||||
|
||||
import { javaIpCheck, javaParseInt, type DnsResourceRecord } from '@mdg-filius/core';
|
||||
import { isHostsDomainName } 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 { DNS_HOSTS_PATH, dnsRecordsOf, readDnsHosts, type HostsRecord } from '@mdg-filius/core';
|
||||
|
||||
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]))$/;
|
||||
/** A domain name as Java's patterns should mean it (core's isHostsDomainName). */
|
||||
export const isDomainName = isHostsDomainName;
|
||||
|
||||
/** 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;
|
||||
return javaFileContent(root, '/dns/hosts');
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
// /mailserver/konten.txt at once), and an e-mail program's inbox and sent
|
||||
// mails.
|
||||
|
||||
import { portSnapshot, topologySnapshot, type DnsServerConfiguration, type EchoServerSnapshot, type EmailServerConfiguration, type EmailServerSnapshot, type Id, type WebServerSnapshot, type TopologySnapshot } from '@mdg-filius/core';
|
||||
import { portSnapshot, topologySnapshot, type DnsServerSnapshot, type EchoServerSnapshot, type EmailServerConfiguration, type EmailServerSnapshot, type Id, type WebServerSnapshot, type TopologySnapshot } from '@mdg-filius/core';
|
||||
import type { ImportedProject } from './importProject.js';
|
||||
|
||||
export function projectSnapshot(project: ImportedProject): TopologySnapshot {
|
||||
const dnsServers = new Map<Id, DnsServerConfiguration>();
|
||||
const dnsServers = new Map<Id, DnsServerSnapshot>();
|
||||
for (const [nodeId, configuration] of project.dnsServerConfigurations) {
|
||||
if (project.activePrograms.get(nodeId)?.has('dnsServer') === true) dnsServers.set(nodeId, configuration);
|
||||
dnsServers.set(nodeId, { ...configuration, isActive: project.activePrograms.get(nodeId)?.has('dnsServer') === true });
|
||||
}
|
||||
const echoServers = new Map<Id, EchoServerSnapshot>();
|
||||
for (const [nodeId, configuration] of project.echoServerConfigurations) {
|
||||
|
||||
@@ -714,7 +714,7 @@ describe('importProject rules', () => {
|
||||
expect(project.hostWirelessConfigurations.get(withoutNetwork!)).toEqual({ isEnabled: true, ssid: '' });
|
||||
});
|
||||
|
||||
it('runs only the DNS servers that are switched on (projectSnapshot)', () => {
|
||||
it('runs every DNS server, switched on or off, and says which is on (projectSnapshot)', () => {
|
||||
const dnsServer = (isActive: boolean): FlsApplication => ({ key: 'dns', javaClass: 'filius.software.dns.DNSServer', source: flsNode('Rechner').source, isActive });
|
||||
const project = importProject(
|
||||
topology({
|
||||
@@ -728,7 +728,10 @@ describe('importProject rules', () => {
|
||||
const [on, off] = project.graph.nodes.map((node) => node.id);
|
||||
|
||||
expect([...project.dnsServerConfigurations.keys()]).toEqual([on, off]);
|
||||
expect([...projectSnapshot(project).dnsServerConfigurationsByNodeId.keys()]).toEqual([on]);
|
||||
expect([...projectSnapshot(project).dnsServerConfigurationsByNodeId].map(([id, server]) => [id, server.isActive])).toEqual([
|
||||
[on, true],
|
||||
[off, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs every echo server, switched on or off, and every generic client (projectSnapshot)', () => {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// What the DNS server's window shows, as the room keeps it and every client
|
||||
// shows it (plan §7.3), after GUIApplicationDNSServerWindow (measured with
|
||||
// tools/java-oracle's DnsWindowRun): its button, the box for recursive
|
||||
// resolution, and the rows of its three tables — name and address (A), mail
|
||||
// domain and mail server (MX), domain and name server (NS) — as /dns/hosts
|
||||
// holds them.
|
||||
|
||||
export type DnsTable = 'A' | 'MX' | 'NS';
|
||||
|
||||
export interface DnsServerView {
|
||||
readonly active: boolean;
|
||||
readonly recursive: boolean;
|
||||
readonly rows: Readonly<Record<DnsTable, readonly (readonly [string, string])[]>>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
export * from './host.js';
|
||||
export * from './sim.js';
|
||||
export * from './terminal.js';
|
||||
export * from './dns.js';
|
||||
export * from './echo.js';
|
||||
export * from './mail.js';
|
||||
export * from './web.js';
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { CaptureRow, PageLanguage } from '@mdg-filius/core';
|
||||
export const SIM_PROTOCOL_VERSION = 1;
|
||||
|
||||
/** The programs whose windows a client can watch and act in. */
|
||||
export const APP_KINDS = ['terminal', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'gnutella'] as const;
|
||||
export const APP_KINDS = ['terminal', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'gnutella', 'dnsServer'] as const;
|
||||
export type AppKind = (typeof APP_KINDS)[number];
|
||||
|
||||
/**
|
||||
@@ -78,7 +78,8 @@ export type HostMessage =
|
||||
* does not run, a browser that never ran): the browser's (BrowserView), the
|
||||
* web server's (WebServerView), the e-mail program's and server's
|
||||
* (EmailClientView, EmailServerView), the echo server's and the generic
|
||||
* client's (EchoServerView, SimpleClientView).
|
||||
* client's (EchoServerView, SimpleClientView), the DNS server's
|
||||
* (DnsServerView).
|
||||
*/
|
||||
| { readonly v: 1; readonly kind: 'view'; readonly node: string; readonly app: AppKind; readonly view: unknown }
|
||||
/**
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
type Topic,
|
||||
type EmailClientView,
|
||||
type EmailServerView,
|
||||
type DnsServerView,
|
||||
type EchoServerView,
|
||||
type SimpleClientView,
|
||||
type TraceFrame,
|
||||
@@ -244,6 +245,12 @@ export class SimulationRoom {
|
||||
active.echoServer = echo.isActive;
|
||||
settings.echoServer = { port: echo.port };
|
||||
}
|
||||
const dns = this.engine.dnsServer(id);
|
||||
const dnsSettings = setting('dnsServer');
|
||||
if (dns !== undefined && dnsSettings !== undefined) {
|
||||
active.dnsServer = dns.isActive;
|
||||
settings.dnsServer = { ...(dnsSettings as object), recursiveResolutionEnabled: dns.recursiveResolutionEnabled, records: dns.answering };
|
||||
}
|
||||
const client = this.engine.simpleClient(id);
|
||||
if (client !== undefined) settings.simpleClient = { targetAddress: client.targetAddress, targetPort: client.targetPort };
|
||||
settings.emailClient = snapshot.emailClientsByNodeId.get(id);
|
||||
@@ -454,6 +461,38 @@ export class SimulationRoom {
|
||||
return this.actInEchoServer(input);
|
||||
case 'simpleClient':
|
||||
return this.actInSimpleClient(input);
|
||||
case 'dnsServer':
|
||||
return this.actInDnsServer(input);
|
||||
default:
|
||||
return 'unknownAction';
|
||||
}
|
||||
}
|
||||
|
||||
/** The start button, the box for recursive resolution, a table's "Hinzufügen", "Auswahl entfernen" and a cell written. */
|
||||
private actInDnsServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||
const server = this.engine.dnsServer(input.node);
|
||||
if (server === undefined) return 'unknownApp';
|
||||
const { args } = input;
|
||||
const type = args.type === 'A' || args.type === 'MX' || args.type === 'NS' ? args.type : undefined;
|
||||
const row = Number.isInteger(args.row) ? (args.row as number) : undefined;
|
||||
switch (input.action) {
|
||||
case 'setActive':
|
||||
if (typeof args.active !== 'boolean') return 'badArgs';
|
||||
server.setActive(args.active);
|
||||
return undefined;
|
||||
case 'setRecursive':
|
||||
if (typeof args.enabled !== 'boolean') return 'badArgs';
|
||||
server.setRecursiveResolutionEnabled(args.enabled);
|
||||
return undefined;
|
||||
case 'addRecord':
|
||||
if (type === undefined || typeof args.name !== 'string' || typeof args.data !== 'string') return 'badArgs';
|
||||
return server.addRecord(type, args.name, args.data) ? undefined : 'badArgs';
|
||||
case 'removeRecord':
|
||||
if (type === undefined || row === undefined) return 'badArgs';
|
||||
return server.removeRecord(type, row) ? undefined : 'badArgs';
|
||||
case 'changeRecord':
|
||||
if (type === undefined || row === undefined || (args.column !== 0 && args.column !== 1) || typeof args.value !== 'string') return 'badArgs';
|
||||
return server.changeRecord(type, row, args.column, args.value) ? undefined : 'badArgs';
|
||||
default:
|
||||
return 'unknownAction';
|
||||
}
|
||||
@@ -634,6 +673,13 @@ export class SimulationRoom {
|
||||
const view: EmailClientView = { account: client.account, inbox: client.inbox.map((entry) => entry.mail), sent: client.sent.map((entry) => entry.mail) };
|
||||
return view;
|
||||
}
|
||||
case 'dnsServer': {
|
||||
const server = this.engine.dnsServer(node);
|
||||
if (server === undefined) return null;
|
||||
const rows = (type: 'A' | 'MX' | 'NS') => server.rows(type).map(({ record }) => [record.domainName, record.rdata] as const);
|
||||
const view: DnsServerView = { active: server.isActive, recursive: server.recursiveResolutionEnabled, rows: { A: rows('A'), MX: rows('MX'), NS: rows('NS') } };
|
||||
return view;
|
||||
}
|
||||
case 'echoServer': {
|
||||
const server = this.engine.echoServer(node);
|
||||
if (server === undefined) return null;
|
||||
@@ -725,8 +771,8 @@ export class SimulationRoom {
|
||||
client.viewSent.set(topic, key);
|
||||
client.send(message({ kind: 'view', node: node!, app: app as AppKind, view }));
|
||||
}
|
||||
// The browser's lines are its view.
|
||||
if (app === 'webBrowser') continue;
|
||||
// The browser's lines are its view; the DNS server's window has none.
|
||||
if (app === 'webBrowser' || app === 'dnsServer') continue;
|
||||
}
|
||||
const sent = client.appSent.get(topic);
|
||||
if (app === 'terminal') {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// The DNS server's window in the room, on FILIUS' example email_komplett:
|
||||
// its view (button, box, the three tables as /dns/hosts holds them), its
|
||||
// actions, /dns/hosts written by another into the document and shown at
|
||||
// once, and what a run leaves in the project (the file, whether it runs,
|
||||
// recursive resolution, the records in its settings). The server's steps
|
||||
// against Java's window are packages/core/test/dnsWindow.test.ts.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
import { changeFiles, fileText, readProject, writeProject } from '@mdg-filius/doc';
|
||||
import { openProjectArchive } from '@mdg-filius/fls';
|
||||
import type { DnsServerView, HostMessage } from '@mdg-filius/protocol';
|
||||
import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const corpus = join(here, '../../../corpus/java-examples');
|
||||
|
||||
function setUp() {
|
||||
let ids = 0;
|
||||
const doc = new Y.Doc();
|
||||
writeProject(
|
||||
doc,
|
||||
openProjectArchive(new Uint8Array(readFileSync(join(corpus, 'email_komplett.fls'))), () => `00000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`),
|
||||
);
|
||||
const id = readProject(doc).graph.nodes.find((node) => node.displayName === 'DNS-Server')!.id;
|
||||
let now = 0;
|
||||
const room = new SimulationRoom(doc, { now: () => now, seed: () => 5 });
|
||||
let view: DnsServerView | null | undefined;
|
||||
const errors: string[] = [];
|
||||
const lines: HostMessage[] = [];
|
||||
const client = room.connect((message: HostMessage) => {
|
||||
if (message.kind === 'app') lines.push(message);
|
||||
if (message.kind === 'view' && message.app === 'dnsServer') view = message.view as DnsServerView | null;
|
||||
if (message.kind === 'error') errors.push(message.code);
|
||||
});
|
||||
const send = (fields: object) => room.receive(client, { v: 1, ...fields });
|
||||
send({ kind: 'sub', topics: [`app:${id}/dnsServer`] });
|
||||
send({ kind: 'start' });
|
||||
const run = (milliseconds: number) => {
|
||||
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
|
||||
now += TICK_MILLISECONDS;
|
||||
room.tick();
|
||||
}
|
||||
};
|
||||
run(TICK_MILLISECONDS);
|
||||
const act = (action: string, args: object = {}) => send({ kind: 'input', node: id, app: 'dnsServer', action, args, reqId: action });
|
||||
return { doc, id, room, view: () => view, errors, lines, act, run, send };
|
||||
}
|
||||
|
||||
describe("the DNS server's window in the room", () => {
|
||||
it('shows the server and its tables, takes its actions, and shows what another writes into /dns/hosts at once', () => {
|
||||
const { doc, id, view, act, run, errors, lines } = setUp();
|
||||
// Java's DNS window has no lines: none are sent.
|
||||
expect(lines).toEqual([]);
|
||||
const hosts = () => fileText(doc, id, '/dns/hosts')!.toString();
|
||||
expect(view()!.active).toBe(true);
|
||||
const before = view()!.rows.A.length;
|
||||
act('addRecord', { type: 'A', name: 'neu.schule.de', data: '10.0.0.1' });
|
||||
act('addRecord', { type: 'MX', name: 'schule.de', data: 'mail.schule.de' });
|
||||
act('addRecord', { type: 'NS', name: 'schule.de', data: 'ns.schule.de' });
|
||||
expect(view()!.rows.A.at(-1)).toEqual(['neu.schule.de.', '10.0.0.1']);
|
||||
expect([view()!.rows.MX.at(-1), view()!.rows.NS.at(-1)]).toEqual([
|
||||
['schule.de.', 'mail.schule.de.'],
|
||||
['schule.de.', 'ns.schule.de.'],
|
||||
]);
|
||||
// In the document's file at once.
|
||||
expect(hosts().endsWith('neu.schule.de. A 3600 10.0.0.1\nschule.de. MX 3600 mail.schule.de.\nschule.de. NS 3600 ns.schule.de.\n')).toBe(true);
|
||||
act('changeRecord', { type: 'A', row: before, column: 1, value: '10.0.0.2' });
|
||||
expect(view()!.rows.A.at(-1)).toEqual(['neu.schule.de.', '10.0.0.2']);
|
||||
act('removeRecord', { type: 'NS', row: 0 });
|
||||
expect(view()!.rows.NS).toEqual([]);
|
||||
act('setRecursive', { enabled: true });
|
||||
act('setActive', { active: false });
|
||||
expect([view()!.active, view()!.recursive]).toEqual([false, true]);
|
||||
expect(errors).toEqual([]);
|
||||
// The text editor writes the file: the table shows it.
|
||||
changeFiles(doc, id, 'me', (files) => files.writeTextFile('/dns/hosts', `${files.textFile('/dns/hosts')}extern.schule.de. A 3600 10.0.0.9\n`));
|
||||
run(TICK_MILLISECONDS);
|
||||
expect(view()!.rows.A.at(-1)).toEqual(['extern.schule.de.', '10.0.0.9']);
|
||||
});
|
||||
|
||||
it('leaves in the project what the run made of the server', () => {
|
||||
const { doc, id, act, send } = setUp();
|
||||
act('addRecord', { type: 'A', name: 'neu.schule.de', data: '10.0.0.1' });
|
||||
act('setRecursive', { enabled: true });
|
||||
act('setActive', { active: false });
|
||||
send({ kind: 'stop' });
|
||||
const project = readProject(doc);
|
||||
expect(project.activePrograms.get(id)?.has('dnsServer') ?? false).toBe(false);
|
||||
expect(project.dnsServerConfigurations.get(id)!.recursiveResolutionEnabled).toBe(true);
|
||||
expect(project.dnsServerConfigurations.get(id)!.records.at(-1)).toEqual({ name: 'neu.schule.de', type: 'A', ttlSeconds: 3600, target: '10.0.0.1' });
|
||||
expect(project.virtualFileSystems.get(id)!.textFile('/dns/hosts').endsWith('neu.schule.de. A 3600 10.0.0.1\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses what the window never hands over', () => {
|
||||
const { act, errors } = setUp();
|
||||
act('setActive', { active: 'no' });
|
||||
act('setRecursive', {});
|
||||
act('addRecord', { type: 'TXT', name: 'a.de', data: 'x' });
|
||||
act('addRecord', { type: 'A', name: '', data: '10.0.0.1' });
|
||||
act('addRecord', { type: 'A', name: 'a.de', data: 5 });
|
||||
act('removeRecord', { type: 'A', row: 1.5 });
|
||||
act('removeRecord', { type: 'NS', row: 0 });
|
||||
act('changeRecord', { type: 'A', row: 0, column: 2, value: 'x' });
|
||||
act('changeRecord', { type: 'A', row: 0, column: 1, value: 'kein' });
|
||||
act('changeRecord', { type: 'A', row: 0, column: 1, value: 3 });
|
||||
act('restart');
|
||||
expect(errors).toEqual(['badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'unknownAction']);
|
||||
});
|
||||
});
|
||||
@@ -1493,3 +1493,37 @@ Gemessen (03.10.2026):
|
||||
konnte nicht bestimmt werden!“.
|
||||
* Nach dem Lauf speichert Java den Port des Servers, dass er läuft, und
|
||||
Adresse und Port, die der Client zuletzt nahm.
|
||||
|
||||
### DNS-Server (F4.6g2)
|
||||
|
||||
`DnsWindowRun` öffnet Javas `GUIApplicationDNSServerWindow` auf dem
|
||||
DNS-Server von mail-lan (Einträge A, MX gemischt) und benutzt es wie eine
|
||||
Person: Startknopf, Kästchen „Aktiviere rekursive Domain-Auflösung“, je
|
||||
Reiter (A, MX, NS) die zwei Felder und „Hinzufügen“ (auch mit dem, was Java
|
||||
nicht nimmt), eine Tabellenzelle bearbeitet (wie Javas Tabelle sie beim Ende
|
||||
der Bearbeitung schreibt), eine Zeile gewählt und „Auswahl entfernen“;
|
||||
daneben schreibt ein anderes Programm in `/dns/hosts`. Je Schritt: Knopf,
|
||||
Kästchen, Felder, die drei Tabellen, die Datei, Javas Dialoge und was der
|
||||
Resolver des ersten Rechners für einige Namen bekommt
|
||||
(`packages/core/test/dnsWindow.java.json`).
|
||||
|
||||
Gemessen (03.10.2026):
|
||||
|
||||
* Javas DNS-Server liest `/dns/hosts` bei jeder Anfrage: Ein von außen
|
||||
eingetragener Name wird sofort beantwortet. Angehalten laufen Anfragen in
|
||||
die Zeitüberschreitung.
|
||||
* Das Adressfeld zeigt anfangs die eigene Adresse; nach „Hinzufügen“ sind
|
||||
beide Felder leer. Was Javas Muster nicht nehmen, meldet ein Dialog mit
|
||||
einem gültigen Beispiel; eine ungültige Zelle geht still zurück.
|
||||
Doppelte Einträge sind erlaubt. Ein neuer Eintrag bekommt seinen Punkt am
|
||||
Namen (MX und NS auch am Ziel) und TTL 3600.
|
||||
* Javas Fehler:
|
||||
* Eine bearbeitete Zelle zählt nur die Einträge ihrer Art und schreibt
|
||||
nach dem Treffer in jeden Eintrag anderer Art bis zum nächsten eigenen:
|
||||
Der Mailserver eines MX-Eintrags landete als „Adresse“ im folgenden
|
||||
A-Eintrag, der dann ungültig ist und beim nächsten Schreiben verschwand.
|
||||
* „Hinzufügen“ schreibt die Liste zurück, die Java vorher gelesen hat:
|
||||
Was ein anderes Programm inzwischen in die Datei schrieb, ist weg.
|
||||
* Eine leere Maildomain wird genommen (`. MX 3600 x.`).
|
||||
* „Auswahl entfernen“ entfernt den ersten Eintrag des Namens, bei zwei
|
||||
gleichen Namen also womöglich den anderen.
|
||||
|
||||
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
|
||||
fi
|
||||
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
||||
mkdir -p "$here/.build"
|
||||
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java" "$here/src/FileExplorerWindowRun.java" "$here/src/EchoWindowRun.java"
|
||||
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java" "$here/src/FileExplorerWindowRun.java" "$here/src/EchoWindowRun.java" "$here/src/DnsWindowRun.java"
|
||||
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
||||
mkdir -p "$here/.build/home"
|
||||
# Removed first: a crashed run must not leave the previous result standing.
|
||||
@@ -432,6 +432,16 @@ HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -
|
||||
"$root/packages/core/test/scenarios/mail-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true
|
||||
test -s "$output"
|
||||
|
||||
# FILIUS' DNS server window used as a person does — the button, the box,
|
||||
# each tab's fields and buttons, a cell edited, a row removed, /dns/hosts
|
||||
# changed beside it, the first computer's resolver asked (DnsWindowRun): the
|
||||
# DNS server's window (packages/core/test/dnsWindow.test.ts).
|
||||
output="$root/packages/core/test/dnsWindow.java.json"
|
||||
rm -f "$output"
|
||||
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" DnsWindowRun \
|
||||
"$root/packages/core/test/scenarios/mail-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true
|
||||
test -s "$output"
|
||||
|
||||
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
|
||||
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts)
|
||||
# and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
// Uses FILIUS' own DNS server window (GUIApplicationDNSServerWindow on a
|
||||
// GUIDesktopPanel) on mail-lan's DNS server as a person does: the start
|
||||
// button, the check box for recursive resolution, each tab's two fields and
|
||||
// "Hinzufügen" (also with what Java does not take), a table cell edited (as
|
||||
// Java's table writes it when its editing stops), a row chosen and "Auswahl
|
||||
// entfernen"; and /dns/hosts changed beside it as another program would.
|
||||
// Java's message dialogs are answered and written down. After each step it
|
||||
// writes what the window shows (the button, the box, the fields, the three
|
||||
// tables), the file, and what the first computer's resolver gets for a few
|
||||
// names. The reference for the room's DNS server window
|
||||
// (packages/sim/test/dnsWindow.test.ts).
|
||||
//
|
||||
// Usage: java -cp filius.jar:lib/*:classes DnsWindowRun <scenario.json> <output json>
|
||||
|
||||
import filius.gui.anwendungssicht.DNSConfigTable;
|
||||
import filius.gui.anwendungssicht.GUIApplicationDNSServerWindow;
|
||||
import filius.gui.anwendungssicht.GUIDesktopPanel;
|
||||
import filius.software.system.Betriebssystem;
|
||||
import filius.software.system.Datei;
|
||||
import filius.software.system.InternetKnotenBetriebssystem;
|
||||
|
||||
import java.awt.Component;
|
||||
import java.awt.Container;
|
||||
import java.awt.Window;
|
||||
import java.io.FileOutputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JTable;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.table.TableModel;
|
||||
|
||||
public class DnsWindowRun {
|
||||
static final String CLIENT = "00000000-0000-0000-0000-000000000201";
|
||||
static final String SERVER = "00000000-0000-0000-0000-000000000202";
|
||||
static final String[] NAMES = { "www.filius.de", "web.filius.de", "neu.schule.de", "extern.schule.de" };
|
||||
|
||||
static final List<Object> steps = new ArrayList<>();
|
||||
static final List<String> dialogs = new ArrayList<>();
|
||||
static GUIApplicationDNSServerWindow window;
|
||||
static Betriebssystem server;
|
||||
static InternetKnotenBetriebssystem client;
|
||||
|
||||
static Object field(Class<?> owner, Object object, String name) throws Exception {
|
||||
Field field = owner.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field.get(object);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static <T> T find(Container container, Class<T> kind) {
|
||||
for (Component component : container.getComponents()) {
|
||||
if (kind.isInstance(component)) return (T) component;
|
||||
if (component instanceof Container) {
|
||||
T found = find((Container) component, kind);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void watchDialogs() {
|
||||
Thread watcher = new Thread(() -> {
|
||||
while (true) {
|
||||
for (Window each : Window.getWindows()) {
|
||||
if (!(each instanceof JDialog) || !each.isVisible()) continue;
|
||||
JOptionPane pane = find((Container) each, JOptionPane.class);
|
||||
if (pane == null) continue;
|
||||
synchronized (dialogs) {
|
||||
dialogs.add(String.valueOf(pane.getMessage()));
|
||||
}
|
||||
pane.setValue(JOptionPane.OK_OPTION);
|
||||
each.setVisible(false);
|
||||
}
|
||||
try {
|
||||
Thread.sleep(50);
|
||||
} catch (InterruptedException error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
watcher.setDaemon(true);
|
||||
watcher.start();
|
||||
}
|
||||
|
||||
static void onEdt(Runnable action) throws Exception {
|
||||
SwingUtilities.invokeLater(action);
|
||||
Thread.sleep(500);
|
||||
}
|
||||
|
||||
static List<List<String>> rows(JTable table) {
|
||||
List<List<String>> rows = new ArrayList<>();
|
||||
TableModel model = table.getModel();
|
||||
for (int row = 0; row < model.getRowCount(); row++) {
|
||||
List<String> cells = new ArrayList<>();
|
||||
for (int column = 0; column < model.getColumnCount(); column++) cells.add(String.valueOf(model.getValueAt(row, column)));
|
||||
rows.add(cells);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
static String file() {
|
||||
Datei hosts = server.getDateisystem().holeDatei("/dns/hosts");
|
||||
return hosts == null ? null : hosts.getDateiInhalt();
|
||||
}
|
||||
|
||||
/** What the first computer's resolver gets for the name: the address, or the exception's class. */
|
||||
static String resolve(String name) throws Exception {
|
||||
AtomicReference<String> result = new AtomicReference<>("(no answer)");
|
||||
Thread thread = new Thread(() -> {
|
||||
try {
|
||||
result.set(String.valueOf(client.holeDNSClient().holeIPAdresse(name)));
|
||||
} catch (Exception error) {
|
||||
result.set(error.getClass().getSimpleName());
|
||||
}
|
||||
});
|
||||
thread.start();
|
||||
thread.join(30000);
|
||||
return result.get();
|
||||
}
|
||||
|
||||
static void step(String name, boolean ask) throws Exception {
|
||||
Class<?> w = GUIApplicationDNSServerWindow.class;
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("step", name);
|
||||
result.put("button", ((JButton) field(w, window, "buttonStart")).getText());
|
||||
result.put("recursive", ((JCheckBox) field(w, window, "recResolution")).isSelected());
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
for (String each : new String[] { "aDomainField", "aIpField", "mxMaildomainField", "mxURLField", "nsDomainField", "nsDomainServerField" }) {
|
||||
fields.put(each, ((JTextField) field(w, window, each)).getText());
|
||||
}
|
||||
result.put("fields", fields);
|
||||
result.put("a", rows((JTable) field(w, window, "aRecordsTable")));
|
||||
result.put("mx", rows((JTable) field(w, window, "mxRecordsTable")));
|
||||
result.put("ns", rows((JTable) field(w, window, "nsRecordsTable")));
|
||||
result.put("file", file());
|
||||
synchronized (dialogs) {
|
||||
result.put("dialogs", new ArrayList<>(dialogs));
|
||||
dialogs.clear();
|
||||
}
|
||||
if (ask) {
|
||||
Map<String, String> answers = new LinkedHashMap<>();
|
||||
for (String each : NAMES) answers.put(each, resolve(each));
|
||||
result.put("resolved", answers);
|
||||
}
|
||||
steps.add(result);
|
||||
}
|
||||
|
||||
static void type(String name, String text) throws Exception {
|
||||
JTextField field = (JTextField) field(GUIApplicationDNSServerWindow.class, window, name);
|
||||
onEdt(() -> field.setText(text));
|
||||
}
|
||||
|
||||
/** The tab's "Hinzufügen" or "Auswahl entfernen" (the n-th of that text in the window). */
|
||||
static void press(String text, int index) throws Exception {
|
||||
List<JButton> found = new ArrayList<>();
|
||||
collect(window, text, found);
|
||||
JButton button = found.get(index);
|
||||
onEdt(button::doClick);
|
||||
}
|
||||
|
||||
static void collect(Container container, String text, List<JButton> found) {
|
||||
for (Component component : container.getComponents()) {
|
||||
if (component instanceof JButton && text.equals(((JButton) component).getText())) found.add((JButton) component);
|
||||
if (component instanceof Container) collect((Container) component, text, found);
|
||||
}
|
||||
}
|
||||
|
||||
/** A cell written as Java's table writes it: edited, then its editing stopped. */
|
||||
static void edit(String table, int row, int column, String value) throws Exception {
|
||||
DNSConfigTable each = (DNSConfigTable) field(GUIApplicationDNSServerWindow.class, window, table);
|
||||
onEdt(() -> {
|
||||
each.editCellAt(row, column);
|
||||
((JTextField) each.getEditorComponent()).setText(value);
|
||||
each.getCellEditor().stopCellEditing();
|
||||
});
|
||||
}
|
||||
|
||||
static void choose(String table, int row) throws Exception {
|
||||
JTable each = (JTable) field(GUIApplicationDNSServerWindow.class, window, table);
|
||||
onEdt(() -> each.setRowSelectionInterval(row, row));
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
try {
|
||||
run(args);
|
||||
} catch (Throwable error) {
|
||||
error.printStackTrace();
|
||||
System.exit(1);
|
||||
}
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
static void run(String[] args) throws Exception {
|
||||
ScenarioNetwork network = ScenarioNetwork.read(args[0]);
|
||||
server = (Betriebssystem) network.nodes.get(SERVER).getSystemSoftware();
|
||||
client = (InternetKnotenBetriebssystem) network.nodes.get(CLIENT).getSystemSoftware();
|
||||
network.start();
|
||||
Thread.sleep(500);
|
||||
watchDialogs();
|
||||
|
||||
JFrame frame = new JFrame();
|
||||
GUIDesktopPanel desktop = new GUIDesktopPanel(server);
|
||||
frame.getContentPane().add(desktop);
|
||||
window = (GUIApplicationDNSServerWindow) desktop.starteAnwendung("filius.software.dns.DNSServer");
|
||||
SwingUtilities.invokeAndWait(() -> {
|
||||
frame.pack();
|
||||
frame.setVisible(true);
|
||||
});
|
||||
Thread.sleep(500);
|
||||
step("open", true);
|
||||
|
||||
// A records: what Java does not take, then a new one; the fields after it.
|
||||
type("aDomainField", "kein name!");
|
||||
type("aIpField", "192.168.0.30");
|
||||
press("Hinzufügen", 0);
|
||||
step("A: add an invalid name", false);
|
||||
type("aDomainField", "neu.schule.de");
|
||||
type("aIpField", "192.168.0.300");
|
||||
press("Hinzufügen", 0);
|
||||
step("A: add an invalid address", false);
|
||||
type("aDomainField", "neu.schule.de");
|
||||
type("aIpField", "10.0.0.1");
|
||||
press("Hinzufügen", 0);
|
||||
step("A: add neu.schule.de", true);
|
||||
type("aDomainField", "neu.schule.de");
|
||||
type("aIpField", "10.0.0.2");
|
||||
press("Hinzufügen", 0);
|
||||
step("A: add neu.schule.de again", false);
|
||||
|
||||
// MX and NS.
|
||||
type("mxMaildomainField", "schule.de");
|
||||
type("mxURLField", "mail.schule.de");
|
||||
press("Hinzufügen", 1);
|
||||
step("MX: add schule.de", false);
|
||||
type("mxMaildomainField", "");
|
||||
type("mxURLField", "x");
|
||||
press("Hinzufügen", 1);
|
||||
step("MX: add with no domain", false);
|
||||
type("nsDomainField", "schule.de");
|
||||
type("nsDomainServerField", "ns.schule.de");
|
||||
press("Hinzufügen", 2);
|
||||
step("NS: add schule.de", false);
|
||||
|
||||
// A table cell edited: the first A record's name; an invalid value; the address of the third.
|
||||
edit("aRecordsTable", 0, 0, "web.filius.de");
|
||||
step("A: edit row 0's name to web.filius.de", true);
|
||||
edit("aRecordsTable", 1, 1, "999.1.1.1");
|
||||
step("A: edit row 1's address to 999.1.1.1", false);
|
||||
edit("aRecordsTable", 2, 1, "192.168.0.99");
|
||||
step("A: edit row 2's address", false);
|
||||
edit("mxRecordsTable", 0, 1, "post.filius.de");
|
||||
step("MX: edit row 0's server", false);
|
||||
|
||||
// A row chosen and removed: the second neu.schule.de.
|
||||
choose("aRecordsTable", 5);
|
||||
press("Auswahl entfernen", 0);
|
||||
step("A: remove row 5", true);
|
||||
|
||||
// Recursive resolution, start and stop.
|
||||
JCheckBox recursive = (JCheckBox) field(GUIApplicationDNSServerWindow.class, window, "recResolution");
|
||||
onEdt(recursive::doClick);
|
||||
step("recursive on", false);
|
||||
JButton start = (JButton) field(GUIApplicationDNSServerWindow.class, window, "buttonStart");
|
||||
onEdt(start::doClick);
|
||||
Thread.sleep(1000);
|
||||
step("button once", true);
|
||||
onEdt(start::doClick);
|
||||
Thread.sleep(1000);
|
||||
step("button twice", true);
|
||||
|
||||
// Another program writes /dns/hosts; the window, the answers; then a record added in the window.
|
||||
Datei hosts = server.getDateisystem().holeDatei("/dns/hosts");
|
||||
onEdt(() -> hosts.setDateiInhalt(hosts.getDateiInhalt() + "extern.schule.de. A 3600 10.0.0.9\n"));
|
||||
step("file changed by another program", true);
|
||||
type("aDomainField", "danach.schule.de");
|
||||
type("aIpField", "10.0.0.3");
|
||||
press("Hinzufügen", 0);
|
||||
step("A: add after the file changed", true);
|
||||
choose("aRecordsTable", 0);
|
||||
press("Auswahl entfernen", 0);
|
||||
step("A: remove row 0 after the file changed", true);
|
||||
|
||||
Map<String, Object> output = new LinkedHashMap<>();
|
||||
output.put("steps", steps);
|
||||
try (FileOutputStream out = new FileOutputStream(args[1])) {
|
||||
out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
network.stop();
|
||||
System.out.println("dns window steps: " + steps.size());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user