software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10298 -480

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"sultan/pkg/agent"
)
func runAgent(ctx context.Context, args []string, stderr io.Writer) int {
if len(args) > 0 && args[0] == "maintenance" {
return runAgentMaintenance(ctx, args[1:], stderr)
}
cfg, err := parseAgentFlags(args, stderr, os.Getenv)
if errors.Is(err, flag.ErrHelp) {
return 0
}
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil))
a, err := agent.New(cfg)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
if err := a.Run(ctx); err != nil {
fmt.Fprintln(stderr, "Agent beendet mit Fehler:", err)
return 1
}
return 0
}
// parseAgentFlags liest die Flags von `sultan agent`. SULTAN_TOKEN und
// SULTAN_SERVER dienen als Defaults (curl | sh, Compose; Argumente wären über
// ps für andere Benutzer sichtbar) – Flags gewinnen.
func parseAgentFlags(args []string, output io.Writer, getenv func(string) string) (agent.Config, error) {
fs := flag.NewFlagSet("sultan agent", flag.ContinueOnError)
fs.SetOutput(output)
var cfg agent.Config
fs.StringVar(&cfg.ServerURL, "server", "", "Server-Adresse, z. B. https://ingress.example.com:8443 (Override; sonst aus der Einladung)")
fs.StringVar(&cfg.Token, "token", "", "Einladung von `sultan token invite` (nur beim ersten Start)")
fs.StringVar(&cfg.DataDir, "data-dir", "./suldata", "Datenverzeichnis (node.key, node.json)")
fs.StringVar(&cfg.NodeID, "node-id", "", "Knoten-ID (Default: aus dem Hostnamen)")
fs.StringVar(&cfg.DockerSocket, "docker-socket", "/var/run/docker.sock", `Docker-Socket ("none" = ohne Docker)`)
fs.StringVar(&cfg.BackendHost, "backend-host", "", "Adresse, unter der der Ingress die Dienste erreicht (Default: automatisch)")
fs.StringVar(&cfg.BackendInterface, "backend-interface", "", "alternativ: IP dieses Interfaces verwenden (z. B. wt0 für NetBird)")
fs.StringVar(&cfg.ConfigDir, "config-dir", "/etc/sultan.d", `Dienste als YAML (*.yaml) für Hosts ohne Docker-Labels ("none" = aus)`)
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
return agent.Config{}, err
}
if err := applyEnv(fs, getenv); err != nil {
return agent.Config{}, err
}
if fs.NArg() > 0 {
return agent.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
}
return cfg, nil
}
// runAgentMaintenance: `sultan agent maintenance on|off [--for] [--reason]
// [--public-reason]` – meldet Wartung dieses Knotens beim Server an oder ab,
// z. B. aus einem Patch- oder Reboot-Skript.
func runAgentMaintenance(ctx context.Context, args []string, stderr io.Writer) int {
if len(args) == 0 || (args[0] != "on" && args[0] != "off") {
fmt.Fprintln(stderr, "Aufruf: sultan agent maintenance on|off [--for 4h|0] [--reason TEXT] [--public-reason TEXT] [--data-dir DIR]")
return 2
}
on := args[0] == "on"
fs := flag.NewFlagSet("sultan agent maintenance", flag.ContinueOnError)
fs.SetOutput(stderr)
dataDir := fs.String("data-dir", "./suldata", "Datenverzeichnis des Agenten")
server := fs.String("server", "", "Server-Adresse (Override)")
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")
annotateEnv(fs)
if err := fs.Parse(args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if err := applyEnv(fs, os.Getenv); err != nil {
fmt.Fprintln(stderr, err)
return 2
}
d, err := parseMaintenanceFor(*forRaw)
if err != nil {
fmt.Fprintln(stderr, "--for:", err)
return 2
}
a, err := agent.New(agent.Config{DataDir: *dataDir, ServerURL: *server, Logger: slog.New(slog.NewTextHandler(stderr, nil))})
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
if err := a.SetMaintenance(ctx, on, d, *reason, *publicReason); err != nil {
fmt.Fprintln(stderr, "Fehler:", err)
return 1
}
if on {
fmt.Fprintln(stderr, "Wartung angemeldet – Besucher sehen jetzt die Wartungsseite.")
} else {
fmt.Fprintln(stderr, "Wartung beendet.")
}
return 0
}
+97
View File
@@ -0,0 +1,97 @@
package cli
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
)
func TestAgentFlags(t *testing.T) {
cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "./suldata" || cfg.DockerSocket != "/var/run/docker.sock" || cfg.ServerURL != "" || cfg.Token != "" ||
cfg.ConfigDir != "/etc/sultan.d" {
t.Errorf("Defaults: %+v", cfg)
}
cfg, err = parseAgentFlags([]string{
"--server=https://ingress:8443", "--token", "sltn_enroll_x.y", "--data-dir=/srv/sul",
"--node-id=pi", "--docker-socket=none", "--backend-host=100.64.0.42", "--backend-interface=wt0",
"--config-dir=/srv/sultan.d",
}, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.ServerURL != "https://ingress:8443" || cfg.Token != "sltn_enroll_x.y" || cfg.DataDir != "/srv/sul" ||
cfg.NodeID != "pi" || cfg.DockerSocket != "none" || cfg.BackendHost != "100.64.0.42" || cfg.BackendInterface != "wt0" ||
cfg.ConfigDir != "/srv/sultan.d" {
t.Errorf("Overrides: %+v", cfg)
}
if _, err := parseAgentFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil {
t.Error("unbekanntes Flag akzeptiert")
}
}
func TestRun_AgentNotEnrolled(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
code, _, stderr := run(context.Background(), "agent", "--data-dir="+dir, "--docker-socket=none")
if code != 1 || !strings.Contains(stderr, "--token") {
t.Errorf("code=%d stderr=%q", code, stderr)
}
}
func TestRun_AgentBadConfig(t *testing.T) {
code, _, stderr := run(context.Background(), "agent", "--server=http://ingress:8443", "--token=x")
if code != 2 || !strings.Contains(stderr, "https") {
t.Errorf("http-URL: code=%d stderr=%q", code, stderr)
}
if code, _, _ := run(context.Background(), "agent", "--help"); code != 0 {
t.Errorf("--help: code=%d", code)
}
}
func noEnv(string) string { return "" }
// Token und Server auch per Umgebung (curl | sh, Compose) – Flags gewinnen.
func TestAgentFlags_Environment(t *testing.T) {
env := map[string]string{"SULTAN_TOKEN": "sltn_enroll_env", "SULTAN_SERVER": "https://env:8443"}
getenv := func(k string) string { return env[k] }
cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, getenv)
if err != nil || cfg.Token != "sltn_enroll_env" || cfg.ServerURL != "https://env:8443" {
t.Errorf("aus Umgebung: %+v %v", cfg, err)
}
cfg, _ = parseAgentFlags([]string{"--token=flag", "--server=https://flag:8443"}, &bytes.Buffer{}, getenv)
if cfg.Token != "flag" || cfg.ServerURL != "https://flag:8443" {
t.Errorf("Flags müssen gewinnen: %+v", cfg)
}
}
func TestRun_AgentMaintenanceCommand(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
code, _, stderr := run(context.Background(), "agent", "maintenance", "on", "--data-dir="+dir)
if code != 1 || !strings.Contains(stderr, "registriert") {
t.Errorf("nicht registriert: code=%d stderr=%q", code, stderr)
}
for _, args := range [][]string{
{"agent", "maintenance"},
{"agent", "maintenance", "vielleicht"},
{"agent", "maintenance", "on", "--for=bald"},
} {
if code, _, _ := run(context.Background(), args...); code != 2 {
t.Errorf("%v: code=%d", args, code)
}
}
}
func TestAgentFlags_EnvironmentAll(t *testing.T) {
cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, envMap(map[string]string{
"SULTAN_DATA_DIR": "/srv/agent", "SULTAN_NODE_ID": "pi", "SULTAN_CONFIG_DIR": "none", "SULTAN_BACKEND_INTERFACE": "wt0",
}))
if err != nil || cfg.DataDir != "/srv/agent" || cfg.NodeID != "pi" || cfg.ConfigDir != "none" || cfg.BackendInterface != "wt0" {
t.Errorf("aus Umgebung: %+v %v", cfg, err)
}
}
+38 -11
View File
@@ -1,8 +1,8 @@
// Package cli implementiert die Befehlszeile von sultan:
//
// sultan server Registry, Stream-Hub, Policy-Engine, Traefik-Provider
// sultan agent (noch nicht implementiert)
// sultan token (noch nicht implementiert)
// sultan agent Agent: Enrollment, Stream, Docker-Watcher
// sultan token Mandanten, Einladungen, Knoten, Lizenzen verwalten
// sultan version
//
// Run ist ohne Prozessgrenzen testbar; main ruft nur Run auf.
@@ -15,6 +15,8 @@ import (
"fmt"
"io"
"log/slog"
"os"
"strings"
"time"
"sultan/pkg/server"
@@ -27,8 +29,9 @@ const usage = `sultan – verteiltes Ingress-Routing mit ZeroTrust-Service-Disco
Befehle:
sultan server Server starten (Registry, Agent-Port, Traefik-Provider)
sultan agent Agent starten (noch nicht implementiert)
sultan token Mandanten, Einladungen, Lizenzen verwalten (noch nicht implementiert)
sultan agent Agent starten (Docker-Host oder Bare-Metal)
sultan token Mandanten, Einladungen, Knoten und Lizenzen verwalten
sultan dump-landing eingebaute Lander-Seiten als Vorlagen ausgeben
sultan version Version ausgeben
sultan help diese Hilfe
@@ -51,9 +54,12 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return 0
case "server":
return runServer(ctx, args[1:], stdout, stderr)
case "agent", "token":
fmt.Fprintf(stderr, "sultan %s: noch nicht implementiert (siehe Roadmap)\n", args[0])
return 1
case "token":
return runToken(ctx, args[1:], stdout, stderr)
case "agent":
return runAgent(ctx, args[1:], stderr)
case "dump-landing":
return runDump(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "unbekannter Befehl %q\n\n%s", args[0], usage)
return 2
@@ -61,7 +67,7 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int {
cfg, err := parseServerFlags(args, stderr)
cfg, err := parseServerFlags(args, stderr, os.Getenv)
if errors.Is(err, flag.ErrHelp) {
return 0
}
@@ -83,8 +89,8 @@ func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int
}
// parseServerFlags liest die Flags von `sultan server`; Defaults wie in
// server.Config.
func parseServerFlags(args []string, output io.Writer) (server.Config, error) {
// server.Config. Jedes Flag ist auch als SULTAN_<FLAG> setzbar (Flags gewinnen).
func parseServerFlags(args []string, output io.Writer, getenv func(string) string) (server.Config, error) {
fs := flag.NewFlagSet("sultan server", flag.ContinueOnError)
fs.SetOutput(output)
var cfg server.Config
@@ -92,13 +98,34 @@ func parseServerFlags(args []string, output io.Writer) (server.Config, error) {
fs.StringVar(&cfg.AgentAddr, "agent-addr", ":8443", "Adresse des Agent-Ports (nur TLS)")
fs.StringVar(&cfg.ProviderAddr, "provider-addr", "127.0.0.1:8080",
"Adresse für Traefik (/v1/traefik-config, Klartext – im Container :8080 und nur auf 127.0.0.1 veröffentlichen)")
fs.StringVar(&cfg.InterceptorURL, "interceptor-url", "http://127.0.0.1:8081", "Adresse des Sultan-Interceptors/Landers")
fs.Var((*stringList)(&cfg.PublicURLs), "public-url", "Adresse, unter der Agents den Server erreichen (z. B. https://ingress.example.com:8443); mehrfach angebbar (öffentlich, NetBird …); wird gemerkt, die erste steckt in Einladungen")
fs.StringVar(&cfg.LanderAddr, "lander-addr", "127.0.0.1:8081", `Adresse des eingebauten Landers (Hinweis-/Wartungsseiten); "none" = externer Lander`)
fs.StringVar(&cfg.InterceptorURL, "interceptor-url", "", "URL, unter der Traefik den Lander erreicht (Default: --lander-addr)")
fs.StringVar(&cfg.HosterName, "hoster-name", "", "Name des Hosting-Anbieters (Einladungsseiten)")
fs.StringVar(&cfg.HosterURL, "hoster-url", "", "Angebotsseite des Hosting-Anbieters")
fs.StringVar(&cfg.ProURL, "pro-url", "", "Pro-Angebot, verlinkt auf Community-Wartungsseiten")
fs.DurationVar(&cfg.RefreshInterval, "refresh", time.Minute, "Intervall der Neubewertung aller Knoten")
fs.Var((*stringList)(&cfg.SlackWebhooks), "notify-slack", "Slack-kompatibler Webhook für Benachrichtigungen (mehrfach angebbar)")
fs.Var((*stringList)(&cfg.NtfyURLs), "notify-ntfy", "ntfy-Topic-URL für Benachrichtigungen, z. B. https://ntfy.sh/sultan (mehrfach angebbar)")
fs.StringVar(&cfg.NtfyToken, "notify-ntfy-token", "", "Access-Token für ntfy")
fs.DurationVar(&cfg.OfflineAlertAfter, "offline-alert-after", 15*time.Minute, "Alarm, wenn ein Knoten so lange getrennt ist (nicht bei Wartung)")
fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)")
fs.DurationVar(&cfg.LeaseDuration, "lease-duration", 0, "feste Lease-Laufzeit für alle Knoten (0 = je Tier: Community 14 Tage, kommerziell 7 Tage)")
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
return server.Config{}, err
}
if err := applyEnv(fs, getenv); err != nil {
return server.Config{}, err
}
if fs.NArg() > 0 {
return server.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
}
return cfg, nil
}
// stringList ist ein mehrfach angebbares String-Flag.
type stringList []string
func (l *stringList) String() string { return strings.Join(*l, ",") }
func (l *stringList) Set(v string) error { *l = append(*l, v); return nil }
+99 -17
View File
@@ -38,41 +38,42 @@ func TestRun_Version(t *testing.T) {
}
}
func TestRun_NotYetImplemented(t *testing.T) {
for _, cmd := range []string{"agent", "token"} {
code, _, stderr := run(context.Background(), cmd)
if code != 1 || !strings.Contains(stderr, "noch nicht implementiert") {
t.Errorf("%s: code=%d stderr=%q", cmd, code, stderr)
}
}
}
func TestServerFlags(t *testing.T) {
cfg, err := parseServerFlags(nil, &bytes.Buffer{})
cfg, err := parseServerFlags(nil, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "./suldata" || cfg.AgentAddr != ":8443" || cfg.ProviderAddr != "127.0.0.1:8080" ||
cfg.InterceptorURL != "http://127.0.0.1:8081" || cfg.RefreshInterval != time.Minute {
cfg.LanderAddr != "127.0.0.1:8081" || cfg.InterceptorURL != "" || cfg.RefreshInterval != time.Minute {
t.Errorf("Defaults: %+v", cfg)
}
cfg, err = parseServerFlags([]string{
"--data-dir=/srv/sul", "--agent-addr", ":9443", "--provider-addr=:8080",
"--interceptor-url=http://lander:8081", "--refresh=30s",
}, &bytes.Buffer{})
"--interceptor-url=http://lander:8081", "--refresh=30s", "--lander-addr=none",
"--hoster-name=Beispiel-Hosting", "--hoster-url=https://hoster.example", "--pro-url=https://hoster.example/pro",
"--public-url=https://ingress.example:8443", "--public-url=https://100.64.0.1:8443",
"--lease-duration=1h",
"--notify-slack=https://hooks.example/a", "--notify-slack=https://hooks.example/b",
"--notify-ntfy=https://ntfy.example/sultan", "--notify-ntfy-token=tk_x", "--offline-alert-after=10m",
}, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "/srv/sul" || cfg.AgentAddr != ":9443" || cfg.ProviderAddr != ":8080" ||
cfg.InterceptorURL != "http://lander:8081" || cfg.RefreshInterval != 30*time.Second {
cfg.InterceptorURL != "http://lander:8081" || cfg.RefreshInterval != 30*time.Second || cfg.LanderAddr != "none" ||
cfg.HosterName != "Beispiel-Hosting" || cfg.HosterURL != "https://hoster.example" || cfg.ProURL != "https://hoster.example/pro" ||
strings.Join(cfg.PublicURLs, ",") != "https://ingress.example:8443,https://100.64.0.1:8443" || cfg.LeaseDuration != time.Hour ||
strings.Join(cfg.SlackWebhooks, ",") != "https://hooks.example/a,https://hooks.example/b" ||
strings.Join(cfg.NtfyURLs, ",") != "https://ntfy.example/sultan" || cfg.NtfyToken != "tk_x" ||
cfg.OfflineAlertAfter != 10*time.Minute {
t.Errorf("Overrides: %+v", cfg)
}
if _, err := parseServerFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}); err == nil {
if _, err := parseServerFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil {
t.Error("unbekanntes Flag akzeptiert")
}
if _, err := parseServerFlags([]string{"überzählig"}, &bytes.Buffer{}); err == nil {
if _, err := parseServerFlags([]string{"überzählig"}, &bytes.Buffer{}, noEnv); err == nil {
t.Error("überzähliges Argument akzeptiert")
}
}
@@ -99,7 +100,7 @@ func TestRun_ServerStartsAndStops(t *testing.T) {
}
done := make(chan result, 1)
go func() {
code, _, stderr := run(ctx, "server", "--data-dir="+dir, "--agent-addr=127.0.0.1:0", "--provider-addr=127.0.0.1:0")
code, _, stderr := run(ctx, "server", "--data-dir="+dir, "--agent-addr=127.0.0.1:0", "--provider-addr=127.0.0.1:0", "--lander-addr=127.0.0.1:0")
done <- result{code, stderr}
}()
@@ -137,3 +138,84 @@ func TestRun_ServerStartError(t *testing.T) {
t.Errorf("unbrauchbares Datenverzeichnis: code=%d stderr=%q", code, stderr)
}
}
func TestRun_DumpLanding(t *testing.T) {
dir := filepath.Join(t.TempDir(), "landing")
code, stdout, stderr := run(context.Background(), "dump-landing", "--out="+dir)
if code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr)
}
if !strings.Contains(stdout, "maintenance.html") {
t.Errorf("Ausgabe ohne Dateiliste: %q", stdout)
}
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil {
t.Error("README.txt fehlt")
}
if code, _, stderr := run(context.Background(), "dump-landing", "--out="+dir); code != 1 || !strings.Contains(stderr, "--force") {
t.Errorf("ohne --force: code=%d stderr=%q", code, stderr)
}
if code, _, _ := run(context.Background(), "dump-landing", "--out="+dir, "--force"); code != 0 {
t.Errorf("mit --force: code=%d", code)
}
if code, _, _ := run(context.Background(), "dump-landing", "überzählig"); code != 2 {
t.Errorf("überzähliges Argument: code=%d", code)
}
}
func TestDumpLandingDefaultDir(t *testing.T) {
out, err := parseDumpFlags(nil, &bytes.Buffer{})
if err != nil || out.dir != "./suldata/landing" || out.force {
t.Errorf("Defaults: %+v %v", out, err)
}
}
func envMap(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
// Jedes Flag ist auch als SULTAN_<FLAG> setzbar; Flags gewinnen, Listen
// sind kommagetrennt und werden durch ein Flag ersetzt, nicht ergänzt.
func TestServerFlags_Environment(t *testing.T) {
env := envMap(map[string]string{
"SULTAN_DATA_DIR": "/srv/sul",
"SULTAN_PUBLIC_URL": "https://a.example:8443, https://100.64.0.1:8443",
"SULTAN_NOTIFY_NTFY_TOKEN": "tk_geheim",
"SULTAN_REFRESH": "30s",
"SULTAN_ENROLL_LIMIT": "5",
})
cfg, err := parseServerFlags(nil, &bytes.Buffer{}, env)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "/srv/sul" || strings.Join(cfg.PublicURLs, ",") != "https://a.example:8443,https://100.64.0.1:8443" ||
cfg.NtfyToken != "tk_geheim" || cfg.RefreshInterval != 30*time.Second || cfg.EnrollLimit != 5 {
t.Errorf("aus Umgebung: %+v", cfg)
}
cfg, err = parseServerFlags([]string{"--public-url=https://c.example:8443", "--data-dir=/anders"}, &bytes.Buffer{}, env)
if err != nil {
t.Fatal(err)
}
if strings.Join(cfg.PublicURLs, ",") != "https://c.example:8443" || cfg.DataDir != "/anders" {
t.Errorf("Flags müssen gewinnen (Listen ersetzen): %v %q", cfg.PublicURLs, cfg.DataDir)
}
if _, err := parseServerFlags(nil, &bytes.Buffer{}, envMap(map[string]string{"SULTAN_REFRESH": "bald"})); err == nil ||
!strings.Contains(err.Error(), "SULTAN_REFRESH") {
t.Errorf("fehlerhafte Variable nicht benannt: %v", err)
}
var help bytes.Buffer
parseServerFlags([]string{"--help"}, &help, noEnv)
if !strings.Contains(help.String(), "SULTAN_PUBLIC_URL") || !strings.Contains(help.String(), "SULTAN_NOTIFY_NTFY_TOKEN") {
t.Errorf("Hilfe ohne Variablennamen:\n%s", help.String())
}
}
func TestServerFlags_EnrollLimit(t *testing.T) {
cfg, _ := parseServerFlags(nil, &bytes.Buffer{}, noEnv)
if cfg.EnrollLimit != 10 {
t.Errorf("Default %d", cfg.EnrollLimit)
}
cfg, _ = parseServerFlags([]string{"--enroll-limit=-1"}, &bytes.Buffer{}, noEnv) // -1 = unbegrenzt
if cfg.EnrollLimit != -1 {
t.Errorf("--enroll-limit=-1: %d", cfg.EnrollLimit)
}
}
+52
View File
@@ -0,0 +1,52 @@
package cli
import (
"errors"
"flag"
"fmt"
"io"
"sultan/pkg/lander"
)
type dumpOptions struct {
dir string
force bool
}
func parseDumpFlags(args []string, output io.Writer) (dumpOptions, error) {
fs := flag.NewFlagSet("sultan dump-landing", flag.ContinueOnError)
fs.SetOutput(output)
var o dumpOptions
fs.StringVar(&o.dir, "out", "./suldata/landing", "Zielverzeichnis (der Server liest <data-dir>/landing)")
fs.BoolVar(&o.force, "force", false, "vorhandene Dateien überschreiben")
if err := fs.Parse(args); err != nil {
return o, err
}
if fs.NArg() > 0 {
return o, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
}
return o, nil
}
// runDump schreibt die eingebauten Lander-Seiten als Vorlagen zum Anpassen.
func runDump(args []string, stdout, stderr io.Writer) int {
o, err := parseDumpFlags(args, stderr)
if errors.Is(err, flag.ErrHelp) {
return 0
}
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
files, err := lander.Dump(o.dir, o.force)
if err != nil {
fmt.Fprintln(stderr, "Fehler:", err)
return 1
}
for _, f := range files {
fmt.Fprintln(stdout, f)
}
fmt.Fprintln(stderr, "Vorlagen geschrieben – Änderungen wirken sofort, ohne Neustart (siehe README.txt).")
return 0
}
+51
View File
@@ -0,0 +1,51 @@
package cli
import (
"flag"
"fmt"
"strings"
)
// envName bildet einen Flag-Namen auf seine Umgebungsvariable ab:
// public-url → SULTAN_PUBLIC_URL.
func envName(flagName string) string {
return "SULTAN_" + strings.ToUpper(strings.ReplaceAll(flagName, "-", "_"))
}
// annotateEnv ergänzt die Hilfe jedes Flags um den Namen der Variable.
func annotateEnv(fs *flag.FlagSet) {
fs.VisitAll(func(f *flag.Flag) { f.Usage += " [" + envName(f.Name) + "]" })
}
// applyEnv setzt nach fs.Parse alle Flags, die nicht auf der Befehlszeile
// standen, aus SULTAN_<FLAG>. Flags gewinnen also; Listen-Flags werden aus
// kommagetrennten Werten gefüllt (und von einem Flag ersetzt, nicht ergänzt).
func applyEnv(fs *flag.FlagSet, getenv func(string) string) error {
explicit := map[string]bool{}
fs.Visit(func(f *flag.Flag) { explicit[f.Name] = true })
var err error
fs.VisitAll(func(f *flag.Flag) {
if err != nil || explicit[f.Name] {
return
}
name := envName(f.Name)
v := strings.TrimSpace(getenv(name))
if v == "" {
return
}
values := []string{v}
if _, isList := f.Value.(*stringList); isList {
values = strings.Split(v, ",")
}
for _, item := range values {
if item = strings.TrimSpace(item); item == "" {
continue
}
if e := f.Value.Set(item); e != nil {
err = fmt.Errorf("%s=%q: %w", name, v, e)
return
}
}
})
return err
}
+639
View File
@@ -0,0 +1,639 @@
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
}
+506
View File
@@ -0,0 +1,506 @@
package cli
import (
"context"
"crypto/ed25519"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/license"
"sultan/pkg/server"
"sultan/pkg/store"
)
// initDataDir initialisiert ein Datenverzeichnis so, wie es der erste
// Serverstart tut.
func initDataDir(t *testing.T) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "suldata")
s, err := server.New(context.Background(), server.Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
s.Shutdown(context.Background())
return dir
}
func openStore(t *testing.T, dir string) *store.Store {
t.Helper()
st, err := store.Open(context.Background(), filepath.Join(dir, "sultan.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// tok ruft `sultan token …` mit --data-dir auf.
func tok(t *testing.T, dir string, args ...string) (int, string, string) {
t.Helper()
full := append([]string{"token"}, args...)
// --data-dir hinter den Unterbefehl hängen (z. B. "tenant add --data-dir=…").
full = append(full, "--data-dir="+dir)
return run(context.Background(), full...)
}
func mustTok(t *testing.T, dir string, args ...string) string {
t.Helper()
code, stdout, stderr := tok(t, dir, args...)
if code != 0 {
t.Fatalf("sultan token %s: code=%d stderr=%s", strings.Join(args, " "), code, stderr)
}
return stdout
}
func addTenant(t *testing.T, dir, id string, extra ...string) {
t.Helper()
mustTok(t, dir, append([]string{"tenant", "add", "--id=" + id, "--name=Kunde " + id, "--email=" + id + "@example.com", "--domains=" + id + ".de,*." + id + ".de"}, extra...)...)
}
func TestToken_Usage(t *testing.T) {
code, _, stderr := run(context.Background(), "token")
if code != 2 {
t.Errorf("ohne Unterbefehl: code=%d", code)
}
for _, want := range []string{"tenant add", "invite", "node list", "node maintenance", "node resume", "license issue", "purge"} {
if !strings.Contains(stderr, want) {
t.Errorf("Hilfe ohne %q:\n%s", want, stderr)
}
}
if code, _, _ := run(context.Background(), "token", "zaubern"); code != 2 {
t.Errorf("unbekannter Unterbefehl: code=%d", code)
}
if code, _, _ := run(context.Background(), "token", "tenant"); code != 2 {
t.Errorf("tenant ohne Aktion: code=%d", code)
}
}
// Ein Tippfehler bei --data-dir darf keine neue Server-Identität erzeugen.
func TestToken_RequiresInitializedDataDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "vertippt")
code, _, stderr := tok(t, dir, "tenant", "list")
if code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
t.Errorf("code=%d stderr=%q", code, stderr)
}
if _, err := os.Stat(dir); err == nil {
t.Error("Datenverzeichnis wurde angelegt")
}
}
func TestToken_TenantAddAndList(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--tier=pro", "--max-routes=20", "--valid-until=2027-12-31")
st := openStore(t, dir)
tn, err := st.GetTenant(context.Background(), "kunde")
if err != nil {
t.Fatal(err)
}
if tn.Tier != store.TierPro || tn.MaxRoutes != 20 || tn.Status != store.TenantActive ||
!tn.ValidUntil.Equal(time.Date(2027, 12, 31, 0, 0, 0, 0, time.UTC)) ||
strings.Join(tn.AllowedDomains, ",") != "kunde.de,*.kunde.de" {
t.Errorf("gespeichert: %+v", tn)
}
out := mustTok(t, dir, "tenant", "list")
for _, want := range []string{"ID", "kunde", "pro", "active", "2027-12-31", "*.kunde.de"} {
if !strings.Contains(out, want) {
t.Errorf("Liste ohne %q:\n%s", want, out)
}
}
}
func TestToken_TenantAddDefaults(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "homelab")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "homelab")
if tn.Tier != store.TierCommunity || tn.MaxRoutes != 5 {
t.Errorf("Defaults: tier=%s max_routes=%d", tn.Tier, tn.MaxRoutes)
}
if d := time.Until(tn.ValidUntil); d < 364*24*time.Hour || d > 366*24*time.Hour {
t.Errorf("Default-Laufzeit: %v", d)
}
}
func TestToken_TenantAddDomainsNormalized(t *testing.T) {
dir := initDataDir(t)
mustTok(t, dir, "tenant", "add", "--id=k", "--name=K", "--email=k@x.de", "--domains= Shop.DE. , *.Firma.de")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "k")
if strings.Join(tn.AllowedDomains, ",") != "shop.de,*.firma.de" {
t.Errorf("Domains: %v", tn.AllowedDomains)
}
}
func TestToken_TenantAddErrors(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
tests := []struct {
name string
args []string
code int
}{
{"doppelt", []string{"tenant", "add", "--id=kunde", "--name=K", "--email=k@x.de", "--domains=k.de"}, 1},
{"ID fehlt", []string{"tenant", "add", "--name=K", "--email=k@x.de", "--domains=k.de"}, 2},
{"Domains fehlen", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de"}, 2},
{"Domain ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=*.de"}, 1},
{"ID ungültig", []string{"tenant", "add", "--id=a__b", "--name=K", "--email=k@x.de", "--domains=k.de"}, 1},
{"Tier ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=k.de", "--tier=gold"}, 1},
{"Datum ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=k.de", "--valid-until=morgen"}, 2},
}
for _, tt := range tests {
if code, _, stderr := tok(t, dir, tt.args...); code != tt.code {
t.Errorf("%s: code=%d (erwartet %d) stderr=%q", tt.name, code, tt.code, stderr)
}
}
}
func TestToken_TenantSetStatusAndDelete(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=suspended_payment")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde")
if tn.Status != store.TenantSuspendedPayment {
t.Errorf("Status = %s", tn.Status)
}
if code, _, _ := tok(t, dir, "tenant", "set-status", "--id=kunde", "--status=pausiert"); code != 1 {
t.Errorf("ungültiger Status: code=%d", code)
}
if code, _, _ := tok(t, dir, "tenant", "set-status", "--id=fehlt", "--status=active"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
mustTok(t, dir, "tenant", "delete", "--id=kunde")
if _, err := openStore(t, dir).GetTenant(context.Background(), "kunde"); err == nil {
t.Error("Mandant nicht gelöscht")
}
if code, _, _ := tok(t, dir, "tenant", "delete", "--id=kunde"); code != 1 {
t.Errorf("zweites Löschen: code=%d", code)
}
}
// Die Einladung muss den Fingerprint des echten Server-Zertifikats tragen
// und beim Server einlösbar sein.
func TestToken_Invite(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
code, stdout, stderr := tok(t, dir, "invite", "--tenant=kunde")
if code != 0 {
t.Fatalf("invite: code=%d stderr=%s", code, stderr)
}
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) != 1 {
t.Fatalf("stdout muss genau den Token enthalten: %q", stdout)
}
inv, err := crypto.ParseEnrollmentToken(lines[0])
if err != nil {
t.Fatalf("Token nicht lesbar: %v", err)
}
cert, _ := crypto.LoadOrCreateServerCert(dir)
if inv.Fingerprint != cert.Fingerprint {
t.Error("Token trägt nicht den Fingerprint des Server-Zertifikats")
}
if !strings.Contains(stderr, "kunde") {
t.Errorf("Hinweis auf stderr fehlt: %q", stderr)
}
st := openStore(t, dir)
if _, err := st.Enroll(context.Background(), inv.Secret, store.Node{ID: "n1", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil {
t.Errorf("Token nicht einlösbar: %v", err)
}
}
func TestToken_InviteErrors(t *testing.T) {
dir := initDataDir(t)
if code, _, _ := tok(t, dir, "invite", "--tenant=fehlt"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
if code, _, _ := tok(t, dir, "invite"); code != 2 {
t.Errorf("ohne --tenant: code=%d", code)
}
addTenant(t, dir, "kunde")
for _, ttl := range []string{"--ttl=0s", "--ttl=-1h", "--ttl=bald"} {
if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", ttl); code != 2 {
t.Errorf("%s: code=%d", ttl, code)
}
}
if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", "--ttl=7d"); code != 0 {
t.Errorf("--ttl=7d (Tage) nicht akzeptiert: code=%d", code)
}
}
func TestToken_Nodes(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
if _, err := st.CreateNode(context.Background(), store.Node{ID: "node-1", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "192.168.1.20"}); err != nil {
t.Fatal(err)
}
out := mustTok(t, dir, "node", "list")
for _, want := range []string{"ID", "node-1", "kunde", "pi", "online", "192.168.1.20"} {
if !strings.Contains(out, want) {
t.Errorf("Liste ohne %q:\n%s", want, out)
}
}
if out := mustTok(t, dir, "node", "list", "--tenant=andere"); strings.Contains(out, "node-1") {
t.Errorf("Filter nach Mandant greift nicht:\n%s", out)
}
mustTok(t, dir, "node", "drain", "--id=node-1")
if n, _ := st.GetNode(context.Background(), "node-1"); n.Status != store.NodeDrained {
t.Errorf("drain: %s", n.Status)
}
mustTok(t, dir, "node", "resume", "--id=node-1")
if n, _ := st.GetNode(context.Background(), "node-1"); n.Status != store.NodeOffline {
t.Errorf("resume nach drain: %s (erwartet offline; online setzt erst der Stream)", n.Status)
}
mustTok(t, dir, "node", "delete", "--id=node-1")
if _, err := st.GetNode(context.Background(), "node-1"); err == nil {
t.Error("Knoten nicht gelöscht")
}
if code, _, _ := tok(t, dir, "node", "drain", "--id=fehlt"); code != 1 {
t.Errorf("unbekannter Knoten: code=%d", code)
}
}
func TestToken_LicenseIssue(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--tier=enterprise", "--max-routes=-1", "--valid-until=2027-06-30")
stdout := mustTok(t, dir, "license", "issue", "--tenant=kunde")
master, err := auth.ReadPrivateKey(filepath.Join(dir, "master.key"))
if err != nil {
t.Fatal(err)
}
l, err := license.Parse([]byte(stdout), master.Public().(ed25519.PublicKey))
if err != nil {
t.Fatalf("Lizenz ungültig: %v\n%s", err, stdout)
}
if l.TenantID != "kunde" || l.Tier != license.TierEnterprise || l.MaxRoutes != -1 || !l.AllowsDomain("www.kunde.de") {
t.Errorf("Lizenz: %+v", l)
}
if !json.Valid([]byte(stdout)) {
t.Error("stdout ist kein reines JSON")
}
if code, _, _ := tok(t, dir, "license", "issue", "--tenant=fehlt"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
}
func TestToken_Purge(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
inv := strings.TrimSpace(mustTok(t, dir, "invite", "--tenant=kunde"))
parsed, _ := crypto.ParseEnrollmentToken(inv)
st := openStore(t, dir)
st.Enroll(context.Background(), parsed.Secret, store.Node{ID: "n1", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"})
out := mustTok(t, dir, "purge")
if !strings.Contains(out, "1") {
t.Errorf("purge-Ausgabe: %q", out)
}
}
// Funktioniert auch, während der Server läuft (gleiche SQLite-DB, WAL).
func TestToken_WhileServerRuns(t *testing.T) {
dir := initDataDir(t)
s, err := server.New(context.Background(), server.Config{
DataDir: dir, AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0", LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatal(err)
}
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Shutdown(context.Background())
addTenant(t, dir, "kunde")
inv := strings.TrimSpace(mustTok(t, dir, "invite", "--tenant=kunde"))
parsed, err := crypto.ParseEnrollmentToken(inv)
if err != nil || parsed.Fingerprint != s.Fingerprint() {
t.Errorf("Token passt nicht zum laufenden Server: %v", err)
}
}
func setPublicURL(t *testing.T, dir, url string) {
t.Helper()
s, err := server.New(context.Background(), server.Config{DataDir: dir, PublicURLs: []string{url, "https://100.64.0.1:8443"}, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
s.Shutdown(context.Background())
}
func TestToken_InviteEmbedsPublicURL(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
// Ohne öffentliche Adresse: Token ohne URL, Hinweis auf --public-url.
code, stdout, stderr := tok(t, dir, "invite", "--tenant=kunde")
inv, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout))
if code != 0 || inv.ServerURL != "" || !strings.Contains(stderr, "--public-url") {
t.Errorf("ohne Adresse: code=%d url=%q stderr=%q", code, inv.ServerURL, stderr)
}
setPublicURL(t, dir, "https://ingress.example:8443")
code, stdout, stderr = tok(t, dir, "invite", "--tenant=kunde")
inv, err := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout))
if code != 0 || err != nil || inv.ServerURL != "https://ingress.example:8443" {
t.Errorf("mit gemerkter Adresse: code=%d %+v %v", code, inv, err)
}
if !strings.Contains(stderr, "SULTAN_TOKEN=") {
t.Errorf("Hinweis ohne fertige Befehlszeile: %q", stderr)
}
// Pro Einladung überschreibbar, z. B. NetBird-Adresse für diesen Knoten.
_, stdout, _ = tok(t, dir, "invite", "--tenant=kunde", "--server-url=https://100.64.0.1:8443")
if inv, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)); inv.ServerURL != "https://100.64.0.1:8443" {
t.Errorf("--server-url: %+v", inv)
}
if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", "--server-url=http://x:8443"); code != 2 {
t.Errorf("ungültige --server-url: code=%d", code)
}
}
func TestToken_NodeMaintenance(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"})
out := mustTok(t, dir, "node", "maintenance", "--id=pi", "--for=2h", "--reason=Kernel-Update", "--public-reason=Sicherheitsupdate")
if !strings.Contains(out, "pi") {
t.Errorf("Ausgabe: %q", out)
}
n, _ := st.GetNode(context.Background(), "pi")
if m := n.Maintenance; m == nil || m.Reason != "Kernel-Update" || m.PublicReason != "Sicherheitsupdate" ||
time.Until(m.Until) < 119*time.Minute || time.Until(m.Until) > 121*time.Minute {
t.Errorf("gespeichert: %+v", n.Maintenance)
}
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "MAINTENANCE") || !strings.Contains(list, "bis ") {
t.Errorf("Liste ohne Wartung:\n%s", list)
}
mustTok(t, dir, "node", "maintenance", "--id=pi") // Default 4h
if n, _ := st.GetNode(context.Background(), "pi"); time.Until(n.Maintenance.Until) < 239*time.Minute {
t.Errorf("Default-Dauer: %v", time.Until(n.Maintenance.Until))
}
mustTok(t, dir, "node", "maintenance", "--id=pi", "--for=0")
if n, _ := st.GetNode(context.Background(), "pi"); !n.Maintenance.Until.IsZero() {
t.Error("--for=0 nicht unbefristet")
}
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "unbefristet") {
t.Errorf("Liste:\n%s", list)
}
mustTok(t, dir, "node", "resume", "--id=pi")
if n, _ := st.GetNode(context.Background(), "pi"); n.Maintenance != nil {
t.Error("resume beendet die Wartung nicht")
}
for _, args := range [][]string{
{"node", "maintenance", "--id=pi", "--for=bald"},
{"node", "maintenance", "--id=pi", "--for=-1h"},
{"node", "maintenance"},
} {
if code, _, _ := tok(t, dir, args...); code != 2 {
t.Errorf("%v: code=%d", args, code)
}
}
if code, _, _ := tok(t, dir, "node", "maintenance", "--id=fehlt"); code != 1 {
t.Errorf("unbekannter Knoten: code=%d", code)
}
}
func TestToken_NodeCommand(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"})
mustTok(t, dir, "node", "command", "--id=pi", "--cmd=rotate_key", "--reason=Routine")
mustTok(t, dir, "node", "drain", "--id=pi") // erzeugt zusätzlich einen Hinweis an den Agent
cmds, _ := st.PendingNodeCommands(context.Background(), "pi")
if len(cmds) != 2 || cmds[0].Command != "rotate_key" || cmds[0].Reason != "Routine" || cmds[1].Command != "drain" {
t.Errorf("Warteschlange: %+v", cmds)
}
if code, _, _ := tok(t, dir, "node", "command", "--id=pi", "--cmd=rm -rf /"); code != 2 {
t.Errorf("beliebiger Befehl: code=%d", code)
}
if code, _, _ := tok(t, dir, "node", "command", "--id=fehlt", "--cmd=resync"); code != 1 {
t.Errorf("unbekannter Knoten: code=%d", code)
}
}
func TestToken_NodeListShowsLease(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"})
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "LEASE") {
t.Errorf("Spalte LEASE fehlt:\n%s", list)
}
st.PutLease(context.Background(), store.Lease{NodeID: "pi", IssuedAt: time.Now(), RenewAfter: time.Now().Add(time.Hour),
ValidUntil: time.Date(2030, 1, 2, 3, 4, 0, 0, time.Local), Document: "{}"})
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "2030-01-02 03:04") {
t.Errorf("Lease-Ende fehlt:\n%s", list)
}
}
func TestToken_TenantAddFeatures(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--features=custom_middleware,websockets")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde")
if strings.Join(tn.Features, ",") != "custom_middleware,websockets" {
t.Errorf("Features: %v", tn.Features)
}
if code, _, _ := tok(t, dir, "tenant", "add", "--id=x", "--name=x", "--email=x@x.de", "--domains=x.de", "--features=teleport"); code != 1 {
t.Errorf("unbekanntes Feature: code=%d", code)
}
}
func TestToken_TenantPayment(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--tier=pro")
st := openStore(t, dir)
mustTok(t, dir, "tenant", "payment", "--id=kunde", "--overdue-since=2026-09-01")
if tn, _ := st.GetTenant(context.Background(), "kunde"); !tn.PaymentOverdueSince.Equal(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) {
t.Errorf("offen seit: %v", tn.PaymentOverdueSince)
}
if list := mustTok(t, dir, "tenant", "list"); !strings.Contains(list, "PAYMENT") || !strings.Contains(list, "offen seit 2026-09-01") {
t.Errorf("Liste ohne Zahlungsstatus:\n%s", list)
}
mustTok(t, dir, "tenant", "payment", "--id=kunde", "--paid")
if tn, _ := st.GetTenant(context.Background(), "kunde"); !tn.PaymentOverdueSince.IsZero() {
t.Error("--paid setzt nicht zurück")
}
for _, args := range [][]string{
{"tenant", "payment", "--id=kunde"},
{"tenant", "payment", "--id=kunde", "--paid", "--overdue-since=2026-09-01"},
{"tenant", "payment", "--id=kunde", "--overdue-since=gestern"},
} {
if code, _, _ := tok(t, dir, args...); code != 2 {
t.Errorf("%v: code=%d", args, code)
}
}
if code, _, _ := tok(t, dir, "tenant", "payment", "--id=fehlt", "--paid"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
}
func TestToken_DataDirFromEnvironment(t *testing.T) {
dir := initDataDir(t)
t.Setenv("SULTAN_DATA_DIR", dir)
if code, _, stderr := run(context.Background(), "token", "tenant", "list"); code != 0 {
t.Errorf("code=%d stderr=%s", code, stderr)
}
}