SheetRun now clicks through Java's JConnectionsDialog with a router, a computer and a switch cabled in the GUIContainer: a click marks a card, the same click unmarks it, another card swaps the cables or moves the one; "+" adds 192.168.0.10/24 up to eight cards, "-" removes the last card with its cable down to none, and a router without a card keeps no gateway. The document adds, removes and swaps a router's cards (the same cables, the same devices), Java's click logic is one function for the test and the page, and the sheet names the address at a cable's far end. The page "Verbindungen verwalten" shows Java's RJ45 buttons in Java's colours with each card's far end below. Not adopted: the last card stays. A card tab or a mark whose card is undone away falls back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
671 lines
34 KiB
TypeScript
671 lines
34 KiB
TypeScript
// 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<Y.Map<unknown>>(ROOTS.nodes);
|
|
const links = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.links);
|
|
const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<unknown>;
|
|
const kindOf = (node: Y.Map<unknown>) => 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<unknown>, key: string, fields: Readonly<Record<string, string | number | boolean>>): Y.Map<unknown> {
|
|
const settings = settingsOf(node);
|
|
let map = settings.get(key) as Y.Map<unknown> | undefined;
|
|
if (map === undefined) {
|
|
map = toY(fields) as Y.Map<unknown>;
|
|
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<unknown>, 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<unknown> | undefined)?.set('isActive', false);
|
|
}, origin);
|
|
}
|
|
|
|
const wirelessOf = (node: Y.Map<unknown>) => (settingsOf(node).get('wireless') as Y.Map<unknown> | 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<unknown> | 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<unknown> | 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<unknown>) => node.get('ports') as Y.Array<Y.Map<unknown>>;
|
|
|
|
/**
|
|
* 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<unknown> | undefined;
|
|
if (config === undefined) {
|
|
config = toY({ ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' }) as Y.Map<unknown>;
|
|
port.set('config', config);
|
|
}
|
|
config.set(field, value);
|
|
if (index === 0) (settingsOf(device).get('device') as Y.Map<unknown> | 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<unknown> | 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<Y.Map<unknown>> | 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<unknown>();
|
|
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<unknown>; 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<unknown>): Y.Array<Y.Map<unknown>> {
|
|
let routes = settingsOf(node).get('routes') as Y.Array<Y.Map<unknown>> | undefined;
|
|
if (routes === undefined) {
|
|
routes = new Y.Array<Y.Map<unknown>>();
|
|
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<Record<RouteColumn, string>>, 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<unknown>]), 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<Y.Map<unknown>> | 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<Y.Map<unknown>> | 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<Column extends string>(
|
|
columns: readonly Column[],
|
|
rule: (column: Column) => FieldRule,
|
|
cells: Readonly<Record<Column, string>>,
|
|
): { readonly values: Record<Column, string> } | { 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<Column, string> };
|
|
}
|
|
|
|
/** 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<unknown>): Y.Map<unknown> {
|
|
let server = settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined;
|
|
if (server === undefined) {
|
|
server = toY({ ...dhcpServerConfiguration(), staticAssignments: [] }) as Y.Map<unknown>;
|
|
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<unknown>) => {
|
|
const server = dhcpServerOf(node);
|
|
let list = server.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
|
|
if (list === undefined) {
|
|
list = new Y.Array<Y.Map<unknown>>();
|
|
server.set('staticAssignments', list);
|
|
}
|
|
return list;
|
|
};
|
|
|
|
/** Whether another static assignment has this MAC address (Java compares ignoring case). */
|
|
function macTaken(list: Y.Array<Y.Map<unknown>>, 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<Record<StaticAssignmentColumn, string>>,
|
|
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<unknown> | undefined;
|
|
const list = existing?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | 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<unknown>]), 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<unknown> | undefined)?.get('dhcpServer') as Y.Map<unknown> | undefined;
|
|
const entries = list?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | 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<unknown> | undefined)?.get('dhcpServer') as Y.Map<unknown> | undefined)?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | 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<Record<PortForwardingColumn, string>>) {
|
|
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<unknown>) => {
|
|
let list = settingsOf(node).get('portForwarding') as Y.Array<Y.Map<unknown>> | undefined;
|
|
if (list === undefined) {
|
|
list = new Y.Array<Y.Map<unknown>>();
|
|
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<Record<PortForwardingColumn, string>>,
|
|
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<unknown>]), 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<Y.Map<unknown>> | 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<Y.Map<unknown>> | 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<unknown>): Y.Map<unknown> {
|
|
let firewall = settingsOf(node).get('firewall') as Y.Map<unknown> | undefined;
|
|
if (firewall === undefined) {
|
|
firewall = toY({ ...firewallConfiguration(), rules: [] }) as Y.Map<unknown>;
|
|
settingsOf(node).set('firewall', firewall);
|
|
}
|
|
return firewall;
|
|
}
|
|
|
|
const rulesOf = (node: Y.Map<unknown>) => {
|
|
const firewall = firewallOf(node);
|
|
let rules = firewall.get('rules') as Y.Array<Y.Map<unknown>> | undefined;
|
|
if (rules === undefined) {
|
|
rules = new Y.Array<Y.Map<unknown>>();
|
|
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<unknown>]);
|
|
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<unknown>, index: number) => ((settingsOf(node).get('firewall') as Y.Map<unknown> | undefined)?.get('rules') as Y.Array<Y.Map<unknown>> | 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<unknown> | undefined)?.get('rules') as Y.Array<Y.Map<unknown>> | 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<unknown>]);
|
|
}, 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<unknown> | undefined)?.get('rules') as Y.Array<Y.Map<unknown>> | undefined);
|
|
if (rules === undefined || index < 0 || index >= rules.length) return;
|
|
doc.transact(() => rules.delete(index, 1), origin);
|
|
}
|