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= sultan agent\n", tok.ServerURL) } else { fmt.Fprint(env.stderr, "Auf dem Agent-Host: sultan agent --server=https://:8443 --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 }