mirror of
https://github.com/coredns/coredns.git
synced 2026-08-20 23:08:28 -04:00
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:
@@ -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 `*.<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
|
||||
|
||||
293
plugin/template/expr_test.go
Normal file
293
plugin/template/expr_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
298
plugin/template/var_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user