feat(license): gate features by entitlement and enforce maxAgents
ci / Go (lint, test, vuln) (push) Successful in 6m43s
ci / Protobuf (push) Successful in 7s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m35s
install / Installation (debian:12) (push) Successful in 3m20s
ci / Web (lint, test, audit, build) (push) Successful in 12m41s
ci / Shell and licence headers (push) Successful in 14s
ci / Integration (real BIND and Kea) (push) Successful in 2m50s
ci / Development environment (compose) (push) Successful in 15s
install / Installation (rocky:9) (push) Successful in 4m1s
install / Installation (ubuntu:24.04) (push) Successful in 3m23s
ci / Release build (dry run) (push) Failing after 10m38s
install / Installation (debian:13) (push) Successful in 3m22s
install / Installation (ubuntu:22.04) (push) Successful in 3m33s

Clusters (DDI_CLUSTER), Kea HA pairs (DDI_KEA_HA), dynamic zones
(DDI_DYNAMIC_ZONES) and SSO (DDI_SSO) now need the matching Keygen
entitlement; the trial grants all of them. Only taking a feature into
use is blocked (403 license_feature): existing clusters, HA pairs,
dynamic zones and enabled SSO keep working and stay editable.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sir_melonchen
2026-10-18 17:51:55 +02:00
co-authored by Claude Opus 5.5
parent 91b243f05f
commit cb7f4c9f3d
31 changed files with 783 additions and 60 deletions
+2 -2
View File
@@ -51,11 +51,11 @@ Fehler sind Problem-Dokumente nach RFC 9457 (`application/problem+json`):
- `problems` listet Feldfehler mit eigenem `code`, optional `field` und `params`.
- `request_id` entspricht dem Header `X-Request-ID` und findet sich in den Server-Logs wieder.
Wichtige Statuscodes: `401` (nicht angemeldet, `mfa_required`), `403` (`forbidden`, `csrf`, `license_read_only`, `password_change_required`), `404`, `409` (Konflikt, z. B. `network_overlap`, `version_conflict`), `422` (Validierung), `429` (zu viele Anmeldeversuche, mit `Retry-After`).
Wichtige Statuscodes: `401` (nicht angemeldet, `mfa_required`), `403` (`forbidden`, `csrf`, `license_read_only`, `license_limit`, `license_feature`, `password_change_required`), `404`, `409` (Konflikt, z. B. `network_overlap`, `version_conflict`), `422` (Validierung), `429` (zu viele Anmeldeversuche, mit `Retry-After`).
## Schreibschutz durch die Lizenz
Ist der Server schreibgeschützt (siehe [LICENSING.md](LICENSING.md)), antworten alle ändernden Endpunkte mit `403 license_read_only`. Lesen, Export, Anmeldung, eigene Filter und die Lizenzverwaltung bleiben erlaubt.
Ist der Server schreibgeschützt (siehe [LICENSING.md](LICENSING.md)), antworten alle ändernden Endpunkte mit `403 license_read_only`. Lesen, Export, Anmeldung, eigene Filter und die Lizenzverwaltung bleiben erlaubt. Überschreitet eine Änderung ein Limit der Lizenz (Adressen, Agents), antwortet der Server mit `403 license_limit` (Parameter `kind`, `limit`); braucht sie eine nicht freigeschaltete Funktion, mit `403 license_feature` (Parameter `feature`, ADR 0027).
## Gleichzeitige Änderungen
+39 -2
View File
@@ -43,16 +43,53 @@ Das Skript legt an (und verwendet beim erneuten Aufruf wieder):
- täglicher Heartbeat; ein Server, der sich einen Tag nicht meldet, wird freigegeben und beim nächsten Kontakt automatisch neu aktiviert
- Laufzeit ein Jahr
- Standardlimits in den Metadaten: `maxAgents: 10`, `maxAddresses: 5000`
- **Entitlements** für die Funktionen (siehe unten): `DDI_CLUSTER`, `DDI_KEA_HA`, `DDI_DYNAMIC_ZONES`, `DDI_SSO`
Die ausgegebene Produkt-ID wird beim Server als `KEYGEN_PRODUCT_ID` eingetragen.
## Limits und Funktionen
| In Keygen | Name/Code | Wirkung |
| --- | --- | --- |
| Lizenz-Metadaten | `maxAddresses` | Höchstzahl der Einträge der Adresstabelle (alle Netze zusammen) |
| Lizenz-Metadaten | `maxAgents` | Höchstzahl registrierter Agents (DNS und DHCP zusammen, gesperrte zählen nicht) |
| Entitlement | `DDI_CLUSTER` | DNS- und DHCP-Cluster |
| Entitlement | `DDI_KEA_HA` | Kea-HA: DHCP-Cluster mit Primary und Secondary (braucht zusätzlich `DDI_CLUSTER`) |
| Entitlement | `DDI_DYNAMIC_ZONES` | dynamische Zonen (Updates per RFC 2136, z. B. Active Directory) |
| Entitlement | `DDI_SSO` | Anmeldung per Single Sign-On (OIDC) |
- Fehlt ein Metadatum, gilt dafür keine Grenze. Die Schreibweise muss genau stimmen.
- In der **Testphase** ist alles freigeschaltet und unbegrenzt.
- Ohne eine Funktion lässt sie sich nicht **neu** in Gebrauch nehmen (kein neuer Cluster, kein neues Mitglied, kein neues HA-Paar, keine neue dynamische Zone, SSO nicht einschaltbar). **Was schon besteht, läuft weiter** und bleibt bearbeitbar – eine Lizenzänderung schaltet weder DNS/DHCP noch den Zugang ab ([ADR 0027](adr/0027-funktionen-per-entitlement-agent-limit.md)).
- Am Agent-Limit lassen sich keine Registrierungs-Tokens mehr erzeugen, und Registrierungen werden abgelehnt. Bestehende Agents laufen weiter. Für eine Neuregistrierung (`enroll --force`) den alten Agent vorher sperren oder löschen.
- Die Seite **Lizenz** zeigt Nutzung, Limits und welche Funktionen freigeschaltet sind; gesperrte Bedienelemente sind in der Oberfläche deaktiviert.
## Lizenz ausstellen
```sh
tools/keygen-setup/keygen-setup.sh create-license --name "Kunde GmbH" --max-agents 20 --max-addresses 20000
tools/keygen-setup/keygen-setup.sh create-license --name "Kunde GmbH" --max-agents 20 --max-addresses 20000 \
--features DDI_CLUSTER,DDI_KEA_HA,DDI_DYNAMIC_ZONES,DDI_SSO # oder --features all
```
Ausgegeben werden Lizenz-ID und **Lizenzschlüssel**. Limits pro Lizenz stehen in deren Metadaten (`maxAgents`, `maxAddresses`); Feature-Freischaltungen erfolgen über Keygen-Entitlements.
Ausgegeben werden Lizenz-ID, **Lizenzschlüssel** und die zugeordneten Funktionen. Limits pro Lizenz stehen in deren Metadaten (`maxAgents`, `maxAddresses`), Funktionen als Entitlements der Lizenz.
Funktionen einer bestehenden Lizenz ändern:
```sh
tools/keygen-setup/keygen-setup.sh features --license <LIZENZ-ID> # anzeigen
tools/keygen-setup/keygen-setup.sh features --license <LIZENZ-ID> --add DDI_SSO --remove DDI_KEA_HA
```
Limits einer bestehenden Lizenz ändern (Keygen ersetzt `metadata` vollständig, also beide Werte angeben):
```sh
curl -sS -X PATCH "https://license.beugsiud.de/v1/accounts/<ACCOUNT-ID>/licenses/<LIZENZ-ID>" \
-H "Authorization: Bearer $KEYGEN_ADMIN_TOKEN" \
-H "Content-Type: application/vnd.api+json" -H "Accept: application/vnd.api+json" \
-d '{"data":{"type":"licenses","attributes":{"metadata":{"maxAgents":20,"maxAddresses":20000}}}}'
```
Der Server übernimmt Änderungen bei der nächsten Prüfung (stündlich) oder sofort mit **Lizenz → Jetzt prüfen**. Offline-Lizenzdateien enthalten die Entitlements zum Zeitpunkt des Exports; nach einer Änderung neu exportieren.
Der Administrator trägt den Schlüssel in der Oberfläche unter **Lizenz → Lizenzschlüssel eintragen** ein. Der Server prüft ihn, aktiviert sich selbst als Maschine (Fingerprint aus der Machine-ID, nicht die Machine-ID selbst) und meldet sich danach regelmäßig.
+9 -1
View File
@@ -6,7 +6,7 @@ Neue Einträge oben im Abschnitt „Verlauf“ ergänzen und den Abschnitt „St
---
## Stand (2026-10-18, nach v0.9.1)
## Stand (2026-10-18, nach ADR 0027)
| Meilenstein | Status | Tag |
| --- | --- | --- |
@@ -21,6 +21,7 @@ Neue Einträge oben im Abschnitt „Verlauf“ ergänzen und den Abschnitt „St
| 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.
@@ -135,6 +136,13 @@ 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.
@@ -0,0 +1,33 @@
# 0027 – Funktionen per Entitlement, Agent-Limit
- **Status:** Akzeptiert
- **Datum:** 2026-10-18
## Kontext
ADR 0016 sieht Feature-Flags aus Keygen-Entitlements und die Limits `maxAgents`/`maxAddresses` aus den Lizenz-Metadaten vor. Umgesetzt war nur `maxAddresses`; die Entitlements wurden geladen und angezeigt, schalteten aber nichts, und `maxAgents` wurde nicht geprüft. Der Nutzer will Cluster, Kea-HA, SSO und dynamische Zonen getrennt lizenzieren und Agents zählen.
## Entscheidung
- **Entitlement-Codes** (Keygen, Groß-/Kleinschreibung wie angegeben):
| Code | Schaltet frei | Ohne den Code gesperrt |
| --- | --- | --- |
| `DDI_CLUSTER` | DNS- und DHCP-Cluster | Cluster anlegen; Agents zu einem Cluster hinzufügen |
| `DDI_KEA_HA` | Kea-HA | einen DHCP-Cluster zum HA-Paar machen (Primary **und** Secondary) |
| `DDI_DYNAMIC_ZONES` | dynamische Zonen (RFC 2136, AD) | Zone dynamisch anlegen; Zone auf dynamisch umstellen |
| `DDI_SSO` | Anmeldung per OIDC | SSO einschalten |
Ein HA-Paar braucht `DDI_CLUSTER` und `DDI_KEA_HA`.
- **Bestand bleibt:** Gesperrt wird nur, was eine Funktion neu in Gebrauch nimmt. Was besteht (aus der Testphase oder einer früheren Lizenz), läuft weiter, bleibt bearbeitbar und löschbar: Cluster lassen sich umbenennen und verkleinern, ein HA-Paar bleibt eins (auch mit getauschtem Partner, wenn `DDI_CLUSTER` da ist), eine dynamische Zone bleibt dynamisch und editierbar, eingeschaltetes SSO bleibt eingeschaltet und änderbar. Der Generator berücksichtigt Lizenzen nicht. Grund: wie in ADR 0016 darf eine Lizenzänderung DNS, DHCP und den Zugang nicht abschalten.
- **Testphase:** schaltet alle Funktionen frei (wie bisher ohne Limits). **Karenzzeit:** es gelten die Entitlements der zuletzt gültigen Lizenz. Ohne Lizenz-Manager (Tests, Werkzeuge) ist alles erlaubt.
- **Agents:** `maxAgents` zählt alle nicht gesperrten Agents (`revoked_at IS NULL`). Geprüft beim Erzeugen eines Registrierungs-Tokens und – maßgeblich – bei der Registrierung selbst, dort unter einer Advisory-Sperre, damit parallele Registrierungen das Limit nicht gemeinsam überschreiten. Bestehende Agents über dem Limit (Lizenz verkleinert) laufen weiter. Eine Neuregistrierung (`enroll --force`) legt einen neuen Agent an; am Limit muss der alte vorher gesperrt oder gelöscht werden.
- **API:** Fehler `403 license_feature` mit Parameter `feature` (Code), `403 license_limit` mit `kind=agents`. `GET /license` liefert zusätzlich `available_features` (die freigeschalteten bekannten Codes, in der Testphase alle) und zählt `usage.agents`.
- **Oberfläche:** Die Lizenzseite zeigt die vier Funktionen mit Status. Gesperrte Bedienelemente sind deaktiviert und erklärt (Cluster anlegen und Mitglied hinzufügen, zweites DHCP-Mitglied, Modus „dynamisch“, SSO einschalten); der Server prüft unabhängig davon.
- **Keygen:** `tools/keygen-setup/keygen-setup.sh setup` legt die vier Entitlements an; `create-license --features …` und `features --license … --add/--remove …` ordnen sie Lizenzen zu.
## Konsequenzen
- Wer nach der Testphase eine Lizenz ohne eine Funktion einspielt, behält das in der Testphase Eingerichtete. Das ist bewusst großzügig (Soft-Control, siehe ADR 0016).
- Bestehende Lizenzen ohne Entitlements verlieren das Anlegen von Clustern, HA-Paaren, dynamischen Zonen und das Einschalten von SSO; die Entitlements müssen ihnen zugeordnet werden.
- Netze sind weiterhin nicht begrenzt; das Adresslimit bleibt `maxAddresses`.
+1
View File
@@ -30,3 +30,4 @@ Jede Grundsatzentscheidung wird hier als kurze ADR festgehalten. Format siehe [0
| [0024](0024-auslieferung-installer-m6.md) | Auslieferung: Installer, systemd, Pakete, Release-Signatur (M6) | Akzeptiert |
| [0025](0025-dhcp-pro-adresse-pruefungen-ui.md) | DHCP pro Adresse, DNS für dynamische Einträge, Bereichs- und Massenänderungen, Prüfungen, Oberfläche | Akzeptiert |
| [0026](0026-eintraege-in-dynamischen-zonen.md) | Einträge der Adresstabelle in dynamischen Zonen (RFC-2136-Updates durch den Agent) | Akzeptiert |
| [0027](0027-funktionen-per-entitlement-agent-limit.md) | Funktionen per Entitlement (Cluster, Kea-HA, dynamische Zonen, SSO), Agent-Limit | Akzeptiert |
+20 -3
View File
@@ -443,6 +443,7 @@ paths:
put:
tags: [settings]
operationId: setOIDCSettings
description: Enabling SSO needs the license feature DDI_SSO (403 license_feature, ADR 0027); settings that are already enabled stay editable.
requestBody:
required: true
content:
@@ -638,6 +639,7 @@ paths:
post:
tags: [agents]
operationId: createEnrollmentToken
description: Fails with 403 license_limit (kind agents) when the license allows no further agent (maxAgents, revoked agents do not count; ADR 0027). Enrollment checks the limit again.
requestBody:
required: true
content:
@@ -1319,6 +1321,8 @@ paths:
the other role), agent_in_other_cluster, cluster_primary_exists
(second primary), cluster_member_limit (DHCP: more than one primary
and one secondary), invalid_ip (address).
License (ADR 0027): 403 license_feature with feature DDI_CLUSTER, or
DDI_KEA_HA for a DHCP cluster with primary and secondary.
requestBody:
required: true
content:
@@ -1348,7 +1352,11 @@ paths:
put:
tags: [clusters]
operationId: updateCluster
description: Replaces all mutable fields including the member list. The role is immutable and ignored. `version` must match.
description: |
Replaces all mutable fields including the member list. The role is
immutable and ignored. `version` must match. License (ADR 0027): adding
an agent needs DDI_CLUSTER, turning a DHCP cluster into an HA pair
DDI_KEA_HA (403 license_feature); removing members always works.
requestBody:
required: true
content:
@@ -1486,6 +1494,8 @@ paths:
invalid_domain, zone_kind_mismatch (reverse zones must end in
in-addr.arpa or ip6.arpa, forward zones must not), cluster_role_mismatch
(cluster is not a DNS cluster), not_found (cluster or key).
A dynamic zone needs the license feature DDI_DYNAMIC_ZONES (403
license_feature, ADR 0027).
requestBody:
required: true
content:
@@ -1532,7 +1542,10 @@ paths:
put:
tags: [zones]
operationId: updateZone
description: Replaces all mutable fields. Name and kind are immutable and ignored. `version` must match.
description: |
Replaces all mutable fields. Name and kind are immutable and ignored.
`version` must match. Switching a zone to dynamic needs DDI_DYNAMIC_ZONES
(403 license_feature, ADR 0027); an existing dynamic zone stays editable.
requestBody:
required: true
content:
@@ -1799,7 +1812,7 @@ components:
LicenseStatus:
type: object
additionalProperties: false
required: [state, reason, limits, features, fingerprint, usage]
required: [state, reason, limits, features, available_features, fingerprint, usage]
properties:
state: { $ref: "#/components/schemas/LicenseState" }
reason: { type: string, description: "Translated by the UI (ok, trial, trial_expired, unreachable, expired, suspended, …)." }
@@ -1819,6 +1832,10 @@ components:
max_agents: { type: integer }
max_addresses: { type: integer }
features: { type: array, items: { type: string } }
available_features:
type: array
items: { type: string, enum: [DDI_CLUSTER, DDI_DYNAMIC_ZONES, DDI_KEA_HA, DDI_SSO] }
description: "Features the server grants now (ADR 0027): all during the trial, otherwise those among the license entitlements. Without one, creating clusters, Kea HA pairs, dynamic zones or enabling SSO returns 403 license_feature (param feature); what exists keeps working."
fingerprint: { type: string }
usage:
type: object
+29 -18
View File
@@ -156,28 +156,38 @@ func (s *streamChecker) Unwrap() http.ResponseWriter { return s.ResponseWriter }
// env is a running API backed by a fresh database.
type env struct {
t *testing.T
srv *httptest.Server
svc *service.Service
st *store.Store
lic *license.Manager
now *time.Time
pki *pki.Authority
hub *agenthub.Hub
bus *events.Bus
t *testing.T
srv *httptest.Server
svc *service.Service
st *store.Store
lic *license.Manager
prov *stubProvider
now *time.Time
pki *pki.Authority
hub *agenthub.Hub
bus *events.Bus
}
type stubProvider struct{}
// stubProvider rejects every key unless a test sets valid (then it grants
// features and limits).
type stubProvider struct {
valid bool
features []string
limits license.Limits
}
func (stubProvider) Validate(context.Context, string, string) (*license.Validation, error) {
func (p *stubProvider) Validate(context.Context, string, string) (*license.Validation, error) {
if p.valid {
return &license.Validation{Valid: true, Code: "VALID", LicenseID: "lic-1", Name: "Test", Limits: p.limits}, nil
}
return &license.Validation{Code: "NOT_FOUND"}, nil
}
func (stubProvider) Activate(context.Context, string, string, string, string) error { return nil }
func (stubProvider) Entitlements(context.Context, string, string) ([]string, error) {
return nil, nil
func (p *stubProvider) Activate(context.Context, string, string, string, string) error { return nil }
func (p *stubProvider) Entitlements(context.Context, string, string) ([]string, error) {
return p.features, nil
}
func (stubProvider) Heartbeat(context.Context, string, string) error { return nil }
func (stubProvider) Deactivate(context.Context, string, string) error { return nil }
func (p *stubProvider) Heartbeat(context.Context, string, string) error { return nil }
func (p *stubProvider) Deactivate(context.Context, string, string) error { return nil }
type storePersistence struct{ st *store.Store }
@@ -220,7 +230,8 @@ func newEnvFull(t *testing.T, adjust func(*Options), adjustServer func(*http.Ser
now := time.Now().UTC()
clock := func() time.Time { return now }
log := slog.New(slog.DiscardHandler)
lic := license.NewManager(license.ManagerOptions{Provider: stubProvider{}, Persistence: storePersistence{st},
prov := &stubProvider{}
lic := license.NewManager(license.ManagerOptions{Provider: prov, Persistence: storePersistence{st},
Keyring: kr, Fingerprint: "fp", Grace: 14 * 24 * time.Hour, Now: clock, Logger: log})
if err := lic.Load(context.Background()); err != nil {
t.Fatal(err)
@@ -253,7 +264,7 @@ func newEnvFull(t *testing.T, adjust func(*Options), adjustServer func(*http.Ser
t.Cleanup(srv.Close)
// Runs first: open event streams end, so that Close does not wait.
t.Cleanup(bus.Close)
return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, now: &now, pki: authority, hub: hub, bus: bus}
return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, prov: prov, now: &now, pki: authority, hub: hub, bus: bus}
}
// advance moves the shared clock (sessions, license).
@@ -0,0 +1,160 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package httpapi
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"errors"
"slices"
"testing"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
// setLicense activates a license with the given features and agent limit
// (nil: unlimited).
func (e *env) setLicense(maxAgents *int, features ...string) {
e.t.Helper()
e.prov.valid, e.prov.features, e.prov.limits = true, features, license.Limits{MaxAgents: maxAgents}
if err := e.lic.SetKey(context.Background(), "TEST-KEY-123456"); err != nil {
e.t.Fatal(err)
}
}
// wantFeature expects a 403 license_feature naming feature.
func wantFeature(t *testing.T, r *response, feature string) {
t.Helper()
if p := r.problem(t); p.Code != "license_feature" || p.Params["feature"] != feature {
t.Errorf("want license_feature %s, got %+v", feature, p)
}
}
func TestLicenseFeatures(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
member := func(id, role, addr string) map[string]any {
return map[string]any{"agent_id": id, "role": role, "address": addr}
}
dns1 := e.enroll(admin.createToken("dns").Secret, "dns")
dns2 := e.enroll(admin.createToken("dns").Secret, "dns")
dhcp1 := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
dhcp2 := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
// The trial grants every feature.
var st service.LicenseStatus
admin.mustStatus(200, "GET", "/api/v1/license", nil).json(t, &st)
if st.State != license.StateTrial || len(st.AvailableFeatures) != len(license.KnownFeatures) || st.Usage.Agents != 4 {
t.Fatalf("trial %+v", st)
}
// Created during the trial, kept afterwards.
var dyn service.ZoneDTO
admin.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "ad.example.test", "mode": "dynamic",
"allow_update": []string{"192.0.2.10/32"}}).json(t, &dyn)
var dnsC service.ClusterDTO
admin.mustStatus(201, "POST", "/api/v1/clusters", map[string]any{"name": "DNS", "role": "dns",
"members": []any{member(dns1, "primary", "192.0.2.53")}}).json(t, &dnsC)
// A license without features.
e.setLicense(nil)
admin.mustStatus(200, "GET", "/api/v1/license", nil).json(t, &st)
if st.State != license.StateValid || len(st.AvailableFeatures) != 0 {
t.Fatalf("licensed %+v", st)
}
// Dynamic zones: no new ones, the existing one stays editable.
wantFeature(t, admin.mustStatus(403, "POST", "/api/v1/zones", map[string]any{"name": "dyn.example.test",
"mode": "dynamic", "allow_update": []string{"192.0.2.10/32"}}), "DDI_DYNAMIC_ZONES")
var managed service.ZoneDTO
admin.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "office.example.test"}).json(t, &managed)
wantFeature(t, admin.mustStatus(403, "PUT", "/api/v1/zones/"+managed.ID, map[string]any{"mode": "dynamic",
"allow_update": []string{"192.0.2.10/32"}, "version": managed.Version}), "DDI_DYNAMIC_ZONES")
admin.mustStatus(200, "PUT", "/api/v1/zones/"+dyn.ID, map[string]any{"mode": "dynamic",
"allow_update": []string{"192.0.2.10/32", "192.0.2.11/32"}, "description": "AD", "version": dyn.Version})
// Clusters: no new cluster, no new member; removing and renaming work.
wantFeature(t, admin.mustStatus(403, "POST", "/api/v1/clusters", map[string]any{"name": "DNS 2", "role": "dns"}),
"DDI_CLUSTER")
wantFeature(t, admin.mustStatus(403, "PUT", "/api/v1/clusters/"+dnsC.ID, map[string]any{"name": "DNS",
"members": []any{member(dns1, "primary", "192.0.2.53"), member(dns2, "secondary", "192.0.2.54")},
"version": dnsC.Version}), "DDI_CLUSTER")
admin.mustStatus(200, "PUT", "/api/v1/clusters/"+dnsC.ID, map[string]any{"name": "DNS office",
"members": []any{member(dns1, "primary", "192.0.2.53")}, "version": dnsC.Version}).json(t, &dnsC)
// SSO: enabling needs the feature.
oidc := map[string]any{"enabled": true, "issuer": "https://idp.example.test", "client_id": "ddi",
"client_secret": "secret", "display_name": "IdP", "scopes": []string{}, "username_claim": "preferred_username",
"role_claim": "", "role_mapping": map[string]string{}, "default_role": "viewer"}
wantFeature(t, admin.mustStatus(403, "PUT", "/api/v1/settings/oidc", oidc), "DDI_SSO")
oidc["enabled"] = false
admin.mustStatus(200, "PUT", "/api/v1/settings/oidc", oidc)
// Clusters without Kea HA: a DHCP cluster may not become an HA pair.
e.setLicense(nil, "DDI_CLUSTER", "DDI_SSO")
wantFeature(t, admin.mustStatus(403, "POST", "/api/v1/clusters", map[string]any{"name": "DHCP", "role": "dhcp",
"members": []any{member(dhcp1, "primary", "192.0.2.67"), member(dhcp2, "secondary", "192.0.2.68")}}),
"DDI_KEA_HA")
var dhcpC service.ClusterDTO
admin.mustStatus(201, "POST", "/api/v1/clusters", map[string]any{"name": "DHCP", "role": "dhcp",
"members": []any{member(dhcp1, "primary", "192.0.2.67")}}).json(t, &dhcpC)
wantFeature(t, admin.mustStatus(403, "PUT", "/api/v1/clusters/"+dhcpC.ID, map[string]any{"name": "DHCP",
"members": []any{member(dhcp1, "primary", "192.0.2.67"), member(dhcp2, "secondary", "192.0.2.68")},
"version": dhcpC.Version}), "DDI_KEA_HA")
oidc["enabled"] = true
admin.mustStatus(200, "PUT", "/api/v1/settings/oidc", oidc)
e.setLicense(nil, "DDI_CLUSTER", "DDI_KEA_HA")
admin.mustStatus(200, "PUT", "/api/v1/clusters/"+dhcpC.ID, map[string]any{"name": "DHCP",
"members": []any{member(dhcp1, "primary", "192.0.2.67"), member(dhcp2, "secondary", "192.0.2.68")},
"version": dhcpC.Version})
admin.mustStatus(200, "GET", "/api/v1/license", nil).json(t, &st)
if !slices.Equal(st.AvailableFeatures, []string{"DDI_CLUSTER", "DDI_KEA_HA"}) {
t.Errorf("available %v", st.AvailableFeatures)
}
}
func TestLicenseAgentLimit(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
e.enroll(admin.createToken("dns").Secret, "dns")
pending := admin.createToken("dhcp")
two := 2
e.setLicense(&two)
e.enroll(admin.createToken("dns").Secret, "dns")
// At the limit: no new token, and an older token cannot enroll.
r := admin.mustStatus(403, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": "dns"})
if p := r.problem(t); p.Code != "license_limit" || p.Params["kind"] != "agents" || p.Params["limit"] != "2" {
t.Errorf("token %+v", p)
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
if err != nil {
t.Fatal(err)
}
_, err = e.svc.EnrollAgent(context.Background(), service.EnrollInput{Token: pending.Secret, CSR: csr, Role: "dhcp",
Hostname: "dhcp", AgentVersion: "1.0.0"})
var se *service.Error
if !errors.As(err, &se) || se.Code != "license_limit" {
t.Fatalf("enroll at the limit: %v", err)
}
// Revoked agents do not count.
var agents agentList
admin.mustStatus(200, "GET", "/api/v1/agents", nil).json(t, &agents)
admin.mustStatus(200, "POST", "/api/v1/agents/"+agents.Items[0].ID+"/revoke", nil)
e.enroll(pending.Secret, "dhcp")
var st service.LicenseStatus
admin.mustStatus(200, "GET", "/api/v1/license", nil).json(t, &st)
if st.Usage.Agents != 2 || st.Limits.MaxAgents == nil || *st.Limits.MaxAgents != 2 {
t.Errorf("usage %+v limits %+v", st.Usage, st.Limits)
}
}
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package license
import "slices"
// Feature is the code of a Keygen entitlement that unlocks a function of
// the server (ADR 0027).
type Feature string
// Features.
const (
FeatureCluster Feature = "DDI_CLUSTER"
FeatureKeaHA Feature = "DDI_KEA_HA"
FeatureSSO Feature = "DDI_SSO"
FeatureDynamicZones Feature = "DDI_DYNAMIC_ZONES"
)
// KnownFeatures lists every feature the server checks, sorted.
var KnownFeatures = []Feature{FeatureCluster, FeatureDynamicZones, FeatureKeaHA, FeatureSSO}
// availableFeatures returns the known features the evaluation grants: all
// during the trial, otherwise those among the license's entitlements.
func availableFeatures(e Evaluation, c *Cache) []string {
out := []string{}
for _, f := range KnownFeatures {
if e.State == StateTrial || (c != nil && slices.Contains(c.Features, string(f))) {
out = append(out, string(f))
}
}
return out
}
// HasFeature reports whether the current license grants f. The trial grants
// every feature; a license grants the features named by its entitlements,
// also during the grace period.
func (m *Manager) HasFeature(f Feature) bool {
if !m.loaded.Load() {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
return slices.Contains(availableFeatures(m.evaluateLocked(), m.cache), string(f))
}
+29
View File
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package license
import (
"slices"
"testing"
)
func TestAvailableFeatures(t *testing.T) {
c := &Cache{Features: []string{"DDI_SSO", "OTHER", "ddi_cluster"}}
cases := []struct {
name string
state State
cache *Cache
want []string
}{
{"trial grants all", StateTrial, nil, []string{"DDI_CLUSTER", "DDI_DYNAMIC_ZONES", "DDI_KEA_HA", "DDI_SSO"}},
{"valid: known entitlements only, exact case", StateValid, c, []string{"DDI_SSO"}},
{"grace keeps them", StateGrace, c, []string{"DDI_SSO"}},
{"no license", StateReadOnly, nil, []string{}},
}
for _, tc := range cases {
got := availableFeatures(Evaluation{State: tc.state}, tc.cache)
if !slices.Equal(got, tc.want) {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
}
+5 -2
View File
@@ -182,7 +182,10 @@ type Status struct {
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
Limits Limits `json:"limits"`
Features []string `json:"features"`
Fingerprint string `json:"fingerprint"`
// AvailableFeatures are the known features the server grants now
// (ADR 0027): all during the trial.
AvailableFeatures []string `json:"available_features"`
Fingerprint string `json:"fingerprint"`
}
// Status returns the current status (re-evaluated against the clock).
@@ -191,7 +194,7 @@ func (m *Manager) Status() Status {
defer m.mu.Unlock()
e := m.evaluateLocked()
s := Status{State: e.State, Reason: e.Reason, TrialEndsAt: e.TrialEndsAt, GraceEndsAt: e.GraceEndsAt,
Features: []string{}, Fingerprint: m.opts.Fingerprint}
Features: []string{}, AvailableFeatures: availableFeatures(e, m.cache), Fingerprint: m.opts.Fingerprint}
if c := m.cache; c != nil {
s.Source = c.Source
s.KeyHint = keyHint(c.Key)
+3
View File
@@ -102,6 +102,9 @@ func (s *Service) EnrollAgent(ctx context.Context, in EnrollInput) (*EnrollResul
if tk.Role != in.Role {
return ErrTokenRoleMismatch
}
if err := s.checkAgentLimit(ctx, tx, 1); err != nil {
return err
}
agentID := agenthub.NewID()
cert, err := s.opts.PKI.SignAgentCSR(in.CSR, agentID)
if errors.Is(err, pki.ErrInvalidCSR) {
+5
View File
@@ -443,6 +443,11 @@ func (s *Service) CreateEnrollmentToken(ctx context.Context, a *Actor, in TokenI
tk.CreatedBy = &uid
}
err := s.inTx(ctx, func(tx *store.Tx) error {
// A token for an agent the license does not allow would only fail
// at enrollment; say so now.
if err := s.checkAgentLimit(ctx, tx, 1); err != nil {
return err
}
if err := tx.InsertEnrollmentToken(ctx, tk); err != nil {
return err
}
+43
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"errors"
"net/netip"
"slices"
"strconv"
"strings"
"time"
@@ -16,6 +17,7 @@ import (
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
@@ -259,6 +261,37 @@ func (s *Service) checkMembers(ctx context.Context, tx *store.Tx, c *store.Clust
return out, nil
}
// checkClusterFeatures checks the license for a cluster whose members change
// from old (nil for a new cluster) to members (ADR 0027): without
// DDI_CLUSTER no agent may join, and a DHCP cluster may only become an HA
// pair (primary and secondary) with DDI_KEA_HA. Removing members and
// changing other fields of an existing cluster stay possible.
func (s *Service) checkClusterFeatures(role string, old, members []store.ClusterMember) error {
for _, m := range members {
if !slices.ContainsFunc(old, func(o store.ClusterMember) bool { return o.AgentID == m.AgentID }) {
if err := s.requireFeature(license.FeatureCluster); err != nil {
return err
}
break
}
}
if role == RoleDHCP && haPair(members) && !haPair(old) {
return s.requireFeature(license.FeatureKeaHA)
}
return nil
}
// haPair reports whether a DHCP cluster has a primary and a secondary, which
// makes the generator configure Kea HA.
func haPair(members []store.ClusterMember) bool {
var p, sec bool
for _, m := range members {
p = p || m.MemberRole == string(configgen.MemberPrimary)
sec = sec || m.MemberRole == string(configgen.MemberSecondary)
}
return p && sec
}
// ListClusters returns the clusters of a role ("" for all), sorted by role
// and name.
func (s *Service) ListClusters(ctx context.Context, role string) ([]ClusterDTO, error) {
@@ -297,6 +330,9 @@ func (s *Service) CreateCluster(ctx context.Context, a *Actor, in ClusterInput)
if !validRole(in.Role) {
return ClusterDTO{}, invalidField("invalid_role", "role")
}
if err := s.requireFeature(license.FeatureCluster); err != nil {
return ClusterDTO{}, err
}
c := &store.Cluster{ID: agenthub.NewID(), Role: in.Role}
if problems := normalizeCluster(&in, in.Role, c); len(problems) > 0 {
return ClusterDTO{}, invalid(problems...)
@@ -321,6 +357,9 @@ func (s *Service) CreateCluster(ctx context.Context, a *Actor, in ClusterInput)
if err != nil {
return err
}
if err := s.checkClusterFeatures(c.Role, nil, members); err != nil {
return err
}
if err := tx.InsertCluster(ctx, c); err != nil {
return err
}
@@ -392,6 +431,7 @@ func (s *Service) UpdateCluster(ctx context.Context, a *Actor, id string, in Clu
return ErrVersionConflict
}
before = s.clusterAudit(c)
oldMembers := c.Members
if problems := normalizeCluster(&in, c.Role, c); len(problems) > 0 {
return invalid(problems...)
}
@@ -399,6 +439,9 @@ func (s *Service) UpdateCluster(ctx context.Context, a *Actor, id string, in Clu
if err != nil {
return err
}
if err := s.checkClusterFeatures(c.Role, oldMembers, members); err != nil {
return err
}
if err := tx.UpdateCluster(ctx, c); err != nil {
return err
}
+1
View File
@@ -74,6 +74,7 @@ var (
ErrSelfDelete = newError(KindConflict, "self_delete", "you cannot delete your own account")
ErrNetworkNotEmpty = newError(KindConflict, "network_not_empty", "the network still has addresses")
ErrLicenseLimit = newError(KindForbidden, "license_limit", "the license limit is reached")
ErrLicenseFeature = newError(KindForbidden, "license_feature", "the license does not include this feature")
ErrPasswordChange = newError(KindForbidden, "password_change_required", "the password must be changed first")
ErrAlreadyInitialized = newError(KindConflict, "already_initialized", "users already exist")
ErrMapTooLarge = newError(KindInvalid, "map_too_large", "the address map is only available for IPv4 networks up to /22")
+47 -3
View File
@@ -24,12 +24,56 @@ type LicenseUsage struct {
// LicenseStatus returns the current license status with usage.
func (s *Service) LicenseStatus(ctx context.Context) (LicenseStatus, error) {
n, err := s.st.Q().CountAddresses(ctx)
q := s.st.Q()
n, err := q.CountAddresses(ctx)
if err != nil {
return LicenseStatus{}, translate(err)
}
// Agents are counted from M3 on.
return LicenseStatus{Status: s.opts.License.Status(), Usage: LicenseUsage{Addresses: n}}, nil
agents, err := q.CountActiveAgents(ctx)
if err != nil {
return LicenseStatus{}, translate(err)
}
return LicenseStatus{Status: s.opts.License.Status(), Usage: LicenseUsage{Addresses: n, Agents: agents}}, nil
}
// hasFeature reports whether the license grants f (ADR 0027). Without a
// license manager (tests, tools) everything is allowed.
func (s *Service) hasFeature(f license.Feature) bool {
return s.opts.License == nil || s.opts.License.HasFeature(f)
}
// requireFeature fails with license_feature if the license does not grant
// f. Callers check only what newly uses a feature: what exists keeps working
// and stays editable (ADR 0027).
func (s *Service) requireFeature(f license.Feature) error {
if s.hasFeature(f) {
return nil
}
e := *ErrLicenseFeature
e.Params = map[string]string{"feature": string(f)}
return &e
}
// checkAgentLimit fails with license_limit if adding agents would exceed
// maxAgents. Revoked agents do not count.
func (s *Service) checkAgentLimit(ctx context.Context, tx *store.Tx, adding int) error {
if s.opts.License == nil {
return nil
}
max := s.opts.License.Limits().MaxAgents
if max == nil {
return nil
}
n, err := tx.CountActiveAgents(ctx)
if err != nil {
return err
}
if n+adding > *max {
e := *ErrLicenseLimit
e.Params = map[string]string{"limit": itoa(*max), "kind": "agents"}
return &e
}
return nil
}
// licenseError maps license errors to API errors.
+9 -1
View File
@@ -12,6 +12,7 @@ import (
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/auth"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
@@ -126,10 +127,17 @@ func (s *Service) SetOIDCSettings(ctx context.Context, a *Actor, cfg OIDCSetting
return OIDCSettings{}, invalid(problems...)
}
_, oldSecret, err := s.loadOIDC(ctx)
old, oldSecret, err := s.loadOIDC(ctx)
if err != nil {
return OIDCSettings{}, err
}
// Enabling SSO needs the feature; settings that are already enabled
// stay editable (ADR 0027).
if cfg.Enabled && !old.Enabled {
if err := s.requireFeature(license.FeatureSSO); err != nil {
return OIDCSettings{}, err
}
}
newSecret := oldSecret
if secret != nil {
newSecret = *secret
+13
View File
@@ -13,6 +13,7 @@ import (
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen/derive"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
@@ -240,6 +241,11 @@ func (s *Service) CreateZone(ctx context.Context, a *Actor, in ZoneInput) (ZoneD
if err != nil {
return ZoneDTO{}, err
}
if in.Mode == string(configgen.ZoneDynamic) {
if err := s.requireFeature(license.FeatureDynamicZones); err != nil {
return ZoneDTO{}, err
}
}
z := &store.Zone{Name: name, Kind: kind}
var out ZoneDTO
err = s.inTx(ctx, func(tx *store.Tx) error {
@@ -273,6 +279,13 @@ func (s *Service) UpdateZone(ctx context.Context, a *Actor, id string, in ZoneIn
return ErrVersionConflict
}
before := zoneDTO(z)
// An existing dynamic zone stays editable without the feature; only
// switching a zone to dynamic needs it (ADR 0027).
if in.Mode == string(configgen.ZoneDynamic) && z.Mode != string(configgen.ZoneDynamic) {
if err := s.requireFeature(license.FeatureDynamicZones); err != nil {
return err
}
}
if err := s.applyZone(ctx, tx, &in, z); err != nil {
return err
}
+18
View File
@@ -114,6 +114,24 @@ func (t *Tx) GetAgent(ctx context.Context, id string, forUpdate bool) (*Agent, e
return scanAgent(t.q.QueryRow(ctx, sql, id))
}
// agentLimitLockKey serializes enrollments against the agent limit
// (pg_advisory_xact_lock).
const agentLimitLockKey = 0x64646902
// CountActiveAgents returns the number of agents that are not revoked
// (license limit, ADR 0027). Within a transaction it first takes a lock so
// that concurrent enrollments cannot both pass the limit.
func (t *Tx) CountActiveAgents(ctx context.Context) (int, error) {
if t.hooks != nil {
if _, err := t.q.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, int64(agentLimitLockKey)); err != nil {
return 0, mapErr(err)
}
}
var n int
err := t.q.QueryRow(ctx, `SELECT count(*) FROM agents WHERE revoked_at IS NULL`).Scan(&n)
return n, mapErr(err)
}
// ListAgents returns all agents ordered by role and hostname.
func (t *Tx) ListAgents(ctx context.Context) ([]*Agent, error) {
rows, err := t.q.Query(ctx, `SELECT `+agentColumns+` FROM agents ORDER BY role, lower(hostname), id`)
+98 -6
View File
@@ -6,10 +6,16 @@
#
# Usage:
# keygen-setup.sh setup
# create (or reuse) the product and the standard policy; prints the
# product ID for KEYGEN_PRODUCT_ID
# create (or reuse) the product, the standard policy and the
# entitlements of the features (ADR 0027); prints the product ID for
# KEYGEN_PRODUCT_ID
# keygen-setup.sh create-license --name NAME [--max-agents N] [--max-addresses N] [--expiry DATE]
# issue a license under the standard policy; prints the license key
# [--features LIST]
# issue a license under the standard policy; prints the license key.
# LIST is comma-separated: DDI_CLUSTER,DDI_KEA_HA,DDI_DYNAMIC_ZONES,DDI_SSO
# (or "all")
# keygen-setup.sh features --license ID [--add LIST] [--remove LIST]
# show or change the features of a license
# keygen-setup.sh checkout --license ID [--ttl SECONDS] [--encrypt] [--out FILE]
# write an offline license file (for isolated networks)
# keygen-setup.sh list
@@ -27,6 +33,13 @@ set -euo pipefail
readonly PRODUCT_NAME="ddi-management-server"
readonly PRODUCT_CODE="ddi-management-server"
readonly POLICY_NAME="ddi-server-standard"
# Entitlement codes the server checks (ADR 0027) and their names in Keygen.
readonly -A FEATURES=(
[DDI_CLUSTER]="DDI clusters"
[DDI_KEA_HA]="DDI Kea HA"
[DDI_DYNAMIC_ZONES]="DDI dynamic zones"
[DDI_SSO]="DDI single sign-on"
)
KEYGEN_URL="${KEYGEN_URL:-https://license.beugsiud.de}"
KEYGEN_ACCOUNT_ID="${KEYGEN_ACCOUNT_ID:-9de54794-8e94-4250-83f6-87e3d5686a0b}"
@@ -37,7 +50,7 @@ die() {
}
usage() {
sed -n '4,25p' "$0" | sed 's/^# \{0,1\}//'
sed -n '4,/^# Requires/p' "$0" | sed 's/^# \{0,1\}//'
}
for tool in curl jq; do
@@ -65,6 +78,46 @@ find_policy() {
api GET "/policies?product=${product}&limit=100" | jq -r --arg name "$POLICY_NAME" '.data[] | select(.attributes.name == $name) | .id' | head -n1
}
# entitlement_id prints the ID of the entitlement with code $1 (empty if none).
entitlement_id() {
api GET "/entitlements?limit=100" | jq -r --arg code "$1" '.data[] | select(.attributes.code == $code) | .id' | head -n1
}
# feature_list checks a comma-separated feature list ("all" for every
# feature) and prints one code per line.
feature_list() {
local list="$1" code
if [[ "$list" == "all" ]]; then
printf '%s\n' "${!FEATURES[@]}" | sort
return
fi
IFS=',' read -r -a codes <<<"$list"
for code in "${codes[@]}"; do
code="${code// /}"
[[ -z "$code" ]] && continue
[[ -n "${FEATURES[$code]:-}" ]] || die "unknown feature ${code} (known: ${!FEATURES[*]})"
printf '%s\n' "$code"
done
}
# license_entitlements changes the entitlements of license $1: method POST
# attaches, DELETE detaches the features in list $3.
license_entitlements() {
local license="$1" method="$2" list="$3" code id ids=()
while read -r code; do
id="$(entitlement_id "$code")"
[[ -n "$id" ]] || die "entitlement ${code} missing: run setup first"
ids+=("$id")
done < <(feature_list "$list")
[[ ${#ids[@]} -gt 0 ]] || return 0
api "$method" "/licenses/${license}/entitlements" \
"$(printf '%s\n' "${ids[@]}" | jq -R . | jq -s '{ data: map({ type: "entitlements", id: . }) }')" >/dev/null
}
show_features() {
api GET "/licenses/$1/entitlements?limit=100" | jq -r '"features: " + ([.data[].attributes.code] | sort | join(", ") | if . == "" then "none" else . end)'
}
cmd_setup() {
local product policy
product="$(find_product)"
@@ -108,24 +161,39 @@ cmd_setup() {
else
echo "policy exists: ${policy}"
fi
local code id
for code in $(printf '%s\n' "${!FEATURES[@]}" | sort); do
id="$(entitlement_id "$code")"
if [[ -z "$id" ]]; then
api POST /entitlements "$(jq -n --arg name "${FEATURES[$code]}" --arg code "$code" '{
data: { type: "entitlements", attributes: { name: $name, code: $code } }
}')" >/dev/null
echo "created entitlement ${code}"
else
echo "entitlement exists: ${code}"
fi
done
echo
echo "Configure the management server with:"
echo " KEYGEN_PRODUCT_ID=${product}"
}
cmd_create_license() {
local name="" max_agents="10" max_addresses="5000" expiry=""
local name="" max_agents="10" max_addresses="5000" expiry="" features=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--max-agents) max_agents="$2"; shift 2 ;;
--max-addresses) max_addresses="$2"; shift 2 ;;
--expiry) expiry="$2"; shift 2 ;;
--features) features="$2"; shift 2 ;;
*) die "unknown option $1" ;;
esac
done
[[ -n "$name" ]] || die "--name is required"
[[ "$max_agents" =~ ^[0-9]+$ && "$max_addresses" =~ ^[0-9]+$ ]] || die "limits must be numbers"
feature_list "$features" >/dev/null
local product policy
product="$(find_product)"
[[ -n "$product" ]] || die "run setup first"
@@ -141,7 +209,30 @@ cmd_create_license() {
relationships: { policy: { data: { type: "policies", id: $policy } } }
}
}')"
api POST /licenses "$body" | jq -r '"license id: \(.data.id)\nlicense key: \(.data.attributes.key)\nexpires: \(.data.attributes.expiry // "never")"'
local created id
created="$(api POST /licenses "$body")"
id="$(jq -r '.data.id' <<<"$created")"
license_entitlements "$id" POST "$features"
jq -r '"license id: \(.data.id)\nlicense key: \(.data.attributes.key)\nexpires: \(.data.attributes.expiry // "never")"' <<<"$created"
show_features "$id"
}
cmd_features() {
local license="" add="" remove=""
while [[ $# -gt 0 ]]; do
case "$1" in
--license) license="$2"; shift 2 ;;
--add) add="$2"; shift 2 ;;
--remove) remove="$2"; shift 2 ;;
*) die "unknown option $1" ;;
esac
done
[[ -n "$license" ]] || die "--license is required"
feature_list "$add" >/dev/null
feature_list "$remove" >/dev/null
license_entitlements "$license" POST "$add"
license_entitlements "$license" DELETE "$remove"
show_features "$license"
}
cmd_checkout() {
@@ -183,6 +274,7 @@ main() {
case "$cmd" in
setup) cmd_setup "$@" ;;
create-license) cmd_create_license "$@" ;;
features) cmd_features "$@" ;;
checkout) cmd_checkout "$@" ;;
list) cmd_list "$@" ;;
-h | --help | help | "") usage ;;
Generated Vendored
+19 -2
View File
@@ -320,6 +320,7 @@ export interface paths {
cookie?: never;
};
get: operations["getOIDCSettings"];
/** @description Enabling SSO needs the license feature DDI_SSO (403 license_feature, ADR 0027); settings that are already enabled stay editable. */
put: operations["setOIDCSettings"];
post?: never;
delete?: never;
@@ -479,6 +480,7 @@ export interface paths {
};
get: operations["listEnrollmentTokens"];
put?: never;
/** @description Fails with 403 license_limit (kind agents) when the license allows no further agent (maxAgents, revoked agents do not count; ADR 0027). Enrollment checks the limit again. */
post: operations["createEnrollmentToken"];
delete?: never;
options?: never;
@@ -931,6 +933,8 @@ export interface paths {
* the other role), agent_in_other_cluster, cluster_primary_exists
* (second primary), cluster_member_limit (DHCP: more than one primary
* and one secondary), invalid_ip (address).
* License (ADR 0027): 403 license_feature with feature DDI_CLUSTER, or
* DDI_KEA_HA for a DHCP cluster with primary and secondary.
*/
post: operations["createCluster"];
delete?: never;
@@ -949,7 +953,12 @@ export interface paths {
cookie?: never;
};
get: operations["getCluster"];
/** @description Replaces all mutable fields including the member list. The role is immutable and ignored. `version` must match. */
/**
* @description Replaces all mutable fields including the member list. The role is
* immutable and ignored. `version` must match. License (ADR 0027): adding
* an agent needs DDI_CLUSTER, turning a DHCP cluster into an HA pair
* DDI_KEA_HA (403 license_feature); removing members always works.
*/
put: operations["updateCluster"];
post?: never;
/**
@@ -1052,6 +1061,8 @@ export interface paths {
* invalid_domain, zone_kind_mismatch (reverse zones must end in
* in-addr.arpa or ip6.arpa, forward zones must not), cluster_role_mismatch
* (cluster is not a DNS cluster), not_found (cluster or key).
* A dynamic zone needs the license feature DDI_DYNAMIC_ZONES (403
* license_feature, ADR 0027).
*/
post: operations["createZone"];
delete?: never;
@@ -1086,7 +1097,11 @@ export interface paths {
cookie?: never;
};
get: operations["getZone"];
/** @description Replaces all mutable fields. Name and kind are immutable and ignored. `version` must match. */
/**
* @description Replaces all mutable fields. Name and kind are immutable and ignored.
* `version` must match. Switching a zone to dynamic needs DDI_DYNAMIC_ZONES
* (403 license_feature, ADR 0027); an existing dynamic zone stays editable.
*/
put: operations["updateZone"];
post?: never;
delete: operations["deleteZone"];
@@ -1252,6 +1267,8 @@ export interface components {
max_addresses?: number;
};
features: string[];
/** @description Features the server grants now (ADR 0027): all during the trial, otherwise those among the license entitlements. Without one, creating clusters, Kea HA pairs, dynamic zones or enabling SSO returns 403 license_feature (param feature); what exists keeps working. */
available_features: ("DDI_CLUSTER" | "DDI_DYNAMIC_ZONES" | "DDI_KEA_HA" | "DDI_SSO")[];
fingerprint: string;
usage: {
addresses: number;
+8 -1
View File
@@ -6,7 +6,14 @@ import { ApiError, type FieldProblem } from './api/client';
/** Translates an error (ApiError code + params, or anything else). */
export function errorText(t: TFunction, err: unknown): string {
if (err instanceof ApiError) {
return t(`errors.${err.code}`, { defaultValue: t('errors.unknown'), ...err.problem.params });
const params = { ...err.problem.params };
// license_feature names the entitlement code (ADR 0027).
if (params.feature) {
params.feature = t(`license.featureNames.${params.feature}`, {
defaultValue: params.feature,
});
}
return t(`errors.${err.code}`, { defaultValue: t('errors.unknown'), ...params });
}
return t('errors.unknown');
}
+16
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import type { TFunction } from 'i18next';
import { api, unwrap, type Schemas } from './api/client';
import { useSession } from './auth/context';
export type LicenseStatus = Schemas['LicenseStatus'];
@@ -20,3 +21,18 @@ export function useLicense(enabled: boolean) {
export function reasonText(t: TFunction, reason: string): string {
return t(`license.reason.${reason}`, { defaultValue: reason });
}
/** Functions a license unlocks by entitlement (ADR 0027). */
export type Feature = LicenseStatus['available_features'][number];
export const features: Feature[] = ['DDI_CLUSTER', 'DDI_KEA_HA', 'DDI_DYNAMIC_ZONES', 'DDI_SSO'];
/**
* Whether the license grants a feature. Until the status is loaded (or for
* users who may not read it) the answer is true: the server checks anyway.
*/
export function useFeature(feature: Feature): boolean {
const { can } = useSession();
const { data } = useLicense(can('license.read'));
return data ? data.available_features.includes(feature) : true;
}
+21 -5
View File
@@ -174,7 +174,18 @@
"recheck": "Jetzt prüfen",
"remove": "Lizenz entfernen",
"removeConfirm": "Lizenz entfernen? Der Server läuft danach ohne Lizenz.",
"dataPlane": "Die Lizenz betrifft nur den Management-Server. DNS- und DHCP-Server laufen immer weiter."
"dataPlane": "Die Lizenz betrifft nur den Management-Server. DNS- und DHCP-Server laufen immer weiter.",
"featureNames": {
"DDI_CLUSTER": "Cluster",
"DDI_KEA_HA": "Kea-HA (DHCP-Hochverfügbarkeit)",
"DDI_DYNAMIC_ZONES": "Dynamische Zonen",
"DDI_SSO": "Single Sign-On (OIDC)"
},
"featureOn": "freigeschaltet",
"featureOff": "nicht enthalten",
"featuresTrial": "In der Testphase ist alles freigeschaltet.",
"entitlements": "Entitlements der Lizenz",
"featureMissing": "{{feature}} ist in der Lizenz nicht enthalten."
},
"networks": {
"title": "Netze",
@@ -477,7 +488,8 @@
"defaultRole": "Standardrolle",
"defaultNone": "keine (Anmeldung ablehnen)",
"redirectHint": "Redirect-URI",
"invalidMapping": "Ungültige Zeile: {{line}}"
"invalidMapping": "Ungültige Zeile: {{line}}",
"licenseSSO": "Single Sign-On ist in der Lizenz nicht enthalten."
},
"errors": {
"unknown": "Es ist ein Fehler aufgetreten.",
@@ -558,7 +570,8 @@
"range_invalid": "Ungültiger Bereich.",
"range_outside_network": "Bereich liegt nicht im Netz {{network}}.",
"range_too_large": "Bereich zu groß (höchstens {{max}} Adressen).",
"invalid_type": "Typ {{type}} ist hier nicht erlaubt."
"invalid_type": "Typ {{type}} ist hier nicht erlaubt.",
"license_feature": "{{feature}} ist in der Lizenz nicht enthalten."
},
"problems": {
"required": "Pflichtfeld.",
@@ -990,7 +1003,9 @@
"networkCount_other": "{{count}} Netze",
"deleteConfirm": "Cluster {{name}} löschen? Die Mitglieder kommen in die Standardgruppe; der Transfer-Schlüssel wird gelöscht.",
"defaultCluster": "Standard",
"defaultGroup": "keiner (Standardgruppe)"
"defaultGroup": "keiner (Standardgruppe)",
"licenseCluster": "Cluster sind in der Lizenz nicht enthalten. Bestehende Cluster laufen weiter und lassen sich bearbeiten, neue Cluster und neue Mitglieder nicht.",
"licenseKeaHA": "Kea-HA ist in der Lizenz nicht enthalten: Ein DHCP-Cluster hat dann nur einen Primary."
},
"tsig": {
"title": "TSIG-Schlüssel",
@@ -1078,7 +1093,8 @@
"networks": "Netze",
"takeOver": "Übernehmen",
"takeOverName": "{{name}} übernehmen",
"takeOverHint": "Wird als explizite Zone angelegt; die Records aus der Adresstabelle bleiben."
"takeOverHint": "Wird als explizite Zone angelegt; die Records aus der Adresstabelle bleiben.",
"licenseDynamic": "Dynamische Zonen sind in der Lizenz nicht enthalten."
},
"records": {
"title": "Records",
+21 -5
View File
@@ -174,7 +174,18 @@
"recheck": "Check now",
"remove": "Remove license",
"removeConfirm": "Remove the license? The server then runs without one.",
"dataPlane": "The license only affects the management server. DNS and DHCP servers always keep running."
"dataPlane": "The license only affects the management server. DNS and DHCP servers always keep running.",
"featureNames": {
"DDI_CLUSTER": "Clusters",
"DDI_KEA_HA": "Kea HA (DHCP high availability)",
"DDI_DYNAMIC_ZONES": "Dynamic zones",
"DDI_SSO": "Single sign-on (OIDC)"
},
"featureOn": "included",
"featureOff": "not included",
"featuresTrial": "Everything is unlocked during the trial.",
"entitlements": "License entitlements",
"featureMissing": "{{feature}} is not included in the license."
},
"networks": {
"title": "Networks",
@@ -477,7 +488,8 @@
"defaultRole": "Default role",
"defaultNone": "none (reject sign-in)",
"redirectHint": "Redirect URI",
"invalidMapping": "Invalid line: {{line}}"
"invalidMapping": "Invalid line: {{line}}",
"licenseSSO": "Single sign-on is not included in the license."
},
"errors": {
"unknown": "Something went wrong.",
@@ -558,7 +570,8 @@
"range_invalid": "Invalid range.",
"range_outside_network": "Range is not inside network {{network}}.",
"range_too_large": "Range too large (at most {{max}} addresses).",
"invalid_type": "Type {{type}} is not allowed here."
"invalid_type": "Type {{type}} is not allowed here.",
"license_feature": "{{feature}} is not included in the license."
},
"problems": {
"required": "Required.",
@@ -990,7 +1003,9 @@
"networkCount_other": "{{count}} networks",
"deleteConfirm": "Delete cluster {{name}}? Its members join the default group; the transfer key is deleted.",
"defaultCluster": "Default",
"defaultGroup": "none (default group)"
"defaultGroup": "none (default group)",
"licenseCluster": "Clusters are not included in the license. Existing clusters keep running and remain editable; new clusters and new members are not possible.",
"licenseKeaHA": "Kea HA is not included in the license: a DHCP cluster can only have a primary."
},
"tsig": {
"title": "TSIG keys",
@@ -1078,7 +1093,8 @@
"networks": "Networks",
"takeOver": "Take over",
"takeOverName": "Take over {{name}}",
"takeOverHint": "Created as an explicit zone; the records from the address table remain."
"takeOverHint": "Created as an explicit zone; the records from the address table remain.",
"licenseDynamic": "Dynamic zones are not included in the license."
},
"records": {
"title": "Records",
+37
View File
@@ -346,6 +346,43 @@ describe('clusters', () => {
});
});
it('locks what the license does not include (ADR 0027)', async () => {
mockApi({
...handlers,
'GET /api/v1/license': () => ({ body: { ...validLicense, available_features: [] } }),
});
renderApp('/clusters');
expect(await screen.findByRole('button', { name: 'DNS-Cluster anlegen' })).toBeDisabled();
expect(
screen.getAllByText(/Cluster sind in der Lizenz nicht enthalten/).length,
).toBeGreaterThan(0);
// Existing clusters stay editable, but no member can be added.
const dnsRow = rowOf(screen.getByText('dns-a'));
await userEvent.click(within(dnsRow).getByRole('button', { name: 'Bearbeiten' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByRole('button', { name: 'Mitglied hinzufügen' })).toBeDisabled();
expect(within(dialog).getByRole('button', { name: 'Speichern' })).toBeEnabled();
});
it('allows a single DHCP server but no HA pair without DDI_KEA_HA', async () => {
mockApi({
...handlers,
'GET /api/v1/license': () => ({
body: { ...validLicense, available_features: ['DDI_CLUSTER'] },
}),
});
renderApp('/clusters');
const dhcpRow = rowOf(await screen.findByText('dhcp-a'));
await userEvent.click(within(dhcpRow).getByRole('button', { name: 'Bearbeiten' }));
const dialog = await screen.findByRole('dialog');
expect(
within(dialog).getByText(/Kea-HA ist in der Lizenz nicht enthalten/),
).toBeInTheDocument();
const add = within(dialog).getByRole('button', { name: 'Mitglied hinzufügen' });
await userEvent.click(add);
expect(add).toBeDisabled();
});
it('explains cluster_in_use on delete', async () => {
mockApi({
...handlers,
+16 -2
View File
@@ -28,6 +28,7 @@ import {
Spinner,
} from '../components/ui';
import { fieldErrors } from '../errors';
import { useFeature } from '../license';
import { nsToText, parseNS } from '../nameServers';
import { pendingKey } from '../pending';
import { SecretDialog } from './TSIGKeysPage';
@@ -80,6 +81,13 @@ function ClusterForm({
const { t } = useTranslation();
const qc = useQueryClient();
const agents = useAgents();
const clusterOk = useFeature('DDI_CLUSTER');
const haOk = useFeature('DDI_KEA_HA');
// An existing HA pair stays editable without the feature (ADR 0027).
const wasPair =
!!cluster?.members.some((m) => m.role === 'primary') &&
cluster.members.some((m) => m.role === 'secondary');
const pairLocked = role === 'dhcp' && !haOk && !wasPair;
const [name, setName] = useState(cluster?.name ?? '');
const [description, setDescription] = useState(cluster?.description ?? '');
const [rows, setRows] = useState<MemberRow[]>(
@@ -153,7 +161,7 @@ function ClusterForm({
...(current && !current.address && agent ? { address: hostOf(agent.remote_address) } : {}),
});
};
const full = role === 'dhcp' && rows.length >= 2;
const full = role === 'dhcp' && (rows.length >= 2 || (pairLocked && rows.length >= 1));
const primaries = rows.filter((r) => r.role === 'primary').length;
const known = [
'name',
@@ -297,12 +305,14 @@ function ClusterForm({
{errors.members}
</div>
)}
{!clusterOk && <Notice kind="warning">{t('clusters.licenseCluster')}</Notice>}
{clusterOk && pairLocked && <Notice>{t('clusters.licenseKeaHA')}</Notice>}
{role === 'dns' && rows.length > 0 && primaries === 0 && (
<Notice kind="warning">{t('clusters.noPrimaryHint')}</Notice>
)}
<button
type="button"
disabled={full}
disabled={full || !clusterOk}
onClick={() => {
setRows((rs) => [
...rs,
@@ -492,6 +502,7 @@ export function ClustersPage() {
const { can } = useSession();
const qc = useQueryClient();
const admin = can('settings.admin');
const clusterOk = useFeature('DDI_CLUSTER');
const q = useClusters();
const [editing, setEditing] = useState<{ role: AgentRole; cluster?: Cluster } | null>(null);
const [deleting, setDeleting] = useState<Cluster | null>(null);
@@ -518,6 +529,7 @@ export function ClustersPage() {
/>
{q.isPending && <Spinner />}
<ErrorBox error={q.error} />
{admin && !clusterOk && <Notice kind="warning">{t('clusters.licenseCluster')}</Notice>}
{q.data &&
roles.map((role) => {
const list = q.data.items.filter((c) => c.role === role);
@@ -530,6 +542,8 @@ export function ClustersPage() {
<button
type="button"
className="small"
disabled={!clusterOk}
title={clusterOk ? undefined : t('clusters.licenseCluster')}
onClick={() => {
setEditing({ role });
}}
+20 -3
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Fragment, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, unwrap } from '../api/client';
@@ -17,7 +17,7 @@ import {
} from '../components/ui';
import { fieldErrors } from '../errors';
import { formatDate, formatDateTime, formatNumber } from '../format';
import { licenseKey, reasonText, useLicense, type LicenseStatus } from '../license';
import { features, licenseKey, reasonText, useLicense, type LicenseStatus } from '../license';
const stateKind: Record<LicenseStatus['state'], string> = {
trial: 'warning',
@@ -204,9 +204,26 @@ export function LicensePage() {
limit={s.limits.max_agents}
/>
</dl>
<h3>{t('license.features')}</h3>
{s.state === 'trial' && <p className="muted">{t('license.featuresTrial')}</p>}
<dl className="kv">
{features.map((f) => (
<Fragment key={f}>
<dt>{t(`license.featureNames.${f}`)}</dt>
<dd>
{s.available_features.includes(f) ? (
<Badge kind="success">{t('license.featureOn')}</Badge>
) : (
<Badge>{t('license.featureOff')}</Badge>
)}{' '}
<span className="mono muted">{f}</span>
</dd>
</Fragment>
))}
</dl>
{s.features.length > 0 && (
<>
<h3>{t('license.features')}</h3>
<h3>{t('license.entitlements')}</h3>
<p>
{s.features.map((f) => (
<Badge key={f}>{f}</Badge>
+6 -1
View File
@@ -4,9 +4,10 @@ import { useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { api, unwrap, type Schemas } from '../api/client';
import { ErrorBox, Field, PageHeader, Spinner } from '../components/ui';
import { ErrorBox, Field, Notice, PageHeader, Spinner } from '../components/ui';
import { fieldErrors } from '../errors';
import { splitList } from '../format';
import { useFeature } from '../license';
type Settings = Schemas['OIDCSettings'];
type Role = Schemas['Role'];
@@ -50,6 +51,8 @@ function OIDCSettingsForm({ initial }: { initial: Settings }) {
const [mapping, setMapping] = useState(mappingToText(initial.role_mapping));
const [secret, setSecret] = useState('');
const [badLine, setBadLine] = useState<string | null>(null);
// Enabled settings stay editable without the feature (ADR 0027).
const ssoLocked = !useFeature('DDI_SSO') && !initial.enabled;
const save = useMutation({
mutationFn: (body: Settings & { client_secret?: string }) =>
@@ -95,10 +98,12 @@ function OIDCSettingsForm({ initial }: { initial: Settings }) {
}
/>
<form id="oidc-form" className="form narrow card" onSubmit={submit}>
{ssoLocked && <Notice kind="warning">{t('oidc.licenseSSO')}</Notice>}
<label className="checkbox">
<input
type="checkbox"
checked={form.enabled}
disabled={ssoLocked && !form.enabled}
onChange={(e) => {
set('enabled', e.target.checked);
}}
+9 -2
View File
@@ -9,6 +9,7 @@ import { ClusterSelect } from '../components/ClusterSelect';
import { ConfirmDialog, ErrorBox, Field, Help, Modal, Notice } from '../components/ui';
import { fieldErrors } from '../errors';
import { splitList } from '../format';
import { useFeature } from '../license';
import { pendingKey } from '../pending';
export type Zone = Schemas['Zone'];
@@ -52,6 +53,8 @@ export function ZoneForm({
const [name, setName] = useState(zone?.name ?? draft?.name ?? '');
const [kind, setKind] = useState<ZoneKind | ''>(zone?.kind ?? draft?.kind ?? '');
const [mode, setMode] = useState<ZoneMode>(zone?.mode ?? 'managed');
// An existing dynamic zone stays dynamic without the feature (ADR 0027).
const dynamicLocked = !useFeature('DDI_DYNAMIC_ZONES') && zone?.mode !== 'dynamic';
const [clusterId, setClusterId] = useState<string | null>(zone?.cluster_id ?? null);
const [ttl, setTtl] = useState(zone?.default_ttl ? String(zone.default_ttl) : '');
const [allowUpdate, setAllowUpdate] = useState(zone?.allow_update.join(', ') ?? '');
@@ -154,7 +157,11 @@ export function ZoneForm({
</select>
)}
</Field>
<Field label={t('zones.mode')} error={fe.mode} hint={t(`zones.modeHelp.${mode}`)}>
<Field
label={t('zones.mode')}
error={fe.mode}
hint={dynamicLocked ? t('zones.licenseDynamic') : t(`zones.modeHelp.${mode}`)}
>
{(id) => (
<select
id={id}
@@ -164,7 +171,7 @@ export function ZoneForm({
}}
>
{modes.map((m) => (
<option key={m} value={m}>
<option key={m} value={m} disabled={m === 'dynamic' && dynamicLocked}>
{t(`zones.modes.${m}`)}
</option>
))}
+2 -1
View File
@@ -110,7 +110,8 @@ export const validLicense = {
state: 'valid',
reason: 'ok',
limits: {},
features: [],
features: ['DDI_CLUSTER', 'DDI_KEA_HA', 'DDI_DYNAMIC_ZONES', 'DDI_SSO'],
available_features: ['DDI_CLUSTER', 'DDI_DYNAMIC_ZONES', 'DDI_KEA_HA', 'DDI_SSO'],
fingerprint: 'fp',
usage: { addresses: 0, agents: 0 },
};