Files
sultan/pkg/cli/token.go
T
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

811 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cli
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"text/tabwriter"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/license"
"sultan/pkg/policy"
"sultan/pkg/server"
"sultan/pkg/store"
"sultan/pkg/stream"
)
const tokenUsage = `sultan token – Mandanten, Einladungen, Knoten und Lizenzen verwalten.
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!)
sultan token tenant payment --id ID --overdue-since JJJJ-MM-TT | --paid
(offene Zahlung → Mahnstufen nach 8/25/32 Tagen, nur kommerziell)
sultan token invite --tenant ID [--ttl 24h|7d] [--server-url https://…]
sultan token node list [--tenant ID]
sultan token node maintenance --id ID [--for 4h|0] [--reason TEXT] [--public-reason TEXT]
(Routen bleiben, Besucher sehen die Wartungsseite)
sultan token node drain --id ID (Routen werden entfernt, bis resume)
sultan token node resume --id ID (beendet Wartung und drain)
sultan token node delete --id ID
sultan token node command --id ID --cmd resync|rotate_key [--reason TEXT]
(wird beim nächsten Refresh bzw. Verbinden zugestellt)
sultan token license issue --tenant ID
sultan token purge (verbrauchte/abgelaufene Einladungen löschen)
Alle Befehle: --data-dir DIR (Default ./suldata)
`
// usageError: Bedienfehler (Exit-Code 2).
type usageError struct{ error }
type tokenEnv struct {
ctx context.Context
stdout, stderr io.Writer
}
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,
"tenant payment": tenantPayment,
"invite": invite,
"node list": nodeList,
"node drain": nodeSetStatus(store.NodeDrained),
"node maintenance": nodeMaintenance,
"node resume": nodeResume,
"node command": nodeCommand,
"node delete": nodeDelete,
"license issue": licenseIssue,
"purge": purge,
}
func runToken(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, tokenUsage)
return 2
}
if args[0] == "help" || args[0] == "-h" || args[0] == "--help" {
fmt.Fprint(stdout, tokenUsage)
return 0
}
name, rest := args[0], args[1:]
if (name == "tenant" || name == "node" || name == "license") && len(rest) > 0 {
name, rest = name+" "+rest[0], rest[1:]
}
cmd, ok := tokenCmds[name]
if !ok {
fmt.Fprintf(stderr, "unbekannter Befehl \"token %s\"\n\n%s", name, tokenUsage)
return 2
}
err := cmd(tokenEnv{ctx: ctx, stdout: stdout, stderr: stderr}, rest)
var ue usageError
switch {
case err == nil:
return 0
case errors.Is(err, flag.ErrHelp):
return 0
case errors.As(err, &ue):
fmt.Fprintln(stderr, err)
return 2
default:
fmt.Fprintln(stderr, "Fehler:", err)
return 1
}
}
// flags legt ein FlagSet mit --data-dir an.
func flags(name string, env tokenEnv) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet("sultan token "+name, flag.ContinueOnError)
fs.SetOutput(env.stderr)
dir := fs.String("data-dir", "./suldata", "Datenverzeichnis des Servers")
return fs, dir
}
func parse(fs *flag.FlagSet, args []string, required ...string) error {
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return err
}
return usageError{err}
}
if err := applyEnv(fs, os.Getenv); err != nil {
return usageError{err}
}
if fs.NArg() > 0 {
return usageError{fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))}
}
for _, name := range required {
if fs.Lookup(name).Value.String() == "" {
return usageError{fmt.Errorf("--%s fehlt", name)}
}
}
return nil
}
// openDataDir öffnet die DB eines bereits initialisierten Datenverzeichnisses.
// Ein nicht initialisiertes wird nie angelegt: Ein Tippfehler bei --data-dir
// würde sonst eine neue Server-Identität (Zertifikat, Master-Key) erzeugen.
func openDataDir(env tokenEnv, dir string) (*store.Store, error) {
for _, f := range []string{"sultan.db", "server.crt"} {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
return nil, fmt.Errorf("Datenverzeichnis %s nicht initialisiert (%s fehlt) – zuerst `sultan server` starten oder --data-dir prüfen", dir, f)
}
}
return store.Open(env.ctx, filepath.Join(dir, "sultan.db"))
}
func tenantAdd(env tokenEnv, args []string) error {
fs, dir := flags("tenant add", env)
id := fs.String("id", "", "Mandanten-ID (a–z, 0–9, - und _)")
name := fs.String("name", "", "Name")
email := fs.String("email", "", "Kontakt-E-Mail")
domains := fs.String("domains", "", "erlaubte Domains, kommagetrennt (z. B. kunde.de,*.kunde.de)")
tier := fs.String("tier", string(store.TierCommunity), "community, pro oder enterprise")
maxRoutes := fs.Int("max-routes", 5, "maximale Anzahl Routen (-1 = unbegrenzt)")
validUntil := fs.String("valid-until", "", "Laufzeitende JJJJ-MM-TT (Default: in einem Jahr)")
featuresRaw := fs.String("features", "", "Features, kommagetrennt (custom_middleware, websockets, tcp_stream)")
if err := parse(fs, args, "id", "name", "email", "domains"); err != nil {
return err
}
until := time.Now().AddDate(1, 0, 0)
if *validUntil != "" {
t, err := parseDate(*validUntil)
if err != nil {
return usageError{err}
}
until = t
}
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)
}
normalized, err := parseDomains(*domains)
if err != nil {
return err
}
features, err := parseFeatures(*featuresRaw)
if err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
t, err := st.CreateTenant(env.ctx, store.Tenant{
ID: *id, Name: *name, Email: *email, Tier: store.Tier(*tier), MaxRoutes: *maxRoutes,
AllowedDomains: normalized, ValidUntil: until, Features: features,
})
if err != nil {
return err
}
fmt.Fprintf(env.stdout, "Mandant %s angelegt (%s, gültig bis %s)\n", t.ID, t.Tier, t.ValidUntil.Format(time.DateOnly))
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
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("Datum %q nicht lesbar (JJJJ-MM-TT)", s)
}
func tenantList(env tokenEnv, args []string) error {
fs, dir := flags("tenant list", env)
if err := parse(fs, args); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
tenants, err := st.ListTenants(env.ctx)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tTIER\tSTATUS\tMAX_ROUTES\tVALID_UNTIL\tPAYMENT\tDOMAINS")
for _, t := range tenants {
payment := "-"
if !t.PaymentOverdueSince.IsZero() {
payment = "offen seit " + t.PaymentOverdueSince.UTC().Format(time.DateOnly)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", t.ID, t.Name, t.Tier, t.Status, t.MaxRoutes,
t.ValidUntil.Format(time.DateOnly), payment, strings.Join(t.AllowedDomains, ","))
}
return tw.Flush()
}
func tenantSetStatus(env tokenEnv, args []string) error {
fs, dir := flags("tenant set-status", env)
id := fs.String("id", "", "Mandanten-ID")
status := fs.String("status", "", "active, suspended_payment, quarantined oder expired")
if err := parse(fs, args, "id", "status"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.SetTenantStatus(env.ctx, *id, store.TenantStatus(*status)); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Mandant %s: Status %s (wirkt beim nächsten Refresh des Servers)\n", *id, *status)
return nil
}
// tenantPayment setzt bzw. löscht die offene Zahlung (bis zur Admin-API).
func tenantPayment(env tokenEnv, args []string) error {
fs, dir := flags("tenant payment", env)
id := fs.String("id", "", "Mandanten-ID")
since := fs.String("overdue-since", "", "Fälligkeitsdatum der offenen Zahlung (JJJJ-MM-TT)")
paid := fs.Bool("paid", false, "Zahlung eingegangen – Mahnstufe zurücksetzen")
if err := parse(fs, args, "id"); err != nil {
return err
}
if (*since == "") == !*paid {
return usageError{errors.New("genau eines von --overdue-since oder --paid angeben")}
}
var due time.Time
if *since != "" {
t, err := parseDate(*since)
if err != nil {
return usageError{err}
}
due = t
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.SetTenantPayment(env.ctx, *id, due); err != nil {
return err
}
if *paid {
fmt.Fprintf(env.stdout, "Mandant %s: Zahlung eingegangen – Mahnstufe zurückgesetzt\n", *id)
} else {
fmt.Fprintf(env.stdout, "Mandant %s: Zahlung offen seit %s (Mahnstufen ab 8/25/32 Tagen)\n", *id, due.Format(time.DateOnly))
}
return nil
}
func tenantDelete(env tokenEnv, args []string) error {
fs, dir := flags("tenant delete", env)
id := fs.String("id", "", "Mandanten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.DeleteTenant(env.ctx, *id); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Mandant %s samt Knoten und Einladungen gelöscht\n", *id)
return nil
}
func invite(env tokenEnv, args []string) error {
fs, dir := flags("invite", env)
tenant := fs.String("tenant", "", "Mandanten-ID")
ttlRaw := fs.String("ttl", "24h", "Gültigkeit der Einladung (z. B. 2h, 24h, 7d)")
serverURL := fs.String("server-url", "", "Server-Adresse für diese Einladung (Default: sultan server --public-url)")
if err := parse(fs, args, "tenant"); err != nil {
return err
}
ttl, err := parseTTL(*ttlRaw)
if err != nil {
return usageError{err}
}
if *serverURL != "" {
u, err := crypto.ValidateServerURL(*serverURL)
if err != nil {
return usageError{err}
}
*serverURL = u
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if *serverURL == "" {
urls, err := server.LoadPublicURLs(env.ctx, st)
if err != nil {
return err
}
if len(urls) > 0 {
*serverURL = urls[0] // die übrigen bekommt der Agent per config-Befehl
}
}
cert, err := crypto.LoadOrCreateServerCert(*dir)
if err != nil {
return err
}
tok, err := crypto.NewEnrollmentToken(cert.Fingerprint, *serverURL)
if err != nil {
return err
}
expires := time.Now().Add(ttl)
if err := st.AddEnrollmentToken(env.ctx, tok.Secret, *tenant, expires); err != nil {
return err
}
fmt.Fprintln(env.stdout, tok.String())
fmt.Fprintf(env.stderr, "Einladung für Mandant %s, einmalig gültig bis %s.\n", *tenant, expires.Format(time.DateTime))
if tok.ServerURL != "" {
fmt.Fprintf(env.stderr, "Server-Adresse %s ist enthalten. Auf dem Agent-Host:\n SULTAN_TOKEN=<token> sultan agent\n", tok.ServerURL)
} else {
fmt.Fprint(env.stderr, "Auf dem Agent-Host: sultan agent --server=https://<ingress>:8443 --token=<token>\n"+
"Tipp: Mit `sultan server --public-url=https://…` (oder invite --server-url) steckt die Adresse in der Einladung.\n")
}
return nil
}
// parseTTL versteht Go-Dauern (2h, 90m) und zusätzlich Tage (7d).
func parseTTL(s string) (time.Duration, error) {
var d time.Duration
if days, ok := strings.CutSuffix(s, "d"); ok {
n, err := strconv.Atoi(days)
if err != nil {
return 0, fmt.Errorf("--ttl %q nicht lesbar", s)
}
d = time.Duration(n) * 24 * time.Hour
} else {
var err error
if d, err = time.ParseDuration(s); err != nil {
return 0, fmt.Errorf("--ttl %q nicht lesbar (z. B. 2h, 24h, 7d)", s)
}
}
if d <= 0 {
return 0, fmt.Errorf("--ttl muss positiv sein")
}
return d, nil
}
func nodeList(env tokenEnv, args []string) error {
fs, dir := flags("node list", env)
tenant := fs.String("tenant", "", "nur Knoten dieses Mandanten")
if err := parse(fs, args); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
nodes, err := st.ListNodes(env.ctx, *tenant)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTENANT\tHOSTNAME\tSTATUS\tMAINTENANCE\tLEASE\tIP\tLAST_SEEN")
for _, n := range nodes {
lease := "-"
if l, err := st.GetLease(env.ctx, n.ID); err == nil {
lease = "bis " + l.ValidUntil.Local().Format("2006-01-02 15:04")
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", n.ID, n.TenantID, n.Hostname, n.Status,
maintenanceLabel(n.Maintenance), lease, n.IPAddress, n.LastSeen.Local().Format(time.DateTime))
}
return tw.Flush()
}
func nodeSetStatus(status store.NodeStatus) tokenCmd {
return func(env tokenEnv, args []string) error {
fs, dir := flags("node", env)
id := fs.String("id", "", "Knoten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.SetNodeStatus(env.ctx, *id, status); err != nil {
return err
}
notifyAgent(env, st, *id, stream.CmdDrain, "")
fmt.Fprintf(env.stdout, "Knoten %s: Status %s (wirkt beim nächsten Refresh des Servers)\n", *id, status)
return nil
}
}
func maintenanceLabel(m *store.Maintenance) string {
switch {
case m == nil:
return "-"
case !m.Active(time.Now()):
return "abgelaufen"
case m.Until.IsZero():
return "unbefristet"
default:
return "bis " + m.Until.Local().Format("2006-01-02 15:04")
}
}
// parseMaintenanceFor versteht wie --ttl Go-Dauern und Tage; 0 = unbefristet.
func parseMaintenanceFor(s string) (time.Duration, error) {
if s == "0" {
return 0, nil
}
return parseTTL(s)
}
func nodeMaintenance(env tokenEnv, args []string) error {
fs, dir := flags("node maintenance", env)
id := fs.String("id", "", "Knoten-ID")
forRaw := fs.String("for", "4h", "Dauer (z. B. 30m, 4h, 2d; 0 = unbefristet)")
reason := fs.String("reason", "", "interner Grund (Log, Monitoring)")
publicReason := fs.String("public-reason", "", "öffentlicher Grund – ersetzt den Standardtext der Wartungsseite")
if err := parse(fs, args, "id"); err != nil {
return err
}
d, err := parseMaintenanceFor(*forRaw)
if err != nil {
return usageError{fmt.Errorf("--for: %w", err)}
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
m := store.Maintenance{Reason: *reason, PublicReason: *publicReason}
if d > 0 {
m.Until = time.Now().Add(d)
}
if err := st.SetNodeMaintenance(env.ctx, *id, m); err != nil {
return err
}
notifyAgent(env, st, *id, stream.CmdMaintenance, *reason)
fmt.Fprintf(env.stdout, "Knoten %s in Wartung (%s) – wirkt beim nächsten Refresh des Servers (≤ 1 min)\n",
*id, maintenanceLabel(&store.Maintenance{Since: time.Now(), Until: m.Until}))
return nil
}
// nodeResume beendet Wartung und drain.
func nodeResume(env tokenEnv, args []string) error {
fs, dir := flags("node resume", env)
id := fs.String("id", "", "Knoten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
n, err := st.GetNode(env.ctx, *id)
if err != nil {
return err
}
if n.Status == store.NodeDrained {
// online setzt erst der Stream.
if err := st.SetNodeStatus(env.ctx, *id, store.NodeOffline); err != nil {
return err
}
}
if n.Maintenance != nil {
if err := st.ClearNodeMaintenance(env.ctx, *id); err != nil {
return err
}
}
notifyAgent(env, st, *id, stream.CmdResume, "")
fmt.Fprintf(env.stdout, "Knoten %s: Wartung/drain beendet – wirkt beim nächsten Refresh des Servers\n", *id)
return nil
}
// notifyAgent reiht einen Hinweis für den Agenten ein (bester Versuch).
func notifyAgent(env tokenEnv, st *store.Store, nodeID, command, reason string) {
if err := st.EnqueueNodeCommand(env.ctx, nodeID, command, reason); err != nil {
fmt.Fprintln(env.stderr, "Hinweis an den Agenten nicht eingereiht:", err)
}
}
// nodeCommand reiht einen Befehl ein. Nur die aktiven Befehle sind erlaubt;
// drain/maintenance/resume erzeugen ihre Hinweise selbst.
func nodeCommand(env tokenEnv, args []string) error {
fs, dir := flags("node command", env)
id := fs.String("id", "", "Knoten-ID")
cmd := fs.String("cmd", "", "resync oder rotate_key")
reason := fs.String("reason", "", "Grund (Log)")
if err := parse(fs, args, "id", "cmd"); err != nil {
return err
}
if *cmd != stream.CmdResync && *cmd != stream.CmdRotateKey {
return usageError{fmt.Errorf("--cmd %q: erlaubt sind resync und rotate_key", *cmd)}
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.EnqueueNodeCommand(env.ctx, *id, *cmd, *reason); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Befehl %s für %s eingereiht – Zustellung beim nächsten Refresh bzw. Verbinden\n", *cmd, *id)
return nil
}
func nodeDelete(env tokenEnv, args []string) error {
fs, dir := flags("node delete", env)
id := fs.String("id", "", "Knoten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.DeleteNode(env.ctx, *id); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Knoten %s gelöscht (Agent muss neu eingeladen werden)\n", *id)
return nil
}
func licenseIssue(env tokenEnv, args []string) error {
fs, dir := flags("license issue", env)
tenant := fs.String("tenant", "", "Mandanten-ID")
if err := parse(fs, args, "tenant"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
t, err := st.GetTenant(env.ctx, *tenant)
if err != nil {
return err
}
master, err := auth.ReadPrivateKey(filepath.Join(*dir, "master.key"))
if err != nil {
return err
}
l, err := (&license.Issuer{Key: master}).Issue(license.License{
TenantID: t.ID,
Tier: license.Tier(t.Tier),
MaxRoutes: t.MaxRoutes,
AllowedDomains: t.AllowedDomains,
ValidUntil: t.ValidUntil,
})
if err != nil {
return err
}
out, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
fmt.Fprintln(env.stdout, string(out))
return nil
}
func purge(env tokenEnv, args []string) error {
fs, dir := flags("purge", env)
if err := parse(fs, args); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
n, err := st.PurgeEnrollmentTokens(env.ctx, 0)
if err != nil {
return err
}
fmt.Fprintf(env.stdout, "%d Einladung(en) gelöscht\n", n)
return nil
}