From c2d6dd694f395183e2b4aadd2cb82759f6ff04fc Mon Sep 17 00:00:00 2001 From: Bjoern Runden Date: Mon, 2 Nov 2026 18:32:28 +0100 Subject: [PATCH] =?UTF-8?q?sultan=20token=20tenant=20update:=20Mandanten?= =?UTF-8?q?=20nachtr=C3=A4glich=20=C3=A4ndern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weitere Compose-Stacks eines Kunden sind der Regelfall; bisher ließen sich Domains und Routenlimit nur per delete + neu anlegen (und damit ohne Knoten) ändern. Die Änderung liegt bewusst nur auf dem Server: Ein Agent darf sich keine Domains selbst freigeben. - Domains und Features werden hinzugefügt/entfernt statt ersetzt; unbekannter Eintrag beim Entfernen ist ein Fehler, die letzte Domain bleibt - Verlängerung in die Zukunft setzt expired auf active (der Ablauf-Job setzt das nie zurück); Quarantäne und Zahlungsverzug bleiben - Wechsel auf kommerziell mit vergangenem valid_until verlangt --valid-until Co-Authored-By: Claude Opus 5.5 --- .ai/sultan-system-architecture.md | 5 +- CLAUDE.md | 2 +- docs/community.md | 13 ++ docs/hoster.md | 20 ++- pkg/cli/token.go | 209 +++++++++++++++++++++++++++--- pkg/cli/token_test.go | 159 ++++++++++++++++++++++- pkg/server/server_test.go | 30 +++++ 7 files changed, 413 insertions(+), 25 deletions(-) diff --git a/.ai/sultan-system-architecture.md b/.ai/sultan-system-architecture.md index ab25f83..7abae77 100644 --- a/.ai/sultan-system-architecture.md +++ b/.ai/sultan-system-architecture.md @@ -458,6 +458,7 @@ sultan | Befehl | Wirkung | |---|---| | `tenant add --id --name --email --domains [--tier community] [--max-routes 5] [--valid-until +1 Jahr] [--features …]` | Mandant anlegen; Domains werden wie in der Lizenz normalisiert und geprüft; Features fließen in Lizenz, Lease und Policy | +| `tenant update --id [--add-domains] [--remove-domains] [--name] [--email] [--tier] [--max-routes] [--valid-until] [--add-features] [--remove-features]` | Nur Angegebenes ändern, wirkt beim nächsten Refresh; Verlängerung setzt `expired` auf `active`; Wechsel auf kommerziell mit vergangenem Datum verlangt `--valid-until` | | `tenant list` / `tenant set-status --id --status` / `tenant delete --id` | Anzeigen, Status setzen (wirkt beim nächsten Refresh), Löschen inkl. Knoten und Einladungen | | `invite --tenant [--ttl 24h\|7d] [--server-url]` | Einladung `sltn_enroll_.[.]`; Adresse aus `--server-url` oder der gemerkten `--public-url`; stdout enthält **nur** den Token, Hinweise auf stderr | | `node list [--tenant]` | Knoten mit Status und Wartung (`bis …`, `unbefristet`, `abgelaufen`) | @@ -894,6 +895,7 @@ services: | 2026-10-18 | **6e umgesetzt:** Checkmk des Accountants auf eigenem Loopback-Port `127.0.0.1:8082`, Quelle = letzter Bericht; Wartung dämpft auf OK; Rate-Limiter nach `pkg/ratelimit` verschoben und für `/enroll` des Accountants genutzt. | Unabhängig von 6d nutzbar; einheitliches Verhalten von Server und Accountant. | | 2026-10-18 | **Historie je Ort** (Payee, Mandant, Knoten, Protokoll, Router) statt je Posten; Kunden entstehen automatisch aus der Mandanten-ID der Berichte. | `where` braucht den Ort (wohin springen, wo vorher); Posten für die Abrechnung lassen sich daraus ableiten; der Mandant ist durch die Einladung auf dem Server verlässlich, also keine Nachpflege. | | 2026-10-18 | **6d umgesetzt:** Posten = Orte je (Mandant, Protokoll, Router) zusammengefasst; Beginn voller Monat (Zuordnung verschiebt), Ende bis Ultimo mit tagesgenauer Gegenrechnung als Hinweis; Klasse Zuordnung > Produkt > Pauschale > nicht zugeordnet; Preise optional, Schattenpreis für free/internal; Pauschale als eigene Zeile; CSV mit BOM, Semikolon, Dezimalkomma. | Deckt sich mit der Praxis (Lexware, Pauschale + Zusatz-Services); Lücken (kein Kunde, kein Produkt, keine Zahlweise) werden sichtbar statt still verschluckt. | +| 2026-11-02 | **`tenant update` nur auf dem Server:** Domains werden hinzugefügt/entfernt statt als Liste ersetzt; unbekannter Eintrag beim Entfernen = Fehler; letzte Domain bleibt; Verlängerung in die Zukunft setzt `expired` auf `active` (Quarantäne und Zahlungsverzug bleiben); Wechsel auf kommerziell mit vergangenem `valid_until` verlangt `--valid-until`. Routing folgt sofort dem Mandanten-Datensatz, die Kopie in der Lease erst bei der nächsten Erneuerung. | Weitere Kunden-Stacks sind der Regelfall. Der Agent darf sich keine Rechte selbst geben (Spec-Kerninvariante). Ersetzen wäre bei Tippfehlern gefährlich; der Ablauf-Job setzt `expired` nie zurück. | | geplant | Geordneter Zertifikatswechsel; optionale Root-CA. (Signierte Server-Events: umgesetzt.) | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. | --- @@ -975,7 +977,6 @@ services: **Projektstand 2026-10-03: vorerst fertig.** Erster Feldtest (webproxy11 + dockweb11) erfolgreich; Betriebsdoku unter `docs/` (`community.md`, `wartung.md`, `hoster.md`). Weitere Iterationen nach Schwierigkeiten im Betrieb. Offen aus dem Abschluss: * **Ungetestet im Feld:** Agent als systemd-Dienst (ohne Docker); kommerzieller Betrieb (Lizenzen, Mahnwesen, Vertragsende, pro/enterprise). -* **`tenant update`** (Vertragsende, Domains, Tier, Routenlimit) – Voraussetzung für zahlende Kunden; `store.UpdateTenant` existiert, CLI fehlt. * **Lander-Vorlagen gestalten** („hübsch“): die eingebauten Seiten sind bewusst minimal ohne CSS. * **Registry mit Vanity-URL:** `zot.chmln.de` ist noch nicht weiter getrieben (Image-Name unabhängig vom Registry-Host, z. B. `cr./sultan`). * **Geparkt:** Veröffentlichung auf GitHub (Quellen, Releases, ggf. ghcr.io-Spiegel). @@ -993,7 +994,7 @@ services: * Admin-API (nur kommerziell): Mandanten, Lizenzen, Zahlungsstatus für Rechnungssystem und Ansible; Authentifizierung per API-Token, TLS über ACME. * *(umgesetzt 2026-10-02: Rate-Limit für `/enroll`, Umgebungsvariablen für alle Flags, „wieder online“-Meldung, Checkmk mit Sekunden und Zeitzone)* * Agent: Healthchecks auch für Docker-Container (Docker-Health-Status oder Labels `sultan.healthcheck.*`). -* CLI: `tenant update` (Domains/Tier/Laufzeit ändern), JSON-Ausgabe für Skripte. +* CLI: JSON-Ausgabe für Skripte. *(umgesetzt 2026-11-02: `tenant update`)* * **`public_reason` zweisprachig** (klein): Die Wartungsseite ist de/en, der öffentliche Grund bisher nur ein Text. Vorschlag: `--public-reason` mehrfach mit Sprachpräfix (`de:…`, `en:…`; ohne Präfix = für alle Sprachen), Label analog `sultan.maintenance.public_reason.`; Übertragung im bestehenden Header `X-Sultan-Public-Reason` als URL-kodierte Form (`de=…&en=…`), Lander wählt je Sprachblock, fehlt eine Sprache → der andere Text. * Sicherheit (3.4): geordneter Zertifikatswechsel, optionale Root-CA. * **Ausblick Monitoring:** Die Zahl der Policy-Verstöße der letzten 24 h (`Sultan_Policy_Violations`) zählt der Server derzeit im Speicher; nach einem Neustart beginnt sie bei 0. Für Checkmk reicht das. Wird sie dauerhaft gebraucht (Auswertung, Audit), ist das eine kleine Erweiterung: Verstöße mit Zeitpunkt in einer Tabelle ablegen und im Aufräum-Job nach der Frist löschen. diff --git a/CLAUDE.md b/CLAUDE.md index 4cfd8c1..7f166d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ Go-Modul `sultan` (Go 1.26). `main.go` ist nur eine Hülle um `pkg/cli`; umgeset **CLI-Umgebung:** `pkg/cli/env.go` – `annotateEnv`/`applyEnv` machen jedes Flag als `SULTAN_` setzbar (nach `fs.Parse`, nur für nicht gesetzte Flags; Listen-Flags `stringList` kommagetrennt). Neue Flags bekommen das automatisch. Rate-Limit: `pkg/server/ratelimit.go` (`EnrollLimit`, `-1` = aus). -Betriebsdoku: `docs/community.md`, `docs/wartung.md`, `docs/hoster.md` – bei Verhaltensänderungen mitpflegen. Projekt gilt seit 2026-10-03 vorerst als fertig; offene Punkte (tenant update, systemd-Agent und kommerzieller Betrieb ungetestet, Lander-Design, Vanity-Registry, GitHub geparkt) und Fachfragen: Spec Abschnitt 9. Nächster Baustein: **Accountant** (Spec 5.3, Roadmap Schritt 6; 6a–6c und 6e umgesetzt) – Schritt 6 komplett (6a–6e); offen u. a. HTTP-API (Dokuwiki), Mandanten-Statusberichte, Abo-Abgleich mit Lexware (geparkt) – Spec 5.3 und Abschnitt 9. +Betriebsdoku: `docs/community.md`, `docs/wartung.md`, `docs/hoster.md` – bei Verhaltensänderungen mitpflegen. Projekt gilt seit 2026-10-03 vorerst als fertig; offene Punkte (systemd-Agent und kommerzieller Betrieb ungetestet, Lander-Design, Vanity-Registry, GitHub geparkt) und Fachfragen: Spec Abschnitt 9. Nächster Baustein: **Accountant** (Spec 5.3, Roadmap Schritt 6; 6a–6c und 6e umgesetzt) – Schritt 6 komplett (6a–6e); offen u. a. HTTP-API (Dokuwiki), Mandanten-Statusberichte, Abo-Abgleich mit Lexware (geparkt) – Spec 5.3 und Abschnitt 9. Tests werden zuerst als Vorgaben geschrieben, dann implementiert. Zeitabhängige Tests nutzen kurze Intervalle über die Konfigurationsfelder bzw. das injizierbare `Client.sleep`; mit `-race -count=30` auf Stabilität prüfen. diff --git a/docs/community.md b/docs/community.md index 742db7d..4266af4 100644 --- a/docs/community.md +++ b/docs/community.md @@ -253,6 +253,19 @@ Soll TLS erst beim Backend enden, braucht der Mandant das Feature `tcp_stream` ( - Jeder TCP-Router zählt als Route. - Bei Wartung oder gestopptem Container gibt es keine Wartungsseite, die Route ist dann weg. +#### Weiterer Stack oder weitere URL für denselben Mandanten + +Einen neuen Compose-Stack neben dem bestehenden startest du einfach mit seinen Labels wie oben. Der Agent findet ihn selbst; neue Einladung, Agent-Neustart und Änderung am Agent sind nicht nötig. Router-Namen müssen auf dem Knoten eindeutig sein, und jeder Router zählt gegen `--max-routes`. + +Nur wenn die neue Domain noch nicht abgedeckt ist oder das Routenlimit nicht reicht, änderst du den Mandanten **auf dem Server**: + +```bash +docker compose exec sultan-server sultan token tenant update --id homelab --add-domains "neu.example.org,*.neu.example.org" +docker compose exec sultan-server sultan token tenant update --id homelab --max-routes 20 +``` + +Das wirkt ohne Neustart beim nächsten Refresh (bis zu 1 Minute). Eine zuvor abgelehnte Route erscheint dann von selbst. Domains und Limit legt bewusst nur der Server fest: Ein Agent darf sich keine Domains selbst freigeben. + ### 3.4 Erreichbarkeit Traefik → Backend Der Agent meldet `http://:`. Prüfen vom **Ingress** aus: diff --git a/docs/hoster.md b/docs/hoster.md index 082da89..0e6d094 100644 --- a/docs/hoster.md +++ b/docs/hoster.md @@ -31,7 +31,24 @@ Aus dem Vertrag gehen diese Werte in den Mandanten: | Vertragsende | `--valid-until JJJJ-MM-TT` | Default: heute + 1 Jahr. **Danach sehen Besucher die Landingpage „Hosting abgelaufen“.** | | Zusatzfunktionen | `--features` | `custom_middleware`, `websockets`, `tcp_stream`. Ohne `custom_middleware` sind nur Standard-Middlewares erlaubt (Redirects, Pfad-Umschreibung, Header, Kompression). BasicAuth, Rate-Limit, IP-Allowlist usw. brauchen das Feature, sonst wird der Router mit Verstoß abgelehnt. `chain` und `errors` sind nie erlaubt. `tcp_stream` erlaubt TLS-Passthrough (Kunde mit eigener CA/TLS, z. B. tinyCA): nur `HostSNI` mit einzelnen Hosts aus den Domains, nur Port 443; ab Paywall oder bei Wartung ist die Route ganz weg (keine Hinweisseite möglich). | -**Achtung:** Vertragsende, Domains und Paket lassen sich bisher **nicht nachträglich ändern** (`tenant update` fehlt, siehe Lücken). Deshalb `--valid-until` und `--domains` beim Anlegen sorgfältig setzen. +Alle Werte außer der Kennung lassen sich später mit `tenant update` ändern (siehe unten). + +### Mandant nachträglich ändern + +```bash +sultan token tenant update --id k10042 --add-domains "zweitshop.de,www.zweitshop.de" # weiterer Stack des Kunden +sultan token tenant update --id k10042 --remove-domains alt-kunde.de # Routen darauf fallen weg +sultan token tenant update --id k10042 --max-routes 10 +sultan token tenant update --id k10042 --valid-until 2028-12-31 # Vertrag verlängern +sultan token tenant update --id k10042 --tier pro --valid-until 2027-12-31 # von community auf pro +sultan token tenant update --id k10042 --add-features websockets --remove-features tcp_stream +``` + +- Nur Angegebenes ändert sich. Es wirkt ohne Neustart beim nächsten Refresh (bis zu 1 Minute); Knoten und Einladungen bleiben. +- **Verlängern:** Liegt das neue `--valid-until` in der Zukunft, wird der Status `expired` wieder `active`. Quarantäne und Zahlungsverzug bleiben bestehen. +- **Wechsel von `community` auf `pro`/`enterprise`:** Liegt das bisherige Datum in der Vergangenheit, verlangt der Befehl `--valid-until`, sonst wären die Seiten sofort abgelaufen. +- Die letzte Domain lässt sich nicht entfernen. Ein Tippfehler bei `--remove-domains` ist ein Fehler und ändert nichts. +- Ein verkürztes Limit wirkt alphabetisch: Überzählige Router werden verworfen. ## 2. Netzwerk mit dem Kunden klären @@ -176,7 +193,6 @@ sultan token tenant delete --id k10042 # löscht Mandant, Knoten und offene | Lücke | Folge | Notlösung | |---|---|---| -| **`tenant update` fehlt** | Vertragsende, Domains, Paket, Routenlimit nicht änderbar | Keine saubere: `tenant delete` + neu anlegen löscht auch die Knoten (neue Einladungen nötig). Erste Priorität für den kommerziellen Betrieb. | | pro = enterprise | kein technischer Unterschied | vertraglich regeln | | Keine Admin-API | Zahlungsstatus nur per CLI | Skript über `docker compose exec` | | Kommerzieller Betrieb nicht im Feld getestet | – | mit einem Testmandanten und kurzen Fristen (`--valid-until` in wenigen Tagen, `payment --overdue-since` in der Vergangenheit) durchspielen | diff --git a/pkg/cli/token.go b/pkg/cli/token.go index 9d6117a..c39d8e1 100644 --- a/pkg/cli/token.go +++ b/pkg/cli/token.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "slices" "strconv" "strings" "text/tabwriter" @@ -29,6 +30,11 @@ Arbeitet direkt auf dem Datenverzeichnis des Servers (auch während er läuft). sultan token tenant add --id ID --name NAME --email MAIL --domains a.de,*.b.de [--tier community|pro|enterprise] [--max-routes 5] [--valid-until JJJJ-MM-TT] [--features custom_middleware,websockets,tcp_stream] + sultan token tenant update --id ID [--add-domains a.de,*.a.de] [--remove-domains b.de] + [--name NAME] [--email MAIL] [--tier community|pro|enterprise] + [--max-routes N] [--valid-until JJJJ-MM-TT] + [--add-features LISTE] [--remove-features LISTE] + (nur Angegebenes ändert sich; wirkt ohne Neustart beim nächsten Refresh) sultan token tenant list sultan token tenant set-status --id ID --status active|suspended_payment|quarantined|expired sultan token tenant delete --id ID (löscht auch Knoten und Einladungen!) @@ -61,6 +67,7 @@ type tokenCmd func(env tokenEnv, args []string) error var tokenCmds = map[string]tokenCmd{ "tenant add": tenantAdd, + "tenant update": tenantUpdate, "tenant list": tenantList, "tenant set-status": tenantSetStatus, "tenant delete": tenantDelete, @@ -176,26 +183,13 @@ func tenantAdd(env tokenEnv, args []string) error { if !policy.ValidID(*id) || *id == "sultan" { return fmt.Errorf("Mandanten-ID %q ungültig (a–z, 0–9, - und _, nicht am Rand, kein \"__\", nicht \"sultan\")", *id) } - var normalized []string - for _, d := range strings.Split(*domains, ",") { - n, err := license.NormalizeDomain(d) - if err != nil { - return fmt.Errorf("Domain %q ungültig (Beispiele: kunde.de, *.kunde.de; keine Wildcard auf TLD)", strings.TrimSpace(d)) - } - normalized = append(normalized, n) + normalized, err := parseDomains(*domains) + if err != nil { + return err } - - var features []string - for _, f := range strings.Split(*featuresRaw, ",") { - if f = strings.TrimSpace(f); f == "" { - continue - } - switch license.Feature(f) { - case license.FeatureCustomMiddleware, license.FeatureWebsockets, license.FeatureTCPStream: - features = append(features, f) - default: - return fmt.Errorf("unbekanntes Feature %q (custom_middleware, websockets, tcp_stream)", f) - } + features, err := parseFeatures(*featuresRaw) + if err != nil { + return err } st, err := openDataDir(env, *dir) if err != nil { @@ -213,6 +207,183 @@ func tenantAdd(env tokenEnv, args []string) error { return nil } +// parseDomains normalisiert eine kommagetrennte Domainliste. +func parseDomains(raw string) ([]string, error) { + var out []string + for _, d := range strings.Split(raw, ",") { + n, err := license.NormalizeDomain(d) + if err != nil { + return nil, fmt.Errorf("Domain %q ungültig (Beispiele: kunde.de, *.kunde.de; keine Wildcard auf TLD)", strings.TrimSpace(d)) + } + out = append(out, n) + } + return out, nil +} + +// parseFeatures prüft eine kommagetrennte Feature-Liste; leer ist erlaubt. +func parseFeatures(raw string) ([]string, error) { + var out []string + for _, f := range strings.Split(raw, ",") { + if f = strings.TrimSpace(f); f == "" { + continue + } + switch license.Feature(f) { + case license.FeatureCustomMiddleware, license.FeatureWebsockets, license.FeatureTCPStream: + out = append(out, f) + default: + return nil, fmt.Errorf("unbekanntes Feature %q (custom_middleware, websockets, tcp_stream)", f) + } + } + return out, nil +} + +// removeThenAdd entfernt aus list und hängt danach an; Vorhandenes bleibt an +// seiner Stelle. Ein zu entfernender Eintrag, den es nicht gibt, ist ein Fehler: +// Ein Tippfehler darf nicht wie ein erfolgreiches Entziehen aussehen. +func removeThenAdd(what string, list, remove, add []string) ([]string, error) { + out := append([]string{}, list...) + for _, r := range remove { + i := slices.Index(out, r) + if i < 0 { + return nil, fmt.Errorf("%s %q ist nicht eingetragen (vorhanden: %s)", what, r, strings.Join(list, ",")) + } + out = slices.Delete(out, i, i+1) + } + for _, a := range add { + if !slices.Contains(out, a) { + out = append(out, a) + } + } + return out, nil +} + +// tenantUpdate ändert nur die angegebenen Felder. Ein leerer Wert heißt „nicht +// angegeben“; deshalb sind auch --max-routes und --tier Strings (0 ist ein +// gültiges Limit, und applyEnv lässt sich über fs.Visit nicht erkennen). +func tenantUpdate(env tokenEnv, args []string) error { + fs, dir := flags("tenant update", env) + id := fs.String("id", "", "Mandanten-ID") + name := fs.String("name", "", "neuer Name") + email := fs.String("email", "", "neue Kontakt-E-Mail") + addDomains := fs.String("add-domains", "", "Domains hinzufügen, kommagetrennt") + removeDomains := fs.String("remove-domains", "", "Domains entfernen, kommagetrennt (Routen darauf fallen weg)") + tier := fs.String("tier", "", "community, pro oder enterprise") + maxRoutes := fs.String("max-routes", "", "maximale Anzahl Routen (-1 = unbegrenzt)") + validUntil := fs.String("valid-until", "", "Laufzeitende JJJJ-MM-TT") + addFeatures := fs.String("add-features", "", "Features hinzufügen, kommagetrennt") + removeFeatures := fs.String("remove-features", "", "Features entfernen, kommagetrennt") + if err := parse(fs, args, "id"); err != nil { + return err + } + if *name+*email+*addDomains+*removeDomains+*tier+*maxRoutes+*validUntil+*addFeatures+*removeFeatures == "" { + return usageError{errors.New("nichts zu ändern (siehe sultan token help)")} + } + routes := 0 + if *maxRoutes != "" { + n, err := strconv.Atoi(*maxRoutes) + if err != nil { + return usageError{fmt.Errorf("--max-routes %q: keine Zahl", *maxRoutes)} + } + routes = n + } + var until time.Time + if *validUntil != "" { + d, err := parseDate(*validUntil) + if err != nil { + return usageError{err} + } + until = d + } + var domainsAdd, domainsRemove []string + var err error + if *addDomains != "" { + if domainsAdd, err = parseDomains(*addDomains); err != nil { + return err + } + } + if *removeDomains != "" { + if domainsRemove, err = parseDomains(*removeDomains); err != nil { + return err + } + } + featuresAdd, err := parseFeatures(*addFeatures) + if err != nil { + return err + } + featuresRemove, err := parseFeatures(*removeFeatures) + if err != nil { + return err + } + + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + t, err := st.GetTenant(env.ctx, *id) + if err != nil { + return err + } + now := time.Now() + wasCommercial := t.Tier != store.TierCommunity + + if t.AllowedDomains, err = removeThenAdd("Domain", t.AllowedDomains, domainsRemove, domainsAdd); err != nil { + return err + } + if len(t.AllowedDomains) == 0 { + return errors.New("mindestens eine Domain muss bleiben") + } + if t.Features, err = removeThenAdd("Feature", t.Features, featuresRemove, featuresAdd); err != nil { + return err + } + if *name != "" { + t.Name = *name + } + if *email != "" { + t.Email = *email + } + if *tier != "" { + t.Tier = store.Tier(*tier) + } + if *maxRoutes != "" { + t.MaxRoutes = routes + } + if *validUntil != "" { + t.ValidUntil = until + } + // Community prüft valid_until nicht. Ein altes Datum würde beim Wechsel auf + // ein kommerzielles Paket die Seiten beim nächsten Refresh abschalten. + if !wasCommercial && t.Tier != store.TierCommunity && *validUntil == "" && !now.Before(t.ValidUntil) { + return fmt.Errorf("Laufzeitende %s liegt in der Vergangenheit: beim Wechsel auf %s auch --valid-until angeben", + t.ValidUntil.Format(time.DateOnly), t.Tier) + } + // Der Ablauf-Job setzt expired nie zurück: Eine Verlängerung muss das tun. + // Quarantäne und Zahlungsverzug bleiben, das sind eigene Entscheidungen. + reactivated := *validUntil != "" && t.Status == store.TenantExpired && now.Before(until) + if reactivated { + t.Status = store.TenantActive + } + + t, err = st.UpdateTenant(env.ctx, t) + if err != nil { + return err + } + limit := strconv.Itoa(t.MaxRoutes) + if t.MaxRoutes < 0 { + limit = "unbegrenzt" + } + fmt.Fprintf(env.stdout, "Mandant %s geändert (%s, Status %s, gültig bis %s, Routen %s)\n Domains: %s\n Features: %s\n", + t.ID, t.Tier, t.Status, t.ValidUntil.Format(time.DateOnly), limit, + strings.Join(t.AllowedDomains, ","), strings.Join(t.Features, ",")) + if reactivated { + fmt.Fprintln(env.stdout, "Vertrag verlängert: Status von expired auf active gesetzt.") + } + if len(domainsRemove) > 0 { + fmt.Fprintln(env.stdout, "Hinweis: Routen auf entfernten Domains fallen beim nächsten Refresh weg.") + } + return nil +} + func parseDate(s string) (time.Time, error) { if t, err := time.Parse(time.DateOnly, s); err == nil { return t, nil diff --git a/pkg/cli/token_test.go b/pkg/cli/token_test.go index 71596d1..105c3e4 100644 --- a/pkg/cli/token_test.go +++ b/pkg/cli/token_test.go @@ -69,7 +69,7 @@ func TestToken_Usage(t *testing.T) { if code != 2 { t.Errorf("ohne Unterbefehl: code=%d", code) } - for _, want := range []string{"tenant add", "invite", "node list", "node maintenance", "node resume", "license issue", "purge"} { + for _, want := range []string{"tenant add", "tenant update", "invite", "node list", "node maintenance", "node resume", "license issue", "purge"} { if !strings.Contains(stderr, want) { t.Errorf("Hilfe ohne %q:\n%s", want, stderr) } @@ -504,3 +504,160 @@ func TestToken_DataDirFromEnvironment(t *testing.T) { t.Errorf("code=%d stderr=%s", code, stderr) } } + +func TestToken_TenantUpdateDomains(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + + // Normalisiert anfügen, Vorhandenes nicht doppelt, Reihenfolge bleibt. + mustTok(t, dir, "tenant", "update", "--id=kunde", "--add-domains= Neu.DE. ,kunde.de,*.neu.de") + tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") + if got := strings.Join(tn.AllowedDomains, ","); got != "kunde.de,*.kunde.de,neu.de,*.neu.de" { + t.Errorf("nach add: %s", got) + } + + out := mustTok(t, dir, "tenant", "update", "--id=kunde", "--remove-domains=NEU.de,*.neu.de") + tn, _ = openStore(t, dir).GetTenant(context.Background(), "kunde") + if got := strings.Join(tn.AllowedDomains, ","); got != "kunde.de,*.kunde.de" { + t.Errorf("nach remove: %s", got) + } + if !strings.Contains(out, "Routen") { + t.Errorf("kein Hinweis auf wegfallende Routen:\n%s", out) + } + + // Tausch in einem Aufruf: erst entfernen, dann hinzufügen. + mustTok(t, dir, "tenant", "update", "--id=kunde", "--remove-domains=kunde.de,*.kunde.de", "--add-domains=anders.de") + tn, _ = openStore(t, dir).GetTenant(context.Background(), "kunde") + if got := strings.Join(tn.AllowedDomains, ","); got != "anders.de" { + t.Errorf("nach Tausch: %s", got) + } +} + +// Was nicht angegeben ist, bleibt – auch Status, offene Zahlung und Features. +func TestToken_TenantUpdateKeepsOtherFields(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde", "--tier=pro", "--max-routes=20", "--valid-until=2099-12-31", "--features=websockets") + mustTok(t, dir, "tenant", "payment", "--id=kunde", "--overdue-since=2026-01-15") + mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=suspended_payment") + + mustTok(t, dir, "tenant", "update", "--id=kunde", "--add-domains=neu.de") + + tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") + if tn.Name != "Kunde kunde" || tn.Email != "kunde@example.com" || tn.Tier != store.TierPro || tn.MaxRoutes != 20 || + tn.Status != store.TenantSuspendedPayment || tn.PaymentOverdueSince.IsZero() || + !tn.ValidUntil.Equal(time.Date(2099, 12, 31, 0, 0, 0, 0, time.UTC)) || + strings.Join(tn.Features, ",") != "websockets" { + t.Errorf("Felder verändert: %+v", tn) + } +} + +func TestToken_TenantUpdateFields(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde", "--features=websockets") + out := mustTok(t, dir, "tenant", "update", "--id=kunde", "--name=Neu GmbH", "--email=neu@x.de", "--tier=pro", + "--max-routes=-1", "--valid-until=2099-06-30", "--add-features=tcp_stream,websockets", "--remove-features=websockets") + tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") + if tn.Name != "Neu GmbH" || tn.Email != "neu@x.de" || tn.Tier != store.TierPro || tn.MaxRoutes != -1 || + !tn.ValidUntil.Equal(time.Date(2099, 6, 30, 0, 0, 0, 0, time.UTC)) { + t.Errorf("gespeichert: %+v", tn) + } + if got := strings.Join(tn.Features, ","); got != "tcp_stream,websockets" { + t.Errorf("Features: %s", got) + } + // Die Ausgabe zeigt den neuen Stand, auch das, was tenant list nicht zeigt. + for _, want := range []string{"kunde", "pro", "2099-06-30", "unbegrenzt", "tcp_stream", "kunde.de"} { + if !strings.Contains(out, want) { + t.Errorf("Ausgabe ohne %q:\n%s", want, out) + } + } + + // max-routes=0 ist ein gültiger Wert und nicht „nicht angegeben“. + mustTok(t, dir, "tenant", "update", "--id=kunde", "--max-routes=0") + tn, _ = openStore(t, dir).GetTenant(context.Background(), "kunde") + if tn.MaxRoutes != 0 { + t.Errorf("max_routes = %d", tn.MaxRoutes) + } +} + +// Verlängern nach Vertragsende: Sonst bliebe die Seite trotz neuem Datum auf +// „Hosting abgelaufen“, denn der Job setzt expired nie zurück. +func TestToken_TenantUpdateExtensionReactivates(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde", "--tier=pro", "--valid-until=2020-01-01") + mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=expired") + + // Andere Änderungen und ein Datum in der Vergangenheit reaktivieren nicht. + mustTok(t, dir, "tenant", "update", "--id=kunde", "--add-domains=neu.de") + mustTok(t, dir, "tenant", "update", "--id=kunde", "--valid-until=2021-01-01") + tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") + if tn.Status != store.TenantExpired { + t.Errorf("Status = %s, erwartet expired", tn.Status) + } + + out := mustTok(t, dir, "tenant", "update", "--id=kunde", "--valid-until=2099-01-01") + tn, _ = openStore(t, dir).GetTenant(context.Background(), "kunde") + if tn.Status != store.TenantActive { + t.Errorf("Status = %s, erwartet active", tn.Status) + } + if !strings.Contains(out, "active") { + t.Errorf("Reaktivierung nicht gemeldet:\n%s", out) + } + + // Quarantäne ist eine bewusste Sperre und bleibt. + mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=quarantined") + mustTok(t, dir, "tenant", "update", "--id=kunde", "--valid-until=2099-02-01") + tn, _ = openStore(t, dir).GetTenant(context.Background(), "kunde") + if tn.Status != store.TenantQuarantined { + t.Errorf("Status = %s, erwartet quarantined", tn.Status) + } +} + +// Community prüft valid_until nicht; ein altes Datum würde beim Wechsel auf +// ein kommerzielles Paket die Seiten sofort abschalten. +func TestToken_TenantUpdateCommercialNeedsFutureDate(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde", "--valid-until=2020-01-01") + + if code, _, stderr := tok(t, dir, "tenant", "update", "--id=kunde", "--tier=pro"); code != 1 || !strings.Contains(stderr, "--valid-until") { + t.Errorf("code=%d stderr=%q", code, stderr) + } + tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") + if tn.Tier != store.TierCommunity { + t.Errorf("Tier trotz Fehler geändert: %s", tn.Tier) + } + mustTok(t, dir, "tenant", "update", "--id=kunde", "--tier=pro", "--valid-until=2099-01-01") +} + +func TestToken_TenantUpdateErrors(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + tests := []struct { + name string + args []string + code int + }{ + {"ID fehlt", []string{"--add-domains=a.de"}, 2}, + {"keine Änderung", []string{"--id=kunde"}, 2}, + {"unbekannter Mandant", []string{"--id=fehlt", "--add-domains=a.de"}, 1}, + {"Domain ungültig", []string{"--id=kunde", "--add-domains=*.de"}, 1}, + {"Tippfehler beim Entfernen", []string{"--id=kunde", "--remove-domains=kunde.dee"}, 1}, + {"letzte Domain", []string{"--id=kunde", "--remove-domains=kunde.de,*.kunde.de"}, 1}, + {"Feature unbekannt", []string{"--id=kunde", "--add-features=teleport"}, 1}, + {"Feature nicht vorhanden", []string{"--id=kunde", "--remove-features=websockets"}, 1}, + {"Tier ungültig", []string{"--id=kunde", "--tier=gold"}, 1}, + {"Routen keine Zahl", []string{"--id=kunde", "--max-routes=viele"}, 2}, + {"Routen zu klein", []string{"--id=kunde", "--max-routes=-2"}, 1}, + {"Datum ungültig", []string{"--id=kunde", "--valid-until=morgen"}, 2}, + } + before, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") + for _, tt := range tests { + args := append([]string{"tenant", "update"}, tt.args...) + if code, _, stderr := tok(t, dir, args...); code != tt.code { + t.Errorf("%s: code=%d (erwartet %d) stderr=%q", tt.name, code, tt.code, stderr) + } + } + after, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") + if strings.Join(after.AllowedDomains, ",") != strings.Join(before.AllowedDomains, ",") || after.MaxRoutes != before.MaxRoutes { + t.Errorf("trotz Fehler geändert: %+v", after) + } +} diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index c480843..6a2aeb3 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -453,6 +453,36 @@ func TestRefreshLoopAppliesTenantChanges(t *testing.T) { }) } +// Der Regelfall beim Kunden: Der Dienst meldet eine Domain, die erst danach +// beim Mandanten eingetragen wird. Die Route erscheint ohne neue Meldung des +// Agents; entzieht man die Domain wieder, verschwindet sie. +func TestRefreshLoopAppliesDomainChanges(t *testing.T) { + s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond }) + createTenant(t, s, "kunde", "kunde.de") + agent := newAgent(t, s, issueToken(t, s, "kunde")) + agent.enroll(t, "n1") + agent.connect(t) + eventually(t, "Stream", func() bool { return s.hub.Connected("n1") }) + agent.do(t, http.MethodPost, "/api/v1/state", wpState("neu.de")) + eventually(t, "Verstoß: keine Route", func() bool { return providerConfig(t, s) == "{}" }) + + setDomains := func(domains ...string) { + t.Helper() + tn, err := s.store.GetTenant(ctx, "kunde") + if err != nil { + t.Fatal(err) + } + tn.AllowedDomains = domains + if _, err := s.store.UpdateTenant(ctx, tn); err != nil { + t.Fatal(err) + } + } + setDomains("kunde.de", "neu.de") + eventually(t, "Route nach Freigabe", func() bool { return strings.Contains(providerConfig(t, s), "neu.de") }) + setDomains("kunde.de") + eventually(t, "Route nach Entzug weg", func() bool { return providerConfig(t, s) == "{}" }) +} + func TestDirectory(t *testing.T) { s, _ := startServer(t) createTenant(t, s, "kunde", "kunde.de")