Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 73 additions & 30 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import (
"path/filepath"
"strings"
"time"
"unicode"

"github.com/BurntSushi/toml"
"github.com/miekg/dns"
"github.com/semihalev/sdns/internal/dnsclient"
"github.com/semihalev/sdns/internal/dnsname"
"github.com/semihalev/zlog/v2"
)

Expand Down Expand Up @@ -51,36 +53,38 @@ type Config struct {
// ForwardZones route individual zones to their own upstreams. They are
// consulted before ForwarderServers, which remains the whole-server
// setting: a query matching no zone resolves normally.
ForwardZones []ForwardZoneConfig `toml:"forward_zone"`
AccessList []string
LogLevel string
AccessLog string
Bind Addrs
BindTLS Addrs
BindDOH Addrs
BindDOQ Addrs
TLSCertificate string
TLSPrivateKey string
API string
BearerToken string //nolint:gosec // G117 - not a hardcoded credential, loaded from config file
Nullroute string
Nullroutev6 string
HostsFile string
OutboundIPs []string
OutboundIP6s []string
Timeout Duration
QueryTimeout Duration
Expire uint32
CacheSize int
CachePersist bool `toml:"cache_persist"`
Prefetch uint32
Maxdepth int
RateLimit int
ClientRateLimit int
NSID string
Blocklist []string
Whitelist []string
Chaos bool
ForwardZones []ForwardZoneConfig `toml:"forward_zone"`
AccessList []string
LogLevel string
AccessLog string
Bind Addrs
BindTLS Addrs
BindDOH Addrs
BindDOQ Addrs
TLSCertificate string
TLSPrivateKey string
API string
BearerToken string //nolint:gosec // G117 - not a hardcoded credential, loaded from config file
Nullroute string
Nullroutev6 string
HostsFile string
HostsFileCheckNames bool `toml:"hostsfilechecknames"`
HostsFileZones []string `toml:"hostsfilezones"`
OutboundIPs []string
OutboundIP6s []string
Timeout Duration
QueryTimeout Duration
Expire uint32
CacheSize int
CachePersist bool `toml:"cache_persist"`
Prefetch uint32
Maxdepth int
RateLimit int
ClientRateLimit int
NSID string
Blocklist []string
Whitelist []string
Chaos bool
// QnameMinLevel is deprecated and retained so older configs keep
// parsing. It capped minimization by delegation depth rather than by
// the queries a lookup spends, which is what RFC 9156 section 2.3
Expand Down Expand Up @@ -345,6 +349,39 @@ func validDomainName(name string) bool {
return ok
}

// validHostsFileZone checks the DNS name structure used for hosts-file
// suffixes. These are generic DNS labels, not LDH host labels; only empty
// names, wildcard syntax, literal whitespace/control characters, and malformed
// or overlong DNS names are rejected. Decimal escapes may represent whitespace
// or control bytes. A trailing root dot is optional; "." names the root zone.
func validHostsFileZone(name string) bool {
if strings.IndexFunc(name, func(r rune) bool {
return unicode.IsSpace(r) || unicode.IsControl(r)
}) >= 0 {
return false
}
var wire [255]byte
packed, ok := dnsname.AppendWireName(wire[:0], name)
if !ok {
return false
}
// Inspect label contents after DNS escapes have been decoded. The length
// octet is not label content (and can itself equal the byte for '*').
for i := 0; i < len(packed); {
labelLen := int(packed[i])
if labelLen == 0 {
break
}
for _, b := range packed[i+1 : i+1+labelLen] {
if b == '*' {
return false
}
}
i += labelLen + 1
}
return true
}

// ForwardZoneFor returns the most specific configured forward zone covering
// qname, or nil when the query resolves normally. Most specific wins so a
// narrower zone can be pointed somewhere else than the one containing it.
Expand Down Expand Up @@ -966,6 +1003,12 @@ accesslist = [
# Leave empty to disable
hostsfile = ""

# Validate each hosts-file name as an ASCII DNS hostname before importing it
hostsfilechecknames = false

# DNS zones that limit which hosts-file names are imported
hostsfilezones = []


# ============================
# Performance and Limits
Expand Down
2 changes: 2 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,8 @@ func TestConfigDefaults(t *testing.T) {
"rfc9520 = true",
"serve_stale = false",
"serve_stale_max_ttl = \"24h\"",
"hostsfilechecknames = false",
"hostsfilezones = []",
"# Upstream Servers",
"# API and Logging",
"# Filtering and Blocking",
Expand Down
5 changes: 5 additions & 0 deletions config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,11 @@ func (c *Config) validateTrustAndIdentity(add func(string, ...any)) {
add("hostsfile = %q: %v", c.HostsFile, err)
}
}
for i, zone := range c.HostsFileZones {
if !validHostsFileZone(zone) {
add("hostsfilezones[%d] = %q: must be a valid DNS zone name", i, zone)
}
}

// Only read when the feature is on, so a stale source left behind by an
// operator who turned hyperlocal off does not stop the server.
Expand Down
96 changes: 96 additions & 0 deletions config/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"math/big"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -2214,6 +2215,101 @@ func TestValidateRelativeAndAbsoluteAreOnePlace(t *testing.T) {
}
}

func TestValidateHostsFileZones(t *testing.T) {
for _, tc := range []struct {
name string
zones []string
want string
}{
{name: "root", zones: []string{"."}},
{name: "uppercase unrooted", zones: []string{"Corp.Example"}},
{name: "uppercase rooted", zones: []string{"Corp.Example."}},
{name: "escaped dot rooted", zones: []string{"foo\\.."}},
{name: "escaped trailing dot unrooted", zones: []string{"foo\\."}},
{name: "unicode generic label", zones: []string{"münchen.example."}},
{name: "unicode ü label", zones: []string{"ü"}},
{name: "unicode ü child", zones: []string{"child.ü"}},
{name: "unicode generic label unrooted", zones: []string{"münchen.example"}},
{name: "unicode child label", zones: []string{"child.münchen.example"}},
{name: "escaped unicode bytes", zones: []string{`m\195\188nchen.example.`}},
{name: "escaped whitespace bytes", zones: []string{`example\032zone`}},
{name: "escaped control bytes", zones: []string{`example\001zone`}},
{name: "one backslash before dot", zones: []string{`\.example`}},
{name: "two backslashes before dot", zones: []string{`\\.example`}},
{name: "three backslashes before dot", zones: []string{`\\\.example`}},
{name: "four backslashes before dot", zones: []string{`\\\\.example`}},
{name: "63 octet label", zones: []string{strings.Repeat("a", 63) + ".example"}},
{name: "255 octet wire name", zones: []string{strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 61)}},
{name: "multiple zones", zones: []string{"example", "office.example.", "_services.example"}},
{name: "asterisk byte in label length", zones: []string{strings.Repeat("a", 42) + ".example"}},
{name: "invalid empty entry", zones: []string{"example.", ""}, want: "hostsfilezones[1]"},
{name: "invalid wildcard", zones: []string{"*.example"}, want: "hostsfilezones[0]"},
{name: "invalid wildcard character", zones: []string{"host*.example"}, want: "hostsfilezones[0]"},
{name: "invalid escaped wildcard label", zones: []string{"\\042.example"}, want: "hostsfilezones[0]"},
{name: "invalid escaped wildcard character", zones: []string{"host\\042.example"}, want: "hostsfilezones[0]"},
{name: "invalid repeated dots", zones: []string{"a..example"}, want: "hostsfilezones[0]"},
{name: "invalid whitespace", zones: []string{"example zone"}, want: "hostsfilezones[0]"},
{name: "invalid literal control", zones: []string{"example\x01zone"}, want: "hostsfilezones[0]"},
{name: "invalid long label", zones: []string{strings.Repeat("a", 64) + ".example"}, want: "hostsfilezones[0]"},
{name: "invalid long wire name", zones: []string{strings.Repeat(strings.Repeat("a", 62)+".", 4) + "abcd"}, want: "hostsfilezones[0]"},
} {
t.Run(tc.name, func(t *testing.T) {
err := (&Config{HostsFileZones: tc.zones}).Validate()
if tc.want == "" && err != nil {
t.Fatalf("Validate() rejected usable zones: %v", err)
}
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
t.Fatalf("Validate() = %v, want an error naming %q", err, tc.want)
}
})
}
}

