First commit Go counter service

This commit is contained in:
Dimitrije Dimitrijevic
2026-10-15 02:12:18 +02:00
commit c43ff17627
59 changed files with 11205 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
// counterctl shows today's (or a given day's) incoming/outgoing counters for
// an address or a domain by asking the running migadu_counters daemon
// (GET /counters), the same way rcptctl talks to rcptd.
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/BurntSushi/toml"
"migadu_counters/internal/api"
"migadu_counters/internal/config"
)
// Set by -ldflags at build time.
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, os.Getenv, time.Now))
}
// cliConfig mirrors the daemon config fields the CLI needs; TOML ignores the
// rest, so the daemon's own config file can be used as is.
type cliConfig struct {
Addr string `toml:"addr"`
AuthToken string `toml:"auth_token"`
}
func run(args []string, stdout, stderr io.Writer, getenv func(string) string, now func() time.Time) int {
fs := flag.NewFlagSet("counterctl", flag.ContinueOnError)
fs.SetOutput(stderr)
address := fs.String("address", "", "mailbox address to show (A, D and O levels)")
domain := fs.String("domain", "", "domain to show (D and O levels)")
dateFlag := fs.String("date", "", "ISO 8601 calendar day YYYY-MM-DD, UTC (default: today)")
jsonOut := fs.Bool("json", false, "print the raw API response")
inOnly := fs.Bool("in", false, "show only the incoming counters")
outOnly := fs.Bool("out", false, "show only the outgoing counters")
configPath := fs.String("config", "", "config file (default: "+strings.Join(config.DefaultPaths, " or ")+")")
urlFlag := fs.String("url", "", "service base URL (default: derived from addr in the config)")
showVersion := fs.Bool("version", false, "print version and exit")
fs.Usage = func() {
fmt.Fprintf(stderr, "Usage: counterctl --address me@example.com | --domain example.com [--date YYYY-MM-DD] [--in|--out] [--json] [--config PATH] [--url URL]\n\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return 1
}
if *showVersion {
fmt.Fprintf(stdout, "counterctl %s (commit %s, built %s)\n", version, commit, date)
return 0
}
if (*address == "") == (*domain == "") {
fmt.Fprintln(stderr, "exactly one of --address or --domain is required")
fs.Usage()
return 1
}
q := url.Values{}
if *address != "" {
addr, err := api.NormalizeEmail(*address)
if err != nil {
fmt.Fprintf(stderr, "invalid address %q: %v\n", *address, err)
return 1
}
q.Set("address", addr)
} else {
q.Set("domain", strings.ToLower(strings.TrimSpace(*domain)))
}
if *dateFlag != "" {
day, err := api.ParseDate(*dateFlag, now())
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
q.Set("date", day)
if t, _ := time.Parse("2006-01-02", day); now().UTC().Sub(t) > 31*24*time.Hour {
fmt.Fprintf(stderr, "warning: %s is more than 31 days ago; counter keys expire after 31 days and will read as 0\n", day)
}
}
cfg, err := loadConfig(*configPath, *urlFlag != "")
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
token := cfg.AuthToken
if v := getenv(config.EnvAuthToken); v != "" {
token = v
}
base := *urlFlag
if base == "" {
base = baseURL(cfg.Addr)
if host := strings.TrimPrefix(strings.TrimPrefix(base, "http://"), "https://"); !strings.HasPrefix(host, "localhost") && !strings.HasPrefix(host, "127.0.0.1") && !strings.HasPrefix(host, "[::1]") {
fmt.Fprintf(stderr, "warning: %s is not local; the auth token travels over plaintext HTTP\n", base)
}
}
req, err := http.NewRequest(http.MethodGet, base+"/counters?"+q.Encode(), nil)
if err != nil {
fmt.Fprintf(stderr, "bad URL: %v\n", err)
return 1
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
fmt.Fprintf(stderr, "request failed: %v\n", err)
return 1
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(body))
var e api.ErrorResponse
if json.Unmarshal(body, &e) == nil && e.Error != "" {
msg = e.Error
}
fmt.Fprintf(stderr, "server answered %d: %s\n", resp.StatusCode, msg)
return 1
}
if *jsonOut {
stdout.Write(body)
if len(body) == 0 || body[len(body)-1] != '\n' {
io.WriteString(stdout, "\n")
}
return 0
}
var r api.CountersResponse
if err := json.Unmarshal(body, &r); err != nil {
fmt.Fprintf(stderr, "unexpected response: %v\n%s\n", err, body)
return 1
}
showIn, showOut := flows(*inOnly, *outOnly)
printTable(stdout, r, showIn, showOut)
return 0
}
// flows picks the table columns: --in or --out alone restrict the table to
// that flow; neither or both show both flows.
func flows(in, out bool) (showIn, showOut bool) {
if in == out {
return true, true
}
return in, out
}
// loadConfig reads addr/auth_token from the daemon config. Without -config
// the default paths are tried; when none exists the CLI can still run with
// -url and the token from the environment.
func loadConfig(path string, haveURL bool) (cliConfig, error) {
var cfg cliConfig
if path == "" {
found, err := config.Discover("")
if err != nil {
if haveURL {
return cfg, nil
}
return cfg, err
}
path = found
}
if _, err := toml.DecodeFile(path, &cfg); err != nil {
return cfg, fmt.Errorf("read config %s: %v", path, err)
}
return cfg, nil
}
// baseURL turns the daemon's listen address into a URL: ":5555" →
// "http://localhost:5555"; a full URL is used as is.
func baseURL(addr string) string {
if addr == "" {
addr = ":5555"
}
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
return strings.TrimRight(addr, "/")
}
if strings.HasPrefix(addr, ":") {
addr = "localhost" + addr
}
return "http://" + addr
}
func printTable(w io.Writer, r api.CountersResponse, showIn, showOut bool) {
dom, org := "-", "-"
if r.Domain != "" {
dom = r.Domain
}
if r.OrgID != nil {
org = strconv.FormatInt(*r.OrgID, 10)
}
fmt.Fprintf(w, "%s date %s (UTC) domain %s org %s\n", r.Query, r.Date, dom, org)
tw := tabwriter.NewWriter(w, 0, 8, 2, ' ', 0)
head := []string{"LEVEL", "ID"}
if showIn {
head = append(head, "IN", "LIMIT")
}
if showOut {
head = append(head, "OUT", "LIMIT")
}
fmt.Fprintln(tw, strings.Join(head, "\t"))
for _, l := range r.Levels {
cols := []string{l.Level, l.ID}
if showIn {
cols = append(cols, count(l.In, l.InLimit), limit(l.InLimit))
}
if showOut {
cols = append(cols, count(l.Out, l.OutLimit), limit(l.OutLimit))
}
fmt.Fprintln(tw, strings.Join(cols, "\t"))
}
tw.Flush()
if !r.Managed {
fmt.Fprintln(w, "note: domain is not in rspamd_domains_db (not counted)")
}
if r.Exempt {
fmt.Fprintln(w, "note: address is exempt from counting")
}
}
// count renders a value, marked with "!" once it reached its limit.
func count(n int64, lim *int64) string {
s := strconv.FormatInt(n, 10)
if lim != nil && n >= *lim {
s += "!"
}
return s
}
func limit(lim *int64) string {
if lim == nil {
return "-"
}
return strconv.FormatInt(*lim, 10)
}