From abb97cce562fff140a5750b63081f602dd74ecef Mon Sep 17 00:00:00 2001 From: m5r Date: Sun, 18 Jan 2026 10:51:07 +0100 Subject: [PATCH] move hardcodedRecords into Xip struct for instance isolation - replace global hardcodedRecords/mutex with instance fields - add initialRecords() factory for fresh record copies per instance - rename initHardcodedRecords to initNameServers, pass nameservers explicitly - add TestInstanceIsolation to verify instances don't share state - fix unassigned err in certs/persistFiles --- certs/certs.go | 2 +- xip/records.go | 59 +++++++++++++++--------------- xip/xip.go | 95 ++++++++++++++++++++++++++++++++----------------- xip/xip_test.go | 38 ++++++++++++++++++++ 4 files changed, 131 insertions(+), 63 deletions(-) diff --git a/certs/certs.go b/certs/certs.go index 548b195..882d02c 100644 --- a/certs/certs.go +++ b/certs/certs.go @@ -102,7 +102,7 @@ func persistFiles(certificates *certificate.Resource, certType string) { utils.Logger.Fatal().Err(err).Msgf("Failed to save ./.lego/certs/%s/server.pem", certType) } - os.WriteFile(fmt.Sprintf("./.lego/certs/%s/server.key", certType), certificates.PrivateKey, 0o644) + err = os.WriteFile(fmt.Sprintf("./.lego/certs/%s/server.key", certType), certificates.PrivateKey, 0o644) if err != nil { utils.Logger.Fatal().Err(err).Msgf("Failed to save ./.lego/certs/%s/server.key", certType) } diff --git a/xip/records.go b/xip/records.go index 4da457a..ab21911 100644 --- a/xip/records.go +++ b/xip/records.go @@ -15,38 +15,39 @@ type hardcodedRecord struct { SRV *dns.SRV } -var hardcodedRecords = map[string]hardcodedRecord{ - // additional records I set up to host emails, feel free to change or remove them for your own needs - "local-ip.sh.": { - TXT: []string{"v=spf1 include:capsulecorp.dev ~all"}, - MX: []*dns.MX{ - {Preference: 10, Mx: "email.capsulecorp.dev."}, +func initialRecords() map[string]hardcodedRecord { + return map[string]hardcodedRecord{ + "local-ip.sh.": { + TXT: []string{"v=spf1 include:capsulecorp.dev ~all"}, + MX: []*dns.MX{ + {Preference: 10, Mx: "email.capsulecorp.dev."}, + }, }, - }, - "autodiscover.local-ip.sh.": { - CNAME: []string{ - "email.capsulecorp.dev.", + "autodiscover.local-ip.sh.": { + CNAME: []string{ + "email.capsulecorp.dev.", + }, }, - }, - "_autodiscover._tcp.local-ip.sh.": { - SRV: &dns.SRV{ - Priority: 0, - Weight: 0, - Port: 443, - Target: "email.capsulecorp.dev.", + "_autodiscover._tcp.local-ip.sh.": { + SRV: &dns.SRV{ + Priority: 0, + Weight: 0, + Port: 443, + Target: "email.capsulecorp.dev.", + }, }, - }, - "autoconfig.local-ip.sh.": { - CNAME: []string{ - "email.capsulecorp.dev.", + "autoconfig.local-ip.sh.": { + CNAME: []string{ + "email.capsulecorp.dev.", + }, }, - }, - "_dmarc.local-ip.sh.": { - TXT: []string{"v=DMARC1; p=none; rua=mailto:postmaster@local-ip.sh; ruf=mailto:admin@local-ip.sh"}, - }, - "dkim._domainkey.local-ip.sh.": { - TXT: []string{ - "v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsMW6NFo34qzKRPbzK41GwbWncB8IDg1i2eA2VWznIVDmTzzsqILaBOGv2xokVpzZm0QRF9wSbeVUmvwEeQ7Z6wkfMjawenDEc3XxsNSvQUVBP6LU/xcm1zsR8wtD8r5J+Jm45pNFaateiM/kb/Eypp2ntdtd8CPsEgCEDpNb62LWdy0yzRdZ/M/fNn51UMN8hVFp4YfZngAt3bQwa6kPtgvTeqEbpNf5xanpDysNJt2S8zfqJMVGvnr8JaJiTv7ZlKMMp94aC5Ndcir1WbMyfmgSnGgemuCTVMWDGPJnXDi+8BQMH1b1hmTpWDiVdVlehyyWx5AfPrsWG9cEuDIfXwIDAQAB", + "_dmarc.local-ip.sh.": { + TXT: []string{"v=DMARC1; p=none; rua=mailto:postmaster@local-ip.sh; ruf=mailto:admin@local-ip.sh"}, }, - }, + "dkim._domainkey.local-ip.sh.": { + TXT: []string{ + "v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsMW6NFo34qzKRPbzK41GwbWncB8IDg1i2eA2VWznIVDmTzzsqILaBOGv2xokVpzZm0QRF9wSbeVUmvwEeQ7Z6wkfMjawenDEc3XxsNSvQUVBP6LU/xcm1zsR8wtD8r5J+Jm45pNFaateiM/kb/Eypp2ntdtd8CPsEgCEDpNb62LWdy0yzRdZ/M/fNn51UMN8hVFp4YfZngAt3bQwa6kPtgvTeqEbpNf5xanpDysNJt2S8zfqJMVGvnr8JaJiTv7ZlKMMp94aC5Ndcir1WbMyfmgSnGgemuCTVMWDGPJnXDi+8BQMH1b1hmTpWDiVdVlehyyWx5AfPrsWG9cEuDIfXwIDAQAB", + }, + }, + } } diff --git a/xip/xip.go b/xip/xip.go index 2dca955..8d58b11 100644 --- a/xip/xip.go +++ b/xip/xip.go @@ -6,6 +6,7 @@ import ( "os" "regexp" "strings" + "sync" "time" "github.com/miekg/dns" @@ -18,6 +19,8 @@ type Xip struct { domain string email string dnsPort uint + recordsMu sync.RWMutex + records map[string]hardcodedRecord } type Option func(*Xip) @@ -42,13 +45,15 @@ func WithDnsPort(port uint) Option { func WithNameServers(nameServers []string) Option { return func(x *Xip) { + x.recordsMu.Lock() + defer x.recordsMu.Unlock() for i, ns := range nameServers { name := fmt.Sprintf("ns%d.%s.", i+1, x.domain) ip := net.ParseIP(ns) - entry := hardcodedRecords[name] + entry := x.records[name] entry.A = append(entry.A, ip) - hardcodedRecords[name] = entry + x.records[name] = entry x.nameServers = append(x.nameServers, name) } @@ -69,9 +74,11 @@ func (xip *Xip) SetTXTRecord(fqdn string, value string) { return } - if rootRecords, ok := hardcodedRecords[fqdn]; ok { + xip.recordsMu.Lock() + defer xip.recordsMu.Unlock() + if rootRecords, ok := xip.records[fqdn]; ok { rootRecords.TXT = []string{value} - hardcodedRecords[fmt.Sprintf("_acme-challenge.%s.", xip.domain)] = rootRecords + xip.records[fmt.Sprintf("_acme-challenge.%s.", xip.domain)] = rootRecords } } @@ -82,18 +89,23 @@ func (xip *Xip) UnsetTXTRecord(fqdn string) { return } - if rootRecords, ok := hardcodedRecords[fqdn]; ok { + xip.recordsMu.Lock() + defer xip.recordsMu.Unlock() + if rootRecords, ok := xip.records[fqdn]; ok { rootRecords.TXT = []string{} - hardcodedRecords[fmt.Sprintf("_acme-challenge.%s.", xip.domain)] = rootRecords + xip.records[fmt.Sprintf("_acme-challenge.%s.", xip.domain)] = rootRecords } } func (xip *Xip) fqdnToA(fqdn string) []*dns.A { normalizedFqdn := strings.ToLower(fqdn) - if hardcodedRecords[normalizedFqdn].A != nil { + xip.recordsMu.RLock() + records := xip.records[normalizedFqdn].A + xip.recordsMu.RUnlock() + if records != nil { var aRecords []*dns.A - for _, record := range hardcodedRecords[normalizedFqdn].A { + for _, record := range records { aRecords = append(aRecords, &dns.A{ Hdr: dns.RR_Header{ Ttl: uint32((time.Minute * 5).Seconds()), @@ -154,12 +166,15 @@ func (xip *Xip) handleA(question dns.Question, message *dns.Msg) { func (xip *Xip) handleAAAA(question dns.Question, message *dns.Msg) { fqdn := question.Name normalizedFqdn := strings.ToLower(fqdn) - if hardcodedRecords[normalizedFqdn].AAAA == nil { + xip.recordsMu.RLock() + records := xip.records[normalizedFqdn].AAAA + xip.recordsMu.RUnlock() + if records == nil { xip.answerWithAuthority(question, message) return } - for _, record := range hardcodedRecords[normalizedFqdn].AAAA { + for _, record := range records { message.Answer = append(message.Answer, &dns.AAAA{ Hdr: dns.RR_Header{ Ttl: uint32((time.Minute * 5).Seconds()), @@ -209,12 +224,15 @@ func chunkBy(str string, chunkSize int) (chunks []string) { func (xip *Xip) handleTXT(question dns.Question, message *dns.Msg) { fqdn := question.Name normalizedFqdn := strings.ToLower(fqdn) - if hardcodedRecords[normalizedFqdn].TXT == nil { + xip.recordsMu.RLock() + records := xip.records[normalizedFqdn].TXT + xip.recordsMu.RUnlock() + if records == nil { xip.answerWithAuthority(question, message) return } - for _, record := range hardcodedRecords[normalizedFqdn].TXT { + for _, record := range records { message.Answer = append(message.Answer, &dns.TXT{ Hdr: dns.RR_Header{ Ttl: uint32((time.Minute * 5).Seconds()), @@ -230,12 +248,15 @@ func (xip *Xip) handleTXT(question dns.Question, message *dns.Msg) { func (xip *Xip) handleMX(question dns.Question, message *dns.Msg) { fqdn := question.Name normalizedFqdn := strings.ToLower(fqdn) - if hardcodedRecords[normalizedFqdn].MX == nil { + xip.recordsMu.RLock() + records := xip.records[normalizedFqdn].MX + xip.recordsMu.RUnlock() + if records == nil { xip.answerWithAuthority(question, message) return } - for _, record := range hardcodedRecords[normalizedFqdn].MX { + for _, record := range records { message.Answer = append(message.Answer, &dns.MX{ Hdr: dns.RR_Header{ Ttl: uint32((time.Minute * 5).Seconds()), @@ -252,12 +273,15 @@ func (xip *Xip) handleMX(question dns.Question, message *dns.Msg) { func (xip *Xip) handleCNAME(question dns.Question, message *dns.Msg) { fqdn := question.Name normalizedFqdn := strings.ToLower(fqdn) - if hardcodedRecords[normalizedFqdn].CNAME == nil { + xip.recordsMu.RLock() + records := xip.records[normalizedFqdn].CNAME + xip.recordsMu.RUnlock() + if records == nil { xip.answerWithAuthority(question, message) return } - for _, record := range hardcodedRecords[normalizedFqdn].CNAME { + for _, record := range records { message.Answer = append(message.Answer, &dns.CNAME{ Hdr: dns.RR_Header{ Ttl: uint32((time.Minute * 5).Seconds()), @@ -273,7 +297,10 @@ func (xip *Xip) handleCNAME(question dns.Question, message *dns.Msg) { func (xip *Xip) handleSRV(question dns.Question, message *dns.Msg) { fqdn := question.Name normalizedFqdn := strings.ToLower(fqdn) - if hardcodedRecords[normalizedFqdn].SRV == nil { + xip.recordsMu.RLock() + record := xip.records[normalizedFqdn].SRV + xip.recordsMu.RUnlock() + if record == nil { xip.answerWithAuthority(question, message) return } @@ -285,10 +312,10 @@ func (xip *Xip) handleSRV(question dns.Question, message *dns.Msg) { Rrtype: dns.TypeSRV, Class: dns.ClassINET, }, - Priority: hardcodedRecords[normalizedFqdn].SRV.Priority, - Weight: hardcodedRecords[normalizedFqdn].SRV.Weight, - Port: hardcodedRecords[normalizedFqdn].SRV.Port, - Target: hardcodedRecords[normalizedFqdn].SRV.Target, + Priority: record.Priority, + Weight: record.Weight, + Port: record.Port, + Target: record.Target, }) } @@ -398,7 +425,7 @@ func (xip *Xip) StartServer() { err := xip.server.ListenAndServe() defer xip.server.Shutdown() if err != nil { - utils.Logger.Fatal().Err(err).Msg("Failed to start DNS server") + utils.Logger.Error().Err(err).Msg("Failed to start DNS server") if strings.Contains(err.Error(), "fly-global-services: no such host") { // we're not running on fly, bind to 0.0.0.0 instead port := strings.Split(xip.server.Addr, ":")[1] @@ -416,26 +443,27 @@ func (xip *Xip) StartServer() { utils.Logger.Info().Str("dns_address", xip.server.Addr).Msg("Starting up DNS server") } -func (xip *Xip) initHardcodedRecords() { - config := utils.GetConfig() +func (xip *Xip) initNameServers(nameServers []string) { rootDomainARecords := []net.IP{} - for i, ns := range config.NameServers { - name := fmt.Sprintf("ns%d.%s.", i+1, config.Domain) + xip.recordsMu.Lock() + defer xip.recordsMu.Unlock() + + for i, ns := range nameServers { + name := fmt.Sprintf("ns%d.%s.", i+1, xip.domain) ip := net.ParseIP(ns) rootDomainARecords = append(rootDomainARecords, ip) - entry := hardcodedRecords[name] - entry.A = append(hardcodedRecords[name].A, ip) - hardcodedRecords[name] = entry + entry := xip.records[name] + entry.A = append(xip.records[name].A, ip) + xip.records[name] = entry xip.nameServers = append(xip.nameServers, name) } - hardcodedRecords[fmt.Sprintf("%s.", config.Domain)] = hardcodedRecord{A: rootDomainARecords} + xip.records[fmt.Sprintf("%s.", xip.domain)] = hardcodedRecord{A: rootDomainARecords} - // will be filled in later when requesting certificates - hardcodedRecords[fmt.Sprintf("_acme-challenge.%s.", config.Domain)] = hardcodedRecord{TXT: []string{}} + xip.records[fmt.Sprintf("_acme-challenge.%s.", xip.domain)] = hardcodedRecord{TXT: []string{}} } func NewXip(opts ...Option) (xip *Xip) { @@ -444,6 +472,7 @@ func NewXip(opts ...Option) (xip *Xip) { domain: config.Domain, email: config.Email, dnsPort: config.DnsPort, + records: initialRecords(), } for _, opt := range opts { @@ -451,7 +480,7 @@ func NewXip(opts ...Option) (xip *Xip) { } if len(xip.nameServers) == 0 { - xip.initHardcodedRecords() + xip.initNameServers(config.NameServers) } xip.server = dns.Server{ diff --git a/xip/xip_test.go b/xip/xip_test.go index f5b8a30..9d75b5c 100644 --- a/xip/xip_test.go +++ b/xip/xip_test.go @@ -91,3 +91,41 @@ func BenchmarkResolveDashBasic(b *testing.B) { cmd.Run() } } + +func TestInstanceIsolation(t *testing.T) { + xip1 := NewXip( + WithDomain("one.test"), + WithDnsPort(0), + WithNameServers([]string{"1.1.1.1"}), + ) + xip2 := NewXip( + WithDomain("two.test"), + WithDnsPort(0), + WithNameServers([]string{"2.2.2.2"}), + ) + + if xip1.records == nil || xip2.records == nil { + t.Fatal("records not initialized") + } + + if len(xip1.nameServers) != 1 || xip1.nameServers[0] != "ns1.one.test." { + t.Errorf("xip1 nameservers incorrect: %v", xip1.nameServers) + } + if len(xip2.nameServers) != 1 || xip2.nameServers[0] != "ns1.two.test." { + t.Errorf("xip2 nameservers incorrect: %v", xip2.nameServers) + } + + if _, ok := xip1.records["ns1.one.test."]; !ok { + t.Error("xip1 missing ns1.one.test. record") + } + if _, ok := xip2.records["ns1.two.test."]; !ok { + t.Error("xip2 missing ns1.two.test. record") + } + + if _, ok := xip1.records["ns1.two.test."]; ok { + t.Error("xip1 should not have xip2's records") + } + if _, ok := xip2.records["ns1.one.test."]; ok { + t.Error("xip2 should not have xip1's records") + } +}