diff --git a/data/templates/load-balancing/nftables-wlb.j2 b/data/templates/load-balancing/nftables-wlb.j2
new file mode 100644
index 000000000..75604aca1
--- /dev/null
+++ b/data/templates/load-balancing/nftables-wlb.j2
@@ -0,0 +1,64 @@
+#!/usr/sbin/nft -f
+
+{% if first_install is not vyos_defined %}
+delete table ip vyos_wanloadbalance
+{% endif %}
+table ip vyos_wanloadbalance {
+    chain wlb_nat_postrouting {
+        type nat hook postrouting priority srcnat - 1; policy accept;
+{% for ifname, health_conf in interface_health.items() if health_state[ifname].if_addr %}
+{%     if disable_source_nat is not vyos_defined %}
+{%         set state = health_state[ifname] %}
+        ct mark {{ state.mark }} counter snat to {{ state.if_addr }}
+{%     endif %}
+{% endfor %}
+    }
+
+    chain wlb_mangle_prerouting {
+        type filter hook prerouting priority mangle; policy accept;
+{% for ifname, health_conf in interface_health.items() %}
+{%     set state = health_state[ifname] %}
+{%     if sticky_connections is vyos_defined %}
+        iifname "{{ ifname }}" ct state new ct mark set {{ state.mark }}
+{%     endif %}
+{% endfor %}
+{% if rule is vyos_defined %}
+{%     for rule_id, rule_conf in rule.items() %}
+{%         if rule_conf.exclude is vyos_defined %}
+        {{ rule_conf | wlb_nft_rule(rule_id, exclude=True, action='accept') }}
+{%         else %}
+{%             set limit = rule_conf.limit is vyos_defined %}
+        {{ rule_conf | wlb_nft_rule(rule_id, limit=limit, weight=True, health_state=health_state) }}
+        {{ rule_conf | wlb_nft_rule(rule_id, restore_mark=True) }}
+{%         endif %}
+{%     endfor %}
+{% endif %}
+    }
+
+    chain wlb_mangle_output {
+        type filter hook output priority -150; policy accept;
+{% if enable_local_traffic is vyos_defined %}
+        meta mark != 0x0 counter accept
+        meta l4proto icmp counter accept
+        ip saddr 127.0.0.0/8 ip daddr 127.0.0.0/8 counter accept
+{%     if rule is vyos_defined %}
+{%         for rule_id, rule_conf in rule.items() %}
+{%             if rule_conf.exclude is vyos_defined  %}
+        {{ rule_conf | wlb_nft_rule(rule_id, local=True, exclude=True, action='accept') }}
+{%             else %}
+{%                 set limit = rule_conf.limit is vyos_defined %}
+        {{ rule_conf | wlb_nft_rule(rule_id, local=True, limit=limit, weight=True, health_state=health_state) }}
+        {{ rule_conf | wlb_nft_rule(rule_id, local=True, restore_mark=True) }}
+{%             endif %}
+{%         endfor %}
+{%     endif %}
+{% endif %}
+    }
+
+{% for ifname, health_conf in interface_health.items() %}
+{%     set state = health_state[ifname] %}
+    chain wlb_mangle_isp_{{ ifname }} {
+        meta mark set {{ state.mark }} ct mark set {{ state.mark }} counter accept
+    }
+{% endfor %}
+}
diff --git a/data/templates/load-balancing/wlb.conf.j2 b/data/templates/load-balancing/wlb.conf.j2
deleted file mode 100644
index 7f04d797e..000000000
--- a/data/templates/load-balancing/wlb.conf.j2
+++ /dev/null
@@ -1,134 +0,0 @@
-### Autogenerated by load-balancing_wan.py ###
-
-{% if disable_source_nat is vyos_defined %}
-disable-source-nat
-{% endif %}
-{% if enable_local_traffic is vyos_defined %}
-enable-local-traffic
-{% endif %}
-{% if sticky_connections is vyos_defined %}
-sticky-connections inbound
-{% endif %}
-{% if flush_connections is vyos_defined %}
-flush-conntrack
-{% endif %}
-{% if hook is vyos_defined %}
-hook "{{ hook }}"
-{% endif %}
-{% if interface_health is vyos_defined %}
-health {
-{%     for interface, interface_config in interface_health.items() %}
-    interface {{ interface }} {
-{%         if interface_config.failure_count is vyos_defined %}
-        failure-ct  {{ interface_config.failure_count }}
-{%         endif %}
-{%         if interface_config.success_count is vyos_defined %}
-        success-ct  {{ interface_config.success_count }}
-{%         endif %}
-{%         if interface_config.nexthop is vyos_defined %}
-        nexthop {{ interface_config.nexthop }}
-{%         endif %}
-{%         if interface_config.test is vyos_defined %}
-{%             for test_rule, test_config in interface_config.test.items() %}
-        rule {{ test_rule }} {
-{%                 if test_config.type is vyos_defined %}
-{%                     set type_translate = {'ping': 'ping', 'ttl': 'udp', 'user-defined': 'user-defined'} %}
-            type {{ type_translate[test_config.type] }} {
-{%                     if test_config.ttl_limit is vyos_defined and test_config.type == 'ttl' %}
-                ttl {{ test_config.ttl_limit }}
-{%                     endif %}
-{%                     if test_config.test_script is vyos_defined and test_config.type == 'user-defined' %}
-                test-script {{ test_config.test_script }}
-{%                     endif %}
-{%                     if test_config.target is vyos_defined %}
-                target {{ test_config.target }}
-{%                     endif %}
-                resp-time {{ test_config.resp_time | int * 1000 }}
-            }
-{%                 endif %}
-        }
-{%             endfor %}
-{%         endif %}
-    }
-{%     endfor %}
-}
-{% endif %}
-
-{% if rule is vyos_defined %}
-{%     for rule, rule_config in rule.items() %}
-rule {{ rule }} {
-{%         if rule_config.exclude is vyos_defined  %}
-    exclude
-{%         endif %}
-{%         if rule_config.failover is vyos_defined  %}
-    failover
-{%         endif %}
-{%         if rule_config.limit is vyos_defined %}
-    limit {
-{%             if rule_config.limit.burst is vyos_defined %}
-        burst {{ rule_config.limit.burst }}
-{%             endif %}
-{%             if rule_config.limit.rate is vyos_defined %}
-        rate {{ rule_config.limit.rate }}
-{%             endif %}
-{%             if rule_config.limit.period is vyos_defined %}
-        period {{ rule_config.limit.period }}
-{%             endif %}
-{%             if rule_config.limit.threshold is vyos_defined %}
-        thresh {{ rule_config.limit.threshold }}
-{%             endif %}
-        }
-{%         endif %}
-{%         if rule_config.per_packet_balancing is vyos_defined  %}
-    per-packet-balancing
-{%         endif %}
-{%         if rule_config.protocol is vyos_defined  %}
-    protocol {{ rule_config.protocol }}
-{%         endif %}
-{%         if rule_config.destination is vyos_defined %}
-    destination {
-{%             if rule_config.destination.address is vyos_defined  %}
-        address "{{ rule_config.destination.address }}"
-{%             endif %}
-{%             if rule_config.destination.port is vyos_defined  %}
-{%                 if '-' in rule_config.destination.port %}
-        port-ipt "-m multiport  --dports {{ rule_config.destination.port | replace('-', ':') }}"
-{%                 elif ',' in rule_config.destination.port %}
-        port-ipt "-m multiport  --dports {{ rule_config.destination.port }}"
-{%                 else %}
-        port-ipt " --dport {{ rule_config.destination.port }}"
-{%                 endif %}
-{%             endif %}
-    }
-{%         endif %}
-{%         if rule_config.source is vyos_defined %}
-    source {
-{%             if rule_config.source.address is vyos_defined  %}
-        address "{{ rule_config.source.address }}"
-{%             endif %}
-{%             if rule_config.source.port is vyos_defined  %}
-{%                 if '-' in rule_config.source.port %}
-        port-ipt "-m multiport  --sports {{ rule_config.source.port | replace('-', ':') }}"
-{%                 elif ',' in rule_config.destination.port %}
-        port-ipt "-m multiport  --sports {{ rule_config.source.port }}"
-{%                 else %}
-        port.ipt " --sport {{ rule_config.source.port }}"
-{%                 endif %}
-{%             endif %}
-    }
-{%         endif %}
-{%         if rule_config.inbound_interface is vyos_defined  %}
-    inbound-interface {{ rule_config.inbound_interface }}
-{%         endif %}
-{%         if rule_config.interface is vyos_defined  %}
-{%             for interface, interface_config in rule_config.interface.items() %}
-    interface {{ interface }} {
-{%                 if interface_config.weight is vyos_defined %}
-        weight {{ interface_config.weight }}
-{%                 endif %}
-    }
-{%             endfor %}
-{%         endif %}
-}
-{%     endfor %}
-{% endif %}
diff --git a/debian/control b/debian/control
index 57709ea24..0d040a374 100644
--- a/debian/control
+++ b/debian/control
@@ -1,399 +1,396 @@
 Source: vyos-1x
 Section: contrib/net
 Priority: extra
 Maintainer: VyOS Package Maintainers <maintainers@vyos.net>
 Build-Depends:
   debhelper (>= 9),
   dh-python,
   fakeroot,
   gcc,
   iproute2,
   libvyosconfig0 (>= 0.0.7),
   libzmq3-dev,
   python3 (>= 3.10),
 # For QA
   pylint,
 # For generating command definitions
   python3-lxml,
   python3-xmltodict,
 # For running tests
   python3-coverage,
   python3-hurry.filesize,
   python3-netaddr,
   python3-netifaces,
   python3-nose,
   python3-jinja2,
   python3-paramiko,
   python3-passlib,
   python3-psutil,
   python3-requests,
   python3-setuptools,
   python3-tabulate,
   python3-zmq,
   quilt,
   whois
 Standards-Version: 3.9.6
 
 Package: vyos-1x
 Architecture: amd64 arm64
 Pre-Depends:
   libpam-runtime [amd64],
   libnss-tacplus [amd64],
   libpam-tacplus [amd64],
   libpam-radius-auth (= 1.5.0-cl3u7) [amd64],
   libnss-mapuser (= 1.1.0-cl3u3) [amd64]
 Depends:
 ## Fundamentals
   ${python3:Depends} (>= 3.10),
   dialog,
   libvyosconfig0,
   libpam-cap,
   bash-completion,
   ipvsadm,
   udev,
   less,
   at,
   rsync,
   vyatta-bash,
   vyatta-biosdevname,
   vyatta-cfg,
   vyos-http-api-tools,
   vyos-utils,
 ## End of Fundamentals
 ## Python libraries used in multiple modules and scripts
   python3,
   python3-cryptography,
   python3-hurry.filesize,
   python3-inotify,
   python3-jinja2,
   python3-jmespath,
   python3-netaddr,
   python3-netifaces,
   python3-paramiko,
   python3-passlib,
   python3-pyroute2,
   python3-psutil,
   python3-pyhumps,
   python3-pystache,
   python3-pyudev,
   python3-six,
   python3-tabulate,
   python3-voluptuous,
   python3-xmltodict,
   python3-zmq,
 ## End of Python libraries
 ## Basic System services and utilities
   coreutils,
   sudo,
   systemd,
   bsdmainutils,
   openssl,
   curl,
   dbus,
   file,
   iproute2 (>= 6.0.0),
   linux-cpupower,
 # ipaddrcheck is widely used in IP value validators
   ipaddrcheck,
   ethtool (>= 6.10),
   lm-sensors,
   procps,
   netplug,
   sed,
   ssl-cert,
   tuned,
   beep,
   wide-dhcpv6-client,
 # Generic colorizer
   grc,
 ## End of System services and utilities
 ## For the installer
   fdisk,
   gdisk,
   mdadm,
   efibootmgr,
   libefivar1,
   dosfstools,
   grub-efi-amd64-signed [amd64],
   grub-efi-arm64-bin [arm64],
   mokutil [amd64],
   shim-signed [amd64],
   sbsigntool [amd64],
 # Image signature verification tool
   minisign,
 # Live filesystem tools
   squashfs-tools,
   fuse-overlayfs,
 ## End installer
   auditd,
   iputils-arping,
   iputils-ping,
   isc-dhcp-client,
 # For "vpn pptp", "vpn l2tp", "vpn sstp", "service ipoe-server"
   accel-ppp,
 # End "vpn pptp", "vpn l2tp", "vpn sstp", "service ipoe-server"
   avahi-daemon,
   conntrack,
   conntrackd,
 ## Conf mode features
 # For "interfaces wireless"
   hostapd,
   hsflowd,
   iw,
   wireless-regdb,
   wpasupplicant (>= 0.6.7),
 # End "interfaces wireless"
 # For "interfaces wwan"
   modemmanager,
   usb-modeswitch,
   libqmi-utils,
 # End "interfaces wwan"
 # For "interfaces openvpn"
   openvpn,
   openvpn-auth-ldap,
   openvpn-auth-radius,
   openvpn-otp,
   openvpn-dco,
   libpam-google-authenticator,
 # End "interfaces openvpn"
 # For "interfaces wireguard"
   wireguard-tools,
   qrencode,
 # End "interfaces wireguard"
 # For "interfaces pppoe"
   pppoe,
 # End "interfaces pppoe"
 # For "interfaces sstpc"
   sstp-client,
 # End "interfaces sstpc"
 # For "protocols *"
   frr (>= 10.2),
   frr-pythontools,
   frr-rpki-rtrlib,
   frr-snmp,
 # End "protocols *"
 # For "protocols igmp-proxy"
   igmpproxy,
 # End "protocols igmp-proxy"
 # For "pki"
   certbot,
 # End "pki"
 # For "service console-server"
   conserver-client,
   conserver-server,
   console-data,
   dropbear,
 # End "service console-server"
 # For "service aws glb"
   aws-gwlbtun,
 # For "service dns dynamic"
   ddclient (>= 3.11.1),
 # End "service dns dynamic"
 # # For "service ids"
   fastnetmon [amd64],
   suricata,
   suricata-update,
 # End "service ids"
 # # For "service ndp-proxy"
   ndppd,
 # End "service ndp-proxy"
 # For "service router-advert"
   radvd,
 # End "service route-advert"
 # For "load-balancing haproxy"
   haproxy,
 # End "load-balancing haproxy"
