diff options
Diffstat (limited to 'src')
62 files changed, 6719 insertions, 601 deletions
diff --git a/src/completion/list_ntp_servers.sh b/src/completion/list_ntp_servers.sh new file mode 100755 index 000000000..d0977fbd6 --- /dev/null +++ b/src/completion/list_ntp_servers.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# Completion script used to select specific NTP server +/bin/cli-shell-api -- listEffectiveNodes system ntp server | sed "s/'//g" diff --git a/src/conf_mode/accel_pppoe.py b/src/conf_mode/accel_pppoe.py new file mode 100755 index 000000000..e1668d3c9 --- /dev/null +++ b/src/conf_mode/accel_pppoe.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import re +import subprocess +import jinja2 +import socket +import time +import syslog as sl + +from vyos.config import Config +from vyos import ConfigError + +pidfile = r'/var/run/accel_pppoe.pid' +pppoe_cnf_dir = r'/etc/accel-ppp/pppoe' +chap_secrets = pppoe_cnf_dir + '/chap-secrets' +pppoe_conf = pppoe_cnf_dir + '/pppoe.config' +# accel-pppd -d -c /etc/accel-ppp/pppoe/pppoe.config -p /var/run/accel_pppoe.pid + +### config path creation +if not os.path.exists(pppoe_cnf_dir): + os.makedirs(pppoe_cnf_dir) + sl.syslog(sl.LOG_NOTICE, pppoe_cnf_dir + " created") + +pppoe_config = ''' +### generated by accel_pppoe.py ### +[modules] +log_syslog +pppoe +ippool +{% if client_ipv6_pool %} +ipv6pool +{% endif %} +chap-secrets +auth_pap +auth_chap_md5 +auth_mschap_v1 +auth_mschap_v2 +#pppd_compat +shaper +{% if snmp == 'enable' or snmp == 'enable-ma' %} +net-snmp +{% endif %} +{% if limits %} +connlimit +{% endif %} +{% if authentication['mode'] == 'radius' %} +radius +{% endif %} + +[core] +thread-count={{thread_cnt}} + +[log] +syslog=accel-pppoe,daemon +copy=1 +level=5 + +{% if snmp == 'enable-ma' %} +[snmp] +master=1 +{% endif -%} + +[client-ip-range] +disable + +[ip-pool] +{% if client_ip_pool %} +{{client_ip_pool}} +{% endif %} +gw-ip-address={{ppp_gw}} + +{% if client_ipv6_pool %} +[ipv6-pool] +{% for prfx in client_ipv6_pool['prefix']: %} +{{prfx}} +{% endfor %} +{% for prfx in client_ipv6_pool['delegate-prefix']: %} +delegate={{prfx}} +{% endfor %} +{% endif -%} + +{% if dns %} +[dns] +{% if dns[0] %} +dns1={{dns[0]}} +{% endif -%} +{% if dns[1] %} +dns2={{dns[1]}} +{% endif -%} +{% endif -%} + +{% if dnsv6 %} +[dnsv6] +{% for srv in dnsv6: %} +dns={{srv}} +{% endfor %} +{% endif -%} + +{% if wins %} +[wins] +{% if wins[0] %} +wins1={{wins[0]}} +{% endif %} +{% if wins[1] %} +wins2={{wins[1]}} +{% endif -%} +{% endif -%} + +{% if authentication['mode'] == 'local' %} +[chap-secrets] +chap-secrets=/etc/accel-ppp/pppoe/chap-secrets +{% endif -%} + +{% if authentication['mode'] == 'radius' %} +[radius] +{% for rsrv in authentication['radiussrv']: %} +server={{rsrv}},{{authentication['radiussrv'][rsrv]['secret']}},\ +req-limit={{authentication['radiussrv'][rsrv]['req-limit']}},\ +fail-time={{authentication['radiussrv'][rsrv]['fail-time']}} +{% endfor %} +{% if authentication['radiusopt']['timeout'] %} +timeout={{authentication['radiusopt']['timeout']}} +{% endif %} +{% if authentication['radiusopt']['acct-timeout'] %} +acct-timeout={{authentication['radiusopt']['acct-timeout']}} +{% endif %} +{% if authentication['radiusopt']['max-try'] %} +max-try={{authentication['radiusopt']['max-try']}} +{% endif %} +{% if authentication['radiusopt']['nas-id'] %} +nas-identifier={{authentication['radiusopt']['nas-id']}} +{% endif %} +{% if authentication['radiusopt']['nas-ip'] %} +nas-ip-address={{authentication['radiusopt']['nas-ip']}} +{% endif -%} +{% if authentication['radiusopt']['dae-srv'] %} +dae-server={{authentication['radiusopt']['dae-srv']['ip-addr']}}:\ +{{authentication['radiusopt']['dae-srv']['port']}},\ +{{authentication['radiusopt']['dae-srv']['secret']}} +{% endif -%} +gw-ip-address={{ppp_gw}} +verbose=1 + +{% if authentication['radiusopt']['shaper'] %} +[shaper] +verbose=1 +attr={{authentication['radiusopt']['shaper']['attr']}} +{% endif -%} +{% endif %} + +[ppp] +verbose=1 +check-ip=1 +single-session=replace +{% if ppp_options['ccp'] %} +ccp=1 +{% endif %} +{% if ppp_options['min-mtu'] %} +min-mtu={{ppp_options['min-mtu']}} +{% else %} +min-mtu={{mtu}} +{% endif %} +{% if ppp_options['mru'] %} +mru={{ppp_options['mru']}} +{% endif %} +{% if ppp_options['mppe'] %} +mppe={{ppp_options['mppe']}} +{% else %} +mppe=prefer +{% endif %} +{% if ppp_options['lcp-echo-interval'] %} +lcp-echo-interval={{ppp_options['lcp-echo-interval']}} +{% else %} +lcp-echo-interval=30 +{% endif %} +{% if ppp_options['lcp-echo-timeout'] %} +lcp-echo-timeout={{ppp_options['lcp-echo-timeout']}} +{% endif %} +{% if ppp_options['lcp-echo-failure'] %} +lcp-echo-failure={{ppp_options['lcp-echo-failure']}} +{% else %} +lcp-echo-failure=3 +{% endif %} +{% if ppp_options['ipv4'] %} +ipv4={{ppp_options['ipv4']}} +{% endif %} +{% if ppp_options['ipv6'] %} +ipv6={{ppp_options['ipv6']}} +{% if ppp_options['ipv6-intf-id'] %} +ipv6-intf-id={{ppp_options['ipv6-intf-id']}} +{% endif %} +{% if ppp_options['ipv6-peer-intf-id'] %} +ipv6-peer-intf-id={{ppp_options['ipv6-peer-intf-id']}} +{% endif %} +{% if ppp_options['ipv6-accept-peer-intf-id'] %} +ipv6-accept-peer-intf-id={{ppp_options['ipv6-accept-peer-intf-id']}} +{% endif %} +{% endif %} +mtu={{mtu}} + +[pppoe] +verbose=1 +{% if concentrator %} +ac-name={{concentrator}} +{% endif %} +{% if interface %} +{% for int in interface %} +interface={{int}} +{% endfor %} +{% endif %} +{% if svc_name %} +service-name={{svc_name}} +{% endif %} +pado-delay=0 +# maybe: called-sid, tr101, padi-limit etc. + +{% if limits %} +[connlimit] +limit={{limits['conn-limit']}} +burst={{limits['burst']}} +timeout={{limits['timeout']}} +{% endif %} + +[cli] +tcp=127.0.0.1:2001 +''' + +### pppoe chap secrets +chap_secrets_conf = ''' +# username server password acceptable local IP addresses shaper +{% for user in authentication['local-users'] %} +{% if authentication['local-users'][user]['state'] == 'enabled' %} +{% if (authentication['local-users'][user]['upload']) and (authentication['local-users'][user]['download']) %} +{{user}}\t*\t{{authentication['local-users'][user]['passwd']}}\t{{authentication['local-users'][user]['ip']}}\t\ +{{authentication['local-users'][user]['download']}}/{{authentication['local-users'][user]['upload']}} +{% else %} +{{user}}\t*\t{{authentication['local-users'][user]['passwd']}}\t{{authentication['local-users'][user]['ip']}} +{% endif %} +{% endif %} +{% endfor %} +''' +### +# inline helper functions +### +# depending on hw and threads, daemon needs a little to start +# if it takes longer than 100 * 0.5 secs, exception is being raised +# not sure if that's the best way to check it, but it worked so far quite well +### +def chk_con(): + cnt = 0 + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + while True: + try: + s.connect(("127.0.0.1", 2001)) + break + except ConnectionRefusedError: + time.sleep(0.5) + cnt +=1 + if cnt == 100: + raise("failed to start pppoe server") + break + +### chap_secrets file if auth mode local +def write_chap_secrets(c): + tmpl = jinja2.Template(chap_secrets_conf, trim_blocks=True) + chap_secrets_txt = tmpl.render(c) + old_umask = os.umask(0o077) + open(chap_secrets,'w').write(chap_secrets_txt) + os.umask(old_umask) + sl.syslog(sl.LOG_NOTICE, chap_secrets + ' written') + +def accel_cmd(cmd=''): + if not cmd: + return None + try: + ret = subprocess.check_output(['/usr/bin/accel-cmd',cmd]).decode().strip() + return ret + except: + return 1 + +### +# inline helper functions end +### + +def get_config(): + c = Config() + if not c.exists('service pppoe-server'): + return None + + config_data = { + 'concentrator' : 'vyos-ac', + 'authentication' : { + 'local-users' : { + }, + 'mode' : 'local', + 'radiussrv' : {}, + 'radiusopt' : {} + }, + 'client_ip_pool' : '', + 'client_ipv6_pool' : {}, + 'interface' : [], + 'ppp_gw' : '', + 'svc_name' : '', + 'dns' : [], + 'dnsv6' : [], + 'wins' : [], + 'mtu' : '1492', + 'ppp_options' : {}, + 'limits' : {}, + 'snmp' : 'disable' + } + + c.set_level('service pppoe-server') + ### general options + if c.exists('access-concentrator'): + config_data['concentrator'] = c.return_value('access-concentrator') + if c.exists('service-name'): + config_data['svc_name'] = c.return_value('service-name') + if c.exists('interface'): + config_data['interface'] = c.return_values('interface') + if c.exists('local-ip'): + config_data['ppp_gw'] = c.return_value('local-ip') + if c.exists('dns-servers'): + if c.return_value('dns-servers server-1'): + config_data['dns'].append(c.return_value('dns-servers server-1')) + if c.return_value('dns-servers server-2'): + config_data['dns'].append(c.return_value('dns-servers server-2')) + if c.exists('dnsv6-servers'): + if c.return_value('dnsv6-servers server-1'): + config_data['dnsv6'].append(c.return_value('dnsv6-servers server-1')) + if c.return_value('dnsv6-servers server-2'): + config_data['dnsv6'].append(c.return_value('dnsv6-servers server-2')) + if c.return_value('dnsv6-servers server-3'): + config_data['dnsv6'].append(c.return_value('dnsv6-servers server-3')) + if c.exists('wins-servers'): + if c.return_value('wins-servers server-1'): + config_data['wins'].append(c.return_value('wins-servers server-1')) + if c.return_value('wins-servers server-2'): + config_data['wins'].append(c.return_value('wins-servers server-2')) + if c.exists('client-ip-pool'): + if c.exists('client-ip-pool start'): + config_data['client_ip_pool'] = c.return_value('client-ip-pool start') + if c.exists('client-ip-pool stop'): + config_data['client_ip_pool'] += '-' + re.search('[0-9]+$', c.return_value('client-ip-pool stop')).group(0) + else: + raise ConfigError('client ip pool stop required') + if c.exists('client-ipv6-pool prefix'): + config_data['client_ipv6_pool']['prefix'] = c.return_values('client-ipv6-pool prefix') + if c.exists('client-ipv6-pool delegate-prefix'): + config_data['client_ipv6_pool']['delegate-prefix'] = c.return_values('client-ipv6-pool delegate-prefix') + if c.exists('limits'): + if c.exists('limits burst'): + config_data['limits']['burst'] = str(c.return_value('limits burst')) + if c.exists('limits timeout'): + config_data['limits']['timeout'] = str(c.return_value('limits timeout')) + if c.exists('limits connection-limit'): + config_data['limits']['conn-limit'] = str(c.return_value('limits connection-limit')) + if c.exists('snmp'): + config_data['snmp'] = 'enable' + if c.exists('snmp master-agent'): + config_data['snmp'] = 'enable-ma' + + #### authentication mode local + if not c.exists('authentication mode'): + raise ConfigError('pppoe-server authentication mode required') + + if c.exists('authentication mode local'): + if c.exists('authentication local-users username'): + for usr in c.list_nodes('authentication local-users username'): + config_data['authentication']['local-users'].update( + { + usr : { + 'passwd' : None, + 'state' : 'enabled', + 'ip' : '*', + 'upload' : None, + 'download' : None + } + } + ) + if c.exists('authentication local-users username ' + usr + ' password'): + config_data['authentication']['local-users'][usr]['passwd'] = c.return_value('authentication local-users username ' + usr + ' password') + if c.exists('authentication local-users username ' + usr + ' disable'): + config_data['authentication']['local-users'][usr]['state'] = 'disable' + if c.exists('authentication local-users username ' + usr + ' static-ip'): + config_data['authentication']['local-users'][usr]['ip'] = c.return_value('authentication local-users username ' + usr + ' static-ip') + if c.exists('authentication local-users username ' + usr + ' rate-limit download'): + config_data['authentication']['local-users'][usr]['download'] = c.return_value('authentication local-users username ' + usr + ' rate-limit download') + if c.exists('authentication local-users username ' + usr + ' rate-limit upload'): + config_data['authentication']['local-users'][usr]['upload'] = c.return_value('authentication local-users username ' + usr + ' rate-limit upload') + + ### authentication mode radius servers and settings + + if c.exists('authentication mode radius'): + config_data['authentication']['mode'] = 'radius' + rsrvs = c.list_nodes('authentication radius-server') + for rsrv in rsrvs: + if c.return_value('authentication radius-server ' + rsrv + ' fail-time') == None: + ftime = '0' + else: + ftime = str(c.return_value('authentication radius-server ' + rsrv + ' fail-time')) + if c.return_value('authentication radius-server ' + rsrv + ' req-limit') == None: + reql = '0' + else: + reql = str(c.return_value('authentication radius-server ' + rsrv + ' req-limit')) + config_data['authentication']['radiussrv'].update( + { + rsrv : { + 'secret' : c.return_value('authentication radius-server ' + rsrv + ' secret'), + 'fail-time' : ftime, + 'req-limit' : reql + } + } + ) + + #### advanced radius-setting + if c.exists('authentication radius-settings'): + if c.exists('authentication radius-settings acct-timeout'): + config_data['authentication']['radiusopt']['acct-timeout'] = c.return_value('authentication radius-settings acct-timeout') + if c.exists('authentication radius-settings max-try'): + config_data['authentication']['radiusopt']['max-try'] = c.return_value('authentication radius-settings max-try') + if c.exists('authentication radius-settings timeout'): + config_data['authentication']['radiusopt']['timeout'] = c.return_value('authentication radius-settings timeout') + if c.exists('authentication radius-settings nas-identifier'): + config_data['authentication']['radiusopt']['nas-id'] = c.return_value('authentication radius-settings nas-identifier') + if c.exists('authentication radius-settings nas-ip-address'): + config_data['authentication']['radiusopt']['nas-ip'] = c.return_value('authentication radius-settings nas-ip-address') + if c.exists('authentication radius-settings dae-server'): + config_data['authentication']['radiusopt'].update( + { + 'dae-srv' : { + 'ip-addr' : c.return_value('authentication radius-settings dae-server ip-address'), + 'port' : c.return_value('authentication radius-settings dae-server port'), + 'secret' : str(c.return_value('authentication radius-settings dae-server secret')) + } + } + ) + #### filter-id is the internal accel default if attribute is empty + #### set here as default for visibility which may change in the future + if c.exists('authentication radius-settings rate-limit enable'): + if not c.exists('authentication radius-settings rate-limit attribute'): + config_data['authentication']['radiusopt']['shaper'] = { + 'attr' : 'Filter-ID' + } + else: + config_data['authentication']['radiusopt']['shaper'] = { + 'attr' : c.return_value('authentication radius-settings rate-limit attribute') + } + + if c.exists('mtu'): + config_data['mtu'] = c.return_value('mtu') + + ### ppp_options + ppp_options = {} + if c.exists('ppp-options'): + if c.exists('ppp-options ccp'): + ppp_options['ccp'] = c.return_value('ppp-options ccp') + if c.exists('ppp-options min-mtu'): + ppp_options['min-mtu'] = c.return_value('ppp-options min-mtu') + if c.exists('ppp-options mru'): + ppp_options['mru'] = c.return_value('ppp-options mru') + if c.exists('ppp-options mppe deny'): + ppp_options['mppe'] = 'deny' + if c.exists('ppp-options mppe require'): + ppp_options['mppe'] = 'requre' + if c.exists('ppp-options mppe prefer'): + ppp_options['mppe'] = 'prefer' + if c.exists('ppp-options lcp-echo-failure'): + ppp_options['lcp-echo-failure'] = c.return_value('ppp-options lcp-echo-failure') + if c.exists('ppp-options lcp-echo-interval'): + ppp_options['lcp-echo-interval'] = c.return_value('ppp-options lcp-echo-interval') + if c.exists('ppp-options ipv4'): + ppp_options['ipv4'] = c.return_value('ppp-options ipv4') + if c.exists('ppp-options ipv6'): + ppp_options['ipv6'] = c.return_value('ppp-options ipv6') + if c.exists('ppp-options ipv6-accept-peer-intf-id'): + ppp_options['ipv6-accept-peer-intf-id']= 1 + if c.exists('ppp-options ipv6-intf-id'): + ppp_options['ipv6-intf-id'] = c.return_value('ppp-options ipv6-intf-id') + if c.exists('ppp-options ipv6-peer-intf-id'): + ppp_options['ipv6-peer-intf-id'] = c.return_value('ppp-options ipv6-peer-intf-id') + if c.exists('ppp-options lcp-echo-timeout'): + ppp_options['lcp-echo-timeout'] = c.return_value('ppp-options lcp-echo-timeout') + + if len(ppp_options) !=0: + config_data['ppp_options'] = ppp_options + + return config_data + +def verify(c): + if c == None: + return None + if c['authentication']['mode'] == 'local': + if not c['authentication']['local-users']: + raise ConfigError('pppoe-server authentication local-users required') + + for usr in c['authentication']['local-users']: + if not c['authentication']['local-users'][usr]['passwd']: + raise ConfigError('user ' + usr + ' requires a password') + ### if up/download is set, check that both have a value + if c['authentication']['local-users'][usr]['upload']: + if not c['authentication']['local-users'][usr]['download']: + raise ConfigError('user ' + usr + ' requires download speed value') + if c['authentication']['local-users'][usr]['download']: + if not c['authentication']['local-users'][usr]['upload']: + raise ConfigError('user ' + usr + ' requires upload speed value') + + if not c['ppp_gw']: + raise ConfigError('pppoe-server local-ip required') + + if c['authentication']['mode'] == 'radius': + if len(c['authentication']['radiussrv']) == 0: + raise ConfigError('radius server required') + for rsrv in c['authentication']['radiussrv']: + if c['authentication']['radiussrv'][rsrv]['secret'] == None: + raise ConfigError('radius server ' + rsrv + ' needs a secret configured') + +def generate(c): + if c == None: + return None + + ### accel-cmd reload doesn't work so any change results in a restart of the daemon + try: + if os.cpu_count() == 1: + c['thread_cnt'] = 1 + else: + c['thread_cnt'] = int(os.cpu_count()/2) + except KeyError: + if os.cpu_count() == 1: + c['thread_cnt'] = 1 + else: + c['thread_cnt'] = int(os.cpu_count()/2) + + tmpl = jinja2.Template(pppoe_config, trim_blocks=True) + config_text = tmpl.render(c) + open(pppoe_conf,'w').write(config_text) + + if c['authentication']['local-users']: + write_chap_secrets(c) + + return c + +def apply(c): + if c == None: + if os.path.exists(pidfile): + accel_cmd('shutdown hard') + if os.path.exists(pidfile): + os.remove(pidfile) + return None + + if not os.path.exists(pidfile): + ret = subprocess.call(['/usr/sbin/accel-pppd','-c',pppoe_conf,'-p',pidfile,'-d']) + chk_con() + if ret !=0 and os.path.exists(pidfile): + os.remove(pidfile) + raise ConfigError('accel-pppd failed to start') + else: + accel_cmd('restart') + sl.syslog(sl.LOG_NOTICE, "reloading config via daemon restart") + +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/accel_pptp.py b/src/conf_mode/accel_pptp.py new file mode 100755 index 000000000..6c53e8dd4 --- /dev/null +++ b/src/conf_mode/accel_pptp.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import re +import subprocess +import jinja2 +import socket +import time +import syslog as sl + +from vyos.config import Config +from vyos import ConfigError + +pidfile = r'/var/run/accel_pptp.pid' +pptp_cnf_dir = r'/etc/accel-ppp/pptp' +chap_secrets = pptp_cnf_dir + '/chap-secrets' +pptp_conf = pptp_cnf_dir + '/pptp.config' +# accel-pppd -d -c /etc/accel-ppp/pppoe/pppoe.config -p /var/run/accel_pppoe.pid + +### config path creation +if not os.path.exists(pptp_cnf_dir): + os.makedirs(pptp_cnf_dir) + sl.syslog(sl.LOG_NOTICE, pptp_cnf_dir + " created") + +pptp_config = ''' +### generated by accel_pptp.py ### +[modules] +log_syslog +pptp +ippool +chap-secrets +{% if authentication['auth_proto'] %} +{{ authentication['auth_proto'] }} +{% else %} +auth_mschap_v2 +{% endif %} +{% if authentication['mode'] == 'radius' %} +radius +{% endif -%} + +[core] +thread-count={{thread_cnt}} + +[log] +syslog=accel-pptp,daemon +copy=1 +level=5 + +{% if dns %} +[dns] +{% if dns[0] %} +dns1={{dns[0]}} +{% endif %} +{% if dns[1] %} +dns2={{dns[1]}} +{% endif %} +{% endif %} + +{% if wins %} +[wins] +{% if wins[0] %} +wins1={{wins[0]}} +{% endif %} +{% if wins[1] %} +wins2={{wins[1]}} +{% endif %} +{% endif %} + +[pptp] +bind={{outside_addr}} +verbose=5 +ppp-max-mtu={{mtu}} +mppe={{authentication['mppe']}} +echo-interval=10 +echo-failure=3 + + +[client-ip-range] +0.0.0.0/0 + +[ip-pool] +tunnel={{client_ip_pool}} +gw-ip-address={{gw_ip}} + +{% if authentication['mode'] == 'local' %} +[chap-secrets] +chap-secrets=/etc/accel-ppp/pptp/chap-secrets +{% endif %} + +[ppp] +verbose=5 +check-ip=1 +single-session=replace + +{% if authentication['mode'] == 'radius' %} +[radius] +{% for rsrv in authentication['radiussrv']: %} +server={{rsrv}},{{authentication['radiussrv'][rsrv]['secret']}},\ +req-limit={{authentication['radiussrv'][rsrv]['req-limit']}},\ +fail-time={{authentication['radiussrv'][rsrv]['fail-time']}} +{% endfor %} +timeout=30 +acct-timeout=30 +max-try=3 +{%endif %} + +[cli] +tcp=127.0.0.1:2003 +''' + +### pptp chap secrets +chap_secrets_conf = ''' +# username server password acceptable local IP addresses +{% for user in authentication['local-users'] %} +{% if authentication['local-users'][user]['state'] == 'enabled' %} +{{user}}\t*\t{{authentication['local-users'][user]['passwd']}}\t{{authentication['local-users'][user]['ip']}} +{% endif %} +{% endfor %} +''' +### +# inline helper functions +### +# depending on hw and threads, daemon needs a little to start +# if it takes longer than 100 * 0.5 secs, exception is being raised +# not sure if that's the best way to check it, but it worked so far quite well +### +def chk_con(): + cnt = 0 + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + while True: + try: + s.connect(("127.0.0.1", 2003)) + break + except ConnectionRefusedError: + time.sleep(0.5) + cnt +=1 + if cnt == 100: + raise("failed to start pptp server") + break + +### chap_secrets file if auth mode local +def write_chap_secrets(c): + tmpl = jinja2.Template(chap_secrets_conf, trim_blocks=True) + chap_secrets_txt = tmpl.render(c) + old_umask = os.umask(0o077) + open(chap_secrets,'w').write(chap_secrets_txt) + os.umask(old_umask) + sl.syslog(sl.LOG_NOTICE, chap_secrets + ' written') + +def accel_cmd(cmd=''): + if not cmd: + return None + try: + ret = subprocess.check_output(['/usr/bin/accel-cmd','-p','2003',cmd]).decode().strip() + return ret + except: + return 1 + +### +# inline helper functions end +### + +def get_config(): + c = Config() + if not c.exists('vpn pptp remote-access '): + return None + + c.set_level('vpn pptp remote-access') + config_data = { + 'authentication' : { + 'mode' : 'local', + 'local-users' : { + }, + 'radiussrv' : {}, + 'auth_proto' : 'auth_mschap_v2', + 'mppe' : 'require' + }, + 'outside_addr' : '', + 'dns' : [], + 'wins' : [], + 'client_ip_pool' : '', + 'mtu' : '1436', + } + + ### general options ### + + if c.exists('dns-servers server-1'): + config_data['dns'].append( c.return_value('dns-servers server-1')) + if c.exists('dns-servers server-2'): + config_data['dns'].append( c.return_value('dns-servers server-2')) + if c.exists('wins-servers server-1'): + config_data['wins'].append( c.return_value('wins-servers server-1')) + if c.exists('wins-servers server-2'): + config_data['wins'].append( c.return_value('wins-servers server-2')) + if c.exists('outside-address'): + config_data['outside_addr'] = c.return_value('outside-address') + + ### auth local + if c.exists('authentication mode local'): + if c.exists('authentication local-users username'): + for usr in c.list_nodes('authentication local-users username'): + config_data['authentication']['local-users'].update( + { + usr : { + 'passwd' : '', + 'state' : 'enabled', + 'ip' : '' + } + } + ) + + if c.exists('authentication local-users username ' + usr + ' password'): + config_data['authentication']['local-users'][usr]['passwd'] = c.return_value('authentication local-users username ' + usr + ' password') + if c.exists('authentication local-users username ' + usr + ' disable'): + config_data['authentication']['local-users'][usr]['state'] = 'disable' + if c.exists('authentication local-users username ' + usr + ' static-ip'): + config_data['authentication']['local-users'][usr]['ip'] = c.return_value('authentication local-users username ' + usr + ' static-ip') + + ### authentication mode radius servers and settings + + if c.exists('authentication mode radius'): + config_data['authentication']['mode'] = 'radius' + rsrvs = c.list_nodes('authentication radius server') + for rsrv in rsrvs: + if c.return_value('authentication radius server ' + rsrv + ' fail-time') == None: + ftime = '0' + else: + ftime = str(c.return_value('authentication radius server ' + rsrv + ' fail-time')) + if c.return_value('authentication radius-server ' + rsrv + ' req-limit') == None: + reql = '0' + else: + reql = str(c.return_value('authentication radius server ' + rsrv + ' req-limit')) + + config_data['authentication']['radiussrv'].update( + { + rsrv : { + 'secret' : c.return_value('authentication radius server ' + rsrv + ' key'), + 'fail-time' : ftime, + 'req-limit' : reql + } + } + ) + + if c.exists('client-ip-pool'): + if c.exists('client-ip-pool start'): + config_data['client_ip_pool'] = c.return_value('client-ip-pool start') + if c.exists('client-ip-pool stop'): + config_data['client_ip_pool'] += '-' + re.search('[0-9]+$', c.return_value('client-ip-pool stop')).group(0) + if c.exists('mtu'): + config_data['mtu'] = c.return_value('mtu') + + + ### gateway address + if c.exists('gateway-address'): + config_data['gw_ip'] = c.return_value('gateway-address') + else: + config_data['gw_ip'] = re.sub('[0-9]+$','1',config_data['client_ip_pool']) + + if c.exists('authentication require'): + if c.return_value('authentication require') == 'pap': + config_data['authentication']['auth_proto'] = 'auth_pap' + if c.return_value('authentication require') == 'chap': + config_data['authentication']['auth_proto'] = 'auth_chap_md5' + if c.return_value('authentication require') == 'mschap': + config_data['authentication']['auth_proto'] = 'auth_mschap_v1' + if c.return_value('authentication require') == 'mschap-v2': + config_data['authentication']['auth_proto'] = 'auth_mschap_v2' + + if c.exists('authentication mppe'): + config_data['authentication']['mppe'] = c.return_value('authentication mppe') + + return config_data + +def verify(c): + if c == None: + return None + + if c['authentication']['mode'] == 'local': + if not c['authentication']['local-users']: + raise ConfigError('pppoe-server authentication local-users required') + for usr in c['authentication']['local-users']: + if not c['authentication']['local-users'][usr]['passwd']: + raise ConfigError('user ' + usr + ' requires a password') + + if c['authentication']['mode'] == 'radius': + if len(c['authentication']['radiussrv']) == 0: + raise ConfigError('radius server required') + for rsrv in c['authentication']['radiussrv']: + if c['authentication']['radiussrv'][rsrv]['secret'] == None: + raise ConfigError('radius server ' + rsrv + ' needs a secret configured') + +def generate(c): + if c == None: + return None + + ### accel-cmd reload doesn't work so any change results in a restart of the daemon + try: + if os.cpu_count() == 1: + c['thread_cnt'] = 1 + else: + c['thread_cnt'] = int(os.cpu_count()/2) + except KeyError: + if os.cpu_count() == 1: + c['thread_cnt'] = 1 + else: + c['thread_cnt'] = int(os.cpu_count()/2) + + tmpl = jinja2.Template(pptp_config, trim_blocks=True) + config_text = tmpl.render(c) + open(pptp_conf,'w').write(config_text) + + if c['authentication']['local-users']: + write_chap_secrets(c) + + return c + +def apply(c): + if c == None: + if os.path.exists(pidfile): + accel_cmd('shutdown hard') + if os.path.exists(pidfile): + os.remove(pidfile) + return None + + if not os.path.exists(pidfile): + ret = subprocess.call(['/usr/sbin/accel-pppd','-c',pptp_conf,'-p',pidfile,'-d']) + chk_con() + if ret !=0 and os.path.exists(pidfile): + os.remove(pidfile) + raise ConfigError('accel-pppd failed to start') + else: + ### if gw ip changes, only restart doesn't work + accel_cmd('restart') + sl.syslog(sl.LOG_NOTICE, "reloading config via daemon restart") + +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/arp.py b/src/conf_mode/arp.py new file mode 100755 index 000000000..aeca08432 --- /dev/null +++ b/src/conf_mode/arp.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import re +import syslog as sl +import subprocess + +from vyos.config import Config +from vyos import ConfigError + +arp_cmd = '/usr/sbin/arp' + +def get_config(): + c = Config() + if not c.exists('protocols static arp'): + return None + + c.set_level('protocols static') + config_data = {} + + for ip_addr in c.list_nodes('arp'): + config_data.update( + { + ip_addr : c.return_value('arp ' + ip_addr + ' hwaddr') + } + ) + + return config_data + +def generate(c): + c_eff = Config() + c_eff.set_level('protocols static') + c_eff_cnf = {} + for ip_addr in c_eff.list_effective_nodes('arp'): + c_eff_cnf.update( + { + ip_addr : c_eff.return_effective_value('arp ' + ip_addr + ' hwaddr') + } + ) + + config_data = { + 'remove' : [], + 'update' : {} + } + ### removal + if c == None: + for ip_addr in c_eff_cnf: + config_data['remove'].append(ip_addr) + else: + for ip_addr in c_eff_cnf: + if not ip_addr in c or c[ip_addr] == None: + config_data['remove'].append(ip_addr) + + ### add/update + if c != None: + for ip_addr in c: + if not ip_addr in c_eff_cnf: + config_data['update'][ip_addr] = c[ip_addr] + if ip_addr in c_eff_cnf: + if c[ip_addr] != c_eff_cnf[ip_addr] and c[ip_addr] != None: + config_data['update'][ip_addr] = c[ip_addr] + + return config_data + +def apply(c): + for ip_addr in c['remove']: + sl.syslog(sl.LOG_NOTICE, "arp -d " + ip_addr) + subprocess.call([arp_cmd + ' -d ' + ip_addr + ' >/dev/null 2>&1'], shell=True) + + for ip_addr in c['update']: + sl.syslog(sl.LOG_NOTICE, "arp -s " + ip_addr + " " + c['update'][ip_addr]) + subprocess.call([arp_cmd + ' -s ' + ip_addr + ' ' + c['update'][ip_addr] ], shell=True) + + +if __name__ == '__main__': + try: + c = get_config() + ## syntax verification is done via cli + config = generate(c) + apply(config) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/bcast_relay.py b/src/conf_mode/bcast_relay.py index 95f6215b5..8889e701c 100755 --- a/src/conf_mode/bcast_relay.py +++ b/src/conf_mode/bcast_relay.py @@ -19,56 +19,105 @@ import sys import os import fnmatch -import subprocess +import jinja2 from vyos.config import Config from vyos import ConfigError config_file = r'/etc/default/udp-broadcast-relay' +config_tmpl = """ +### Autogenerated by bcast_relay.py ### + +# UDP broadcast relay configuration for instance {{ id }} +{%- if description %} +# Comment: {{ description }} +{% endif %} +DAEMON_ARGS="{% if address %}-s {{ address }} {% endif %}{{ id }} {{ port }} {{ interfaces | join(' ') }}" + +""" + +default_config_data = { + 'disabled': False, + 'instances': [] +} + def get_config(): + relay = default_config_data conf = Config() - conf.set_level("service broadcast-relay id") - relay_id = conf.list_nodes("") - relays = [] - - for id in relay_id: - interface_list = [] - address = conf.return_value("{0} address".format(id)) - description = conf.return_value("{0} description".format(id)) - port = conf.return_value("{0} port".format(id)) - - # split the interface name listing and form a list - if conf.exists("{0} interface".format(id)): - intfs_names = [] - intfs_names = conf.return_values("{0} interface".format(id)) - - for name in intfs_names: - interface_list.append(name) - - relay = { - "id": id, - "address": address, - "description": description, - "interfaces" : interface_list, - "port": port + if not conf.exists('service broadcast-relay'): + return None + else: + conf.set_level('service broadcast-relay') + + # Service can be disabled by user + if conf.exists('disable'): + relay['disabled'] = True + return relay + + # Parse configuration of each individual instance + if conf.exists('id'): + for id in conf.list_nodes('id'): + conf.set_level('service broadcast-relay id {0}'.format(id)) + config = { + 'id': id, + 'disabled': False, + 'address': '', + 'description': '', + 'interfaces': [], + 'port': '' } - relays.append(relay) - return relays + # Check if individual broadcast relay service is disabled + if conf.exists('disable'): + config['disabled'] = True + + # Source IP of forwarded packets, if empty original senders address is used + if conf.exists('address'): + config['address'] = conf.return_value('address') + + # A description for each individual broadcast relay service + if conf.exists('description'): + config['description'] = conf.return_value('description') + + # UDP port to listen on for broadcast frames + if conf.exists('port'): + config['port'] = conf.return_value('port') + + # Network interfaces to listen on for broadcast frames to be relayed + if conf.exists('interface'): + config['interfaces'] = conf.return_values('interface') + + relay['instances'].append(config) -def verify(relays): - for relay in relays: - if not relay["port"]: - raise ConfigError("UDP broadcast relay 'id {0}' requires a port number".format(relay["id"])) + return relay - if len(relay["interfaces"]) < 2: - raise ConfigError("UDP broadcast relay 'id {0}' requires at least 2 interfaces".format(relay["id"])) +def verify(relay): + if relay is None: + return None + + if relay['disabled']: + return None + + for r in relay['instances']: + # we don't have to check this instance when it's disabled + if r['disabled']: + continue + + # we certainly require a UDP port to listen to + if not r['port']: + raise ConfigError('UDP broadcast relay "{0}" requires a port number'.format(r['id'])) + + # Relaying data without two interface is kinda senseless ... + if len(r['interfaces']) < 2: + raise ConfigError('UDP broadcast relay "id {0}" requires at least 2 interfaces'.format(r['id'])) return None -def generate(relays): - config_header = '### Autogenerated by bcast_relay.py ###\n' + +def generate(relay): + if relay is None: + return None config_dir = os.path.dirname(config_file) config_filename = os.path.basename(config_file) @@ -82,32 +131,43 @@ def generate(relays): # sort our list active_configs.sort() + # delete old configuration files for id in active_configs[:]: - os.unlink(config_file + id) - - for relay in relays: - file = config_file + str(relay["id"]) - interfaces = ' '.join(str(intf) for intf in relay["interfaces"]) - config_args = 'DAEMON_ARGS="{0} {1}"\n'.format(relay["port"], interfaces) - - f = open(file, 'w') - f.write(config_header) - if relay["description"]: - f.write('# ' + relay["description"] + '\n') - f.write(config_args) - f.close() + if os.path.exists(config_file + id): + os.unlink(config_file + id) + + # If the service is disabled, we can bail out here + if relay['disabled']: + print('Warning: UDP broadcast relay service will be deactivated because it is disabled') + return None + + for r in relay['instances']: + # Skip writing instance config when it's disabled + if r['disabled']: + continue + + # configuration filename contains instance id + file = config_file + str(r['id']) + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render(r) + with open(file, 'w') as f: + f.write(config_text) return None -def apply(relays): +def apply(relay): # first stop all running services - cmd = "sudo systemctl stop udp-broadcast-relay@{1..99}" - os.system(cmd) + os.system('sudo systemctl stop udp-broadcast-relay@{1..99}') + + if (relay is None) or relay['disabled']: + return None # start only required service instances - for relay in relays: - cmd = "sudo systemctl start udp-broadcast-relay@{0}".format(relay["id"]) - os.system(cmd) + for r in relay['instances']: + # Don't start individual instance when it's disabled + if r['disabled']: + continue + os.system('sudo systemctl start udp-broadcast-relay@{0}'.format(r['id'])) return None diff --git a/src/conf_mode/bridge_has_members.py b/src/conf_mode/bridge_has_members.py new file mode 100755 index 000000000..712a9cc46 --- /dev/null +++ b/src/conf_mode/bridge_has_members.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 vyos.config + +if len(sys.argv) < 2: + print("Argument (bridge interface name) is required") + sys.exit(1) +else: + bridge = sys.argv[1] + +c = vyos.config.Config() + +members = [] + + +# Check in ethernet and bonding interfaces +for p in ["interfaces ethernet", "interfaces bonding"]: + intfs = c.list_nodes(p) + for i in intfs: + intf_bridge_path = "{0} {1} bridge-group bridge".format(p, i) + if c.exists(intf_bridge_path): + intf_bridge = c.return_value(intf_bridge_path) + if intf_bridge == bridge: + members.append(i) + # Walk VLANs + for v in c.list_nodes("{0} {1} vif".format(p, i)): + vif_bridge_path = "{0} {1} vif {2} bridge-group bridge".format(p, i, v) + if c.exists(vif_bridge_path): + vif_bridge = c.return_value(vif_bridge_path) + if vif_bridge == bridge: + members.append("{0}.{1}".format(i, v)) + # Walk QinQ interfaces + for vs in c.list_nodes("{0} {1} vif-s".format(p, i)): + vifs_bridge_path = "{0} {1} vif-s {2} bridge-group bridge".format(p, i, vs) + if c.exists(vifs_bridge_path): + vifs_bridge = c.return_value(vifs_bridge_path) + if vifs_bridge == bridge: + members.append("{0}.{1}".format(i, vs)) + for vc in c.list_nodes("{0} {1} vif-s {2} vif-c".format(p, i, vs)): + vifc_bridge_path = "{0} {1} vif-s {2} vif-c {3} bridge-group bridge".format(p, i, vs, vc) + if c.exists(vifc_bridge_path): + vifc_bridge = c.return_value(vifc_bridge_path) + if vifc_bridge == bridge: + members.append("{0}.{1}.{2}".format(i, vs, vc)) + +# Check tunnel interfaces +for t in c.list_nodes("interfaces tunnel"): + tunnel_bridge_path = "interfaces tunnel {0} parameters ip bridge-group bridge".format(t) + if c.exists(tunnel_bridge_path): + intf_bridge = c.return_value(tunnel_bridge_path) + if intf_bridge == bridge: + members.append(t) + +# Check OpenVPN interfaces +for o in c.list_nodes("interfaces openvpn"): + ovpn_bridge_path = "interfaces openvpn {0} bridge-group bridge".format(o) + if c.exists(ovpn_bridge_path): + intf_bridge = c.return_value(ovpn_bridge_path) + if intf_bridge == bridge: + members.append(o) + +if members: + print("Bridge {0} cannot be deleted because some interfaces are configured as its members".format(bridge)) + print("The following interfaces are members of {0}: {1}".format(bridge, " ".join(members))) + sys.exit(1) +else: + sys.exit(0) diff --git a/src/conf_mode/dhcp_relay.py b/src/conf_mode/dhcp_relay.py new file mode 100755 index 000000000..73e0153df --- /dev/null +++ b/src/conf_mode/dhcp_relay.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import jinja2 + +from vyos.config import Config +from vyos import ConfigError + +config_file = r'/etc/default/isc-dhcp-relay' + +# Please be careful if you edit the template. +config_tmpl = """ +### Autogenerated by dhcp_relay.py ### + +# Defaults for isc-dhcp-relay initscript +# sourced by /etc/init.d/isc-dhcp-relay + +# +# This is a POSIX shell fragment +# + +# What servers should the DHCP relay forward requests to? +SERVERS="{{ server | join(' ') }}" + +# On what interfaces should the DHCP relay (dhrelay) serve DHCP requests? +INTERFACES="{{ interface | join(' ') }}" + +# Additional options that are passed to the DHCP relay daemon? +OPTIONS="-4 {{ options | join(' ') }}" +""" + +default_config_data = { + 'interface': [], + 'server': [], + 'options': [], + 'hop_count': '10', + 'relay_agent_packets': 'forward' +} + +def get_config(): + relay = default_config_data + conf = Config() + if not conf.exists('service dhcp-relay'): + return None + else: + conf.set_level('service dhcp-relay') + + # Network interfaces to listen on + if conf.exists('interface'): + relay['interface'] = conf.return_values('interface') + + # Servers equal to the address of the DHCP server(s) + if conf.exists('server'): + relay['server'] = conf.return_values('server') + + conf.set_level('service dhcp-relay relay-options') + + if conf.exists('hop-count'): + count = '-c ' + conf.return_value('hop-count') + relay['options'].append(count) + + # Specify the maximum packet size to send to a DHCPv4/BOOTP server. + # This might be done to allow sufficient space for addition of relay agent + # options while still fitting into the Ethernet MTU size. + # + # Available in DHCPv4 mode only: + if conf.exists('max-size'): + size = '-A ' + conf.return_value('max-size') + relay['options'].append(size) + + # Control the handling of incoming DHCPv4 packets which already contain + # relay agent options. If such a packet does not have giaddr set in its + # header, the DHCP standard requires that the packet be discarded. However, + # if giaddr is set, the relay agent may handle the situation in four ways: + # It may append its own set of relay options to the packet, leaving the + # supplied option field intact; it may replace the existing agent option + # field; it may forward the packet unchanged; or, it may discard it. + # + # Available in DHCPv4 mode only: + if conf.exists('relay-agents-packets'): + pkt = '-a -m ' + conf.return_value('relay-agents-packets') + relay['options'].append(pkt) + + return relay + +def verify(relay): + # bail out early - looks like removal from running config + if relay is None: + return None + + if len(relay['interface']) < 2: + # We can only issue a warning otherwise old configurations might break + print('WARNING: At least two interfaces are required for DHCP relay\n' \ + 'to work\n') + + if 'lo' in relay['interface']: + raise ConfigError('DHCP relay does not support the loopback interface.') + + if len(relay['server']) == 0: + raise ConfigError('No DHCP relay server(s) configured.\n' \ + 'At least one DHCP relay server required.') + + return None + +def generate(relay): + # bail out early - looks like removal from running config + if relay is None: + return None + + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render(relay) + with open(config_file, 'w') as f: + f.write(config_text) + + return None + +def apply(relay): + if relay is not None: + os.system('sudo systemctl restart isc-dhcp-relay.service') + else: + # DHCP relay support is removed in the commit + os.system('sudo systemctl stop isc-dhcp-relay.service') + os.unlink(config_file) + + 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/dhcp_server.py b/src/conf_mode/dhcp_server.py new file mode 100755 index 000000000..c8ae2fa91 --- /dev/null +++ b/src/conf_mode/dhcp_server.py @@ -0,0 +1,826 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import ipaddress +import jinja2 +import socket +import struct + +import vyos.validate + +from vyos.config import Config +from vyos import ConfigError + +config_file = r'/etc/dhcp/dhcpd.conf' +lease_file = r'/config/dhcpd.leases' +daemon_config_file = r'/etc/default/isc-dhcp-server' + +# Please be careful if you edit the template. +config_tmpl = """ +### Autogenerated by dhcp_server.py ### + +# For options please consult the following website: +# https://www.isc.org/wp-content/uploads/2017/08/dhcp43options.html +# +# log-facility local7; + +{% if hostfile_update %} +on release { + set ClientName = pick-first-value(host-decl-name, option fqdn.hostname, option host-name); + set ClientIp = binary-to-ascii(10, 8, ".",leased-address); + set ClientMac = binary-to-ascii(16, 8, ":",substring(hardware, 1, 6)); + set ClientDomain = pick-first-value(config-option domain-name, "..YYZ!"); + execute("/usr/libexec/vyos/system/on-dhcp-event.sh", "release", ClientName, ClientIp, ClientMac, ClientDomain); +} + +on expiry { + set ClientName = pick-first-value(host-decl-name, option fqdn.hostname, option host-name); + set ClientIp = binary-to-ascii(10, 8, ".",leased-address); + set ClientMac = binary-to-ascii(16, 8, ":",substring(hardware, 1, 6)); + set ClientDomain = pick-first-value(config-option domain-name, "..YYZ!"); + execute("/usr/libexec/vyos/system/on-dhcp-event.sh", "release", ClientName, ClientIp, ClientMac, ClientDomain); +} +{% endif %} +{%- if host_decl_name %} +use-host-decl-names on; +{%- endif %} +ddns-update-style {% if ddns_enable -%} interim {%- else -%} none {%- endif %}; +{% if static_route -%} +option rfc3442-static-route code 121 = array of integer 8; +option windows-static-route code 249 = array of integer 8; +{%- endif %} +{% if wpad -%} +option wpad-url code 252 = text; +{% endif %} + +{%- if global_parameters %} +# The following {{ global_parameters | length }} line(s) were added as global-parameters in the CLI and have not been validated +{%- for param in global_parameters %} +{{ param }} +{%- endfor -%} +{%- endif %} + +# Failover configuration +{% for network in shared_network %} +{%- if not network.disabled -%} +{%- for subnet in network.subnet %} +{%- if subnet.failover_name -%} +failover peer "{{ subnet.failover_name }}" { +{%- if subnet.failover_status == 'primary' %} + primary; + mclt 1800; + split 128; +{%- elif subnet.failover_status == 'secondary' %} + secondary; +{%- endif %} + address {{ subnet.failover_local_addr }}; + port 520; + peer address {{ subnet.failover_peer_addr }}; + peer port 520; + max-response-delay 30; + max-unacked-updates 10; + load balance max seconds 3; +} +{% endif -%} +{% endfor -%} +{% endif -%} +{% endfor %} + +# Shared network configration(s) +{% for network in shared_network %} +{%- if not network.disabled -%} +shared-network {{ network.name }} { + {%- if network.authoritative %} + authoritative; + {%- endif %} + {%- if network.network_parameters %} + # The following {{ network.network_parameters | length }} line(s) were added as shared-network-parameters in the CLI and have not been validated + {%- for param in network.network_parameters %} + {{ param }} + {%- endfor %} + {%- endif %} + {%- for subnet in network.subnet %} + subnet {{ subnet.address }} netmask {{ subnet.netmask }} { + {%- if subnet.dns_server %} + option domain-name-servers {{ subnet.dns_server | join(', ') }}; + {%- endif %} + {%- if subnet.domain_search %} + option domain-search {{ subnet.domain_search | join(', ') }}; + {%- endif %} + {%- if subnet.ntp_server %} + option ntp-servers {{ subnet.ntp_server | join(', ') }}; + {%- endif %} + {%- if subnet.pop_server %} + option pop-server {{ subnet.pop_server | join(', ') }}; + {%- endif %} + {%- if subnet.smtp_server %} + option smtp-server {{ subnet.smtp_server | join(', ') }}; + {%- endif %} + {%- if subnet.time_server %} + option time-servers {{ subnet.time_server | join(', ') }}; + {%- endif %} + {%- if subnet.wins_server %} + option netbios-name-servers {{ subnet.wins_server | join(', ') }}; + {%- endif %} + {%- if subnet.static_route %} + option rfc3442-static-route {{ subnet.static_route }}; + option windows-static-route {{ subnet.static_route }}; + {%- endif %} + {%- if subnet.ip_forwarding %} + option ip-forwarding true; + {%- endif -%} + {%- if subnet.default_router %} + option routers {{ subnet.default_router }}; + {%- endif -%} + {%- if subnet.server_identifier %} + option dhcp-server-identifier {{ subnet.server_identifier }}; + {%- endif -%} + {%- if subnet.domain_name %} + option domain-name "{{ subnet.domain_name }}"; + {%- endif -%} + {%- if subnet.subnet_parameters %} + # The following {{ subnet.subnet_parameters | length }} line(s) were added as subnet-parameters in the CLI and have not been validated + {%- for param in subnet.subnet_parameters %} + {{ param }} + {%- endfor -%} + {%- endif %} + {%- if subnet.tftp_server %} + option tftp-server-name "{{ subnet.tftp_server }}"; + {%- endif -%} + {%- if subnet.bootfile_name %} + option bootfile-name "{{ subnet.bootfile_name }}"; + filename "{{ subnet.bootfile_name }}"; + {%- endif -%} + {%- if subnet.bootfile_server %} + next-server {{ subnet.bootfile_server }}; + {%- endif -%} + {%- if subnet.time_offset %} + option time-offset {{ subnet.time_offset }}; + {%- endif -%} + {%- if subnet.wpad_url %} + option wpad-url "{{ subnet.wpad_url }}"; + {%- endif -%} + {%- if subnet.client_prefix_length %} + option subnet-mask {{ subnet.client_prefix_length }}; + {%- endif -%} + {% if subnet.lease %} + default-lease-time {{ subnet.lease }}; + max-lease-time {{ subnet.lease }}; + {%- endif -%} + {%- for host in subnet.static_mapping %} + {% if not host.disabled -%} + host {{ network.name }}_{{ host.name }} { + fixed-address {{ host.ip_address }}; + hardware ethernet {{ host.mac_address }}; + {%- if host.static_parameters %} + # The following {{ host.static_parameters | length }} line(s) were added as static-mapping-parameters in the CLI and have not been validated + {%- for param in host.static_parameters %} + {{ param }} + {%- endfor -%} + {%- endif %} + } + {%- endif %} + {%- endfor %} + {%- if subnet.failover_name %} + pool { + failover peer "{{ subnet.failover_name }}"; + deny dynamic bootp clients; + {%- for range in subnet.range %} + range {{ range.start }} {{ range.stop }}; + {%- endfor %} + } + {%- else %} + {%- for range in subnet.range %} + range {{ range.start }} {{ range.stop }}; + {%- endfor %} + {%- endif %} + } + {%- endfor %} + on commit { + set shared-networkname = "{{ network.name }}"; + {% if hostfile_update -%} + set ClientName = pick-first-value(host-decl-name, option fqdn.hostname, option host-name); + set ClientIp = binary-to-ascii(10, 8, ".", leased-address); + set ClientMac = binary-to-ascii(16, 8, ":", substring(hardware, 1, 6)); + set ClientDomain = pick-first-value(config-option domain-name, "..YYZ!"); + execute("/usr/libexec/vyos/system/on-dhcp-event.sh", "commit", ClientName, ClientIp, ClientMac, ClientDomain); + {%- endif %} + } +} +{%- endif %} +{% endfor %} +""" + +daemon_tmpl = """ +### Autogenerated by dhcp_server.py ### + +# sourced by /etc/init.d/isc-dhcp-server + +DHCPD_CONF=/etc/dhcp/dhcpd.conf +DHCPD_PID=/var/run/dhcpd.pid +OPTIONS="-4 -lf {{ lease_file }}" +INTERFACES="" +""" + +default_config_data = { + 'lease_file': lease_file, + 'disabled': False, + 'ddns_enable': False, + 'global_parameters': [], + 'hostfile_update': False, + 'host_decl_name': False, + 'static_route': False, + 'wpad': False, + 'shared_network': [], +} + +def get_config(): + dhcp = default_config_data + conf = Config() + if not conf.exists('service dhcp-server'): + return None + else: + conf.set_level('service dhcp-server') + + # check for global disable of DHCP service + if conf.exists('disable'): + dhcp['disabled'] = True + + # check for global dynamic DNS upste + if conf.exists('dynamic-dns-update'): + dhcp['ddns_enable'] = True + + # HACKS AND TRICKS + # + # check for global 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters from any user + if conf.exists('global-parameters'): + dhcp['global_parameters'] = conf.return_values('global-parameters') + + # check for global DHCP server updating /etc/host per lease + if conf.exists('hostfile-update'): + dhcp['hostfile_update'] = True + + # If enabled every host declaration within that scope, the name provided + # for the host declaration will be supplied to the client as its hostname. + if conf.exists('host-decl-name'): + dhcp['host_decl_name'] = True + + # check for multiple, shared networks served with DHCP addresses + if conf.exists('shared-network-name'): + for network in conf.list_nodes('shared-network-name'): + conf.set_level('service dhcp-server shared-network-name {0}'.format(network)) + config = { + 'name': network, + 'authoritative': False, + 'description': '', + 'disabled': False, + 'network_parameters': [], + 'subnet': [] + } + # check if DHCP server should be authoritative on this network + if conf.exists('authoritative'): + config['authoritative'] = True + + # A description for this given network + if conf.exists('description'): + config['description'] = conf.return_value('description') + + # If disabled, the shared-network configuration becomes inactive in + # the running DHCP server instance + if conf.exists('disable'): + config['disabled'] = True + + # HACKS AND TRICKS + # + # check for 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters + # from any user + # + # deprecate this and issue a warning like we do for DNS forwarding? + if conf.exists('shared-network-parameters'): + config['network_parameters'] = conf.return_values('shared-network-parameters') + + # check for multiple subnet configurations in a shared network + # config segment + if conf.exists('subnet'): + for net in conf.list_nodes('subnet'): + conf.set_level('service dhcp-server shared-network-name {0} subnet {1}'.format(network, net)) + subnet = { + 'network': net, + 'address': str(ipaddress.ip_network(net).network_address), + 'netmask': str(ipaddress.ip_network(net).netmask), + 'bootfile_name': '', + 'bootfile_server': '', + 'client_prefix_length': '', + 'default_router': '', + 'dns_server': [], + 'domain_name': '', + 'domain_search': [], + 'exclude': [], + 'failover_local_addr': '', + 'failover_name': '', + 'failover_peer_addr': '', + 'failover_status': '', + 'ip_forwarding': False, + 'lease': '86400', + 'ntp_server': [], + 'pop_server': [], + 'server_identifier': '', + 'smtp_server': [], + 'range': [], + 'static_mapping': [], + 'static_subnet': '', + 'static_router': '', + 'static_route': '', + 'subnet_parameters': [], + 'tftp_server': '', + 'time_offset': '', + 'time_server': [], + 'wins_server': [], + 'wpad_url': '' + } + + # Used to identify a bootstrap file + if conf.exists('bootfile-name'): + subnet['bootfile_name'] = conf.return_value('bootfile-name') + + # Specify host address of the server from which the initial boot file + # (specified above) is to be loaded. Should be a numeric IP address or + # domain name. + if conf.exists('bootfile-server'): + subnet['bootfile_server'] = conf.return_value('bootfile-server') + + # The subnet mask option specifies the client's subnet mask as per RFC 950. If no subnet + # mask option is provided anywhere in scope, as a last resort dhcpd will use the subnet + # mask from the subnet declaration for the network on which an address is being assigned. + if conf.exists('client-prefix-length'): + # snippet borrowed from https://stackoverflow.com/questions/33750233/convert-cidr-to-subnet-mask-in-python + host_bits = 32 - int(conf.return_value('client-prefix-length')) + subnet['client_prefix_length'] = socket.inet_ntoa(struct.pack('!I', (1 << 32) - (1 << host_bits))) + + # Default router IP address on the client's subnet + if conf.exists('default-router'): + subnet['default_router'] = conf.return_value('default-router') + + # Specifies a list of Domain Name System (STD 13, RFC 1035) name servers available to + # the client. Servers should be listed in order of preference. + if conf.exists('dns-server'): + subnet['dns_server'] = conf.return_values('dns-server') + + # Option specifies the domain name that client should use when resolving hostnames + # via the Domain Name System. + if conf.exists('domain-name'): + subnet['domain_name'] = conf.return_value('domain-name') + + # The domain-search option specifies a 'search list' of Domain Names to be used + # by the client to locate not-fully-qualified domain names. + if conf.exists('domain-search'): + for domain in conf.return_values('domain-search'): + subnet['domain_search'].append('"' + domain + '"') + + # IP address (local) for failover peer to connect + if conf.exists('failover local-address'): + subnet['failover_local_addr'] = conf.return_value('failover local-address') + + # DHCP failover peer name + if conf.exists('failover name'): + subnet['failover_name'] = conf.return_value('failover name') + + # IP address (remote) of failover peer + if conf.exists('failover peer-address'): + subnet['failover_peer_addr'] = conf.return_value('failover peer-address') + + # DHCP failover peer status (primary|secondary) + if conf.exists('failover status'): + subnet['failover_status'] = conf.return_value('failover status') + + # Option specifies whether the client should configure its IP layer for packet + # forwarding + if conf.exists('ip-forwarding'): + subnet['ip_forwarding'] = True + + # Time should be the length in seconds that will be assigned to a lease if the + # client requesting the lease does not ask for a specific expiration time + if conf.exists('lease'): + subnet['lease'] = conf.return_value('lease') + + # Specifies a list of IP addresses indicating NTP (RFC 5905) servers available + # to the client. + if conf.exists('ntp-server'): + subnet['ntp_server'] = conf.return_values('ntp-server') + + # POP3 server option specifies a list of POP3 servers available to the client. + # Servers should be listed in order of preference. + if conf.exists('pop-server'): + subnet['pop_server'] = conf.return_values('pop-server') + + # DHCP servers include this option in the DHCPOFFER in order to allow the client + # to distinguish between lease offers. DHCP clients use the contents of the + # 'server identifier' field as the destination address for any DHCP messages + # unicast to the DHCP server + if conf.exists('server-identifier'): + subnet['server_identifier'] = conf.return_value('server-identifier') + + # SMTP server option specifies a list of SMTP servers available to the client. + # Servers should be listed in order of preference. + if conf.exists('smtp-server'): + subnet['smtp_server'] = conf.return_values('smtp-server') + + # For any subnet on which addresses will be assigned dynamically, there must be at + # least one range statement. The range statement gives the lowest and highest IP + # addresses in a range. All IP addresses in the range should be in the subnet in + # which the range statement is declared. + if conf.exists('range'): + for range in conf.list_nodes('range'): + range = { + 'start': conf.return_value('range {0} start'.format(range)), + 'stop': conf.return_value('range {0} stop'.format(range)) + } + subnet['range'].append(range) + + # IP address that needs to be excluded from DHCP lease range + if conf.exists('exclude'): + # We have no need to store the exclude addresses. Exclude addresses + # are recalculated into several ranges + exclude = [] + subnet['exclude'] = conf.return_values('exclude') + for addr in subnet['exclude']: + exclude.append(ipaddress.ip_address(addr)) + + # sort excluded IP addresses ascending + exclude = sorted(exclude) + + # calculate multipe ranges based on the excluded IP addresses + output = [] + for range in subnet['range']: + range_start = range['start'] + range_stop = range['stop'] + + for i in exclude: + # Excluded IP address must be in out specified range + if (i >= ipaddress.ip_address(range_start)) and (i <= ipaddress.ip_address(range_stop)): + # Build up new IP address range ending one IP address before + # our exclude address + range = { + 'start': str(range_start), + 'stop': str(i - 1) + } + # Our next IP address range will start one address after + # our exclude address + range_start = i + 1 + output.append(range) + + # Take care of last IP address range spanning from the last exclude + # address (+1) to the end of the initial configured range + if i is exclude[-1]: + last = { + 'start': str(i + 1), + 'stop': str(range_stop) + } + output.append(last) + else: + # IP address not inside search range, take range is it is + output.append(range) + + # We successfully build up a new list containing several IP address + # ranges, replace IP address range in our dictionary + subnet['range'] = output + + # Static DHCP leases + if conf.exists('static-mapping'): + for mapping in conf.list_nodes('static-mapping'): + conf.set_level('service dhcp-server shared-network-name {0} subnet {1} static-mapping {2}'.format(network, net, mapping)) + mapping = { + 'name': mapping, + 'disabled': False, + 'ip_address': '', + 'mac_address': '', + 'static_parameters': [] + } + + # This static lease is disabled + if conf.exists('disable'): + mapping['disabled'] = True + + # IP address used for this DHCP client + if conf.exists('ip-address'): + mapping['ip_address'] = conf.return_value('ip-address') + + # MAC address of requesting DHCP client + if conf.exists('mac-address'): + mapping['mac_address'] = conf.return_value('mac-address') + + # HACKS AND TRICKS + # + # check for 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters + # from any user + # + # deprecate this and issue a warning like we do for DNS forwarding? + if conf.exists('static-mapping-parameters'): + mapping['static_parameters'] = conf.return_values('static-mapping-parameters') + + # append static-mapping configuration to subnet list + subnet['static_mapping'].append(mapping) + + # Reset config level to matching hirachy + conf.set_level('service dhcp-server shared-network-name {0} subnet {1}'.format(network, net)) + + # This option specifies a list of static routes that the client should install in its routing + # cache. If multiple routes to the same destination are specified, they are listed in descending + # order of priority. + if conf.exists('static-route destination-subnet'): + subnet['static_subnet'] = conf.return_value('static-route destination-subnet') + # Required for global config section + dhcp['static_route'] = True + + if conf.exists('static-route router'): + subnet['static_router'] = conf.return_value('static-route router') + + if subnet['static_router'] and subnet['static_subnet']: + # https://ercpe.de/blog/pushing-static-routes-with-isc-dhcp-server + # Option format is: + # <netmask>, <network-byte1>, <network-byte2>, <network-byte3>, <router-byte1>, <router-byte2>, <router-byte3> + # where bytes with the value 0 are omitted. + net = ipaddress.ip_network(subnet['static_subnet']) + # add netmask + string = str(net.prefixlen) + ',' + # add network bytes + bytes = str(net.network_address).split('.') + for b in bytes: + if b != '0': + string += b + ',' + + # add router bytes + bytes = subnet['static_router'].split('.') + for b in bytes: + string += b + if b is not bytes[-1]: + string += ',' + + subnet['static_route'] = string + + # HACKS AND TRICKS + # + # check for 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters + # from any user + # + # deprecate this and issue a warning like we do for DNS forwarding? + if conf.exists('subnet-parameters'): + subnet['subnet_parameters'] = conf.return_values('subnet-parameters') + + # This option is used to identify a TFTP server and, if supported by the client, should have + # the same effect as the server-name declaration. BOOTP clients are unlikely to support this + # option. Some DHCP clients will support it, and others actually require it. + if conf.exists('tftp-server-name'): + subnet['tftp_server'] = conf.return_value('tftp-server-name') + + # The time-offset option specifies the offset of the client’s subnet in seconds from + # Coordinated Universal Time (UTC). + if conf.exists('time-offset'): + subnet['time_offset'] = conf.return_value('time-offset') + + # The time-server option specifies a list of RFC 868 time servers available to the client. + # Servers should be listed in order of preference. + if conf.exists('time-server'): + subnet['time_server'] = conf.return_values('time-server') + + # The NetBIOS name server (NBNS) option specifies a list of RFC 1001/1002 NBNS name servers + # listed in order of preference. NetBIOS Name Service is currently more commonly referred to + # as WINS. WINS servers can be specified using the netbios-name-servers option. + if conf.exists('wins-server'): + subnet['wins_server'] = conf.return_values('wins-server') + + # URL for Web Proxy Autodiscovery Protocol + if conf.exists('wpad-url'): + subnet['wpad_url'] = conf.return_value('wpad-url') + # Required for global config section + dhcp['wpad'] = True + + # append subnet configuration to shared network subnet list + config['subnet'].append(subnet) + + # append shared network configuration to config dictionary + dhcp['shared_network'].append(config) + + return dhcp + +def verify(dhcp): + if (dhcp is None) or (dhcp['disabled'] is True): + return None + + # If DHCP is enabled we need one share-network + if len(dhcp['shared_network']) == 0: + raise ConfigError('No DHCP shared networks configured.\n' \ + 'At least one DHCP shared network must be configured.') + + # Inspect shared-network/subnet + failover_names = [] + listen_ok = False + subnets = [] + + # A shared-network requires a subnet definition + for network in dhcp['shared_network']: + if len(network['subnet']) == 0: + raise ConfigError('No DHCP lease subnets configured for {0}. At least one\n' \ + 'lease subnet must be configured for each shared network.'.format(network['name'])) + + for subnet in network['subnet']: + # Subnet static route declaration requires destination and router + if subnet['static_subnet'] or subnet['static_router']: + if not (subnet['static_subnet'] and subnet['static_router']): + raise ConfigError('Please specify missing DHCP static-route parameter(s):\n' \ + 'destination-subnet | router') + + # Failover requires all 4 parameters set + if subnet['failover_local_addr'] or subnet['failover_peer_addr'] or subnet['failover_name'] or subnet['failover_status']: + if not (subnet['failover_local_addr'] and subnet['failover_peer_addr'] and subnet['failover_name'] and subnet['failover_status']): + raise ConfigError('Please specify missing DHCP failover parameter(s):\n' \ + 'local-address | peer-address | name | status') + + # Failover names must be uniquie + if subnet['failover_name'] in failover_names: + raise ConfigError('Failover names must be unique:\n' \ + '{0} has already been configured!'.format(subnet['failover_name'])) + else: + failover_names.append(subnet['failover_name']) + + # Failover requires start/stop ranges for pool + if (len(subnet['range']) == 0): + raise ConfigError('At least one start-stop range must be configured for {0}\n' \ + 'to set up DHCP failover!'.format(subnet['network'])) + + # Check if DHCP address range is inside configured subnet declaration + range_start = [] + range_stop = [] + for range in subnet['range']: + start = range['start'] + stop = range['stop'] + # DHCP stop IP required after start IP + if start and not stop: + raise ConfigError('DHCP range stop address for start {0} is not defined!'.format(start)) + + # Start address must be inside network + if not ipaddress.ip_address(start) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCP range start address {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(start, subnet['network'], network['name'])) + + # Stop address must be inside network + if not ipaddress.ip_address(stop) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCP range stop address {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(stop, subnet['network'], network['name'])) + + # Stop address must be greater or equal to start address + if not ipaddress.ip_address(stop) >= ipaddress.ip_address(start): + raise ConfigError('DHCP range stop address {0} must be greater or equal\n' \ + 'to the range start address {1}!'.format(stop, start)) + + # Range start address must be unique + if start in range_start: + raise ConfigError('Conflicting DHCP lease range:\n' \ + 'Pool start address {0} defined multipe times!'.format(start)) + else: + range_start.append(start) + + # Range stop address must be unique + if stop in range_stop: + raise ConfigError('Conflicting DHCP lease range:\n' \ + 'Pool stop address {0} defined multipe times!'.format(stop)) + else: + range_stop.append(stop) + + # Exclude addresses must be in bound + for exclude in subnet['exclude']: + if not ipaddress.ip_address(exclude) in ipaddress.ip_network(subnet['network']): + raise ConfigError('Exclude IP address {0} is outside of the DHCP lease network {1}\n' \ + 'under shared network {2}!'.format(exclude, subnet['network'], network['name'])) + + # At least one DHCP address range or static-mapping required + active_mapping = False + if (len(subnet['range']) == 0): + for mapping in subnet['static_mapping']: + # we need at least one active mapping + if (not active_mapping) and (not mapping['disabled']): + active_mapping = True + else: + active_mapping = True + + if not active_mapping: + raise ConfigError('No DHCP address range or active static-mapping set\n' \ + 'for subnet {0}!'.format(subnet['network'])) + + # Static IP address mappings require both an IP address and MAC address + for mapping in subnet['static_mapping']: + # Static IP address must be configured + if not mapping['ip_address']: + raise ConfigError('DHCP static lease IP address not specified for static mapping\n' \ + '{0} under shared network name {1}!'.format(mapping['name'], network['name'])) + + # Static IP address must be in bound + if not ipaddress.ip_address(mapping['ip_address']) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCP static lease IP address {0} for static mapping {1}\n' \ + 'in shared network {2} is outside DHCP lease subnet {3}!' \ + .format(mapping['ip_address'], mapping['name'], network['name'], subnet['network'])) + + # Static mapping requires MAC address + if not mapping['mac_address']: + raise ConfigError('DHCP static lease MAC address not specified for static mapping\n' \ + '{0} under shared network name {1}!'.format(mapping['name'], network['name'])) + + # There must be one subnet connected to a listen interface. + # This only counts if the network itself is not disabled! + if not network['disabled']: + if vyos.validate.is_subnet_connected(subnet['network'], primary=True): + listen_ok = True + + # Subnets must be non overlapping + if subnet['network'] in subnets: + raise ConfigError('DHCP subnets must be unique! Subnet {0} defined multiple times!'.format(subnet)) + else: + subnets.append(subnet['network']) + + # Check for overlapping subnets + net = ipaddress.ip_network(subnet['network']) + for n in subnets: + net2 = ipaddress.ip_network(n) + if (net != net2): + if net.overlaps(net2): + raise ConfigError('DHCP conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) + + if not listen_ok: + raise ConfigError('None of the DHCP lease subnets are inside any configured subnet on\n' \ + 'broadcast interfaces. At least one lease subnet must be set such that\n' \ + 'DHCP server listens on a one broadcast interface!') + + return None + +def generate(dhcp): + if dhcp is None: + return None + + if dhcp['disabled'] is True: + print('Warning: DHCP server will be deactivated because it is disabled') + return None + + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render(dhcp) + + # Please see: https://phabricator.vyos.net/T1129 for quoting of the raw parameters + # we can pass to ISC DHCPd + config_text = config_text.replace(""",'"') + + with open(config_file, 'w') as f: + f.write(config_text) + + tmpl = jinja2.Template(daemon_tmpl) + config_text = tmpl.render(dhcp) + with open(daemon_config_file, 'w') as f: + f.write(config_text) + + return None + +def apply(dhcp): + if (dhcp is None) or dhcp['disabled']: + # DHCP server is removed in the commit + os.system('sudo systemctl stop isc-dhcp-server.service') + if os.path.exists(config_file): + os.unlink(config_file) + if os.path.exists(daemon_config_file): + os.unlink(daemon_config_file) + else: + # If our file holding DHCP leases does yet not exist - create it + if not os.path.exists(lease_file): + os.mknod(lease_file) + + os.system('sudo systemctl restart isc-dhcp-server.service') + + 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/dhcpv6_relay.py b/src/conf_mode/dhcpv6_relay.py new file mode 100755 index 000000000..5868abe8a --- /dev/null +++ b/src/conf_mode/dhcpv6_relay.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import jinja2 + +from vyos.config import Config +from vyos import ConfigError + +config_file = r'/etc/default/isc-dhcpv6-relay' + +# Please be careful if you edit the template. +config_tmpl = """ +### Autogenerated by dhcpv6_relay.py ### + +# Defaults for isc-dhcpv6-relay initscript sourced by /etc/init.d/isc-dhcpv6-relay +OPTIONS="-6 -l {{ listen_addr | join(' -l ') }} -u {{ upstream_addr | join(' -u ') }} {{ options | join(' ') }}" + +""" + +default_config_data = { + 'listen_addr': [], + 'upstream_addr': [], + 'options': [], +} + +def get_config(): + relay = default_config_data + conf = Config() + if not conf.exists('service dhcpv6-relay'): + return None + else: + conf.set_level('service dhcpv6-relay') + + # Network interfaces/address to listen on for DHCPv6 query(s) + if conf.exists('listen-interface'): + interfaces = conf.list_nodes('listen-interface') + for intf in interfaces: + addr = conf.return_value('listen-interface {0} address'.format(intf)) + listen = addr + '%' + intf + relay['listen_addr'].append(listen) + + # Upstream interface/address for remote DHCPv6 server + if conf.exists('upstream-interface'): + interfaces = conf.list_nodes('upstream-interface') + for intf in interfaces: + addresses = conf.return_values('upstream-interface {0} address'.format(intf)) + for addr in addresses: + server = addr + '%' + intf + relay['upstream_addr'].append(server) + + # Maximum hop count. When forwarding packets, dhcrelay discards packets + # which have reached a hop count of COUNT. Default is 10. Maximum is 255. + if conf.exists('max-hop-count'): + count = '-c ' + conf.return_value('max-hop-count') + relay['options'].append(count) + + if conf.exists('use-interface-id-option'): + relay['options'].append('-I') + + return relay + +def verify(relay): + # bail out early - looks like removal from running config + if relay is None: + return None + + if len(relay['listen_addr']) == 0 or len(relay['upstream_addr']) == 0: + raise ConfigError('Must set at least one listen and upstream interface.') + + return None + +def generate(relay): + # bail out early - looks like removal from running config + if relay is None: + return None + + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render(relay) + with open(config_file, 'w') as f: + f.write(config_text) + + return None + +def apply(relay): + if relay is not None: + os.system('sudo systemctl restart isc-dhcpv6-relay.service') + else: + # DHCPv6 relay support is removed in the commit + os.system('sudo systemctl stop isc-dhcpv6-relay.service') + os.unlink(config_file) + + 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/dhcpv6_server.py b/src/conf_mode/dhcpv6_server.py new file mode 100755 index 000000000..bb3e6e90d --- /dev/null +++ b/src/conf_mode/dhcpv6_server.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import ipaddress + +import jinja2 + +import vyos.validate + +from vyos.config import Config +from vyos import ConfigError + +config_file = r'/etc/dhcp/dhcpd6.conf' +lease_file = r'/config/dhcpd6.leases' +daemon_config_file = r'/etc/default/isc-dhcpv6-server' + +# Please be careful if you edit the template. +config_tmpl = """ +### Autogenerated by dhcpv6_server.py ### + +# For options please consult the following website: +# https://www.isc.org/wp-content/uploads/2017/08/dhcp43options.html + +log-facility local7; +{%- if preference %} +option dhcp6.preference {{ preference }}; +{%- endif %} + +# Shared network configration(s) +{% for network in shared_network %} +{%- if not network.disabled -%} +shared-network {{ network.name }} { + {%- for subnet in network.subnet %} + subnet6 {{ subnet.network }} { + {%- for range in subnet.range6_prefix %} + range6 {{ range.prefix }}{{ " temporary" if range.temporary }}; + {%- endfor %} + {%- for range in subnet.range6 %} + range6 {{ range.start }} {{ range.stop }}; + {%- endfor %} + {%- if subnet.domain_search %} + option dhcp6.domain-search {{ subnet.domain_search | join(', ') }}; + {%- endif %} + {%- if subnet.lease_def %} + default-lease-time {{ subnet.lease_def }}; + {%- endif %} + {%- if subnet.lease_max %} + max-lease-time {{ subnet.lease_max }}; + {%- endif %} + {%- if subnet.lease_min %} + min-lease-time {{ subnet.lease_min }}; + {%- endif %} + {%- if subnet.dns_server %} + option dhcp6.name-servers {{ subnet.dns_server | join(', ') }}; + {%- endif %} + {%- if subnet.nis_domain %} + option dhcp6.nis-domain-name "{{ subnet.nis_domain }}"; + {%- endif %} + {%- if subnet.nis_server %} + option dhcp6.nis-servers {{ subnet.nis_server | join(', ') }}; + {%- endif %} + {%- if subnet.nisp_domain %} + option dhcp6.nisp-domain-name "{{ subnet.nisp_domain }}"; + {%- endif %} + {%- if subnet.nisp_server %} + option dhcp6.nisp-servers {{ subnet.nisp_server | join(', ') }}; + {%- endif %} + {%- if subnet.sip_address %} + option dhcp6.sip-servers-addresses {{ subnet.sip_address | join(', ') }}; + {%- endif %} + {%- if subnet.sip_hostname %} + option dhcp6.sip-servers-names {{ subnet.sip_hostname | join(', ') }}; + {%- endif %} + {%- if subnet.sntp_server %} + option dhcp6.sntp-servers {{ subnet.sntp_server | join(', ') }}; + {%- endif %} + {%- for host in subnet.static_mapping %} + {% if not host.disabled -%} + host {{ network.name }}_{{ host.name }} { + host-identifier option dhcp6.client-id "{{ host.client_identifier }}"; + fixed-address6 {{ host.ipv6_address }}; + } + {%- endif %} + {%- endfor %} + } + {%- endfor %} +} +{%- endif %} +{% endfor %} + +""" + +daemon_tmpl = """ +### Autogenerated by dhcp_server.py ### + +# sourced by /etc/init.d/isc-dhcpv6-server + +DHCPD_CONF=/etc/dhcp/dhcpd6.conf +DHCPD_PID=/var/run/dhcpd6.pid +OPTIONS="-6 -lf {{ lease_file }}" +INTERFACES="" +""" + +default_config_data = { + 'lease_file': lease_file, + 'preference': '', + 'disabled': False, + 'shared_network': [] +} + +def get_config(): + dhcpv6 = default_config_data + conf = Config() + if not conf.exists('service dhcpv6-server'): + return None + else: + conf.set_level('service dhcpv6-server') + + # Check for global disable of DHCPv6 service + if conf.exists('disable'): + dhcpv6['disabled'] = True + return dhcpv6 + + # Preference of this DHCPv6 server compared with others + if conf.exists('preference'): + dhcpv6['preference'] = conf.return_value('preference') + + # check for multiple, shared networks served with DHCPv6 addresses + if conf.exists('shared-network-name'): + for network in conf.list_nodes('shared-network-name'): + conf.set_level('service dhcpv6-server shared-network-name {0}'.format(network)) + config = { + 'name': network, + 'disabled': False, + 'subnet': [] + } + + # If disabled, the shared-network configuration becomes inactive + if conf.exists('disable'): + config['disabled'] = True + + # check for multiple subnet configurations in a shared network + if conf.exists('subnet'): + for net in conf.list_nodes('subnet'): + conf.set_level('service dhcpv6-server shared-network-name {0} subnet {1}'.format(network, net)) + subnet = { + 'network': net, + 'range6_prefix': [], + 'range6': [], + 'default_router': '', + 'dns_server': [], + 'domain_name': '', + 'domain_search': [], + 'lease_def': '', + 'lease_min': '', + 'lease_max': '', + 'nis_domain': '', + 'nis_server': [], + 'nisp_domain': '', + 'nisp_server': [], + 'sip_address': [], + 'sip_hostname': [], + 'sntp_server': [], + 'static_mapping': [] + } + + # For any subnet on which addresses will be assigned dynamically, there must be at + # least one address range statement. The range statement gives the lowest and highest + # IP addresses in a range. All IP addresses in the range should be in the subnet in + # which the range statement is declared. + if conf.exists('address-range prefix'): + for prefix in conf.list_nodes('address-range prefix'): + range = { + 'prefix': prefix, + 'temporary': False + } + + # Address range will be used for temporary addresses + if conf.exists('address-range prefix {0} temporary'.format(range['prefix'])): + range['temporary'] = True + + # Append to subnet temporary range6 list + subnet['range6_prefix'].append(range) + + if conf.exists('address-range start'): + for range in conf.list_nodes('address-range start'): + range = { + 'start': range, + 'stop': conf.return_value('address-range start {0} stop'.format(range)) + } + + # Append to subnet range6 list + subnet['range6'].append(range) + + # The domain-search option specifies a 'search list' of Domain Names to be used + # by the client to locate not-fully-qualified domain names. + if conf.exists('domain-search'): + for domain in conf.return_values('domain-search'): + subnet['domain_search'].append('"' + domain + '"') + + # IPv6 address valid lifetime + # (at the end the address is no longer usable by the client) + # (set to 30 days, the usual IPv6 default) + if conf.exists('lease-time default'): + subnet['lease_def'] = conf.return_value('lease-time default') + + # Time should be the maximum length in seconds that will be assigned to a lease. + # The only exception to this is that Dynamic BOOTP lease lengths, which are not + # specified by the client, are not limited by this maximum. + if conf.exists('lease-time maximum'): + subnet['lease_max'] = conf.return_value('lease-time maximum') + + # Time should be the minimum length in seconds that will be assigned to a lease + if conf.exists('lease-time minimum'): + subnet['lease_min'] = conf.return_value('lease-time minimum') + + # Specifies a list of Domain Name System name servers available to the client. + # Servers should be listed in order of preference. + if conf.exists('name-server'): + subnet['dns_server'] = conf.return_values('name-server') + + # Ancient NIS (Network Information Service) domain name + if conf.exists('nis-domain'): + subnet['nis_domain'] = conf.return_value('nis-domain') + + # Ancient NIS (Network Information Service) servers + if conf.exists('nis-server'): + subnet['nis_server'] = conf.return_values('nis-server') + + # Ancient NIS+ (Network Information Service) domain name + if conf.exists('nisplus-domain'): + subnet['nisp_domain'] = conf.return_value('nisplus-domain') + + # Ancient NIS+ (Network Information Service) servers + if conf.exists('nisplus-server'): + subnet['nisp_server'] = conf.return_values('nisplus-server') + + # Prefix Delegation (RFC 3633) + if conf.exists('prefix-delegation'): + print('TODO: This option is actually not implemented right now!') + + # Local SIP server that is to be used for all outbound SIP requests - IPv6 address + if conf.exists('sip-server-address'): + subnet['sip_address'] = conf.return_values('sip-server-address') + + # Local SIP server that is to be used for all outbound SIP requests - hostname + if conf.exists('sip-server-name'): + for hostname in conf.return_values('sip-server-name'): + subnet['sip_hostname'].append('"' + hostname + '"') + + # List of local SNTP servers available for the client to synchronize their clocks + if conf.exists('sntp-server'): + subnet['sntp_server'] = conf.return_values('sntp-server') + + # + # Static DHCP v6 leases + # + if conf.exists('static-mapping'): + for mapping in conf.list_nodes('static-mapping'): + conf.set_level('service dhcpv6-server shared-network-name {0} subnet {1} static-mapping {2}'.format(network, net, mapping)) + mapping = { + 'name': mapping, + 'disabled': False, + 'ipv6_address': '', + 'client_identifier': '', + } + + # This static lease is disabled + if conf.exists('disable'): + mapping['disabled'] = True + + # IPv6 address used for this DHCP client + if conf.exists('ipv6-address'): + mapping['ipv6_address'] = conf.return_value('ipv6-address') + + # This option specifies the client’s DUID identifier. DUIDs are similar but different from DHCPv4 client identifiers + if conf.exists('identifier'): + mapping['client_identifier'] = conf.return_value('identifier') + + # append static mapping configuration tu subnet list + subnet['static_mapping'].append(mapping) + + # append subnet configuration to shared network subnet list + config['subnet'].append(subnet) + + + # append shared network configuration to config dictionary + dhcpv6['shared_network'].append(config) + + return dhcpv6 + +def verify(dhcpv6): + if dhcpv6 is None: + return None + + if dhcpv6['disabled']: + return None + + # If DHCP is enabled we need one share-network + if len(dhcpv6['shared_network']) == 0: + raise ConfigError('No DHCPv6 shared networks configured.\n' \ + 'At least one DHCPv6 shared network must be configured.') + + # Inspect shared-network/subnet + subnets = [] + listen_ok = False + + for network in dhcpv6['shared_network']: + # A shared-network requires a subnet definition + if len(network['subnet']) == 0: + raise ConfigError('No DHCPv6 lease subnets configured for {0}. At least one\n' \ + 'lease subnet must be configured for each shared network.'.format(network['name'])) + + range6_start = [] + range6_stop = [] + for subnet in network['subnet']: + # Ususal range declaration with a start and stop address + for range6 in subnet['range6']: + # shorten names + start = range6['start'] + stop = range6['stop'] + + # DHCPv6 stop address is required + if start and not stop: + raise ConfigError('DHCPv6 range stop address for start {0} is not defined!'.format(start)) + + # Start address must be inside network + if not ipaddress.ip_address(start) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCPv6 range start address {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(start, subnet['network'], network['name'])) + + # Stop address must be inside network + if not ipaddress.ip_address(stop) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCPv6 range stop address {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(stop, subnet['network'], network['name'])) + + # Stop address must be greater or equal to start address + if not ipaddress.ip_address(stop) >= ipaddress.ip_address(start): + raise ConfigError('DHCPv6 range stop address {0} must be greater or equal\n' \ + 'to the range start address {1}!'.format(stop, start)) + + # DHCPv6 range start address must be unique - two ranges can't + # start with the same address - makes no sense + if start in range6_start: + raise ConfigError('Conflicting DHCPv6 lease range:\n' \ + 'Pool start address {0} defined multipe times!'.format(start)) + else: + range6_start.append(start) + + # DHCPv6 range stop address must be unique - two ranges can't + # end with the same address - makes no sense + if stop in range6_stop: + raise ConfigError('Conflicting DHCPv6 lease range:\n' \ + 'Pool stop address {0} defined multipe times!'.format(stop)) + else: + range6_stop.append(stop) + + # We also have prefixes that require checking + for prefix in subnet['range6_prefix']: + # If configured prefix does not match our subnet, we have to check that it's inside + if ipaddress.ip_network(prefix['prefix']) != ipaddress.ip_network(subnet['network']): + # Configured prefixes must be inside our network + if not ipaddress.ip_network(prefix['prefix']) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCPv6 prefix {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(prefix['prefix'], subnet['network'], network['name'])) + + # DHCPv6 requires at least one configured address range or one static mapping + if not network['disabled']: + if vyos.validate.is_subnet_connected(subnet['network']): + listen_ok = True + + # DHCPv6 subnet must not overlap. ISC DHCP also complains about overlapping + # subnets: "Warning: subnet 2001:db8::/32 overlaps subnet 2001:db8:1::/32" + net = ipaddress.ip_network(subnet['network']) + for n in subnets: + net2 = ipaddress.ip_network(n) + if (net != net2): + if net.overlaps(net2): + raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) + + if not listen_ok: + raise ConfigError('None of the DHCPv6 subnets are connected to a subnet6 on\n' \ + 'this machine. At least one subnet6 must be connected such that\n' \ + 'DHCPv6 listens on an interface!') + + + return None + +def generate(dhcpv6): + if dhcpv6 is None: + return None + + if dhcpv6['disabled']: + print('Warning: DHCPv6 server will be deactivated because it is disabled') + return None + + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render(dhcpv6) + with open(config_file, 'w') as f: + f.write(config_text) + + tmpl = jinja2.Template(daemon_tmpl) + config_text = tmpl.render(dhcpv6) + with open(daemon_config_file, 'w') as f: + f.write(config_text) + + return None + +def apply(dhcpv6): + if (dhcpv6 is None) or dhcpv6['disabled']: + # DHCP server is removed in the commit + os.system('sudo systemctl stop isc-dhcpv6-server.service') + if os.path.exists(config_file): + os.unlink(config_file) + if os.path.exists(daemon_config_file): + os.unlink(daemon_config_file) + else: + # If our file holding DHCPv6 leases does yet not exist - create it + if not os.path.exists(lease_file): + os.mknod(lease_file) + + os.system('sudo systemctl restart isc-dhcpv6-server.service') + + 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/dns_forwarding.py b/src/conf_mode/dns_forwarding.py index 43be9d526..135f6fec0 100755 --- a/src/conf_mode/dns_forwarding.py +++ b/src/conf_mode/dns_forwarding.py @@ -36,9 +36,11 @@ config_tmpl = """ # Non-configurable defaults daemon=yes threads=1 -allow-from=0.0.0.0/0 +allow-from=0.0.0.0/0, ::/0 log-common-errors=yes non-local-bind=yes +query-local-address=0.0.0.0 +query-local-address6=:: # cache-size max-cache-entries={{ cache_size }} @@ -65,8 +67,12 @@ forward-zones={% for d in domains %} # dnssec dnssec={{ dnssec }} +{% if name_servers -%} # name-server forward-zones-recurse=.={{ name_servers | join(';') }} +{% else %} +# no name-servers specified - start full recursor +{% endif %} """ @@ -114,10 +120,10 @@ def get_config(): if conf.exists('domain'): for node in conf.list_nodes('domain'): - server = conf.return_values("domain {0} server".format(node)) + servers = conf.return_values("domain {0} server".format(node)) domain = { "name": node, - "servers": server + "servers": bracketize_ipv6_addrs(servers) } dns['domains'].append(domain) @@ -138,6 +144,8 @@ def get_config(): dns['name_servers'] = dns['name_servers'] + system_name_servers conf.set_level('service dns forwarding') + dns['name_servers'] = bracketize_ipv6_addrs(dns['name_servers']) + if conf.exists('listen-address'): dns['listen_on'] = conf.return_values('listen-address') @@ -193,6 +201,10 @@ def get_config(): 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: diff --git a/src/conf_mode/dynamic_dns.py b/src/conf_mode/dynamic_dns.py index 90d4ff567..ff3c1f825 100755 --- a/src/conf_mode/dynamic_dns.py +++ b/src/conf_mode/dynamic_dns.py @@ -24,6 +24,7 @@ from vyos.config import Config from vyos import ConfigError config_file = r'/etc/ddclient.conf' +cache_file = r'/var/cache/ddclient/ddclient.cache' config_tmpl = """ ### Autogenerated by dynamic_dns.py ### @@ -38,8 +39,8 @@ cache=/var/cache/ddclient/ddclient.cache # # ddclient configuration for interface "{{ interface.interface }}": # -{% if interface.web_url and interface.web_skip -%} -use=web, web={{ interface.web_url}}, web-skip={{ interface.web_skip }} +{% if interface.web_url -%} +use=web, web='{{ interface.web_url}}' {%- if interface.web_skip %}, web-skip='{{ interface.web_skip }}'{% endif %} {% else -%} use=if, if={{ interface.interface }} {% endif -%} @@ -63,6 +64,9 @@ protocol={{ srv.protocol }} max-interval=28d login={{ srv.login }} password='{{ srv.password }}' +{% if srv.server -%} +server={{ srv.server }} +{% endif -%} {{ host }} {% endfor %} {% endfor %} @@ -148,7 +152,7 @@ def get_config(): 'server': '', 'custom' : False } - + # preload protocol from default service mapping if service in default_service_protocol.keys(): srv['protocol'] = default_service_protocol[service] @@ -242,6 +246,9 @@ def generate(dyndns): return None def apply(dyndns): + if os.path.exists(cache_file): + os.unlink(cache_file) + if dyndns is None: os.system('/etc/init.d/ddclient stop') else: diff --git a/src/conf_mode/host_name.py b/src/conf_mode/host_name.py index 3b3958f7f..81a52e87f 100755 --- a/src/conf_mode/host_name.py +++ b/src/conf_mode/host_name.py @@ -24,94 +24,240 @@ import os import re import sys import subprocess +import copy +import jinja2 +import glob from vyos.config import Config from vyos import ConfigError +config_file_hosts = '/etc/hosts' +config_file_resolv = '/etc/resolv.conf' -hosts_file = '/etc/hosts' -hostname_regex = re.compile("^[A-Za-z0-9][-.A-Za-z0-9]*[A-Za-z0-9]$") -local_addr = '127.0.1.1' # NOSONAR +config_tmpl_hosts = """ +### Autogenerated by host_name.py ### +127.0.0.1 localhost {{ hostname }}{% 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 + +# 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 %} + +""" + +# borrowed from: https://github.com/donjajo/py-world/blob/master/resolvconfReader.py, THX! +def get_resolvers(file): + resolvers = [] + try: + with open(file, 'r') as resolvconf: + for line in resolvconf.readlines(): + line = line.split('#',1)[0]; + line = line.rstrip(); + if 'nameserver' in line: + resolvers.append(line.split()[1]) + return resolvers + except IOError: + return [] + +def get_dhcp_search_doms(file): + search_doms = [] + try: + with open(file, 'r') as resolvconf: + for line in resolvconf.readlines(): + line = line.split('#',1)[0]; + line = line.rstrip(); + if 'search' in line: + return re.sub('^search','',line).lstrip().split() + except IOError: + return [] + +default_config_data = { + 'hostname': 'vyos', + 'domain_name': '', + 'domain_search': [], + 'nameserver': [], + 'no_dhcp_ns': False +} def get_config(): - """Get configuration""" - conf = Config() + conf = Config() + hosts = copy.deepcopy(default_config_data) - hostname = conf.return_value("system host-name") - domain = conf.return_value("system domain-name") + if conf.exists("system host-name"): + hosts['hostname'] = conf.return_value("system host-name") - # No one likes fixups, but we really don't want VyOS fail to boot - # if hostname is not in the config - if not hostname: - hostname = "vyos" + if conf.exists("system domain-name"): + hosts['domain_name'] = conf.return_value("system domain-name") + hosts['domain_search'].append(hosts['domain_name']) - if domain: - fqdn = "{0}.{1}".format(hostname, domain) - else: - fqdn = hostname + for search in conf.return_values("system domain-search domain"): + hosts['domain_search'].append(search) - return {"hostname": hostname, "domain": domain, "fqdn": fqdn} + if conf.exists("system name-server"): + hosts['nameserver'] = conf.return_values("system name-server") + if conf.exists("system disable-dhcp-nameservers"): + hosts['no_dhcp_ns'] = conf.exists('system disable-dhcp-nameservers') -def verify(config): - """Verify configuration""" - # check for invalid host + ## system static-host-mapping + hosts['static_host_mapping'] = { 'hostnames' : {}} - # pattern $VAR(@) "^[[:alnum:]][-.[:alnum:]]*[[:alnum:]]$" ; "invalid host name $VAR(@)" - if not hostname_regex.match(config["hostname"]): - raise ConfigError('Invalid host name ' + config["hostname"]) + 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( conf.return_values('system static-host-mapping host-name ' + hn + ' alias') ) - # pattern $VAR(@) "^.{1,63}$" ; "invalid host-name length" - length = len(config["hostname"]) - if length < 1 or length > 63: - raise ConfigError( - 'Invalid host-name length, must be less than 63 characters') + return hosts +def verify(config): + if config is None: return None + # pattern $VAR(@) "^[[:alnum:]][-.[:alnum:]]*[[:alnum:]]$" ; "invalid host name $VAR(@)" + hostname_regex = re.compile("^[A-Za-z0-9][-.A-Za-z0-9]*[A-Za-z0-9]$") + if not hostname_regex.match(config['hostname']): + raise ConfigError('Invalid host name ' + config["hostname"]) -def generate(config): - """Generate configuration files""" - # read the hosts file - with open(hosts_file, 'r') as f: - hosts = f.read() - - # get the current hostname - old_hostname = subprocess.check_output(['hostname']).decode().strip() - - # replace the local host line - vyos_host_line_re = re.compile(r"({}\s+{}.*)".format(local_addr, old_hostname)) - vyos_host_line = "{}\t{} # VyOS entry\n".format(local_addr, config["fqdn"]) - if re.search(vyos_host_line_re, hosts): - hosts = re.sub(vyos_host_line_re, vyos_host_line, hosts) - else: - # On boot (or after errors), the /etc/hosts file has no line for vyos hostname, - # so we have to add it - hosts = "{0}\n{1}".format(hosts, vyos_host_line) - - with open(hosts_file, 'w') as f: - f.write(hosts) + # pattern $VAR(@) "^.{1,63}$" ; "invalid host-name length" + length = len(config['hostname']) + if length < 1 or length > 63: + raise ConfigError('Invalid host-name length, must be less than 63 characters') + + # The search list is currently limited to six domains with a total of 256 characters. + # https://linux.die.net/man/5/resolv.conf + if len(config['domain_search']) > 6: + raise ConfigError('The search list is currently limited to six domains') + + tmp = ' '.join(config['domain_search']) + if len(tmp) > 256: + raise ConfigError('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) + + return None + +def generate(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 = [] + for file in glob.glob('/etc/resolv.conf.dhclient-new*'): + for r in get_resolvers(file): + dhcp_ns.append(r) -def apply(config): - """Apply configuration""" - os.system("hostnamectl set-hostname --static {0}".format(config["fqdn"])) + if not config['no_dhcp_ns']: + config['nameserver'] += dhcp_ns + for file in glob.glob('/etc/resolv.conf.dhclient-new*'): + config['domain_search'] = get_dhcp_search_doms(file) + + # 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) - # restart services that use the hostname - os.system("systemctl restart rsyslog.service") + 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) + + return None + +def apply(config): + if config is None: return None + fqdn = config['hostname'] + if config['domain_name']: + fqdn += '.' + config['domain_name'] + + os.system("hostnamectl set-hostname --static {0}".format(fqdn.rstrip('.'))) + + # Restart services that use the hostname + os.system("systemctl restart rsyslog.service") + + # If SNMP is running, restart it too + if os.system("pgrep snmpd > /dev/null") == 0: + os.system("systemctl restart snmpd.service") + + # restart pdns if it is used + if os.system("/usr/bin/rec_control ping >/dev/null 2>&1") == 0: + os.system("/etc/init.d/pdns-recursor restart >/dev/null") + + return None if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) + 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/igmp_proxy.py b/src/conf_mode/igmp_proxy.py new file mode 100755 index 000000000..b994369af --- /dev/null +++ b/src/conf_mode/igmp_proxy.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import jinja2 + +from vyos.config import Config +from vyos import ConfigError + +config_file = r'/etc/igmpproxy.conf' + +# Please be careful if you edit the template. +config_tmpl = """ +######################################################## +# +# autogenerated by igmp_proxy.py +# +# The configuration file must define one upstream +# interface, and one or more downstream interfaces. +# +# If multicast traffic originates outside the +# upstream subnet, the "altnet" option can be +# used in order to define legal multicast sources. +# (Se example...) +# +# The "quickleave" should be used to avoid saturation +# of the upstream link. The option should only +# be used if it's absolutely nessecary to +# accurately imitate just one Client. +# +######################################################## + +{% if not disable_quickleave -%} +quickleave +{% endif -%} + +{% for i in interface %} +# Configuration for {{ i.interface }} ({{ i.role }} interface) +{% if i.role == 'disabled' -%} +phyint {{ i.interface }} disabled +{%- else -%} +phyint {{ i.interface }} {{ i.role }} ratelimit 0 threshold {{ i.threshold }} +{%- endif -%} +{%- for subnet in i.alt_subnet %} + altnet {{ subnet }} +{%- endfor %} +{%- for subnet in i.whitelist %} + whitelist {{ subnet }} +{%- endfor %} +{% endfor %} +""" + +default_config_data = { + 'disable': False, + 'disable_quickleave': False, + 'interface': [], +} + +def get_config(): + igmp_proxy = default_config_data + conf = Config() + if not conf.exists('protocols igmp-proxy'): + return None + else: + conf.set_level('protocols igmp-proxy') + + # Network interfaces to listen on + if conf.exists('disable'): + igmp_proxy['disable'] = True + + # Option to disable "quickleave" + if conf.exists('disable-quickleave'): + igmp_proxy['disable_quickleave'] = True + + for intf in conf.list_nodes('interface'): + conf.set_level('protocols igmp-proxy interface {0}'.format(intf)) + interface = { + 'interface': intf, + 'alt_subnet': [], + 'role': 'downstream', + 'threshold': '1', + 'whitelist': [] + } + + if conf.exists('alt-subnet'): + interface['alt_subnet'] = conf.return_values('alt-subnet') + + if conf.exists('role'): + interface['role'] = conf.return_value('role') + + if conf.exists('threshold'): + interface['threshold'] = conf.return_value('threshold') + + if conf.exists('whitelist'): + interface['whitelist'] = conf.return_values('whitelist') + + # Append interface configuration to global configuration list + igmp_proxy['interface'].append(interface) + + return igmp_proxy + +def verify(igmp_proxy): + # bail out early - looks like removal from running config + if igmp_proxy is None: + return None + + # bail out early - service is disabled + if igmp_proxy['disable']: + return None + + # at least two interfaces are required, one upstream and one downstream + if len(igmp_proxy['interface']) < 2: + raise ConfigError('Must define an upstream and at least 1 downstream interface!') + + upstream = 0 + for i in igmp_proxy['interface']: + if "upstream" == i['role']: + upstream += 1 + + if upstream == 0: + raise ConfigError('At least 1 upstream interface is required!') + elif upstream > 1: + raise ConfigError('Only 1 upstream interface allowed!') + + return None + +def generate(igmp_proxy): + # bail out early - looks like removal from running config + if igmp_proxy is None: + return None + + # bail out early - service is disabled, but inform user + if igmp_proxy['disable']: + print('Warning: IGMP Proxy will be deactivated because it is disabled') + return None + + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render(igmp_proxy) + with open(config_file, 'w') as f: + f.write(config_text) + + return None + +def apply(igmp_proxy): + if igmp_proxy is None or igmp_proxy['disable']: + # IGMP Proxy support is removed in the commit + os.system('sudo systemctl stop igmpproxy.service') + if os.path.exists(config_file): + os.unlink(config_file) + else: + os.system('sudo systemctl restart igmpproxy.service') + + 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/mdns_repeater.py b/src/conf_mode/mdns_repeater.py index 474a6a5cf..cef735c0d 100755 --- a/src/conf_mode/mdns_repeater.py +++ b/src/conf_mode/mdns_repeater.py @@ -18,7 +18,7 @@ import sys import os - +import jinja2 import netifaces from vyos.config import Config @@ -26,60 +26,78 @@ from vyos import ConfigError config_file = r'/etc/default/mdns-repeater' -def get_config(): - interface_list = [] +config_tmpl = """ +### Autogenerated by mdns_repeater.py ### +DAEMON_ARGS="{{ interfaces | join(' ') }}" +""" + +default_config_data = { + 'disabled': False, + 'interfaces': [] +} +def get_config(): + mdns = default_config_data conf = Config() - conf.set_level('service mdns repeater') - if not conf.exists(''): - return interface_list + if not conf.exists('service mdns repeater'): + return None + else: + conf.set_level('service mdns repeater') - if conf.exists('interface'): - intfs_names = [] - intfs_names = conf.return_values('interface') + # Service can be disabled by user + if conf.exists('disable'): + mdns['disabled'] = True + return mdns - for name in intfs_names: - interface_list.append(name) + # Interface to repeat mDNS advertisements + if conf.exists('interface'): + mdns['interfaces'] = conf.return_values('interface') - return interface_list + return mdns def verify(mdns): - # '0' interfaces are possible, think of service deletion. Only '1' is not supported! - if len(mdns) == 1: - raise ConfigError('At least 2 interfaces must be specified but %d given!' % len(mdns)) - - # For mdns-repeater to work it is essential that the interfaces - # have an IP address assigned - for intf in mdns: - try: - netifaces.ifaddresses(intf)[netifaces.AF_INET] - except KeyError as e: - raise ConfigError('No IP address configured for interface "%s"!' % intf) + if mdns is None: + return None + + if mdns['disabled']: + return None + + # We need at least two interfaces to repeat mDNS advertisments + if len(mdns['interfaces']) < 2: + raise ConfigError('mDNS repeater requires at least 2 configured interfaces!') + + # For mdns-repeater to work it is essential that the interfaces has + # an IPv4 address assigned + for interface in mdns['interfaces']: + if netifaces.AF_INET in netifaces.ifaddresses(interface).keys(): + if len(netifaces.ifaddresses(interface)[netifaces.AF_INET]) < 1: + raise ConfigError('mDNS repeater requires an IPv6 address configured on interface %s!'.format(interface)) return None def generate(mdns): - config_header = '### Autogenerated by mdns_repeater.py ###\n' - if len(mdns) > 0: - config_args = 'DAEMON_ARGS="' + ' '.join(str(e) for e in mdns) + '"\n' - else: - config_args = 'DAEMON_ARGS=""\n' + if mdns is None: + return None + + if mdns['disabled']: + print('Warning: mDNS repeater will be deactivated because it is disabled') + return None - # write new configuration file - f = open(config_file, 'w') - f.write(config_header) - f.write(config_args) - f.close() + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render(mdns) + with open(config_file, 'w') as f: + f.write(config_text) return None def apply(mdns): - if len(mdns) == 0: - cmd = "sudo systemctl stop mdns-repeater" + if (mdns is None) or mdns['disabled']: + os.system('sudo systemctl stop mdns-repeater') + if os.path.exists(config_file): + os.unlink(config_file) else: - cmd = "sudo systemctl restart mdns-repeater" + os.system('sudo systemctl restart mdns-repeater') - os.system(cmd) return None if __name__ == '__main__': diff --git a/src/conf_mode/ntp.py b/src/conf_mode/ntp.py index 2a6088575..f706d502f 100755 --- a/src/conf_mode/ntp.py +++ b/src/conf_mode/ntp.py @@ -21,6 +21,7 @@ import os import jinja2 import ipaddress +import copy from vyos.config import Config from vyos import ConfigError @@ -36,12 +37,11 @@ config_tmpl = """ # driftfile /var/lib/ntp/ntp.drift # By default, only allow ntpd to query time sources, ignore any incoming requests -restrict default ignore +restrict default noquery nopeer notrap nomodify # Local users have unrestricted access, allowing reconfiguration via ntpdc restrict 127.0.0.1 restrict -6 ::1 - # # Configurable section # @@ -50,7 +50,6 @@ restrict -6 ::1 {% for s in servers -%} # Server configuration for: {{ s.name }} server {{ s.name }} iburst {{ s.options | join(" ") }} - {% endfor -%} {% endif %} @@ -79,7 +78,7 @@ default_config_data = { } def get_config(): - ntp = default_config_data + ntp = copy.deepcopy(default_config_data) conf = Config() if not conf.exists('system ntp'): return None @@ -108,8 +107,6 @@ def get_config(): "name": node, "options": [] } - if conf.exists('server {0} dynamic'.format(node)): - options.append('dynamic') if conf.exists('server {0} noselect'.format(node)): options.append('noselect') if conf.exists('server {0} preempt'.format(node)): @@ -154,10 +151,10 @@ def generate(ntp): def apply(ntp): if ntp is not None: - os.system('sudo /usr/sbin/invoke-rc.d ntp force-reload') + os.system('sudo systemctl restart ntp.service') else: - # NTP suuport is removed in the commit - os.system('sudo /usr/sbin/invoke-rc.d ntp stop') + # NTP support is removed in the commit + os.system('sudo systemctl stop ntp.service') os.unlink(config_file) return None diff --git a/src/conf_mode/snmp.py b/src/conf_mode/snmp.py index 3b47ffc98..06d2e253a 100755 --- a/src/conf_mode/snmp.py +++ b/src/conf_mode/snmp.py @@ -24,12 +24,12 @@ import pwd import time import jinja2 -import ipaddress import random import binascii import re import vyos.version +import vyos.validate from vyos.config import Config from vyos import ConfigError @@ -38,6 +38,7 @@ config_file_client = r'/etc/snmp/snmp.conf' config_file_daemon = r'/etc/snmp/snmpd.conf' config_file_access = r'/usr/share/snmp/snmpd.conf' config_file_user = r'/var/lib/snmp/snmpd.conf' +config_file_init = r'/etc/default/snmpd' # SNMP OIDs used to mark auth/priv type OIDs = { @@ -47,7 +48,7 @@ OIDs = { 'des' : '.1.3.6.1.6.3.10.1.2.2', 'none': '.1.3.6.1.6.3.10.1.2.1' } -# SNMPS template - be careful if you edit the template. +# SNMP template (/etc/snmp/snmp.conf) - be careful if you edit the template. client_config_tmpl = """ ### Autogenerated by snmp.py ### {% if trap_source -%} @@ -56,39 +57,38 @@ clientaddr {{ trap_source }} """ -# SNMPS template - be careful if you edit the template. +# SNMP template (/usr/share/snmp/snmpd.conf) - be careful if you edit the template. access_config_tmpl = """ ### Autogenerated by snmp.py ### -{% if v3_users %} -{% for u in v3_users %} +{%- for u in v3_users %} {{ u.mode }}user {{ u.name }} -{% endfor %} -{% endif -%} +{%- endfor %} + rwuser {{ vyos_user }} """ -# SNMPS template - be careful if you edit the template. +# SNMP template (/var/lib/snmp/snmpd.conf) - be careful if you edit the template. user_config_tmpl = """ ### Autogenerated by snmp.py ### # user -{% if v3_users %} -{% for u in v3_users %} -{% if u.authOID == 'none' %} +{%- for u in v3_users %} +{%- if u.authOID == 'none' %} createUser {{ u.name }} -{% elif u.authPassword %} +{%- elif u.authPassword %} createUser {{ u.name }} {{ u.authProtocol | upper }} "{{ u.authPassword }}" {{ u.privProtocol | upper }} {{ u.privPassword }} -{% else %} +{%- else %} usmUser 1 3 {{ u.engineID }} "{{ u.name }}" "{{ u.name }}" NULL {{ u.authOID }} {{ u.authMasterKey }} {{ u.privOID }} {{ u.privMasterKey }} 0x -{% endif %} -{% endfor %} -{% endif %} +{%- endif %} +{%- endfor %} createUser {{ vyos_user }} MD5 "{{ vyos_user_pass }}" DES +{%- if v3_engineid %} oldEngineID {{ v3_engineid }} +{%- endif %} """ -# SNMPS template - be careful if you edit the template. +# SNMP template (/etc/snmp/snmpd.conf) - be careful if you edit the template. daemon_config_tmpl = """ ### Autogenerated by snmp.py ### @@ -96,15 +96,10 @@ daemon_config_tmpl = """ sysObjectID 1.3.6.1.4.1.44641 sysServices 14 master agentx -agentXPerms 0755 0755 +agentXPerms 0777 0777 pass .1.3.6.1.2.1.31.1.1.1.18 /opt/vyatta/sbin/if-mib-alias smuxpeer .1.3.6.1.2.1.83 smuxpeer .1.3.6.1.2.1.157 -smuxpeer .1.3.6.1.4.1.3317.1.2.2 -smuxpeer .1.3.6.1.4.1.3317.1.2.3 -smuxpeer .1.3.6.1.4.1.3317.1.2.5 -smuxpeer .1.3.6.1.4.1.3317.1.2.8 -smuxpeer .1.3.6.1.4.1.3317.1.2.9 smuxsocket localhost # linkUp/Down configure the Event MIB tables to monitor @@ -122,110 +117,112 @@ monitor -r 10 -e linkDownTrap "Generate linkDown" ifOperStatus == 2 ######################## # configurable section # ######################## - {% if v3_tsm_key %} [snmp] localCert {{ v3_tsm_key }} -{% endif %} +{%- endif %} # Default system description is VyOS version sysDescr VyOS {{ version }} -{% if description -%} +{% if description %} # Description SysDescr {{ description }} -{% endif %} +{%- endif %} # Listen agentaddress unix:/run/snmpd.socket{% if listen_on %}{% for li in listen_on %},{{ li }}{% endfor %}{% else %},udp:161,udp6:161{% endif %}{% if v3_tsm_key %},tlstcp:{{ v3_tsm_port }},dtlsudp::{{ v3_tsm_port }}{% endif %} # SNMP communities -{% if communities -%} -{% for c in communities %} -{% if c.network -%} -{% for network in c.network %} +{%- for c in communities %} + +{%- if c.network_v4 %} +{%- for network in c.network_v4 %} {{ c.authorization }}community {{ c.name }} {{ network }} -{{ c.authorization }}community6 {{ c.name }} {{ network }} -{% endfor %} -{% else %} +{%- endfor %} +{%- elif not c.has_source %} {{ c.authorization }}community {{ c.name }} +{%- endif %} + +{%- if c.network_v6 %} +{%- for network in c.network_v6 %} +{{ c.authorization }}community6 {{ c.name }} {{ network }} +{%- endfor %} +{%- elif not c.has_source %} {{ c.authorization }}community6 {{ c.name }} -{% endif %} -{% endfor %} -{% endif %} +{%- endif %} + +{%- endfor %} -{% if contact -%} +{% if contact %} # system contact information SysContact {{ contact }} -{% endif %} +{%- endif %} -{% if location -%} +{% if location %} # system location information SysLocation {{ location }} -{% endif %} +{%- endif %} {% if smux_peers -%} # additional smux peers -{% for sp in smux_peers %} +{%- for sp in smux_peers %} smuxpeer {{ sp }} -{% endfor %} -{% endif %} +{%- endfor %} +{%- endif %} {% if trap_targets -%} # if there is a problem - tell someone! -{% for t in trap_targets %} +{%- for t in trap_targets %} trap2sink {{ t.target }}{% if t.port -%}:{{ t.port }}{% endif %} {{ t.community }} -{% endfor %} -{% endif %} +{%- endfor %} +{%- endif %} +{%- if v3_enabled %} # # SNMPv3 stuff goes here # -{% if v3_enabled %} - # views -{% if v3_views -%} -{% for v in v3_views %} -{% for oid in v.oids %} +{%- for v in v3_views %} +{%- for oid in v.oids %} view {{ v.name }} included .{{ oid.oid }} -{% endfor %} -{% endfor %} -{% endif %} +{%- endfor %} +{%- endfor %} # access # context sec.model sec.level match read write notif -{% if v3_groups -%} -{% for g in v3_groups %} -{% if g.mode == 'ro' %} -access {{ g.name }} "" usm {{ g.seclevel }} exact {{ g.view }} none none -access {{ g.name }} "" tsm {{ g.seclevel }} exact {{ g.view }} none none -{% elif g.mode == 'rw' %} -access {{ g.name }} "" usm {{ g.seclevel }} exact {{ g.view }} {{ g.view }} none -access {{ g.name }} "" tsm {{ g.seclevel }} exact {{ g.view }} {{ g.view }} none -{% endif %} -{% endfor -%} -{% endif %} +{%- for g in v3_groups %} +access {{ g.name }} "" usm {{ g.seclevel }} exact {{ g.view }} {% if g.mode == 'ro' %}none{% else %}{{ g.view }}{% endif %} none +access {{ g.name }} "" tsm {{ g.seclevel }} exact {{ g.view }} {% if g.mode == 'ro' %}none{% else %}{{ g.view }}{% endif %} none +{%- endfor %} # trap-target -{% if v3_traps -%} -{% for t in v3_traps %} +{%- for t in v3_traps %} trapsess -v 3 {{ '-Ci' if t.type == 'inform' }} -e {{ t.engineID }} -u {{ t.secName }} -l {{ t.secLevel }} -a {{ t.authProtocol }} {% if t.authPassword %}-A {{ t.authPassword }}{% elif t.authMasterKey %}-3m {{ t.authMasterKey }}{% endif %} -x {{ t.privProtocol }} {% if t.privPassword %}-X {{ t.privPassword }}{% elif t.privMasterKey %}-3M {{ t.privMasterKey }}{% endif %} {{ t.ipProto }}:{{ t.ipAddr }}:{{ t.ipPort }} -{% endfor -%} -{% endif %} +{%- endfor %} # group -{% if v3_users -%} -{% for u in v3_users %} +{%- for u in v3_users %} group {{ u.group }} usm {{ u.name }} group {{ u.group }} tsm {{ u.name }} {% endfor %} -{% endif %} +{%- endif %} +""" -{% endif %} +# SNMP template (/etc/default/snmpd) - be careful if you edit the template. +init_config_tmpl = """ +### Autogenerated by snmp.py ### +# This file controls the activity of snmpd + +# snmpd control (yes means start daemon). +SNMPDRUN=yes +# snmpd options (use syslog, close stdin/out/err). +SNMPDOPTS='-LSed -u snmp -g snmp -p /run/snmpd.pid' """ default_config_data = { 'listen_on': [], + 'listen_address': [], 'communities': [], 'smux_peers': [], 'location' : '', @@ -271,14 +268,32 @@ def get_config(): community = { 'name': name, 'authorization': 'ro', - 'network': [] + 'network_v4': [], + 'network_v6': [], + 'has_source' : False } if conf.exists('community {0} authorization'.format(name)): community['authorization'] = conf.return_value('community {0} authorization'.format(name)) + # Subnet of SNMP client(s) allowed to contact system if conf.exists('community {0} network'.format(name)): - community['network'] = conf.return_values('community {0} network'.format(name)) + for addr in conf.return_values('community {0} network'.format(name)): + if vyos.validate.is_ipv4(addr): + community['network_v4'].append(addr) + else: + community['network_v6'].append(addr) + + # IP address of SNMP client allowed to contact system + if conf.exists('community {0} client'.format(name)): + for addr in conf.return_values('community {0} client'.format(name)): + if vyos.validate.is_ipv4(addr): + community['network_v4'].append(addr) + else: + community['network_v6'].append(addr) + + if (len(community['network_v4']) > 0) or (len(community['network_v6']) > 0): + community['has_source'] = True snmp['communities'].append(community) @@ -290,21 +305,20 @@ def get_config(): if conf.exists('listen-address'): for addr in conf.list_nodes('listen-address'): - listen = '' port = '161' if conf.exists('listen-address {0} port'.format(addr)): port = conf.return_value('listen-address {0} port'.format(addr)) - if ipaddress.ip_address(addr).version == 4: - # udp:127.0.0.1:161 - listen = 'udp:' + addr + ':' + port - elif ipaddress.ip_address(addr).version == 6: - # udp6:[::1]:161 - listen = 'udp6:' + '[' + addr + ']' + ':' + port - else: - raise ConfigError('Invalid IP address version') + snmp['listen_address'].append((addr, port)) - snmp['listen_on'].append(listen) + # Always listen on localhost if an explicit address has been configured + # This is a safety measure to not end up with invalid listen addresses + # that are not configured on this system. See https://phabricator.vyos.net/T850 + if not '127.0.0.1' in conf.list_nodes('listen-address'): + snmp['listen_address'].append(('127.0.0.1', '161')) + + if not '::1' in conf.list_nodes('listen-address'): + snmp['listen_address'].append(('::1', '161')) if conf.exists('location'): snmp['location'] = conf.return_value('location') @@ -579,6 +593,24 @@ def verify(snmp): if not os.path.isfile('/config/snmp/tls/certs/' + snmp['v3_tsm_key']): raise ConfigError('TSM key must be fingerprint or filename in "/config/snmp/tls/certs/" folder') + for listen in snmp['listen_address']: + addr = listen[0] + port = listen[1] + + if vyos.validate.is_ipv4(addr): + # example: udp:127.0.0.1:161 + listen = 'udp:' + addr + ':' + port + else: + # example: udp6:[::1]:161 + listen = 'udp6:' + '[' + addr + ']' + ':' + port + + # We only wan't to configure addresses that exist on the system. + # Hint the user if they don't exist + if vyos.validate.is_addr_assigned(addr): + snmp['listen_on'].append(listen) + else: + print('WARNING: SNMP listen address {0} not configured!'.format(addr)) + if 'v3_groups' in snmp.keys(): for group in snmp['v3_groups']: # @@ -641,48 +673,45 @@ def verify(snmp): # Group must exist prior to mapping it into a group # seclevel will be extracted from group # - error = True if user['group']: + error = True if 'v3_groups' in snmp.keys(): for group in snmp['v3_groups']: if group['name'] == user['group']: seclevel = group['seclevel'] error = False - if error: - raise ConfigError('You must create group "{0}" first'.format(user['group'])) + if error: + raise ConfigError('You must create group "{0}" first'.format(user['group'])) # Depending on the configured security level # the user has to provide additional info - if seclevel in ('auth', 'priv'): - if user['authPassword'] and user['authMasterKey']: - raise ConfigError('Can not mix "encrypted-key" and "plaintext-key" for user auth') + if user['authPassword'] and user['authMasterKey']: + raise ConfigError('Can not mix "encrypted-key" and "plaintext-key" for user auth') - if (not user['authPassword'] and not user['authMasterKey']): - raise ConfigError('Must specify encrypted-key or plaintext-key for user auth') + if (not user['authPassword'] and not user['authMasterKey']): + raise ConfigError('Must specify encrypted-key or plaintext-key for user auth') - # seclevel 'priv' is more restrictive - if seclevel in ('priv'): - if user['privPassword'] and user['privMasterKey']: - raise ConfigError('Can not mix "encrypted-key" and "plaintext-key" for user privacy') + if user['privPassword'] and user['privMasterKey']: + raise ConfigError('Can not mix "encrypted-key" and "plaintext-key" for user privacy') - if user['privPassword'] == '' and user['privMasterKey'] == '': - raise ConfigError('Must specify encrypted-key or plaintext-key for user privacy') + if user['privPassword'] == '' and user['privMasterKey'] == '': + raise ConfigError('Must specify encrypted-key or plaintext-key for user privacy') - if user['privMasterKey'] and user['engineID'] == '': - raise ConfigError('Can not have "encrypted-key" without engineid') + if user['privMasterKey'] and user['engineID'] == '': + raise ConfigError('Can not have "encrypted-key" without engineid') - if user['authPassword'] == '' and user['authMasterKey'] == '' and user['privTsmKey'] == '': - raise ConfigError('Must specify auth or tsm-key for user auth') + if user['authPassword'] == '' and user['authMasterKey'] == '' and user['privTsmKey'] == '': + raise ConfigError('Must specify auth or tsm-key for user auth') - if user['mode'] == '': - raise ConfigError('Must specify user mode ro/rw') + if user['mode'] == '': + raise ConfigError('Must specify user mode ro/rw') - if user['privTsmKey']: - if not tsmKeyPattern.match(snmp['v3_tsm_key']): - if not os.path.isfile('/etc/snmp/tls/certs/' + snmp['v3_tsm_key']): - if not os.path.isfile('/config/snmp/tls/certs/' + snmp['v3_tsm_key']): - raise ConfigError('User TSM key must be fingerprint or filename in "/config/snmp/tls/certs/" folder') + if user['privTsmKey']: + if not tsmKeyPattern.match(snmp['v3_tsm_key']): + if not os.path.isfile('/etc/snmp/tls/certs/' + snmp['v3_tsm_key']): + if not os.path.isfile('/config/snmp/tls/certs/' + snmp['v3_tsm_key']): + raise ConfigError('User TSM key must be fingerprint or filename in "/config/snmp/tls/certs/" folder') if 'v3_views' in snmp.keys(): for view in snmp['v3_views']: @@ -705,29 +734,35 @@ def generate(snmp): return None # Write client config file - tmpl = jinja2.Template(client_config_tmpl, trim_blocks=True) + tmpl = jinja2.Template(client_config_tmpl) config_text = tmpl.render(snmp) with open(config_file_client, 'w') as f: f.write(config_text) # Write server config file - tmpl = jinja2.Template(daemon_config_tmpl, trim_blocks=True) + tmpl = jinja2.Template(daemon_config_tmpl) config_text = tmpl.render(snmp) with open(config_file_daemon, 'w') as f: f.write(config_text) # Write access rights config file - tmpl = jinja2.Template(access_config_tmpl, trim_blocks=True) + tmpl = jinja2.Template(access_config_tmpl) config_text = tmpl.render(snmp) with open(config_file_access, 'w') as f: f.write(config_text) # Write access rights config file - tmpl = jinja2.Template(user_config_tmpl, trim_blocks=True) + tmpl = jinja2.Template(user_config_tmpl) config_text = tmpl.render(snmp) with open(config_file_user, 'w') as f: f.write(config_text) + # Write init config file + tmpl = jinja2.Template(init_config_tmpl) + config_text = tmpl.render(snmp) + with open(config_file_init, 'w') as f: + f.write(config_text) + return None def apply(snmp): @@ -761,9 +796,20 @@ def apply(snmp): # start SNMP daemon os.system("sudo systemctl restart snmpd.service") - # the passwords are not available immediately so this is a workaround - # and should be changed to polling - time.sleep(2) + # Passwords are not available immediately in the configuration file, + # after daemon startup - we wait until they have been processed by + # snmpd, which we see when a magic line appears in this file. + snmpReady = False + while not snmpReady: + while not os.path.exists(config_file_user): + time.sleep(1) + + with open(config_file_user, 'r') as f: + for line in f: + # Search for our magic string inside the file + if '**** DO NOT EDIT THIS FILE ****' in line: + snmpReady = True + break # Back in the Perl days the configuration was re-read and any # plaintext password inside the configuration was replaced by @@ -795,6 +841,9 @@ def apply(snmp): os.system('vyos_libexec_dir=/usr/libexec/vyos /opt/vyatta/sbin/my_delete service snmp v3 user "{0}" auth plaintext-key > /dev/null'.format(cfg['user'])) os.system('vyos_libexec_dir=/usr/libexec/vyos /opt/vyatta/sbin/my_delete service snmp v3 user "{0}" privacy plaintext-key > /dev/null'.format(cfg['user'])) + # Enable AgentX in FRR + os.system('vtysh -c "configure terminal" -c "agentx" >/dev/null') + return None if __name__ == '__main__': diff --git a/src/conf_mode/ssh.py b/src/conf_mode/ssh.py index f1ac19473..2a5cba99a 100755 --- a/src/conf_mode/ssh.py +++ b/src/conf_mode/ssh.py @@ -67,82 +67,103 @@ UseDNS {{ host_validation }} # Specifies the port number that sshd listens on. The default is 22. # Multiple options of this type are permitted. +{% if mport|length != 0 %} +{% for p in mport %} +Port {{ p }} +{% endfor %} +{% else %} Port {{ port }} +{% endif %} # Gives the verbosity level that is used when logging messages from sshd LogLevel {{ log_level }} # Specifies whether root can log in using ssh -PermitRootLogin {{ allow_root }} +PermitRootLogin no # Specifies whether password authentication is allowed PasswordAuthentication {{ password_authentication }} -{% if listen_on -%} +{% if listen_on %} # Specifies the local addresses sshd should listen on -{% for a in listen_on -%} +{% for a in listen_on %} ListenAddress {{ a }} -{% endfor -%} +{% endfor %} +{{ "\n" }} {% endif %} -{% if ciphers -%} +{%- if ciphers %} # Specifies the ciphers allowed. Multiple ciphers must be comma-separated. # # NOTE: As of now, there is no 'multi' node for 'ciphers', thus we have only one :/ Ciphers {{ ciphers | join(",") }} +{{ "\n" }} {% endif %} -{% if mac -%} +{%- if mac %} # Specifies the available MAC (message authentication code) algorithms. The MAC # algorithm is used for data integrity protection. Multiple algorithms must be # comma-separated. # # NOTE: As of now, there is no 'multi' node for 'mac', thus we have only one :/ MACs {{ mac | join(",") }} +{{ "\n" }} {% endif %} -{% if key_exchange -%} +{%- if key_exchange %} # Specifies the available KEX (Key Exchange) algorithms. Multiple algorithms must # be comma-separated. # # NOTE: As of now, there is no 'multi' node for 'key-exchange', thus we have only one :/ KexAlgorithms {{ key_exchange | join(",") }} +{{ "\n" }} {% endif %} -{% if allow_users -%} +{%- if allow_users %} # This keyword can be followed by a list of user name patterns, separated by spaces. # If specified, login is allowed only for user names that match one of the patterns. # Only user names are valid, a numerical user ID is not recognized. AllowUsers {{ allow_users | join(" ") }} +{{ "\n" }} {% endif %} -{% if allow_groups -%} +{%- if allow_groups %} # This keyword can be followed by a list of group name patterns, separated by spaces. # If specified, login is allowed only for users whose primary group or supplementary # group list matches one of the patterns. Only group names are valid, a numerical group # ID is not recognized. AllowGroups {{ allow_groups | join(" ") }} +{{ "\n" }} {% endif %} -{% if deny_users -%} +{%- if deny_users %} # This keyword can be followed by a list of user name patterns, separated by spaces. # Login is disallowed for user names that match one of the patterns. Only user names # are valid, a numerical user ID is not recognized. DenyUsers {{ deny_users | join(" ") }} +{{ "\n" }} {% endif %} -{% if deny_groups -%} +{%- if deny_groups %} # This keyword can be followed by a list of group name patterns, separated by spaces. # Login is disallowed for users whose primary group or supplementary group list matches # one of the patterns. Only group names are valid, a numerical group ID is not recognized. DenyGroups {{ deny_groups | join(" ") }} +{{ "\n" }} +{% endif %} + +{%- if client_keepalive %} +# Sets a timeout interval in seconds after which if no data has been received from the client, +# sshd will send a message through the encrypted channel to request a response from the client. +# The default is 0, indicating that these messages will not be sent to the client. +# This option applies to protocol version 2 only. +ClientAliveInterval {{ client_keepalive }} {% endif %} """ default_config_data = { 'port' : '22', 'log_level': 'INFO', - 'allow_root': 'no', 'password_authentication': 'yes', 'host_validation': 'yes' } @@ -171,9 +192,6 @@ def get_config(): deny_groups = conf.return_values('access-control deny group') ssh['deny_groups'] = deny_groups - if conf.exists('allow-root'): - ssh['allow-root'] = 'yes' - if conf.exists('ciphers'): ciphers = conf.return_values('ciphers') ssh['ciphers'] = ciphers @@ -208,8 +226,17 @@ def get_config(): ssh['mac'] = mac if conf.exists('port'): - port = conf.return_value('port') - ssh['port'] = port + ports = conf.return_values('port') + mport = [] + + for prt in ports: + mport.append(prt) + + ssh['mport'] = mport + + if conf.exists('client-keepalive-interval'): + client_keepalive = conf.return_value('client-keepalive-interval') + ssh['client_keepalive'] = client_keepalive return ssh @@ -228,7 +255,7 @@ def generate(ssh): if ssh is None: return None - tmpl = jinja2.Template(config_tmpl) + tmpl = jinja2.Template(config_tmpl, trim_blocks=True) config_text = tmpl.render(ssh) with open(config_file, 'w') as f: f.write(config_text) @@ -236,10 +263,10 @@ def generate(ssh): def apply(ssh): if ssh is not None and 'port' in ssh.keys(): - os.system("sudo systemctl restart ssh") + os.system("sudo systemctl restart ssh.service") else: # SSH access is removed in the commit - os.system("sudo systemctl stop ssh") + os.system("sudo systemctl stop ssh.service") os.unlink(config_file) return None diff --git a/src/conf_mode/syslog.py b/src/conf_mode/syslog.py index 5dfc6f390..d600146f3 100755 --- a/src/conf_mode/syslog.py +++ b/src/conf_mode/syslog.py @@ -30,6 +30,15 @@ from vyos import ConfigError configs = ''' ## generated by syslog.py ## ## file based logging +{% if files['global']['marker'] -%} +$ModLoad immark +{% if files['global']['marker-interval'] %} +$MarkMessagePeriod {{files['global']['marker-interval']}} +{% endif %} +{% endif -%} +{% if files['global']['preserver_fqdn'] -%} +$PreserveFQDN on +{% endif -%} {% for file in files %} $outchannel {{file}},{{files[file]['log-file']}},{{files[file]['max-size']}},{{files[file]['action-on-max-size']}} {{files[file]['selectors']}} :omfile:${{file}} @@ -80,10 +89,10 @@ def get_config(): c.set_level('system syslog') config_data = { - 'files' : {}, + 'files' : {}, 'console' : {}, - 'hosts' : {}, - 'user' : {} + 'hosts' : {}, + 'user' : {} } ##### @@ -93,21 +102,28 @@ def get_config(): config_data['files'].update( { 'global' : { - 'log-file' : '/var/log/vyos-rsyslog', - 'max-size' : 262144, + 'log-file' : '/var/log/messages', + 'max-size' : 262144, 'action-on-max-size' : '/usr/sbin/logrotate /etc/logrotate.d/vyos-rsyslog', - 'selectors' : '*.notice;local7.debug', - 'max-files' : '5' + 'selectors' : '*.notice;local7.debug', + 'max-files' : '5', + 'preserver_fqdn' : False } } ) + if c.exists('global marker'): + config_data['files']['global']['marker'] = True + if c.exists('global marker interval'): + config_data['files']['global']['marker-interval'] = c.return_value('global marker interval') if c.exists('global facility'): config_data['files']['global']['selectors'] = generate_selectors(c, 'global facility') if c.exists('global archive size'): config_data['files']['global']['max-size'] = int(c.return_value('global archive size'))* 1024 if c.exists('global archive files'): config_data['files']['global']['max-files'] = c.return_value('global archive files') + if c.exists('global preserve-fqdn'): + config_data['files']['global']['preserver_fqdn'] = True ### # set system syslog file @@ -215,26 +231,41 @@ def generate_selectors(c, config_node): return selectors def generate(c): + if c == None: + return None + tmpl = jinja2.Template(configs, trim_blocks=True) config_text = tmpl.render(c) - #print (config_text) with open('/etc/rsyslog.d/vyos-rsyslog.conf', 'w') as f: f.write(config_text) ## eventually write for each file its own logrotate file, since size is defined it shouldn't matter tmpl = jinja2.Template(logrotate_configs, trim_blocks=True) config_text = tmpl.render(c) - #print (config_text) with open('/etc/logrotate.d/vyos-rsyslog', 'w') as f: f.write(config_text) def verify(c): if c == None: return None + # + # /etc/rsyslog.conf is generated somewhere and copied over the original (exists in /opt/vyatta/etc/rsyslog.conf) + # it interferes with the global logging, to make sure we are using a single base, template is enforced here + # + if not os.path.islink('/etc/rsyslog.conf'): + os.remove('/etc/rsyslog.conf') + os.symlink('/usr/share/vyos/templates/rsyslog/rsyslog.conf', '/etc/rsyslog.conf') + + # /var/log/vyos-rsyslog were the old files, we may want to clean those up, but currently there + # is a chance that someone still needs it, so I don't automatically remove them + + if c == None: + return None fac = ['*','auth','authpriv','cron','daemon','kern','lpr','mail','mark','news','protocols','security',\ 'syslog','user','uucp','local0','local1','local2','local3','local4','local5','local6','local7'] lvl = ['emerg','alert','crit','err','warning','notice','info','debug','*'] + for conf in c: if c[conf]: for item in c[conf]: @@ -250,10 +281,16 @@ def verify(c): def apply(c): ### vyatta-log.conf is being generated somewhere ### this is just a quick hack to remove the old configfile - - if os.path.exists('/etc/rsyslog.d/vyatta-log.conf'): - os.remove('/etc/rsyslog.d/vyatta-log.conf') - os.system("sudo systemctl restart rsyslog >/dev/null") + + if c == None: + ### systemd restarts it, using kill + #os.system("sudo systemctl stop rsyslog >/dev/null 2>&1") + print ("systemd sends messages to rsyslog, rsyslog won't be stopped") + else: + if os.path.exists('/etc/rsyslog.d/vyatta-log.conf'): + os.remove('/etc/rsyslog.d/vyatta-log.conf') + + os.system("sudo systemctl restart rsyslog >/dev/null") if __name__ == '__main__': try: diff --git a/src/conf_mode/task_scheduler.py b/src/conf_mode/task_scheduler.py index 285afe2b5..b171e9576 100755 --- a/src/conf_mode/task_scheduler.py +++ b/src/conf_mode/task_scheduler.py @@ -49,7 +49,7 @@ def make_command(executable, arguments): if arguments: return("sg vyattacfg \"{0} {1}\"".format(executable, arguments)) else: - return(executable) + return("sg vyattacfg \"{0}\"".format(executable)) def get_config(): conf = Config() diff --git a/src/conf_mode/tftp_server.py b/src/conf_mode/tftp_server.py new file mode 100755 index 000000000..ff7cad0c9 --- /dev/null +++ b/src/conf_mode/tftp_server.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import stat +import pwd +import copy +import glob + +import jinja2 +import vyos.validate + +from vyos.config import Config +from vyos import ConfigError + +config_file = r'/etc/default/tftpd' + +# Please be careful if you edit the template. +config_tmpl = """ +### Autogenerated by tftp_server.py ### +DAEMON_ARGS="--listen --user tftp --address {% for a in listen-%}{{ a }}{% endfor %}{% if allow_upload %} --create --umask 000{% endif %} --secure {{ directory }}" + + +""" + +default_config_data = { + 'directory': '', + 'allow_upload': False, + 'port': '69', + 'listen': [] +} + +def get_config(): + tftpd = copy.deepcopy(default_config_data) + conf = Config() + if not conf.exists('service tftp-server'): + return None + else: + conf.set_level('service tftp-server') + + if conf.exists('directory'): + tftpd['directory'] = conf.return_value('directory') + + if conf.exists('allow-upload'): + tftpd['allow_upload'] = True + + if conf.exists('port'): + tftpd['port'] = conf.return_value('port') + + tftpd['listen'] = conf.return_values('listen-address') + + return tftpd + +def verify(tftpd): + # bail out early - looks like removal from running config + if tftpd is None: + return None + + # Configuring allowed clients without a server makes no sense + if not tftpd['directory']: + raise ConfigError('TFTP root directory must be configured!') + + if not tftpd['listen']: + raise ConfigError('TFTP server listen address must be configured!') + + for addr in tftpd['listen']: + if not vyos.validate.is_addr_assigned(addr): + print('WARNING: TFTP server listen address {0} not assigned to any interface!'.format(addr)) + + return None + +def generate(tftpd): + # cleanup any available configuration file + # files will be recreated on demand + for i in glob.glob(config_file + '*'): + os.unlink(i) + + # bail out early - looks like removal from running config + if tftpd is None: + return None + + idx = 0 + for listen in tftpd['listen']: + config = copy.deepcopy(tftpd) + if vyos.validate.is_ipv4(listen): + config['listen'] = [listen + ":" + tftpd['port'] + " -4"] + else: + config['listen'] = ["[" + listen + "]" + tftpd['port'] + " -6"] + + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render(config) + file = config_file + str(idx) + with open(file, 'w') as f: + f.write(config_text) + + idx = idx + 1 + + return None + +def apply(tftpd): + # stop all services first - then we will decide + os.system('sudo systemctl stop tftpd@{0..20}') + + # bail out early - e.g. service deletion + if tftpd is None: + return None + + tftp_root = tftpd['directory'] + if not os.path.exists(tftp_root): + os.makedirs(tftp_root) + os.chmod(tftp_root, stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR|stat.S_IRGRP|stat.S_IXGRP|stat.S_IROTH|stat.S_IXOTH) + + # get UNIX uid for user 'tftp' + tftp_uid = pwd.getpwnam('tftp').pw_uid + tftp_gid = pwd.getpwnam('tftp').pw_gid + + # get UNIX uid for tftproot directory + dir_uid = os.stat(tftp_root).st_uid + dir_gid = os.stat(tftp_root).st_gid + + # adjust uid/gid of tftproot directory if files don't belong to user tftp + if (tftp_uid != dir_uid) or (tftp_gid != dir_gid): + os.chown(tftp_root, tftp_uid, tftp_gid) + + idx = 0 + for listen in tftpd['listen']: + os.system('sudo systemctl restart tftpd@{0}.service'.format(idx)) + idx = idx + 1 + + 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/vrrp.py b/src/conf_mode/vrrp.py index d21e3ef40..bc833b63f 100755 --- a/src/conf_mode/vrrp.py +++ b/src/conf_mode/vrrp.py @@ -76,7 +76,10 @@ vrrp_instance {{ group.name }} { {%- endif %} {% endif -%} - {% if group.use_vmac -%} + {% if group.use_vmac and group.peer_address -%} + use_vmac {{group.interface}}v{{group.vrid}} + vmac_xmit_base + {% elif group.use_vmac -%} use_vmac {{group.interface}}v{{group.vrid}} {% endif -%} @@ -183,7 +186,7 @@ def get_config(): if not group["priority"]: group["priority"] = 100 if not group["preempt_delay"]: - group["preempt_delay"] = 5 * 60 + group["preempt_delay"] = 0 if not group["health_check_interval"]: group["health_check_interval"] = 60 if not group["health_check_count"]: @@ -273,7 +276,7 @@ def verify(data): count = len(_groups) - 1 index = 0 while (index < count): - if _groups[index]["vrid"] == _groups[index + 1]["vrid"]: + if (_groups[index]["vrid"] == _groups[index + 1]["vrid"]) and (_groups[index]["interface"] == _groups[index + 1]["interface"]): raise ConfigError("VRID {0} is used in groups {1} and {2} that both use interface {3}. Groups on the same interface must use different VRIDs".format( _groups[index]["vrid"], _groups[index]["name"], _groups[index + 1]["name"], _groups[index]["interface"])) else: diff --git a/src/conf_mode/wireguard.py b/src/conf_mode/wireguard.py new file mode 100755 index 000000000..e893dba47 --- /dev/null +++ b/src/conf_mode/wireguard.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import re +import syslog as sl +import subprocess + +from vyos.config import Config +from vyos import ConfigError + +dir = r'/config/auth/wireguard' +pk = dir + '/private.key' +pub = dir + '/public.key' +psk_file = r'/tmp/psk' + +def check_kmod(): + if not os.path.exists('/sys/module/wireguard'): + sl.syslog(sl.LOG_NOTICE, "loading wirguard kmod") + if os.system('sudo modprobe wireguard') != 0: + sl.syslog(sl.LOG_NOTICE, "modprobe wireguard failed") + raise ConfigError("modprobe wireguard failed") + +def get_config(): + c = Config() + if not c.exists('interfaces wireguard'): + return None + + c.set_level('interfaces') + intfcs = c.list_nodes('wireguard') + intfcs_eff = c.list_effective_nodes('wireguard') + new_lst = list(set(intfcs) - set(intfcs_eff)) + del_lst = list(set(intfcs_eff) - set(intfcs)) + + config_data = { + 'interfaces' : {} + } + ### setting defaults and determine status of the config + for intfc in intfcs: + cnf = 'wireguard ' + intfc + # default data struct + config_data['interfaces'].update( + { + intfc : { + 'addr' : '', + 'descr' : intfc, ## snmp ifAlias + 'lport' : '', + 'status' : 'exists', + 'state' : 'enabled', + 'mtu' : '1420', + 'peer' : {} + } + } + ) + + ### determine status either delete or create + for i in new_lst: + config_data['interfaces'][i]['status'] = 'create' + + for i in del_lst: + config_data['interfaces'].update( + { + i : { + 'status': 'delete' + } + } + ) + + ### based on the status, setup conf values + for intfc in intfcs: + cnf = 'wireguard ' + intfc + if config_data['interfaces'][intfc]['status'] != 'delete': + ### addresses + if c.exists(cnf + ' address'): + config_data['interfaces'][intfc]['addr'] = c.return_values(cnf + ' address') + ### interface up/down + if c.exists(cnf + ' disable'): + config_data['interfaces'][intfc]['state'] = 'disable' + ### listen port + if c.exists(cnf + ' port'): + config_data['interfaces'][intfc]['lport'] = c.return_value(cnf + ' port') + ### description + if c.exists(cnf + ' description'): + config_data['interfaces'][intfc]['descr'] = c.return_value(cnf + ' description') + ### mtu + if c.exists(cnf + ' mtu'): + config_data['interfaces'][intfc]['mtu'] = c.return_value(cnf + ' mtu') + ### peers + if c.exists(cnf + ' peer'): + for p in c.list_nodes(cnf + ' peer'): + if not c.exists(cnf + ' peer ' + p + ' disable'): + config_data['interfaces'][intfc]['peer'].update( + { + p : { + 'allowed-ips' : [], + 'endpoint' : '', + 'pubkey' : '' + } + } + ) + if c.exists(cnf + ' peer ' + p + ' pubkey'): + config_data['interfaces'][intfc]['peer'][p]['pubkey'] = c.return_value(cnf + ' peer ' + p + ' pubkey') + if c.exists(cnf + ' peer ' + p + ' allowed-ips'): + config_data['interfaces'][intfc]['peer'][p]['allowed-ips'] = c.return_values(cnf + ' peer ' + p + ' allowed-ips') + if c.exists(cnf + ' peer ' + p + ' endpoint'): + config_data['interfaces'][intfc]['peer'][p]['endpoint'] = c.return_value(cnf + ' peer ' + p + ' endpoint') + if c.exists(cnf + ' peer ' + p + ' persistent-keepalive'): + config_data['interfaces'][intfc]['peer'][p]['persistent-keepalive'] = c.return_value(cnf + ' peer ' + p + ' persistent-keepalive') + if c.exists(cnf + ' peer ' + p + ' preshared-key'): + config_data['interfaces'][intfc]['peer'][p]['psk'] = c.return_value(cnf + ' peer ' + p + ' preshared-key') + + return config_data + +def verify(c): + if not c: + return None + + for i in c['interfaces']: + if c['interfaces'][i]['status'] != 'delete': + if not c['interfaces'][i]['addr']: + raise ConfigError("address required for interface " + i) + if not c['interfaces'][i]['peer']: + raise ConfigError("peer required on interface " + i) + + for p in c['interfaces'][i]['peer']: + if not c['interfaces'][i]['peer'][p]['allowed-ips']: + raise ConfigError("allowed-ips required on interface " + i + " for peer " + p) + if not c['interfaces'][i]['peer'][p]['pubkey']: + raise ConfigError("pubkey from your peer is mandatory on " + i + " for peer " + p) + + +def apply(c): + ### no wg config left, delete all wireguard devices on the os + if not c: + net_devs = os.listdir('/sys/class/net/') + for dev in net_devs: + if os.path.isdir('/sys/class/net/' + dev): + buf = open('/sys/class/net/' + dev + '/uevent', 'r').read() + if re.search("DEVTYPE=wireguard", buf, re.I|re.M): + wg_intf = re.sub("INTERFACE=", "", re.search("INTERFACE=.*", buf, re.I|re.M).group(0)) + sl.syslog(sl.LOG_NOTICE, "removing interface " + wg_intf) + subprocess.call(['ip l d dev ' + wg_intf + ' >/dev/null'], shell=True) + return None + + ### + ## find the diffs between effective config an new config + ### + c_eff = Config() + c_eff.set_level('interfaces wireguard') + + ### link status up/down aka interface disable + + for intf in c['interfaces']: + if not c['interfaces'][intf]['status'] == 'delete': + if c['interfaces'][intf]['state'] == 'disable': + sl.syslog(sl.LOG_NOTICE, "disable interface " + intf) + subprocess.call(['ip l s dev ' + intf + ' down ' + ' &>/dev/null'], shell=True) + else: + sl.syslog(sl.LOG_NOTICE, "enable interface " + intf) + subprocess.call(['ip l s dev ' + intf + ' up ' + ' &>/dev/null'], shell=True) + + ### deletion of a specific interface + for intf in c['interfaces']: + if c['interfaces'][intf]['status'] == 'delete': + sl.syslog(sl.LOG_NOTICE, "removing interface " + intf) + subprocess.call(['ip l d dev ' + intf + ' &>/dev/null'], shell=True) + + ### peer deletion + peer_eff = c_eff.list_effective_nodes( intf + ' peer') + peer_cnf = [] + try: + for p in c['interfaces'][intf]['peer']: + peer_cnf.append(p) + except KeyError: + pass + + peer_rem = list(set(peer_eff) - set(peer_cnf)) + for p in peer_rem: + pkey = c_eff.return_effective_value( intf + ' peer ' + p +' pubkey') + remove_peer(intf, pkey) + + ### peer pubkey update + ### wg identifies peers by its pubky, so we have to remove the peer first + ### it will recreated it then below with the new key from the cli config + for p in peer_eff: + if p in peer_cnf: + ekey = c_eff.return_effective_value( intf + ' peer ' + p +' pubkey') + nkey = c['interfaces'][intf]['peer'][p]['pubkey'] + if nkey != ekey: + sl.syslog(sl.LOG_NOTICE, "peer " + p + ' changed pubkey from ' + ekey + 'to key ' + nkey + ' on interface ' + intf) + remove_peer(intf, ekey) + + ### new config + if c['interfaces'][intf]['status'] == 'create': + if not os.path.exists(pk): + raise ConfigError("No keys found, generate them by executing: \'run generate wireguard keypair\'") + + subprocess.call(['ip l a dev ' + intf + ' type wireguard 2>/dev/null'], shell=True) + for addr in c['interfaces'][intf]['addr']: + add_addr(intf, addr) + + subprocess.call(['ip l set up dev ' + intf + ' mtu ' + c['interfaces'][intf]['mtu'] + ' &>/dev/null'], shell=True) + configure_interface(c, intf) + + ### config updates + if c['interfaces'][intf]['status'] == 'exists': + ### IP address change + addr_eff = re.sub("\'", "", c_eff.return_effective_values(intf + ' address')).split() + addr_rem = list(set(addr_eff) - set(c['interfaces'][intf]['addr'])) + addr_add = list(set(c['interfaces'][intf]['addr']) - set(addr_eff)) + + if len(addr_rem) != 0: + for addr in addr_rem: + del_addr(intf, addr) + + if len(addr_add) != 0: + for addr in addr_add: + add_addr(intf, addr) + + ## mtu update + mtu = c['interfaces'][intf]['mtu'] + if mtu != 1420: + sl.syslog(sl.LOG_NOTICE, "setting mtu to " + mtu + " on " + intf) + subprocess.call(['ip l set mtu ' + mtu + ' dev ' + intf + ' &>/dev/null'], shell=True) + + + ### persistent-keepalive + for p in c['interfaces'][intf]['peer']: + val_eff = "" + val = "" + + try: + val = c['interfaces'][intf]['peer'][p]['persistent-keepalive'] + except KeyError: + pass + + if c_eff.exists_effective(intf + ' peer ' + p + ' persistent-keepalive'): + val_eff = c_eff.return_effective_value(intf + ' peer ' + p + ' persistent-keepalive') + + ### disable keepalive + if val_eff and not val: + c['interfaces'][intf]['peer'][p]['persistent-keepalive'] = 0 + + ### set new keepalive value + if not val_eff and val: + c['interfaces'][intf]['peer'][p]['persistent-keepalive'] = val + + ## wg command call + configure_interface(c, intf) + + ### ifalias for snmp from description + if c['interfaces'][intf]['status'] != 'delete': + descr_eff = c_eff.return_effective_value(intf + ' description') + cnf_descr = c['interfaces'][intf]['descr'] + if descr_eff != cnf_descr: + with open('/sys/class/net/' + str(intf) + '/ifalias', 'w') as fh: + fh.write(str(cnf_descr)) + +def configure_interface(c, intf): + for p in c['interfaces'][intf]['peer']: + ## config init for wg call + wg_config = { + 'interface' : intf, + 'port' : 0, + 'private-key' : pk, + 'pubkey' : '', + 'psk' : '/dev/null', + 'allowed-ips' : [], + 'fwmark' : 0x00, + 'endpoint' : None, + 'keepalive' : 0 + } + + ## mandatory settings + wg_config['pubkey'] = c['interfaces'][intf]['peer'][p]['pubkey'] + wg_config['allowed-ips'] = c['interfaces'][intf]['peer'][p]['allowed-ips'] + + ## optional settings + # listen-port + if c['interfaces'][intf]['lport']: + wg_config['port'] = c['interfaces'][intf]['lport'] + + ## endpoint + if c['interfaces'][intf]['peer'][p]['endpoint']: + wg_config['endpoint'] = c['interfaces'][intf]['peer'][p]['endpoint'] + + ## persistent-keepalive + if 'persistent-keepalive' in c['interfaces'][intf]['peer'][p]: + wg_config['keepalive'] = c['interfaces'][intf]['peer'][p]['persistent-keepalive'] + + ## preshared-key - is only read from a file, it's called via sudo redirection doesn't work either + if 'psk' in c['interfaces'][intf]['peer'][p]: + old_umask = os.umask(0o077) + open(psk_file, 'w').write(str(c['interfaces'][intf]['peer'][p]['psk'])) + os.umask(old_umask) + wg_config['psk'] = psk_file + + ### assemble wg command + cmd = "sudo wg set " + intf + cmd += " listen-port " + str(wg_config['port']) + cmd += " private-key " + wg_config['private-key'] + cmd += " peer " + wg_config['pubkey'] + cmd += " preshared-key " + wg_config['psk'] + cmd += " allowed-ips " + for ap in wg_config['allowed-ips']: + if ap != wg_config['allowed-ips'][-1]: + cmd += ap + "," + else: + cmd += ap + + if wg_config['endpoint']: + cmd += " endpoint " + wg_config['endpoint'] + + if wg_config['keepalive'] != 0: + cmd += " persistent-keepalive " + wg_config['keepalive'] + else: + cmd += " persistent-keepalive 0" + + sl.syslog(sl.LOG_NOTICE, cmd) + #print (cmd) + subprocess.call([cmd], shell=True) + """ remove psk_file """ + if os.path.exists(psk_file): + os.remove(psk_file) + +def add_addr(intf, addr): + # see https://phabricator.vyos.net/T949 + ret = subprocess.call(['ip a a dev ' + intf + ' ' + addr + ' &>/dev/null'], shell=True) + sl.syslog(sl.LOG_NOTICE, "ip a a dev " + intf + " " + addr) + +def del_addr(intf, addr): + ret = subprocess.call(['ip a d dev ' + intf + ' ' + addr + ' &>/dev/null'], shell=True) + sl.syslog(sl.LOG_NOTICE, "ip a d dev " + intf + " " + addr) + +def remove_peer(intf, peer_key): + cmd = 'sudo wg set ' + str(intf) + ' peer ' + peer_key + ' remove &>/dev/null' + ret = subprocess.call([cmd], shell=True) + sl.syslog(sl.LOG_NOTICE, "peer " + peer_key + " removed from " + intf) + +if __name__ == '__main__': + try: + check_kmod() + c = get_config() + verify(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/etc/init.d/igmpproxy b/src/etc/init.d/igmpproxy new file mode 100755 index 000000000..4a2c94a4d --- /dev/null +++ b/src/etc/init.d/igmpproxy @@ -0,0 +1,166 @@ +#!/bin/sh +### BEGIN INIT INFO +# Provides: igmpproxy +# Required-Start: $local_fs $network $remote_fs $syslog +# Required-Stop: $local_fs $network $remote_fs $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: IGMP multicast routing daemon +# Description: IGMPproxy is a simple dynamic Multicast Routing Daemon +# using only IGMP signalling. It's intended for simple +# forwarding of Multicast traffic between networks. +### END INIT INFO + +# Author: Pali Rohár <pali.rohar@gmail.com> + +# Do NOT "set -e" + +# PATH should only include /usr/* if it runs after the mountnfs.sh script +PATH=/sbin:/usr/sbin:/bin:/usr/bin +DESC="igmpproxy" +NAME=igmpproxy +DAEMON=/sbin/igmpproxy +DAEMON_ARGS="/etc/igmpproxy.conf" +PIDFILE=/var/run/$NAME.pid +SCRIPTNAME=/etc/init.d/$NAME + +# Exit if the package is not installed +[ -x "$DAEMON" ] || exit 0 + +# Read configuration variable file if it is present +[ -r /etc/default/$NAME ] && . /etc/default/$NAME + +# Load the VERBOSE setting and other rcS variables +. /lib/init/vars.sh + +# Define LSB log_* functions. +# Depend on lsb-base (>= 3.2-14) to ensure that this file is present +# and status_of_proc is working. +. /lib/lsb/init-functions + +# +# Function that starts the daemon/service +# +do_start() +{ + # Return + # 0 if daemon has been started + # 1 if daemon was already running + # 2 if daemon could not be started + start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \ + || return 1 + start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON -b -m -- \ + $DAEMON_OPTS $DAEMON_ARGS \ + || return 2 + # The above code will not work for interpreted scripts, use the next + # six lines below instead (Ref: #643337, start-stop-daemon(8) ) + #start-stop-daemon --start --quiet --pidfile $PIDFILE --startas $DAEMON \ + # --name $NAME --test > /dev/null \ + # || return 1 + #start-stop-daemon --start --quiet --pidfile $PIDFILE --startas $DAEMON \ + # --name $NAME -- $DAEMON_ARGS \ + # || return 2 + + # Add code here, if necessary, that waits for the process to be ready + # to handle requests from services started subsequently which depend + # on this one. As a last resort, sleep for some time. +} + +# +# Function that stops the daemon/service +# +do_stop() +{ + # Return + # 0 if daemon has been stopped + # 1 if daemon was already stopped + # 2 if daemon could not be stopped + # other if a failure occurred + start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE --name $NAME + RETVAL="$?" + [ "$RETVAL" = 2 ] && return 2 + # Wait for children to finish too if this is a daemon that forks + # and if the daemon is only ever run from this initscript. + # If the above conditions are not satisfied then add some other code + # that waits for the process to drop all resources that could be + # needed by services started subsequently. A last resort is to + # sleep for some time. + start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON + [ "$?" = 2 ] && return 2 + # Many daemons don't delete their pidfiles when they exit. + rm -f $PIDFILE + return "$RETVAL" +} + +# +# Function that sends a SIGHUP to the daemon/service +# +do_reload() { + # + # If the daemon can reload its configuration without + # restarting (for example, when it is sent a SIGHUP), + # then implement that here. + # + start-stop-daemon --stop --signal 1 --quiet --pidfile $PIDFILE --name $NAME + return 0 +} + +case "$1" in + start) + [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC " "$NAME" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? + ;; + #reload|force-reload) + # + # If do_reload() is not implemented then leave this commented out + # and leave 'force-reload' as an alias for 'restart'. + # + #log_daemon_msg "Reloading $DESC" "$NAME" + #do_reload + #log_end_msg $? + #;; + restart|force-reload) + # + # If the "reload" option is implemented then remove the + # 'force-reload' alias + # + log_daemon_msg "Restarting $DESC" "$NAME" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; + *) + #echo "Usage: $SCRIPTNAME {start|stop|restart|reload|force-reload}" >&2 + echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 + exit 3 + ;; +esac + +: diff --git a/src/etc/init.d/isc-dhcpv6-relay b/src/etc/init.d/isc-dhcpv6-relay new file mode 100755 index 000000000..5a8ce620c --- /dev/null +++ b/src/etc/init.d/isc-dhcpv6-relay @@ -0,0 +1,50 @@ +#!/bin/sh +# +# + +### BEGIN INIT INFO +# Provides: isc-dhcpv6-relay +# Required-Start: $remote_fs $network +# Required-Stop: $remote_fs $network +# Should-Start: $local_fs +# Should-Stop: $local_fs +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: IPv6 DHCP relay +# Description: Dynamic Host Configuration Protocol Relay for IPv6 +### END INIT INFO + +# It is not safe to start if we don't have a default configuration... +if [ ! -f /etc/default/isc-dhcpv6-relay ]; then + echo "/etc/default/isc-dhcpv6-relay does not exist! - Aborting..." + exit 1 +fi + +# Source init functions +. /lib/lsb/init-functions + +# Read init script configuration (interfaces the daemon should listen on +# and the DHCP server we should forward requests to.) +[ -f /etc/default/isc-dhcpv6-relay ] && . /etc/default/isc-dhcpv6-relay + +DHCRELAYPID=/var/run/dhcv6relay.pid + +case "$1" in + start) + start-stop-daemon --start --quiet --pidfile $DHCRELAYPID \ + --exec /usr/sbin/dhcrelay -- -q $OPTIONS -pf $DHCRELAYPID + ;; + stop) + start-stop-daemon --stop --quiet --pidfile $DHCRELAYPID + ;; + restart | force-reload) + $0 stop + sleep 2 + $0 start + ;; + *) + echo "Usage: /etc/init.d/isc-dhcpv6-relay {start|stop|restart|force-reload}" + exit 1 +esac + +exit 0 diff --git a/src/etc/init.d/isc-dhcpv6-server b/src/etc/init.d/isc-dhcpv6-server new file mode 100755 index 000000000..441827d5f --- /dev/null +++ b/src/etc/init.d/isc-dhcpv6-server @@ -0,0 +1,113 @@ +#!/bin/sh +# +# + +### BEGIN INIT INFO +# Provides: isc-dhcpv6-server +# Required-Start: $remote_fs $network $syslog +# Required-Stop: $remote_fs $network $syslog +# Should-Start: $local_fs slapd $named +# Should-Stop: $local_fs slapd +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: IPv6 DHCP server +# Description: Dynamic Host Configuration Protocol Server for IPv6 +### END INIT INFO + +PATH=/sbin:/bin:/usr/sbin:/usr/bin + +test -f /usr/sbin/dhcpd || exit 0 + +DHCPD_DEFAULT="${DHCPD_DEFAULT:-/etc/default/isc-dhcpv6-server}" + +# It is not safe to start if we don't have a default configuration... +if [ ! -f "$DHCPD_DEFAULT" ]; then + echo "$DHCPD_DEFAULT does not exist! - Aborting..." + exit 0 +fi + +. /lib/lsb/init-functions + +# Read init script configuration +[ -f "$DHCPD_DEFAULT" ] && . "$DHCPD_DEFAULT" + +NAME=dhcpd +DESC="ISC DHCP server" +# fallback to default config file +DHCPD_CONF=${DHCPD_CONF:-/etc/dhcp/dhcpd.conf} +# try to read pid file name from config file, with fallback to /var/run/dhcpd.pid +if [ -z "$DHCPD_PID" ]; then + DHCPD_PID=$(sed -n -e 's/^[ \t]*pid-file-name[ \t]*"(.*)"[ \t]*;.*$/\1/p' < "$DHCPD_CONF" 2>/dev/null | head -n 1) +fi +DHCPD_PID="${DHCPD_PID:-/var/run/dhcpd.pid}" + +test_config() +{ + if ! /usr/sbin/dhcpd -t $OPTIONS -q -cf "$DHCPD_CONF" > /dev/null 2>&1; then + echo "dhcpd self-test failed. Please fix $DHCPD_CONF." + echo "The error was: " + /usr/sbin/dhcpd -t $OPTIONS -cf "$DHCPD_CONF" + exit 1 + fi + touch /var/lib/dhcp/dhcpd.leases +} + +# single arg is -v for messages, -q for none +check_status() +{ + if [ ! -r "$DHCPD_PID" ]; then + test "$1" != -v || echo "$NAME is not running." + return 3 + fi + if read pid < "$DHCPD_PID" && ps -p "$pid" > /dev/null 2>&1; then + test "$1" != -v || echo "$NAME is running." + return 0 + else + test "$1" != -v || echo "$NAME is not running but $DHCPD_PID exists." + return 1 + fi +} + +case "$1" in + start) + test_config + log_daemon_msg "Starting $DESC" "$NAME" + start-stop-daemon --start --quiet --pidfile "$DHCPD_PID" \ + --exec /usr/sbin/dhcpd -- \ + -q $OPTIONS -cf "$DHCPD_CONF" -pf "$DHCPD_PID" $INTERFACES + sleep 2 + + if check_status -q; then + log_end_msg 0 + else + log_failure_msg "check syslog for diagnostics." + log_end_msg 1 + exit 1 + fi + ;; + stop) + log_daemon_msg "Stopping $DESC" "$NAME" + start-stop-daemon --stop --quiet --pidfile "$DHCPD_PID" + log_end_msg $? + rm -f "$DHCPD_PID" + ;; + restart | force-reload) + test_config + $0 stop + sleep 2 + $0 start + if [ "$?" != "0" ]; then + exit 1 + fi + ;; + status) + echo -n "Status of $DESC: " + check_status -v + exit "$?" + ;; + *) + echo "Usage: $0 {start|stop|restart|force-reload|status}" + exit 1 +esac + +exit 0 diff --git a/src/helpers/commands-pipe.py b/src/helpers/commands-pipe.py deleted file mode 100755 index 1120bb09e..000000000 --- a/src/helpers/commands-pipe.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/python3 - -import sys -import re - -from signal import signal, SIGPIPE, SIG_DFL -from vyos.configtree import ConfigTree - -signal(SIGPIPE,SIG_DFL) - -config_string = sys.stdin.read().strip() - -if not config_string: - sys.exit(0) - -# When used in conf mode pipe, the config given to the script is likely incomplete -# and breaks the "all top level nodes are neither tag nor leaf" -# invariant, so we wrap it into a fake node. -# Since nodes don't normally start with an underscore, -# __root__ is hygienic enough. -config_string = "__root__ {{ {0} \n }}".format(config_string) - -config_re = re.compile(r'(set|comment)\s+__root__\s+(.*)') - -config = ConfigTree(config_string) -commands = config.to_commands() -commands = config_re.sub("\\1 \\2", commands) - -print(commands) diff --git a/src/helpers/validate-value.py b/src/helpers/validate-value.py index d702739b5..36f996d38 100755 --- a/src/helpers/validate-value.py +++ b/src/helpers/validate-value.py @@ -23,7 +23,7 @@ try: except Exception as exn: if debug: print(exn) - else: + else: pass try: diff --git a/src/migration-scripts/dhcp-relay/1-to-2 b/src/migration-scripts/dhcp-relay/1-to-2 new file mode 100755 index 000000000..b72da1028 --- /dev/null +++ b/src/migration-scripts/dhcp-relay/1-to-2 @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +# Delete "set service dhcp-relay relay-options port" option +# Delete "set service dhcpv6-relay listen-port" option + +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) + +if not (config.exists(['service', 'dhcp-relay', 'relay-options', 'port']) or config.exists(['service', 'dhcpv6-relay', 'listen-port'])): + # Nothing to do + sys.exit(0) +else: + # Delete abandoned node + config.delete(['service', 'dhcp-relay', 'relay-options', 'port']) + # Delete abandoned node + config.delete(['service', 'dhcpv6-relay', 'listen-port']) + + 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/dhcp-server/4-to-5 b/src/migration-scripts/dhcp-server/4-to-5 new file mode 100755 index 000000000..baccd5a2b --- /dev/null +++ b/src/migration-scripts/dhcp-server/4-to-5 @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +# Removes boolean operator from: +# - "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 ip-forwarding enable (true|false)" +# - "set service dhcp-server shared-network-name <xyz> authoritative (true|false)" +# - "set service dhcp-server disabled (true|false)" + +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) + +if not config.exists(['service', 'dhcp-server']): + # Nothing to do + sys.exit(0) +else: + base = ['service', 'dhcp-server'] + # Make node "set service dhcp-server dynamic-dns-update enable (true|false)" valueless + if config.exists(base + ['dynamic-dns-update']): + bool_val = config.return_value(base + ['dynamic-dns-update', 'enable']) + + # Delete the node with the old syntax + config.delete(base + ['dynamic-dns-update']) + if str(bool_val) == 'true': + # Enable dynamic-dns-update with new syntax + config.set(base + ['dynamic-dns-update'], value=None) + + # Make node "set service dhcp-server disabled (true|false)" valueless + if config.exists(base + ['disabled']): + bool_val = config.return_value(base + ['disabled']) + + # Delete the node with the old syntax + config.delete(base + ['disabled']) + if str(bool_val) == 'true': + # Now disable DHCP server with the new syntax + config.set(base + ['disable'], value=None) + + # Make node "set service dhcp-server hostfile-update (enable|disable) valueless + if config.exists(base + ['hostfile-update']): + bool_val = config.return_value(base + ['hostfile-update']) + + # Delete the node with the old syntax incl. all subnodes + config.delete(base + ['hostfile-update']) + if str(bool_val) == 'enable': + # Enable hostfile update with new syntax + config.set(base + ['hostfile-update'], value=None) + + # Run this for every instance if 'shared-network-name' + for network in config.list_nodes(base + ['shared-network-name']): + base_network = base + ['shared-network-name', network] + # format as tag node to avoid loading problems + config.set_tag(base + ['shared-network-name']) + + # Run this for every specified 'subnet' + for subnet in config.list_nodes(base_network + ['subnet']): + base_subnet = base_network + ['subnet', subnet] + # format as tag node to avoid loading problems + config.set_tag(base_network + ['subnet']) + + # Make node "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 ip-forwarding enable" valueless + if config.exists(base_subnet + ['ip-forwarding', 'enable']): + bool_val = config.return_value(base_subnet + ['ip-forwarding', 'enable']) + # Delete the node with the old syntax + config.delete(base_subnet + ['ip-forwarding']) + if str(bool_val) == 'true': + # Recreate node with new syntax + config.set(base_subnet + ['ip-forwarding'], value=None) + + # Rename node "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 start <172.16.0.4> stop <172.16.0.9> + if config.exists(base_subnet + ['start']): + # This is the new "range" id for DHCP lease ranges + r_id = 0 + for range in config.list_nodes(base_subnet + ['start']): + range_start = range + range_stop = config.return_value(base_subnet + ['start', range_start, 'stop']) + + # Delete the node with the old syntax + config.delete(base_subnet + ['start', range_start]) + + # Create the node for the new syntax + # Note: range is a tag node, counter is its child, not a value + config.set(base_subnet + ['range', r_id]) + config.set(base_subnet + ['range', r_id, 'start'], value=range_start) + config.set(base_subnet + ['range', r_id, 'stop'], value=range_stop) + + # format as tag node to avoid loading problems + config.set_tag(base_subnet + ['range']) + + # increment range id for possible next range definition + r_id += 1 + + # Delete the node with the old syntax + config.delete(['service', 'dhcp-server', 'shared-network-name', network, 'subnet', subnet, 'start']) + + + # Make node "set service dhcp-server shared-network-name <xyz> authoritative" valueless + if config.exists(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']): + authoritative = config.return_value(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']) + + # Delete the node with the old syntax + config.delete(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']) + + # Recreate node with new syntax - if required + if authoritative == "enable": + config.set(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']) + + 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/ipsec/4-to-5 b/src/migration-scripts/ipsec/4-to-5 new file mode 100755 index 000000000..b64aa8462 --- /dev/null +++ b/src/migration-scripts/ipsec/4-to-5 @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 + +# log-modes have changed, keyword all to any + +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() + +ctree = ConfigTree(config_file) + +if not ctree.exists(['vpn', 'ipsec', 'logging','log-modes']): + # Nothing to do + sys.exit(0) +else: + lmodes = ctree.return_values(['vpn', 'ipsec', 'logging','log-modes']) + for mode in lmodes: + if mode == 'all': + ctree.set(['vpn', 'ipsec', 'logging','log-modes'], value='any', replace=True) + + try: + open(file_name,'w').write(ctree.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/l2tp/0-to-1 b/src/migration-scripts/l2tp/0-to-1 new file mode 100755 index 000000000..f6c716df1 --- /dev/null +++ b/src/migration-scripts/l2tp/0-to-1 @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +# Unclutter L2TP VPN configuiration - move radius-server top level tag +# nodes to a regular node which now also configures the radius source address +# used when querying a radius server + +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) + +cfg_base = ['vpn', 'l2tp', 'remote-access', 'authentication'] +if not config.exists(cfg_base): + # Nothing to do + sys.exit(0) +else: + # Migrate "vpn l2tp authentication radius-source-address" to new + # "vpn l2tp authentication radius source-address" + if config.exists(cfg_base + ['radius-source-address']): + address = config.return_value(cfg_base + ['radius-source-address']) + # delete old configuration node + config.delete(cfg_base + ['radius-source-address']) + # write new configuration node + config.set(cfg_base + ['radius', 'source-address'], value=address) + + # Migrate "vpn l2tp authentication radius-server" tag node to new + # "vpn l2tp authentication radius server" tag node + for server in config.list_nodes(cfg_base + ['radius-server']): + base_server = cfg_base + ['radius-server', server] + key = config.return_value(base_server + ['key']) + + # delete old configuration node + config.delete(base_server) + # write new configuration node + config.set(cfg_base + ['radius', 'server', server, 'key'], value=key) + + # format as tag node + config.set_tag(cfg_base + ['radius', 'server']) + + # delete top level tag node + if config.exists(cfg_base + ['radius-server']): + config.delete(cfg_base + ['radius-server']) + + 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/ntp/0-to-1 b/src/migration-scripts/ntp/0-to-1 new file mode 100755 index 000000000..9c66f3109 --- /dev/null +++ b/src/migration-scripts/ntp/0-to-1 @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +# Delete "set system ntp server <n> dynamic" option + +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) + +if not config.exists(['system', 'ntp']): + # Nothing to do + sys.exit(0) +else: + # Delete abandoned leaf node if found inside tag node for + # "set system ntp server <n> dynamic" + base = ['system', 'ntp', 'server'] + for server in config.list_nodes(base): + if config.exists(base + [server, 'dynamic']): + config.delete(base + [server, 'dynamic']) + + 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/pppoe-server/0-to-1 b/src/migration-scripts/pppoe-server/0-to-1 new file mode 100755 index 000000000..bb24211b6 --- /dev/null +++ b/src/migration-scripts/pppoe-server/0-to-1 @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 + +# Convert "service pppoe-server authentication radius-server node key" +# to: +# "service pppoe-server authentication radius-server node secret" + +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() + +ctree = ConfigTree(config_file) + + +if not ctree.exists(['service', 'pppoe-server', 'authentication','radius-server']): + # Nothing to do + sys.exit(0) +else: + nodes = ctree.list_nodes(['service', 'pppoe-server', 'authentication','radius-server']) + for node in nodes: + if ctree.exists(['service', 'pppoe-server', 'authentication', 'radius-server', node, 'key']): + val = ctree.return_value(['service', 'pppoe-server', 'authentication', 'radius-server', node, 'key']) + ctree.set(['service', 'pppoe-server', 'authentication', 'radius-server', node, 'secret'], value=val, replace=False) + ctree.delete(['service', 'pppoe-server', 'authentication', 'radius-server', node, 'key']) + try: + open(file_name,'w').write(ctree.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/pptp/0-to-1 b/src/migration-scripts/pptp/0-to-1 new file mode 100755 index 000000000..d0c7a83b5 --- /dev/null +++ b/src/migration-scripts/pptp/0-to-1 @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +# Unclutter PPTP VPN configuiration - move radius-server top level tag +# nodes to a regular node which now also configures the radius source address +# used when querying a radius server + +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) + +cfg_base = ['vpn', 'pptp', 'remote-access', 'authentication'] +if not config.exists(cfg_base): + # Nothing to do + sys.exit(0) +else: + # Migrate "vpn pptp authentication radius-source-address" to new + # "vpn pptp authentication radius source-address" + if config.exists(cfg_base + ['radius-source-address']): + address = config.return_value(cfg_base + ['radius-source-address']) + # delete old configuration node + config.delete(cfg_base + ['radius-source-address']) + # write new configuration node + config.set(cfg_base + ['radius', 'source-address'], value=address) + + # Migrate "vpn pptp authentication radius-server" tag node to new + # "vpn pptp authentication radius server" tag node + for server in config.list_nodes(cfg_base + ['radius-server']): + base_server = cfg_base + ['radius-server', server] + key = config.return_value(base_server + ['key']) + + # delete old configuration node + config.delete(base_server) + # write new configuration node + config.set(cfg_base + ['radius', 'server', server, 'key'], value=key) + + # format as tag node + config.set_tag(cfg_base + ['radius', 'server']) + + # delete top level tag node + if config.exists(cfg_base + ['radius-server']): + config.delete(cfg_base + ['radius-server']) + + 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/quagga/2-to-3 b/src/migration-scripts/quagga/2-to-3 new file mode 100755 index 000000000..4c1cd86a3 --- /dev/null +++ b/src/migration-scripts/quagga/2-to-3 @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 + +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) + +def migrate_neighbor(config, neighbor_path, neighbor): + if config.exists(neighbor_path): + neighbors = config.list_nodes(neighbor_path) + for neighbor in neighbors: + # Move the valueless options: as-override, next-hop-self, route-reflector-client, route-server-client, + # remove-private-as + for valueless_option in ['as-override', 'nexthop-self', 'route-reflector-client', 'route-server-client', + 'remove-private-as']: + if config.exists(neighbor_path + [neighbor, valueless_option]): + config.set(neighbor_path + [neighbor] + af_path + [valueless_option]) + config.delete(neighbor_path + [neighbor, valueless_option]) + + # Move filter options: distribute-list, filter-list, prefix-list, and route-map + # They share the same syntax inside so we can group them + for filter_type in ['distribute-list', 'filter-list', 'prefix-list', 'route-map']: + if config.exists(neighbor_path + [neighbor, filter_type]): + for filter_dir in ['import', 'export']: + if config.exists(neighbor_path + [neighbor, filter_type, filter_dir]): + filter_name = config.return_value(neighbor_path + [neighbor, filter_type, filter_dir]) + config.set(neighbor_path + [neighbor] + af_path + [filter_type, filter_dir], value=filter_name) + config.delete(neighbor_path + [neighbor, filter_type]) + + # Move simple leaf node options: maximum-prefix, unsuppress-map, weight + for leaf_option in ['maximum-prefix', 'unsuppress-map', 'weight']: + if config.exists(neighbor_path + [neighbor, leaf_option]): + if config.exists(neighbor_path + [neighbor, leaf_option]): + leaf_opt_value = config.return_value(neighbor_path + [neighbor, leaf_option]) + config.set(neighbor_path + [neighbor] + af_path + [leaf_option], value=leaf_opt_value) + config.delete(neighbor_path + [neighbor, leaf_option]) + + # The rest is special cases, for better or worse + + # Move allowas-in + if config.exists(neighbor_path + [neighbor, 'allowas-in']): + if config.exists(neighbor_path + [neighbor, 'allowas-in', 'number']): + allowas_in = config.return_value(neighbor_path + [neighbor, 'allowas-in', 'number']) + config.set(neighbor_path + [neighbor] + af_path + ['allowas-in', 'number'], value=allowas_in) + config.delete(neighbor_path + [neighbor, 'allowas-in']) + + # Move attribute-unchanged options + if config.exists(neighbor_path + [neighbor, 'attribute-unchanged']): + for attr in ['as-path', 'med', 'next-hop']: + if config.exists(neighbor_path + [neighbor, 'attribute-unchanged', attr]): + config.set(neighbor_path + [neighbor] + af_path + ['attribute-unchanged', attr]) + config.delete(neighbor_path + [neighbor, 'attribute-unchanged', attr]) + config.delete(neighbor_path + [neighbor, 'attribute-unchanged']) + + # Move capability options + if config.exists(neighbor_path + [neighbor, 'capability']): + # "capability dynamic" is a peer-global option, we only migrate ORF + if config.exists(neighbor_path + [neighbor, 'capability', 'orf']): + if config.exists(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list']): + for orf in ['send', 'receive']: + if config.exists(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list', orf]): + config.set(neighbor_path + [neighbor] + af_path + ['capability', 'orf', 'prefix-list', orf]) + config.delete(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list', orf]) + config.delete(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list']) + config.delete(neighbor_path + [neighbor, 'capability', 'orf']) + + # Move default-originate + if config.exists(neighbor_path + [neighbor, 'default-originate']): + if config.exists(neighbor_path + [neighbor, 'default-originate', 'route-map']): + route_map = config.return_value(neighbor_path + [neighbor, 'default-originate', 'route-map']) + config.set(neighbor_path + [neighbor] + af_path + ['default-originate', 'route-map'], value=route_map) + else: + # Empty default-originate node is meaningful so we re-create it + config.set(neighbor_path + [neighbor] + af_path + ['default-originate']) + config.delete(neighbor_path + [neighbor, 'default-originate']) + + # Move soft-reconfiguration + if config.exists(neighbor_path + [neighbor, 'soft-reconfiguration']): + if config.exists(neighbor_path + [neighbor, 'soft-reconfiguration', 'inbound']): + config.set(neighbor_path + [neighbor] + af_path + ['soft-reconfiguration', 'inbound']) + # Empty soft-reconfiguration is meaningless, so we just remove it + config.delete(neighbor_path + [neighbor, 'soft-reconfiguration']) + + # Move disable-send-community + if config.exists(neighbor_path + [neighbor, 'disable-send-community']): + for comm_type in ['standard', 'extended']: + if config.exists(neighbor_path + [neighbor, 'disable-send-community', comm_type]): + config.set(neighbor_path + [neighbor] + af_path + ['disable-send-community', comm_type]) + config.delete(neighbor_path + [neighbor, 'disable-send-community', comm_type]) + config.delete(neighbor_path + [neighbor, 'disable-send-community']) + + +if not config.exists(['protocols', 'bgp']): + # Nothing to do + sys.exit(0) +else: + # Just to avoid writing it so many times + af_path = ['address-family', 'ipv4-unicast'] + + # Check if BGP is actually configured and obtain the ASN + asn_list = config.list_nodes(['protocols', 'bgp']) + if asn_list: + # There's always just one BGP node, if any + asn = asn_list[0] + bgp_path = ['protocols', 'bgp', asn] + else: + # There's actually no BGP, just its empty shell + sys.exit(0) + + ## Move global IPv4-specific BGP options to "address-family ipv4-unicast" + + # Move networks + network_path = ['protocols', 'bgp', asn, 'network'] + if config.exists(network_path): + config.set(bgp_path + af_path + ['network']) + config.set_tag(bgp_path + af_path + ['network']) + + networks = config.list_nodes(network_path) + for network in networks: + config.set(bgp_path + af_path + ['network', network]) + if config.exists(network_path + [network, 'route-map']): + route_map = config.return_value(network_path + [network, 'route-map']) + config.set(bgp_path + af_path + ['network', network, 'route-map'], value=route_map) + config.delete(network_path) + + # Move aggregate-address statements + aggregate_path = ['protocols', 'bgp', asn, 'aggregate-address'] + if config.exists(aggregate_path): + config.set(bgp_path + af_path + ['aggregate-address']) + config.set_tag(bgp_path + af_path + ['aggregate-address']) + + aggregates = config.list_nodes(aggregate_path) + for aggregate in aggregates: + config.set(bgp_path + af_path + ['aggregate-address', aggregate]) + if config.exists(aggregate_path + [aggregate, 'as-set']): + config.set(bgp_path + af_path + ['aggregate-address', aggregate, 'as-set']) + if config.exists(aggregate_path + [aggregate, 'summary-only']): + config.set(bgp_path + af_path + ['aggregate-address', aggregate, 'summary-only']) + config.delete(aggregate_path) + + ## Migrate neighbor options + neighbor_path = ['protocols', 'bgp', asn, 'neighbor'] + if config.exists(neighbor_path): + neighbors = config.list_nodes(neighbor_path) + for neighbor in neighbors: + migrate_neighbor(config, neighbor_path, neighbor) + + peer_group_path = ['protocols', 'bgp', asn, 'peer-group'] + if config.exists(peer_group_path): + peer_groups = config.list_nodes(peer_group_path) + for peer_group in peer_groups: + migrate_neighbor(config, peer_group_path, peer_group) + + ## Migrate redistribute statements + redistribute_path = ['protocols', 'bgp', asn, 'redistribute'] + if config.exists(redistribute_path): + config.set(bgp_path + af_path + ['redistribute']) + + redistributes = config.list_nodes(redistribute_path) + for redistribute in redistributes: + config.set(bgp_path + af_path + ['redistribute', redistribute]) + if config.exists(redistribute_path + [redistribute, 'metric']): + redist_metric = config.return_value(redistribute_path + [redistribute, 'metric']) + config.set(bgp_path + af_path + ['redistribute', redistribute, 'metric'], value=redist_metric) + if config.exists(redistribute_path + [redistribute, 'route-map']): + redist_route_map = config.return_value(redistribute_path + [redistribute, 'route-map']) + config.set(bgp_path + af_path + ['redistribute', redistribute, 'route-map'], value=redist_route_map) + + config.delete(redistribute_path) + + 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/ssh/0-to-1 b/src/migration-scripts/ssh/0-to-1 new file mode 100755 index 000000000..91b832276 --- /dev/null +++ b/src/migration-scripts/ssh/0-to-1 @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 + +# Delete "service ssh allow-root" option + +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) + +if not config.exists(['service', 'ssh', 'allow-root']): + # Nothing to do + sys.exit(0) +else: + # Delete node with abandoned command + config.delete(['service', 'ssh', 'allow-root']) + + 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/system/10-to-11 b/src/migration-scripts/system/10-to-11 new file mode 100755 index 000000000..e6467243c --- /dev/null +++ b/src/migration-scripts/system/10-to-11 @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +# Unclutter RADIUS configuiration for 'system login radius-server' +# Move radius-server top level tag nodes to a regular node which allows us +# to specify additional general features for the RADIUS client + +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) +cfg_base = ['system', 'login'] +if not config.exists(cfg_base + ['radius-server']): + # Nothing to do + sys.exit(0) +else: + # Migrate "system login radius-server" tag node to new + # "system login radius server" tag node and also rename the "secret" node to "key" + for server in config.list_nodes(cfg_base + ['radius-server']): + base_server = cfg_base + ['radius-server', server] + # "key" node is mandatory + key = config.return_value(base_server + ['secret']) + config.set(cfg_base + ['radius', 'server', server, 'key'], value=key) + + # "port" is optional + if config.exists(base_server + ['port']): + port = config.return_value(base_server + ['port']) + config.set(cfg_base + ['radius', 'server', server, 'port'], value=port) + + # "timeout is optional" + if config.exists(base_server + ['timeout']): + timeout = config.return_value(base_server + ['timeout']) + config.set(cfg_base + ['radius', 'server', server, 'timeout'], value=timeout) + + # format as tag node + config.set_tag(cfg_base + ['radius', 'server']) + + # delete old configuration node + config.delete(base_server) + + # delete top level tag node + if config.exists(cfg_base + ['radius-server']): + config.delete(cfg_base + ['radius-server']) + + 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/system/8-to-9 b/src/migration-scripts/system/8-to-9 new file mode 100755 index 000000000..db3fefdea --- /dev/null +++ b/src/migration-scripts/system/8-to-9 @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 + +# Deletes "system package" option as it is deprecated + +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) + +if not config.exists(['system', 'package']): + # Nothing to do + sys.exit(0) +else: + # Delete the node with the old syntax + config.delete(['system', 'package']) + + 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/system/9-to-10 b/src/migration-scripts/system/9-to-10 new file mode 100755 index 000000000..c4343ec55 --- /dev/null +++ b/src/migration-scripts/system/9-to-10 @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 + +# converts opertator accts. to admin level accts. + +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) + +if not config.exists(['system', 'login', 'user']): + # Nothing to do, which shouldn't happen anyway + # only if you wipe the config and reboot. + sys.exit(0) +else: + for usr in config.list_nodes(['system', 'login', 'user']): + if config.return_value(['system', 'login', 'user', usr, 'level']) == 'operator': + config.set(['system', 'login', 'user', usr, 'level'], value="admin", replace=True) + + try: + open(file_name,'w').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/webproxy/1-to-2 b/src/migration-scripts/webproxy/1-to-2 new file mode 100755 index 000000000..070ff356d --- /dev/null +++ b/src/migration-scripts/webproxy/1-to-2 @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +# migrate old style `webproxy proxy-bypass 1.2.3.4/24` +# to new style `webproxy whitelist destination-address 1.2.3.4/24` + +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) + +cfg_webproxy_base = ['service', 'webproxy'] +if not config.exists(cfg_webproxy_base + ['proxy-bypass']): + # Nothing to do + sys.exit(0) +else: + bypass_addresses = config.return_values(cfg_webproxy_base + ['proxy-bypass']) + # delete old configuration node + config.delete(cfg_webproxy_base + ['proxy-bypass']) + for bypass_address in bypass_addresses: + # add data to new configuration node + config.set(cfg_webproxy_base + ['whitelist', 'destination-address'], value=bypass_address, replace=False) + + # save updated configuration + 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/op_mode/cpu_summary.py b/src/op_mode/cpu_summary.py index 7324c75ad..cfd321522 100755 --- a/src/op_mode/cpu_summary.py +++ b/src/op_mode/cpu_summary.py @@ -1,10 +1,22 @@ #!/usr/bin/env python3 +# +# Copyright (C) 2018 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 re - from vyos.util import colon_separated_to_dict - FILE_NAME = '/proc/cpuinfo' with open(FILE_NAME, 'r') as f: diff --git a/src/op_mode/dynamic_dns_status.py b/src/op_mode/dynamic_dns.py index bbff01f49..0d457e247 100755 --- a/src/op_mode/dynamic_dns_status.py +++ b/src/op_mode/dynamic_dns.py @@ -1,5 +1,21 @@ #!/usr/bin/env python3 +# +# Copyright (C) 2018 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 argparse import jinja2 import sys import time @@ -18,7 +34,7 @@ update-status: {{ entry.status }} {% endfor -%} """ -if __name__ == '__main__': +def show_status(): # Do nothing if service is not configured c = Config() if not c.exists_effective('service dns dynamic'): @@ -65,3 +81,26 @@ if __name__ == '__main__': tmpl = jinja2.Template(OUT_TMPL_SRC) print(tmpl.render(data)) + + +def update_ddns(): + os.system('systemctl stop ddclient') + os.remove(cache_file) + os.system('systemctl start ddclient') + + +def main(): + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group() + group.add_argument("--status", help="Show DDNS status", action="store_true") + group.add_argument("--update", help="Update DDNS on a given interface", action="store_true") + args = parser.parse_args() + + if args.status: + show_status() + elif args.update: + update_ddns() + + +if __name__ == '__main__': + main() diff --git a/src/op_mode/maya_date.py b/src/op_mode/maya_date.py index 7d8aefc91..847b543e0 100755 --- a/src/op_mode/maya_date.py +++ b/src/op_mode/maya_date.py @@ -27,17 +27,16 @@ class MayaDate(object): It represents the number of days passed since some date in the past the Maya believed is the day our world was created. - + Tzolkin calendar is for religious purposes, it has two independent cycles of 13 and 20 days, where 13 day cycle days are numbered, and 20 day cycle days are named. - + Haab calendar is for agriculture and daily life, it's a 365 day calendar with 18 months 20 days each, and 5 nameless days. The smallest unit of the long count calendar is one day (kin). - """ """ The long count calendar uses five different base 18 or base 20 @@ -131,7 +130,7 @@ class MayaDate(object): """ Seconds in day, for conversion from timestamp """ seconds_in_day = 60 * 60 * 24 - + def __init__(self, timestamp): if timestamp is None: self.days = self.start_days diff --git a/src/op_mode/powerctrl.py b/src/op_mode/powerctrl.py index f73d6c005..2f6112fb7 100755 --- a/src/op_mode/powerctrl.py +++ b/src/op_mode/powerctrl.py @@ -1,142 +1,175 @@ #!/usr/bin/env python3 +# +# Copyright (C) 2018 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 argparse -from datetime import datetime, timedelta, time as type_time, date as type_date -from subprocess import check_output,CalledProcessError,STDOUT +import subprocess import re +from datetime import datetime, timedelta, time as type_time, date as type_date +from subprocess import check_output, CalledProcessError, STDOUT + def yn(msg, default=False): - default_msg = "[Y/n]" if default else "[y/N]" - while True: - sys.stdout.write("%s %s " % (msg,default_msg)) - c = input().lower() - if c == '': - return default - elif c in ("y", "ye","yes"): - return True - elif c in ("n", "no"): - return False - else: - sys.stdout.write("Please respond with yes/y or no/n\n") + default_msg = "[Y/n]" if default else "[y/N]" + while True: + sys.stdout.write("%s %s " % (msg,default_msg)) + c = input().lower() + if c == '': + return default + elif c in ("y", "ye","yes"): + return True + elif c in ("n", "no"): + return False + else: + sys.stdout.write("Please respond with yes/y or no/n\n") def valid_time(s): - try: - return datetime.strptime(s, "%H:%M").time() - except ValueError: - return None + try: + return datetime.strptime(s, "%H:%M").time() + except ValueError: + return None def valid_date(s): + try: + return datetime.strptime(s, "%d%m%Y").date() + except ValueError: try: - return datetime.strptime(s, "%d%m%Y").date() + return datetime.strptime(s, "%d/%m/%Y").date() except ValueError: + try: + return datetime.strptime(s, "%d.%m.%Y").date() + except ValueError: try: - return datetime.strptime(s, "%d/%m/%Y").date() + return datetime.strptime(s, "%d:%m:%Y").date() except ValueError: - try: - return datetime.strptime(s, "%d.%m.%Y").date() - except ValueError: - try: - return datetime.strptime(s, "%d:%m:%Y").date() - except ValueError: - return None + return None def check_shutdown(): - try: - cmd = check_output(["/bin/systemctl","status","systemd-shutdownd.service"]) - #Shutodwn is scheduled - r = re.findall(r'Status: \"(.*)\"\n', cmd.decode())[0] - print(r) - except CalledProcessError as e: - #Shutdown is not scheduled - print("Shutdown is not scheduled") + try: + cmd = check_output(["/bin/systemctl","status","systemd-shutdownd.service"]) + #Shutodwn is scheduled + r = re.findall(r'Status: \"(.*)\"\n', cmd.decode())[0] + print(r) + except CalledProcessError as e: + #Shutdown is not scheduled + print("Shutdown is not scheduled") def cancel_shutdown(): - try: - cmd = check_output(["/sbin/shutdown","-c"]) - except CalledProcessError as e: - sys.exit("Error aborting shutdown: %s" % e) + try: + cmd = check_output(["/sbin/shutdown","-c"]) + except CalledProcessError as e: + sys.exit("Error aborting shutdown: %s" % e) def execute_shutdown(time, reboot = True, ask=True): - if not ask: - action = "reboot" if reboot else "poweroff" - if not yn("Are you sure you want to %s this system?" % action): - sys.exit(0) - - action = "-r" if reboot else "-P" - - if len(time) == 0: - cmd = check_output(["/sbin/shutdown",action,"now"],stderr=STDOUT) - print(cmd.decode().split(",",1)[0]) - return - - # Try to extract date from the first argument - if len(time) == 1: - time = time[0].split(" ",1) - - if len(time) == 1: - ts=valid_time(time[0]) - if time[0].isdigit() or valid_time(time[0]): - cmd = check_output(["/sbin/shutdown",action,time[0]],stderr=STDOUT) - else: - sys.exit("Timestamp needs to be in format of 12:34") - - elif len(time) == 2: - ts = valid_time(time[0]) - ds = valid_date(time[1]) - if ts and ds: - t = datetime.combine(ds, ts) - td = t-datetime.now() - t2 = 1+int(td.total_seconds())//60 # Get total minutes - cmd = check_output(["/sbin/shutdown",action,str(t2)],stderr=STDOUT) - else: - sys.exit("Timestamp needs to be in format of 12:34\nDatestamp in the format of DD.MM.YY") - else: - sys.exit("Could not decode time and date") + if not ask: + action = "reboot" if reboot else "poweroff" + if not yn("Are you sure you want to %s this system?" % action): + sys.exit(0) + + action = "-r" if reboot else "-P" + if len(time) == 0: + ### T870 legacy reboot job support + chk_vyatta_based_reboots() + ### + + cmd = check_output(["/sbin/shutdown",action,"now"],stderr=STDOUT) print(cmd.decode().split(",",1)[0]) + return -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--yes", "-y", - help="dont as for shutdown", - action="store_true", - dest="yes") - action = parser.add_mutually_exclusive_group(required=True) - action.add_argument("--reboot", "-r", - help="Reboot the system", - nargs="*", - metavar="Minutes|HH:MM") - - action.add_argument("--poweroff", "-p", - help="Poweroff the system", - nargs="*", - metavar="Minutes|HH:MM") - - action.add_argument("--cancel", "-c", - help="Cancel pending shutdown", - action="store_true") - - action.add_argument("--check", - help="Check pending chutdown", - action="store_true") - args = parser.parse_args() + # Try to extract date from the first argument + if len(time) == 1: + time = time[0].split(" ",1) - try: - if args.reboot: - execute_shutdown(args.reboot, reboot=True, ask=args.yes) - if args.poweroff: - execute_shutdown(args.poweroff, reboot=False,ask=args.yes) - if args.cancel: - cancel_shutdown() - if args.check: - check_shutdown() - except KeyboardInterrupt: - sys.exit("Interrupted") + if len(time) == 1: + ts = valid_time(time[0]) + if time[0].isdigit() or valid_time(time[0]): + cmd = check_output(["/sbin/shutdown",action,time[0]],stderr=STDOUT) + else: + sys.exit("Timestamp needs to be in format of 12:34") + + elif len(time) == 2: + ts = valid_time(time[0]) + ds = valid_date(time[1]) + if ts and ds: + t = datetime.combine(ds, ts) + td = t - datetime.now() + t2 = 1 + int(td.total_seconds())//60 # Get total minutes + cmd = check_output(["/sbin/shutdown",action,str(t2)],stderr=STDOUT) + else: + sys.exit("Timestamp needs to be in format of 12:34\nDatestamp in the format of DD.MM.YY") + else: + sys.exit("Could not decode time and date") + + print(cmd.decode().split(",",1)[0]) + +def chk_vyatta_based_reboots(): + ### T870 commit-confirm is still using the vyatta code base, once gone, the code below can be removed + ### legacy scheduled reboot s are using at and store the is as /var/run/<name>.job + ### name is the node of scheduled the job, commit-confirm checks for that + + f = r'/var/run/confirm.job' + if os .path.exists(f): + jid = open(f).read().strip() + if jid != 0: + subprocess.call(['sudo', 'atrm', jid]) + os.remove(f) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--yes", "-y", + help="dont as for shutdown", + action="store_true", + dest="yes") + action = parser.add_mutually_exclusive_group(required=True) + action.add_argument("--reboot", "-r", + help="Reboot the system", + nargs="*", + metavar="Minutes|HH:MM") + + action.add_argument("--poweroff", "-p", + help="Poweroff the system", + nargs="*", + metavar="Minutes|HH:MM") + + action.add_argument("--cancel", "-c", + help="Cancel pending shutdown", + action="store_true") + + action.add_argument("--check", + help="Check pending chutdown", + action="store_true") + args = parser.parse_args() + + try: + if args.reboot is not None: + execute_shutdown(args.reboot, reboot=True, ask=args.yes) + if args.poweroff is not None: + execute_shutdown(args.poweroff, reboot=False,ask=args.yes) + if args.cancel: + cancel_shutdown() + if args.check: + check_shutdown() + except KeyboardInterrupt: + sys.exit("Interrupted") if __name__ == "__main__": - main() + main() diff --git a/src/op_mode/restart_dhcp_relay.py b/src/op_mode/restart_dhcp_relay.py new file mode 100755 index 000000000..ab02d1eb3 --- /dev/null +++ b/src/op_mode/restart_dhcp_relay.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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/>. + +# File: restart_dhcp_relay.py +# Purpose: +# Restart IPv4 and IPv6 DHCP relay instances of dhcrelay service + +import sys +import argparse +import os + +import vyos.config + +parser = argparse.ArgumentParser() +parser.add_argument("--ipv4", action="store_true", help="Restart IPv4 DHCP relay") +parser.add_argument("--ipv6", action="store_true", help="Restart IPv6 DHCP relay") + +if __name__ == '__main__': + args = parser.parse_args() + c = vyos.config.Config() + + if args.ipv4: + # Do nothing if service is not configured + if not c.exists_effective('service dhcp-relay'): + print("DHCP relay service not configured") + else: + os.system('sudo systemctl restart isc-dhcp-relay.service') + + sys.exit(0) + elif args.ipv6: + # Do nothing if service is not configured + if not c.exists_effective('service dhcpv6-relay'): + print("DHCPv6 relay service not configured") + else: + os.system('sudo systemctl restart isc-dhcpv6-relay.service') + + sys.exit(0) + else: + parser.print_help() + sys.exit(1) diff --git a/src/op_mode/show_dhcp.py b/src/op_mode/show_dhcp.py new file mode 100755 index 000000000..4c4ee6355 --- /dev/null +++ b/src/op_mode/show_dhcp.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 json +import argparse +import ipaddress +import tabulate +import sys + +from vyos.config import Config +from isc_dhcp_leases import Lease, IscDhcpLeases + +lease_file = "/config/dhcpd.leases" +pool_key = "shared-networkname" + +def in_pool(lease, pool): + if pool_key in lease.sets: + if lease.sets[pool_key] == pool: + return True + + return False + +def get_lease_data(lease): + data = {} + + # End time may not be present in backup leases + try: + data["expires"] = lease.end.strftime("%Y/%m/%d %H:%M:%S") + except: + data["expires"] = "" + + data["hardware_address"] = lease.ethernet + data["hostname"] = lease.hostname + data["ip"] = lease.ip + + try: + data["pool"] = lease.sets[pool_key] + except: + data["pool"] = "" + + return data + +def get_leases(leases, state=None, pool=None): + leases = IscDhcpLeases(lease_file).get() + + if state is not None: + leases = list(filter(lambda x: x.binding_state == 'active', leases)) + + if pool is not None: + leases = list(filter(lambda x: in_pool(x, pool), leases)) + + return list(map(get_lease_data, leases)) + +def show_leases(leases): + headers = ["IP address", "Hardware address", "Lease expiration", "Pool", "Client Name"] + + lease_list = [] + for l in leases: + lease_list.append([l["ip"], l["hardware_address"], l["expires"], l["pool"], l["hostname"]]) + + output = tabulate.tabulate(lease_list, headers) + + print(output) + +def get_pool_size(config, pool): + size = 0 + subnets = config.list_effective_nodes("service dhcp-server shared-network-name {0} subnet".format(pool)) + for s in subnets: + ranges = config.list_effective_nodes("service dhcp-server shared-network-name {0} subnet {1} range".format(pool, s)) + for r in ranges: + start = config.return_effective_value("service dhcp-server shared-network-name {0} subnet {1} range {2} start".format(pool, s, r)) + stop = config.return_effective_value("service dhcp-server shared-network-name {0} subnet {1} range {2} stop".format(pool, s, r)) + + size += int(ipaddress.IPv4Address(stop)) - int(ipaddress.IPv4Address(start)) + + return size + +def show_pool_stats(stats): + headers = ["Pool", "Size", "Leases", "Available", "Usage"] + output = tabulate.tabulate(stats, headers) + + print(output) + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + + group = parser.add_mutually_exclusive_group() + group.add_argument("-l", "--leases", action="store_true", help="Show DHCP leases") + group.add_argument("-s", "--statistics", action="store_true", help="Show DHCP statistics") + + parser.add_argument("-e", "--expired", action="store_true", help="Show expired leases") + parser.add_argument("-p", "--pool", type=str, action="store", help="Show lease for specific pool") + parser.add_argument("-j", "--json", action="store_true", default=False, help="Product JSON output") + + args = parser.parse_args() + + # Do nothing if service is not configured + config = Config() + if not config.exists_effective('service dhcp-server'): + print("DHCP service is not configured") + sys.exit(0) + + if args.leases: + if args.expired: + if args.pool: + leases = get_leases(lease_file, state='free', pool=args.pool) + else: + leases = get_leases(lease_file, state='free') + else: + if args.pool: + leases = get_leases(lease_file, state='active', pool=args.pool) + else: + leases = get_leases(lease_file, state='active') + + if args.json: + print(json.dumps(leases, indent=4)) + else: + show_leases(leases) + elif args.statistics: + pools = [] + + # Get relevant pools + if args.pool: + pools = [args.pool] + else: + pools = config.list_effective_nodes("service dhcp-server shared-network-name") + + # Get pool usage stats + stats = [] + for p in pools: + size = get_pool_size(config, p) + leases = len(get_leases(lease_file, state='active', pool=args.pool)) + + if size != 0: + use_percentage = round(leases / size) * 100 + else: + use_percentage = 0 + + if args.json: + pool_stats = {"pool": p, "size": size, "leases": leases, + "available": (size - leases), "percentage": use_percentage} + else: + # For tabulate + pool_stats = [p, size, leases, size - leases, "{0}%".format(use_percentage)] + stats.append(pool_stats) + + # Print stats + if args.json: + print(json.dumps(stats, indent=4)) + else: + show_pool_stats(stats) + else: + print("Use either --leases or --statistics option") diff --git a/src/op_mode/show_dhcpv6.py b/src/op_mode/show_dhcpv6.py new file mode 100755 index 000000000..bf73b92ea --- /dev/null +++ b/src/op_mode/show_dhcpv6.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 json +import argparse +import ipaddress +import tabulate +import sys + +from vyos.config import Config +from isc_dhcp_leases import Lease, IscDhcpLeases + +lease_file = "/config/dhcpdv6.leases" + +def get_lease_data(lease): + data = {} + + # End time may not be present in backup leases + try: + data["expires"] = lease.end.strftime("%Y/%m/%d %H:%M:%S") + except: + data["expires"] = "" + + data["duid"] = lease.host_identifier_string + data["ip"] = lease.ip + + return data + +def get_leases(leases, state=None): + leases = IscDhcpLeases(lease_file).get() + + if state is not None: + leases = list(filter(lambda x: x.binding_state == 'active', leases)) + + return list(map(get_lease_data, leases)) + +def show_leases(leases): + headers = ["IPv6 address", "Lease expiration", "DUID"] + + lease_list = [] + for l in leases: + lease_list.append([l["ip"], l["expires"], l["duid"]]) + + output = tabulate.tabulate(lease_list, headers) + + print(output) + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + + group = parser.add_mutually_exclusive_group() + group.add_argument("-l", "--leases", action="store_true", help="Show DHCP leases") + group.add_argument("-s", "--statistics", action="store_true", help="Show DHCP statistics") + + parser.add_argument("-p", "--pool", type=str, action="store", help="Show lease for specific pool") + parser.add_argument("-j", "--json", action="store_true", default=False, help="Product JSON output") + + args = parser.parse_args() + + # Do nothing if service is not configured + c = Config() + if not c.exists_effective('service dhcpv6-server'): + print("DHCPv6 service is not configured") + sys.exit(0) + + if args.leases: + leases = get_leases(lease_file, state='active') + show_leases(leases) + elif args.statistics: + print("DHCPv6 statistics option is not available") + else: + print("Invalid option") diff --git a/src/op_mode/show_igmpproxy.py b/src/op_mode/show_igmpproxy.py new file mode 100755 index 000000000..5ccc16287 --- /dev/null +++ b/src/op_mode/show_igmpproxy.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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/>. + +# File: show_igmpproxy.py +# Purpose: +# Display istatistics from IPv4 IGMP proxy. +# Used by the "run show ip multicast" command tree. + +import sys +import jinja2 +import argparse +import ipaddress +import socket + +import vyos.config + +# Output Template for "show ip multicast interface" command +# +# Example: +# Interface BytesIn PktsIn BytesOut PktsOut Local +# eth0 0.0b 0 0.0b 0 xxx.xxx.xxx.65 +# eth1 0.0b 0 0.0b 0 xxx.xxx.xx.201 +# eth0.3 0.0b 0 0.0b 0 xxx.xxx.x.7 +# tun1 0.0b 0 0.0b 0 xxx.xxx.xxx.2 +vif_out_tmpl = """ +{%- for r in data %} +{{ "%-10s"|format(r.interface) }} {{ "%-12s"|format(r.bytes_in) }} {{ "%-12s"|format(r.pkts_in) }} {{ "%-12s"|format(r.bytes_out) }} {{ "%-12s"|format(r.pkts_out) }} {{ "%-15s"|format(r.loc) }} +{%- endfor %} +""" + +# Output Template for "show ip multicast mfc" command +# +# Example: +# Group Origin In Out Pkts Bytes Wrong +# xxx.xxx.xxx.250 xxx.xx.xxx.75 -- +# xxx.xxx.xx.124 xx.xxx.xxx.26 -- +mfc_out_tmpl = """ +{%- for r in data %} +{{ "%-15s"|format(r.group) }} {{ "%-15s"|format(r.origin) }} {{ "%-12s"|format(r.pkts) }} {{ "%-12s"|format(r.bytes) }} {{ "%-12s"|format(r.wrong) }} {{ "%-10s"|format(r.iif) }} {{ "%-20s"|format(r.oifs|join(', ')) }} +{%- endfor %} +""" + +parser = argparse.ArgumentParser() +parser.add_argument("--interface", action="store_true", help="Interface Statistics") +parser.add_argument("--mfc", action="store_true", help="Multicast Forwarding Cache") + +def byte_string(size): + # convert size to integer + size = int(size) + + # One Terrabyte + s_TB = 1024 * 1024 * 1024 * 1024 + # One Gigabyte + s_GB = 1024 * 1024 * 1024 + # One Megabyte + s_MB = 1024 * 1024 + # One Kilobyte + s_KB = 1024 + # One Byte + s_B = 1 + + if size > s_TB: + return str(round((size/s_TB), 2)) + 'TB' + elif size > s_GB: + return str(round((size/s_GB), 2)) + 'GB' + elif size > s_MB: + return str(round((size/s_MB), 2)) + 'MB' + elif size > s_KB: + return str(round((size/s_KB), 2)) + 'KB' + else: + return str(round((size/s_B), 2)) + 'b' + + return None + +def kernel2ip(addr): + """ + Convert any given addr from Linux Kernel to a proper, IPv4 address + using the correct host byte order. + """ + + # Convert from hex 'FE000A0A' to decimal '4261415434' + addr = int(addr, 16) + # Kernel ABI _always_ uses network byteorder + addr = socket.ntohl(addr) + + return ipaddress.IPv4Address( addr ) + +def do_mr_vif(): + """ + Read contents of file /proc/net/ip_mr_vif and print a more human + friendly version to the command line. IPv4 addresses present as + 32bit integers in hex format are converted to IPv4 notation, too. + """ + + with open('/proc/net/ip_mr_vif', 'r') as f: + lines = len(f.readlines()) + if lines < 2: + return None + + result = { + 'data': [] + } + + # Build up table format string + table_format = { + 'interface': 'Interface', + 'pkts_in' : 'PktsIn', + 'pkts_out' : 'PktsOut', + 'bytes_in' : 'BytesIn', + 'bytes_out': 'BytesOut', + 'loc' : 'Local' + } + result['data'].append(table_format) + + # read and parse information from /proc filesystema + with open('/proc/net/ip_mr_vif', 'r') as f: + header_line = next(f) + for line in f: + data = { + 'interface': line.split()[1], + 'pkts_in' : line.split()[3], + 'pkts_out' : line.split()[5], + + # convert raw byte number to something more human readable + # Note: could be replaced by Python3 hurry.filesize module + 'bytes_in' : byte_string( line.split()[2] ), + 'bytes_out': byte_string( line.split()[4] ), + + # convert IP address from hex 'FE000A0A' to decimal '4261415434' + 'loc' : kernel2ip( line.split()[7] ), + } + result['data'].append(data) + + return result + +def do_mr_mfc(): + """ + Read contents of file /proc/net/ip_mr_cache and print a more human + friendly version to the command line. IPv4 addresses present as + 32bit integers in hex format are converted to IPv4 notation, too. + """ + + with open('/proc/net/ip_mr_cache', 'r') as f: + lines = len(f.readlines()) + if lines < 2: + return None + + # We need this to convert from interface index to a real interface name + # Thus we also skip the format identifier on list index 0 + vif = do_mr_vif()['data'][1:] + + result = { + 'data': [] + } + + # Build up table format string + table_format = { + 'group' : 'Group', + 'origin': 'Origin', + 'iif' : 'In', + 'oifs' : ['Out'], + 'pkts' : 'Pkts', + 'bytes' : 'Bytes', + 'wrong' : 'Wrong' + } + result['data'].append(table_format) + + # read and parse information from /proc filesystem + with open('/proc/net/ip_mr_cache', 'r') as f: + header_line = next(f) + for line in f: + data = { + # convert IP address from hex 'FE000A0A' to decimal '4261415434' + 'group' : kernel2ip( line.split()[0] ), + 'origin': kernel2ip( line.split()[1] ), + + 'iif' : '--', + 'pkts' : '', + 'bytes' : '', + 'wrong' : '', + 'oifs' : [] + } + + iif = int( line.split()[2] ) + if not ((iif == -1) or (iif == 65535)): + data['pkts'] = line.split()[3] + data['bytes'] = byte_string( line.split()[4] ) + data['wrong'] = line.split()[5] + + # convert index to real interface name + data['iif'] = vif[iif]['interface'] + + # convert each output interface index to a real interface name + for oif in line.split()[6:]: + idx = int( oif.split(':')[0] ) + data['oifs'].append( vif[idx]['interface'] ) + + result['data'].append(data) + + return result + +if __name__ == '__main__': + args = parser.parse_args() + + # Do nothing if service is not configured + c = vyos.config.Config() + if not c.exists_effective('protocols igmp-proxy'): + print("IGMP proxy is not configured") + sys.exit(0) + + if args.interface: + data = do_mr_vif() + if data: + tmpl = jinja2.Template(vif_out_tmpl) + print(tmpl.render(data)) + + sys.exit(0) + elif args.mfc: + data = do_mr_mfc() + if data: + tmpl = jinja2.Template(mfc_out_tmpl) + print(tmpl.render(data)) + + sys.exit(0) + else: + parser.print_help() + sys.exit(1) + diff --git a/src/op_mode/show_ipsec_sa.py b/src/op_mode/show_ipsec_sa.py new file mode 100755 index 000000000..0828743e8 --- /dev/null +++ b/src/op_mode/show_ipsec_sa.py @@ -0,0 +1,100 @@ +#!/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 re +import sys + +import vici +import tabulate +import hurry.filesize + +import vyos.util + + +try: + session = vici.Session() + sas = session.list_sas() +except PermissionError: + print("You do not have a permission to connect to the IPsec daemon") + sys.exit(1) +except ConnectionRefusedError: + print("IPsec is not runing") + sys.exit(1) +except Exception as e: + print("An error occured: {0}".format(e)) + sys.exit(1) + +sa_data = [] + +for sa in sas: + # list_sas() returns a list of single-item dicts + for peer in sa: + parent_sa = sa[peer] + + if parent_sa["state"] == b"ESTABLISHED": + state = "up" + else: + state = "down" + + if state == "up": + uptime = vyos.util.seconds_to_human(parent_sa["established"].decode()) + else: + uptime = "N/A" + + remote_host = parent_sa["remote-host"].decode() + remote_id = parent_sa["remote-id"].decode() + + if remote_host == remote_id: + remote_id = "N/A" + + # The counters can only be obtained from the child SAs + child_sas = parent_sa["child-sas"] + installed_sas = {k: v for k, v in child_sas.items() if v["state"] == b"INSTALLED"} + + if not installed_sas: + data = [peer, state, "N/A", "N/A", "N/A", "N/A", "N/A", "N/A"] + sa_data.append(data) + else: + for csa in installed_sas: + isa = installed_sas[csa] + + bytes_in = hurry.filesize.size(int(isa["bytes-in"].decode())) + bytes_out = hurry.filesize.size(int(isa["bytes-out"].decode())) + bytes_str = "{0}/{1}".format(bytes_in, bytes_out) + + pkts_in = hurry.filesize.size(int(isa["packets-in"].decode()), system=hurry.filesize.si) + pkts_out = hurry.filesize.size(int(isa["packets-out"].decode()), system=hurry.filesize.si) + pkts_str = "{0}/{1}".format(pkts_in, pkts_out) + # Remove B from <1K values + pkts_str = re.sub(r'B', r'', pkts_str) + + enc = isa["encr-alg"].decode() + key_size = isa["encr-keysize"].decode() + hash = isa["integ-alg"].decode() + if "dh-group" in isa: + dh_group = isa["dh-group"].decode() + else: + dh_group = "" + proposal = "{0}_{1}/{2}".format(enc, key_size, hash) + if dh_group: + proposal = "{0}/{1}".format(proposal, dh_group) + + data = [peer, state, uptime, bytes_str, pkts_str, remote_host, remote_id, proposal] + sa_data.append(data) + +headers = ["Connection", "State", "Uptime", "Bytes In/Out", "Packets In/Out", "Remote address", "Remote ID", "Proposal"] +output = tabulate.tabulate(sa_data, headers) +print(output) diff --git a/src/op_mode/system_integrity.py b/src/op_mode/system_integrity.py new file mode 100755 index 000000000..886d94f16 --- /dev/null +++ b/src/op_mode/system_integrity.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 os +import subprocess +import re +import itertools +from datetime import datetime, timedelta + +verf = r'/usr/libexec/vyos/op_mode/version.py' + +def get_sys_build_version(): + if not os.path.exists(verf): + return None + + a = subprocess.check_output(['/usr/libexec/vyos/op_mode/version.py']).decode() + if re.search('^Built on:.+',a, re.M) == None: + return None + + dt = ( re.sub('Built on: +','', re.search('^Built on:.+',a, re.M).group(0)) ) + return datetime.strptime(dt,'%a %d %b %Y %H:%M %Z') + +def check_pkgs(dt): + pkg_diffs = { + 'buildtime' : str(dt), + 'pkg' : {} + } + + pkg_info = os.listdir('/var/lib/dpkg/info/') + for file in pkg_info: + if re.search('\.list$', file): + fts = os.stat('/var/lib/dpkg/info/' + file).st_mtime + dt_str = (datetime.utcfromtimestamp(fts).strftime('%Y-%m-%d %H:%M:%S')) + fdt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S') + if fdt > dt: + pkg_diffs['pkg'].update( { str(re.sub('\.list','',file)) : str(fdt)}) + + if len(pkg_diffs['pkg']) != 0: + return pkg_diffs + else: + return None + +def main(): + dt = get_sys_build_version() + pkgs = check_pkgs(dt) + if pkgs != None: + print ("The following packages don\'t fit the image creation time\nbuild time:\t" + pkgs['buildtime']) + for k, v in pkgs['pkg'].items(): + print ("installed: " + v + '\t' + k) + +if __name__ == '__main__': + sys.exit( main() ) + diff --git a/src/op_mode/wireguard.py b/src/op_mode/wireguard.py new file mode 100755 index 000000000..66622c04c --- /dev/null +++ b/src/op_mode/wireguard.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 argparse +import os +import sys +import subprocess +import syslog as sl + +from vyos import ConfigError + +dir = r'/config/auth/wireguard' +pk = dir + '/private.key' +pub = dir + '/public.key' +psk = dir + '/preshared.key' + +def check_kmod(): + """ check if kmod is loaded, if not load it """ + if not os.path.exists('/sys/module/wireguard'): + sl.syslog(sl.LOG_NOTICE, "loading wirguard kmod") + if os.system('sudo modprobe wireguard') != 0: + sl.syslog(sl.LOG_ERR, "modprobe wireguard failed") + raise ConfigError("modprobe wireguard failed") + +def generate_keypair(): + """ generates a keypair which is stored in /config/auth/wireguard """ + ret = subprocess.call(['wg genkey | tee ' + pk + '|wg pubkey > ' + pub], shell=True) + if ret != 0: + raise ConfigError("wireguard key-pair generation failed") + else: + sl.syslog(sl.LOG_NOTICE, "new keypair wireguard key generated in " + dir) + +def genkey(): + """ helper function to check, regenerate the keypair """ + old_umask = os.umask(0o077) + if os.path.exists(pk) and os.path.exists(pub): + try: + choice = input("You already have a wireguard key-pair already, do you want to re-generate? [y/n] ") + if choice == 'y' or choice == 'Y': + generate_keypair() + except KeyboardInterrupt: + sys.exit(0) + else: + """ if keypair is bing executed from a running iso """ + if not os.path.exists(dir): + os.umask(old_umask) + subprocess.call(['sudo mkdir -p ' + dir], shell=True) + subprocess.call(['sudo chgrp vyattacfg ' + dir], shell=True) + subprocess.call(['sudo chmod 770 ' + dir], shell=True) + generate_keypair() + os.umask(old_umask) + +def showkey(key): + """ helper function to show privkey or pubkey """ + if key == "pub": + if os.path.exists(pub): + print ( open(pub).read().strip() ) + else: + print("no public key found") + + if key == "pk": + if os.path.exists(pk): + print ( open(pk).read().strip() ) + else: + print("no private key found") + +def genpsk(): + """ generates a preshared key and shows it on stdout, it's stroed only in the config """ + subprocess.call(['wg genpsk'], shell=True) + +if __name__ == '__main__': + check_kmod() + + parser = argparse.ArgumentParser(description='wireguard key management') + parser.add_argument('--genkey', action="store_true", help='generate key-pair') + parser.add_argument('--showpub', action="store_true", help='shows public key') + parser.add_argument('--showpriv', action="store_true", help='shows private key') + parser.add_argument('--genpsk', action="store_true", help='generates preshared-key') + args = parser.parse_args() + + try: + if args.genkey: + genkey() + if args.showpub: + showkey("pub") + if args.showpriv: + showkey("pk") + if args.genpsk: + genpsk() + + except ConfigError as e: + print(e) + sys.exit(1) + diff --git a/src/system/normalize-ip b/src/system/normalize-ip new file mode 100755 index 000000000..08f922a8e --- /dev/null +++ b/src/system/normalize-ip @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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/>. +# +# + +# Normalizes IPv6 addresses so that they can be passed to iproute2, +# since iproute2 will not take an address with leading zeroes for an argument + +import re +import sys +import ipaddress + + +if __name__ == '__main__': + if len(sys.argv) < 2: + print("Argument required") + sys.exit(1) + + address_string, prefix_length = re.match(r'(.+)/(.+)', sys.argv[1]).groups() + + try: + address = ipaddress.IPv6Address(address_string) + normalized_address = address.compressed + except ipaddress.AddressValueError: + # It's likely an IPv4 address, do nothing + normalized_address = address_string + + print("{0}/{1}".format(normalized_address, prefix_length)) + sys.exit(0) + diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh new file mode 100755 index 000000000..02bbd4c3c --- /dev/null +++ b/src/system/on-dhcp-event.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +# This script came from ubnt.com forum user "bradd" in the following post +# http://community.ubnt.com/t5/EdgeMAX/Automatic-DNS-resolution-of-DHCP-client-names/td-p/651311 +# It has been modified by Ubiquiti to update the /etc/host file +# instead of adding to the CLI. +# Thanks to forum user "itsmarcos" for bug fix & improvements +# Thanks to forum user "ruudboon" for multiple domain fix +# Thanks to forum user "chibby85" for expire patch and static-mapping + +if [ $# -lt 5 ]; then + echo Invalid args + logger -s -t on-dhcp-event "Invalid args \"$@\"" + exit 1 +fi + +action=$1 +client_name=$2 +client_ip=$3 +client_mac=$4 +domain=$5 +file=/etc/hosts +changes=0 + +if [ -z "$client_name" ]; then + logger -s -t on-dhcp-event "Client name was empty, using MAC \"$client_mac\" instead" + client_name=$(echo "client-"$client_mac | tr : -) +fi + +if [ "$domain" == "..YYZ!" ]; then + client_fqdn_name=$client_name + client_search_expr=$client_name +else + client_fqdn_name=$client_name.$domain + client_search_expr="$client_name\\.$domain" +fi + +case "$action" in + commit) # add mapping for new lease + echo "- new lease event, setting static mapping for host "\ + "$client_fqdn_name (MAC=$client_mac, IP=$client_ip)" + # + # grep fails miserably with \t in the search expression. + # In the following line one <Ctrl-V> <TAB> is used after $client_search_expr + # followed by a single space + grep -q " $client_search_expr #on-dhcp-event " $file + if [ $? == 0 ]; then + echo pattern found, removing + wc1=`cat $file | wc -l` + sudo sed -i "/ $client_search_expr\t #on-dhcp-event /d" $file + wc2=`cat $file | wc -l` + if [ "$wc1" -eq "$wc2" ]; then + echo No change + fi + else + echo pattern NOT found + fi + + # check if hostname already exists (e.g. a static host mapping) + # if so don't overwrite + grep -q " $client_search_expr " $file + if [ $? == 0 ]; then + echo host $client_fqdn_name already exists, exiting + exit 1 + fi + + line="$client_ip\t $client_fqdn_name\t #on-dhcp-event $client_mac" + sudo sh -c "echo -e '$line' >> $file" + ((changes++)) + echo Entry was added + ;; + + release) # delete mapping for released address + echo "- lease release event, deleting static mapping for host $client_fqdn_name" + wc1=`cat $file | wc -l` + sudo sed -i "/ $client_search_expr\t #on-dhcp-event /d" $file + wc2=`cat $file | wc -l` + if [ "$wc1" -eq "$wc2" ]; then + echo No change + else + echo Entry was removed + ((changes++)) + fi + ;; + + *) + logger -s -t on-dhcp-event "Invalid command \"$1\"" + exit 1; + ;; +esac + +if [ $changes -gt 0 ]; then + echo Success + pid=`pgrep pdns_recursor` + if [ -n "$pid" ]; then + sudo rec_control reload-zones + fi +else + echo No changes made +fi +exit 0 + + diff --git a/src/system/post-upgrade b/src/system/post-upgrade new file mode 100755 index 000000000..41b7c01ba --- /dev/null +++ b/src/system/post-upgrade @@ -0,0 +1,3 @@ +#!/bin/sh + +chown -R root:vyattacfg /config diff --git a/src/systemd/tftpd@.service b/src/systemd/tftpd@.service new file mode 100644 index 000000000..e5c289466 --- /dev/null +++ b/src/systemd/tftpd@.service @@ -0,0 +1,14 @@ +[Unit] +Description=TFTP server +After=network.target +RequiresMountsFor=/run + +[Service] +Type=forking +#NotifyAccess=main +EnvironmentFile=-/etc/default/tftpd%I +ExecStart=/usr/sbin/in.tftpd "$DAEMON_ARGS" +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/src/tests/test_config_parser.py b/src/tests/test_config_parser.py new file mode 100644 index 000000000..e47770a7f --- /dev/null +++ b/src/tests/test_config_parser.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 tempfile +import unittest +from unittest import TestCase, mock + +import vyos.configtree + + +class TestConfigParser(TestCase): + def setUp(self): + with open('tests/data/config.valid', 'r') as f: + config_string = f.read() + self.config = vyos.configtree.ConfigTree(config_string) + + def test_top_level_valueless(self): + self.assertTrue(self.config.exists(["top-level-valueless-node"])) + + def test_top_level_leaf(self): + self.assertTrue(self.config.exists(["top-level-leaf-node"])) + self.assertEqual(self.config.return_value(["top-level-leaf-node"]), "foo") + + def test_top_level_tag(self): + self.assertTrue(self.config.exists(["top-level-tag-node"])) + # No sorting is intentional, child order must be preserved + self.assertEqual(self.config.list_nodes(["top-level-tag-node"]), ["foo", "bar"]) + + def test_copy(self): + self.config.copy(["top-level-tag-node", "bar"], ["top-level-tag-node", "baz"]) + print(self.config.to_string()) + self.assertTrue(self.config.exists(["top-level-tag-node", "baz"])) + + def test_copy_duplicate(self): + with self.assertRaises(vyos.configtree.ConfigTreeError): + self.config.copy(["top-level-tag-node", "foo"], ["top-level-tag-node", "bar"]) + + def test_rename(self): + self.config.rename(["top-level-tag-node", "bar"], "quux") + print(self.config.to_string()) + self.assertTrue(self.config.exists(["top-level-tag-node", "quux"])) + + def test_rename_duplicate(self): + with self.assertRaises(vyos.configtree.ConfigTreeError): + self.config.rename(["top-level-tag-node", "foo"], "bar") diff --git a/src/tests/test_host_name.py b/src/tests/test_host_name.py deleted file mode 100644 index 8c5210d5f..000000000 --- a/src/tests/test_host_name.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018 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 tempfile -import unittest -from unittest import TestCase, mock - -from vyos import ConfigError -try: - from src.conf_mode import host_name -except ModuleNotFoundError: # for unittest.main() - import sys - sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) - from src.conf_mode import host_name - - -class TestHostName(TestCase): - - def test_get_config(self): - tests = [ - {'name': 'empty_hostname_and_domain', - 'host-name': '', - 'domain-name': '', - 'expected': {"hostname": 'vyos', "domain": '', "fqdn": 'vyos'}}, - {'name': 'empty_hostname', - 'host-name': '', - 'domain-name': 'localdomain', - 'expected': {"hostname": 'vyos', "domain": 'localdomain', "fqdn": 'vyos.localdomain'}}, - {'name': 'has_hostname', - 'host-name': 'router', - 'domain-name': '', - 'expected': {"hostname": 'router', "domain": '', "fqdn": 'router'}}, - {'name': 'has_hostname_and_domain', - 'host-name': 'router', - 'domain-name': 'localdomain', - 'expected': {"hostname": 'router', "domain": 'localdomain', "fqdn": 'router.localdomain'}}, - ] - for t in tests: - def mocked_return_value(path, default=None): - return t[path.split()[1]] - - with self.subTest(msg=t['name'], hostname=t['host-name'], domain=t['domain-name'], expected=t['expected']): - with mock.patch('vyos.config.Config.return_value', side_effect=mocked_return_value): - actual = host_name.get_config() - self.assertEqual(actual, t['expected']) - - - def test_verify(self): - tests = [ - {'name': 'valid_hostname', - 'config': {"hostname": 'vyos', "domain": 'localdomain', "fqdn": 'vyos.localdomain'}, - 'expected': None}, - {'name': 'invalid_hostname', - 'config': {"hostname": 'vyos..', "domain": '', "fqdn": ''}, - 'expected': ConfigError}, - {'name': 'invalid_hostname_length', - 'config': {"hostname": 'a'*64, "domain": '', "fqdn": ''}, - 'expected': ConfigError} - ] - for t in tests: - with self.subTest(msg=t['name'], config=t['config'], expected=t['expected']): - if t['expected'] is not None: - with self.assertRaises(t['expected']): - host_name.verify(t['config']) - else: - host_name.verify(t['config']) - - def test_generate(self): - tests = [ - {'name': 'has_old_entry', - 'has_old_entry': True, - 'config': {"hostname": 'router', "domain": 'localdomain', "fqdn": 'router.localdomain'}, - 'expected': ['127.0.1.1', 'router.localdomain']}, - {'name': 'no_old_entry', - 'has_old_entry': False, - 'config': {"hostname": 'router', "domain": 'localdomain', "fqdn": 'router.localdomain'}, - 'expected': ['127.0.1.1', 'router.localdomain']}, - ] - for t in tests: - with self.subTest(msg=t['name'], config=t['config'], has_old_entry=t['has_old_entry'], expected=t['expected']): - m = mock.MagicMock(return_value=b'debian') - with mock.patch('subprocess.check_output', m): - host_name.hosts_file = tempfile.mkstemp()[1] - if t['has_old_entry']: - with open(host_name.hosts_file, 'w') as f: - f.writelines(['\n127.0.1.1 {} # VyOS entry'.format('debian')]) - host_name.generate(t['config']) - if len(t['expected']) > 0: - self.assertTrue(os.path.isfile(host_name.hosts_file)) - with open(host_name.hosts_file) as f: - actual = f.read() - self.assertEqual( - t['expected'], actual.splitlines()[1].split()[0:2]) - os.remove(host_name.hosts_file) - else: - self.assertFalse(os.path.isfile(host_name.hosts_file)) - - - def test_apply(self): - tests = [ - {'name': 'valid_hostname', - 'config': {"hostname": 'router', "domain": 'localdomain', "fqdn": 'router.localdomain'}, - 'expected': [mock.call('hostnamectl set-hostname --static router.localdomain'), - mock.call('systemctl restart rsyslog.service')]} - ] - for t in tests: - with self.subTest(msg=t['name'], c=t['config'], expected=t['expected']): - with mock.patch('os.system') as os_system: - host_name.apply(t['config']) - os_system.assert_has_calls(t['expected']) - - -if __name__ == "__main__": - unittest.main() diff --git a/src/tests/test_initial_setup.py b/src/tests/test_initial_setup.py index 023a30723..c4c59b827 100644 --- a/src/tests/test_initial_setup.py +++ b/src/tests/test_initial_setup.py @@ -25,7 +25,7 @@ import vyos.configtree import vyos.initialsetup as vis -class TestHostName(TestCase): +class TestInitialSetup(TestCase): def setUp(self): with open('tests/data/config.boot.default', 'r') as f: config_string = f.read() diff --git a/src/tests/test_ntp.py b/src/tests/test_ntp.py new file mode 100644 index 000000000..1cde490b4 --- /dev/null +++ b/src/tests/test_ntp.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 tempfile +import unittest +from unittest import TestCase, mock +import ipaddress +from contextlib import ExitStack +import textwrap + +from vyos import ConfigError +from vyos.config import Config +try: + from src.conf_mode import ntp +except ModuleNotFoundError: # for unittest.main() + import sys + sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) + from src.conf_mode import ntp + + +class TestNtp(TestCase): + + def test_get_config(self): + tests = [ + { + 'name': 'empty', + 'config': { + 'system ntp': None, + }, + 'expected': None, + }, + { + 'name': 'full-options', + 'config': { + 'system ntp': 'yes', + 'allow-clients address': ['192.0.2.0/24'], + 'listen-address': ['198.51.100.0/24'], + 'server': ['example.com'], + 'server example.com noselect': 'yes', + 'server example.com preempt': 'yes', + 'server example.com prefer': 'yes', + }, + 'expected': { + 'allowed_networks': [{ + 'address': ipaddress.ip_address('192.0.2.0'), + 'netmask': ipaddress.ip_address('255.255.255.0'), + 'network': '192.0.2.0/24', + }], + 'listen_address': ['198.51.100.0/24'], + 'servers': [ + {'name': 'example.com', 'options': ['noselect', 'preempt', 'prefer']} + ] + }, + }, + { + 'name': 'non-options', + 'config': { + 'system ntp': 'yes', + 'allow-clients address': ['192.0.2.0/24'], + 'listen-address': ['198.51.100.0/24'], + 'server': ['example.com'], + }, + 'expected': { + 'allowed_networks': [{ + 'address': ipaddress.ip_address('192.0.2.0'), + 'netmask': ipaddress.ip_address('255.255.255.0'), + 'network': '192.0.2.0/24', + }], + 'listen_address': ['198.51.100.0/24'], + 'servers': [ + {'name': 'example.com', 'options': []} + ] + }, + }, + ] + for case in tests: + def mocked_fn(path): + return case['config'].get(path) + + with self.subTest(msg = case['name']): + m = { + 'return_value': mock.Mock(side_effect = mocked_fn), + 'return_values': mock.Mock(side_effect = mocked_fn), + 'list_nodes': mock.Mock(side_effect = mocked_fn), + 'exists': mock.Mock(side_effect = mocked_fn), + } + with mock.patch.multiple(Config, **m): + actual = ntp.get_config() + self.assertEqual(actual, case['expected']) + + def test_verify(self): + tests = [ + { + 'name': 'none', + 'config': None, + 'expected': None + }, + { + 'name': 'valid', + 'config': { + 'allowed_networks': [{ + 'address': ipaddress.ip_address('192.0.2.1'), + 'netmask': ipaddress.ip_address('255.255.255.0'), + 'network': '192.0.2.0/24', + }], + 'listen_address': ['198.51.100.0/24'], + 'servers': [ + {'name': 'example.com', 'options': ['noselect', 'preempt', 'prefer']} + ] + }, + 'expected': None, + }, + { + 'name': 'not configure servers', + 'config': { + 'allowed_networks': [{ + 'address': ipaddress.ip_address('192.0.2.1'), + 'netmask': ipaddress.ip_address('255.255.255.0'), + 'network': '192.0.2.0/24', + }], + 'servers': [] + }, + 'expected': ConfigError, + }, + { + 'name': 'does not exist in the network', + 'config': { + 'allowed_networks': [{ + 'address': ipaddress.ip_address('192.0.2.1'), + 'netmask': ipaddress.ip_address('255.255.255.0'), + 'network': '192.0.2.0/50', # invalid netmask + }], + 'listen_address': ['198.51.100.0/24'], + 'servers': [ + {'name': 'example.com', 'options': []} + ] + }, + 'expected': ConfigError, + }, + ] + for case in tests: + with self.subTest(msg = case['name']): + if case['expected'] is not None: + with self.assertRaises(case['expected']): + ntp.verify(case['config']) + else: + ntp.verify(case['config']) + + def test_generate(self): + tests = [ + { + 'name': 'empty', + 'config': None, + 'expected': '', + }, + { + 'name': 'valid', + 'config': { + 'allowed_networks': [ + { + 'address': ipaddress.ip_address('192.0.2.1'), + 'netmask': ipaddress.ip_address('255.255.255.0'), + 'network': '192.0.2.0/24', + }, + { + 'address': ipaddress.ip_address('198.51.100.1'), + 'netmask': ipaddress.ip_address('255.255.255.0'), + 'network': '198.51.100.0/24', + }, + ], + 'listen_address': ['198.51.100.0/24'], + 'servers': [ + {'name': '1.example.com', 'options': ['noselect', 'preempt', 'prefer']}, + {'name': '2.example.com', 'options': []}, + ] + }, + 'expected': textwrap.dedent(''' + ### Autogenerated by ntp.py ### + + # + # Non-configurable defaults + # + driftfile /var/lib/ntp/ntp.drift + # By default, only allow ntpd to query time sources, ignore any incoming requests + restrict default noquery nopeer notrap nomodify + # Local users have unrestricted access, allowing reconfiguration via ntpdc + restrict 127.0.0.1 + restrict -6 ::1 + + # + # Configurable section + # + + # Server configuration for: 1.example.com + server 1.example.com iburst noselect preempt prefer + # Server configuration for: 2.example.com + server 2.example.com iburst + + + # Client configuration for network: 192.0.2.0/24 + restrict 192.0.2.1 mask 255.255.255.0 nomodify notrap nopeer + + # Client configuration for network: 198.51.100.0/24 + restrict 198.51.100.1 mask 255.255.255.0 nomodify notrap nopeer + + + + # NTP should listen on configured addresses only + interface ignore wildcard + interface listen 198.51.100.0/24 + + '''), + }, + ] + + for case in tests: + with self.subTest(msg = case['name']): + with tempfile.NamedTemporaryFile() as fp: + ntp.config_file = fp.name + + ntp.generate(case['config']) + actual = fp.file.read().decode('ascii') + print(actual) + self.assertEqual(case['expected'], actual) + + def test_apply(self): + with tempfile.NamedTemporaryFile(delete = False) as fp: + ntp.config_file = fp.name + with mock.patch('os.system') as os_system: + ntp.apply({}) # some configure + os_system.assert_has_calls([ + mock.call('sudo systemctl restart ntp.service'), + ]) + self.assertTrue(os.path.exists(fp.name)) + + ntp.apply(None) # empty configure + os_system.assert_has_calls([ + mock.call('sudo systemctl stop ntp.service'), + ]) + self.assertFalse(os.path.exists(fp.name)) + +if __name__ == "__main__": + unittest.main() diff --git a/src/utils/vyos-config-to-commands b/src/utils/vyos-config-to-commands index 8b50f7c5d..7147bc5ff 100755 --- a/src/utils/vyos-config-to-commands +++ b/src/utils/vyos-config-to-commands @@ -19,6 +19,14 @@ else: except OSError as e: print("Could not read config file {0}: {1}".format(file_name, e), file=sys.stderr) +# This script is usually called with the output of "cli-shell-api showCfg", which does not +# escape backslashes. "ConfigTree()" expects escaped backslashes when parsing a config +# string (and also prints them itself). Therefore this script would fail. +# Manually escape backslashes here to handle backslashes in any configuration strings +# properly. The alternative would be to modify the output of "cli-shell-api showCfg", +# but that may be break other things who rely on that specific output. +config_string = config_string.replace("\\", "\\\\") + try: config = ConfigTree(config_string) commands = config.to_commands() diff --git a/src/validators/mac-address b/src/validators/mac-address new file mode 100755 index 000000000..d6ec6d712 --- /dev/null +++ b/src/validators/mac-address @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 re + +if len(sys.argv) == 2: + pattern = "^([0-9A-Fa-f]{2}[:]){5}([0-9A-Fa-f]{2})$" + if re.match(pattern, sys.argv[1]): + sys.exit(0) + else: + sys.exit(1) + |