feat: implement (full) DNS server App, provider as ACMEDNSProvider

This is a rewrite of almost everything. The provided modules no longer
rely on undocumented/deprecated behavior.
It's no longer possible to have multiple DNS "servers" on different
sockets, but the new version allows serving arbitrary records. Records
can be defined in the config or Caddyfile.
The provider communicates with the Server via a channel.
This commit is contained in:
xaos 2023-03-22 16:23:11 +01:00
parent a6d39a06aa
commit 15a4840b03
5 changed files with 639 additions and 215 deletions

188
app.go Normal file
View file

@ -0,0 +1,188 @@
package stub
import (
"fmt"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/miekg/dns"
"go.uber.org/zap"
)
type App struct {
// the address & port on which to serve DNS for the challenge
Address string `json:"address,omitempty"`
// Statically configured set of records to serve
Records []string `json:"records,omitempty"`
ctx *caddy.Context // set in Provision()
logger *zap.Logger // set in Provision()
requests chan request // set in Provision()
shutdown chan struct{} // set in Provision()
}
func (App) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "dns",
New: func() caddy.Module { return &App{} },
}
}
// Provision sets up the module. Implements caddy.Provisioner.
func (a *App) Provision(ctx caddy.Context) error {
a.logger = ctx.Logger()
if a.requests == nil {
a.requests = make(chan request)
}
if a.Records == nil {
a.Records = []string{}
}
if a.shutdown == nil {
a.shutdown = make(chan struct{})
}
if a.Address == "" {
a.Address = ":53"
}
return nil
}
func (a *App) Start() error {
parsed, err := caddy.ParseNetworkAddress(a.Address)
if err != nil {
return err
}
parsed.Network = "udp"
a.logger.Debug("starting app", zap.Stringer("address", parsed))
srv := Server{
Address: parsed,
logger: a.logger,
shutdown: a.shutdown,
ctx: a.ctx,
requests: a.requests,
Records: make(map[key][]dns.RR),
}
for _, record_string := range a.Records {
record, err := dns.NewRR(record_string)
if err != nil {
return err
}
srv.insert_record(record)
}
if len(a.Records) > 0 {
a.logger.Debug("loaded records", zap.Int("count", len(a.Records)))
} else {
a.logger.Debug("no records loaded")
}
err = srv.start_stop_server()
if err != nil {
return err
}
go srv.main()
return nil
}
func (a *App) Stop() error {
a.logger.Debug("stopping app")
close(a.shutdown)
return nil
}
// UnmarshalCaddyfile sets up the DNS provider from Caddyfile tokens. Syntax:
//
// dns [address] {
// bind <address>
// [record "<record>"]
// }
func (a *App) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
if d.NextArg() {
a.Address = d.Val()
_, err := caddy.ParseNetworkAddress(a.Address)
if err != nil {
return d.WrapErr(err)
}
}
if d.NextArg() {
return d.ArgErr()
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "bind":
if a.Address != "" {
return d.Err("Bind address already set")
}
if d.NextArg() {
a.Address = d.Val()
_, err := caddy.ParseNetworkAddress(a.Address)
if err != nil {
return d.WrapErr(err)
}
}
if d.NextArg() {
return d.ArgErr()
}
case "record":
if d.NextArg() {
rr, err := dns.NewRR(d.Val())
if err != nil {
return d.WrapErr(err)
}
if rr == nil {
return d.Err("invalid empty record")
}
a.Records = append(a.Records, rr.String())
} else {
return d.ArgErr()
}
if d.NextArg() {
return d.ArgErr()
}
default:
return d.Errf("unrecognized subdirective '%s'", d.Val())
}
}
}
if a.Address == "" {
a.Address = ":53"
}
return nil
}
// parseApp configures the "dns" global option from Caddyfile.
// Syntax:
//
// dns [address] {
// bind <address>
// [record <record>]
// }
func parseApp(d *caddyfile.Dispenser, prev interface{}) (interface{}, error) {
var a App
var warnings []caddyconfig.Warning
if prev != nil {
return nil, fmt.Errorf("multiple DNS servers are not supported!")
}
err := a.UnmarshalCaddyfile(d)
if err != nil {
return nil, err
}
// tell Caddyfile adapter that this is the JSON for an app
return httpcaddyfile.App{
Name: "dns",
Value: caddyconfig.JSON(a, &warnings),
}, nil
}
// Interface guards
var (
_ caddy.App = (*App)(nil)
_ caddy.Provisioner = (*App)(nil)
)

