summaryrefslogtreecommitdiffstats
path: root/modules/renumber
diff options
context:
space:
mode:
Diffstat (limited to 'modules/renumber')
-rw-r--r--modules/renumber/.packaging/test.config4
-rw-r--r--modules/renumber/README.rst36
-rw-r--r--modules/renumber/renumber.lua181
-rw-r--r--modules/renumber/renumber.test.lua103
4 files changed, 324 insertions, 0 deletions
diff --git a/modules/renumber/.packaging/test.config b/modules/renumber/.packaging/test.config
new file mode 100644
index 0000000..37f136a
--- /dev/null
+++ b/modules/renumber/.packaging/test.config
@@ -0,0 +1,4 @@
+-- SPDX-License-Identifier: GPL-3.0-or-later
+modules.load('renumber')
+assert(renumber)
+quit()
diff --git a/modules/renumber/README.rst b/modules/renumber/README.rst
new file mode 100644
index 0000000..2e68991
--- /dev/null
+++ b/modules/renumber/README.rst
@@ -0,0 +1,36 @@
+.. SPDX-License-Identifier: GPL-3.0-or-later
+
+.. _mod-renumber:
+
+IP address renumbering
+======================
+
+The module renumbers addresses in answers to different address space.
+e.g. you can redirect malicious addresses to a blackhole, or use private address ranges
+in local zones, that will be remapped to real addresses by the resolver.
+
+
+.. warning:: While requests are still validated using DNSSEC, the signatures
+ are stripped from final answer. The reason is that the address synthesis
+ breaks signatures. You can see whether an answer was valid or not based on
+ the AD flag.
+
+Example configuration
+---------------------
+
+.. code-block:: lua
+
+ modules = {
+ renumber = {
+ -- Source subnet, destination subnet
+ {'10.10.10.0/24', '192.168.1.0'},
+ -- Remap /16 block to localhost address range
+ {'166.66.0.0/16', '127.0.0.0'},
+ -- Remap /26 subnet (64 ip addresses)
+ {'166.55.77.128/26', '127.0.0.192'},
+ -- Remap a /32 block to a single address
+ {'2001:db8::/32', '::1!'},
+ }
+ }
+
+.. TODO: renumber.name() hangs in vacuum, kind of. No occurrences in code or docs, and probably bad UX.
diff --git a/modules/renumber/renumber.lua b/modules/renumber/renumber.lua
new file mode 100644
index 0000000..60803d5
--- /dev/null
+++ b/modules/renumber/renumber.lua
@@ -0,0 +1,181 @@
+-- SPDX-License-Identifier: GPL-3.0-or-later
+-- Module interface
+local ffi = require('ffi')
+local prefixes_global = {}
+
+-- get address from config: either subnet prefix or fixed endpoint
+local function extract_address(target)
+ local idx = string.find(target, "!", 1, true)
+ if idx == nil then
+ return target, false
+ end
+ if idx ~= #target then
+ error("[renumber] \"!\" symbol in target is only accepted at the end of address")
+ end
+ return string.sub(target, 1, idx - 1), true
+end
+
+-- Create bitmask from integer mask for single octet: 2 -> 11000000
+local function getOctetBitmask(intMask)
+ return bit.lshift(bit.rshift(255, 8 - intMask), 8 - intMask)
+end
+
+-- Merge ipNet with ipHost, using intMask
+local function mergeIps(ipNet, ipHost, intMask)
+ local octetMask
+ local result = ""
+
+ if #ipNet ~= #ipHost then
+ return nil
+ end
+
+ if intMask == nil then
+ return ipNet
+ end
+
+ for currentOctetNo = 1, #ipNet do
+ if intMask >= 8 then
+ result = result .. ipNet:sub(currentOctetNo,currentOctetNo)
+ elseif (intMask <= 0) then
+ result = result .. ipHost:sub(currentOctetNo,currentOctetNo)
+ else
+ octetMask = getOctetBitmask(intMask)
+ result = result .. string.char(bit.bor(
+ bit.band(string.byte(ipNet:sub(currentOctetNo,currentOctetNo)), octetMask),
+ bit.band(string.byte(ipHost:sub(currentOctetNo,currentOctetNo)), bit.bnot(octetMask))
+ ))
+ end
+ intMask = intMask - 8
+ end
+
+ return result
+end
+
+-- Create subnet prefix rule
+local function matchprefix(subnet, addr)
+ local is_exact
+ addr, is_exact = extract_address(addr)
+ local target = kres.str2ip(addr)
+ if target == nil then error('[renumber] invalid address: '..addr) end
+ local addrtype = string.find(addr, ':', 1, true) and kres.type.AAAA or kres.type.A
+ local subnet_cd = ffi.new('char[16]')
+ local bitlen = ffi.C.kr_straddr_subnet(subnet_cd, subnet)
+ if bitlen < 0 then error('[renumber] invalid subnet: '..subnet) end
+ return {subnet_cd, bitlen, target, addrtype, is_exact}
+end
+
+-- Create name match rule
+local function matchname(name, addr)
+ local is_exact
+ addr, is_exact = extract_address(addr) -- though matchname() always leads to replacing whole address
+ local target = kres.str2ip(addr)
+ if target == nil then error('[renumber] invalid address: '..addr) end
+ local owner = todname(name)
+ if not name then error('[renumber] invalid name: '..name) end
+ local addrtype = string.find(addr, ':', 1, true) and kres.type.AAAA or kres.type.A
+ return {owner, nil, target, addrtype, is_exact}
+end
+
+-- Add subnet prefix rewrite rule
+local function add_prefix(subnet, addr)
+ local prefix = matchprefix(subnet, addr)
+ table.insert(prefixes_global, prefix)
+end
+
+-- Match IP against given subnet or record owner
+local function match_subnet(subnet, bitlen, addrtype, rr)
+ local addr = rr.rdata
+ return addrtype == rr.type and
+ ((bitlen and (#addr >= bitlen / 8) and (ffi.C.kr_bitcmp(subnet, addr, bitlen) == 0)) or subnet == rr.owner)
+end
+
+-- Renumber address record
+local function renumber_record(tbl, rr)
+ for i = 1, #tbl do
+ local prefix = tbl[i]
+ local subnet = prefix[1]
+ local bitlen = prefix[2]
+ local target = prefix[3]
+ local addrtype = prefix[4]
+ local is_exact = prefix[5]
+
+ -- Match record type to address family and record address to given subnet
+ -- If provided, compare record owner to prefix name
+ if match_subnet(subnet, bitlen, addrtype, rr) then
+ if is_exact then
+ rr.rdata = target
+ else
+ local mergedHost = mergeIps(target, rr.rdata, bitlen)
+ if mergedHost ~= nil then rr.rdata = mergedHost end
+ end
+
+ return rr
+ end
+ end
+ return nil
+end
+
+-- Renumber addresses based on config
+local function rule(prefixes)
+ return function (state, req)
+ if state == kres.FAIL then return state end
+ local pkt = req.answer
+ -- Only successful answers
+ local records = pkt:section(kres.section.ANSWER)
+ local ancount = #records
+ if ancount == 0 then return state end
+ -- Find renumber candidates
+ local changed = false
+ for i = 1, ancount do
+ local rr = records[i]
+ if rr.type == kres.type.A or rr.type == kres.type.AAAA then
+ local new_rr = renumber_record(prefixes, rr)
+ if new_rr ~= nil then
+ records[i] = new_rr
+ changed = true
+ end
+ end
+ end
+ -- If not rewritten, chain action
+ if not changed then return state end
+ -- Replace section if renumbering
+ local qname = pkt:qname()
+ local qclass = pkt:qclass()
+ local qtype = pkt:qtype()
+ pkt:recycle()
+ pkt:question(qname, qclass, qtype)
+ for i = 1, ancount do
+ local rr = records[i]
+ -- Strip signatures as rewritten data cannot be validated
+ if rr.type ~= kres.type.RRSIG then
+ pkt:put(rr.owner, rr.ttl, rr.class, rr.type, rr.rdata)
+ end
+ end
+ req:set_extended_error(kres.extended_error.FORGED, "DUQR")
+ return state
+ end
+end
+
+-- Export module interface
+local M = {
+ prefix = matchprefix,
+ name = matchname,
+ rule = rule,
+ match_subnet = match_subnet,
+}
+
+-- Config
+function M.config (conf)
+ if conf == nil then return end
+ if type(conf) ~= 'table' or type(conf[1]) ~= 'table' then
+ error('[renumber] expected { {prefix, target}, ... }')
+ end
+ for i = 1, #conf do add_prefix(conf[i][1], conf[i][2]) end
+end
+
+-- Layers
+M.layer = {
+ finish = rule(prefixes_global),
+}
+
+return M
diff --git a/modules/renumber/renumber.test.lua b/modules/renumber/renumber.test.lua
new file mode 100644
index 0000000..97d6a6f
--- /dev/null
+++ b/modules/renumber/renumber.test.lua
@@ -0,0 +1,103 @@
+local function gen_rrset(owner, rrtype, rdataset)
+ assert(type(rdataset) == 'table' or type(rdataset) == 'string')
+ if type(rdataset) ~= 'table' then
+ rdataset = { rdataset }
+ end
+ local rrset = kres.rrset(todname(owner), rrtype, kres.class.IN, 3600)
+ for _, rdata in pairs(rdataset) do
+ assert(rrset:add_rdata(rdata, #rdata))
+ end
+ return rrset
+end
+
+local function prepare_cache()
+ cache.open(100*MB)
+ cache.clear()
+
+ local ffi = require('ffi')
+ local c = kres.context().cache
+
+ assert(c:insert(
+ gen_rrset('a10-0.test.',
+ kres.type.A, kres.str2ip('10.0.0.1')),
+ nil, ffi.C.KR_RANK_SECURE + ffi.C.KR_RANK_AUTH))
+ assert(c:insert(
+ gen_rrset('a10-2.test.',
+ kres.type.A, kres.str2ip('10.2.0.1')),
+ nil, ffi.C.KR_RANK_SECURE + ffi.C.KR_RANK_AUTH))
+ assert(c:insert(
+ gen_rrset('a10-0plus2.test.',
+ kres.type.A, {
+ kres.str2ip('10.0.0.1'),
+ kres.str2ip('10.2.0.1')
+ }),
+ nil, ffi.C.KR_RANK_SECURE + ffi.C.KR_RANK_AUTH))
+ assert(c:insert(
+ gen_rrset('a10-3plus4.test.',
+ kres.type.A, {
+ kres.str2ip('10.3.0.1'),
+ kres.str2ip('10.4.0.1')
+ }),
+ nil, ffi.C.KR_RANK_SECURE + ffi.C.KR_RANK_AUTH))
+ assert(c:insert(
+ gen_rrset('a166-66.test.',
+ kres.type.A, kres.str2ip('166.66.42.123')),
+ nil, ffi.C.KR_RANK_SECURE + ffi.C.KR_RANK_AUTH))
+ assert(c:insert(
+ gen_rrset('a167-81.test.',
+ kres.type.A, kres.str2ip('167.81.254.221')),
+ nil, ffi.C.KR_RANK_SECURE + ffi.C.KR_RANK_AUTH))
+ assert(c:insert(
+ gen_rrset('aaaa-db8-1.test.',
+ kres.type.AAAA, {
+ kres.str2ip('2001:db8:1::1'),
+ kres.str2ip('2001:db8:1::2'),
+ }),
+ nil, ffi.C.KR_RANK_SECURE + ffi.C.KR_RANK_AUTH))
+
+ c:commit()
+end
+
+local check_answer = require('test_utils').check_answer
+
+local function test_renumber()
+ check_answer('unknown IPv4 range passes through unaffected',
+ 'a10-0.test.', kres.type.A, kres.rcode.NOERROR, '10.0.0.1')
+ check_answer('known IPv4 range is remapped when matching first-defined rule',
+ 'a10-2.test.', kres.type.A, kres.rcode.NOERROR, '192.168.2.1')
+ check_answer('mix of known and unknown IPv4 ranges is remapped correctly',
+ 'a10-0plus2.test.', kres.type.A, kres.rcode.NOERROR, {'192.168.2.1', '10.0.0.1'})
+ check_answer('mix of known and unknown IPv4 ranges is remapped correctly to exact address',
+ 'a10-3plus4.test.', kres.type.A, kres.rcode.NOERROR, {'10.3.0.1', '192.168.3.10'})
+ check_answer('known IPv4 range is remapped when matching second-defined rule',
+ 'a166-66.test.', kres.type.A, kres.rcode.NOERROR, '127.0.42.123')
+ check_answer('known IPv4 range is remapped when matching a rule with netmask not on a byte boundary',
+ 'a167-81.test.', kres.type.A, kres.rcode.NOERROR, {'127.0.30.221'})
+
+ check_answer('two AAAA records',
+ 'aaaa-db8-1.test.', kres.type.AAAA, kres.rcode.NOERROR,
+ {'2001:db8:2::2', '2001:db8:2::1'})
+end
+
+net.ipv4 = false
+net.ipv6 = false
+
+trust_anchors.remove('.')
+policy.add(policy.all(policy.DEBUG_ALWAYS))
+policy.add(policy.suffix(policy.PASS, {todname('test.')}))
+prepare_cache()
+
+log_level('debug')
+modules.load('renumber < cache')
+renumber.config({
+ -- Source subnet, destination subnet
+ {'10.2.0.0/24', '192.168.2.0'},
+ {'10.4.0.0/24', '192.168.3.10!'},
+ {'166.66.0.0/16', '127.0.0.0'},
+ {'167.81.255.0/19', '127.0.0.0'},
+ {'2001:db8:1::/48', '2001:db8:2::'},
+})
+
+return {
+ test_renumber,
+}