From 4c233fceed9cb65571a61a8e202c5c6520a29376 Mon Sep 17 00:00:00 2001 From: Pierre Gaufillet Date: Mon, 6 Apr 2026 10:54:02 +0200 Subject: [PATCH] lease-sync: add DHCP lease sync daemon Add lease-sync, an event-driven daemon that synchronizes DHCP leases between dnsmasq instances on clustered OpenWrt routers. Features: - Real-time lease replication via dnsmasq ubus add_lease/delete_lease - AES-256-GCM encrypted UDP peer-to-peer protocol - Last-Writer-Wins conflict resolution with millisecond timestamps - Full sync on startup with peer reconciliation - Retry queue for transient ubus failures - IPv4 and IPv6 lease support Requires the dnsmasq ubus lease methods patch (openwrt/openwrt). Source: https://github.com/pgaufillet/lease-sync (v1.1.0) Tested on: OpenWrt 24.10 and 25.12 (x86_64 + filogic), production. Signed-off-by: Pierre Gaufillet --- net/lease-sync/Makefile | 88 +++++++++++++++++++ net/lease-sync/files/lease-sync.config | 12 +++ net/lease-sync/files/lease-sync.hotplug | 112 ++++++++++++++++++++++++ net/lease-sync/files/lease-sync.init | 104 ++++++++++++++++++++++ 4 files changed, 316 insertions(+) create mode 100644 net/lease-sync/Makefile create mode 100644 net/lease-sync/files/lease-sync.config create mode 100644 net/lease-sync/files/lease-sync.hotplug create mode 100644 net/lease-sync/files/lease-sync.init diff --git a/net/lease-sync/Makefile b/net/lease-sync/Makefile new file mode 100644 index 00000000000000..72396c8755850d --- /dev/null +++ b/net/lease-sync/Makefile @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright (c) 2025-2026 Pierre Gaufillet + +include $(TOPDIR)/rules.mk + +PKG_NAME:=lease-sync +PKG_VERSION:=1.1.0 +PKG_RELEASE:=1 + +PKG_SOURCE_PROTO:=git +PKG_SOURCE_URL:=https://github.com/pgaufillet/lease-sync.git +PKG_SOURCE_VERSION:=v1.1.0 +PKG_MIRROR_HASH:=3fcf2cb2eeb20ba5171c35a8d431db752a645dc50f9474f6f803e0ada5a2d28e + +PKG_MAINTAINER:=Pierre Gaufillet +PKG_LICENSE:=GPL-2.0-or-later +PKG_LICENSE_FILES:=LICENSE + +PKG_BUILD_PARALLEL:=1 + +include $(INCLUDE_DIR)/package.mk + +define Package/lease-sync + SECTION:=net + CATEGORY:=Network + SUBMENU:=IP Addresses and Names + TITLE:=DHCP Lease Synchronization Daemon + URL:=https://github.com/pgaufillet/lease-sync + DEPENDS:=+libubus +libubox +libopenssl +dnsmasq +endef + +define Package/lease-sync/description + High-availability DHCP lease synchronization daemon for dnsmasq. + Enables active/active HA clusters by synchronizing DHCP leases + between multiple dnsmasq instances via ubus and UDP. +endef + +define Package/lease-sync/conffiles +/etc/config/lease-sync +/etc/lease-sync/ +endef + +define Build/Compile + $(MAKE) -C $(PKG_BUILD_DIR)/daemon \ + CC="$(TARGET_CC)" \ + CFLAGS="$(TARGET_CFLAGS) $(TARGET_CPPFLAGS)" \ + LDFLAGS="$(TARGET_LDFLAGS)" \ + LIBS="-lubus -lubox -lssl -lcrypto" \ + STATEDIR="/etc/lease-sync" +endef + +define Package/lease-sync/install + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) $(PKG_BUILD_DIR)/daemon/lease-sync $(1)/usr/sbin/ + + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/lease-sync.init $(1)/etc/init.d/lease-sync + + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/lease-sync.config $(1)/etc/config/lease-sync + + $(INSTALL_DIR) $(1)/etc/lease-sync + + $(INSTALL_DIR) $(1)/etc/hotplug.d/dhcp + $(INSTALL_BIN) ./files/lease-sync.hotplug $(1)/etc/hotplug.d/dhcp/50-lease-sync + +endef + +define Package/lease-sync/prerm +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ]; then + /etc/init.d/lease-sync stop >/dev/null 2>&1 + /etc/init.d/lease-sync disable >/dev/null 2>&1 +fi +exit 0 +endef + +define Package/lease-sync/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + /etc/init.d/lease-sync enable + echo "NOTE: Restart dnsmasq to activate the DHCP hotplug handler:" + echo " /etc/init.d/dnsmasq restart" +} +exit 0 +endef + +$(eval $(call BuildPackage,lease-sync)) diff --git a/net/lease-sync/files/lease-sync.config b/net/lease-sync/files/lease-sync.config new file mode 100644 index 00000000000000..b3ab17c3d331f2 --- /dev/null +++ b/net/lease-sync/files/lease-sync.config @@ -0,0 +1,12 @@ +config lease-sync 'config' + option enabled '0' + option node_id '' + option sync_port '5378' + option sync_interval '30' + option peer_timeout '120' + option persist_interval '60' + option log_level '2' + +# Example peer configuration +# config peer +# option address '192.168.1.2' diff --git a/net/lease-sync/files/lease-sync.hotplug b/net/lease-sync/files/lease-sync.hotplug new file mode 100644 index 00000000000000..d738d98432297e --- /dev/null +++ b/net/lease-sync/files/lease-sync.hotplug @@ -0,0 +1,112 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright (c) 2025-2026 Pierre Gaufillet +# +# DHCP Lease Event Handler for lease-sync +# This hotplug script publishes DHCP events to ubus for the lease-sync daemon +# Installed to: /etc/hotplug.d/dhcp/50-lease-sync + +# Only process DHCP lease events +[ -n "$ACTION" ] || exit 0 + +# Configuration +UBUS_EVENT="dhcp.lease" +LOG_TAG="lease-sync-hotplug" +NODE_ID_FILE="/etc/lease-sync/node-id" + +# Logging function +log() { + logger -t "$LOG_TAG" -p "daemon.$1" "$2" +} + +# Get or generate node ID +get_node_id() { + if [ -f "$NODE_ID_FILE" ]; then + cat "$NODE_ID_FILE" + else + cat /sys/class/net/br-lan/address 2>/dev/null || hostname + fi +} + +# Sanitize a string for safe JSON embedding. +# Escapes backslash and double-quote characters to prevent injection. +sanitize_json() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +# Build JSON payload for ubus event +build_lease_json() { + local json='{' + + # Map hotplug ACTION to lease-sync action + case "$ACTION" in + add) json="${json}\"action\":\"add\"" ;; + remove) json="${json}\"action\":\"del\"" ;; + update) json="${json}\"action\":\"old\"" ;; + *) return 1 ;; + esac + + # Required fields from hotplug environment + # (HOSTNAME is untrusted DHCP client input — must sanitize) + [ -n "$IPADDR" ] && json="${json},\"ip\":\"$IPADDR\"" + [ -n "$MACADDR" ] && json="${json},\"mac\":\"$MACADDR\"" + [ -n "$HOSTNAME" ] && json="${json},\"hostname\":\"$(sanitize_json "$HOSTNAME")\"" + + # Node identification + json="${json},\"node_id\":\"$(sanitize_json "$(get_node_id)")\"" + json="${json},\"timestamp\":$(date +%s)" + + # Lease timing (from dnsmasq environment, validated as numeric) + case "$DNSMASQ_LEASE_EXPIRES" in + ''|*[!0-9]*) ;; + *) json="${json},\"expires\":$DNSMASQ_LEASE_EXPIRES" ;; + esac + case "$DNSMASQ_TIME_REMAINING" in + ''|*[!0-9]*) ;; + *) json="${json},\"time_remaining\":$DNSMASQ_TIME_REMAINING" ;; + esac + case "$DNSMASQ_LEASE_LENGTH" in + ''|*[!0-9]*) ;; + *) json="${json},\"lease_length\":$DNSMASQ_LEASE_LENGTH" ;; + esac + + # Client identification + [ -n "$DNSMASQ_CLIENT_ID" ] && json="${json},\"client_id\":\"$DNSMASQ_CLIENT_ID\"" + [ -n "$DNSMASQ_INTERFACE" ] && json="${json},\"interface\":\"$DNSMASQ_INTERFACE\"" + + # IPv6 specific fields (IAID may have "T" prefix for temporary addresses) + if [ -n "$DNSMASQ_IAID" ]; then + local iaid_num + iaid_num=$(echo "$DNSMASQ_IAID" | sed 's/^T//') + case "$iaid_num" in + ''|*[!0-9]*) log err "Invalid DNSMASQ_IAID='$DNSMASQ_IAID', skipping" ;; + *) + json="${json},\"iaid\":$iaid_num" + if echo "$DNSMASQ_IAID" | grep -q "^T"; then + json="${json},\"is_temporary\":1" + else + json="${json},\"is_temporary\":0" + fi + ;; + esac + fi + + json="${json}}" + echo "$json" +} + +# Publish event to ubus +case "$ACTION" in + add|remove|update) + [ -z "$IPADDR" ] && exit 0 + + payload=$(build_lease_json) + if ubus send "$UBUS_EVENT" "$payload" 2>/dev/null; then + log info "$ACTION event for $IPADDR published" + else + log err "Failed to publish $ACTION event for $IPADDR" + fi + ;; +esac + +exit 0 diff --git a/net/lease-sync/files/lease-sync.init b/net/lease-sync/files/lease-sync.init new file mode 100644 index 00000000000000..47d1dcbca14d5a --- /dev/null +++ b/net/lease-sync/files/lease-sync.init @@ -0,0 +1,104 @@ +#!/bin/sh /etc/rc.common +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright (c) 2025-2026 Pierre Gaufillet +# lease-sync daemon init script for OpenWrt (procd) + +START=99 +STOP=10 + +USE_PROCD=1 + +PROG=/usr/sbin/lease-sync +CONF=/etc/lease-sync/config + +start_service() { + # Ensure configuration directory exists + [ -d /etc/lease-sync ] || mkdir -p /etc/lease-sync + [ -d /var/lib/lease-sync ] || mkdir -p /var/lib/lease-sync + + # Generate configuration from UCI + . /lib/functions.sh + config_load lease-sync + + local enabled sync_port sync_interval persist_interval peer_timeout log_level node_id + config_get_bool enabled config enabled 0 + + # If disabled in UCI, don't start + [ "$enabled" -eq 0 ] && { + logger -t lease-sync -p daemon.info "lease-sync disabled in UCI configuration" + return 0 + } + + config_get sync_port config sync_port "5378" + config_get sync_interval config sync_interval "30" + config_get persist_interval config persist_interval "60" + config_get peer_timeout config peer_timeout "120" + config_get log_level config log_level "2" + config_get node_id config node_id "" + + # Generate flat configuration file + logger -t lease-sync -p daemon.info "Generating configuration from UCI" + + cat > "$CONF" <> "$CONF" + + # Add peers from UCI (list option) + add_peer() { + echo "peer=$1" >> "$CONF" + } + config_list_foreach config peer add_peer + + logger -t lease-sync -p daemon.info "Configuration generated with $(grep -c '^peer=' "$CONF" 2>/dev/null || echo 0) peers" + + procd_open_instance lease-sync + procd_set_param command "$PROG" -c "$CONF" + + # Respawn settings + procd_set_param respawn ${respawn_threshold:-3600} ${respawn_timeout:-5} ${respawn_retry:-5} + + # File descriptor limits + procd_set_param limits nofile="1024 1024" + + # Standard streams + procd_set_param stdout 1 + procd_set_param stderr 1 + + # Use syslog for logging + procd_set_param syslog 1 + + procd_close_instance + + logger -t lease-sync -p daemon.info "Starting lease-sync daemon" +} + +stop_service() { + logger -t lease-sync -p daemon.info "Stopping lease-sync daemon" +} + +service_triggers() { + # Reload on network changes + procd_add_reload_trigger "network" + + # Watch configuration file for changes + procd_add_reload_trigger "lease-sync" +} + +# lease-sync does not support SIGHUP config reload, so reload_service() +# performs a full restart (stop + start). Brief sync gap during restart. +reload_service() { + logger -t lease-sync -p daemon.info "Restarting lease-sync daemon (full restart, no SIGHUP support)" + stop + start +}