256 lines
7.1 KiB
Go
256 lines
7.1 KiB
Go
// 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)
|
|
}
|