Compare commits

1 Commits
Author SHA1 Message Date
brandClaude Opus 5.5 c2d6dd694f sultan token tenant update: Mandanten nachträglich ändern
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 <noreply@anthropic.com>
2026-11-02 18:32:28 +01:00
7 changed files with 412 additions and 24 deletions

No files matched your search

+3 -2
View File
@@ -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_<secret>.<fingerprint>[.<server-url>]`; 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.<domain>/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.<lang>`; Ü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.
+1 -1
View File
@@ -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_<FLAG>` 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.
+13
View File
@@ -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://<backend-ip>:<veröffentlichter Port>`. Prüfen vom **Ingress** aus:
+18 -2
View File
@@ -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 |
+189 -18
View File
@@ -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)
normalized, err := parseDomains(*domains)
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)
}
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)
return err
}
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
+158 -1
View File
@@ -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)
}
}
+30
View File
@@ -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")