Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb7f4c9f3d | ||
|
|
91b243f05f | ||
|
|
617d6d9d72 | ||
|
|
6329ce2427 |
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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
@@ -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" {
|
||||
|
||||
+2
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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'
|
||||
+19
-2
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
))}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user