Files
sultan/pkg/cli/accountant.go
T

517 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}