diff options
author | Christian Breunig <christian@breunig.cc> | 2023-12-30 23:25:20 +0100 |
---|---|---|
committer | Christian Breunig <christian@breunig.cc> | 2023-12-31 23:49:48 +0100 |
commit | 4ef110fd2c501b718344c72d495ad7e16d2bd465 (patch) | |
tree | e98bf08f93c029ec4431a3b6ca078e7562e0cc58 /src/conf_mode/service_dns_forwarding.py | |
parent | 2286b8600da6c631b17e1d5b9b341843e50f9abf (diff) | |
download | vyos-1x-4ef110fd2c501b718344c72d495ad7e16d2bd465.tar.gz vyos-1x-4ef110fd2c501b718344c72d495ad7e16d2bd465.zip |
T5474: establish common file name pattern for XML conf mode commands
We will use _ as CLI level divider. The XML definition filename and also
the Python helper should match the CLI node.
Example:
set interfaces ethernet -> interfaces_ethernet.xml.in
set interfaces bond -> interfaces_bond.xml.in
set service dhcp-server -> service_dhcp-server-xml.in
Diffstat (limited to 'src/conf_mode/service_dns_forwarding.py')
-rwxr-xr-x | src/conf_mode/service_dns_forwarding.py | 358 |
1 files changed, 358 insertions, 0 deletions
diff --git a/src/conf_mode/service_dns_forwarding.py b/src/conf_mode/service_dns_forwarding.py new file mode 100755 index 000000000..c186f47af --- /dev/null +++ b/src/conf_mode/service_dns_forwarding.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from netifaces import interfaces +from sys import exit +from glob import glob + +from vyos.config import Config +from vyos.hostsd_client import Client as hostsd_client +from vyos.template import render +from vyos.template import bracketize_ipv6 +from vyos.utils.process import call +from vyos.utils.permission import chown +from vyos.utils.dict import dict_search + +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +pdns_rec_user = pdns_rec_group = 'pdns' +pdns_rec_run_dir = '/run/powerdns' +pdns_rec_lua_conf_file = f'{pdns_rec_run_dir}/recursor.conf.lua' +pdns_rec_hostsd_lua_conf_file = f'{pdns_rec_run_dir}/recursor.vyos-hostsd.conf.lua' +pdns_rec_hostsd_zones_file = f'{pdns_rec_run_dir}/recursor.forward-zones.conf' +pdns_rec_config_file = f'{pdns_rec_run_dir}/recursor.conf' + +hostsd_tag = 'static' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'dns', 'forwarding'] + if not conf.exists(base): + return None + + dns = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + # some additions to the default dictionary + if 'system' in dns: + base_nameservers = ['system', 'name-server'] + if conf.exists(base_nameservers): + dns.update({'system_name_server': conf.return_values(base_nameservers)}) + + if 'authoritative_domain' in dns: + dns['authoritative_zones'] = [] + dns['authoritative_zone_errors'] = [] + for node in dns['authoritative_domain']: + zonedata = dns['authoritative_domain'][node] + if ('disable' in zonedata) or (not 'records' in zonedata): + continue + zone = { + 'name': node, + 'file': "{}/zone.{}.conf".format(pdns_rec_run_dir, node), + 'records': [], + } + + recorddata = zonedata['records'] + + for rtype in [ 'a', 'aaaa', 'cname', 'mx', 'ns', 'ptr', 'txt', 'spf', 'srv', 'naptr' ]: + if rtype not in recorddata: + continue + for subnode in recorddata[rtype]: + if 'disable' in recorddata[rtype][subnode]: + continue + + rdata = recorddata[rtype][subnode] + + if rtype in [ 'a', 'aaaa' ]: + if not 'address' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one address is required') + continue + + if subnode == 'any': + subnode = '*' + + for address in rdata['address']: + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': address + }) + elif rtype in ['cname', 'ptr', 'ns']: + if not 'target' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: target is required') + continue + + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': '{}.'.format(rdata['target']) + }) + elif rtype == 'mx': + if not 'server' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one server is required') + continue + + for servername in rdata['server']: + serverdata = rdata['server'][servername] + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': '{} {}.'.format(serverdata['priority'], servername) + }) + elif rtype == 'txt': + if not 'value' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one value is required') + continue + + for value in rdata['value']: + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': "\"{}\"".format(value.replace("\"", "\\\"")) + }) + elif rtype == 'spf': + if not 'value' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: value is required') + continue + + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': '"{}"'.format(rdata['value'].replace("\"", "\\\"")) + }) + elif rtype == 'srv': + if not 'entry' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one entry is required') + continue + + for entryno in rdata['entry']: + entrydata = rdata['entry'][entryno] + if not 'hostname' in entrydata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: hostname is required for entry {entryno}') + continue + + if not 'port' in entrydata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: port is required for entry {entryno}') + continue + + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': '{} {} {} {}.'.format(entrydata['priority'], entrydata['weight'], entrydata['port'], entrydata['hostname']) + }) + elif rtype == 'naptr': + if not 'rule' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one rule is required') + continue + + for ruleno in rdata['rule']: + ruledata = rdata['rule'][ruleno] + flags = "" + if 'lookup-srv' in ruledata: + flags += "S" + if 'lookup-a' in ruledata: + flags += "A" + if 'resolve-uri' in ruledata: + flags += "U" + if 'protocol-specific' in ruledata: + flags += "P" + + if 'order' in ruledata: + order = ruledata['order'] + else: + order = ruleno + + if 'regexp' in ruledata: + regexp= ruledata['regexp'].replace("\"", "\\\"") + else: + regexp = '' + + if ruledata['replacement']: + replacement = '{}.'.format(ruledata['replacement']) + else: + replacement = '' + + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': '{} {} "{}" "{}" "{}" {}'.format(order, ruledata['preference'], flags, ruledata['service'], regexp, replacement) + }) + + dns['authoritative_zones'].append(zone) + + return dns + +def verify(dns): + # bail out early - looks like removal from running config + if not dns: + return None + + if 'listen_address' not in dns: + raise ConfigError('DNS forwarding requires a listen-address') + + if 'allow_from' not in dns: + raise ConfigError('DNS forwarding requires an allow-from network') + + # we can not use dict_search() when testing for domain servers + # as a domain will contains dot's which is out dictionary delimiter. + if 'domain' in dns: + for domain in dns['domain']: + if 'name_server' not in dns['domain'][domain]: + raise ConfigError(f'No server configured for domain {domain}!') + + if 'dns64_prefix' in dns: + dns_prefix = dns['dns64_prefix'].split('/')[1] + # RFC 6147 requires prefix /96 + if int(dns_prefix) != 96: + raise ConfigError('DNS 6to4 prefix must be of length /96') + + if ('authoritative_zone_errors' in dns) and dns['authoritative_zone_errors']: + for error in dns['authoritative_zone_errors']: + print(error) + raise ConfigError('Invalid authoritative records have been defined') + + if 'system' in dns: + if not 'system_name_server' in dns: + print('Warning: No "system name-server" configured') + + return None + +def generate(dns): + # bail out early - looks like removal from running config + if not dns: + return None + + render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', + dns, user=pdns_rec_user, group=pdns_rec_group) + + render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.j2', + dns, user=pdns_rec_user, group=pdns_rec_group) + + for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'): + os.unlink(zone_filename) + + if 'authoritative_zones' in dns: + for zone in dns['authoritative_zones']: + render(zone['file'], 'dns-forwarding/recursor.zone.conf.j2', + zone, user=pdns_rec_user, group=pdns_rec_group) + + + # if vyos-hostsd didn't create its files yet, create them (empty) + for file in [pdns_rec_hostsd_lua_conf_file, pdns_rec_hostsd_zones_file]: + with open(file, 'a'): + pass + chown(file, user=pdns_rec_user, group=pdns_rec_group) + + return None + +def apply(dns): + if not dns: + # DNS forwarding is removed in the commit + call('systemctl stop pdns-recursor.service') + + if os.path.isfile(pdns_rec_config_file): + os.unlink(pdns_rec_config_file) + + for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'): + os.unlink(zone_filename) + else: + ### first apply vyos-hostsd config + hc = hostsd_client() + + # add static nameservers to hostsd so they can be joined with other + # sources + hc.delete_name_servers([hostsd_tag]) + if 'name_server' in dns: + # 'name_server' is of the form + # {'192.0.2.1': {'port': 53}, '2001:db8::1': {'port': 853}, ...} + # canonicalize them as ['192.0.2.1:53', '[2001:db8::1]:853', ...] + nslist = [(lambda h, p: f"{bracketize_ipv6(h)}:{p['port']}")(h, p) + for (h, p) in dns['name_server'].items()] + hc.add_name_servers({hostsd_tag: nslist}) + + # delete all nameserver tags + hc.delete_name_server_tags_recursor(hc.get_name_server_tags_recursor()) + + ## add nameserver tags - the order determines the nameserver order! + # our own tag (static) + hc.add_name_server_tags_recursor([hostsd_tag]) + + if 'system' in dns: + hc.add_name_server_tags_recursor(['system']) + else: + hc.delete_name_server_tags_recursor(['system']) + + # add dhcp nameserver tags for configured interfaces + if 'system_name_server' in dns: + for interface in dns['system_name_server']: + # system_name_server key contains both IP addresses and interface + # names (DHCP) to use DNS servers. We need to check if the + # value is an interface name - only if this is the case, add the + # interface based DNS forwarder. + if interface in interfaces(): + hc.add_name_server_tags_recursor(['dhcp-' + interface, + 'dhcpv6-' + interface ]) + + # hostsd will generate the forward-zones file + # the list and keys() are required as get returns a dict, not list + hc.delete_forward_zones(list(hc.get_forward_zones().keys())) + if 'domain' in dns: + zones = dns['domain'] + for domain in zones.keys(): + # 'name_server' is of the form + # {'192.0.2.1': {'port': 53}, '2001:db8::1': {'port': 853}, ...} + # canonicalize them as ['192.0.2.1:53', '[2001:db8::1]:853', ...] + zones[domain]['name_server'] = [(lambda h, p: f"{bracketize_ipv6(h)}:{p['port']}")(h, p) + for (h, p) in zones[domain]['name_server'].items()] + hc.add_forward_zones(zones) + + # hostsd generates NTAs for the authoritative zones + # the list and keys() are required as get returns a dict, not list + hc.delete_authoritative_zones(list(hc.get_authoritative_zones())) + if 'authoritative_zones' in dns: + hc.add_authoritative_zones(list(map(lambda zone: zone['name'], dns['authoritative_zones']))) + + # call hostsd to generate forward-zones and its lua-config-file + hc.apply() + + ### finally (re)start pdns-recursor + call('systemctl restart pdns-recursor.service') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) |