diff options
-rw-r--r-- | debian/changelog | 6 | ||||
-rw-r--r-- | debian/control | 2 | ||||
-rwxr-xr-x | debian/rules | 5 | ||||
-rw-r--r-- | interface-definitions/vrrp.xml | 255 | ||||
-rw-r--r-- | op-mode-definitions/vrrp.xml | 37 | ||||
-rw-r--r-- | python/vyos/keepalived.py | 153 | ||||
-rwxr-xr-x | src/conf_mode/vrrp.py | 338 | ||||
-rwxr-xr-x | src/migration-scripts/vrrp/1-to-2 | 270 | ||||
-rwxr-xr-x | src/op_mode/vrrp.py | 98 | ||||
-rwxr-xr-x | src/system/vrrp-script-wrapper.py | 77 |
10 files changed, 1240 insertions, 1 deletions
diff --git a/debian/changelog b/debian/changelog index 84836fb29..e8519e87a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +vyos-1x (1.2.0-1) unstable; urgency=medium + + * T666, T616: new implementation of the VRRP CLI. + + -- Daniil Baturin <daniil@baturin.org> Fri, 27 Jul 2018 10:25:52 +0200 + vyos-1x (1.0.6) unstable; urgency=medium * T736: Rewrite remote logging (syslog) to XML/Python diff --git a/debian/control b/debian/control index 8a6bf3da6..c87e7452c 100644 --- a/debian/control +++ b/debian/control @@ -35,7 +35,7 @@ Depends: python3, iputils-arping, libvyosconfig0, beep, - keepalived, + keepalived (>=2.0.5), ${shlibs:Depends}, ${misc:Depends} Description: VyOS configuration scripts and data diff --git a/debian/rules b/debian/rules index d284471ec..15dfec551 100755 --- a/debian/rules +++ b/debian/rules @@ -9,6 +9,7 @@ VYOS_CFG_TMPL_DIR := /opt/vyatta/share/vyatta-cfg/templates VYOS_OP_TMPL_DIR := /opt/vyatta/share/vyatta-op/templates MIGRATION_SCRIPTS_DIR := /opt/vyatta/etc/config-migrate/migrate/ +SYSTEM_SCRIPTS_DIR := usr/libexec/vyos/system %: dh $@ --with python3, --with quilt @@ -48,6 +49,10 @@ override_dh_auto_install: mkdir -p $(DIR)/$(MIGRATION_SCRIPTS_DIR) cp -r src/migration-scripts/* $(DIR)/$(MIGRATION_SCRIPTS_DIR) + # Install system scripts + mkdir -p $(DIR)/$(SYSTEM_SCRIPTS_DIR) + cp -r src/system/* $(DIR)/$(SYSTEM_SCRIPTS_DIR) + # Install configuration command definitions mkdir -p $(DIR)/$(VYOS_CFG_TMPL_DIR) cp -r templates-cfg/* $(DIR)/$(VYOS_CFG_TMPL_DIR) diff --git a/interface-definitions/vrrp.xml b/interface-definitions/vrrp.xml new file mode 100644 index 000000000..72419efe9 --- /dev/null +++ b/interface-definitions/vrrp.xml @@ -0,0 +1,255 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="high-availability"> + <properties> + <help>High availability settings</help> + </properties> + <children> + <node name="vrrp" owner="${vyos_conf_scripts_dir}/vrrp.py"> + <properties> + <priority>800</priority> <!-- after all interfaces and conntrack-sync --> + <help>Virtual Router Redundancy Protocol settings</help> + </properties> + <children> + <tagNode name="group"> + <properties> + <help>VRRP group</help> + </properties> + <children> + <leafNode name="interface"> + <properties> + <help>Network interface</help> + <completionHelp> + <script>${vyos_completion_dir}/list_interfaces.py --broadcast</script> + </completionHelp> + </properties> + </leafNode> + <leafNode name="advertise-interval"> + <properties> + <help>Advertise interval</help> + <valueHelp> + <format>1-255</format> + <description>Advertise interval in seconds (default: 1)</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-255"/> + </constraint> + </properties> + </leafNode> + <node name="authentication"> + <properties> + <help>VRRP authentication</help> + </properties> + <children> + <leafNode name="password"> + <properties> + <help>VRRP password</help> + <valueHelp> + <format>text</format> + <description>Password string (up to 8 characters)</description> + </valueHelp> + <constraint> + <regex>.{1,8}</regex> + </constraint> + <constraintErrorMessage>Password must not be longer than 8 characters</constraintErrorMessage> + </properties> + </leafNode> + <leafNode name="type"> + <properties> + <help>Authentication type</help> + <completionHelp> + <list>plaintext-password ah</list> + </completionHelp> + <constraint> + <regex>(plaintext-password|ah)</regex> + </constraint> + <constraintErrorMessage>Authentication type must be plaintext-password or ah</constraintErrorMessage> + </properties> + </leafNode> + </children> + </node> + <leafNode name="description"> + <properties> + <help>Group description</help> + </properties> + </leafNode> + <leafNode name="disable"> + <properties> + <valueless/> + <help>Disable VRRP group</help> + </properties> + </leafNode> + <node name="health-check"> + <properties> + <help>Health check script</help> + </properties> + <children> + <leafNode name="failure-count"> + <properties> + <help>Health check failure count required for transition to fault (default: 3)</help> + <constraint> + <validator name="numeric" argument="--positive" /> + </constraint> + </properties> + </leafNode> + <leafNode name="interval"> + <properties> + <help>Health check execution interval in seconds (default: 60)</help> + <constraint> + <validator name="numeric" argument="--positive"/> + </constraint> + </properties> + </leafNode> + <leafNode name="script"> + <properties> + <help>Health check script file</help> + <constraint> + <validator name="script"/> + </constraint> + </properties> + </leafNode> + </children> + </node> + <leafNode name="hello-source-address"> + <properties> + <help>VRRP hello source address (IPv4 or IPv6)</help> + <constraint> + <validator name="ipv4-address"/> + <validator name="ipv6-address"/> + </constraint> + <valueHelp> + <format><IPv4|IPv6></format> + <description>IPv4 or IPv6 hello source address</description> + </valueHelp> + </properties> + </leafNode> + <leafNode name="peer-address"> + <properties> + <help>Unicast VRRP peer address (IPv4 or IPv6)</help> + <constraint> + <validator name="ipv4-address"/> + <validator name="ipv6-address"/> + </constraint> + <valueHelp> + <format><IPv4|IPv6></format> + <description>IPv4 or IPv6 unicast peer address</description> + </valueHelp> + </properties> + </leafNode> + <leafNode name="no-preempt"> + <properties> + <valueless/> + <help>Disable master preemption</help> + </properties> + </leafNode> + <leafNode name="preempt-delay"> + <properties> + <help>Preempt delay (in seconds)</help> + <constraint> + <validator name="numeric" argument="--positive"/> + </constraint> + </properties> + </leafNode> + <leafNode name="priority"> + <properties> + <help>Router priority</help> + <constraint> + <validator name="numeric" argument="--range 1-255"/> + </constraint> + <valueHelp> + <format>1-255</format> + <description>Router priority (default: 100)</description> + </valueHelp> + </properties> + </leafNode> + <leafNode name="rfc3768-compatibility"> + <properties> + <valueless/> + <help>Use VRRP virtual MAC address as per RFC3768</help> + </properties> + </leafNode> + <node name="transition-script"> + <properties> + <help>VRRP transition scripts</help> + </properties> + <children> + <leafNode name="master"> + <properties> + <help>Script to run on VRRP state transition to master</help> + <constraint> + <validator name="script"/> + </constraint> + </properties> + </leafNode> + <leafNode name="backup"> + <properties> + <help>Script to run on VRRP state transition to backup</help> + <constraint> + <validator name="script"/> + </constraint> + </properties> + </leafNode> + <leafNode name="fault"> + <properties> + <help>Script to run on VRRP state transition to fault</help> + <constraint> + <validator name="script"/> + </constraint> + </properties> + </leafNode> + </children> + </node> + <leafNode name="virtual-address"> + <properties> + <multi/> + <help>Virtual address (IPv4 or IPv6, but they must not be mixed in one group)</help> + <constraint> + <validator name="ipv4-host"/> + <validator name="ipv6-host"/> + </constraint> + <constraintErrorMessage>Virtual address must be a valid IPv4 or IPv6 address with prefix length (e.g. 192.0.2.3/24 or 2001:db8:ff::10/64)</constraintErrorMessage> + <valueHelp> + <format><IPv4|IPv6></format> + <description>IPv4 or IPv6 virtual address</description> + </valueHelp> + </properties> + </leafNode> + <leafNode name="vrid"> + <properties> + <help>Virtual router identifier</help> + <constraint> + <validator name="numeric" argument="--range 1-255"/> + </constraint> + <valueHelp> + <format>1-255</format> + <description>Virtual router identifier</description> + </valueHelp> + </properties> + </leafNode> + </children> + </tagNode> + <tagNode name="sync-group"> + <properties> + <help>VRRP sync group</help> + </properties> + <children> + <leafNode name="member"> + <properties> + <multi/> + <help>Sync group member</help> + <valueHelp> + <format>text</format> + <description>VRRP group name</description> + </valueHelp> + <completionHelp> + <path>high-availability vrrp group</path> + </completionHelp> + </properties> + </leafNode> + </children> + </tagNode> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/op-mode-definitions/vrrp.xml b/op-mode-definitions/vrrp.xml new file mode 100644 index 000000000..856fb440d --- /dev/null +++ b/op-mode-definitions/vrrp.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interfaceDefinition> + <node name="show"> + <children> + <node name="vrrp"> + <properties> + <help>Show VRRP (Virtual Router Redundancy Protocol) information</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vrrp.py --summary</command> + <children> + <node name="statistics"> + <properties> + <help>Show VRRP statistics</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vrrp.py --statistics</command> + </node> + <node name="detail"> + <properties> + <help>Show detailed VRRP state information</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vrrp.py --data</command> + </node> + </children> + </node> + </children> + </node> + <node name="restart"> + <children> + <node name="vrrp"> + <properties> + <help>Restart the VRRP (Virtual Router Redundancy Protocol) process</help> + </properties> + <command>sudo systemctl restart keepalived.service</command> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/python/vyos/keepalived.py b/python/vyos/keepalived.py new file mode 100644 index 000000000..4114aa736 --- /dev/null +++ b/python/vyos/keepalived.py @@ -0,0 +1,153 @@ +# Copyright 2018 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import re +import os +import signal +import json + +import vyos.util + + +pid_file = '/var/run/keepalived.pid' +state_file = '/tmp/keepalived.data' +stats_file = '/tmp/keepalived.stats' +json_file = '/tmp/keepalived.json' + +state_dir = '/var/run/vyos/vrrp/' + +def vrrp_running(): + if not os.path.exists(vyos.keepalived.pid_file) \ + or not vyos.util.process_running(vyos.keepalived.pid_file): + return False + else: + return True + +def keepalived_running(): + return vyos.util.process_running(pid_file) + +def force_state_data_dump(): + pid = vyos.util.read_file(pid_file) + os.kill(int(pid), signal.SIGUSR1) + +def force_stats_dump(): + pid = vyos.util.read_file(pid_file) + os.kill(int(pid), signal.SIGUSR2) + +def force_json_dump(): + pid = vyos.util.read_file(pid_file) + os.kill(int(pid), signal.SIGRTMIN+2) + +def get_json_data(): + with open(json_file, 'r') as f: + j = json.load(f) + return j + +def get_statistics(): + return vyos.util.read_file(stats_file) + +def get_state_data(): + return vyos.util.read_file(state_file) + +def decode_state(code): + state = None + if code == 0: + state = "INIT" + elif code == 1: + state = "BACKUP" + elif code == 2: + state = "MASTER" + elif code == 3: + state = "FAULT" + else: + state = "UNKNOWN" + + return state + +## The functions are mainly for transition script wrappers +## to compensate for the fact that keepalived doesn't keep persistent +## state between reloads. +def get_old_state(group): + file = os.path.join(state_dir, "{0}.state".format(group)) + if os.path.exists(file): + with open(file, 'r') as f: + data = f.read().strip() + return data + else: + return None + +def save_state(group, state): + if not os.path.exists(state_dir): + os.makedirs(state_dir) + + file = os.path.join(state_dir, "{0}.state".format(group)) + with open(file, 'w') as f: + f.write(state) + +## These functions are for the old, and hopefully obsolete plaintext +## (non machine-readable) data format introduced by Vyatta back in the days +## They are kept here just in case, if JSON output option turns out or becomes +## insufficient. + +def read_state_data(): + with open(state_file, 'r') as f: + lines = f.readlines() + return lines + +def parse_keepalived_data(data_lines): + vrrp_groups = {} + + # Scratch variable + group_name = None + + # Sadly there is no explicit end marker in that format, so we have + # only two states, one before the first VRRP instance is encountered + # and one after an instance/"group" was encountered + # We'll set group_name once the first group is encountered, + # and assume we are inside a group if it's set afterwards + # + # It may not be necessary since the keywords found inside groups and before + # the VRRP Topology section seem to have no intersection, + # but better safe than sorry. + + for line in data_lines: + if re.match(r'^\s*VRRP Instance', line, re.IGNORECASE): + # Example: "VRRP Instance = Foo" + name = re.match(r'^\s*VRRP Instance\s+=\s+(.*)$', line, re.IGNORECASE).groups()[0].strip() + group_name = name + vrrp_groups[name] = {} + elif re.match(r'^\s*State', line, re.IGNORECASE) and group_name: + # Example: " State = MASTER" + group_state = re.match(r'^\s*State\s+=\s+(.*)$', line, re.IGNORECASE).groups()[0].strip() + vrrp_groups[group_name]["state"] = group_state + elif re.match(r'^\s*Last transition', line, re.IGNORECASE) and group_name: + # Example: " Last transition = 1532043820 (Thu Jul 19 23:43:40 2018)" + trans_time = re.match(r'^\s*Last transition\s+=\s+(\d+)\s', line, re.IGNORECASE).groups()[0] + vrrp_groups[group_name]["last_transition"] = trans_time + elif re.match(r'^\s*Interface', line, re.IGNORECASE) and group_name: + # Example: " Interface = eth0.30" + interface = re.match(r'\s*Interface\s+=\s+(.*)$', line, re.IGNORECASE).groups()[0].strip() + vrrp_groups[group_name]["interface"] = interface + elif re.match(r'^\s*Virtual Router ID', line, re.IGNORECASE) and group_name: + # Example: " Virtual Router ID = 14" + vrid = re.match(r'^\s*Virtual Router ID\s+=\s+(.*)$', line, re.IGNORECASE).groups()[0].strip() + vrrp_groups[group_name]["vrid"] = vrid + elif re.match(r'^\s*------< Interfaces', line, re.IGNORECASE): + # Interfaces section appears to always be present, + # and there's nothing of interest for us below that section, + # so we use it as an end of input marker + break + + return vrrp_groups diff --git a/src/conf_mode/vrrp.py b/src/conf_mode/vrrp.py new file mode 100755 index 000000000..155b71aa8 --- /dev/null +++ b/src/conf_mode/vrrp.py @@ -0,0 +1,338 @@ +#!/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 subprocess +import ipaddress + +import jinja2 + +import vyos.config +import vyos.keepalived + +from vyos import ConfigError + + +config_file = "/etc/keepalived/keepalived.conf" + +config_tmpl = """ +# Autogenerated by VyOS +# Do not edit this file, all your changes will be lost +# on next commit or reboot + +{% for group in groups -%} + +{% if group.health_check_script -%} +vrrp_script healthcheck_{{ group.name }} { + script {{ group.health_check_script }} + interval {{ group.health_check_interval }} + fall {{ group.health_check_count }} + rise 1 + +} +{% endif %} + +vrrp_instance {{ group.name }} { + {% if group.description -%} + # {{ group.description }} + {% endif -%} + + state BACKUP + interface {{ group.interface }} + virtual_router_id {{ group.vrid }} + priority {{ group.priority }} + advert_int {{ group.advertise_interval }} + + {% if group.preempt -%} + preempt_delay {{ group.preempt_delay }} + {% else -%} + nopreempt + {% endif -%} + + {% if group.peer_address -%} + unicast_peer { {{ group.peer_address }} } + {% endif -%} + + {% if group.hello_source -%} + {%- if group.peer_address -%} + unicast_src_ip {{ group.hello_source }} + {%- else -%} + mcast_src_ip {{ group.hello_source }} + {%- endif %} + {% endif -%} + + {% if group.use_vmac -%} + use_vmac {{group.interface}}v{{group.vrid}} + {% endif -%} + + {% if group.auth_password -%} + authentication { + auth_pass {{ group.auth_password }} + auth_type {{ group.auth_type }} + } + {% endif -%} + + virtual_ipaddress { + {% for addr in group.virtual_addresses -%} + {{ addr }} + {% endfor -%} + } + + {% if group.health_check_script -%} + track_script { + healthcheck_{{ group.name }} + } + {% endif -%} + + {% if group.master_script -%} + notify_master "/usr/libexec/vyos/system/vrrp-script-wrapper.py --script {{ group.master_script }} --state master --group {{ group.name }} --interface {{ group.interface }}" + {% endif -%} + + {% if group.backup_script -%} + notify_backup "/usr/libexec/vyos/system/vrrp-script-wrapper.py --script {{ group.backup_script }} --state backup --group {{ group.name }} --interface {{ group.interface }}" + {% endif -%} + + {% if group.fault_script -%} + notify_fault "/usr/libexec/vyos/system/vrrp-script-wrapper.py --script {{ group.fault_script }} --state fault --group {{ group.name }} --interface {{ group.interface }}" + {% endif -%} +} + +{% endfor -%} + +{% for sync_group in sync_groups -%} +vrrp_sync_group {{ sync_group.name }} { + group { + {% for member in sync_group.members -%} + {{ member }} + {% endfor -%} + } + + {% if sync_group.conntrack_sync -%} + notify_master "/opt/vyatta/sbin/vyatta-vrrp-conntracksync.sh master {{ sync_group.name }}" + notify_backup "/opt/vyatta/sbin/vyatta-vrrp-conntracksync.sh backup {{ sync_group.name }}" + notify_fault "/opt/vyatta/sbin/vyatta-vrrp-conntracksync.sh fault {{ sync_group.name }}" + {% endif -%} +} + +{% endfor -%} + +""" + +def get_config(): + vrrp_groups = [] + sync_groups = [] + + config = vyos.config.Config() + + # Get the VRRP groups + for group_name in config.list_nodes("high-availability vrrp group"): + config.set_level("high-availability vrrp group {0}".format(group_name)) + + # Retrieve the values + group = {"preempt": True, "use_vmac": False, "disable": False} + + if config.exists("disable"): + group["disable"] = True + + group["name"] = group_name + group["vrid"] = config.return_value("vrid") + group["interface"] = config.return_value("interface") + group["description"] = config.return_value("description") + group["advertise_interval"] = config.return_value("advertise-interval") + group["priority"] = config.return_value("priority") + group["hello_source"] = config.return_value("hello-source-address") + group["peer_address"] = config.return_value("peer-address") + group["sync_group"] = config.return_value("sync-group") + group["preempt_delay"] = config.return_value("preempt-delay") + group["virtual_addresses"] = config.return_values("virtual-address") + + group["auth_password"] = config.return_value("authentication password") + group["auth_type"] = config.return_value("authentication type") + + group["health_check_script"] = config.return_value("health-check script") + group["health_check_interval"] = config.return_value("health-check interval") + group["health_check_count"] = config.return_value("health-check failure-count") + + group["master_script"] = config.return_value("transition-script master") + group["backup_script"] = config.return_value("transition-script backup") + group["fault_script"] = config.return_value("transition-script fault") + + if config.exists("no-preempt"): + group["preempt"] = False + if config.exists("rfc3768-compatibility"): + group["use_vmac"] = True + + # Substitute defaults where applicable + if not group["advertise_interval"]: + group["advertise_interval"] = 1 + if not group["priority"]: + group["priority"] = 100 + if not group["preempt_delay"]: + group["preempt_delay"] = 5 * 60 + if not group["health_check_interval"]: + group["health_check_interval"] = 60 + if not group["health_check_count"]: + group["health_check_count"] = 3 + + # FIXUP: translate our option for auth type to keepalived's syntax + # for simplicity + if group["auth_type"]: + if group["auth_type"] == "plaintext-password": + group["auth_type"] = "PASS" + else: + group["auth_type"] = "AH" + + vrrp_groups.append(group) + + config.set_level("") + + # Get the sync group used for conntrack-sync + conntrack_sync_group = None + if config.exists("service conntrack-sync failover-mechanism vrrp"): + conntrack_sync_group = config.return_value("service conntrack-sync failover-mechanism vrrp sync-group") + + # Get the sync groups + for sync_group_name in config.list_nodes("high-availability vrrp sync-group"): + config.set_level("high-availability vrrp sync-group {0}".format(sync_group_name)) + + sync_group = {"conntrack_sync": False} + sync_group["name"] = sync_group_name + sync_group["members"] = config.return_values("member") + if conntrack_sync_group: + if conntrack_sync_group == sync_group_name: + sync_group["conntrack_sync"] = True + + sync_groups.append(sync_group) + + return (vrrp_groups, sync_groups) + +def verify(data): + vrrp_groups, sync_groups = data + + for group in vrrp_groups: + # Check required fields + if not group["vrid"]: + raise ConfigError("vrid is required but not set in VRRP group {0}".format(group["name"])) + if not group["interface"]: + raise ConfigError("interface is required but not set in VRRP group {0}".format(group["name"])) + if not group["virtual_addresses"]: + raise ConfigError("virtual-address is required but not set in VRRP group {0}".format(group["name"])) + + if group["auth_password"] and (not group["auth_type"]): + raise ConfigError("authentication type is required but not set in VRRP group {0}".format(group["name"])) + + # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction + + # XXX: filter on map object is destructive, so we force it to list. + # Additionally, filter objects always evaluate to True, empty or not, + # so we force them to lists as well. + vaddrs = list(map(lambda i: ipaddress.ip_interface(i), group["virtual_addresses"])) + vaddrs4 = list(filter(lambda x: isinstance(x, ipaddress.IPv4Interface), vaddrs)) + vaddrs6 = list(filter(lambda x: isinstance(x, ipaddress.IPv6Interface), vaddrs)) + + if vaddrs4 and vaddrs6: + raise ConfigError("VRRP group {0} mixes IPv4 and IPv6 virtual addresses, this is not allowed. Create separate groups for IPv4 and IPv6".format(group["name"])) + + if vaddrs4: + if group["hello_source"]: + hsa = ipaddress.ip_address(group["hello_source"]) + if isinstance(hsa, ipaddress.IPv6Address): + raise ConfigError("VRRP group {0} uses IPv4 but its hello-source-address is IPv6".format(group["name"])) + if group["peer_address"]: + pa = ipaddress.ip_address(group["peer_address"]) + if isinstance(hsa, ipaddress.IPv6Address): + raise ConfigError("VRRP group {0} uses IPv4 but its peer-address is IPv6".format(group["name"])) + + if vaddrs6: + if group["hello_source"]: + hsa = ipaddress.ip_address(group["hello_source"]) + if isinstance(hsa, ipaddress.IPv4Address): + raise ConfigError("VRRP group {0} uses IPv6 but its hello-source-address is IPv4".format(group["name"])) + if group["peer_address"]: + pa = ipaddress.ip_address(group["peer_address"]) + if isinstance(hsa, ipaddress.IPv4Address): + raise ConfigError("VRRP group {0} uses IPv6 but its peer-address is IPv4".format(group["name"])) + + # Disallow same VRID on multiple interfaces + _groups = sorted(vrrp_groups, key=(lambda x: x["interface"])) + count = len(_groups) - 1 + index = 0 + while (index < count): + if _groups[index]["vrid"] == _groups[index + 1]["vrid"]: + 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: + index += 1 + + # Check sync groups + vrrp_group_names = list(map(lambda x: x["name"], vrrp_groups)) + + for sync_group in sync_groups: + for m in sync_group["members"]: + if not (m in vrrp_group_names): + raise ConfigError("VRRP sync-group {0} refers to VRRP group {1}, but group {1} does not exist".format(sync_group["name"], m)) + +def generate(data): + vrrp_groups, sync_groups = data + + # Remove disabled groups from the sync group member lists + for sync_group in sync_groups: + for member in sync_group["members"]: + g = list(filter(lambda x: x["name"] == member, vrrp_groups))[0] + if g["disable"]: + print("Warning: ignoring disabled VRRP group {0} in sync-group {1}".format(g["name"], sync_group["name"])) + # Filter out disabled groups + vrrp_groups = list(filter(lambda x: x["disable"] != True, vrrp_groups)) + + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render({"groups": vrrp_groups, "sync_groups": sync_groups}) + + with open(config_file, 'w') as f: + f.write(config_text) + return None + +def apply(data): + vrrp_groups, sync_groups = data + if vrrp_groups: + if not vyos.keepalived.vrrp_running(): + print("Starting the VRRP process") + ret = subprocess.call("sudo systemctl restart keepalived.service", shell=True) + else: + print("Reloading the VRRP process") + ret = subprocess.call("sudo systemctl reload keepalived.service", shell=True) + + if ret != 0: + raise ConfigError("keepalived failed to start") + else: + # VRRP is removed in the commit + print("Stopping the VRRP process") + subprocess.call("sudo systemctl stop keepalived.service", shell=True) + os.unlink(config_file) + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print("VRRP error: {0}".format(str(e))) + sys.exit(1) diff --git a/src/migration-scripts/vrrp/1-to-2 b/src/migration-scripts/vrrp/1-to-2 new file mode 100755 index 000000000..b2e61dd38 --- /dev/null +++ b/src/migration-scripts/vrrp/1-to-2 @@ -0,0 +1,270 @@ +#!/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 +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) + +# Convert the old VRRP syntax to the new syntax + +# The old approach was to put VRRP groups inside interfaces, +# as in "interfaces ethernet eth0 vrrp vrrp-group 10 ...". +# It was supported only under ethernet and bonding and their +# respective vif, vif-s, and vif-c subinterfaces + +def get_vrrp_group(path): + group = {"preempt": True, "rfc_compatibility": False, "disable": False} + + if config.exists(path + ["advertise-interval"]): + group["advertise_interval"] = config.return_value(path + ["advertise-interval"]) + + if config.exists(path + ["description"]): + group["description"] = config.return_value(path + ["description"]) + + if config.exists(path + ["disable"]): + group["disable"] = True + + if config.exists(path + ["hello-source-address"]): + group["hello_source"] = config.return_value(path + ["hello-source-address"]) + + # 1.1.8 didn't have it, but earlier 1.2.0 did, we don't want to break + # configs of early adopters! + if config.exists(path + ["peer-address"]): + group["peer_address"] = config.return_value(path + ["peer-address"]) + + if config.exists(path + ["preempt"]): + preempt = config.return_value(path + ["preempt"]) + if preempt == "false": + group["preempt"] = False + + if config.exists(path + ["rfc3768-compatibility"]): + group["rfc_compatibility"] = True + + if config.exists(path + ["preempt-delay"]): + group["preempt_delay"] = config.return_value(path + ["preempt-delay"]) + + if config.exists(path + ["priority"]): + group["priority"] = config.return_value(path + ["priority"]) + + if config.exists(path + ["sync-group"]): + group["sync_group"] = config.return_value(path + ["sync-group"]) + + if config.exists(path + ["authentication", "type"]): + group["auth_type"] = config.return_value(path + ["authentication", "type"]) + + if config.exists(path + ["authentication", "password"]): + group["auth_password"] = config.return_value(path + ["authentication", "password"]) + + if config.exists(path + ["virtual-address"]): + group["virtual_addresses"] = config.return_values(path + ["virtual-address"]) + + if config.exists(path + ["run-transition-scripts"]): + if config.exists(path + ["run-transition-scripts", "master"]): + group["master_script"] = config.return_value(path + ["run-transition-scripts", "master"]) + if config.exists(path + ["run-transition-scripts", "backup"]): + group["backup_script"] = config.return_value(path + ["run-transition-scripts", "backup"]) + if config.exists(path + ["run-transition-scripts", "fault"]): + group["fault_script"] = config.return_value(path + ["run-transition-scripts", "fault"]) + + # Also not present in 1.1.8, but supported by earlier 1.2.0 + if config.exists(path + ["health-check"]): + if config.exists(path + ["health-check", "interval"]): + group["health_check_interval"] = config.return_value(path + ["health-check", "interval"]) + if config.exists(path + ["health-check", "failure-count"]): + group["health_check_count"] = config.return_value(path + ["health-check", "failure-count"]) + if config.exists(path + ["health-check", "script"]): + group["health_check_script"] = config.return_value(path + ["health-check", "script"]) + + return group + +# Since VRRP is all over the place, there's no way to just check a path and exit early +# if it doesn't exist, we have to walk all interfaces and collect VRRP settings from them. +# Only if no data is collected from any interface we can conclude that VRRP is not configured +# and exit. + +groups = [] +base_paths = [] + +if config.exists(["interfaces", "ethernet"]): + base_paths.append("ethernet") +if config.exists(["interfaces", "bonding"]): + base_paths.append("bonding") + +for bp in base_paths: + parent_path = ["interfaces", bp] + + parent_intfs = config.list_nodes(parent_path) + + for pi in parent_intfs: + # Extract VRRP groups from the parent interface + vg_path =[pi, "vrrp", "vrrp-group"] + if config.exists(parent_path + vg_path): + pgroups = config.list_nodes(parent_path + vg_path) + for pg in pgroups: + g = get_vrrp_group(parent_path + vg_path + [pg]) + g["interface"] = pi + g["vrid"] = pg + groups.append(g) + + # Delete the VRRP subtree + # If left in place, configs will not load correctly + config.delete(parent_path + [pi, "vrrp"]) + + # Extract VRRP groups from 802.1q VLAN interfaces + if config.exists(parent_path + [pi, "vif"]): + vifs = config.list_nodes(parent_path + [pi, "vif"]) + for vif in vifs: + vif_vg_path = [pi, "vif", vif, "vrrp", "vrrp-group"] + if config.exists(parent_path + vif_vg_path): + vifgroups = config.list_nodes(parent_path + vif_vg_path) + for vif_group in vifgroups: + g = get_vrrp_group(parent_path + vif_vg_path + [vif_group]) + g["interface"] = "{0}.{1}".format(pi, vif) + g["vrid"] = vif_group + groups.append(g) + + config.delete(parent_path + [pi, "vif", vif, "vrrp"]) + + # Extract VRRP groups from 802.3ad QinQ service VLAN interfaces + if config.exists(parent_path + [pi, "vif-s"]): + vif_ss = config.list_nodes(parent_path + [pi, "vif-s"]) + for vif_s in vif_ss: + vifs_vg_path = [pi, "vif-s", vif_s, "vrrp", "vrrp-group"] + if config.exists(parent_path + vifs_vg_path): + vifsgroups = config.list_nodes(parent_path + vifs_vg_path) + for vifs_group in vifsgroups: + g = get_vrrp_group(parent_path + vifs_vg_path + [vifs_group]) + g["interface"] = "{0}.{1}".format(pi, vif_s) + g["vrid"] = vifs_group + groups.append(g) + + config.delete(parent_path + [pi, "vif-s", vif_s, "vrrp"]) + + # Extract VRRP groups from QinQ client VLAN interfaces nested in the vif-s + if config.exists(parent_path + [pi, "vif-s", vif_s, "vif-c"]): + vif_cs = config.list_nodes(parent_path + [pi, "vif-s", vif_s, "vif-c"]) + for vif_c in vif_cs: + vifc_vg_path = [pi, "vif-s", vif_s, "vif-c", vif_c, "vrrp", "vrrp-group"] + vifcgroups = config.list_nodes(parent_path + vifc_vg_path) + for vifc_group in vifcgroups: + g = get_vrrp_group(parent_path + vifc_vg_path + [vifc_group]) + g["interface"] = "{0}.{1}.{2}".format(pi, vif_s, vif_c) + g["vrid"] = vifc_group + groups.append(g) + + config.delete(parent_path + [pi, "vif-s", vif_s, "vif-c", vif_c, "vrrp"]) + +# If nothing was collected before this point, it means the config has no VRRP setup +if not groups: + sys.exit(0) + +# Otherwise, there is VRRP to convert + +# Now convert the collected groups to the new syntax +base_group_path = ["high-availability", "vrrp", "group"] +sync_path = ["high-availability", "vrrp", "sync-group"] + +for g in groups: + group_name = "{0}-{1}".format(g["interface"], g["vrid"]) + group_path = base_group_path + [group_name] + + config.set(group_path + ["interface"], value=g["interface"]) + config.set(group_path + ["vrid"], value=g["vrid"]) + + if "advertise_interval" in g: + config.set(group_path + ["advertise-interval"], value=g["advertise_interval"]) + + if "priority" in g: + config.set(group_path + ["priority"], value=g["priority"]) + + if not g["preempt"]: + config.set(group_path + ["no-preempt"], value=None) + + if "preempt_delay" in g: + config.set(group_path + ["preempt-delay"], value=g["preempt_delay"]) + + if g["rfc_compatibility"]: + config.set(group_path + ["rfc3768-compatibility"], value=None) + + if g["disable"]: + config.set(group_path + ["disable"], value=None) + + if "hello_source" in g: + config.set(group_path + ["hello-source-address"], value=g["hello_source"]) + + if "peer_address" in g: + config.set(group_path + ["peer-address"], value=g["peer_address"]) + + if "auth_password" in g: + config.set(group_path + ["authentication", "password"], value=g["auth_password"]) + if "auth_type" in g: + config.set(group_path + ["authentication", "type"], value=g["auth_type"]) + + if "master_script" in g: + config.set(group_path + ["transition-script", "master"], value=g["master_script"]) + if "backup_script" in g: + config.set(group_path + ["transition-script", "backup"], value=g["backup_script"]) + if "fault_script" in g: + config.set(group_path + ["transition-script", "fault"], value=g["fault_script"]) + + if "health_check_interval" in g: + config.set(group_path + ["health-check", "interval"], value=g["health_check_interval"]) + if "health_check_count" in g: + config.set(group_path + ["health-check", "failure-count"], value=g["health_check_count"]) + if "health_check_script" in g: + config.set(group_path + ["health-check", "script"], value=g["health_check_script"]) + + # Not that it should ever be absent... + if "virtual_addresses" in g: + # The new CLI disallows addresses without prefix length + # Pre-rewrite configs didn't support IPv6 VRRP, but handle it anyway + for va in g["virtual_addresses"]: + if not re.search(r'/', va): + if re.search(r':', va): + va = "{0}/128".format(va) + else: + va = "{0}/32".format(va) + config.set(group_path + ["virtual-address"], value=va, replace=False) + + # Sync group + if "sync_group" in g: + config.set(sync_path + [g["sync_group"], "member"], value=group_name, replace=False) + +# Set the tag flag +config.set_tag(base_group_path) +if config.exists(sync_path): + config.set_tag(sync_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/op_mode/vrrp.py b/src/op_mode/vrrp.py new file mode 100755 index 000000000..ba8b56de3 --- /dev/null +++ b/src/op_mode/vrrp.py @@ -0,0 +1,98 @@ +#!/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 time +import argparse + +import tabulate + +import vyos.keepalived +import vyos.util + + +def print_summary(): + try: + vyos.keepalived.force_json_dump() + # Wait for keepalived to produce the data + # Replace with inotify or similar if it proves problematic + time.sleep(0.2) + json_data = vyos.keepalived.get_json_data() + except: + print("VRRP information is not available") + + groups = [] + for group in json_data: + data = group["data"] + + name = data["iname"] + + ltrans_timestamp = float(data["last_transition"]) + ltrans_time = vyos.util.seconds_to_human(int(time.time() - ltrans_timestamp)) + + interface = data["ifp_ifname"] + vrid = data["vrid"] + + state = vyos.keepalived.decode_state(data["state"]) + + row = [name, interface, vrid, state, ltrans_time] + groups.append(row) + + headers = ["Name", "Interface", "VRID", "State", "Last Transition"] + output = tabulate.tabulate(groups, headers) + print(output) + +def print_statistics(): + try: + vyos.keepalived.force_stats_dump() + time.sleep(0.2) + output = vyos.keepalived.get_statistics() + print(output) + except: + print("VRRP statistics are not available") + +def print_state_data(): + try: + vyos.keepalived.force_state_data_dump() + time.sleep(0.2) + output = vyos.keepalived.get_state_data() + print(output) + except: + print("VRRP information is not available") + +parser = argparse.ArgumentParser() +group = parser.add_mutually_exclusive_group() +group.add_argument("-s", "--summary", action="store_true", help="Print VRRP summary") +group.add_argument("-t", "--statistics", action="store_true", help="Print VRRP statistics") +group.add_argument("-d", "--data", action="store_true", help="Print detailed VRRP data") + +args = parser.parse_args() + +# Exit early if VRRP is dead or not configured +if not vyos.keepalived.vrrp_running(): + print("VRRP is not running") + sys.exit(0) + +if args.summary: + print_summary() +elif args.statistics: + print_statistics() +elif args.data: + print_state_data() +else: + parser.print_help() + sys.exit(1) diff --git a/src/system/vrrp-script-wrapper.py b/src/system/vrrp-script-wrapper.py new file mode 100755 index 000000000..5d6aa6c55 --- /dev/null +++ b/src/system/vrrp-script-wrapper.py @@ -0,0 +1,77 @@ +#!/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 subprocess +import argparse +import syslog + +import vyos.util +import vyos.keepalived + + +parser = argparse.ArgumentParser() +parser.add_argument("-s", "--script", type=str, help="Script to run") +parser.add_argument("-t", "--state", type=str, help="VRRP state") +parser.add_argument("-g", "--group", type=str, help="VRRP group") +parser.add_argument("-i", "--interface", type=str, help="Network interface") + +syslog.openlog('vyos-vrrp-wrapper') + +args = parser.parse_args() +if not args.script or not args.state or not args.group \ + or not args.interface: + parser.print_usage() + sys.exit(1) + +# Get the old state if it exists and compare it to the current state received +# in command line options to avoid executing scripts if no real transition occured. +# This is necessary because keepalived does not keep persistent state data even between +# config reloads and will cheerfully execute everything whether it's required or not. + +old_state = vyos.keepalived.get_old_state(args.group) + +if (old_state is None) or (old_state != args.state): + exitcode = 0 + + # Run the script and save the new state + + # Change the process GID to the config owners group to avoid screwing up + # running config permissions + os.setgid(vyos.util.get_cfg_group_id()) + + syslog.syslog(syslog.LOG_NOTICE, 'Running transition script {0} for VRRP group {1}'.format(args.script, args.group)) + try: + ret = subprocess.call([args.script, args.state, args.interface, args.group]) + if ret != 0: + syslog.syslog(syslog.LOG_ERR, "Transition script {0} failed, exit status: {1}".format(args.script, ret)) + exitcode = ret + except Exception as e: + syslog.syslog(syslog.LOG_ERR, "Failed to execute transition script {0}: {1}".format(args.script, e)) + exitcode = 1 + + if exitcode == 0: + syslog.syslog(syslog.LOG_NOTICE, "Transition script {0} executed successfully".format(args.script)) + + vyos.keepalived.save_state(args.group, args.state) +else: + syslog.syslog(syslog.LOG_NOTICE, "State of the group {0} has not changed, not running transition script".format(args.group)) + +syslog.closelog() +sys.exit(exitcode) |