Templates with expr-lang (#8450)

* plugin/template: Add expr-lang variables

Signed-off-by: Andri Yngvason <andri@yngvason.is>

* plugin/template: Add extra expressions that must match

Signed-off-by: Andri Yngvason <andri@yngvason.is>

* plugin/template: README: Add embedded device resolution example

Signed-off-by: Andri Yngvason <andri@yngvason.is>

---------

Signed-off-by: Andri Yngvason <andri@yngvason.is>
This commit is contained in:
Andri Yngvason
2026-08-20 03:27:03 +00:00
committed by GitHub
parent a53ecb7c2d
commit a1154dcee5
6 changed files with 982 additions and 0 deletions

View File

@@ -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` clients 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 `*.<id>.example.com` instead,
where `<id>` 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<ip>[0-9]{1,3}(-[0-9]{1,3}){3})[.](?P<id>[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

View File

@@ -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<n>[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<n>[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<n>[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)
}
})
}
}

View File

@@ -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()

View File

@@ -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>[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>[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 {

View File

@@ -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
}

298
plugin/template/var_test.go Normal file
View File

@@ -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<a>[0-9]+)-(?P<b>[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>[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<n>[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())
}
}
}