├── views.yml ├── LICENSE.md ├── views.py └── README.md /views.yml: -------------------------------------------------------------------------------- 1 | redirect: 'rdr on wan0 proto tcp to {wan} -> {lan}' 2 | #ifs: 'WAN subnet(s)': 'LAN subnet(s)' 3 | lan0: 4 | '169.254.10.0/25': '192.168.10.0' 5 | '169.254.20.0/25': '192.168.20.0' 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # LICENSE: BSD3CLAUSE 2 | 3 | Copyright © 2012-2020, Yarema 4 | 5 | This software is open source. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions 9 | are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, 12 | this list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | * Neither the name of the organization nor the names of its 19 | contributors may be used to endorse or promote products derived from this 20 | software without specific prior written permission. 21 | 22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 23 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 24 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 25 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE 26 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 27 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 28 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 29 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 30 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 31 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 32 | POSSIBILITY OF SUCH DAMAGE. 33 | -------------------------------------------------------------------------------- /views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | '''views.py: Split Horizon rewriter plugin for the Unbound DNS resolver''' 3 | 4 | # Copyright © 2012-2020, Yarema 5 | # 6 | # This software is open source. 7 | # 8 | # Redistribution and use in source and binary forms, with or without 9 | # modification, are permitted provided that the following conditions 10 | # are met: 11 | # 12 | # * Redistributions of source code must retain the above copyright notice, 13 | # this list of conditions and the following disclaimer. 14 | # 15 | # * Redistributions in binary form must reproduce the above copyright notice, 16 | # this list of conditions and the following disclaimer in the documentation 17 | # and/or other materials provided with the distribution. 18 | # 19 | # * Neither the name of the organization nor the names of its 20 | # contributors may be used to endorse or promote products derived from this 21 | # software without specific prior written permission. 22 | # 23 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 24 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 25 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 26 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE 27 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 28 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 29 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 30 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 31 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 32 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 33 | # POSSIBILITY OF SUCH DAMAGE. 34 | 35 | import os 36 | import yaml 37 | from socket import inet_aton 38 | from netaddr import * 39 | 40 | views = {} # The Split Horizon dictionary 41 | 42 | def init(id, cfg): 43 | '''Populate the external to internal address mapping''' 44 | addrs = {} # IPv4 address(es) on internal (LAN) interface 45 | config = yaml.load(open(os.path.splitext(cfg.python_script.str)[0]+'.yml'), Loader=yaml.CSafeLoader) 46 | for ifs in config.keys(): 47 | if type(config[ifs]) is dict: 48 | for ip in [l.split()[1] for l in os.popen('/sbin/ifconfig %s 2>/dev/null' % ifs) if l.split()[0] == 'inet']: 49 | addrs[ip] = IPAddress(ip) # Collect IPv4 addresses configured on ifs 50 | for wan, lan in config[ifs].items(): 51 | lan = IPNetwork('%s/%s' % (lan.split('/')[0], wan.split('/')[1])) 52 | wan = IPNetwork(wan) 53 | for ip in addrs: 54 | if addrs[ip] in lan: 55 | # Key the views with a four byte binary of the external IPv4 address 56 | views.update(zip(map(inet_aton, map(str, wan)), map(str, lan))) 57 | return True 58 | 59 | def deinit(id): 60 | return True 61 | 62 | def inform_super(id, qstate, superqstate, qdata): 63 | return True 64 | 65 | def operate(id, event, qstate, qdata): 66 | '''Rewrite to internal address if iterator fetches an external address''' 67 | 68 | if event == MODULE_EVENT_NEW or event == MODULE_EVENT_PASS: 69 | # Pass on the new event to the iterator 70 | qstate.ext_state[id] = MODULE_WAIT_MODULE 71 | return True 72 | 73 | if event == MODULE_EVENT_MODDONE: 74 | # Iterator finished, show response (if any) 75 | if qstate.return_msg: 76 | r = qstate.return_msg.rep 77 | if r: 78 | for i in range(0, r.rrset_count): 79 | rr = r.rrsets[i] 80 | if rr.rk.type_str == 'A': 81 | d = rr.entry.data 82 | for j in range(0, d.count+d.rrsig_count): 83 | addr = d.rr_data[j][2:6] # The last four bytes contain the IPv4 address 84 | if addr in views: 85 | msg = DNSMessage(qstate.qinfo.qname_str, RR_TYPE_A, RR_CLASS_IN, PKT_QR | PKT_RA | PKT_AA) 86 | msg.answer.append('%s IN A %s' % (qstate.qinfo.qname_str, views[addr])) 87 | if msg.set_return_msg(qstate): 88 | qstate.return_msg.rep.security = 2 # we don't need validation, result is valid 89 | qstate.return_rcode = RCODE_NOERROR 90 | else: 91 | log_err("pythonmod: cannot create response") 92 | qstate.ext_state[id] = MODULE_ERROR 93 | return True 94 | qstate.ext_state[id] = MODULE_FINISHED 95 | return True 96 | 97 | log_err("pythonmod: bad event") 98 | qstate.ext_state[id] = MODULE_ERROR 99 | return True 100 | 101 | if __name__ == '__main__': 102 | config = yaml.load(open(os.path.splitext(__file__)[0]+'.yml'), Loader=yaml.CSafeLoader) 103 | if 'redirect' in config: 104 | redirect = config['redirect'] 105 | del config['redirect'] 106 | else: 107 | redirect = 'rdr on wan0 proto tcp to {wan} -> {lan}' 108 | for ifs in config.keys(): 109 | for wan, lan in config[ifs].items(): 110 | lan = IPNetwork('%s/%s' % (lan.split('/')[0], wan.split('/')[1])) 111 | wan = IPNetwork(wan) 112 | views.update(zip(map(str, wan), map(str, lan))) 113 | for wan in sorted(views, key=inet_aton): 114 | print(redirect.format(wan=wan, lan=views[wan])) 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Unbound-Views][] 2 | 3 | [Unbound-Views][] is a Split-Horizon Views plugin for the [Unbound][] DNS 4 | resolver. 5 | 6 | ## Same but different 7 | 8 | [PowerDNS-renumber.lua][PowerDNS-renumber] and the [Knot-Resolver renumber][Knot-renumber] module. 9 | 10 | ## Problem: [Redirection and Reflection][Reflection] 11 | 12 | Quoting from the [OpenBSD pf FAQ][Reflection]: 13 | 14 | > Often, redirection rules are used to forward incoming connections from 15 | > the Internet to a local server with a private address in the internal 16 | > network or LAN, as in: 17 | > 18 | > server = 192.168.1.40 19 | > 20 | > pass in on $ext_if proto tcp from any to $ext_if port 80 \ 21 | > rdr-to $server port 80 22 | > 23 | > But when the redirection rule is tested from a client on the LAN, it 24 | > doesn't work. 25 | 26 | ## Solution: [Split-Horizon DNS][] 27 | 28 | Quoting again from the [OpenBSD pf FAQ][Split-Horizon DNS]: 29 | 30 | > It's possible to configure DNS servers to answer queries from local hosts 31 | > differently than external queries so that local clients will receive the 32 | > internal server's address during name resolution. They will then connect 33 | > directly to the local server, and the firewall isn't involved at all. 34 | > This reduces local traffic since packets don't have to be sent through 35 | > the firewall. 36 | 37 | [Unbound-Views][] implements the [Split-Horizon DNS][] solution with a 38 | [Python][] plugin for the excellent [Unbound][] DNS resolver. This requires 39 | that the [Unbound][] DNS resolver has the [Python][] module installed and 40 | configured: 41 | 42 | server: 43 | # chroot needs to be disabled otherwise 44 | # the python module will not load 45 | chroot: "" 46 | module-config: "validator python iterator" 47 | python: 48 | # Full path to the Unbound-Views script file to load 49 | python-script: "/usr/local/etc/unbound/views.py" 50 | 51 | [Unbound-Views][] Split-Horizon is configured with a [YAML][] file located 52 | in the same directory as the `views.py` plugin script: 53 | 54 | redirect: 'rdr on wan0 proto tcp to {wan} -> {lan}' 55 | # ifs ## WAN subnet(s) #### LAN subnet(s) # 56 | lan0: 57 | '169.254.10.0/25': '192.168.10.0' 58 | '169.254.20.0/25': '192.168.20.0' 59 | 60 | The interface, `lan0` in the example above, needs to be the same as the 61 | interface that [Unbound][] is listening on facing the LAN. Both subnets, 62 | the WAN and the LAN side must be the same size. 63 | 64 | When `ifconfig lan0` has an IPv4 address configured within the range of 65 | one of the LAN subnets, then any address within the range of the WAN 66 | subnet will be rewritten to a corresponding address within the LAN subnet. 67 | 68 | For example, if the `lan0` interface is configured to `192.168.10.5` and a 69 | lookup resolves to `169.254.10.69` then `192.168.10.69` will be returned. 70 | However, since `lan0` does NOT have an IPv4 address within the 71 | 192.168.20.0/25 subnet, the second set of subnets will not be searched. 72 | This allows for the same `views.yml` config file to be installed on 73 | multiple routers each configured on different LAN subnets and/or interface 74 | names. 75 | 76 | The same [views.yml][] config file can be used on a machine where the 77 | `lan0` interface is configured with an address from the second LAN subnet. 78 | In that case the first set of subnets will be ignored and only the second 79 | set will be searched for split horizon candidates. 80 | 81 | Unlike some other Split-Horizon Views implementations, [Unbound-Views][] 82 | does not require that anything special be configured and served by 83 | Authoritative DNS servers whether they are under your control or not. 84 | 85 | [Unbound-Views][] has a convenient pf `rdr` generator which outputs an 86 | OpenBSD/pf syntax configuration file ready to pull in to `/etc/pf.conf` 87 | with an `include "/etc/pf.rdr"`: 88 | 89 | python /usr/local/etc/unbound/views.py > /etc/pf.rdr 90 | 91 | It's probably a good idea to edit out the redirects for the public IP 92 | address assigned to the edge router(s) and the broadcast address. Meaning 93 | the first and last redirects in the generated output. If using CARP, then 94 | most likely the first three or more redirects need to be omitted. 95 | 96 | The optional `redirect:` setting in [views.yml][] defaults to the 97 | [OpenBSD pf Redirection][Redirection] syntax: 98 | 99 | rdr on wan0 proto tcp to {wan} -> {lan} 100 | 101 | Set `redirect:` in [views.yml][] to whatever fits your needs to help you 102 | generate the firewall rules. Be sure to include the _from_ `{wan}` and 103 | _to_ `{lan}` variables for the [Python][] format string of the `redirect:` 104 | setting. 105 | 106 | ## Getting Started 107 | 108 | * Install [Unbound][] with the [Python][] module enabled and configure as 109 | described above. 110 | * Install [PyYAML][] and the [netaddr][] [Python][] modules required by 111 | [views.py][]. 112 | * Install [views.py][], [views.yml][] and this [README][] in [Unbound][]'s 113 | configuration folder. 114 | * In [views.yml][] edit the interface name and the CIDR subnet ranges to 115 | reflect your actual setup. 116 | * Configure pf (or the firewall of your choice) to portforward the redirects. 117 | * Restart [Unbound][]. 118 | * Profit!!! 119 | 120 | ## License 121 | 122 | See [LICENSE](https://GitHub.com/yds/unbound-views/blob/master/LICENSE.md "BSD3CLAUSE"). 123 | 124 | [Redirection]:http://www.OpenBSD.org/faq/pf/rdr.html "PF: Redirection (Port Forwarding)" 125 | [Reflection]:http://www.OpenBSD.org/faq/pf/rdr.html#reflect "Redirection and Reflection" 126 | [Split-Horizon DNS]:http://www.OpenBSD.org/faq/pf/rdr.html#splitdns "Split-Horizon DNS" 127 | [Unbound]:http://Unbound.net/ "Unbound is a validating, recursive, and caching DNS resolver" 128 | [Python]:https://www.Python.org/ "Python is a great object-oriented, interpreted, and interactive programming language" 129 | [netaddr]:https://PyPi.Python.org/pypi/netaddr "Pythonic manipulation of IPv4, IPv6, CIDR, EUI and MAC network addresses" 130 | [PyYAML]:http://www.PyYAML.org/ "YAML Ain't Markup Language" 131 | [YAML]:http://www.YAML.org/ "YAML Ain't Markup Language" 132 | [README]:https://GitHub.com/yds/unbound-views/blob/master/README.md 133 | [views.py]:https://GitHub.com/yds/unbound-views/blob/master/views.py 134 | [views.yml]:https://GitHub.com/yds/unbound-views/blob/master/views.yml 135 | [Unbound-Views]:https://GitHub.com/yds/unbound-views/ "Split-Horizon Views plugin for the Unbound DNS resolver" 136 | [Knot-renumber]:https://Knot-Resolver.ReadTheDocs.io/en/stable/modules-renumber.html "Knot-Resolver renumber module" 137 | [PowerDNS-renumber]:https://GitHub.com/yds/PowerDNS-renumber.lua "IP address renumbering Lua script for PowerDNS Recursor" 138 | --------------------------------------------------------------------------------