Initial import — snapshot from admin host /srv/gosec/gsc-ops-api
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>
This commit is contained in:
324
internal/service/dns.go
Normal file
324
internal/service/dns.go
Normal file
@@ -0,0 +1,324 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user