This repo had no version control prior to this commit. The import is a
straight snapshot of the working tree at 2026-05-03; the deployed
binary on fihelvop01 was being rebuilt from this source via `make
build` + scp into place, with no upstream review path.
The snapshot already includes one in-flight fix made on 2026-05-03 to
internal/service/persona.go:GetSelfModel — the handler queried
`source` and `strength` columns plus an `is_active = true` filter on
persona.persona_commitments, none of which exist on that table (its
shape is session-bound commitments with `status`, `commitment_meta`,
etc.). The query returned a 500 every time SynapseHub bootstrapped a
persona's self-model, dropping the IdentityConstraints / Commitments /
ConscienceStandards layer from the assembled prompt. The patched
query reads existing columns only (commitment_text, commitment_type),
filters on `status='active'`, and synthesises Source="learned" /
Strength=1.0 to keep the SelfModel response shape stable for callers.
Verified live: `GET /api/v1/personas/70f7cfd9-.../self-model` now
returns 200 with `{identityConstraints:[],commitments:[],
conscienceStandards:[]}` instead of 500.
Future changes go through PRs against this repo — no more bin-only
deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
325 lines
7.2 KiB
Go
325 lines
7.2 KiB
Go
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
|
|
}
|