Refactoring (#325)
* Refactor core * Re-added tests * Small fixes * Add tests for acmetxt cidrslice and util funcs * Remove the last dangling reference to old logging package * Refactoring (#327) * chore: enable more linters and fix linter issues * ci: enable linter checks on all branches and disable recurring checks recurring linter checks don't make that much sense. The code & linter checks should not change on their own over night ;) * chore: update packages * Revert "chore: update packages" This reverts commit 30250bf28c4b39e9e5b3af012a4e28ab036bf9af. * chore: manually upgrade some packages * Updated dependencies, wrote changelog entry and fixed namespace for release * Refactoring - improving coverage (#371) * Increase code coverage in acmedns * More testing of ReadConfig() and its fallback mechanism * Found that if someone put a '"' double quote into the filename that we configure zap to log to, it would cause the the JSON created to be invalid. I have replaced the JSON string with proper config * Better handling of config options for api.TLS - we now error on an invalid value instead of silently failing. added a basic test for api.setupTLS() (to increase test coverage) * testing nameserver isOwnChallenge and isAuthoritative methods * add a unit test for nameserver answerOwnChallenge * fix linting errors * bump go and golangci-lint versions in github actions * Update golangci-lint.yml Bumping github-actions workflow versions to accommodate some changes in upstream golanci-lint * Bump Golang version to 1.23 (currently the oldest supported version) Bump golanglint-ci to 2.0.2 and migrate the config file. This should resolve the math/rand/v2 issue * bump golanglint-ci action version * Fixing up new golanglint-ci warnings and errors --------- Co-authored-by: Joona Hoikkala <5235109+joohoi@users.noreply.github.com> * Minor refactoring, error returns and e2e testing suite * Add a few tests * Fix linter and umask setting * Update github actions * Refine concurrency configuration for GitHub actions * HTTP timeouts to API, and self-validation mutex to nameserver ops --------- Co-authored-by: Florian Ritterhoff <32478819+fritterhoff@users.noreply.github.com> Co-authored-by: Jason Playne <jason@jasonplayne.com>
This commit is contained in:
co-authored by
Florian Ritterhoff
Jason Playne
parent
b7a0a8a7bc
commit
5a7bc230b8
@@ -0,0 +1,396 @@
|
||||
package nameserver
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/erikstmartin/go-testdb"
|
||||
"github.com/miekg/dns"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"github.com/joohoi/acme-dns/pkg/acmedns"
|
||||
"github.com/joohoi/acme-dns/pkg/database"
|
||||
)
|
||||
|
||||
type resolver struct {
|
||||
server string
|
||||
}
|
||||
|
||||
var records = []string{
|
||||
"auth.example.org. A 192.168.1.100",
|
||||
"ns1.auth.example.org. A 192.168.1.101",
|
||||
"cn.example.org CNAME something.example.org.",
|
||||
"!''b', unparseable ",
|
||||
"ns2.auth.example.org. A 192.168.1.102",
|
||||
}
|
||||
|
||||
func loggerHasEntryWithMessage(message string, logObserver *observer.ObservedLogs) bool {
|
||||
return len(logObserver.FilterMessage(message).All()) > 0
|
||||
}
|
||||
|
||||
func fakeConfigAndLogger() (acmedns.AcmeDnsConfig, *zap.SugaredLogger, *observer.ObservedLogs) {
|
||||
c := acmedns.AcmeDnsConfig{}
|
||||
c.Database.Engine = "sqlite"
|
||||
c.Database.Connection = ":memory:"
|
||||
obsCore, logObserver := observer.New(zap.DebugLevel)
|
||||
obsLogger := zap.New(obsCore).Sugar()
|
||||
return c, obsLogger, logObserver
|
||||
}
|
||||
|
||||
func setupDNS() (acmedns.AcmednsNS, acmedns.AcmednsDB, *observer.ObservedLogs) {
|
||||
config, logger, logObserver := fakeConfigAndLogger()
|
||||
config.General.Domain = "auth.example.org"
|
||||
config.General.Listen = "127.0.0.1:15353"
|
||||
config.General.Proto = "udp"
|
||||
config.General.Nsname = "ns1.auth.example.org"
|
||||
config.General.Nsadmin = "admin.example.org"
|
||||
config.General.StaticRecords = records
|
||||
config.General.Debug = false
|
||||
db, _ := database.Init(&config, logger)
|
||||
server := Nameserver{Config: &config, DB: db, Logger: logger, personalAuthKey: ""}
|
||||
server.Domains = make(map[string]Records)
|
||||
server.Server = &dns.Server{Addr: config.General.Listen, Net: config.General.Proto}
|
||||
server.ParseRecords()
|
||||
server.OwnDomain = "auth.example.org."
|
||||
return &server, db, logObserver
|
||||
}
|
||||
|
||||
func (r *resolver) lookup(host string, qtype uint16) (*dns.Msg, error) {
|
||||
msg := new(dns.Msg)
|
||||
msg.Id = dns.Id()
|
||||
msg.Question = make([]dns.Question, 1)
|
||||
msg.Question[0] = dns.Question{Name: dns.Fqdn(host), Qtype: qtype, Qclass: dns.ClassINET}
|
||||
in, err := dns.Exchange(msg, r.server)
|
||||
if err != nil {
|
||||
return in, fmt.Errorf("Error querying the server [%v]", err)
|
||||
}
|
||||
if in != nil && in.Rcode != dns.RcodeSuccess {
|
||||
return in, fmt.Errorf("Received error from the server [%s]", dns.RcodeToString[in.Rcode])
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func TestQuestionDBError(t *testing.T) {
|
||||
config, logger, _ := fakeConfigAndLogger()
|
||||
config.General.Listen = "127.0.0.1:15353"
|
||||
config.General.Proto = "udp"
|
||||
config.General.Domain = "auth.example.org"
|
||||
config.General.Nsname = "ns1.auth.example.org"
|
||||
config.General.Nsadmin = "admin.example.org"
|
||||
config.General.StaticRecords = records
|
||||
config.General.Debug = false
|
||||
db, _ := database.Init(&config, logger)
|
||||
server := Nameserver{Config: &config, DB: db, Logger: logger, personalAuthKey: ""}
|
||||
server.Domains = make(map[string]Records)
|
||||
server.ParseRecords()
|
||||
testdb.SetQueryWithArgsFunc(func(query string, args []driver.Value) (result driver.Rows, err error) {
|
||||
columns := []string{"Username", "Password", "Subdomain", "Value", "LastActive"}
|
||||
return testdb.RowsFromSlice(columns, [][]driver.Value{}), errors.New("Prepared query error")
|
||||
})
|
||||
|
||||
defer testdb.Reset()
|
||||
|
||||
tdb, err := sql.Open("testdb", "")
|
||||
if err != nil {
|
||||
t.Errorf("Got error: %v", err)
|
||||
}
|
||||
oldDb := db.GetBackend()
|
||||
|
||||
db.SetBackend(tdb)
|
||||
defer db.SetBackend(oldDb)
|
||||
|
||||
q := dns.Question{Name: dns.Fqdn("whatever.tld"), Qtype: dns.TypeTXT, Qclass: dns.ClassINET}
|
||||
_, err = server.answerTXT(q)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
config, logger, logObserver := fakeConfigAndLogger()
|
||||
config.General.Listen = "127.0.0.1:15353"
|
||||
config.General.Proto = "udp"
|
||||
config.General.Domain = ")"
|
||||
config.General.Nsname = "ns1.auth.example.org"
|
||||
config.General.Nsadmin = "admin.example.org"
|
||||
config.General.StaticRecords = records
|
||||
config.General.Debug = false
|
||||
config.General.StaticRecords = []string{}
|
||||
db, _ := database.Init(&config, logger)
|
||||
server := Nameserver{Config: &config, DB: db, Logger: logger, personalAuthKey: ""}
|
||||
server.Domains = make(map[string]Records)
|
||||
server.ParseRecords()
|
||||
if !loggerHasEntryWithMessage("Error while adding SOA record", logObserver) {
|
||||
t.Errorf("Expected SOA parsing to return error, but did not find one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveA(t *testing.T) {
|
||||
server, _, _ := setupDNS()
|
||||
errChan := make(chan error, 1)
|
||||
waitLock := sync.Mutex{}
|
||||
waitLock.Lock()
|
||||
server.SetNotifyStartedFunc(waitLock.Unlock)
|
||||
go server.Start(errChan)
|
||||
waitLock.Lock()
|
||||
resolv := resolver{server: "127.0.0.1:15353"}
|
||||
answer, err := resolv.lookup("auth.example.org", dns.TypeA)
|
||||
if err != nil {
|
||||
t.Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(answer.Answer) == 0 {
|
||||
t.Error("No answer for DNS query")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = resolv.lookup("nonexistent.domain.tld", dns.TypeA)
|
||||
if err == nil {
|
||||
t.Errorf("Was expecting error because of NXDOMAIN but got none")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestEDNS(t *testing.T) {
|
||||
resolv := resolver{server: "127.0.0.1:15353"}
|
||||
answer, _ := resolv.lookup("auth.example.org", dns.TypeOPT)
|
||||
if answer.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Was expecing NOERROR rcode for OPT query, but got [%s] instead.", dns.RcodeToString[answer.Rcode])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEDNSA(t *testing.T) {
|
||||
msg := new(dns.Msg)
|
||||
msg.Id = dns.Id()
|
||||
msg.Question = make([]dns.Question, 1)
|
||||
msg.Question[0] = dns.Question{Name: dns.Fqdn("auth.example.org"), Qtype: dns.TypeA, Qclass: dns.ClassINET}
|
||||
// Set EDNS0 with DO=1
|
||||
msg.SetEdns0(512, true)
|
||||
in, err := dns.Exchange(msg, "127.0.0.1:15353")
|
||||
if err != nil {
|
||||
t.Errorf("Error querying the server [%v]", err)
|
||||
}
|
||||
if in != nil && in.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Received error from the server [%s]", dns.RcodeToString[in.Rcode])
|
||||
}
|
||||
opt := in.IsEdns0()
|
||||
if opt == nil {
|
||||
t.Errorf("Should have got OPT back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEDNSBADVERS(t *testing.T) {
|
||||
msg := new(dns.Msg)
|
||||
msg.Id = dns.Id()
|
||||
msg.Question = make([]dns.Question, 1)
|
||||
msg.Question[0] = dns.Question{Name: dns.Fqdn("auth.example.org"), Qtype: dns.TypeA, Qclass: dns.ClassINET}
|
||||
// Set EDNS0 with version 1
|
||||
o := new(dns.OPT)
|
||||
o.SetVersion(1)
|
||||
o.Hdr.Name = "."
|
||||
o.Hdr.Rrtype = dns.TypeOPT
|
||||
msg.Extra = append(msg.Extra, o)
|
||||
in, err := dns.Exchange(msg, "127.0.0.1:15353")
|
||||
if err != nil {
|
||||
t.Errorf("Error querying the server [%v]", err)
|
||||
}
|
||||
if in != nil && in.Rcode != dns.RcodeBadVers {
|
||||
t.Errorf("Received unexpected rcode from the server [%s]", dns.RcodeToString[in.Rcode])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCNAME(t *testing.T) {
|
||||
resolv := resolver{server: "127.0.0.1:15353"}
|
||||
expected := "cn.example.org. 3600 IN CNAME something.example.org."
|
||||
answer, err := resolv.lookup("cn.example.org", dns.TypeCNAME)
|
||||
if err != nil {
|
||||
t.Errorf("Got unexpected error: %s", err)
|
||||
}
|
||||
if len(answer.Answer) != 1 {
|
||||
t.Errorf("Expected exactly 1 RR in answer, but got %d instead.", len(answer.Answer))
|
||||
}
|
||||
if answer.Answer[0].Header().Rrtype != dns.TypeCNAME {
|
||||
t.Errorf("Expected a CNAME answer, but got [%s] instead.", dns.TypeToString[answer.Answer[0].Header().Rrtype])
|
||||
}
|
||||
if answer.Answer[0].String() != expected {
|
||||
t.Errorf("Expected CNAME answer [%s] but got [%s] instead.", expected, answer.Answer[0].String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthoritative(t *testing.T) {
|
||||
resolv := resolver{server: "127.0.0.1:15353"}
|
||||
answer, _ := resolv.lookup("nonexistent.auth.example.org", dns.TypeA)
|
||||
if answer.Rcode != dns.RcodeNameError {
|
||||
t.Errorf("Was expecing NXDOMAIN rcode, but got [%s] instead.", dns.RcodeToString[answer.Rcode])
|
||||
}
|
||||
if len(answer.Ns) != 1 {
|
||||
t.Errorf("Was expecting exactly one answer (SOA) for invalid subdomain, but got %d", len(answer.Ns))
|
||||
}
|
||||
if answer.Ns[0].Header().Rrtype != dns.TypeSOA {
|
||||
t.Errorf("Was expecting SOA record as answer for NXDOMAIN but got [%s]", dns.TypeToString[answer.Ns[0].Header().Rrtype])
|
||||
}
|
||||
if !answer.Authoritative {
|
||||
t.Errorf("Was expecting authoritative bit to be set")
|
||||
}
|
||||
nanswer, _ := resolv.lookup("nonexsitent.nonauth.tld", dns.TypeA)
|
||||
if len(nanswer.Answer) > 0 {
|
||||
t.Errorf("Didn't expect answers for non authotitative domain query")
|
||||
}
|
||||
if nanswer.Authoritative {
|
||||
t.Errorf("Authoritative bit should not be set for non-authoritative domain.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTXT(t *testing.T) {
|
||||
iServer, db, _ := setupDNS()
|
||||
server := iServer.(*Nameserver)
|
||||
var validTXT string
|
||||
// acme-dns validation in pkg/api/util.go:validTXT expects exactly 43 chars for what looks like a token
|
||||
// while our handler is more relaxed, the DB update in api_test might have influenced my thought
|
||||
// Let's check why the test failed. Ah, "Received error from the server [REFUSED]"? No, "NXDOMAIN"?
|
||||
// Wait, the failure was: "Test 0: Expected answer but got: Received error from the server [SERVFAIL]"
|
||||
// Or was it? The log was truncated.
|
||||
// Actually, the registration atxt.Value is NOT used for Update, it uses ACMETxtPost.
|
||||
// ACMETxtPost.Value needs to be valid.
|
||||
|
||||
atxt, err := db.Register(acmedns.Cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Could not initiate db record: [%v]", err)
|
||||
return
|
||||
}
|
||||
|
||||
update := acmedns.ACMETxtPost{
|
||||
Subdomain: atxt.Subdomain,
|
||||
Value: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 43 chars
|
||||
}
|
||||
validTXT = update.Value
|
||||
|
||||
err = db.Update(update)
|
||||
if err != nil {
|
||||
t.Errorf("Could not update db record: [%v]", err)
|
||||
return
|
||||
}
|
||||
|
||||
for i, test := range []struct {
|
||||
subDomain string
|
||||
expTXT string
|
||||
getAnswer bool
|
||||
validAnswer bool
|
||||
}{
|
||||
{atxt.Subdomain, validTXT, true, true},
|
||||
{atxt.Subdomain, "invalid", true, false},
|
||||
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", validTXT, false, false},
|
||||
} {
|
||||
q := dns.Question{Name: dns.Fqdn(test.subDomain + ".auth.example.org"), Qtype: dns.TypeTXT, Qclass: dns.ClassINET}
|
||||
ansRRs, rcode, _, err := server.answer(q)
|
||||
if err != nil {
|
||||
if test.getAnswer {
|
||||
t.Fatalf("Test %d: Expected answer but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ansRRs) > 0 {
|
||||
if !test.getAnswer && rcode == dns.RcodeNameError {
|
||||
t.Errorf("Test %d: Expected no answer, but got: [%v]", i, ansRRs)
|
||||
}
|
||||
if test.getAnswer {
|
||||
err = hasExpectedTXTAnswer(ansRRs, test.expTXT)
|
||||
if err != nil {
|
||||
if test.validAnswer {
|
||||
t.Errorf("Test %d: %v", i, err)
|
||||
}
|
||||
} else {
|
||||
if !test.validAnswer {
|
||||
t.Errorf("Test %d: Answer was not expected to be valid, answer [%q], compared to [%s]", i, ansRRs, test.expTXT)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if test.getAnswer {
|
||||
t.Errorf("Test %d: Expected answer, but didn't get one", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasExpectedTXTAnswer(answer []dns.RR, cmpTXT string) error {
|
||||
for _, record := range answer {
|
||||
// We expect only one answer, so no need to loop through the answer slice
|
||||
if rec, ok := record.(*dns.TXT); ok {
|
||||
for _, txtValue := range rec.Txt {
|
||||
if txtValue == cmpTXT {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errmsg := fmt.Sprintf("Got answer of unexpected type [%q]", answer[0])
|
||||
return errors.New(errmsg)
|
||||
}
|
||||
}
|
||||
return errors.New("Expected answer not found")
|
||||
}
|
||||
|
||||
func TestAnswerTXTError(t *testing.T) {
|
||||
config, logger, _ := fakeConfigAndLogger()
|
||||
db, _ := database.Init(&config, logger)
|
||||
server := Nameserver{Config: &config, DB: db, Logger: logger}
|
||||
|
||||
testdb.SetQueryWithArgsFunc(func(query string, args []driver.Value) (result driver.Rows, err error) {
|
||||
return testdb.RowsFromSlice([]string{}, [][]driver.Value{}), errors.New("DB error")
|
||||
})
|
||||
defer testdb.Reset()
|
||||
|
||||
tdb, _ := sql.Open("testdb", "")
|
||||
oldDb := db.GetBackend()
|
||||
db.SetBackend(tdb)
|
||||
defer db.SetBackend(oldDb)
|
||||
|
||||
q := dns.Question{Name: "whatever.auth.example.org.", Qtype: dns.TypeTXT}
|
||||
_, err := server.answerTXT(q)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error from answerTXT when DB fails, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerNameError(t *testing.T) {
|
||||
iServer, _, _ := setupDNS()
|
||||
server := iServer.(*Nameserver)
|
||||
q := dns.Question{Name: "notauth.com.", Qtype: dns.TypeA}
|
||||
_, rcode, auth, _ := server.answer(q)
|
||||
if rcode != dns.RcodeNameError {
|
||||
t.Errorf("Expected NXDOMAIN for non-authoritative domain, got %s", dns.RcodeToString[rcode])
|
||||
}
|
||||
if auth {
|
||||
t.Errorf("Expected auth bit to be false for non-authoritative domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseInsensitiveResolveA(t *testing.T) {
|
||||
resolv := resolver{server: "127.0.0.1:15353"}
|
||||
answer, err := resolv.lookup("aUtH.eXAmpLe.org", dns.TypeA)
|
||||
if err != nil {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
|
||||
if len(answer.Answer) == 0 {
|
||||
t.Error("No answer for DNS query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseInsensitiveResolveSOA(t *testing.T) {
|
||||
resolv := resolver{server: "127.0.0.1:15353"}
|
||||
answer, _ := resolv.lookup("doesnotexist.aUtH.eXAmpLe.org", dns.TypeSOA)
|
||||
if answer.Rcode != dns.RcodeNameError {
|
||||
t.Errorf("Was expecing NXDOMAIN rcode, but got [%s] instead.", dns.RcodeToString[answer.Rcode])
|
||||
}
|
||||
|
||||
if len(answer.Ns) == 0 {
|
||||
t.Error("No SOA answer for DNS query")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package nameserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func (n *Nameserver) handleRequest(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
// handle edns0
|
||||
opt := r.IsEdns0()
|
||||
if opt != nil {
|
||||
if opt.Version() != 0 {
|
||||
// Only EDNS0 is standardized
|
||||
m.Rcode = dns.RcodeBadVers
|
||||
m.SetEdns0(512, false)
|
||||
} else {
|
||||
// We can safely do this as we know that we're not setting other OPT RRs within acme-dns.
|
||||
m.SetEdns0(512, false)
|
||||
if r.Opcode == dns.OpcodeQuery {
|
||||
n.readQuery(m)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if r.Opcode == dns.OpcodeQuery {
|
||||
n.readQuery(m)
|
||||
}
|
||||
}
|
||||
_ = w.WriteMsg(m)
|
||||
}
|
||||
|
||||
func (n *Nameserver) readQuery(m *dns.Msg) {
|
||||
var authoritative = false
|
||||
for _, que := range m.Question {
|
||||
if rr, rc, auth, err := n.answer(que); err == nil {
|
||||
if auth {
|
||||
authoritative = auth
|
||||
}
|
||||
m.Rcode = rc
|
||||
m.Answer = append(m.Answer, rr...)
|
||||
}
|
||||
}
|
||||
m.Authoritative = authoritative
|
||||
if authoritative {
|
||||
if m.Rcode == dns.RcodeNameError {
|
||||
m.Ns = append(m.Ns, n.SOA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Nameserver) answer(q dns.Question) ([]dns.RR, int, bool, error) {
|
||||
var rcode int
|
||||
var err error
|
||||
var txtRRs []dns.RR
|
||||
loweredName := strings.ToLower(q.Name)
|
||||
var authoritative = n.isAuthoritative(loweredName)
|
||||
if !n.isOwnChallenge(loweredName) && !n.answeringForDomain(loweredName) {
|
||||
rcode = dns.RcodeNameError
|
||||
}
|
||||
r, _ := n.getRecord(loweredName, q.Qtype)
|
||||
if q.Qtype == dns.TypeTXT {
|
||||
if n.isOwnChallenge(loweredName) {
|
||||
txtRRs, err = n.answerOwnChallenge(q)
|
||||
} else {
|
||||
txtRRs, err = n.answerTXT(q)
|
||||
}
|
||||
if err == nil {
|
||||
r = append(r, txtRRs...)
|
||||
}
|
||||
}
|
||||
if len(r) > 0 {
|
||||
// Make sure that we return NOERROR if there were dynamic records for the domain
|
||||
rcode = dns.RcodeSuccess
|
||||
}
|
||||
n.Logger.Debugw("Answering question for domain",
|
||||
"qtype", dns.TypeToString[q.Qtype],
|
||||
"domain", q.Name,
|
||||
"rcode", dns.RcodeToString[rcode])
|
||||
return r, rcode, authoritative, nil
|
||||
}
|
||||
|
||||
func (n *Nameserver) answerTXT(q dns.Question) ([]dns.RR, error) {
|
||||
var ra []dns.RR
|
||||
subdomain := sanitizeDomainQuestion(q.Name)
|
||||
atxt, err := n.DB.GetTXTForDomain(subdomain)
|
||||
if err != nil {
|
||||
n.Logger.Errorw("Error while trying to get record",
|
||||
"error", err.Error())
|
||||
return ra, err
|
||||
}
|
||||
for _, v := range atxt {
|
||||
if len(v) > 0 {
|
||||
r := new(dns.TXT)
|
||||
r.Hdr = dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}
|
||||
r.Txt = append(r.Txt, v)
|
||||
ra = append(ra, r)
|
||||
}
|
||||
}
|
||||
return ra, nil
|
||||
}
|
||||
|
||||
func (n *Nameserver) isAuthoritative(name string) bool {
|
||||
if n.answeringForDomain(name) {
|
||||
return true
|
||||
}
|
||||
off := 0
|
||||
for {
|
||||
i, next := dns.NextLabel(name, off)
|
||||
if next {
|
||||
return false
|
||||
}
|
||||
off = i
|
||||
if n.answeringForDomain(name[off:]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Nameserver) isOwnChallenge(name string) bool {
|
||||
if strings.HasPrefix(name, "_acme-challenge.") {
|
||||
domain := name[16:]
|
||||
if domain == n.OwnDomain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// answeringForDomain checks if we have any records for a domain
|
||||
func (n *Nameserver) answeringForDomain(name string) bool {
|
||||
if n.OwnDomain == name {
|
||||
return true
|
||||
}
|
||||
_, ok := n.Domains[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (n *Nameserver) getRecord(name string, qtype uint16) ([]dns.RR, error) {
|
||||
var rr []dns.RR
|
||||
var cnames []dns.RR
|
||||
domain, ok := n.Domains[name]
|
||||
if !ok {
|
||||
return rr, fmt.Errorf("no records for domain %s", name)
|
||||
}
|
||||
for _, ri := range domain.Records {
|
||||
if ri.Header().Rrtype == qtype {
|
||||
rr = append(rr, ri)
|
||||
}
|
||||
if ri.Header().Rrtype == dns.TypeCNAME {
|
||||
cnames = append(cnames, ri)
|
||||
}
|
||||
}
|
||||
if len(rr) == 0 {
|
||||
return cnames, nil
|
||||
}
|
||||
return rr, nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package nameserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func TestNameserver_isOwnChallenge(t *testing.T) {
|
||||
type fields struct {
|
||||
OwnDomain string
|
||||
}
|
||||
type args struct {
|
||||
name string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "is own challenge",
|
||||
fields: fields{
|
||||
OwnDomain: "some-domain.test.",
|
||||
},
|
||||
args: args{
|
||||
name: "_acme-challenge.some-domain.test.",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "challenge but not for us",
|
||||
fields: fields{
|
||||
OwnDomain: "some-domain.test.",
|
||||
},
|
||||
args: args{
|
||||
name: "_acme-challenge.some-other-domain.test.",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "not a challenge",
|
||||
fields: fields{
|
||||
OwnDomain: "domain.test.",
|
||||
},
|
||||
args: args{
|
||||
name: "domain.test.",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "other request challenge",
|
||||
fields: fields{
|
||||
OwnDomain: "domain.test.",
|
||||
},
|
||||
args: args{
|
||||
name: "my-domain.test.",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
n := &Nameserver{
|
||||
OwnDomain: tt.fields.OwnDomain,
|
||||
}
|
||||
if got := n.isOwnChallenge(tt.args.name); got != tt.want {
|
||||
t.Errorf("isOwnChallenge() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameserver_isAuthoritative(t *testing.T) {
|
||||
type fields struct {
|
||||
OwnDomain string
|
||||
Domains map[string]Records
|
||||
}
|
||||
type args struct {
|
||||
q dns.Question
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "is authoritative own domain",
|
||||
fields: fields{
|
||||
OwnDomain: "auth.domain.",
|
||||
},
|
||||
args: args{
|
||||
q: dns.Question{Name: "auth.domain."},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "is authoritative other domain",
|
||||
fields: fields{
|
||||
OwnDomain: "auth.domain.",
|
||||
Domains: map[string]Records{
|
||||
"other-domain.test.": {Records: nil},
|
||||
},
|
||||
},
|
||||
args: args{
|
||||
q: dns.Question{Name: "other-domain.test."},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "is authoritative sub domain",
|
||||
fields: fields{
|
||||
OwnDomain: "auth.domain.",
|
||||
Domains: map[string]Records{
|
||||
"other-domain.test.": {Records: nil},
|
||||
},
|
||||
},
|
||||
args: args{
|
||||
q: dns.Question{Name: "sub.auth.domain."},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "is not authoritative own",
|
||||
fields: fields{
|
||||
OwnDomain: "auth.domain.",
|
||||
Domains: map[string]Records{
|
||||
"other-domain.test.": {Records: nil},
|
||||
},
|
||||
},
|
||||
args: args{
|
||||
q: dns.Question{Name: "special-auth.domain."},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
n := &Nameserver{
|
||||
OwnDomain: tt.fields.OwnDomain,
|
||||
Domains: tt.fields.Domains,
|
||||
}
|
||||
if got := n.isAuthoritative(tt.args.q.Name); got != tt.want {
|
||||
t.Errorf("isAuthoritative() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package nameserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/joohoi/acme-dns/pkg/acmedns"
|
||||
)
|
||||
|
||||
// Records is a slice of ResourceRecords
|
||||
type Records struct {
|
||||
Records []dns.RR
|
||||
}
|
||||
|
||||
type Nameserver struct {
|
||||
Config *acmedns.AcmeDnsConfig
|
||||
DB acmedns.AcmednsDB
|
||||
Logger *zap.SugaredLogger
|
||||
Server *dns.Server
|
||||
OwnDomain string
|
||||
NotifyStartedFunc func()
|
||||
SOA dns.RR
|
||||
mu sync.RWMutex
|
||||
personalAuthKey string
|
||||
Domains map[string]Records
|
||||
errChan chan error
|
||||
}
|
||||
|
||||
func InitAndStart(config *acmedns.AcmeDnsConfig, db acmedns.AcmednsDB, logger *zap.SugaredLogger, errChan chan error) []acmedns.AcmednsNS {
|
||||
dnsservers := make([]acmedns.AcmednsNS, 0)
|
||||
waitLock := sync.Mutex{}
|
||||
if strings.HasPrefix(config.General.Proto, "both") {
|
||||
|
||||
// Handle the case where DNS server should be started for both udp and tcp
|
||||
udpProto := "udp"
|
||||
tcpProto := "tcp"
|
||||
if strings.HasSuffix(config.General.Proto, "4") {
|
||||
udpProto += "4"
|
||||
tcpProto += "4"
|
||||
} else if strings.HasSuffix(config.General.Proto, "6") {
|
||||
udpProto += "6"
|
||||
tcpProto += "6"
|
||||
}
|
||||
dnsServerUDP := NewDNSServer(config, db, logger, udpProto)
|
||||
dnsservers = append(dnsservers, dnsServerUDP)
|
||||
dnsServerUDP.ParseRecords()
|
||||
dnsServerTCP := NewDNSServer(config, db, logger, tcpProto)
|
||||
dnsservers = append(dnsservers, dnsServerTCP)
|
||||
dnsServerTCP.ParseRecords()
|
||||
// wait for the server to get started to proceed
|
||||
waitLock.Lock()
|
||||
dnsServerUDP.SetNotifyStartedFunc(waitLock.Unlock)
|
||||
go dnsServerUDP.Start(errChan)
|
||||
waitLock.Lock()
|
||||
dnsServerTCP.SetNotifyStartedFunc(waitLock.Unlock)
|
||||
go dnsServerTCP.Start(errChan)
|
||||
waitLock.Lock()
|
||||
} else {
|
||||
dnsServer := NewDNSServer(config, db, logger, config.General.Proto)
|
||||
dnsservers = append(dnsservers, dnsServer)
|
||||
dnsServer.ParseRecords()
|
||||
waitLock.Lock()
|
||||
dnsServer.SetNotifyStartedFunc(waitLock.Unlock)
|
||||
go dnsServer.Start(errChan)
|
||||
waitLock.Lock()
|
||||
}
|
||||
return dnsservers
|
||||
}
|
||||
|
||||
// NewDNSServer parses the DNS records from config and returns a new DNSServer struct
|
||||
func NewDNSServer(config *acmedns.AcmeDnsConfig, db acmedns.AcmednsDB, logger *zap.SugaredLogger, proto string) acmedns.AcmednsNS {
|
||||
// dnsServerTCP := NewDNSServer(DB, Config.General.Listen, tcpProto, Config.General.Domain)
|
||||
server := Nameserver{Config: config, DB: db, Logger: logger}
|
||||
server.Server = &dns.Server{Addr: config.General.Listen, Net: proto}
|
||||
domain := config.General.Domain
|
||||
if !strings.HasSuffix(domain, ".") {
|
||||
domain = domain + "."
|
||||
}
|
||||
server.OwnDomain = strings.ToLower(domain)
|
||||
server.personalAuthKey = ""
|
||||
server.Domains = make(map[string]Records)
|
||||
return &server
|
||||
}
|
||||
|
||||
func (n *Nameserver) Start(errorChannel chan error) {
|
||||
n.errChan = errorChannel
|
||||
dns.HandleFunc(".", n.handleRequest)
|
||||
n.Logger.Infow("Starting DNS listener",
|
||||
"addr", n.Server.Addr,
|
||||
"proto", n.Server.Net)
|
||||
if n.NotifyStartedFunc != nil {
|
||||
n.Server.NotifyStartedFunc = n.NotifyStartedFunc
|
||||
}
|
||||
err := n.Server.ListenAndServe()
|
||||
if err != nil {
|
||||
errorChannel <- fmt.Errorf("DNS server %s failed: %w", n.Server.Net, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Nameserver) SetNotifyStartedFunc(fun func()) {
|
||||
n.Server.NotifyStartedFunc = fun
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package nameserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// ParseRecords parses a slice of DNS record string
|
||||
func (n *Nameserver) ParseRecords() {
|
||||
for _, v := range n.Config.General.StaticRecords {
|
||||
rr, err := dns.NewRR(strings.ToLower(v))
|
||||
if err != nil {
|
||||
n.Logger.Errorw("Could not parse RR from config",
|
||||
"error", err.Error(),
|
||||
"rr", v)
|
||||
continue
|
||||
}
|
||||
// Add parsed RR
|
||||
n.appendRR(rr)
|
||||
}
|
||||
// Create serial
|
||||
serial := time.Now().Format("2006010215")
|
||||
// Add SOA
|
||||
SOAstring := fmt.Sprintf("%s. SOA %s. %s. %s 28800 7200 604800 86400", strings.ToLower(n.Config.General.Domain), strings.ToLower(n.Config.General.Nsname), strings.ToLower(n.Config.General.Nsadmin), serial)
|
||||
soarr, err := dns.NewRR(SOAstring)
|
||||
if err != nil {
|
||||
n.Logger.Errorw("Error while adding SOA record",
|
||||
"error", err.Error(),
|
||||
"soa", SOAstring)
|
||||
} else {
|
||||
n.appendRR(soarr)
|
||||
n.SOA = soarr
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Nameserver) appendRR(rr dns.RR) {
|
||||
addDomain := rr.Header().Name
|
||||
_, ok := n.Domains[addDomain]
|
||||
if !ok {
|
||||
n.Domains[addDomain] = Records{[]dns.RR{rr}}
|
||||
} else {
|
||||
drecs := n.Domains[addDomain]
|
||||
drecs.Records = append(drecs.Records, rr)
|
||||
n.Domains[addDomain] = drecs
|
||||
}
|
||||
n.Logger.Debugw("Adding new record to domain",
|
||||
"recordtype", dns.TypeToString[rr.Header().Rrtype],
|
||||
"domain", addDomain)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package nameserver
|
||||
|
||||
import "strings"
|
||||
|
||||
func sanitizeDomainQuestion(d string) string {
|
||||
dom := strings.ToLower(d)
|
||||
firstDot := strings.Index(d, ".")
|
||||
if firstDot > 0 {
|
||||
dom = dom[0:firstDot]
|
||||
}
|
||||
return dom
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package nameserver
|
||||
|
||||
import "github.com/miekg/dns"
|
||||
|
||||
// SetOwnAuthKey sets the ACME challenge token for completing dns validation for acme-dns server itself
|
||||
func (n *Nameserver) SetOwnAuthKey(key string) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.personalAuthKey = key
|
||||
}
|
||||
|
||||
// answerOwnChallenge answers to ACME challenge for acme-dns own certificate
|
||||
func (n *Nameserver) answerOwnChallenge(q dns.Question) ([]dns.RR, error) {
|
||||
n.mu.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
r := new(dns.TXT)
|
||||
r.Hdr = dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}
|
||||
r.Txt = append(r.Txt, n.personalAuthKey)
|
||||
return []dns.RR{r}, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package nameserver
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func TestNameserver_answerOwnChallenge(t *testing.T) {
|
||||
type fields struct {
|
||||
personalAuthKey string
|
||||
}
|
||||
type args struct {
|
||||
q dns.Question
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
want []dns.RR
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "answer own challenge",
|
||||
fields: fields{
|
||||
personalAuthKey: "some key text",
|
||||
},
|
||||
args: args{
|
||||
q: dns.Question{
|
||||
Name: "something",
|
||||
Qtype: 0,
|
||||
Qclass: 0,
|
||||
},
|
||||
},
|
||||
want: []dns.RR{
|
||||
&dns.TXT{
|
||||
Hdr: dns.RR_Header{Name: "something", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1},
|
||||
Txt: []string{"some key text"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
n := &Nameserver{}
|
||||
|
||||
n.SetOwnAuthKey(tt.fields.personalAuthKey)
|
||||
if n.personalAuthKey != tt.fields.personalAuthKey {
|
||||
t.Errorf("failed to set personal auth key: got = %s, want %s", n.personalAuthKey, tt.fields.personalAuthKey)
|
||||
return
|
||||
}
|
||||
|
||||
got, err := n.answerOwnChallenge(tt.args.q)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("answerOwnChallenge() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("answerOwnChallenge() got = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user