mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-15 08:02:42 +00:00
405 lines
16 KiB
Go
405 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/utils/paging"
|
|
)
|
|
|
|
type remoteAdminRoute struct {
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Permissions []string `json:"permissions"`
|
|
FreshAuth bool `json:"fresh_auth"`
|
|
Blocked string `json:"unavailable_reason,omitempty"`
|
|
}
|
|
|
|
type remoteAdminHelper struct {
|
|
Command string
|
|
Method string
|
|
Path string
|
|
IDFlag string
|
|
}
|
|
|
|
var remoteAdminHelpers = []remoteAdminHelper{
|
|
{"workers list", "GET", "/admin/workers", ""},
|
|
{"workers show", "GET", "/admin/workers/:id", "id"},
|
|
{"workers stats", "GET", "/admin/workers/:id/stats", "id"},
|
|
{"workers mailboxes", "GET", "/admin/workers/:id/emails", "id"},
|
|
{"fleet nodes", "GET", "/admin/fleet/nodes", ""},
|
|
{"fleet capacity", "GET", "/admin/fleet/capacity", ""},
|
|
{"fleet decisions", "GET", "/admin/fleet/decisions", ""},
|
|
{"fleet dedicated", "GET", "/admin/fleet/dedicated", ""},
|
|
{"fleet release", "GET", "/admin/fleet/release", ""},
|
|
{"logs", "GET", "/admin/fleet/nodes/:id/logs", "node-id"},
|
|
{"broker", "GET", "/admin/fleet/nodes/:id/broker", "node-id"},
|
|
{"mailbox list", "GET", "/admin/mailboxes", ""},
|
|
{"sync list", "GET", "/admin/sync", ""},
|
|
{"sends in-flight", "GET", "/admin/sends/in-flight", ""},
|
|
{"recovery dead-letters", "GET", "/admin/tasks/dead-letters", ""},
|
|
{"recovery failures", "GET", "/admin/tasks/failures", ""},
|
|
{"recovery replay", "POST", "/admin/tasks/dead-letters/:id/replay", "id"},
|
|
{"recovery clear-throttle", "POST", "/admin/sync/:id/clear-throttle", "id"},
|
|
{"recovery restart-backfill", "POST", "/admin/sync/:id/restart-backfill", "id"},
|
|
{"recovery webhooks", "GET", "/admin/webhooks/health", ""},
|
|
{"recovery reclaim-webhooks", "POST", "/admin/webhooks/reclaim", ""},
|
|
{"jobs list", "GET", "/admin/jobs", ""},
|
|
{"health status", "GET", "/admin/system/status", ""},
|
|
{"health instance", "GET", "/admin/instance/health", ""},
|
|
{"health monitoring", "GET", "/admin/instance/monitoring", ""},
|
|
{"health limits", "GET", "/admin/instance/limits", ""},
|
|
}
|
|
|
|
func remoteAdminUsage() {
|
|
fmt.Fprintln(os.Stderr, "Usage: warmblyctl admin <family> <action> [--url URL | --server NAME] [flags]\nRead-only diagnostics are the default; every mutation requires --write and exact --confirm.")
|
|
for _, helper := range remoteAdminHelpers {
|
|
fmt.Fprintf(os.Stderr, " %-28s %-6s %s\n", helper.Command, helper.Method, helper.Path)
|
|
}
|
|
fmt.Fprintln(os.Stderr, " catalog Offline JSON inventory of all existing admin routes and gates\n api METHOD /admin/path Bounded JSON escape hatch; --query key=value, --data @file or -\nUse --help after a helper to see its typed filters. No raw logs, tokens, message bodies or headers are printed.")
|
|
}
|
|
|
|
func runRemoteAdmin(ctx context.Context, args []string) error {
|
|
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
|
remoteAdminUsage()
|
|
return nil
|
|
}
|
|
if args[0] == "catalog" {
|
|
fs := newRemoteFlagSet("admin catalog")
|
|
if err := parseRemoteFlags(fs, args[1:]); err != nil {
|
|
return err
|
|
}
|
|
routes := append([]remoteAdminRoute(nil), remoteAdminRoutes...)
|
|
sort.Slice(routes, func(i, j int) bool { return routes[i].Path+routes[i].Method < routes[j].Path+routes[j].Method })
|
|
type entry struct {
|
|
remoteAdminRoute
|
|
ReadOnly bool `json:"read_only"`
|
|
ConfirmationRequired bool `json:"confirmation_required"`
|
|
ReplayRiskRequired bool `json:"replay_risk_required"`
|
|
}
|
|
entries := make([]entry, 0, len(routes))
|
|
for _, route := range routes {
|
|
entries = append(entries, entry{route, route.Method == "GET", route.Method != "GET", strings.HasSuffix(route.Path, "/replay")})
|
|
}
|
|
return writeRemoteJSON(os.Stdout, entries)
|
|
}
|
|
if args[0] == "api" {
|
|
return runRemoteAdminAPI(ctx, args[1:])
|
|
}
|
|
for _, helper := range remoteAdminHelpers {
|
|
parts := strings.Fields(helper.Command)
|
|
if len(args) < len(parts) {
|
|
continue
|
|
}
|
|
if strings.Join(args[:len(parts)], " ") == helper.Command {
|
|
return runRemoteAdminHelper(ctx, helper, args[len(parts):])
|
|
}
|
|
}
|
|
remoteAdminUsage()
|
|
return remoteFailure(2, "unknown_admin_command", "Unknown admin family or action. Choose a listed helper or catalog entry.")
|
|
}
|
|
|
|
var remotePathWord = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,79}$`)
|
|
var remoteQueryKey = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
|
|
|
func findRemoteAdminRoute(method, requestPath string) (remoteAdminRoute, error) {
|
|
if path.Clean(requestPath) != requestPath || !strings.HasPrefix(requestPath, "/admin/") || strings.ContainsAny(requestPath, "%?#\\\r\n") {
|
|
return remoteAdminRoute{}, remoteFailure(2, "invalid_admin_path", "Use an exact /admin catalog path without query, escapes, traversal or trailing slash. Supply query flags separately.")
|
|
}
|
|
parts := strings.Split(requestPath, "/")
|
|
for _, route := range remoteAdminRoutes {
|
|
if route.Method != method {
|
|
continue
|
|
}
|
|
template := strings.Split(route.Path, "/")
|
|
if len(template) != len(parts) {
|
|
continue
|
|
}
|
|
match := true
|
|
for i, segment := range template {
|
|
if !strings.HasPrefix(segment, ":") {
|
|
if segment != parts[i] {
|
|
match = false
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
name := strings.TrimPrefix(segment, ":")
|
|
if name == "id" || strings.HasSuffix(strings.ToLower(name), "id") {
|
|
id, err := uuid.Parse(parts[i])
|
|
if err != nil || id == uuid.Nil || id.String() != parts[i] {
|
|
match = false
|
|
break
|
|
}
|
|
} else if !remotePathWord.MatchString(parts[i]) || parts[i] == "." || parts[i] == ".." {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
return route, nil
|
|
}
|
|
}
|
|
return remoteAdminRoute{}, remoteFailure(2, "endpoint_not_cataloged", "Method and path are not a valid catalog entry. No request was sent.")
|
|
}
|
|
|
|
type remoteQueries []string
|
|
|
|
func (q *remoteQueries) String() string { return "" }
|
|
func (q *remoteQueries) Set(value string) error {
|
|
*q = append(*q, value)
|
|
return nil
|
|
}
|
|
|
|
func parseRemoteQueries(raw remoteQueries) (url.Values, error) {
|
|
values := url.Values{}
|
|
if len(raw) > 40 {
|
|
return nil, remoteFailure(2, "invalid_query", "At most 40 query filters are accepted.")
|
|
}
|
|
for _, item := range raw {
|
|
key, value, found := strings.Cut(item, "=")
|
|
if !found || !remoteQueryKey.MatchString(key) || len(value) > 2048 || strings.IndexFunc(value, func(r rune) bool { return r < 32 || r == 127 }) >= 0 {
|
|
return nil, remoteFailure(2, "invalid_query", "Query filters must be bounded key=value pairs without control characters.")
|
|
}
|
|
for _, secret := range []string{"token", "secret", "password", "credential", "authorization", "api_key"} {
|
|
if strings.Contains(key, secret) {
|
|
return nil, remoteFailure(2, "invalid_query", "Credentials and passwords must not be placed in query flags.")
|
|
}
|
|
}
|
|
if _, duplicate := values[key]; duplicate {
|
|
return nil, remoteFailure(2, "invalid_query", "Duplicate query keys are not accepted.")
|
|
}
|
|
values.Set(key, value)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func readRemoteBody(source string) (json.RawMessage, error) {
|
|
if source == "" {
|
|
return nil, nil
|
|
}
|
|
var reader io.Reader = os.Stdin
|
|
if source != "-" {
|
|
if !strings.HasPrefix(source, "@") || len(source) < 2 {
|
|
return nil, remoteFailure(2, "invalid_body_source", "Use --data @file or --data - for stdin. Inline JSON, passwords and credentials are never accepted in argv.")
|
|
}
|
|
f, err := os.Open(strings.TrimPrefix(source, "@"))
|
|
if err != nil {
|
|
return nil, remoteFailure(2, "invalid_body_source", "Cannot open the JSON input file. No filename or raw input was printed.")
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
return nil, remoteFailure(2, "invalid_body_source", "JSON input must be a regular file or explicit stdin.")
|
|
}
|
|
reader = f
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(reader, (1<<20)+1))
|
|
if err != nil || len(body) > 1<<20 {
|
|
return nil, remoteFailure(2, "invalid_json", "JSON input is unreadable or exceeds 1 MiB.")
|
|
}
|
|
value, err := decodeRemoteJSON(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := value.(map[string]any); !ok {
|
|
return nil, remoteFailure(2, "invalid_json", "Admin JSON input must be an object.")
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func executeRemoteAdmin(ctx context.Context, sel remoteSelection, method, requestPath string, query url.Values, body json.RawMessage, write bool, confirmation string, replayRisk bool) error {
|
|
route, err := findRemoteAdminRoute(method, requestPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if route.Blocked != "" {
|
|
return remoteFailure(8, "endpoint_unavailable", route.Blocked+". No request was sent.")
|
|
}
|
|
if method == "GET" {
|
|
if body != nil || write || confirmation != "" || replayRisk {
|
|
return remoteFailure(2, "invalid_arguments", "Read-only commands do not accept bodies or write/replay flags.")
|
|
}
|
|
} else {
|
|
if err := requireRemoteWrite(method, requestPath, write, confirmation); err != nil {
|
|
return err
|
|
}
|
|
if strings.HasSuffix(route.Path, "/replay") && !replayRisk {
|
|
return remoteFailure(8, "replay_risk_required", "Replay also requires --acknowledge-send-risk after inspecting provider evidence. This flag is not evidence that a send did or did not happen. No request was sent.")
|
|
}
|
|
}
|
|
client, err := newRemoteClient(ctx, sel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.store.close()
|
|
if len(query) > 0 {
|
|
requestPath += "?" + query.Encode()
|
|
}
|
|
payload, err := client.do(ctx, method, requestPath, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return printRemoteResponse(payload)
|
|
}
|
|
|
|
func runRemoteAdminAPI(ctx context.Context, args []string) error {
|
|
if len(args) < 2 {
|
|
remoteAdminUsage()
|
|
return remoteFailure(2, "invalid_arguments", "admin api requires METHOD /admin/path followed by flags.")
|
|
}
|
|
method, requestPath := strings.ToUpper(args[0]), args[1]
|
|
fs := newRemoteFlagSet("admin api METHOD /admin/path")
|
|
var sel remoteSelection
|
|
sel.flags(fs)
|
|
write, confirm := remoteWriteFlags(fs)
|
|
risk := fs.Bool("acknowledge-send-risk", false, "Explicit dead-letter replay risk acknowledgement; never establishes send authority")
|
|
data := fs.String("data", "", "JSON object from @file or - (stdin), never inline argv")
|
|
var queries remoteQueries
|
|
fs.Var(&queries, "query", "Bounded query filter key=value; may be repeated")
|
|
if err := parseRemoteFlags(fs, args[2:]); err != nil {
|
|
return err
|
|
}
|
|
// Validate route and confirmation before opening body input or credential storage.
|
|
route, err := findRemoteAdminRoute(method, requestPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if route.Blocked != "" {
|
|
return remoteFailure(8, "endpoint_unavailable", route.Blocked)
|
|
}
|
|
if method != "GET" {
|
|
if err := requireRemoteWrite(method, requestPath, *write, *confirm); err != nil {
|
|
return err
|
|
}
|
|
} else if *data != "" {
|
|
return remoteFailure(2, "invalid_arguments", "GET commands cannot carry JSON bodies.")
|
|
}
|
|
query, err := parseRemoteQueries(queries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := readRemoteBody(*data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return executeRemoteAdmin(ctx, sel, method, requestPath, query, body, *write, *confirm, *risk)
|
|
}
|
|
|
|
func runRemoteAdminHelper(ctx context.Context, helper remoteAdminHelper, args []string) error {
|
|
fs := newRemoteFlagSet("admin " + helper.Command)
|
|
var sel remoteSelection
|
|
sel.flags(fs)
|
|
id := ""
|
|
if helper.IDFlag != "" {
|
|
fs.StringVar(&id, helper.IDFlag, "", "Required canonical UUID for this target")
|
|
}
|
|
query := url.Values{}
|
|
var limit *int
|
|
var cursor, q, state, status, level, after, before, mailbox *string
|
|
if helper.Method == "GET" && (helper.Command == "logs" || helper.Command == "sync list" || helper.Command == "mailbox list" || helper.Command == "sends in-flight" || helper.Command == "recovery dead-letters" || helper.Command == "recovery failures") {
|
|
limit = fs.Int("limit", 100, "Maximum rows, 1 to 200 (server may apply a lower endpoint cap)")
|
|
}
|
|
switch helper.Command {
|
|
case "logs":
|
|
level = fs.String("level", "", "info, warn or error")
|
|
after = fs.String("after", "", "Inclusive RFC3339 observation time")
|
|
before = fs.String("before", "", "Inclusive RFC3339 observation time")
|
|
mailbox = fs.String("mailbox-id", "", "Optional mailbox UUID")
|
|
case "sync list":
|
|
state = fs.String("state", "", "all, throttled, backfilling, stalled, pending or complete")
|
|
q = fs.String("q", "", "Mailbox search (never a password or token)")
|
|
cursor = fs.String("cursor", "", "Cursor from the preceding page")
|
|
case "mailbox list":
|
|
q = fs.String("q", "", "Mailbox search (never a password or token)")
|
|
cursor = fs.String("cursor", "", "Cursor from the preceding page")
|
|
case "recovery dead-letters":
|
|
status = fs.String("status", "", "pending, replayed or failed")
|
|
cursor = fs.String("cursor", "", "Cursor UUID from the preceding page")
|
|
}
|
|
var write bool
|
|
var confirm string
|
|
var risk bool
|
|
if helper.Method != "GET" {
|
|
fs.BoolVar(&write, "write", false, "Explicitly authorize this mutation")
|
|
fs.StringVar(&confirm, "confirm", "", "Repeat exact METHOD /admin/target-path")
|
|
if helper.Command == "recovery replay" {
|
|
fs.BoolVar(&risk, "acknowledge-send-risk", false, "Acknowledge replay risk after inspecting provider evidence")
|
|
}
|
|
}
|
|
if err := parseRemoteFlags(fs, args); err != nil {
|
|
return err
|
|
}
|
|
requestPath := helper.Path
|
|
if helper.IDFlag != "" {
|
|
parsed, err := uuid.Parse(id)
|
|
if err != nil || parsed == uuid.Nil || parsed.String() != id {
|
|
return remoteFailure(2, "invalid_id", "This helper requires a nonzero canonical UUID target.")
|
|
}
|
|
requestPath = strings.ReplaceAll(requestPath, ":id", id)
|
|
}
|
|
if limit != nil {
|
|
if *limit < 1 || *limit > 200 {
|
|
return remoteFailure(2, "invalid_limit", "--limit must be between 1 and 200.")
|
|
}
|
|
query.Set("limit", strconv.Itoa(*limit))
|
|
}
|
|
for key, filter := range map[string]*string{"q": q, "cursor": cursor, "state": state, "status": status, "level": level, "after": after, "before": before, "mailbox_id": mailbox} {
|
|
if filter != nil && *filter != "" {
|
|
query.Set(key, *filter)
|
|
}
|
|
}
|
|
for key, allowed := range map[string]string{"state": "all throttled backfilling stalled pending complete", "status": "pending replayed failed", "level": "info warn error"} {
|
|
if value := query.Get(key); value != "" && !remoteWordSet(allowed)[value] {
|
|
return remoteFailure(2, "invalid_filter", "Invalid enumerated filter. Use --help for supported values.")
|
|
}
|
|
}
|
|
if value := query.Get("mailbox_id"); value != "" {
|
|
if parsed, err := uuid.Parse(value); err != nil || parsed == uuid.Nil || parsed.String() != value {
|
|
return remoteFailure(2, "invalid_filter", "--mailbox-id requires a canonical UUID.")
|
|
}
|
|
}
|
|
var afterTime, beforeTime time.Time
|
|
for key, target := range map[string]*time.Time{"after": &afterTime, "before": &beforeTime} {
|
|
if value := query.Get(key); value != "" {
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
return remoteFailure(2, "invalid_time", "Log timestamps must use RFC3339.")
|
|
}
|
|
*target = parsed
|
|
}
|
|
}
|
|
if !afterTime.IsZero() && !beforeTime.IsZero() && afterTime.After(beforeTime) {
|
|
return remoteFailure(2, "invalid_time", "--after must not be later than --before.")
|
|
}
|
|
if cursor != nil && *cursor != "" {
|
|
if helper.Command == "recovery dead-letters" {
|
|
if parsed, err := uuid.Parse(*cursor); err != nil || parsed.String() != *cursor {
|
|
return remoteFailure(2, "invalid_cursor", "Dead-letter cursor must be a canonical UUID.")
|
|
}
|
|
} else if _, err := paging.DecodeOffsetCursor(*cursor); err != nil {
|
|
return remoteFailure(2, "invalid_cursor", "Use the next_cursor from this endpoint, not a page number.")
|
|
}
|
|
}
|
|
var raw remoteQueries
|
|
for key, values := range query {
|
|
raw = append(raw, key+"="+values[0])
|
|
}
|
|
validated, err := parseRemoteQueries(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return executeRemoteAdmin(ctx, sel, helper.Method, requestPath, validated, nil, write, confirm, risk)
|
|
}
|