move upstream project (https://github.com/itsxaos/stub) into subdir

This commit is contained in:
saces 2026-02-15 13:13:05 +01:00
parent e36782c04b
commit 99a5e07224
11 changed files with 0 additions and 0 deletions

3
caddyapp/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/caddy
*.sublime-workspace
*.sublime-project

22
caddyapp/LICENSE Normal file
View file

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright © 2023 xaos
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

183
caddyapp/README.md Normal file
View file

@ -0,0 +1,183 @@
Stub DNS module for Caddy
=========================
> **Warning**
>
> This is alpha-quality, at best. It is not ready for production use. Use at your own risk!
The easiest way to get a wildcard certificate with Caddy: almost as *magical* as the automatic [HTTP challenge](https://caddyserver.com/docs/automatic-https#http-challenge)!
This module **does not require any DNS API**; it simply serves the DNS itself.
You just need to set up your DNS *once* to delegate all queries for the `_acme-challenge` zone to the IP where Caddy is running.
From then on, Caddy can fulfill all DNS challenges itself by opening a temporary DNS server.
Basically, by redirecting the DNS challenge queries to the host itself, the DNS challenge can be solved just like the [HTTP challenge](https://caddyserver.com/docs/automatic-https#http-challenge).
Pros:
- no DNS API required
- no credentials stored on the device
- no need to connect to another server to complete the challenge
- no propagation delays
- no separate DNS server process to manage
- server only listens for DNS queries when it has records to serve
- can also serve arbitrary DNS records defined in the configuration
Cons:
- requires one additional DNS record in the requested zone
- UDP port 53 needs to be exposed & externally accessible (or port 53 on another host forwarded to it)
- ACME CA (i.e. Let's Encrypt) needs to connect to your server (like the [HTTP](https://caddyserver.com/docs/automatic-https#http-challenge) & [TLS-ALPN](https://caddyserver.com/docs/automatic-https#tls-alpn-challenge) challenge)
- can't have another public DNS server running on the same IP (see [below](#already-running-a-dns-server))
- can't have multiple Caddies running on different hosts authenticate for the same domain
## Limitations & Bugs
- non-`IN` class records are not supported
- no DNSSEC or EDNS
- UDP-only
- currently, only one DNS server can be defined, and it can only listen on a single address
- not optimized
- reloading / changing the configuration while attempting to solve the DNS challenge will probably cause it to fail
Currently, solving the DNS challenge seems to require disabling the propagation checks.
This may or may not be a bug with the implementation.
In any case, there is no reason to wait & check for propagation, since the server will start listening immediately.
A potentially serious issue exists with the way records are parsed from the configuration (Caddyfile & JSON): because it is possible to `$INCLUDE` a *file*, it may be possible for an attacker *who has control over the configuration* (and perhaps even a rogue ACME CA, though this is seems unlikely) to get Caddy to read files it shouldn't.
Parts of those files could then be exposed in the Caddy logs and, if they happen to look like zone files, get served over DNS.
## Required DNS Record for the ACME challenge
To solve the ACME DNS challenge, the DNS zone needs to be set up to direct all DNS queries for the `_acme-challenge` subdomain of the zone you're trying to authenticate to the server that's running Caddy.
This should be pretty simple, but you still need to be careful and make sure **only** queries for the `_acme-challenge` subdomain get sent to your server.
Otherwise, you would need to configure the DNS app to serve all the records for your zone.
So, let's say you have this record (could be an `AAAA` record too) wherever you bought your domain/host your DNS:
```
example.com. A 192.0.2.123
```
Simply create a record like:
```
_acme-challenge.example.com. NS example.com.
```
And you're done!
Since DNS can be a little confusing, here's a quick recap of what this means.
The first record means "if you want to *connect to* `example.com`, go to `192.0.2.123`".
You need a record like this for clients (i.e. a web browser) to connect to your website, and you've probably set this up already.
The second record, which you need to add for this module to work, means "if you want to *look up a domain* in `_acme-challenge.example.com.`, then you need to ask the nameserver running at `example.com`".
This will cause the client (e.g. Let's Encrypt or another ACME CA) to look up the first record as well, since it now knows it has to *connect to* `example.com` (though not to make an HTTP request like your browser would) to complete the query, and then it will get the `TXT` record for the challenge directly from your server.
## Configuration
The provider does not require any configuration value, but it might be necessary to configure the server with the (local) IP address and port to serve the DNS on.
If no address is configured, the server will default to `:53`.
In this case, when the IP is not specified, DNS will be served on *all* of the addresses assigned to the machine, and this may work for you.
However, many systems already have a DNS server running for local use: for instance, [systemd-resolved](https://wiki.archlinux.org/title/Systemd-resolved) listens on `127.0.0.53` by default.
In this case, it will not be possible to bind to the wildcard address, since it would overlap with systemd-resolved, and the provider will fail with `bind: address already in use`.
To avoid this, specify the (externally accessible) IP address you want to use.
For the port, you'll need to use `53` since that is the DNS port, and that's where Let's Encrypt (or whatever ACME CA you use) will query for the challenge.
Still, this isn't hard-coded to allow for more complicated setups and forwarding.
Note: It is technically possible to specify a protocol before the address (as in `udp/127.0.0.1:53`).
Do not do this.
Only UDP is supported, specifying other protocols will either cause an error, or worse, get silently ignored.
### Already running a DNS server?
If you're already hosting a DNS server on the machine that's running Caddy (and you don't want to make Caddy serve all its records), you'll need to do some additional configuration.
The issue essentially boils down to not having enough IP addresses to host DNS on (you can create as many subdomains as you like, but they all have to point to an IP address in the end), and it can be resolved in two ways.
The first and arguably *cleaner* solution is to figure out a way to get your DNS server to forward / recurse queries for the `_acme-challenge` subdomain to some internal address & port and have this module listen on that.
Note though that DNS has two kinds of "forwarding": one where the server will tell the client where to go to make their query ("iterative") and one where the server will do it on behalf of the client ("recursive").
Obviously, if you use an internal address that the client (e.g. LE) can't reach itself, you'll need to get your server to do the second kind.
If you manage to get this to work, please let people know how!
The second solution is to just use IPv6 since you probably have tons of IPv6 addresses you can use anyway, and Let's Encrypt has supported it for many years.
This is (arguably, again) less clean than the first because you'll need to set up another `NS` record and also an `AAAA` record to point it to, but it may be easier if you already have a reasonable setup for IPv6 (e.g. firewall rules).
## Caddy module names
App:
```
dns
```
Provider:
```
dns.providers.internal
```
## Config examples
### ACME DNS Provider
To use this module for the ACME DNS challenge, [configure the ACME issuer in your Caddy JSON](https://caddyserver.com/docs/json/apps/tls/automation/policies/issuer/acme/) like so:
```json
{
"module": "acme",
"challenges": {
"dns": {
"provider": {
"name": "internal"
}
}
}
}
```
or with the Caddyfile:
```
# one site
tls {
dns internal
# disable propagation checks, may be necessary
propagation_timeout -1
}
```
Unlike other providers, global configuration with `acme_dns` does *not* work!
### DNS Server
The server can be configured with an address to bind to, and records to serve.
Both are optional.
```json
{
"apps": {
"dns": {
"address": "192.0.2.123:53",
"records": [
"example.com.\t3600\tIN\tA\t192.0.2.123"
]
}
}
}
```
Or, in the [global options](https://caddyserver.com/docs/caddyfile/options) block at the beginning of the Caddyfile:
```
{
dns 192.0.2.123:53
}
```
And to define a record to serve:
```
{
dns 192.0.2.123:53 {
record "example.com. A 192.0.2.123"
}
}
```
The syntax for defining records is pretty straight-forward, at least for simple record types.
It uses the [`miekg/dns.NewRR()`](https://pkg.go.dev/github.com/miekg/dns#NewRR) function to parse the definitions.
The linked page has (some) more information: "full [zone file](https://en.wikipedia.org/wiki/Zone_file) syntax" is supported.

188
caddyapp/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)
)

137
caddyapp/go.mod Normal file
View file

@ -0,0 +1,137 @@
module github.com/itsxaos/stub
go 1.20
require (
github.com/caddyserver/caddy/v2 v2.6.4
github.com/libdns/libdns v0.2.1
github.com/miekg/dns v1.1.50
go.uber.org/zap v1.24.0
)
require (
filippo.io/edwards25519 v1.0.0 // indirect
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.0 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/Microsoft/go-winio v0.6.0 // indirect
github.com/alecthomas/chroma/v2 v2.5.0 // indirect
github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 // indirect
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/caddyserver/certmagic v0.17.2 // indirect
github.com/cenkalti/backoff/v4 v4.1.2 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/dgraph-io/badger v1.6.2 // indirect
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/fxamacker/cbor/v2 v2.4.0 // indirect
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-kit/kit v0.10.0 // indirect
github.com/go-logfmt/logfmt v0.5.1 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
github.com/golang/glog v1.0.0 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/cel-go v0.13.0 // indirect
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/huandu/xstrings v1.3.3 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.13.0 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.12.0 // indirect
github.com/jackc/pgx/v4 v4.17.2 // indirect
github.com/klauspost/compress v1.15.15 // indirect
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-isatty v0.0.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/mholt/acmez v1.1.0 // indirect
github.com/micromdm/scep/v2 v2.1.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/onsi/ginkgo/v2 v2.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/quic-go/qpack v0.4.0 // indirect
github.com/quic-go/qtls-go1-18 v0.2.0 // indirect
github.com/quic-go/qtls-go1-19 v0.2.0 // indirect
github.com/quic-go/qtls-go1-20 v0.1.0 // indirect
github.com/quic-go/quic-go v0.32.0 // indirect
github.com/rs/xid v1.4.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/slackhq/nebula v1.6.1 // indirect
github.com/smallstep/certificates v0.23.2 // indirect
github.com/smallstep/nosql v0.5.0 // indirect
github.com/smallstep/truststore v0.12.1 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/cobra v1.6.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
github.com/tailscale/tscert v0.0.0-20230124224810-c6dc1f4049b2 // indirect
github.com/urfave/cli v1.22.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yuin/goldmark v1.5.4 // indirect
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20220924101305-151362477c87 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.39.0 // indirect
go.opentelemetry.io/otel v1.13.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.4.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.4.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.4.0 // indirect
go.opentelemetry.io/otel/metric v0.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.13.0 // indirect
go.opentelemetry.io/otel/trace v1.13.0 // indirect
go.opentelemetry.io/proto/otlp v0.12.0 // indirect
go.step.sm/cli-utils v0.7.5 // indirect
go.step.sm/crypto v0.23.2 // indirect
go.step.sm/linkedca v0.19.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect
golang.org/x/mod v0.6.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/term v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/tools v0.2.0 // indirect
google.golang.org/genproto v0.0.0-20230202175211-008b39050e57 // indirect
google.golang.org/grpc v1.52.3 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v1.0.0 // indirect
)

1168
caddyapp/go.sum Normal file

File diff suppressed because it is too large Load diff

249
caddyapp/log.go Normal file
View file

@ -0,0 +1,249 @@
package stub
import (
"github.com/libdns/libdns"
"github.com/miekg/dns"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// Wrapper for logging (relevant parts of) dns.Msg
type LoggableDNSMsg struct{ *dns.Msg }
// MarshalLogObject satisfies the zapcore.ObjectMarshaler interface.
func (m LoggableDNSMsg) MarshalLogObject(enc zapcore.ObjectEncoder) error {
// adapted version of MsgHdr.String() from github.com/miekg/dns
enc.AddUint16("id", m.Id)
enc.AddString("opcode", dns.OpcodeToString[m.Opcode])
enc.AddString("status", dns.RcodeToString[m.Rcode])
flag_array := func(arr zapcore.ArrayEncoder) error {
if m.Response {
arr.AppendString("qr")
}
if m.Authoritative {
arr.AppendString("aa")
}
if m.Truncated {
arr.AppendString("tc")
}
if m.RecursionDesired {
arr.AppendString("rd")
}
if m.RecursionAvailable {
arr.AppendString("ra")
}
if m.Zero {
arr.AppendString("z")
}
if m.AuthenticatedData {
arr.AppendString("ad")
}
if m.CheckingDisabled {
arr.AppendString("cd")
}
return nil
}
enc.AddArray("flags", zapcore.ArrayMarshalerFunc(flag_array))
log_questions(enc, &m.Question)
log_answers(enc, &m.Answer)
// not logged:
// - EDNS0 "OPT pseudosection" from m.IsEdns0()
// - "authority section" in m.Ns
// - "additional section" in m.Extra
return nil
}
func log_answers(enc zapcore.ObjectEncoder, answers *[]dns.RR) {
if len(*answers) > 0 {
array := func(arr zapcore.ArrayEncoder) error {
for _, r := range *answers {
object := func(obj zapcore.ObjectEncoder) error {
log_RR(obj, r)
return nil
}
arr.AppendObject(zapcore.ObjectMarshalerFunc(object))
}
return nil
}
enc.AddArray("answer", zapcore.ArrayMarshalerFunc(array))
}
}
func log_questions(enc zapcore.ObjectEncoder, questions *[]dns.Question) {
if len(*questions) > 0 {
array := func(arr zapcore.ArrayEncoder) error {
for _, q := range *questions {
object := func(obj zapcore.ObjectEncoder) error {
obj.AddString("name", q.Name)
obj.AddString("class", dns.ClassToString[q.Qclass])
obj.AddString("type", dns.TypeToString[q.Qtype])
return nil
}
arr.AppendObject(zapcore.ObjectMarshalerFunc(object))
}
return nil
}
enc.AddArray("question", zapcore.ArrayMarshalerFunc(array))
}
}
// Only logs the "content"/values of the RR for common types
func log_RR(enc zapcore.ObjectEncoder, rr dns.RR) {
hdr := rr.Header()
enc.AddString("name", hdr.Name)
enc.AddString("class", dns.ClassToString[hdr.Class])
enc.AddString("type", dns.TypeToString[hdr.Rrtype])
enc.AddUint32("TTL", hdr.Ttl)
switch r := rr.(type) {
case *dns.A:
enc.AddString("A", r.A.String())
case *dns.AAAA:
enc.AddString("AAAA", r.AAAA.String())
case *dns.AFSDB:
// case *dns.AMTRELAY:
case *dns.ANY: // empty
case *dns.APL:
case *dns.AVC:
case *dns.CAA:
enc.AddUint8("flag", r.Flag)
enc.AddString("tag", r.Tag)
enc.AddString("value", r.Value)
case *dns.CDNSKEY:
case *dns.CDS:
case *dns.CERT:
case *dns.CNAME:
enc.AddString("target", r.Target)
case *dns.CSYNC:
case *dns.DHCID:
case *dns.DLV:
case *dns.DNAME:
enc.AddString("target", r.Target)
case *dns.DNSKEY:
case *dns.DS:
case *dns.EID:
case *dns.EUI48:
case *dns.EUI64:
case *dns.GID:
case *dns.GPOS:
case *dns.HINFO:
case *dns.HIP:
case *dns.HTTPS:
// case *dns.IPSECKEY:
case *dns.KEY:
case *dns.KX:
case *dns.L32:
case *dns.L64:
case *dns.LOC:
case *dns.LP:
case *dns.MB:
case *dns.MD:
case *dns.MF:
case *dns.MG:
case *dns.MINFO:
case *dns.MR:
case *dns.MX:
enc.AddString("MX", r.Mx)
enc.AddUint16("preference", r.Preference)
case *dns.NAPTR:
case *dns.NID:
case *dns.NIMLOC:
case *dns.NINFO:
case *dns.NS:
enc.AddString("NS", r.Ns)
case *dns.NSAPPTR:
case *dns.NSEC:
case *dns.NSEC3:
case *dns.NSEC3PARAM:
case *dns.NULL:
case *dns.OPENPGPKEY:
enc.AddString("public_key", r.PublicKey)
case *dns.OPT:
case *dns.PTR:
enc.AddString("PTR", r.Ptr)
case *dns.PX:
case *dns.RKEY:
case *dns.RP:
case *dns.RRSIG:
case *dns.RT:
case *dns.SIG:
case *dns.SMIMEA:
case *dns.SOA:
enc.AddString("NS", r.Ns)
enc.AddString("mbox", r.Mbox)
enc.AddUint32("serial", r.Serial)
enc.AddUint32("retry", r.Retry)
enc.AddUint32("refresh", r.Refresh)
enc.AddUint32("expire", r.Expire)
enc.AddUint32("minttl", r.Minttl)
case *dns.SPF:
zap.Strings("TXT", r.Txt).AddTo(enc)
case *dns.SRV:
enc.AddUint16("priority", r.Priority)
enc.AddUint16("weight", r.Weight)
enc.AddUint16("port", r.Port)
enc.AddString("target", r.Target)
case *dns.SSHFP:
enc.AddUint8("algorithm", r.Algorithm)
enc.AddUint8("type", r.Type)
enc.AddString("fingerprint", r.FingerPrint)
case *dns.SVCB:
case *dns.TA:
case *dns.TALINK:
case *dns.TKEY:
case *dns.TLSA:
case *dns.TSIG:
case *dns.TXT:
zap.Strings("TXT", r.Txt).AddTo(enc)
case *dns.UID:
case *dns.UINFO:
case *dns.URI:
case *dns.X25:
case *dns.ZONEMD:
default:
}
}
func log_libdns_record(record *libdns.Record) zapcore.ObjectMarshaler {
f := func(enc zapcore.ObjectEncoder) error {
enc.AddString("ID", record.ID)
enc.AddString("type", record.Type)
enc.AddString("name", record.Name)
enc.AddString("value", record.Value)
enc.AddString("TTL", record.TTL.String())
if record.Priority != 0 {
enc.AddInt("priority", record.Priority)
}
return nil
}
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
caddyapp/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)
)

260
caddyapp/server.go Normal file
View file

@ -0,0 +1,260 @@
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) bool {
key := rr_key(record)
current, exists := srv.Records[key]
if exists {
filtered := []dns.RR{}
as_string := record.String()
for _, rec := range current {
// TODO: see if there is a more efficient way to compare these
// just rec != record does not seem to work, might be doing ptr eq
if rec.String() != as_string {
filtered = append(filtered, rec)
}
}
if len(filtered) == 0 {
delete(srv.Records, key)
} else {
srv.Records[key] = filtered
}
return len(filtered) < len(current)
} else {
// doesn't exist, nothing to delete
return false
}
}
// 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) {
var count_field zap.Field
if r.append {
for _, record := range r.records {
srv.insert_record(record)
}
count_field = zap.Int("appended_records", len(r.records))
} else {
count := 0
for _, record := range r.records {
if srv.delete_record(record) {
count += 1
}
}
count_field = zap.Int("deleted_records", count)
}
srv.logger.Debug("handled", zap.Object("request", r), count_field)
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
}
}

352
caddyapp/server_test.go Normal file
View file

@ -0,0 +1,352 @@
package stub
import (
"math/rand"
"reflect"
"strings"
"testing"
"time"
"unicode"
"github.com/caddyserver/caddy/v2/caddytest"
"github.com/miekg/dns"
)
const dns_address string = "127.0.0.1:53535"
const dns_only string = `{
admin localhost:2999
debug
dns 127.0.0.1:53535 {
record "sub123.example.com. A 127.0.0.1"
record "ABC123. AAAA ::"
record "example.com. CAA 0 issue ca.example.net"
record "example.com. CNAME test123"
record "example.com. 3333 IN NS ns1.example.com."
record "example.com. MX 42 mx.example.com."
record "_caddy._tcp.example.com. SRV 3 33 2999 test123."
record "txt123.example.com. TXT Test123"
record "whitespace. TXT Test 123 ABC XYZ"
}
}
`
const dns_only_json string = `{
"admin": {
"listen": "localhost:2999"
},
"logging": {
"logs": {
"default": {
"level": "DEBUG"
}
}
},
"apps": {
"dns": {
"address": "127.0.0.1:53535",
"records": [
"sub123.example.com.\t3600\tIN\tA\t127.0.0.1",
"ABC123.\t3600\tIN\tAAAA\t::",
"example.com.\t3600\tIN\tCAA\t0 issue \"ca.example.net\"",
"example.com.\t3600\tIN\tCNAME\ttest123.",
"example.com.\t3333\tIN\tNS\tns1.example.com.",
"example.com.\t3600\tIN\tMX\t42 mx.example.com.",
"_caddy._tcp.example.com.\t3600\tIN\tSRV\t3 33 2999 test123.",
"txt123.example.com.\t3600\tIN\tTXT\t\"Test123\"",
"whitespace.\t3600\tIN\tTXT\t\"Test\" \"123\" \"ABC\" \"XYZ\""
]
}
}
}`
const dns_but_empty string = `{
admin localhost:2999
debug
dns 127.0.0.1:53535
}
`
const dns_but_empty_json string = `{
"admin": {
"listen": "localhost:2999"
},
"logging": {
"logs": {
"default": {
"level": "debug"
}
}
},
"apps": {
"dns": {
"address": "127.0.0.1:53535"
}
}
}`
func TestDNSConfig(t *testing.T) {
caddytest.AssertAdapt(t, dns_only, "caddyfile", dns_only_json)
}
func check_exists(t *testing.T, record string) {
rr, err := dns.NewRR(record)
if err != nil {
t.Fatal("invalid record; ", record, "\nerror:", err)
}
in := query_dns(t, rr.Header().Name, rr.Header().Rrtype)
if in.MsgHdr.Rcode != dns.RcodeSuccess {
t.Fatal("DNS error: ", dns.RcodeToString[in.MsgHdr.Rcode])
}
expected := rr.String()
received := in.Answer[0].String()
if expected != received {
t.Fatal(
"record mismatch!",
"\nexpected: ",
expected,
"\nreceived: ",
received,
)
}
// Gives different results than the string comparison. unclear why
//if !reflect.DeepEqual(in.Answer[0], rr) {
// t.Fatal("answer section mismatch!")
//}
}
func query_dns(t *testing.T, name string, qtype uint16) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(name, qtype)
c := new(dns.Client)
c.DialTimeout = 1 * time.Second
in, rtt, err := c.Exchange(m, dns_address)
_ = rtt
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(in.Question, m.Question) {
t.Fatal("question section mismatch!")
}
return in
}
func wAcKY_casE(input string) string {
INPUT := strings.ToUpper(input)
InPuT := ""
for i := range input {
if rand.Intn(2) == 1 {
InPuT = InPuT + INPUT[i:i]
} else {
InPuT = InPuT + input[i:i]
}
}
return InPuT
}
// https://datatracker.ietf.org/doc/html/draft-vixie-dnsext-dns0x20-00
func qUeRY_dNs(t *testing.T, name string, qtype uint16) *dns.Msg {
runes := []rune{}
for _, c := range name {
if rand.Intn(2) == 1 {
runes = append(runes, unicode.ToUpper(c))
} else {
runes = append(runes, c)
}
}
nAmE := string(runes)
println("before: ", name, "\nafter: ", nAmE)
m := new(dns.Msg)
m.SetQuestion(nAmE, qtype)
c := new(dns.Client)
c.DialTimeout = 1 * time.Second
in, rtt, err := c.Exchange(m, dns_address)
_ = rtt
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(in.Question, m.Question) {
t.Fatal(
"question section mismatch!",
"\nsent: ",
m.Question,
"\nreceived: ",
in.Question,
)
}
return in
}
func cHEcK_eXiSTs(t *testing.T, record string) {
rr, err := dns.NewRR(record)
if err != nil {
t.Fatal("invalid record; ", record, "\nerror:", err)
}
in := qUeRY_dNs(t, rr.Header().Name, rr.Header().Rrtype)
if in.MsgHdr.Rcode != dns.RcodeSuccess {
t.Fatal("DNS error: ", dns.RcodeToString[in.MsgHdr.Rcode])
}
expected := rr.String()
received := in.Answer[0].String()
if expected != received {
t.Fatal(
"record mismatch!",
"\nexpected: ",
expected,
"\nreceived: ",
received,
)
}
}
func check_errors(t *testing.T, m *dns.Msg, rcode int) {
c := new(dns.Client)
c.DialTimeout = 100 * time.Millisecond
in, rtt, err := c.Exchange(m, dns_address)
_ = rtt
if err != nil {
t.Fatal(err, "\n", m)
}
/*
if !reflect.DeepEqual(in.Question, m.Question) {
t.Fatal(
"question section mismatch!",
"\nsent: ",
m.Question,
"\nreceived: ",
in.Question,
)
}
*/
if in.Rcode != rcode {
t.Fatal(
"rcode mismatch:",
"\nexpected: ",
rcode,
"\nreceived: ",
in.Rcode,
"\nquery:\n",
m,
)
}
}
func check_fails(t *testing.T, m *dns.Msg, error_contains string) {
c := new(dns.Client)
c.DialTimeout = 100 * time.Millisecond
in, rtt, err := c.Exchange(m, dns_address)
_ = rtt
if in != nil {
t.Fatal(
"query was expected to fail with: \"",
error_contains,
"\", but returned response!\n",
in.String(),
)
}
if err == nil {
t.Fatal(
"query was expected to fail with: \"",
error_contains,
"\", but did not return an error!",
)
}
if !strings.Contains(err.Error(), error_contains) {
// TODO: this might be flaky
t.Fatal(
"query was expected to fail with: \"",
error_contains,
"\", but returned error: ",
err,
)
}
}
func TestServer(t *testing.T) {
// I guess I have to seed my own RNG like a caveman
rand.Seed(time.Now().UnixNano())
caddytest.Default.TestRequestTimeout = 1 * time.Second
caddytest.Default.LoadRequestTimeout = 1 * time.Second
tester := caddytest.NewTester(t)
tester.InitServer(dns_only, "caddyfile")
records := []string{
"sub123.example.com. A 127.0.0.1",
"ABC123 AAAA ::",
"example.com. CAA 0 issue ca.example.net",
"example.com CNAME test123",
"example.com. 3333 IN NS ns1.example.com.",
"example.com. MX 42 mx.example.com.",
"_caddy._tcp.example.com. SRV 3 33 2999 test123.",
"txt123.example.com. TXT Test123",
"whitespace. TXT Test 123 ABC XYZ",
}
for _, record := range records {
check_exists(t, record)
/* WACKY-CASE QUERIES */
cHEcK_eXiSTs(t, record)
}
empty := new(dns.Msg)
check_errors(t, empty, dns.RcodeFormatError)
non_existent := new(dns.Msg)
non_existent.SetQuestion("does.not.exist.example.com.", dns.TypeA)
check_errors(t, non_existent, dns.RcodeNameError)
chaos := new(dns.Msg)
chaos.SetQuestion("sub123.example.com.", dns.TypeA)
chaos.Question[0].Qclass = dns.ClassCHAOS
check_errors(t, chaos, dns.RcodeNotImplemented)
/* IGNORE NON-QUESTIONS */
question := new(dns.Msg)
question.SetQuestion("sub123.example.com.", dns.TypeA)
not_a_question := new(dns.Msg)
not_a_question.SetReply(not_a_question)
answer, _ := dns.NewRR("sub123.example.com. A 127.0.0.1")
not_a_question.Answer = []dns.RR{answer}
check_fails(t, not_a_question, "timeout")
/* REFUSE MULTI-QUESTIONS */
multi := new(dns.Msg)
multi.SetQuestion("example.com.", dns.TypeCNAME)
q1 := multi.Question[0]
multi.SetQuestion("sub123.example.com.", dns.TypeA)
multi.Question = append(multi.Question, q1)
check_errors(t, multi, dns.RcodeFormatError)
}
// The server only listens for DNS queries if there are records to serve
func TestEmptyServer(t *testing.T) {
caddytest.Default.TestRequestTimeout = 1 * time.Second
caddytest.Default.LoadRequestTimeout = 1 * time.Second
tester := caddytest.NewTester(t)
tester.InitServer(dns_but_empty, "caddyfile")
question := new(dns.Msg)
question.SetQuestion("sub123.example.com.", dns.TypeA)
check_fails(t, question, "refused")
}

47
caddyapp/stub.go Normal file
View file

@ -0,0 +1,47 @@
package stub
import (
"strconv"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/libdns/libdns"
"github.com/miekg/dns"
)
// 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(App{})
caddy.RegisterModule(Provider{})
httpcaddyfile.RegisterGlobalOption("dns", parseApp)
}
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)
}