summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--debian/control1
-rw-r--r--interface-definitions/dns-forwarding.xml26
-rw-r--r--interface-definitions/interfaces-dummy.xml51
-rw-r--r--interface-definitions/interfaces-loopback.xml46
-rw-r--r--interface-definitions/interfaces-openvpn.xml48
-rw-r--r--python/vyos/configinterface.py23
-rw-r--r--python/vyos/configtree.py8
-rw-r--r--python/vyos/hostsd_client.py62
-rw-r--r--python/vyos/interfaces.py11
-rwxr-xr-xsrc/conf_mode/dns_forwarding.py62
-rwxr-xr-xsrc/conf_mode/host_name.py196
-rwxr-xr-xsrc/conf_mode/interface-bridge.py9
-rwxr-xr-xsrc/conf_mode/interface-dummy.py113
-rwxr-xr-xsrc/conf_mode/interface-loopback.py102
-rwxr-xr-xsrc/conf_mode/interface-openvpn.py31
-rwxr-xr-xsrc/migration-scripts/dns-forwarding/0-to-150
-rwxr-xr-xsrc/migration-scripts/dns-forwarding/1-to-278
-rwxr-xr-xsrc/services/vyos-hostsd268
-rw-r--r--src/systemd/vyos-hostsd.service23
-rwxr-xr-xsrc/utils/vyos-hostsd-client82
20 files changed, 981 insertions, 309 deletions
diff --git a/debian/control b/debian/control
index 41b402fc1..12eb7c309 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/interface-definitions/dns-forwarding.xml b/interface-definitions/dns-forwarding.xml
index 56820608c..a88c174e3 100644
--- a/interface-definitions/dns-forwarding.xml
+++ b/interface-definitions/dns-forwarding.xml
@@ -97,6 +97,23 @@
<valueless/>
</properties>
</leafNode>
+ <leafNode name="allow-from">
+ <properties>
+ <help>Networks allowed to query this server</help>
+ <valueHelp>
+ <format>ipv4net</format>
+ <description>IP address and prefix length</description>
+ </valueHelp>
+ <valueHelp>
+ <format>ipv6net</format>
+ <description>IPv6 address and prefix length</description>
+ </valueHelp>
+ <multi/>
+ <constraint>
+ <validator name="ip-prefix"/>
+ </constraint>
+ </properties>
+ </leafNode>
<leafNode name="listen-address">
<properties>
<help>Addresses to listen for DNS queries [REQUIRED]</help>
@@ -115,15 +132,6 @@
</constraint>
</properties>
</leafNode>
- <leafNode name="listen-on">
- <properties>
- <help>Interface to listen for DNS queries [DEPRECATED]</help>
- <completionHelp>
- <script>${vyos_completion_dir}/list_interfaces.py</script>
- </completionHelp>
- <multi/>
- </properties>
- </leafNode>
<leafNode name="negative-ttl">
<properties>
<help>Maximum amount of time negative entries are cached</help>
diff --git a/interface-definitions/interfaces-dummy.xml b/interface-definitions/interfaces-dummy.xml
new file mode 100644
index 000000000..7c425480a
--- /dev/null
+++ b/interface-definitions/interfaces-dummy.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0"?>
+<interfaceDefinition>
+ <node name="interfaces">
+ <children>
+ <tagNode name="dummy" owner="${vyos_conf_scripts_dir}/interface-dummy.py">
+ <properties>
+ <help>Dummy interface name</help>
+ <priority>300</priority>
+ <constraint>
+ <regex>dum[0-9]+$</regex>
+ </constraint>
+ <constraintErrorMessage>Dummy interface must be named dumN</constraintErrorMessage>
+ <valueHelp>
+ <format>dumN</format>
+ <description>Dummy interface name</description>
+ </valueHelp>
+ </properties>
+ <children>
+ <leafNode name="address">
+ <properties>
+ <help>IP address</help>
+ <valueHelp>
+ <format>ipv4net</format>
+ <description>IPv4 address and prefix length</description>
+ </valueHelp>
+ <valueHelp>
+ <format>ipv6net</format>
+ <description>IPv6 address and prefix length</description>
+ </valueHelp>
+ <multi/>
+ </properties>
+ </leafNode>
+ <leafNode name="description">
+ <properties>
+ <help>Interface description</help>
+ <constraint>
+ <regex>^.{1,256}$</regex>
+ </constraint>
+ <constraintErrorMessage>Interface description too long (limit 256 characters)</constraintErrorMessage>
+ </properties>
+ </leafNode>
+ <leafNode name="disable">
+ <properties>
+ <help>Disable interface</help>
+ </properties>
+ </leafNode>
+ </children>
+ </tagNode>
+ </children>
+ </node>
+</interfaceDefinition>
diff --git a/interface-definitions/interfaces-loopback.xml b/interface-definitions/interfaces-loopback.xml
new file mode 100644
index 000000000..267731b1c
--- /dev/null
+++ b/interface-definitions/interfaces-loopback.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0"?>
+<interfaceDefinition>
+ <node name="interfaces">
+ <children>
+ <tagNode name="loopback" owner="${vyos_conf_scripts_dir}/interface-loopback.py">
+ <properties>
+ <help>Loopback interface</help>
+ <priority>300</priority>
+ <constraint>
+ <regex>lo$</regex>
+ </constraint>
+ <constraintErrorMessage>Loopback interface must be named lo</constraintErrorMessage>
+ <valueHelp>
+ <format>lo</format>
+ <description>Loopback interface</description>
+ </valueHelp>
+ </properties>
+ <children>
+ <leafNode name="address">
+ <properties>
+ <help>IP address</help>
+ <valueHelp>
+ <format>ipv4net</format>
+ <description>IPv4 address and prefix length</description>
+ </valueHelp>
+ <valueHelp>
+ <format>ipv6net</format>
+ <description>IPv6 address and prefix length</description>
+ </valueHelp>
+ <multi/>
+ </properties>
+ </leafNode>
+ <leafNode name="description">
+ <properties>
+ <help>Interface description</help>
+ <constraint>
+ <regex>^.{1,256}$</regex>
+ </constraint>
+ <constraintErrorMessage>Interface description too long (limit 256 characters)</constraintErrorMessage>
+ </properties>
+ </leafNode>
+ </children>
+ </tagNode>
+ </children>
+ </node>
+</interfaceDefinition>
diff --git a/interface-definitions/interfaces-openvpn.xml b/interface-definitions/interfaces-openvpn.xml
index d4e903c48..bb5c5a965 100644
--- a/interface-definitions/interfaces-openvpn.xml
+++ b/interface-definitions/interfaces-openvpn.xml
@@ -361,54 +361,6 @@
<help>Server-mode options</help>
</properties>
<children>
- <node name="2-factor-authentication">
- <properties>
- <help>Two Factor Authentication providers</help>
- </properties>
- <children>
- <node name="authy">
- <properties>
- <help>Authy Two Factor Authentication providers</help>
- </properties>
- <children>
- <leafNode name="api-key">
- <properties>
- <help>Authy api key</help>
- </properties>
- </leafNode>
- <tagNode name="user">
- <properties>
- <help>Authy users (must be email address)</help>
- <constraint>
- <regex>[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$</regex>
- </constraint>
- <constraintErrorMessage>Invalid email address</constraintErrorMessage>
- </properties>
- <children>
- <leafNode name="country-calling-code">
- <properties>
- <help>Country calling codes</help>
- <constraint>
- <regex>[0-9]+$</regex>
- </constraint>
- <constraintErrorMessage>Invalid Country Calling Code</constraintErrorMessage>
- </properties>
- </leafNode>
- <leafNode name="phone-number">
- <properties>
- <help>Mobile phone number</help>
- <constraint>
- <regex>[0-9]+$</regex>
- </constraint>
- <constraintErrorMessage>Invalid Phone Number</constraintErrorMessage>
- </properties>
- </leafNode>
- </children>
- </tagNode>
- </children>
- </node>
- </children>
- </node>
<tagNode name="client">
<properties>
<help>Client-specific settings</help>
diff --git a/python/vyos/configinterface.py b/python/vyos/configinterface.py
index 0f5b0842c..188d5b9e2 100644
--- a/python/vyos/configinterface.py
+++ b/python/vyos/configinterface.py
@@ -123,11 +123,9 @@ def add_interface_address(intf, addr):
os.system('/opt/vyatta/sbin/vyatta-dhcpv6-client.pl --start -ifname "{}"'.format(intf))
elif vyos.validate.is_ipv4(addr):
if not vyos.validate.is_intf_addr_assigned(intf, addr):
- print("Assigning {} to {}".format(addr, intf))
os.system('sudo ip -4 addr add "{}" broadcast + dev "{}"'.format(addr, intf))
elif vyos.validate.is_ipv6(addr):
if not vyos.validate.is_intf_addr_assigned(intf, addr):
- print("Assigning {} to {}".format(addr, intf))
os.system('sudo ip -6 addr add "{}" dev "{}"'.format(addr, intf))
else:
raise ConfigError('{} is not a valid interface address'.format(addr))
@@ -151,3 +149,24 @@ def remove_interface_address(intf, addr):
raise ConfigError('{} is not a valid interface address'.format(addr))
pass
+
+def remove_interface(ifname):
+ """
+ Remove given interface from operating system, e.g. 'dum0'
+ """
+
+ if os.path.isdir('/sys/class/net/' + ifname):
+ os.system('ip link delete "{}"'.format(ifname))
+
+ pass
+
+def add_interface(type, ifname):
+ """
+ Add given interface to operating system, e.g. add 'dummy' interface with
+ name 'dum0'
+ """
+
+ if not os.path.isdir('/sys/class/net/' + ifname):
+ os.system('ip link add "{}" type "{}"'.format(ifname, type))
+
+ pass
diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py
index a812b62ec..8832a5a63 100644
--- a/python/vyos/configtree.py
+++ b/python/vyos/configtree.py
@@ -185,6 +185,14 @@ class ConfigTree(object):
return self.__to_commands(self.__config).decode()
def set(self, path, value=None, replace=True):
+ """Set new entry in VyOS configuration.
+ path: configuration path e.g. 'system dns forwarding listen-address'
+ value: value to be added to node, e.g. '172.18.254.201'
+ replace: True: current occurance will be replaced
+ False: new value will be appended to current occurances - use
+ this for adding values to a multi node
+ """
+
check_path(path)
path_str = " ".join(map(str, path)).encode()
diff --git a/python/vyos/hostsd_client.py b/python/vyos/hostsd_client.py
new file mode 100644
index 000000000..e2f05071b
--- /dev/null
+++ b/python/vyos/hostsd_client.py
@@ -0,0 +1,62 @@
+import json
+
+import zmq
+
+
+SOCKET_PATH = "ipc:///run/vyos-hostsd.sock"
+
+
+class VyOSHostsdError(Exception):
+ pass
+
+
+class Client(object):
+ def __init__(self):
+ try:
+ 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)
+ except zmq.error.Again:
+ raise VyOSHostsdError("Could not connect to vyos-hostsd")
+
+ def _communicate(self, msg):
+ try:
+ 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'])
+ except zmq.error.Again:
+ raise VyOSHostsdError("Could not connect to vyos-hostsd")
+
+ 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/python/vyos/interfaces.py b/python/vyos/interfaces.py
index 2e8ee4feb..d69ce9d04 100644
--- a/python/vyos/interfaces.py
+++ b/python/vyos/interfaces.py
@@ -43,3 +43,14 @@ def list_interfaces_of_type(typ):
else:
r = re.compile('^{0}\d+'.format(types_data[typ]))
return list(filter(lambda i: re.match(r, i), all_intfs))
+
+def get_type_of_interface(intf):
+ with open(intf_type_data_file, 'r') as f:
+ types_data = json.load(f)
+
+ for key,val in types_data.items():
+ r = re.compile('^{0}\d+'.format(val))
+ if re.match(r, intf):
+ return key
+
+ raise ValueError("No type found for interface name: {0}".format(intf))
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)
+