-# For "load-balancing wan"
-  vyatta-wanloadbalance,
-# End "load-balancing wan"
 # For "service dhcp-relay"
   isc-dhcp-relay,
 # For "service dhcp-server"
   kea,
 # End "service dhcp-server"
 # For "service lldp"
   lldpd,
 # End "service lldp"
 # For "service https"
   nginx-light,
 # End "service https"
 # For "service ssh"
   openssh-server,
   sshguard,
 # End "service ssh"
 # For "service salt-minion"
   salt-minion,
 # End "service salt-minion"
 # For "service snmp"
   snmp,
   snmpd,
 # End "service snmp"
 # For "service webproxy"
   squid,
   squidclient,
   squidguard,
 # End "service webproxy"
 # For "service monitoring prometheus node-exporter"
   node-exporter,
 # End "service monitoring prometheus node-exporter"
 # For "service monitoring prometheus frr-exporter"
   frr-exporter,
 # End "service monitoring prometheus frr-exporter"
 # For "service monitoring prometheus blackbox-exporter"
   blackbox-exporter,
 # End "service monitoring prometheus blackbox-exporter"
 # For "service monitoring telegraf"
   telegraf (>= 1.20),
 # End "service monitoring telegraf"
 # For "service monitoring zabbix-agent"
   zabbix-agent2,
 # End "service monitoring zabbix-agent"
 # For "service tftp-server"
   tftpd-hpa,
 # End "service tftp-server"
 # For "service dns forwarding"
   pdns-recursor,
 # End "service dns forwarding"
 # For "service sla owamp"
   owamp-client,
   owamp-server,
 # End "service sla owamp"
 # For "service sla twamp"
   twamp-client,
   twamp-server,
 # End "service sla twamp"
 # For "service broadcast-relay"
   udp-broadcast-relay,
 # End "service broadcast-relay"
 # For "high-availability vrrp"
   keepalived (>=2.0.5),
 # End "high-availability-vrrp"
 # For "system console"
   util-linux,
 # End "system console"
 # For "system task-scheduler"
   cron,
 # End "system task-scheduler"
 # For "system lcd"
   lcdproc,
   lcdproc-extra-drivers,
 # End "system lcd"
 # For "system config-management commit-archive"
   git,
 # End "system config-management commit-archive"
 # For firewall
   libndp-tools,
   libnetfilter-conntrack3,
   libnfnetlink0,
   nfct,
   nftables (>= 0.9.3),
 # For "vpn ipsec"
   strongswan (>= 5.9),
   strongswan-swanctl (>= 5.9),
   charon-systemd,
   libcharon-extra-plugins (>=5.9),
   libcharon-extauth-plugins (>=5.9),
   libstrongswan-extra-plugins (>=5.9),
   libstrongswan-standard-plugins (>=5.9),
   python3-vici (>= 5.7.2),
 # End "vpn ipsec"
 # For "nat64"
   jool,
 # End "nat64"
 # For "system conntrack modules rtsp"
   nat-rtsp,
 # End "system conntrack modules rtsp"
 # For "service ntp"
   chrony,
 # End "system ntp"
 # For "vpn openconnect"
   ocserv,
 # End "vpn openconnect"
 # For "system flow-accounting"
   pmacct (>= 1.6.0),
 # End "system flow-accounting"
 # For "system syslog"
   rsyslog,
 # End "system syslog"
 # For "system option keyboard-layout"
   kbd,
 # End "system option keyboard-layout"
 # For "container"
   podman (>=4.9.5),
   netavark,
   aardvark-dns,
 # iptables is only used for containers now, not the the firewall CLI
   iptables,
 # End container
 # For "vpp"
   libvppinfra,
   python3-vpp-api,
   vpp,
   vpp-dev,
   vpp-plugin-core,
   vpp-plugin-dpdk,
 # End "vpp"
 ## End Configuration mode
 ## Operational mode
 # Used for hypervisor model in "run show version"
   hvinfo,
 # For "run traceroute"
   traceroute,
 # For "run monitor traffic"
   tcpdump,
 # End "run monitor traffic"
 # For "show hardware dmi"
   dmidecode,
 # For "run show hardware storage smart"
   smartmontools,
 # For "run show hardware scsi"
   lsscsi,
 # For "run show hardware pci"
   pciutils,
 # For "show hardware usb"
   usbutils,
 # For "run show hardware storage nvme"
   nvme-cli,
 # For "run monitor bandwidth-test"
   iperf,
   iperf3,
 # End "run monitor bandwidth-test"
 # For "run wake-on-lan"
   etherwake,
 # For "run force ipv6-nd"
   ndisc6,
 # For "run monitor bandwidth"
   bmon,
 # For "run format disk"
   parted,
 # End Operational mode
 ## TPM tools
   cryptsetup,
   tpm2-tools,
 ## End TPM tools
 ## Optional utilities
   easy-rsa,
   tcptraceroute,
   mtr-tiny,
   telnet,
   stunnel4,
   uidmap
 ## End optional utilities
 Description: VyOS configuration scripts and data
  VyOS configuration scripts, interface definitions, and everything
 
 Package: vyos-1x-vmware
 Architecture: amd64
 Depends:
  vyos-1x,
  open-vm-tools
 Description: VyOS configuration scripts and data for VMware
  Adds configuration files required for VyOS running on VMware hosts.
 
 Package: vyos-1x-smoketest
 Architecture: all
 Depends:
  skopeo,
  snmp,
  vyos-1x
 Description: VyOS build sanity checking toolkit
diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py
index 89e51707b..86194cd55 100644
--- a/python/vyos/defaults.py
+++ b/python/vyos/defaults.py
@@ -1,70 +1,71 @@
 # Copyright 2018-2025 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 os
 
 base_dir = '/usr/libexec/vyos/'
 
 directories = {
   'base' : base_dir,
   'data' : '/usr/share/vyos/',
   'conf_mode' : f'{base_dir}/conf_mode',
   'op_mode' : f'{base_dir}/op_mode',
   'services' : f'{base_dir}/services',
   'config' : '/opt/vyatta/etc/config',
   'migrate' : '/opt/vyatta/etc/config-migrate/migrate',
   'activate' : f'{base_dir}/activate',
   'log' : '/var/log/vyatta',
   'templates' : '/usr/share/vyos/templates/',
   'certbot' : '/config/auth/letsencrypt',
   'api_schema': f'{base_dir}/services/api/graphql/graphql/schema/',
   'api_client_op': f'{base_dir}/services/api/graphql/graphql/client_op/',
   'api_templates': f'{base_dir}/services/api/graphql/session/templates/',
   'vyos_udev_dir' : '/run/udev/vyos',
   'isc_dhclient_dir' : '/run/dhclient',
   'dhcp6_client_dir' : '/run/dhcp6c',
   'vyos_configdir' : '/opt/vyatta/config',
   'completion_dir' : f'{base_dir}/completion',
-  'ca_certificates' : '/usr/local/share/ca-certificates/vyos'
+  'ca_certificates' : '/usr/local/share/ca-certificates/vyos',
+  'ppp_nexthop_dir' : '/run/ppp_nexthop'
 }
 
 systemd_services = {
     'rsyslog' : 'rsyslog.service',
     'snmpd' : 'snmpd.service',
 }
 
 config_status = '/tmp/vyos-config-status'
 api_config_state = '/run/http-api-state'
 frr_debug_enable = '/tmp/vyos.frr.debug'
 
 cfg_group = 'vyattacfg'
 
 cfg_vintage = 'vyos'
 
 commit_lock = os.path.join(directories['vyos_configdir'], '.lock')
 
 component_version_json = os.path.join(directories['data'], 'component-versions.json')
 
 config_default = os.path.join(directories['data'], 'config.boot.default')
 
 rt_symbolic_names = {
   # Standard routing tables for Linux & reserved IDs for VyOS
   'default': 253, # Confusingly, a final fallthru, not the default. 
   'main': 254,    # The actual global table used by iproute2 unless told otherwise. 
   'local': 255,   # Special kernel loopback table.
 }
 
 rt_global_vrf = rt_symbolic_names['main']
 rt_global_table = rt_symbolic_names['main']
