summaryrefslogtreecommitdiffstats
path: root/modules/dns64/dns64.lua
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--modules/dns64/dns64.lua220
1 files changed, 220 insertions, 0 deletions
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