summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniil Baturin <daniil@baturin.org>2019-08-20 18:53:35 +0200
committerDaniil Baturin <daniil@baturin.org>2019-08-20 19:34:03 +0200
commite5df80a3f54da23858c382a8e491ff9901317e1e (patch)
tree685ad5a5771d4914ddc2d0aeb06aa3be7b3c5401
parent8ec2d46a618bd19f9dfa7ccb08f33dc869e3b5f0 (diff)
downloadvyos-1x-e5df80a3f54da23858c382a8e491ff9901317e1e.tar.gz
vyos-1x-e5df80a3f54da23858c382a8e491ff9901317e1e.zip
T1598: initial implementation of the hosts keeper daemon.
-rw-r--r--debian/control1
-rw-r--r--python/vyos/hostsd_client.py56
-rwxr-xr-xsrc/services/vyos-hostsd254
-rw-r--r--src/systemd/vyos-hostsd.service22
-rwxr-xr-xsrc/utils/vyos-hostsd-client82
5 files changed, 415 insertions, 0 deletions
diff --git a/debian/control b/debian/control
index a65d0158e..f46e5d08e 100644
--- a/debian/control
+++ b/debian/control
@@ -28,6 +28,7 @@ Depends: python3,
python3-hurry.filesize,
python3-vici (>= 5.7.2),
python3-bottle,
+ python3-zmq,
ipaddrcheck,
tcpdump,
tshark,
diff --git a/python/vyos/hostsd_client.py b/python/vyos/hostsd_client.py
new file mode 100644
index 000000000..e02aefe6f
--- /dev/null
+++ b/python/vyos/hostsd_client.py
@@ -0,0 +1,56 @@
+import json
+
+import zmq
+
+
+SOCKET_PATH = "ipc:///run/vyos-hostsd.sock"
+
+
+class VyOSHostsdError(Exception):
+ pass
+
+
+class Client(object):
+ def __init__(self):
+ context = zmq.Context()
+ self.__socket = context.socket(zmq.REQ)
+ self.__socket.RCVTIMEO = 10000 #ms
+ self.__socket.setsockopt(zmq.LINGER, 0)
+ self.__socket.connect(SOCKET_PATH)
+
+ def _communicate(self, msg):
+ request = json.dumps(msg).encode()
+ self.__socket.send(request)
+
+ reply_msg = self.__socket.recv().decode()
+ reply = json.loads(reply_msg)
+ if 'error' in reply:
+ raise VyOSHostsdError(reply['error'])
+
+ def set_host_name(self, host_name, domain_name, search_domains):
+ msg = {
+ 'type': 'host_name',
+ 'op': 'set',
+ 'data': {
+ 'host_name': host_name,
+ 'domain_name': domain_name,
+ 'search_domains': search_domains
+ }
+ }
+ self._communicate(msg)
+
+ def add_hosts(self, tag, hosts):
+ msg = {'type': 'hosts', 'op': 'add', 'tag': tag, 'data': hosts}
+ self._communicate(msg)
+
+ def delete_hosts(self, tag):
+ msg = {'type': 'hosts', 'op': 'delete', 'tag': tag}
+ self._communicate(msg)
+
+ def add_name_servers(self, tag, servers):
+ msg = {'type': 'name_servers', 'op': 'add', 'tag': tag, 'data': servers}
+ self._communicate(msg)
+
+ def delete_name_servers(self, tag):
+ msg = {'type': 'name_servers', 'op': 'delete', 'tag': tag}
+ self._communicate(msg)
diff --git a/src/services/vyos-hostsd b/src/services/vyos-hostsd
new file mode 100755
index 000000000..972c2e3dc
--- /dev/null
+++ b/src/services/vyos-hostsd
@@ -0,0 +1,254 @@
+#!/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 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 ###
+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
+
+
+{%- 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 ###
+{% 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>}}
+#
+# 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)
+
+
+if __name__ == '__main__':
+ # 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..fe6c163d7
--- /dev/null
+++ b/src/systemd/vyos-hostsd.service
@@ -0,0 +1,22 @@
+[Unit]
+Description=VyOS DNS configuration keeper
+After=auditd.service systemd-user-sessions.service time-sync.target
+
+[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)
+