From 5d2039994244b2d789c5b82ce5a2ac4b2ac80743 Mon Sep 17 00:00:00 2001 From: Pierre Gaufillet Date: Mon, 6 Apr 2026 10:55:51 +0200 Subject: [PATCH] luci-app-ha-cluster: add LuCI interface Add a LuCI web interface for the ha-cluster package. Pages: - Quick Setup: simple form for 2+ router HA configuration - Status: real-time cluster state, peer health, sync statistics - Advanced: keepalived tuning, owsync groups, lease-sync settings 5 JavaScript views (~1700 lines), rpcd backend (~490 lines). Follows standard LuCI patterns (form maps, poll-based status, ACL). Depends on: ha-cluster (openwrt/packages) Signed-off-by: Pierre Gaufillet --- applications/luci-app-ha-cluster/Makefile | 13 + applications/luci-app-ha-cluster/README.md | 135 ++++ .../luci-static/resources/ha-cluster.css | 114 ++++ .../view/ha-cluster/keepalived-advanced.js | 511 +++++++++++++++ .../view/ha-cluster/lease-sync-advanced.js | 79 +++ .../view/ha-cluster/owsync-advanced.js | 206 ++++++ .../resources/view/ha-cluster/simple.js | 585 ++++++++++++++++++ .../resources/view/ha-cluster/status.js | 282 +++++++++ .../root/usr/libexec/rpcd/ha-cluster | 488 +++++++++++++++ .../luci/menu.d/luci-app-ha-cluster.json | 57 ++ .../share/rpcd/acl.d/luci-app-ha-cluster.json | 25 + 11 files changed, 2495 insertions(+) create mode 100644 applications/luci-app-ha-cluster/Makefile create mode 100644 applications/luci-app-ha-cluster/README.md create mode 100644 applications/luci-app-ha-cluster/htdocs/luci-static/resources/ha-cluster.css create mode 100644 applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/keepalived-advanced.js create mode 100644 applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/lease-sync-advanced.js create mode 100644 applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/owsync-advanced.js create mode 100644 applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/simple.js create mode 100644 applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/status.js create mode 100755 applications/luci-app-ha-cluster/root/usr/libexec/rpcd/ha-cluster create mode 100644 applications/luci-app-ha-cluster/root/usr/share/luci/menu.d/luci-app-ha-cluster.json create mode 100644 applications/luci-app-ha-cluster/root/usr/share/rpcd/acl.d/luci-app-ha-cluster.json diff --git a/applications/luci-app-ha-cluster/Makefile b/applications/luci-app-ha-cluster/Makefile new file mode 100644 index 000000000000..4d9cc239eb6a --- /dev/null +++ b/applications/luci-app-ha-cluster/Makefile @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: Apache-2.0 + +include $(TOPDIR)/rules.mk + +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=Pierre Gaufillet + +LUCI_TITLE:=LuCI support for HA Cluster +LUCI_DEPENDS:=+ha-cluster +luci-base +rpcd + +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/applications/luci-app-ha-cluster/README.md b/applications/luci-app-ha-cluster/README.md new file mode 100644 index 000000000000..7d50ecdce1d2 --- /dev/null +++ b/applications/luci-app-ha-cluster/README.md @@ -0,0 +1,135 @@ +# LuCI Application for HA Cluster + +Web interface for managing High Availability clusters on OpenWrt. + +## Features + +### Quick Setup Tab +- Simple multi-router HA configuration (2 or more nodes) +- Priority configuration +- Peer router management +- VRRP instance management (VIPs grouped by instance fail over atomically) +- Virtual IP (VIP) configuration per interface with instance selector +- Inline VRRP instance creation from VIP modal ("Add new..." option) +- Service synchronization selection +- One-click enable/disable + +### Status Tab +- Real-time cluster state (MASTER/BACKUP/FAULT) +- Peer connectivity status +- Service health monitoring (keepalived, owsync, lease-sync) +- DHCP lease sync statistics +- Config sync statistics +- Auto-refresh every 5 seconds + +### Advanced Pages +- **Keepalived (Advanced)**: VRRP instance tuning (timing/auth/tracking/unicast) and health checks +- **owsync (Advanced)**: Custom sync groups, exclusions, poll interval +- **DHCP Sync (Advanced)**: lease-sync tuning and logging + +## Installation + +```bash +apk update +apk add luci-app-ha-cluster +``` + +## Dependencies + +- `ha-cluster` - HA cluster management package +- `luci-base` - LuCI base system +- `rpcd` - RPC daemon + +## Files + +``` +/www/luci-static/resources/ +├── view/ha-cluster/ +│ ├── simple.js # Quick Setup interface +│ ├── status.js # Status dashboard +│ ├── keepalived-advanced.js # Advanced VRRP settings +│ ├── owsync-advanced.js # Advanced config sync settings +│ └── lease-sync-advanced.js # Advanced DHCP sync settings +└── ha-cluster.css # Styles + +/usr/share/luci/menu.d/ +└── luci-app-ha-cluster.json # Menu definition + +/usr/share/rpcd/acl.d/ +└── luci-app-ha-cluster.json # Access control + +/usr/libexec/rpcd/ +└── ha-cluster # RPC backend (shell script) +``` + +## Usage + +1. Navigate to **Services → High Availability** in LuCI +2. Go to **Quick Setup** tab +3. Configure: + - Enable HA Cluster + - Set node name and priority + - Add peer router IP + - Configure Virtual IPs for each interface + - Select services to synchronize +4. Save & Apply + +## Screenshots + +### Quick Setup +Simple form-based configuration for typical multi-router setups: +- Cluster settings (name, priority, type) +- Peer configuration +- Virtual IP addresses +- Service sync options + +### Status Dashboard +Real-time monitoring with: +- Cluster state indicator (color-coded) +- Peer health status +- Service status table +- Sync statistics + +## Development + +### Testing Locally + +```bash +# Build the package +make package/luci-app-ha-cluster/compile + +# Install on router +scp bin/packages/*/luci/luci-app-ha-cluster_*.apk root@router:/tmp/ +ssh root@router apk add /tmp/luci-app-ha-cluster_*.apk + +# Clear LuCI cache +ssh root@router rm -rf /tmp/luci-* +``` + +### Debugging + +Enable debug mode in browser console: +```javascript +L.env.sessionid +``` + +Check RPC calls: +```bash +ubus call ha-cluster status +``` + +View logs: +```bash +logread | grep luci +logread | grep ha-cluster +``` + +## License + +Apache-2.0 + +luci-app-ha-cluster has been developed using Claude Code from Anthropic. + +## Maintainer + +Pierre Gaufillet diff --git a/applications/luci-app-ha-cluster/htdocs/luci-static/resources/ha-cluster.css b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/ha-cluster.css new file mode 100644 index 000000000000..bdee4f19fb7e --- /dev/null +++ b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/ha-cluster.css @@ -0,0 +1,114 @@ +/* SPDX-License-Identifier: Apache-2.0 + * Copyright (c) 2025-2026 Pierre Gaufillet + */ + +/* HA Cluster Status Styles */ + +.ha-cluster-status-overview { + display: flex; + justify-content: space-around; + margin: 1em 0; +} + +.ha-cluster-status-card { + flex: 1; + padding: 1em; + margin: 0 0.5em; + background: #f8f9fa; + border-radius: 4px; + border-left: 4px solid #007bff; + text-align: center; +} + +.ha-cluster-status-card h4 { + margin: 0 0 0.5em 0; + color: #6c757d; + font-size: 0.9em; + text-transform: uppercase; +} + +.ha-cluster-status-value { + font-size: 2em; + font-weight: bold; +} + +.ha-cluster-state-master { + color: #28a745; +} + +.ha-cluster-state-backup { + color: #ffc107; +} + +.ha-cluster-state-fault { + color: #dc3545; +} + +.ha-cluster-state-unknown { + color: #6c757d; +} + +.ha-cluster-peer-online { + color: #28a745; +} + +.ha-cluster-peer-offline { + color: #dc3545; +} + +.ha-cluster-service-running { + color: #28a745; +} + +.ha-cluster-service-stopped { + color: #dc3545; +} + +/* Simple form hints */ +.ha-cluster-hint { + display: block; + margin-top: 0.5em; + padding: 0.5em; + background: #e7f3ff; + border-left: 3px solid #2196f3; + font-size: 0.9em; +} + +.ha-cluster-warning { + background: #fff3cd; + border-left-color: #ffc107; + color: #856404; +} + +.ha-cluster-error { + background: #f8d7da; + border-left-color: #dc3545; + color: #721c24; +} + +.ha-cluster-success { + background: #d4edda; + border-left-color: #28a745; + color: #155724; +} + +/* Status indicators */ +.ha-cluster-indicator { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 0.5em; +} + +.ha-cluster-indicator.online { + background-color: #28a745; +} + +.ha-cluster-indicator.offline { + background-color: #dc3545; +} + +.ha-cluster-indicator.warning { + background-color: #ffc107; +} diff --git a/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/keepalived-advanced.js b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/keepalived-advanced.js new file mode 100644 index 000000000000..bf6564d6bccd --- /dev/null +++ b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/keepalived-advanced.js @@ -0,0 +1,511 @@ +/* + * Copyright (c) 2025-2026 Pierre Gaufillet + * SPDX-License-Identifier: Apache-2.0 + */ +'use strict'; +'require view'; +'require form'; +'require uci'; +'require network'; +'require fs'; +'require ui'; +'require rpc'; + +var callGetInterfaces = rpc.declare({ + object: 'network.interface', + method: 'dump', + expect: { 'interface': [] } +}); + +var hooksPath = '/etc/hotplug.d/keepalived'; +var systemHook = null; + +var hookTemplate = '#!/bin/sh\n' + + '# Keepalived state change hook\n' + + '# Environment: $ACTION (MASTER/BACKUP/FAULT/STOP), $NAME (instance), $TYPE\n' + + '\n' + + '[ "$TYPE" = "INSTANCE" ] || exit 0\n' + + '\n' + + 'case "$ACTION" in\n' + + ' MASTER)\n' + + ' # Actions when becoming MASTER\n' + + ' ;;\n' + + ' BACKUP)\n' + + ' # Actions when becoming BACKUP\n' + + ' ;;\n' + + ' FAULT)\n' + + ' # Actions on fault\n' + + ' ;;\n' + + 'esac\n' + + '\n' + + 'exit 0\n'; + +return view.extend({ + load: function() { + return Promise.all([ + uci.load('ha-cluster'), + network.getDevices(), + L.resolveDefault(fs.list(hooksPath), []), + callGetInterfaces() + ]); + }, + + handleHookEdit: function(filename, ev) { + var filepath = hooksPath + '/' + filename; + + return L.resolveDefault(fs.read(filepath), '').then(L.bind(function(content) { + // Use template if content is empty or just whitespace + var displayContent = (content && content.trim()) ? content : hookTemplate; + + ui.showModal(_('Edit Hook: %s').format(filename), [ + E('p', {}, _('Shell script executed on VRRP state changes. Environment variables: $ACTION, $NAME, $TYPE.')), + E('textarea', { + 'id': 'modal-hook-content', + 'rows': 20, + 'wrap': 'off' + }, displayContent), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button-negative', + 'click': ui.createHandlerFn(this, 'handleHookDeleteFromModal', filename) + }, _('Delete')), + ' ', + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'btn cbi-button-positive', + 'click': ui.createHandlerFn(this, 'handleHookSaveFromModal', filename) + }, _('Save')) + ]) + ]); + }, this)); + }, + + handleHookSaveFromModal: function(filename, ev) { + var textarea = document.getElementById('modal-hook-content'); + var content = (textarea.value || '').trim().replace(/\r\n/g, '\n') + '\n'; + var filepath = hooksPath + '/' + filename; + + return fs.write(filepath, content).then(function() { + ui.hideModal(); + ui.addNotification(null, E('p', _('Hook "%s" saved.').format(filename)), 'info'); + }).catch(function(e) { + ui.addNotification(null, E('p', _('Unable to save hook: %s').format(e.message))); + }); + }, + + handleHookDeleteFromModal: function(filename, ev) { + if (!confirm(_('Delete hook "%s"?').format(filename))) + return; + + var filepath = hooksPath + '/' + filename; + + return fs.remove(filepath).then(function() { + ui.hideModal(); + var row = document.querySelector('[data-hook="' + CSS.escape(filename) + '"]'); + if (row) row.remove(); + ui.addNotification(null, E('p', _('Hook "%s" deleted.').format(filename)), 'info'); + }).catch(function(e) { + ui.addNotification(null, E('p', _('Unable to delete hook: %s').format(e.message))); + }); + }, + + handleHookAdd: function(ev) { + var nameInput = document.getElementById('new-hook-name'); + var filename = (nameInput.value || '').trim(); + + if (!filename) { + ui.addNotification(null, E('p', _('Please enter a hook name.'))); + return; + } + + // Validate filename (alphanumeric, dash, underscore) + if (!/^[a-zA-Z0-9_-]+$/.test(filename)) { + ui.addNotification(null, E('p', _('Hook name must contain only letters, numbers, dashes, and underscores.'))); + return; + } + + var filepath = hooksPath + '/' + filename; + + return fs.write(filepath, hookTemplate).then(function() { + nameInput.value = ''; + ui.addNotification(null, E('p', _('Hook "%s" created. Reload page to edit.').format(filename)), 'info'); + }).catch(function(e) { + ui.addNotification(null, E('p', _('Unable to create hook: %s').format(e.message))); + }); + }, + + renderHookRow: function(file) { + var filename = file.name; + + return E('tr', { 'class': 'tr', 'data-hook': filename }, [ + E('td', { 'class': 'td' }, filename), + E('td', { 'class': 'td cbi-section-actions' }, [ + E('button', { + 'class': 'btn cbi-button-edit', + 'click': ui.createHandlerFn(this, 'handleHookEdit', filename), + 'title': _('Edit') + }, _('Edit')) + ]) + ]); + }, + + render: function(data) { + var netDevs = data[1] || []; + var hookFiles = (data[2] || []).filter(function(f) { + return f.name !== systemHook && f.type === 'file'; + }); + var interfaces = data[3] || []; + var scriptSections = uci.sections('ha-cluster', 'script') || []; + var m, s, o; + var self = this; + + var ifaceIconUrl = function(ifname) { + var dev = null; + for (var i = 0; i < netDevs.length; i++) { + if (netDevs[i].getName && netDevs[i].getName() === ifname) { + dev = netDevs[i]; + break; + } + } + var type = dev ? dev.getType() : 'ethernet'; + return L.resource('icons/%s.svg').format(type); + }; + + m = new form.Map('ha-cluster', _('High Availability - Advanced VRRP'), + _('Advanced VRRP instance tuning. VIPs and instance assignment are in General.')); + + // === Global Settings === + s = m.section(form.TypedSection, 'advanced', _('Global Settings'), + _('Keepalived global options and email notifications.')); + s.anonymous = true; + s.addremove = false; + + o = s.option(form.Value, 'max_auto_priority', _('Max Auto Priority'), + _('Set to 0 to disable auto-priority (recommended).')); + o.datatype = 'uinteger'; + o.placeholder = '0'; + o.default = '0'; + + o = s.option(form.ListValue, 'log_level', _('HA Cluster Log Level'), + _('Verbosity of ha-cluster shell scripts logging.')); + o.value('0', _('Error')); + o.value('1', _('Warning')); + o.value('2', _('Info (default)')); + o.value('3', _('Debug')); + o.default = '2'; + + o = s.option(form.Flag, 'enable_notifications', _('Enable Email Notifications'), + _('Send email on state changes. Requires SMTP server.')); + o.default = '0'; + + o = s.option(form.DynamicList, 'notification_email', _('Recipient Emails')); + o.depends('enable_notifications', '1'); + o.placeholder = 'admin@example.com'; + + o = s.option(form.Value, 'notification_email_from', _('From Email')); + o.depends('enable_notifications', '1'); + o.placeholder = 'ha-cluster@router.local'; + + o = s.option(form.Value, 'smtp_server', _('SMTP Server')); + o.depends('enable_notifications', '1'); + o.placeholder = '192.168.1.100'; + + // === VRRP Instances === + s = m.section(form.GridSection, 'vrrp_instance', _('VRRP Instances'), + _('The General page creates a single "main" instance for atomic failover of all VIPs. Add extra instances here for independent failover groups.')); + s.anonymous = false; + s.addremove = true; + s.sortable = true; + s.nodescriptions = true; + s.tab('general', _('General')); + s.tab('timing', _('Timing')); + s.tab('auth', _('Authentication')); + s.tab('tracking', _('Tracking')); + s.tab('unicast', _('Unicast')); + + o = s.option(form.DummyValue, '_vrid_display', _('VRID')); + o.modalonly = false; + o.textvalue = function(section_id) { + return uci.get('ha-cluster', section_id, 'vrid') || '-'; + }; + + o = s.option(form.DummyValue, '_iface_display', _('Interface')); + o.modalonly = false; + o.textvalue = function(section_id) { + return uci.get('ha-cluster', section_id, 'interface') || '-'; + }; + + o = s.option(form.DummyValue, '_vip_count', _('VIPs')); + o.textvalue = function(section_id) { + var count = uci.sections('ha-cluster', 'vip').filter(function(vip) { + return vip.vrrp_instance === section_id; + }).length; + return String(count); + }; + o.modalonly = false; + + // === General Tab === + o = s.taboption('general', form.Value, 'vrid', _('VRID'), + _('Virtual Router ID (1-127). Must match on all cluster nodes. 128-255 reserved for auto-generated IPv6 instances.')); + o.datatype = 'range(1,127)'; + o.rmempty = false; + o.modalonly = true; + o.validate = function(section_id, value) { + if (!value) return true; + var vrid = parseInt(value); + var instances = uci.sections('ha-cluster', 'vrrp_instance'); + for (var i = 0; i < instances.length; i++) { + if (instances[i]['.name'] === section_id) continue; + if (parseInt(instances[i].vrid) === vrid) + return _('VRID %d is already used by instance "%s"').format(vrid, instances[i]['.name']); + } + return true; + }; + + o = s.taboption('general', form.ListValue, 'interface', _('Primary Interface'), + _('Interface used for VRRP advertisements.')); + o.rmempty = false; + o.modalonly = true; + if (interfaces && interfaces.length) { + var seen3 = {}; + interfaces.forEach(function(iface) { + if (iface.interface && iface.interface !== 'loopback') { + var ifname = iface.interface; + if (seen3[ifname]) return; + seen3[ifname] = true; + o.value(ifname, ifname); + } + }); + } + o.cfgvalue = function(section_id) { + var ifname = uci.get('ha-cluster', section_id, 'interface'); + if (ifname && !(this.keylist || []).includes(ifname)) { + this.value(ifname, ifname); + } + return ifname; + }; + + // === Timing Tab === + o = s.taboption('timing', form.Value, 'advert_int', _('Advertisement Interval'), + _('VRRP advertisement frequency (seconds). Lower = faster failover. Default: 1.')); + o.datatype = 'float'; + o.placeholder = '1.0'; + o.default = '1'; + o.modalonly = true; + + o = s.taboption('timing', form.Value, 'priority', _('Priority'), + _('Override global priority for this instance. Leave empty for global priority.')); + o.datatype = 'range(1,255)'; + o.placeholder = _('Use global priority'); + o.optional = true; + o.modalonly = true; + + o = s.taboption('timing', form.Flag, 'nopreempt', _('Keep Current MASTER (disable preemption)'), + _('Current MASTER stays MASTER even if higher-priority router returns.')); + o.default = '1'; + o.modalonly = true; + + o = s.taboption('timing', form.Value, 'preempt_delay', _('Preempt Delay'), + _('Seconds before higher-priority router takes over. Prevents flapping. Recommended: 30-60.')); + o.datatype = 'uinteger'; + o.placeholder = '30'; + o.optional = true; + o.modalonly = true; + + o = s.taboption('timing', form.Value, 'garp_master_delay', _('GARP Delay'), + _('Seconds before sending Gratuitous ARP after becoming MASTER. Helps with slow switches. Default: 5.')); + o.datatype = 'uinteger'; + o.placeholder = '5'; + o.optional = true; + o.modalonly = true; + + // === Authentication Tab === + o = s.taboption('auth', form.ListValue, 'auth_type', _('Authentication Type'), + _('PASS: basic (max 8 chars, cleartext). AH: cryptographic (requires kmod-ipsec).')); + o.value('none', _('None')); + o.value('pass', _('Simple Password (PASS)')); + o.value('ah', _('IPSec AH')); + o.default = 'none'; + o.modalonly = true; + o.cfgvalue = function(section_id) { + var v = uci.get('ha-cluster', section_id, 'auth_type') || 'none'; + if (v && !(this.keylist || []).includes(v)) { + this.value(v, v); + } + return v; + }; + + o = s.taboption('auth', form.Value, 'auth_pass', _('Authentication Password'), + _('Shared secret (4-8 chars). Must be identical on all nodes.')); + o.password = true; + o.datatype = 'and(minlength(4),maxlength(8))'; + o.placeholder = _('4-8 characters'); + o.depends('auth_type', 'pass'); + o.modalonly = true; + + // === Tracking Tab === + o = s.taboption('tracking', form.DynamicList, 'track_interface', _('Track Interfaces'), + _('Failover when tracked interface goes DOWN. Common: track WAN for internet failover.')); + netDevs.forEach(function(dev) { + if (dev.getName) { + o.value(dev.getName()); + } + }); + o.placeholder = _('Select interface'); + o.modalonly = true; + + o = s.taboption('tracking', form.DynamicList, 'track_script', _('Track Scripts'), + _('Failover when health check fails. Define scripts in Health Checks section below.')); + scriptSections.forEach(function(script) { + if (script['.name']) { + o.value(script['.name']); + } + }); + o.placeholder = _('Select script'); + o.modalonly = true; + + // === Unicast Tab === + // Compute auto-derived unicast values from peer config + var peers = uci.sections('ha-cluster', 'peer'); + var transport = uci.get('ha-cluster', 'config', 'vrrp_transport') || 'multicast'; + var autoSrcIp = ''; + var autoPeerAddrs = []; + for (var pi = 0; pi < peers.length; pi++) { + if (peers[pi].address) + autoPeerAddrs.push(peers[pi].address); + if (!autoSrcIp && peers[pi].source_address) + autoSrcIp = peers[pi].source_address; + } + + o = s.taboption('unicast', form.Value, 'unicast_src_ip', _('Unicast Source IP'), + (transport === 'unicast' && autoSrcIp) + ? _('Per-instance override. When empty, auto-derived from peer config: %s').format(autoSrcIp) + : _('This router\'s IP for unicast VRRP. Leave empty for multicast (default).')); + o.datatype = 'ipaddr'; + o.placeholder = (transport === 'unicast' && autoSrcIp) + ? _('Auto: %s').format(autoSrcIp) + : _('e.g., 192.168.1.1'); + o.optional = true; + o.modalonly = true; + + o = s.taboption('unicast', form.DynamicList, 'unicast_peer', _('Unicast Peer IPs'), + (transport === 'unicast' && autoPeerAddrs.length > 0) + ? _('Per-instance override. When empty, auto-derived from peer config: %s').format(autoPeerAddrs.join(', ')) + : _('Peer router IPs. Required when using unicast mode. Both src_ip and peer must be set.')); + o.datatype = 'ipaddr'; + o.placeholder = _('e.g., 192.168.1.2'); + o.modalonly = true; + + // === Health Checks (VRRP Scripts) === + s = m.section(form.GridSection, 'script', _('Health Checks (VRRP Scripts)'), + _('Custom checks referenced by track_script. Examples: ping gateway, check DNS, verify VPN.')); + s.anonymous = false; + s.addremove = true; + s.sortable = true; + + o = s.option(form.DummyValue, '_script', _('Script')); + o.cfgvalue = function(section_id) { return uci.get('ha-cluster', section_id, 'script') || '-'; }; + o.modalonly = false; + + o = s.option(form.Value, 'script', _('Script Command'), + _('Command returning 0 for success. Use absolute paths.')); + o.placeholder = '/bin/ping -c 1 -W 1 8.8.8.8'; + o.rmempty = false; + o.modalonly = true; + + o = s.option(form.Value, 'interval', _('Interval'), + _('Seconds between checks.')); + o.datatype = 'uinteger'; + o.placeholder = '5'; + o.default = '5'; + o.modalonly = true; + + o = s.option(form.Value, 'timeout', _('Timeout'), + _('Max seconds for script.')); + o.datatype = 'uinteger'; + o.placeholder = '2'; + o.optional = true; + o.modalonly = true; + + o = s.option(form.Value, 'weight', _('Weight'), + _('Priority adjustment on failure (e.g., -10).')); + o.datatype = 'integer'; + o.placeholder = '-10'; + o.optional = true; + o.modalonly = true; + + o = s.option(form.Value, 'rise', _('Rise'), + _('Successes before healthy.')); + o.datatype = 'uinteger'; + o.placeholder = '2'; + o.optional = true; + o.modalonly = true; + + o = s.option(form.Value, 'fall', _('Fall'), + _('Failures before unhealthy.')); + o.datatype = 'uinteger'; + o.placeholder = '2'; + o.optional = true; + o.modalonly = true; + + o = s.option(form.Value, 'user', _('User'), + _('Run as user (default: root).')); + o.datatype = 'and(minlength(1),maxlength(32))'; + o.placeholder = 'nobody'; + o.optional = true; + o.modalonly = true; + + // Render form.Map, then append hooks section + return m.render().then(L.bind(function(mapEl) { + // Build hooks section with grid + var hooksTable = E('table', { 'class': 'table cbi-section-table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Name')), + E('th', { 'class': 'th cbi-section-actions' }, '') + ]) + ]); + + var tbody = E('tbody', { 'id': 'hooks-tbody' }); + hookFiles.forEach(L.bind(function(file) { + tbody.appendChild(this.renderHookRow(file)); + }, this)); + + if (hookFiles.length === 0) { + tbody.appendChild(E('tr', { 'class': 'tr placeholder' }, [ + E('td', { 'class': 'td', 'colspan': '2', 'style': 'text-align: center; font-style: italic; color: #888;' }, + _('No custom hooks defined.')) + ])); + } + + hooksTable.appendChild(tbody); + + var hooksContainer = E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('State Change Hooks')), + E('div', { 'class': 'cbi-section-descr' }, + _('Shell scripts executed on VRRP state changes. Click Edit to modify. Environment: $ACTION, $NAME, $TYPE.')), + hooksTable, + E('div', { 'class': 'cbi-section-create' }, [ + E('div', {}, [ + E('input', { + 'type': 'text', + 'class': 'cbi-section-create-name', + 'id': 'new-hook-name', + 'placeholder': _('e.g., 60-vpn-failover') + }) + ]), + E('button', { + 'class': 'cbi-button cbi-button-add', + 'click': ui.createHandlerFn(this, 'handleHookAdd') + }, _('Add')) + ]) + ]); + + mapEl.appendChild(hooksContainer); + return mapEl; + }, this)); + } +}); diff --git a/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/lease-sync-advanced.js b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/lease-sync-advanced.js new file mode 100644 index 000000000000..45722efe7536 --- /dev/null +++ b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/lease-sync-advanced.js @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025-2026 Pierre Gaufillet + * SPDX-License-Identifier: Apache-2.0 + */ +'use strict'; +'require view'; +'require form'; +'require uci'; +'require fs'; + +return view.extend({ + load: function() { + return Promise.all([ + uci.load('ha-cluster'), + L.resolveDefault(fs.stat('/usr/sbin/lease-sync'), null) + ]); + }, + + render: function(data) { + var leaseSyncInstalled = data[1] != null; + + if (!leaseSyncInstalled) { + return E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, _('High Availability - Advanced Lease Sync')), + E('div', { 'class': 'alert-message info' }, [ + E('p', {}, _('Install the lease-sync package to enable real-time DHCP lease synchronization.')), + E('p', {}, [ + E('a', { 'href': '/cgi-bin/luci/admin/system/package-manager' }, _('Go to Software page') + ' \u2192') + ]) + ]) + ]); + } + + var m, s, o; + + m = new form.Map('ha-cluster', _('High Availability - Advanced Lease Sync'), + _('Tuning for high-churn environments, unreliable networks, or troubleshooting.')); + + // Tuning section + s = m.section(form.TypedSection, 'advanced', _('Lease-Sync Tuning'), + _('Default values work for most deployments.')); + s.anonymous = true; + s.addremove = false; + + o = s.option(form.Value, 'lease_sync_port', _('Sync Port'), + _('UDP port for lease sync.')); + o.datatype = 'port'; + o.placeholder = '5378'; + o.default = '5378'; + + o = s.option(form.Value, 'lease_sync_interval', _('Sync Interval'), + _('Seconds between full sync requests. Safety net for missed updates.')); + o.datatype = 'uinteger'; + o.placeholder = '30'; + o.default = '30'; + + o = s.option(form.Value, 'lease_sync_peer_timeout', _('Peer Timeout'), + _('Seconds without heartbeat before peer is offline. Should be 4x sync_interval.')); + o.datatype = 'uinteger'; + o.placeholder = '120'; + o.default = '120'; + + o = s.option(form.Value, 'lease_sync_persist_interval', _('Persist Interval'), + _('Seconds between disk writes. Increase to 300 for flash storage.')); + o.datatype = 'uinteger'; + o.placeholder = '60'; + o.default = '60'; + + o = s.option(form.ListValue, 'lease_sync_log_level', _('Log Level'), + _('Use Debug (3) for troubleshooting sync issues.')); + o.value('0', _('Error')); + o.value('1', _('Warning')); + o.value('2', _('Info (default)')); + o.value('3', _('Debug')); + o.default = '2'; + + return m.render(); + } +}); diff --git a/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/owsync-advanced.js b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/owsync-advanced.js new file mode 100644 index 000000000000..d94ce9f9e83c --- /dev/null +++ b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/owsync-advanced.js @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025-2026 Pierre Gaufillet + * SPDX-License-Identifier: Apache-2.0 + */ +'use strict'; +'require view'; +'require form'; +'require uci'; +'require ui'; + +var ChipListValue = form.Value.extend({ + __name__: 'CBI.ChipListValue', + + renderChip: function(container, value) { + var self = this; + var chip = E('span', { + 'class': 'label', + 'style': 'display: inline-flex; align-items: center; margin: 2px 4px 2px 0; padding: 4px 8px; background: #f0f0f0; border-radius: 12px; font-family: monospace; font-size: 12px;', + 'data-value': value + }, [ + E('span', {}, value), + E('button', { + 'type': 'button', + 'style': 'border: none; background: transparent; cursor: pointer; margin-left: 6px; padding: 0; font-size: 14px; color: #666; line-height: 1;', + 'title': _('Remove'), + 'click': function(ev) { + ev.currentTarget.parentNode.remove(); + } + }, '\u00d7') + ]); + container.appendChild(chip); + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var values = L.toArray(cfgvalue); + var self = this; + + var chipsContainer = E('div', { + 'id': this.cbid(section_id) + '-chips', + 'style': 'display: flex; flex-wrap: wrap; align-items: center; min-height: 32px; padding: 4px 0;' + }); + + values.forEach(function(v) { + self.renderChip(chipsContainer, v); + }); + + if (values.length === 0) { + chipsContainer.appendChild(E('span', { + 'style': 'font-style: italic; color: #888;' + }, _('No exclusions configured'))); + } + + var input = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'placeholder': this.placeholder || '', + 'style': 'flex: 1; min-width: 200px;' + }); + + var addBtn = E('button', { + 'class': 'cbi-button cbi-button-add', + 'style': 'margin-left: 4px;', + 'click': L.bind(function(ev) { + var val = input.value.trim(); + if (!val) return; + + // Check for duplicates + var existing = chipsContainer.querySelectorAll('[data-value]'); + for (var i = 0; i < existing.length; i++) { + if (existing[i].getAttribute('data-value') === val) { + ui.addNotification(null, E('p', _('Pattern already exists.'))); + return; + } + } + + // Remove placeholder text if present + var placeholder = chipsContainer.querySelector('span[style*="italic"]'); + if (placeholder) placeholder.remove(); + + this.renderChip(chipsContainer, val); + input.value = ''; + }, this) + }, _('Add')); + + return E('div', {}, [ + chipsContainer, + E('div', { 'style': 'display: flex; align-items: center; margin-top: 4px;' }, [ + input, + addBtn + ]) + ]); + }, + + formvalue: function(section_id) { + var container = document.getElementById(this.cbid(section_id) + '-chips'); + if (!container) return []; + + var chips = container.querySelectorAll('[data-value]'); + var values = []; + for (var i = 0; i < chips.length; i++) { + values.push(chips[i].getAttribute('data-value')); + } + return values; + }, + + write: function(section_id, formvalue) { + uci.set('ha-cluster', section_id, this.option, formvalue); + }, + + remove: function(section_id) { + uci.unset('ha-cluster', section_id, this.option); + } +}); + +return view.extend({ + load: function() { + return uci.load('ha-cluster'); + }, + + render: function() { + var m, s, o; + var self = this; + var baseServices = ['dhcp', 'firewall', 'wireless']; + + m = new form.Map('ha-cluster', _('High Availability - Advanced Config Sync'), + _('Custom sync groups for services beyond General settings (e.g., VPN, mwan3).')); + + // === Global Settings === + s = m.section(form.TypedSection, 'advanced', _('Global Settings'), + _('owsync global options.')); + s.anonymous = true; + s.addremove = false; + + o = s.option(form.Value, 'sync_interval', _('Poll Interval (seconds)'), + _('Fallback scan interval. Changes are also detected via inotify events.')); + o.datatype = 'uinteger'; + o.placeholder = '30'; + o.default = '30'; + + o = s.option(form.ListValue, 'owsync_log_level', _('Log Level'), + _('Verbosity of owsync daemon logging.')); + o.value('0', _('Error')); + o.value('1', _('Warning')); + o.value('2', _('Info (default)')); + o.value('3', _('Debug')); + o.default = '2'; + + // === Additional Sync Groups === + s = m.section(form.GridSection, 'service', _('Additional Sync Groups'), + _('Click Edit to configure files.')); + s.anonymous = false; + s.addremove = true; + s.sortable = true; + s.nodescriptions = true; + + // Filter out base services - they are managed in General + s.filter = function(section_id) { + return baseServices.indexOf(section_id) === -1; + }; + + o = s.option(form.DummyValue, '_enabled_status', _('Status')); + o.textvalue = function(section_id) { + var enabled = uci.get('ha-cluster', section_id, 'enabled'); + if (enabled === '1' || enabled === true) { + return E('span', { 'class': 'cbi-value-field' }, [ + E('span', { 'class': 'label success' }, _('Enabled')) + ]); + } else { + return E('span', { 'class': 'cbi-value-field' }, [ + E('span', { 'class': 'label' }, _('Disabled')) + ]); + } + }; + o.modalonly = false; + + o = s.option(form.DummyValue, '_files_count', _('Files')); + o.textvalue = function(section_id) { + var files = uci.get('ha-cluster', section_id, 'config_files') || []; + if (!Array.isArray(files)) files = files ? [files] : []; + return E('span', { 'class': 'cbi-value-field' }, files.length + ' ' + _('configured')); + }; + o.modalonly = false; + + o = s.option(form.Flag, 'enabled', _('Enable Sync'), + _('Enable synchronization for this group.')); + o.default = '1'; + o.modalonly = true; + + o = s.option(form.DynamicList, 'config_files', _('Files/Directories to Sync'), + _('UCI names (e.g., "openvpn") or absolute paths (e.g., "/etc/openvpn/keys").')); + o.datatype = 'string'; + o.placeholder = 'openvpn or /etc/openvpn/keys'; + o.modalonly = true; + + // === Exclusions === + s = m.section(form.TypedSection, 'exclude', _('Exclusions'), + _('Files excluded from sync. Default: network, system, ha-cluster, owsync.')); + s.anonymous = true; + s.addremove = false; + + o = s.option(ChipListValue, 'file', _('Excluded Files')); + o.placeholder = '/etc/dropbear/*.key'; + + return m.render(); + } +}); diff --git a/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/simple.js b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/simple.js new file mode 100644 index 000000000000..d4f34f99ac16 --- /dev/null +++ b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/simple.js @@ -0,0 +1,585 @@ +/* + * Copyright (c) 2025-2026 Pierre Gaufillet + * SPDX-License-Identifier: Apache-2.0 + */ +'use strict'; +'require view'; +'require form'; +'require uci'; +'require rpc'; +'require ui'; +'require network'; +'require fs'; + +var callGetInterfaces = rpc.declare({ + object: 'network.interface', + method: 'dump', + expect: { 'interface': [] } +}); + +var callGenerateKey = rpc.declare({ + object: 'ha-cluster', + method: 'generate_key' +}); + +return view.extend({ + load: function() { + return Promise.all([ + uci.load('ha-cluster'), + callGetInterfaces(), + network.getDevices(), + L.resolveDefault(fs.stat('/usr/sbin/lease-sync'), null) + ]); + }, + + render: function(data) { + var interfaces = data[1] || []; + var netDevs = data[2] || []; + var leaseSyncInstalled = data[3] != null; + var m, s, o; + + // Build set of VIP addresses to exclude from source address selection + var vipAddresses = {}; + uci.sections('ha-cluster', 'vip').forEach(function(vip) { + if (vip.address) + vipAddresses[vip.address] = true; + }); + + // Filter function for valid source addresses + var isValidSourceAddress = function(addr) { + // Exclude loopback + if (addr.startsWith('127.') || addr === '::1') + return false; + // Exclude link-local IPv6 + if (addr.startsWith('fe80:')) + return false; + // Exclude VIPs + if (vipAddresses[addr]) + return false; + return true; + }; + + // Helper to detect address family + var isIPv6 = function(addr) { + return addr && addr.indexOf(':') !== -1; + }; + + // Deterministic VRID from instance name (DJB2 hash, range 1-127) + var computeVrid = function(ifname) { + var hash = 5381; + for (var i = 0; i < ifname.length; i++) + hash = ((hash << 5) + hash + ifname.charCodeAt(i)) & 0xffffffff; + return ((hash >>> 0) % 127) + 1; + }; + + var ifaceIconUrl = function(ifname) { + var dev = null; + for (var i = 0; i < netDevs.length; i++) { + if (netDevs[i].getName && netDevs[i].getName() === ifname) { + dev = netDevs[i]; + break; + } + } + var type = dev ? dev.getType() : 'ethernet'; + return L.resource('icons/%s.svg').format(type); + }; + + m = new form.Map('ha-cluster', _('High Availability - General'), + _('Configure a high availability cluster with 2 or more routers. Advanced options are available in the Advanced pages.')); + + // Global Settings Section + s = m.section(form.NamedSection, 'config', 'global', _('Cluster Configuration')); + s.anonymous = false; + s.addremove = false; + + o = s.option(form.Flag, 'enabled', _('Enable HA Cluster'), + _('Enable high availability clustering on this router')); + o.rmempty = false; + + o = s.option(form.Value, 'node_priority', _('Priority'), + _('VRRP priority (1-255). Higher priority becomes MASTER.')); + o.datatype = 'range(1,255)'; + o.placeholder = '100'; + o.default = '100'; + o.rmempty = false; + + o = s.option(form.ListValue, 'vrrp_transport', _('VRRP Transport'), + _('Multicast is the default. Use unicast when multicast is unavailable on the network.')); + o.value('multicast', _('Multicast')); + o.value('unicast', _('Unicast')); + o.default = 'multicast'; + o.rmempty = false; + + // Peer Configuration + s = m.section(form.GridSection, 'peer', _('Peer Routers')); + s.anonymous = true; + s.addremove = true; + s.sortable = true; + + // Custom handleAdd following DDNS pattern: + // 1. Show modal with separate form.Map (isolated from main form) + // 2. On Add: save main form first (preserves pending changes like node_name) + // 3. Then add new peer to UCI + // 4. Then refresh main form (now includes saved changes + new peer) + s.handleAdd = function(ev) { + var _this = this; + var mainMap = this.map; + + var m2 = new form.Map('ha-cluster'); + var s2 = m2.section(form.NamedSection, '_new_'); + + s2.render = function() { + return Promise.all([ + {}, + this.renderUCISection('_new_') + ]).then(this.renderContents.bind(this)); + }; + + var peerName = s2.option(form.Value, 'name', _('Peer Name')); + peerName.rmempty = false; + peerName.datatype = 'hostname'; + peerName.placeholder = 'router2'; + + var peerAddress = s2.option(form.Value, 'address', _('Peer IP Address')); + peerAddress.rmempty = false; + peerAddress.datatype = 'ipaddr'; + peerAddress.placeholder = '192.168.1.2'; + + var peerSource = s2.option(form.ListValue, 'source_address', _('Source Address'), + _('Local IP to use when contacting this peer. Must match address family (IPv4/IPv6) of peer.')); + // Populate with all valid source addresses + interfaces.forEach(function(iface) { + (iface['ipv4-address'] || []).forEach(function(addr) { + if (isValidSourceAddress(addr.address)) + peerSource.value(addr.address, addr.address + ' (' + iface.interface + ')'); + }); + (iface['ipv6-address'] || []).forEach(function(addr) { + if (isValidSourceAddress(addr.address)) + peerSource.value(addr.address, addr.address + ' (' + iface.interface + ')'); + }); + }); + + var peerSyncEnabled = s2.option(form.Flag, 'sync_enabled', _('Enable Synchronization'), + _('When disabled, this peer participates in VRRP failover but is excluded from config and lease sync. Use for non-OpenWrt peers.')); + peerSyncEnabled.default = '1'; + + m2.render().then(function(nodes) { + ui.showModal(_('Add Peer Router'), [ + nodes, + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), ' ', + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + var nameVal = peerName.formvalue('_new_'); + var addrVal = peerAddress.formvalue('_new_'); + var sourceVal = peerSource.formvalue('_new_'); + var syncVal = peerSyncEnabled.formvalue('_new_'); + + if (!nameVal || !addrVal) { + ui.addNotification(null, E('p', _('Please fill in all fields')), 'warning'); + return; + } + + if (!sourceVal) { + ui.addNotification(null, E('p', _('Please select a source address')), 'warning'); + return; + } + + // Validate address family match + var peerIsV6 = isIPv6(addrVal); + var sourceIsV6 = isIPv6(sourceVal); + if (peerIsV6 !== sourceIsV6) { + ui.addNotification(null, E('p', _('Address family mismatch: peer and source must both be IPv4 or both be IPv6')), 'warning'); + return; + } + + // Save main form first (preserves all pending changes) + // Then add new peer, then refresh + mainMap.save(function() { + uci.add('ha-cluster', 'peer'); + var sections = uci.sections('ha-cluster', 'peer'); + var newSection = sections[sections.length - 1]['.name']; + uci.set('ha-cluster', newSection, 'name', nameVal); + uci.set('ha-cluster', newSection, 'address', addrVal); + uci.set('ha-cluster', newSection, 'source_address', sourceVal); + uci.set('ha-cluster', newSection, 'sync_enabled', syncVal || '1'); + }).then(function() { + ui.hideModal(); + return mainMap.load(); + }).then(function() { + return mainMap.reset(); + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, + E('p', {}, _('Failed to add peer: ') + err.message), + 'danger'); + }); + } + }, _('Add')) + ]) + ], 'cbi-modal'); + }); + }; + + o = s.option(form.DummyValue, '_display', _('Peer')); + o.modalonly = false; + o.textvalue = function(section_id) { + var name = uci.get('ha-cluster', section_id, 'name') || '-'; + var addr = uci.get('ha-cluster', section_id, 'address') || ''; + return name + (addr ? ' (' + addr + ')' : ''); + }; + + o = s.option(form.Value, 'name', _('Peer Name')); + o.placeholder = 'router2'; + o.datatype = 'hostname'; + o.rmempty = false; + o.modalonly = true; + + var addrOpt = s.option(form.Value, 'address', _('Peer IP Address')); + addrOpt.datatype = 'ipaddr'; + addrOpt.placeholder = '192.168.1.2'; + addrOpt.rmempty = false; + addrOpt.modalonly = true; + var srcOpt = s.option(form.ListValue, 'source_address', _('Source Address'), + _('Local IP to use when contacting this peer. Must match address family (IPv4/IPv6) of peer.')); + // Populate with all valid source addresses + interfaces.forEach(function(iface) { + (iface['ipv4-address'] || []).forEach(function(addr) { + if (isValidSourceAddress(addr.address)) + srcOpt.value(addr.address, addr.address + ' (' + iface.interface + ')'); + }); + (iface['ipv6-address'] || []).forEach(function(addr) { + if (isValidSourceAddress(addr.address)) + srcOpt.value(addr.address, addr.address + ' (' + iface.interface + ')'); + }); + }); + srcOpt.rmempty = false; + srcOpt.modalonly = true; + + // Cross-field validation for address family match + // Using this.map.lookupOption() pattern from VIP validation + addrOpt.validate = function(section_id, value) { + var src = this.map.lookupOption('source_address', section_id); + var srcval = src ? src[0].formvalue(section_id) : ''; + if (value && srcval) { + var peerIsV6 = value.indexOf(':') !== -1; + var sourceIsV6 = srcval.indexOf(':') !== -1; + if (peerIsV6 !== sourceIsV6) + return _('Family mismatch: both must be IPv4 or both IPv6'); + } + return true; + }; + + srcOpt.validate = function(section_id, value) { + var addr = this.map.lookupOption('address', section_id); + var addrval = addr ? addr[0].formvalue(section_id) : ''; + if (value && addrval) { + var peerIsV6 = addrval.indexOf(':') !== -1; + var sourceIsV6 = value.indexOf(':') !== -1; + if (peerIsV6 !== sourceIsV6) + return _('Family mismatch: both must be IPv4 or both IPv6'); + } + return true; + }; + + o = s.option(form.Flag, 'sync_enabled', _('Enable Synchronization'), + _('When disabled, this peer participates in VRRP failover but is excluded from config and lease sync. Use for non-OpenWrt peers.')); + o.default = '1'; + o.rmempty = false; + o.modalonly = true; + + // Virtual IP Configuration + s = m.section(form.GridSection, 'vip', _('Virtual IP Addresses'), + _('All VIPs share a single failover group and switch together. Use the Advanced page to create independent failover groups.')); + s.anonymous = true; + s.addremove = true; + s.sortable = true; + s.addbtntitle = _('Add VIP...'); + + // Garbage collection: remove orphaned vrrp_instance when last VIP is deleted + s.handleRemove = function(section_id, ev) { + var instName = uci.get('ha-cluster', section_id, 'vrrp_instance') || ''; + + uci.remove('ha-cluster', section_id); + + if (instName) { + var hasVips = uci.sections('ha-cluster', 'vip').some(function(vip) { + return vip.vrrp_instance === instName; + }); + if (!hasVips) + uci.remove('ha-cluster', instName); + } + + return this.map.save(null, true).catch(function(err) { + ui.addNotification(null, + E('p', {}, _('Failed to save: ') + err.message), + 'danger'); + }); + }; + + o = s.option(form.DummyValue, '_interface_display', _('Interface')); + o.textvalue = function(section_id) { + var ifname = uci.get('ha-cluster', section_id, 'interface') || ''; + return E('span', {}, [ + E('img', { 'src': ifaceIconUrl(ifname), 'style': 'width:16px;height:16px;vertical-align:middle;margin-right:4px' }), + E('span', {}, ifname || '-') + ]); + }; + o.modalonly = false; + + o = s.option(form.DummyValue, '_vrid_display', _('VRID')); + o.textvalue = function(section_id) { + var inst = uci.get('ha-cluster', section_id, 'vrrp_instance') || ''; + if (!inst) return '-'; + return uci.get('ha-cluster', inst, 'vrid') || '-'; + }; + o.modalonly = false; + + o = s.option(form.ListValue, 'interface', _('Interface')); + o.rmempty = false; + o.modalonly = true; + // Populate with available interfaces + if (interfaces && interfaces.length) { + var seen = {}; + interfaces.forEach(function(iface) { + if (iface.interface && iface.interface !== 'loopback') { + var ifname = iface.interface; + if (seen[ifname]) return; + seen[ifname] = true; + var device = iface.device || ifname; + o.value(ifname, ifname + (device !== ifname ? ' (' + device + ')' : '')); + } + }); + } + o.cfgvalue = function(section_id) { + var ifname = uci.get('ha-cluster', section_id, 'interface'); + if (ifname && !(this.keylist || []).includes(ifname)) { + this.value(ifname, ifname); + } + return ifname; + }; + o.write = function(section_id, value) { + uci.set('ha-cluster', section_id, 'interface', value); + + // Ensure 'main' instance exists; attach VIP to it + var instName = 'main'; + var exists = uci.sections('ha-cluster', 'vrrp_instance').some(function(inst) { + return inst['.name'] === instName; + }); + if (!exists) { + var globalPriority = uci.get('ha-cluster', 'config', 'node_priority') || '100'; + // Default advertisement interface: 'lan' if available, else blank + var defaultIface = ''; + var ifaceList = interfaces || []; + for (var i = 0; i < ifaceList.length; i++) { + if (ifaceList[i].interface === 'lan') { defaultIface = 'lan'; break; } + } + uci.add('ha-cluster', 'vrrp_instance', instName); + uci.set('ha-cluster', instName, 'vrid', String(computeVrid(instName))); + if (defaultIface) + uci.set('ha-cluster', instName, 'interface', defaultIface); + uci.set('ha-cluster', instName, 'priority', globalPriority); + uci.set('ha-cluster', instName, 'nopreempt', '1'); + uci.set('ha-cluster', instName, 'advert_int', '1'); + } + uci.set('ha-cluster', section_id, 'vrrp_instance', instName); + }; + + o = s.option(form.Value, 'address', _('Virtual IP Address (IPv4)')); + o.datatype = 'ip4addr'; + o.placeholder = '192.168.1.254'; + o.rmempty = true; + o.description = _('Optional. You can configure IPv4 only, IPv6 only, or both (dual-stack).'); + o.validate = function(section_id, value) { + var addr6 = this.map.lookupOption('address6', section_id); + var addr6val = addr6 ? addr6[0].formvalue(section_id) : ''; + if (!value && !addr6val) + return _('At least one of IPv4 or IPv6 address must be set'); + return true; + }; + + o = s.option(form.Value, 'netmask', _('Netmask')); + o.datatype = 'ip4addr'; + o.placeholder = '255.255.255.0'; + o.default = '255.255.255.0'; + o.rmempty = false; + o.modalonly = true; + o.depends({ address: /.+/ }); + + o = s.option(form.Value, 'address6', _('Virtual IP Address (IPv6)')); + o.datatype = 'ip6addr'; + o.placeholder = 'fd00::1'; + o.rmempty = true; + o.modalonly = true; + o.description = _('Optional. If IPv6 is configured, keepalived creates a second instance with VRID+128.'); + o.validate = function(section_id, value) { + var addr = this.map.lookupOption('address', section_id); + var addrval = addr ? addr[0].formvalue(section_id) : ''; + if (!value && !addrval) + return _('At least one of IPv4 or IPv6 address must be set'); + return true; + }; + + o = s.option(form.Value, 'prefix6', _('IPv6 Prefix Length')); + o.datatype = 'range(1,128)'; + o.placeholder = '64'; + o.default = '64'; + o.rmempty = true; + o.modalonly = true; + o.depends({ address6: /.+/ }); + + o = s.option(form.Flag, 'enabled', _('Enable')); + o.default = '1'; + o.rmempty = false; + o.modalonly = true; + + // Configuration Synchronization (files + method) + s = m.section(form.NamedSection, 'config', 'global', _('Configuration Synchronization'), + _('Select which /etc/config files to synchronize and how synchronization is performed.')); + + // Encryption settings first + o = s.option(form.Flag, 'sync_encryption', _('Encrypt Sync Traffic'), + _('Enable AES-256-GCM encryption for configuration sync (owsync) and DHCP lease sync (lease-sync). Recommended for security.')); + o.default = '1'; + + var encryption_key_option = s.option(form.Value, 'encryption_key', _('Encryption Key'), + _('256-bit AES encryption key (64 hexadecimal characters). Used by both owsync and lease-sync. Must be identical on all cluster nodes.')); + encryption_key_option.depends('sync_encryption', '1'); + encryption_key_option.password = true; + encryption_key_option.datatype = 'and(hexstring,rangelength(64,64))'; + encryption_key_option.rmempty = true; + encryption_key_option.placeholder = 'Click "Generate New Key" button below to create a secure random key'; + + o = s.option(form.Button, '_generate_key', _('Generate New Key')); + o.depends('sync_encryption', '1'); + o.inputtitle = _('Generate New Key'); + o.inputstyle = 'action'; + o.onclick = function(section_id) { + // Show modal + ui.showModal(_('Generating encryption key...'), [ + E('p', { 'class': 'spinning' }, _('Generating secure random key...')) + ], 'wait'); + + // Call RPC method + L.resolveDefault(callGenerateKey(), {}).then(function(result) { + ui.hideModal(); + + if (result && result.success && result.key) { + // Set the encryption key field via LuCI option API + var uiEl = encryption_key_option.getUIElement(section_id); + if (uiEl) { + uiEl.setValue(result.key); + } + + // Show success modal with copy instructions + ui.showModal(_('Encryption Key Generated'), [ + E('p', {}, _('Encryption key generated successfully!')), + E('p', { 'class': 'alert-message warning' }, [ + E('strong', {}, _('IMPORTANT:')), + ' ', + _('Copy this key to all cluster nodes.') + ]), + E('pre', { + 'style': 'background: #f5f5f5; padding: 10px; border-radius: 4px; user-select: all; font-family: monospace; word-break: break-all;', + 'onclick': 'this.select(); document.execCommand("copy");' + }, result.key), + E('p', { 'style': 'margin-top: 1em;' }, _('Click the key above to select and copy it.')), + E('div', { 'class': 'alert-message info', 'style': 'margin-top: 1em;' }, [ + E('strong', {}, _('Next steps:')), + E('ol', { 'style': 'margin: 0.5em 0;' }, [ + E('li', {}, _('Save & Apply changes on this router')), + E('li', {}, _('On each peer router, go to: HA Cluster → Quick Setup → Synchronization Settings')), + E('li', {}, _('Enable "Encrypt Sync Traffic"')), + E('li', {}, _('Paste this key into the "Encryption Key" field')), + E('li', {}, _('Save & Apply on all peer routers')) + ]) + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button-positive', + 'click': ui.hideModal + }, _('Close')) + ]) + ]); + + // Try to copy to clipboard if supported + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(result.key).then(function() { + ui.addNotification(null, E('p', {}, _('Key copied to clipboard')), 'info'); + }).catch(function(err) { + console.warn('Clipboard write failed (key still visible in modal):', err); + }); + } + } else { + ui.addNotification(null, + E('p', {}, _('Failed to generate key: ') + (result.error || _('Unknown error'))), + 'danger'); + } + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, + E('p', {}, _('Error generating key: ') + err.message), + 'danger'); + }); + }; + + // DHCP Lease Sync - maps to service.dhcp.sync_leases + if (leaseSyncInstalled) { + o = s.option(form.Flag, '_dhcp_leases', _('Enable Real-time DHCP Lease Sync'), + _('Synchronize DHCP leases in real-time using the lease-sync daemon for seamless client failover')); + o.default = '1'; + o.rmempty = false; + o.cfgvalue = function() { return uci.get('ha-cluster', 'dhcp', 'sync_leases'); }; + o.write = function(section_id, value) { uci.set('ha-cluster', 'dhcp', 'sync_leases', value); }; + o.remove = function() { uci.set('ha-cluster', 'dhcp', 'sync_leases', '0'); }; + } else { + o = s.option(form.DummyValue, '_dhcp_leases', _('Real-time DHCP Lease Sync')); + o.rawhtml = true; + o.cfgvalue = function() { + return '' + _('Install the lease-sync package to enable real-time DHCP lease synchronization.') + '' + + ' ' + _('Go to Software page') + ' \u2192'; + }; + } + + // Sync method selection + o = s.option(form.ListValue, 'sync_method', _('Sync Method'), + _('owsync: real-time file synchronization. none: disable file synchronization.')); + o.value('owsync', _('owsync')); + o.value('none', _('none')); + o.default = 'owsync'; + o.rmempty = false; + + // Sync DHCP Configuration - maps to service.dhcp.enabled + o = s.option(form.Flag, '_dhcp_config', _('Sync DHCP Configuration'), + _('Synchronize /etc/config/dhcp (address pools, static leases, DNS settings)')); + o.default = '1'; + o.rmempty = false; + o.cfgvalue = function() { return uci.get('ha-cluster', 'dhcp', 'enabled'); }; + o.write = function(section_id, value) { uci.set('ha-cluster', 'dhcp', 'enabled', value); }; + o.remove = function() { uci.set('ha-cluster', 'dhcp', 'enabled', '0'); }; + + // Sync Firewall - maps to service.firewall.enabled + o = s.option(form.Flag, '_firewall_config', _('Sync Firewall Rules'), + _('Synchronize /etc/config/firewall (port forwarding, rules, zones). Review rules before enabling.')); + o.default = '0'; + o.rmempty = false; + o.cfgvalue = function() { return uci.get('ha-cluster', 'firewall', 'enabled'); }; + o.write = function(section_id, value) { uci.set('ha-cluster', 'firewall', 'enabled', value); }; + o.remove = function() { uci.set('ha-cluster', 'firewall', 'enabled', '0'); }; + + // Sync Wireless - maps to service.wireless.enabled + o = s.option(form.Flag, '_wireless_config', _('Sync Wireless Configuration'), + _('Synchronize /etc/config/wireless (SSID, encryption). WARNING: Only enable if routers have identical WiFi hardware!')); + o.default = '0'; + o.rmempty = false; + o.cfgvalue = function() { return uci.get('ha-cluster', 'wireless', 'enabled'); }; + o.write = function(section_id, value) { uci.set('ha-cluster', 'wireless', 'enabled', value); }; + o.remove = function() { uci.set('ha-cluster', 'wireless', 'enabled', '0'); }; + + return m.render(); + } +}); diff --git a/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/status.js b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/status.js new file mode 100644 index 000000000000..d5e310d44749 --- /dev/null +++ b/applications/luci-app-ha-cluster/htdocs/luci-static/resources/view/ha-cluster/status.js @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2025-2026 Pierre Gaufillet + * SPDX-License-Identifier: Apache-2.0 + */ +'use strict'; +'require view'; +'require poll'; +'require rpc'; +'require dom'; +'require ui'; +'require fs'; + +var callHAClusterStatus = rpc.declare({ + object: 'ha-cluster', + method: 'status', + expect: { } +}); + +return view.extend({ + __init__: function() { + this.super('__init__', arguments); + this.pollData = null; + }, + + load: function() { + return Promise.all([ + this.loadData(), + L.resolveDefault(fs.stat('/usr/sbin/lease-sync'), null) + ]); + }, + + loadData: function() { + return callHAClusterStatus().catch(function(err) { + console.error('ha-cluster status RPC failed:', err); + return {}; + }); + }, + + formatUptime: function(seconds) { + if (!seconds) return '-'; + var days = Math.floor(seconds / 86400); + var hours = Math.floor((seconds % 86400) / 3600); + var mins = Math.floor((seconds % 3600) / 60); + + var parts = []; + if (days > 0) parts.push(days + 'd'); + if (hours > 0) parts.push(hours + 'h'); + if (mins > 0 || parts.length === 0) parts.push(mins + 'm'); + + return parts.join(' '); + }, + + formatRelativeTime: function(unixTimestamp) { + if (!unixTimestamp || unixTimestamp === 0) { + return _('Never'); + } + + var now = Math.floor(Date.now() / 1000); + var diff = now - unixTimestamp; + + if (diff < 60) { + return _('Just now'); + } else if (diff < 3600) { + var mins = Math.floor(diff / 60); + return mins + ' ' + (mins === 1 ? _('minute ago') : _('minutes ago')); + } else if (diff < 86400) { + var hours = Math.floor(diff / 3600); + return hours + ' ' + (hours === 1 ? _('hour ago') : _('hours ago')); + } else { + var days = Math.floor(diff / 86400); + return days + ' ' + (days === 1 ? _('day ago') : _('days ago')); + } + }, + + renderServiceRow: function(name, serviceInfo) { + var isRunning = (serviceInfo && serviceInfo.running) || false; + var isRequired = (serviceInfo && serviceInfo.required) || false; + var pid = (serviceInfo && serviceInfo.pid) || '-'; + var uptime = (serviceInfo && this.formatUptime(serviceInfo.uptime)) || '-'; + + // Determine status badge using LuCI standard classes + var statusBadge; + if (isRunning) { + statusBadge = E('span', { 'class': 'label success' }, _('Running')); + } else if (isRequired) { + statusBadge = E('span', { 'class': 'label important' }, _('Stopped')); + } else { + statusBadge = E('span', { 'class': 'label' }, _('Disabled')); + } + + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, name), + E('td', { 'class': 'td left' }, statusBadge), + E('td', { 'class': 'td left' }, String(pid)), + E('td', { 'class': 'td left' }, uptime) + ]); + }, + + renderStatus: function(data, leaseSyncInstalled) { + var status = data || {}; + var services = status.services || {}; + + var clusterState = status.state || 'UNKNOWN'; + var nodeRole = status.role || 'UNKNOWN'; + var peers = status.peers || []; + var syncStatus = status.sync || {}; + + // Determine cluster state label class + var stateClass; + switch (clusterState) { + case 'HEALTHY': + stateClass = 'label success'; + break; + case 'DEGRADED': + stateClass = 'label warning'; + break; + case 'FAULTY': + stateClass = 'label important'; + break; + default: + stateClass = 'label'; + } + + // Determine node role label class + var roleClass; + switch (nodeRole) { + case 'MASTER': + roleClass = 'label success'; + break; + case 'BACKUP': + roleClass = 'label notice'; + break; + case 'FAULT': + roleClass = 'label important'; + break; + case 'MIXED': + roleClass = 'label warning'; + break; + default: + roleClass = 'label'; + } + + // Build per-instance role rows (shown when multiple instances exist) + var instances = status.instances || {}; + var instanceNames = Object.keys(instances); + + return [ + E('h3', {}, _('Cluster Status')), + + // Two separate tables side-by-side for clear visual separation + E('div', { 'class': 'cbi-section', 'style': 'display: flex; gap: 2em;' }, [ + // Table 1: Cluster Overview + E('div', { 'class': 'cbi-section-node', 'style': 'flex: 1;' }, [ + E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th', 'colspan': '2' }, _('Cluster Overview')) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, _('Cluster State')), + E('td', { 'class': 'td left' }, [ + E('span', { 'class': stateClass }, clusterState) + ]) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, _('Node Role')), + E('td', { 'class': 'td left' }, [ + E('span', { 'class': roleClass }, nodeRole) + ]) + ]) + ].concat(instanceNames.length > 1 ? instanceNames.map(function(name) { + var instRole = instances[name]; + var instClass; + switch (instRole) { + case 'MASTER': instClass = 'label success'; break; + case 'BACKUP': instClass = 'label notice'; break; + case 'FAULT': instClass = 'label important'; break; + default: instClass = 'label'; + } + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left', 'style': 'padding-left: 2em;' }, + _('Instance: %s').format(name)), + E('td', { 'class': 'td left' }, [ + E('span', { 'class': instClass }, instRole) + ]) + ]); + }) : [])) + ]), + // Table 2: Last Synchronization + E('div', { 'class': 'cbi-section-node', 'style': 'flex: 1;' }, [ + E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th', 'colspan': '2' }, _('Last Synchronization')) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, _('Config Sync')), + E('td', { 'class': 'td left' }, this.formatRelativeTime(syncStatus.config_last_sync)) + ]) + ].concat(leaseSyncInstalled ? [ + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, _('Lease Sync')), + E('td', { 'class': 'td left' }, this.formatRelativeTime(syncStatus.lease_last_sync)) + ]) + ] : [])) + ]) + ]), + + // Service Status + E('h3', {}, _('Service Status')), + E('div', { 'class': 'cbi-section cbi-section-node' }, [ + E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Service')), + E('th', { 'class': 'th' }, _('Status')), + E('th', { 'class': 'th' }, _('PID')), + E('th', { 'class': 'th' }, _('Uptime')) + ]), + this.renderServiceRow('keepalived', services.keepalived), + this.renderServiceRow('owsync', services.owsync) + ].concat(leaseSyncInstalled ? [ + this.renderServiceRow('lease-sync', services['lease-sync']) + ] : [])) + ]), + + // Peer Status + E('h3', {}, _('Peer Status')), + E('div', { 'class': 'cbi-section cbi-section-node' }, [ + peers.length > 0 ? + E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Peer Name')), + E('th', { 'class': 'th' }, _('Address')), + E('th', { 'class': 'th' }, _('State')), + E('th', { 'class': 'th' }, _('Last Seen')) + ]) + ].concat(peers.map(function(peer) { + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, peer.name || '-'), + E('td', { 'class': 'td left' }, peer.address || '-'), + E('td', { 'class': 'td left' }, + E('span', { + 'class': peer.online ? 'label success' : 'label important' + }, peer.online ? _('Online') : _('Offline')) + ), + E('td', { 'class': 'td left' }, peer.last_seen || '-') + ]); + }))) : + E('div', { 'class': 'alert-message warning' }, _('No peers configured')) + ]) + ]; + }, + + render: function(data) { + var statusData = data[0]; + var leaseSyncInstalled = data[1] != null; + + var view = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, _('High Availability Status')), + E('div', { 'class': 'cbi-map-descr' }, + _('Real-time status of the HA cluster. Updates every 5 seconds.')) + ]); + + var content = E('div', { 'id': 'ha_status_content' }); + view.appendChild(content); + dom.content(content, this.renderStatus(statusData, leaseSyncInstalled)); + + // Set up auto-refresh polling + poll.add(L.bind(function() { + return this.loadData().then(L.bind(function(data) { + var statusContent = document.getElementById('ha_status_content'); + if (statusContent) { + dom.content(statusContent, this.renderStatus(data, leaseSyncInstalled)); + } + }, this)); + }, this), 5); + + return view; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/applications/luci-app-ha-cluster/root/usr/libexec/rpcd/ha-cluster b/applications/luci-app-ha-cluster/root/usr/libexec/rpcd/ha-cluster new file mode 100755 index 000000000000..8f0f1fd6bbee --- /dev/null +++ b/applications/luci-app-ha-cluster/root/usr/libexec/rpcd/ha-cluster @@ -0,0 +1,488 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2025-2026 Pierre Gaufillet + +. /usr/share/libubox/jshn.sh + +case "$1" in + list) + # List available methods and their parameters + json_init + json_add_object "generate_key" + json_close_object + json_add_object "status" + json_close_object + json_add_object "apply" + json_close_object + json_add_object "get_source_for_peer" + json_add_string peer_address "str" + json_close_object + json_dump + ;; + call) + case "$2" in + generate_key) + # Generate secure 256-bit encryption key + key=$(hexdump -n 32 -v -e '1/1 "%02x"' /dev/urandom 2>/dev/null) + + if [ -z "$key" ] || [ ${#key} -ne 64 ]; then + json_init + json_add_boolean success 0 + json_add_string error "Failed to generate key" + json_dump + exit 1 + fi + + json_init + json_add_boolean success 1 + json_add_string key "$key" + json_dump + ;; + + status) + # Get comprehensive cluster status + + # Initialize variables + node_role="UNKNOWN" + cluster_state="STOPPED" + services_ok=1 + peers_online=0 + peers_total=0 + instance_roles="" + + # Check if ha-cluster is enabled + ha_enabled=$(uci -q get ha-cluster.config.enabled) + + if [ "$ha_enabled" != "1" ]; then + cluster_state="STOPPED" + else + # Get keepalived status via ubus + keepalived_data=$(ubus call keepalived dump 2>/dev/null) + + if [ -n "$keepalived_data" ]; then + # Parse per-instance VRRP state (in subshell to avoid state pollution) + # Output format: instance_name:STATE per line + instance_roles=$( + . /usr/share/libubox/jshn.sh + json_load "$keepalived_data" 2>/dev/null || exit 1 + json_select status 2>/dev/null || exit 1 + json_get_keys instances + + for instance in $instances; do + json_select "$instance" || continue + + inst_role="UNKNOWN" + json_get_var vrrp_state state 2>/dev/null + if [ -n "$vrrp_state" ]; then + case "$vrrp_state" in + [Mm][Aa][Ss][Tt][Ee][Rr]) inst_role="MASTER" ;; + [Bb][Aa][Cc][Kk][Uu][Pp]) inst_role="BACKUP" ;; + [Ff][Aa][Uu][Ll][Tt]) inst_role="FAULT" ;; + esac + else + # Fallback: infer state from statistics + json_select stats 2>/dev/null + if [ $? -eq 0 ]; then + json_get_var become_master become_master + json_get_var release_master release_master + if [ "$become_master" -gt "$release_master" ]; then + inst_role="MASTER" + else + inst_role="BACKUP" + fi + json_select .. + fi + fi + + echo "$instance:$inst_role" + json_select .. + done + ) + + # Compute aggregate role from per-instance roles + has_fault=0 + has_master=0 + has_backup=0 + instance_count=0 + for entry in $instance_roles; do + role_val="${entry##*:}" + case "$role_val" in + FAULT) has_fault=1 ;; + MASTER) has_master=1 ;; + BACKUP) has_backup=1 ;; + esac + instance_count=$((instance_count + 1)) + done + + if [ "$has_fault" -eq 1 ]; then + node_role="FAULT" + elif [ "$has_master" -eq 1 ] && [ "$has_backup" -eq 0 ]; then + node_role="MASTER" + elif [ "$has_backup" -eq 1 ] && [ "$has_master" -eq 0 ]; then + node_role="BACKUP" + elif [ "$has_master" -eq 1 ] && [ "$has_backup" -eq 1 ]; then + node_role="MIXED" + elif [ "$instance_count" -eq 0 ]; then + node_role="UNKNOWN" + fi + else + node_role="FAULT" + services_ok=0 + fi + + # Ensure node_role is always set (fallback if parsing failed) + node_role="${node_role:-UNKNOWN}" + + # Check critical services based on configuration + # keepalived is always mandatory + if ! pgrep "keepalived" >/dev/null 2>&1; then + services_ok=0 + fi + + # owsync is only required if sync_method='owsync' + sync_method=$(uci -q get ha-cluster.config.sync_method) + if [ "$sync_method" = "owsync" ]; then + if ! pgrep "owsync" >/dev/null 2>&1; then + services_ok=0 + fi + fi + + # lease-sync is required if any service has sync_leases='1' + lease_sync_required=0 + for svc_section in $(uci show ha-cluster 2>/dev/null | grep "=service$" | cut -d. -f2 | cut -d= -f1); do + sync_leases=$(uci -q get "ha-cluster.$svc_section.sync_leases") + if [ "$sync_leases" = "1" ]; then + lease_sync_required=1 + break + fi + done + if [ "$lease_sync_required" -eq 1 ]; then + if ! pgrep "lease-sync" >/dev/null 2>&1; then + services_ok=0 + fi + fi + + + fi + + # Get sync statistics (MVP: timestamps only) + config_last_sync=0 + lease_last_sync=0 + + # Check owsync database for last sync timestamp + # BusyBox doesn't support stat -c, use date -r instead + if [ -f /etc/owsync/owsync.db ]; then + config_last_sync=$(date -r /etc/owsync/owsync.db +%s 2>/dev/null || echo 0) + fi + + # Check dnsmasq lease file for last sync timestamp + # Get leasefile location from UCI (default: /tmp/dhcp.leases) + leasefile=$(uci -q get dhcp.@dnsmasq[0].leasefile) + leasefile=${leasefile:-/tmp/dhcp.leases} + if [ -f "$leasefile" ]; then + lease_last_sync=$(date -r "$leasefile" +%s 2>/dev/null || echo 0) + fi + + # Build peer list and check connectivity in a single pass + json_init + json_add_string role "$node_role" + + # Add per-instance roles + json_add_object instances + for entry in $instance_roles; do + inst_name="${entry%%:*}" + inst_role="${entry##*:}" + # Validate values + case "$inst_role" in + MASTER|BACKUP|FAULT|UNKNOWN) ;; + *) inst_role="UNKNOWN" ;; + esac + json_add_string "$inst_name" "$inst_role" + done + json_close_object + + json_add_array peers + for peer_section in $(uci show ha-cluster 2>/dev/null | grep "=peer$" | cut -d. -f2 | cut -d= -f1); do + peer_name=$(uci -q get "ha-cluster.$peer_section.name") + peer_addr=$(uci -q get "ha-cluster.$peer_section.address") + if [ -n "$peer_addr" ]; then + peers_total=$((peers_total + 1)) + peer_online=0 + case "$peer_addr" in + *:*) ping_cmd="ping6" ;; + *) ping_cmd="ping" ;; + esac + if $ping_cmd -c 1 -W 1 "$peer_addr" >/dev/null 2>&1; then + peer_online=1 + peers_online=$((peers_online + 1)) + last_seen="Just now" + else + last_seen="Unreachable" + fi + + json_add_object + json_add_string name "${peer_name:-$peer_section}" + json_add_string address "$peer_addr" + json_add_boolean online "$peer_online" + json_add_string last_seen "$last_seen" + json_close_object + fi + done + json_close_array + + # Calculate cluster health (after single-pass peer ping) + if [ "$ha_enabled" != "1" ]; then + cluster_state="STOPPED" + elif [ "$services_ok" -eq 0 ]; then + cluster_state="FAULTY" + elif [ "$peers_online" -lt "$peers_total" ] && [ "$peers_total" -gt 0 ]; then + cluster_state="DEGRADED" + elif [ "$services_ok" -eq 1 ]; then + cluster_state="HEALTHY" + fi + json_add_string state "$cluster_state" + + + # Add service status for frontend display with required flag + # Determine which services are required based on config + sync_method=$(uci -q get ha-cluster.config.sync_method) + lease_sync_required=0 + for svc_check in $(uci show ha-cluster 2>/dev/null | grep "=service$" | cut -d. -f2 | cut -d= -f1); do + if [ "$(uci -q get ha-cluster.$svc_check.sync_leases)" = "1" ]; then + lease_sync_required=1 + break + fi + done + + json_add_object services + + # keepalived - always required + json_add_object "keepalived" + json_add_boolean required 1 + svc_pid=$(pgrep -o "keepalived" 2>/dev/null) + if [ -n "$svc_pid" ]; then + json_add_boolean running 1 + json_add_int pid "$svc_pid" + start_time=$(awk '{print $22}' /proc/$svc_pid/stat 2>/dev/null) + if [ -n "$start_time" ]; then + uptime_jiffies=$(awk '{print $1 * 100}' /proc/uptime 2>/dev/null) + uptime_sec=$(awk "BEGIN{print int(($uptime_jiffies - $start_time) / 100)}") + json_add_int uptime "$uptime_sec" + else + json_add_int uptime 0 + fi + else + json_add_boolean running 0 + json_add_int pid 0 + json_add_int uptime 0 + fi + json_close_object + + # owsync - required only if sync_method='owsync' + json_add_object "owsync" + if [ "$sync_method" = "owsync" ]; then + json_add_boolean required 1 + else + json_add_boolean required 0 + fi + svc_pid=$(pgrep -o "owsync" 2>/dev/null) + if [ -n "$svc_pid" ]; then + json_add_boolean running 1 + json_add_int pid "$svc_pid" + start_time=$(awk '{print $22}' /proc/$svc_pid/stat 2>/dev/null) + if [ -n "$start_time" ]; then + uptime_jiffies=$(awk '{print $1 * 100}' /proc/uptime 2>/dev/null) + uptime_sec=$(awk "BEGIN{print int(($uptime_jiffies - $start_time) / 100)}") + json_add_int uptime "$uptime_sec" + else + json_add_int uptime 0 + fi + else + json_add_boolean running 0 + json_add_int pid 0 + json_add_int uptime 0 + fi + json_close_object + + # lease-sync - required if any service has sync_leases='1' + json_add_object "lease-sync" + if [ "$lease_sync_required" -eq 1 ]; then + json_add_boolean required 1 + else + json_add_boolean required 0 + fi + svc_pid=$(pgrep -o "lease-sync" 2>/dev/null) + if [ -n "$svc_pid" ]; then + json_add_boolean running 1 + json_add_int pid "$svc_pid" + start_time=$(awk '{print $22}' /proc/$svc_pid/stat 2>/dev/null) + if [ -n "$start_time" ]; then + uptime_jiffies=$(awk '{print $1 * 100}' /proc/uptime 2>/dev/null) + uptime_sec=$(awk "BEGIN{print int(($uptime_jiffies - $start_time) / 100)}") + json_add_int uptime "$uptime_sec" + else + json_add_int uptime 0 + fi + else + json_add_boolean running 0 + json_add_int pid 0 + json_add_int uptime 0 + fi + json_close_object + + json_close_object + + json_add_object sync + json_add_int config_last_sync "$config_last_sync" + json_add_int lease_last_sync "$lease_last_sync" + json_close_object + + json_dump + ;; + + get_source_for_peer) + # Get recommended source address for reaching a peer + # Uses kernel route resolution, filters out VIPs + + # Parse input + json_load "$3" + json_get_var peer_address peer_address + + if [ -z "$peer_address" ]; then + json_init + json_add_boolean success 0 + json_add_string error "peer_address required" + json_dump + exit 1 + fi + + # Validate peer_address (prevent injection) + case "$peer_address" in + *[!0-9a-fA-F.:]*) + json_init + json_add_boolean success 0 + json_add_string error "Invalid peer address" + json_dump + exit 1 + ;; + esac + + # Detect address family (IPv6 contains colons) + case "$peer_address" in + *:*) addr_family="6" ;; + *) addr_family="4" ;; + esac + + # Get kernel's preferred source via ip route get + if [ "$addr_family" = "6" ]; then + route_output=$(ip -6 route get "$peer_address" 2>/dev/null) + else + route_output=$(ip -4 route get "$peer_address" 2>/dev/null) + fi + if [ -z "$route_output" ]; then + json_init + json_add_boolean success 0 + json_add_string error "No route to peer" + json_dump + exit 1 + fi + + # Parse: "192.168.1.2 via 10.0.0.1 dev eth0 src 10.0.0.2" + # or: "192.168.1.2 dev eth0 src 192.168.1.1" + kernel_src=$(echo "$route_output" | grep -oE 'src [0-9a-fA-F.:]+' | head -1 | cut -d' ' -f2) + route_dev=$(echo "$route_output" | grep -oE 'dev [^ ]+' | head -1 | cut -d' ' -f2) + + if [ -z "$kernel_src" ] || [ -z "$route_dev" ]; then + json_init + json_add_boolean success 0 + json_add_string error "Could not determine source" + json_dump + exit 1 + fi + + # Build VIP set from UCI + vip_list="" + for vip_section in $(uci show ha-cluster 2>/dev/null | grep "=vip$" | cut -d. -f2 | cut -d= -f1); do + vip_addr=$(uci -q get "ha-cluster.$vip_section.address") + if [ -n "$vip_addr" ]; then + vip_list="$vip_list $vip_addr" + fi + done + + # Check if kernel_src is a VIP + is_vip=0 + for vip in $vip_list; do + if [ "$kernel_src" = "$vip" ]; then + is_vip=1 + break + fi + done + + recommended_src="$kernel_src" + + if [ "$is_vip" -eq 1 ]; then + # Find alternative address on same interface, same address family + # Get addresses on route_dev matching the peer's address family + alt_src="" + if [ "$addr_family" = "6" ]; then + addr_list=$(ip -6 -o addr show dev "$route_dev" 2>/dev/null | awk '{print $4}' | cut -d/ -f1) + else + addr_list=$(ip -4 -o addr show dev "$route_dev" 2>/dev/null | awk '{print $4}' | cut -d/ -f1) + fi + for addr in $addr_list; do + # Skip loopback, link-local + case "$addr" in + 127.*|::1|fe80:*) continue ;; + esac + # Skip VIPs + skip=0 + for vip in $vip_list; do + if [ "$addr" = "$vip" ]; then + skip=1 + break + fi + done + if [ "$skip" -eq 0 ]; then + alt_src="$addr" + break + fi + done + + if [ -n "$alt_src" ]; then + recommended_src="$alt_src" + fi + fi + + json_init + json_add_boolean success 1 + json_add_string source "$recommended_src" + json_add_string interface "$route_dev" + json_add_boolean is_vip_filtered "$is_vip" + json_dump + ;; + + apply) + # Reload ha-cluster service + if /etc/init.d/ha-cluster reload >/dev/null 2>&1; then + json_init + json_add_boolean success 1 + json_dump + else + json_init + json_add_boolean success 0 + json_add_string error "Failed to reload service" + json_dump + exit 1 + fi + ;; + + *) + json_init + json_add_boolean success 0 + json_add_string error "Unknown method: $2" + json_dump + exit 1 + ;; + esac + ;; +esac diff --git a/applications/luci-app-ha-cluster/root/usr/share/luci/menu.d/luci-app-ha-cluster.json b/applications/luci-app-ha-cluster/root/usr/share/luci/menu.d/luci-app-ha-cluster.json new file mode 100644 index 000000000000..74046cd4bd4b --- /dev/null +++ b/applications/luci-app-ha-cluster/root/usr/share/luci/menu.d/luci-app-ha-cluster.json @@ -0,0 +1,57 @@ +{ + "admin/services/ha-cluster": { + "title": "High Availability", + "order": 60, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": [ "luci-app-ha-cluster" ] + } + }, + + "admin/services/ha-cluster/status": { + "title": "Status", + "order": 10, + "action": { + "type": "view", + "path": "ha-cluster/status" + } + }, + + "admin/services/ha-cluster/simple": { + "title": "General", + "order": 20, + "action": { + "type": "view", + "path": "ha-cluster/simple" + } + }, + + "admin/services/ha-cluster/keepalived-advanced": { + "title": "Advanced VRRP", + "order": 30, + "action": { + "type": "view", + "path": "ha-cluster/keepalived-advanced" + } + }, + + "admin/services/ha-cluster/owsync-advanced": { + "title": "Advanced Config Sync", + "order": 40, + "action": { + "type": "view", + "path": "ha-cluster/owsync-advanced" + } + }, + + "admin/services/ha-cluster/lease-sync-advanced": { + "title": "Advanced Lease Sync", + "order": 50, + "action": { + "type": "view", + "path": "ha-cluster/lease-sync-advanced" + } + } +} diff --git a/applications/luci-app-ha-cluster/root/usr/share/rpcd/acl.d/luci-app-ha-cluster.json b/applications/luci-app-ha-cluster/root/usr/share/rpcd/acl.d/luci-app-ha-cluster.json new file mode 100644 index 000000000000..3c73946ae28d --- /dev/null +++ b/applications/luci-app-ha-cluster/root/usr/share/rpcd/acl.d/luci-app-ha-cluster.json @@ -0,0 +1,25 @@ +{ + "luci-app-ha-cluster": { + "description": "Grant access to HA Cluster configuration", + "read": { + "file": { + "/etc/hotplug.d/keepalived/*": [ "list", "read" ] + }, + "ubus": { + "ha-cluster": [ "status", "generate_key", "get_source_for_peer" ], + "keepalived": [ "dump" ], + "service": [ "list" ] + }, + "uci": [ "ha-cluster", "keepalived", "owsync", "lease-sync", "dhcp", "network" ] + }, + "write": { + "file": { + "/etc/hotplug.d/keepalived/*": [ "write" ] + }, + "ubus": { + "ha-cluster": [ "apply" ] + }, + "uci": [ "ha-cluster" ] + } + } +}