diff --git a/python/vyos/template.py b/python/vyos/template.py
index be9f781a6..7ba608b32 100755
--- a/python/vyos/template.py
+++ b/python/vyos/template.py
@@ -1,990 +1,995 @@
 # Copyright 2019-2024 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 functools
 import os
 
 from jinja2 import Environment
 from jinja2 import FileSystemLoader
 from jinja2 import ChainableUndefined
 from vyos.defaults import directories
 from vyos.utils.dict import dict_search_args
 from vyos.utils.file import makedir
 from vyos.utils.permission import chmod
 from vyos.utils.permission import chown
 
 # We use a mutable global variable for the default template directory
 # to make it possible to call scripts from this repository
 # outside of live VyOS systems.
 # If something (like the image build scripts)
 # want to call a script, they can modify the default location
 # to the repository path.
 DEFAULT_TEMPLATE_DIR = directories["templates"]
 
 # Holds template filters registered via register_filter()
 _FILTERS = {}
 _TESTS = {}
 
 # reuse Environments with identical settings to improve performance
 @functools.lru_cache(maxsize=2)
 def _get_environment(location=None):
     from os import getenv
 
     if location is None:
         loc_loader=FileSystemLoader(DEFAULT_TEMPLATE_DIR)
     else:
         loc_loader=FileSystemLoader(location)
     env = Environment(
         # Don't check if template files were modified upon re-rendering
         auto_reload=False,
         # Cache up to this number of templates for quick re-rendering
         cache_size=100,
         loader=loc_loader,
         trim_blocks=True,
         undefined=ChainableUndefined,
         extensions=['jinja2.ext.loopcontrols']
     )
     env.filters.update(_FILTERS)
     env.tests.update(_TESTS)
     return env
 
 
 def register_filter(name, func=None):
     """Register a function to be available as filter in templates under given name.
 
     It can also be used as a decorator, see below in this module for examples.
 
     :raise RuntimeError:
         when trying to register a filter after a template has been rendered already
     :raise ValueError: when trying to register a name which was taken already
     """
     if func is None:
         return functools.partial(register_filter, name)
     if _get_environment.cache_info().currsize:
         raise RuntimeError(
             "Filters can only be registered before rendering the first template"
         )
     if name in _FILTERS:
         raise ValueError(f"A filter with name {name!r} was registered already")
     _FILTERS[name] = func
     return func
 
 def register_test(name, func=None):
     """Register a function to be available as test in templates under given name.
 
     It can also be used as a decorator, see below in this module for examples.
 
     :raise RuntimeError:
         when trying to register a test after a template has been rendered already
     :raise ValueError: when trying to register a name which was taken already
     """
     if func is None:
         return functools.partial(register_test, name)
     if _get_environment.cache_info().currsize:
         raise RuntimeError(
             "Tests can only be registered before rendering the first template"
             )
     if name in _TESTS:
         raise ValueError(f"A test with name {name!r} was registered already")
     _TESTS[name] = func
     return func
 
 
 def render_to_string(template, content, formater=None, location=None):
     """Render a template from the template directory, raise on any errors.
 
     :param template: the path to the template relative to the template folder
     :param content: the dictionary of variables to put into rendering context
     :param formater:
         if given, it has to be a callable the rendered string is passed through
 
     The parsed template files are cached, so rendering the same file multiple times
     does not cause as too much overhead.
     If used everywhere, it could be changed to load the template from Python
     environment variables from an importable Python module generated when the Debian
     package is build (recovering the load time and overhead caused by having the
     file out of the code).
     """
     template = _get_environment(location).get_template(template)
     rendered = template.render(content)
     if formater is not None:
         rendered = formater(rendered)
     return rendered
 
 
 def render(
     destination,
     template,
     content,
     formater=None,
     permission=None,
     user=None,
     group=None,
     location=None,
 ):
     """Render a template from the template directory to a file, raise on any errors.
 
     :param destination: path to the file to save the rendered template in
     :param permission: permission bitmask to set for the output file
     :param user: user to own the output file
     :param group: group to own the output file
 
     All other parameters are as for :func:`render_to_string`.
     """
     # Create the directory if it does not exist
     folder = os.path.dirname(destination)
     makedir(folder, user, group)
 
     # As we are opening the file with 'w', we are performing the rendering before
     # calling open() to not accidentally erase the file if rendering fails
     rendered = render_to_string(template, content, formater, location)
 
     # Write to file
     with open(destination, "w") as file:
         chmod(file.fileno(), permission)
         chown(file.fileno(), user, group)
         file.write(rendered)
 
 
 ##################################
 # Custom template filters follow #
 ##################################
 @register_filter('force_to_list')
 def force_to_list(value):
     """ Convert scalars to single-item lists and leave lists untouched """
     if isinstance(value, list):
         return value
     else:
         return [value]
 
 @register_filter('seconds_to_human')
 def seconds_to_human(seconds, separator=""):
     """ Convert seconds to human-readable values like 1d6h15m23s """
     from vyos.utils.convert import seconds_to_human
     return seconds_to_human(seconds, separator=separator)
 
 @register_filter('bytes_to_human')
 def bytes_to_human(bytes, initial_exponent=0, precision=2):
     """ Convert bytes to human-readable values like 1.44M """
     from vyos.utils.convert import bytes_to_human
     return bytes_to_human(bytes, initial_exponent=initial_exponent, precision=precision)
 
 @register_filter('human_to_bytes')
 def human_to_bytes(value):
     """ Convert a data amount with a unit suffix to bytes, like 2K to 2048 """
     from vyos.utils.convert import human_to_bytes
     return human_to_bytes(value)
 
 @register_filter('ip_from_cidr')
 def ip_from_cidr(prefix):
     """ Take an IPv4/IPv6 CIDR host and strip cidr mask.
     Example:
     192.0.2.1/24 -> 192.0.2.1, 2001:db8::1/64 -> 2001:db8::1
     """
     from ipaddress import ip_interface
     return str(ip_interface(prefix).ip)
 
 @register_filter('address_from_cidr')
 def address_from_cidr(prefix):
     """ Take an IPv4/IPv6 CIDR prefix and convert the network to an "address".
     Example:
     192.0.2.0/24 -> 192.0.2.0, 2001:db8::/48 -> 2001:db8::
     """
     from ipaddress import ip_network
     return str(ip_network(prefix).network_address)
 
 @register_filter('bracketize_ipv6')
 def bracketize_ipv6(address):
     """ Place a passed IPv6 address into [] brackets, do nothing for IPv4 """
     if is_ipv6(address):
         return f'[{address}]'
     return address
 
 @register_filter('dot_colon_to_dash')
 def dot_colon_to_dash(text):
     """ Replace dot and colon to dash for string
     Example:
     192.0.2.1 => 192-0-2-1, 2001:db8::1 => 2001-db8--1
     """
     text = text.replace(":", "-")
     text = text.replace(".", "-")
     return text
 
 @register_filter('generate_uuid4')
 def generate_uuid4(text):
     """ Generate random unique ID
     Example:
       % uuid4()
       UUID('958ddf6a-ef14-4e81-8cfb-afb12456d1c5')
     """
     from uuid import uuid4
     return uuid4()
 
 @register_filter('netmask_from_cidr')
 def netmask_from_cidr(prefix):
     """ Take CIDR prefix and convert the prefix length to a "subnet mask".
     Example:
       - 192.0.2.0/24 -> 255.255.255.0
       - 2001:db8::/48 -> ffff:ffff:ffff::
     """
     from ipaddress import ip_network
     return str(ip_network(prefix).netmask)
 
 @register_filter('netmask_from_ipv4')
 def netmask_from_ipv4(address):
     """ Take IP address and search all attached interface IP addresses for the
     given one. After address has been found, return the associated netmask.
 
     Example:
       - 172.18.201.10 -> 255.255.255.128
     """
     from netifaces import interfaces
     from netifaces import ifaddresses
     from netifaces import AF_INET
     for interface in interfaces():
         tmp = ifaddresses(interface)
         if AF_INET in tmp:
             for af_addr in tmp[AF_INET]:
                 if 'addr' in af_addr:
                     if af_addr['addr'] == address:
                         return af_addr['netmask']
 
     raise ValueError
 
 @register_filter('is_ip_network')
 def is_ip_network(addr):
     """ Take IP(v4/v6) address and validate if the passed argument is a network
     or a host address.
 
     Example:
       - 192.0.2.0          -> False
       - 192.0.2.10/24      -> False
       - 192.0.2.0/24       -> True
       - 2001:db8::         -> False
       - 2001:db8::100      -> False
       - 2001:db8::/48      -> True
       - 2001:db8:1000::/64 -> True
     """
     try:
         from ipaddress import ip_network
         # input variables must contain a / to indicate its CIDR notation
         if len(addr.split('/')) != 2:
             raise ValueError()
         ip_network(addr)
         return True
     except:
         return False
 
 @register_filter('network_from_ipv4')
 def network_from_ipv4(address):
     """ Take IP address and search all attached interface IP addresses for the
     given one. After address has been found, return the associated network
     address.
 
     Example:
       - 172.18.201.10 has mask 255.255.255.128 -> network is 172.18.201.0
     """
     netmask = netmask_from_ipv4(address)
     from ipaddress import ip_interface
     cidr_prefix = ip_interface(f'{address}/{netmask}').network
     return address_from_cidr(cidr_prefix)
 
 @register_filter('is_interface')
 def is_interface(interface):
     """ Check if parameter is a valid local interface name """
     from vyos.utils.network import interface_exists
     return interface_exists(interface)
 
 @register_filter('is_ip')
 def is_ip(addr):
     """ Check addr if it is an IPv4 or IPv6 address """
     return is_ipv4(addr) or is_ipv6(addr)
 
 @register_filter('is_ipv4')
 def is_ipv4(text):
     """ Filter IP address, return True on IPv4 address, False otherwise """
     from ipaddress import ip_interface
     try: return ip_interface(text).version == 4
     except: return False
 
 @register_filter('is_ipv6')
 def is_ipv6(text):
     """ Filter IP address, return True on IPv6 address, False otherwise """
     from ipaddress import ip_interface
     try: return ip_interface(text).version == 6
     except: return False
 
 @register_filter('first_host_address')
 def first_host_address(prefix):
     """ Return first usable (host) IP address from given prefix.
     Example:
       - 10.0.0.0/24 -> 10.0.0.1
       - 2001:db8::/64 -> 2001:db8::
     """
     from ipaddress import ip_interface
     tmp = ip_interface(prefix).network
     return str(tmp.network_address +1)
 
 @register_filter('last_host_address')
 def last_host_address(text):
     """ Return first usable IP address from given prefix.
     Example:
       - 10.0.0.0/24 -> 10.0.0.254
       - 2001:db8::/64 -> 2001:db8::ffff:ffff:ffff:ffff
     """
     from ipaddress import ip_interface
     from ipaddress import IPv4Network
     from ipaddress import IPv6Network
 
     addr = ip_interface(text)
     if addr.version == 4:
         return str(IPv4Network(addr).broadcast_address - 1)
 
     return str(IPv6Network(addr).broadcast_address)
 
 @register_filter('inc_ip')
 def inc_ip(address, increment):
     """ Increment given IP address by 'increment'
 
     Example (inc by 2):
       - 10.0.0.0/24 -> 10.0.0.2
       - 2001:db8::/64 -> 2001:db8::2
     """
     from ipaddress import ip_interface
     return str(ip_interface(address).ip + int(increment))
 
 @register_filter('dec_ip')
 def dec_ip(address, decrement):
     """ Decrement given IP address by 'decrement'
 
     Example (inc by 2):
       - 10.0.0.0/24 -> 10.0.0.2
       - 2001:db8::/64 -> 2001:db8::2
     """
     from ipaddress import ip_interface
     return str(ip_interface(address).ip - int(decrement))
 
 @register_filter('compare_netmask')
 def compare_netmask(netmask1, netmask2):
     """
     Compare two IP netmask if they have the exact same size.
 
     compare_netmask('10.0.0.0/8', '20.0.0.0/8') -> True
     compare_netmask('10.0.0.0/8', '20.0.0.0/16') -> False
     """
     from ipaddress import ip_network
     try:
         return ip_network(netmask1).netmask == ip_network(netmask2).netmask
     except:
         return False
 
 @register_filter('isc_static_route')
 def isc_static_route(subnet, router):
     # 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.
     from ipaddress import ip_network
     net = ip_network(subnet)
     # add netmask
     string = str(net.prefixlen) + ','
     # add network bytes
     if net.prefixlen:
         width = net.prefixlen // 8
         if net.prefixlen % 8:
             width += 1
         string += ','.join(map(str,tuple(net.network_address.packed)[:width])) + ','
 
     # add router bytes
     string += ','.join(router.split('.'))
 
     return string
 
 @register_filter('is_file')
 def is_file(filename):
     if os.path.exists(filename):
         return os.path.isfile(filename)
     return False
 
 @register_filter('get_dhcp_router')
 def get_dhcp_router(interface):
     """ Static routes can point to a router received by a DHCP reply. This
     helper is used to get the current default router from the DHCP reply.
 
     Returns False of no router is found, returns the IP address as string if
     a router is found.
     """
     lease_file = directories['isc_dhclient_dir'] + f'/dhclient_{interface}.leases'
     if not os.path.exists(lease_file):
         return None
 
     from vyos.utils.file import read_file
     for line in read_file(lease_file).splitlines():
         if 'option routers' in line:
             (_, _, address) = line.split()
             return address.rstrip(';')
 
 @register_filter('natural_sort')
 def natural_sort(iterable):
     import re
     from jinja2.runtime import Undefined
 
     if isinstance(iterable, Undefined) or iterable is None:
         return list()
 
     def convert(text):
         return int(text) if text.isdigit() else text.lower()
     def alphanum_key(key):
         return [convert(c) for c in re.split('([0-9]+)', str(key))]
 
     return sorted(iterable, key=alphanum_key)
 
 @register_filter('get_ipv4')
 def get_ipv4(interface):
     """ Get interface IPv4 addresses"""
     from vyos.ifconfig import Interface
     return Interface(interface).get_addr_v4()
 
 @register_filter('get_ipv6')
 def get_ipv6(interface):
     """ Get interface IPv6 addresses"""
     from vyos.ifconfig import Interface
     return Interface(interface).get_addr_v6()
 
 @register_filter('get_ip')
 def get_ip(interface):
     """ Get interface IP addresses"""
     from vyos.ifconfig import Interface
     return Interface(interface).get_addr()
 
 def get_first_ike_dh_group(ike_group):
     if ike_group and 'proposal' in ike_group:
         for priority, proposal in ike_group['proposal'].items():
             if 'dh_group' in proposal:
                 return 'dh-group' + proposal['dh_group']
     return 'dh-group2' # Fallback on dh-group2
 
 @register_filter('get_esp_ike_cipher')
 def get_esp_ike_cipher(group_config, ike_group=None):
     pfs_lut = {
         'dh-group1'  : 'modp768',
         'dh-group2'  : 'modp1024',
         'dh-group5'  : 'modp1536',
         'dh-group14' : 'modp2048',
         'dh-group15' : 'modp3072',
         'dh-group16' : 'modp4096',
         'dh-group17' : 'modp6144',
         'dh-group18' : 'modp8192',
         'dh-group19' : 'ecp256',
         'dh-group20' : 'ecp384',
         'dh-group21' : 'ecp521',
         'dh-group22' : 'modp1024s160',
         'dh-group23' : 'modp2048s224',
         'dh-group24' : 'modp2048s256',
         'dh-group25' : 'ecp192',
         'dh-group26' : 'ecp224',
         'dh-group27' : 'ecp224bp',
         'dh-group28' : 'ecp256bp',
         'dh-group29' : 'ecp384bp',
         'dh-group30' : 'ecp512bp',
         'dh-group31' : 'curve25519',
         'dh-group32' : 'curve448'
     }
 
     ciphers = []
     if 'proposal' in group_config:
         for priority, proposal in group_config['proposal'].items():
             # both encryption and hash need to be specified for a proposal
             if not {'encryption', 'hash'} <= set(proposal):
                 continue
 
             tmp = '{encryption}-{hash}'.format(**proposal)
             if 'prf' in proposal:
                 tmp += '-' + proposal['prf']
             if 'dh_group' in proposal:
                 tmp += '-' + pfs_lut[ 'dh-group' +  proposal['dh_group'] ]
             elif 'pfs' in group_config and group_config['pfs'] != 'disable':
                 group = group_config['pfs']
                 if group_config['pfs'] == 'enable':
                     group = get_first_ike_dh_group(ike_group)
                 tmp += '-' + pfs_lut[group]
 
             ciphers.append(tmp)
     return ciphers
 
 @register_filter('get_uuid')
 def get_uuid(seed):
     """ Get interface IP addresses"""
     if seed:
         from hashlib import md5
         from uuid import UUID
         tmp = md5()
         tmp.update(seed.encode('utf-8'))
         return str(UUID(tmp.hexdigest()))
     else:
         from uuid import uuid1
         return uuid1()
 
 openvpn_translate = {
     'des': 'des-cbc',
     '3des': 'des-ede3-cbc',
     'bf128': 'bf-cbc',
     'bf256': 'bf-cbc',
     'aes128gcm': 'aes-128-gcm',
     'aes128': 'aes-128-cbc',
     'aes192gcm': 'aes-192-gcm',
     'aes192': 'aes-192-cbc',
     'aes256gcm': 'aes-256-gcm',
     'aes256': 'aes-256-cbc'
 }
 
 @register_filter('openvpn_cipher')
 def get_openvpn_cipher(cipher):
     if cipher in openvpn_translate:
         return openvpn_translate[cipher].upper()
     return cipher.upper()
 
 @register_filter('openvpn_data_ciphers')
 def get_openvpn_data_ciphers(ciphers):
     out = []
     for cipher in ciphers:
         if cipher in openvpn_translate:
             out.append(openvpn_translate[cipher])
         else:
             out.append(cipher)
     return ':'.join(out).upper()
 
 @register_filter('snmp_auth_oid')
 def snmp_auth_oid(type):
     if type not in ['md5', 'sha', 'aes', 'des', 'none']:
         raise ValueError()
 
     OIDs = {
         'md5' : '.1.3.6.1.6.3.10.1.1.2',
         'sha' : '.1.3.6.1.6.3.10.1.1.3',
         'aes' : '.1.3.6.1.6.3.10.1.2.4',
         'des' : '.1.3.6.1.6.3.10.1.2.2',
         'none': '.1.3.6.1.6.3.10.1.2.1'
     }
     return OIDs[type]
 
 @register_filter('nft_action')
 def nft_action(vyos_action):
     if vyos_action == 'accept':
         return 'return'
     return vyos_action
 
 @register_filter('nft_rule')
 def nft_rule(rule_conf, fw_hook, fw_name, rule_id, ip_name='ip'):
     from vyos.firewall import parse_rule
     return parse_rule(rule_conf, fw_hook, fw_name, rule_id, ip_name)
 
 @register_filter('nft_default_rule')
 def nft_default_rule(fw_conf, fw_name, family):
     output = ['counter']
     default_action = fw_conf['default_action']
     #family = 'ipv6' if ipv6 else 'ipv4'
 
     if 'default_log' in fw_conf:
         action_suffix = default_action[:1].upper()
         output.append(f'log prefix "[{family}-{fw_name[:19]}-default-{action_suffix}]"')
 
     #output.append(nft_action(default_action))
     output.append(f'{default_action}')
     if 'default_jump_target' in fw_conf:
         target = fw_conf['default_jump_target']
         def_suffix = '6' if family == 'ipv6' else ''
         output.append(f'NAME{def_suffix}_{target}')
 
     output.append(f'comment "{fw_name} default-action {default_action}"')
     return " ".join(output)
 
 @register_filter('nft_state_policy')
 def nft_state_policy(conf, state):
     out = [f'ct state {state}']
 
     if 'log' in conf:
         log_state = state[:3].upper()
         log_action = (conf['action'] if 'action' in conf else 'accept')[:1].upper()
         out.append(f'log prefix "[STATE-POLICY-{log_state}-{log_action}]"')
 
         if 'log_level' in conf:
             log_level = conf['log_level']
             out.append(f'level {log_level}')
 
     out.append('counter')
 
     if 'action' in conf:
         out.append(conf['action'])
 
     return " ".join(out)
 
 @register_filter('nft_intra_zone_action')
 def nft_intra_zone_action(zone_conf, ipv6=False):
     if 'intra_zone_filtering' in zone_conf:
         intra_zone = zone_conf['intra_zone_filtering']
         fw_name = 'ipv6_name' if ipv6 else 'name'
         name_prefix = 'NAME6_' if ipv6 else 'NAME_'
 
         if 'action' in intra_zone:
             if intra_zone['action'] == 'accept':
                 return 'return'
             return intra_zone['action']
         elif dict_search_args(intra_zone, 'firewall', fw_name):
             name = dict_search_args(intra_zone, 'firewall', fw_name)
             return f'jump {name_prefix}{name}'
     return 'return'
 
 @register_filter('nft_nested_group')
 def nft_nested_group(out_list, includes, groups, key):
     if not vyos_defined(out_list):
         out_list = []
 
     def add_includes(name):
         if key in groups[name]:
             for item in groups[name][key]:
                 if item in out_list:
                     continue
                 out_list.append(item)
 
         if 'include' in groups[name]:
             for name_inc in groups[name]['include']:
                 add_includes(name_inc)
 
     for name in includes:
         add_includes(name)
     return out_list
 
 @register_filter('nat_rule')
 def nat_rule(rule_conf, rule_id, nat_type, ipv6=False):
     from vyos.nat import parse_nat_rule
     return parse_nat_rule(rule_conf, rule_id, nat_type, ipv6)
 
 @register_filter('nat_static_rule')
 def nat_static_rule(rule_conf, rule_id, nat_type):
     from vyos.nat import parse_nat_static_rule
     return parse_nat_static_rule(rule_conf, rule_id, nat_type)
 
 @register_filter('conntrack_rule')
 def conntrack_rule(rule_conf, rule_id, action, ipv6=False):
     ip_prefix = 'ip6' if ipv6 else 'ip'
     def_suffix = '6' if ipv6 else ''
     output = []
 
     if 'inbound_interface' in rule_conf:
         ifname = rule_conf['inbound_interface']
         if ifname != 'any':
             output.append(f'iifname {ifname}')
 
     if 'protocol' in rule_conf:
         if action != 'timeout':
             proto = rule_conf['protocol']
         else:
             for protocol, protocol_config in rule_conf['protocol'].items():
                 proto = protocol
         if proto != 'all':
             output.append(f'meta l4proto {proto}')
 
     tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags')
     if tcp_flags and action != 'timeout':
         from vyos.firewall import parse_tcp_flags
         output.append(parse_tcp_flags(tcp_flags))
 
     for side in ['source', 'destination']:
         if side in rule_conf:
             side_conf = rule_conf[side]
             prefix = side[0]
 
             if 'address' in side_conf:
                 address = side_conf['address']
                 operator = ''
                 if address[0] == '!':
                     operator = '!='
                     address = address[1:]
                 output.append(f'{ip_prefix} {prefix}addr {operator} {address}')
 
             if 'port' in side_conf:
                 port = side_conf['port']
                 operator = ''
                 if port[0] == '!':
                     operator = '!='
                     port = port[1:]
                 output.append(f'th {prefix}port {operator} {port}')
 
             if 'group' in side_conf:
                 group = side_conf['group']
 
                 if 'address_group' in group:
                     group_name = group['address_group']
                     operator = ''
                     if group_name[0] == '!':
                         operator = '!='
                         group_name = group_name[1:]
                     output.append(f'{ip_prefix} {prefix}addr {operator} @A{def_suffix}_{group_name}')
                 # Generate firewall group domain-group
                 elif 'domain_group' in group:
                     group_name = group['domain_group']
                     operator = ''
                     if group_name[0] == '!':
                         operator = '!='
                         group_name = group_name[1:]
                     output.append(f'{ip_prefix} {prefix}addr {operator} @D_{group_name}')
                 elif 'network_group' in group:
                     group_name = group['network_group']
                     operator = ''
                     if group_name[0] == '!':
                         operator = '!='
                         group_name = group_name[1:]
                     output.append(f'{ip_prefix} {prefix}addr {operator} @N{def_suffix}_{group_name}')
                 if 'port_group' in group:
                     group_name = group['port_group']
 
                     if proto == 'tcp_udp':
                         proto = 'th'
 
                     operator = ''
                     if group_name[0] == '!':
                         operator = '!='
                         group_name = group_name[1:]
 
                     output.append(f'{proto} {prefix}port {operator} @P_{group_name}')
 
     if action == 'ignore':
         output.append('counter notrack')
         output.append(f'comment "ignore-{rule_id}"')
     else:
         output.append(f'counter ct timeout set ct-timeout-{rule_id}')
         output.append(f'comment "timeout-{rule_id}"')
 
     return " ".join(output)
 
 @register_filter('conntrack_ct_policy')
 def conntrack_ct_policy(protocol_conf):
     output = []
     for item in protocol_conf:
         item_value = protocol_conf[item]
         output.append(f'{item}: {item_value}')
 
     return ", ".join(output)
 
