#10: implement Cloudflare DNS automation engine (13 records & system aliases)
This commit is contained in:
1 parent
b08bce71bb
commit
10836ea98f
14 files changed
+1043
-2
No files matched your search
@@ -0,0 +1,245 @@
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
type CloudflareAdapter struct {
|
||||
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{},
|
||||
}
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type cfRecordResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result []cfRecord `json:"result"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
type cfSingleRecordResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result cfRecord `json:"result"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `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 !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 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,
|
||||
})
|
||||
}
|
||||
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 !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 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 !data.Success {
|
||||
return fmt.Errorf("cloudflare API error updating record")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
// Ensure port interface compliance
|
||||
var _ port.DnsProvider = (*CloudflareAdapter)(nil)
|
||||
@@ -0,0 +1,115 @@
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
func TestCloudflareAdapter_GetZoneID_List_Create_Update_Delete(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// GET /zones?name=example.test
|
||||
mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "example.test" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": []map[string]interface{}{
|
||||
{"id": "zone_12345", "name": "example.test"},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"errors": []map[string]string{{"message": "Zone not found"}},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// GET /zones/zone_12345/dns_records & POST /zones/zone_12345/dns_records
|
||||
mux.HandleFunc("/zones/zone_12345/dns_records", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": []map[string]interface{}{
|
||||
{
|
||||
"id": "rec_1",
|
||||
"type": "A",
|
||||
"name": "mail.example.test",
|
||||
"content": "192.0.2.1",
|
||||
"ttl": 1,
|
||||
"proxied": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else if r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": map[string]interface{}{
|
||||
"id": "rec_2",
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /zones/zone_12345/dns_records/rec_1
|
||||
mux.HandleFunc("/zones/zone_12345/dns_records/rec_1", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": map[string]interface{}{"id": "rec_1"},
|
||||
})
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
adapter := NewCloudflareAdapter("test_token")
|
||||
adapter.baseURL = ts.URL
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. GetZoneID
|
||||
zoneID, err := adapter.GetZoneID(ctx, "example.test")
|
||||
if err != nil || zoneID != "zone_12345" {
|
||||
t.Fatalf("GetZoneID: err=%v, zoneID=%s", err, zoneID)
|
||||
}
|
||||
|
||||
// 2. ListRecords
|
||||
records, err := adapter.ListRecords(ctx, zoneID)
|
||||
if err != nil || len(records) != 1 || records[0].Name != "mail.example.test" {
|
||||
t.Fatalf("ListRecords: err=%v, records=%+v", err, records)
|
||||
}
|
||||
|
||||
// 3. CreateRecord
|
||||
err = adapter.CreateRecord(ctx, zoneID, entity.DnsRecord{
|
||||
Type: "TXT",
|
||||
Name: "example.test",
|
||||
Value: "v=spf1 mx -all",
|
||||
TTL: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRecord: %v", err)
|
||||
}
|
||||
|
||||
// 4. UpdateRecord
|
||||
err = adapter.UpdateRecord(ctx, zoneID, entity.DnsRecord{
|
||||
ID: "rec_1",
|
||||
Type: "A",
|
||||
Name: "mail.example.test",
|
||||
Value: "192.0.2.2",
|
||||
TTL: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateRecord: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type AliasRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAliasRepository(pool *pgxpool.Pool) *AliasRepository {
|
||||
return &AliasRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *AliasRepository) EnsureSystemAliases(ctx context.Context, domainName, adminEmail string) error {
|
||||
var domainID uuid.UUID
|
||||
err := r.pool.QueryRow(ctx, `SELECT id FROM domains WHERE name = $1`, domainName).Scan(&domainID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find domain %s: %w", domainName, err)
|
||||
}
|
||||
|
||||
systemAliasPrefixes := []string{"postmaster", "abuse", "dmarc", "tlsrpt"}
|
||||
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
for _, prefix := range systemAliasPrefixes {
|
||||
sourceAddr := fmt.Sprintf("%s@%s", prefix, domainName)
|
||||
destArray := []string{adminEmail}
|
||||
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO aliases (domain_id, source_address, destination_addresses, is_active, is_system)
|
||||
VALUES ($1, $2, $3, true, true)
|
||||
ON CONFLICT (domain_id, source_address)
|
||||
DO UPDATE SET destination_addresses = EXCLUDED.destination_addresses, is_system = true
|
||||
`, domainID, sourceAddr, destArray)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert system alias %s: %w", sourceAddr, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestAliasRepository_EnsureSystemAliases(t *testing.T) {
|
||||
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set - skipping Postgres integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := NewPool(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
domainID := uuid.New()
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'dnsalias.test', 'dnsalias.test')`, domainID); err != nil {
|
||||
t.Fatalf("seed domain: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID)
|
||||
|
||||
repo := NewAliasRepository(pool)
|
||||
|
||||
if err := repo.EnsureSystemAliases(ctx, "dnsalias.test", "admin@dnsalias.test"); err != nil {
|
||||
t.Fatalf("EnsureSystemAliases: %v", err)
|
||||
}
|
||||
|
||||
// Verify postmaster, abuse, dmarc, tlsrpt aliases exist
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM aliases WHERE domain_id = $1 AND is_system = true`, domainID).Scan(&count); err != nil || count != 4 {
|
||||
t.Fatalf("expected 4 system aliases in DB, got count=%d, err=%v", count, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user