feat(core): event queue, Ethernet, switch, ARP, IPv4, ICMP, routing and firewall
First slice of the TypeScript port of the filius-ipad runtime engine, with 29 tests ported from the Swift suite. Parts not ported yet throw NotPortedError instead of guessing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
// Ported from filius-ipad (GPLv2/GPLv3):
|
||||
// State/TopologyJavaRouteTable.swift (row projection, validation, drafts)
|
||||
// Java counterpart: filius.software.vermittlungsschicht.Weiterleitungstabelle
|
||||
// (holeTabelle: the order of the generated rows).
|
||||
// The Swift file also carries table geometry for SwiftUI; that belongs to the
|
||||
// editor and is not ported here.
|
||||
|
||||
import { formatIPv4, maskAddress } from './ipv4.js';
|
||||
import type { InterfaceConfiguration, ManualRoute } from './config.js';
|
||||
|
||||
export type RouteOrigin = 'localHost' | 'connected' | 'localhost' | 'manual' | 'defaultGateway' | 'rip';
|
||||
|
||||
export interface RouteRow {
|
||||
readonly destinationNetwork: string;
|
||||
readonly subnetMask: string;
|
||||
readonly nextHop: string;
|
||||
readonly interfaceIPAddress: string;
|
||||
readonly origin: RouteOrigin;
|
||||
readonly isEditable: boolean;
|
||||
readonly metric?: number;
|
||||
readonly expiresAtMilliseconds?: number;
|
||||
}
|
||||
|
||||
export type RouteTableColumn = 'destination' | 'subnetMask' | 'nextGateway' | 'interface';
|
||||
|
||||
/**
|
||||
* A Java table octet: digits only, leading zeros allowed, at most three
|
||||
* significant digits, 0…255 ("001" is valid, " 1" and "+1" are not).
|
||||
*/
|
||||
function parseJavaOctet(segment: string): number | undefined {
|
||||
if (segment.length === 0 || !/^[0-9]+$/.test(segment)) return undefined;
|
||||
const significant = segment.replace(/^0+/, '');
|
||||
if (significant.length === 0) return 0;
|
||||
if (significant.length > 3) return undefined;
|
||||
const value = Number(significant);
|
||||
return value <= 255 ? value : undefined;
|
||||
}
|
||||
|
||||
function parseJavaIPv4(value: string): number | undefined {
|
||||
const segments = value.split('.');
|
||||
if (segments.length !== 4) return undefined;
|
||||
let address = 0;
|
||||
for (const segment of segments) {
|
||||
const octet = parseJavaOctet(segment);
|
||||
if (octet === undefined) return undefined;
|
||||
address = ((address << 8) | octet) >>> 0;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
export function isValidJavaIPAddress(value: string): boolean {
|
||||
return parseJavaIPv4(value) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* FILIUS uses `musterSubNetz`, a dotted-decimal syntax check. It deliberately
|
||||
* accepts non-contiguous masks in the route editor.
|
||||
*/
|
||||
export function isValidJavaSubnetMask(value: string): boolean {
|
||||
return parseJavaIPv4(value) !== undefined;
|
||||
}
|
||||
|
||||
export function isValidRouteCell(value: string, column: RouteTableColumn): boolean {
|
||||
return column === 'subnetMask' ? isValidJavaSubnetMask(value) : isValidJavaIPAddress(value);
|
||||
}
|
||||
|
||||
function sameNetwork(lhs: string, rhs: string, mask: string): boolean {
|
||||
const left = parseJavaIPv4(lhs);
|
||||
const right = parseJavaIPv4(rhs);
|
||||
const netmask = parseJavaIPv4(mask);
|
||||
if (left === undefined || right === undefined || netmask === undefined) return false;
|
||||
return maskAddress(left, netmask) === maskAddress(right, netmask);
|
||||
}
|
||||
|
||||
function networkAddress(ipAddress: string, subnetMask: string): string | undefined {
|
||||
const address = parseJavaIPv4(ipAddress);
|
||||
const mask = parseJavaIPv4(subnetMask);
|
||||
if (address === undefined || mask === undefined) return undefined;
|
||||
return formatIPv4(maskAddress(address, mask));
|
||||
}
|
||||
|
||||
/**
|
||||
* The static table as Java shows it: the interfaces' own addresses (newest
|
||||
* first), their networks, loopback, the manual rows, then the default route.
|
||||
*/
|
||||
export function routeTableRows(
|
||||
interfaceConfigurations: readonly InterfaceConfiguration[],
|
||||
manualRoutes: readonly ManualRoute[],
|
||||
defaultGateway: string | undefined,
|
||||
): RouteRow[] {
|
||||
const validInterfaces = interfaceConfigurations.filter(
|
||||
(configuration) =>
|
||||
parseJavaIPv4(configuration.ipAddress) !== undefined &&
|
||||
parseJavaIPv4(configuration.subnetMask) !== undefined,
|
||||
);
|
||||
|
||||
const result: RouteRow[] = manualRoutes.map((route) => ({
|
||||
destinationNetwork: route.destinationNetwork,
|
||||
subnetMask: route.subnetMask,
|
||||
nextHop: route.gateway,
|
||||
interfaceIPAddress: route.interfaceIPAddress,
|
||||
origin: 'manual',
|
||||
isEditable: true,
|
||||
}));
|
||||
|
||||
result.unshift({
|
||||
destinationNetwork: '127.0.0.0',
|
||||
subnetMask: '255.0.0.0',
|
||||
nextHop: '127.0.0.1',
|
||||
interfaceIPAddress: '127.0.0.1',
|
||||
origin: 'localhost',
|
||||
isEditable: false,
|
||||
});
|
||||
|
||||
for (const configuration of validInterfaces) {
|
||||
const network = networkAddress(configuration.ipAddress, configuration.subnetMask);
|
||||
if (network === undefined) continue;
|
||||
result.unshift({
|
||||
destinationNetwork: network,
|
||||
subnetMask: configuration.subnetMask,
|
||||
nextHop: configuration.ipAddress,
|
||||
interfaceIPAddress: configuration.ipAddress,
|
||||
origin: 'connected',
|
||||
isEditable: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const configuration of validInterfaces) {
|
||||
result.unshift({
|
||||
destinationNetwork: configuration.ipAddress,
|
||||
subnetMask: '255.255.255.255',
|
||||
nextHop: '127.0.0.1',
|
||||
interfaceIPAddress: '127.0.0.1',
|
||||
origin: 'localHost',
|
||||
isEditable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const trimmedGateway = defaultGateway?.trim() ?? '';
|
||||
const primaryInterface = validInterfaces[0];
|
||||
if (parseJavaIPv4(trimmedGateway) !== undefined && primaryInterface !== undefined) {
|
||||
let selectedInterface = primaryInterface.ipAddress;
|
||||
for (const configuration of validInterfaces) {
|
||||
if (sameNetwork(trimmedGateway, configuration.ipAddress, configuration.subnetMask)) {
|
||||
selectedInterface = configuration.ipAddress;
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
destinationNetwork: '0.0.0.0',
|
||||
subnetMask: '0.0.0.0',
|
||||
nextHop: trimmedGateway,
|
||||
interfaceIPAddress: selectedInterface,
|
||||
origin: 'defaultGateway',
|
||||
isEditable: false,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function visibleRouteRows(rows: readonly RouteRow[], showAllEntries: boolean): RouteRow[] {
|
||||
return showAllEntries ? [...rows] : rows.filter((row) => row.isEditable);
|
||||
}
|
||||
|
||||
/** One row being edited in the route editor. */
|
||||
export interface RouteDraft {
|
||||
readonly id: string;
|
||||
destinationNetwork: string;
|
||||
subnetMask: string;
|
||||
nextGateway: string;
|
||||
interfaceIPAddress: string;
|
||||
isPendingInsertion: boolean;
|
||||
}
|
||||
|
||||
export function blankRouteDraft(id: string): RouteDraft {
|
||||
return {
|
||||
id,
|
||||
destinationNetwork: '',
|
||||
subnetMask: '',
|
||||
nextGateway: '',
|
||||
interfaceIPAddress: '',
|
||||
isPendingInsertion: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function routeDraftFromRoute(id: string, route: ManualRoute): RouteDraft {
|
||||
return {
|
||||
id,
|
||||
destinationNetwork: route.destinationNetwork,
|
||||
subnetMask: route.subnetMask,
|
||||
nextGateway: route.gateway,
|
||||
interfaceIPAddress: route.interfaceIPAddress,
|
||||
isPendingInsertion: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The route a draft stands for — only once all four cells are valid. */
|
||||
export function manualRouteFromDraft(draft: RouteDraft): ManualRoute | undefined {
|
||||
if (
|
||||
!isValidRouteCell(draft.destinationNetwork, 'destination') ||
|
||||
!isValidRouteCell(draft.subnetMask, 'subnetMask') ||
|
||||
!isValidRouteCell(draft.nextGateway, 'nextGateway') ||
|
||||
!isValidRouteCell(draft.interfaceIPAddress, 'interface')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
destinationNetwork: draft.destinationNetwork,
|
||||
subnetMask: draft.subnetMask,
|
||||
gateway: draft.nextGateway,
|
||||
interfaceIPAddress: draft.interfaceIPAddress,
|
||||
};
|
||||
}
|
||||
|
||||
/** At most one blank row waits at the end of the editor. */
|
||||
export function appendingBlankDraftIfNeeded(
|
||||
drafts: readonly RouteDraft[],
|
||||
newId: () => string,
|
||||
): RouteDraft[] {
|
||||
if (drafts.some((draft) => draft.isPendingInsertion)) return [...drafts];
|
||||
return [...drafts, blankRouteDraft(newId())];
|
||||
}
|
||||
Reference in New Issue
Block a user