+@register_filter('wlb_nft_rule')
+def wlb_nft_rule(rule_conf, rule_id, local=False, exclude=False, limit=False, weight=None, health_state=None, action=None, restore_mark=False):
+    from vyos.wanloadbalance import nft_rule as wlb_nft_rule
+    return wlb_nft_rule(rule_conf, rule_id, local, exclude, limit, weight, health_state, action, restore_mark)
+
 @register_filter('range_to_regex')
 def range_to_regex(num_range):
     """Convert range of numbers or list of ranges
        to regex
 
        % range_to_regex('11-12')
        '(1[1-2])'
        % range_to_regex(['11-12', '14-15'])
        '(1[1-2]|1[4-5])'
     """
     from vyos.range_regex import range_to_regex
     if isinstance(num_range, list):
         data = []
         for entry in num_range:
             if '-' not in entry:
                 data.append(entry)
             else:
                 data.append(range_to_regex(entry))
         return f'({"|".join(data)})'
 
     if '-' not in num_range:
         return num_range
 
     regex = range_to_regex(num_range)
     return f'({regex})'
 
 @register_filter('kea_address_json')
 def kea_address_json(addresses):
     from json import dumps
     from vyos.utils.network import is_addr_assigned
 
     out = []
 
     for address in addresses:
         ifname = is_addr_assigned(address, return_ifname=True, include_vrf=True)
 
         if not ifname:
             continue
 
         out.append(f'{ifname}/{address}')
 
     return dumps(out)
 
 @register_filter('kea_high_availability_json')
 def kea_high_availability_json(config):
     from json import dumps
 
     source_addr = config['source_address']
     remote_addr = config['remote']
     ha_mode = 'hot-standby' if config['mode'] == 'active-passive' else 'load-balancing'
     ha_role = config['status']
 
     if ha_role == 'primary':
         peer1_role = 'primary'
         peer2_role = 'standby' if ha_mode == 'hot-standby' else 'secondary'
     else:
         peer1_role = 'standby' if ha_mode == 'hot-standby' else 'secondary'
         peer2_role = 'primary'
 
     data = {
         'this-server-name': os.uname()[1],
         'mode': ha_mode,
         'heartbeat-delay': 10000,
         'max-response-delay': 10000,
         'max-ack-delay': 5000,
         'max-unacked-clients': 0,
         'peers': [
         {
             'name': os.uname()[1],
             'url': f'http://{source_addr}:647/',
             'role': peer1_role,
             'auto-failover': True
         },
         {
             'name': config['name'],
             'url': f'http://{remote_addr}:647/',
             'role': peer2_role,
             'auto-failover': True
         }]
     }
 
     if 'ca_cert_file' in config:
         data['trust-anchor'] = config['ca_cert_file']
 
     if 'cert_file' in config:
         data['cert-file'] = config['cert_file']
 
     if 'cert_key_file' in config:
         data['key-file'] = config['cert_key_file']
 
     return dumps(data)
 
 @register_filter('kea_shared_network_json')
 def kea_shared_network_json(shared_networks):
     from vyos.kea import kea_parse_options
     from vyos.kea import kea_parse_subnet
     from json import dumps
     out = []
 
     for name, config in shared_networks.items():
         if 'disable' in config:
             continue
 
         network = {
             'name': name,
             'authoritative': ('authoritative' in config),
             'subnet4': []
         }
 
         if 'option' in config:
             network['option-data'] = kea_parse_options(config['option'])
 
             if 'bootfile_name' in config['option']:
                 network['boot-file-name'] = config['option']['bootfile_name']
 
             if 'bootfile_server' in config['option']:
                 network['next-server'] = config['option']['bootfile_server']
 
         if 'subnet' in config:
             for subnet, subnet_config in config['subnet'].items():
                 if 'disable' in subnet_config:
                     continue
                 network['subnet4'].append(kea_parse_subnet(subnet, subnet_config))
 
         out.append(network)
 
     return dumps(out, indent=4)
 
 @register_filter('kea6_shared_network_json')
 def kea6_shared_network_json(shared_networks):
     from vyos.kea import kea6_parse_options
     from vyos.kea import kea6_parse_subnet
     from json import dumps
     out = []
 
     for name, config in shared_networks.items():
         if 'disable' in config:
             continue
 
         network = {
             'name': name,
             'subnet6': []
         }
 
         if 'option' in config:
             network['option-data'] = kea6_parse_options(config['option'])
 
         if 'interface' in config:
             network['interface'] = config['interface']
 
         if 'subnet' in config:
             for subnet, subnet_config in config['subnet'].items():
                 network['subnet6'].append(kea6_parse_subnet(subnet, subnet_config))
 
         out.append(network)
 
     return dumps(out, indent=4)
 
 @register_test('vyos_defined')
 def vyos_defined(value, test_value=None, var_type=None):
     """
     Jinja2 plugin to test if a variable is defined and not none - vyos_defined
     will test value if defined and is not none and return true or false.
 
     If test_value is supplied, the value must also pass == test_value to return true.
     If var_type is supplied, the value must also be of the specified class/type
 
     Examples:
     1. Test if var is defined and not none:
     {% if foo is vyos_defined %}
     ...
     {% endif %}
 
     2. Test if variable is defined, not none and has value "something"
     {% if bar is vyos_defined("something") %}
     ...
     {% endif %}
 
     Parameters
     ----------
     value : any
         Value to test from ansible
     test_value : any, optional
         Value to test in addition of defined and not none, by default None
     var_type : ['float', 'int', 'str', 'list', 'dict', 'tuple', 'bool'], optional
         Type or Class to test for
 
     Returns
     -------
     boolean
         True if variable matches criteria, False in other cases.
 
     Implementation inspired and re-used from https://github.com/aristanetworks/ansible-avd/
     """
 
     from jinja2 import Undefined
 
     if isinstance(value, Undefined) or value is None:
         # Invalid value - return false
         return False
     elif test_value is not None and value != test_value:
         # Valid value but not matching the optional argument
         return False
     elif str(var_type).lower() in ['float', 'int', 'str', 'list', 'dict', 'tuple', 'bool'] and str(var_type).lower() != type(value).__name__:
         # Invalid class - return false
         return False
     else:
         # Valid value and is matching optional argument if provided - return true
         return True
