A cgroup OOM kill is the worst outcome available to a resolver: an outage rather than a cache miss, with no chance to serve stale on the way out. So: - memory.limit / memory.reserve / memory.cache_max_bytes, a new config section. limit becomes a GOMEMLIMIT through debug.SetMemoryLimit, reduced by the reserve. The reserve exists because the Go heap does not account for goroutine stacks, runtime metadata, or the bbolt mmap backing the cache snapshot and the query log — handing the runtime the full cgroup limit is how you get killed by a mechanism that believed it had headroom. - config.Size, so a budget reads as "512MiB" rather than a byte count converted by hand. Strict about junk: "512MiBx" is an error. - The cache byte budget is now actually fed to the LRU. It already enforced maxBytes per shard on insert, but had no setter and nothing ever called one, so the footprint of cache.size was unbounded: one cached ANY or TXT answer can be tens of kilobytes, so 500k entries is anywhere from 150MB to several GB depending on what the traffic asked for. - api.pprof mounts net/http/pprof on the admin listener. The pprof handlers sit on the app-level mux, not inside the API's own handler, so they would bypass the auth middleware entirely; the option is therefore refused unless every admin address is loopback. Profiling means port-forwarding, so this is safe by construction rather than by policy, and cmdline cannot leak an argument that happens to be a key. - serveUDP was calling net.ResolveUDPAddr per packet. The peer already is a *net.UDPAddr; that call builds a string, re-parses the port and allocates on the busiest path in the process. Also fixes a flaky test: TestParallelPolicyAnswersFromTheFastestUpstream asserted the slow upstreams were hit, but the fan-out cancels on the first answer, so whether they were dispatched before cancellation is a real race. It now asserts the answer came from the fast upstream and that nothing waited.
338 lines
11 KiB
Go
338 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestDurationUnmarshalYAMLAccepted(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
node string
|
|
want time.Duration
|
|
}{
|
|
{"seconds", `5s`, 5 * time.Second},
|
|
{"compound minutes seconds", `1m30s`, 90 * time.Second},
|
|
{"milliseconds", `250ms`, 250 * time.Millisecond},
|
|
{"hours minutes", `1h30m`, 90 * time.Minute},
|
|
{"sub second", `1.5s`, 1500 * time.Millisecond},
|
|
{"microseconds", `10us`, 10 * time.Microsecond},
|
|
{"zero", `0s`, 0},
|
|
{"negative", `-5s`, -5 * time.Second},
|
|
{"padded", `" 30s "`, 30 * time.Second},
|
|
{"empty string is zero", `""`, 0},
|
|
{"null is zero", `null`, 0},
|
|
{"tilde is zero", `~`, 0},
|
|
{"int seconds", `7`, 7 * time.Second},
|
|
{"float seconds", `1.5`, 1500 * time.Millisecond},
|
|
{"negative int seconds", `-7`, -7 * time.Second},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var got struct {
|
|
D Duration `yaml:"d"`
|
|
}
|
|
if err := yaml.Unmarshal([]byte("d: "+tc.node), &got); err != nil {
|
|
t.Fatalf("unmarshal %s: %v", tc.node, err)
|
|
}
|
|
if got.D.D() != tc.want {
|
|
t.Errorf("d: %s = %v, want %v", tc.node, got.D.D(), tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDurationUnmarshalYAMLRejected(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
node string
|
|
mention string
|
|
}{
|
|
{"nonsense", `abc`, `"abc"`},
|
|
{"nonsense", `abc`, `"abc"`},
|
|
{"space before unit", `"5 s"`, `"5 s"`},
|
|
{"unknown unit", `10x`, `"10x"`},
|
|
{"overflow", `2562048h`, `"2562048h"`},
|
|
{"float overflow", `1e400s`, `1e400s`},
|
|
{"numeric seconds overflow", `1e30`, `"1e30"`},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var got struct {
|
|
D Duration `yaml:"d"`
|
|
}
|
|
err := yaml.Unmarshal([]byte("d: "+tc.node), &got)
|
|
if err == nil {
|
|
t.Fatalf("unmarshal %s: expected error, got %v", tc.node, got.D.D())
|
|
}
|
|
if !strings.Contains(err.Error(), tc.mention) {
|
|
t.Errorf("error %q does not mention offending value %s", err, tc.mention)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDurationUnmarshalYAMLNonScalar(t *testing.T) {
|
|
for _, node := range []string{`{a: 1}`, `[1,2]`} {
|
|
var got struct {
|
|
D Duration `yaml:"d"`
|
|
}
|
|
if err := yaml.Unmarshal([]byte("d: "+node), &got); err == nil {
|
|
t.Errorf("unmarshal d: %s: expected error, got %v", node, got.D.D())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDurationMarshalYAMLRoundTrip(t *testing.T) {
|
|
for _, d := range []Duration{
|
|
Duration(5 * time.Second),
|
|
Duration(90 * time.Second),
|
|
Duration(250 * time.Millisecond),
|
|
Duration(12 * time.Hour),
|
|
Duration(0),
|
|
} {
|
|
out, err := yaml.Marshal(map[string]Duration{"d": d})
|
|
if err != nil {
|
|
t.Fatalf("marshal %v: %v", d.D(), err)
|
|
}
|
|
var back struct {
|
|
D Duration `yaml:"d"`
|
|
}
|
|
if err := yaml.Unmarshal(out, &back); err != nil {
|
|
t.Fatalf("unmarshal %q: %v", out, err)
|
|
}
|
|
if back.D != d {
|
|
t.Errorf("round trip %v via %q: got %v", d.D(), strings.TrimSpace(string(out)), back.D.D())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Duration has no custom JSON unmarshaller; pin what encoding/json does with
|
|
// it so a change here is a deliberate one.
|
|
func TestDurationJSONForm(t *testing.T) {
|
|
var got struct {
|
|
D Duration `json:"d"`
|
|
}
|
|
if err := json.Unmarshal([]byte(`{"d":5}`), &got); err != nil {
|
|
t.Fatalf("bare number: %v", err)
|
|
}
|
|
if got.D.D() != 5*time.Nanosecond {
|
|
t.Errorf("bare JSON number = %v (nanoseconds, not seconds)", got.D.D())
|
|
}
|
|
if err := json.Unmarshal([]byte(`{"d":"5s"}`), &got); err == nil {
|
|
t.Errorf("quoted JSON duration decoded to %v; string form is unsupported", got.D.D())
|
|
}
|
|
out, err := json.Marshal(Duration(5 * time.Second))
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
if string(out) != "5000000000" {
|
|
t.Errorf("json.Marshal = %s, want nanoseconds", out)
|
|
}
|
|
}
|
|
|
|
// clearEnv removes name for the rest of the test, restoring it afterwards.
|
|
func clearEnv(t *testing.T, name string) {
|
|
t.Helper()
|
|
if old, ok := os.LookupEnv(name); ok {
|
|
t.Setenv(name, old) // registers the restore-on-cleanup
|
|
}
|
|
if err := os.Unsetenv(name); err != nil {
|
|
t.Fatalf("unset %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
func TestExpandEnvSubstitution(t *testing.T) {
|
|
t.Setenv("FD_TEST_KEY", "s3cr3t")
|
|
t.Setenv("FD_TEST_EMPTY", "")
|
|
t.Setenv("FD_TEST_NESTED", "${FD_TEST_KEY}")
|
|
clearEnv(t, "FD_TEST_MISSING")
|
|
tests := []struct {
|
|
name, in, want string
|
|
}{
|
|
{"double quoted scalar", `api_keys: ["${FD_TEST_KEY}"]` + "\n", `api_keys: ["s3cr3t"]` + "\n"},
|
|
{"plain unquoted scalar", "cert_file: /etc/${FD_TEST_KEY}.pem\n", "cert_file: /etc/s3cr3t.pem\n"},
|
|
{"single quoted scalar", "password: '${FD_TEST_KEY}'\n", "password: 's3cr3t'\n"},
|
|
{"whole value", "password: ${FD_TEST_KEY}\n", "password: s3cr3t\n"},
|
|
{"two references on one line", "x: ${FD_TEST_KEY}/${FD_TEST_KEY}\n", "x: s3cr3t/s3cr3t\n"},
|
|
{"value is not rescanned", "x: ${FD_TEST_NESTED}\n", "x: ${FD_TEST_KEY}\n"},
|
|
{"reference line comment", "# api_keys: ${FD_TEST_KEY}\n", "# api_keys: ${FD_TEST_KEY}\n"},
|
|
{"reference trailing comment", "level: info # set ${FD_TEST_KEY} later\n", "level: info # set ${FD_TEST_KEY} later\n"},
|
|
{"indented comment", "dns:\n # port ${FD_TEST_KEY}\n max_udp_size: 1232\n", "dns:\n # port ${FD_TEST_KEY}\n max_udp_size: 1232\n"},
|
|
{"bare dollar in url", "url: https://x.invalid/?a=$b\n", "url: https://x.invalid/?a=$b\n"},
|
|
{"bare dollar blocklist line", " url: https://h.invalid/hosts?$format=raw$other\n", " url: https://h.invalid/hosts?$format=raw$other\n"},
|
|
{"bare dollar at end of line", "url: https://x.invalid/$\n", "url: https://x.invalid/$\n"},
|
|
{"colon default when unset", "x: ${FD_TEST_MISSING:-fallback}\n", "x: fallback\n"},
|
|
{"hyphen default when unset", "x: [${FD_TEST_MISSING-fallback}]\n", "x: [fallback]\n"},
|
|
{"colon default when empty", "x: ${FD_TEST_EMPTY:-fallback}\n", "x: fallback\n"},
|
|
{"hyphen default when empty stays empty", "x: [${FD_TEST_EMPTY-fallback}]\n", "x: []\n"},
|
|
{"empty default", "x: [${FD_TEST_MISSING:-}]\n", "x: []\n"},
|
|
{"empty value without default", "x: [${FD_TEST_EMPTY}]\n", "x: []\n"},
|
|
{"hash without leading space is data", "x: a#${FD_TEST_KEY}\n", "x: a#s3cr3t\n"},
|
|
{"hash inside double quotes is data", "x: \"# ${FD_TEST_KEY}\"\n", "x: \"# s3cr3t\"\n"},
|
|
{"hash inside single quotes is data", "x: '# ${FD_TEST_KEY}'\n", "x: '# s3cr3t'\n"},
|
|
{"crlf line ending", "x: ${FD_TEST_KEY}\r\ny: 1\r\n", "x: s3cr3t\r\ny: 1\r\n"},
|
|
{"crlf comment", "# ${FD_TEST_KEY}\r\n", "# ${FD_TEST_KEY}\r\n"},
|
|
{"no trailing newline", "x: ${FD_TEST_KEY}", "x: s3cr3t"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := expandEnv([]byte(tc.in))
|
|
if err != nil {
|
|
t.Fatalf("expandEnv(%q): %v", tc.in, err)
|
|
}
|
|
if string(got) != tc.want {
|
|
t.Errorf("expandEnv(%q)\n got %q\nwant %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandEnvMultiLine(t *testing.T) {
|
|
t.Setenv("FD_TEST_KEY", "abc123")
|
|
clearEnv(t, "FD_TEST_MISSING")
|
|
in := strings.Join([]string{
|
|
"# leading comment ${FD_TEST_KEY}",
|
|
"api:",
|
|
" auth:",
|
|
` api_keys: ["${FD_TEST_KEY}"] # trailing ${FD_TEST_KEY}`,
|
|
" username: ${FD_TEST_MISSING:-admin}",
|
|
" password: ${FD_TEST_MISSING-none}",
|
|
"",
|
|
"metrics:",
|
|
" enabled: false # ${FD_TEST_MISSING} in a comment is fine",
|
|
"",
|
|
}, "\n")
|
|
want := strings.Join([]string{
|
|
"# leading comment ${FD_TEST_KEY}",
|
|
"api:",
|
|
" auth:",
|
|
` api_keys: ["abc123"] # trailing ${FD_TEST_KEY}`,
|
|
" username: admin",
|
|
" password: none",
|
|
"",
|
|
"metrics:",
|
|
" enabled: false # ${FD_TEST_MISSING} in a comment is fine",
|
|
"",
|
|
}, "\n")
|
|
got, err := expandEnv([]byte(in))
|
|
if err != nil {
|
|
t.Fatalf("expandEnv: %v", err)
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("expandEnv multi-line\n got %q\nwant %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExpandEnvWithoutReferencesIsByteIdentical(t *testing.T) {
|
|
raw := []byte("# no references here\nurl: https://x.invalid/?a=$b&c=${not_this}\n'# ${FD_TEST_KEY}'\n")
|
|
clearEnv(t, "FD_TEST_KEY")
|
|
got, err := expandEnv(raw)
|
|
if err != nil {
|
|
t.Fatalf("expandEnv: %v", err)
|
|
}
|
|
if !bytes.Equal(got, raw) {
|
|
t.Errorf("expandEnv changed a document with no references:\n got %q\nwant %q", got, raw)
|
|
}
|
|
}
|
|
|
|
func TestExpandEnvErrors(t *testing.T) {
|
|
clearEnv(t, "FD_TEST_MISSING")
|
|
t.Setenv("FD_TEST_KEY", "abc123")
|
|
tests := []struct {
|
|
name, in string
|
|
mentions []string
|
|
}{
|
|
{"unset without default", "x: ${FD_TEST_MISSING}\n", []string{"FD_TEST_MISSING", "line 1:"}},
|
|
{"unset on third line", "a: 1\nb: 2\nc: ${FD_TEST_MISSING}\n", []string{"FD_TEST_MISSING", "line 3:"}},
|
|
{"empty-but-set without default", "x: [${FD_TEST_MISSING}]\n", []string{"FD_TEST_MISSING"}},
|
|
{"unterminated reference", "x: ${FD_TEST_KEY\n", []string{"unterminated ${"}},
|
|
{"unterminated on later line", "a: 1\nb: ${FD_TEST_KEY\n", []string{"unterminated ${", "line 2:"}},
|
|
{"empty name", "x: ${}\n", []string{"empty variable name in ${}"}},
|
|
{"empty name with colon default", "x: ${:-fallback}\n", []string{"empty variable name"}},
|
|
{"unterminated inside comment is ignored", "# x: ${FD_TEST_KEY\ny: 1\n", nil},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := expandEnv([]byte(tc.in))
|
|
if len(tc.mentions) == 0 {
|
|
if err != nil {
|
|
t.Fatalf("expandEnv(%q) = %v, want success", tc.in, err)
|
|
}
|
|
if string(got) != tc.in {
|
|
t.Errorf("expandEnv(%q) = %q, want it copied verbatim", tc.in, got)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("expandEnv(%q) = %q, want error", tc.in, got)
|
|
}
|
|
for _, m := range tc.mentions {
|
|
if !strings.Contains(err.Error(), m) {
|
|
t.Errorf("error %q does not mention %q", err, m)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadSubstitutesEnvInFile(t *testing.T) {
|
|
t.Setenv("FD_TEST_KEY", "long-random-api-key")
|
|
t.Setenv("FD_TEST_PW", "p@ss")
|
|
path := writeTempConfig(t, `api:
|
|
auth:
|
|
mode: apikey
|
|
api_keys: ["${FD_TEST_KEY}"]
|
|
username: 'admin-${FD_TEST_PW}'
|
|
password: prefix-${FD_TEST_PW}
|
|
# the key is documented here: ${FD_TEST_KEY}
|
|
blocklist:
|
|
inline:
|
|
- "example.com"
|
|
`)
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
want := []string{"long-random-api-key"}
|
|
if len(cfg.API.Auth.APIKeys) != 1 || cfg.API.Auth.APIKeys[0] != want[0] {
|
|
t.Errorf("api_keys = %q, want %q", cfg.API.Auth.APIKeys, want)
|
|
}
|
|
if cfg.API.Auth.Username != "admin-p@ss" {
|
|
t.Errorf("username = %q, want admin-p@ss", cfg.API.Auth.Username)
|
|
}
|
|
if cfg.API.Auth.Password != "prefix-p@ss" {
|
|
t.Errorf("password = %q, want prefix-p@ss", cfg.API.Auth.Password)
|
|
}
|
|
}
|
|
|
|
func TestLoadUnsetEnvIsFatalAndNamesVariable(t *testing.T) {
|
|
clearEnv(t, "FD_TEST_MISSING")
|
|
path := writeTempConfig(t, "api:\n auth:\n mode: apikey\n api_keys: [\"${FD_TEST_MISSING}\"]\nblocklist:\n inline:\n - \"example.com\"\n")
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Fatal("Load: want error for unset variable")
|
|
}
|
|
for _, want := range []string{"FD_TEST_MISSING", path} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error %q does not mention %q", err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeTempConfig(t *testing.T, content string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
return path
|
|
}
|