diff --git a/plugin/template/README.md b/plugin/template/README.md index 6ac5b9548..d043e63e6 100644 --- a/plugin/template/README.md +++ b/plugin/template/README.md @@ -13,6 +13,8 @@ The *template* plugin allows you to dynamically respond to queries by just writi ~~~ template CLASS TYPE [ZONE...] { match REGEX... + var NAME EXPRESSION + expr EXPRESSION answer RR additional RR authority RR @@ -31,6 +33,12 @@ template CLASS TYPE [ZONE...] { * `answer|additional|authority` **RR** A [RFC 1035](https://tools.ietf.org/html/rfc1035#section-5) style resource record fragment built by a [Go template](https://golang.org/pkg/text/template/) that contains the reply. Specifying no answer will result in a response with an empty answer section. +* `var` **NAME** **EXPRESSION** sets the variable **NAME** to the result of **EXPRESSION**, evaluated for each matching query + and available to the templates as `.Var.NAME`. Multiple variables may be set; each one may use the variables declared before it. + See the **Expressions** section. +* `expr` **EXPRESSION** a condition that must evaluate to `true` for the query to match. All conditions must be true for a + complete match; otherwise the query is subject to `fallthrough`, as if no regex had matched. Conditions may use the + variables declared with `var`. See the **Expressions** section. * `rcode` **CODE** A response code (`NXDOMAIN, SERVFAIL, ...`). The default is `NOERROR`. Valid response code values are per the `RcodeToString` map defined by the `miekg/dns` package in `msg.go`. * `ederror` **EXTENDED_ERROR_CODE** is an extended DNS error code as a number defined in `RFC8914` (0, 1, 2,..., 24). @@ -55,6 +63,7 @@ Each resource record is a full-featured [Go template](https://golang.org/pkg/tex * `.Message` the complete incoming DNS message. * `.Question` the matched question section. * `.Remote` client’s IP address +* `.Var` the variables defined with `var` (e.g. `.Var.myvariable`). * `.Meta` a function that takes a metadata name and returns the value, if the metadata plugin is enabled. For example, `.Meta "kubernetes/client-namespace"` @@ -68,6 +77,21 @@ The output of the template must be a [RFC 1035](https://tools.ietf.org/html/rfc1 like `{{$var}}` will be interpreted as a reference to an environment variable by CoreDNS (and Caddy) while `{{ $var }}` will work. See [Bugs](#bugs) and corefile(5). +## Expressions + +The **EXPRESSION** of a `var` or `expr` is written in the expr language, the same as used by the *view* plugin. See +https://expr-lang.org/docs/language-definition as a detailed reference for valid syntax. + +Expressions can reference the DNS query functions and utility functions listed in the *view* plugin's documentation, the +variables declared by preceding `var` options, and + +* `group(name string) string`: the capture group named _name_ of the matching regex, or `""` if there is no such group. +* `group(index int) string`: the _index_-th capture group of the matching regex, `group(0)` being the entire match, or `""` + if there is no such group. + +A variable name must be a valid identifier and must not be the name of an existing function, or of a keyword or literal of +the expr language, such as `len` or `true`. + ## Metrics If monitoring is enabled (via the *prometheus* plugin) then the following metrics are exported: @@ -186,6 +210,36 @@ Having templates to map certain PTR/A pairs is a common pattern. Fallthrough is needed for mixed domains where only some responses are templated. +### Resolve device addresses for HTTPS on LAN + +For an embedded device on a local network to be trusted, it needs to have a certificate signed by a +CA and the CA needs to verify that the device controls the name for which it is issued. As the local +address may change, a certificate is issued for the wildcard subdomain `*..example.com` instead, +where `` is a unique id for the given device. The same trick as in the example above can be used +to find out the actual IP address. + +Resolving to public IP addresses would allow for this to be used in phishing attacks, so an +additional expression must be satisfied to limit the allowed IP range. + +~~~ corefile +. { + forward . 8.8.8.8 + + template IN A example.com { + match ^(?P[0-9]{1,3}(-[0-9]{1,3}){3})[.](?P[a-z2-7]{26})[.]example[.]com[.]$ + var ip replace(group('ip'), '-', '.') + expr "any(['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '169.254.0.0/16'], incidr(ip, #))" + answer "{{ .Name }} 60 IN A {{ .Var.ip }}" + fallthrough + } +} +~~~ + +The regular expression for the unique device id matches a base32 encoded string of a 128-bit device +id, with the padding removed. + +Note that an expression using `#` must be quoted, or it will be interpreted as a comment. + ### Resolve hexadecimal ip pattern using parseInt ~~~ corefile diff --git a/plugin/template/expr_test.go b/plugin/template/expr_test.go new file mode 100644 index 000000000..5c7aee442 --- /dev/null +++ b/plugin/template/expr_test.go @@ -0,0 +1,293 @@ +package template + +import ( + "context" + "testing" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/plugin/metadata" + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func TestExpr(t *testing.T) { + tests := []struct { + name string + config string + qname string + qtype uint16 + md map[string]string + expectedCode int + expectedAnswer []string + }{ + { + name: "True", + config: `template IN A example. { + expr name() == 'a.example.' + answer "{{ .Name }} 60 IN A 10.0.0.1" + }`, + qname: "a.example.", + expectedAnswer: []string{"a.example. 60 IN A 10.0.0.1"}, + }, + { + name: "FalseServfail", + config: `template IN A example. { + expr name() == 'b.example.' + answer "{{ .Name }} 60 IN A 10.0.0.1" + }`, + qname: "a.example.", + expectedCode: dns.RcodeServerFailure, + }, + { + name: "FalseFallthrough", + config: `template IN A example. { + expr name() == 'b.example.' + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough + }`, + qname: "a.example.", + expectedCode: rcodeFallthrough, + }, + { + name: "FalseFallthroughZoneMismatch", + config: `template IN A example. { + expr name() == 'b.example.' + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough other.example. + }`, + qname: "a.example.", + expectedCode: dns.RcodeServerFailure, + }, + { + name: "FalseFallsToNextTemplate", + config: `template IN A example. { + expr name() == 'b.example.' + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough + } + template IN A example. { + answer "{{ .Name }} 60 IN A 10.0.0.2" + }`, + qname: "a.example.", + expectedAnswer: []string{"a.example. 60 IN A 10.0.0.2"}, + }, + { + name: "AllTrue", + config: `template IN A example. { + expr name() == 'a.example.' + expr type() == 'A' + expr incidr(client_ip(), '10.0.0.0/8') + answer "{{ .Name }} 60 IN A 10.0.0.1" + }`, + qname: "a.example.", + expectedAnswer: []string{"a.example. 60 IN A 10.0.0.1"}, + }, + { + name: "LastFalse", + config: `template IN A example. { + expr name() == 'a.example.' + expr type() == 'A' + expr incidr(client_ip(), '192.0.2.0/24') + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough + }`, + qname: "a.example.", + expectedCode: rcodeFallthrough, + }, + { + name: "UsesVars", + config: `template IN A example. { + match ^(?P[0-9]+)[.]example[.]$ + var num int(group('n')) + expr num > 1 + answer "{{ .Name }} 60 IN A 10.0.0.{{ .Var.num }}" + fallthrough + }`, + qname: "2.example.", + expectedAnswer: []string{"2.example. 60 IN A 10.0.0.2"}, + }, + { + name: "UsesVarsFalse", + config: `template IN A example. { + match ^(?P[0-9]+)[.]example[.]$ + var num int(group('n')) + expr num > 1 + answer "{{ .Name }} 60 IN A 10.0.0.{{ .Var.num }}" + fallthrough + }`, + qname: "1.example.", + expectedCode: rcodeFallthrough, + }, + { + name: "UsesGroup", + config: `template IN A example. { + match ^(?P[0-9]+)[.]example[.]$ + expr group('n') == '2' && group(0) == '2.example.' + answer "{{ .Name }} 60 IN A 10.0.0.{{ .Group.n }}" + fallthrough + }`, + qname: "2.example.", + expectedAnswer: []string{"2.example. 60 IN A 10.0.0.2"}, + }, + { + name: "Metadata", + config: `template IN A example. { + expr metadata('test/region') == 'eu' + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough + }`, + qname: "a.example.", + md: map[string]string{"test/region": "eu"}, + expectedAnswer: []string{"a.example. 60 IN A 10.0.0.1"}, + }, + { + name: "MetadataMismatch", + config: `template IN A example. { + expr metadata('test/region') == 'eu' + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough + }`, + qname: "a.example.", + md: map[string]string{"test/region": "us"}, + expectedCode: rcodeFallthrough, + }, + { + name: "NonBool", + config: `template IN A example. { + expr 1 + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough + }`, + qname: "a.example.", + expectedCode: rcodeFallthrough, + }, + { + name: "RuntimeError", + config: `template IN A example. { + expr incidr('notanip', '10.0.0.0/8') + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough + }`, + qname: "a.example.", + expectedCode: dns.RcodeServerFailure, + }, + { + name: "NoMatchNoEvaluation", + config: `template IN A example. { + match ^a[.]example[.]$ + expr incidr('notanip', '10.0.0.0/8') + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough + }`, + qname: "b.example.", + expectedCode: rcodeFallthrough, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := caddy.NewTestController("dns", tc.config) + handler, err := templateParse(c) + if err != nil { + t.Fatalf("expected no config error, got: %v", err) + } + handler.Next = test.NextHandler(rcodeFallthrough, nil) + + ctx := context.Background() + if tc.md != nil { + ctx = metadata.ContextWithMetadata(ctx) + for k, v := range tc.md { + value := v + metadata.SetValueFunc(ctx, k, func() string { return value }) + } + } + + qtype := tc.qtype + if qtype == 0 { + qtype = dns.TypeA + } + req := &dns.Msg{Question: []dns.Question{{Name: tc.qname, Qclass: dns.ClassINET, Qtype: qtype}}} + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + + code, err := handler.ServeDNS(ctx, rec, req) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if code != tc.expectedCode { + t.Fatalf("expected rcode %v, got %v", tc.expectedCode, code) + } + if tc.expectedCode != dns.RcodeSuccess { + return + } + + verifySection(t, "answer", rec.Msg.Answer, tc.expectedAnswer) + }) + } +} + +func TestExprMatch(t *testing.T) { + tests := []struct { + name string + config string + qname string + expectedMatch bool + expectedFthrough bool + }{ + { + name: "AllTrue", + config: `template IN A example. { + expr name() == 'a.example.' + expr true + fallthrough + }`, + qname: "a.example.", + expectedMatch: true, + }, + { + name: "FirstFalse", + config: `template IN A example. { + expr false + expr true + fallthrough + }`, + qname: "a.example.", + expectedFthrough: true, + }, + { + name: "FalseWithoutFallthrough", + config: `template IN A example. { + expr false + }`, + qname: "a.example.", + }, + { + name: "ErrorWithFallthrough", + config: `template IN A example. { + expr incidr('notanip', '10.0.0.0/8') + fallthrough + }`, + qname: "a.example.", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := caddy.NewTestController("dns", tc.config) + handler, err := templateParse(c) + if err != nil { + t.Fatalf("expected no config error, got: %v", err) + } + + req := &dns.Msg{Question: []dns.Question{{Name: tc.qname, Qclass: dns.ClassINET, Qtype: dns.TypeA}}} + _, match, fthrough := handler.Templates[0].match(context.Background(), requestFor(req)) + if match != tc.expectedMatch { + t.Fatalf("expected match %v, got %v", tc.expectedMatch, match) + } + if fthrough != tc.expectedFthrough { + t.Fatalf("expected fallthrough %v, got %v", tc.expectedFthrough, fthrough) + } + }) + } +} diff --git a/plugin/template/setup.go b/plugin/template/setup.go index 8dda86def..2e8da7ba0 100644 --- a/plugin/template/setup.go +++ b/plugin/template/setup.go @@ -1,8 +1,11 @@ package template import ( + "context" + "reflect" "regexp" "strconv" + "strings" gotmpl "text/template" "github.com/coredns/caddy" @@ -10,6 +13,10 @@ import ( "github.com/coredns/coredns/plugin" "github.com/coredns/coredns/plugin/pkg/upstream" + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/ast" + "github.com/expr-lang/expr/builtin" + "github.com/expr-lang/expr/parser" "github.com/miekg/dns" ) @@ -17,6 +24,17 @@ import ( // OOM during regex compilation with malicious input. const maxRegexpLen = 10000 +var varNameRegexp = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +func isExprIdentifier(name string) bool { + tree, err := parser.Parse(name) + if err != nil { + return false + } + ident, ok := tree.Node.(*ast.IdentifierNode) + return ok && ident.Value == name +} + func init() { plugin.Register("template", setupTemplate) } func setupTemplate(c *caddy.Controller) error { @@ -63,6 +81,8 @@ func templateParse(c *caddy.Controller) (handler Handler, err error) { t.answer = make([]*gotmpl.Template, 0) t.upstream = upstream.New() + varEnv := exprEnv(context.Background(), nil, &templateData{}) + for c.NextBlock() { switch c.Val() { case "match": @@ -121,6 +141,41 @@ func templateParse(c *caddy.Controller) (handler Handler, err error) { t.authority = append(t.authority, tmpl) } + case "var": + args := c.RemainingArgs() + if len(args) < 2 { + return handler, c.ArgErr() + } + if !varNameRegexp.MatchString(args[0]) { + return handler, c.Errf("invalid variable name %q", args[0]) + } + _, isEnv := varEnv[args[0]] + _, isBuiltin := builtin.Index[args[0]] + if isEnv || isBuiltin || !isExprIdentifier(args[0]) { + return handler, c.Errf("variable name %q is reserved", args[0]) + } + prog, err := expr.Compile(strings.Join(args[1:], " "), expr.Env(varEnv), expr.DisableBuiltin("type")) + if err != nil { + return handler, c.Errf("could not compile expression: %s, %v", args[0], err) + } + if rt := prog.Node().Type(); rt == nil || rt.Kind() == reflect.Interface { + varEnv[args[0]] = new(any) + } else { + varEnv[args[0]] = reflect.Zero(rt).Interface() + } + t.vars = append(t.vars, variable{name: args[0], prog: prog}) + + case "expr": + args := c.RemainingArgs() + if len(args) == 0 { + return handler, c.ArgErr() + } + prog, err := expr.Compile(strings.Join(args, " "), expr.Env(varEnv), expr.DisableBuiltin("type")) + if err != nil { + return handler, c.Errf("could not compile expression: %v", err) + } + t.exprs = append(t.exprs, prog) + case "rcode": if !c.NextArg() { return handler, c.ArgErr() diff --git a/plugin/template/setup_test.go b/plugin/template/setup_test.go index 54fcd0a8d..cc7f09ed5 100644 --- a/plugin/template/setup_test.go +++ b/plugin/template/setup_test.go @@ -201,6 +201,239 @@ func TestSetupParse(t *testing.T) { } } +func TestSetupParseVar(t *testing.T) { + tests := []struct { + inputFileRules string + shouldErr bool + varCount int + }{ + { + `template ANY ANY example. { + var a 1 + }`, + false, 1, + }, + { + `template ANY ANY example. { + var a 1 + var b a + 1 + var c 'x' + name() + }`, + false, 3, + }, + { + `template ANY ANY example. { + match ^(?P[a-z]+)[.]example[.]$ + var a group("a") + group(1) + group(0) + }`, + false, 1, + }, + { + `template ANY ANY example. { + var + }`, + true, 0, + }, + { + `template ANY ANY example. { + var a + }`, + true, 0, + }, + { + `template ANY ANY example. { + var 1a 1 + }`, + true, 0, + }, + { + `template ANY ANY example. { + var a-b 1 + }`, + true, 0, + }, + { + `template ANY ANY example. { + var name 1 + }`, + true, 0, + }, + { + `template ANY ANY example. { + var group 1 + }`, + true, 0, + }, + { + `template ANY ANY example. { + var len 1 + }`, + true, 0, + }, + { + `template ANY ANY example. { + var true 1 + }`, + true, 0, + }, + { + `template ANY ANY example. { + var let 1 + }`, + true, 0, + }, + { + `template ANY ANY example. { + var a 1 + + }`, + true, 0, + }, + { + `template ANY ANY example. { + var a invalid expression + }`, + true, 0, + }, + { + `template ANY ANY example. { + var a b + 1 + var b 1 + }`, + true, 0, + }, + { + `template ANY ANY example. { + var a undefined + }`, + true, 0, + }, + { + `template ANY ANY example. { + var a 1 + var a 2 + }`, + true, 0, + }, + { + `template ANY ANY example. { + var a 1 + var b a + 1 + var c 'x' + b + }`, + true, 0, + }, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputFileRules) + handler, err := templateParse(c) + + if err == nil && test.shouldErr { + t.Errorf("Test %d expected errors, but got no error\n---\n%s\n---", i, test.inputFileRules) + continue + } + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d expected no errors, but got '%v'", i, err) + } + continue + } + if got := len(handler.Templates[0].vars); got != test.varCount { + t.Errorf("Test %d expected %d vars, but got %d", i, test.varCount, got) + } + } +} + +func TestSetupParseExpr(t *testing.T) { + tests := []struct { + inputFileRules string + shouldErr bool + exprCount int + }{ + { + `template ANY ANY example. { + expr name() == 'a.example.' + }`, + false, 1, + }, + { + `template ANY ANY example. { + expr name() == 'a.example.' + expr incidr(client_ip(), '10.0.0.0/8') + }`, + false, 2, + }, + { + `template ANY ANY example. { + var a 1 + expr a == 1 + }`, + false, 1, + }, + { + `template ANY ANY example. { + match ^(?P[a-z]+)[.]example[.]$ + expr group('a') != '' + }`, + false, 1, + }, + { + `template ANY ANY example. { + expr 1 + }`, + false, 1, + }, + { + `template ANY ANY example. { + expr + }`, + true, 0, + }, + { + `template ANY ANY example. { + expr name() == + }`, + true, 0, + }, + { + `template ANY ANY example. { + expr invalid expression + }`, + true, 0, + }, + { + `template ANY ANY example. { + expr undefined == 1 + }`, + true, 0, + }, + { + `template ANY ANY example. { + var a 1 + expr a + 'x' + }`, + true, 0, + }, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputFileRules) + handler, err := templateParse(c) + + if err == nil && test.shouldErr { + t.Errorf("Test %d expected errors, but got no error\n---\n%s\n---", i, test.inputFileRules) + continue + } + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d expected no errors, but got '%v'", i, err) + } + continue + } + if got := len(handler.Templates[0].exprs); got != test.exprCount { + t.Errorf("Test %d expected %d exprs, but got %d", i, test.exprCount, got) + } + } +} + func TestSetupParseLargeRegex(t *testing.T) { largeRegex := strings.Repeat("a", maxRegexpLen+1) config := fmt.Sprintf(`template ANY A example.com { diff --git a/plugin/template/template.go b/plugin/template/template.go index 5eac81e8e..bacee6f98 100644 --- a/plugin/template/template.go +++ b/plugin/template/template.go @@ -10,9 +10,12 @@ import ( "github.com/coredns/coredns/plugin" "github.com/coredns/coredns/plugin/metadata" "github.com/coredns/coredns/plugin/metrics" + "github.com/coredns/coredns/plugin/pkg/expression" "github.com/coredns/coredns/plugin/pkg/fall" "github.com/coredns/coredns/request" + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" "github.com/miekg/dns" ) @@ -36,6 +39,13 @@ type template struct { ederror *ederror fall fall.F upstream Upstreamer + vars []variable + exprs []*vm.Program +} + +type variable struct { + name string + prog *vm.Program } type ederror struct { @@ -59,6 +69,7 @@ type templateData struct { Message *dns.Msg Question *dns.Question Remote string + Var map[string]any md map[string]metadata.Func } @@ -74,6 +85,20 @@ func (data *templateData) Meta(metaName string) string { return "" } +func exprEnv(ctx context.Context, state *request.Request, data *templateData) map[string]any { + env := expression.DefaultEnv(ctx, state) + env["group"] = func(name any) string { + switch n := name.(type) { + case int: + return data.Group[strconv.Itoa(n)] + case string: + return data.Group[n] + } + return "" + } + return env +} + // ServeDNS implements the plugin.Handler interface. func (h Handler) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { state := request.Request{W: w, Req: r} @@ -220,6 +245,30 @@ func (t template) match(ctx context.Context, state request.Request) (*templateDa } } + if len(t.vars) > 0 || len(t.exprs) > 0 { + exprState := state // &state would escape unconditionally + env := exprEnv(ctx, &exprState, data) + data.Var = make(map[string]any) + for _, v := range t.vars { + result, err := expr.Run(v.prog, env) + if err != nil { + return data, false, false + } + env[v.name] = result + data.Var[v.name] = result + } + + for _, prog := range t.exprs { + result, err := expr.Run(prog, env) + if err != nil { + return data, false, false + } + if b, ok := result.(bool); !ok || !b { + return data, false, t.fall.Through(state.Name()) + } + } + } + return data, true, false } diff --git a/plugin/template/var_test.go b/plugin/template/var_test.go new file mode 100644 index 000000000..f56250706 --- /dev/null +++ b/plugin/template/var_test.go @@ -0,0 +1,298 @@ +package template + +import ( + "context" + "testing" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/plugin/metadata" + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +func TestVar(t *testing.T) { + tests := []struct { + name string + config string + qname string + qtype uint16 + md map[string]string + expectedCode int + expectedErr bool + expectedAnswer []string + expectedExtra []string + expectedNs []string + }{ + { + name: "Constant", + config: `template IN A example. { + var ttl 60 + answer "{{ .Name }} {{ .Var.ttl }} IN A 10.0.0.1" + }`, + qname: "a.example.", + expectedAnswer: []string{"a.example. 60 IN A 10.0.0.1"}, + }, + { + name: "Chained", + config: `template IN A example. { + var a 1 + var b a + 1 + var c b * 10 + answer "{{ .Name }} 60 IN A 10.{{ .Var.a }}.{{ .Var.b }}.{{ .Var.c }}" + }`, + qname: "a.example.", + expectedAnswer: []string{"a.example. 60 IN A 10.1.2.20"}, + }, + { + name: "GroupByNameAndIndex", + config: `template IN A example. { + match ^(?P[0-9]+)-(?P[0-9]+)[.]example[.]$ + var a group('a') + var b group(2) + var whole group(0) + answer "{{ .Var.whole }} 60 IN A 10.0.{{ .Var.a }}.{{ .Var.b }}" + }`, + qname: "12-34.example.", + expectedAnswer: []string{"12-34.example. 60 IN A 10.0.12.34"}, + }, + { + name: "GroupMissing", + config: `template IN TXT example. { + match ^(?P[a-z]+)[.]example[.]$ + var missing group('nosuchgroup') + group(9) + answer "{{ .Name }} 60 IN TXT \"empty={{ eq .Var.missing \"\" }}\"" + }`, + qname: "a.example.", + qtype: dns.TypeTXT, + expectedAnswer: []string{`a.example. 60 IN TXT "empty=true"`}, + }, + { + name: "QueryFunctions", + config: `template IN A example. { + var n name() + var t type() + var ip client_ip() + answer "{{ .Var.n }} 60 IN A {{ .Var.ip }}" + additional "{{ .Var.n }} 60 IN TXT \"{{ .Var.t }}\"" + }`, + qname: "a.example.", + expectedAnswer: []string{"a.example. 60 IN A 10.240.0.1"}, + expectedExtra: []string{`a.example. 60 IN TXT "A"`}, + }, + { + name: "Metadata", + config: `template IN A example. { + var region metadata('test/region') + answer "{{ .Var.region }}.{{ .Name }} 60 IN A 10.0.0.1" + }`, + qname: "a.example.", + md: map[string]string{"test/region": "eu"}, + expectedAnswer: []string{"eu.a.example. 60 IN A 10.0.0.1"}, + }, + { + name: "BoolInTemplateCondition", + config: `template IN A example. { + var local incidr(client_ip(), '10.0.0.0/8') + answer "{{ .Name }} 60 IN A {{ if .Var.local }}10.0.0.1{{ else }}192.0.2.1{{ end }}" + }`, + qname: "a.example.", + expectedAnswer: []string{"a.example. 60 IN A 10.0.0.1"}, + }, + { + name: "UsedInAllSections", + config: `template IN A example. { + var target 'ns0.example.' + answer "{{ .Name }} 60 IN A 10.0.0.1" + additional "{{ .Var.target }} 60 IN A 10.0.0.2" + authority "example. 60 IN NS {{ .Var.target }}" + }`, + qname: "a.example.", + expectedAnswer: []string{"a.example. 60 IN A 10.0.0.1"}, + expectedExtra: []string{"ns0.example. 60 IN A 10.0.0.2"}, + expectedNs: []string{"example. 60 IN NS ns0.example."}, + }, + { + name: "NoVars", + config: `template IN TXT example. { + answer "{{ .Name }} 60 IN TXT \"{{ len .Var }}\"" + }`, + qname: "a.example.", + qtype: dns.TypeTXT, + expectedAnswer: []string{`a.example. 60 IN TXT "0"`}, + }, + { + name: "PerTemplateScope", + config: `template IN A example. { + match ^a[.]example[.]$ + var v '1' + answer "{{ .Name }} 60 IN A 10.0.0.{{ .Var.v }}" + } + template IN A example. { + match ^b[.]example[.]$ + answer "{{ .Name }} 60 IN TXT \"{{ len .Var }}\"" + }`, + qname: "a.example.", + expectedAnswer: []string{"a.example. 60 IN A 10.0.0.1"}, + }, + { + name: "RuntimeError", + config: `template IN A example. { + var bad incidr('notanip', '10.0.0.0/8') + answer "{{ .Name }} 60 IN A 10.0.0.1" + }`, + qname: "a.example.", + expectedCode: dns.RcodeServerFailure, + }, + { + name: "RuntimeErrorWithFallthrough", + config: `template IN A example. { + var bad incidr('notanip', '10.0.0.0/8') + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough + }`, + qname: "a.example.", + expectedCode: dns.RcodeServerFailure, + }, + { + name: "NoMatchNoEvaluation", + config: `template IN A example. { + match ^a[.]example[.]$ + var bad incidr('notanip', '10.0.0.0/8') + answer "{{ .Name }} 60 IN A 10.0.0.1" + fallthrough + }`, + qname: "b.example.", + expectedCode: rcodeFallthrough, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := caddy.NewTestController("dns", tc.config) + handler, err := templateParse(c) + if err != nil { + t.Fatalf("expected no config error, got: %v", err) + } + handler.Next = test.NextHandler(rcodeFallthrough, nil) + + ctx := context.Background() + if tc.md != nil { + ctx = metadata.ContextWithMetadata(ctx) + for k, v := range tc.md { + value := v + metadata.SetValueFunc(ctx, k, func() string { return value }) + } + } + + qtype := tc.qtype + if qtype == 0 { + qtype = dns.TypeA + } + req := &dns.Msg{Question: []dns.Question{{Name: tc.qname, Qclass: dns.ClassINET, Qtype: qtype}}} + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + + code, err := handler.ServeDNS(ctx, rec, req) + if err != nil && !tc.expectedErr { + t.Fatalf("expected no error, got: %v", err) + } + if err == nil && tc.expectedErr { + t.Fatalf("expected an error, got none") + } + if code != tc.expectedCode { + t.Fatalf("expected rcode %v, got %v", tc.expectedCode, code) + } + if tc.expectedCode != dns.RcodeSuccess { + return + } + + verifySection(t, "answer", rec.Msg.Answer, tc.expectedAnswer) + verifySection(t, "additional", rec.Msg.Extra, tc.expectedExtra) + verifySection(t, "authority", rec.Msg.Ns, tc.expectedNs) + }) + } +} + +func TestVarMatch(t *testing.T) { + c := caddy.NewTestController("dns", `template IN A example. { + match ^(?P[0-9]+)[.]example[.]$ + var num int(group('n')) + var double num * 2 + var label 'x' + group('n') + var flag num > 1 + }`) + handler, err := templateParse(c) + if err != nil { + t.Fatalf("expected no config error, got: %v", err) + } + + req := &dns.Msg{Question: []dns.Question{{Name: "2.example.", Qclass: dns.ClassINET, Qtype: dns.TypeA}}} + state := requestFor(req) + + data, match, fthrough := handler.Templates[0].match(context.Background(), state) + if !match { + t.Fatalf("expected a match, fallthrough %v", fthrough) + } + + expected := map[string]any{ + "num": 2, + "double": 4, + "label": "x2", + "flag": true, + } + if len(data.Var) != len(expected) { + t.Fatalf("expected %d variables, got %d: %v", len(expected), len(data.Var), data.Var) + } + for name, want := range expected { + got, ok := data.Var[name] + if !ok { + t.Errorf("variable %q missing", name) + continue + } + if got != want { + t.Errorf("variable %q: expected %#v, got %#v", name, want, got) + } + } +} + +func TestVarMatchError(t *testing.T) { + c := caddy.NewTestController("dns", `template IN A example. { + var bad incidr('notanip', '10.0.0.0/8') + fallthrough + }`) + handler, err := templateParse(c) + if err != nil { + t.Fatalf("expected no config error, got: %v", err) + } + + req := &dns.Msg{Question: []dns.Question{{Name: "a.example.", Qclass: dns.ClassINET, Qtype: dns.TypeA}}} + _, match, fthrough := handler.Templates[0].match(context.Background(), requestFor(req)) + if match { + t.Fatal("expected no match") + } + if fthrough { + t.Fatal("expected no fallthrough") + } +} + +func requestFor(req *dns.Msg) request.Request { + return request.Request{W: &test.ResponseWriter{}, Req: req} +} + +func verifySection(t *testing.T, section string, rrs []dns.RR, expected []string) { + t.Helper() + if len(rrs) != len(expected) { + t.Fatalf("expected %d %s records, got %d: %v", len(expected), section, len(rrs), rrs) + } + for i, e := range expected { + want, err := dns.NewRR(e) + if err != nil { + t.Fatalf("could not parse expected %s record %q: %v", section, e, err) + } + if rrs[i].String() != want.String() { + t.Errorf("%s record %d: expected %q, got %q", section, i, want.String(), rrs[i].String()) + } + } +}