func TestValidateHostsFileOptionsReportTogether(t *testing.T) {
err := (&Config{
DNSSEC: "maybe",
HostsFileZones: []string{"good.example", "*.bad.example", ""},
}).Validate()
if err == nil {
t.Fatal("Validate() accepted invalid hosts-file zones")
}
for _, want := range []string{"dnssec", "hostsfilezones[1]", "hostsfilezones[2]"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("Validate() = %v, missing a problem for %q", err, want)
}
}
}

func TestLoadHostsFileOptionsAndDefaults(t *testing.T) {
dir := t.TempDir()
load := func(body string) *Config {
t.Helper()
path := filepath.Join(dir, "sdns.conf")
body = fmt.Sprintf("version = %q\ndirectory = %q\nipv6access = true\ndnssec = \"off\"\nrootservers = [\"192.0.2.53:53\"]\n", configver, filepath.Join(dir, "db")) + body
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path, "test")
if err != nil {
t.Fatalf("Load() failed: %v", err)
}
return cfg
}

defaults := load("")
if defaults.HostsFileCheckNames || len(defaults.HostsFileZones) != 0 {
t.Fatalf("omitted hosts-file options = (%v, %q), want (false, [])", defaults.HostsFileCheckNames, defaults.HostsFileZones)
}

configured := load("hostsfilechecknames = true\nhostsfilezones = [\"Corp.Example.\", \"office.example\"]\n")
if !configured.HostsFileCheckNames {
t.Fatal("Load() lost hostsfilechecknames = true")
}
if want := []string{"Corp.Example.", "office.example"}; !reflect.DeepEqual(configured.HostsFileZones, want) {
t.Fatalf("HostsFileZones = %q, want %q", configured.HostsFileZones, want)
}
}

