Something went wrong. Try again.
forked from scottlanoue.com/atmosphere-mail
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585// 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=<new>, ADMIN_TOKEN_PREV=<old>.// 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})}