diff options
Diffstat (limited to 'modules/dns64')
-rw-r--r-- | modules/dns64/.packaging/test.config | 4 | ||||
-rw-r--r-- | modules/dns64/README.rst | 62 | ||||
-rw-r--r-- | modules/dns64/dns64.lua | 220 | ||||
-rw-r--r-- | modules/dns64/dns64.test.lua | 53 |
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 |