summaryrefslogtreecommitdiffstats
path: root/modules/dns64/dns64.lua
blob: b4fb1ecb7d1d78f6fbd40a9f19bac4e4b3f552b9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
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