4 Commits
Author SHA1 Message Date
sir_melonchenandClaude Opus 5.5 cb7f4c9f3d feat(license): gate features by entitlement and enforce maxAgents
ci / Go (lint, test, vuln) (push) Successful in 6m43s
ci / Protobuf (push) Successful in 7s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m35s
install / Installation (debian:12) (push) Successful in 3m20s
ci / Web (lint, test, audit, build) (push) Successful in 12m41s
ci / Shell and licence headers (push) Successful in 14s
ci / Integration (real BIND and Kea) (push) Successful in 2m50s
ci / Development environment (compose) (push) Successful in 15s
install / Installation (rocky:9) (push) Successful in 4m1s
install / Installation (ubuntu:24.04) (push) Successful in 3m23s
ci / Release build (dry run) (push) Failing after 10m38s
install / Installation (debian:13) (push) Successful in 3m22s
install / Installation (ubuntu:22.04) (push) Successful in 3m33s
Clusters (DDI_CLUSTER), Kea HA pairs (DDI_KEA_HA), dynamic zones
(DDI_DYNAMIC_ZONES) and SSO (DDI_SSO) now need the matching Keygen
entitlement; the trial grants all of them. Only taking a feature into
use is blocked (403 license_feature): existing clusters, HA pairs,
dynamic zones and enabled SSO keep working and stay editable.

