feat(core): the firewall settings page of routers after Java, measured against Java
The page konfig changes the firewall at run time (into the snapshot, back into the project through projectWithSimulationChanges). KonfigCases feeds Java's plug-in directly (25 cases, de and en); RouterWebRun plays two stories over the network. Ten Java bugs named, not adopted. The web server answers a method not written GET or POST with 501, as Java does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ export * from './runtime/apps/server.js';
|
||||
export * from './runtime/apps/echo.js';
|
||||
export * from './runtime/apps/web.js';
|
||||
export * from './runtime/apps/routerPages.js';
|
||||
export * from './runtime/apps/firewallSettings.js';
|
||||
export * from './runtime/http.js';
|
||||
export * from './util/equal.js';
|
||||
export * from './util/javaBase64.js';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -81,6 +81,11 @@ const TEXTS: Record<PageLanguage, PageTexts> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** jfirewalldialog_msg33, msg34: a rule's action as the pages name it. */
|
||||
export function firewallActionText(action: 'accept' | 'drop', language: PageLanguage): string {
|
||||
return action === 'accept' ? TEXTS[language].accept : TEXTS[language].drop;
|
||||
}
|
||||
|
||||
/** The index page a router or home router keeps in /webserver (WebServer.erzeugeIndexDatei). */
|
||||
export function routerIndexPage(kind: 'router' | 'gateway', language: PageLanguage): string {
|
||||
return TEMPLATES[kind === 'router' ? 'routerIndex' : 'gatewayIndex'][language];
|
||||
@@ -114,7 +119,7 @@ export function firewallLogText(entry: FirewallLogEntry, language: PageLanguage)
|
||||
if (entry.kind === 'icmpDropped') {
|
||||
return `${texts.icmpDropped} ${entry.senderIPAddress} -> ${entry.receiverIPAddress} (code: ${entry.code}, type: ${entry.type})`;
|
||||
}
|
||||
return `${texts.ruleMatched} #${entry.ruleNumber} (${firewallRuleText(entry.rule, entry.firstInterface)}) -> ${entry.accepted ? texts.accept : texts.drop}`;
|
||||
return `${texts.ruleMatched} #${entry.ruleNumber} (${firewallRuleText(entry.rule, entry.firstInterface)}) -> ${firewallActionText(entry.accepted ? 'accept' : 'drop', language)}`;
|
||||
}
|
||||
|
||||
/** FirewallWebLog.holeHtmlSeite: the log since the engine was made. */
|
||||
|
||||
@@ -129,6 +129,8 @@ export class WebServer extends TcpServerProgram {
|
||||
});
|
||||
const parsed = parseHttpRequest(message);
|
||||
if (parsed.type === 'invalid') return response(parsed.statusCode);
|
||||
// HTTPNachricht reads the method in any case; the server answers only GET and POST as written.
|
||||
if (parsed.request.method !== HTTP_GET && parsed.request.method !== HTTP_POST) return response(501);
|
||||
const path = httpPath(parsed.request.path);
|
||||
let file = path === '/' ? 'index.html' : path.startsWith('/') ? path.slice(1) : path;
|
||||
let folder = this.virtualHostFolder(parsed.request.host);
|
||||
@@ -189,7 +191,7 @@ function javaSplit(text: string, separator: string): string[] {
|
||||
|
||||
/** EingabenUeberpruefung.musterDomain and musterIpAdresse. */
|
||||
const DOMAIN_PATTERN = /^([a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9]){0,1}(\.[a-zA-Z]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9]){0,1})*){0,1}\.{0,1}$/;
|
||||
const IP_ADDRESS_PATTERN =
|
||||
export const IP_ADDRESS_PATTERN =
|
||||
/^(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]))$/;
|
||||
|
||||
function isHostName(text: string): boolean {
|
||||
|
||||
@@ -116,6 +116,7 @@ import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.
|
||||
import { EchoServer, SimpleClient } from './apps/echo.js';
|
||||
import { WebBrowser, WebServer, type WebServerPage } from './apps/web.js';
|
||||
import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } from './apps/routerPages.js';
|
||||
import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js';
|
||||
import { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||
import {
|
||||
PRODUCTION_TRACE_RETENTION,
|
||||
@@ -1152,7 +1153,17 @@ export class NetworkRuntimeEngine {
|
||||
const files = new VirtualFileSystem();
|
||||
files.createDirectory('/webserver', true);
|
||||
files.writeTextFile('/webserver/index.html', routerIndexPage(kind, language));
|
||||
const pages = new Map<string, WebServerPage>([['log', () => firewallLogPage(this.firewallLog(nodeId), language)]]);
|
||||
const pages = new Map<string, WebServerPage>([
|
||||
['log', () => firewallLogPage(this.firewallLog(nodeId), language)],
|
||||
[
|
||||
'konfig',
|
||||
(postData) => {
|
||||
const result = applyFirewallSettings(this.firewallConfiguration(nodeId), postData);
|
||||
if (result.configuration !== this.firewallConfiguration(nodeId)) this.setFirewallConfiguration(nodeId, result.configuration);
|
||||
return firewallSettingsPage(result.configuration, language, result.rejected);
|
||||
},
|
||||
],
|
||||
]);
|
||||
if (kind === 'router') {
|
||||
pages.set('routes', () => {
|
||||
const rip = this.ripTable(nodeId);
|
||||
@@ -5096,6 +5107,11 @@ export class NetworkRuntimeEngine {
|
||||
|
||||
// ── Firewall ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** The node's firewall as it is now: changed at run time (the page konfig), it is in the snapshot, and so in the project saved from it. */
|
||||
firewallConfiguration(nodeId: Id): FirewallConfiguration {
|
||||
return this.stateValue.topologySnapshot.firewallConfigurationsByNodeId.get(nodeId) ?? firewallConfiguration();
|
||||
}
|
||||
|
||||
setFirewallConfiguration(nodeId: Id, configuration: FirewallConfiguration): void {
|
||||
const configurations = new Map(this.stateValue.topologySnapshot.firewallConfigurationsByNodeId);
|
||||
configurations.set(nodeId, configuration);
|
||||
@@ -5111,8 +5127,7 @@ export class NetworkRuntimeEngine {
|
||||
* recorded, including "firewall inactive".
|
||||
*/
|
||||
evaluateFirewall(packet: IPv4Packet, nodeId: Id, incomingInterfaceId?: Id, frameIdentity?: number): FirewallDecision {
|
||||
const configuration =
|
||||
this.stateValue.topologySnapshot.firewallConfigurationsByNodeId.get(nodeId) ?? firewallConfiguration();
|
||||
const configuration = this.firewallConfiguration(nodeId);
|
||||
const decide = (accepted: boolean, ruleIndex: number | undefined, detail: string): FirewallDecision =>
|
||||
this.recordFirewallDecision(packet, frameIdentity, nodeId, incomingInterfaceId, accepted, ruleIndex, detail);
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ function words(line: string): string[] {
|
||||
}
|
||||
|
||||
/** Java's String.trim. */
|
||||
function javaTrim(text: string): string {
|
||||
export function javaTrim(text: string): string {
|
||||
let start = 0;
|
||||
let end = text.length;
|
||||
while (start < end && text.charCodeAt(start) <= 0x20) start += 1;
|
||||
@@ -211,3 +211,37 @@ export function decodeUriPath(path: string): string | undefined {
|
||||
}
|
||||
return new TextDecoder().decode(new Uint8Array(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* URLDecoder.decode(text, "UTF-8"), as a form's data is read: "+" a space,
|
||||
* each run of "%xy" the characters of those UTF-8 bytes; undefined where
|
||||
* Java throws (an escape cut off or not hexadecimal; Java's parseInt also
|
||||
* takes a sign there, and refuses only a negative value).
|
||||
*/
|
||||
export function javaUrlDecode(text: string): string | undefined {
|
||||
let decoded = '';
|
||||
let index = 0;
|
||||
while (index < text.length) {
|
||||
const character = text[index]!;
|
||||
if (character === '+') {
|
||||
decoded += ' ';
|
||||
index += 1;
|
||||
} else if (character === '%') {
|
||||
const bytes: number[] = [];
|
||||
while (index + 2 < text.length && text[index] === '%') {
|
||||
const hex = text.slice(index + 1, index + 3);
|
||||
if (!/^[+-]?[0-9A-Fa-f]+$/.test(hex)) return undefined;
|
||||
const value = Number.parseInt(hex, 16);
|
||||
if (value < 0) return undefined;
|
||||
bytes.push(value);
|
||||
index += 3;
|
||||
}
|
||||
if (index < text.length && text[index] === '%') return undefined;
|
||||
decoded += new TextDecoder().decode(new Uint8Array(bytes));
|
||||
} else {
|
||||
decoded += character;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Cases for tools/java-oracle's KonfigCases: FILIUS' firewall settings page
|
||||
# (konfig) fed with what a browser posts. Read by firewallSettings.test.ts.
|
||||
case getEmpty empty
|
||||
GET
|
||||
case getThree three
|
||||
GET
|
||||
POST
|
||||
case deactivate three
|
||||
POST btnSave=speichern
|
||||
POST firewallActivate=1&dropICMP=1&onlySYN=1&btnSave=speichern
|
||||
case defaultPolicy three
|
||||
POST defaultPolicy=accept&defPolSet=speichern
|
||||
POST defaultPolicy=xyz&defPolSet=speichern
|
||||
POST defaultPolicy=accept
|
||||
POST defaultPolicy=accept&defPolSet=speichern
|
||||
POST defPolSet=speichern
|
||||
case moveUp three
|
||||
POST radioRule=2&moveup=rauf
|
||||
POST radioRule=1&moveup=rauf
|
||||
POST radioRule=4&moveup=rauf
|
||||
POST moveup=rauf
|
||||
POST radioRule=3&radioRule=2&moveup=rauf
|
||||
POST radioRule=2&radioRule=abc&moveup=rauf
|
||||
POST radioRule=%2B3&moveup=rauf
|
||||
case moveDown three
|
||||
POST radioRule=1&movedown=runter
|
||||
POST radioRule=3&movedown=runter
|
||||
POST movedown=runter
|
||||
case moveDownZero three
|
||||
POST radioRule=0&movedown=runter
|
||||
case delete three
|
||||
POST radioRule=2&delrule=l%C3%B6schen
|
||||
POST radioRule=0&delrule=l%C3%B6schen
|
||||
POST radioRule=abc&delrule=l%C3%B6schen
|
||||
POST radioRule=9&delrule=l%C3%B6schen
|
||||
case addEmpty empty
|
||||
POST srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop&addrule=hinzuf%C3%BCgen
|
||||
POST srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=80&action=accept&addrule=hinzuf%C3%BCgen
|
||||
POST srcIP=&srcMask=&destIP=&destMask=&protocol=UDP&port=-1&action=drop&addrule=hinzuf%C3%BCgen
|
||||
POST srcIP=&srcMask=&destIP=&destMask=&protocol=tcp&port=+53+&action=ACCEPT&addrule=hinzuf%C3%BCgen
|
||||
POST addrule=x
|
||||
case addSource empty
|
||||
POST srcIP=10.0.0.0&srcMask=255.255.255.0&destIP=&destMask=&protocol=TCP&port=80&action=drop&addrule=hinzuf%C3%BCgen
|
||||
case addSourceMaskFirst empty
|
||||
POST srcMask=255.255.255.0&srcIP=10.0.0.0&destIP=&destMask=&protocol=TCP&port=80&action=drop&addrule=hinzuf%C3%BCgen
|
||||
case sourceRule sourceRule
|
||||
GET
|
||||
case addDestination empty
|
||||
POST srcIP=&srcMask=&destIP=20.0.0.0&destMask=255.0.0.0&protocol=UDP&port=&action=accept&addrule=hinzuf%C3%BCgen
|
||||
case addDestinationMaskFirst empty
|
||||
POST srcIP=&srcMask=&destMask=255.0.0.0&destIP=20.0.0.0&protocol=UDP&port=&action=accept&addrule=hinzuf%C3%BCgen
|
||||
case destinationRule destinationRule
|
||||
GET
|
||||
case errors three
|
||||
POST srcIP=10.0.0.300&srcMask=&destIP=&destMask=255.0.255.0&protocol=UDP&port=abc&action=accept&addrule=hinzuf%C3%BCgen
|
||||
GET
|
||||
POST radioRule=1&moveup=rauf
|
||||
POST srcIP=10.0.0.1&srcMask=1.2.3.4&destIP=&destMask=255.0.0.0&protocol=TCP&port=0&action=drop&addrule=hinzuf%C3%BCgen
|
||||
POST srcIP=&srcMask=0.0.0.0&destIP=1.2.3.4&destMask=0.0.0.0&protocol=*&port=65536&action=drop&addrule=hinzuf%C3%BCgen
|
||||
POST srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=080&action=drop&addrule=hinzuf%C3%BCgen
|
||||
POST srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=%2B80&action=drop&addrule=hinzuf%C3%BCgen
|
||||
POST srcIP=&srcMask=255.255.255.0.&destIP=&destMask=255.255.255&protocol=*&port=65535&action=drop&addrule=hinzuf%C3%BCgen
|
||||
case errorAndSave three
|
||||
POST srcIP=1.2.3&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop&btnSave=speichern&addrule=hinzuf%C3%BCgen
|
||||
case markup empty
|
||||
POST srcIP=%3Cb%3E%22&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop&addrule=hinzuf%C3%BCgen
|
||||
case dollar empty
|
||||
POST srcIP=%241&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop&addrule=hinzuf%C3%BCgen
|
||||
case encoding empty
|
||||
POST srcIP=+&srcMask=&destIP=&destMask=&protocol=%2A&port=%38&action=drop&addrule=hinzuf%C3%BCgen
|
||||
POST \u000abtnSave=speichern&firewallActivate=1
|
||||
POST BTNSAVE=x&firewallActivate=1
|
||||
POST =x&btnSave
|
||||
case masks empty
|
||||
POST srcIP=&srcMask=%2B255.255.255.0&destIP=1.2.3&destMask=255.255.255.255.0&protocol=TCP&protocol=UDP&port=&action=drop&addrule=hinzuf%C3%BCgen
|
||||
POST srcIP=&srcMask=255.255.255.256&destIP=&destMask=255.255.255.255&protocol=*&port=&action=accept&action=drop&addrule=hinzuf%C3%BCgen
|
||||
case portZero portZero
|
||||
GET
|
||||
case ampersand empty
|
||||
POST srcIP=%26&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop&addrule=hinzuf%C3%BCgen
|
||||
# What Java's browser window posts (Swing's form: every field, the button pressed).
|
||||
case browserForm three
|
||||
POST firewallActivate=1&onlySYN=1&btnSave=speichern&defaultPolicy=drop&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop
|
||||
POST onlySYN=1&defaultPolicy=accept&defPolSet=speichern&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop
|
||||
POST firewallActivate=1&onlySYN=1&defaultPolicy=drop&radioRule=3&moveup=rauf&srcIP=10.0.0.0&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop
|
||||
POST firewallActivate=1&onlySYN=1&defaultPolicy=drop&srcIP=&srcMask=&destIP=&destMask=&protocol=UDP&port=53&action=accept&addrule=hinzuf%C3%BCgen
|
||||
POST firewallActivate=1&onlySYN=1&defaultPolicy=drop&radioRule=1&delrule=l%C3%B6schen&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop
|
||||
case malformed three
|
||||
POST radioRule=2&moveup=rauf&x=%zz
|
||||
POST radioRule=2&moveup=rauf&x=%
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,163 @@
|
||||
// The firewall settings page of routers and home routers (konfig,
|
||||
// apps/firewallSettings.ts) against Java FILIUS' own: tools/java-oracle's
|
||||
// KonfigCases fed Java's page with the steps of firewall-settings.cases.txt
|
||||
// (firewall-settings.<de|en>.java.json). Each step's page and the firewall
|
||||
// after it must be Java's, in both languages, except where Java errs.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyFirewallSettings,
|
||||
firewallConfiguration,
|
||||
firewallRule,
|
||||
firewallSettingsPage,
|
||||
javaUrlDecode,
|
||||
type FirewallConfiguration,
|
||||
type FirewallRule,
|
||||
type PageLanguage,
|
||||
} from '../src/index.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
type JavaRule = [string, string, string, string, number, string, string];
|
||||
|
||||
interface JavaFirewall {
|
||||
isActive: boolean;
|
||||
dropIcmp: boolean;
|
||||
filterSynSegmentsOnly: boolean;
|
||||
defaultPolicy: string;
|
||||
rules: JavaRule[];
|
||||
}
|
||||
|
||||
interface JavaStep {
|
||||
post: string | null;
|
||||
page: string | null;
|
||||
firewall: JavaFirewall;
|
||||
}
|
||||
|
||||
interface JavaCase {
|
||||
name: string;
|
||||
start: string;
|
||||
steps: JavaStep[];
|
||||
}
|
||||
|
||||
function javaRule(rule: FirewallRule): JavaRule {
|
||||
return [rule.sourceIPAddress, rule.sourceSubnetMask, rule.destinationIPAddress, rule.destinationSubnetMask, rule.port, rule.protocol, rule.action];
|
||||
}
|
||||
|
||||
function asJava(configuration: FirewallConfiguration): JavaFirewall {
|
||||
return {
|
||||
isActive: configuration.isActive,
|
||||
dropIcmp: configuration.dropIcmp,
|
||||
filterSynSegmentsOnly: configuration.filterSynSegmentsOnly,
|
||||
defaultPolicy: configuration.defaultPolicy,
|
||||
rules: configuration.rules.map(javaRule),
|
||||
};
|
||||
}
|
||||
|
||||
/** KonfigCases' start states. */
|
||||
const STARTS: Record<string, FirewallConfiguration> = {
|
||||
empty: firewallConfiguration(),
|
||||
three: firewallConfiguration({
|
||||
isActive: true,
|
||||
rules: [
|
||||
firewallRule({ port: 80, protocol: 'tcp', action: 'accept' }),
|
||||
firewallRule({ sourceIPAddress: '999.999.999.999', destinationIPAddress: '20.0.0.0', destinationSubnetMask: '255.0.0.0', protocol: 'udp', action: 'drop' }),
|
||||
firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', protocol: 'all', action: 'drop' }),
|
||||
],
|
||||
}),
|
||||
sourceRule: firewallConfiguration({ rules: [firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.255.255.0', port: 80, protocol: 'tcp', action: 'drop' })] }),
|
||||
portZero: firewallConfiguration({ rules: [firewallRule({ port: 0, protocol: 'icmp', action: 'accept' })] }),
|
||||
destinationRule: firewallConfiguration({ rules: [firewallRule({ destinationIPAddress: '20.0.0.0', destinationSubnetMask: '255.0.0.0', protocol: 'udp', action: 'accept' })] }),
|
||||
};
|
||||
|
||||
type Cases = Record<string, JavaCase>;
|
||||
|
||||
const step = (cases: Cases, name: string, index: number) => cases[name]!.steps[index]!;
|
||||
|
||||
/** A step as Java means it: another step's page and firewall, which Java answers for the same state. */
|
||||
const asStep =
|
||||
(name: string, index: number) =>
|
||||
(cases: Cases, measured: JavaStep): JavaStep => ({ ...step(cases, name, index), post: measured.post });
|
||||
|
||||
/**
|
||||
* Where Java errs, each step as Java means it (the core's departures, see
|
||||
* firewallSettings.ts).
|
||||
*/
|
||||
const JAVA_BUGS: Record<string, Record<number, (cases: Cases, measured: JavaStep) => JavaStep>> = {
|
||||
// Java checks the source IP against the mask not read yet, and the mask
|
||||
// against the IP not read yet: it refuses every rule with an address,
|
||||
// whichever field comes first. The core takes the rule.
|
||||
addSource: { 0: asStep('sourceRule', 0) },
|
||||
addSourceMaskFirst: { 0: asStep('sourceRule', 0) },
|
||||
addDestination: { 0: asStep('destinationRule', 0) },
|
||||
addDestinationMaskFirst: { 0: asStep('destinationRule', 0) },
|
||||
// Java keeps the error of a failed add for every later page.
|
||||
errors: { 1: asStep('getThree', 0) },
|
||||
// Save and add at once: Java saves, but writes the add's errors as a bare
|
||||
// list and fills the form in. The core answers the saved firewall.
|
||||
errorAndSave: { 0: asStep('deactivate', 0) },
|
||||
// Java writes the form's text into the page as it came.
|
||||
markup: { 0: (_, measured) => ({ ...measured, page: measured.page!.replace('value="<b>""', 'value="<b>""') }) },
|
||||
// A "$" in the form's text: Java's replaceAll reads it as a group, throws,
|
||||
// and the page fails (500). The core answers as for any other bad address.
|
||||
dollar: { 0: (cases, measured) => ({ ...measured, page: step(cases, 'markup', 0).page!.replace('value="<b>""', 'value="$1"') }) },
|
||||
// An "&" typed into a field: Java decodes the data before it splits it,
|
||||
// reads an empty source and adds a rule. The core keeps the "&" in its
|
||||
// field and answers as for any other bad address.
|
||||
ampersand: { 0: (cases) => ({ ...step(cases, 'markup', 0), page: step(cases, 'markup', 0).page!.replace('value="<b>""', 'value="&"') }) },
|
||||
// Moving rule 0 down: Java throws (its worker thread ends, no page comes).
|
||||
moveDownZero: { 0: asStep('getThree', 0) },
|
||||
// An escape that is not one: Java's decoder throws. The core reads the
|
||||
// data as it came, as Java's code means to.
|
||||
malformed: { 0: asStep('moveUp', 0), 1: asStep('getThree', 0) },
|
||||
};
|
||||
|
||||
function javaCases(language: PageLanguage): Cases {
|
||||
const list = JSON.parse(readFileSync(join(here, `firewall-settings.${language}.java.json`), 'utf8')) as JavaCase[];
|
||||
return Object.fromEntries(list.map((each) => [each.name, each]));
|
||||
}
|
||||
|
||||
describe('the firewall settings page as Java FILIUS answers it', () => {
|
||||
for (const language of ['de', 'en'] as const) {
|
||||
const cases = javaCases(language);
|
||||
|
||||
it(`corrects Java only where it errs (${language})`, () => {
|
||||
for (const [name, steps] of Object.entries(JAVA_BUGS)) {
|
||||
for (const [index, correct] of Object.entries(steps)) {
|
||||
const measured = step(cases, name, Number(index));
|
||||
expect(correct(cases, measured), `${name} ${index}`).not.toEqual(measured);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const [name, javaCase] of Object.entries(cases)) {
|
||||
it(`${name} (${language})`, () => {
|
||||
let configuration = STARTS[javaCase.start]!;
|
||||
for (const [index, measured] of javaCase.steps.entries()) {
|
||||
const expected = JAVA_BUGS[name]?.[index]?.(cases, measured) ?? measured;
|
||||
const result = applyFirewallSettings(configuration, measured.post ?? undefined);
|
||||
configuration = result.configuration;
|
||||
expect(asJava(configuration), `step ${index}: the firewall`).toEqual(expected.firewall);
|
||||
expect(firewallSettingsPage(configuration, language, result.rejected), `step ${index}: the page`).toBe(expected.page);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("a form's data as Java's URLDecoder reads it", () => {
|
||||
it('turns + into spaces and runs of escapes into UTF-8 characters', () => {
|
||||
expect(javaUrlDecode('a+b%C3%BC%E2%82%AC%41')).toBe('a bü€A');
|
||||
expect(javaUrlDecode('%+f%-0x')).toBe('\u000f\u0000x');
|
||||
});
|
||||
|
||||
it('fails where Java throws: an escape cut off, not hexadecimal, or negative', () => {
|
||||
expect(javaUrlDecode('%zz')).toBeUndefined();
|
||||
expect(javaUrlDecode('%4')).toBeUndefined();
|
||||
expect(javaUrlDecode('%41%')).toBeUndefined();
|
||||
expect(javaUrlDecode('%-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
firewallLogPage,
|
||||
firewallRule,
|
||||
firewallRuleText,
|
||||
firewallSettingsPage,
|
||||
forwardingTablePage,
|
||||
interfaceKey,
|
||||
javaTextLength,
|
||||
@@ -140,6 +141,42 @@ describe("a router's firewall log", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a router's settings page (konfig)", () => {
|
||||
const pageOf = (engine: NetworkRuntimeEngine) => {
|
||||
const pages = engine.webBrowser(LEFT)!.log.all.flatMap((line) => (line.kind === 'page' ? [line] : []));
|
||||
return pages[pages.length - 1];
|
||||
};
|
||||
const ask = (engine: NetworkRuntimeEngine, data?: string) => {
|
||||
engine.webBrowser(LEFT)!.open('10.0.0.1/konfig', data);
|
||||
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 5_000 });
|
||||
return pageOf(engine);
|
||||
};
|
||||
|
||||
it('changes the firewall at run time, into the snapshot; a page without data changes nothing', () => {
|
||||
const engine = startedEngine({ ...network(), webBrowserNodeIds: new Set([LEFT]) });
|
||||
const before = engine.state.topologySnapshot;
|
||||
expect(ask(engine)).toMatchObject({ statusCode: 200, contentType: 'text/html' });
|
||||
expect(engine.state.topologySnapshot).toBe(before);
|
||||
const page = ask(engine, 'firewallActivate=1&btnSave=speichern&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop');
|
||||
expect(engine.state.topologySnapshot.firewallConfigurationsByNodeId.get(ROUTER)).toEqual(
|
||||
firewallConfiguration({ isActive: true, defaultPolicy: 'accept', filterSynSegmentsOnly: false, rules: [firewallRule({ protocol: 'udp', port: 7, action: 'drop' })] }),
|
||||
);
|
||||
// The browser reads the answer as HTTPNachricht does: without its last empty lines.
|
||||
expect(page?.body).toBe(firewallSettingsPage(engine.firewallConfiguration(ROUTER), 'de').replace(/\n+$/, ''));
|
||||
});
|
||||
|
||||
it("answers in the project's language, and on a home router as on a router", () => {
|
||||
const gateway = network({ language: 'en', webBrowserNodeIds: new Set([LEFT]) });
|
||||
const engine = startedEngine({ ...gateway, nodes: gateway.nodes.map((node) => (node.id === ROUTER ? { ...node, kind: 'gateway' as const } : node)) });
|
||||
const page = ask(engine, 'srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=22&action=accept&addrule=add');
|
||||
expect(page?.body).toContain('<title>Router Firewall Configuration</title>');
|
||||
expect(engine.firewallConfiguration(ROUTER).rules).toEqual([firewallRule({ protocol: 'udp', port: 7, action: 'drop' }), firewallRule({ protocol: 'tcp', port: 22, action: 'accept' })]);
|
||||
const rejected = ask(engine, 'srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=0&action=accept&addrule=add');
|
||||
expect(rejected?.body).toContain('<font color="red">Erroneous input(s):<ul><li>Port invalid</li></ul></font><br>');
|
||||
expect(engine.firewallConfiguration(ROUTER).rules).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a RIP route's public address (hopPublicIp)", () => {
|
||||
it('is the telling router\'s, also when a shorter way replaces the route', () => {
|
||||
const engine = startedEngine(network({ ripEnabledByNodeId: new Map([[ROUTER, true]]), firewallConfigurationsByNodeId: new Map() }));
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
// not compared. Java's router server sometimes never listens (setAktiv wakes
|
||||
// its thread only when it already waits), and takes no initial sequence
|
||||
// number then; the engine's always listens: sequence numbers are compared
|
||||
// below the millions. The settings page (konfig) comes with F3.2c.
|
||||
// below the millions. The settings page (konfig) changes the firewall at run
|
||||
// time: a ping passes once "drop ICMP" is saved off; a rule moved above the
|
||||
// one that accepts the browser shuts it out.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -46,15 +48,53 @@ const open =
|
||||
engine.webBrowser(browser)!.open(typed);
|
||||
};
|
||||
|
||||
const post =
|
||||
(typed: string, data: string) =>
|
||||
(engine: NetworkRuntimeEngine, browser: string): void => {
|
||||
engine.webBrowser(browser)!.open(typed, data);
|
||||
};
|
||||
|
||||
const ping = (engine: NetworkRuntimeEngine, browser: string): void => {
|
||||
engine.sendIcmpEcho(browser, '20.0.0.2', { sequenceNumber: 1 });
|
||||
};
|
||||
|
||||
/** The form as Java's browser window posts it: every field, then the button pressed. */
|
||||
const FORM = {
|
||||
saveWithoutIcmp: 'firewallActivate=1&onlySYN=1&btnSave=speichern&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop',
|
||||
addTcpDrop: 'firewallActivate=1&dropICMP=1&onlySYN=1&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=80&action=drop&addrule=hinzuf%C3%BCgen',
|
||||
moveSecondUp: 'firewallActivate=1&dropICMP=1&onlySYN=1&defaultPolicy=accept&radioRule=2&moveup=rauf&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop',
|
||||
};
|
||||
|
||||
const STORIES: Record<string, Story> = {
|
||||
routerIndex: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1')]] },
|
||||
routerRoutes: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1/routes')]] },
|
||||
routerKonfig: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1/konfig')]] },
|
||||
konfigIcmp: {
|
||||
scenario: 'router-web',
|
||||
browser: id(301),
|
||||
router: id(302),
|
||||
acts: [
|
||||
[500, ping],
|
||||
[3500, post('10.0.0.1/konfig', FORM.saveWithoutIcmp)],
|
||||
[7000, ping],
|
||||
],
|
||||
},
|
||||
konfigBlock: {
|
||||
scenario: 'router-web',
|
||||
browser: id(301),
|
||||
router: id(302),
|
||||
acts: [
|
||||
[500, post('10.0.0.1/konfig', FORM.addTcpDrop)],
|
||||
[4000, post('10.0.0.1/konfig', FORM.moveSecondUp)],
|
||||
[8000, open('10.0.0.1')],
|
||||
],
|
||||
},
|
||||
routerLog: {
|
||||
scenario: 'router-web',
|
||||
browser: id(301),
|
||||
router: id(302),
|
||||
acts: [
|
||||
[500, (engine, browser) => engine.sendIcmpEcho(browser, '20.0.0.2', { sequenceNumber: 1 })],
|
||||
[500, ping],
|
||||
[3500, open('10.0.0.1')],
|
||||
[7000, open('10.0.0.1/log')],
|
||||
],
|
||||
@@ -78,12 +118,21 @@ const withoutInitialNumbers = (text: string) =>
|
||||
.replace(/(seq|ack)=(\d+)/g, (_, name: string, value: string) => `${name}=${Number(value) % 1_000_000}`)
|
||||
.replace(/(SEQ|ACK): \d+\.(\d{3}\.\d{3})/g, '$1: n.$2');
|
||||
|
||||
/** Java's wait for a SYN's answer (TCPSocket: three times 2.75 s, its timeout here). */
|
||||
const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750;
|
||||
|
||||
/**
|
||||
* Where Java errs: its run corrected, as Java means it. routerLog: the
|
||||
* German log page closes its title with </titel> (sw_firewallweblog_msg1);
|
||||
* the engine writes </title>.
|
||||
* the engine writes </title>. konfigBlock: the browser's SYN, dropped by the
|
||||
* firewall, goes once in Java, not three times (webJava.test.ts, noServer);
|
||||
* the page "server not reachable" comes two attempts later in the engine.
|
||||
*/
|
||||
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
|
||||
konfigBlock: (java) => ({
|
||||
...java,
|
||||
events: java.events.map(([time, who, text]) => (who === 'browser' && text.startsWith('page 0') ? [time + 2 * JAVA_TIMEOUT_MILLISECONDS, who, text] : [time, who, text])),
|
||||
}),
|
||||
routerLog: (java) => {
|
||||
const fixed = (text: string | null) => text?.replace('</titel>', '</title>') ?? null;
|
||||
const rows = (byCard: Record<string, (string | null)[][]> | undefined) =>
|
||||
@@ -103,6 +152,11 @@ const PAGE_PLACEHOLDERS: Record<string, (text: string) => string> = {
|
||||
ripRoutes: (text) => text.replace(/<td>-?\d+<\/td>(<td>[\d.]+<\/td><td><a )/g, '<td>validity</td>$1'),
|
||||
};
|
||||
|
||||
/** Where the frames are compared only up to a time (Java's). konfigBlock: the engine's second SYN, which Java does not send. */
|
||||
const UNTIL: Record<string, (java: JavaRun) => number> = {
|
||||
konfigBlock: (java) => java.events.find(([, who, text]) => who === 'browser' && text.startsWith('page 0'))![0] - 250,
|
||||
};
|
||||
|
||||
type Lines = Record<Program, [number, string][]>;
|
||||
|
||||
function javaLines(java: JavaRun): Lines {
|
||||
@@ -162,6 +216,7 @@ describe('the pages of routers and home routers as Java FILIUS serves them', ()
|
||||
cell: (text) => placeholders(withoutInitialNumbers(text)),
|
||||
tables: java,
|
||||
from: firstCall,
|
||||
until: UNTIL[name]?.(measured),
|
||||
starts: { java: 0, engine: 0 },
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -137,6 +137,11 @@ describe('the web server', () => {
|
||||
expect(ask(engine, 'HALLO', 'GET', 'GET /')).toEqual(['HTTP/1.1 501 Not Implemented\n', 'HTTP/1.1 400 Bad Request\n', 'HTTP/1.1 200 OK\nContent-type: text/html\n\nStart']);
|
||||
});
|
||||
|
||||
it('answers only GET and POST as Java writes them: a method in small letters is read, then answered 501 (WebServerMitarbeiter)', () => {
|
||||
const engine = startedEngine(network(files({ '/webserver/index.html': 'Start' })));
|
||||
expect(ask(engine, 'get /', 'Post /\n\nx=1')).toEqual(['HTTP/1.1 501 Not Implemented\n', 'HTTP/1.1 501 Not Implemented\n']);
|
||||
});
|
||||
|
||||
it('serves nothing outside its document root (Java serves any file of the host), and no folder', () => {
|
||||
const engine = startedEngine(network(files({ '/geheim.txt': 'geheim', '/webserver/ordner/a.txt': 'a' })));
|
||||
expect(ask(engine, 'GET /../geheim.txt HTTP/1.1', 'GET /ordner/../../geheim.txt HTTP/1.1', 'GET /ordner HTTP/1.1', 'GET /ordner/./a.txt?x HTTP/1.1')).toEqual([
|
||||
|
||||
Reference in New Issue
Block a user