diff --git a/data/op-mode-standardized.json b/data/op-mode-standardized.json index 170f0d259..c2bfc3094 100644 --- a/data/op-mode-standardized.json +++ b/data/op-mode-standardized.json @@ -1,34 +1,35 @@ [ "accelppp.py", "bgp.py", "bonding.py", "bridge.py", "cgnat.py", "config_mgmt.py", "conntrack.py", "container.py", "cpu.py", "dhcp.py", "dns.py", "evpn.py", "interfaces.py", "ipsec.py", +"load-balancing_wan.py", "lldp.py", "log.py", "memory.py", "multicast.py", "nat.py", "neighbor.py", "openconnect.py", "openvpn.py", "otp.py", "qos.py", "reset_vpn.py", "load-balancing_haproxy.py", "route.py", "storage.py", "system.py", "uptime.py", "version.py", "vrf.py" ] diff --git a/op-mode-definitions/load-balancing_wan.xml.in b/op-mode-definitions/load-balancing_wan.xml.in new file mode 100644 index 000000000..91c57c1f4 --- /dev/null +++ b/op-mode-definitions/load-balancing_wan.xml.in @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interfaceDefinition> + <node name="restart"> + <children> + <node name="wan-load-balance"> + <properties> + <help>Restart Wide Area Network (WAN) load-balancing daemon</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/restart.py restart_service --name load-balancing_wan</command> + </node> + </children> + </node> + <node name="show"> + <children> + <node name="wan-load-balance"> + <properties> + <help>Show Wide Area Network (WAN) load-balancing information</help> + </properties> + <command>${vyos_op_scripts_dir}/load-balancing_wan.py show_summary</command> + <children> + <node name="connection"> + <properties> + <help>Show Wide Area Network (WAN) load-balancing flow</help> + </properties> + <command>${vyos_op_scripts_dir}/load-balancing_wan.py show_connection</command> + </node> + <node name="status"> + <properties> + <help>Show WAN load-balancing statistics</help> + </properties> + <command>${vyos_op_scripts_dir}/load-balancing_wan.py show_status</command> + </node> + </children> + </node> + </children> + </node> +</interfaceDefinition> \ No newline at end of file diff --git a/src/op_mode/load-balancing_wan.py b/src/op_mode/load-balancing_wan.py new file mode 100755 index 000000000..9fa473802 --- /dev/null +++ b/src/op_mode/load-balancing_wan.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 re +import sys + +from datetime import datetime + +from vyos.config import Config +from vyos.utils.process import cmd + +import vyos.opmode + +wlb_status_file = '/run/wlb_status.json' + +status_format = '''Interface: {ifname} +Status: {status} +Last Status Change: {last_change} +Last Interface Success: {last_success} +Last Interface Failure: {last_failure} +Interface Failures: {failures} +''' + +def _verify(func): + """Decorator checks if WLB config exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = Config() + if not config.exists(['load-balancing', 'wan']): + unconf_message = 'WAN load-balancing is not configured' + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + return func(*args, **kwargs) + return _wrapper + +def _get_raw_data(): + with open(wlb_status_file, 'r') as f: + data = json.loads(f.read()) + if not data: + return {} + return data + +def _get_formatted_output(raw_data): + for ifname, if_data in raw_data.items(): + latest_change = if_data['last_success'] if if_data['last_success'] > if_data['last_failure'] else if_data['last_failure'] + + change_dt = datetime.fromtimestamp(latest_change) if latest_change > 0 else None + success_dt = datetime.fromtimestamp(if_data['last_success']) if if_data['last_success'] > 0 else None + failure_dt = datetime.fromtimestamp(if_data['last_failure']) if if_data['last_failure'] > 0 else None + now = datetime.utcnow() + + fmt_data = { + 'ifname': ifname, + 'status': "active" if if_data['state'] else "failed", + 'last_change': change_dt.strftime("%Y-%m-%d %H:%M:%S") if change_dt else 'N/A', + 'last_success': str(now - success_dt) if success_dt else 'N/A', + 'last_failure': str(now - failure_dt) if failure_dt else 'N/A', + 'failures': if_data['failure_count'] + } + print(status_format.format(**fmt_data)) + +@_verify +def show_summary(raw: bool): + data = _get_raw_data() + + if raw: + return data + else: + return _get_formatted_output(data) + +@_verify +def show_connection(raw: bool): + res = cmd('sudo conntrack -L -n') + lines = res.split("\n") + filtered_lines = [line for line in lines if re.search(r' mark=[1-9]', line)] + + if raw: + return filtered_lines + + for line in lines: + print(line) + +@_verify +def show_status(raw: bool): + res = cmd('sudo nft list chain ip vyos_wanloadbalance wlb_mangle_prerouting') + lines = res.split("\n") + filtered_lines = [line.replace("\t", "") for line in lines[3:-2] if 'meta mark set' not in line] + + if raw: + return filtered_lines + + for line in filtered_lines: + print(line) + +if __name__ == "__main__": + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/restart.py b/src/op_mode/restart.py index 3b0031f34..efa835485 100755 --- a/src/op_mode/restart.py +++ b/src/op_mode/restart.py @@ -1,149 +1,154 @@ #!/usr/bin/env python3 # # Copyright (C) 2024 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 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 typing import vyos.opmode from vyos.configquery import ConfigTreeQuery from vyos.utils.process import call from vyos.utils.commit import commit_in_progress config = ConfigTreeQuery() service_map = { 'dhcp': { 'systemd_service': 'kea-dhcp4-server', 'path': ['service', 'dhcp-server'], }, 'dhcpv6': { 'systemd_service': 'kea-dhcp6-server', 'path': ['service', 'dhcpv6-server'], }, 'dns_dynamic': { 'systemd_service': 'ddclient', 'path': ['service', 'dns', 'dynamic'], }, 'dns_forwarding': { 'systemd_service': 'pdns-recursor', 'path': ['service', 'dns', 'forwarding'], }, 'haproxy': { 'systemd_service': 'haproxy', 'path': ['load-balancing', 'haproxy'], }, 'igmp_proxy': { 'systemd_service': 'igmpproxy', 'path': ['protocols', 'igmp-proxy'], }, 'ipsec': { 'systemd_service': 'strongswan', 'path': ['vpn', 'ipsec'], }, + 'load-balancing_wan': { + 'systemd_service': 'vyos-wan-load-balance', + 'path': ['load-balancing', 'wan'], + }, 'mdns_repeater': { 'systemd_service': 'avahi-daemon', 'path': ['service', 'mdns', 'repeater'], }, 'router_advert': { 'systemd_service': 'radvd', 'path': ['service', 'router-advert'], }, 'snmp': { 'systemd_service': 'snmpd', }, 'ssh': { 'systemd_service': 'ssh', }, 'suricata': { 'systemd_service': 'suricata', }, 'vrrp': { 'systemd_service': 'keepalived', 'path': ['high-availability', 'vrrp'], }, 'webproxy': { 'systemd_service': 'squid', }, } services = typing.Literal[ 'dhcp', 'dhcpv6', 'dns_dynamic', 'dns_forwarding', 'haproxy', 'igmp_proxy', 'ipsec', + 'load-balancing_wan', 'mdns_repeater', 'router_advert', 'snmp', 'ssh', 'suricata', 'vrrp', 'webproxy', ] def _verify(func): """Decorator checks if DHCP(v6) config exists""" from functools import wraps @wraps(func) def _wrapper(*args, **kwargs): config = ConfigTreeQuery() name = kwargs.get('name') human_name = name.replace('_', '-') if commit_in_progress(): print(f'Cannot restart {human_name} service while a commit is in progress') sys.exit(1) # Get optional CLI path from service_mapping dict # otherwise use "service name" CLI path path = ['service', name] if 'path' in service_map[name]: path = service_map[name]['path'] # Check if config does not exist if not config.exists(path): raise vyos.opmode.UnconfiguredSubsystem( f'Service {human_name} is not configured!' ) if config.exists(path + ['disable']): raise vyos.opmode.UnconfiguredSubsystem( f'Service {human_name} is disabled!' ) return func(*args, **kwargs) return _wrapper @_verify def restart_service(raw: bool, name: services, vrf: typing.Optional[str]): systemd_service = service_map[name]['systemd_service'] if vrf: call(f'systemctl restart "{systemd_service}@{vrf}.service"') else: call(f'systemctl restart "{systemd_service}.service"') if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) if res: print(res) except (ValueError, vyos.opmode.Error) as e: print(e) sys.exit(1)