diff --git a/python/vyos/wanloadbalance.py b/python/vyos/wanloadbalance.py
new file mode 100644
index 000000000..62e109f21
--- /dev/null
+++ b/python/vyos/wanloadbalance.py
@@ -0,0 +1,153 @@
+#!/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 os
+
+from vyos.defaults import directories
+from vyos.utils.process import run
+
+dhclient_lease = 'dhclient_{0}.lease'
+
+def nft_rule(rule_conf, rule_id, local=False, exclude=False, limit=False, weight=None, health_state=None, action=None, restore_mark=False):
+    output = []
+
+    if 'inbound_interface' in rule_conf:
+        ifname = rule_conf['inbound_interface']
+        if local and not exclude:
+            output.append(f'oifname != "{ifname}"')
+        elif not local:
+            output.append(f'iifname "{ifname}"')
+
+    if 'protocol' in rule_conf and rule_conf['protocol'] != 'all':
+        protocol = rule_conf['protocol']
+        operator = ''
+
+        if protocol[:1] == '!':
+            operator = '!='
+            protocol = protocol[1:]
+
+        if protocol == 'tcp_udp':
+            protocol = '{ tcp, udp }'
+
+        output.append(f'meta l4proto {operator} {protocol}')
+
+    for direction in ['source', 'destination']:
+        if direction not in rule_conf:
+            continue
+
+        direction_conf = rule_conf[direction]
+        prefix = direction[:1]
+
+        if 'address' in direction_conf:
+            operator = ''
+            address = direction_conf['address']
+            if address[:1] == '!':
+                operator = '!='
+                address = address[1:]
+            output.append(f'ip {prefix}addr {operator} {address}')
+
+        if 'port' in direction_conf:
+            operator = ''
+            port = direction_conf['port']
+            if port[:1] == '!':
+                operator = '!='
+                port = port[1:]
+            output.append(f'th {prefix}port {operator} {port}')
+
+    if 'source_based_routing' not in rule_conf and not restore_mark:
+        output.append('ct state new')
+
+    if limit and 'limit' in rule_conf and 'rate' in rule_conf['limit']:
+        output.append(f'limit rate {rule_conf["limit"]["rate"]}/{rule_conf["limit"]["period"]}')
+        if 'burst' in rule_conf['limit']:
+            output.append(f'burst {rule_conf["limit"]["burst"]} packets')
+
+    output.append('counter')
+
+    if restore_mark:
+        output.append('meta mark set ct mark')
+    elif weight:
+        weights, total_weight = wlb_weight_interfaces(rule_conf, health_state)
+        if len(weights) > 1: # Create weight-based verdict map
+            vmap_str = ", ".join(f'{weight} : jump wlb_mangle_isp_{ifname}' for ifname, weight in weights)
+            output.append(f'numgen random mod {total_weight} vmap {{ {vmap_str} }}')
+        elif len(weights) == 1: # Jump to single ISP
+            ifname, _ = weights[0]
+            output.append(f'jump wlb_mangle_isp_{ifname}')
+        else: # No healthy interfaces
+            return ""
+    elif action:
+        output.append(action)
+
+    return " ".join(output)
+
+def wlb_weight_interfaces(rule_conf, health_state):
+    interfaces = []
+
+    for ifname, if_conf in rule_conf['interface'].items():
+        if ifname in health_state and health_state[ifname]['state']:
+            weight = int(if_conf.get('weight', 1))
+            interfaces.append((ifname, weight))
+
+    if not interfaces:
+        return [], 0
+
+    if 'failover' in rule_conf:
+        for ifpair in sorted(interfaces, key=lambda i: i[1], reverse=True):
+            return [ifpair], ifpair[1] # Return highest weight interface that is ACTIVE when in failover
+
+    total_weight = sum(weight for _, weight in interfaces)
+    out = []
+    start = 0
+    for ifname, weight in sorted(interfaces, key=lambda i: i[1]): # build weight ranges
+        end = start + weight - 1
+        out.append((ifname, f'{start}-{end}' if end > start else start))
+        start = weight
+
+    return out, total_weight
+
+def health_ping_host(host, ifname, count=1, wait_time=0):
+    cmd_str = f'ping -c {count} -W {wait_time} -I {ifname} {host}'
+    rc = run(cmd_str)
+    return rc == 0
+
+def health_ping_host_ttl(host, ifname, count=1, ttl_limit=0):
+    cmd_str = f'ping -c {count} -t {ttl_limit} -I {ifname} {host}'
+    rc = run(cmd_str)
+    return rc != 0
+
+def parse_dhcp_nexthop(ifname):
+    lease_file = os.path.join(directories['isc_dhclient_dir'], dhclient_lease.format(ifname))
+
+    if not os.path.exists(lease_file):
+        return False
+
+    with open(lease_file, 'r') as f:
+        for line in f.readlines():
+            data = line.replace('\n', '').split('=')
+            if data[0] == 'new_routers':
+                return data[1].replace("'", '').split(" ")[0]
+
+    return None
+
+def parse_ppp_nexthop(ifname):
+    nexthop_file = os.path.join(directories['ppp_nexthop_dir'], ifname)
+
+    if not os.path.exists(nexthop_file):
+        return False
+
+    with open(nexthop_file, 'r') as f:
+        return f.read()
diff --git a/smoketest/scripts/cli/base_vyostest_shim.py b/smoketest/scripts/cli/base_vyostest_shim.py
index a89b8dce5..edf940efd 100644
--- a/smoketest/scripts/cli/base_vyostest_shim.py
+++ b/smoketest/scripts/cli/base_vyostest_shim.py
@@ -1,210 +1,219 @@
 # Copyright (C) 2021-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 os
 import unittest
 import paramiko
 import pprint
 
 from time import sleep
 from typing import Type
 
 from vyos.configsession import ConfigSession
 from vyos.configsession import ConfigSessionError
 from vyos import ConfigError
 from vyos.defaults import commit_lock
 from vyos.utils.process import cmd
 from vyos.utils.process import run
 
 save_config = '/tmp/vyos-smoketest-save'
 
 # The commit process is not finished until all pending files from
 # VYATTA_CHANGES_ONLY_DIR are copied to VYATTA_ACTIVE_CONFIGURATION_DIR. This
 # is done inside libvyatta-cfg1 and the FUSE UnionFS part. On large non-
 # interactive commits FUSE UnionFS might not replicate the real state in time,
 # leading to errors when querying the working and effective configuration.
 # TO BE DELETED AFTER SWITCH TO IN MEMORY CONFIG
 CSTORE_GUARD_TIME = 4
 
 # This class acts as shim between individual Smoketests developed for VyOS and
 # the Python UnitTest framework. Before every test is loaded, we dump the current
 # system configuration and reload it after the test - despite the test results.
 #
 # Using this approach we can not render a live system useless while running any
 # kind of smoketest. In addition it adds debug capabilities like printing the
 # command used to execute the test.
 class VyOSUnitTestSHIM:
     class TestCase(unittest.TestCase):
         # if enabled in derived class, print out each and every set/del command
         # on the CLI. This is usefull to grap all the commands required to
         # trigger the certain failure condition.
         # Use "self.debug = True" in derived classes setUp() method
         debug = False
         # Time to wait after a commit to ensure the CStore is up to date
         # only required for testcases using FRR
         _commit_guard_time = 0
         @classmethod
         def setUpClass(cls):
             cls._session = ConfigSession(os.getpid())
             cls._session.save_config(save_config)
             if os.path.exists('/tmp/vyos.smoketest.debug'):
                 cls.debug = True
             pass
 
         @classmethod
         def tearDownClass(cls):
             # discard any pending changes which might caused a messed up config
             cls._session.discard()
             # ... and restore the initial state
             cls._session.migrate_and_load_config(save_config)
 
             try:
                 cls._session.commit()
             except (ConfigError, ConfigSessionError):
                 cls._session.discard()
                 cls.fail(cls)
 
         def cli_set(self, path, value=None):
             if self.debug:
                 str = f'set {" ".join(path)} {value}' if value else f'set {" ".join(path)}'
                 print(str)
             self._session.set(path, value)
 
         def cli_delete(self, config):
             if self.debug:
                 print('del ' + ' '.join(config))
             self._session.delete(config)
 
         def cli_discard(self):
             if self.debug:
                 print('DISCARD')
             self._session.discard()
 
         def cli_commit(self):
             if self.debug:
                 print('commit')
             self._session.commit()
             # During a commit there is a process opening commit_lock, and run()
             # returns 0
             while run(f'sudo lsof -nP {commit_lock}') == 0:
                 sleep(0.250)
             # Wait for CStore completion for fast non-interactive commits
             sleep(self._commit_guard_time)
 
         def op_mode(self, path : list) -> None:
             """
             Execute OP-mode command and return stdout
             """
             if self.debug:
                 print('commit')
             path = ' '.join(path)
             out = cmd(f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {path}')
             if self.debug:
                 print(f'\n\ncommand "{path}" returned:\n')
                 pprint.pprint(out)
             return out
 
         def getFRRconfig(self, string=None, end='$', endsection='^!',
                          substring=None, endsubsection=None, empty_retry=0):
             """
             Retrieve current "running configuration" from FRR
 
             string:        search for a specific start string in the configuration
             end:           end of the section to search for (line ending)
             endsection:    end of the configuration
             substring:     search section under the result found by string
             endsubsection: end of the subsection (usually something with "exit")
             """
             command = f'vtysh -c "show run no-header"'
             if string:
                 command += f' | sed -n "/^{string}{end}/,/{endsection}/p"'
                 if substring and endsubsection:
                     command += f' | sed -n "/^{substring}/,/{endsubsection}/p"'
             out = cmd(command)
             if self.debug:
                 print(f'\n\ncommand "{command}" returned:\n')
                 pprint.pprint(out)
             if empty_retry > 0:
                 retry_count = 0
                 while not out and retry_count < empty_retry:
                     if self.debug and retry_count % 10 == 0:
                         print(f"Attempt {retry_count}: FRR config is still empty. Retrying...")
                     retry_count += 1
                     sleep(1)
                     out = cmd(command)
                 if not out:
                     print(f'FRR configuration still empty after {empty_retry} retires!')
             return out
 
         @staticmethod
         def ssh_send_cmd(command, username, password, hostname='localhost'):
             """ SSH command execution helper """
             # Try to login via SSH
             ssh_client = paramiko.SSHClient()
             ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
             ssh_client.connect(hostname=hostname, username=username, password=password)
             _, stdout, stderr = ssh_client.exec_command(command)
             output = stdout.read().decode().strip()
             error = stderr.read().decode().strip()
             ssh_client.close()
             return output, error
 
         # Verify nftables output
         def verify_nftables(self, nftables_search, table, inverse=False, args=''):
             nftables_output = cmd(f'sudo nft {args} list table {table}')
 
             for search in nftables_search:
                 matched = False
                 for line in nftables_output.split("\n"):
                     if all(item in line for item in search):
                         matched = True
                         break
                 self.assertTrue(not matched if inverse else matched, msg=search)
 
         def verify_nftables_chain(self, nftables_search, table, chain, inverse=False, args=''):
             nftables_output = cmd(f'sudo nft {args} list chain {table} {chain}')
 
             for search in nftables_search:
                 matched = False
                 for line in nftables_output.split("\n"):
                     if all(item in line for item in search):
                         matched = True
                         break
                 self.assertTrue(not matched if inverse else matched, msg=search)
 
+        def verify_nftables_chain_exists(self, table, chain, inverse=False):
+            try:
+                cmd(f'sudo nft list chain {table} {chain}')
+                if inverse:
+                    self.fail(f'Chain exists: {table} {chain}')
+            except OSError:
+                if not inverse:
+                    self.fail(f'Chain does not exist: {table} {chain}')
+
         # Verify ip rule output
         def verify_rules(self, rules_search, inverse=False, addr_family='inet'):
             rule_output = cmd(f'ip -family {addr_family} rule show')
 
             for search in rules_search:
                 matched = False
                 for line in rule_output.split("\n"):
                     if all(item in line for item in search):
                         matched = True
                         break
                 self.assertTrue(not matched if inverse else matched, msg=search)
 
 # standard construction; typing suggestion: https://stackoverflow.com/a/70292317
 def ignore_warning(warning: Type[Warning]):
     import warnings
     from functools import wraps
 
     def inner(f):
         @wraps(f)
         def wrapped(*args, **kwargs):
             with warnings.catch_warnings():
                 warnings.simplefilter("ignore", category=warning)
                 return f(*args, **kwargs)
         return wrapped
     return inner
diff --git a/smoketest/scripts/cli/test_load-balancing_wan.py b/smoketest/scripts/cli/test_load-balancing_wan.py
index 92b4000b8..f652988b2 100755
--- a/smoketest/scripts/cli/test_load-balancing_wan.py
+++ b/smoketest/scripts/cli/test_load-balancing_wan.py
@@ -1,254 +1,322 @@
 #!/usr/bin/env python3
 #
 # Copyright (C) 2022-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 os
 import unittest
 import time
 
 from base_vyostest_shim import VyOSUnitTestSHIM
+from vyos.utils.file import chmod_755
+from vyos.utils.file import write_file
 from vyos.utils.process import call
 from vyos.utils.process import cmd
 
 base_path = ['load-balancing']
 
 def create_netns(name):
     return call(f'sudo ip netns add {name}')
 
 def create_veth_pair(local='veth0', peer='ceth0'):
     return call(f'sudo ip link add {local} type veth peer name {peer}')
 
 def move_interface_to_netns(iface, netns_name):
     return call(f'sudo ip link set {iface} netns {netns_name}')
 
 def rename_interface(iface, new_name):
     return call(f'sudo ip link set {iface} name {new_name}')
 
 def cmd_in_netns(netns, cmd):
     return call(f'sudo ip netns exec {netns} {cmd}')
 
 def delete_netns(name):
     return call(f'sudo ip netns del {name}')
 
 class TestLoadBalancingWan(VyOSUnitTestSHIM.TestCase):
     @classmethod
     def setUpClass(cls):
         super(TestLoadBalancingWan, cls).setUpClass()
 
         # ensure we can also run this test on a live system - so lets clean
         # out the current configuration :)
         cls.cli_delete(cls, base_path)
 
     def tearDown(self):
         self.cli_delete(base_path)
         self.cli_commit()
 
+        removed_chains = [
+            'wlb_mangle_isp_veth1',
+            'wlb_mangle_isp_veth2',
+            'wlb_mangle_isp_eth201',
+            'wlb_mangle_isp_eth202'
+        ]
+
+        for chain in removed_chains:
+            self.verify_nftables_chain_exists('ip vyos_wanloadbalance', chain, inverse=True)
+
     def test_table_routes(self):
         ns1 = 'ns201'
         ns2 = 'ns202'
         ns3 = 'ns203'
         iface1 = 'eth201'
         iface2 = 'eth202'
         iface3 = 'eth203'
         container_iface1 = 'ceth0'
         container_iface2 = 'ceth1'
         container_iface3 = 'ceth2'
 
         # Create network namespeces
         create_netns(ns1)
         create_netns(ns2)
         create_netns(ns3)
         create_veth_pair(iface1, container_iface1)
         create_veth_pair(iface2, container_iface2)
         create_veth_pair(iface3, container_iface3)
 
         move_interface_to_netns(container_iface1, ns1)
         move_interface_to_netns(container_iface2, ns2)
         move_interface_to_netns(container_iface3, ns3)
         call(f'sudo ip address add 203.0.113.10/24 dev {iface1}')
         call(f'sudo ip address add 192.0.2.10/24 dev {iface2}')
         call(f'sudo ip address add 198.51.100.10/24 dev {iface3}')
         call(f'sudo ip link set dev {iface1} up')
         call(f'sudo ip link set dev {iface2} up')
         call(f'sudo ip link set dev {iface3} up')
         cmd_in_netns(ns1, f'ip link set {container_iface1} name eth0')
         cmd_in_netns(ns2, f'ip link set {container_iface2} name eth0')
         cmd_in_netns(ns3, f'ip link set {container_iface3} name eth0')
         cmd_in_netns(ns1, 'ip address add 203.0.113.1/24 dev eth0')
         cmd_in_netns(ns2, 'ip address add 192.0.2.1/24 dev eth0')
         cmd_in_netns(ns3, 'ip address add 198.51.100.1/24 dev eth0')
         cmd_in_netns(ns1, 'ip link set dev eth0 up')
         cmd_in_netns(ns2, 'ip link set dev eth0 up')
         cmd_in_netns(ns3, 'ip link set dev eth0 up')
 
         # Set load-balancing configuration
+        self.cli_set(base_path + ['wan', 'hook', '/bin/true'])
         self.cli_set(base_path + ['wan', 'interface-health', iface1, 'failure-count', '2'])
         self.cli_set(base_path + ['wan', 'interface-health', iface1, 'nexthop', '203.0.113.1'])
         self.cli_set(base_path + ['wan', 'interface-health', iface1, 'success-count', '1'])
         self.cli_set(base_path + ['wan', 'interface-health', iface2, 'failure-count', '2'])
         self.cli_set(base_path + ['wan', 'interface-health', iface2, 'nexthop', '192.0.2.1'])
         self.cli_set(base_path + ['wan', 'interface-health', iface2, 'success-count', '1'])
 
         self.cli_set(base_path + ['wan', 'rule', '10', 'inbound-interface', iface3])
         self.cli_set(base_path + ['wan', 'rule', '10', 'source', 'address', '198.51.100.0/24'])
-
+        self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface1])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface2])
 
         # commit changes
         self.cli_commit()
 
         time.sleep(5)
         # Check default routes in tables 201, 202
         # Expected values
         original = 'default via 203.0.113.1 dev eth201'
         tmp = cmd('sudo ip route show table 201')
         self.assertEqual(tmp, original)
 
         original = 'default via 192.0.2.1 dev eth202'
         tmp = cmd('sudo ip route show table 202')
         self.assertEqual(tmp, original)
 
         # Delete veth interfaces and netns
         for iface in [iface1, iface2, iface3]:
             call(f'sudo ip link del dev {iface}')
 
         delete_netns(ns1)
         delete_netns(ns2)
         delete_netns(ns3)
 
     def test_check_chains(self):
