-- 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