summaryrefslogtreecommitdiffstats
path: root/modules/dns64
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-08 20:37:50 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-08 20:37:50 +0000
commitc1f743ab2e4a7046d5500875a47d1f62c8624603 (patch)
tree709946d52f5f3bbaeb38be9e3f1d56d11f058237 /modules/dns64
parentInitial commit. (diff)
downloadknot-resolver-upstream/5.7.1.tar.xz
knot-resolver-upstream/5.7.1.zip
Adding upstream version 5.7.1.upstream/5.7.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'modules/dns64')
-rw-r--r--modules/dns64/.packaging/test.config4
-rw-r--r--modules/dns64/README.rst62
-rw-r--r--modules/dns64/dns64.lua220
-rw-r--r--modules/dns64/dns64.test.lua53
4 files changed, 339 insertions, 0 deletions
diff --git a/modules/dns64/.packaging/test.config b/modules/dns64/.packaging/test.config
new file mode 100644
index 0000000..5abf524
--- /dev/null
+++ b/modules/dns64/.packaging/test.config
@@ -0,0 +1,4 @@
+-- SPDX-License-Identifier: GPL-3.0-or-later
+modules.load('dns64')
+assert(dns64)
+quit()
diff --git a/modules/dns64/README.rst b/modules/dns64/README.rst
new file mode 100644
index 0000000..04d2427
--- /dev/null
+++ b/modules/dns64/README.rst
@@ -0,0 +1,62 @@
+.. SPDX-License-Identifier: GPL-3.0-or-later
+
+.. _mod-dns64:
+
+DNS64
+=====
+
+The module for :rfc:`6147` DNS64 AAAA-from-A record synthesis, it is used to enable client-server communication between an IPv6-only client and an IPv4-only server. See the well written `introduction`_ in the PowerDNS documentation.
+If no address is passed (i.e. ``nil``), the well-known prefix ``64:ff9b::`` is used.
+
+.. _introduction: https://doc.powerdns.com/md/recursor/dns64
+
+Simple example
+--------------
+
+.. code-block:: lua
+
+ -- Load the module with default settings
+ modules = { 'dns64' }
+ -- Reconfigure later
+ dns64.config({ prefix = '2001:db8::aabb:0:0' })
+
+.. warning:: The module currently won't work well with :func:`policy.STUB`.
+ Also, the IPv6 ``prefix`` passed in configuration is assumed to be ``/96``.
+
+.. tip:: The A record sub-requests will be DNSSEC secured, but the synthetic AAAA records can't be. Make sure the last mile between stub and resolver is secure to avoid spoofing.
+
+
+Advanced options
+----------------
+
+TTL in CNAME generated in the reverse ``ip6.arpa.`` subtree is configurable:
+
+.. code-block:: lua
+
+ dns64.config({ prefix = '2001:db8:77ff::', rev_ttl = 300 })
+
+You can specify a set of IPv6 subnets that are disallowed in answer.
+If they appear, they will be replaced by AAAAs generated from As.
+
+.. code-block:: lua
+
+ dns64.config({
+ prefix = '2001:db8:3::',
+ exclude_subnets = { '2001:db8:888::/48', '::ffff/96' },
+ })
+ -- You could even pass '::/0' to always force using generated AAAAs.
+
+In case you don't want dns64 for all clients,
+you can set ``DNS64_DISABLE`` flag via the :ref:`view module <mod-view>`.
+
+.. code-block:: lua
+
+ modules = { 'dns64', 'view' }
+ -- disable dns64 for all IPv4 source addresses
+ view:addr('0.0.0.0/0', policy.all(policy.FLAGS('DNS64_DISABLE')))
+ -- disable dns64 for all IPv6 source addresses
+ view:addr('::/0', policy.all(policy.FLAGS('DNS64_DISABLE')))
+ -- re-enable dns64 for two IPv6 subnets
+ view:addr('2001:db8:11::/48', policy.all(policy.FLAGS(nil, 'DNS64_DISABLE')))
+ view:addr('2001:db8:93::/48', policy.all(policy.FLAGS(nil, 'DNS64_DISABLE')))
+
diff --git a/modules/dns64/dns64.lua b/modules/dns64/dns64.lua
new file mode 100644
index 0000000..b4fb1ec
--- /dev/null
+++ b/modules/dns64/dns64.lua
@@ -0,0 +1,220 @@
+-- SPDX-License-Identifier: GPL-3.0-or-later
+-- Module interface
+local kres = require('kres')
+local ffi = require('ffi')
+local C = ffi.C
+local M = { layer = { } }
+local addr_buf = ffi.new('char[16]')
+
+--[[
+Missing parts of the RFC:
+ > The implementation SHOULD support mapping of separate IPv4 address
+ > ranges to separate IPv6 prefixes for AAAA record synthesis. This
+ > allows handling of special use IPv4 addresses [RFC5735].
+
+ TODO: support different prefix lengths, defaulting to /96 if not specified
+ https://tools.ietf.org/html/rfc6052#section-2.2
+]]
+
+-- Config
+function M.config(conf)
+ if type(conf) ~= 'table' then
+ conf = { prefix = conf }
+ end
+ M.proxy = kres.str2ip(tostring(conf.prefix or '64:ff9b::'))
+ if M.proxy == nil or #M.proxy ~= 16 then
+ error(string.format('[dns64] %q is not a valid IPv6 address', conf.prefix), 2)
+ end
+
+ M.rev_ttl = conf.rev_ttl or 60
+ M.rev_suffix = kres.str2dname(M.proxy
+ :sub(1, 96/8)
+ -- hexdump, reverse, intersperse by dots
+ :gsub('.', function (ch) return string.format('%02x', string.byte(ch)) end)
+ :reverse()
+ :gsub('.', '%1.')
+ .. 'ip6.arpa.'
+ )
+
+ -- RFC 6147.5.1.4
+ M.exclude_subnets = {}
+ if conf.exclude_subnets ~= nil and type(conf.exclude_subnets) ~= 'table' then
+ error('[dns64] .exclude_subnets is not a table')
+ end
+ for _, subnet_cfg in ipairs(conf.exclude_subnets or { '::ffff/96' }) do
+ local subnet = {}
+ subnet.prefix = ffi.new('char[16]')
+ subnet.bitlen = C.kr_straddr_subnet(subnet.prefix, tostring(subnet_cfg))
+ if subnet.bitlen < 0 or not string.find(subnet_cfg, ':', 1, true) then
+ error(string.format('[dns64] failed to parse IPv6 subnet: %q', subnet_cfg))
+ end
+ table.insert(M.exclude_subnets, subnet)
+ end
+end
+
+-- Filter the AAAA records from the last ANSWER, return iff it's NODATA afterwards.
+-- Currently the implementation is lazy and kills it all if any AAAA is excluded.
+local function do_exclude_prefixes(qry)
+ local rrsel = qry.request.answ_selected
+ for i = 0, tonumber(rrsel.len) - 1 do
+ local rr_e = rrsel.at[i] -- struct ranked_rr_array_entry
+ if rr_e.qry_uid ~= qry.uid or rr_e.rr.type ~= kres.type.AAAA or not rr_e.to_wire
+ then goto next_rrset end
+ -- Found answer AAAA RRset
+ for _, subnet in ipairs(M.exclude_subnets) do
+ for j = 0, rr_e.rr:rdcount() - 1 do
+ local rd = rr_e.rr:rdata_pt(j)
+ if rd.len == 16 and C.kr_bitcmp(subnet.prefix, rd.data, subnet.bitlen) == 0 then
+ -- We can't use this RR. TODO: and we're lazy,
+ -- so we kill the whole RRset instead of filtering.
+ rr_e.to_wire = false
+ return true
+ end
+ end
+ end
+ -- We can use the answer -> return false
+ -- We use a nonsensical if to fool the parser; is return adjacent to a label forbidden?
+ if true then return false end
+
+ ::next_rrset::
+ end
+ -- No RRset found, it was probably NODATA.
+ return true
+end
+
+function M.layer.consume(state, req, pkt)
+ if state == kres.FAIL then return state end
+ local qry = req:current()
+ -- Observe only final answers in IN class where request has no CD flag.
+ if M.proxy == nil or not qry.flags.RESOLVED or qry.flags.DNS64_DISABLE
+ or pkt:qclass() ~= kres.class.IN or req.qsource.packet:cd() then
+ return state
+ end
+
+ -- Observe final AAAA NODATA responses to the current SNAME.
+ if pkt:qtype() == kres.type.AAAA and pkt:qname() == qry:name()
+ and qry.flags.RESOLVED and not qry.flags.CNAME and qry.parent == nil
+ and pkt:rcode() == kres.rcode.NOERROR and do_exclude_prefixes(qry) then
+ -- Start a *marked* corresponding A sub-query.
+ local extraFlags = kres.mk_qflags({})
+ extraFlags.DNSSEC_WANT = qry.flags.DNSSEC_WANT
+ extraFlags.AWAIT_CUT = true
+ extraFlags.DNS64_MARK = true
+ req:push(pkt:qname(), kres.type.A, kres.class.IN, extraFlags, qry)
+ return state
+ end
+
+
+ -- Observe answer to the marked sub-query, and convert all A records in ANSWER
+ -- to corresponding AAAA records to be put into the request's answer.
+ if not qry.flags.DNS64_MARK then return state end
+ -- Find rank for the NODATA answer.
+ -- That will result into corresponding AD flag. See RFC 6147 5.5.2.
+ local neg_rank
+ if qry.parent.flags.DNSSEC_WANT and not qry.parent.flags.DNSSEC_INSECURE
+ then neg_rank = ffi.C.KR_RANK_SECURE
+ else neg_rank = ffi.C.KR_RANK_INSECURE
+ end
+ -- Find TTL bound from SOA, according to RFC 6147 5.1.7.4.
+ local max_ttl = 600
+ for i = 1, tonumber(req.auth_selected.len) do
+ local entry = req.auth_selected.at[i - 1]
+ if entry.qry_uid == qry.parent.uid and entry.rr
+ and entry.rr.type == kres.type.SOA
+ and entry.rr.rclass == kres.class.IN then
+ max_ttl = entry.rr:ttl()
+ end
+ end
+ -- Find the As and do the conversion itself.
+ for i = 1, tonumber(req.answ_selected.len) do
+ local orig = req.answ_selected.at[i - 1]
+ if orig.qry_uid == qry.uid and orig.rr.type == kres.type.A then
+ local rank = neg_rank
+ if orig.rank < rank then rank = orig.rank end
+ -- Disable GC, as this object doesn't own owner or RDATA, it's just a reference
+ local ttl = orig.rr:ttl()
+ if ttl > max_ttl then ttl = max_ttl end
+ local rrs = ffi.gc(kres.rrset(nil, kres.type.AAAA, orig.rr.rclass, ttl), nil)
+ rrs._owner = orig.rr._owner
+ for k = 1, orig.rr.rrs.count do
+ local rdata = orig.rr:rdata( k - 1 )
+ ffi.copy(addr_buf, M.proxy, 12)
+ ffi.copy(addr_buf + 12, rdata, 4)
+ ffi.C.knot_rrset_add_rdata(rrs, ffi.string(addr_buf, 16), 16, req.pool)
+ end
+ ffi.C.kr_ranked_rrarray_add(
+ req.answ_selected,
+ rrs,
+ rank,
+ true,
+ qry.uid,
+ req.pool)
+ end
+ end
+ ffi.C.kr_ranked_rrarray_finalize(req.answ_selected, qry.uid, req.pool)
+ req:set_extended_error(kres.extended_error.FORGED, "BHD4: DNS64 synthesis")
+end
+
+local function hexchar2int(char)
+ if char >= string.byte('0') and char <= string.byte('9') then
+ return char - string.byte('0')
+ elseif char >= string.byte('a') and char <= string.byte('f') then
+ return 10 + char - string.byte('a')
+ else
+ return nil
+ end
+end
+
+-- Map the reverse subtree by generating CNAMEs; similarly to the hints module.
+--
+-- RFC 6147.5.3.1.2 says we SHOULD only generate CNAME if it points to data,
+-- but I can't see what's wrong with a CNAME to an NXDOMAIN/NODATA
+-- Reimplementation idea: as-if we had a DNAME in policy/cache?
+function M.layer.produce(_, req, pkt)
+ local qry = req.current_query
+ local sname = qry.sname
+ if ffi.C.knot_dname_in_bailiwick(sname, M.rev_suffix) < 0 or qry.flags.DNS64_DISABLE
+ then return end
+ -- Update packet question if it was minimized.
+ qry.flags.NO_MINIMIZE = true
+ if not ffi.C.knot_dname_is_equal(pkt.wire + 12, sname) or not pkt:has_wire() then
+ if not pkt:recycle() or not pkt:question(sname, qry.sclass, qry.stype)
+ then return end
+ end
+
+ -- Generate a CNAME iff the full address is queried; otherwise leave NODATA.
+ local labels_missing = 16*2 + 2 - ffi.C.knot_dname_labels(sname, nil)
+ if labels_missing == 0 then
+ -- Transforming v6 labels (hex) to v4 ones (decimal) isn't trivial:
+ local labels = sname
+ local v4name = ''
+ for _ = 1, 4 do -- append one IPv4 label at a time into v4name
+ local v4octet = 0
+ for i = 0, 1 do
+ if labels[0] ~= 1 then return end
+ local ch = hexchar2int(labels[1])
+ if not ch then return end
+ v4octet = v4octet + ch * 16^i
+ labels = labels + 2
+ end
+ v4octet = tostring(v4octet)
+ v4name = v4name .. string.char(#v4octet) .. v4octet
+ end
+ v4name = v4name .. '\7in-addr\4arpa\0'
+ if not pkt:put(sname, M.rev_ttl, kres.class.IN, kres.type.CNAME, v4name)
+ then return end
+ end
+
+ -- Simple finishing touches.
+ if labels_missing < 0 then -- and use NXDOMAIN for too long queries
+ pkt:rcode(kres.rcode.NXDOMAIN)
+ else
+ pkt:rcode(kres.rcode.NOERROR)
+ end
+ pkt.parsed = pkt.size
+ pkt:aa(true)
+ pkt:qr(true)
+ qry.flags.CACHED = true
+end
+
+return M
diff --git a/modules/dns64/dns64.test.lua b/modules/dns64/dns64.test.lua
new file mode 100644
index 0000000..45956a4
--- /dev/null
+++ b/modules/dns64/dns64.test.lua
@@ -0,0 +1,53 @@
+-- SPDX-License-Identifier: GPL-3.0-or-later
+local condition = require('cqueues.condition')
+
+-- setup resolver
+modules = { 'hints', 'dns64' }
+hints['dns64.example'] = '192.168.1.1'
+hints.use_nodata(true) -- Respond NODATA to AAAA query
+hints.ttl(60)
+dns64.config('fe80::21b:77ff:0:0')
+
+-- helper to wait for query resolution
+local function wait_resolve(qname, qtype)
+ local waiting, done, cond = false, false, condition.new()
+ local rcode, answers = kres.rcode.SERVFAIL, {}
+ resolve {
+ name = qname,
+ type = qtype,
+ finish = function (answer, _)
+ rcode = answer:rcode()
+ answers = answer:section(kres.section.ANSWER)
+ -- Signal as completed
+ if waiting then
+ cond:signal()
+ end
+ done = true
+ end,
+ }
+ -- Wait if it didn't finish immediately
+ if not done then
+ waiting = true
+ cond:wait()
+ end
+ return rcode, answers
+end
+
+-- test builtin rules
+local function test_builtin_rules()
+ local rcode, answers = wait_resolve('dns64.example', kres.type.AAAA)
+ same(rcode, kres.rcode.NOERROR, 'dns64.example returns NOERROR')
+ same(#answers, 1, 'dns64.example synthesised answer')
+ local expect = {'dns64.example.', '60', 'AAAA', 'fe80::21b:77ff:c0a8:101'}
+ if #answers > 0 then
+ local rr = {kres.rr2str(answers[1]):match('(%S+)%s+(%S+)%s+(%S+)%s+(%S+)')}
+ same(rr, expect, 'dns64.example synthesised correct AAAA record')
+ end
+end
+
+-- plan tests
+local tests = {
+ test_builtin_rules,
+}
+
+return tests