Autarch Will Control The Internet
This commit is contained in:
525
services/dns-server/server/zones.go
Normal file
525
services/dns-server/server/zones.go
Normal file
@@ -0,0 +1,525 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// RecordType represents supported DNS record types.
|
||||
type RecordType string
|
||||
|
||||
const (
|
||||
TypeA RecordType = "A"
|
||||
TypeAAAA RecordType = "AAAA"
|
||||
TypeCNAME RecordType = "CNAME"
|
||||
TypeMX RecordType = "MX"
|
||||
TypeTXT RecordType = "TXT"
|
||||
TypeNS RecordType = "NS"
|
||||
TypeSRV RecordType = "SRV"
|
||||
TypePTR RecordType = "PTR"
|
||||
TypeSOA RecordType = "SOA"
|
||||
)
|
||||
|
||||
// Record is a single DNS record.
|
||||
type Record struct {
|
||||
ID string `json:"id"`
|
||||
Type RecordType `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
TTL uint32 `json:"ttl"`
|
||||
Priority uint16 `json:"priority,omitempty"` // MX, SRV
|
||||
Weight uint16 `json:"weight,omitempty"` // SRV
|
||||
Port uint16 `json:"port,omitempty"` // SRV
|
||||
}
|
||||
|
||||
// Zone represents a DNS zone with its records.
|
||||
type Zone struct {
|
||||
Domain string `json:"domain"`
|
||||
SOA SOARecord `json:"soa"`
|
||||
Records []Record `json:"records"`
|
||||
DNSSEC bool `json:"dnssec"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SOARecord holds SOA-specific fields.
|
||||
type SOARecord struct {
|
||||
PrimaryNS string `json:"primary_ns"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
Serial uint32 `json:"serial"`
|
||||
Refresh uint32 `json:"refresh"`
|
||||
Retry uint32 `json:"retry"`
|
||||
Expire uint32 `json:"expire"`
|
||||
MinTTL uint32 `json:"min_ttl"`
|
||||
}
|
||||
|
||||
// ZoneStore manages zones on disk and in memory.
|
||||
type ZoneStore struct {
|
||||
mu sync.RWMutex
|
||||
zones map[string]*Zone
|
||||
zonesDir string
|
||||
}
|
||||
|
||||
// NewZoneStore creates a store backed by a directory.
|
||||
func NewZoneStore(dir string) *ZoneStore {
|
||||
os.MkdirAll(dir, 0755)
|
||||
return &ZoneStore{
|
||||
zones: make(map[string]*Zone),
|
||||
zonesDir: dir,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAll reads all zone files from disk.
|
||||
func (s *ZoneStore) LoadAll() error {
|
||||
entries, err := os.ReadDir(s.zonesDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.zonesDir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var z Zone
|
||||
if err := json.Unmarshal(data, &z); err != nil {
|
||||
continue
|
||||
}
|
||||
s.zones[dns.Fqdn(z.Domain)] = &z
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save writes a zone to disk.
|
||||
func (s *ZoneStore) Save(z *Zone) error {
|
||||
z.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
data, err := json.MarshalIndent(z, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fname := filepath.Join(s.zonesDir, z.Domain+".json")
|
||||
return os.WriteFile(fname, data, 0644)
|
||||
}
|
||||
|
||||
// Get returns a zone by domain.
|
||||
func (s *ZoneStore) Get(domain string) *Zone {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.zones[dns.Fqdn(domain)]
|
||||
}
|
||||
|
||||
// List returns all zones.
|
||||
func (s *ZoneStore) List() []*Zone {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]*Zone, 0, len(s.zones))
|
||||
for _, z := range s.zones {
|
||||
result = append(result, z)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Create adds a new zone.
|
||||
func (s *ZoneStore) Create(domain string) (*Zone, error) {
|
||||
fqdn := dns.Fqdn(domain)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.zones[fqdn]; exists {
|
||||
return nil, fmt.Errorf("zone %s already exists", domain)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
z := &Zone{
|
||||
Domain: domain,
|
||||
SOA: SOARecord{
|
||||
PrimaryNS: "ns1." + domain,
|
||||
AdminEmail: "admin." + domain,
|
||||
Serial: uint32(time.Now().Unix()),
|
||||
Refresh: 3600,
|
||||
Retry: 600,
|
||||
Expire: 86400,
|
||||
MinTTL: 300,
|
||||
},
|
||||
Records: []Record{
|
||||
{ID: "ns1", Type: TypeNS, Name: domain + ".", Value: "ns1." + domain + ".", TTL: 3600},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.zones[fqdn] = z
|
||||
return z, s.Save(z)
|
||||
}
|
||||
|
||||
// Delete removes a zone.
|
||||
func (s *ZoneStore) Delete(domain string) error {
|
||||
fqdn := dns.Fqdn(domain)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.zones[fqdn]; !exists {
|
||||
return fmt.Errorf("zone %s not found", domain)
|
||||
}
|
||||
delete(s.zones, fqdn)
|
||||
fname := filepath.Join(s.zonesDir, domain+".json")
|
||||
os.Remove(fname)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRecord adds a record to a zone.
|
||||
func (s *ZoneStore) AddRecord(domain string, rec Record) error {
|
||||
fqdn := dns.Fqdn(domain)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
z, ok := s.zones[fqdn]
|
||||
if !ok {
|
||||
return fmt.Errorf("zone %s not found", domain)
|
||||
}
|
||||
|
||||
if rec.ID == "" {
|
||||
rec.ID = fmt.Sprintf("r%d", time.Now().UnixNano())
|
||||
}
|
||||
if rec.TTL == 0 {
|
||||
rec.TTL = 300
|
||||
}
|
||||
|
||||
z.Records = append(z.Records, rec)
|
||||
z.SOA.Serial++
|
||||
return s.Save(z)
|
||||
}
|
||||
|
||||
// DeleteRecord removes a record by ID.
|
||||
func (s *ZoneStore) DeleteRecord(domain, recordID string) error {
|
||||
fqdn := dns.Fqdn(domain)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
z, ok := s.zones[fqdn]
|
||||
if !ok {
|
||||
return fmt.Errorf("zone %s not found", domain)
|
||||
}
|
||||
|
||||
for i, r := range z.Records {
|
||||
if r.ID == recordID {
|
||||
z.Records = append(z.Records[:i], z.Records[i+1:]...)
|
||||
z.SOA.Serial++
|
||||
return s.Save(z)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("record %s not found", recordID)
|
||||
}
|
||||
|
||||
// UpdateRecord updates a record by ID.
|
||||
func (s *ZoneStore) UpdateRecord(domain, recordID string, rec Record) error {
|
||||
fqdn := dns.Fqdn(domain)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
z, ok := s.zones[fqdn]
|
||||
if !ok {
|
||||
return fmt.Errorf("zone %s not found", domain)
|
||||
}
|
||||
|
||||
for i, r := range z.Records {
|
||||
if r.ID == recordID {
|
||||
rec.ID = recordID
|
||||
z.Records[i] = rec
|
||||
z.SOA.Serial++
|
||||
return s.Save(z)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("record %s not found", recordID)
|
||||
}
|
||||
|
||||
// Lookup finds records matching a query name and type within all zones.
|
||||
func (s *ZoneStore) Lookup(name string, qtype uint16) []dns.RR {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
fqdn := dns.Fqdn(name)
|
||||
var results []dns.RR
|
||||
|
||||
// Find the zone for this name
|
||||
for zoneDomain, z := range s.zones {
|
||||
if !dns.IsSubDomain(zoneDomain, fqdn) {
|
||||
continue
|
||||
}
|
||||
// Check records
|
||||
for _, rec := range z.Records {
|
||||
recFQDN := dns.Fqdn(rec.Name)
|
||||
if recFQDN != fqdn {
|
||||
continue
|
||||
}
|
||||
if rr := recordToRR(rec, fqdn); rr != nil {
|
||||
if qtype == dns.TypeANY || rr.Header().Rrtype == qtype {
|
||||
results = append(results, rr)
|
||||
}
|
||||
}
|
||||
}
|
||||
// SOA for zone apex
|
||||
if fqdn == zoneDomain && (qtype == dns.TypeSOA || qtype == dns.TypeANY) {
|
||||
soa := &dns.SOA{
|
||||
Hdr: dns.RR_Header{Name: zoneDomain, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: z.SOA.MinTTL},
|
||||
Ns: dns.Fqdn(z.SOA.PrimaryNS),
|
||||
Mbox: dns.Fqdn(z.SOA.AdminEmail),
|
||||
Serial: z.SOA.Serial,
|
||||
Refresh: z.SOA.Refresh,
|
||||
Retry: z.SOA.Retry,
|
||||
Expire: z.SOA.Expire,
|
||||
Minttl: z.SOA.MinTTL,
|
||||
}
|
||||
results = append(results, soa)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func recordToRR(rec Record, fqdn string) dns.RR {
|
||||
hdr := dns.RR_Header{Name: fqdn, Class: dns.ClassINET, Ttl: rec.TTL}
|
||||
|
||||
switch rec.Type {
|
||||
case TypeA:
|
||||
hdr.Rrtype = dns.TypeA
|
||||
rr := &dns.A{Hdr: hdr}
|
||||
rr.A = parseIP(rec.Value)
|
||||
if rr.A == nil {
|
||||
return nil
|
||||
}
|
||||
return rr
|
||||
case TypeAAAA:
|
||||
hdr.Rrtype = dns.TypeAAAA
|
||||
rr := &dns.AAAA{Hdr: hdr}
|
||||
rr.AAAA = parseIP(rec.Value)
|
||||
if rr.AAAA == nil {
|
||||
return nil
|
||||
}
|
||||
return rr
|
||||
case TypeCNAME:
|
||||
hdr.Rrtype = dns.TypeCNAME
|
||||
return &dns.CNAME{Hdr: hdr, Target: dns.Fqdn(rec.Value)}
|
||||
case TypeMX:
|
||||
hdr.Rrtype = dns.TypeMX
|
||||
return &dns.MX{Hdr: hdr, Preference: rec.Priority, Mx: dns.Fqdn(rec.Value)}
|
||||
case TypeTXT:
|
||||
hdr.Rrtype = dns.TypeTXT
|
||||
return &dns.TXT{Hdr: hdr, Txt: []string{rec.Value}}
|
||||
case TypeNS:
|
||||
hdr.Rrtype = dns.TypeNS
|
||||
return &dns.NS{Hdr: hdr, Ns: dns.Fqdn(rec.Value)}
|
||||
case TypeSRV:
|
||||
hdr.Rrtype = dns.TypeSRV
|
||||
return &dns.SRV{Hdr: hdr, Priority: rec.Priority, Weight: rec.Weight, Port: rec.Port, Target: dns.Fqdn(rec.Value)}
|
||||
case TypePTR:
|
||||
hdr.Rrtype = dns.TypePTR
|
||||
return &dns.PTR{Hdr: hdr, Ptr: dns.Fqdn(rec.Value)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseIP(s string) net.IP {
|
||||
return net.ParseIP(s)
|
||||
}
|
||||
|
||||
// ExportZoneFile exports a zone in BIND zone file format.
|
||||
func (s *ZoneStore) ExportZoneFile(domain string) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
z, ok := s.zones[dns.Fqdn(domain)]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("zone %s not found", domain)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("; Zone file for %s\n", z.Domain))
|
||||
b.WriteString(fmt.Sprintf("; Exported at %s\n", time.Now().UTC().Format(time.RFC3339)))
|
||||
b.WriteString(fmt.Sprintf("$ORIGIN %s.\n", z.Domain))
|
||||
b.WriteString(fmt.Sprintf("$TTL %d\n\n", z.SOA.MinTTL))
|
||||
|
||||
// SOA
|
||||
b.WriteString(fmt.Sprintf("@ IN SOA %s. %s. (\n", z.SOA.PrimaryNS, z.SOA.AdminEmail))
|
||||
b.WriteString(fmt.Sprintf(" %d ; serial\n", z.SOA.Serial))
|
||||
b.WriteString(fmt.Sprintf(" %d ; refresh\n", z.SOA.Refresh))
|
||||
b.WriteString(fmt.Sprintf(" %d ; retry\n", z.SOA.Retry))
|
||||
b.WriteString(fmt.Sprintf(" %d ; expire\n", z.SOA.Expire))
|
||||
b.WriteString(fmt.Sprintf(" %d ; minimum TTL\n)\n\n", z.SOA.MinTTL))
|
||||
|
||||
// Records grouped by type
|
||||
for _, rec := range z.Records {
|
||||
name := rec.Name
|
||||
// Make relative to origin
|
||||
suffix := "." + z.Domain + "."
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
name = strings.TrimSuffix(name, suffix)
|
||||
} else if name == z.Domain+"." {
|
||||
name = "@"
|
||||
}
|
||||
|
||||
switch rec.Type {
|
||||
case TypeMX:
|
||||
b.WriteString(fmt.Sprintf("%-24s %d IN MX %d %s\n", name, rec.TTL, rec.Priority, rec.Value))
|
||||
case TypeSRV:
|
||||
b.WriteString(fmt.Sprintf("%-24s %d IN SRV %d %d %d %s\n", name, rec.TTL, rec.Priority, rec.Weight, rec.Port, rec.Value))
|
||||
default:
|
||||
b.WriteString(fmt.Sprintf("%-24s %d IN %-6s %s\n", name, rec.TTL, rec.Type, rec.Value))
|
||||
}
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ImportZoneFile parses a BIND-style zone file and adds records.
|
||||
// Returns number of records added.
|
||||
func (s *ZoneStore) ImportZoneFile(domain, content string) (int, error) {
|
||||
fqdn := dns.Fqdn(domain)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
z, ok := s.zones[fqdn]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("zone %s not found — create it first", domain)
|
||||
}
|
||||
|
||||
added := 0
|
||||
zp := dns.NewZoneParser(strings.NewReader(content), dns.Fqdn(domain), "")
|
||||
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
|
||||
hdr := rr.Header()
|
||||
rec := Record{
|
||||
ID: fmt.Sprintf("imp%d", time.Now().UnixNano()+int64(added)),
|
||||
Name: hdr.Name,
|
||||
TTL: hdr.Ttl,
|
||||
}
|
||||
|
||||
switch v := rr.(type) {
|
||||
case *dns.A:
|
||||
rec.Type = TypeA
|
||||
rec.Value = v.A.String()
|
||||
case *dns.AAAA:
|
||||
rec.Type = TypeAAAA
|
||||
rec.Value = v.AAAA.String()
|
||||
case *dns.CNAME:
|
||||
rec.Type = TypeCNAME
|
||||
rec.Value = v.Target
|
||||
case *dns.MX:
|
||||
rec.Type = TypeMX
|
||||
rec.Value = v.Mx
|
||||
rec.Priority = v.Preference
|
||||
case *dns.TXT:
|
||||
rec.Type = TypeTXT
|
||||
rec.Value = strings.Join(v.Txt, " ")
|
||||
case *dns.NS:
|
||||
rec.Type = TypeNS
|
||||
rec.Value = v.Ns
|
||||
case *dns.SRV:
|
||||
rec.Type = TypeSRV
|
||||
rec.Value = v.Target
|
||||
rec.Priority = v.Priority
|
||||
rec.Weight = v.Weight
|
||||
rec.Port = v.Port
|
||||
case *dns.PTR:
|
||||
rec.Type = TypePTR
|
||||
rec.Value = v.Ptr
|
||||
default:
|
||||
continue // Skip unsupported types
|
||||
}
|
||||
|
||||
z.Records = append(z.Records, rec)
|
||||
added++
|
||||
}
|
||||
|
||||
if added > 0 {
|
||||
z.SOA.Serial++
|
||||
s.Save(z)
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// CloneZone duplicates a zone under a new domain.
|
||||
func (s *ZoneStore) CloneZone(srcDomain, dstDomain string) (*Zone, error) {
|
||||
srcFQDN := dns.Fqdn(srcDomain)
|
||||
dstFQDN := dns.Fqdn(dstDomain)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
src, ok := s.zones[srcFQDN]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source zone %s not found", srcDomain)
|
||||
}
|
||||
if _, exists := s.zones[dstFQDN]; exists {
|
||||
return nil, fmt.Errorf("destination zone %s already exists", dstDomain)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
z := &Zone{
|
||||
Domain: dstDomain,
|
||||
SOA: SOARecord{
|
||||
PrimaryNS: strings.Replace(src.SOA.PrimaryNS, srcDomain, dstDomain, -1),
|
||||
AdminEmail: strings.Replace(src.SOA.AdminEmail, srcDomain, dstDomain, -1),
|
||||
Serial: uint32(time.Now().Unix()),
|
||||
Refresh: src.SOA.Refresh,
|
||||
Retry: src.SOA.Retry,
|
||||
Expire: src.SOA.Expire,
|
||||
MinTTL: src.SOA.MinTTL,
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Clone records, replacing domain references
|
||||
for _, rec := range src.Records {
|
||||
newRec := rec
|
||||
newRec.ID = fmt.Sprintf("c%d", time.Now().UnixNano())
|
||||
newRec.Name = strings.Replace(rec.Name, srcDomain, dstDomain, -1)
|
||||
newRec.Value = strings.Replace(rec.Value, srcDomain, dstDomain, -1)
|
||||
z.Records = append(z.Records, newRec)
|
||||
time.Sleep(time.Nanosecond) // Ensure unique IDs
|
||||
}
|
||||
|
||||
s.zones[dstFQDN] = z
|
||||
return z, s.Save(z)
|
||||
}
|
||||
|
||||
// BulkAddRecords adds multiple records at once.
|
||||
func (s *ZoneStore) BulkAddRecords(domain string, records []Record) (int, error) {
|
||||
fqdn := dns.Fqdn(domain)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
z, ok := s.zones[fqdn]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("zone %s not found", domain)
|
||||
}
|
||||
|
||||
added := 0
|
||||
for _, rec := range records {
|
||||
if rec.ID == "" {
|
||||
rec.ID = fmt.Sprintf("b%d", time.Now().UnixNano()+int64(added))
|
||||
}
|
||||
if rec.TTL == 0 {
|
||||
rec.TTL = 300
|
||||
}
|
||||
z.Records = append(z.Records, rec)
|
||||
added++
|
||||
}
|
||||
|
||||
if added > 0 {
|
||||
z.SOA.Serial++
|
||||
s.Save(z)
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
Reference in New Issue
Block a user