// Package cli implementiert die Befehlszeile von sultan: // // sultan server Registry, Stream-Hub, Policy-Engine, Traefik-Provider // sultan agent Agent: Enrollment, Stream, Docker-Watcher // sultan token Mandanten, Einladungen, Knoten, Lizenzen verwalten // sultan accountant Zentrale für Inventar und Abrechnung // sultan version // // Run ist ohne Prozessgrenzen testbar; main ruft nur Run auf. package cli import ( "context" "errors" "flag" "fmt" "io" "log/slog" "os" "strings" "time" "sultan/pkg/server" ) // Version wird beim Bauen gesetzt: -ldflags "-X sultan/pkg/cli.Version=1.2.3". var Version = "dev" const usage = `sultan – verteiltes Ingress-Routing mit ZeroTrust-Service-Discovery Befehle: sultan server Server starten (Registry, Agent-Port, Traefik-Provider) sultan agent Agent starten (Docker-Host oder Bare-Metal) sultan token Mandanten, Einladungen, Knoten und Lizenzen verwalten sultan accountant Zentrale für Inventar und Abrechnung starten bzw. verwalten sultan dump-landing eingebaute Lander-Seiten als Vorlagen ausgeben sultan version Version ausgeben sultan help diese Hilfe Hilfe zu einem Befehl: sultan --help ` // Run führt die Befehlszeile aus und liefert den Exit-Code (0 ok, 1 Fehler, // 2 Bedienfehler). ctx endet z. B. bei SIGTERM. func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { if len(args) == 0 { fmt.Fprint(stderr, usage) return 2 } switch args[0] { case "help", "-h", "--help": fmt.Fprint(stdout, usage) return 0 case "version": fmt.Fprintf(stdout, "sultan %s\nZeitzone: %s\n", Version, time.Local) return 0 case "server": return runServer(ctx, args[1:], stdout, stderr) case "token": return runToken(ctx, args[1:], stdout, stderr) case "agent": return runAgent(ctx, args[1:], stderr) case "accountant": return runAccountant(ctx, args[1:], stdout, 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 } } func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int { cfg, err := parseServerFlags(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)) cfg.Version = Version s, err := server.New(ctx, cfg) if err != nil { fmt.Fprintln(stderr, "Start fehlgeschlagen:", err) return 1 } if err := s.Run(ctx); err != nil { fmt.Fprintln(stderr, "Server beendet mit Fehler:", err) return 1 } return 0 } // parseServerFlags liest die Flags von `sultan server`; Defaults wie in // server.Config. Jedes Flag ist auch als SULTAN_ 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 fs.StringVar(&cfg.DataDir, "data-dir", "./suldata", "Datenverzeichnis (sultan.db, server.key/.crt, master.key)") 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.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)") fs.StringVar(&cfg.AccountantToken, "accountant-token", "", "Einladung von `sultan accountant invite` (nur beim ersten Start; danach accountant.json)") fs.StringVar(&cfg.AccountantURL, "accountant", "", "Accountant-Adresse, z. B. https://accountant.example.de:8444 (Override; sonst aus der Einladung)") 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, ",") } // Set ignoriert leere Werte (z. B. eine leere Variable in Compose). func (l *stringList) Set(v string) error { if v = strings.TrimSpace(v); v != "" { *l = append(*l, v) } return nil }