maxAgents is checked when creating an enrollment token and, under an
advisory lock, at enrollment; revoked agents do not count. GET /license
reports available_features and the agent usage. The UI shows the
features on the license page and disables locked controls, and
keygen-setup.sh creates the entitlements and assigns them to licenses
(ADR 0027).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:51:55 +02:00
sir_melonchenandClaude Opus 5.5 91b243f05f docs(ad): checks after switching DCs to BIND and SYSVOL hints
ci / Go (lint, test, vuln) (push) Successful in 6m39s
ci / Protobuf (push) Successful in 7s
ci / Shell and licence headers (push) Successful in 14s
ci / Release build (dry run) (push) Successful in 3m14s
ci / Integration (real BIND and Kea) (push) Successful in 2m54s
ci / Development environment (compose) (push) Successful in 14s
install / Installation (debian:12) (push) Successful in 3m23s
ci / Web (lint, test, audit, build) (push) Successful in 1m49s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m30s
install / Installation (debian:13) (push) Successful in 3m26s
install / Installation (rocky:9) (push) Successful in 6m39s
install / Installation (ubuntu:22.04) (push) Successful in 3m35s
install / Installation (ubuntu:24.04) (push) Successful in 3m35s
dcdiag /test:dns needs the Windows DNS service and stays red with BIND;
list the checks that work instead, explain the _msdcs delegation message
and the DFSR state that blocks a second DC. Fix the script examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:54:03 +02:00
sir_melonchenandClaude Opus 5.5 617d6d9d72 feat(tools): PowerShell script that switches a domain controller to DDI
ci / Protobuf (push) Successful in 7s
ci / Release build (dry run) (push) Successful in 3m10s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m31s
ci / Web (lint, test, audit, build) (push) Successful in 1m49s
ci / Shell and licence headers (push) Successful in 15s
ci / Integration (real BIND and Kea) (push) Successful in 2m49s
ci / Development environment (compose) (push) Successful in 14s
install / Installation (debian:12) (push) Successful in 3m24s
install / Installation (debian:13) (push) Successful in 3m32s
install / Installation (rocky:9) (push) Successful in 4m9s
install / Installation (ubuntu:22.04) (push) Failing after 5m2s
install / Installation (ubuntu:24.04) (push) Successful in 3m34s
ci / Go (lint, test, vuln) (push) Successful in 6m22s
Sets the DNS servers, optionally makes a DHCP address static, disables
the Windows DNS server, re-registers the locator records and waits until
they answer at the DDI DNS server. Works on Server Core (PowerShell 5.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:31:59 +02:00
sir_melonchenandClaude Opus 5.5 6329ce2427 fix(bindgen): accept Active Directory names in dynamic zones
ci / Go (lint, test, vuln) (push) Successful in 6m42s
ci / Shell and licence headers (push) Successful in 13s
ci / Release build (dry run) (push) Successful in 3m9s
install / Installation (debian:13) (push) Successful in 3m29s
install / Installation (rocky:9) (push) Successful in 4m6s
ci / Protobuf (push) Successful in 7s
ci / Web (lint, test, audit, build) (push) Successful in 1m43s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m27s
ci / Integration (real BIND and Kea) (push) Successful in 2m50s
ci / Development environment (compose) (push) Successful in 14s
install / Installation (debian:12) (push) Successful in 3m25s
install / Installation (ubuntu:22.04) (push) Successful in 3m36s
install / Installation (ubuntu:24.04) (push) Successful in 3m26s
release / release (push) Successful in 3m18s
Netlogon registers A records below _msdcs (gc._msdcs.<domain>), which the
default check-names of primary zones refuses; dynamic zones now get
check-names ignore. Troubleshooting for dcdiag in ACTIVE-DIRECTORY.md, an
end-to-end check of such an update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:20:20 +02:00
38 changed files with 1089 additions and 61 deletions
+38 -1
View File
@@ -53,7 +53,7 @@ So zieht eine AD-integrierte Zone eines Windows-DNS-Servers auf die BIND-Server
dig @192.0.2.53 A dc1.ad.example.com
```
Auf einem DC zusätzlich `dcdiag /test:dns`. Fehlen Records, im Log des Primary (`named`) nach `update … denied` suchen: Meist passt die Quelladresse des DCs nicht zu `allow_update`.
Auf einem DC zusätzlich `dcdiag /test:RegisterInDNS /DnsDomain:<domain>` (`dcdiag /test:dns` taugt nur, solange der Windows-DNS-Server noch läuft, siehe [Prüfen nach der Umstellung](#prüfen-nach-der-umstellung)). Fehlen Records, im Log des Primary (`named`) nach `update … denied` suchen: Meist passt die Quelladresse des DCs nicht zu `allow_update`.
8. **Clients umstellen:** Die DNS-Server der Clients auf die BIND-Server setzen – in DDI über die DNS-Server der Netze (DHCP-Option 6), bei festen Adressen per Hand oder Gruppenrichtlinie. Den Windows-DNS-Server mit der alten Zone erst abschalten bzw. die Zone dort löschen, wenn alle Leases erneuert sind; bis dahin beide Seiten vergleichen.
**Folgen ohne GSS-TSIG:** „Sichere“ Updates (GSS-TSIG/Kerberos) beherrscht BIND hier nicht. Updates sind nur über die Quelladresse (und TSIG, das Windows nicht kann) abgesichert; wer im Netz eine DC-Adresse fälschen kann, kann Einträge der Domäne ändern – die DCs deshalb in ein eigenes, gefiltertes Netz. Windows-Clients können sich nicht selbst registrieren (ihre Updates werden abgelehnt, solange ihre Netze nicht in `allow_update` stehen, und das sollen sie nicht); ihre Namen kommen aus der Adresstabelle. Namen dynamischer DHCP-Clients wären Aufgabe von Kea-DDNS (noch nicht umgesetzt). Ein Aufräumen veralteter Einträge (Scavenging) gibt es nicht.
@@ -90,3 +90,40 @@ Microsoft-Clients und -DCs beherrschen TSIG nicht; für sie bleibt nur die Freig
- Eine Zone von `managed` auf `dynamic` umzustellen ist möglich: Die vorhandene Zonendatei bleibt bestehen und wird ab dann von BIND gepflegt. Der umgekehrte Weg verwirft alle per Update angelegten Records.
- Scavenging (Aufräumen veralteter dynamischer Einträge) übernimmt BIND nicht; veraltete Records müssen per `nsupdate` entfernt werden.
- MX-Records einer dynamischen Zone, deren Ziel in der Zone liegt, brauchen dort einen A- oder AAAA-Record: Sonst lehnt BIND das Update des Agents ab (Prüfung `check-integrity`), und das Ausrollen der Revision scheitert.
## Skript für die Domain Controller
[`tools/windows/Set-DcDnsToDdi.ps1`](../tools/windows/Set-DcDnsToDdi.ps1) stellt einen DC (auch Server Core, Windows PowerShell 5.1) auf die BIND-Server um: prüft die Zone auf dem DDI-Server, setzt die DNS-Server der Netzwerkkarte, macht auf Wunsch eine DHCP-Adresse statisch (`-MakeStatic`), stoppt und deaktiviert den Windows-DNS-Server, registriert A-Record und Locator-Records neu (`ipconfig /registerdns`, `nltest /dsregdns`) und wartet, bis A-, SRV- und `_msdcs`-Einträge am DDI-Server antworten. Vorher die Adresse des DCs als Update-Quelle der Zone eintragen und ausrollen.
```powershell
.\Set-DcDnsToDdi.ps1 -DnsServer 10.10.10.151 -WhatIf # nur anzeigen
.\Set-DcDnsToDdi.ps1 -DnsServer 10.10.10.151 -MakeStatic -StartIsmServ -Sync
```
## Fehlersuche: `dcdiag /test:dns` meldet fehlende Einträge
- **A-Record des DCs da, SRV-Records fehlen:** Den A-Record trägt der DNS-Client ein (`ipconfig /registerdns`), die SRV-Records, den `<GUID>._msdcs`-CNAME, `gc._msdcs` und `DomainDnsZones` trägt **Netlogon** ein. Lief `nltest /dsregdns` (oder der Start von Netlogon), bevor die Update-Quellen stimmten, versucht Netlogon es erst Stunden später erneut. Abhilfe auf jedem DC: `nltest /dsregdns` oder `net stop netlogon && net start netlogon`, dann `dcdiag /test:dns` wiederholen.
- **`gc._msdcs` oder andere A-Records unter `_msdcs` fehlen:** BIND prüft in Primary-Zonen Hostnamen (`check-names`) und lehnt Unterstriche ab. Seit v0.9.1 erzeugt DDI für dynamische Zonen `check-names ignore;`; ältere Versionen nach dem Update neu ausrollen.
- **Updates werden abgelehnt:** Windows schickt Updates an den Server aus dem SOA der Zone (Primary). `nslookup -type=SOA <domain> <dns-server>` muss den BIND-Primary nennen, und dessen Name muss auflösen (z. B. als Eintrag in der Adresstabelle). Die Adressen der DCs müssen unter *Update-Quellen* der Zone stehen. Im Log von BIND (`journalctl -u named` bzw. `-u bind9`) steht bei jeder Ablehnung die Quell-IP (`update … denied`).
- **„invalid DNS server“ / keine Internet-Namen:** DDI betreibt BIND standardmäßig rein autoritativ. Zeigen DCs oder Clients ausschließlich auf BIND, unter *DNS/DHCP-Einstellungen* Rekursion einschalten, die eigenen Netze unter „Rekursion erlaubt für“ eintragen und Forwarder angeben (z. B. Router oder Resolver des Providers).
- **Domain Controller mit DHCP-Adresse:** dcdiag warnt zu Recht; DCs sollten ihre IP fest im Betriebssystem eingetragen haben.
- **„Broken delegated domain _msdcs“:** BIND führt `_msdcs` in der Zone selbst, ohne Delegation. Die Meldung stammt aus den Daten eines noch laufenden Windows-DNS-Servers (auf dem DC, auf dem dcdiag läuft); sie verschwindet, sobald dort der Dienst gestoppt ist. Eine eigene Zone `_msdcs.<domain>` in DDI ist nicht nötig – sie würde die vorhandenen `_msdcs`-Einträge der Hauptzone verdecken.
## Prüfen nach der Umstellung
`dcdiag /test:dns` setzt einen Windows-DNS-Server auf dem DC voraus. Ist dieser gestoppt, meldet der Test „DNS service is stopped“ bzw. „no DNS RPC connectivity“ und überspringt den Rest; mit BIND bleibt er daher rot. Ebenso erzeugt dcdiag selbst DCOM-Fehler (Ereignis 10028) im Systemprotokoll, weil es die DNS-Server per Microsoft-RPC verwalten will. Aussagekräftig sind stattdessen:
```powershell
dcdiag /test:RegisterInDNS /DnsDomain:ad.example.com # darf der DC seine Einträge registrieren?
nltest /dsgetdc:ad.example.com /force # DC-Locator
dcdiag # Standardtests ohne DNS-Test
repadmin /replsummary
```
Scheitern dabei *Advertising* und *NetLogons* (Freigabe `NETLOGON` fehlt, `SysvolReady` = 0), liegt das nicht an DNS, sondern an der SYSVOL-Replikation (DFSR). Konnten die DCs sich vor der Umstellung lange nicht erreichen, steht ein DC oft im Zustand 5 mit Ereignis 4012 (`MaxOfflineTimeInDays` überschritten) und der andere wartet mit 4612/4614 auf die Erstsynchronisation. Abhilfe ist eine autoritative SYSVOL-Synchronisation vom DC mit der gültigen Kopie (Microsoft KB 2218556). Zustand abfragen:
```powershell
Get-CimInstance -Namespace root\microsoftdfs -ClassName DfsrReplicatedFolderInfo | Select-Object ReplicatedFolderName,State # 4 = normal
```
Auf Server Core fehlt `dfsrdiag`; statt `dfsrdiag pollad` genügt `Restart-Service DFSR`.
+2 -2
View File
@@ -51,11 +51,11 @@ Fehler sind Problem-Dokumente nach RFC 9457 (`application/problem+json`):
- `problems` listet Feldfehler mit eigenem `code`, optional `field` und `params`.
- `request_id` entspricht dem Header `X-Request-ID` und findet sich in den Server-Logs wieder.
Wichtige Statuscodes: `401` (nicht angemeldet, `mfa_required`), `403` (`forbidden`, `csrf`, `license_read_only`, `password_change_required`), `404`, `409` (Konflikt, z. B. `network_overlap`, `version_conflict`), `422` (Validierung), `429` (zu viele Anmeldeversuche, mit `Retry-After`).
Wichtige Statuscodes: `401` (nicht angemeldet, `mfa_required`), `403` (`forbidden`, `csrf`, `license_read_only`, `license_limit`, `license_feature`, `password_change_required`), `404`, `409` (Konflikt, z. B. `network_overlap`, `version_conflict`), `422` (Validierung), `429` (zu viele Anmeldeversuche, mit `Retry-After`).
## Schreibschutz durch die Lizenz
Ist der Server schreibgeschützt (siehe [LICENSING.md](LICENSING.md)), antworten alle ändernden Endpunkte mit `403 license_read_only`. Lesen, Export, Anmeldung, eigene Filter und die Lizenzverwaltung bleiben erlaubt.
Ist der Server schreibgeschützt (siehe [LICENSING.md](LICENSING.md)), antworten alle ändernden Endpunkte mit `403 license_read_only`. Lesen, Export, Anmeldung, eigene Filter und die Lizenzverwaltung bleiben erlaubt. Überschreitet eine Änderung ein Limit der Lizenz (Adressen, Agents), antwortet der Server mit `403 license_limit` (Parameter `kind`, `limit`); braucht sie eine nicht freigeschaltete Funktion, mit `403 license_feature` (Parameter `feature`, ADR 0027).
## Gleichzeitige Änderungen
+39 -2
View File
@@ -43,16 +43,53 @@ Das Skript legt an (und verwendet beim erneuten Aufruf wieder):
- täglicher Heartbeat; ein Server, der sich einen Tag nicht meldet, wird freigegeben und beim nächsten Kontakt automatisch neu aktiviert
- Laufzeit ein Jahr
- Standardlimits in den Metadaten: `maxAgents: 10`, `maxAddresses: 5000`
- **Entitlements** für die Funktionen (siehe unten): `DDI_CLUSTER`, `DDI_KEA_HA`, `DDI_DYNAMIC_ZONES`, `DDI_SSO`
Die ausgegebene Produkt-ID wird beim Server als `KEYGEN_PRODUCT_ID` eingetragen.
## Limits und Funktionen
| In Keygen | Name/Code | Wirkung |
| --- | --- | --- |
| Lizenz-Metadaten | `maxAddresses` | Höchstzahl der Einträge der Adresstabelle (alle Netze zusammen) |
| Lizenz-Metadaten | `maxAgents` | Höchstzahl registrierter Agents (DNS und DHCP zusammen, gesperrte zählen nicht) |
| Entitlement | `DDI_CLUSTER` | DNS- und DHCP-Cluster |
| Entitlement | `DDI_KEA_HA` | Kea-HA: DHCP-Cluster mit Primary und Secondary (braucht zusätzlich `DDI_CLUSTER`) |
| Entitlement | `DDI_DYNAMIC_ZONES` | dynamische Zonen (Updates per RFC 2136, z. B. Active Directory) |
| Entitlement | `DDI_SSO` | Anmeldung per Single Sign-On (OIDC) |
- Fehlt ein Metadatum, gilt dafür keine Grenze. Die Schreibweise muss genau stimmen.
- In der **Testphase** ist alles freigeschaltet und unbegrenzt.
- Ohne eine Funktion lässt sie sich nicht **neu** in Gebrauch nehmen (kein neuer Cluster, kein neues Mitglied, kein neues HA-Paar, keine neue dynamische Zone, SSO nicht einschaltbar). **Was schon besteht, läuft weiter** und bleibt bearbeitbar – eine Lizenzänderung schaltet weder DNS/DHCP noch den Zugang ab ([ADR 0027](adr/0027-funktionen-per-entitlement-agent-limit.md)).
- Am Agent-Limit lassen sich keine Registrierungs-Tokens mehr erzeugen, und Registrierungen werden abgelehnt. Bestehende Agents laufen weiter. Für eine Neuregistrierung (`enroll --force`) den alten Agent vorher sperren oder löschen.
- Die Seite **Lizenz** zeigt Nutzung, Limits und welche Funktionen freigeschaltet sind; gesperrte Bedienelemente sind in der Oberfläche deaktiviert.
## Lizenz ausstellen
```sh
tools/keygen-setup/keygen-setup.sh create-license --name "Kunde GmbH" --max-agents 20 --max-addresses 20000
tools/keygen-setup/keygen-setup.sh create-license --name "Kunde GmbH" --max-agents 20 --max-addresses 20000 \
--features DDI_CLUSTER,DDI_KEA_HA,DDI_DYNAMIC_ZONES,DDI_SSO # oder --features all
```
Ausgegeben werden Lizenz-ID und **Lizenzschlüssel**. Limits pro Lizenz stehen in deren Metadaten (`maxAgents`, `maxAddresses`); Feature-Freischaltungen erfolgen über Keygen-Entitlements.
Ausgegeben werden Lizenz-ID, **Lizenzschlüssel** und die zugeordneten Funktionen. Limits pro Lizenz stehen in deren Metadaten (`maxAgents`, `maxAddresses`), Funktionen als Entitlements der Lizenz.
Funktionen einer bestehenden Lizenz ändern:
```sh
tools/keygen-setup/keygen-setup.sh features --license <LIZENZ-ID> # anzeigen
tools/keygen-setup/keygen-setup.sh features --license <LIZENZ-ID> --add DDI_SSO --remove DDI_KEA_HA
```
Limits einer bestehenden Lizenz ändern (Keygen ersetzt `metadata` vollständig, also beide Werte angeben):
```sh
curl -sS -X PATCH "https://license.beugsiud.de/v1/accounts/<ACCOUNT-ID>/licenses/<LIZENZ-ID>" \
-H "Authorization: Bearer $KEYGEN_ADMIN_TOKEN" \
-H "Content-Type: application/vnd.api+json" -H "Accept: application/vnd.api+json" \
-d '{"data":{"type":"licenses","attributes":{"metadata":{"maxAgents":20,"maxAddresses":20000}}}}'
```
Der Server übernimmt Änderungen bei der nächsten Prüfung (stündlich) oder sofort mit **Lizenz → Jetzt prüfen**. Offline-Lizenzdateien enthalten die Entitlements zum Zeitpunkt des Exports; nach einer Änderung neu exportieren.
Der Administrator trägt den Schlüssel in der Oberfläche unter **Lizenz → Lizenzschlüssel eintragen** ein. Der Server prüft ihn, aktiviert sich selbst als Maschine (Fingerprint aus der Machine-ID, nicht die Machine-ID selbst) und meldet sich danach regelmäßig.
+17 -1
View File
@@ -6,7 +6,7 @@ Neue Einträge oben im Abschnitt „Verlauf“ ergänzen und den Abschnitt „St
---
## Stand (2026-10-17, nach ADR 0026)
## Stand (2026-10-18, nach ADR 0027)
| Meilenstein | Status | Tag |
| --- | --- | --- |
@@ -20,6 +20,8 @@ Neue Einträge oben im Abschnitt „Verlauf“ ergänzen und den Abschnitt „St
| Anpassungen nach Rückmeldung (ADR 0025: DHCP pro Adresse, DNS für dynamische Einträge, Bereichs-/Massenänderungen, Prüfung, neue Oberfläche) | fertig | `v0.8.0` |
| Korrekturen aus dem Betrieb: Prüfung mit Raw-ICMP und DNS-Agents (`v0.8.1`), von Hand registrierte Agents als Dienst (`v0.8.2`) | fertig | `v0.8.1`, `v0.8.2` |
| Einträge in dynamischen Zonen per `nsupdate` (ADR 0026), AD-Zone in DDI | fertig | `v0.9.0` |
| `check-names ignore` für dynamische Zonen (AD-Records unter `_msdcs`), DC-Umstellungsskript | fertig | `v0.9.1` |
| Funktionen per Entitlement (Cluster, Kea-HA, dynamische Zonen, SSO), Agent-Limit (ADR 0027) | fertig | `v0.10.0` |
| M7 (Standalone, DHCPv6, DNSSEC, Discovery) | nur Schnittstellen vorbereitet (Brief §12) | – |
Branch `main`, alles gepusht nach `https://git.beugsiud.de/beug.k/DDI`. Releases mit Artefakten über den Release-Workflow.
@@ -134,6 +136,20 @@ Datenbanktests brauchen `DDI_TEST_DATABASE_URL` (z. B. `postgres://ddi:ddi-dev-o
## Verlauf
### 2026-10-18 – Funktionen per Entitlement, Agent-Limit (ADR 0027, `v0.10.0`)
- **Anlass:** Der Nutzer will Cluster, Kea-HA, SSO und dynamische Zonen getrennt lizenzieren und Agents zählen. Bis dahin wurden Entitlements nur angezeigt und `maxAgents` nie geprüft (nur `maxAddresses`); `usage.agents` war immer 0.
- **Umsetzung:** `license.Manager.HasFeature` (Testphase: alles), Prüfungen im Service nur dort, wo eine Funktion neu in Gebrauch kommt (Cluster anlegen/Mitglied hinzufügen, DHCP-Cluster wird HA-Paar, Zone wird dynamisch, SSO wird eingeschaltet); Bestand bleibt bearbeitbar. `maxAgents` bei Token-Erzeugung und Registrierung (Advisory-Lock, gesperrte Agents zählen nicht). API: `403 license_feature`, `available_features`. Oberfläche: Funktionsübersicht auf der Lizenzseite, deaktivierte Bedienelemente mit Hinweis. `keygen-setup.sh`: Entitlements anlegen, `--features`, Befehl `features`.
- **Werkzeug:** Auf BRLN-WKS1 fehlte `link.exe` in der Go-Installation (`go test` scheiterte mit „no such tool link“); aus dem offiziellen Archiv `go1.27.2.windows-amd64.zip` (SHA-256 gegen go.dev geprüft) wiederhergestellt.
- **Für den Nutzer:** Seine Installation läuft in der Testphase (alles frei). Seine Lizenz in Keygen braucht vor dem Eintragen die Entitlements (`keygen-setup.sh setup`, dann `features --license … --add all`), sonst kann er z. B. keine weitere dynamische Zone anlegen (die vorhandene AD-Zone bliebe aber nutzbar).
### 2026-10-18 – AD-Umstellung beim Nutzer (`v0.9.1`, DC-Skript)
- **v0.9.1:** BIND lehnte die Registrierung von `gc._msdcs.<domain>` (A-Record mit Unterstrich im Namen) ab, weil Primary-Zonen standardmäßig Hostnamen prüfen (`check-names`). Dynamische Zonen erhalten jetzt `check-names ignore;`; der End-to-End-Test trägt `gc._msdcs` per `nsupdate` ein.
- **`tools/windows/Set-DcDnsToDdi.ps1`:** stellt einen DC (auch Server Core) auf die BIND-Server um und prüft danach A-, SRV- und `_msdcs`-Einträge am DDI-Server. Beim Nutzer für DC2 benutzt.
- **Lehren aus der Umstellung (in `ACTIVE-DIRECTORY.md` übernommen):** `dcdiag /test:dns` ist mit BIND immer rot (setzt den Windows-DNS-Dienst voraus) und erzeugt selbst DCOM-Fehler; stattdessen `dcdiag /test:RegisterInDNS`, `nltest /dsgetdc`, `dcdiag`, `repadmin`. „Broken delegated domain _msdcs“ kam vom noch laufenden Windows-DNS-Dienst auf DC2, nicht aus BIND. Eine eigene Zone `_msdcs.<domain>` ist unnötig (DDI lehnt Unterstriche in Zonennamen ab; das zu ändern wäre nur für Fälle wie `_domainkey` interessant). Der Rest (Advertising/NetLogons) war ein SYSVOL-Problem: DC1 im DFSR-Zustand 5 (Ereignis 4012, `MaxOfflineTimeInDays`), DC2 wartend (4612) – behoben per autoritativer SYSVOL-Synchronisation von DC1. Auf Server Core fehlt `dfsrdiag`; `Restart-Service DFSR` ersetzt `pollad`.
- **Beim Nutzer noch offen:** feste IPs der DCs (`-MakeStatic`), Rekursion/Forwarder auf BIND für Internet-Namen, RODC `dbrlnvl-rodc1t` nicht erreichbar (Fehler 58), `dcdiag-test-record` in der Zone.
### 2026-10-17 – Rückmeldungen aus dem Betrieb (`v0.8.1`, `v0.8.2`) und AD-Zone in DDI (`v0.9.0`)
- **v0.8.1, Adressprüfung auf dem echten Server:** Ping meldete „socket: permission denied“ (unprivilegierte ICMP-Sockets nicht erlaubt, `ping_group_range`), DNS fragte nur den System-Resolver. Jetzt Rückfall auf einen Raw-Socket mit `CAP_NET_RAW` (einzige Capability der Server-Unit), DNS-Agents ohne Cluster als Server der Standardgruppe, eigene Frist je Server, Hinweise im Ergebnis (`system_resolver`, `not_deployed` – per Audit-IDs, nicht per Zeitstempel, weil Server- und Datenbankuhr abweichen können). End-to-End- und Installationstest prüfen die Prüfung ausdrücklich.
@@ -0,0 +1,33 @@
# 0027 – Funktionen per Entitlement, Agent-Limit
- **Status:** Akzeptiert
- **Datum:** 2026-10-18
## Kontext
ADR 0016 sieht Feature-Flags aus Keygen-Entitlements und die Limits `maxAgents`/`maxAddresses` aus den Lizenz-Metadaten vor. Umgesetzt war nur `maxAddresses`; die Entitlements wurden geladen und angezeigt, schalteten aber nichts, und `maxAgents` wurde nicht geprüft. Der Nutzer will Cluster, Kea-HA, SSO und dynamische Zonen getrennt lizenzieren und Agents zählen.
## Entscheidung
- **Entitlement-Codes** (Keygen, Groß-/Kleinschreibung wie angegeben):
| Code | Schaltet frei | Ohne den Code gesperrt |
| --- | --- | --- |
| `DDI_CLUSTER` | DNS- und DHCP-Cluster | Cluster anlegen; Agents zu einem Cluster hinzufügen |
| `DDI_KEA_HA` | Kea-HA | einen DHCP-Cluster zum HA-Paar machen (Primary **und** Secondary) |
| `DDI_DYNAMIC_ZONES` | dynamische Zonen (RFC 2136, AD) | Zone dynamisch anlegen; Zone auf dynamisch umstellen |
| `DDI_SSO` | Anmeldung per OIDC | SSO einschalten |
Ein HA-Paar braucht `DDI_CLUSTER` und `DDI_KEA_HA`.
- **Bestand bleibt:** Gesperrt wird nur, was eine Funktion neu in Gebrauch nimmt. Was besteht (aus der Testphase oder einer früheren Lizenz), läuft weiter, bleibt bearbeitbar und löschbar: Cluster lassen sich umbenennen und verkleinern, ein HA-Paar bleibt eins (auch mit getauschtem Partner, wenn `DDI_CLUSTER` da ist), eine dynamische Zone bleibt dynamisch und editierbar, eingeschaltetes SSO bleibt eingeschaltet und änderbar. Der Generator berücksichtigt Lizenzen nicht. Grund: wie in ADR 0016 darf eine Lizenzänderung DNS, DHCP und den Zugang nicht abschalten.
- **Testphase:** schaltet alle Funktionen frei (wie bisher ohne Limits). **Karenzzeit:** es gelten die Entitlements der zuletzt gültigen Lizenz. Ohne Lizenz-Manager (Tests, Werkzeuge) ist alles erlaubt.
- **Agents:** `maxAgents` zählt alle nicht gesperrten Agents (`revoked_at IS NULL`). Geprüft beim Erzeugen eines Registrierungs-Tokens und – maßgeblich – bei der Registrierung selbst, dort unter einer Advisory-Sperre, damit parallele Registrierungen das Limit nicht gemeinsam überschreiten. Bestehende Agents über dem Limit (Lizenz verkleinert) laufen weiter. Eine Neuregistrierung (`enroll --force`) legt einen neuen Agent an; am Limit muss der alte vorher gesperrt oder gelöscht werden.
- **API:** Fehler `403 license_feature` mit Parameter `feature` (Code), `403 license_limit` mit `kind=agents`. `GET /license` liefert zusätzlich `available_features` (die freigeschalteten bekannten Codes, in der Testphase alle) und zählt `usage.agents`.
- **Oberfläche:** Die Lizenzseite zeigt die vier Funktionen mit Status. Gesperrte Bedienelemente sind deaktiviert und erklärt (Cluster anlegen und Mitglied hinzufügen, zweites DHCP-Mitglied, Modus „dynamisch“, SSO einschalten); der Server prüft unabhängig davon.
- **Keygen:** `tools/keygen-setup/keygen-setup.sh setup` legt die vier Entitlements an; `create-license --features …` und `features --license … --add/--remove …` ordnen sie Lizenzen zu.
## Konsequenzen
- Wer nach der Testphase eine Lizenz ohne eine Funktion einspielt, behält das in der Testphase Eingerichtete. Das ist bewusst großzügig (Soft-Control, siehe ADR 0016).
- Bestehende Lizenzen ohne Entitlements verlieren das Anlegen von Clustern, HA-Paaren, dynamischen Zonen und das Einschalten von SSO; die Entitlements müssen ihnen zugeordnet werden.
- Netze sind weiterhin nicht begrenzt; das Adresslimit bleibt `maxAddresses`.
+1
View File
@@ -30,3 +30,4 @@ Jede Grundsatzentscheidung wird hier als kurze ADR festgehalten. Format siehe [0
| [0024](0024-auslieferung-installer-m6.md) | Auslieferung: Installer, systemd, Pakete, Release-Signatur (M6) | Akzeptiert |
| [0025](0025-dhcp-pro-adresse-pruefungen-ui.md) | DHCP pro Adresse, DNS für dynamische Einträge, Bereichs- und Massenänderungen, Prüfungen, Oberfläche | Akzeptiert |
| [0026](0026-eintraege-in-dynamischen-zonen.md) | Einträge der Adresstabelle in dynamischen Zonen (RFC-2136-Updates durch den Agent) | Akzeptiert |
| [0027](0027-funktionen-per-entitlement-agent-limit.md) | Funktionen per Entitlement (Cluster, Kea-HA, dynamische Zonen, SSO), Agent-Limit | Akzeptiert |
+20 -3
View File
@@ -443,6 +443,7 @@ paths:
put:
tags: [settings]
operationId: setOIDCSettings
description: Enabling SSO needs the license feature DDI_SSO (403 license_feature, ADR 0027); settings that are already enabled stay editable.
requestBody:
required: true
content:
@@ -638,6 +639,7 @@ paths:
post:
tags: [agents]
operationId: createEnrollmentToken
description: Fails with 403 license_limit (kind agents) when the license allows no further agent (maxAgents, revoked agents do not count; ADR 0027). Enrollment checks the limit again.
requestBody:
required: true
content:
@@ -1319,6 +1321,8 @@ paths:
the other role), agent_in_other_cluster, cluster_primary_exists
(second primary), cluster_member_limit (DHCP: more than one primary
and one secondary), invalid_ip (address).
License (ADR 0027): 403 license_feature with feature DDI_CLUSTER, or
DDI_KEA_HA for a DHCP cluster with primary and secondary.
requestBody:
required: true
content:
@@ -1348,7 +1352,11 @@ paths:
put:
tags: [clusters]
operationId: updateCluster
description: Replaces all mutable fields including the member list. The role is immutable and ignored. `version` must match.
description: |
Replaces all mutable fields including the member list. The role is
immutable and ignored. `version` must match. License (ADR 0027): adding
an agent needs DDI_CLUSTER, turning a DHCP cluster into an HA pair
DDI_KEA_HA (403 license_feature); removing members always works.
requestBody:
required: true
content:
@@ -1486,6 +1494,8 @@ paths:
invalid_domain, zone_kind_mismatch (reverse zones must end in
in-addr.arpa or ip6.arpa, forward zones must not), cluster_role_mismatch
(cluster is not a DNS cluster), not_found (cluster or key).
A dynamic zone needs the license feature DDI_DYNAMIC_ZONES (403
license_feature, ADR 0027).
requestBody:
required: true
content:
@@ -1532,7 +1542,10 @@ paths:
put:
tags: [zones]
operationId: updateZone
description: Replaces all mutable fields. Name and kind are immutable and ignored. `version` must match.
description: |
Replaces all mutable fields. Name and kind are immutable and ignored.
`version` must match. Switching a zone to dynamic needs DDI_DYNAMIC_ZONES
(403 license_feature, ADR 0027); an existing dynamic zone stays editable.
requestBody:
required: true
content:
@@ -1799,7 +1812,7 @@ components:
LicenseStatus:
type: object
additionalProperties: false
required: [state, reason, limits, features, fingerprint, usage]
required: [state, reason, limits, features, available_features, fingerprint, usage]
properties:
state: { $ref: "#/components/schemas/LicenseState" }
reason: { type: string, description: "Translated by the UI (ok, trial, trial_expired, unreachable, expired, suspended, …)." }
@@ -1819,6 +1832,10 @@ components:
max_agents: { type: integer }
max_addresses: { type: integer }
features: { type: array, items: { type: string } }
available_features:
type: array
items: { type: string, enum: [DDI_CLUSTER, DDI_DYNAMIC_ZONES, DDI_KEA_HA, DDI_SSO] }
description: "Features the server grants now (ADR 0027): all during the trial, otherwise those among the license entitlements. Without one, creating clusters, Kea HA pairs, dynamic zones or enabling SSO returns 403 license_feature (param feature); what exists keeps working."
fingerprint: { type: string }
usage:
type: object
@@ -143,6 +143,12 @@ func renderConf(m *model) []byte {
w.stmt(1, fmt.Sprintf("allow-transfer %s;", braceList([]string{keyElem(z.transferKey)})))
}
w.stmt(1, fmt.Sprintf("allow-update %s;", braceList([]string{update})))
if z.dynamic {
// Active Directory registers A records below _msdcs (e.g.
// gc._msdcs.<domain>), which the default host name check of
// primary zones refuses.
w.stmt(1, "check-names ignore;")
}
w.stmt(0, "};")
}
return w.b.Bytes()
@@ -9,6 +9,7 @@ zone "dyn.test" {
file "/etc/bind/ddi-suite/zones/db.dyn.test";
notify no;
allow-update { "ddi-update-dyn.test"; };
check-names ignore;
};
zone "keys-only.test" {
@@ -16,6 +17,7 @@ zone "keys-only.test" {
file "/etc/bind/ddi-suite/zones/db.keys-only.test";
notify no;
allow-update { "ddi-update-keys-only.test"; };
check-names ignore;
};
zone "static.test" {
@@ -9,6 +9,7 @@ zone "2.0.192.in-addr.arpa" {
file "/etc/bind/ddi-suite/zones/db.2.0.192.in-addr.arpa";
notify no;
allow-update { "ddi-update-2.0.192.in-addr.arpa"; };
check-names ignore;
};
zone "office.test" {
@@ -16,6 +17,7 @@ zone "office.test" {
file "/etc/bind/ddi-suite/zones/db.office.test";
notify no;
allow-update { "ddi-update-office.test"; };
check-names ignore;
};
zone "remote.test" {
@@ -8,6 +8,7 @@ zone "dyn.test" {
file "/etc/bind/ddi-suite/zones/db.dyn.test";
notify no;
allow-update { "ddi-update-dyn.test"; };
check-names ignore;
};
zone "static.test" {
+29 -18
View File
@@ -156,28 +156,38 @@ func (s *streamChecker) Unwrap() http.ResponseWriter { return s.ResponseWriter }
// env is a running API backed by a fresh database.
type env struct {
t *testing.T
srv *httptest.Server
svc *service.Service
st *store.Store
lic *license.Manager
now *time.Time
pki *pki.Authority
hub *agenthub.Hub
bus *events.Bus
t *testing.T
srv *httptest.Server
svc *service.Service
st *store.Store
lic *license.Manager
prov *stubProvider
now *time.Time
pki *pki.Authority
hub *agenthub.Hub
bus *events.Bus
}
type stubProvider struct{}
// stubProvider rejects every key unless a test sets valid (then it grants
// features and limits).
type stubProvider struct {
valid bool
features []string
limits license.Limits
}
func (stubProvider) Validate(context.Context, string, string) (*license.Validation, error) {
func (p *stubProvider) Validate(context.Context, string, string) (*license.Validation, error) {
if p.valid {
return &license.Validation{Valid: true, Code: "VALID", LicenseID: "lic-1", Name: "Test", Limits: p.limits}, nil
}
return &license.Validation{Code: "NOT_FOUND"}, nil
}
func (stubProvider) Activate(context.Context, string, string, string, string) error { return nil }
func (stubProvider) Entitlements(context.Context, string, string) ([]string, error) {
return nil, nil
func (p *stubProvider) Activate(context.Context, string, string, string, string) error { return nil }
func (p *stubProvider) Entitlements(context.Context, string, string) ([]string, error) {
return p.features, nil
}
func (stubProvider) Heartbeat(context.Context, string, string) error { return nil }
func (stubProvider) Deactivate(context.Context, string, string) error { return nil }
func (p *stubProvider) Heartbeat(context.Context, string, string) error { return nil }
func (p *stubProvider) Deactivate(context.Context, string, string) error { return nil }
type storePersistence struct{ st *store.Store }
@@ -220,7 +230,8 @@ func newEnvFull(t *testing.T, adjust func(*Options), adjustServer func(*http.Ser
now := time.Now().UTC()
clock := func() time.Time { return now }
log := slog.New(slog.DiscardHandler)
lic := license.NewManager(license.ManagerOptions{Provider: stubProvider{}, Persistence: storePersistence{st},
prov := &stubProvider{}
lic := license.NewManager(license.ManagerOptions{Provider: prov, Persistence: storePersistence{st},
Keyring: kr, Fingerprint: "fp", Grace: 14 * 24 * time.Hour, Now: clock, Logger: log})
if err := lic.Load(context.Background()); err != nil {
t.Fatal(err)
@@ -253,7 +264,7 @@ func newEnvFull(t *testing.T, adjust func(*Options), adjustServer func(*http.Ser
t.Cleanup(srv.Close)
// Runs first: open event streams end, so that Close does not wait.
t.Cleanup(bus.Close)
return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, now: &now, pki: authority, hub: hub, bus: bus}
return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, prov: prov, now: &now, pki: authority, hub: hub, bus: bus}
}
// advance moves the shared clock (sessions, license).
@@ -0,0 +1,160 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package httpapi
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"errors"
"slices"
"testing"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
// setLicense activates a license with the given features and agent limit
// (nil: unlimited).
func (e *env) setLicense(maxAgents *int, features ...string) {
e.t.Helper()
e.prov.valid, e.prov.features, e.prov.limits = true, features, license.Limits{MaxAgents: maxAgents}
if err := e.lic.SetKey(context.Background(), "TEST-KEY-123456"); err != nil {
e.t.Fatal(err)
}
}
// wantFeature expects a 403 license_feature naming feature.
func wantFeature(t *testing.T, r *response, feature string) {
t.Helper()
if p := r.problem(t); p.Code != "license_feature" || p.Params["feature"] != feature {
t.Errorf("want license_feature %s, got %+v", feature, p)
}
}
func TestLicenseFeatures(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
member := func(id, role, addr string) map[string]any {
return map[string]any{"agent_id": id, "role": role, "address": addr}
}
dns1 := e.enroll(admin.createToken("dns").Secret, "dns")
dns2 := e.enroll(admin.createToken("dns").Secret, "dns")
dhcp1 := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
dhcp2 := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
// The trial grants every feature.
var st service.LicenseStatus
admin.mustStatus(200, "GET", "/api/v1/license", nil).json(t, &st)
if st.State != license.StateTrial || len(st.AvailableFeatures) != len(license.KnownFeatures) || st.Usage.Agents != 4 {
t.Fatalf("trial %+v", st)
}
// Created during the trial, kept afterwards.
var dyn service.ZoneDTO
admin.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "ad.example.test", "mode": "dynamic",
"allow_update": []string{"192.0.2.10/32"}}).json(t, &dyn)
var dnsC service.ClusterDTO
admin.mustStatus(201, "POST", "/api/v1/clusters", map[string]any{"name": "DNS", "role": "dns",
"members": []any{member(dns1, "primary", "192.0.2.53")}}).json(t, &dnsC)
// A license without features.
e.setLicense(nil)
admin.mustStatus(200, "GET", "/api/v1/license", nil).json(t, &st)
if st.State != license.StateValid || len(st.AvailableFeatures) != 0 {
t.Fatalf("licensed %+v", st)
}
// Dynamic zones: no new ones, the existing one stays editable.
wantFeature(t, admin.mustStatus(403, "POST", "/api/v1/zones", map[string]any{"name": "dyn.example.test",
"mode": "dynamic", "allow_update": []string{"192.0.2.10/32"}}), "DDI_DYNAMIC_ZONES")
var managed service.ZoneDTO
admin.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "office.example.test"}).json(t, &managed)
wantFeature(t, admin.mustStatus(403, "PUT", "/api/v1/zones/"+managed.ID, map[string]any{"mode": "dynamic",
"allow_update": []string{"192.0.2.10/32"}, "version": managed.Version}), "DDI_DYNAMIC_ZONES")
admin.mustStatus(200, "PUT", "/api/v1/zones/"+dyn.ID, map[string]any{"mode": "dynamic",
"allow_update": []string{"192.0.2.10/32", "192.0.2.11/32"}, "description": "AD", "version": dyn.Version})
// Clusters: no new cluster, no new member; removing and renaming work.
wantFeature(t, admin.mustStatus(403, "POST", "/api/v1/clusters", map[string]any{"name": "DNS 2", "role": "dns"}),
"DDI_CLUSTER")
wantFeature(t, admin.mustStatus(403, "PUT", "/api/v1/clusters/"+dnsC.ID, map[string]any{"name": "DNS",
"members": []any{member(dns1, "primary", "192.0.2.53"), member(dns2, "secondary", "192.0.2.54")},
"version": dnsC.Version}), "DDI_CLUSTER")
admin.mustStatus(200, "PUT", "/api/v1/clusters/"+dnsC.ID, map[string]any{"name": "DNS office",
"members": []any{member(dns1, "primary", "192.0.2.53")}, "version": dnsC.Version}).json(t, &dnsC)
// SSO: enabling needs the feature.
oidc := map[string]any{"enabled": true, "issuer": "https://idp.example.test", "client_id": "ddi",
"client_secret": "secret", "display_name": "IdP", "scopes": []string{}, "username_claim": "preferred_username",
"role_claim": "", "role_mapping": map[string]string{}, "default_role": "viewer"}
wantFeature(t, admin.mustStatus(403, "PUT", "/api/v1/settings/oidc", oidc), "DDI_SSO")
oidc["enabled"] = false
admin.mustStatus(200, "PUT", "/api/v1/settings/oidc", oidc)
// Clusters without Kea HA: a DHCP cluster may not become an HA pair.
e.setLicense(nil, "DDI_CLUSTER", "DDI_SSO")
wantFeature(t, admin.mustStatus(403, "POST", "/api/v1/clusters", map[string]any{"name": "DHCP", "role": "dhcp",
"members": []any{member(dhcp1, "primary", "192.0.2.67"), member(dhcp2, "secondary", "192.0.2.68")}}),
"DDI_KEA_HA")
var dhcpC service.ClusterDTO
admin.mustStatus(201, "POST", "/api/v1/clusters", map[string]any{"name": "DHCP", "role": "dhcp",
"members": []any{member(dhcp1, "primary", "192.0.2.67")}}).json(t, &dhcpC)
wantFeature(t, admin.mustStatus(403, "PUT", "/api/v1/clusters/"+dhcpC.ID, map[string]any{"name": "DHCP",
"members": []any{member(dhcp1, "primary", "192.0.2.67"), member(dhcp2, "secondary", "192.0.2.68")},
"version": dhcpC.Version}), "DDI_KEA_HA")
oidc["enabled"] = true
admin.mustStatus(200, "PUT", "/api/v1/settings/oidc", oidc)
e.setLicense(nil, "DDI_CLUSTER", "DDI_KEA_HA")
admin.mustStatus(200, "PUT", "/api/v1/clusters/"+dhcpC.ID, map[string]any{"name": "DHCP",
"members": []any{member(dhcp1, "primary", "192.0.2.67"), member(dhcp2, "secondary", "192.0.2.68")},
"version": dhcpC.Version})
admin.mustStatus(200, "GET", "/api/v1/license", nil).json(t, &st)
if !slices.Equal(st.AvailableFeatures, []string{"DDI_CLUSTER", "DDI_KEA_HA"}) {
t.Errorf("available %v", st.AvailableFeatures)
}
}
func TestLicenseAgentLimit(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
e.enroll(admin.createToken("dns").Secret, "dns")
pending := admin.createToken("dhcp")
two := 2
e.setLicense(&two)
e.enroll(admin.createToken("dns").Secret, "dns")
// At the limit: no new token, and an older token cannot enroll.
r := admin.mustStatus(403, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": "dns"})
if p := r.problem(t); p.Code != "license_limit" || p.Params["kind"] != "agents" || p.Params["limit"] != "2" {
t.Errorf("token %+v", p)
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
if err != nil {
t.Fatal(err)
}
_, err = e.svc.EnrollAgent(context.Background(), service.EnrollInput{Token: pending.Secret, CSR: csr, Role: "dhcp",
Hostname: "dhcp", AgentVersion: "1.0.0"})
var se *service.Error
if !errors.As(err, &se) || se.Code != "license_limit" {
t.Fatalf("enroll at the limit: %v", err)
}
// Revoked agents do not count.
var agents agentList
admin.mustStatus(200, "GET", "/api/v1/agents", nil).json(t, &agents)
admin.mustStatus(200, "POST", "/api/v1/agents/"+agents.Items[0].ID+"/revoke", nil)
e.enroll(pending.Secret, "dhcp")
var st service.LicenseStatus
admin.mustStatus(200, "GET", "/api/v1/license", nil).json(t, &st)
if st.Usage.Agents != 2 || st.Limits.MaxAgents == nil || *st.Limits.MaxAgents != 2 {
t.Errorf("usage %+v limits %+v", st.Usage, st.Limits)
}
}
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package license
import "slices"
// Feature is the code of a Keygen entitlement that unlocks a function of
// the server (ADR 0027).
type Feature string
// Features.
const (
FeatureCluster Feature = "DDI_CLUSTER"
FeatureKeaHA Feature = "DDI_KEA_HA"
FeatureSSO Feature = "DDI_SSO"
FeatureDynamicZones Feature = "DDI_DYNAMIC_ZONES"
)
// KnownFeatures lists every feature the server checks, sorted.
var KnownFeatures = []Feature{FeatureCluster, FeatureDynamicZones, FeatureKeaHA, FeatureSSO}
// availableFeatures returns the known features the evaluation grants: all
// during the trial, otherwise those among the license's entitlements.
func availableFeatures(e Evaluation, c *Cache) []string {
out := []string{}
for _, f := range KnownFeatures {
if e.State == StateTrial || (c != nil && slices.Contains(c.Features, string(f))) {
out = append(out, string(f))
}
}
return out
}
// HasFeature reports whether the current license grants f. The trial grants
// every feature; a license grants the features named by its entitlements,
// also during the grace period.
func (m *Manager) HasFeature(f Feature) bool {
if !m.loaded.Load() {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
return slices.Contains(availableFeatures(m.evaluateLocked(), m.cache), string(f))
}
+29
View File
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package license
import (
"slices"
"testing"
)
func TestAvailableFeatures(t *testing.T) {
c := &Cache{Features: []string{"DDI_SSO", "OTHER", "ddi_cluster"}}
cases := []struct {
name string
state State
cache *Cache
want []string
}{
{"trial grants all", StateTrial, nil, []string{"DDI_CLUSTER", "DDI_DYNAMIC_ZONES", "DDI_KEA_HA", "DDI_SSO"}},
{"valid: known entitlements only, exact case", StateValid, c, []string{"DDI_SSO"}},
{"grace keeps them", StateGrace, c, []string{"DDI_SSO"}},
{"no license", StateReadOnly, nil, []string{}},
}
for _, tc := range cases {
got := availableFeatures(Evaluation{State: tc.state}, tc.cache)
if !slices.Equal(got, tc.want) {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
}
+5 -2
View File
@@ -182,7 +182,10 @@ type Status struct {
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
Limits Limits `json:"limits"`
Features []string `json:"features"`
Fingerprint string `json:"fingerprint"`
// AvailableFeatures are the known features the server grants now
// (ADR 0027): all during the trial.
AvailableFeatures []string `json:"available_features"`
Fingerprint string `json:"fingerprint"`
}
// Status returns the current status (re-evaluated against the clock).
@@ -191,7 +194,7 @@ func (m *Manager) Status() Status {
defer m.mu.Unlock()
e := m.evaluateLocked()
s := Status{State: e.State, Reason: e.Reason, TrialEndsAt: e.TrialEndsAt, GraceEndsAt: e.GraceEndsAt,
Features: []string{}, Fingerprint: m.opts.Fingerprint}
Features: []string{}, AvailableFeatures: availableFeatures(e, m.cache), Fingerprint: m.opts.Fingerprint}
if c := m.cache; c != nil {
s.Source = c.Source
s.KeyHint = keyHint(c.Key)
+3
View File
@@ -102,6 +102,9 @@ func (s *Service) EnrollAgent(ctx context.Context, in EnrollInput) (*EnrollResul
if tk.Role != in.Role {
return ErrTokenRoleMismatch
}
if err := s.checkAgentLimit(ctx, tx, 1); err != nil {
return err
}
agentID := agenthub.NewID()
cert, err := s.opts.PKI.SignAgentCSR(in.CSR, agentID)
if errors.Is(err, pki.ErrInvalidCSR) {
+5
View File
@@ -443,6 +443,11 @@ func (s *Service) CreateEnrollmentToken(ctx context.Context, a *Actor, in TokenI
tk.CreatedBy = &uid
}
err := s.inTx(ctx, func(tx *store.Tx) error {
// A token for an agent the license does not allow would only fail
// at enrollment; say so now.
if err := s.checkAgentLimit(ctx, tx, 1); err != nil {
return err
}
if err := tx.InsertEnrollmentToken(ctx, tk); err != nil {
return err
}
+43
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"errors"
"net/netip"
"slices"
"strconv"
"strings"
"time"
@@ -16,6 +17,7 @@ import (
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
@@ -259,6 +261,37 @@ func (s *Service) checkMembers(ctx context.Context, tx *store.Tx, c *store.Clust
return out, nil
}
// checkClusterFeatures checks the license for a cluster whose members change
// from old (nil for a new cluster) to members (ADR 0027): without
// DDI_CLUSTER no agent may join, and a DHCP cluster may only become an HA
// pair (primary and secondary) with DDI_KEA_HA. Removing members and
// changing other fields of an existing cluster stay possible.
func (s *Service) checkClusterFeatures(role string, old, members []store.ClusterMember) error {
for _, m := range members {
if !slices.ContainsFunc(old, func(o store.ClusterMember) bool { return o.AgentID == m.AgentID }) {
if err := s.requireFeature(license.FeatureCluster); err != nil {
return err
}
break
}
}
if role == RoleDHCP && haPair(members) && !haPair(old) {
return s.requireFeature(license.FeatureKeaHA)
}
return nil
}
// haPair reports whether a DHCP cluster has a primary and a secondary, which
// makes the generator configure Kea HA.
func haPair(members []store.ClusterMember) bool {
var p, sec bool
for _, m := range members {
p = p || m.MemberRole == string(configgen.MemberPrimary)
sec = sec || m.MemberRole == string(configgen.MemberSecondary)
}
return p && sec
}
// ListClusters returns the clusters of a role ("" for all), sorted by role
// and name.
func (s *Service) ListClusters(ctx context.Context, role string) ([]ClusterDTO, error) {
@@ -297,6 +330,9 @@ func (s *Service) CreateCluster(ctx context.Context, a *Actor, in ClusterInput)
if !validRole(in.Role) {
return ClusterDTO{}, invalidField("invalid_role", "role")
}
if err := s.requireFeature(license.FeatureCluster); err != nil {
return ClusterDTO{}, err
}
c := &store.Cluster{ID: agenthub.NewID(), Role: in.Role}
if problems := normalizeCluster(&in, in.Role, c); len(problems) > 0 {
return ClusterDTO{}, invalid(problems...)
@@ -321,6 +357,9 @@ func (s *Service) CreateCluster(ctx context.Context, a *Actor, in ClusterInput)
if err != nil {
return err
}
if err := s.checkClusterFeatures(c.Role, nil, members); err != nil {
return err
}
if err := tx.InsertCluster(ctx, c); err != nil {
return err
}
@@ -392,6 +431,7 @@ func (s *Service) UpdateCluster(ctx context.Context, a *Actor, id string, in Clu
return ErrVersionConflict
}
before = s.clusterAudit(c)
oldMembers := c.Members
if problems := normalizeCluster(&in, c.Role, c); len(problems) > 0 {
return invalid(problems...)
}
@@ -399,6 +439,9 @@ func (s *Service) UpdateCluster(ctx context.Context, a *Actor, id string, in Clu
if err != nil {
return err
}
if err := s.checkClusterFeatures(c.Role, oldMembers, members); err != nil {
return err
}
if err := tx.UpdateCluster(ctx, c); err != nil {
return err
}
+1
View File
@@ -74,6 +74,7 @@ var (
ErrSelfDelete = newError(KindConflict, "self_delete", "you cannot delete your own account")
ErrNetworkNotEmpty = newError(KindConflict, "network_not_empty", "the network still has addresses")
ErrLicenseLimit = newError(KindForbidden, "license_limit", "the license limit is reached")
ErrLicenseFeature = newError(KindForbidden, "license_feature", "the license does not include this feature")
ErrPasswordChange = newError(KindForbidden, "password_change_required", "the password must be changed first")
ErrAlreadyInitialized = newError(KindConflict, "already_initialized", "users already exist")
ErrMapTooLarge = newError(KindInvalid, "map_too_large", "the address map is only available for IPv4 networks up to /22")
+47 -3
View File
@@ -24,12 +24,56 @@ type LicenseUsage struct {
// LicenseStatus returns the current license status with usage.
func (s *Service) LicenseStatus(ctx context.Context) (LicenseStatus, error) {
n, err := s.st.Q().CountAddresses(ctx)
q := s.st.Q()
n, err := q.CountAddresses(ctx)
if err != nil {
return LicenseStatus{}, translate(err)
}
// Agents are counted from M3 on.
return LicenseStatus{Status: s.opts.License.Status(), Usage: LicenseUsage{Addresses: n}}, nil
agents, err := q.CountActiveAgents(ctx)
if err != nil {
return LicenseStatus{}, translate(err)
}
return LicenseStatus{Status: s.opts.License.Status(), Usage: LicenseUsage{Addresses: n, Agents: agents}}, nil
}
// hasFeature reports whether the license grants f (ADR 0027). Without a
// license manager (tests, tools) everything is allowed.
func (s *Service) hasFeature(f license.Feature) bool {
return s.opts.License == nil || s.opts.License.HasFeature(f)
}
// requireFeature fails with license_feature if the license does not grant
// f. Callers check only what newly uses a feature: what exists keeps working
// and stays editable (ADR 0027).
func (s *Service) requireFeature(f license.Feature) error {
if s.hasFeature(f) {
return nil
}
e := *ErrLicenseFeature
e.Params = map[string]string{"feature": string(f)}
return &e
}
// checkAgentLimit fails with license_limit if adding agents would exceed
// maxAgents. Revoked agents do not count.
func (s *Service) checkAgentLimit(ctx context.Context, tx *store.Tx, adding int) error {
if s.opts.License == nil {
return nil
}
max := s.opts.License.Limits().MaxAgents
if max == nil {
return nil
}
n, err := tx.CountActiveAgents(ctx)
if err != nil {
return err
}
if n+adding > *max {
e := *ErrLicenseLimit
e.Params = map[string]string{"limit": itoa(*max), "kind": "agents"}
return &e
}
return nil
}
// licenseError maps license errors to API errors.
+9 -1
View File
@@ -12,6 +12,7 @@ import (
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/auth"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
@@ -126,10 +127,17 @@ func (s *Service) SetOIDCSettings(ctx context.Context, a *Actor, cfg OIDCSetting
return OIDCSettings{}, invalid(problems...)
}
_, oldSecret, err := s.loadOIDC(ctx)
old, oldSecret, err := s.loadOIDC(ctx)
if err != nil {
return OIDCSettings{}, err
}
// Enabling SSO needs the feature; settings that are already enabled
// stay editable (ADR 0027).
if cfg.Enabled && !old.Enabled {
if err := s.requireFeature(license.FeatureSSO); err != nil {
return OIDCSettings{}, err
}
}
newSecret := oldSecret
if secret != nil {
newSecret = *secret
+13
View File
@@ -13,6 +13,7 @@ import (
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen/derive"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
@@ -240,6 +241,11 @@ func (s *Service) CreateZone(ctx context.Context, a *Actor, in ZoneInput) (ZoneD
if err != nil {
return ZoneDTO{}, err
}
if in.Mode == string(configgen.ZoneDynamic) {
if err := s.requireFeature(license.FeatureDynamicZones); err != nil {
return ZoneDTO{}, err
}
}
z := &store.Zone{Name: name, Kind: kind}
var out ZoneDTO
err = s.inTx(ctx, func(tx *store.Tx) error {
@@ -273,6 +279,13 @@ func (s *Service) UpdateZone(ctx context.Context, a *Actor, id string, in ZoneIn
return ErrVersionConflict
}
before := zoneDTO(z)
// An existing dynamic zone stays editable without the feature; only
// switching a zone to dynamic needs it (ADR 0027).
if in.Mode == string(configgen.ZoneDynamic) && z.Mode != string(configgen.ZoneDynamic) {
if err := s.requireFeature(license.FeatureDynamicZones); err != nil {
return err
}
}
if err := s.applyZone(ctx, tx, &in, z); err != nil {
return err
}
+18
View File
@@ -114,6 +114,24 @@ func (t *Tx) GetAgent(ctx context.Context, id string, forUpdate bool) (*Agent, e
return scanAgent(t.q.QueryRow(ctx, sql, id))
}
// agentLimitLockKey serializes enrollments against the agent limit
// (pg_advisory_xact_lock).
const agentLimitLockKey = 0x64646902
// CountActiveAgents returns the number of agents that are not revoked
// (license limit, ADR 0027). Within a transaction it first takes a lock so
// that concurrent enrollments cannot both pass the limit.
func (t *Tx) CountActiveAgents(ctx context.Context) (int, error) {
if t.hooks != nil {
if _, err := t.q.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, int64(agentLimitLockKey)); err != nil {
return 0, mapErr(err)
}
}
var n int
err := t.q.QueryRow(ctx, `SELECT count(*) FROM agents WHERE revoked_at IS NULL`).Scan(&n)
return n, mapErr(err)
}
// ListAgents returns all agents ordered by role and hostname.
func (t *Tx) ListAgents(ctx context.Context) ([]*Agent, error) {
rows, err := t.q.Query(ctx, `SELECT `+agentColumns+` FROM agents ORDER BY role, lower(hostname), id`)
+3
View File
@@ -245,10 +245,13 @@ server 127.0.0.1
zone ad.e2e.test
update add dc1.ad.e2e.test. 300 A 192.0.2.77
update add _ldap._tcp.dc._msdcs.ad.e2e.test. 300 SRV 0 100 389 dc1.ad.e2e.test.
update add gc._msdcs.ad.e2e.test. 300 A 192.0.2.77
send
EOF
expect "192.0.2.77" dig_short dc1.ad.e2e.test A
wait_dig2 "0 100 389 dc1.ad.e2e.test." _ldap._tcp.dc._msdcs.ad.e2e.test SRV
# Active Directory registers A records below _msdcs (check-names ignore).
expect "192.0.2.77" dig_short gc._msdcs.ad.e2e.test A
key="$(api tsig-secret e2e-update)"
if dc exec -T bind2 nsupdate <<EOF; then
server $bind_ip
+98 -6
View File
@@ -6,10 +6,16 @@
#
# Usage:
# keygen-setup.sh setup
# create (or reuse) the product and the standard policy; prints the
# product ID for KEYGEN_PRODUCT_ID
# create (or reuse) the product, the standard policy and the
# entitlements of the features (ADR 0027); prints the product ID for
# KEYGEN_PRODUCT_ID
# keygen-setup.sh create-license --name NAME [--max-agents N] [--max-addresses N] [--expiry DATE]
# issue a license under the standard policy; prints the license key
# [--features LIST]
# issue a license under the standard policy; prints the license key.
# LIST is comma-separated: DDI_CLUSTER,DDI_KEA_HA,DDI_DYNAMIC_ZONES,DDI_SSO
# (or "all")
# keygen-setup.sh features --license ID [--add LIST] [--remove LIST]
# show or change the features of a license
# keygen-setup.sh checkout --license ID [--ttl SECONDS] [--encrypt] [--out FILE]
# write an offline license file (for isolated networks)
# keygen-setup.sh list
@@ -27,6 +33,13 @@ set -euo pipefail
readonly PRODUCT_NAME="ddi-management-server"
readonly PRODUCT_CODE="ddi-management-server"
readonly POLICY_NAME="ddi-server-standard"
# Entitlement codes the server checks (ADR 0027) and their names in Keygen.
readonly -A FEATURES=(
[DDI_CLUSTER]="DDI clusters"
[DDI_KEA_HA]="DDI Kea HA"
[DDI_DYNAMIC_ZONES]="DDI dynamic zones"
[DDI_SSO]="DDI single sign-on"
)
KEYGEN_URL="${KEYGEN_URL:-https://license.beugsiud.de}"
KEYGEN_ACCOUNT_ID="${KEYGEN_ACCOUNT_ID:-9de54794-8e94-4250-83f6-87e3d5686a0b}"
@@ -37,7 +50,7 @@ die() {
}
usage() {
sed -n '4,25p' "$0" | sed 's/^# \{0,1\}//'
sed -n '4,/^# Requires/p' "$0" | sed 's/^# \{0,1\}//'
}
for tool in curl jq; do
@@ -65,6 +78,46 @@ find_policy() {
api GET "/policies?product=${product}&limit=100" | jq -r --arg name "$POLICY_NAME" '.data[] | select(.attributes.name == $name) | .id' | head -n1
}
# entitlement_id prints the ID of the entitlement with code $1 (empty if none).
entitlement_id() {
api GET "/entitlements?limit=100" | jq -r --arg code "$1" '.data[] | select(.attributes.code == $code) | .id' | head -n1
}
# feature_list checks a comma-separated feature list ("all" for every
# feature) and prints one code per line.
feature_list() {
local list="$1" code
if [[ "$list" == "all" ]]; then
printf '%s\n' "${!FEATURES[@]}" | sort
return
fi
IFS=',' read -r -a codes <<<"$list"
for code in "${codes[@]}"; do
code="${code// /}"
[[ -z "$code" ]] && continue
[[ -n "${FEATURES[$code]:-}" ]] || die "unknown feature ${code} (known: ${!FEATURES[*]})"
printf '%s\n' "$code"
done
}
# license_entitlements changes the entitlements of license $1: method POST
# attaches, DELETE detaches the features in list $3.
license_entitlements() {
local license="$1" method="$2" list="$3" code id ids=()
while read -r code; do
id="$(entitlement_id "$code")"
[[ -n "$id" ]] || die "entitlement ${code} missing: run setup first"
ids+=("$id")
done < <(feature_list "$list")
[[ ${#ids[@]} -gt 0 ]] || return 0
api "$method" "/licenses/${license}/entitlements" \
"$(printf '%s\n' "${ids[@]}" | jq -R . | jq -s '{ data: map({ type: "entitlements", id: . }) }')" >/dev/null
}
show_features() {
api GET "/licenses/$1/entitlements?limit=100" | jq -r '"features: " + ([.data[].attributes.code] | sort | join(", ") | if . == "" then "none" else . end)'
}
cmd_setup() {
local product policy
product="$(find_product)"
@@ -108,24 +161,39 @@ cmd_setup() {
else
echo "policy exists: ${policy}"
fi
local code id
for code in $(printf '%s\n' "${!FEATURES[@]}" | sort); do
id="$(entitlement_id "$code")"
if [[ -z "$id" ]]; then
api POST /entitlements "$(jq -n --arg name "${FEATURES[$code]}" --arg code "$code" '{
data: { type: "entitlements", attributes: { name: $name, code: $code } }
}')" >/dev/null
echo "created entitlement ${code}"
else
echo "entitlement exists: ${code}"
fi
done
echo
echo "Configure the management server with:"
echo " KEYGEN_PRODUCT_ID=${product}"
}
cmd_create_license() {
local name="" max_agents="10" max_addresses="5000" expiry=""
local name="" max_agents="10" max_addresses="5000" expiry="" features=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--max-agents) max_agents="$2"; shift 2 ;;
--max-addresses) max_addresses="$2"; shift 2 ;;
--expiry) expiry="$2"; shift 2 ;;
--features) features="$2"; shift 2 ;;
*) die "unknown option $1" ;;
esac
done
[[ -n "$name" ]] || die "--name is required"
[[ "$max_agents" =~ ^[0-9]+$ && "$max_addresses" =~ ^[0-9]+$ ]] || die "limits must be numbers"
feature_list "$features" >/dev/null
local product policy
product="$(find_product)"
[[ -n "$product" ]] || die "run setup first"
@@ -141,7 +209,30 @@ cmd_create_license() {
relationships: { policy: { data: { type: "policies", id: $policy } } }
}
}')"
api POST /licenses "$body" | jq -r '"license id: \(.data.id)\nlicense key: \(.data.attributes.key)\nexpires: \(.data.attributes.expiry // "never")"'
local created id
created="$(api POST /licenses "$body")"
id="$(jq -r '.data.id' <<<"$created")"
license_entitlements "$id" POST "$features"
jq -r '"license id: \(.data.id)\nlicense key: \(.data.attributes.key)\nexpires: \(.data.attributes.expiry // "never")"' <<<"$created"
show_features "$id"
}
cmd_features() {
local license="" add="" remove=""
while [[ $# -gt 0 ]]; do
case "$1" in
--license) license="$2"; shift 2 ;;
--add) add="$2"; shift 2 ;;
--remove) remove="$2"; shift 2 ;;
*) die "unknown option $1" ;;
esac
done
[[ -n "$license" ]] || die "--license is required"
feature_list "$add" >/dev/null
feature_list "$remove" >/dev/null
license_entitlements "$license" POST "$add"
license_entitlements "$license" DELETE "$remove"
show_features "$license"
}
cmd_checkout() {
@@ -183,6 +274,7 @@ main() {
case "$cmd" in
setup) cmd_setup "$@" ;;
create-license) cmd_create_license "$@" ;;
features) cmd_features "$@" ;;
checkout) cmd_checkout "$@" ;;
list) cmd_list "$@" ;;
-h | --help | help | "") usage ;;
+246
View File
@@ -0,0 +1,246 @@
# SPDX-License-Identifier: LicenseRef-DDI-Proprietary
#Requires -Version 5.1
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Switches a domain controller (also Server Core) to the BIND servers of the
DDI Suite and re-registers its Active Directory DNS records there.
.DESCRIPTION
Steps (each one only if needed, -WhatIf shows them without changing
anything):
1. Checks that the DDI DNS server answers authoritatively for the AD zone.
2. Sets the DNS servers of the network adapter(s) to -DnsServer.
3. Optionally turns the DHCP address into a static one (-MakeStatic).
4. Stops and disables the Windows DNS Server service (the zone lives in
BIND now; a second copy on the DC would diverge).
5. Re-registers the A record (ipconfig /registerdns) and the domain
controller locator records (nltest /dsregdns).
6. Waits until the DC's A record, its _msdcs CNAME and its LDAP SRV record
answer at -DnsServer, then optionally triggers replication.
Before running it, add this DC's address as update source of the zone in
DDI (Zones > <domain> > update sources, e.g. 10.10.10.201/32) and deploy.
See docs/ACTIVE-DIRECTORY.md.
.PARAMETER DnsServer
DNS servers of the DDI Suite (BIND), the primary first.
.PARAMETER Domain
AD DNS domain; default: the domain of this computer.
.PARAMETER InterfaceAlias
Network adapter(s) to change; default: all connected adapters with an
IPv4 default gateway.
.PARAMETER MakeStatic
Replace a DHCP address by the same address as static configuration
(domain controllers should not use DHCP).
.PARAMETER KeepDnsServerService
Do not stop and disable the Windows DNS Server service.
.PARAMETER StartIsmServ
Set the Intersite Messaging service to automatic and start it (dcdiag
reports it otherwise).
.PARAMETER Sync
Trigger replication with all partners afterwards (repadmin /syncall /AdeP).
.PARAMETER TimeoutSeconds
How long to wait for the records to appear (default 180).
.EXAMPLE
.\Set-DcDnsToDdi.ps1 -DnsServer 10.10.10.151 -WhatIf
.\Set-DcDnsToDdi.ps1 -DnsServer 10.10.10.151 -MakeStatic -StartIsmServ -Sync
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string[]] $DnsServer,
[string] $Domain,
[string[]] $InterfaceAlias,
[switch] $MakeStatic,
[switch] $KeepDnsServerService,
[switch] $StartIsmServ,
[switch] $Sync,
[ValidateRange(10, 3600)]
[int] $TimeoutSeconds = 180
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Step([string] $Text) { Write-Host "==> $Text" -ForegroundColor Cyan }
function Write-Ok([string] $Text) { Write-Host " ok $Text" -ForegroundColor Green }
function Write-Note([string] $Text) { Write-Host " $Text" }
# Asks the DDI server only (no hosts file, no cache); $null if missing.
function Get-DdiRecord([string] $Name, [string] $Type) {
try {
return Resolve-DnsName -Server $DnsServer[0] -Name $Name -Type $Type -DnsOnly -NoHostsFile -QuickTimeout -ErrorAction Stop |
Where-Object { $_.Section -eq 'Answer' }
} catch {
return $null
}
}
foreach ($s in $DnsServer) {
$ip = $null
if (-not [System.Net.IPAddress]::TryParse($s, [ref] $ip)) { throw "Not an IP address: $s" }
}
$cs = Get-CimInstance -ClassName Win32_ComputerSystem
if ($cs.DomainRole -lt 4) { throw "This computer is not a domain controller (DomainRole $($cs.DomainRole))." }
if (-not $Domain) { $Domain = $cs.Domain }
$hostName = $env:COMPUTERNAME.ToLowerInvariant()
$fqdn = "$hostName.$Domain"
Write-Step "Domain controller $fqdn, DDI DNS server(s): $($DnsServer -join ', ')"
# --- 1. The DDI server must hold the zone ----------------------------------
Write-Step "Check that $($DnsServer[0]) is authoritative for $Domain"
$soa = Get-DdiRecord $Domain 'SOA'
if (-not $soa) { throw "$($DnsServer[0]) gives no SOA for $Domain. Create the zone in DDI (dynamic) and deploy first." }
Write-Ok "SOA: primary $($soa.PrimaryServer), serial $($soa.SerialNumber)"
$mname = Get-DdiRecord $soa.PrimaryServer 'A'
if (-not $mname) {
Write-Warning "The SOA primary $($soa.PrimaryServer) does not resolve at $($DnsServer[0]). Windows sends updates to that name: add it to the address table in DDI."
} else {
Write-Ok "updates go to $($soa.PrimaryServer) ($(@($mname.IPAddress) -join ', '))"
}
# --- 2./3. Network adapters --------------------------------------------------
Write-Step 'DNS servers of the network adapter(s)'
if ($InterfaceAlias) {
$configs = Get-NetIPConfiguration -InterfaceAlias $InterfaceAlias
} else {
$configs = Get-NetIPConfiguration | Where-Object { $_.IPv4DefaultGateway -and $_.NetAdapter.Status -eq 'Up' }
}
if (-not $configs) { throw 'No connected adapter with a default gateway found; use -InterfaceAlias.' }
foreach ($c in @($configs)) {
$alias = $c.InterfaceAlias
$current = @(Get-DnsClientServerAddress -InterfaceAlias $alias -AddressFamily IPv4).ServerAddresses
if (($current -join ',') -eq ($DnsServer -join ',')) {
Write-Ok "$alias already uses $($DnsServer -join ', ')"
} elseif ($PSCmdlet.ShouldProcess($alias, "Set DNS servers $($DnsServer -join ', ') (was: $($current -join ', '))")) {
Set-DnsClientServerAddress -InterfaceAlias $alias -ServerAddresses $DnsServer
Write-Ok "$alias now uses $($DnsServer -join ', ') (was: $($current -join ', '))"
}
# Register this connection's address in DNS (default, but make sure).
$dnsClient = Get-DnsClient -InterfaceAlias $alias
if (-not $dnsClient.RegisterThisConnectionsAddress -and $PSCmdlet.ShouldProcess($alias, 'Enable DNS registration')) {
Set-DnsClient -InterfaceAlias $alias -RegisterThisConnectionsAddress $true
Write-Ok "$alias registers its address in DNS"
}
$ipIf = Get-NetIPInterface -InterfaceAlias $alias -AddressFamily IPv4
if ($ipIf.Dhcp -eq 'Enabled') {
if (-not $MakeStatic) {
Write-Warning "$alias gets its address by DHCP; domain controllers should use a static address (-MakeStatic)."
} else {
$addr = Get-NetIPAddress -InterfaceAlias $alias -AddressFamily IPv4 | Where-Object { $_.PrefixOrigin -eq 'Dhcp' } | Select-Object -First 1
$gw = ($c.IPv4DefaultGateway | Select-Object -First 1).NextHop
if (-not $addr -or -not $gw) { throw "Cannot read the DHCP address or gateway of $alias." }
if ($PSCmdlet.ShouldProcess($alias, "Make $($addr.IPAddress)/$($addr.PrefixLength) via $gw static")) {
# Taking DHCP off removes the leased address; set it again
# right away. Keep the connection: no restart of the adapter.
Set-NetIPInterface -InterfaceAlias $alias -AddressFamily IPv4 -Dhcp Disabled
Get-NetIPAddress -InterfaceAlias $alias -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue
Get-NetRoute -InterfaceAlias $alias -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue
New-NetIPAddress -InterfaceAlias $alias -IPAddress $addr.IPAddress -PrefixLength $addr.PrefixLength -DefaultGateway $gw | Out-Null
Set-DnsClientServerAddress -InterfaceAlias $alias -ServerAddresses $DnsServer
Write-Ok "$alias static: $($addr.IPAddress)/$($addr.PrefixLength), gateway $gw"
}
}
} else {
Write-Ok "$alias has a static address"
}
}
if ($PSCmdlet.ShouldProcess('DNS client cache', 'Clear')) { Clear-DnsClientCache }
# --- 4. Windows DNS Server service ---------------------------------------------
Write-Step 'Windows DNS Server service'
$dnsSvc = Get-Service -Name DNS -ErrorAction SilentlyContinue
if (-not $dnsSvc) {
Write-Ok 'not installed'
} elseif ($KeepDnsServerService) {
Write-Note "kept ($($dnsSvc.Status)) because of -KeepDnsServerService"
} else {
if ($dnsSvc.Status -ne 'Stopped' -and $PSCmdlet.ShouldProcess('DNS', 'Stop the Windows DNS Server service')) {
Stop-Service -Name DNS -Force
Write-Ok 'stopped'
}
if ((Get-CimInstance Win32_Service -Filter "Name='DNS'").StartMode -ne 'Disabled' -and
$PSCmdlet.ShouldProcess('DNS', 'Disable the Windows DNS Server service')) {
Set-Service -Name DNS -StartupType Disabled
Write-Ok 'disabled'
}
}
if ($StartIsmServ) {
Write-Step 'Intersite Messaging (IsmServ)'
$ism = Get-Service -Name IsmServ -ErrorAction SilentlyContinue
if ($ism -and $PSCmdlet.ShouldProcess('IsmServ', 'Set to automatic and start')) {
Set-Service -Name IsmServ -StartupType Automatic
if ($ism.Status -ne 'Running') { Start-Service -Name IsmServ }
Write-Ok 'running'
}
}
# --- 5. Registration ------------------------------------------------------------
Write-Step 'Register the DNS records of this domain controller'
if ($PSCmdlet.ShouldProcess($fqdn, 'ipconfig /registerdns and nltest /dsregdns')) {
Register-DnsClient
& nltest.exe /dsregdns | Out-Null
if ($LASTEXITCODE -ne 0) { throw "nltest /dsregdns failed (exit $LASTEXITCODE)" }
Write-Ok 'registration started'
}
if ($WhatIfPreference) {
Write-Note 'WhatIf: no verification.'
return
}
# --- 6. Verification ---------------------------------------------------------------
Write-Step "Wait up to $TimeoutSeconds s for the records at $($DnsServer[0])"
$myIps = @(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | ForEach-Object { $_.IPAddress })
$guid = $null
try {
Import-Module ActiveDirectory -ErrorAction Stop
$dc = Get-ADDomainController -Identity $env:COMPUTERNAME
$guid = (Get-ADObject -Identity $dc.NTDSSettingsObjectDN -Properties objectGUID).objectGUID.Guid
} catch {
Write-Warning "Cannot read the DSA GUID (ActiveDirectory module): $($_.Exception.Message). The _msdcs CNAME is not checked."
}
$checks = [ordered]@{
"A $fqdn" = { $r = Get-DdiRecord $fqdn 'A'; $r -and (@($r.IPAddress) | Where-Object { $myIps -contains $_ }) }
"SRV _ldap._tcp.dc._msdcs.$Domain" = { $r = Get-DdiRecord "_ldap._tcp.dc._msdcs.$Domain" 'SRV'; $r -and (@($r.NameTarget) -contains $fqdn) }
"SRV _kerberos._tcp.$Domain" = { $r = Get-DdiRecord "_kerberos._tcp.$Domain" 'SRV'; $r -and (@($r.NameTarget) -contains $fqdn) }
}
if ($guid) {
$checks["CNAME $guid._msdcs.$Domain"] = { $r = Get-DdiRecord "$guid._msdcs.$Domain" 'CNAME'; [bool]$r }
}
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$missing = @($checks.Keys)
while ($missing.Count -gt 0 -and (Get-Date) -lt $deadline) {
$missing = @($missing | Where-Object { -not (& $checks[$_]) })
if ($missing.Count -gt 0) { Start-Sleep -Seconds 5 }
}
foreach ($k in $checks.Keys) {
if ($missing -contains $k) { Write-Warning "missing: $k" } else { Write-Ok $k }
}
if ($missing.Count -gt 0) {
Write-Warning ('Records are missing. Check in DDI that this DC''s address is an update source of the zone ' +
'(and deployed), and look for "update ... denied" in the BIND log (journalctl -u named / -u bind9). ' +
'Event log: System, source "DNS Client Events" (8015-8020), source NETLOGON (5774, 5781).')
exit 1
}
if ($Sync) {
Write-Step 'Trigger replication with all partners'
& repadmin.exe /syncall /AdeP
}
Write-Step 'Done. Check with: dcdiag and repadmin /replsummary'
Generated Vendored
+19 -2
View File
@@ -320,6 +320,7 @@ export interface paths {
cookie?: never;
};
get: operations["getOIDCSettings"];
/** @description Enabling SSO needs the license feature DDI_SSO (403 license_feature, ADR 0027); settings that are already enabled stay editable. */
put: operations["setOIDCSettings"];
post?: never;
delete?: never;
@@ -479,6 +480,7 @@ export interface paths {
};
get: operations["listEnrollmentTokens"];
put?: never;
/** @description Fails with 403 license_limit (kind agents) when the license allows no further agent (maxAgents, revoked agents do not count; ADR 0027). Enrollment checks the limit again. */
post: operations["createEnrollmentToken"];
delete?: never;
options?: never;
@@ -931,6 +933,8 @@ export interface paths {
* the other role), agent_in_other_cluster, cluster_primary_exists
* (second primary), cluster_member_limit (DHCP: more than one primary
* and one secondary), invalid_ip (address).
* License (ADR 0027): 403 license_feature with feature DDI_CLUSTER, or
* DDI_KEA_HA for a DHCP cluster with primary and secondary.
*/
post: operations["createCluster"];
delete?: never;
@@ -949,7 +953,12 @@ export interface paths {
cookie?: never;
};
get: operations["getCluster"];
/** @description Replaces all mutable fields including the member list. The role is immutable and ignored. `version` must match. */
/**
* @description Replaces all mutable fields including the member list. The role is
* immutable and ignored. `version` must match. License (ADR 0027): adding
* an agent needs DDI_CLUSTER, turning a DHCP cluster into an HA pair
* DDI_KEA_HA (403 license_feature); removing members always works.
*/
put: operations["updateCluster"];
post?: never;
/**
@@ -1052,6 +1061,8 @@ export interface paths {
* invalid_domain, zone_kind_mismatch (reverse zones must end in
* in-addr.arpa or ip6.arpa, forward zones must not), cluster_role_mismatch
* (cluster is not a DNS cluster), not_found (cluster or key).
* A dynamic zone needs the license feature DDI_DYNAMIC_ZONES (403
* license_feature, ADR 0027).
*/
post: operations["createZone"];
delete?: never;
@@ -1086,7 +1097,11 @@ export interface paths {
cookie?: never;
};
get: operations["getZone"];
/** @description Replaces all mutable fields. Name and kind are immutable and ignored. `version` must match. */
/**
* @description Replaces all mutable fields. Name and kind are immutable and ignored.
* `version` must match. Switching a zone to dynamic needs DDI_DYNAMIC_ZONES
* (403 license_feature, ADR 0027); an existing dynamic zone stays editable.
*/
put: operations["updateZone"];
post?: never;
delete: operations["deleteZone"];
@@ -1252,6 +1267,8 @@ export interface components {
max_addresses?: number;
};
features: string[];
/** @description Features the server grants now (ADR 0027): all during the trial, otherwise those among the license entitlements. Without one, creating clusters, Kea HA pairs, dynamic zones or enabling SSO returns 403 license_feature (param feature); what exists keeps working. */
available_features: ("DDI_CLUSTER" | "DDI_DYNAMIC_ZONES" | "DDI_KEA_HA" | "DDI_SSO")[];
fingerprint: string;
usage: {
addresses: number;
+8 -1
View File
@@ -6,7 +6,14 @@ import { ApiError, type FieldProblem } from './api/client';
/** Translates an error (ApiError code + params, or anything else). */
export function errorText(t: TFunction, err: unknown): string {
if (err instanceof ApiError) {
return t(`errors.${err.code}`, { defaultValue: t('errors.unknown'), ...err.problem.params });
const params = { ...err.problem.params };
// license_feature names the entitlement code (ADR 0027).
if (params.feature) {
params.feature = t(`license.featureNames.${params.feature}`, {
defaultValue: params.feature,
});
}
return t(`errors.${err.code}`, { defaultValue: t('errors.unknown'), ...params });
}
return t('errors.unknown');
}
+16
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import type { TFunction } from 'i18next';
import { api, unwrap, type Schemas } from './api/client';
import { useSession } from './auth/context';
export type LicenseStatus = Schemas['LicenseStatus'];
@@ -20,3 +21,18 @@ export function useLicense(enabled: boolean) {
export function reasonText(t: TFunction, reason: string): string {
return t(`license.reason.${reason}`, { defaultValue: reason });
}
/** Functions a license unlocks by entitlement (ADR 0027). */
export type Feature = LicenseStatus['available_features'][number];
export const features: Feature[] = ['DDI_CLUSTER', 'DDI_KEA_HA', 'DDI_DYNAMIC_ZONES', 'DDI_SSO'];
/**
* Whether the license grants a feature. Until the status is loaded (or for
* users who may not read it) the answer is true: the server checks anyway.
*/
export function useFeature(feature: Feature): boolean {
const { can } = useSession();
const { data } = useLicense(can('license.read'));
return data ? data.available_features.includes(feature) : true;
}
+21 -5
View File
@@ -174,7 +174,18 @@
"recheck": "Jetzt prüfen",
"remove": "Lizenz entfernen",
"removeConfirm": "Lizenz entfernen? Der Server läuft danach ohne Lizenz.",
"dataPlane": "Die Lizenz betrifft nur den Management-Server. DNS- und DHCP-Server laufen immer weiter."
"dataPlane": "Die Lizenz betrifft nur den Management-Server. DNS- und DHCP-Server laufen immer weiter.",
"featureNames": {
"DDI_CLUSTER": "Cluster",
"DDI_KEA_HA": "Kea-HA (DHCP-Hochverfügbarkeit)",
"DDI_DYNAMIC_ZONES": "Dynamische Zonen",
"DDI_SSO": "Single Sign-On (OIDC)"
},
"featureOn": "freigeschaltet",
"featureOff": "nicht enthalten",
"featuresTrial": "In der Testphase ist alles freigeschaltet.",
"entitlements": "Entitlements der Lizenz",
"featureMissing": "{{feature}} ist in der Lizenz nicht enthalten."
},
"networks": {
"title": "Netze",
@@ -477,7 +488,8 @@
"defaultRole": "Standardrolle",
"defaultNone": "keine (Anmeldung ablehnen)",
"redirectHint": "Redirect-URI",
"invalidMapping": "Ungültige Zeile: {{line}}"
"invalidMapping": "Ungültige Zeile: {{line}}",
"licenseSSO": "Single Sign-On ist in der Lizenz nicht enthalten."
},
"errors": {
"unknown": "Es ist ein Fehler aufgetreten.",
@@ -558,7 +570,8 @@
"range_invalid": "Ungültiger Bereich.",
"range_outside_network": "Bereich liegt nicht im Netz {{network}}.",
"range_too_large": "Bereich zu groß (höchstens {{max}} Adressen).",
"invalid_type": "Typ {{type}} ist hier nicht erlaubt."
"invalid_type": "Typ {{type}} ist hier nicht erlaubt.",
"license_feature": "{{feature}} ist in der Lizenz nicht enthalten."
},
"problems": {
"required": "Pflichtfeld.",
@@ -990,7 +1003,9 @@
"networkCount_other": "{{count}} Netze",
"deleteConfirm": "Cluster {{name}} löschen? Die Mitglieder kommen in die Standardgruppe; der Transfer-Schlüssel wird gelöscht.",
"defaultCluster": "Standard",
"defaultGroup": "keiner (Standardgruppe)"
"defaultGroup": "keiner (Standardgruppe)",
"licenseCluster": "Cluster sind in der Lizenz nicht enthalten. Bestehende Cluster laufen weiter und lassen sich bearbeiten, neue Cluster und neue Mitglieder nicht.",
"licenseKeaHA": "Kea-HA ist in der Lizenz nicht enthalten: Ein DHCP-Cluster hat dann nur einen Primary."
},
"tsig": {
"title": "TSIG-Schlüssel",
@@ -1078,7 +1093,8 @@
"networks": "Netze",
"takeOver": "Übernehmen",
"takeOverName": "{{name}} übernehmen",
"takeOverHint": "Wird als explizite Zone angelegt; die Records aus der Adresstabelle bleiben."
"takeOverHint": "Wird als explizite Zone angelegt; die Records aus der Adresstabelle bleiben.",
"licenseDynamic": "Dynamische Zonen sind in der Lizenz nicht enthalten."
},
"records": {
"title": "Records",
+21 -5
View File
@@ -174,7 +174,18 @@
"recheck": "Check now",
"remove": "Remove license",
"removeConfirm": "Remove the license? The server then runs without one.",
"dataPlane": "The license only affects the management server. DNS and DHCP servers always keep running."
"dataPlane": "The license only affects the management server. DNS and DHCP servers always keep running.",
"featureNames": {
"DDI_CLUSTER": "Clusters",
"DDI_KEA_HA": "Kea HA (DHCP high availability)",
"DDI_DYNAMIC_ZONES": "Dynamic zones",
"DDI_SSO": "Single sign-on (OIDC)"
},
"featureOn": "included",
"featureOff": "not included",
"featuresTrial": "Everything is unlocked during the trial.",
"entitlements": "License entitlements",
"featureMissing": "{{feature}} is not included in the license."
},
"networks": {
"title": "Networks",
@@ -477,7 +488,8 @@
"defaultRole": "Default role",
"defaultNone": "none (reject sign-in)",
"redirectHint": "Redirect URI",
"invalidMapping": "Invalid line: {{line}}"
"invalidMapping": "Invalid line: {{line}}",
"licenseSSO": "Single sign-on is not included in the license."
},
"errors": {
"unknown": "Something went wrong.",
@@ -558,7 +570,8 @@
"range_invalid": "Invalid range.",
"range_outside_network": "Range is not inside network {{network}}.",
"range_too_large": "Range too large (at most {{max}} addresses).",
"invalid_type": "Type {{type}} is not allowed here."
"invalid_type": "Type {{type}} is not allowed here.",
"license_feature": "{{feature}} is not included in the license."
},
"problems": {
"required": "Required.",
@@ -990,7 +1003,9 @@
"networkCount_other": "{{count}} networks",
"deleteConfirm": "Delete cluster {{name}}? Its members join the default group; the transfer key is deleted.",
"defaultCluster": "Default",
"defaultGroup": "none (default group)"
"defaultGroup": "none (default group)",
"licenseCluster": "Clusters are not included in the license. Existing clusters keep running and remain editable; new clusters and new members are not possible.",
"licenseKeaHA": "Kea HA is not included in the license: a DHCP cluster can only have a primary."
},
"tsig": {
"title": "TSIG keys",
@@ -1078,7 +1093,8 @@
"networks": "Networks",
"takeOver": "Take over",
"takeOverName": "Take over {{name}}",
"takeOverHint": "Created as an explicit zone; the records from the address table remain."
"takeOverHint": "Created as an explicit zone; the records from the address table remain.",
"licenseDynamic": "Dynamic zones are not included in the license."
},
"records": {
"title": "Records",
+37
View File
@@ -346,6 +346,43 @@ describe('clusters', () => {
});
});
it('locks what the license does not include (ADR 0027)', async () => {
mockApi({
...handlers,
'GET /api/v1/license': () => ({ body: { ...validLicense, available_features: [] } }),
});
renderApp('/clusters');
expect(await screen.findByRole('button', { name: 'DNS-Cluster anlegen' })).toBeDisabled();
expect(
screen.getAllByText(/Cluster sind in der Lizenz nicht enthalten/).length,
).toBeGreaterThan(0);
// Existing clusters stay editable, but no member can be added.
const dnsRow = rowOf(screen.getByText('dns-a'));
await userEvent.click(within(dnsRow).getByRole('button', { name: 'Bearbeiten' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByRole('button', { name: 'Mitglied hinzufügen' })).toBeDisabled();
expect(within(dialog).getByRole('button', { name: 'Speichern' })).toBeEnabled();
});
it('allows a single DHCP server but no HA pair without DDI_KEA_HA', async () => {
mockApi({
...handlers,
'GET /api/v1/license': () => ({
body: { ...validLicense, available_features: ['DDI_CLUSTER'] },
}),
});
renderApp('/clusters');
const dhcpRow = rowOf(await screen.findByText('dhcp-a'));
await userEvent.click(within(dhcpRow).getByRole('button', { name: 'Bearbeiten' }));
const dialog = await screen.findByRole('dialog');
expect(
within(dialog).getByText(/Kea-HA ist in der Lizenz nicht enthalten/),
).toBeInTheDocument();
const add = within(dialog).getByRole('button', { name: 'Mitglied hinzufügen' });
await userEvent.click(add);
expect(add).toBeDisabled();
});
it('explains cluster_in_use on delete', async () => {
mockApi({
...handlers,
+16 -2
View File
@@ -28,6 +28,7 @@ import {
Spinner,
} from '../components/ui';
import { fieldErrors } from '../errors';
import { useFeature } from '../license';
import { nsToText, parseNS } from '../nameServers';
import { pendingKey } from '../pending';
import { SecretDialog } from './TSIGKeysPage';
@@ -80,6 +81,13 @@ function ClusterForm({
const { t } = useTranslation();
const qc = useQueryClient();
const agents = useAgents();
const clusterOk = useFeature('DDI_CLUSTER');
const haOk = useFeature('DDI_KEA_HA');
// An existing HA pair stays editable without the feature (ADR 0027).
const wasPair =
!!cluster?.members.some((m) => m.role === 'primary') &&
cluster.members.some((m) => m.role === 'secondary');
const pairLocked = role === 'dhcp' && !haOk && !wasPair;
const [name, setName] = useState(cluster?.name ?? '');
const [description, setDescription] = useState(cluster?.description ?? '');
const [rows, setRows] = useState<MemberRow[]>(
@@ -153,7 +161,7 @@ function ClusterForm({
...(current && !current.address && agent ? { address: hostOf(agent.remote_address) } : {}),
});
};
const full = role === 'dhcp' && rows.length >= 2;
const full = role === 'dhcp' && (rows.length >= 2 || (pairLocked && rows.length >= 1));
const primaries = rows.filter((r) => r.role === 'primary').length;
const known = [
'name',
@@ -297,12 +305,14 @@ function ClusterForm({
{errors.members}
</div>
)}
{!clusterOk && <Notice kind="warning">{t('clusters.licenseCluster')}</Notice>}
{clusterOk && pairLocked && <Notice>{t('clusters.licenseKeaHA')}</Notice>}
{role === 'dns' && rows.length > 0 && primaries === 0 && (
<Notice kind="warning">{t('clusters.noPrimaryHint')}</Notice>
)}
<button
type="button"
disabled={full}
disabled={full || !clusterOk}
onClick={() => {
setRows((rs) => [
...rs,
@@ -492,6 +502,7 @@ export function ClustersPage() {
const { can } = useSession();
const qc = useQueryClient();
const admin = can('settings.admin');
const clusterOk = useFeature('DDI_CLUSTER');
const q = useClusters();
const [editing, setEditing] = useState<{ role: AgentRole; cluster?: Cluster } | null>(null);
const [deleting, setDeleting] = useState<Cluster | null>(null);
@@ -518,6 +529,7 @@ export function ClustersPage() {
/>
{q.isPending && <Spinner />}
<ErrorBox error={q.error} />
{admin && !clusterOk && <Notice kind="warning">{t('clusters.licenseCluster')}</Notice>}
{q.data &&
roles.map((role) => {
const list = q.data.items.filter((c) => c.role === role);
@@ -530,6 +542,8 @@ export function ClustersPage() {
<button
type="button"
className="small"
disabled={!clusterOk}
title={clusterOk ? undefined : t('clusters.licenseCluster')}
onClick={() => {
setEditing({ role });
}}
+20 -3
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Fragment, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, unwrap } from '../api/client';
@@ -17,7 +17,7 @@ import {
} from '../components/ui';
import { fieldErrors } from '../errors';
import { formatDate, formatDateTime, formatNumber } from '../format';
import { licenseKey, reasonText, useLicense, type LicenseStatus } from '../license';
import { features, licenseKey, reasonText, useLicense, type LicenseStatus } from '../license';
const stateKind: Record<LicenseStatus['state'], string> = {
trial: 'warning',
@@ -204,9 +204,26 @@ export function LicensePage() {
limit={s.limits.max_agents}
/>
</dl>
<h3>{t('license.features')}</h3>
{s.state === 'trial' && <p className="muted">{t('license.featuresTrial')}</p>}
<dl className="kv">
{features.map((f) => (
<Fragment key={f}>
<dt>{t(`license.featureNames.${f}`)}</dt>
<dd>
{s.available_features.includes(f) ? (
<Badge kind="success">{t('license.featureOn')}</Badge>
) : (
<Badge>{t('license.featureOff')}</Badge>
)}{' '}
<span className="mono muted">{f}</span>
</dd>
</Fragment>
))}
</dl>
{s.features.length > 0 && (
<>
<h3>{t('license.features')}</h3>
<h3>{t('license.entitlements')}</h3>
<p>
{s.features.map((f) => (
<Badge key={f}>{f}</Badge>
+6 -1
View File
@@ -4,9 +4,10 @@ import { useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { api, unwrap, type Schemas } from '../api/client';
import { ErrorBox, Field, PageHeader, Spinner } from '../components/ui';
import { ErrorBox, Field, Notice, PageHeader, Spinner } from '../components/ui';
import { fieldErrors } from '../errors';
import { splitList } from '../format';
import { useFeature } from '../license';
type Settings = Schemas['OIDCSettings'];
type Role = Schemas['Role'];
@@ -50,6 +51,8 @@ function OIDCSettingsForm({ initial }: { initial: Settings }) {
const [mapping, setMapping] = useState(mappingToText(initial.role_mapping));
const [secret, setSecret] = useState('');
const [badLine, setBadLine] = useState<string | null>(null);
// Enabled settings stay editable without the feature (ADR 0027).
const ssoLocked = !useFeature('DDI_SSO') && !initial.enabled;
const save = useMutation({
mutationFn: (body: Settings & { client_secret?: string }) =>
@@ -95,10 +98,12 @@ function OIDCSettingsForm({ initial }: { initial: Settings }) {
}
/>
<form id="oidc-form" className="form narrow card" onSubmit={submit}>
{ssoLocked && <Notice kind="warning">{t('oidc.licenseSSO')}</Notice>}
<label className="checkbox">
<input
type="checkbox"
checked={form.enabled}
disabled={ssoLocked && !form.enabled}
onChange={(e) => {
set('enabled', e.target.checked);
}}
+9 -2
View File
@@ -9,6 +9,7 @@ import { ClusterSelect } from '../components/ClusterSelect';
import { ConfirmDialog, ErrorBox, Field, Help, Modal, Notice } from '../components/ui';
import { fieldErrors } from '../errors';
import { splitList } from '../format';
import { useFeature } from '../license';
import { pendingKey } from '../pending';
export type Zone = Schemas['Zone'];
@@ -52,6 +53,8 @@ export function ZoneForm({
const [name, setName] = useState(zone?.name ?? draft?.name ?? '');
const [kind, setKind] = useState<ZoneKind | ''>(zone?.kind ?? draft?.kind ?? '');
const [mode, setMode] = useState<ZoneMode>(zone?.mode ?? 'managed');
// An existing dynamic zone stays dynamic without the feature (ADR 0027).
const dynamicLocked = !useFeature('DDI_DYNAMIC_ZONES') && zone?.mode !== 'dynamic';
const [clusterId, setClusterId] = useState<string | null>(zone?.cluster_id ?? null);
const [ttl, setTtl] = useState(zone?.default_ttl ? String(zone.default_ttl) : '');
const [allowUpdate, setAllowUpdate] = useState(zone?.allow_update.join(', ') ?? '');
@@ -154,7 +157,11 @@ export function ZoneForm({
</select>
)}
</Field>
<Field label={t('zones.mode')} error={fe.mode} hint={t(`zones.modeHelp.${mode}`)}>
<Field
label={t('zones.mode')}
error={fe.mode}
hint={dynamicLocked ? t('zones.licenseDynamic') : t(`zones.modeHelp.${mode}`)}
>
{(id) => (
<select
id={id}
@@ -164,7 +171,7 @@ export function ZoneForm({
}}
>
{modes.map((m) => (
<option key={m} value={m}>
<option key={m} value={m} disabled={m === 'dynamic' && dynamicLocked}>
{t(`zones.modes.${m}`)}
</option>
))}
+2 -1
View File
@@ -110,7 +110,8 @@ export const validLicense = {
state: 'valid',
reason: 'ok',
limits: {},
features: [],
features: ['DDI_CLUSTER', 'DDI_KEA_HA', 'DDI_DYNAMIC_ZONES', 'DDI_SSO'],
available_features: ['DDI_CLUSTER', 'DDI_DYNAMIC_ZONES', 'DDI_KEA_HA', 'DDI_SSO'],
fingerprint: 'fp',
usage: { addresses: 0, agents: 0 },
};