#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics
This commit is contained in:
1 parent
7249419efc
commit
d56f29d084
70 files changed
+5163
-659
No files matched your search
@@ -5,240 +5,392 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
// rateLimitMaxAttempts bounds the 429 retry loop so a throttled zone
|
||||
// delays the reconcile instead of blocking it forever.
|
||||
rateLimitMaxAttempts = 4
|
||||
rateLimitMaxBackoff = 30 * time.Second
|
||||
recordsPerPage = 100
|
||||
)
|
||||
|
||||
type CloudflareAdapter struct {
|
||||
apiToken string
|
||||
baseURL string
|
||||
apiToken string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewCloudflareAdapter(apiToken string) *CloudflareAdapter {
|
||||
return &CloudflareAdapter{
|
||||
apiToken: apiToken,
|
||||
baseURL: "https://api.cloudflare.com/client/v4",
|
||||
httpClient: &http.Client{},
|
||||
apiToken: apiToken,
|
||||
baseURL: "https://api.cloudflare.com/client/v4",
|
||||
// Without a timeout a stalled API call would hold the reconcile - and
|
||||
// on the periodic drift job, the goroutine - open indefinitely.
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type cfError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type cfResultInfo struct {
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type cfZoneResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"result"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
Errors []cfError `json:"errors"`
|
||||
}
|
||||
|
||||
type cfRecord struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
TTL int `json:"ttl"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Proxied bool `json:"proxied,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content,omitempty"`
|
||||
TTL int `json:"ttl"`
|
||||
// Pointers so that "absent" and "explicitly false" stay distinguishable:
|
||||
// Cloudflare applies its own default for an omitted proxied flag, and a
|
||||
// proxied mail host silently breaks SMTP (PLAN.md section 7.1).
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Proxied *bool `json:"proxied,omitempty"`
|
||||
// Data carries the structured form Cloudflare requires for SRV and TLSA;
|
||||
// those types reject a flat content string.
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
type cfRecordResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result []cfRecord `json:"result"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
Success bool `json:"success"`
|
||||
Result []cfRecord `json:"result"`
|
||||
ResultInfo cfResultInfo `json:"result_info"`
|
||||
Errors []cfError `json:"errors"`
|
||||
}
|
||||
|
||||
type cfSingleRecordResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result cfRecord `json:"result"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
Success bool `json:"success"`
|
||||
Result cfRecord `json:"result"`
|
||||
Errors []cfError `json:"errors"`
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) GetZoneID(ctx context.Context, domainName string) (string, error) {
|
||||
reqURL := fmt.Sprintf("%s/zones?name=%s", a.baseURL, url.QueryEscape(domainName))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.setHeaders(req)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data cfZoneResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", fmt.Errorf("decode json: %w", err)
|
||||
if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !data.Success || len(data.Result) == 0 {
|
||||
errMsg := "zone not found"
|
||||
if len(data.Errors) > 0 {
|
||||
errMsg = data.Errors[0].Message
|
||||
}
|
||||
return "", fmt.Errorf("cloudflare API error: %s", errMsg)
|
||||
return "", fmt.Errorf("cloudflare: zone %q not found or token lacks Zone:Read on it", domainName)
|
||||
}
|
||||
|
||||
return data.Result[0].ID, nil
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) ListRecords(ctx context.Context, zoneID string) ([]entity.DnsRecord, error) {
|
||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=100", a.baseURL, zoneID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.setHeaders(req)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data cfRecordResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("decode json: %w", err)
|
||||
}
|
||||
|
||||
if !data.Success {
|
||||
return nil, fmt.Errorf("cloudflare API error listing records")
|
||||
}
|
||||
|
||||
var out []entity.DnsRecord
|
||||
for _, r := range data.Result {
|
||||
out = append(out, entity.DnsRecord{
|
||||
ID: r.ID,
|
||||
Type: r.Type,
|
||||
Name: r.Name,
|
||||
Value: r.Content,
|
||||
TTL: r.TTL,
|
||||
Priority: r.Priority,
|
||||
Proxied: r.Proxied,
|
||||
Comment: r.Comment,
|
||||
})
|
||||
|
||||
for page := 1; ; page++ {
|
||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=%d&page=%d", a.baseURL, zoneID, recordsPerPage, page)
|
||||
|
||||
var data cfRecordResponse
|
||||
if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !data.Success {
|
||||
return nil, fmt.Errorf("cloudflare: listing records for zone %s failed", zoneID)
|
||||
}
|
||||
|
||||
for _, r := range data.Result {
|
||||
rec := entity.DnsRecord{
|
||||
ID: r.ID,
|
||||
Type: r.Type,
|
||||
Name: r.Name,
|
||||
Value: r.Content,
|
||||
TTL: r.TTL,
|
||||
Priority: r.Priority,
|
||||
Comment: r.Comment,
|
||||
}
|
||||
if r.Proxied != nil {
|
||||
rec.Proxied = *r.Proxied
|
||||
}
|
||||
// Cloudflare returns SRV and TLSA with content already rendered,
|
||||
// but older zones and some responses only populate data; fall back
|
||||
// to it so a structured record is not seen as an empty value and
|
||||
// rewritten on every reconcile.
|
||||
if rec.Value == "" && len(r.Data) > 0 {
|
||||
rec.Value = renderStructuredValue(r.Type, r.Data)
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
|
||||
// Zones larger than one page would otherwise look like they are
|
||||
// missing records, and the reconciler would try to re-create them.
|
||||
if data.ResultInfo.TotalPages <= page || len(data.Result) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) CreateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
|
||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records", a.baseURL, zoneID)
|
||||
cfBody := cfRecord{
|
||||
Type: record.Type,
|
||||
Name: record.Name,
|
||||
Content: record.Value,
|
||||
TTL: record.TTL,
|
||||
Priority: record.Priority,
|
||||
Proxied: record.Proxied,
|
||||
Comment: record.Comment,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(cfBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setHeaders(req)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data cfSingleRecordResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return err
|
||||
if err := a.doJSON(ctx, http.MethodPost, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil {
|
||||
return fmt.Errorf("create %s %s: %w", record.Type, record.Name, err)
|
||||
}
|
||||
|
||||
if !data.Success {
|
||||
errMsg := "create record failed"
|
||||
if len(data.Errors) > 0 {
|
||||
errMsg = data.Errors[0].Message
|
||||
}
|
||||
return fmt.Errorf("cloudflare API error: %s", errMsg)
|
||||
return fmt.Errorf("cloudflare: create %s %s failed", record.Type, record.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) UpdateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
|
||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, record.ID)
|
||||
cfBody := cfRecord{
|
||||
Type: record.Type,
|
||||
Name: record.Name,
|
||||
Content: record.Value,
|
||||
TTL: record.TTL,
|
||||
Priority: record.Priority,
|
||||
Proxied: record.Proxied,
|
||||
Comment: record.Comment,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(cfBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setHeaders(req)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data cfSingleRecordResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return err
|
||||
if err := a.doJSON(ctx, http.MethodPut, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil {
|
||||
return fmt.Errorf("update %s %s: %w", record.Type, record.Name, err)
|
||||
}
|
||||
|
||||
if !data.Success {
|
||||
return fmt.Errorf("cloudflare API error updating record")
|
||||
return fmt.Errorf("cloudflare: update %s %s failed", record.Type, record.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) DeleteRecord(ctx context.Context, zoneID string, recordID string) error {
|
||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, recordID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setHeaders(req)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
var data cfSingleRecordResponse
|
||||
if err := a.doJSON(ctx, http.MethodDelete, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
|
||||
return fmt.Errorf("delete record %s: %w", recordID, err)
|
||||
}
|
||||
if !data.Success {
|
||||
return fmt.Errorf("cloudflare: delete record %s failed", recordID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toCfRecord maps a desired record onto the API payload. The proxied flag is
|
||||
// only meaningful for the record types Cloudflare can proxy; sending it for a
|
||||
// TXT or MX record is rejected.
|
||||
func toCfRecord(record entity.DnsRecord) cfRecord {
|
||||
body := cfRecord{
|
||||
ID: record.ID,
|
||||
Type: record.Type,
|
||||
Name: record.Name,
|
||||
Content: record.Value,
|
||||
TTL: record.TTL,
|
||||
Priority: record.Priority,
|
||||
Comment: record.Comment,
|
||||
}
|
||||
|
||||
switch strings.ToUpper(record.Type) {
|
||||
case "A", "AAAA", "CNAME":
|
||||
proxied := record.Proxied
|
||||
body.Proxied = &proxied
|
||||
|
||||
case "SRV", "TLSA":
|
||||
// These types are rejected with a flat content string; the API wants
|
||||
// the components broken out.
|
||||
if data := structuredData(record); data != nil {
|
||||
body.Data = data
|
||||
body.Content = ""
|
||||
body.Priority = nil
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// structuredData splits an SRV or TLSA record value into the field map the
|
||||
// Cloudflare API expects. It returns nil when the value is not in the
|
||||
// canonical presentation form, letting the caller fall back to content.
|
||||
func structuredData(record entity.DnsRecord) map[string]any {
|
||||
fields := strings.Fields(record.Value)
|
||||
|
||||
switch strings.ToUpper(record.Type) {
|
||||
case "SRV":
|
||||
// Presentation form: "<priority> <weight> <port> <target>".
|
||||
if len(fields) != 4 {
|
||||
return nil
|
||||
}
|
||||
priority, err1 := strconv.Atoi(fields[0])
|
||||
weight, err2 := strconv.Atoi(fields[1])
|
||||
port, err3 := strconv.Atoi(fields[2])
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"priority": priority,
|
||||
"weight": weight,
|
||||
"port": port,
|
||||
"target": strings.TrimSuffix(fields[3], "."),
|
||||
}
|
||||
|
||||
case "TLSA":
|
||||
// Presentation form: "<usage> <selector> <matching type> <data>".
|
||||
if len(fields) != 4 {
|
||||
return nil
|
||||
}
|
||||
usage, err1 := strconv.Atoi(fields[0])
|
||||
selector, err2 := strconv.Atoi(fields[1])
|
||||
matchingType, err3 := strconv.Atoi(fields[2])
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"usage": usage,
|
||||
"selector": selector,
|
||||
"matching_type": matchingType,
|
||||
"certificate": fields[3],
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+a.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// renderStructuredValue is the inverse of structuredData: it rebuilds the
|
||||
// presentation form so the reconciler can compare a published record against
|
||||
// the desired one.
|
||||
func renderStructuredValue(recordType string, data map[string]any) string {
|
||||
number := func(key string) string {
|
||||
switch v := data[key].(type) {
|
||||
case float64: // JSON numbers decode as float64
|
||||
return strconv.Itoa(int(v))
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
text := func(key string) string {
|
||||
if v, ok := data[key].(string); ok {
|
||||
return strings.TrimSuffix(v, ".")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
switch strings.ToUpper(recordType) {
|
||||
case "SRV":
|
||||
return strings.Join([]string{number("priority"), number("weight"), number("port"), text("target")}, " ")
|
||||
case "TLSA":
|
||||
return strings.Join([]string{number("usage"), number("selector"), number("matching_type"), text("certificate")}, " ")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// doJSON performs one API call, retrying while Cloudflare reports 429 and
|
||||
// honouring Retry-After (PLAN.md section 7.5). It fails on any non-2xx status
|
||||
// rather than handing a decoder an error page.
|
||||
func (a *CloudflareAdapter) doJSON(ctx context.Context, method, reqURL string, body any, out any, errsOf func() []cfError) error {
|
||||
var payload []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
if payload, err = json.Marshal(body); err != nil {
|
||||
return fmt.Errorf("encode request body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
backoff := time.Second
|
||||
|
||||
for attempt := 1; ; attempt++ {
|
||||
var reader io.Reader
|
||||
if payload != nil {
|
||||
reader = bytes.NewReader(payload)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests && attempt < rateLimitMaxAttempts {
|
||||
wait := retryAfter(resp.Header.Get("Retry-After"), backoff)
|
||||
resp.Body.Close()
|
||||
backoff = min(backoff*2, rateLimitMaxBackoff)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read response body: %w", readErr)
|
||||
}
|
||||
|
||||
// Decode first so an error envelope can be quoted back to the operator,
|
||||
// then judge the status. Cloudflare returns its envelope on 4xx too.
|
||||
decodeErr := json.Unmarshal(respBody, out)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("cloudflare API returned HTTP %d: %s", resp.StatusCode, describeErrors(errsOf(), respBody))
|
||||
}
|
||||
if decodeErr != nil {
|
||||
return fmt.Errorf("decode json: %w", decodeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func describeErrors(errs []cfError, raw []byte) string {
|
||||
if len(errs) > 0 {
|
||||
messages := make([]string, 0, len(errs))
|
||||
for _, e := range errs {
|
||||
messages = append(messages, e.Message)
|
||||
}
|
||||
return strings.Join(messages, "; ")
|
||||
}
|
||||
const maxRaw = 200
|
||||
if len(raw) > maxRaw {
|
||||
raw = raw[:maxRaw]
|
||||
}
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
|
||||
// retryAfter reads the header as delta-seconds (RFC 9110 section 10.2.3),
|
||||
// falling back to the caller's exponential backoff when it is absent or
|
||||
// unparseable.
|
||||
func retryAfter(header string, fallback time.Duration) time.Duration {
|
||||
if header == "" {
|
||||
return fallback
|
||||
}
|
||||
seconds, err := strconv.Atoi(strings.TrimSpace(header))
|
||||
if err != nil || seconds < 0 {
|
||||
return fallback
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
// Ensure port interface compliance
|
||||
|
||||
Reference in new issue
Block a user