Accountant: Inventar, where, Checkmk, Abrechnung

This commit is contained in:
br committed 2026-10-18 17:08:58 +02:00
1 parent 9f16148cde
commit dd93277750
59 files changed
+7522 -113

No files matched your search

+516
View File
@@ -0,0 +1,516 @@
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 <host|mandant> 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 <data-dir>/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_<FLAG> 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=<token> sultan server …\n", tok.ServerURL)
} else {
fmt.Fprint(env.stderr, "Auf dem Sultan-Server: SULTAN_ACCOUNTANT_TOKEN=<token> sultan server --accountant=https://<accountant>: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 <host|mandant>")}
}
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()
}