import { isIPv4, isIPv6 } from 'node:net' import { pruefeNow, pruefeText } from './util.js' /** * Grenzen des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 8): Zaehler im Speicher dieses * Prozesses, je Schluessel ein festes Fenster ab dem ersten gezaehlten Ereignis. Die Schluessel enthalten Adressen oder * IPs und verlassen den Speicher nie (kein Log, keine Datenbank). Hoechstens 10 000 Schluessel: ist die Map voll, * faellt der aelteste (zuerst begonnene) Zaehler weg; ein abgelaufenes Fenster wird beim naechsten Zaehlen neu begonnen. * Ein Neustart des Prozesses leert alles (die Grenzen sind Missbrauchsschutz, kein Buchungsrecht). * * Allgemein: zaehle / zaehleAlle (true = Ereignis erlaubt und gezaehlt, false = Grenze erreicht, nichts gezaehlt). * Mails des Widerrufs: darfG08 und darfK21NichtZugeordnet; sie gelten nur fuer Erklaerungen ohne passende * Buchung, ein Treffer bekommt seine Mails immer. * Oeffentliche Schnittstelle (routes/publicTermine.js): darfAnfrage / darfAnfrageSite (lesende Abrufe und Token-Aufrufe * je IP und je siteKey), darfBuchungsversuch (je IP), darfHaltung (je siteKey), darfWiderrufsversuch (je IP) und darfG01 * (Bestaetigungsmails je Adresse und Funktion). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist * ein ganzes /64, die Adresse darin wechselt beliebig. * * Speicher: zwei getrennte Maps mit je hoechstens 10 000 Schluesseln. Die Zaehler je IP (Schluessel mit 'ip:') haben * beliebig viele, von Angreifern frei waehlbare Schluessel; sie duerfen die langlebigen Zaehler je Adresse, Funktion * und siteKey (G01 und G08 einen Tag, Haltungen eine Stunde) nie verdraengen. Ist eine Map voll, werden zuerst alle * abgelaufenen Zaehler entfernt (jeder Eintrag merkt sich das Ende seines Fensters); erst wenn dann noch kein Platz ist, * faellt der aelteste noch laufende Zaehler dieser Map weg. */ const MAX_SCHLUESSEL = 10000 const STUNDE_MS = 60 * 60 * 1000 const TAG_MS = 24 * STUNDE_MS /** G08 (Eingangsbestaetigung des Widerrufs): je Empfaengeradresse und Tag, je Funktion und Stunde. */ const G08_JE_ADRESSE = 3 const G08_JE_FUNKTION = 20 /** K21 "nicht zugeordnet": je Funktion und Stunde. */ const K21_NICHT_ZUGEORDNET_JE_FUNKTION = 30 const MINUTE_MS = 60 * 1000 /** Lesende Abrufe und Aufrufe mit Token: je IP und Minute, je siteKey und Minute. */ const ANFRAGEN_JE_IP = 120 const ANFRAGEN_JE_SITE = 600 /** Buchungsversuche (jedes POST auf /buchungen, auch Honeypot): je IP und 10 Minuten; gehaltene Termine: je siteKey und Stunde. */ const BUCHUNGEN_JE_IP = 10 const BUCHUNGEN_FENSTER_MS = 10 * MINUTE_MS const HALTUNGEN_JE_SITE = 25 /** Widerrufserklaerungen: je IP und Stunde. */ const WIDERRUFE_JE_IP = 10 /** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */ const G01_JE_ADRESSE = 5 /** * Eine Map schluessel -> { start, ende (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne. * fruehesterAblauf ist eine untere Schranke fuer das kleinste ende: solange nowMs darunter liegt, kann es nichts * Abgelaufenes geben und das Aufraeumen einer vollen Map entfaellt (kein Durchlauf je neuem Schluessel). */ const neuerSpeicher = () => ({ map: new Map(), fruehesterAblauf: Infinity }) const ipZaehler = neuerSpeicher() const sonstigeZaehler = neuerSpeicher() /** Zaehler je IP ('ip:...') und alle anderen liegen in getrennten Maps. */ const speicherFuer = (schluessel) => (schluessel.startsWith('ip:') ? ipZaehler : sonstigeZaehler) /** Der Zaehler des Schluessels, wenn sein Fenster zum Zeitpunkt nowMs noch laeuft; sonst undefined. */ function laufend(schluessel, fensterMs, nowMs) { const eintrag = speicherFuer(schluessel).map.get(schluessel) return eintrag && nowMs < eintrag.start + fensterMs ? eintrag : undefined } /** Entfernt alle abgelaufenen Zaehler und merkt sich das fruehste Ende der uebrigen. */ function raeumeAbgelaufene(speicher, nowMs) { let fruehester = Infinity for (const [schluessel, eintrag] of speicher.map) { if (eintrag.ende <= nowMs) speicher.map.delete(schluessel) else if (eintrag.ende < fruehester) fruehester = eintrag.ende } speicher.fruehesterAblauf = fruehester } /** Beginnt ein neues Fenster fuer den Schluessel; macht in einer vollen Map zuerst Platz (abgelaufene, dann der aelteste). */ function beginne(schluessel, fensterMs, nowMs) { const speicher = speicherFuer(schluessel) speicher.map.delete(schluessel) // neu einfuegen = ans Ende der Reihenfolge if (speicher.map.size >= MAX_SCHLUESSEL && nowMs >= speicher.fruehesterAblauf) raeumeAbgelaufene(speicher, nowMs) if (speicher.map.size >= MAX_SCHLUESSEL) speicher.map.delete(speicher.map.keys().next().value) const ende = nowMs + fensterMs speicher.map.set(schluessel, { start: nowMs, ende, n: 1 }) if (ende < speicher.fruehesterAblauf) speicher.fruehesterAblauf = ende } /** * Zaehlt ein Ereignis je Regel, aber nur, wenn keine Regel ihre Grenze erreicht hat (alle oder keine). * @param {{ schluessel: string, max: number, fensterMs: number }[]} regeln * @param {Date} now * @returns {boolean} true = erlaubt (gezaehlt), false = mindestens eine Grenze erreicht */ export function zaehleAlle(regeln, now) { const nowMs = pruefeNow(now, 'zaehleAlle') if (!Array.isArray(regeln) || regeln.length === 0) throw new TypeError('zaehleAlle: regeln fehlen') for (const { schluessel, max, fensterMs } of regeln) { pruefeText(schluessel, 'schluessel') if (!Number.isInteger(max) || max < 0 || !(fensterMs > 0)) throw new TypeError('zaehleAlle: max oder fensterMs ungueltig') if ((laufend(schluessel, fensterMs, nowMs)?.n ?? 0) >= max) return false } for (const { schluessel, fensterMs } of regeln) { const eintrag = laufend(schluessel, fensterMs, nowMs) if (eintrag) eintrag.n += 1 else beginne(schluessel, fensterMs, nowMs) } return true } /** Ein Ereignis zaehlen: hoechstens max je Fenster. => true = erlaubt (gezaehlt), false = Grenze erreicht. */ export function zaehle(schluessel, max, fensterMs, now) { return zaehleAlle([{ schluessel, max, fensterMs }], now) } /** Darf eine G08 an diese (normalisierte) Adresse gehen? Hoechstens 3 je Adresse und Tag, 20 je Funktion und Stunde. */ export function darfG08(featureId, adresse, now) { return zaehleAlle( [ { schluessel: `g08-adresse:${adresse}`, max: G08_JE_ADRESSE, fensterMs: TAG_MS }, { schluessel: `g08-funktion:${featureId}`, max: G08_JE_FUNKTION, fensterMs: STUNDE_MS }, ], now, ) } /** Darf eine K21 "nicht zugeordnet" an den Betrieb gehen? Hoechstens 30 je Funktion und Stunde. */ export function darfK21NichtZugeordnet(featureId, now) { return zaehle(`k21-nicht-zugeordnet:${featureId}`, K21_NICHT_ZUGEORDNET_JE_FUNKTION, STUNDE_MS, now) } // ---------------------------------------------------------------- IP, siteKey, G01 const acht = (gruppen) => gruppen.length === 8 && gruppen.every((g) => /^[0-9a-f]{1,4}$/.test(g)) /** Die acht Gruppen einer IPv6-Adresse (ohne Zonenkennung, Kurzform "::" und eingebettete IPv4 aufgeloest) oder null. */ function ipv6Gruppen(adresse) { let s = adresse.split('%')[0] const v4 = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(s) if (v4) { const [a, b, c, d] = v4.slice(1).map(Number) s = `${s.slice(0, v4.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}` } const teile = s.split('::') if (teile.length > 2) return null const links = teile[0] === '' ? [] : teile[0].split(':') const rechts = teile.length === 2 && teile[1] !== '' ? teile[1].split(':') : [] const gruppen = teile.length === 2 ? [...links, ...Array(Math.max(0, 8 - links.length - rechts.length)).fill('0'), ...rechts] : links return acht(gruppen) ? gruppen : null } /** * Zaehlschluessel einer Client-Adresse (req.ip): IPv4 unveraendert, IPv4 in IPv6 (::ffff:a.b.c.d) als IPv4, IPv6 je /64 * (die ersten vier Gruppen, vierstellig); fehlt die Adresse oder ist sie unlesbar: 'unbekannt' (alle teilen sich einen Zaehler). */ export function ipSchluessel(ip) { const s = typeof ip === 'string' ? ip.trim().toLowerCase() : '' if (isIPv4(s)) return s if (!isIPv6(s)) return 'unbekannt' const gruppen = ipv6Gruppen(s) if (!gruppen) return 'unbekannt' const werte = gruppen.map((g) => parseInt(g, 16)) // ::ffff:a.b.c.d (Gruppen 0-4 null, Gruppe 5 = ffff) ist ein IPv4-Client hinter einem Dual-Stack-Socket if (werte.slice(0, 5).every((w) => w === 0) && werte[5] === 0xffff) { return `${werte[6] >> 8}.${werte[6] & 255}.${werte[7] >> 8}.${werte[7] & 255}` } return `v6:${werte.slice(0, 4).map((w) => w.toString(16).padStart(4, '0')).join(':')}` } /** Darf diese IP (IPv6 je /64) jetzt einen lesenden Abruf oder Token-Aufruf machen? Hoechstens 120 je Minute. */ export function darfAnfrage(ip, now) { return zaehle(`ip:anfrage:${ipSchluessel(ip)}`, ANFRAGEN_JE_IP, MINUTE_MS, now) } /** Darf diese Funktion (siteKey) jetzt einen lesenden Abruf beantworten? Hoechstens 600 je Minute fuer alle zusammen. */ export function darfAnfrageSite(siteKey, now) { return zaehle(`anfrage-site:${siteKey}`, ANFRAGEN_JE_SITE, MINUTE_MS, now) } /** Darf diese IP (IPv6 je /64) jetzt ein Buchungsformular abschicken? Hoechstens 10 je 10 Minuten (jeder Versuch zaehlt). */ export function darfBuchungsversuch(ip, now) { return zaehle(`ip:buchung:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now) } /** * Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 25 je Stunde: so loest eine Funktion hoechstens 25 G01 * je Stunde aus, unter dem Mailkontingent je Funktion (config.termine.mailsProFunktion, outbox.js). */ export function darfHaltung(siteKey, now) { return zaehle(`haltung-site:${siteKey}`, HALTUNGEN_JE_SITE, STUNDE_MS, now) } /** Darf diese IP (IPv6 je /64) jetzt eine Widerrufserklaerung abschicken? Hoechstens 10 je Stunde. */ export function darfWiderrufsversuch(ip, now) { return zaehle(`ip:widerruf:${ipSchluessel(ip)}`, WIDERRUFE_JE_IP, STUNDE_MS, now) } /** Darf an diese (normalisierte) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag (gehalten wie "zu viele"). */ export function darfG01(featureId, adresse, now) { return zaehle(`g01:${featureId}:${adresse}`, G01_JE_ADRESSE, TAG_MS, now) } /** Leert alle Zaehler (Tests). */ export function resetTermineLimitsForTests() { for (const speicher of [ipZaehler, sonstigeZaehler]) { speicher.map.clear() speicher.fruehesterAblauf = Infinity } }