From cb7f4c9f3ddd1e93cddb087734787333ccbc670c Mon Sep 17 00:00:00 2001 From: sir_melonchen Date: Sun, 18 Oct 2026 17:51:55 +0200 Subject: [PATCH] feat(license): gate features by entitlement and enforce maxAgents 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 --- docs/API.md | 4 +- docs/LICENSING.md | 41 ++++- docs/LOGBUCH.md | 10 +- ...-funktionen-per-entitlement-agent-limit.md | 33 ++++ docs/adr/README.md | 1 + server/api/openapi.yaml | 23 ++- server/internal/httpapi/harness_test.go | 47 +++-- .../internal/httpapi/license_features_test.go | 160 ++++++++++++++++++ server/internal/license/features.go | 44 +++++ server/internal/license/features_test.go | 29 ++++ server/internal/license/manager.go | 7 +- server/internal/service/agentplane.go | 3 + server/internal/service/agents.go | 5 + server/internal/service/clusters.go | 43 +++++ server/internal/service/errors.go | 1 + server/internal/service/license.go | 50 +++++- server/internal/service/oidc.go | 10 +- server/internal/service/zones.go | 13 ++ server/internal/store/agents.go | 18 ++ tools/keygen-setup/keygen-setup.sh | 104 +++++++++++- web/src/api/schema.d.ts | 21 ++- web/src/errors.ts | 9 +- web/src/license.ts | 16 ++ web/src/locales/de.json | 26 ++- web/src/locales/en.json | 26 ++- web/src/m5.test.tsx | 37 ++++ web/src/pages/ClustersPage.tsx | 18 +- web/src/pages/LicensePage.tsx | 23 ++- web/src/pages/OIDCSettingsPage.tsx | 7 +- web/src/pages/ZoneForm.tsx | 11 +- web/src/test-utils.tsx | 3 +- 31 files changed, 783 insertions(+), 60 deletions(-) create mode 100644 docs/adr/0027-funktionen-per-entitlement-agent-limit.md create mode 100644 server/internal/httpapi/license_features_test.go create mode 100644 server/internal/license/features.go create mode 100644 server/internal/license/features_test.go diff --git a/docs/API.md b/docs/API.md index 77f90ab..36761e3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/docs/LICENSING.md b/docs/LICENSING.md index a3f6078..8a8d313 100644 --- a/docs/LICENSING.md +++ b/docs/LICENSING.md @@ -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 # anzeigen +tools/keygen-setup/keygen-setup.sh features --license --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//licenses/" \ + -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. diff --git a/docs/LOGBUCH.md b/docs/LOGBUCH.md index 4488b0c..09168ed 100644 --- a/docs/LOGBUCH.md +++ b/docs/LOGBUCH.md @@ -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.` (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. diff --git a/docs/adr/0027-funktionen-per-entitlement-agent-limit.md b/docs/adr/0027-funktionen-per-entitlement-agent-limit.md new file mode 100644 index 0000000..bb8d924 --- /dev/null +++ b/docs/adr/0027-funktionen-per-entitlement-agent-limit.md @@ -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`. diff --git a/docs/adr/README.md b/docs/adr/README.md index bce5c64..1f738e6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 0eefba3..6476d61 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -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 diff --git a/server/internal/httpapi/harness_test.go b/server/internal/httpapi/harness_test.go index 133a394..ed944e0 100644 --- a/server/internal/httpapi/harness_test.go +++ b/server/internal/httpapi/harness_test.go @@ -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). diff --git a/server/internal/httpapi/license_features_test.go b/server/internal/httpapi/license_features_test.go new file mode 100644 index 0000000..44a037a --- /dev/null +++ b/server/internal/httpapi/license_features_test.go @@ -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) + } +} diff --git a/server/internal/license/features.go b/server/internal/license/features.go new file mode 100644 index 0000000..17d2ef1 --- /dev/null +++ b/server/internal/license/features.go @@ -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)) +} diff --git a/server/internal/license/features_test.go b/server/internal/license/features_test.go new file mode 100644 index 0000000..70d5b1e --- /dev/null +++ b/server/internal/license/features_test.go @@ -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) + } + } +} diff --git a/server/internal/license/manager.go b/server/internal/license/manager.go index db80f14..749b29f 100644 --- a/server/internal/license/manager.go +++ b/server/internal/license/manager.go @@ -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) diff --git a/server/internal/service/agentplane.go b/server/internal/service/agentplane.go index 241ead3..71dfbe8 100644 --- a/server/internal/service/agentplane.go +++ b/server/internal/service/agentplane.go @@ -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) { diff --git a/server/internal/service/agents.go b/server/internal/service/agents.go index 5613e75..d4a92d1 100644 --- a/server/internal/service/agents.go +++ b/server/internal/service/agents.go @@ -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 } diff --git a/server/internal/service/clusters.go b/server/internal/service/clusters.go index 9ccd53a..8d611ce 100644 --- a/server/internal/service/clusters.go +++ b/server/internal/service/clusters.go @@ -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 } diff --git a/server/internal/service/errors.go b/server/internal/service/errors.go index 9bea506..62a616c 100644 --- a/server/internal/service/errors.go +++ b/server/internal/service/errors.go @@ -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") diff --git a/server/internal/service/license.go b/server/internal/service/license.go index 3d8f25a..dcb718d 100644 --- a/server/internal/service/license.go +++ b/server/internal/service/license.go @@ -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. diff --git a/server/internal/service/oidc.go b/server/internal/service/oidc.go index b38db7c..7a83bb3 100644 --- a/server/internal/service/oidc.go +++ b/server/internal/service/oidc.go @@ -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 diff --git a/server/internal/service/zones.go b/server/internal/service/zones.go index 1f36df4..4d3b5cb 100644 --- a/server/internal/service/zones.go +++ b/server/internal/service/zones.go @@ -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 } diff --git a/server/internal/store/agents.go b/server/internal/store/agents.go index 99b4693..e262c4d 100644 --- a/server/internal/store/agents.go +++ b/server/internal/store/agents.go @@ -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`) diff --git a/tools/keygen-setup/keygen-setup.sh b/tools/keygen-setup/keygen-setup.sh index 7db4486..7d11182 100755 --- a/tools/keygen-setup/keygen-setup.sh +++ b/tools/keygen-setup/keygen-setup.sh @@ -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 ;; diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 7321f20..dd04761 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -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; diff --git a/web/src/errors.ts b/web/src/errors.ts index 069c3a2..86683dd 100644 --- a/web/src/errors.ts +++ b/web/src/errors.ts @@ -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'); } diff --git a/web/src/license.ts b/web/src/license.ts index f61f92c..aeb8ec8 100644 --- a/web/src/license.ts +++ b/web/src/license.ts @@ -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; +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 282b18c..35454e9 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index e41b2c6..0e5ec3d 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/m5.test.tsx b/web/src/m5.test.tsx index 1bacee7..5bdb6d8 100644 --- a/web/src/m5.test.tsx +++ b/web/src/m5.test.tsx @@ -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, diff --git a/web/src/pages/ClustersPage.tsx b/web/src/pages/ClustersPage.tsx index 39503de..63d846d 100644 --- a/web/src/pages/ClustersPage.tsx +++ b/web/src/pages/ClustersPage.tsx @@ -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( @@ -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} )} + {!clusterOk && {t('clusters.licenseCluster')}} + {clusterOk && pairLocked && {t('clusters.licenseKeaHA')}} {role === 'dns' && rows.length > 0 && primaries === 0 && ( {t('clusters.noPrimaryHint')} )}