// SPDX-License-Identifier: AGPL-3.0-or-later package admin import ( "crypto/rand" "crypto/subtle" "encoding/json" "fmt" "log" "net" "net/http" "strconv" "strings" "time" "golang.org/x/crypto/bcrypt" "comail/internal/enroll" "comail/internal/notify" "comail/internal/relay" "comail/internal/relaystore" ) // dummyBcryptHash is a bcrypt hash over a random-at-startup 32-byte // secret. It exists solely so the self-service member endpoints can burn // a bcrypt.CompareHashAndPassword cycle on the DID-not-found / // domain-not-found paths, equalising wall-clock response time with the // real "DID found, wrong key" path. Without this, the not-found branch // returns in under a millisecond while the bad-key branch runs a full // bcrypt (~100ms), which is a reliable remote DID-enumeration oracle. // // The secret that was hashed is never referenced again — the only // property we care about is "CompareHashAndPassword against this hash // takes the same time as against any other DefaultCost hash", which is // true for any hash produced by bcrypt.GenerateFromPassword at the same // cost. We generate fresh bytes on every process start to avoid shipping // a statically-known hash in the binary. var dummyBcryptHash = func() []byte { var buf [32]byte if _, err := rand.Read(buf[:]); err != nil { // crypto/rand failing on process start is unrecoverable — the // whole relay depends on a working RNG for API key minting, // challenge nonces, DKIM, etc. panic(fmt.Sprintf("admin: crypto/rand.Read for dummy bcrypt hash: %v", err)) } h, err := bcrypt.GenerateFromPassword(buf[:], bcrypt.DefaultCost) if err != nil { panic(fmt.Sprintf("admin: bcrypt.GenerateFromPassword for dummy hash: %v", err)) } return h }() // equalizeBcryptTiming burns an approximately-bcrypt-shaped amount of // CPU so a caller on the not-found / bad-input branch can't distinguish // that path from the real "found, wrong key" one. The result is // intentionally discarded — the cycles are the point. func equalizeBcryptTiming(providedKey string) { _ = bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(providedKey)) } // pendingEnrollmentTTL bounds how long a published DNS record remains valid // for completing an enrollment. Long enough that a user can publish records // at their registrar and wait for propagation, short enough that leaked // tokens cannot be held indefinitely. 24h mirrors typical registrar TTL // caps and matches how Let's Encrypt treats its own DNS-01 challenges. const pendingEnrollmentTTL = 24 * time.Hour // DID syntax validation lives in internal/did. The shared validator // permits %-encoded did:web (e.g. example.com%3A8080 for ports), which // the prior local regex incorrectly rejected — log-injection mitigation // now relies on HashForLog redaction at log sites, not on filtering % // from the syntax. // isValidDomain checks if a domain is syntactically valid. func isValidDomain(domain string) bool { if domain == "" || len(domain) > 253 { return false } // Must contain at least one dot if !strings.Contains(domain, ".") { return false } // Must not start or end with a dot/hyphen if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.HasPrefix(domain, "-") || strings.HasSuffix(domain, "-") { return false } // Each label must be valid for _, label := range strings.Split(domain, ".") { if len(label) == 0 || len(label) > 63 { return false } for _, c := range label { if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { return false } } } // Must not be an IP address if net.ParseIP(domain) != nil { return false } return true } // clientIPFromRemoteAddr strips the port suffix from net/http's // RemoteAddr so the rate limiter buckets by IP rather than by // (IP, ephemeral-port) pair. Falls back to the raw string if the // address is already host-only or otherwise unparseable — better to // treat a malformed-looking peer as a single IP than to silently // bypass the limiter. func clientIPFromRemoteAddr(remoteAddr string) string { host, _, err := net.SplitHostPort(remoteAddr) if err != nil { return remoteAddr } return host } // API handles admin endpoints for the relay. type API struct { store *relaystore.Store adminToken string prevAdminToken string // optional second-active token for zero-downtime rotation; see SetPrevAdminToken domain string // relay domain (e.g. "atmos.email") labelChecker *relay.LabelChecker spfChecker *relay.SPFChecker domainVerifier *enroll.DomainVerifier mux *http.ServeMux // enableMigrations gates the one-time encryption-migration endpoints // (/admin/migrate-dkim-encryption, /admin/migrate-forward-to-encryption). // When false (the default), those endpoints return 404 so a probing // scan can't even confirm they exist. Operators flip this to true // only for the duration of a migration window. Wired via // SetMigrationEndpointsEnabled. enableMigrations bool // Pool-level operator DKIM (optional; set via SetOperatorDKIM). // Drives the GET /admin/operator/dkim copy-paste view. operatorDKIMKeys *relay.DKIMKeys operatorDKIMDomain string // warmup is the optional warmup sender for operator-initiated sends. // Wired via SetWarmupSender; nil disables the /admin/warmup endpoint. warmup *relay.WarmupSender // System-mail hooks. Set via SetOpMailer. Optional — when nil, all // transactional notifications (operator-ping, welcome, key-regen) // are skipped with a logged warning. operatorForwardTo names the // external address the relay pings when a new pending enrollment // lands; empty disables operator-ping. Welcome and regenerate-key // still fire based on the member's contact_email. opMailer *relay.OpMailer operatorForwardTo string opMailRelayDomain string opMailReviewBaseURL string // Optional ntfy push for new-signup operator alerts. Wired via // SetSignupNtfy in cmd/relay when NTFY_ATMOS_TOKEN is in env and // cfg.NtfySignupTopicURL is set. nil disables — the operator-ping // email path still fires regardless. signupNtfyTopicURL string signupNtfyToken string // notifier sends structured operator events to a pluggable // webhook. Nil = disabled. Wired via SetNotifier. notifier *notify.Sender // enrollStartLimiter protects the single public, unauthenticated // endpoint (/admin/enroll-start) from per-IP flood abuse. Shared // state across all requests — constructed in NewComplete and never // swapped out, so no additional locking at the field level. enrollStartLimiter *rateLimiter // enrollAuthVerifier, when non-nil, gates /admin/enroll-start and // /admin/enroll on a successful AT Proto OAuth-verified DID. Nil // preserves legacy behavior (DNS-TXT-only ownership proof). Wired // via SetEnrollAuthVerifier in cmd/relay after the EnrollHandler // exists; do NOT enroll new members in production with this nil. enrollAuthVerifier EnrollAuthVerifier // paidTiersEnabled gates per-tier domain caps (internal ref). // When false (default — ATMOS_FEATURE_PAID_TIERS off), the API // uses EffectiveCapForPlan, which collapses every plan to // PreTierDomainCap (1 domain) regardless of the row's plan column. // When true, CapForPlan's per-tier table applies. Set via // SetPaidTiersEnabled at wire time. paidTiersEnabled bool // paidTiersEnabledFn, when non-nil, is read LIVE via PaidTiersEnabled() // and takes precedence over the static bool, so a live flag override // (internal ref) changes the cap gate without a restart. Tests use // the static setter; production wires the provider. paidTiersEnabledFn func() bool } // EnrollAuthVerifier resolves an OAuth-verified DID from an inbound // HTTP request (typically by reading an enroll-auth ticket cookie set // after a successful AT Proto OAuth flow). Returns the verified DID // and true on success, or "" / false when no proof is present. // // The relay package owns the implementation; the admin API only // consumes the interface so its package boundary stays free of UI // imports. See internal/admin/ui (EnrollHandler.VerifyAuthCookie). type EnrollAuthVerifier interface { VerifyAuthCookie(r *http.Request) (did string, ok bool) } // SetEnrollAuthVerifier installs the OAuth-verifier dependency. Must // be called before /admin/enroll-start and /admin/enroll receive // production traffic; safe to call once during wiring. func (a *API) SetEnrollAuthVerifier(v EnrollAuthVerifier) { a.enrollAuthVerifier = v } // SetPaidTiersEnabled flips the per-tier domain-cap path on or off. // Default off — every plan resolves to PreTierDomainCap (1). Wired // from cmd/relay's feature-flag map at startup; not exposed to runtime // reconfiguration. func (a *API) SetPaidTiersEnabled(on bool) { a.paidTiersEnabled = on } // SetPaidTiersProvider wires a live provider for the paid-tiers gate // (internal ref). When set it is read fresh on every PaidTiersEnabled() // call, so a runtime flag override takes effect without a restart. func (a *API) SetPaidTiersProvider(fn func() bool) { a.paidTiersEnabledFn = fn } // PaidTiersEnabled reports the flag's current state, reading the live // provider when wired (production) and the static bool otherwise (tests). // Read by the /admin/enroll-start + /admin/enroll phases so the cap-gate // calls route through EffectiveCapForPlan with the right input. func (a *API) PaidTiersEnabled() bool { if a.paidTiersEnabledFn != nil { return a.paidTiersEnabledFn() } return a.paidTiersEnabled } // DomainCapByPlan is the per-tier ceiling on how many sending domains // a single DID may enroll (internal ref). Paid tiers unlock more // domains; the default self-host tier stays at 1 so a fresh / unpaid // member can't fan out across the shared IP before warming. // // Keep this list aligned with relaystore.ValidMemberPlans — adding a // new tier means appending to both. The map is read-only at runtime; // CapForPlan is the only sanctioned accessor so the unknown-plan // fallback stays consistent across call sites. var DomainCapByPlan = map[string]int{ relaystore.PlanSelfHost: 1, relaystore.PlanPersonal: 1, relaystore.PlanBusiness: 5, relaystore.PlanAgency: 25, } // CapForPlan returns the domain-count ceiling for a member's plan // (internal ref). An unknown / empty / mis-spelled plan falls back // to the self-host cap — the most-cautious tier — so a code path that // reads from an uninitialized field can never accidentally unlock // more domains than the lowest tier allows. The fallback also covers // future-tier strings written by a newer admin process before the // reading process learns about them. func CapForPlan(plan string) int { if n, ok := DomainCapByPlan[plan]; ok { return n } return DomainCapByPlan[relaystore.PlanSelfHost] } // PreTierDomainCap is the per-member domain cap used when the // FeaturePaidTiers kill switch is off (today's steady state — every // member is on the free Self-Host plan). Mirrors the public /pricing // promise of 1 domain on the free tier. Used by EffectiveCapForPlan // when paid-tier gating is disabled so every member shares the same // flat ceiling regardless of plan column. const PreTierDomainCap = 1 // EffectiveCapForPlan returns the operative domain ceiling once the // FeaturePaidTiers kill switch is taken into account. When // paidTiersEnabled is false (default — ATMOS_FEATURE_PAID_TIERS unset // or false), every plan collapses to PreTierDomainCap (1), matching // the public /pricing promise that the free Self-Host tier gets 1 // sending domain. When true, behavior matches CapForPlan exactly. // // All gate sites (canAddDomain, checkDomainAvailable, checkEnrollStart) // route through this helper so the flag has a single point of control. func EffectiveCapForPlan(plan string, paidTiersEnabled bool) int { if !paidTiersEnabled { return PreTierDomainCap } return CapForPlan(plan) } // DomainCapCTA is the upgrade hint surfaced when a member trips the // per-tier domain cap. Centralised so every "limit reached" message // (UI banner, enroll API error, enroll-start error) points at the // same path. The /pricing route lands in internal ref; until it // ships the link is a 404, but the string lives in the error so the // CTA goes live the moment the route does without re-touching every // caller. const DomainCapCTA = "/pricing" // DomainCapExceededMessage formats the canonical "you've hit your // tier's domain limit" error. Names the tier + the count so the user // knows exactly what they're hitting, and includes the /pricing CTA // (see DomainCapCTA) so they have a next step. Used by canAddDomain // (UI), checkDomainAvailable (enroll API), and checkEnrollStart // (enroll-start API) so the prose stays in one place. func DomainCapExceededMessage(plan string) string { return fmt.Sprintf( "Your %s tier allows up to %d domains. Upgrade at %s to add more.", plan, CapForPlan(plan), DomainCapCTA, ) } // EffectiveDomainCapExceededMessage formats the cap-exceeded error // with the FeaturePaidTiers kill switch threaded in. When the flag // is off, the message reads "The free tier is limited to 1 domain. // Contact the operator if you need more." — no tier name, no /pricing // CTA, because commercial tiers are dark. When the flag is on, it // delegates to DomainCapExceededMessage so the existing per-tier // prose stays canonical. func EffectiveDomainCapExceededMessage(plan string, paidTiersEnabled bool) string { if paidTiersEnabled { return DomainCapExceededMessage(plan) } noun := "domains" if PreTierDomainCap == 1 { noun = "domain" } return fmt.Sprintf( "The free tier is limited to %d %s. Contact the operator if you need more.", PreTierDomainCap, noun, ) } // enrollStartRateLimit is the number of /admin/enroll-start requests // permitted per source IP per window. Generous enough that a confused // user hammering the wizard isn't locked out; tight enough that a // script-kiddie spam bot fills DB rows and access logs measurably faster // than we can tolerate. const enrollStartRateLimit = 10 // enrollStartRateWindow is the sliding window over which enrollStartRateLimit // applies. Matches the pending-enrollment TTL's order of magnitude so that // an abuser can't pair a one-hour spam burst with a fresh set of tokens // on the next boundary. const enrollStartRateWindow = time.Hour // New creates an admin API handler. func New(store *relaystore.Store, adminToken, domain string) *API { return NewWithLabelChecker(store, adminToken, domain, nil) } // NewWithLabelChecker creates an admin API handler with label bypass support. func NewWithLabelChecker(store *relaystore.Store, adminToken, domain string, lc *relay.LabelChecker) *API { return NewFull(store, adminToken, domain, lc, nil) } // NewFull creates an admin API handler with all optional components. func NewFull(store *relaystore.Store, adminToken, domain string, lc *relay.LabelChecker, spf *relay.SPFChecker) *API { return NewComplete(store, adminToken, domain, lc, spf, nil) } // NewComplete creates an admin API handler including DNS-TXT domain- // ownership verification for self-service enrollment. Pass a nil verifier // to disable the self-service path (admin-force enrollment still works). func NewComplete(store *relaystore.Store, adminToken, domain string, lc *relay.LabelChecker, spf *relay.SPFChecker, dv *enroll.DomainVerifier) *API { a := &API{ store: store, adminToken: adminToken, domain: domain, labelChecker: lc, spfChecker: spf, domainVerifier: dv, mux: http.NewServeMux(), enrollStartLimiter: newRateLimiter(enrollStartRateLimit, enrollStartRateWindow, nil), } a.mux.HandleFunc("/admin/enroll", a.handleEnroll) a.mux.HandleFunc("/admin/enroll-marque", a.handleEnrollMarque) a.mux.HandleFunc("/admin/enroll-start", a.handleEnrollStart) a.mux.HandleFunc("/admin/members", a.handleListMembers) a.mux.HandleFunc("/admin/member/", a.handleMember) a.mux.HandleFunc("/admin/stats", a.handleStats) a.mux.HandleFunc("/admin/bypass-labels", a.handleBypassList) // Durable notification queue: /admin/notifications/dead-letter // lists rows that hit MaxNotificationAttempts so an operator can // triage them. Admin-authenticated; read-only. a.mux.HandleFunc("/admin/notifications/dead-letter", a.handleNotificationsDeadLetter) // Inbound reply forwarding: sets/updates the forward_to for a registered // member domain. Admin-authenticated. Body: {"forwardTo": "real@mailbox.com"} a.mux.HandleFunc("/admin/domain/", a.handleDomain) a.mux.HandleFunc("/admin/warmup", a.handleWarmup) // Per-DID send/bounce/complaint rollup over a rolling window. // Read by the labeler's clean-sender computation and useful // to operators investigating a specific member's deliverability. a.mux.HandleFunc("/admin/sender-reputation", a.handleSenderReputation) // Append-only OAuth signin audit. Powers the Grafana "who signed in // and when" panel. Admin-authenticated; read-only. a.mux.HandleFunc("/admin/oauth-signins", a.handleOAuthSignins) // Idempotent migration: rotate any legacy plaintext DKIM private // keys in member_domains into AES-256-GCM blobs using RELAY_PII_KEY. // One-shot operator hit after #386 ships, to close the residual // gap for members enrolled before that change. Idempotent — safe // to re-run. See internal ref. a.mux.HandleFunc("/admin/migrate-dkim-encryption", a.handleMigrateDKIMEncryption) // Idempotent migration for the `forward_to` column (internal ref). // Same shape as #387 but for the member's inbound-mailbox field; // runs once after #390 ships to retro-encrypt rows enrolled before. a.mux.HandleFunc("/admin/migrate-forward-to-encryption", a.handleMigrateForwardToEncryption) a.mux.HandleFunc("/admin/dkim-check/", a.handleDKIMCheck) // Phase 5c-3a: typed AccountStore-over-loopback endpoints. cmd/web // uses these via internal/adminclient/account.go to satisfy the // AccountStore interface that AccountHandler/RecoverHandler take, // without giving cmd/web direct database access. See // account_api.go for the per-endpoint contract. a.mux.HandleFunc("/admin/account/member/", a.handleAccountMember) a.mux.HandleFunc("/admin/account/domain/", a.handleAccountDomain) a.mux.HandleFunc("/admin/account/pending-enrollment/", a.handleAccountPendingEnrollment) // Public email verification endpoint — no auth required. Members click // the link from their verification email to confirm contact_email ownership. a.mux.HandleFunc("/verify-email", a.HandleVerifyEmail) // Public (API-key-authenticated) endpoint for members to check their own status. // No admin token required — authenticated by the member's SMTP API key. a.mux.HandleFunc("/member/status", a.handleMemberSelfStatus) // Self-service deliverability metrics — API-key-authenticated like /member/status. a.mux.HandleFunc("/member/deliverability", a.handleMemberSelfDeliverability) // Self-service forward_to update — API-key-authenticated like /member/status. a.mux.HandleFunc("/member/forward-to", a.handleMemberSelfForwardTo) return a } func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.mux.ServeHTTP(w, r) } // --- Auth middleware --- func (a *API) requireAuth(w http.ResponseWriter, r *http.Request) bool { provided := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if a.adminToken == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return false } // Constant-time compare against both the primary and the optional // previous token. The previous-token slot exists to support zero- // downtime rotation: an operator deploys with ADMIN_TOKEN_PREV set to // the OLD value while ADMIN_TOKEN holds the NEW value, updates every // downstream consumer, then redeploys with ADMIN_TOKEN_PREV cleared. // Both comparisons run unconditionally so timing cannot leak which // slot accepted the request. primaryOK := subtle.ConstantTimeCompare([]byte(provided), []byte(a.adminToken)) == 1 prevOK := a.prevAdminToken != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(a.prevAdminToken)) == 1 if !primaryOK && !prevOK { http.Error(w, "unauthorized", http.StatusUnauthorized) return false } return true } // SetPrevAdminToken installs an optional second-active admin token for // zero-downtime rotation. While set, the auth middleware accepts EITHER // the primary (a.adminToken) or this previous value. Empty string // clears the slot. Constant-time compared against both unconditionally. // // Rotation procedure (see docs/operator-runbook.md): // 1. Deploy with ADMIN_TOKEN=, ADMIN_TOKEN_PREV=. // 2. Update every consumer (CI secrets, scripts, dashboards) to the new token. // 3. Redeploy with ADMIN_TOKEN_PREV cleared. // // Long-term answer is the DB-backed multi-token primitive tracked in a // follow-up chainlink — this slot is a deliberately small surface that // keeps the rotation runbook humane without adding a new auth backend. func (a *API) SetPrevAdminToken(token string) { a.prevAdminToken = token } // SetMigrationEndpointsEnabled toggles the one-time encryption-migration // endpoints on or off. When false (the default), the handlers return // 404 — a probing scan cannot distinguish "endpoint disabled" from // "endpoint never existed." Operators flip this true only for the // duration of a migration window, then back to false. func (a *API) SetMigrationEndpointsEnabled(enabled bool) { a.enableMigrations = enabled } // SetEnrollStartRateLimit reconfigures the per-source-IP rate limit on // the unauthenticated /admin/enroll-start endpoint. limit is the number // of requests permitted per window; window is the sliding-window // duration. Both must be positive — non-positive values are ignored // and the existing limiter is left in place. Wired from cfg in // cmd/relay so operators can dial it per deployment without a code // change (and so the published docs don't need to disclose the exact // numbers). func (a *API) SetEnrollStartRateLimit(limit int, window time.Duration) { if limit <= 0 || window <= 0 { return } a.enrollStartLimiter = newRateLimiter(limit, window, nil) } // --- OAuth signins audit --- // OAuthSigninResponse is one row of the /admin/oauth-signins JSON // payload. Field names are part of the dashboard contract; renaming // requires a coordinated dashboard update. type OAuthSigninResponse struct { ID int64 `json:"id"` DID string `json:"did"` Handle string `json:"handle"` PDS string `json:"pds"` Domain string `json:"domain"` Flow string `json:"flow"` CreatedAt string `json:"createdAt"` // RFC3339 UTC } // handleOAuthSignins returns recent OAuth callback completions in // newest-first order. Query parameters: // // since — RFC3339 timestamp; only rows >= this time are returned // limit — integer; default 500, capped at 5000 // // Response: {"signins":[{...}, ...]} func (a *API) handleOAuthSignins(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !a.requireAuth(w, r) { return } var since time.Time if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { t, err := time.Parse(time.RFC3339, raw) if err != nil { http.Error(w, "invalid 'since' (must be RFC3339)", http.StatusBadRequest) return } since = t.UTC() } limit := 0 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 0 { http.Error(w, "invalid 'limit'", http.StatusBadRequest) return } limit = n } signins, err := a.store.ListOAuthSignins(r.Context(), since, limit) if err != nil { log.Printf("admin.oauth_signins: error=%v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } resp := make([]OAuthSigninResponse, len(signins)) for i, s := range signins { resp[i] = OAuthSigninResponse{ ID: s.ID, DID: s.DID, Handle: s.Handle, PDS: s.PDS, Domain: s.Domain, Flow: s.Flow, CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339), } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string][]OAuthSigninResponse{"signins": resp}) }