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>
811 lines
25 KiB
Go
811 lines
25 KiB
Go
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
|
||
}
|