26
log.go
View file

@ -222,3 +222,29 @@ func log_libdns_record(record *libdns.Record) zapcore.ObjectMarshaler {
return zapcore.ObjectMarshalerFunc(f)
}
// MarshalLogObject satisfies the zapcore.ObjectMarshaler interface.
func (r request) MarshalLogObject(enc zapcore.ObjectEncoder) error {
enc.AddString("zone", r.zone)
if r.append {
enc.AddString("type", "append")
} else {
enc.AddString("type", "delete")
}
if len(r.records) > 0 {
array := func(arr zapcore.ArrayEncoder) error {
for _, r := range r.records {
object := func(obj zapcore.ObjectEncoder) error {
log_RR(obj, r)
return nil
}
arr.AppendObject(zapcore.ObjectMarshalerFunc(object))
}
return nil
}
enc.AddArray("records", zapcore.ArrayMarshalerFunc(array))
}
return nil
}

144
provider.go Normal file
View file

@ -0,0 +1,144 @@
package stub
import (
"context"
"fmt"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"go.uber.org/zap"
)
type Provider struct {
app_channel chan request
logger *zap.Logger // set in Provision()
}
// CaddyModule returns the Caddy module information.
func (Provider) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "dns.providers.internal",
New: func() caddy.Module { return &Provider{} },
}
}
// Provision sets up the module. Implements caddy.Provisioner.
func (p *Provider) Provision(ctx caddy.Context) error {
p.logger = ctx.Logger()
if !ctx.AppIsConfigured("dns") {
p.logger.Warn("DNS app not yet configured")
}
app, err := ctx.App("dns")
if err != nil {
return err
}
if app == nil {
return fmt.Errorf("failed to load DNS app")
}
dns_app, ok := app.(*App)
if !ok {
return fmt.Errorf("received invalid app")
}
p.app_channel = dns_app.requests
return nil
}
// UnmarshalCaddyfile sets up the DNS provider from Caddyfile tokens. Syntax:
//
// dns internal
func (p *Provider) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
if d.NextArg() {
return d.ArgErr()
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
default:
return d.Errf("unrecognized subdirective '%s'", d.Val())
}
}
}
return nil
}
func (p *Provider) convert(zone string, set []libdns.Record) ([]dns.RR, error) {
converted := []dns.RR{}
for _, r := range set {
rr, err := record_to_rr(zone, r)
if err != nil {
p.logger.Error(
"failed to convert",
zap.Error(err),
zap.String("zone", zone),
zap.Object("record", log_libdns_record(&r)),
)
return nil, err
}
converted = append(converted, rr)
}
return converted, nil
}
func (p *Provider) make_request(
ctx context.Context,
zone string,
append bool,
recs []libdns.Record,
) ([]libdns.Record, error) {
resp := make(chan error)
records, err := p.convert(zone, recs)
if err != nil {
return nil, err
}
req := request{
append: append,
zone: zone,
records: records,
responder: resp,
}
p.app_channel <- req
p.logger.Debug("sent request", zap.Object("request", req))
select {
case err = <-resp:
if err != nil {
p.logger.Debug("request failed", zap.Error(err))
return nil, err
} else {
p.logger.Debug("request succeeded")
return recs, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (p *Provider) AppendRecords(
ctx context.Context,
zone string,
recs []libdns.Record,
) ([]libdns.Record, error) {
return p.make_request(ctx, zone, true, recs)
}
func (p *Provider) DeleteRecords(
ctx context.Context,
zone string,
recs []libdns.Record,
) ([]libdns.Record, error) {
return p.make_request(ctx, zone, false, recs)
}
// Interface guards
var (
_ caddy.Provisioner = (*Provider)(nil)
_ caddyfile.Unmarshaler = (*Provider)(nil)
_ libdns.RecordAppender = (*Provider)(nil)
_ libdns.RecordDeleter = (*Provider)(nil)
)

249
server.go Normal file
View file

@ -0,0 +1,249 @@
package stub
import (
"errors"
"net"
"strings"
"github.com/caddyserver/caddy/v2"
"github.com/miekg/dns"
"go.uber.org/zap"
)
// A DNS Query coming in from outside
type query struct {
w dns.ResponseWriter
r *dns.Msg
}
type key struct {
Type dns.Type
Name string
}
type Server struct {
// the address & port on which to serve DNS for the challenge
Address caddy.NetworkAddress `json:"address,omitempty"`
// Statically configured records to serve
Records map[key][]dns.RR `json:"records,omitempty"`
logger *zap.Logger // set by App.start()
ctx *caddy.Context // set by App.start()
shutdown chan struct{} // set by App.start()
requests chan request // set by App.start()
dns_server *dns.Server // set by start_stop_server()
queries chan query // set by start_stop_server()
}
func rr_key(record dns.RR) key {
return key{
Type: dns.Type(record.Header().Rrtype),
Name: strings.ToLower(record.Header().Name),
}
}
func (srv *Server) insert_record(record dns.RR) {
key := rr_key(record)
current, exists := srv.Records[key]
if exists {
// TODO: de-duplicate?
srv.Records[key] = append(current, record)
} else {
srv.Records[key] = []dns.RR{record}
}
}
func (srv *Server) delete_record(record dns.RR) {
key := rr_key(record)
current, exists := srv.Records[key]
if exists {
filtered := []dns.RR{}
for _, rec := range current {
if rec != record {
filtered = append(filtered, rec)
}
}
if len(filtered) == 0 {
delete(srv.Records, key)
} else {
srv.Records[key] = filtered
}
} else {
// doesn't exist, nothing to delete
}
}
// This is the "main loop" of the DNS server
// To avoid having to synchronize access to the records map, it is owned
// exclusively by this loop, and the methods it calls.
// All DNS queries coming from outside, as well as all requests to create
// or delete DNS records coming from within the process are serialized by
// the select statement.
func (srv *Server) main() {
srv.logger.Debug(
"main loop running",
zap.Int("record_count", len(srv.Records)),
)
for {
select {
case r := <-srv.requests:
srv.handle_request(r)
case q := <-srv.queries:
srv.handle_query(q)
case <-srv.shutdown:
srv.logger.Debug("stopping main loop")
if srv.dns_server != nil {
srv.dns_server.Shutdown()
}
return
}
}
}
func (srv *Server) handle_request(r request) {
srv.logger.Debug("received", zap.Object("request", r))
if r.append {
for _, record := range r.records {
srv.insert_record(record)
}
} else {
for _, record := range r.records {
srv.delete_record(record)
}
}
r.responder <- srv.start_stop_server()
}
func (srv *Server) start_stop_server() error {
if srv.queries == nil {
srv.queries = make(chan query)
}
if len(srv.Records) == 0 {
if srv.dns_server != nil {
srv.logger.Debug("no more records to serve, shutting down server")
err := srv.dns_server.Shutdown()
srv.dns_server = nil
return err
}
srv.logger.Debug("no records to serve")
return nil
} else {
if srv.dns_server == nil {
conn, err := srv.bind()
if err != nil {
srv.logger.Error(
"failed to bind",
zap.Stringer("address", srv.Address),
zap.Error(err),
)
return err
}
// spawn the server
handler := make_proxy(srv.queries)
server := &dns.Server{
PacketConn: conn,
Net: "udp",
Handler: handler,
TsigSecret: nil,
}
srv.logger.Debug(
"starting server",
zap.Int("record_count", len(srv.Records)),
)
go srv.serve(server)
// store the server for shutdown later
srv.dns_server = server
return nil
}
srv.logger.Debug(
"server already running",
zap.Int("record_count", len(srv.Records)),
)
return nil
}
}
func (srv *Server) bind() (net.PacketConn, error) {
conn, err := srv.Address.Listen(srv.ctx, 0, net.ListenConfig{})
if err != nil {
return nil, err
}
pkt_conn := conn.(net.PacketConn)
if pkt_conn == nil {
return nil, errors.New("invalid address")
}
srv.logger.Debug("bound to socket", zap.Stringer("address", srv.Address))
return pkt_conn, nil
}
func (srv *Server) handle_query(q query) {
// dns.DefaultMsgAcceptFunc already checks that the query is fairly
// reasonable.
m := new(dns.Msg)
m.SetReply(q.r)
reject_and_log := func(code int, reason string) {
m.Rcode = code
m.Answer = []dns.RR{}
srv.logger.Debug(
"rejecting query",
zap.Stringer("address", q.w.RemoteAddr()),
zap.String("reason", reason),
zap.Object("response", LoggableDNSMsg{m}),
)
q.w.WriteMsg(m)
}
qstn := q.r.Question[0]
if !(qstn.Qclass == dns.ClassINET || qstn.Qclass == dns.ClassANY) {
// TODO: consider just not worrying about this
reject_and_log(dns.RcodeNotImplemented, "invalid class")
return
}
// queries may be wAcKY casE
// https://datatracker.ietf.org/doc/html/draft-vixie-dnsext-dns0x20-00
key := key {
Type: dns.Type(qstn.Qtype),
Name: strings.ToLower(qstn.Name),
}
records, exists := srv.Records[key]
if !exists {
reject_and_log(dns.RcodeNameError, "no such record")
return
}
m.Authoritative = true
m.Answer = records
srv.logger.Debug(
"answering query",
zap.Stringer("address", q.w.RemoteAddr()),
zap.Object("response", LoggableDNSMsg{m}),
)
q.w.WriteMsg(m)
}
func (srv *Server) serve(server *dns.Server) {
err := server.ActivateAndServe()
if err != nil {
srv.logger.Error("dns.ActivateAndServe failed", zap.Error(err))
} else {
srv.logger.Debug("server terminated successfully")
}
}
// dns.HandlerFunc that forwards every query into a channel
func make_proxy(sink chan query) dns.HandlerFunc {
return func(w dns.ResponseWriter, r *dns.Msg) {
q := query{w, r}
sink <- q
}
}

247
stub.go
View file

@ -1,230 +1,47 @@
package stub
import (
"context"
"net"
"strings"
"strconv"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/mholt/acmez"
"github.com/mholt/acmez/acme"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/libdns/libdns"
"github.com/miekg/dns"
)
// TTL of the challenge TXT record to serve
const challenge_ttl = 600 // (anything is probably fine here)
type StubDNS struct {
// the address & port on which to serve DNS for the challenge
Address string `json:"address,omitempty"`
server *dns.Server // set in Present()
logger *zap.Logger // set in Provision()
// An in-process request to create or delete a DNS record
type request struct {
append bool
zone string
records []dns.RR
responder chan error
}
func init() {
caddy.RegisterModule(StubDNS{})
caddy.RegisterModule(App{})
caddy.RegisterModule(Provider{})
httpcaddyfile.RegisterGlobalOption("dns", parseApp)
}
// CaddyModule returns the Caddy module information.
func (StubDNS) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "dns.providers.stub_dns",
New: func() caddy.Module { return &StubDNS{} },
func record_to_rr(zone string, record libdns.Record) (dns.RR, error) {
maybe_priority := ""
if record.Priority != 0 {
maybe_priority += strconv.FormatInt(int64(record.Priority), 10)
maybe_priority += " "
}
//TODO: consider fixing this with dns.StringToType & dns.TypeToRR
// Problem is putting the value in, since it will be a different field
// for every type.
// Also, will probably require parsing the value anyway (e.g. to net.IP)
//TODO: does the value need to be escaped?!
return dns.NewRR(
dns.Fqdn(record.Name+"."+zone) +
" " +
strconv.FormatInt(int64(record.TTL.Seconds()), 10) +
" IN " +
record.Type +
" " +
maybe_priority +
record.Value)
}
// Provision sets up the module. Implements caddy.Provisioner.
func (p *StubDNS) Provision(ctx caddy.Context) error {
p.logger = ctx.Logger()
repl := caddy.NewReplacer()
before := p.Address
p.Address = repl.ReplaceAll(p.Address, "")
p.logger.Debug(
"provisioned",
zap.String("address", p.Address),
zap.String("address_before_replace", before),
)
return nil
}
// UnmarshalCaddyfile sets up the DNS provider from Caddyfile tokens. Syntax:
//
// stub_dns [address] {
// address <address>
// }
func (s *StubDNS) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
if d.NextArg() {
s.Address = d.Val()
}
if d.NextArg() {
return d.ArgErr()
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "address":
if s.Address != "" {
return d.Err("Address already set")
}
if d.NextArg() {
s.Address = d.Val()
}
if d.NextArg() {
return d.ArgErr()
}
default:
return d.Errf("unrecognized subdirective '%s'", d.Val())
}
}
}
if s.Address == "" {
return d.Err("missing Address")
}
return nil
}
func (s *StubDNS) Present(ctx context.Context, challenge acme.Challenge) error {
// get challenge parameters
fqdn := dns.Fqdn(challenge.DNS01TXTRecordName())
content := challenge.DNS01KeyAuthorization()
s.logger.Debug(
"presenting record",
zap.String("name", fqdn),
zap.String("content", content),
zap.String("address", s.Address),
)
// dns.Server.ListenAndServe blocks when it binds successfully,
// so it has to run in a separate task and can't return errors directly
if err := try_bind(ctx, s.Address); err != nil {
s.logger.Error(
"failed to bind",
zap.String("address", s.Address),
zap.Error(err),
)
return err
}
// spawn the server
handler := s.make_handler(fqdn, content)
server := &dns.Server{
Addr: s.Address,
Net: "udp",
Handler: handler,
TsigSecret: nil,
}
go s.serve(server)
// store the server for shutdown later
s.server = server
return nil
}
func (p *StubDNS) CleanUp(ctx context.Context, _ acme.Challenge) error {
if p.server == nil {
p.logger.Debug("server never started, nothing to clean up")
return nil
} else {
p.logger.Debug(
"shutting down DNS server",
zap.String("address", p.Address),
)
return p.server.ShutdownContext(ctx)
}
}
// quickly check whether it's possible to bind to the address
func try_bind(ctx context.Context, address string) error {
var lc net.ListenConfig
conn, err := lc.ListenPacket(ctx, "udp", address)
if conn != nil {
return conn.Close()
}
return err
}
func (s *StubDNS) serve(server *dns.Server) {
err := server.ListenAndServe()
if err != nil {
s.logger.Error(
"DNS ListenAndServe returned an error!",
zap.Error(err),
)
} else {
s.logger.Debug("server terminated successfully")
}
}
func (s *StubDNS) make_handler(fqdn string, txt string) dns.HandlerFunc {
logger := s.logger
handler := func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
logger.Debug(
"received DNS query",
zap.Stringer("address", w.RemoteAddr()),
zap.Object("request", LoggableDNSMsg{r}),
)
reject_and_log := func(code int, reason string) {
m.Rcode = code
m.Answer = []dns.RR{}
logger.Debug(
"rejecting query",
zap.String("reason", reason),
zap.Object("response", LoggableDNSMsg{m}),
)
w.WriteMsg(m)
}
if len(r.Question) != 1 {
reject_and_log(dns.RcodeRefused, "not exactly 1 question")
return
}
q := r.Question[0]
domain := q.Name
switch {
case r.Response:
reject_and_log(dns.RcodeRefused, "not a query")
case !(q.Qclass == dns.ClassINET || q.Qclass == dns.ClassANY):
reject_and_log(dns.RcodeNotImplemented, "invalid class")
// queries may be wAcKY casE
// https://datatracker.ietf.org/doc/html/draft-vixie-dnsext-dns0x20-00
case !strings.EqualFold(domain, fqdn):
reject_and_log(dns.RcodeNameError, "wrong domain")
case q.Qtype != dns.TypeTXT:
reject_and_log(dns.RcodeRefused, "invalid type")
default:
m.Authoritative = true
rr := new(dns.TXT)
rr.Hdr = dns.RR_Header{
Name: fqdn, // only question section has to match wAcKY casE
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: uint32(challenge_ttl),
}
rr.Txt = []string{txt}
m.Answer = []dns.RR{rr}
logger.Debug(
"replying",
zap.Object("response", LoggableDNSMsg{m}),
)
w.WriteMsg(m)
}
}
return handler
}
// Interface guards
var (
_ acmez.Solver = (*StubDNS)(nil)
_ caddy.Provisioner = (*StubDNS)(nil)
_ caddyfile.Unmarshaler = (*StubDNS)(nil)
)