// What the device sheet does to the document: a device's settings changed as // FILIUS' settings panels change them (JHostKonfiguration, // JSwitchKonfiguration, JModemKonfiguration, JVermittlungsrechnerKonfiguration // with its JWeiterleitungsTabelle, JGatewayConfiguration; measured with // SheetRun), each a transaction with the person's origin, like the design // tools' commands. // // A text field's setter takes the text only when the field's rule does // (fields.ts) and says so; the device keeps its value otherwise. A host's // wireless card and a switch's SSID move the wireless connections as Java // moves them: a host joins the first switch whose SSID it names, a switch // that changes its SSID loses its wireless hosts. // // Not adopted from Java: a host that shows its MAC address as its name takes // that address as its name as soon as another field is taken (SheetRun's // flow useMacAsNameThenOff); here the name stays what it was. And Java's // search for the access point (updateWifiConnection) keeps the last switch // with the SSID it passes until a device that is neither comes after the // host — which switch depends on unrelated devices; here it is the first. // Nor: an interface address that passes the panel's pattern but not // IP.ipCheck (".0.0.1") becomes null in Java, and a router's panel then // throws; a table cell Java refuses is stored as null. Here neither is taken. // Nor that a DHCP server keeps its bounds as typed (".0.0.1" too), drops a // second static assignment of a MAC address without a word, and that a home // router keeps any port forwarding row, valid or not (its firmware then // forwards only the valid ones): here a value passes its rule or is not // taken, and the sheet says why. Nor that Java's firewall table writes a // cell into the rule before it checks it, so that a rule holds "abc" or a // mask of 255.0.255.0 while the table shows the old value, and reads a port // that is no number as "all ports". Nor that Java's connections dialog // removes a router's last card too: a router without one keeps no gateway // (setStandardGateway writes it into the cards; SheetRun's "connections"), // nor could a file hold it; here one card stays. import * as Y from 'yjs'; import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, ALL_PORTS, dhcpServerConfiguration, firewallConfiguration, firewallRule, forwardsIPv4ByDefault, type FirewallAction, type FirewallProtocol, type DhcpStaticAssignment, type GatewayPortForwardingRow, type Id, type ManualRoute, type NodeKind, } from '@mdg-filius/core'; import { modemPair } from '@mdg-filius/fls'; import { connect, ROUTER_INTERFACES, type ConnectError } from './commands.js'; import { checkField, type CheckedField, type FieldRule } from './fields.js'; import { inOrder, ROOTS } from './projectDoc.js'; import { fromY, toY, type PlainValue } from './values.js'; const nodes = (doc: Y.Doc) => doc.getMap>(ROOTS.nodes); const links = (doc: Y.Doc) => doc.getMap>(ROOTS.links); const settingsOf = (node: Y.Map) => node.get('settings') as Y.Map; const kindOf = (node: Y.Map) => node.get('kind') as NodeKind; /** A setting of a device as a map of its fields, made with these fields when the device has none. */ function settingMap(node: Y.Map, key: string, fields: Readonly>): Y.Map { const settings = settingsOf(node); let map = settings.get(key) as Y.Map | undefined; if (map === undefined) { map = toY(fields) as Y.Map; settings.set(key, map); } return map; } /** Changes a device in one transaction when the text passes the rule; says what it came to. */ function setChecked(doc: Y.Doc, id: Id, checked: CheckedField, change: (node: Y.Map, value: string) => void, origin: unknown): CheckedField { const node = nodes(doc).get(id); if (node === undefined || 'invalid' in checked) return checked; doc.transact(() => change(node, checked.value), origin); return checked; } export function renameDevice(doc: Y.Doc, id: Id, name: string, origin: unknown): void { const node = nodes(doc).get(id); if (node !== undefined) doc.transact(() => node.set('name', name), origin); } /** What a PC or notebook shows as its name (Host.useIPAsName, useMACAsName): its address, its MAC address, both or its name. */ export function setHostLabels(doc: Y.Doc, id: Id, labels: { readonly ipAddress: boolean; readonly macAddress: boolean }, origin: unknown): void { const node = nodes(doc).get(id); if (node === undefined) return; const policy = labels.ipAddress && labels.macAddress ? 'ipAndMAC' : labels.ipAddress ? 'ipAddress' : labels.macAddress ? 'macAddress' : 'manual'; doc.transact(() => node.set('labelPolicy', policy), origin); } export type HostAddressField = 'ipAddress' | 'subnetMask' | 'defaultGateway' | 'dnsServer'; const ADDRESS_RULES = { ipAddress: 'ipAddress', subnetMask: 'subnetMask', defaultGateway: 'ipAddressOrEmpty', dnsServer: 'ipAddressOrEmpty' } as const; /** A PC's or notebook's address, mask, gateway or DNS server (Betriebssystem.setzeIPAdresse …). */ export function setHostAddress(doc: Y.Doc, id: Id, field: HostAddressField, text: string, origin: unknown): CheckedField { return setChecked( doc, id, checkField(ADDRESS_RULES[field], text), (node, value) => settingMap(node, 'device', { ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '' }).set(field, value), origin, ); } /** IP forwarding, kept only where it differs from the kind's (as the project keeps it). */ export function setIpForwarding(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void { const node = nodes(doc).get(id); if (node === undefined) return; doc.transact(() => { if (on === forwardsIPv4ByDefault(kindOf(node))) settingsOf(node).delete('ipForwarding'); else settingsOf(node).set('ipForwarding', on); }, origin); } /** * DHCP for the configuration. On a PC or notebook it stops the device's own * DHCP server (JHostKonfiguration.aenderungenAnnehmen); a home router's * panel (its WAN card) leaves its server running, as Java does. */ export function setDhcpClient(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void { const node = nodes(doc).get(id); if (node === undefined) return; doc.transact(() => { if (!on) { settingsOf(node).delete('dhcpClient'); return; } settingsOf(node).set('dhcpClient', toY({ isEnabled: true })); if (kindOf(node) === 'pc' || kindOf(node) === 'notebook') (settingsOf(node).get('dhcpServer') as Y.Map | undefined)?.set('isActive', false); }, origin); } const wirelessOf = (node: Y.Map) => (settingsOf(node).get('wireless') as Y.Map | undefined)?.get('isEnabled') === true; /** The cables of a device to a switch: a wireless host's wireless connections. */ function linksToSwitches(doc: Y.Doc, id: Id): string[] { return [...links(doc).entries()] .filter(([, link]) => { const ends = [link.get('sourceNode') as Id, link.get('targetNode') as Id]; const other = ends[0] === id ? ends[1]! : ends[1] === id ? ends[0]! : undefined; const node = other === undefined ? undefined : nodes(doc).get(other); return node !== undefined && kindOf(node) === 'networkSwitch'; }) .map(([linkId]) => linkId); } /** Why a host's card cannot become wireless (Java disables the choice): its cable is plugged. */ export type WirelessError = 'cablePlugged' | ConnectError; /** * A PC's or notebook's card by cable or wireless, and the SSID of the network * it joins ('' for none). Java (JHostKonfiguration.updateWifiConnection): * when either changes, the host leaves its wireless network, then joins the * first switch with that SSID, when it is wireless and names one. A card * whose cable is plugged cannot become wireless. The SSID stays when the * card goes back to the cable, as Java keeps it. */ export function setHostWireless(doc: Y.Doc, id: Id, wireless: boolean, ssid: string, linkId: Id, origin: unknown): { readonly link?: Id } | { readonly error: WirelessError } { const node = nodes(doc).get(id); if (node === undefined) return { error: 'unknownDevice' }; const before = settingsOf(node).get('wireless') as Y.Map | undefined; const wasWireless = wirelessOf(node); const plugged = [...links(doc).values()].some((link) => link.get('sourceNode') === id || link.get('targetNode') === id); if (wireless && !wasWireless && plugged) return { error: 'cablePlugged' }; if (wasWireless === wireless && ((before?.get('ssid') as string | undefined) ?? '') === ssid) return {}; let result: { readonly link?: Id } | { readonly error: WirelessError } = {}; doc.transact(() => { if (wasWireless) for (const each of linksToSwitches(doc, id)) links(doc).delete(each); if (!wireless && ssid === '') settingsOf(node).delete('wireless'); else settingsOf(node).set('wireless', toY({ isEnabled: wireless, ssid })); if (!wireless || ssid === '') return; const accessPoint = inOrder(nodes(doc)).find(([, candidate]) => kindOf(candidate) === 'networkSwitch' && (settingsOf(candidate).get('switch') as Y.Map | undefined)?.get('ssid') === ssid); if (accessPoint !== undefined) result = connect(doc, { node: id }, { node: accessPoint[0] }, linkId, origin); }, origin); return result; } const SWITCH_DEFAULTS = (id: Id) => ({ ...defaultSwitchConfiguration(id) }); /** A switch's SSID; a new one drops its wireless hosts (JSwitchKonfiguration.aenderungenAnnehmen). */ export function setSwitchSsid(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField { return setChecked( doc, id, checkField('ssid', text), (node, value) => { const setting = settingMap(node, 'switch', SWITCH_DEFAULTS(id)); if (setting.get('ssid') === value) return; setting.set('ssid', value); for (const linkId of [...links(doc).keys()]) { const link = links(doc).get(linkId)!; const ends = [link.get('sourceNode') as Id, link.get('targetNode') as Id]; const host = ends[0] === id ? ends[1]! : ends[1] === id ? ends[0]! : undefined; const other = host === undefined ? undefined : nodes(doc).get(host); if (other !== undefined && (kindOf(other) === 'pc' || kindOf(other) === 'notebook') && wirelessOf(other)) links(doc).delete(linkId); } }, origin, ); } /** How long a switch keeps a learned MAC address, in seconds as the panel asks for it. */ export function setSwitchRetention(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField { return setChecked(doc, id, checkField('seconds', text), (node, value) => settingMap(node, 'switch', SWITCH_DEFAULTS(id)).set('retentionTimeMilliseconds', Number(value) * 1000), origin); } /** A switch drawn as a cloud (Switch.cloud), kept only when true. */ export function setSwitchCloud(doc: Y.Doc, id: Id, cloud: boolean, origin: unknown): void { const node = nodes(doc).get(id); if (node === undefined) return; doc.transact(() => { const setting = settingMap(node, 'switch', SWITCH_DEFAULTS(id)); if (cloud) setting.set('showsAsCloud', true); else setting.delete('showsAsCloud'); }, origin); } /** A modem's port: the pair it belongs to (two modems on one port are a pair, as the export writes them). */ export function setModemPort(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField { return setChecked( doc, id, checkField('port', text), (node, value) => settingMap(node, 'remoteLink', { pairIdentifier: '', latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true }).set('pairIdentifier', modemPair(Number(value))), origin, ); } const portsOf = (node: Y.Map) => node.get('ports') as Y.Array>; /** * A router's or home router's interface: its address or mask * (NetzwerkInterface.setIp, setSubnetzMaske). The device's own settings * mirror its first interface, as the project keeps them (the home router's * WAN card). */ export function setInterfaceAddress(doc: Y.Doc, id: Id, portId: Id, field: 'ipAddress' | 'subnetMask', text: string, origin: unknown): CheckedField { const node = nodes(doc).get(id); const ports = node === undefined ? [] : portsOf(node).toArray(); const index = ports.findIndex((port) => port.get('id') === portId); if (index < 0) return checkField(field, text); return setChecked( doc, id, checkField(field, text), (device, value) => { const port = ports[index]!; let config = port.get('config') as Y.Map | undefined; if (config === undefined) { config = toY({ ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' }) as Y.Map; port.set('config', config); } config.set(field, value); if (index === 0) (settingsOf(device).get('device') as Y.Map | undefined)?.set(field, value); }, origin, ); } /** * A router's or home router's standard gateway (setStandardGateway: the * gateway of every card), empty for none. A router keeps its own settings * only while it has a gateway, as the project keeps them. */ export function setRouterGateway(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField { return setChecked( doc, id, checkField('ipAddressOrEmpty', text), (node, value) => { const first = portsOf(node).get(0)?.get('config') as Y.Map | undefined; const device = settingMap(node, 'device', { ipAddress: (first?.get('ipAddress') as string | undefined) ?? '', subnetMask: (first?.get('subnetMask') as string | undefined) ?? '', defaultGateway: '', dnsServer: '', }); device.set('defaultGateway', value); if (kindOf(node) === 'router' && value === '' && device.get('dnsServer') === '') settingsOf(node).delete('device'); }, origin, ); } /** RIP on a router, kept only when on (as the project keeps it). */ export function setRip(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void { const node = nodes(doc).get(id); if (node === undefined) return; doc.transact(() => { if (on) settingsOf(node).set('rip', true); else settingsOf(node).delete('rip'); }, origin); } /** A router's ports, or none for another device. */ function routerPorts(doc: Y.Doc, id: Id): Y.Array> | undefined { const node = nodes(doc).get(id); return node === undefined || kindOf(node) !== 'router' ? undefined : portsOf(node); } /** * A card added to a router after its last (JConnectionsDialog's "+": * hinzuAnschluss, a new NetzwerkInterface with 192.168.0.10/24 and its own * MAC address, here from the port's id), up to eight; says whether it was. */ export function addRouterInterface(doc: Y.Doc, id: Id, portId: Id, origin: unknown): boolean { const ports = routerPorts(doc, id); if (ports === undefined || ports.length >= ROUTER_INTERFACES.maximum) return false; doc.transact(() => { const port = new Y.Map(); port.set('id', portId); port.set('label', `rt${ports.length + 1}`); port.set('occupied', false); port.set('config', toY({ ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' })); ports.push([port]); }, origin); return true; } type PluggedEnd = { readonly linkId: Id; readonly link: Y.Map; readonly end: 'sourcePort' | 'targetPort' }; /** The cables at a device's port, with the end that plugs into it. */ function cablesAt(doc: Y.Doc, id: Id, portId: Id): PluggedEnd[] { const ends: PluggedEnd[] = []; for (const [linkId, link] of links(doc).entries()) { if (link.get('sourceNode') === id && link.get('sourcePort') === portId) ends.push({ linkId, link, end: 'sourcePort' }); else if (link.get('targetNode') === id && link.get('targetPort') === portId) ends.push({ linkId, link, end: 'targetPort' }); } return ends; } /** A router's last card removed with its cable (JConnectionsDialog's "-"), down to one; says whether it was. */ export function removeRouterInterface(doc: Y.Doc, id: Id, origin: unknown): boolean { const ports = routerPorts(doc, id); if (ports === undefined || ports.length <= ROUTER_INTERFACES.fewest) return false; const last = ports.get(ports.length - 1).get('id') as Id; doc.transact(() => { for (const { linkId } of cablesAt(doc, id, last)) links(doc).delete(linkId); ports.delete(ports.length - 1); }, origin); return true; } /** * Two of a router's cards trade their cables (JConnectionsDialog.swapConnection): * both plugged, the cables swap; one plugged, its cable moves to the other * card; neither, nothing changes. The cables stay the same cables, joining the * same devices. Says whether a cable moved. */ export function swapRouterCables(doc: Y.Doc, id: Id, first: Id, second: Id, origin: unknown): boolean { const ports = routerPorts(doc, id); const has = (portId: Id) => ports?.toArray().some((port) => port.get('id') === portId) === true; if (first === second || !has(first) || !has(second)) return false; const atFirst = cablesAt(doc, id, first); const atSecond = cablesAt(doc, id, second); if (atFirst.length === 0 && atSecond.length === 0) return false; doc.transact(() => { for (const { link, end } of atFirst) link.set(end, second); for (const { link, end } of atSecond) link.set(end, first); }, origin); return true; } /** * A click on a card's button in Java's connections dialog, with the card * marked before (null for none): the first click marks a card, a click on it * again unmarks it, a click on another card swaps the two cards' cables * (swapRouterCables) and leaves none marked. */ export function connectionsClick(marked: number | null, clicked: number): { readonly marked: number | null; readonly swap?: readonly [number, number] } { if (marked === null) return { marked: clicked }; if (marked === clicked) return { marked: null }; return { marked: null, swap: [marked, clicked] }; } /** A column of the forwarding table's manual rows. */ export type RouteColumn = keyof ManualRoute; export const ROUTE_COLUMNS: readonly RouteColumn[] = ['destinationNetwork', 'subnetMask', 'gateway', 'interfaceIPAddress']; /** Each column's rule (JWeiterleitungsTabelle: musterIpAdresse, musterSubNetz for the mask). */ const routeRule = (column: RouteColumn) => (column === 'subnetMask' ? 'subnetMask' : 'ipAddress'); function routesOf(node: Y.Map): Y.Array> { let routes = settingsOf(node).get('routes') as Y.Array> | undefined; if (routes === undefined) { routes = new Y.Array>(); settingsOf(node).set('routes', routes); } return routes; } /** * A new manual row, taken once all four cells pass (Java keeps a new row in * the table until Weiterleitungstabelle.validate takes it); the columns * that do not pass otherwise. */ export function addRoute(doc: Y.Doc, id: Id, cells: Readonly>, origin: unknown): { readonly route: ManualRoute } | { readonly invalid: readonly RouteColumn[] } { const checked = ROUTE_COLUMNS.map((column) => [column, checkField(routeRule(column), cells[column])] as const); const invalid = checked.filter(([, each]) => 'invalid' in each).map(([column]) => column); const node = nodes(doc).get(id); if (invalid.length > 0 || node === undefined) return { invalid }; const route = Object.fromEntries(checked.map(([column, each]) => [column, (each as { value: string }).value])) as unknown as ManualRoute; doc.transact(() => routesOf(node).push([toY({ ...route }) as Y.Map]), origin); return { route }; } /** A cell of a manual row (by its place among the manual rows). */ export function setRouteCell(doc: Y.Doc, id: Id, index: number, column: RouteColumn, text: string, origin: unknown): CheckedField { return setChecked(doc, id, checkField(routeRule(column), text), (node, value) => (settingsOf(node).get('routes') as Y.Array> | undefined)?.get(index)?.set(column, value), origin); } /** Removes a manual row (by its place among the manual rows); the list goes with its last row, as the project keeps it. */ export function removeRoute(doc: Y.Doc, id: Id, index: number, origin: unknown): void { const node = nodes(doc).get(id); const routes = node === undefined ? undefined : (settingsOf(node).get('routes') as Y.Array> | undefined); if (node === undefined || routes === undefined || index < 0 || index >= routes.length) return; doc.transact(() => { routes.delete(index, 1); if (routes.length === 0) settingsOf(node).delete('routes'); }, origin); } /** Checks a row's cells by their rules: the values, or the columns that do not pass. */ function checkRow( columns: readonly Column[], rule: (column: Column) => FieldRule, cells: Readonly>, ): { readonly values: Record } | { readonly invalid: readonly Column[] } { const checked = columns.map((column) => [column, checkField(rule(column), cells[column])] as const); const invalid = checked.filter(([, each]) => 'invalid' in each).map(([column]) => column); if (invalid.length > 0) return { invalid }; return { values: Object.fromEntries(checked.map(([column, each]) => [column, (each as { value: string }).value])) as Record }; } /** A device's DHCP server, made as Java's constructor leaves it when the device has none in the document. */ function dhcpServerOf(node: Y.Map): Y.Map { let server = settingsOf(node).get('dhcpServer') as Y.Map | undefined; if (server === undefined) { server = toY({ ...dhcpServerConfiguration(), staticAssignments: [] }) as Y.Map; settingsOf(node).set('dhcpServer', server); } return server; } export type DhcpServerField = 'lowerBoundIPAddress' | 'upperBoundIPAddress' | 'gatewayIPAddress' | 'dnsServerIPAddress'; /** An address of a DHCP server (JDHCPKonfiguration: musterIpAdresse, never empty), as Java's IP.ipCheck writes it. */ export function setDhcpServerAddress(doc: Y.Doc, id: Id, field: DhcpServerField, text: string, origin: unknown): CheckedField { return setChecked(doc, id, checkField('ipAddress', text), (node, value) => dhcpServerOf(node).set(field, value), origin); } /** A DHCP server switched on or off, or handing out its own gateway and DNS server instead of the device's. */ export function setDhcpServerFlag(doc: Y.Doc, id: Id, flag: 'isActive' | 'useOwnSettings', on: boolean, origin: unknown): void { const node = nodes(doc).get(id); if (node !== undefined) doc.transact(() => dhcpServerOf(node).set(flag, on), origin); } export type StaticAssignmentColumn = 'macAddress' | 'ipAddress'; export const STATIC_ASSIGNMENT_COLUMNS: readonly StaticAssignmentColumn[] = ['macAddress', 'ipAddress']; const staticRule = (column: StaticAssignmentColumn): FieldRule => (column === 'macAddress' ? 'macAddress' : 'ipAddress'); const staticAssignmentsOf = (node: Y.Map) => { const server = dhcpServerOf(node); let list = server.get('staticAssignments') as Y.Array> | undefined; if (list === undefined) { list = new Y.Array>(); server.set('staticAssignments', list); } return list; }; /** Whether another static assignment has this MAC address (Java compares ignoring case). */ function macTaken(list: Y.Array>, mac: string, except: number): boolean { return list.toArray().some((entry, index) => index !== except && (entry.get('macAddress') as string).toUpperCase() === mac.toUpperCase()); } /** A static assignment of the DHCP server: once both cells pass and the MAC address has none yet. */ export function addDhcpStaticAssignment( doc: Y.Doc, id: Id, cells: Readonly>, assignmentId: Id, origin: unknown, ): { readonly assignment: DhcpStaticAssignment } | { readonly invalid: readonly StaticAssignmentColumn[] } | { readonly error: 'macTaken' } { const checked = checkRow(STATIC_ASSIGNMENT_COLUMNS, staticRule, cells); const node = nodes(doc).get(id); if ('invalid' in checked || node === undefined) return 'invalid' in checked ? checked : { invalid: [] }; const existing = settingsOf(node).get('dhcpServer') as Y.Map | undefined; const list = existing?.get('staticAssignments') as Y.Array> | undefined; if (list !== undefined && macTaken(list, checked.values.macAddress, -1)) return { error: 'macTaken' }; const assignment = { id: assignmentId, ...checked.values }; doc.transact(() => staticAssignmentsOf(node).push([toY(assignment) as Y.Map]), origin); return { assignment }; } /** A cell of a static assignment; a MAC address another assignment has is not taken. */ export function setDhcpStaticCell(doc: Y.Doc, id: Id, index: number, column: StaticAssignmentColumn, text: string, origin: unknown): CheckedField { const node = nodes(doc).get(id); const list = (node?.get('settings') as Y.Map | undefined)?.get('dhcpServer') as Y.Map | undefined; const entries = list?.get('staticAssignments') as Y.Array> | undefined; const checked = checkField(staticRule(column), text); if (entries === undefined || index < 0 || index >= entries.length) return checked; if ('value' in checked && column === 'macAddress' && macTaken(entries, checked.value, index)) return { invalid: true }; return setChecked(doc, id, checked, () => entries.get(index).set(column, (checked as { value: string }).value), origin); } export function removeDhcpStaticAssignment(doc: Y.Doc, id: Id, index: number, origin: unknown): void { const node = nodes(doc).get(id); const entries = ((node?.get('settings') as Y.Map | undefined)?.get('dhcpServer') as Y.Map | undefined)?.get('staticAssignments') as Y.Array> | undefined; if (entries === undefined || index < 0 || index >= entries.length) return; doc.transact(() => entries.delete(index, 1), origin); } export type PortForwardingColumn = keyof GatewayPortForwardingRow; export const PORT_FORWARDING_COLUMNS: readonly PortForwardingColumn[] = ['protocolValue', 'publicPortValue', 'lanIPAddress', 'lanPortValue']; /** Java's protocols of a port forwarding row, as its dialog offers them. */ export const PORT_FORWARDING_PROTOCOLS = ['TCP', 'UDP'] as const; /** Each column's rule (GatewayFirmware.validateData): a protocol, ports by musterPort, the LAN address by musterIpAdresse. */ const forwardingRule = (column: PortForwardingColumn): FieldRule => (column === 'lanIPAddress' ? 'ipAddress' : column === 'protocolValue' ? 'name' : 'port'); function checkForwardingRow(cells: Readonly>) { const checked = checkRow(PORT_FORWARDING_COLUMNS, forwardingRule, cells); const protocolTaken = (PORT_FORWARDING_PROTOCOLS as readonly string[]).includes(cells.protocolValue); if ('invalid' in checked) return protocolTaken ? checked : { invalid: ['protocolValue' as const, ...checked.invalid] }; return protocolTaken ? checked : { invalid: ['protocolValue' as const] }; } const forwardingOf = (node: Y.Map) => { let list = settingsOf(node).get('portForwarding') as Y.Array> | undefined; if (list === undefined) { list = new Y.Array>(); settingsOf(node).set('portForwarding', list); } return list; }; /** A home router's port forwarding row, once all four cells pass (Java keeps any row; its firmware forwards only the valid ones). */ export function addPortForwarding( doc: Y.Doc, id: Id, cells: Readonly>, origin: unknown, ): { readonly row: GatewayPortForwardingRow } | { readonly invalid: readonly PortForwardingColumn[] } { const checked = checkForwardingRow(cells); const node = nodes(doc).get(id); if ('invalid' in checked || node === undefined) return 'invalid' in checked ? checked : { invalid: [] }; const row = checked.values as GatewayPortForwardingRow; doc.transact(() => forwardingOf(node).push([toY({ ...row }) as Y.Map]), origin); return { row }; } export function setPortForwardingCell(doc: Y.Doc, id: Id, index: number, column: PortForwardingColumn, text: string, origin: unknown): CheckedField { const checked: CheckedField = column === 'protocolValue' ? ((PORT_FORWARDING_PROTOCOLS as readonly string[]).includes(text) ? { value: text } : { invalid: true }) : checkField(forwardingRule(column), text); return setChecked(doc, id, checked, (node, value) => (settingsOf(node).get('portForwarding') as Y.Array> | undefined)?.get(index)?.set(column, value), origin); } /** Removes a port forwarding row; the list goes with its last row, as the project keeps it. */ export function removePortForwarding(doc: Y.Doc, id: Id, index: number, origin: unknown): void { const node = nodes(doc).get(id); const list = node === undefined ? undefined : (settingsOf(node).get('portForwarding') as Y.Array> | undefined); if (node === undefined || list === undefined || index < 0 || index >= list.length) return; doc.transact(() => { list.delete(index, 1); if (list.length === 0) settingsOf(node).delete('portForwarding'); }, origin); } /** A router's or home router's firewall, made as Java's constructor leaves it when the device has none in the document. */ function firewallOf(node: Y.Map): Y.Map { let firewall = settingsOf(node).get('firewall') as Y.Map | undefined; if (firewall === undefined) { firewall = toY({ ...firewallConfiguration(), rules: [] }) as Y.Map; settingsOf(node).set('firewall', firewall); } return firewall; } const rulesOf = (node: Y.Map) => { const firewall = firewallOf(node); let rules = firewall.get('rules') as Y.Array> | undefined; if (rules === undefined) { rules = new Y.Array>(); firewall.set('rules', rules); } return rules; }; /** The firewall switched on, dropping ICMP, or dropping only SYN segments (JFirewallDialog's general tab). */ export function setFirewallFlag(doc: Y.Doc, id: Id, flag: 'isActive' | 'dropIcmp' | 'filterSynSegmentsOnly', on: boolean, origin: unknown): void { const node = nodes(doc).get(id); if (node !== undefined) doc.transact(() => firewallOf(node).set(flag, on), origin); } /** What the firewall does when no rule matches. */ export function setFirewallDefaultPolicy(doc: Y.Doc, id: Id, policy: FirewallAction, origin: unknown): void { const node = nodes(doc).get(id); if (node !== undefined) doc.transact(() => firewallOf(node).set('defaultPolicy', policy), origin); } /** A new rule at the end, as Java's makes it (Firewall.addRule: every field empty, TCP, all ports, drop); its place. */ export function addFirewallRule(doc: Y.Doc, id: Id, origin: unknown): number | undefined { const node = nodes(doc).get(id); if (node === undefined) return undefined; let place = 0; doc.transact(() => { const rules = rulesOf(node); rules.push([toY({ ...firewallRule() }) as Y.Map]); place = rules.length - 1; }, origin); return place; } export type FirewallRuleColumn = 'sourceIPAddress' | 'sourceSubnetMask' | 'destinationIPAddress' | 'destinationSubnetMask' | 'port'; export const FIREWALL_RULE_COLUMNS: readonly FirewallRuleColumn[] = ['sourceIPAddress', 'sourceSubnetMask', 'destinationIPAddress', 'destinationSubnetMask', 'port']; /** Each text column's rule (FirewallRule.validateExistingAttributes; the marker only as a source). */ export const firewallCellRule = (column: FirewallRuleColumn): FieldRule => column === 'sourceIPAddress' ? 'firewallSource' : column === 'destinationIPAddress' ? 'ipAddressOrEmpty' : column === 'port' ? 'portOrEmpty' : 'firewallMask'; const ruleAt = (node: Y.Map, index: number) => ((settingsOf(node).get('firewall') as Y.Map | undefined)?.get('rules') as Y.Array> | undefined)?.get(index); /** A text cell of a rule; an empty port means all ports. */ export function setFirewallRuleCell(doc: Y.Doc, id: Id, index: number, column: FirewallRuleColumn, text: string, origin: unknown): CheckedField { return setChecked( doc, id, checkField(firewallCellRule(column), text), (node, value) => ruleAt(node, index)?.set(column, column === 'port' ? (value === '' ? ALL_PORTS : Number(value)) : value), origin, ); } /** A rule's protocol or action (Java's lists: *, TCP, UDP; accept, drop). */ export function setFirewallRuleChoice(doc: Y.Doc, id: Id, index: number, choice: { readonly protocol: FirewallProtocol } | { readonly action: FirewallAction }, origin: unknown): void { const node = nodes(doc).get(id); const rule = node === undefined ? undefined : ruleAt(node, index); if (rule === undefined) return; doc.transact(() => ('protocol' in choice ? rule.set('protocol', choice.protocol) : rule.set('action', choice.action)), origin); } /** Moves a rule one place up (-1) or down (+1); the first goes no higher, the last no lower (Firewall.moveUp, moveDown). */ export function moveFirewallRule(doc: Y.Doc, id: Id, index: number, step: -1 | 1, origin: unknown): boolean { const node = nodes(doc).get(id); const rules = node === undefined ? undefined : ((settingsOf(node).get('firewall') as Y.Map | undefined)?.get('rules') as Y.Array> | undefined); const target = index + step; if (rules === undefined || index < 0 || index >= rules.length || target < 0 || target >= rules.length) return false; doc.transact(() => { // A Yjs type cannot be inserted twice: the rule moves as a copy of its fields. const moved = fromY(rules.get(index)) as PlainValue; rules.delete(index, 1); rules.insert(target, [toY(moved) as Y.Map]); }, origin); return true; } export function removeFirewallRule(doc: Y.Doc, id: Id, index: number, origin: unknown): void { const node = nodes(doc).get(id); const rules = node === undefined ? undefined : ((settingsOf(node).get('firewall') as Y.Map | undefined)?.get('rules') as Y.Array> | undefined); if (rules === undefined || index < 0 || index >= rules.length) return; doc.transact(() => rules.delete(index, 1), origin); }