software steht so weit.
jetzt build + docker build und testen
This commit is contained in:
1 parent
42f0c0accb
commit
ca6cdcca63
90 files changed
+10298
-480
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user