fix(dns): o botao de reconciliar nao criava registo nenhum
O painel listava os registos em falta e oferecia um botao para os criar, mas esse botao chegava a um endpoint que so escrevia uma linha de auditoria e respondia "REQUESTED". Nunca tocou no DNS. Quem tinha o token da Cloudflare era o servico de protocolos, e a unica coisa capaz de o usar corria num temporizador -- para um dominio so, o de MAIL_DOMAIN. Por isso um dominio adicionado pela consola, como o cienciaembarcada.com.br, nunca era reconciliado: os registos nao apareciam e o unico sintoma visivel era a consola dizer que faltavam, para sempre. - POST /api/v1/admin/dns/reconcile publica os registos de um dominio, agora, e devolve o que foi criado, corrigido e o que ficou em conflito - o temporizador passa a percorrer todos os dominios ativos - conflitos sao reportados, nao resolvidos: um registo do mesmo tipo e nome com outro valor pode ser correio da zona ou um servico de que ninguem se lembra, e nao e deste servidor decidir sobrepo-lo - o erro do fornecedor fica no log, nao na resposta: pode nomear a conta ou a zona Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8227fe2b97
commit
2b369ab373
11 files changed
+497
-17
No files matched your search
@@ -0,0 +1,42 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// DomainRepository lists the domains this server is responsible for.
|
||||
type DomainRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewDomainRepository(pool *pgxpool.Pool) *DomainRepository {
|
||||
return &DomainRepository{pool: pool}
|
||||
}
|
||||
|
||||
// ActiveDomainNames returns every active domain.
|
||||
//
|
||||
// The DNS reconciler used to run against MAIL_DOMAIN alone, so a domain added
|
||||
// through the console was never reconciled: its records simply never appeared,
|
||||
// and the only visible symptom was the console reporting them missing forever.
|
||||
func (r *DomainRepository) ActiveDomainNames(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT name FROM domains WHERE is_active ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list active domains: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package httppresentation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -22,10 +23,79 @@ type DnsSpecSource interface {
|
||||
ExpectedRecords(ctx context.Context, domain string) ([]entity.DnsRecord, error)
|
||||
}
|
||||
|
||||
// ReconcileResult is what one reconciliation did.
|
||||
//
|
||||
// Conflicts are reported rather than resolved: a record of the right type and
|
||||
// name already holding somebody else's value is not this server's to
|
||||
// overwrite - it may be the zone's mail, or a service nobody remembered.
|
||||
type ReconcileResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// DomainReconciler publishes a domain's expected records through the DNS
|
||||
// provider, creating what is missing and correcting what disagrees.
|
||||
//
|
||||
// This is the half the console never had. Verification could say a record was
|
||||
// missing; nothing exposed the ability to then create it, even though the
|
||||
// service holding the provider token could do exactly that.
|
||||
type DomainReconciler interface {
|
||||
ReconcileDomain(ctx context.Context, domain string) (ReconcileResult, error)
|
||||
}
|
||||
|
||||
type adminDnsAPI struct {
|
||||
spec DnsSpecSource
|
||||
verifier DnsRecordVerifier
|
||||
sessions *WebSessionVerifier
|
||||
// reconciler is optional: without it the route reports that the feature
|
||||
// is not configured rather than answering as though it had run.
|
||||
reconciler DomainReconciler
|
||||
}
|
||||
|
||||
// handleReconcile publishes the records a domain is missing.
|
||||
//
|
||||
// The console's reconcile button used to reach an endpoint that wrote an audit
|
||||
// row and answered "REQUESTED" - it never touched DNS, and the operator was
|
||||
// left with a list of missing records and no way to act on it.
|
||||
func (a *adminDnsAPI) handleReconcile(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearerOrCookie(r, "lm_admin_session")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
|
||||
return
|
||||
}
|
||||
if _, err := a.sessions.RequireSurface(token, "admin"); err != nil {
|
||||
// Creating DNS records is an administrative action; a webmail session
|
||||
// carries the wrong audience and stops here.
|
||||
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
|
||||
return
|
||||
}
|
||||
|
||||
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
|
||||
if domain == "" {
|
||||
writeError(w, http.StatusBadRequest, "DOMAIN_REQUIRED", "A domain is required")
|
||||
return
|
||||
}
|
||||
if a.reconciler == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "RECONCILER_UNAVAILABLE",
|
||||
"DNS reconciliation needs a provider token to be configured")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := a.reconciler.ReconcileDomain(r.Context(), domain)
|
||||
if err != nil {
|
||||
// Logged rather than echoed: a provider error can name the account or
|
||||
// the zone, and that is not the browser's business.
|
||||
log.Printf("dns: could not reconcile %s: %v", domain, err)
|
||||
writeError(w, http.StatusBadGateway, "RECONCILE_FAILED",
|
||||
"The DNS provider refused the request. Check the API token and the zone.")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleVerify checks every expected record against public resolvers and
|
||||
@@ -116,6 +186,28 @@ func (r *Router) SetAdminDnsAPI(spec DnsSpecSource, verifier DnsRecordVerifier,
|
||||
r.dns = &adminDnsAPI{spec: spec, verifier: verifier, sessions: NewWebSessionVerifier(sessionSecret)}
|
||||
}
|
||||
|
||||
// handleAdminDnsReconcile publishes the records a domain is missing.
|
||||
func (r *Router) handleAdminDnsReconcile(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
|
||||
return
|
||||
}
|
||||
if r.dns == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "DNS_API_DISABLED",
|
||||
"DNS reconciliation needs JWT_SECRET and a configured domain")
|
||||
return
|
||||
}
|
||||
r.dns.handleReconcile(w, req)
|
||||
}
|
||||
|
||||
// SetDnsReconciler enables the reconcile route to actually publish records.
|
||||
// Without it the route reports that reconciliation is not configured.
|
||||
func (r *Router) SetDnsReconciler(reconciler DomainReconciler) {
|
||||
if r.dns != nil {
|
||||
r.dns.reconciler = reconciler
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleAdminDnsVerify(w http.ResponseWriter, req *http.Request) {
|
||||
if r.dns == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "DNS_API_DISABLED",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package httppresentation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Making the reconcile button do what its name says.
|
||||
//
|
||||
// It answered {"status":"REQUESTED","message":"DNS records are not verified by
|
||||
// this endpoint yet"} - it wrote an audit row and nothing else. The service
|
||||
// that can actually create a record, with the provider token in hand, exposed
|
||||
// no route at all, so the console could report a record missing and had no way
|
||||
// to fix it.
|
||||
|
||||
type stubReconciler struct {
|
||||
called []string
|
||||
created int
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubReconciler) ReconcileDomain(_ context.Context, domain string) (ReconcileResult, error) {
|
||||
s.called = append(s.called, domain)
|
||||
if s.err != nil {
|
||||
return ReconcileResult{}, s.err
|
||||
}
|
||||
return ReconcileResult{Created: s.created, Domain: domain}, nil
|
||||
}
|
||||
|
||||
func newReconcileAPI(t *testing.T, r *stubReconciler) *adminDnsAPI {
|
||||
t.Helper()
|
||||
return &adminDnsAPI{sessions: testVerifier(interopSecret), reconciler: r}
|
||||
}
|
||||
|
||||
func adminRequest(target string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader("{}"))
|
||||
req.Header.Set("Authorization", "Bearer "+adminSurfaceToken2())
|
||||
return req
|
||||
}
|
||||
|
||||
func TestReconcileActuallyReconcilesTheDomain(t *testing.T) {
|
||||
stub := &stubReconciler{created: 11}
|
||||
rec := httptest.NewRecorder()
|
||||
newReconcileAPI(t, stub).handleReconcile(rec, adminRequest("/api/v1/admin/dns/reconcile?domain=example.test"))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("answered %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if len(stub.called) != 1 || stub.called[0] != "example.test" {
|
||||
t.Errorf("reconciled %v, want [example.test]", stub.called)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(rec.Body).Decode(&body)
|
||||
// It reports what it did. The old endpoint reported a status it had not
|
||||
// established, which is how the console came to claim records were
|
||||
// verified when nothing had looked at them.
|
||||
if body["created"] != float64(11) {
|
||||
t.Errorf("reported %v created", body["created"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileRefusesWithoutAnAdminSession(t *testing.T) {
|
||||
stub := &stubReconciler{}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/dns/reconcile?domain=example.test", nil)
|
||||
newReconcileAPI(t, stub).handleReconcile(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("answered %d without a session", rec.Code)
|
||||
}
|
||||
if len(stub.called) != 0 {
|
||||
t.Error("it wrote to DNS without an authenticated caller")
|
||||
}
|
||||
}
|
||||
|
||||
// A user session must not reach it: creating DNS records is an admin action.
|
||||
func TestReconcileRefusesAUserSession(t *testing.T) {
|
||||
stub := &stubReconciler{}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/dns/reconcile?domain=example.test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+interopSession)
|
||||
newReconcileAPI(t, stub).handleReconcile(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("a webmail session reached the reconciler: %d", rec.Code)
|
||||
}
|
||||
if len(stub.called) != 0 {
|
||||
t.Error("a webmail session wrote to DNS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileRequiresADomain(t *testing.T) {
|
||||
stub := &stubReconciler{}
|
||||
rec := httptest.NewRecorder()
|
||||
newReconcileAPI(t, stub).handleReconcile(rec, adminRequest("/api/v1/admin/dns/reconcile"))
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("answered %d with no domain", rec.Code)
|
||||
}
|
||||
if len(stub.called) != 0 {
|
||||
t.Error("it reconciled something with no domain named")
|
||||
}
|
||||
}
|
||||
|
||||
// A provider that refuses has to be reported as a failure, not swallowed into
|
||||
// a cheerful answer the way the old endpoint did.
|
||||
func TestReconcileReportsAProviderFailure(t *testing.T) {
|
||||
stub := &stubReconciler{err: errors.New("cloudflare refused the token")}
|
||||
rec := httptest.NewRecorder()
|
||||
newReconcileAPI(t, stub).handleReconcile(rec, adminRequest("/api/v1/admin/dns/reconcile?domain=example.test"))
|
||||
|
||||
if rec.Code < 500 {
|
||||
t.Errorf("a provider failure answered %d, want a 5xx", rec.Code)
|
||||
}
|
||||
// The provider's own words must not travel to the browser: they can carry
|
||||
// account identifiers.
|
||||
if strings.Contains(rec.Body.String(), "cloudflare refused the token") {
|
||||
t.Errorf("the provider error leaked: %s", rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// Without the reconciler wired, the route says so rather than pretending.
|
||||
func TestReconcileSaysWhenItIsNotConfigured(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
api := &adminDnsAPI{sessions: testVerifier(interopSecret)}
|
||||
api.handleReconcile(rec, adminRequest("/api/v1/admin/dns/reconcile?domain=example.test"))
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("answered %d with no reconciler configured", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func adminSurfaceToken2() string {
|
||||
return mintSessionRaw(WebSession{
|
||||
Subject: "mb-1", Email: "admin@example.test", Role: "SUPER_ADMIN",
|
||||
Surface: "admin", Audience: "lambdamail:admin",
|
||||
Purpose: "session", MfaSatisfied: true,
|
||||
IssuedAt: interopIssuedAt, ExpiresAt: interopIssuedAt + 3600,
|
||||
})
|
||||
}
|
||||
@@ -81,6 +81,7 @@ func (r *Router) registerRoutes() {
|
||||
r.mux.HandleFunc("/api/v1/events", r.handleEvents)
|
||||
r.mux.HandleFunc("/api/v1/admin/tls", r.handleAdminTls)
|
||||
r.mux.HandleFunc("/api/v1/admin/dns/verify", r.handleAdminDnsVerify)
|
||||
r.mux.HandleFunc("/api/v1/admin/dns/reconcile", r.handleAdminDnsReconcile)
|
||||
r.mux.HandleFunc("/api/v1/admin/dkim/keys", r.handleAdminDkimKeys)
|
||||
r.mux.HandleFunc("/api/v1/admin/dkim/rotate", r.handleAdminDkimRotate)
|
||||
r.mux.HandleFunc("/api/v1/mail/folders", r.handleMailFolders)
|
||||
|
||||
@@ -104,11 +104,17 @@ func TestWebSessionVerifier_RejectsExpiredToken(t *testing.T) {
|
||||
// variations, and produces them through the same algorithm.
|
||||
func mintSession(t *testing.T, claims WebSession) string {
|
||||
t.Helper()
|
||||
return mintSessionRaw(claims)
|
||||
}
|
||||
|
||||
// mintSessionRaw is the same without a *testing.T, for helpers that build a
|
||||
// token outside a test body.
|
||||
func mintSessionRaw(claims WebSession) string {
|
||||
header := base64.RawURLEncoding.EncodeToString(
|
||||
[]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||
body, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal claims: %v", err)
|
||||
panic(err)
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(body)
|
||||
|
||||
|
||||
Reference in new issue
Block a user