summaryrefslogtreecommitdiff
path: root/src/services/vyos-hostsd
diff options
context:
space:
mode:
Diffstat (limited to 'src/services/vyos-hostsd')
-rwxr-xr-xsrc/services/vyos-hostsd618
1 files changed, 618 insertions, 0 deletions
diff --git a/src/services/vyos-hostsd b/src/services/vyos-hostsd
new file mode 100755
index 000000000..0079f7e5c
--- /dev/null
+++ b/src/services/vyos-hostsd
@@ -0,0 +1,618 @@
+#!/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 will be added via lua-config-file.
+#
+# { 'type': 'forward_zones',
+# 'op': 'add',
+# 'data': {
+# '<str zone>': {
+# 'nslist': ['<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: {
+ 'nslist': [str],
+ 'addNTA': bool,
+ 'recursion-desired': bool
+ }
+ }
+ }, required=True)
+
+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(0o007)
+ 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}")