diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/conf_mode/dns_forwarding.py | 62 | ||||
-rwxr-xr-x | src/conf_mode/host_name.py | 196 | ||||
-rwxr-xr-x | src/conf_mode/interface-bridge.py | 9 | ||||
-rwxr-xr-x | src/conf_mode/interface-dummy.py | 113 | ||||
-rwxr-xr-x | src/conf_mode/interface-loopback.py | 102 | ||||
-rwxr-xr-x | src/conf_mode/interface-openvpn.py | 31 | ||||
-rwxr-xr-x | src/migration-scripts/dns-forwarding/0-to-1 | 50 | ||||
-rwxr-xr-x | src/migration-scripts/dns-forwarding/1-to-2 | 78 | ||||
-rwxr-xr-x | src/services/vyos-hostsd | 268 | ||||
-rw-r--r-- | src/systemd/vyos-hostsd.service | 23 | ||||
-rwxr-xr-x | src/utils/vyos-hostsd-client | 82 |
11 files changed, 764 insertions, 250 deletions
diff --git a/src/conf_mode/dns_forwarding.py b/src/conf_mode/dns_forwarding.py index 3ca77adee..9e81c7294 100755 --- a/src/conf_mode/dns_forwarding.py +++ b/src/conf_mode/dns_forwarding.py @@ -44,7 +44,7 @@ config_tmpl = """ # Non-configurable defaults daemon=yes threads=1 -allow-from=0.0.0.0/0, ::/0 +allow-from={{ allow_from | join(',') }} log-common-errors=yes non-local-bind=yes query-local-address=0.0.0.0 @@ -83,10 +83,10 @@ dnssec={{ dnssec }} """ default_config_data = { + 'allow_from': [], 'cache_size': 10000, 'export_hosts_file': 'yes', 'listen_on': [], - 'interfaces': [], 'name_servers': [], 'negative_ttl': 3600, 'domains': [], @@ -121,6 +121,9 @@ def get_config(arguments): conf.set_level('service dns forwarding') + if conf.exists('allow-from'): + dns['allow_from'] = conf.return_values('allow-from') + if conf.exists('cache-size'): cache_size = conf.return_value('cache-size') dns['cache_size'] = cache_size @@ -164,46 +167,6 @@ def get_config(arguments): if conf.exists('dnssec'): dns['dnssec'] = conf.return_value('dnssec') - ## Hacks and tricks - - # The old VyOS syntax that comes from dnsmasq was "listen-on $interface". - # pdns wants addresses instead, so we emulate it by looking up all addresses - # of a given interface and writing them to the config - if conf.exists('listen-on'): - print("WARNING: since VyOS 1.2.0, \"service dns forwarding listen-on\" is a limited compatibility option.") - print("It will only make DNS forwarder listen on addresses assigned to the interface at the time of commit") - print("which means it will NOT work properly with VRRP/clustering or addresses received from DHCP.") - print("Please reconfigure your system with \"service dns forwarding listen-address\" instead.") - - interfaces = conf.return_values('listen-on') - - listen4 = [] - listen6 = [] - for interface in interfaces: - try: - addrs = netifaces.ifaddresses(interface) - except ValueError: - print( - "WARNING: interface {0} does not exist".format(interface)) - continue - - if netifaces.AF_INET in addrs.keys(): - for ip4 in addrs[netifaces.AF_INET]: - listen4.append(ip4['addr']) - - if netifaces.AF_INET6 in addrs.keys(): - for ip6 in addrs[netifaces.AF_INET6]: - listen6.append(ip6['addr']) - - if (not listen4) and (not (listen6)): - print( - "WARNING: interface {0} has no configured addresses".format(interface)) - - dns['listen_on'] = dns['listen_on'] + listen4 + listen6 - - # Save interfaces in the dict for the reference - dns['interfaces'] = interfaces - # Add name servers received from DHCP if conf.exists('dhcp'): interfaces = [] @@ -216,12 +179,10 @@ def get_config(arguments): return dns - def bracketize_ipv6_addrs(addrs): """Wraps each IPv6 addr in addrs in [], leaving IPv4 addrs untouched.""" return ['[{0}]'.format(a) if a.count(':') > 1 else a for a in addrs] - def verify(dns): # bail out early - looks like removal from running config if dns is None: @@ -231,6 +192,10 @@ def verify(dns): raise ConfigError( "Error: DNS forwarding requires either a listen-address (preferred) or a listen-on option") + if not dns['allow_from']: + raise ConfigError( + "Error: DNS forwarding requires an allow-from network") + if dns['domains']: for domain in dns['domains']: if not domain['servers']: @@ -239,7 +204,6 @@ def verify(dns): return None - def generate(dns): # bail out early - looks like removal from running config if dns is None: @@ -251,16 +215,14 @@ def generate(dns): f.write(config_text) return None - def apply(dns): - if dns is not None: - os.system("systemctl restart pdns-recursor") - else: + if dns is None: # DNS forwarding is removed in the commit os.system("systemctl stop pdns-recursor") if os.path.isfile(config_file): os.unlink(config_file) - + else: + os.system("systemctl restart pdns-recursor") if __name__ == '__main__': args = parser.parse_args() diff --git a/src/conf_mode/host_name.py b/src/conf_mode/host_name.py index 2fad57db6..bb1ec9597 100755 --- a/src/conf_mode/host_name.py +++ b/src/conf_mode/host_name.py @@ -30,57 +30,12 @@ import argparse import jinja2 import vyos.util +import vyos.hostsd_client from vyos.config import Config from vyos import ConfigError -parser = argparse.ArgumentParser() -parser.add_argument("--dhclient", action="store_true", - help="Started from dhclient-script") - -config_file_hosts = '/etc/hosts' -config_file_resolv = '/etc/resolv.conf' - -config_tmpl_hosts = """ -### Autogenerated by host_name.py ### -127.0.0.1 localhost -127.0.1.1 {{ hostname }}{% if domain_name %}.{{ domain_name }} {{ hostname }}{% endif %} - -# The following lines are desirable for IPv6 capable hosts -::1 localhost ip6-localhost ip6-loopback -fe00::0 ip6-localnet -ff00::0 ip6-mcastprefix -ff02::1 ip6-allnodes -ff02::2 ip6-allrouters - -# static hostname mappings -{%- if static_host_mapping['hostnames'] %} -{% for hn in static_host_mapping['hostnames'] -%} -{{static_host_mapping['hostnames'][hn]['ipaddr']}}\t{{static_host_mapping['hostnames'][hn]['alias']}}\t{{hn}} -{% endfor -%} -{%- endif %} - -### modifications from other scripts should be added below - -""" - -config_tmpl_resolv = """ -### Autogenerated by host_name.py ### -{% for ns in nameserver -%} -nameserver {{ ns }} -{% endfor -%} - -{%- if domain_name %} -domain {{ domain_name }} -{%- endif %} - -{%- if domain_search %} -search {{ domain_search | join(" ") }} -{%- endif %} - -""" - default_config_data = { 'hostname': 'vyos', 'domain_name': '', @@ -89,32 +44,10 @@ default_config_data = { 'no_dhcp_ns': False } -# borrowed from: https://github.com/donjajo/py-world/blob/master/resolvconfReader.py, THX! -def get_resolvers(file): - resolv = {} - try: - with open(file, 'r') as resolvconf: - lines = [line.split('#', 1)[0].rstrip() - for line in resolvconf.readlines()] - resolvers = [line.split()[1] - for line in lines if 'nameserver' in line] - domains = [line.split()[1] for line in lines if 'search' in line] - resolv['resolvers'] = resolvers - resolv['domains'] = domains - return resolv - except IOError: - return [] - - -def get_config(arguments): +def get_config(): conf = Config() hosts = copy.deepcopy(default_config_data) - if arguments.dhclient: - conf.exists = conf.exists_effective - conf.return_value = conf.return_effective_value - conf.return_values = conf.return_effective_values - if conf.exists("system host-name"): hosts['hostname'] = conf.return_value("system host-name") # This may happen if the config is not loaded yet, @@ -136,19 +69,15 @@ def get_config(arguments): hosts['no_dhcp_ns'] = conf.exists('system disable-dhcp-nameservers') # system static-host-mapping - hosts['static_host_mapping'] = {'hostnames': {}} + hosts['static_host_mapping'] = [] if conf.exists('system static-host-mapping host-name'): for hn in conf.list_nodes('system static-host-mapping host-name'): - hosts['static_host_mapping']['hostnames'][hn] = { - 'ipaddr': conf.return_value('system static-host-mapping host-name ' + hn + ' inet'), - 'alias': '' - } - - if conf.exists('system static-host-mapping host-name ' + hn + ' alias'): - a = conf.return_values( - 'system static-host-mapping host-name ' + hn + ' alias') - hosts['static_host_mapping']['hostnames'][hn]['alias'] = " ".join(a) + mapping = {} + mapping['host'] = hn + mapping['address'] = conf.return_value('system static-host-mapping host-name {0} inet'.format(hn)) + mapping['aliases'] = conf.return_values('system static-host-mapping host-name {0} alias'.format(hn)) + hosts['static_host_mapping'].append(mapping) return hosts @@ -180,83 +109,43 @@ def verify(config): 'The search list is currently limited to 256 characters') # static mappings alias hostname - if config['static_host_mapping']['hostnames']: - for hn in config['static_host_mapping']['hostnames']: - if not config['static_host_mapping']['hostnames'][hn]['ipaddr']: - raise ConfigError('IP address required for ' + hn) - for hn_alias in config['static_host_mapping']['hostnames'][hn]['alias'].split(' '): - if not hostname_regex.match(hn_alias) and len(hn_alias) != 0: - raise ConfigError('Invalid hostname alias ' + hn_alias) + if config['static_host_mapping']: + for m in config['static_host_mapping']: + if not m['address']: + raise ConfigError('IP address required for ' + m['host']) + for a in m['aliases']: + if not hostname_regex.match(a) and len(a) != 0: + raise ConfigError('Invalid alias \'{0}\' in mapping {1}'.format(a, m['host'])) return None def generate(config): + pass + +def apply(config): if config is None: return None - # If "system disable-dhcp-nameservers" is __configured__ all DNS resolvers - # received via dhclient should not be added into the final 'resolv.conf'. - # - # We iterate over every resolver file and retrieve the received nameservers - # for later adjustment of the system nameservers - dhcp_ns = [] - dhcp_sd = [] - for file in glob.glob('/etc/resolv.conf.dhclient-new*'): - for key, value in get_resolvers(file).items(): - ns = [r for r in value if key == 'resolvers'] - dhcp_ns.extend(ns) - sd = [d for d in value if key == 'domains'] - dhcp_sd.extend(sd) - - if not config['no_dhcp_ns']: - config['nameserver'] += dhcp_ns - config['domain_search'] += dhcp_sd - - # Prune duplicate values - # Not order preserving, but then when multiple DHCP clients are used, - # there can't be guarantees about the order anyway - dhcp_ns = list(set(dhcp_ns)) - dhcp_sd = list(set(dhcp_sd)) - - # We have third party scripts altering /etc/hosts, too. - # One example are the DHCP hostname update scripts thus we need to cache in - # every modification first - so changing domain-name, domain-search or hostname - # during runtime works - old_hosts = "" - with open(config_file_hosts, 'r') as f: - # Skips text before the beginning of our marker. - # NOTE: Marker __MUST__ match the one specified in config_tmpl_hosts - for line in f: - if line.strip() == '### modifications from other scripts should be added below': - break - - for line in f: - # This additional line.strip() filters empty lines - if line.strip(): - old_hosts += line - - # Add an additional newline - old_hosts += '\n' - - tmpl = jinja2.Template(config_tmpl_hosts) - config_text = tmpl.render(config) - - with open(config_file_hosts, 'w') as f: - f.write(config_text) - f.write(old_hosts) - - tmpl = jinja2.Template(config_tmpl_resolv) - config_text = tmpl.render(config) - with open(config_file_resolv, 'w') as f: - f.write(config_text) + ## Send the updated data to vyos-hostsd - return None + # vyos-hostsd uses "tags" to identify data sources + tag = "static" + try: + client = vyos.hostsd_client.Client() -def apply(config): - if config is None: - return None + client.set_host_name(config['hostname'], config['domain_name'], config['domain_search']) + + client.delete_name_servers(tag) + client.add_name_servers(tag, config['nameserver']) + + client.delete_hosts(tag) + client.add_hosts(tag, config['static_host_mapping']) + except vyos.hostsd_client.VyOSHostsdError as e: + raise ConfigError(str(e)) + + ## Actually update the hostname -- vyos-hostsd doesn't do that # No domain name -- the Debian way. hostname_new = config['hostname'] @@ -283,22 +172,9 @@ def apply(config): if __name__ == '__main__': - args = parser.parse_args() - - if args.dhclient: - # There's a big chance it was triggered by a commit still in progress - # so we need to wait until the new values are in the running config - vyos.util.wait_for_commit_lock() - - try: - c = get_config(args) - # If it's called from dhclient, then either: - # a) verification was already done at commit time - # b) it's run on an unconfigured system, e.g. by cloud-init - # Therefore, verification is either redundant or useless - if not args.dhclient: - verify(c) + c = get_config() + verify(c) generate(c) apply(c) except ConfigError as e: diff --git a/src/conf_mode/interface-bridge.py b/src/conf_mode/interface-bridge.py index 543349e7b..65b5c4066 100755 --- a/src/conf_mode/interface-bridge.py +++ b/src/conf_mode/interface-bridge.py @@ -178,9 +178,6 @@ def get_config(): return bridge def verify(bridge): - if bridge is None: - return None - conf = Config() for br in conf.list_nodes('interfaces bridge'): # it makes no sense to verify ourself in this case @@ -195,15 +192,9 @@ def verify(bridge): return None def generate(bridge): - if bridge is None: - return None - return None def apply(bridge): - if bridge is None: - return None - cmd = '' if bridge['deleted']: # bridges need to be shutdown first diff --git a/src/conf_mode/interface-dummy.py b/src/conf_mode/interface-dummy.py new file mode 100755 index 000000000..ff9d57c89 --- /dev/null +++ b/src/conf_mode/interface-dummy.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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 +import sys +import copy + +import vyos.configinterface as VyIfconfig + +from vyos.config import Config +from vyos import ConfigError + +default_config_data = { + 'address': [], + 'address_remove': [], + 'deleted': False, + 'description': '', + 'disable': False, + 'intf': '' +} + +def diff(first, second): + second = set(second) + return [item for item in first if item not in second] + +def get_config(): + dummy = copy.deepcopy(default_config_data) + conf = Config() + + # determine tagNode instance + try: + dummy['intf'] = os.environ['VYOS_TAGNODE_VALUE'] + except KeyError as E: + print("Interface not specified") + + # Check if interface has been removed + if not conf.exists('interfaces dummy ' + dummy['intf']): + dummy['deleted'] = True + return dummy + + # set new configuration level + conf.set_level('interfaces dummy ' + dummy['intf']) + + # retrieve configured interface addresses + if conf.exists('address'): + dummy['address'] = conf.return_values('address') + + # retrieve interface description + if conf.exists('description'): + dummy['description'] = conf.return_value('description') + + # Disable this interface + if conf.exists('disable'): + dummy['disable'] = True + + # Determine interface addresses (currently effective) - to determine which + # address is no longer valid and needs to be removed from the interface + eff_addr = conf.return_effective_values('address') + act_addr = conf.return_values('address') + dummy['address_remove'] = diff(eff_addr, act_addr) + + return dummy + +def verify(dummy): + return None + +def generate(dummy): + return None + +def apply(dummy): + # Remove dummy interface + if dummy['deleted']: + VyIfconfig.remove_interface(dummy['intf']) + else: + # Interface will only be added if it yet does not exist + VyIfconfig.add_interface('dummy', dummy['intf']) + + # update interface description used e.g. within SNMP + VyIfconfig.set_description(dummy['intf'], dummy['description']) + + # Configure interface address(es) + for addr in dummy['address_remove']: + VyIfconfig.remove_interface_address(dummy['intf'], addr) + + for addr in dummy['address']: + VyIfconfig.add_interface_address(dummy['intf'], addr) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/interface-loopback.py b/src/conf_mode/interface-loopback.py new file mode 100755 index 000000000..445a9af64 --- /dev/null +++ b/src/conf_mode/interface-loopback.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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 +import sys +import copy + +import vyos.configinterface as VyIfconfig + +from vyos.config import Config +from vyos import ConfigError + +default_config_data = { + 'address': [], + 'address_remove': [], + 'deleted': False, + 'description': '', +} + +def diff(first, second): + second = set(second) + return [item for item in first if item not in second] + +def get_config(): + loopback = copy.deepcopy(default_config_data) + conf = Config() + + # determine tagNode instance + try: + loopback['intf'] = os.environ['VYOS_TAGNODE_VALUE'] + except KeyError as E: + print("Interface not specified") + + # Check if interface has been removed + if not conf.exists('interfaces loopback ' + loopback['intf']): + loopback['deleted'] = True + + # set new configuration level + conf.set_level('interfaces loopback ' + loopback['intf']) + + # retrieve configured interface addresses + if conf.exists('address'): + loopback['address'] = conf.return_values('address') + + # retrieve interface description + if conf.exists('description'): + loopback['description'] = conf.return_value('description') + + # Determine interface addresses (currently effective) - to determine which + # address is no longer valid and needs to be removed from the interface + eff_addr = conf.return_effective_values('address') + act_addr = conf.return_values('address') + loopback['address_remove'] = diff(eff_addr, act_addr) + + return loopback + +def verify(loopback): + return None + +def generate(loopback): + return None + +def apply(loopback): + # Remove loopback interface + if not loopback['deleted']: + # update interface description used e.g. within SNMP + VyIfconfig.set_description(loopback['intf'], loopback['description']) + + # Configure interface address(es) + for addr in loopback['address']: + VyIfconfig.add_interface_address(loopback['intf'], addr) + + # Remove interface address(es) + for addr in loopback['address_remove']: + VyIfconfig.remove_interface_address(loopback['intf'], addr) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/interface-openvpn.py b/src/conf_mode/interface-openvpn.py index e4bde7bb0..a8313378b 100755 --- a/src/conf_mode/interface-openvpn.py +++ b/src/conf_mode/interface-openvpn.py @@ -219,10 +219,6 @@ client-config-dir /opt/vyatta/etc/openvpn/ccd/{{ intf }} {% for option in options -%} {{ option }} {% endfor -%} - -{%- if server_2fa_authy_key %} -plugin /usr/lib/authy/authy-openvpn.so https://api.authy.com/protected/json {{ server_2fa_authy_key }} nopam -{% endif %} """ client_tmpl = """ @@ -269,8 +265,6 @@ default_config_data = { 'remote_address': '', 'remote_host': [], 'remote_port': '', - 'server_2fa_authy_key': '', - 'server_2fa_authy': [], 'client': [], 'server_domain': '', 'server_max_conn': '', @@ -453,31 +447,6 @@ def get_config(): if conf.exists('replace-default-route local'): openvpn['redirect_gateway'] = 'local def1' - # Two Factor Authentication providers - # currently limited to authy - if conf.exists('2-factor-authentication authy api-key'): - openvpn['server_2fa_authy_key'] = conf.return_value('2-factor-authentication authy api-key') - - # Authy users (must be email address) - for user in conf.list_nodes('server 2-factor-authentication authy user'): - # set configuration level - conf.set_level('interfaces openvpn ' + openvpn['intf'] + ' 2-factor-authentication authy user ' + user) - data = { - 'user': user, - 'country_code': '', - 'mobile_number': '' - } - - # Country calling codes - if conf.exists('country-calling-code'): - data['country_code'] = conf.return_value('country-calling-code') - - # Mobile phone number - if conf.exists('phone-number'): - data['mobile_number'] = conf.return_value('phone-number') - - openvpn['server_2fa_authy'].append(data) - # Topology for clients if conf.exists('server topology'): openvpn['server_topology'] = conf.return_value('server topology') diff --git a/src/migration-scripts/dns-forwarding/0-to-1 b/src/migration-scripts/dns-forwarding/0-to-1 new file mode 100755 index 000000000..6e8720eef --- /dev/null +++ b/src/migration-scripts/dns-forwarding/0-to-1 @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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/>. +# + +# This migration script will check if there is a allow-from directive configured +# for the dns forwarding service - if not, the node will be created with the old +# default values of 0.0.0.0/0 and ::/0 + +import sys +from vyos.configtree import ConfigTree + +if (len(sys.argv) < 1): + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +base = ['service', 'dns', 'forwarding'] +if not config.exists(base): + # Nothing to do + sys.exit(0) +else: + if not config.exists(base + ['allow-from']): + config.set(base + ['allow-from'], value='0.0.0.0/0', replace=False) + config.set(base + ['allow-from'], value='::/0', replace=False) + + try: + with open(file_name, 'w') as f: + f.write(config.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/dns-forwarding/1-to-2 b/src/migration-scripts/dns-forwarding/1-to-2 new file mode 100755 index 000000000..31ba5573f --- /dev/null +++ b/src/migration-scripts/dns-forwarding/1-to-2 @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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/>. +# + +# This migration script will remove the deprecated 'listen-on' statement +# from the dns forwarding service and will add the corresponding +# listen-address nodes instead. This is required as PowerDNS can only listen +# on interface addresses and not on interface names. + +import sys + +from ipaddress import ip_interface +from vyos.configtree import ConfigTree +from vyos.interfaces import get_type_of_interface + +if (len(sys.argv) < 1): + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +base = ['service', 'dns', 'forwarding'] +if not config.exists(base): + # Nothing to do + sys.exit(0) +else: + if config.exists(base + ['listen-on']): + listen_intf = config.return_values(base + ['listen-on']) + # Delete node with abandoned command + config.delete(base + ['listen-on']) + + # retrieve interface addresses for every configured listen-on interface + listen_addr = [] + for intf in listen_intf: + # we need to treat vif and vif-s interfaces differently, + # both "real interfaces" use dots for vlan identifiers - those + # need to be exchanged with vif and vif-s identifiers + if intf.count('.') == 1: + # this is a regular VLAN interface + intf = intf.split('.')[0] + ' vif ' + intf.split('.')[1] + elif intf.count('.') == 2: + # this is a QinQ VLAN interface + intf = intf.split('.')[0] + ' vif-s ' + intf.split('.')[1] + ' vif-c ' + intf.split('.')[2] + + path = ['interfaces', get_type_of_interface(intf), intf, 'address'] + + # retrieve corresponding interface addresses in CIDR format + # those need to be converted in pure IP addresses without network information + for addr in config.return_values(path): + listen_addr.append( ip_interface(addr).ip ) + + for addr in listen_addr: + config.set(base + ['listen-address'], value=addr, replace=False) + + try: + with open(file_name, 'w') as f: + f.write(config.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/services/vyos-hostsd b/src/services/vyos-hostsd new file mode 100755 index 000000000..1bcb2e1f5 --- /dev/null +++ b/src/services/vyos-hostsd @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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 +import sys +import time +import json +import signal +import traceback + +import zmq + +import jinja2 + +debug = True + +DATA_DIR = "/var/lib/vyos/" +STATE_FILE = os.path.join(DATA_DIR, "hostsd.state") + +SOCKET_PATH = "ipc:///run/vyos-hostsd.sock" + +RESOLV_CONF_FILE = '/etc/resolv.conf' +HOSTS_FILE = '/etc/hosts' + +hosts_tmpl_source = """ +### Autogenerated by VyOS ### +### Do not edit, your changes will get overwritten ### + +# Local host +127.0.0.1 localhost +127.0.1.1 {{ host_name }}{% if domain_name %}.{{ domain_name }}{% endif %} + +# The following lines are desirable for IPv6 capable hosts +::1 localhost ip6-localhost ip6-loopback +fe00::0 ip6-localnet +ff00::0 ip6-mcastprefix +ff02::1 ip6-allnodes +ff02::2 ip6-allrouters + +# From DHCP and "system static host-mapping" +{%- if hosts %} +{% for h in hosts -%} +{{hosts[h]['address']}}\t{{h}}\t{% for a in hosts[h]['aliases'] %} {{a}} {% endfor %} +{% endfor %} +{%- endif %} +""" + +hosts_tmpl = jinja2.Template(hosts_tmpl_source) + +resolv_tmpl_source = """ +### Autogenerated by VyOS ### +### Do not edit, your changes will get overwritten ### + +{% for ns in name_servers -%} +nameserver {{ns}} +{% endfor -%} + +{%- if domain_name %} +domain {{ domain_name }} +{%- endif %} + +{%- if search_domains %} +search {{ search_domains | join(" ") }} +{%- endif %} + +""" + +resolv_tmpl = jinja2.Template(resolv_tmpl_source) + +# The state data includes a list of name servers +# and a list of hosts entries. +# +# Name servers have the following structure: +# {"server": {"tag": <str>}} +# +# Hosts entries are similar: +# {"host": {"tag": <str>, "address": <str>, "aliases": <str list>}} +# +# The tag is either "static" or "dhcp-<intf>" +# It's used to distinguish entries created +# by different scripts so that they can be removed +# and re-created without having to track what needs +# to be changed +STATE = { + "name_servers": {}, + "hosts": {}, + "host_name": "vyos", + "domain_name": "", + "search_domains": []} + + +def make_resolv_conf(data): + resolv_conf = resolv_tmpl.render(data) + print("Writing /etc/resolv.conf") + with open(RESOLV_CONF_FILE, 'w') as f: + f.write(resolv_conf) + +def make_hosts_file(state): + print("Writing /etc/hosts") + hosts = hosts_tmpl.render(state) + with open(HOSTS_FILE, 'w') as f: + f.write(hosts) + +def add_hosts(data, entries, tag): + hosts = data['hosts'] + + if not entries: + return + + for e in entries: + host = e['host'] + hosts[host] = {} + hosts[host]['tag'] = tag + hosts[host]['address'] = e['address'] + hosts[host]['aliases'] = e['aliases'] + +def delete_hosts(data, tag): + hosts = data['hosts'] + keys_for_deletion = [] + + # You can't delete items from a dict while iterating over it, + # so we build a list of doomed items first + for h in hosts: + if hosts[h]['tag'] == tag: + keys_for_deletion.append(h) + + for k in keys_for_deletion: + del hosts[k] + +def add_name_servers(data, entries, tag): + name_servers = data['name_servers'] + + if not entries: + return + + for e in entries: + name_servers[e] = {} + name_servers[e]['tag'] = tag + +def delete_name_servers(data, tag): + name_servers = data['name_servers'] + keys_for_deletion = [] + + for ns in name_servers: + if name_servers[ns]['tag'] == tag: + keys_for_deletion.append(ns) + + for k in keys_for_deletion: + del name_servers[k] + +def set_host_name(state, data): + if data['host_name']: + state['host_name'] = data['host_name'] + if data['domain_name']: + state['domain_name'] = data['domain_name'] + if data['search_domains']: + state['search_domains'] = data['search_domains'] + +def get_option(msg, key): + if key in msg: + return msg[key] + else: + raise ValueError("Missing required option \"{0}\"".format(key)) + +def handle_message(msg_json): + msg = json.loads(msg_json) + + op = get_option(msg, 'op') + _type = get_option(msg, 'type') + + if op == 'delete': + tag = get_option(msg, 'tag') + + if _type == 'name_servers': + delete_name_servers(STATE, tag) + elif _type == 'hosts': + delete_hosts(STATE, tag) + else: + raise ValueError("Unknown message type {0}".format(_type)) + elif op == 'add': + tag = get_option(msg, 'tag') + entries = get_option(msg, 'data') + if _type == 'name_servers': + add_name_servers(STATE, entries, tag) + elif _type == 'hosts': + add_hosts(STATE, entries, tag) + else: + raise ValueError("Unknown message type {0}".format(_type)) + elif op == 'set': + # Host name/domain name/search domain are set without a tag, + # there can be only one anyway + data = get_option(msg, 'data') + if _type == 'host_name': + set_host_name(STATE, data) + else: + raise ValueError("Unknown message type {0}".format(_type)) + else: + raise ValueError("Unknown operation {0}".format(op)) + + make_resolv_conf(STATE) + make_hosts_file(STATE) + + print("Saving state to {0}".format(STATE_FILE)) + with open(STATE_FILE, 'w') as f: + json.dump(STATE, f) + +def exit_handler(sig, frame): + """ Clean up the state when shutdown correctly """ + print("Cleaning up state") + os.unlink(STATE_FILE) + sys.exit(0) + + +if __name__ == '__main__': + signal.signal(signal.SIGTERM, exit_handler) + + # Create a directory for state checkpoints + os.makedirs(DATA_DIR, exist_ok=True) + if os.path.exists(STATE_FILE): + with open(STATE_FILE, 'r') as f: + try: + data = json.load(f) + STATE = data + except: + print(traceback.format_exc()) + print("Failed to load the state file, using default") + + context = zmq.Context() + socket = context.socket(zmq.REP) + socket.bind(SOCKET_PATH) + + while True: + # Wait for next request from client + message = socket.recv().decode() + print("Received a configuration change request") + if debug: + print("Request data: {0}".format(message)) + + resp = {} + + try: + handle_message(message) + except ValueError as e: + resp['error'] = str(e) + except: + print(traceback.format_exc()) + resp['error'] = "Internal error" + + if debug: + print("Sent response: {0}".format(resp)) + + # Send reply back to client + socket.send(json.dumps(resp).encode()) diff --git a/src/systemd/vyos-hostsd.service b/src/systemd/vyos-hostsd.service new file mode 100644 index 000000000..8ff213d2b --- /dev/null +++ b/src/systemd/vyos-hostsd.service @@ -0,0 +1,23 @@ +[Unit] +Description=VyOS DNS configuration keeper +After=auditd.service systemd-user-sessions.service time-sync.target +Before=vyos-router.service cloud-init.service + +[Service] +ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-hostsd +Type=idle +KillMode=process + +SyslogIdentifier=vyos-hostsd +SyslogFacility=daemon + +Restart=on-failure + +# Does't work but leave it here +User=root +Group=vyattacfg + +[Install] +# Installing in a earlier target leaves ExecStartPre waiting +WantedBy=getty.target + diff --git a/src/utils/vyos-hostsd-client b/src/utils/vyos-hostsd-client new file mode 100755 index 000000000..d3105c9cf --- /dev/null +++ b/src/utils/vyos-hostsd-client @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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 sys +import argparse + +import vyos.hostsd_client + +parser = argparse.ArgumentParser() +group = parser.add_mutually_exclusive_group() +group.add_argument('--add-hosts', action="store_true") +group.add_argument('--delete-hosts', action="store_true") +group.add_argument('--add-name-servers', action="store_true") +group.add_argument('--delete-name-servers', action="store_true") +group.add_argument('--set-host-name', action="store_true") + +parser.add_argument('--host', type=str, action="append") +parser.add_argument('--name-server', type=str, action="append") +parser.add_argument('--host-name', type=str) +parser.add_argument('--domain-name', type=str) +parser.add_argument('--search-domain', type=str, action="append") + +parser.add_argument('--tag', type=str) + +args = parser.parse_args() + +try: + client = vyos.hostsd_client.Client() + + if args.add_hosts: + if not args.tag: + raise ValueError("Tag is required for this operation") + data = [] + for h in args.host: + entry = {} + params = h.split(",") + if len(params) < 2: + raise ValueError("Malformed host entry") + entry['host'] = params[0] + entry['address'] = params[1] + entry['aliases'] = params[2:] + data.append(entry) + client.add_hosts(args.tag, data) + elif args.delete_hosts: + if not args.tag: + raise ValueError("Tag is required for this operation") + client.delete_hosts(args.tag) + elif args.add_name_servers: + if not args.tag: + raise ValueError("Tag is required for this operation") + client.add_name_servers(args.tag, args.name_server) + elif args.delete_name_servers: + if not args.tag: + raise ValueError("Tag is required for this operation") + client.delete_name_servers(args.tag) + elif args.set_host_name: + client.set_host_name(args.host_name, args.domain_name, args.search_domain) + else: + raise ValueError("Operation required") + +except ValueError as e: + print("Incorrect options: {0}".format(e)) + sys.exit(1) +except vyos.hostsd_client.VyOSHostsdError as e: + print("Server returned an error: {0}".format(e)) + sys.exit(1) + |