func TestValidateViewMode(t *testing.T) {
for _, mode := range []string{"", "overlay", "authoritative-owner", "typo", "Overlay", " overlay "} {
cfg := &Config{Views: []ViewConfig{{Zone: "office", Mode: mode}}}
Expand Down
6 changes: 6 additions & 0 deletions contrib/linux/sdns.conf
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,12 @@ accesslist = [
# Leave empty to disable
hostsfile = ""

# Validate each hosts-file name as an ASCII DNS hostname before importing it
hostsfilechecknames = false

# DNS zones that limit which hosts-file names are imported
hostsfilezones = []


# ============================
# Performance and Limits
Expand Down
35 changes: 31 additions & 4 deletions docs/_docs/configuration/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,39 @@ automatically.

```toml
hostsfile = "/etc/hosts"
hostsfilechecknames = true
hostsfilezones = ["home.arpa."]
```

Serves entries from a hosts file directly. Empty disables it. A name the file
lists with addresses of one family only gets NODATA for the other family; the
query is not sent upstream. For answers scoped to particular client networks
rather than served to everyone, use
Serves entries from a hosts file directly. Empty `hostsfile` disables it.
`hostsfilechecknames` and `hostsfilezones` are independent filters: the
first checks each name; the second limits names to configured DNS zones.
Setting `hostsfilechecknames = true` alone checks names without limiting their
zones. An empty zone list disables zone filtering. With both options omitted
or at their defaults, hosts-file importing keeps its existing behavior.

When name checking is enabled, SDNS accepts ASCII RFC 952/1123-style
hostname labels containing letters, digits, and hyphens. Each label must begin
and end with a letter or digit, and may contain hyphens between them. Labels
are limited to 63 octets and names to 255 octets on the DNS wire. A trailing
dot is optional. A leading `*.` is allowed for a wildcard entry. An invalid
name is skipped with a warning that identifies its file and line; it contributes no
forward answer or PTR record. For a line with aliases, each name is considered
individually. The first accepted normal name is promoted to the row's primary
name for CNAME and PTR processing. Equivalent names in one row are imported once
when either filter is enabled; addresses from separate rows still aggregate.
A surviving wildcard keeps the existing wildcard-row precedence and does not
create a PTR record.

`hostsfilezones` entries are DNS zones. Matching is label-aware and
case-insensitive: a zone matches its apex and subdomains, so `corp.example`
matches `printer.corp.example` but not `notcorp.example`. Root `.` and
names with or without a trailing dot are accepted. A name outside every
configured zone continues through normal resolution.

A name the file lists with addresses of one family only gets NODATA for the
other family; the query is not sent upstream. For answers scoped to particular
client networks rather than served to everyone, use
[views]({{ '/docs/features/views/' | relative_url }}).

## Per-domain metrics
Expand Down
5 changes: 4 additions & 1 deletion docs/_docs/reference/config-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ The page that explains each top-level key is listed under
| `block_aaaa` | `false` | Suppress normal client IN/AAAA queries with unsigned NODATA, ahead of local answers, cache and DNS64; internal resolution and IPv6 transports are unaffected |
| `accesslist` | `["0.0.0.0/0", "::0/0"]` | Clients allowed to query, narrow this; `[]` or omitted allows every client |
| `hostsfile` | `""` | Serve entries from a hosts file |
| `hostsfilechecknames` | `false` | Reject malformed names from a hosts file before importing them; independent of zone filtering |
| `hostsfilezones` | `[]` | Optional DNS zones limiting imported hosts-file names; empty preserves unrestricted import |
| `timeout` | `"2s"` | Per upstream query; `"0"` or omitted means `"2s"` |
| `querytimeout` | `"10s"` | For one whole client query; `"0"` or omitted means `"10s"` |
| `expire` | `600` | Seconds; lifetime cap of RFC 8020 subtree cuts and RFC 8198 proofs (fixed caps of 24 hours and 3 hours sit above it; `0` or omitted leaves only those). Neither mechanism runs with `dnssec = "off"` or `forwarderservers` set. Resolution failures use `failure_cache_*` |
Expand Down Expand Up @@ -137,7 +139,8 @@ The page that explains each top-level key is listed under
- [Access control and blocking]({{ '/docs/configuration/access-control/' | relative_url }}):
`accesslist`, `ratelimit`, `clientratelimit`, `cookiesecret`, the `reflex*`
keys, `blocklists`, `blocklist`, `whitelist`, `nullroute`, `nullroutev6`,
`blocklistdir`, `block_aaaa`, `hostsfile`, `domainmetrics`, `domainmetricslimit`.
`blocklistdir`, `block_aaaa`, `hostsfile`, `hostsfilechecknames`,
`hostsfilezones`, `domainmetrics`, `domainmetricslimit`.
- [Views and forwarding]({{ '/docs/features/views/' | relative_url }}):
`fallbackservers`, `forwarderservers`.
- [Local root zone]({{ '/docs/features/hyperlocal-root/' | relative_url }}):
Expand Down
Loading
Loading