#!/usr/bin/env python3 # # Copyright (C) 2019-2020 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/>. # ######### # USAGE # ######### # This daemon listens on its socket for JSON messages. # The received message format is: # # { 'type': '<message type>', # 'op': '<message operation>', # 'data': <data list or dict> # } # # For supported message types, see below. # 'op' can be 'add', delete', 'get', 'set' or 'apply'. # Different message types support different sets of operations and different # data formats. # # Changes to configuration made via add or delete don't take effect immediately, # they are remembered in a state variable and saved to disk to a state file. # State is remembered across daemon restarts but not across system reboots # as it's saved in a temporary filesystem (/run). # # 'apply' is a special operation that applies the configuration from the cached # state, rendering all config files and reloading relevant daemons (currently # just pdns-recursor via rec-control). # # note: 'add' operation also acts as 'update' as it uses dict.update, if the # 'data' dict item value is a dict. If it is a list, it uses list.append. # ### tags # Tags can be arbitrary, but they are generally in this format: # 'static', 'system', 'dhcp(v6)-<intf>' or 'dhcp-server-<client ip>' # They are used to distinguish entries created by different scripts so they can # be removed and recreated without having to track what needs to be changed. # They are also used as a way to control which tags settings (e.g. nameservers) # get added to various config files via name_server_tags_(recursor|system) # ### name_server_tags_(recursor|system) # A list of tags whose nameservers and search domains is used to generate # /etc/resolv.conf and pdns-recursor config. # system list is used to generate resolv.conf. # recursor list is used to generate pdns-rec forward-zones. # When generating each file, the order of nameservers is as per the order of # name_server_tags (the order in which tags were added), then the order in # which the name servers for each tag were added. # #### Message types # ### name_servers # # { 'type': 'name_servers', # 'op': 'add', # 'data': { # '<str tag>': ['<str nameserver>', ...], # ... # } # } # # { 'type': 'name_servers', # 'op': 'delete', # 'data': ['<str tag>', ...] # } # # { 'type': 'name_servers', # 'op': 'get', # 'tag_regex': '<str regex>' # } # response: # { 'data': { # '<str tag>': ['<str nameserver>', ...], # ... # } # } # ### name_server_tags # # { 'type': 'name_server_tags', # 'op': 'add', # 'data': ['<str tag>', ...] # } # # { 'type': 'name_server_tags', # 'op': 'delete', # 'data': ['<str tag>', ...] # } # # { 'type': 'name_server_tags', # 'op': 'get', # } # response: # { 'data': ['<str tag>', ...] } # ### forward_zones ## Additional zones added to pdns-recursor forward-zones-file. ## If recursion_desired is true, '+' will be prepended to the zone line. ## If addnta is true, a NTA (Negative Trust Anchor) will be added via ## lua-config-file. # # { 'type': 'forward_zones', # 'op': 'add', # 'data': { # '<str zone>': { # 'server': ['<str nameserver>', ...], # 'addnta': <bool>, # 'recursion_desired': <bool> # } # ... # } # } # # { 'type': 'forward_zones', # 'op': 'delete', # 'data': ['<str zone>', ...] # } # # { 'type': 'forward_zones', # 'op': 'get', # } # response: # { 'data': { # '<str zone>': { ... }, # ... # } # } # # ### search_domains # # { 'type': 'search_domains', # 'op': 'add', # 'data': { # '<str tag>': ['<str domain>', ...], # ... # } # } # # { 'type': 'search_domains', # 'op': 'delete', # 'data': ['<str tag>', ...] # } # # { 'type': 'search_domains', # 'op': 'get', # } # response: # { 'data': { # '<str tag>': ['<str domain>', ...], # ... # } # } # ### hosts # # { 'type': 'hosts', # 'op': 'add', # 'data': { # '<str tag>': { # '<str host>': { # 'address': '<str address>', # 'aliases': ['<str alias>, ...] # }, # ... # }, # ... # } # } # # { 'type': 'hosts', # 'op': 'delete', # 'data': ['<str tag>', ...] # } # # { 'type': 'hosts', # 'op': 'get' # 'tag_regex': '<str regex>' # } # response: # { 'data': { # '<str tag>': { # '<str host>': { # 'address': '<str address>', # 'aliases': ['<str alias>, ...] # }, # ... # }, # ... # } # } ### host_name # # { 'type': 'host_name', # 'op': 'set', # 'data': { # 'host_name': '<str hostname>' # 'domain_name': '<str domainname>' # } # } import os import sys import time import json import signal import traceback import re import logging import zmq from voluptuous import Schema, MultipleInvalid, Required, Any from collections import OrderedDict from vyos.util import popen, chown, chmod_755, makedir, process_named_running from vyos.template import render debug = True # Configure logging logger = logging.getLogger(__name__) # set stream as output logs_handler = logging.StreamHandler() logger.addHandler(logs_handler) if debug: logger.setLevel(logging.DEBUG) else: logger.setLevel(logging.INFO) RUN_DIR = "/run/vyos-hostsd" STATE_FILE = os.path.join(RUN_DIR, "vyos-hostsd.state") SOCKET_PATH = "ipc://" + os.path.join(RUN_DIR, 'vyos-hostsd.sock') RESOLV_CONF_FILE = '/etc/resolv.conf' HOSTS_FILE = '/etc/hosts' PDNS_REC_USER = PDNS_REC_GROUP = 'pdns' PDNS_REC_RUN_DIR = '/run/powerdns' PDNS_REC_LUA_CONF_FILE = f'{PDNS_REC_RUN_DIR}/recursor.vyos-hostsd.conf.lua' PDNS_REC_ZONES_FILE = f'{PDNS_REC_RUN_DIR}/recursor.forward-zones.conf' STATE = { "name_servers": {}, "name_server_tags_recursor": [], "name_server_tags_system": [], "forward_zones": {}, "hosts": {}, "host_name": "vyos", "domain_name": "", "search_domains": {}, "changes": 0 } # the base schema that every received message must be in base_schema = Schema({ Required('op'): Any('add', 'delete', 'set', 'get', 'apply'), 'type': Any('name_servers', 'name_server_tags_recursor', 'name_server_tags_system', 'forward_zones', 'search_domains', 'hosts', 'host_name'), 'data': Any(list, dict), 'tag': str, 'tag_regex': str }) # more specific schemas op_schema = Schema({ 'op': str, }, required=True) op_type_schema = op_schema.extend({ 'type': str, }, required=True) host_name_add_schema = op_type_schema.extend({ 'data': { 'host_name': str, 'domain_name': Any(str, None) } }, required=True) data_dict_list_schema = op_type_schema.extend({ 'data': { str: [str] } }, required=True) data_list_schema = op_type_schema.extend({ 'data': [str] }, required=True) tag_regex_schema = op_type_schema.extend({ 'tag_regex': str }, required=True) forward_zone_add_schema = op_type_schema.extend({ 'data': { str: { 'server': [str], 'addnta': Any({}, None), 'recursion_desired': Any({}, None), } } }, required=False) hosts_add_schema = op_type_schema.extend({ 'data': { str: { str: { 'address': str, 'aliases': [str] } } } }, required=True) # op and type to schema mapping msg_schema_map = { 'name_servers': { 'add': data_dict_list_schema, 'delete': data_list_schema, 'get': tag_regex_schema }, 'name_server_tags_recursor': { 'add': data_list_schema, 'delete': data_list_schema, 'get': op_type_schema }, 'name_server_tags_system': { 'add': data_list_schema, 'delete': data_list_schema, 'get': op_type_schema }, 'forward_zones': { 'add': forward_zone_add_schema, 'delete': data_list_schema, 'get': op_type_schema }, 'search_domains': { 'add': data_dict_list_schema, 'delete': data_list_schema, 'get': tag_regex_schema }, 'hosts': { 'add': hosts_add_schema, 'delete': data_list_schema, 'get': tag_regex_schema }, 'host_name': { 'set': host_name_add_schema }, None: { 'apply': op_schema } } def validate_schema(data): base_schema(data) try: schema = msg_schema_map[data['type'] if 'type' in data else None][data['op']] schema(data) except KeyError: raise ValueError(( 'Invalid or unknown combination: ' f'op: "{data["op"]}", type: "{data["type"]}"')) def pdns_rec_control(command): # pdns-r process name is NOT equal to the name shown in ps if not process_named_running('pdns-r/worker'): logger.info(f'pdns_recursor not running, not sending "{command}"') return logger.info(f'Running "rec_control {command}"') (ret,ret_code) = popen(( f"rec_control --socket-dir={PDNS_REC_RUN_DIR} {command}")) if ret_code > 0: logger.exception(( f'"rec_control {command}" failed with exit status {ret_code}, ' f'output: "{ret}"')) def make_resolv_conf(state): logger.info(f"Writing {RESOLV_CONF_FILE}") render(RESOLV_CONF_FILE, 'vyos-hostsd/resolv.conf.tmpl', state, user='root', group='root') def make_hosts(state): logger.info(f"Writing {HOSTS_FILE}") render(HOSTS_FILE, 'vyos-hostsd/hosts.tmpl', state, user='root', group='root') def make_pdns_rec_conf(state): logger.info(f"Writing {PDNS_REC_LUA_CONF_FILE}") # on boot, /run/powerdns does not exist, so create it makedir(PDNS_REC_RUN_DIR, user=PDNS_REC_USER, group=PDNS_REC_GROUP) chmod_755(PDNS_REC_RUN_DIR) render(PDNS_REC_LUA_CONF_FILE, 'dns-forwarding/recursor.vyos-hostsd.conf.lua.tmpl', state, user=PDNS_REC_USER, group=PDNS_REC_GROUP) logger.info(f"Writing {PDNS_REC_ZONES_FILE}") render(PDNS_REC_ZONES_FILE, 'dns-forwarding/recursor.forward-zones.conf.tmpl', state, user=PDNS_REC_USER, group=PDNS_REC_GROUP) def set_host_name(state, data): if data['host_name']: state['host_name'] = data['host_name'] if 'domain_name' in data: state['domain_name'] = data['domain_name'] def add_items_to_dict(_dict, items): """ Dedupes and preserves sort order. """ assert isinstance(_dict, dict) assert isinstance(items, dict) if not items: return _dict.update(items) def add_items_to_dict_as_keys(_dict, items): """ Added item values are converted to OrderedDict with the value as keys and null values. This is to emulate a list but with inherent deduplication. Dedupes and preserves sort order. """ assert isinstance(_dict, dict) assert isinstance(items, dict) if not items: return for item, item_val in items.items(): if item not in _dict: _dict[item] = OrderedDict({}) _dict[item].update(OrderedDict.fromkeys(item_val)) def add_items_to_list(_list, items): """ Dedupes and preserves sort order. """ assert isinstance(_list, list) assert isinstance(items, list) if not items: return for item in items: if item not in _list: _list.append(item) def delete_items_from_dict(_dict, items): """ items is a list of keys to delete. Doesn't error if the key doesn't exist. """ assert isinstance(_dict, dict) assert isinstance(items, list) for item in items: if item in _dict: del _dict[item] def delete_items_from_list(_list, items): """ items is a list of items to remove. Doesn't error if the key doesn't exist. """ assert isinstance(_list, list) assert isinstance(items, list) for item in items: if item in _list: _list.remove(item) def get_items_from_dict_regex(_dict, item_regex_string): """ Returns the items whose keys match item_regex_string. """ assert isinstance(_dict, dict) assert isinstance(item_regex_string, str) tmp = {} regex = re.compile(item_regex_string) for item in _dict: if regex.match(item): tmp[item] = _dict[item] return tmp 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): result = None op = get_option(msg, 'op') if op in ['add', 'delete', 'set']: STATE['changes'] += 1 if op == 'delete': _type = get_option(msg, 'type') data = get_option(msg, 'data') if _type in ['name_servers', 'forward_zones', 'search_domains', 'hosts']: delete_items_from_dict(STATE[_type], data) elif _type in ['name_server_tags_recursor', 'name_server_tags_system']: delete_items_from_list(STATE[_type], data) else: raise ValueError(f'Operation "{op}" unknown data type "{_type}"') elif op == 'add': _type = get_option(msg, 'type') data = get_option(msg, 'data') if _type in ['name_servers', 'search_domains']: add_items_to_dict_as_keys(STATE[_type], data) elif _type in ['forward_zones', 'hosts']: add_items_to_dict(STATE[_type], data) # maybe we need to rec_control clear-nta each domain that was removed here? elif _type in ['name_server_tags_recursor', 'name_server_tags_system']: add_items_to_list(STATE[_type], data) else: raise ValueError(f'Operation "{op}" unknown data type "{_type}"') elif op == 'set': _type = get_option(msg, 'type') data = get_option(msg, 'data') if _type == 'host_name': set_host_name(STATE, data) else: raise ValueError(f'Operation "{op}" unknown data type "{_type}"') elif op == 'get': _type = get_option(msg, 'type') if _type in ['name_servers', 'search_domains', 'hosts']: tag_regex = get_option(msg, 'tag_regex') result = get_items_from_dict_regex(STATE[_type], tag_regex) elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'forward_zones']: result = STATE[_type] else: raise ValueError(f'Operation "{op}" unknown data type "{_type}"') elif op == 'apply': logger.info(f"Applying {STATE['changes']} changes") make_resolv_conf(STATE) make_hosts(STATE) make_pdns_rec_conf(STATE) pdns_rec_control('reload-lua-config') pdns_rec_control('reload-zones') logger.info("Success") result = {'message': f'Applied {STATE["changes"]} changes'} STATE['changes'] = 0 else: raise ValueError(f"Unknown operation {op}") logger.debug(f"Saving state to {STATE_FILE}") with open(STATE_FILE, 'w') as f: json.dump(STATE, f) return result if __name__ == '__main__': # Create a directory for state checkpoints os.makedirs(RUN_DIR, exist_ok=True) if os.path.exists(STATE_FILE): with open(STATE_FILE, 'r') as f: try: STATE = json.load(f) except: logger.exception(traceback.format_exc()) logger.exception("Failed to load the state file, using default") context = zmq.Context() socket = context.socket(zmq.REP) # Set the right permissions on the socket, then change it back o_mask = os.umask(0o000) socket.bind(SOCKET_PATH) os.umask(o_mask) while True: # Wait for next request from client msg_json = socket.recv().decode() logger.debug(f"Request data: {msg_json}") try: msg = json.loads(msg_json) validate_schema(msg) resp = {} resp['data'] = handle_message(msg) except ValueError as e: resp['error'] = str(e) except MultipleInvalid as e: # raised by schema resp['error'] = f'Invalid message: {str(e)}' logger.exception(resp['error']) except: logger.exception(traceback.format_exc()) resp['error'] = "Internal error" # Send reply back to client socket.send(json.dumps(resp).encode()) logger.debug(f"Sent response: {resp}")