diff --git a/app.go b/app.go new file mode 100644 index 0000000..20a72d8 --- /dev/null +++ b/app.go @@ -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
+// [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
+// [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) +) diff --git a/log.go b/log.go index 3153020..750a60b 100644 --- a/log.go +++ b/log.go @@ -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 +} diff --git a/provider.go b/provider.go new file mode 100644 index 0000000..ec1871f --- /dev/null +++ b/provider.go @@ -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) +) diff --git a/server.go b/server.go new file mode 100644 index 0000000..6800751 --- /dev/null +++ b/server.go @@ -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 + } +} diff --git a/stub.go b/stub.go index 566763c..518d3aa 100644 --- a/stub.go +++ b/stub.go @@ -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
-// } -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) -)