package cli import ( "bytes" "cmp" "context" "encoding/json" "errors" "flag" "fmt" "io" "log/slog" "os" "path/filepath" "strings" "text/tabwriter" "time" "sultan/pkg/accountant" "sultan/pkg/crypto" "sultan/pkg/ledger" ) const accountantUsage = `sultan accountant – Zentrale für Inventar und Abrechnung (Spec 5.3). Sultan-Server (Payees) melden ihr Inventar per signiertem Bericht. sultan accountant [--addr :8444] [--public-url https://…] [--metrics-addr 127.0.0.1:8082] [--data-dir ./accdata] Accountant starten (nur TLS; Adresse wird gemerkt und steckt in Einladungen; Checkmk: curl http://127.0.0.1:8082/metrics/checkmk) sultan accountant invite --payee ID [--ttl 24h|7d] [--server-url https://…] Einladung für einen Sultan-Server (Payee) erzeugen sultan accountant where Störfall: wo läuft eine URL (Backend, Container, Compose-Verzeichnis), seit wann, wo vorher? Teiltreffer genügen. sultan accountant routes [--tenant ID] [--payee ID] alle aktuellen Routen sultan accountant customer add --id ID --name NAME [--group G] [--note TEXT] [--cycle m|q|j|offen] [--flat-rate 89,00] sultan accountant customer set --id ID [dieselben Felder] [--no-flat-rate] (nur angegebene Felder ändern) sultan accountant customer list [--group G] [--unclassified] sultan accountant customer map --tenant T --customer ID [--payee P] Mandant (z. B. Tippfehler) zuordnen sultan accountant customer unmap --tenant T [--payee P] sultan accountant customer delete --id ID Kunden entstehen automatisch aus den Mandanten-IDs der Berichte. sultan accountant product add --id ID --name NAME --class included|billable|free|internal [--price 49,00] sultan accountant product set --id ID --from JJJJ-MM [--class …] [--price … | --no-price] (gilt ab Monat) sultan accountant product price --id ID --customer ID --price 39,00 [--from JJJJ-MM] Kundenpreis sultan accountant product list | product delete --id ID Preise sind optional und nicht buchhaltungsrelevant (Lexware führt). sultan accountant assign (--host H | --tenant T --router R [--protocol tcp]) [--product P] [--class C] [--start JJJJ-MM] [--clear] Posten zuordnen – Vorrang vor dem Label sultan.accountant.billing sultan accountant assignments sultan accountant billing --month JJJJ-MM [--out datei.csv] Monatsabrechnung als CSV (Semikolon, Dezimalkomma) sultan accountant payee list sultan accountant payee report --id ID (letzten Bericht als JSON ausgeben) sultan accountant payee delete --id ID (z. B. vor neuer Einladung nach Schlüsselverlust) sultan accountant dump-checkmk [--force] Checkmk-Vorlage nach /checkmk exportieren (anpassen – wirkt sofort, ohne Neustart) Verwaltungsbefehle arbeiten direkt auf dem Datenverzeichnis (auch während er läuft). Alle Befehle: --data-dir DIR (Default ./accdata) ` var accountantCmds = map[string]tokenCmd{ "invite": accountantInvite, "payee list": payeeList, "payee report": payeeReport, "payee delete": payeeDelete, "dump-checkmk": dumpCheckmk, "where": accountantWhere, "routes": accountantRoutes, "customer add": customerAdd, "customer set": customerSet, "customer list": customerList, "customer delete": customerDelete, "customer map": customerMap, "customer unmap": customerUnmap, "product add": productAdd, "product set": productSet, "product price": productPrice, "product list": productList, "product delete": productDelete, "assign": assign, "assignments": assignments, "billing": billing, } func runAccountant(ctx context.Context, args []string, stdout, stderr io.Writer) int { // Ohne Unterbefehl (bzw. nur Flags): Accountant starten. if len(args) == 0 || len(args[0]) > 0 && args[0][0] == '-' { return serveAccountant(ctx, args, stderr) } if args[0] == "help" { fmt.Fprint(stdout, accountantUsage) return 0 } name, rest := args[0], args[1:] if (name == "payee" || name == "customer" || name == "product") && len(rest) > 0 { name, rest = name+" "+rest[0], rest[1:] } cmd, ok := accountantCmds[name] if !ok { fmt.Fprintf(stderr, "unbekannter Befehl \"accountant %s\"\n\n%s", name, accountantUsage) return 2 } err := cmd(tokenEnv{ctx: ctx, stdout: stdout, stderr: stderr}, rest) var ue usageError switch { case err == nil, 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 } } func serveAccountant(ctx context.Context, args []string, stderr io.Writer) int { cfg, err := parseAccountantFlags(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 := accountant.New(ctx, cfg) if err != nil { fmt.Fprintln(stderr, "Start fehlgeschlagen:", err) return 1 } if err := a.Run(ctx); err != nil { fmt.Fprintln(stderr, "Accountant beendet mit Fehler:", err) return 1 } return 0 } // parseAccountantFlags liest die Flags von `sultan accountant`; jedes Flag ist // auch als SULTAN_ setzbar (Flags gewinnen). func parseAccountantFlags(args []string, output io.Writer, getenv func(string) string) (accountant.Config, error) { fs := flag.NewFlagSet("sultan accountant", flag.ContinueOnError) fs.SetOutput(output) var cfg accountant.Config fs.StringVar(&cfg.DataDir, "data-dir", "./accdata", "Datenverzeichnis (accountant.db, server.key/.crt)") fs.StringVar(&cfg.Addr, "addr", ":8444", "Adresse für Payees (nur TLS)") fs.Var((*stringList)(&cfg.PublicURLs), "public-url", "Adresse, unter der Payees den Accountant erreichen (z. B. https://accountant.example.de:8444); mehrfach angebbar; wird gemerkt, die erste steckt in Einladungen") fs.StringVar(&cfg.MetricsAddr, "metrics-addr", "127.0.0.1:8082", `Checkmk-Ausgabe /metrics/checkmk (Klartext, nur Loopback; "none" = aus)`) fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)") fs.DurationVar(&cfg.StaleWarn, "stale-warn", 2*time.Hour, "Checkmk WARN, wenn ein Payee so lange nicht berichtet hat") fs.DurationVar(&cfg.StaleCrit, "stale-crit", 6*time.Hour, "Checkmk CRIT, wenn ein Payee so lange nicht berichtet hat") annotateEnv(fs) if err := fs.Parse(args); err != nil { return accountant.Config{}, err } if err := applyEnv(fs, getenv); err != nil { return accountant.Config{}, err } if fs.NArg() > 0 { return accountant.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0)) } return cfg, nil } func accountantFlags(name string, env tokenEnv) (*flag.FlagSet, *string) { fs := flag.NewFlagSet("sultan accountant "+name, flag.ContinueOnError) fs.SetOutput(env.stderr) dir := fs.String("data-dir", "./accdata", "Datenverzeichnis des Accountants") return fs, dir } // openLedger öffnet die DB eines bereits initialisierten Datenverzeichnisses // (nie neu anlegen: ein Tippfehler würde sonst eine neue Identität erzeugen). func openLedger(env tokenEnv, dir string) (*ledger.Ledger, error) { for _, f := range []string{"accountant.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 accountant` starten oder --data-dir prüfen", dir, f) } } return ledger.Open(env.ctx, filepath.Join(dir, "accountant.db")) } func accountantInvite(env tokenEnv, args []string) error { fs, dir := accountantFlags("invite", env) payee := fs.String("payee", "", "ID des Sultan-Servers (a–z, 0–9, - und _), z. B. webproxy11") ttlRaw := fs.String("ttl", "24h", "Gültigkeit der Einladung (z. B. 2h, 24h, 7d)") serverURL := fs.String("server-url", "", "Accountant-Adresse für diese Einladung (Default: --public-url)") if err := parse(fs, args, "payee"); 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 } l, err := openLedger(env, *dir) if err != nil { return err } defer l.Close() if *serverURL == "" { urls, err := accountant.LoadPublicURLs(env.ctx, l) if err != nil { return err } if len(urls) > 0 { *serverURL = urls[0] } } 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 := l.AddInvite(env.ctx, tok.Secret, *payee, expires); err != nil { return err } fmt.Fprintln(env.stdout, tok.String()) fmt.Fprintf(env.stderr, "Einladung für Payee %s, einmalig gültig bis %s.\n", *payee, expires.Format(time.DateTime)) if tok.ServerURL != "" { fmt.Fprintf(env.stderr, "Accountant-Adresse %s ist enthalten. Auf dem Sultan-Server:\n SULTAN_ACCOUNTANT_TOKEN= sultan server …\n", tok.ServerURL) } else { fmt.Fprint(env.stderr, "Auf dem Sultan-Server: SULTAN_ACCOUNTANT_TOKEN= sultan server --accountant=https://:8444 …\n"+ "Tipp: Mit `sultan accountant --public-url=https://…` (oder invite --server-url) steckt die Adresse in der Einladung.\n") } return nil } func payeeList(env tokenEnv, args []string) error { fs, dir := accountantFlags("payee list", env) if err := parse(fs, args); err != nil { return err } l, err := openLedger(env, *dir) if err != nil { return err } defer l.Close() payees, err := l.ListPayees(env.ctx) if err != nil { return err } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "ID\tHOSTNAME\tIP\tENROLLED\tLAST_REPORT") for _, p := range payees { last := "nie" if !p.LastReport.IsZero() { last = p.LastReport.Local().Format(time.DateTime) } fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Hostname, p.IPAddress, p.EnrolledAt.Local().Format(time.DateTime), last) } return tw.Flush() } func payeeReport(env tokenEnv, args []string) error { fs, dir := accountantFlags("payee report", env) id := fs.String("id", "", "Payee-ID") if err := parse(fs, args, "id"); err != nil { return err } l, err := openLedger(env, *dir) if err != nil { return err } defer l.Close() body, at, err := l.LatestReport(env.ctx, *id) if err != nil { return err } var buf bytes.Buffer if err := json.Indent(&buf, body, "", " "); err != nil { return err } fmt.Fprintln(env.stdout, buf.String()) fmt.Fprintf(env.stderr, "Bericht von %s, empfangen %s\n", *id, at.Local().Format(time.DateTime)) return nil } func payeeDelete(env tokenEnv, args []string) error { fs, dir := accountantFlags("payee delete", env) id := fs.String("id", "", "Payee-ID") if err := parse(fs, args, "id"); err != nil { return err } l, err := openLedger(env, *dir) if err != nil { return err } defer l.Close() if err := l.DeletePayee(env.ctx, *id); err != nil { return err } fmt.Fprintf(env.stdout, "Payee %s gelöscht\n", *id) return nil } func dumpCheckmk(env tokenEnv, args []string) error { fs, dir := accountantFlags("dump-checkmk", env) force := fs.Bool("force", false, "vorhandene Vorlage überschreiben") if err := parse(fs, args); err != nil { return err } l, err := openLedger(env, *dir) // nur prüfen, ob das Verzeichnis initialisiert ist if err != nil { return err } l.Close() path, err := accountant.DumpCheckmk(*dir, *force) if err != nil { return err } fmt.Fprintln(env.stdout, path) fmt.Fprintln(env.stderr, "Vorlage geschrieben – Änderungen wirken sofort, ohne Neustart. Fehlerhafte Vorlage: eingebaute greift, Checkmk meldet CRIT.") return nil } const shortTime = "2006-01-02 15:04" func localTime(t time.Time) string { if t.IsZero() { return "?" } return t.Local().Format(shortTime) } // splitQuery trennt den Suchbegriff (erstes Argument ohne -) von den Flags; // ein Flag ohne "=" nimmt das folgende Argument als Wert. func splitQuery(args []string) (query string, flags []string) { for i := 0; i < len(args); i++ { a := args[i] switch { case strings.HasPrefix(a, "-"): flags = append(flags, a) if !strings.Contains(a, "=") && a != "-h" && a != "--help" && i+1 < len(args) { i++ flags = append(flags, args[i]) } case query == "": query = a default: flags = append(flags, a) // parse meldet das überzählige Argument } } return query, flags } // routeProblems fasst den Zustand einer Route knapp zusammen ("ok" ohne Befund). func routeProblems(r accountant.ReportRoute, n accountant.ReportNode) string { var p []string if !r.Active { p = append(p, "verworfen") } if r.Down { p = append(p, "gestoppt") } if r.Health == "unhealthy" { p = append(p, "unhealthy") } switch { case n.Maintenance: p = append(p, "Wartung") case !n.Connected: p = append(p, "getrennt") } if len(p) == 0 { return "ok" } return strings.Join(p, ",") } func accountantWhere(env tokenEnv, args []string) error { query, rest := splitQuery(args) fs, dir := accountantFlags("where", env) if err := parse(fs, rest); err != nil { return err } if query == "" { return usageError{errors.New("Suchbegriff fehlt: sultan accountant where ")} } l, err := openLedger(env, *dir) if err != nil { return err } defer l.Close() res, err := accountant.Where(env.ctx, l, query) if err != nil { return err } if len(res.Current) == 0 && len(res.Gone) == 0 { return fmt.Errorf("nichts gefunden für %q", query) } out := env.stdout for i, c := range res.Current { if i > 0 { fmt.Fprintln(out) } r, n := c.Route, c.Node tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) fmt.Fprintf(tw, "URL\t%s (Router %s, %s)\n", strings.Join(r.Hosts, ", "), r.Router, r.Protocol) tenant := r.Tenant if c.TenantName != "" { tenant += " (" + c.TenantName + ")" } fmt.Fprintf(tw, "Mandant\t%s\n", tenant) if c.Customer != nil { cust := c.Customer.ID + " " + c.Customer.Name + " (" + cycleLabel(c.Customer.Cycle) if c.Customer.Group != "" { cust += ", Gruppe " + c.Customer.Group } fmt.Fprintf(tw, "Kunde\t%s)\n", cust) } else { fmt.Fprintf(tw, "Kunde\tnicht zugeordnet\n") } fmt.Fprintf(tw, "Ingress\t%s (Bericht %s)\n", c.Payee, localTime(c.ReportAt)) backend := r.Backend if n.NetBirdIP != "" { backend += ", NetBird " + n.NetBirdIP } fmt.Fprintf(tw, "Backend\t%s (%s)\n", cmp.Or(r.BackendFQDN, r.Backend), backend) container := r.Workload if r.ComposeDir != "" { container += ", compose " + r.ComposeDir if r.ComposeProject != "" { container += " (Projekt " + r.ComposeProject + ")" } } fmt.Fprintf(tw, "Container\t%s\n", container) state := []string{routeProblems(r, n)} if r.State != "" { s := r.State if !r.StartedAt.IsZero() { s += " seit " + localTime(r.StartedAt) } state = append(state, s) } if r.Health != "" { state = append(state, r.Health) } if r.Product != "" { state = append(state, "Produkt "+r.Product) } fmt.Fprintf(tw, "Zustand\t%s\n", strings.Join(state, ", ")) node := r.Node if n.Connected { node += " verbunden" } else { node += " getrennt" } for _, v := range []string{n.OS, prefixed("Docker ", n.DockerVersion)} { if v != "" { node += ", " + v } } fmt.Fprintf(tw, "Knoten\t%s\n", node) fmt.Fprintf(tw, "Seit\t%s\n", localTime(c.Since)) for _, p := range c.Previous { fmt.Fprintf(tw, "Vorher\t%s (%s) %s – %s\n", p.Node, p.Payee, localTime(p.FirstSeen), localTime(p.Ended)) } tw.Flush() } if len(res.Gone) > 0 { if len(res.Current) > 0 { fmt.Fprintln(out) } fmt.Fprintln(out, "Nicht mehr vorhanden:") tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) for _, g := range res.Gone { fmt.Fprintf(tw, " %s\t%s\t%s (%s)\t%s – %s\n", strings.Join(g.Hosts, ", "), g.Tenant, g.Node, g.Payee, localTime(g.FirstSeen), localTime(g.Ended)) } tw.Flush() } return nil } func prefixed(prefix, v string) string { if v == "" { return "" } return prefix + v } func accountantRoutes(env tokenEnv, args []string) error { fs, dir := accountantFlags("routes", env) tenant := fs.String("tenant", "", "nur Routen dieses Mandanten") payee := fs.String("payee", "", "nur Routen dieses Payees") if err := parse(fs, args); err != nil { return err } l, err := openLedger(env, *dir) if err != nil { return err } defer l.Close() routes, err := accountant.Routes(env.ctx, l, accountant.RoutesFilter{Tenant: *tenant, Payee: *payee}) if err != nil { return err } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "HOST\tTENANT\tPAYEE\tNODE\tBACKEND\tSTATE\tSINCE") for _, c := range routes { fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", strings.Join(c.Route.Hosts, ","), c.Route.Tenant, c.Payee, c.Route.Node, cmp.Or(c.Route.BackendFQDN, c.Route.Backend), routeProblems(c.Route, c.Node), localTime(c.Since)) } return tw.Flush() }