package service import ( "fmt" "strings" "github.com/rs/zerolog" "github.com/gosec/gsc-ops-api/internal/client" "github.com/gosec/gsc-ops-api/pkg/types" ) // DNSService handles PowerDNS zone and record operations type DNSService struct { client *client.PowerDNSClient logger zerolog.Logger } // NewDNSService creates a new DNS service func NewDNSService(pdnsClient *client.PowerDNSClient, logger zerolog.Logger) *DNSService { return &DNSService{ client: pdnsClient, logger: logger.With().Str("service", "dns").Logger(), } } // ListZones lists all DNS zones func (s *DNSService) ListZones() ([]types.DNSZone, error) { zones, err := s.client.ListZones() if err != nil { return nil, err } result := make([]types.DNSZone, 0, len(zones)) for _, z := range zones { result = append(result, types.DNSZone{ ID: z.ID, Name: z.Name, Kind: z.Kind, DNSSec: z.DNSSec, Serial: z.Serial, NotifiedSerial: z.NotifiedSerial, }) } return result, nil } // GetZone gets a zone with records func (s *DNSService) GetZone(zoneID string) (*types.DNSZone, error) { z, err := s.client.GetZone(zoneID) if err != nil { return nil, err } zone := &types.DNSZone{ ID: z.ID, Name: z.Name, Kind: z.Kind, DNSSec: z.DNSSec, Serial: z.Serial, NotifiedSerial: z.NotifiedSerial, SOAEdit: z.SOAEdit, SOAEditAPI: z.SOAEditAPI, } records := make([]types.DNSRecord, 0, len(z.RRSets)) for _, rr := range z.RRSets { entries := make([]types.DNSRecordEntry, 0, len(rr.Records)) for _, r := range rr.Records { entries = append(entries, types.DNSRecordEntry{ Content: r.Content, Disabled: r.Disabled, }) } records = append(records, types.DNSRecord{ Name: rr.Name, Type: rr.Type, TTL: rr.TTL, Records: entries, }) } zone.Records = records return zone, nil } // CreateZone creates a new DNS zone func (s *DNSService) CreateZone(req *types.DNSZoneCreate) (*types.DNSZone, error) { kind := req.Kind if kind == "" { kind = "Native" } name := req.Name if !strings.HasSuffix(name, ".") { name += "." } z, err := s.client.CreateZone(&client.ZoneCreate{ Name: name, Kind: kind, Nameservers: req.Nameservers, Masters: req.Masters, }) if err != nil { return nil, err } return &types.DNSZone{ ID: z.ID, Name: z.Name, Kind: z.Kind, }, nil } // UpdateZone updates zone metadata func (s *DNSService) UpdateZone(zoneID string, req *types.DNSZoneUpdate) error { data := make(map[string]interface{}) if req.Kind != nil { data["kind"] = *req.Kind } if req.Masters != nil { data["masters"] = req.Masters } return s.client.UpdateZone(zoneID, data) } // DeleteZone deletes a zone func (s *DNSService) DeleteZone(zoneID string) error { return s.client.DeleteZone(zoneID) } // NotifyZone sends NOTIFY to slaves func (s *DNSService) NotifyZone(zoneID string) error { return s.client.NotifyZone(zoneID) } // ListRecords lists records in a zone func (s *DNSService) ListRecords(zoneID string) ([]types.DNSRecord, error) { zone, err := s.GetZone(zoneID) if err != nil { return nil, err } return zone.Records, nil } // ChangeRecords applies record changes to a zone using PATCH semantics func (s *DNSService) ChangeRecords(zoneID string, changes []types.DNSRecordChange) error { rrsets := make([]client.RRSet, 0, len(changes)) for _, ch := range changes { name := ch.Name if !strings.HasSuffix(name, ".") { name += "." } records := make([]client.Record, 0, len(ch.Records)) for _, r := range ch.Records { records = append(records, client.Record{ Content: r.Content, Disabled: r.Disabled, }) } ttl := ch.TTL if ttl == 0 { ttl = 3600 } rrsets = append(rrsets, client.RRSet{ Name: name, Type: ch.Type, TTL: ttl, ChangeType: ch.ChangeType, Records: records, }) } return s.client.PatchRRSets(zoneID, rrsets) } // SetupDomain creates a zone with standard mail DNS records (MX, SPF, DKIM, DMARC) func (s *DNSService) SetupDomain(req *types.DomainSetup) (*types.DNSZone, error) { domain := req.Domain if !strings.HasSuffix(domain, ".") { domain += "." } // Create zone first zone, err := s.client.CreateZone(&client.ZoneCreate{ Name: domain, Kind: "Native", Nameservers: []string{ "ns1.gosec.cloud.", "ns2.gosec.cloud.", }, }) if err != nil { return nil, fmt.Errorf("failed to create zone: %w", err) } // Build standard mail records mxHost := req.MXHost if mxHost == "" { mxHost = "mail.gosec.cloud." } if !strings.HasSuffix(mxHost, ".") { mxHost += "." } rrsets := []client.RRSet{ { Name: domain, Type: "MX", TTL: 3600, ChangeType: "REPLACE", Records: []client.Record{{Content: "10 " + mxHost}}, }, } // SPF record spf := "v=spf1" if len(req.SPFIncludes) > 0 { for _, inc := range req.SPFIncludes { spf += " include:" + inc } } spf += " mx -all" rrsets = append(rrsets, client.RRSet{ Name: domain, Type: "TXT", TTL: 3600, ChangeType: "REPLACE", Records: []client.Record{{Content: fmt.Sprintf(`"%s"`, spf)}}, }) // DKIM record if req.DKIMKey != "" { rrsets = append(rrsets, client.RRSet{ Name: "default._domainkey." + domain, Type: "TXT", TTL: 3600, ChangeType: "REPLACE", Records: []client.Record{{Content: fmt.Sprintf(`"v=DKIM1; k=rsa; p=%s"`, req.DKIMKey)}}, }) } // DMARC record rrsets = append(rrsets, client.RRSet{ Name: "_dmarc." + domain, Type: "TXT", TTL: 3600, ChangeType: "REPLACE", Records: []client.Record{{Content: fmt.Sprintf(`"v=DMARC1; p=quarantine; rua=mailto:postmaster@%s"`, strings.TrimSuffix(domain, "."))}}, }) if err := s.client.PatchRRSets(zone.ID, rrsets); err != nil { return nil, fmt.Errorf("zone created but record setup failed: %w", err) } result := &types.DNSZone{ ID: zone.ID, Name: zone.Name, Kind: zone.Kind, } return result, nil } // VerifyDomain checks DNS propagation for a domain func (s *DNSService) VerifyDomain(domain string) (*types.DomainVerifyResult, error) { if !strings.HasSuffix(domain, ".") { domain += "." } zone, err := s.client.GetZone(domain) if err != nil { return nil, fmt.Errorf("zone not found: %w", err) } results := make(map[string]string) hasMX, hasSPF, hasDMARC := false, false, false for _, rr := range zone.RRSets { switch { case rr.Type == "MX" && rr.Name == domain: hasMX = true results["MX"] = "OK" case rr.Type == "TXT" && rr.Name == domain: for _, r := range rr.Records { if strings.Contains(r.Content, "v=spf1") { hasSPF = true results["SPF"] = "OK" } } case rr.Type == "TXT" && rr.Name == "_dmarc."+domain: hasDMARC = true results["DMARC"] = "OK" case rr.Type == "TXT" && strings.HasSuffix(rr.Name, "._domainkey."+domain): results["DKIM"] = "OK" } } if !hasMX { results["MX"] = "MISSING" } if !hasSPF { results["SPF"] = "MISSING" } if !hasDMARC { results["DMARC"] = "MISSING" } allOK := true for _, v := range results { if v != "OK" { allOK = false break } } return &types.DomainVerifyResult{ Domain: strings.TrimSuffix(domain, "."), Results: results, AllOK: allOK, }, nil }