mirror of
https://github.com/coredns/coredns.git
synced 2026-08-28 07:37:04 -04:00
feat(siit): Initial version (#8188)
* feat(siit): Initial version Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com> * cleaner readme Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com> * linting and generating Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com> * improving README and removing a useless case Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com> * improvements Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com> * improvements Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com> * linting Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com> * New fixes Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com> * improvements Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com> * improvements Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com> --------- Signed-off-by: Michée Lengronne <michee.lengronne@coppint.com>
This commit is contained in:
@@ -37,6 +37,7 @@ var Directives = []string{
|
|||||||
"dnstap",
|
"dnstap",
|
||||||
"local",
|
"local",
|
||||||
"dns64",
|
"dns64",
|
||||||
|
"siit",
|
||||||
"any",
|
"any",
|
||||||
"chaos",
|
"chaos",
|
||||||
"loadbalance",
|
"loadbalance",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import (
|
|||||||
_ "github.com/coredns/coredns/plugin/secondary"
|
_ "github.com/coredns/coredns/plugin/secondary"
|
||||||
_ "github.com/coredns/coredns/plugin/shed"
|
_ "github.com/coredns/coredns/plugin/shed"
|
||||||
_ "github.com/coredns/coredns/plugin/sign"
|
_ "github.com/coredns/coredns/plugin/sign"
|
||||||
|
_ "github.com/coredns/coredns/plugin/siit"
|
||||||
_ "github.com/coredns/coredns/plugin/template"
|
_ "github.com/coredns/coredns/plugin/template"
|
||||||
_ "github.com/coredns/coredns/plugin/timeouts"
|
_ "github.com/coredns/coredns/plugin/timeouts"
|
||||||
_ "github.com/coredns/coredns/plugin/tls"
|
_ "github.com/coredns/coredns/plugin/tls"
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ log:log
|
|||||||
dnstap:dnstap
|
dnstap:dnstap
|
||||||
local:local
|
local:local
|
||||||
dns64:dns64
|
dns64:dns64
|
||||||
|
siit:siit
|
||||||
any:any
|
any:any
|
||||||
chaos:chaos
|
chaos:chaos
|
||||||
loadbalance:loadbalance
|
loadbalance:loadbalance
|
||||||
|
|||||||
60
plugin/siit/README.md
Normal file
60
plugin/siit/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# siit
|
||||||
|
|
||||||
|
## Name
|
||||||
|
|
||||||
|
*siit* - enables AAAA->A translation support for DNS records based on SIIT (IPv6->IPv4 translation).
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The *siit* plugin will when asked for a domain's A record,
|
||||||
|
synthesizes it from a corresponding AAAA record if it belongs to a certain IP range.
|
||||||
|
|
||||||
|
It also supports arbitrary mapping IPv6->IPv4.
|
||||||
|
|
||||||
|
It is useful when published services are IPv6-only and emit their AAAA record accordingly
|
||||||
|
but IPv4 clients reach them through a siit routing. This plugin generates the associated A records
|
||||||
|
for these clients automatically.
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
|
||||||
|
~~~
|
||||||
|
siit {
|
||||||
|
ipv6_prefix IPV6PREFIX
|
||||||
|
eam IPV4 IPV6
|
||||||
|
}
|
||||||
|
~~~
|
||||||
|
|
||||||
|
* `ipv6_prefix` specifies any local IPv6 prefix to use, instead of the well known prefix (64:ff9b::/96)
|
||||||
|
* `eam` translates the ipv6 to the corresponding ipv4, it can be set multiple times
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
~~~ corefile
|
||||||
|
. {
|
||||||
|
siit {
|
||||||
|
ipv6_prefix 64:1337::/96
|
||||||
|
}
|
||||||
|
}
|
||||||
|
~~~
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
If monitoring is enabled (via the _prometheus_ plugin) then the following metrics are exported:
|
||||||
|
|
||||||
|
- `coredns_siit_requests_translated_total{server}` - counter of DNS requests translated
|
||||||
|
|
||||||
|
The `server` label is explained in the _prometheus_ plugin documentation.
|
||||||
|
|
||||||
|
## Bugs
|
||||||
|
|
||||||
|
* Prefix matching in eam is not implemented yet.
|
||||||
|
* DNSSEC support is not implemented yet. The problem is the same as DNS64. See: [RFC 6147 Section 3](https://tools.ietf.org/html/rfc6147#section-3)
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
See [RFC 6052](https://tools.ietf.org/html/rfc6052) for more information on the SIIT mechanism
|
||||||
|
and [RFC 7757](https://tools.ietf.org/html/rfc7757) about the explicit address mappings (eam) mechanism
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
This plugin is heavily based on [dns64 plugin](../dns64).
|
||||||
18
plugin/siit/metrics.go
Normal file
18
plugin/siit/metrics.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package siit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/coredns/coredns/plugin"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// RequestsTranslatedCount is the number of DNS requests translated by siit.
|
||||||
|
RequestsTranslatedCount = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: plugin.Namespace,
|
||||||
|
Subsystem: pluginName,
|
||||||
|
Name: "requests_translated_total",
|
||||||
|
Help: "Counter of DNS requests translated by siit.",
|
||||||
|
}, []string{"server"})
|
||||||
|
)
|
||||||
138
plugin/siit/setup.go
Normal file
138
plugin/siit/setup.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package siit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/coredns/caddy"
|
||||||
|
"github.com/coredns/coredns/core/dnsserver"
|
||||||
|
"github.com/coredns/coredns/plugin"
|
||||||
|
"github.com/coredns/coredns/plugin/pkg/upstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
const pluginName = "siit"
|
||||||
|
|
||||||
|
func init() { plugin.Register(pluginName, setup) }
|
||||||
|
|
||||||
|
func setup(c *caddy.Controller) error {
|
||||||
|
siit, err := siitParse(c)
|
||||||
|
if err != nil {
|
||||||
|
return plugin.Error(pluginName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler {
|
||||||
|
siit.Next = next
|
||||||
|
return siit
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func siitParse(c *caddy.Controller) (*SIIT, error) {
|
||||||
|
_, defaultPref6, _ := net.ParseCIDR("64:ff9b::/96")
|
||||||
|
siit := &SIIT{
|
||||||
|
Upstream: upstream.New(),
|
||||||
|
IPv6Prefix: defaultPref6,
|
||||||
|
}
|
||||||
|
|
||||||
|
for c.Next() {
|
||||||
|
args := c.RemainingArgs()
|
||||||
|
if len(args) > 0 {
|
||||||
|
return nil, c.ArgErr()
|
||||||
|
}
|
||||||
|
|
||||||
|
for c.NextBlock() {
|
||||||
|
switch c.Val() {
|
||||||
|
case "ipv6_prefix":
|
||||||
|
if !c.NextArg() {
|
||||||
|
return nil, c.ArgErr()
|
||||||
|
}
|
||||||
|
pref, err := parseIpv6Prefix(c, c.Val())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
siit.IPv6Prefix = pref
|
||||||
|
case "eam":
|
||||||
|
pref, err := parseEam(c)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if siit.Eam4 == nil {
|
||||||
|
siit.Eam4 = make(map[string]net.IP)
|
||||||
|
}
|
||||||
|
|
||||||
|
siit.Eam4[pref[1].String()] = pref[0]
|
||||||
|
default:
|
||||||
|
return nil, c.Errf("unknown property '%s'", c.Val())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return siit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIpv6Prefix(c *caddy.Controller, addr string) (*net.IPNet, error) {
|
||||||
|
ip, pref, err := net.ParseCIDR(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test for valid prefix
|
||||||
|
n, total := pref.Mask.Size()
|
||||||
|
if total != 128 {
|
||||||
|
return nil, c.Errf("invalid netmask %d IPv6 address: %q", total, pref)
|
||||||
|
}
|
||||||
|
|
||||||
|
ip16 := ip.To16()
|
||||||
|
if ip16 == nil {
|
||||||
|
return nil, c.Errf("invalid IPv6 prefix %q", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 6052 §2.2: only these lengths are valid.
|
||||||
|
switch n {
|
||||||
|
case 32, 40, 48, 56, 64, 96:
|
||||||
|
// ok
|
||||||
|
default:
|
||||||
|
return nil, c.Errf("invalid prefix length %q: must be one of /32, /40, /48, /56, /64, /96", pref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 6052 §2.2: byte 8 (bits 64-71, the "u" octet) is reserved and
|
||||||
|
// MUST be zero for every valid prefix length — including /96, where
|
||||||
|
// it's still part of the operator-configured prefix bits.
|
||||||
|
if pref.IP.To16()[8] != 0 {
|
||||||
|
return nil, c.Errf("invalid prefix %q: reserved octet (byte 8) must be zero", pref)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEam(c *caddy.Controller) (map[int]net.IP, error) {
|
||||||
|
args := c.RemainingArgs()
|
||||||
|
if len(args) != 2 {
|
||||||
|
return nil, c.ArgErr()
|
||||||
|
}
|
||||||
|
|
||||||
|
pref0 := net.ParseIP(args[0])
|
||||||
|
if pref0 == nil {
|
||||||
|
return nil, c.Errf("invalid IP address: %q", pref0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pref0.To4() == nil {
|
||||||
|
return nil, c.Errf("invalid IPv4 address: %q", pref0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pref1 := net.ParseIP(args[1])
|
||||||
|
if pref1 == nil {
|
||||||
|
return nil, c.Errf("invalid IP address: %q", pref1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pref1.To4() != nil {
|
||||||
|
return nil, c.Errf("invalid IPv6 address: %q", pref1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pref := make(map[int]net.IP)
|
||||||
|
pref[0] = pref0
|
||||||
|
pref[1] = pref1
|
||||||
|
return pref, nil
|
||||||
|
}
|
||||||
206
plugin/siit/setup_test.go
Normal file
206
plugin/siit/setup_test.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package siit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/coredns/caddy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetupSiit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
inputUpstreams string
|
||||||
|
shouldErr bool
|
||||||
|
wantIPv6Prefix string
|
||||||
|
wantEam map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
`siit`,
|
||||||
|
false,
|
||||||
|
"64:ff9b::/96",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix 64:dead::/96
|
||||||
|
}`,
|
||||||
|
false,
|
||||||
|
"64:dead::/96",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix 10.0.0.0/8
|
||||||
|
}`,
|
||||||
|
true,
|
||||||
|
"10.0.0.0/8",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix foobar
|
||||||
|
}`,
|
||||||
|
true,
|
||||||
|
"foobar",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
eam 10.0.0.1 64:dead::1
|
||||||
|
}`,
|
||||||
|
false,
|
||||||
|
"64:ff9b::/96",
|
||||||
|
map[string]string{
|
||||||
|
"64:dead::1": "10.0.0.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
eam 10.0.0.1 64:dead::1
|
||||||
|
eam 10.0.0.2 64:dead::2
|
||||||
|
}`,
|
||||||
|
false,
|
||||||
|
"64:ff9b::/96",
|
||||||
|
map[string]string{
|
||||||
|
"64:dead::1": "10.0.0.1",
|
||||||
|
"64:dead::2": "10.0.0.2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
eam 64:dead::1 10.0.0.1
|
||||||
|
}`,
|
||||||
|
true,
|
||||||
|
"64:ff9b::/96",
|
||||||
|
map[string]string{
|
||||||
|
"64:dead::1": "10.0.0.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
eam foobar 64:dead::1
|
||||||
|
}`,
|
||||||
|
true,
|
||||||
|
"64:ff9b::/96",
|
||||||
|
map[string]string{
|
||||||
|
"foobar": "64:dead::1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
eam 10.0.0.1 foobar
|
||||||
|
}`,
|
||||||
|
true,
|
||||||
|
"64:ff9b::/96",
|
||||||
|
map[string]string{
|
||||||
|
"10.0.0.1": "foobar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix 64:ff9b::/72
|
||||||
|
}`,
|
||||||
|
true, // /72 not in the allowed set (32/40/48/56/64/96)
|
||||||
|
"64:ff9b::/72",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix 64:ff9b::/32
|
||||||
|
}`,
|
||||||
|
false,
|
||||||
|
"64:ff9b::/32",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix 64:ff9b::/40
|
||||||
|
}`,
|
||||||
|
false,
|
||||||
|
"64:ff9b::/40",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix 64:ff9b::/48
|
||||||
|
}`,
|
||||||
|
false,
|
||||||
|
"64:ff9b::/48",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix 64:ff9b::/56
|
||||||
|
}`,
|
||||||
|
false,
|
||||||
|
"64:ff9b::/56",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix 64:ff9b::/64
|
||||||
|
}`,
|
||||||
|
false,
|
||||||
|
"64:ff9b::/64",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// /96 prefix with nonzero byte 8 must be rejected
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix 2001:db8:122:344:ff00::/96
|
||||||
|
}`,
|
||||||
|
true,
|
||||||
|
"2001:db8:122:344:ff00::/96",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// valid /96 prefix, byte 8 zero -- must still be accepted.
|
||||||
|
`siit {
|
||||||
|
ipv6_prefix 2001:db8::/96
|
||||||
|
}`,
|
||||||
|
false,
|
||||||
|
"2001:db8::/96",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// eam missing the second argument must not panic
|
||||||
|
`siit {
|
||||||
|
eam 10.0.0.1
|
||||||
|
}`,
|
||||||
|
true,
|
||||||
|
"64:ff9b::/96",
|
||||||
|
map[string]string{
|
||||||
|
"10.0.0.1": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// eam with too many arguments should error, not silently ignore extras
|
||||||
|
`siit {
|
||||||
|
eam 10.0.0.1 64:dead::1 extra
|
||||||
|
}`,
|
||||||
|
true,
|
||||||
|
"64:ff9b::/96",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
c := caddy.NewTestController("dns", test.inputUpstreams)
|
||||||
|
siit, err := siitParse(c)
|
||||||
|
if (err != nil) != test.shouldErr {
|
||||||
|
t.Errorf("Test %d expected %v error, got %v for %s", i+1, test.shouldErr, err, test.inputUpstreams)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if siit.IPv6Prefix.String() != test.wantIPv6Prefix {
|
||||||
|
t.Errorf("Test %d expected ipv6 prefix %s, got %v", i+1, test.wantIPv6Prefix, siit.IPv6Prefix.String())
|
||||||
|
}
|
||||||
|
gotEam := make(map[string]string, len(siit.Eam4))
|
||||||
|
for v6, v4 := range siit.Eam4 {
|
||||||
|
gotEam[v6] = v4.String()
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(gotEam, test.wantEam) {
|
||||||
|
t.Errorf("Test %d expected eam %v, got %v", i+1, test.wantEam, gotEam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
331
plugin/siit/siit.go
Normal file
331
plugin/siit/siit.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
// Package siit implements a plugin that performs AAAA to A translation.
|
||||||
|
//
|
||||||
|
// See: RFC 6052 (https://tools.ietf.org/html/rfc6052)
|
||||||
|
// See: RFC 7757 (https://tools.ietf.org/html/rfc7757)
|
||||||
|
package siit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coredns/coredns/plugin"
|
||||||
|
"github.com/coredns/coredns/plugin/metrics"
|
||||||
|
"github.com/coredns/coredns/plugin/pkg/nonwriter"
|
||||||
|
"github.com/coredns/coredns/plugin/pkg/response"
|
||||||
|
"github.com/coredns/coredns/request"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpstreamInt wraps the Upstream API for dependency injection during testing
|
||||||
|
type UpstreamInt interface {
|
||||||
|
Lookup(ctx context.Context, state request.Request, name string, typ uint16) (*dns.Msg, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIIT performs SIIT.
|
||||||
|
type SIIT struct {
|
||||||
|
Next plugin.Handler
|
||||||
|
IPv6Prefix *net.IPNet
|
||||||
|
Eam4 map[string]net.IP
|
||||||
|
Upstream UpstreamInt
|
||||||
|
}
|
||||||
|
|
||||||
|
// synthesisCtxKey marks a context as belonging to a translation plugin's own
|
||||||
|
// internal synthesis lookup (as opposed to a query that arrived from a
|
||||||
|
// client). Unexported so it can't collide with keys set by other packages.
|
||||||
|
type synthesisCtxKey struct{}
|
||||||
|
|
||||||
|
// markSynthesisLookup flags ctx as carrying a nested lookup issued by SIIT's
|
||||||
|
// own synthesis logic. The flag propagates through every
|
||||||
|
// plugin.Handler.ServeDNS(ctx, ...) call in the chain, including through
|
||||||
|
// other translation plugins such as dns64, whose own internal
|
||||||
|
// Upstream.Lookup calls reuse the context they were invoked with.
|
||||||
|
func markSynthesisLookup(ctx context.Context) context.Context {
|
||||||
|
return context.WithValue(ctx, synthesisCtxKey{}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSynthesisLookup reports whether ctx was produced by markSynthesisLookup.
|
||||||
|
func isSynthesisLookup(ctx context.Context) bool {
|
||||||
|
v, _ := ctx.Value(synthesisCtxKey{}).(bool)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeDNS implements the plugin.Handler interface.
|
||||||
|
func (d *SIIT) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
|
||||||
|
// A nested lookup issued as part of an in-flight synthesis (SIIT's own,
|
||||||
|
// or another translation plugin's further up the chain, e.g. dns64)
|
||||||
|
// must not be intercepted again — just pass it through. Otherwise SIIT
|
||||||
|
// and dns64 chained together can trigger each other's internal lookups
|
||||||
|
// indefinitely: SIIT's AAAA synthesis lookup re-enters the server and
|
||||||
|
// reaches dns64, whose own internal A lookup (issued with that same
|
||||||
|
// context) re-enters the server and reaches SIIT again as a plain A
|
||||||
|
// query, which SIIT would otherwise translate by issuing yet another
|
||||||
|
// AAAA lookup, and so on with no bound.
|
||||||
|
if isSynthesisLookup(ctx) {
|
||||||
|
return plugin.NextOrFailure(d.Name(), d.Next, ctx, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't proxy if we don't need to.
|
||||||
|
if !d.requestShouldIntercept(&request.Request{W: w, Req: r}) {
|
||||||
|
return plugin.NextOrFailure(d.Name(), d.Next, ctx, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass the request to the next plugin in the chain, but intercept the response.
|
||||||
|
nw := nonwriter.New(w)
|
||||||
|
origRc, origErr := d.Next.ServeDNS(ctx, nw, r)
|
||||||
|
if nw.Msg == nil { // somehow we didn't get a response (or raw bytes were written)
|
||||||
|
return origRc, origErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the response doesn't need SIIT, short-circuit.
|
||||||
|
if !d.responseShouldSIIT(&request.Request{W: w, Req: r}, nw.Msg) {
|
||||||
|
w.WriteMsg(nw.Msg)
|
||||||
|
return origRc, origErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise do the actual SIIT request and response synthesis
|
||||||
|
msg, synthesized, err := d.DoSIIT(ctx, w, r, nw.Msg)
|
||||||
|
if err != nil {
|
||||||
|
// err means we weren't able to even issue the A or AAAA request
|
||||||
|
// to CoreDNS upstream
|
||||||
|
return dns.RcodeServerFailure, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if synthesized {
|
||||||
|
RequestsTranslatedCount.WithLabelValues(metrics.WithServer(ctx)).Inc()
|
||||||
|
}
|
||||||
|
w.WriteMsg(msg)
|
||||||
|
return msg.Rcode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name implements the Handler interface.
|
||||||
|
func (d *SIIT) Name() string { return "siit" }
|
||||||
|
|
||||||
|
// requestShouldIntercept returns true if the request represents one that is eligible
|
||||||
|
// for SIIT rewriting:
|
||||||
|
// 2. The request is of type A
|
||||||
|
// 3. The request is of class INET
|
||||||
|
func (d *SIIT) requestShouldIntercept(req *request.Request) bool {
|
||||||
|
// Do not modify if question is not A or not of class IN. See RFC 6147 5.1
|
||||||
|
return (req.QType() == dns.TypeA) && req.QClass() == dns.ClassINET
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseShouldSIIT returns true if the response indicates we should attempt
|
||||||
|
// SIIT rewriting:
|
||||||
|
// 1. The response has no valid (RFC 5.1.4) A records (RFC 5.1.1)
|
||||||
|
// 2. The response code (RCODE) is not 3 (Name Error) (RFC 5.1.2)
|
||||||
|
//
|
||||||
|
// Note that requestShouldIntercept must also have been true, so the request
|
||||||
|
// is known to be of type A.
|
||||||
|
func (d *SIIT) responseShouldSIIT(req *request.Request, origResponse *dns.Msg) bool {
|
||||||
|
if origResponse.Truncated {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ty, _ := response.Typify(origResponse, time.Now().UTC())
|
||||||
|
|
||||||
|
// Handle NameError normally. See RFC 6147 5.1.2
|
||||||
|
// All other error types are "equivalent" to empty response
|
||||||
|
if ty == response.NameError {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// if response includes A record for an A request, no need to rewrite
|
||||||
|
for _, rr := range origResponse.Answer {
|
||||||
|
if rr.Header().Rrtype == dns.TypeA && req.QType() == dns.TypeA {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoSIIT takes an (empty) response to an A question, issues the AAAA request,
|
||||||
|
// and synthesizes the answer. Returns the response message, or error on internal failure.
|
||||||
|
func (d *SIIT) DoSIIT(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, origResponse *dns.Msg) (*dns.Msg, bool, error) {
|
||||||
|
req := request.Request{W: w, Req: r}
|
||||||
|
defaultreq := dns.TypeAAAA
|
||||||
|
|
||||||
|
resp, err := d.Upstream.Lookup(markSynthesisLookup(ctx), req, req.Name(), defaultreq)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp == nil {
|
||||||
|
// Upstream.Lookup isn't expected to return (nil, nil), but guard
|
||||||
|
// against it explicitly rather than let ServeDNS write a nil
|
||||||
|
// message and then dereference msg.Rcode.
|
||||||
|
return nil, false, errors.New("siit: upstream lookup returned no response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Rcode != dns.RcodeSuccess {
|
||||||
|
// Not a transport error — we got an answer, it's just SERVFAIL/
|
||||||
|
// REFUSED/NXDOMAIN/etc. resp still carries the *internal* AAAA
|
||||||
|
// question we issued, not the client's original A question.
|
||||||
|
// RFC 6147 §5.4 requires the assembled response to carry the
|
||||||
|
// original initiator's question/ID/transaction framing, so we
|
||||||
|
// can't return resp as-is — rebuild the envelope from the
|
||||||
|
// original request and copy over resp's RCODE, flags, and
|
||||||
|
// authority/additional sections.
|
||||||
|
return d.copyErrorResponse(r, resp), false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out, synthesized := d.Synthesize(r, origResponse, resp)
|
||||||
|
return out, synthesized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyErrorResponse builds a response addressed to the client's original
|
||||||
|
// request — correct ID, Question, and QR/Opcode framing via SetReply — while
|
||||||
|
// carrying over the secondary AAAA lookup's RCODE, AA/RA/TC flags, answer,
|
||||||
|
// authority, and additional sections. Used when the internal AAAA lookup
|
||||||
|
// itself failed (SERVFAIL, NXDOMAIN, REFUSED, etc.) so the client never sees
|
||||||
|
// the internal AAAA question in reply to its A query.
|
||||||
|
func (d *SIIT) copyErrorResponse(origReq, resp *dns.Msg) *dns.Msg {
|
||||||
|
ret := new(dns.Msg)
|
||||||
|
ret.SetReply(origReq) // sets Id, Question, Opcode, RD/CD from origReq
|
||||||
|
|
||||||
|
ret.Rcode = resp.Rcode
|
||||||
|
ret.Authoritative = resp.Authoritative
|
||||||
|
ret.RecursionAvailable = resp.RecursionAvailable
|
||||||
|
ret.Truncated = resp.Truncated
|
||||||
|
|
||||||
|
ret.Answer = resp.Answer
|
||||||
|
ret.Ns = resp.Ns
|
||||||
|
ret.Extra = resp.Extra
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthesize merges the AAAA response and the records from the A response.
|
||||||
|
// The bool return reports whether at least one AAAA record was actually
|
||||||
|
// translated into a synthetic A record. false means the caller got back
|
||||||
|
// origResponse unchanged — nothing in the AAAA answer was mappable (no EAM
|
||||||
|
// entry, and the address wasn't in ipv6_prefix) — and this must not be
|
||||||
|
// counted as a translated request.
|
||||||
|
func (d *SIIT) Synthesize(origReq, origResponse, resp *dns.Msg) (*dns.Msg, bool) {
|
||||||
|
ret := dns.Msg{}
|
||||||
|
ret.SetReply(origReq)
|
||||||
|
|
||||||
|
mappedAny := false
|
||||||
|
|
||||||
|
ret.Authoritative = resp.Authoritative
|
||||||
|
ret.RecursionAvailable = resp.RecursionAvailable
|
||||||
|
ret.AuthenticatedData = false
|
||||||
|
|
||||||
|
// persist truncated state of AAAA or A response
|
||||||
|
ret.Truncated = resp.Truncated
|
||||||
|
|
||||||
|
// 5.3.2: SIIT MUST pass the additional section unchanged
|
||||||
|
ret.Extra = resp.Extra
|
||||||
|
ret.Ns = resp.Ns
|
||||||
|
|
||||||
|
// 5.1.7: The TTL is the minimum of the A RR and the SOA RR. If SOA is
|
||||||
|
// unknown, then the TTL is the minimum of A TTL and 600
|
||||||
|
SOATtl := uint32(600) // Default NS record TTL
|
||||||
|
for _, ns := range origResponse.Ns {
|
||||||
|
if ns.Header().Rrtype == dns.TypeSOA {
|
||||||
|
SOATtl = ns.Header().Ttl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.Answer = make([]dns.RR, 0, len(resp.Answer))
|
||||||
|
// convert A records to AAAA records
|
||||||
|
// and vice-versa
|
||||||
|
for _, rr := range resp.Answer {
|
||||||
|
header := rr.Header()
|
||||||
|
// 5.3.3: All other RR's MUST be returned unchanged
|
||||||
|
if header.Rrtype != dns.TypeAAAA {
|
||||||
|
ret.Answer = append(ret.Answer, rr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Rrtype == dns.TypeAAAA {
|
||||||
|
a, mapped := to4(d.Eam4, d.IPv6Prefix, rr.(*dns.AAAA).AAAA)
|
||||||
|
if !mapped {
|
||||||
|
// No EAM entry and address isn't in ipv6_prefix — nothing to synthesize
|
||||||
|
// for this record; skip it rather than emitting an empty-RDATA A record.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedAny = true
|
||||||
|
|
||||||
|
// ttl is min of SOA TTL and A TTL
|
||||||
|
ttl := min(rr.Header().Ttl, SOATtl)
|
||||||
|
|
||||||
|
// Replace AAAA answer with a SIIT A answer
|
||||||
|
ret.Answer = append(ret.Answer, &dns.A{
|
||||||
|
Hdr: dns.RR_Header{
|
||||||
|
Name: header.Name,
|
||||||
|
Rrtype: dns.TypeA,
|
||||||
|
Class: header.Class,
|
||||||
|
Ttl: ttl,
|
||||||
|
},
|
||||||
|
A: a.To16(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !mappedAny {
|
||||||
|
return origResponse, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ret, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractIPv4 reverses CoreDNS's dns64 embedding logic: given a v6 address
|
||||||
|
// that was built by embedding a v4 address into prefix, it extracts that
|
||||||
|
// v4 address back out. prefix must be a valid NAT64 prefix length per
|
||||||
|
// RFC 6052 (/32, /40, /48, /56, /64, or /96).
|
||||||
|
func extractIPv4(v6 net.IP, prefix *net.IPNet) net.IP {
|
||||||
|
n, _ := prefix.Mask.Size()
|
||||||
|
v6 = v6.To16()
|
||||||
|
|
||||||
|
addr := make([]byte, 4)
|
||||||
|
i, j := n/8, 0 // skip the prefix bytes, we don't need them back
|
||||||
|
|
||||||
|
for ; i < 8; i, j = i+1, j+1 {
|
||||||
|
addr[j] = v6[i]
|
||||||
|
}
|
||||||
|
if i == 8 {
|
||||||
|
i++ // skip the reserved "u" byte
|
||||||
|
}
|
||||||
|
for ; j < 4; i, j = i+1, j+1 {
|
||||||
|
addr[j] = v6[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return net.IP(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// to4 takes an IPv6 address and an eam and returns an IPv4 address.
|
||||||
|
func to4(eam map[string]net.IP, ipv6prefix *net.IPNet, addr net.IP) (net.IP, bool) {
|
||||||
|
addr = addr.To16()
|
||||||
|
if addr == nil || addr.To4() != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 7757 §3.3.2: search the EAM table first.
|
||||||
|
if eam[addr.String()] != nil {
|
||||||
|
v4 := eam[addr.String()]
|
||||||
|
return v4, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to RFC 6052 algorithmic translation only if no EAM matched.
|
||||||
|
if ipv6prefix.Contains(addr) {
|
||||||
|
// RFC 6052 §2.2: byte 8 (bits 64-71, the "u" octet) is reserved and
|
||||||
|
// MUST be zero. A correctly-configured prefix guarantees this for
|
||||||
|
// its own bits (validated at setup time), but for prefix lengths
|
||||||
|
// shorter than /96 that byte belongs to the address, not the
|
||||||
|
// prefix, and an upstream AAAA answer isn't required to zero it.
|
||||||
|
// Reject rather than silently translating from a malformed address.
|
||||||
|
if addr[8] != 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
v4 := extractIPv4(addr, ipv6prefix)
|
||||||
|
return v4, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
97
plugin/siit/siit_dns64_test.go
Normal file
97
plugin/siit/siit_dns64_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package siit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/coredns/coredns/plugin"
|
||||||
|
"github.com/coredns/coredns/plugin/dns64"
|
||||||
|
"github.com/coredns/coredns/plugin/pkg/dnstest"
|
||||||
|
"github.com/coredns/coredns/plugin/test"
|
||||||
|
"github.com/coredns/coredns/request"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// emptyBackend always answers NOERROR/no-data for whatever it's asked.
|
||||||
|
type emptyBackend struct{}
|
||||||
|
|
||||||
|
func (emptyBackend) Name() string { return "empty" }
|
||||||
|
func (emptyBackend) ServeDNS(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(r)
|
||||||
|
m.Rcode = dns.RcodeSuccess
|
||||||
|
w.WriteMsg(m)
|
||||||
|
return dns.RcodeSuccess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reentrantUpstream re-invokes top.ServeDNS for every Lookup call, exactly
|
||||||
|
// as CoreDNS's real Upstream/lookup plugin re-enters the whole server. It
|
||||||
|
// fails the test if the recursion depth exceeds a small bound, so a
|
||||||
|
// regression shows up as a test failure instead of a hang/stack overflow.
|
||||||
|
type reentrantUpstream struct {
|
||||||
|
t *testing.T
|
||||||
|
top plugin.Handler
|
||||||
|
calls *int
|
||||||
|
maxOK int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u reentrantUpstream) Lookup(ctx context.Context, state request.Request, name string, typ uint16) (*dns.Msg, error) {
|
||||||
|
*u.calls++
|
||||||
|
if *u.calls > u.maxOK {
|
||||||
|
u.t.Fatalf("recursion exceeded bound: %d internal lookups (siit/dns64 re-entering each other)", *u.calls)
|
||||||
|
}
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion(name, typ)
|
||||||
|
rec := dnstest.NewRecorder(state.W)
|
||||||
|
_, err := u.top.ServeDNS(ctx, rec, m)
|
||||||
|
return rec.Msg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSIITDNS64NoRecursion reproduces the reported handler order
|
||||||
|
// (dns64 -> siit -> empty backend) and asserts the exchange completes
|
||||||
|
// without unbounded internal recursion. It's parameterized over both the
|
||||||
|
// IPv6-client / default-dns64 path and the IPv4-client / allow_ipv4 path,
|
||||||
|
// since they exercise different branches of dns64's interception logic and
|
||||||
|
// a fixed ResponseWriter family would silently only cover one of them.
|
||||||
|
func TestSIITDNS64NoRecursion(t *testing.T) {
|
||||||
|
_, prefix, _ := net.ParseCIDR("64:ff9b::/96")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rw dns.ResponseWriter
|
||||||
|
allowIPv4 bool
|
||||||
|
}{
|
||||||
|
{"IPv6 client, default dns64", &test.ResponseWriter6{}, false},
|
||||||
|
{"IPv4 client, allow_ipv4", &test.ResponseWriter{}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
|
||||||
|
s := &SIIT{IPv6Prefix: prefix, Next: emptyBackend{}}
|
||||||
|
d := &dns64.DNS64{Prefix: prefix, AllowIPv4: tc.allowIPv4, Next: s}
|
||||||
|
|
||||||
|
up := reentrantUpstream{t: t, top: d, calls: &calls, maxOK: 4}
|
||||||
|
s.Upstream = up
|
||||||
|
d.Upstream = up
|
||||||
|
|
||||||
|
r := new(dns.Msg)
|
||||||
|
r.SetQuestion("example.org.", dns.TypeA)
|
||||||
|
|
||||||
|
rec := dnstest.NewRecorder(tc.rw)
|
||||||
|
rc, err := d.ServeDNS(context.Background(), rec, r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ServeDNS returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rc != dns.RcodeSuccess {
|
||||||
|
t.Fatalf("unexpected rcode: %v", rc)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("expected exactly 2 internal lookups (siit's AAAA lookup + dns64's nested A lookup), got %d", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
1164
plugin/siit/siit_test.go
Normal file
1164
plugin/siit/siit_test.go
Normal file
File diff suppressed because it is too large
Load Diff
80
test/siit_test.go
Normal file
80
test/siit_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/coredns/coredns/plugin/pkg/dnstest"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSIITAfterForward is a regression test for the plugin.cfg ordering bug:
|
||||||
|
// siit must run AFTER forward in the plugin chain so it can see (and rewrite)
|
||||||
|
// the AAAA answer forward returns, rather than running before it and never
|
||||||
|
// seeing the final response.
|
||||||
|
//
|
||||||
|
// Unlike the unit tests in plugin/siit, which instantiate SIIT directly and
|
||||||
|
// wire d.Next/d.Upstream to fakes, this test drives the real plugin chain
|
||||||
|
// built from plugin.cfg via a Corefile, so a future ordering regression
|
||||||
|
// (siit placed after cache/forward again) will fail here even though it
|
||||||
|
// can't be detected by the package-level unit tests.
|
||||||
|
func TestSIITAfterForward(t *testing.T) {
|
||||||
|
// Upstream "authoritative" server: NODATA for A, a mapped AAAA for AAAA.
|
||||||
|
upstream := dnstest.NewServer(func(w dns.ResponseWriter, r *dns.Msg) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(r)
|
||||||
|
|
||||||
|
switch r.Question[0].Qtype {
|
||||||
|
case dns.TypeA:
|
||||||
|
// NODATA: success, no answer section -- this is what makes
|
||||||
|
// siit's responseShouldSIIT return true.
|
||||||
|
case dns.TypeAAAA:
|
||||||
|
rr, err := dns.NewRR("example.org. 60 IN AAAA 64:ff9b::192.0.2.42")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to build AAAA record: %v", err)
|
||||||
|
}
|
||||||
|
m.Answer = []dns.RR{rr}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteMsg(m)
|
||||||
|
})
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
corefile := `example.org:0 {
|
||||||
|
siit {
|
||||||
|
ipv6_prefix 64:ff9b::/96
|
||||||
|
}
|
||||||
|
forward . ` + upstream.Addr + `
|
||||||
|
}`
|
||||||
|
|
||||||
|
server, udp, _, err := CoreDNSServerAndPorts(corefile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not start CoreDNS test server: %v", err)
|
||||||
|
}
|
||||||
|
defer server.Stop()
|
||||||
|
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion("example.org.", dns.TypeA)
|
||||||
|
|
||||||
|
resp, err := dns.Exchange(m, udp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Rcode != dns.RcodeSuccess {
|
||||||
|
t.Fatalf("expected RcodeSuccess, got %s", dns.RcodeToString[resp.Rcode])
|
||||||
|
}
|
||||||
|
if len(resp.Answer) != 1 {
|
||||||
|
t.Fatalf("expected 1 answer record, got %d: %v", len(resp.Answer), resp.Answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
a, ok := resp.Answer[0].(*dns.A)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an A record in the answer, got %T: %v", resp.Answer[0], resp.Answer[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "192.0.2.42"
|
||||||
|
if a.A.String() != want {
|
||||||
|
t.Errorf("expected synthesized A record %s, got %s -- if this is empty/unset, siit likely ran before forward in the plugin chain and never saw the upstream's AAAA answer", want, a.A.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user