diff --git a/migrations/0002_add_is_system_to_aliases.down.sql b/migrations/0002_add_is_system_to_aliases.down.sql new file mode 100644 index 0000000..bfaa124 --- /dev/null +++ b/migrations/0002_add_is_system_to_aliases.down.sql @@ -0,0 +1,2 @@ +-- Revert is_system column from aliases table +ALTER TABLE aliases DROP COLUMN IF EXISTS is_system; diff --git a/migrations/0002_add_is_system_to_aliases.up.sql b/migrations/0002_add_is_system_to_aliases.up.sql new file mode 100644 index 0000000..f03c469 --- /dev/null +++ b/migrations/0002_add_is_system_to_aliases.up.sql @@ -0,0 +1,2 @@ +-- Add is_system column to aliases table to track mandatory system security aliases +ALTER TABLE aliases ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT false; diff --git a/services/protocols/internal/application/port/dns_provider.go b/services/protocols/internal/application/port/dns_provider.go new file mode 100644 index 0000000..7ee5bd9 --- /dev/null +++ b/services/protocols/internal/application/port/dns_provider.go @@ -0,0 +1,16 @@ +package port + +import ( + "context" + + "lambdamail/protocols/internal/domain/entity" +) + +// DnsProvider defines operations for managing remote DNS zone records (e.g. Cloudflare). +type DnsProvider interface { + GetZoneID(ctx context.Context, domainName string) (string, error) + ListRecords(ctx context.Context, zoneID string) ([]entity.DnsRecord, error) + CreateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error + UpdateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error + DeleteRecord(ctx context.Context, zoneID string, recordID string) error +} diff --git a/services/protocols/internal/application/port/system_alias_repository.go b/services/protocols/internal/application/port/system_alias_repository.go new file mode 100644 index 0000000..8237d5f --- /dev/null +++ b/services/protocols/internal/application/port/system_alias_repository.go @@ -0,0 +1,8 @@ +package port + +import "context" + +// SystemAliasRepository manages mandatory system security aliases (postmaster, abuse, dmarc, tlsrpt). +type SystemAliasRepository interface { + EnsureSystemAliases(ctx context.Context, domainName string, adminEmail string) error +} diff --git a/services/protocols/internal/application/usecase/sync_dns_records.go b/services/protocols/internal/application/usecase/sync_dns_records.go new file mode 100644 index 0000000..4ac6b71 --- /dev/null +++ b/services/protocols/internal/application/usecase/sync_dns_records.go @@ -0,0 +1,104 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "lambdamail/protocols/internal/application/port" + "lambdamail/protocols/internal/domain/entity" +) + +type SyncDnsRecordsInput struct { + DomainName string + MailHost string + ServerIPv4 string + ServerIPv6 string + RsaDkimPubKey string + EdDkimPubKey string + TlsaHash string + DaneEnabled bool + AdminEmailAddress string +} + +type SyncDnsRecordsOutput struct { + ZoneID string + CreatedCount int + UpdatedCount int + UnchangedCount int +} + +type SyncDnsRecordsUseCase struct { + dnsProvider port.DnsProvider + aliasRepo port.SystemAliasRepository +} + +func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.SystemAliasRepository) *SyncDnsRecordsUseCase { + return &SyncDnsRecordsUseCase{ + dnsProvider: dnsProvider, + aliasRepo: aliasRepo, + } +} + +func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecordsInput) (*SyncDnsRecordsOutput, error) { + if input.DomainName == "" { + return nil, fmt.Errorf("domain name is required") + } + + desiredSpecs := entity.Build13DnsRecordSpecs( + input.DomainName, + input.MailHost, + input.ServerIPv4, + input.ServerIPv6, + input.RsaDkimPubKey, + input.EdDkimPubKey, + input.TlsaHash, + input.DaneEnabled, + ) + + zoneID, err := uc.dnsProvider.GetZoneID(ctx, input.DomainName) + if err != nil { + return nil, fmt.Errorf("get zone id for %s: %w", input.DomainName, err) + } + + existingRecords, err := uc.dnsProvider.ListRecords(ctx, zoneID) + if err != nil { + return nil, fmt.Errorf("list dns records for zone %s: %w", zoneID, err) + } + + existingMap := make(map[string]entity.DnsRecord) + for _, rec := range existingRecords { + key := fmt.Sprintf("%s:%s", strings.ToUpper(rec.Type), strings.ToLower(rec.Name)) + existingMap[key] = rec + } + + output := &SyncDnsRecordsOutput{ZoneID: zoneID} + + for _, spec := range desiredSpecs { + key := fmt.Sprintf("%s:%s", strings.ToUpper(spec.Type), strings.ToLower(spec.Name)) + existing, found := existingMap[key] + + if !found { + if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil { + return nil, fmt.Errorf("create dns record %s %s: %w", spec.Type, spec.Name, err) + } + output.CreatedCount++ + } else if !spec.EqualsNormalized(existing) { + spec.ID = existing.ID + if err := uc.dnsProvider.UpdateRecord(ctx, zoneID, spec); err != nil { + return nil, fmt.Errorf("update dns record %s %s: %w", spec.Type, spec.Name, err) + } + output.UpdatedCount++ + } else { + output.UnchangedCount++ + } + } + + if input.AdminEmailAddress != "" && uc.aliasRepo != nil { + if err := uc.aliasRepo.EnsureSystemAliases(ctx, input.DomainName, input.AdminEmailAddress); err != nil { + return nil, fmt.Errorf("ensure system aliases: %w", err) + } + } + + return output, nil +} diff --git a/services/protocols/internal/application/usecase/sync_dns_records_test.go b/services/protocols/internal/application/usecase/sync_dns_records_test.go new file mode 100644 index 0000000..abeb32a --- /dev/null +++ b/services/protocols/internal/application/usecase/sync_dns_records_test.go @@ -0,0 +1,104 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + "testing" + + "lambdamail/protocols/internal/domain/entity" +) + +type fakeDnsProvider struct { + records map[string]entity.DnsRecord // key -> record +} + +func newFakeDnsProvider() *fakeDnsProvider { + return &fakeDnsProvider{records: make(map[string]entity.DnsRecord)} +} + +func (f *fakeDnsProvider) GetZoneID(_ context.Context, domainName string) (string, error) { + return fmt.Sprintf("zone_%s", domainName), nil +} + +func (f *fakeDnsProvider) ListRecords(_ context.Context, _ string) ([]entity.DnsRecord, error) { + var out []entity.DnsRecord + for _, r := range f.records { + out = append(out, r) + } + return out, nil +} + +func (f *fakeDnsProvider) CreateRecord(_ context.Context, _ string, record entity.DnsRecord) error { + key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name)) + record.ID = fmt.Sprintf("id_%s", key) + f.records[key] = record + return nil +} + +func (f *fakeDnsProvider) UpdateRecord(_ context.Context, _ string, record entity.DnsRecord) error { + key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name)) + f.records[key] = record + return nil +} + +func (f *fakeDnsProvider) DeleteRecord(_ context.Context, _ string, recordID string) error { + for k, r := range f.records { + if r.ID == recordID { + delete(f.records, k) + break + } + } + return nil +} + +type fakeSystemAliasRepository struct { + ensured bool +} + +func (f *fakeSystemAliasRepository) EnsureSystemAliases(_ context.Context, _ string, _ string) error { + f.ensured = true + return nil +} + +func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) { + dns := newFakeDnsProvider() + alias := &fakeSystemAliasRepository{} + uc := NewSyncDnsRecordsUseCase(dns, alias) + + ctx := context.Background() + input := SyncDnsRecordsInput{ + DomainName: "example.test", + MailHost: "mail.example.test", + ServerIPv4: "192.0.2.1", + ServerIPv6: "2001:db8::1", + RsaDkimPubKey: "rsaKey", + EdDkimPubKey: "edKey", + TlsaHash: "hash123", + DaneEnabled: true, + AdminEmailAddress: "admin@example.test", + } + + // 1. Initial Sync -> Creates 13 records + out, err := uc.Execute(ctx, input) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if out.CreatedCount != 13 { + t.Errorf("CreatedCount = %d, want 13", out.CreatedCount) + } + if !alias.ensured { + t.Error("expected system aliases to be ensured") + } + + // 2. Second Sync (idempotent) -> Unchanged 13 records + out2, err := uc.Execute(ctx, input) + if err != nil { + t.Fatalf("Execute 2 failed: %v", err) + } + + if out2.UnchangedCount != 13 || out2.CreatedCount != 0 || out2.UpdatedCount != 0 { + t.Errorf("expected 13 unchanged, got created=%d, updated=%d, unchanged=%d", out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount) + } +} diff --git a/services/protocols/internal/domain/entity/dns_record.go b/services/protocols/internal/domain/entity/dns_record.go new file mode 100644 index 0000000..1af98d7 --- /dev/null +++ b/services/protocols/internal/domain/entity/dns_record.go @@ -0,0 +1,167 @@ +package entity + +import ( + "fmt" + "strings" + "time" +) + +type DnsRecord struct { + ID string + Type string + Name string + Value string + TTL int + Priority *int + Proxied bool + Comment string +} + +// Build13DnsRecordSpecs generates the 13 mandatory DNS record specifications per PLAN.md section 7. +func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkimPubKey, edDkimPubKey, tlsaHash string, daneEnabled bool) []DnsRecord { + if mailHost == "" { + mailHost = fmt.Sprintf("mail.%s", domainName) + } + + records := []DnsRecord{ + // 1. A record for mail host + { + Type: "A", + Name: mailHost, + Value: serverIPv4, + TTL: 1, // Auto + Proxied: false, + Comment: "LambdaMail Mail Host A Record", + }, + // 3. MX record + { + Type: "MX", + Name: domainName, + Value: mailHost, + Priority: intPtr(10), + TTL: 1, + Comment: "LambdaMail Primary MX Record", + }, + // 4. SPF TXT record + { + Type: "TXT", + Name: domainName, + Value: fmt.Sprintf("v=spf1 mx a:%s -all", mailHost), + TTL: 1, + Comment: "LambdaMail SPF Record", + }, + // 5. RSA DKIM TXT record + { + Type: "TXT", + Name: fmt.Sprintf("default._domainkey.%s", domainName), + Value: fmt.Sprintf("v=DKIM1; k=rsa; p=%s", rsaDkimPubKey), + TTL: 1, + Comment: "LambdaMail RSA DKIM Key", + }, + // 6. Ed25519 DKIM TXT record + { + Type: "TXT", + Name: fmt.Sprintf("default-ed._domainkey.%s", domainName), + Value: fmt.Sprintf("v=DKIM1; k=ed25519; p=%s", edDkimPubKey), + TTL: 1, + Comment: "LambdaMail Ed25519 DKIM Key", + }, + // 7. DMARC TXT record + { + Type: "TXT", + Name: fmt.Sprintf("_dmarc.%s", domainName), + Value: fmt.Sprintf("v=DMARC1; p=quarantine; rua=mailto:dmarc@%s; ruf=mailto:dmarc@%s; fo=1; adkim=s; aspf=s", domainName, domainName), + TTL: 1, + Comment: "LambdaMail DMARC Policy", + }, + // 8. MTA-STS TXT record + { + Type: "TXT", + Name: fmt.Sprintf("_mta-sts.%s", domainName), + Value: fmt.Sprintf("v=STSv1; id=%d", time.Now().Unix()), + TTL: 1, + Comment: "LambdaMail MTA-STS Policy Version", + }, + // 9. CNAME for mta-sts host (serves policy via HTTPS) + { + Type: "CNAME", + Name: fmt.Sprintf("mta-sts.%s", domainName), + Value: mailHost, + TTL: 1, + Proxied: true, + Comment: "LambdaMail MTA-STS Endpoint", + }, + // 10. TLS-RPT TXT record + { + Type: "TXT", + Name: fmt.Sprintf("_smtp._tls.%s", domainName), + Value: fmt.Sprintf("v=TLSRPTv1; rua=mailto:tlsrpt@%s", domainName), + TTL: 1, + Comment: "LambdaMail TLS Reporting Destination", + }, + // 12. SRV for IMAPS (RFC 6186) + { + Type: "SRV", + Name: fmt.Sprintf("_imaps._tcp.%s", domainName), + Value: fmt.Sprintf("0 1 993 %s", mailHost), + TTL: 1, + Priority: intPtr(0), + Comment: "LambdaMail IMAPS Autoconfig", + }, + // 13. SRV for Submissions + { + Type: "SRV", + Name: fmt.Sprintf("_submissions._tcp.%s", domainName), + Value: fmt.Sprintf("0 1 465 %s", mailHost), + TTL: 1, + Priority: intPtr(0), + Comment: "LambdaMail Submissions Autoconfig", + }, + } + + // 2. Conditional AAAA record if IPv6 is supplied + if serverIPv6 != "" { + records = append(records, DnsRecord{ + Type: "AAAA", + Name: mailHost, + Value: serverIPv6, + TTL: 1, + Proxied: false, + Comment: "LambdaMail Mail Host AAAA Record", + }) + } + + // 11. Conditional TLSA record if DANE is enabled + if daneEnabled && tlsaHash != "" { + records = append(records, DnsRecord{ + Type: "TLSA", + Name: fmt.Sprintf("_25._tcp.%s", mailHost), + Value: fmt.Sprintf("3 1 1 %s", tlsaHash), + TTL: 1, + Comment: "LambdaMail DANE TLSA Record", + }) + } + + return records +} + +func intPtr(v int) *int { + return &v +} + +// EqualsNormalized compares two DNS records ignoring subtle formatting whitespace/case differences. +func (r DnsRecord) EqualsNormalized(other DnsRecord) bool { + if r.Type != other.Type { + return false + } + if !strings.EqualFold(r.Name, other.Name) { + return false + } + if strings.TrimSpace(r.Value) != strings.TrimSpace(other.Value) { + return false + } + if r.Proxied != other.Proxied { + return false + } + return true +} diff --git a/services/protocols/internal/domain/entity/dns_record_test.go b/services/protocols/internal/domain/entity/dns_record_test.go new file mode 100644 index 0000000..440695e --- /dev/null +++ b/services/protocols/internal/domain/entity/dns_record_test.go @@ -0,0 +1,48 @@ +package entity + +import ( + "testing" +) + +func TestBuild13DnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) { + specs := Build13DnsRecordSpecs( + "example.test", + "mail.example.test", + "192.0.2.1", + "2001:db8::1", + "rsaPubKeyBase64", + "edPubKeyBase64", + "hash123", + true, + ) + + // Should contain 13 records (11 mandatory + 1 AAAA + 1 TLSA) + if len(specs) != 13 { + t.Fatalf("expected 13 records, got %d", len(specs)) + } + + // Verify MX + foundMX := false + for _, r := range specs { + if r.Type == "MX" && r.Name == "example.test" && r.Value == "mail.example.test" { + foundMX = true + if r.Priority == nil || *r.Priority != 10 { + t.Errorf("MX priority = %v, want 10", r.Priority) + } + } + } + if !foundMX { + t.Error("MX record not found in specs") + } + + // Verify DMARC + foundDmarc := false + for _, r := range specs { + if r.Type == "TXT" && r.Name == "_dmarc.example.test" { + foundDmarc = true + } + } + if !foundDmarc { + t.Error("DMARC record not found in specs") + } +} diff --git a/services/protocols/internal/e2e/dns_sync_e2e_test.go b/services/protocols/internal/e2e/dns_sync_e2e_test.go new file mode 100644 index 0000000..87f123f --- /dev/null +++ b/services/protocols/internal/e2e/dns_sync_e2e_test.go @@ -0,0 +1,131 @@ +package e2e + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + + appusecase "lambdamail/protocols/internal/application/usecase" + "lambdamail/protocols/internal/domain/entity" + "lambdamail/protocols/internal/infrastructure/postgres" +) + +func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + ctx := context.Background() + + runtimeDir := t.TempDir() + pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig(). + Port(54336). + RuntimePath(runtimeDir). + StartTimeout(120 * time.Second)) + if err := pg.Start(); err != nil { + t.Fatalf("start embedded postgres: %v", err) + } + defer pg.Stop() + + dbURL := "postgres://postgres:postgres@localhost:54336/postgres?sslmode=disable" + pool, err := postgres.NewPool(ctx, dbURL) + if err != nil { + t.Fatalf("connect pool: %v", err) + } + defer pool.Close() + + root := repoRoot(t) + sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql")) + if err != nil { + t.Fatalf("read migration 0001: %v", err) + } + if _, err := pool.Exec(ctx, string(sql)); err != nil { + t.Fatalf("apply migration 0001: %v", err) + } + + sql0002, err := os.ReadFile(filepath.Join(root, "migrations", "0002_add_is_system_to_aliases.up.sql")) + if err != nil { + t.Fatalf("read migration 0002: %v", err) + } + if _, err := pool.Exec(ctx, string(sql0002)); err != nil { + t.Fatalf("apply migration 0002: %v", err) + } + + // Seed domain & admin mailbox + domainID := uuid.New() + adminMailboxID := uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'dnstest.example', 'dnstest.example')`, domainID); err != nil { + t.Fatalf("seed domain: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'admin', 'admin@dnstest.example', 'hash')`, adminMailboxID, domainID); err != nil { + t.Fatalf("seed mailbox: %v", err) + } + + aliasRepo := postgres.NewAliasRepository(pool) + fakeDns := &e2eFakeDnsProvider{records: make(map[string]entity.DnsRecord)} + + uc := appusecase.NewSyncDnsRecordsUseCase(fakeDns, aliasRepo) + + input := appusecase.SyncDnsRecordsInput{ + DomainName: "dnstest.example", + MailHost: "mail.dnstest.example", + ServerIPv4: "198.51.100.10", + ServerIPv6: "2001:db8::10", + RsaDkimPubKey: "rsaKeyString", + EdDkimPubKey: "edKeyString", + TlsaHash: "hashValue", + DaneEnabled: true, + AdminEmailAddress: "admin@dnstest.example", + } + + out, err := uc.Execute(ctx, input) + if err != nil { + t.Fatalf("SyncDnsRecords execute: %v", err) + } + + if out.CreatedCount != 13 { + t.Errorf("CreatedCount = %d, want 13", out.CreatedCount) + } + + // Verify 4 system aliases in PostgreSQL (postmaster@, abuse@, dmarc@, tlsrpt@) + var aliasCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM aliases WHERE domain_id = $1 AND is_system = true`, domainID).Scan(&aliasCount); err != nil || aliasCount != 4 { + t.Fatalf("expected 4 system aliases in DB, got count=%d, err=%v", aliasCount, err) + } +} + +type e2eFakeDnsProvider struct { + records map[string]entity.DnsRecord +} + +func (f *e2eFakeDnsProvider) GetZoneID(_ context.Context, domainName string) (string, error) { + return "zone_123", nil +} + +func (f *e2eFakeDnsProvider) ListRecords(_ context.Context, _ string) ([]entity.DnsRecord, error) { + var out []entity.DnsRecord + for _, r := range f.records { + out = append(out, r) + } + return out, nil +} + +func (f *e2eFakeDnsProvider) CreateRecord(_ context.Context, _ string, record entity.DnsRecord) error { + k := record.Type + ":" + record.Name + f.records[k] = record + return nil +} + +func (f *e2eFakeDnsProvider) UpdateRecord(_ context.Context, _ string, record entity.DnsRecord) error { + k := record.Type + ":" + record.Name + f.records[k] = record + return nil +} + +func (f *e2eFakeDnsProvider) DeleteRecord(_ context.Context, _ string, recordID string) error { + return nil +} diff --git a/services/protocols/internal/e2e/f2_complete_e2e_test.go b/services/protocols/internal/e2e/f2_complete_e2e_test.go index 05dbc60..3232d10 100644 --- a/services/protocols/internal/e2e/f2_complete_e2e_test.go +++ b/services/protocols/internal/e2e/f2_complete_e2e_test.go @@ -55,10 +55,18 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) { root := repoRoot(t) sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql")) if err != nil { - t.Fatalf("read migration: %v", err) + t.Fatalf("read migration 0001: %v", err) } if _, err := pool.Exec(ctx, string(sql)); err != nil { - t.Fatalf("apply migration: %v", err) + t.Fatalf("apply migration 0001: %v", err) + } + + sql0002, err := os.ReadFile(filepath.Join(root, "migrations", "0002_add_is_system_to_aliases.up.sql")) + if err != nil { + t.Fatalf("read migration 0002: %v", err) + } + if _, err := pool.Exec(ctx, string(sql0002)); err != nil { + t.Fatalf("apply migration 0002: %v", err) } // 2. Seed domain, mailbox, INBOX, Archive diff --git a/services/protocols/internal/infrastructure/cloudflare/cloudflare_adapter.go b/services/protocols/internal/infrastructure/cloudflare/cloudflare_adapter.go new file mode 100644 index 0000000..0f12dfa --- /dev/null +++ b/services/protocols/internal/infrastructure/cloudflare/cloudflare_adapter.go @@ -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) diff --git a/services/protocols/internal/infrastructure/cloudflare/cloudflare_adapter_test.go b/services/protocols/internal/infrastructure/cloudflare/cloudflare_adapter_test.go new file mode 100644 index 0000000..a151820 --- /dev/null +++ b/services/protocols/internal/infrastructure/cloudflare/cloudflare_adapter_test.go @@ -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) + } +} diff --git a/services/protocols/internal/infrastructure/postgres/alias_repository.go b/services/protocols/internal/infrastructure/postgres/alias_repository.go new file mode 100644 index 0000000..7858e53 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/alias_repository.go @@ -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) +} diff --git a/services/protocols/internal/infrastructure/postgres/alias_repository_test.go b/services/protocols/internal/infrastructure/postgres/alias_repository_test.go new file mode 100644 index 0000000..9ef8378 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/alias_repository_test.go @@ -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) + } +}