#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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user