summaryrefslogtreecommitdiff
path: root/src/services/vyos-hostsd
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 /src/services/vyos-hostsd
parent8ec2d46a618bd19f9dfa7ccb08f33dc869e3b5f0 (diff)
downloadvyos-1x-e5df80a3f54da23858c382a8e491ff9901317e1e.tar.gz
vyos-1x-e5df80a3f54da23858c382a8e491ff9901317e1e.zip
T1598: initial implementation of the hosts keeper daemon.
Diffstat (limited to 'src/services/vyos-hostsd')
-rwxr-xr-xsrc/services/vyos-hostsd254
1 files changed, 254 insertions, 0 deletions
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())