-
         ns1 = 'nsA'
         ns2 = 'nsB'
         ns3 = 'nsC'
         iface1 = 'veth1'
         iface2 = 'veth2'
         iface3 = 'veth3'
         container_iface1 = 'ceth0'
         container_iface2 = 'ceth1'
         container_iface3 = 'ceth2'
-        mangle_isp1 = """table ip mangle {
-	chain ISP_veth1 {
-		counter ct mark set 0xc9
-		counter meta mark set 0xc9
-		counter accept
+        mangle_isp1 = """table ip vyos_wanloadbalance {
+	chain wlb_mangle_isp_veth1 {
+		meta mark set 0x000000c9 ct mark set 0x000000c9 counter accept
 	}
 }"""
-        mangle_isp2 = """table ip mangle {
-	chain ISP_veth2 {
-		counter ct mark set 0xca
-		counter meta mark set 0xca
-		counter accept
+        mangle_isp2 = """table ip vyos_wanloadbalance {
+	chain wlb_mangle_isp_veth2 {
+		meta mark set 0x000000ca ct mark set 0x000000ca counter accept
 	}
 }"""
-        mangle_prerouting = """table ip mangle {
-	chain PREROUTING {
+        mangle_prerouting = """table ip vyos_wanloadbalance {
+	chain wlb_mangle_prerouting {
 		type filter hook prerouting priority mangle; policy accept;
-		counter jump WANLOADBALANCE_PRE
-	}
-}"""
-        mangle_wanloadbalance_pre = """table ip mangle {
-	chain WANLOADBALANCE_PRE {
-		iifname "veth3" ip saddr 198.51.100.0/24 ct state new meta random & 2147483647 < 1073741824 counter jump ISP_veth1
-		iifname "veth3" ip saddr 198.51.100.0/24 ct state new counter jump ISP_veth2
+		iifname "veth3" ip saddr 198.51.100.0/24 ct state new limit rate 5/second burst 5 packets counter numgen random mod 11 vmap { 0 : jump wlb_mangle_isp_veth1, 1-10 : jump wlb_mangle_isp_veth2 }
 		iifname "veth3" ip saddr 198.51.100.0/24 counter meta mark set ct mark
 	}
 }"""
-        nat_wanloadbalance = """table ip nat {
-	chain WANLOADBALANCE {
-		ct mark 0xc9 counter snat to 203.0.113.10
-		ct mark 0xca counter snat to 192.0.2.10
-	}
-}"""
-        nat_vyos_pre_snat_hook = """table ip nat {
-	chain VYOS_PRE_SNAT_HOOK {
+        nat_wanloadbalance = """table ip vyos_wanloadbalance {
+	chain wlb_nat_postrouting {
 		type nat hook postrouting priority srcnat - 1; policy accept;
-		counter jump WANLOADBALANCE
+		ct mark 0x000000c9 counter snat to 203.0.113.10
+		ct mark 0x000000ca counter snat to 192.0.2.10
 	}
 }"""
 
         # Create network namespeces
         create_netns(ns1)
         create_netns(ns2)
         create_netns(ns3)
         create_veth_pair(iface1, container_iface1)
         create_veth_pair(iface2, container_iface2)
         create_veth_pair(iface3, container_iface3)
         move_interface_to_netns(container_iface1, ns1)
         move_interface_to_netns(container_iface2, ns2)
         move_interface_to_netns(container_iface3, ns3)
         call(f'sudo ip address add 203.0.113.10/24 dev {iface1}')
         call(f'sudo ip address add 192.0.2.10/24 dev {iface2}')
         call(f'sudo ip address add 198.51.100.10/24 dev {iface3}')
 
         for iface in [iface1, iface2, iface3]:
             call(f'sudo ip link set dev {iface} up')
 
         cmd_in_netns(ns1, f'ip link set {container_iface1} name eth0')
         cmd_in_netns(ns2, f'ip link set {container_iface2} name eth0')
         cmd_in_netns(ns3, f'ip link set {container_iface3} name eth0')
         cmd_in_netns(ns1, 'ip address add 203.0.113.1/24 dev eth0')
         cmd_in_netns(ns2, 'ip address add 192.0.2.1/24 dev eth0')
         cmd_in_netns(ns3, 'ip address add 198.51.100.1/24 dev eth0')
         cmd_in_netns(ns1, 'ip link set dev eth0 up')
         cmd_in_netns(ns2, 'ip link set dev eth0 up')
         cmd_in_netns(ns3, 'ip link set dev eth0 up')
 
         # Set load-balancing configuration
         self.cli_set(base_path + ['wan', 'interface-health', iface1, 'failure-count', '2'])
         self.cli_set(base_path + ['wan', 'interface-health', iface1, 'nexthop', '203.0.113.1'])
         self.cli_set(base_path + ['wan', 'interface-health', iface1, 'success-count', '1'])
         self.cli_set(base_path + ['wan', 'interface-health', iface2, 'failure-count', '2'])
         self.cli_set(base_path + ['wan', 'interface-health', iface2, 'nexthop', '192.0.2.1'])
         self.cli_set(base_path + ['wan', 'interface-health', iface2, 'success-count', '1'])
         self.cli_set(base_path + ['wan', 'rule', '10', 'inbound-interface', iface3])
         self.cli_set(base_path + ['wan', 'rule', '10', 'source', 'address', '198.51.100.0/24'])
         self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface1])
-        self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface2])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface2, 'weight', '10'])
 
         # commit changes
         self.cli_commit()
 
         time.sleep(5)
 
         # Check mangle chains
