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:
Claude (gsc-ops-api init)
2026-05-03 20:06:02 +02:00
commit 3847eb2036
68 changed files with 12982 additions and 0 deletions

324
internal/service/dns.go Normal file
View 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
}