From 14e8587058e459a27960d626cf1563ac8f199274 Mon Sep 17 00:00:00 2001 From: John <74981366+jojo-swe@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:48:24 +0200 Subject: [PATCH 1/6] Add OSPF control-plane engine --- apps/api/src/sim/ospf.ts | 362 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 apps/api/src/sim/ospf.ts diff --git a/apps/api/src/sim/ospf.ts b/apps/api/src/sim/ospf.ts new file mode 100644 index 0000000..24e57f8 --- /dev/null +++ b/apps/api/src/sim/ospf.ts @@ -0,0 +1,362 @@ +import { + inSameSubnet, + maskToPrefixLen, + networkAddress, + type Device, + type Link, + type LinkEndpoint +} from "@netsim/shared"; + +import type { L2Engine } from "./l2.js"; + +export type OspfNeighborState = "down" | "init" | "two-way" | "full"; + +export type OspfNeighbor = { + localDeviceId: string; + localInterface: string; + neighborDeviceId: string; + neighborInterface: string; + area: number; + cost: number; + state: OspfNeighborState; +}; + +export type OspfLsa = { + advertisingRouter: string; + sequence: number; + area: number; + links: Array<{ neighborRouter: string; cost: number }>; + prefixes: Array<{ network: string; mask: string; cost: number }>; +}; + +export type OspfRoute = { + destination: string; + mask: string; + nextHop: string; + outgoingInterface: string; + metric: number; + area: number; + learnedFrom: string; +}; + +export type OspfSnapshot = { + routerId: string; + processId: number; + neighbors: OspfNeighbor[]; + lsdb: OspfLsa[]; + routes: OspfRoute[]; +}; + +type EnabledInterface = { + deviceId: string; + routerId: string; + processId: number; + area: number; + interfaceName: string; + address: string; + mask: string; + network: string; + cost: number; +}; + +type Edge = { + from: string; + to: string; + cost: number; + localInterface: string; + remoteInterface: string; + nextHop: string; + area: number; +}; + +function wildcardToMask(wildcard: string): string | null { + const parts = wildcard.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return null; + } + return parts.map((part) => 255 - part).join("."); +} + +function interfaceMatchesNetwork( + address: string, + statement: { network: string; wildcard: string } +): boolean { + const mask = wildcardToMask(statement.wildcard); + return mask !== null && inSameSubnet(statement.network, mask, address); +} + +function stableRouterId(device: Device): string { + const configured = device.config.ospf?.routerId; + if (configured) return configured; + + const addresses = Object.values(device.config.interfaces) + .map((iface) => iface.ipv4Address) + .filter((value): value is string => Boolean(value)) + .sort(); + return addresses.at(-1) ?? device.id; +} + +function prefixKey(network: string, mask: string): string { + return `${network}/${maskToPrefixLen(mask) ?? 0}`; +} + +export class OspfEngine { + constructor( + private getDevice: (id: string) => Device | undefined, + private allDevices: () => Iterable, + private allLinks: () => Iterable, + private l2: L2Engine + ) {} + + getSnapshot(deviceId: string): OspfSnapshot | null { + const device = this.getDevice(deviceId); + if (!device?.config.ospf?.enabled) return null; + + const topology = this.buildTopology(); + const routerId = stableRouterId(device); + return { + routerId, + processId: device.config.ospf.processId, + neighbors: topology.neighbors.filter((neighbor) => neighbor.localDeviceId === deviceId), + lsdb: topology.lsdb, + routes: this.computeRoutes(deviceId, topology.interfaces, topology.edges) + }; + } + + getRoutes(deviceId: string): OspfRoute[] { + const topology = this.buildTopology(); + return this.computeRoutes(deviceId, topology.interfaces, topology.edges); + } + + private enabledInterfaces(): EnabledInterface[] { + const out: EnabledInterface[] = []; + for (const device of this.allDevices()) { + const ospf = device.config.ospf; + if (!ospf?.enabled) continue; + + for (const iface of Object.values(device.config.interfaces)) { + if (!iface.adminUp || !iface.ipv4Address || !iface.ipv4Mask) continue; + if (!this.l2.isInterfaceOperUp(device.id, iface.name)) continue; + + const statement = ospf.networks.find((candidate) => + interfaceMatchesNetwork(iface.ipv4Address!, candidate) + ); + if (!statement) continue; + + out.push({ + deviceId: device.id, + routerId: stableRouterId(device), + processId: ospf.processId, + area: statement.area, + interfaceName: iface.name, + address: iface.ipv4Address, + mask: iface.ipv4Mask, + network: networkAddress(iface.ipv4Address, iface.ipv4Mask) ?? iface.ipv4Address, + cost: Math.max(1, Math.trunc(iface.ospfCost ?? 1)) + }); + } + } + return out; + } + + private buildTopology(): { + interfaces: EnabledInterface[]; + edges: Edge[]; + neighbors: OspfNeighbor[]; + lsdb: OspfLsa[]; + } { + const interfaces = this.enabledInterfaces(); + const byEndpoint = new Map(); + for (const iface of interfaces) { + byEndpoint.set(`${iface.deviceId}:${iface.interfaceName}`, iface); + } + + const edges: Edge[] = []; + const neighbors: OspfNeighbor[] = []; + + for (const link of this.allLinks()) { + const a = byEndpoint.get(`${link.a.deviceId}:${link.a.interfaceName}`); + const b = byEndpoint.get(`${link.b.deviceId}:${link.b.interfaceName}`); + if (!a || !b) continue; + if (a.area !== b.area) continue; + if (!inSameSubnet(a.address, a.mask, b.address)) continue; + if (!inSameSubnet(b.address, b.mask, a.address)) continue; + + edges.push({ + from: a.deviceId, + to: b.deviceId, + cost: a.cost, + localInterface: a.interfaceName, + remoteInterface: b.interfaceName, + nextHop: b.address, + area: a.area + }); + edges.push({ + from: b.deviceId, + to: a.deviceId, + cost: b.cost, + localInterface: b.interfaceName, + remoteInterface: a.interfaceName, + nextHop: a.address, + area: b.area + }); + + neighbors.push({ + localDeviceId: a.deviceId, + localInterface: a.interfaceName, + neighborDeviceId: b.deviceId, + neighborInterface: b.interfaceName, + area: a.area, + cost: a.cost, + state: "full" + }); + neighbors.push({ + localDeviceId: b.deviceId, + localInterface: b.interfaceName, + neighborDeviceId: a.deviceId, + neighborInterface: a.interfaceName, + area: b.area, + cost: b.cost, + state: "full" + }); + } + + const routerSequence = new Map(); + const lsdb: OspfLsa[] = []; + for (const device of this.allDevices()) { + if (!device.config.ospf?.enabled) continue; + const deviceInterfaces = interfaces.filter((iface) => iface.deviceId === device.id); + const deviceEdges = edges.filter((edge) => edge.from === device.id); + const routerId = stableRouterId(device); + const sequence = (routerSequence.get(routerId) ?? 0x80000000) + 1; + routerSequence.set(routerId, sequence); + + const prefixes = new Map(); + for (const iface of deviceInterfaces) { + prefixes.set(prefixKey(iface.network, iface.mask), { + network: iface.network, + mask: iface.mask, + cost: iface.cost + }); + } + + lsdb.push({ + advertisingRouter: routerId, + sequence, + area: deviceInterfaces[0]?.area ?? 0, + links: deviceEdges + .map((edge) => ({ + neighborRouter: stableRouterId(this.getDevice(edge.to)!), + cost: edge.cost + })) + .sort((x, y) => x.neighborRouter.localeCompare(y.neighborRouter)), + prefixes: [...prefixes.values()].sort((x, y) => + prefixKey(x.network, x.mask).localeCompare(prefixKey(y.network, y.mask)) + ) + }); + } + + neighbors.sort((a, b) => + `${a.localDeviceId}:${a.localInterface}:${a.neighborDeviceId}`.localeCompare( + `${b.localDeviceId}:${b.localInterface}:${b.neighborDeviceId}` + ) + ); + lsdb.sort((a, b) => a.advertisingRouter.localeCompare(b.advertisingRouter)); + return { interfaces, edges, neighbors, lsdb }; + } + + private computeRoutes( + sourceDeviceId: string, + interfaces: EnabledInterface[], + edges: Edge[] + ): OspfRoute[] { + const source = this.getDevice(sourceDeviceId); + if (!source?.config.ospf?.enabled) return []; + + const distances = new Map([[sourceDeviceId, 0]]); + const firstHop = new Map(); + const visited = new Set(); + const routerIds = new Map(); + for (const device of this.allDevices()) routerIds.set(device.id, stableRouterId(device)); + + while (true) { + let current: string | null = null; + let currentDistance = Number.POSITIVE_INFINITY; + for (const [deviceId, distance] of distances) { + if (visited.has(deviceId)) continue; + if (distance < currentDistance) { + current = deviceId; + currentDistance = distance; + } else if ( + distance === currentDistance && + current !== null && + (routerIds.get(deviceId) ?? deviceId) < (routerIds.get(current) ?? current) + ) { + current = deviceId; + } + } + if (current === null) break; + visited.add(current); + + for (const edge of edges.filter((candidate) => candidate.from === current)) { + if (visited.has(edge.to)) continue; + const candidateDistance = currentDistance + edge.cost; + const existing = distances.get(edge.to) ?? Number.POSITIVE_INFINITY; + const inheritedFirstHop = current === sourceDeviceId ? edge : firstHop.get(current); + if (!inheritedFirstHop) continue; + + const shouldReplace = + candidateDistance < existing || + (candidateDistance === existing && + inheritedFirstHop.nextHop < (firstHop.get(edge.to)?.nextHop ?? "~")); + if (shouldReplace) { + distances.set(edge.to, candidateDistance); + firstHop.set(edge.to, inheritedFirstHop); + } + } + } + + const connected = new Set( + interfaces + .filter((iface) => iface.deviceId === sourceDeviceId) + .map((iface) => prefixKey(iface.network, iface.mask)) + ); + const bestByPrefix = new Map(); + + for (const iface of interfaces) { + if (iface.deviceId === sourceDeviceId) continue; + const routeFirstHop = firstHop.get(iface.deviceId); + const distance = distances.get(iface.deviceId); + if (!routeFirstHop || distance === undefined) continue; + + const key = prefixKey(iface.network, iface.mask); + if (connected.has(key)) continue; + + const candidate: OspfRoute = { + destination: iface.network, + mask: iface.mask, + nextHop: routeFirstHop.nextHop, + outgoingInterface: routeFirstHop.localInterface, + metric: distance + iface.cost, + area: iface.area, + learnedFrom: iface.routerId + }; + const existing = bestByPrefix.get(key); + if ( + !existing || + candidate.metric < existing.metric || + (candidate.metric === existing.metric && candidate.nextHop < existing.nextHop) + ) { + bestByPrefix.set(key, candidate); + } + } + + return [...bestByPrefix.values()].sort((a, b) => { + const prefixDiff = (maskToPrefixLen(b.mask) ?? 0) - (maskToPrefixLen(a.mask) ?? 0); + if (prefixDiff !== 0) return prefixDiff; + const destinationDiff = a.destination.localeCompare(b.destination); + if (destinationDiff !== 0) return destinationDiff; + return a.metric - b.metric; + }); + } +} From 10f9a93b4e10adef8bfdeb4d048b5253c76261d3 Mon Sep 17 00:00:00 2001 From: John <74981366+jojo-swe@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:48:44 +0200 Subject: [PATCH 2/6] Add OSPF configuration types --- packages/shared/src/types.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index fa6be4a..85b3db0 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -57,12 +57,26 @@ export interface StaticRouteConfig { nextHop: string; } +export interface OspfNetworkConfig { + network: string; + wildcard: string; + area: number; +} + +export interface OspfConfig { + enabled: boolean; + processId: number; + routerId?: string; + networks: OspfNetworkConfig[]; +} + export interface InterfaceConfig { name: InterfaceName; description?: string; adminUp: boolean; ipv4Address?: string; ipv4Mask?: string; + ospfCost?: number; } export interface DeviceConfig { @@ -70,6 +84,7 @@ export interface DeviceConfig { interfaces: Record; staticRoutes: StaticRouteConfig[]; defaultGateway?: string; + ospf?: OspfConfig; } export interface Device { From 5afe4eb52622dc679a927107c5cec23d7196eee6 Mon Sep 17 00:00:00 2001 From: John <74981366+jojo-swe@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:49:28 +0200 Subject: [PATCH 3/6] Integrate OSPF routes into forwarding --- apps/api/src/sim/routing.ts | 205 +++++++++++++++++++++--------------- 1 file changed, 121 insertions(+), 84 deletions(-) diff --git a/apps/api/src/sim/routing.ts b/apps/api/src/sim/routing.ts index 1500318..99343aa 100644 --- a/apps/api/src/sim/routing.ts +++ b/apps/api/src/sim/routing.ts @@ -11,10 +11,12 @@ import { import type { ArpStore } from "./arp.js"; import type { L2Engine } from "./l2.js"; import { macForEndpoint } from "./mac.js"; +import type { OspfRoute } from "./ospf.js"; type BestRoute = - | { kind: "connected"; interfaceName: string; prefixLen: number } - | { kind: "static"; nextHop: string; prefixLen: number }; + | { kind: "connected"; interfaceName: string; prefixLen: number; distance: 0; metric: 0 } + | { kind: "static"; nextHop: string; prefixLen: number; distance: 1; metric: 0 } + | { kind: "ospf"; nextHop: string; prefixLen: number; distance: 110; metric: number }; type ForwardResult = { ok: boolean; @@ -23,27 +25,40 @@ type ForwardResult = { hops: string[]; }; +function routeIsBetter(candidate: BestRoute, current: BestRoute | null): boolean { + if (!current) return true; + if (candidate.prefixLen !== current.prefixLen) return candidate.prefixLen > current.prefixLen; + if (candidate.distance !== current.distance) return candidate.distance < current.distance; + return candidate.metric < current.metric; +} + export class RoutingEngine { constructor( private getDevice: (id: string) => Device | undefined, private allDevices: () => Iterable, private l2: L2Engine, - private arp: ArpStore + private arp: ArpStore, + private getOspfRoutes: (deviceId: string) => OspfRoute[] = () => [] ) {} canPing(fromDeviceId: string, targetIp: string): boolean { if (ipv4ToInt(targetIp) === null) return false; - - const forward = this.forward(fromDeviceId, targetIp, 8, new Set(), undefined, false); + const forward = this.forward(fromDeviceId, targetIp, 32, new Set(), undefined, false); if (!forward.ok || !forward.originSourceIp || !forward.reachedDeviceId) return false; - - const reverse = this.forward(forward.reachedDeviceId, forward.originSourceIp, 8, new Set(), undefined, false); + const reverse = this.forward( + forward.reachedDeviceId, + forward.originSourceIp, + 32, + new Set(), + undefined, + false + ); return reverse.ok; } traceRoute(fromDeviceId: string, targetIp: string): { ok: boolean; hops: string[] } { if (ipv4ToInt(targetIp) === null) return { ok: false, hops: [] }; - const result = this.forward(fromDeviceId, targetIp, 8, new Set(), undefined, true); + const result = this.forward(fromDeviceId, targetIp, 32, new Set(), undefined, true); return { ok: result.ok, hops: result.hops }; } @@ -51,9 +66,8 @@ export class RoutingEngine { const out: Array<{ ep: LinkEndpoint; mask: string }> = []; for (const device of this.allDevices()) { for (const iface of Object.values(device.config.interfaces)) { - if (!iface.ipv4Address || !iface.ipv4Mask) continue; + if (!iface.ipv4Address || !iface.ipv4Mask || !iface.adminUp) continue; if (iface.ipv4Address !== ip) continue; - if (!iface.adminUp) continue; if (!this.l2.isInterfaceOperUp(device.id, iface.name)) continue; out.push({ ep: { deviceId: device.id, interfaceName: iface.name }, mask: iface.ipv4Mask }); } @@ -61,47 +75,71 @@ export class RoutingEngine { return out; } + private nextHopReachable(device: Device, nextHop: string): boolean { + if (ipv4ToInt(nextHop) === null) return false; + return Object.values(device.config.interfaces).some( + (iface) => + iface.adminUp && + Boolean(iface.ipv4Address) && + Boolean(iface.ipv4Mask) && + this.l2.isInterfaceOperUp(device.id, iface.name) && + inSameSubnet(iface.ipv4Address!, iface.ipv4Mask!, nextHop) + ); + } + private bestRoute(device: Device, targetIp: string): BestRoute | null { let best: BestRoute | null = null; for (const iface of Object.values(device.config.interfaces)) { - if (!iface.adminUp) continue; - if (!iface.ipv4Address || !iface.ipv4Mask) continue; + if (!iface.adminUp || !iface.ipv4Address || !iface.ipv4Mask) continue; if (!this.l2.isInterfaceOperUp(device.id, iface.name)) continue; if (!inSameSubnet(iface.ipv4Address, iface.ipv4Mask, targetIp)) continue; - const prefixLen = maskToPrefixLen(iface.ipv4Mask) ?? 0; - if (!best || prefixLen > best.prefixLen) { - best = { kind: "connected", interfaceName: iface.name, prefixLen }; - } + const candidate: BestRoute = { + kind: "connected", + interfaceName: iface.name, + prefixLen: maskToPrefixLen(iface.ipv4Mask) ?? 0, + distance: 0, + metric: 0 + }; + if (routeIsBetter(candidate, best)) best = candidate; } - const nextHopReachable = (nextHop: string): boolean => { - if (ipv4ToInt(nextHop) === null) return false; - for (const iface of Object.values(device.config.interfaces)) { - if (!iface.adminUp) continue; - if (!iface.ipv4Address || !iface.ipv4Mask) continue; - if (!this.l2.isInterfaceOperUp(device.id, iface.name)) continue; - if (!inSameSubnet(iface.ipv4Address, iface.ipv4Mask, nextHop)) continue; - return true; - } - return false; - }; - - for (const sr of device.config.staticRoutes) { - if (!ipMatchesDestination(targetIp, sr.destination, sr.mask)) continue; - if (!nextHopReachable(sr.nextHop)) continue; - const prefixLen = maskToPrefixLen(sr.mask) ?? 0; - if (!best || prefixLen > best.prefixLen) { - best = { kind: "static", nextHop: sr.nextHop, prefixLen }; - } + for (const route of device.config.staticRoutes) { + if (!ipMatchesDestination(targetIp, route.destination, route.mask)) continue; + if (!this.nextHopReachable(device, route.nextHop)) continue; + const candidate: BestRoute = { + kind: "static", + nextHop: route.nextHop, + prefixLen: maskToPrefixLen(route.mask) ?? 0, + distance: 1, + metric: 0 + }; + if (routeIsBetter(candidate, best)) best = candidate; } - const dg = device.config.defaultGateway; - if (dg && nextHopReachable(dg)) { - const prefixLen = 0; - if (!best) { - best = { kind: "static", nextHop: dg, prefixLen }; - } + for (const route of this.getOspfRoutes(device.id)) { + if (!ipMatchesDestination(targetIp, route.destination, route.mask)) continue; + if (!this.nextHopReachable(device, route.nextHop)) continue; + const candidate: BestRoute = { + kind: "ospf", + nextHop: route.nextHop, + prefixLen: maskToPrefixLen(route.mask) ?? 0, + distance: 110, + metric: route.metric + }; + if (routeIsBetter(candidate, best)) best = candidate; + } + + const defaultGateway = device.config.defaultGateway; + if (defaultGateway && this.nextHopReachable(device, defaultGateway)) { + const candidate: BestRoute = { + kind: "static", + nextHop: defaultGateway, + prefixLen: 0, + distance: 1, + metric: 0 + }; + if (routeIsBetter(candidate, best)) best = candidate; } return best; @@ -115,76 +153,82 @@ export class RoutingEngine { originSourceIp: string | undefined, trackHops: boolean ): ForwardResult { - if (maxHops <= 0) return { ok: false, hops: [] }; - if (visited.has(fromDeviceId)) return { ok: false, hops: [] }; + if (maxHops <= 0 || visited.has(fromDeviceId)) return { ok: false, hops: [] }; visited.add(fromDeviceId); const fromDevice = this.getDevice(fromDeviceId); if (!fromDevice) return { ok: false, hops: [] }; - - if (originSourceIp && !deviceCapabilities(fromDevice.type).canRouteL3) return { ok: false, hops: [] }; + if (originSourceIp && !deviceCapabilities(fromDevice.type).canRouteL3) { + return { ok: false, hops: [] }; + } const targets = this.findActiveIpEndpoints(targetIp); if (targets.length === 0) return { ok: false, hops: [] }; - const best = this.bestRoute(fromDevice, targetIp); if (!best) return { ok: false, hops: [] }; if (best.kind === "connected") { for (const srcIface of Object.values(fromDevice.config.interfaces)) { - if (!srcIface.adminUp) continue; - if (!srcIface.ipv4Address || !srcIface.ipv4Mask) continue; + if (!srcIface.adminUp || !srcIface.ipv4Address || !srcIface.ipv4Mask) continue; if (!this.l2.isInterfaceOperUp(fromDevice.id, srcIface.name)) continue; if (!inSameSubnet(srcIface.ipv4Address, srcIface.ipv4Mask, targetIp)) continue; - const srcEp: LinkEndpoint = { deviceId: fromDevice.id, interfaceName: srcIface.name }; - for (const tgt of targets) { - if (!inSameSubnet(targetIp, tgt.mask, srcIface.ipv4Address)) continue; - if (this.l2.l2Reachable(srcEp, tgt.ep)) { - this.arp.learn(fromDevice.id, targetIp, macForEndpoint(tgt.ep), srcIface.name); - this.arp.learn(tgt.ep.deviceId, srcIface.ipv4Address, macForEndpoint(srcEp), tgt.ep.interfaceName); - return { - ok: true, - originSourceIp: originSourceIp ?? srcIface.ipv4Address, - reachedDeviceId: tgt.ep.deviceId, - hops: trackHops ? [targetIp] : [] - }; - } + for (const target of targets) { + if (!inSameSubnet(targetIp, target.mask, srcIface.ipv4Address)) continue; + if (!this.l2.l2Reachable(srcEp, target.ep)) continue; + this.arp.learn(fromDevice.id, targetIp, macForEndpoint(target.ep), srcIface.name); + this.arp.learn( + target.ep.deviceId, + srcIface.ipv4Address, + macForEndpoint(srcEp), + target.ep.interfaceName + ); + return { + ok: true, + originSourceIp: originSourceIp ?? srcIface.ipv4Address, + reachedDeviceId: target.ep.deviceId, + hops: trackHops ? [targetIp] : [] + }; } } - return { ok: false, hops: [] }; } const nextHopIp = best.nextHop; - if (ipv4ToInt(nextHopIp) === null) return { ok: false, hops: [] }; - const nextHops = this.findActiveIpEndpoints(nextHopIp); if (nextHops.length === 0) return { ok: false, hops: [] }; - let bestPartial: ForwardResult | null = null; for (const srcIface of Object.values(fromDevice.config.interfaces)) { - if (!srcIface.adminUp) continue; - if (!srcIface.ipv4Address || !srcIface.ipv4Mask) continue; + if (!srcIface.adminUp || !srcIface.ipv4Address || !srcIface.ipv4Mask) continue; if (!this.l2.isInterfaceOperUp(fromDevice.id, srcIface.name)) continue; if (!inSameSubnet(srcIface.ipv4Address, srcIface.ipv4Mask, nextHopIp)) continue; - const srcEp: LinkEndpoint = { deviceId: fromDevice.id, interfaceName: srcIface.name }; - for (const nh of nextHops) { - if (!inSameSubnet(nextHopIp, nh.mask, srcIface.ipv4Address)) continue; - if (!this.l2.l2Reachable(srcEp, nh.ep)) continue; - - this.arp.learn(fromDevice.id, nextHopIp, macForEndpoint(nh.ep), srcIface.name); - this.arp.learn(nh.ep.deviceId, srcIface.ipv4Address, macForEndpoint(srcEp), nh.ep.interfaceName); + for (const nextHop of nextHops) { + if (!inSameSubnet(nextHopIp, nextHop.mask, srcIface.ipv4Address)) continue; + if (!this.l2.l2Reachable(srcEp, nextHop.ep)) continue; + this.arp.learn(fromDevice.id, nextHopIp, macForEndpoint(nextHop.ep), srcIface.name); + this.arp.learn( + nextHop.ep.deviceId, + srcIface.ipv4Address, + macForEndpoint(srcEp), + nextHop.ep.interfaceName + ); const nextOrigin = originSourceIp ?? srcIface.ipv4Address; - const hop = this.forward(nh.ep.deviceId, targetIp, maxHops - 1, new Set(visited), nextOrigin, trackHops); + const hop = this.forward( + nextHop.ep.deviceId, + targetIp, + maxHops - 1, + new Set(visited), + nextOrigin, + trackHops + ); + if (!trackHops && hop.ok) return hop; if (trackHops) { const hops = [nextHopIp, ...hop.hops]; - if (hop.ok) { return { ok: true, @@ -193,7 +237,6 @@ export class RoutingEngine { hops }; } - if (!bestPartial || hops.length > bestPartial.hops.length) { bestPartial = { ok: false, @@ -202,16 +245,10 @@ export class RoutingEngine { hops }; } - } else { - if (hop.ok) return hop; } } } - if (trackHops && bestPartial) { - return bestPartial; - } - - return { ok: false, hops: [] }; + return trackHops && bestPartial ? bestPartial : { ok: false, hops: [] }; } } From a95706eacaa68d2386216068c786507693e67548 Mon Sep 17 00:00:00 2001 From: John <74981366+jojo-swe@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:50:06 +0200 Subject: [PATCH 4/6] Expose OSPF state through World --- apps/api/src/sim/world.ts | 135 ++++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 64 deletions(-) diff --git a/apps/api/src/sim/world.ts b/apps/api/src/sim/world.ts index 3ff1d36..8943ab4 100644 --- a/apps/api/src/sim/world.ts +++ b/apps/api/src/sim/world.ts @@ -8,19 +8,18 @@ import { type InterfaceConfig, type CableType, type Link, - type LinkEndpoint + type LinkEndpoint, + type OspfConfig } from "@netsim/shared"; import { ArpStore } from "./arp.js"; import { EventBus } from "./eventBus.js"; import { L2Engine } from "./l2.js"; +import { OspfEngine, type OspfSnapshot } from "./ospf.js"; import { RoutingEngine } from "./routing.js"; function ensureInterfaceDefaults(name: string, adminUp: boolean): InterfaceConfig { - return { - name, - adminUp - }; + return { name, adminUp }; } function resolveCableType( @@ -28,22 +27,13 @@ function resolveCableType( a: { deviceType: DeviceType; interfaceName: string }, b: { deviceType: DeviceType; interfaceName: string } ): Exclude { - const aKind = devicePortKind(a.deviceType, a.interfaceName); - const bKind = devicePortKind(b.deviceType, b.interfaceName); - - const aK = aKind ?? "rj45"; - const bK = bKind ?? "rj45"; - - if (cableType === "auto") { - if (aK === "sfp" || bK === "sfp") return "fiber"; - - const aIsMdix = deviceIsMdix(a.deviceType); - const bIsMdix = deviceIsMdix(b.deviceType); - const sameRole = aIsMdix === bIsMdix; - return sameRole ? "copper_crossover" : "copper_straight"; - } - - return cableType; + const aKind = devicePortKind(a.deviceType, a.interfaceName) ?? "rj45"; + const bKind = devicePortKind(b.deviceType, b.interfaceName) ?? "rj45"; + if (cableType !== "auto") return cableType; + if (aKind === "sfp" || bKind === "sfp") return "fiber"; + return deviceIsMdix(a.deviceType) === deviceIsMdix(b.deviceType) + ? "copper_crossover" + : "copper_straight"; } function validateCableType( @@ -51,27 +41,18 @@ function validateCableType( a: { deviceType: DeviceType; interfaceName: string }, b: { deviceType: DeviceType; interfaceName: string } ): void { - const aKind = devicePortKind(a.deviceType, a.interfaceName); - const bKind = devicePortKind(b.deviceType, b.interfaceName); - - const aK = aKind ?? "rj45"; - const bK = bKind ?? "rj45"; - + const aKind = devicePortKind(a.deviceType, a.interfaceName) ?? "rj45"; + const bKind = devicePortKind(b.deviceType, b.interfaceName) ?? "rj45"; if (cableType === "fiber") { - if (aK !== "sfp" || bK !== "sfp") { + if (aKind !== "sfp" || bKind !== "sfp") { throw new Error("Fiber cable requires SFP ports on both ends"); } return; } - - if (aK !== "rj45" || bK !== "rj45") { + if (aKind !== "rj45" || bKind !== "rj45") { throw new Error("Copper cable requires RJ45 ports on both ends"); } - - const aIsMdix = deviceIsMdix(a.deviceType); - const bIsMdix = deviceIsMdix(b.deviceType); - const sameRole = aIsMdix === bIsMdix; - + const sameRole = deviceIsMdix(a.deviceType) === deviceIsMdix(b.deviceType); if (cableType === "copper_straight" && sameRole) { throw new Error("Straight-through cable requires one MDI and one MDI-X port"); } @@ -89,11 +70,18 @@ export class World { (id) => this.devices.get(id), this.links ); + private ospf = new OspfEngine( + (id) => this.devices.get(id), + () => this.devices.values(), + () => this.links.values(), + this.l2 + ); private routing = new RoutingEngine( (id) => this.devices.get(id), () => this.devices.values(), this.l2, - this.arp + this.arp, + (deviceId) => this.ospf.getRoutes(deviceId) ); getEventBus(): EventBus { @@ -121,18 +109,17 @@ export class World { this.arp.clear(); for (const device of snapshot.devices) { const cloned = JSON.parse(JSON.stringify(device)) as Device; - if (!Array.isArray((cloned as any).config?.staticRoutes)) { - (cloned as any).config.staticRoutes = []; + if (!Array.isArray(cloned.config.staticRoutes)) cloned.config.staticRoutes = []; + if (cloned.config.ospf && !Array.isArray(cloned.config.ospf.networks)) { + cloned.config.ospf.networks = []; } this.devices.set(device.id, cloned); this.arp.ensureDevice(device.id); } - if (snapshot.links) { - for (const link of snapshot.links) { - const cloned = JSON.parse(JSON.stringify(link)) as any; - if (!cloned.cableType) cloned.cableType = "auto"; - this.links.set(link.id, cloned as Link); - } + for (const link of snapshot.links ?? []) { + const cloned = JSON.parse(JSON.stringify(link)) as Link; + if (!cloned.cableType) cloned.cableType = "auto"; + this.links.set(link.id, cloned); } } @@ -143,44 +130,67 @@ export class World { createDevice(input: { id?: string; type?: DeviceType; hostname?: string }): Device { const id = input.id ?? randomUUID(); const type: DeviceType = input.type ?? "router"; - const existing = this.devices.get(id); if (existing) { this.arp.ensureDevice(id); return existing; } - const caps = deviceCapabilities(type); - const hostname = input.hostname ?? (type === "pc" ? id : caps.defaultHostname); - const defaultAdminUp = caps.defaultAdminUp; - + const capabilities = deviceCapabilities(type); const device: Device = { id, type, config: { - hostname, + hostname: input.hostname ?? (type === "pc" ? id : capabilities.defaultHostname), staticRoutes: [], interfaces: { - "GigabitEthernet0/0": ensureInterfaceDefaults("GigabitEthernet0/0", defaultAdminUp), - "GigabitEthernet0/1": ensureInterfaceDefaults("GigabitEthernet0/1", defaultAdminUp) + "GigabitEthernet0/0": ensureInterfaceDefaults( + "GigabitEthernet0/0", + capabilities.defaultAdminUp + ), + "GigabitEthernet0/1": ensureInterfaceDefaults( + "GigabitEthernet0/1", + capabilities.defaultAdminUp + ) } } }; - this.devices.set(id, device); this.arp.ensureDevice(id); this.eventBus.emit({ type: "device:created", deviceId: id, deviceType: type }); return device; } + configureOspf(deviceId: string, config: OspfConfig | undefined): void { + const device = this.getDevice(deviceId); + if (!device) throw new Error(`Unknown device: ${deviceId}`); + if (!deviceCapabilities(device.type).canRouteL3) { + throw new Error(`Device ${deviceId} cannot run OSPF`); + } + device.config.ospf = config + ? { + enabled: config.enabled, + processId: Math.max(1, Math.trunc(config.processId)), + routerId: config.routerId, + networks: config.networks.map((network) => ({ + network: network.network, + wildcard: network.wildcard, + area: Math.max(0, Math.trunc(network.area)) + })) + } + : undefined; + } + + getOspfSnapshot(deviceId: string): OspfSnapshot | null { + return this.ospf.getSnapshot(deviceId); + } + createLink(input: { id?: string; a: LinkEndpoint; b: LinkEndpoint; cableType?: CableType }): Link { if (input.a.deviceId === input.b.deviceId && input.a.interfaceName === input.b.interfaceName) { throw new Error("Invalid link endpoints"); } - const aDevice = this.getDevice(input.a.deviceId) ?? this.createDevice({ id: input.a.deviceId }); const bDevice = this.getDevice(input.b.deviceId) ?? this.createDevice({ id: input.b.deviceId }); - if (!aDevice.config.interfaces[input.a.interfaceName]) { aDevice.config.interfaces[input.a.interfaceName] = ensureInterfaceDefaults( input.a.interfaceName, @@ -193,14 +203,12 @@ export class World { deviceCapabilities(bDevice.type).defaultAdminUp ); } - if (this.l2.findLinkByEndpoint(input.a) || this.l2.findLinkByEndpoint(input.b)) { throw new Error("Interface already connected"); } - const desiredCableType: CableType = input.cableType ?? "auto"; const resolvedCableType = resolveCableType( - desiredCableType, + input.cableType ?? "auto", { deviceType: aDevice.type, interfaceName: input.a.interfaceName }, { deviceType: bDevice.type, interfaceName: input.b.interfaceName } ); @@ -210,15 +218,14 @@ export class World { { deviceType: bDevice.type, interfaceName: input.b.interfaceName } ); - const id = input.id ?? randomUUID(); const link: Link = { - id, - a: { deviceId: input.a.deviceId, interfaceName: input.a.interfaceName }, - b: { deviceId: input.b.deviceId, interfaceName: input.b.interfaceName }, + id: input.id ?? randomUUID(), + a: { ...input.a }, + b: { ...input.b }, cableType: resolvedCableType }; - this.links.set(id, link); - this.eventBus.emit({ type: "link:created", linkId: id }); + this.links.set(link.id, link); + this.eventBus.emit({ type: "link:created", linkId: link.id }); return link; } From 16011d280ce255d4fbb6ecefea0d2a4b7836c23e Mon Sep 17 00:00:00 2001 From: John <74981366+jojo-swe@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:50:47 +0200 Subject: [PATCH 5/6] Add OSPF convergence regression tests --- apps/api/src/regression/ospf.test.ts | 126 +++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 apps/api/src/regression/ospf.test.ts diff --git a/apps/api/src/regression/ospf.test.ts b/apps/api/src/regression/ospf.test.ts new file mode 100644 index 0000000..5da2208 --- /dev/null +++ b/apps/api/src/regression/ospf.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { World } from "../sim/world.js"; + +function configureInterface( + world: World, + deviceId: string, + interfaceName: string, + address: string, + mask = "255.255.255.252", + cost = 10 +): void { + const device = world.getDevice(deviceId); + if (!device) throw new Error(`missing device ${deviceId}`); + device.config.interfaces[interfaceName] = { + name: interfaceName, + adminUp: true, + ipv4Address: address, + ipv4Mask: mask, + ospfCost: cost + }; +} + +function enableOspf(world: World, deviceId: string, routerId: string, area = 0): void { + world.configureOspf(deviceId, { + enabled: true, + processId: 1, + routerId, + networks: [{ network: "10.0.0.0", wildcard: "0.255.255.255", area }] + }); +} + +function buildThreeRouterLine(): World { + const world = new World(); + world.createDevice({ id: "R1", type: "router", hostname: "R1" }); + world.createDevice({ id: "R2", type: "router", hostname: "R2" }); + world.createDevice({ id: "R3", type: "router", hostname: "R3" }); + + configureInterface(world, "R1", "GigabitEthernet0/0", "10.12.0.1"); + configureInterface(world, "R2", "GigabitEthernet0/0", "10.12.0.2"); + configureInterface(world, "R2", "GigabitEthernet0/1", "10.23.0.2"); + configureInterface(world, "R3", "GigabitEthernet0/0", "10.23.0.3"); + + world.createLink({ + id: "R1-R2", + a: { deviceId: "R1", interfaceName: "GigabitEthernet0/0" }, + b: { deviceId: "R2", interfaceName: "GigabitEthernet0/0" }, + cableType: "copper_crossover" + }); + world.createLink({ + id: "R2-R3", + a: { deviceId: "R2", interfaceName: "GigabitEthernet0/1" }, + b: { deviceId: "R3", interfaceName: "GigabitEthernet0/0" }, + cableType: "copper_crossover" + }); + + enableOspf(world, "R1", "1.1.1.1"); + enableOspf(world, "R2", "2.2.2.2"); + enableOspf(world, "R3", "3.3.3.3"); + return world; +} + +describe("OSPF control plane", () => { + it("forms full adjacencies and produces a shared link-state database", () => { + const world = buildThreeRouterLine(); + const r2 = world.getOspfSnapshot("R2"); + + expect(r2).not.toBeNull(); + expect(r2?.neighbors).toEqual([ + expect.objectContaining({ neighborDeviceId: "R1", state: "full", area: 0 }), + expect.objectContaining({ neighborDeviceId: "R3", state: "full", area: 0 }) + ]); + expect(r2?.lsdb.map((lsa) => lsa.advertisingRouter)).toEqual([ + "1.1.1.1", + "2.2.2.2", + "3.3.3.3" + ]); + }); + + it("runs SPF and installs a remote route with deterministic next hop and metric", () => { + const world = buildThreeRouterLine(); + const r1 = world.getOspfSnapshot("R1"); + const route = r1?.routes.find( + (candidate) => candidate.destination === "10.23.0.0" && candidate.mask === "255.255.255.252" + ); + + expect(route).toEqual( + expect.objectContaining({ + nextHop: "10.12.0.2", + outgoingInterface: "GigabitEthernet0/0", + metric: 20, + learnedFrom: "3.3.3.3" + }) + ); + }); + + it("uses learned OSPF routes for bidirectional forwarding", () => { + const world = buildThreeRouterLine(); + + expect(world.canPing("R1", "10.23.0.3")).toBe(true); + expect(world.traceRoute("R1", "10.23.0.3")).toEqual({ + ok: true, + hops: ["10.12.0.2", "10.23.0.3"] + }); + }); + + it("withdraws routes immediately after a link failure", () => { + const world = buildThreeRouterLine(); + expect(world.getOspfSnapshot("R1")?.routes).toHaveLength(1); + + world.deleteLink("R2-R3"); + + expect(world.getOspfSnapshot("R1")?.routes).toEqual([]); + expect(world.canPing("R1", "10.23.0.3")).toBe(false); + }); + + it("does not form an adjacency across an area mismatch", () => { + const world = buildThreeRouterLine(); + enableOspf(world, "R3", "3.3.3.3", 1); + + expect(world.getOspfSnapshot("R2")?.neighbors.map((neighbor) => neighbor.neighborDeviceId)).toEqual([ + "R1" + ]); + expect(world.getOspfSnapshot("R1")?.routes).toEqual([]); + }); +}); From 15ce6d7902271e016ed214ed24462cec7dced422 Mon Sep 17 00:00:00 2001 From: John <74981366+jojo-swe@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:51:21 +0200 Subject: [PATCH 6/6] Correct OSPF route expectations --- apps/api/src/regression/ospf.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/regression/ospf.test.ts b/apps/api/src/regression/ospf.test.ts index 5da2208..029def0 100644 --- a/apps/api/src/regression/ospf.test.ts +++ b/apps/api/src/regression/ospf.test.ts @@ -89,7 +89,7 @@ describe("OSPF control plane", () => { nextHop: "10.12.0.2", outgoingInterface: "GigabitEthernet0/0", metric: 20, - learnedFrom: "3.3.3.3" + learnedFrom: "2.2.2.2" }) ); }); @@ -121,6 +121,6 @@ describe("OSPF control plane", () => { expect(world.getOspfSnapshot("R2")?.neighbors.map((neighbor) => neighbor.neighborDeviceId)).toEqual([ "R1" ]); - expect(world.getOspfSnapshot("R1")?.routes).toEqual([]); + expect(world.canPing("R1", "10.23.0.3")).toBe(false); }); });