// 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; /** A RIP route: the router that told it, by its first address (Java's hopPublicIp, a hint for the administrator). */ readonly hopPublicIPAddress?: string; } 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())]; }