-        tmp = cmd(f'sudo nft -s list chain mangle ISP_{iface1}')
+        tmp = cmd(f'sudo nft -s list chain ip vyos_wanloadbalance wlb_mangle_isp_{iface1}')
         self.assertEqual(tmp, mangle_isp1)
 
-        tmp = cmd(f'sudo nft -s list chain mangle ISP_{iface2}')
+        tmp = cmd(f'sudo nft -s list chain ip vyos_wanloadbalance wlb_mangle_isp_{iface2}')
         self.assertEqual(tmp, mangle_isp2)
 
-        tmp = cmd(f'sudo nft -s list chain mangle PREROUTING')
+        tmp = cmd('sudo nft -s list chain ip vyos_wanloadbalance wlb_mangle_prerouting')
         self.assertEqual(tmp, mangle_prerouting)
 
-        tmp = cmd(f'sudo nft -s list chain mangle WANLOADBALANCE_PRE')
-        self.assertEqual(tmp, mangle_wanloadbalance_pre)
-
         # Check nat chains
-        tmp = cmd(f'sudo nft -s list chain nat WANLOADBALANCE')
+        tmp = cmd('sudo nft -s list chain ip vyos_wanloadbalance wlb_nat_postrouting')
         self.assertEqual(tmp, nat_wanloadbalance)
 
-        tmp = cmd(f'sudo nft -s list chain nat VYOS_PRE_SNAT_HOOK')
-        self.assertEqual(tmp, nat_vyos_pre_snat_hook)
-
         # Delete veth interfaces and netns
         for iface in [iface1, iface2, iface3]:
             call(f'sudo ip link del dev {iface}')
 
         delete_netns(ns1)
         delete_netns(ns2)
         delete_netns(ns3)
 
+    def test_criteria_failover_hook(self):
+        isp1_iface = 'eth0'
+        isp2_iface = 'eth1'
+        lan_iface = 'eth2'
+
+        hook_path = '/tmp/wlb_hook.sh'
+        hook_output_path = '/tmp/wlb_hook_output'
+        hook_script = f"""
+#!/bin/sh
+
+ifname=$WLB_INTERFACE_NAME
+state=$WLB_INTERFACE_STATE
+
+echo "$ifname - $state" > {hook_output_path}
+"""
+
+        write_file(hook_path, hook_script)
+        chmod_755(hook_path)
+
+        self.cli_set(['interfaces', 'ethernet', isp1_iface, 'address', '203.0.113.2/30'])
+        self.cli_set(['interfaces', 'ethernet', isp2_iface, 'address', '192.0.2.2/30'])
+        self.cli_set(['interfaces', 'ethernet', lan_iface, 'address', '198.51.100.2/30'])
+
+        self.cli_set(base_path + ['wan', 'hook', hook_path])
+        self.cli_set(base_path + ['wan', 'interface-health', isp1_iface, 'failure-count', '1'])
+        self.cli_set(base_path + ['wan', 'interface-health', isp1_iface, 'nexthop', '203.0.113.2'])
+        self.cli_set(base_path + ['wan', 'interface-health', isp1_iface, 'success-count', '1'])
+        self.cli_set(base_path + ['wan', 'interface-health', isp2_iface, 'failure-count', '1'])
+        self.cli_set(base_path + ['wan', 'interface-health', isp2_iface, 'nexthop', '192.0.2.2'])
+        self.cli_set(base_path + ['wan', 'interface-health', isp2_iface, 'success-count', '1'])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'failover'])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'inbound-interface', lan_iface])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'protocol', 'udp'])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'source', 'address', '198.51.100.0/24'])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'source', 'port', '53'])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'destination', 'address', '192.0.2.0/24'])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'destination', 'port', '53'])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'interface', isp1_iface])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'interface', isp1_iface, 'weight', '10'])
+        self.cli_set(base_path + ['wan', 'rule', '10', 'interface', isp2_iface])
+
+        # commit changes
+        self.cli_commit()
+
+        time.sleep(5)
+
+        # Verify isp1 + criteria
+
+        nftables_search = [
+            [f'iifname "{lan_iface}"', 'ip saddr 198.51.100.0/24', 'udp sport 53', 'ip daddr 192.0.2.0/24', 'udp dport 53', f'jump wlb_mangle_isp_{isp1_iface}']
+        ]
+
+        self.verify_nftables_chain(nftables_search, 'ip vyos_wanloadbalance', 'wlb_mangle_prerouting')
+
+        # Trigger failure on isp1 health check
+
+        self.cli_delete(['interfaces', 'ethernet', isp1_iface, 'address', '203.0.113.2/30'])
+        self.cli_commit()
+
+        time.sleep(10)
+
+        # Verify failover to isp2
+
+        nftables_search = [
+            [f'iifname "{lan_iface}"', f'jump wlb_mangle_isp_{isp2_iface}']
+        ]
+
+        self.verify_nftables_chain(nftables_search, 'ip vyos_wanloadbalance', 'wlb_mangle_prerouting')
+
+        # Verify hook output
+
+        self.assertTrue(os.path.exists(hook_output_path))
+
+        with open(hook_output_path, 'r') as f:
+            self.assertIn('eth0 - FAILED', f.read())
 
 if __name__ == '__main__':
     unittest.main(verbosity=2)
diff --git a/src/conf_mode/load-balancing_wan.py b/src/conf_mode/load-balancing_wan.py
index 5da0b906b..b3dd80a9a 100755
--- a/src/conf_mode/load-balancing_wan.py
+++ b/src/conf_mode/load-balancing_wan.py
@@ -1,151 +1,118 @@
 #!/usr/bin/env python3
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# Copyright (C) 2023-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 os
-
 from sys import exit
-from shutil import rmtree
 
-from vyos.base import Warning
 from vyos.config import Config
 from vyos.configdep import set_dependents, call_dependents
 from vyos.utils.process import cmd
-from vyos.template import render
 from vyos import ConfigError
 from vyos import airbag
 airbag.enable()
 
-load_balancing_dir = '/run/load-balance'
-load_balancing_conf_file = f'{load_balancing_dir}/wlb.conf'
-systemd_service = 'vyos-wan-load-balance.service'
-
+service = 'vyos-wan-load-balance.service'
 
 def get_config(config=None):
     if config:
         conf = config
     else:
         conf = Config()
 
     base = ['load-balancing', 'wan']
+
     lb = conf.get_config_dict(base, key_mangling=('-', '_'),
                               no_tag_node_value_mangle=True,
                               get_first_key=True,
                               with_recursive_defaults=True)
 
     # prune limit key if not set by user
     for rule in lb.get('rule', []):
         if lb.from_defaults(['rule', rule, 'limit']):
             del lb['rule'][rule]['limit']
 
     set_dependents('conntrack', conf)
 
     return lb
 
 
 def verify(lb):
     if not lb:
         return None
 
-    if 'interface_health' not in lb:
-        raise ConfigError(
-            'A valid WAN load-balance configuration requires an interface with a nexthop!'
-        )
-
-    for interface, interface_config in lb['interface_health'].items():
-        if 'nexthop' not in interface_config:
-            raise ConfigError(
-                f'interface-health {interface} nexthop must be specified!')
-
-        if 'test' in interface_config:
-            for test_rule, test_config in interface_config['test'].items():
-                if 'type' in test_config:
-                    if test_config['type'] == 'user-defined' and 'test_script' not in test_config:
-                        raise ConfigError(
-                            f'test {test_rule} script must be defined for test-script!'
-                        )
-
-    if 'rule' not in lb:
-        Warning(
-            'At least one rule with an (outbound) interface must be defined for WAN load balancing to be active!'
-        )
+    if 'interface_health' in lb:
+        for ifname, health_conf in lb['interface_health'].items():
+            if 'nexthop' not in health_conf:
+                raise ConfigError(f'Nexthop must be configured for interface {ifname}')
+
+            if 'test' not in health_conf:
+                continue
+
+            for test_id, test_conf in health_conf['test'].items():
+                if 'type' not in test_conf:
+                    raise ConfigError(f'No type configured for health test on interface {ifname}')
+
+                if test_conf['type'] == 'user-defined' and 'test_script' not in test_conf:
+                    raise ConfigError(f'Missing user-defined script for health test on interface {ifname}')
     else:
-        for rule, rule_config in lb['rule'].items():
-            if 'inbound_interface' not in rule_config:
-                raise ConfigError(f'rule {rule} inbound-interface must be specified!')
-            if {'failover', 'exclude'} <= set(rule_config):
-                raise ConfigError(f'rule {rule} failover cannot be configured with exclude!')
-            if {'limit', 'exclude'} <= set(rule_config):
-                raise ConfigError(f'rule {rule} limit cannot be used with exclude!')
-            if 'interface' not in rule_config:
-                if 'exclude' not in rule_config:
-                    Warning(
-                        f'rule {rule} will be inactive because no (outbound) interfaces have been defined for this rule'
-                    )
-            for direction in {'source', 'destination'}:
-                if direction in rule_config:
-                    if 'protocol' in rule_config and 'port' in rule_config[
-                            direction]:
-                        if rule_config['protocol'] not in {'tcp', 'udp'}:
-                            raise ConfigError('ports can only be specified when protocol is "tcp" or "udp"')
+        raise ConfigError('Interface health tests must be configured')
 
+    if 'rule' in lb:
+        for rule_id, rule_conf in lb['rule'].items():
+            if 'interface' not in rule_conf:
+                raise ConfigError(f'Interface not specified on load-balancing wan rule {rule_id}')
 
-def generate(lb):
-    if not lb:
-        # Delete /run/load-balance/wlb.conf
-        if os.path.isfile(load_balancing_conf_file):
-            os.unlink(load_balancing_conf_file)
-        # Delete old directories
-        if os.path.isdir(load_balancing_dir):
-            rmtree(load_balancing_dir, ignore_errors=True)
-        if os.path.exists('/var/run/load-balance/wlb.out'):
-            os.unlink('/var/run/load-balance/wlb.out')
+            if 'failover' in rule_conf and 'exclude' in rule_conf:
+                raise ConfigError(f'Failover cannot be configured with exclude on load-balancing wan rule {rule_id}')
 
-        return None
+            if 'limit' in rule_conf:
+                if 'exclude' in rule_conf:
+                    raise ConfigError(f'Limit cannot be configured with exclude on load-balancing wan rule {rule_id}')
 
-    # Create load-balance dir
-    if not os.path.isdir(load_balancing_dir):
-        os.mkdir(load_balancing_dir)
+                if 'rate' in rule_conf['limit'] and 'period' not in rule_conf['limit']:
+                    raise ConfigError(f'Missing "limit period" on load-balancing wan rule {rule_id}')
 
-    render(load_balancing_conf_file, 'load-balancing/wlb.conf.j2', lb)
+                if 'period' in rule_conf['limit'] and 'rate' not in rule_conf['limit']:
+                    raise ConfigError(f'Missing "limit rate" on load-balancing wan rule {rule_id}')
 
-    return None
+            for direction in ['source', 'destination']:
+                if direction in rule_conf:
+                    if 'port' in rule_conf[direction]:
+                        if 'protocol' not in rule_conf:
+                            raise ConfigError(f'Protocol required to specify port on load-balancing wan rule {rule_id}')
+
+                        if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']:
+                            raise ConfigError(f'Protocol must be tcp, udp or tcp_udp to specify port on load-balancing wan rule {rule_id}')
 
+def generate(lb):
+    return None
 
 def apply(lb):
     if not lb:
-        try:
-            cmd(f'systemctl stop {systemd_service}')
-        except Exception as e:
-            print(f"Error message: {e}")
-
+        cmd(f'sudo systemctl stop {service}')
     else:
-        cmd('sudo sysctl -w net.netfilter.nf_conntrack_acct=1')
-        cmd(f'systemctl restart {systemd_service}')
+        cmd(f'sudo systemctl restart {service}')
 
     call_dependents()
 
-    return None
-
-
 if __name__ == '__main__':
     try:
         c = get_config()
         verify(c)
         generate(c)
         apply(c)
     except ConfigError as e:
         print(e)
         exit(1)
