Files
sultan/pkg/cli/token.go
T
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

640 lines
19 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"
"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 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 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)
}
var normalized []string
for _, d := range strings.Split(*domains, ",") {
n, err := license.NormalizeDomain(d)
if err != nil {
return fmt.Errorf("Domain %q ungültig (Beispiele: kunde.de, *.kunde.de; keine Wildcard auf TLD)", strings.TrimSpace(d))
}
normalized = append(normalized, n)
}
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)
}
}
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
}
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
}