diff --git a/linkback/Makefile b/linkback/Makefile index 8bfde78a4..6309510ea 100644 --- a/linkback/Makefile +++ b/linkback/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=linkback PKG_VERSION:=1.0.0 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_MAINTAINER:=Dante PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) @@ -56,6 +56,10 @@ define Package/$(PKG_NAME)/install $(INSTALL_CONF) ./files/linkback.config $(1)/etc/config/linkback $(INSTALL_DIR) $(1)/etc/init.d $(INSTALL_BIN) ./files/linkback.init $(1)/etc/init.d/linkback + + # Install hotplug script + $(INSTALL_DIR) $(1)/etc/hotplug.d/iface + $(INSTALL_BIN) ./files/90-linkback $(1)/etc/hotplug.d/iface/90-linkback endef define Package/$(PKG_NAME)/postinst diff --git a/linkback/files/90-linkback b/linkback/files/90-linkback new file mode 100755 index 000000000..062639eaf --- /dev/null +++ b/linkback/files/90-linkback @@ -0,0 +1,16 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 Dante +# +# Hotplug event trigger script for LinkBack daemon. +# Instantly wakes up linkbackd on interface ifup/ifdown state changes. + +[ "$ACTION" = "ifup" ] || [ "$ACTION" = "ifdown" ] || exit 0 +[ -n "$INTERFACE" ] || exit 0 + +# Check if linkbackd is running +PID=$(pidof linkbackd) +[ -n "$PID" ] || exit 0 + +# Send SIGUSR1 to trigger immediate health check / short-circuit +kill -USR1 $PID 2>/dev/null diff --git a/linkback/src/linkbackd.c b/linkback/src/linkbackd.c index 8b56b986c..51c4d416c 100644 --- a/linkback/src/linkbackd.c +++ b/linkback/src/linkbackd.c @@ -40,6 +40,12 @@ static struct ubus_context *ubus_ctx = NULL; // Prototypes static void restore_all_metrics(void); static bool validate_loaded_config(void); +static volatile sig_atomic_t hotplug_triggered = 0; +static void handle_sigusr1(int sig) { + (void)sig; + hotplug_triggered = 1; +} + static void handle_signal(int sig) { syslog(LOG_INFO, "Received signal %d, exiting...", sig); keep_running = false; @@ -439,6 +445,9 @@ static bool load_config(void) { const char *failover = uci_lookup_option_string(ctx, global_sec, "failover_delay"); if (failover) global_cfg.failover_delay = atoi(failover); + + global_cfg.is_up = false; + global_cfg.last_is_up = false; } // Parse links @@ -527,6 +536,7 @@ static bool load_config(void) { // Default runtime states link->healthy = true; link->is_up = false; + link->last_is_up = false; link->last_checked = 0; // Force immediate check on startup link_count++; @@ -843,9 +853,10 @@ int main(int argc, char **argv) { openlog("linkbackd", LOG_PID | LOG_NDELAY, LOG_DAEMON); syslog(LOG_INFO, "Starting LinkBack daemon..."); - // Register signal handlers for clean exits and metric restoration + // Register signal handlers for clean exits, metric restoration, and hotplug wakeup signal(SIGTERM, handle_signal); signal(SIGINT, handle_signal); + signal(SIGUSR1, handle_sigusr1); // Load configuration if (!load_config()) { @@ -871,59 +882,137 @@ int main(int argc, char **argv) { time_t now = time(NULL); bool any_checked = false; - // If in multi_gw mode, resolve bind interface physical device once per cycle + bool force_check = false; + if (hotplug_triggered) { + hotplug_triggered = 0; + force_check = true; + syslog(LOG_INFO, "[Diagnostic] Hotplug signal received (SIGUSR1), triggering immediate interface & health evaluation."); + } + + // In multi_gw mode, resolve and verify the bind interface state first if (global_cfg.mode == MODE_MULTI_GW) { char dev[MAX_NAME_LEN] = {0}; char gw[MAX_IP_LEN] = {0}; bool if_up = false; - get_interface_ubus_status(global_cfg.interface, dev, sizeof(dev), gw, sizeof(gw), &if_up); - strncpy(global_cfg.device, dev, sizeof(global_cfg.device) - 1); + bool ubus_ok = get_interface_ubus_status(global_cfg.interface, dev, sizeof(dev), gw, sizeof(gw), &if_up); + bool physical_up = ubus_ok && if_up && (dev[0] != '\0'); + + if (dev[0] != '\0') { + strncpy(global_cfg.device, dev, sizeof(global_cfg.device) - 1); + global_cfg.device[sizeof(global_cfg.device) - 1] = '\0'; + } + global_cfg.is_up = physical_up; + + // Diagnostic logging on bind interface UP/DOWN transition + if (global_cfg.is_up != global_cfg.last_is_up) { + syslog(LOG_NOTICE, "[Diagnostic] Multi-GW bind interface '%s' (%s) link state changed: %s -> %s", + global_cfg.interface, global_cfg.device[0] ? global_cfg.device : "unknown", + global_cfg.last_is_up ? "UP" : "DOWN", + global_cfg.is_up ? "UP" : "DOWN"); + global_cfg.last_is_up = global_cfg.is_up; + } + + // P2 & Diagnostic: Global short-circuit when bind interface is DOWN. + // Stop all gateway probe attempts immediately without wasting timeout intervals. + if (!global_cfg.is_up) { + bool state_changed = false; + for (int i = 0; i < link_count; i++) { + link_t *link = &links[i]; + link->is_up = false; + if (global_cfg.device[0] != '\0') { + strncpy(link->device, global_cfg.device, sizeof(link->device) - 1); + link->device[sizeof(link->device) - 1] = '\0'; + } + if (link->gateway_cfg[0] != '\0') { + strncpy(link->gateway, link->gateway_cfg, sizeof(link->gateway) - 1); + link->gateway[sizeof(link->gateway) - 1] = '\0'; + } + + if (link->healthy || link->current_metric != 1000 + link->metric) { + syslog(LOG_WARNING, "[Diagnostic] Multi-GW bind interface '%s' is DOWN. Short-circuiting probe for target '%s' (gw=%s) and demoting metric.", + global_cfg.interface, link->name, link->gateway[0] ? link->gateway : "none"); + link->healthy = false; + update_route_metric(link, 1000 + link->metric, -1); + state_changed = true; + } + + link->current_score = 0; + link->ping_ok = false; + link->dns_ok = false; + link->tcp_ok = false; + link->consecutive_success = 0; + link->consecutive_failure = link->failover_delay; + link->last_checked = now; + } + + if (state_changed) { + write_status_json(); + } + sleep(1); + continue; + } } for (int i = 0; i < link_count; i++) { link_t *link = &links[i]; - // Check if this link is due for checking - if (now - link->last_checked < link->check_interval) { + // Check if this link is due for checking (bypass interval if hotplug triggered) + if (!force_check && (now - link->last_checked < link->check_interval)) { continue; } link->last_checked = now; any_checked = true; // 1. Fetch real-time netifd status - char dev[MAX_NAME_LEN] = {0}; - char gw[MAX_IP_LEN] = {0}; - bool is_up = false; - if (global_cfg.mode == MODE_MULTI_GW) { - // Inherit physical device from global bind interface - is_up = (global_cfg.device[0] != '\0'); - strncpy(dev, global_cfg.device, sizeof(dev) - 1); - strncpy(gw, link->gateway_cfg, sizeof(gw) - 1); + // Inherit verified UP device from global bind interface + link->is_up = true; + strncpy(link->device, global_cfg.device, sizeof(link->device) - 1); + link->device[sizeof(link->device) - 1] = '\0'; + strncpy(link->gateway, link->gateway_cfg, sizeof(link->gateway) - 1); + link->gateway[sizeof(link->gateway) - 1] = '\0'; } else { - // Query netifd for WAN interface - get_interface_ubus_status(link->name, dev, sizeof(dev), gw, sizeof(gw), &is_up); - } + // Multi-WAN mode: query netifd for independent interface status + char dev[MAX_NAME_LEN] = {0}; + char gw[MAX_IP_LEN] = {0}; + bool is_up = false; + bool ubus_ok = get_interface_ubus_status(link->name, dev, sizeof(dev), gw, sizeof(gw), &is_up); + bool physical_up = ubus_ok && is_up && (dev[0] != '\0'); - link->is_up = is_up; - strncpy(link->device, dev, MAX_NAME_LEN - 1); - strncpy(link->gateway, gw, MAX_IP_LEN - 1); + if (physical_up != link->last_is_up) { + syslog(LOG_NOTICE, "[Diagnostic] Multi-WAN interface '%s' (%s) link state changed: %s -> %s", + link->name, dev[0] ? dev : "unknown", + link->last_is_up ? "UP" : "DOWN", + physical_up ? "UP" : "DOWN"); + link->last_is_up = physical_up; + } - // P2 optimization: Interface down short-circuit - if (!is_up || dev[0] == '\0') { - link->healthy = false; - link->current_score = 0; - link->ping_ok = false; - link->dns_ok = false; - link->tcp_ok = false; - link->consecutive_success = 0; - link->consecutive_failure = 0; + link->is_up = physical_up; + if (dev[0] != '\0') { + strncpy(link->device, dev, sizeof(link->device) - 1); + link->device[sizeof(link->device) - 1] = '\0'; + } + if (gw[0] != '\0') { + strncpy(link->gateway, gw, sizeof(link->gateway) - 1); + link->gateway[sizeof(link->gateway) - 1] = '\0'; + } - // If it still has low metric, float it immediately - if (link->current_metric == link->metric) { - update_route_metric(link, 1000 + link->metric, -1); + // Local short-circuit: interface DOWN, skip probe for this link + if (!physical_up) { + if (link->healthy || link->current_metric != 1000 + link->metric) { + syslog(LOG_WARNING, "[Diagnostic] Multi-WAN interface '%s' is DOWN. Short-circuiting probe for link '%s' and demoting metric.", + link->name, link->name); + link->healthy = false; + update_route_metric(link, 1000 + link->metric, -1); + } + link->current_score = 0; + link->ping_ok = false; + link->dns_ok = false; + link->tcp_ok = false; + link->consecutive_success = 0; + link->consecutive_failure = link->failover_delay; + continue; } - continue; } // 2. Perform health checks (P2: 1s non-blocking timeout) @@ -973,7 +1062,7 @@ int main(int argc, char **argv) { if (!link->healthy && link->consecutive_success >= link->recovery_delay) { // Recovered! Failback! link->healthy = true; - syslog(LOG_NOTICE, "Link %s (%s, gw=%s, priority %d) recovered to healthy after %d successes.", + syslog(LOG_NOTICE, "[Diagnostic] Link %s (%s, gw=%s, priority %d) recovered to healthy after %d successes.", link->name, link->device, link->gateway[0] ? link->gateway : "none", link->priority, link->consecutive_success); // Restore original metric @@ -986,7 +1075,7 @@ int main(int argc, char **argv) { if (link->healthy && link->consecutive_failure >= link->failover_delay) { // Failed! Failover! link->healthy = false; - syslog(LOG_WARNING, "Link %s (%s, gw=%s, priority %d) went down after %d failures.", + syslog(LOG_WARNING, "[Diagnostic] Link %s (%s, gw=%s, priority %d) went down after %d failures.", link->name, link->device, link->gateway[0] ? link->gateway : "none", link->priority, link->consecutive_failure); // Push metric out of choice range @@ -999,7 +1088,7 @@ int main(int argc, char **argv) { int expected_metric = (link->is_up && link->healthy) ? link->metric : (1000 + link->metric); int real_metric = get_system_route_metric(link->device, link->gateway, expected_metric); if (real_metric != -1 && real_metric != expected_metric) { - syslog(LOG_WARNING, "Route metric mismatch detected on %s (%s, gw=%s, priority %d): expected %d, got %d. Correcting...", + syslog(LOG_WARNING, "[Diagnostic] Route metric mismatch detected on %s (%s, gw=%s, priority %d): expected %d, got %d. Correcting...", link->name, link->device, link->gateway[0] ? link->gateway : "none", link->priority, expected_metric, real_metric); update_route_metric(link, expected_metric, real_metric); } diff --git a/linkback/src/linkbackd.h b/linkback/src/linkbackd.h index 88d1d8ff2..3935c65b8 100644 --- a/linkback/src/linkbackd.h +++ b/linkback/src/linkbackd.h @@ -51,6 +51,7 @@ typedef struct { char device[MAX_NAME_LEN]; // physical interface name e.g., pppoe-wan, eth1, br-lan char gateway[MAX_IP_LEN]; // gateway IP address bool is_up; // whether interface is reported up by netifd + bool last_is_up; // track interface UP/DOWN transition for logging bool healthy; // daemon link health state int consecutive_success; int consecutive_failure; @@ -73,6 +74,8 @@ typedef struct { linkback_mode_t mode; char interface[MAX_NAME_LEN]; // Bind interface in multi_gw mode (e.g. lan) char device[MAX_NAME_LEN]; // Resolved device in multi_gw mode (e.g. br-lan) + bool is_up; // Bind interface netifd up status + bool last_is_up; // track bind interface UP/DOWN transition for logging int check_interval; int check_timeout; int recovery_delay; diff --git a/luci-app-linkback/Makefile b/luci-app-linkback/Makefile index 1ecdc8cef..9ff2e26e8 100644 --- a/luci-app-linkback/Makefile +++ b/luci-app-linkback/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-linkback PKG_VERSION:=1.0.0 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_MAINTAINER:=Dante LUCI_TITLE:=LuCI support for Multi-WAN LinkBack Failover/Failback diff --git a/luci-app-linkback/htdocs/luci-static/resources/view/linkback/settings.js b/luci-app-linkback/htdocs/luci-static/resources/view/linkback/settings.js index 2e8218523..1d90394a5 100644 --- a/luci-app-linkback/htdocs/luci-static/resources/view/linkback/settings.js +++ b/luci-app-linkback/htdocs/luci-static/resources/view/linkback/settings.js @@ -168,6 +168,8 @@ return view.extend({ uci.set('linkback', section_id, 'enabled', value); }; + var refreshTargetsSection = null; + // 2. Working Mode o = s.option(form.ListValue, 'mode', _('Working Mode'), _('Choose failover mode: Multi-WAN interface failover or Single-WAN multi-gateway redundancy.')); @@ -177,17 +179,12 @@ return view.extend({ o.rmempty = false; o.onchange = function(ev, section_id, value) { uci.set('linkback', section_id, 'mode', value); - var links = uci.sections('linkback', 'link') || []; - if (links.length > 0) { - if (value === 'multi_gw') { - ui.addNotification(null, E('p', - _('已切换为单wan多网关模式。请确保监控目标列表中配置了有效的网关 IP。') - ), 'info'); - } else { - ui.addNotification(null, E('p', - _('已切换为多wan口模式。请确保监控目标列表中选择了正确的 WAN 接口。') - ), 'info'); - } + // 切换工作模式时直接清空旧链路配置,保持单模式纯净性 + uci.sections('linkback', 'link').forEach(function(sec) { + uci.remove('linkback', sec['.name']); + }); + if (typeof refreshTargetsSection === 'function') { + refreshTargetsSection(); } }; @@ -228,11 +225,37 @@ return view.extend({ o.rmempty = false; // --- Monitored Targets Section --- - s = m.section(form.GridSection, 'link', _('Monitored Targets'), + var targets_section = s = m.section(form.GridSection, 'link', _('Monitored Targets'), _('Configure monitored WAN interfaces or next-hop gateways with custom probe targets and priorities (1 = primary, 2 = backup).')); s.anonymous = true; s.addremove = true; + var origSectionRender = s.render; + s.render = function() { + var mode = getActiveMode(); + if (mode === 'multi_gw') { + this.title = _('Monitored Gateways'); + this.description = _('Add next-hop gateways with custom probe targets and priorities (1 = primary, 2 = backup).'); + } else { + this.title = _('Monitored WAN Interfaces'); + this.description = _('Add WAN interfaces from firewall zone with custom probe targets and priorities (1 = primary, 2 = backup).'); + } + return origSectionRender.apply(this, arguments); + }; + + refreshTargetsSection = function() { + var old_node = document.getElementById('cbi-linkback-link') || + document.querySelector('.cbi-section[data-tab="link"]') || + document.querySelector('.cbi-section[id*="link"]'); + if (old_node && old_node.parentNode) { + targets_section.render().then(function(new_node) { + if (old_node.parentNode) { + old_node.parentNode.replaceChild(new_node, old_node); + } + }); + } + }; + // Custom dynamic Modal title s.modaltitle = function(section_id) { var parent_title = _('LinkBack 链路守护') + ' - ' + _('Settings'); @@ -284,27 +307,19 @@ return view.extend({ o.cfgvalue = function(section_id) { var gw = uci.get('linkback', section_id, 'gateway'); var name = uci.get('linkback', section_id, 'name'); - var mode = getActiveMode(); - if (mode === 'multi_gw' || gw) { - if (gw) { - if (name && name !== gw) { - return gw + ' (' + name + ')'; - } - return gw; - } - return name ? (_('Missing Gateway IP: %s').format(name)) : _('Unset'); - } else { - return name || _('Unselected'); + if (gw) { + return (name && name !== gw) ? (gw + ' (' + name + ')') : gw; } + return name || '-'; }; makeTableColumnExpand(o, '24%'); // 3a. Gateway IP (Multi-GW mode only, Modal only) var o_gw = s.option(form.Value, 'gateway', _('Gateway IP'), - _('Next-hop IPv4 address of this gateway (e.g. 192.168.1.254).')); + _('Next-hop IPv4 address of this gateway (e.g. 192.168.1.1).')); o_gw.datatype = 'ip4addr'; o_gw.modalonly = true; - o_gw.placeholder = '192.168.1.254'; + o_gw.placeholder = '192.168.1.1'; var origRenderGw = o_gw.render; o_gw.render = function(option_index, section_id, in_table) { if (getActiveMode() !== 'multi_gw') { @@ -350,9 +365,9 @@ return view.extend({ // 3b. Gateway Alias (Multi-GW mode only, Modal only) var o_alias = s.option(form.Value, 'name_alias', _('Gateway Alias (Optional)'), - _('Descriptive alias for this gateway (e.g. Bypass_GW, Main_Router). If empty, Gateway IP will be used.')); + _('Descriptive alias for this gateway (e.g. Primary_GW, Backup_GW). If empty, Gateway IP will be used.')); o_alias.modalonly = true; - o_alias.placeholder = 'Bypass_GW'; + o_alias.placeholder = 'Primary_GW'; var origRenderAlias = o_alias.render; o_alias.render = function(option_index, section_id, in_table) { if (getActiveMode() !== 'multi_gw') { @@ -432,11 +447,19 @@ return view.extend({ }; // 3. Priority - o = s.option(form.Value, 'priority', _('Priority'), - _('Lower number indicates higher priority (e.g. 1 = Primary, 2 = Backup).')); + o = s.option(form.Value, 'priority', _('Priority')); o.datatype = 'uinteger'; o.default = '1'; o.rmempty = false; + var origRenderPrio = o.render; + o.render = function(option_index, section_id, in_table) { + if (in_table) { + this.description = null; + } else { + this.description = _('Lower number indicates higher priority (e.g. 1 = Primary, 2 = Backup).'); + } + return origRenderPrio.call(this, option_index, section_id, in_table); + }; o.validate = function(section_id, value) { if (value == null || value === '') { return t_priority_empty; diff --git a/luci-app-linkback/po/zh_Hans/linkback.po b/luci-app-linkback/po/zh_Hans/linkback.po index 29b92ad92..983bb7529 100644 --- a/luci-app-linkback/po/zh_Hans/linkback.po +++ b/luci-app-linkback/po/zh_Hans/linkback.po @@ -98,16 +98,16 @@ msgid "Logical interface from firewall WAN zone." msgstr "来自防火墙 WAN 区域的逻辑接口。" msgid "Gateway Alias (Optional)" -msgstr "网关别名 (可选)" +msgstr "网关别名 (选填)" -msgid "Descriptive alias for this gateway (e.g. Bypass_GW, Main_Router). If empty, Gateway IP will be used." -msgstr "该网关的自定义别名 (例如:旁路由主网关、光猫备用网关)。若留空则自动使用网关 IP。" +msgid "Descriptive alias for this gateway (e.g. Primary_GW, Backup_GW). If empty, Gateway IP will be used." +msgstr "该网关的自定义名称 (例如:主网关、备用网关)。若留空则自动使用网关 IP。" msgid "Gateway IP" msgstr "网关 IP" -msgid "Next-hop IPv4 address of this gateway (e.g. 192.168.1.254)." -msgstr "该网关的下一跳 IPv4 地址 (例如:192.168.1.254)。" +msgid "Next-hop IPv4 address of this gateway (e.g. 192.168.1.1)." +msgstr "该网关的下一跳 IPv4 地址 (例如:192.168.1.1)。" msgid "Gateway IP is required." msgstr "网关 IP 不能为空。" @@ -346,9 +346,5 @@ msgstr "未选择" msgid "WAN Interface is required." msgstr "WAN 网络接口不能为空。" -msgid "已切换为单wan多网关模式。请确保监控目标列表中配置了有效的网关 IP。" -msgstr "已切换为单wan多网关模式。请确保监控目标列表中配置了有效的网关 IP。" -msgid "已切换为多wan口模式。请确保监控目标列表中选择了正确的 WAN 接口。" -msgstr "已切换为多wan口模式。请确保监控目标列表中选择了正确的 WAN 接口。" diff --git a/luci-app-meow/Makefile b/luci-app-meow/Makefile index d984b72f1..6c8924e34 100644 --- a/luci-app-meow/Makefile +++ b/luci-app-meow/Makefile @@ -7,7 +7,7 @@ PKG_BASE_VERSION?=0.21.2 # Rewritten automatically by .github/workflows/sync-upstream.yml; the LuCI # package always carries the same version as the core it drives. -PKG_VERSION?=$(PKG_BASE_VERSION).202610010807 +PKG_VERSION?=$(PKG_BASE_VERSION).202610021113 PKG_RELEASE?=1 include $(INCLUDE_DIR)/package.mk diff --git a/meow/Makefile b/meow/Makefile index 576645a44..3d74ba645 100644 --- a/meow/Makefile +++ b/meow/Makefile @@ -11,7 +11,7 @@ PKG_BASE_VERSION?=0.21.2 # All-numeric dotted segments keep the version valid for both package # managers: apk -> meow-0.21.2.202610010430-r1.apk # ipk -> meow_0.21.2.202610010430-1_.ipk -PKG_VERSION?=$(PKG_BASE_VERSION).202610010807 +PKG_VERSION?=$(PKG_BASE_VERSION).202610021113 PKG_RELEASE?=1 include $(INCLUDE_DIR)/package.mk