diff --git a/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb b/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb
new file mode 100755
index 000000000..fff258afa
--- /dev/null
+++ b/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb
@@ -0,0 +1,61 @@
+#!/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/>.
+
+# This is a Python hook script which is invoked whenever a PPPoE session goes
+# "ip-up". It will call into our vyos.ifconfig library and will then execute
+# common tasks for the PPPoE interface. The reason we have to "hook" this is
+# that we can not create a pppoeX interface in advance in linux and then connect
+# pppd to this already existing interface.
+
+import os
+import signal
+
+from sys import argv
+from sys import exit
+
+from vyos.defaults import directories
+
+# When the ppp link comes up, this script is called with the following
+# parameters
+#       $1      the interface name used by pppd (e.g. ppp3)
+#       $2      the tty device name
+#       $3      the tty device speed
+#       $4      the local IP address for the interface
+#       $5      the remote IP address
+#       $6      the parameter specified by the 'ipparam' option to pppd
+
+if (len(argv) < 7):
+    exit(1)
+
+wlb_pid_file = '/run/wlb_daemon.pid'
+
+interface = argv[6]
+nexthop = argv[5]
+
+if not os.path.exists(directories['ppp_nexthop_dir']):
+    os.mkdir(directories['ppp_nexthop_dir'])
+
+nexthop_file = os.path.join(directories['ppp_nexthop_dir'], interface)
+
+with open(nexthop_file, 'w') as f:
+    f.write(nexthop)
+
+# Trigger WLB daemon update
+if os.path.exists(wlb_pid_file):
+    with open(wlb_pid_file, 'r') as f:
+        pid = int(f.read())
+
+        os.kill(pid, signal.SIGUSR2)
diff --git a/src/helpers/vyos-load-balancer.py b/src/helpers/vyos-load-balancer.py
new file mode 100755
index 000000000..2f07160b4
--- /dev/null
+++ b/src/helpers/vyos-load-balancer.py
@@ -0,0 +1,312 @@
+#!/usr/bin/python3
+
+# Copyright 2024 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 json
+import os
+import signal
+import sys
+import time
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.commit import commit_in_progress
+from vyos.utils.network import get_interface_address
+from vyos.utils.process import rc_cmd
+from vyos.utils.process import run
+from vyos.xml_ref import get_defaults
+from vyos.wanloadbalance import health_ping_host
+from vyos.wanloadbalance import health_ping_host_ttl
+from vyos.wanloadbalance import parse_dhcp_nexthop
+from vyos.wanloadbalance import parse_ppp_nexthop
+
+nftables_wlb_conf = '/run/nftables_wlb.conf'
+wlb_status_file = '/run/wlb_status.json'
+wlb_pid_file = '/run/wlb_daemon.pid'
+sleep_interval = 5 # Main loop sleep interval
+
+def health_check(ifname, conf, state, test_defaults):
+    # Run health tests for interface
+
+    if get_ipv4_address(ifname) is None:
+        return False
+
+    if 'test' not in conf:
+        resp_time = test_defaults['resp-time']
+        target = conf['nexthop']
+
+        if target == 'dhcp':
+            target = state['dhcp_nexthop']
+
+        if not target:
+            return False
+
+        return health_ping_host(target, ifname, wait_time=resp_time)
+
+    for test_id, test_conf in conf['test'].items():
+        check_type = test_conf['type']
+
+        if check_type == 'ping':
+            resp_time = test_conf['resp_time']
+            target = test_conf['target']
+            if not health_ping_host(target, ifname, wait_time=resp_time):
+                return False
+        elif check_type == 'ttl':
+            target = test_conf['target']
+            ttl_limit = test_conf['ttl_limit']
+            if not health_ping_host_ttl(target, ifname, ttl_limit=ttl_limit):
+                return False
+        elif check_type == 'user-defined':
+            script = test_conf['test_script']
+            rc = run(script)
+            if rc != 0:
+                return False
+
+    return True
+
+def on_state_change(lb, ifname, state):
+    # Run hook on state change
+    if 'hook' in lb:
+        script_path = os.path.join('/config/scripts/', lb['hook'])
+        env = {
+            'WLB_INTERFACE_NAME': ifname,
+            'WLB_INTERFACE_STATE': 'ACTIVE' if state else 'FAILED'
+        }
+
+        code = run(script_path, env=env)
+        if code != 0:
+            print('WLB hook returned non-zero error code')
+
+    print(f'INFO: State change: {ifname} -> {state}')
+
+def get_ipv4_address(ifname):
+    # Get primary ipv4 address on interface (for source nat)
+    addr_json = get_interface_address(ifname)
+    if 'addr_info' in addr_json and len(addr_json['addr_info']) > 0:
+        for addr_info in addr_json['addr_info']:
+            if addr_info['family'] == 'inet':
+                if 'local' in addr_info:
+                    return addr_json['addr_info'][0]['local']
+    return None
+
+def dynamic_nexthop_update(lb, ifname):
+    # Update on DHCP/PPP address/nexthop changes
+    # Return True if nftables needs to be updated - IP change
+
+    if 'dhcp_nexthop' in lb['health_state'][ifname]:
+        if ifname[:5] == 'pppoe':
+            dhcp_nexthop_addr = parse_ppp_nexthop(ifname)
+        else:
+            dhcp_nexthop_addr = parse_dhcp_nexthop(ifname)
+
+        table_num = lb['health_state'][ifname]['table_number']
+
+        if dhcp_nexthop_addr and lb['health_state'][ifname]['dhcp_nexthop'] != dhcp_nexthop_addr:
+            lb['health_state'][ifname]['dhcp_nexthop'] = dhcp_nexthop_addr
+            run(f'ip route replace table {table_num} default dev {ifname} via {dhcp_nexthop_addr}')
+
+    if_addr = get_ipv4_address(ifname)
+    if if_addr and if_addr != lb['health_state'][ifname]['if_addr']:
+        lb['health_state'][ifname]['if_addr'] = if_addr
+        return True
+
+    return False
+
+def nftables_update(lb):
+    # Atomically reload nftables table from template
+    if not os.path.exists(nftables_wlb_conf):
+        lb['first_install'] = True
+    elif 'first_install' in lb:
+        del lb['first_install']
+
+    render(nftables_wlb_conf, 'load-balancing/nftables-wlb.j2', lb)
+
+    rc, out = rc_cmd(f'nft -f {nftables_wlb_conf}')
+
+    if rc != 0:
+        print('ERROR: Failed to apply WLB nftables config')
+        print('Output:', out)
+        return False
+
+    return True
+
+def cleanup(lb):
+    if 'interface_health' in lb:
+        index = 1
+        for ifname, health_conf in lb['interface_health'].items():
+            table_num = lb['mark_offset'] + index
+            run(f'ip route del table {table_num} default')
+            run(f'ip rule del fwmark {hex(table_num)} table {table_num}')
+            index += 1
+
+    run(f'nft delete table ip vyos_wanloadbalance')
+
+def get_config():
+    conf = Config()
+    base = ['load-balancing', 'wan']
+    lb = conf.get_config_dict(base, key_mangling=('-', '_'),
+                            get_first_key=True, with_recursive_defaults=True)
+
+    lb['test_defaults'] = get_defaults(base + ['interface-health', 'A', 'test', 'B'], get_first_key=True)
+
+    return lb
+
+if __name__ == '__main__':
+    while commit_in_progress():
+        print("Notice: Waiting for commit to complete...")
+        time.sleep(1)
+
+    lb = get_config()
+
+    lb['health_state'] = {}
+    lb['mark_offset'] = 0xc8
+
+    # Create state dicts, interface address and nexthop, install routes and ip rules
+    if 'interface_health' in lb:
+        index = 1
+        for ifname, health_conf in lb['interface_health'].items():
+            table_num = lb['mark_offset'] + index
+            addr = get_ipv4_address(ifname)
+            lb['health_state'][ifname] = {
+                'if_addr': addr,
+                'failure_count': 0,
+                'success_count': 0,
+                'last_success': 0,
+                'last_failure': 0,
+                'state': addr is not None,
+                'state_changed': False,
+                'table_number': table_num,
+                'mark': hex(table_num)
+            }
+
+            if health_conf['nexthop'] == 'dhcp':
+                lb['health_state'][ifname]['dhcp_nexthop'] = None
+
+                dynamic_nexthop_update(lb, ifname)
+            else:
+                run(f'ip route replace table {table_num} default dev {ifname} via {health_conf["nexthop"]}')
+
+            run(f'ip rule add fwmark {hex(table_num)} table {table_num}')
+
+            index += 1
+
+        nftables_update(lb)
+
+        run('ip route flush cache')
+
+        if 'flush_connections' in lb:
+            run('conntrack --delete')
+            run('conntrack -F expect')
+
+        with open(wlb_status_file, 'w') as f:
+            f.write(json.dumps(lb['health_state']))
+
+    # Signal handler SIGUSR2 -> dhcpcd update
+    def handle_sigusr2(signum, frame):
+        for ifname, health_conf in lb['interface_health'].items():
+            if 'nexthop' in health_conf and health_conf['nexthop'] == 'dhcp':
+                retval = dynamic_nexthop_update(lb, ifname)
+
+                if retval:
+                    nftables_update(lb)
+
+    # Signal handler SIGTERM -> exit
+    def handle_sigterm(signum, frame):
+        if os.path.exists(wlb_status_file):
+            os.unlink(wlb_status_file)
+
+        if os.path.exists(wlb_pid_file):
+            os.unlink(wlb_pid_file)
+
+        if os.path.exists(nftables_wlb_conf):
+            os.unlink(nftables_wlb_conf)
+
+        cleanup(lb)
+        sys.exit(0)
+
+    signal.signal(signal.SIGUSR2, handle_sigusr2)
+    signal.signal(signal.SIGINT, handle_sigterm)
+    signal.signal(signal.SIGTERM, handle_sigterm)
+
+    with open(wlb_pid_file, 'w') as f:
+        f.write(str(os.getpid()))
+
+    # Main loop
+
+    try:
+        while True:
+            ip_change = False
+
+            if 'interface_health' in lb:
+                for ifname, health_conf in lb['interface_health'].items():
+                    state = lb['health_state'][ifname]
+
+                    result = health_check(ifname, health_conf, state=state, test_defaults=lb['test_defaults'])
+
+                    state_changed = result != state['state']
+                    state['state_changed'] = False
+
+                    if result:
+                        state['failure_count'] = 0
+                        state['success_count'] += 1
+                        state['last_success'] = time.time()
+                        if state_changed and state['success_count'] >= int(health_conf['success_count']):
+                            state['state'] = True
+                            state['state_changed'] = True
+                    elif not result:
+                        state['failure_count'] += 1
+                        state['success_count'] = 0
+                        state['last_failure'] = time.time()
+                        if state_changed and state['failure_count'] >= int(health_conf['failure_count']):
+                            state['state'] = False
+                            state['state_changed'] = True
+
+                    if state['state_changed']:
+                        state['if_addr'] = get_ipv4_address(ifname)
+                        on_state_change(lb, ifname, state['state'])
+
+                    if dynamic_nexthop_update(lb, ifname):
+                        ip_change = True
+
+            if any(state['state_changed'] for ifname, state in lb['health_state'].items()):
+                if not nftables_update(lb):
+                    break
+
+                run('ip route flush cache')
+
+                if 'flush_connections' in lb:
+                    run('conntrack --delete')
+                    run('conntrack -F expect')
+
+                with open(wlb_status_file, 'w') as f:
+                    f.write(json.dumps(lb['health_state']))
+            elif ip_change:
+                nftables_update(lb)
+
+            time.sleep(sleep_interval)
+    except Exception as e:
+        print('WLB ERROR:', e)
+
+    if os.path.exists(wlb_status_file):
+        os.unlink(wlb_status_file)
+
+    if os.path.exists(wlb_pid_file):
+        os.unlink(wlb_pid_file)
+
+    if os.path.exists(nftables_wlb_conf):
+            os.unlink(nftables_wlb_conf)
+
+    cleanup(lb)
diff --git a/src/systemd/vyos-wan-load-balance.service b/src/systemd/vyos-wan-load-balance.service
index 7d62a2ff6..a59f2c3ae 100644
--- a/src/systemd/vyos-wan-load-balance.service
+++ b/src/systemd/vyos-wan-load-balance.service
@@ -1,15 +1,11 @@
 [Unit]
-Description=VyOS WAN load-balancing service
+Description=VyOS WAN Load Balancer
 After=vyos-router.service
 
 [Service]
-ExecStart=/opt/vyatta/sbin/wan_lb -f /run/load-balance/wlb.conf -d -i /var/run/vyatta/wlb.pid
-ExecReload=/bin/kill -s SIGTERM $MAINPID && sleep 5 && /opt/vyatta/sbin/wan_lb -f /run/load-balance/wlb.conf -d -i /var/run/vyatta/wlb.pid
-ExecStop=/bin/kill -s SIGTERM $MAINPID
-PIDFile=/var/run/vyatta/wlb.pid
-KillMode=process
-Restart=on-failure
-RestartSec=5s
+Type=simple
+Restart=always
+ExecStart=/usr/bin/python3 /usr/libexec/vyos/vyos-load-balancer.py
 
 [Install]
 WantedBy=multi-user.target