RFC: High Availability clustering for OpenWrt (VRRP + config sync + DHCP lease sync)

Hi all,

I'd like to propose a set of packages that fill some gaps in OpenWrt's existing HA story. OpenWrt already ships keepalived for VRRP failover and keepalived-sync for basic file replication, but turning two routers into a production HA pair still requires expertize, custom scripts, and — critically — accepting that DHCP leases are lost on failover.

This proposal adds what's missing: real-time DHCP lease replication so clients survive failover transparently, a bidirectional config sync that supports asymmetric configs, and a single UCI configuration that ties it all together.

This grew out of my experience trying to set up HA with keepalived-sync [1]. After weeks of testing, I ran into fundamental limitations and ended up writing a replacement from scratch, now production-tested for several months.

[1] Keepalived-sync docs and config examples

How it works?

Two or more routers run ha-cluster with identical UCI config. Keepalived handles VRRP failover (active/backup with virtual IPs). owsync keeps /etc/config synchronized. lease-sync replicates DHCP leases in real-time so clients don't notice failover.

Architecture

  1. dnsmasq ubus patch (openwrt/openwrt)
    Adds add_lease, delete_lease, get_leases ubus methods to dnsmasq. Provides ubus parity with existing D-Bus AddDhcpLease/DeleteDhcpLease. ~320 lines, same patterns as existing 200-ubus_dns patch. Also submitted upstream to dnsmasq-discuss.

  2. lease-sync (openwrt/packages)
    Event-driven daemon that replicates DHCP leases between dnsmasq instances using the ubus methods above. AES-256-GCM encrypted UDP. ~4.5K lines C. https://github.com/pgaufillet/lease-sync

  3. owsync (openwrt/packages)
    Lightweight bidirectional file sync daemon. Keeps /etc/config in sync between peers. Useful beyond HA (multi-AP setups, fleet management). AES-256-GCM encryption, polling-based. ~150KB binary.
    https://github.com/pgaufillet/owsync

  4. ha-cluster (openwrt/packages)
    UCI orchestration: reads /etc/config/ha-cluster, generates configs for keepalived + owsync + lease-sync, manages lifecycle via procd. Shell-only (PKGARCH:=all). Depends on keepalived.

  5. luci-app-ha-cluster (openwrt/luci)
    LuCI web interface: Quick Setup, Status dashboard, Advanced tuning.

Status

PRs

I'm submitting all PRs together so you can see the full picture:

Each PR stands on its own and can be reviewed independently. Merge order follows the dependency chain (dnsmasq patch first, luci-app last). owsync has no dependency on the others.

I would be pleased to address any feedback.

Thanks for considering this!

I guess that at this stage it is easier to stick with dnsmasq, since many other things depend on it as well, but…

What about Kea DHCP? It supports HA pairs out of the box. The only thing missing is that we don't have GUI options for it in OpenWrt.

Overall, I’m also interested in something less hacky than the set of scripts I’m using now, as I imagine many others are. Ideally, it would all be available in the GUI, or at least survive an OpenWrt attended upgrade.

I think I’ll test your work in the near future, if life doesn’t interfere with my plans as usual.

I’m not sure, but perhaps this would be more informative?

Overall, testing shows that a multi-WAN configuration takes a bit of effort to set up correctly, but at this point everything is working well. :+1:

Thank you for your comments @woffko :slight_smile:

In particular regarding IPv6 as I am not using it in production. I’ll have a look as soon as possible to your proposal.

I am also working with a multi-WAN configuration (mwan3, openvpn, bird). For the time being, I focus on the stabilisation of the core features and UI (in particular after OpenWrt’s reviews with Claude), but supporting easily more configurations is of course a medium term objective.

Regarding Kea, you are right: I preferred to minimise the impacts on OpenWrt core infrastructure. A good approach in the future would be to support it as an alternative.

I have configured advanced config sync


Here is the config part

ha-cluster.AP_controller=service
ha-cluster.AP_controller.config_files='/.ssh/known_hosts' '/etc/config/apcontroller'

It looks like option .enabled='1' for this entry is missing?
In the GUI it shows

Improvements of the VIP page and bug fix related to the enabled flag in the advanced sync configuration implemented and under test: thank you for your comments :slight_smile:

image

What about the RA priority option? Maybe it should be excluded from synchronization?

Or what about adding a checkbox like “Sync static leases only”?

The issue is that if both routers have the same RA priority, clients may prefer the backup node, which is not what I want, because on the backup node I only have one backup link active while the node is in BACKUP state. And currently it looks like it syncs everything in config.

EDIT: Looks like there is some my old script is running... Investigating... Ok, found and fixed...

The main problem is that the BACKUP HA node was still sending IPv6 Router Advertisements. Even with low RA preference, Windows still installed a default IPv6 route via the BACKUP node’s own link-local address, so clients ended up with two IPv6 gateways: the VRRP VIP fe80::5 and the BACKUP node’s fe80::....

The intended HA design is that dnsmasq stays running on both nodes for DNS/DHCP/lease synchronization, but only the active MASTER should advertise itself as an IPv6 router.

The non-elegant... but working solution was to keep dnsmasq active on both nodes and suppress only outgoing ICMPv6 Router Advertisement packets from the BACKUP node. A Keepalived/HA hotplug hook can add a runtime nftables rule on BACKUP/FAULT to drop outgoing RA packets, and remove that rule on MASTER.

Something like that

#!/bin/sh

TAG="ha-ra-guard"
TABLE="ha_ra_guard"

case "$NAME" in
    lan_v6)
        IFACE="br-lan"
        ;;
    wifi_v6)
        IFACE="bond0.87"
        ;;
    *)
        exit 0
        ;;
esac

ensure_table() {
    nft list table inet "$TABLE" >/dev/null 2>&1 && return 0

    nft -f - <<'NFT'
table inet ha_ra_guard {
    set blocked_ifaces {
        type ifname;
    }

    chain output {
        type filter hook output priority -150; policy accept;
        meta oifname @blocked_ifaces ip6 nexthdr icmpv6 icmpv6 type nd-router-advert counter drop
    }
}
NFT
}

block_ra() {
    ensure_table || exit 1
    nft add element inet "$TABLE" blocked_ifaces "{ \"$IFACE\" }" 2>/dev/null || true
    logger -t "$TAG" "$ACTION $NAME: blocking RA on $IFACE"
}

allow_ra() {
    if nft list table inet "$TABLE" >/dev/null 2>&1; then
        nft delete element inet "$TABLE" blocked_ifaces "{ \"$IFACE\" }" 2>/dev/null || true
    fi
    logger -t "$TAG" "$ACTION $NAME: allowing RA on $IFACE"
}

case "$ACTION" in
    BACKUP|FAULT|STOP)
        block_ra
        ;;
    MASTER)
        allow_ra
        ;;
esac