From 0031a45f293761ce206c04ed7eef22151f525faa Mon Sep 17 00:00:00 2001 From: cayossarian Date: Thu, 12 Feb 2026 14:00:01 -0800 Subject: [PATCH 1/4] net/avahi-reflector: add Avahi mDNS/DNS-SD reflector plugin New plugin that runs avahi-daemon in reflector mode, proxying multicast DNS and DNS Service Discovery traffic across VLANs. Provides a GUI for configuration, a dashboard status widget, and a diagnostics API endpoint. Depends on the avahi-app FreeBSD package. Co-Authored-By: Claude Opus 4.6 --- net/avahi-reflector/Makefile | 7 + net/avahi-reflector/pkg-descr | 16 +++ .../etc/inc/plugins.inc.d/avahireflector.inc | 59 ++++++++ .../AvahiReflector/Api/ServiceController.php | 52 +++++++ .../AvahiReflector/Api/SettingsController.php | 37 +++++ .../AvahiReflector/IndexController.php | 38 +++++ .../OPNsense/AvahiReflector/forms/general.xml | 63 +++++++++ .../OPNsense/AvahiReflector/ACL/ACL.xml | 9 ++ .../AvahiReflector/AvahiReflector.php | 35 +++++ .../AvahiReflector/AvahiReflector.xml | 42 ++++++ .../OPNsense/AvahiReflector/Menu/Menu.xml | 7 + .../views/OPNsense/AvahiReflector/index.volt | 60 ++++++++ .../scripts/OPNsense/AvahiReflector/status.py | 130 ++++++++++++++++++ .../actions.d/actions_avahireflector.conf | 29 ++++ .../OPNsense/AvahiReflector/+TARGETS | 2 + .../OPNsense/AvahiReflector/avahidaemon.conf | 35 +++++ .../OPNsense/AvahiReflector/rc.conf.d | 5 + .../opnsense/www/js/widgets/AvahiReflector.js | 96 +++++++++++++ .../js/widgets/Metadata/AvahiReflector.xml | 22 +++ 19 files changed, 744 insertions(+) create mode 100644 net/avahi-reflector/Makefile create mode 100644 net/avahi-reflector/pkg-descr create mode 100644 net/avahi-reflector/src/etc/inc/plugins.inc.d/avahireflector.inc create mode 100644 net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/ServiceController.php create mode 100644 net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/SettingsController.php create mode 100644 net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/IndexController.php create mode 100644 net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml create mode 100644 net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/ACL/ACL.xml create mode 100644 net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.php create mode 100644 net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.xml create mode 100644 net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/Menu/Menu.xml create mode 100644 net/avahi-reflector/src/opnsense/mvc/app/views/OPNsense/AvahiReflector/index.volt create mode 100755 net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py create mode 100644 net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf create mode 100644 net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/+TARGETS create mode 100644 net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/avahidaemon.conf create mode 100644 net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/rc.conf.d create mode 100644 net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js create mode 100644 net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml diff --git a/net/avahi-reflector/Makefile b/net/avahi-reflector/Makefile new file mode 100644 index 000000000..d302d54c1 --- /dev/null +++ b/net/avahi-reflector/Makefile @@ -0,0 +1,7 @@ +PLUGIN_NAME= avahi-reflector +PLUGIN_VERSION= 1.0 +PLUGIN_COMMENT= Avahi mDNS/DNS-SD reflector for cross-VLAN service discovery +PLUGIN_MAINTAINER= bflood@users.noreply.github.com +PLUGIN_DEPENDS= avahi-app + +.include "../../Mk/plugins.mk" diff --git a/net/avahi-reflector/pkg-descr b/net/avahi-reflector/pkg-descr new file mode 100644 index 000000000..98211b010 --- /dev/null +++ b/net/avahi-reflector/pkg-descr @@ -0,0 +1,16 @@ +Avahi mDNS/DNS-SD Reflector + +Runs avahi-daemon in reflector mode, proxying multicast DNS and +DNS Service Discovery traffic across VLANs. Unlike mdns-repeater, +Avahi handles full mDNS A/AAAA record queries and DNS-SD browsing, +enabling reliable hostname resolution and Bonjour service discovery +across network segments. + +WWW: https://avahi.org/ + +Plugin Changelog +================ + +1.0 + +* Initial release diff --git a/net/avahi-reflector/src/etc/inc/plugins.inc.d/avahireflector.inc b/net/avahi-reflector/src/etc/inc/plugins.inc.d/avahireflector.inc new file mode 100644 index 000000000..364e971e0 --- /dev/null +++ b/net/avahi-reflector/src/etc/inc/plugins.inc.d/avahireflector.inc @@ -0,0 +1,59 @@ +enabled === '1') { + $services[] = [ + 'description' => gettext('Avahi mDNS/DNS-SD Reflector'), + 'configd' => [ + 'start' => ['avahireflector start'], + 'stop' => ['avahireflector stop'], + 'restart' => ['avahireflector restart'], + ], + 'name' => 'avahi-daemon', + 'pidfile' => '/var/run/avahi-daemon/pid', + ]; + } + + return $services; +} + +function avahireflector_xmlrpc_sync() +{ + $result = []; + $result[] = [ + 'description' => gettext('Avahi mDNS/DNS-SD Reflector'), + 'section' => 'OPNsense.AvahiReflector', + 'id' => 'avahireflector', + ]; + return $result; +} diff --git a/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/ServiceController.php b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/ServiceController.php new file mode 100644 index 000000000..7d5e71718 --- /dev/null +++ b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/ServiceController.php @@ -0,0 +1,52 @@ +sessionClose(); + $backend = new Backend(); + $response = $backend->configdRun('avahireflector diagnostics'); + $data = json_decode($response, true); + if ($data === null) { + return ['status' => 'error', 'message' => 'Failed to parse diagnostics output']; + } + return $data; + } +} diff --git a/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/SettingsController.php b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/SettingsController.php new file mode 100644 index 000000000..f89165403 --- /dev/null +++ b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/SettingsController.php @@ -0,0 +1,37 @@ +view->generalForm = $this->getForm('general'); + $this->view->pick('OPNsense/AvahiReflector/index'); + } +} diff --git a/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml new file mode 100644 index 000000000..08e23e98d --- /dev/null +++ b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml @@ -0,0 +1,63 @@ +
+ + header + + + + avahireflector.enabled + + checkbox + + + + avahireflector.interfaces + + select_multiple +
Firewall requirement: Each selected interface needs a pass rule for UDP port 5353 destined to the mDNS multicast addresses: 224.0.0.251 (IPv4) and ff02::fb (IPv6). An IGMP pass rule is also recommended so the firewall can join the IPv4 multicast group. Without these rules, Avahi will not receive mDNS queries or announcements on that interface.

Service traffic: mDNS only handles discovery — clients still need unicast access to the actual service ports across VLANs. For example: AirPlay (TCP 7000, 7100), AirPrint/IPP (TCP 631), HomeKit (TCP 51827). Ensure your inter-VLAN rules allow the relevant service ports between client and device subnets.]]>
+
+ + header + + + + avahireflector.domain_name + + text + + + + avahireflector.use_ipv4 + + checkbox + Enable IPv4 mDNS traffic. + + + avahireflector.use_ipv6 + + checkbox + Enable IPv6 mDNS traffic. + + + header + + + + avahireflector.enable_reflector + + checkbox + + + + avahireflector.reflect_ipv + + checkbox + + + + avahireflector.reflect_filters + + text + true +
Examples: _airplay._tcp, _raop._tcp, _ipp._tcp, _http._tcp]]>
+
+
diff --git a/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/ACL/ACL.xml b/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/ACL/ACL.xml new file mode 100644 index 000000000..81ac65492 --- /dev/null +++ b/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/ACL/ACL.xml @@ -0,0 +1,9 @@ + + + Services: Avahi mDNS Reflector + + ui/avahireflector/* + api/avahireflector/* + + + diff --git a/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.php b/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.php new file mode 100644 index 000000000..dcddb2edb --- /dev/null +++ b/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.php @@ -0,0 +1,35 @@ + + //OPNsense/AvahiReflector + 1.0.0 + Avahi mDNS/DNS-SD reflector settings + + + 0 + Y + + + lan + Y + Y + + + local + Y + /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/ + Enter a valid domain name (e.g. "local" or "home.local"). + + + 1 + Y + + + 0 + Y + + + 1 + Y + + + 0 + Y + + + N + /^[a-zA-Z0-9_][a-zA-Z0-9._-]*$/ + + + diff --git a/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/Menu/Menu.xml b/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/Menu/Menu.xml new file mode 100644 index 000000000..c0131643b --- /dev/null +++ b/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/Menu/Menu.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/net/avahi-reflector/src/opnsense/mvc/app/views/OPNsense/AvahiReflector/index.volt b/net/avahi-reflector/src/opnsense/mvc/app/views/OPNsense/AvahiReflector/index.volt new file mode 100644 index 000000000..f2017a12c --- /dev/null +++ b/net/avahi-reflector/src/opnsense/mvc/app/views/OPNsense/AvahiReflector/index.volt @@ -0,0 +1,60 @@ +{# + # Copyright (C) 2026 cayossarian (Bill Flood) + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # 1. Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # + # 2. Redistributions in binary form must reproduce the above copyright + # notice, this list of conditions and the following disclaimer in the + # documentation and/or other materials provided with the distribution. + # + # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + # AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + # OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + # POSSIBILITY OF SUCH DAMAGE. + #} + + + +
+ {{ partial("layout_partials/base_form", ['fields': generalForm, 'id': 'frm_GeneralSettings']) }} +
+ +
+
diff --git a/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py b/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py new file mode 100755 index 000000000..39ba6cd24 --- /dev/null +++ b/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py @@ -0,0 +1,130 @@ +#!/usr/local/bin/python3 + +""" +Avahi mDNS/DNS-SD Reflector — diagnostics script. +Returns JSON status for the OPNsense dashboard widget and API. +""" + +import json +import os +import subprocess + +PID_FILE = '/var/run/avahi-daemon/pid' +CONF_FILE = '/usr/local/etc/avahi/avahi-daemon.conf' + + +def _read_pid(): + try: + with open(PID_FILE, 'r') as fh: + return int(fh.read().strip()) + except (FileNotFoundError, ValueError): + return None + + +def _process_running(pid): + try: + os.kill(pid, 0) + return True + except (OSError, TypeError): + return False + + +def _process_uptime(pid): + try: + result = subprocess.run( + ['ps', '-o', 'etime=', '-p', str(pid)], + capture_output=True, text=True, timeout=5 + ) + return result.stdout.strip() if result.returncode == 0 else None + except Exception: + return None + + +def _process_memory_mb(pid): + try: + result = subprocess.run( + ['ps', '-o', 'rss=', '-p', str(pid)], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + rss_kb = int(result.stdout.strip()) + return round(rss_kb / 1024) + except Exception: + pass + return None + + +def _parse_conf(): + conf = { + 'domain': 'local', + 'interfaces': '', + 'reflector_enabled': False, + 'use_ipv4': True, + 'use_ipv6': False, + 'reflect_ipv': False, + 'reflect_filters': '', + } + try: + with open(CONF_FILE, 'r') as fh: + for line in fh: + line = line.strip() + if line.startswith('#') or '=' not in line: + continue + key, _, val = line.partition('=') + key = key.strip() + val = val.strip() + if key == 'domain-name': + conf['domain'] = val + elif key == 'allow-interfaces': + conf['interfaces'] = val + elif key == 'enable-reflector': + conf['reflector_enabled'] = val == 'yes' + elif key == 'use-ipv4': + conf['use_ipv4'] = val == 'yes' + elif key == 'use-ipv6': + conf['use_ipv6'] = val == 'yes' + elif key == 'reflect-ipv': + conf['reflect_ipv'] = val == 'yes' + elif key == 'reflect-filters': + conf['reflect_filters'] = val + except FileNotFoundError: + pass + return conf + + +def _mdns_repeater_running(): + try: + result = subprocess.run( + ['pgrep', '-x', 'mdns-repeater'], + capture_output=True, timeout=5 + ) + return result.returncode == 0 + except Exception: + return False + + +def main(): + pid = _read_pid() + running = pid is not None and _process_running(pid) + conf = _parse_conf() + + status = { + 'running': running, + 'pid': pid if running else None, + 'uptime': _process_uptime(pid) if running else None, + 'memory_mb': _process_memory_mb(pid) if running else None, + 'domain': conf['domain'], + 'interfaces': conf['interfaces'], + 'reflector_enabled': conf['reflector_enabled'], + 'use_ipv4': conf['use_ipv4'], + 'use_ipv6': conf['use_ipv6'], + 'reflect_ipv': conf['reflect_ipv'], + 'reflect_filters': conf['reflect_filters'], + 'mdns_repeater_running': _mdns_repeater_running(), + } + + print(json.dumps(status)) + + +if __name__ == '__main__': + main() diff --git a/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf b/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf new file mode 100644 index 000000000..c8ee48d6d --- /dev/null +++ b/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf @@ -0,0 +1,29 @@ +[start] +command:/bin/sh -c "service mdns-repeater onestop 2>/dev/null; service dbus onestart 2>/dev/null; service avahi-daemon onestart 2>&1" +parameters: +type:script +message:starting avahi-daemon + +[stop] +command:/bin/sh -c "service avahi-daemon onestop 2>&1" +parameters: +type:script +message:stopping avahi-daemon + +[restart] +command:/bin/sh -c "service mdns-repeater onestop 2>/dev/null; service dbus onestart 2>/dev/null; service avahi-daemon onerestart 2>&1" +parameters: +type:script +message:restarting avahi-daemon + +[status] +command:/bin/sh -c "service avahi-daemon status 2>&1; true" +parameters: +type:script_output +message:requesting avahi-daemon status + +[diagnostics] +command:/usr/local/opnsense/scripts/OPNsense/AvahiReflector/status.py +parameters: +type:script_output +message:requesting avahi-daemon diagnostics diff --git a/net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/+TARGETS b/net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/+TARGETS new file mode 100644 index 000000000..9d4006cdd --- /dev/null +++ b/net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/+TARGETS @@ -0,0 +1,2 @@ +avahidaemon.conf:/usr/local/etc/avahi/avahi-daemon.conf +rc.conf.d:/etc/rc.conf.d/avahi_daemon diff --git a/net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/avahidaemon.conf b/net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/avahidaemon.conf new file mode 100644 index 000000000..12598753d --- /dev/null +++ b/net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/avahidaemon.conf @@ -0,0 +1,35 @@ +{% from 'OPNsense/Macros/interface.macro' import physical_interface %} +{% if OPNsense.AvahiReflector.enabled|default("0") == "1" %} +{% set intf_list = OPNsense.AvahiReflector.interfaces|default("lan") %} +{% set phys_names = [] %} +{% for intf in intf_list.split(",") %} +{% set phys = physical_interface(intf) %} +{% if phys %} +{% do phys_names.append(phys) %} +{% endif %} +{% endfor %} +[server] +domain-name={{ OPNsense.AvahiReflector.domain_name|default("local") }} +use-ipv4={{ "yes" if OPNsense.AvahiReflector.use_ipv4|default("1") == "1" else "no" }} +use-ipv6={{ "yes" if OPNsense.AvahiReflector.use_ipv6|default("0") == "1" else "no" }} +{% if phys_names %} +allow-interfaces={{ phys_names|join(",") }} +{% endif %} +check-response-ttl=no +allow-point-to-point=no + +[reflector] +enable-reflector={{ "yes" if OPNsense.AvahiReflector.enable_reflector|default("1") == "1" else "no" }} +reflect-ipv={{ "yes" if OPNsense.AvahiReflector.reflect_ipv|default("0") == "1" else "no" }} +{% if OPNsense.AvahiReflector.reflect_filters|default("") != "" %} +reflect-filters={{ OPNsense.AvahiReflector.reflect_filters }} +{% endif %} + +[rlimits] +rlimit-core=0 +rlimit-data=4194304 +rlimit-fsize=0 +rlimit-nofile=768 +rlimit-stack=4194304 +rlimit-nproc=3 +{% endif %} diff --git a/net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/rc.conf.d b/net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/rc.conf.d new file mode 100644 index 000000000..82725066e --- /dev/null +++ b/net/avahi-reflector/src/opnsense/service/templates/OPNsense/AvahiReflector/rc.conf.d @@ -0,0 +1,5 @@ +{% if OPNsense.AvahiReflector.enabled|default("0") == "1" %} +avahi_daemon_enable="YES" +{% else %} +avahi_daemon_enable="NO" +{% endif %} diff --git a/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js b/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js new file mode 100644 index 000000000..04ccfc1e7 --- /dev/null +++ b/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2026 cayossarian (Bill Flood) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +import BaseTableWidget from "./BaseTableWidget.js"; + +export default class AvahiReflector extends BaseTableWidget { + constructor() { + super(); + this.tickTimeout = 30; + } + + getGridOptions() { + return { + headerPosition: 'left' + }; + } + + async onWidgetTick() { + const response = await this.ajaxGet('/api/avahireflector/service/diagnostics'); + + if (!response || response.status === 'error') { + this.displayError(this.translations['unconfigured']); + return; + } + + const rows = []; + + const statusBadge = response.running + ? `${this.translations['running']}` + : `${this.translations['stopped']}`; + rows.push([this.translations['status'], statusBadge]); + + if (response.running) { + if (response.pid !== null) { + rows.push([this.translations['pid'], response.pid]); + } + if (response.uptime !== null) { + rows.push([this.translations['uptime'], response.uptime]); + } + if (response.memory_mb !== null) { + rows.push([this.translations['memory'], `${response.memory_mb} MB`]); + } + } + + rows.push([this.translations['domain'], response.domain || '-']); + + if (response.interfaces) { + rows.push([this.translations['interfaces'], response.interfaces]); + } + + const reflectorLabel = response.reflector_enabled + ? `${this.translations['enabled']}` + : `${this.translations['disabled']}`; + rows.push([this.translations['reflector'], reflectorLabel]); + + if (response.reflect_filters) { + rows.push([this.translations['reflect_filters'], response.reflect_filters]); + } + + if (response.mdns_repeater_running) { + rows.push([ + `${this.translations['conflict']}`, + this.translations['conflict_detail'] + ]); + } + + super.updateTable(rows); + } + + displayError(message) { + super.updateTable([[message, '']]); + } +} diff --git a/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml b/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml new file mode 100644 index 000000000..0f0f32d14 --- /dev/null +++ b/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml @@ -0,0 +1,22 @@ + + Avahi mDNS Reflector + /api/avahireflector/service/diagnostics + + Avahi mDNS Reflector + Status + Running + Stopped + PID + Uptime + Memory + Domain + Interfaces + Reflector + Enabled + Disabled + Conflict + mdns-repeater is also running — disable it to avoid interference. + Reflect Filters + Service not configured + + From 6e1e46b66a2d8333815d85de6cbc4c1ad9023c39 Mon Sep 17 00:00:00 2001 From: cayossarian Date: Thu, 12 Feb 2026 16:00:39 -0800 Subject: [PATCH 2/4] net/avahi-reflector: fix widget, conflict detection, and actions - Fix dashboard widget: use framework getMarkup/createTable pattern, ajaxCall instead of non-existent ajaxGet, correct updateTable signature with table ID, and use text-success/text-danger icons instead of non-existent label-opnsense--success CSS classes - Fix widget metadata XML structure to match framework expectations (AvahiReflector wrapper element, filename, endpoints) - Remove non-existent sessionClose() call from diagnosticsAction - Replace hardcoded mdns-repeater conflict check with dynamic port 5353 detection via sockstat - Remove silent mdns-repeater stop from start/restart actions - Add conflict and Monit guidance to Enable help text Co-Authored-By: Claude Opus 4.6 --- .../AvahiReflector/Api/ServiceController.php | 1 - .../OPNsense/AvahiReflector/forms/general.xml | 2 +- .../scripts/OPNsense/AvahiReflector/status.py | 22 +++++++--- .../actions.d/actions_avahireflector.conf | 4 +- .../opnsense/www/js/widgets/AvahiReflector.js | 42 ++++++++++-------- .../js/widgets/Metadata/AvahiReflector.xml | 44 ++++++++++--------- 6 files changed, 67 insertions(+), 48 deletions(-) diff --git a/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/ServiceController.php b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/ServiceController.php index 7d5e71718..fa0a8ffda 100644 --- a/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/ServiceController.php +++ b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/Api/ServiceController.php @@ -40,7 +40,6 @@ class ServiceController extends ApiMutableServiceControllerBase public function diagnosticsAction() { - $this->sessionClose(); $backend = new Backend(); $response = $backend->configdRun('avahireflector diagnostics'); $data = json_decode($response, true); diff --git a/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml index 08e23e98d..9e823a658 100644 --- a/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml +++ b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml @@ -7,7 +7,7 @@ avahireflector.enabled checkbox - +
Conflict: Avahi binds to UDP port 5353. If another process is already using that port (e.g. mdns-repeater), disable it before enabling Avahi. Also disable or remove any Monit check that would restart the conflicting process. A dashboard widget is available to monitor service status and detect port conflicts.

Monit: To monitor avahi-daemon with Monit, add a Process type check using PID file /var/run/avahi-daemon/pid with start command /usr/local/sbin/pluginctl -s avahireflector start and stop command /usr/local/sbin/pluginctl -s avahireflector stop.]]>
avahireflector.interfaces diff --git a/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py b/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py index 39ba6cd24..d78fd233c 100755 --- a/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py +++ b/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py @@ -92,15 +92,25 @@ def _parse_conf(): return conf -def _mdns_repeater_running(): +def _port_conflict(): + """Check for non-avahi processes bound to UDP 5353.""" try: result = subprocess.run( - ['pgrep', '-x', 'mdns-repeater'], - capture_output=True, timeout=5 + ['sockstat', '-4', '-6', '-l', '-p', '5353', '-P', 'udp'], + capture_output=True, text=True, timeout=5 ) - return result.returncode == 0 + if result.returncode != 0: + return None + conflicts = [] + for line in result.stdout.splitlines()[1:]: + fields = line.split() + if len(fields) >= 2 and fields[1] != 'avahi-daem': + name = fields[1] + if name not in conflicts: + conflicts.append(name) + return ', '.join(conflicts) if conflicts else None except Exception: - return False + return None def main(): @@ -120,7 +130,7 @@ def main(): 'use_ipv6': conf['use_ipv6'], 'reflect_ipv': conf['reflect_ipv'], 'reflect_filters': conf['reflect_filters'], - 'mdns_repeater_running': _mdns_repeater_running(), + 'port_conflict': _port_conflict(), } print(json.dumps(status)) diff --git a/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf b/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf index c8ee48d6d..80beffe41 100644 --- a/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf +++ b/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf @@ -1,5 +1,5 @@ [start] -command:/bin/sh -c "service mdns-repeater onestop 2>/dev/null; service dbus onestart 2>/dev/null; service avahi-daemon onestart 2>&1" +command:/bin/sh -c "service dbus onestart 2>/dev/null; service avahi-daemon onestart 2>&1" parameters: type:script message:starting avahi-daemon @@ -11,7 +11,7 @@ type:script message:stopping avahi-daemon [restart] -command:/bin/sh -c "service mdns-repeater onestop 2>/dev/null; service dbus onestart 2>/dev/null; service avahi-daemon onerestart 2>&1" +command:/bin/sh -c "service dbus onestart 2>/dev/null; service avahi-daemon onerestart 2>&1" parameters: type:script message:restarting avahi-daemon diff --git a/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js b/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js index 04ccfc1e7..6c60cb5db 100644 --- a/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js +++ b/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js @@ -12,7 +12,7 @@ * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * - * THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, @@ -24,8 +24,6 @@ * POSSIBILITY OF SUCH DAMAGE. */ -import BaseTableWidget from "./BaseTableWidget.js"; - export default class AvahiReflector extends BaseTableWidget { constructor() { super(); @@ -34,12 +32,22 @@ export default class AvahiReflector extends BaseTableWidget { getGridOptions() { return { - headerPosition: 'left' + sizeToContent: 650, + minW: 2 }; } + getMarkup() { + let $container = $('
'); + let $table = this.createTable('avahiReflectorTable', { + headerPosition: 'left' + }); + $container.append($table); + return $container; + } + async onWidgetTick() { - const response = await this.ajaxGet('/api/avahireflector/service/diagnostics'); + const response = await this.ajaxCall('/api/avahireflector/service/diagnostics'); if (!response || response.status === 'error') { this.displayError(this.translations['unconfigured']); @@ -48,10 +56,9 @@ export default class AvahiReflector extends BaseTableWidget { const rows = []; - const statusBadge = response.running - ? `${this.translations['running']}` - : `${this.translations['stopped']}`; - rows.push([this.translations['status'], statusBadge]); + const statusColor = response.running ? 'text-success' : 'text-danger'; + const statusText = response.running ? this.translations['running'] : this.translations['stopped']; + rows.push([this.translations['status'], ` ${statusText}`]); if (response.running) { if (response.pid !== null) { @@ -71,26 +78,25 @@ export default class AvahiReflector extends BaseTableWidget { rows.push([this.translations['interfaces'], response.interfaces]); } - const reflectorLabel = response.reflector_enabled - ? `${this.translations['enabled']}` - : `${this.translations['disabled']}`; - rows.push([this.translations['reflector'], reflectorLabel]); + const reflectorColor = response.reflector_enabled ? 'text-success' : 'text-danger'; + const reflectorText = response.reflector_enabled ? this.translations['enabled'] : this.translations['disabled']; + rows.push([this.translations['reflector'], ` ${reflectorText}`]); if (response.reflect_filters) { rows.push([this.translations['reflect_filters'], response.reflect_filters]); } - if (response.mdns_repeater_running) { + if (response.port_conflict) { rows.push([ - `${this.translations['conflict']}`, - this.translations['conflict_detail'] + ` ${this.translations['conflict']}`, + `${response.port_conflict} ${this.translations['conflict_detail']}` ]); } - super.updateTable(rows); + super.updateTable('avahiReflectorTable', rows); } displayError(message) { - super.updateTable([[message, '']]); + super.updateTable('avahiReflectorTable', [[message, '']]); } } diff --git a/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml b/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml index 0f0f32d14..c8d342107 100644 --- a/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml +++ b/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml @@ -1,22 +1,26 @@ - Avahi mDNS Reflector - /api/avahireflector/service/diagnostics - - Avahi mDNS Reflector - Status - Running - Stopped - PID - Uptime - Memory - Domain - Interfaces - Reflector - Enabled - Disabled - Conflict - mdns-repeater is also running — disable it to avoid interference. - Reflect Filters - Service not configured - + + AvahiReflector.js + + /api/avahireflector/service/diagnostics + + + Avahi mDNS Reflector + Status + Running + Stopped + PID + Uptime + Memory + Domain + Interfaces + Reflector + Enabled + Disabled + Conflict + is bound to UDP port 5353 — disable it and any Monit check for it to avoid interference. + Reflect Filters + Service not configured + + From f5d9844a1f98ab0a93638e41da55232648ccee20 Mon Sep 17 00:00:00 2001 From: cayossarian Date: Sat, 14 Feb 2026 11:33:41 -0800 Subject: [PATCH 3/4] net/avahi-reflector: add slot exhaustion auto-recovery and UI improvements Add health check script for Monit to detect avahi-daemon slot pool exhaustion and trigger automatic restarts. The reflector's hardcoded 100-slot pool for legacy unicast reflection can be exhausted by mDNS traffic bursts, causing reflected services to go offline for hours. Changes: - Add avahi_slot_check.sh: stateful syslog scanner with offset+inode tracking, exits non-zero on slot errors for Monit integration - Add healthcheck configd action for manual testing - Extend status.py with slot error summary (today's count, last error timestamp, process start time) in a new health section - Rework dashboard widget: proper BaseTableWidget API usage (getMarkup, createTable, ajaxCall), correct OPNsense CSS badge classes, health and last restart rows promoted to top of widget, PID/memory removed - Fix widget metadata XML structure to match OPNsense conventions (widget wrapper element, filename, endpoints) - Auto-show advanced mode on settings page when reflect_filters has values, so configured filters are visible without manual toggle --- .../OPNsense/AvahiReflector/forms/general.xml | 8 +- .../AvahiReflector/AvahiReflector.php | 37 +++++++++- .../AvahiReflector/AvahiReflector.xml | 2 + .../views/OPNsense/AvahiReflector/index.volt | 11 ++- .../AvahiReflector/avahi_slot_check.sh | 56 ++++++++++++++ .../scripts/OPNsense/AvahiReflector/status.py | 73 +++++++++++++++---- .../actions.d/actions_avahireflector.conf | 10 ++- .../opnsense/www/js/widgets/AvahiReflector.js | 56 +++++++++----- .../js/widgets/Metadata/AvahiReflector.xml | 9 ++- 9 files changed, 219 insertions(+), 43 deletions(-) create mode 100644 net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/avahi_slot_check.sh diff --git a/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml index 9e823a658..9389c9347 100644 --- a/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml +++ b/net/avahi-reflector/src/opnsense/mvc/app/controllers/OPNsense/AvahiReflector/forms/general.xml @@ -7,7 +7,7 @@ avahireflector.enabled checkbox -
Conflict: Avahi binds to UDP port 5353. If another process is already using that port (e.g. mdns-repeater), disable it before enabling Avahi. Also disable or remove any Monit check that would restart the conflicting process. A dashboard widget is available to monitor service status and detect port conflicts.

Monit: To monitor avahi-daemon with Monit, add a Process type check using PID file /var/run/avahi-daemon/pid with start command /usr/local/sbin/pluginctl -s avahireflector start and stop command /usr/local/sbin/pluginctl -s avahireflector stop.]]>
+
avahireflector.interfaces @@ -56,8 +56,10 @@ avahireflector.reflect_filters - text + select_multiple + + true true -
Examples: _airplay._tcp, _raop._tcp, _ipp._tcp, _http._tcp]]>
+
The .local suffix is not required — Avahi uses substring matching, so _ipp._tcp will match _ipp._tcp.local. Omitting the suffix is recommended to stay within the 239-character limit imposed by the avahi-daemon config parser.

Examples: _airplay._tcp, _raop._tcp, _ipp._tcp, _hap._tcp, _matter._tcp]]>
diff --git a/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.php b/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.php index dcddb2edb..e413d0c6f 100644 --- a/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.php +++ b/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.php @@ -1,7 +1,7 @@ \n + * + * "reflect-filters=" is 16 characters, leaving 239 for the value itself + * (256 - 16 - 1 for the trailing newline). + */ + private const REFLECT_FILTERS_MAX_LENGTH = 239; + + public function performValidation($validateFullModel = false) + { + $messages = parent::performValidation($validateFullModel); + + $filters = (string)$this->reflect_filters; + if (strlen($filters) > self::REFLECT_FILTERS_MAX_LENGTH) { + $messages->appendMessage(new Message( + sprintf( + 'Reflect filters exceed the %d-character limit imposed by the ' + . 'avahi-daemon config parser (currently %d). Remove entries or ' + . 'drop the ".local" suffix — Avahi uses substring matching so ' + . 'the suffix is not required.', + self::REFLECT_FILTERS_MAX_LENGTH, + strlen($filters) + ), + $this->reflect_filters->getInternalXMLTagName() + )); + } + + return $messages; + } } diff --git a/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.xml b/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.xml index 7fdc5abc4..bb8891022 100644 --- a/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.xml +++ b/net/avahi-reflector/src/opnsense/mvc/app/models/OPNsense/AvahiReflector/AvahiReflector.xml @@ -37,6 +37,8 @@ N /^[a-zA-Z0-9_][a-zA-Z0-9._-]*$/ + Y + Each entry must be a valid DNS-SD service type (e.g. "_ipp._tcp"). Total length must not exceed 239 characters due to the avahi-daemon config parser line-length limit of 256 bytes. diff --git a/net/avahi-reflector/src/opnsense/mvc/app/views/OPNsense/AvahiReflector/index.volt b/net/avahi-reflector/src/opnsense/mvc/app/views/OPNsense/AvahiReflector/index.volt index f2017a12c..8650076e3 100644 --- a/net/avahi-reflector/src/opnsense/mvc/app/views/OPNsense/AvahiReflector/index.volt +++ b/net/avahi-reflector/src/opnsense/mvc/app/views/OPNsense/AvahiReflector/index.volt @@ -1,5 +1,5 @@ {# - # Copyright (C) 2026 cayossarian (Bill Flood) + # Copyright (C) 2024 OPNsense Community # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -30,6 +30,15 @@ formatTokenizersUI(); $('.selectpicker').selectpicker('refresh'); updateServiceControlUI('avahireflector'); + + // Auto-show advanced fields when reflect_filters has values + const filtersVal = $('#avahireflector\\.reflect_filters').val(); + if (filtersVal && filtersVal.length > 0) { + const $toggle = $('#show_advanced_frm_GeneralSettings'); + if ($toggle.hasClass('fa-toggle-off')) { + $toggle.click(); + } + } }); $('#reconfigureAct').SimpleActionButton({ diff --git a/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/avahi_slot_check.sh b/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/avahi_slot_check.sh new file mode 100644 index 000000000..58961a163 --- /dev/null +++ b/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/avahi_slot_check.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# Monit custom check: detect avahi-daemon slot exhaustion +# Exit 0 = OK, Exit 1 = slot errors detected (Monit restarts avahi-daemon) +# +# The avahi-daemon reflector has a hardcoded 100-slot pool for legacy unicast +# reflection. Bursts of mDNS traffic can exhaust all slots, causing reflected +# services to appear offline for hours until manually restarted. + +LOG_FILE="/var/log/system/latest.log" +STATE_FILE="/var/run/avahi_slot_check.state" +PATTERN="No slot available for legacy unicast reflection" + +if [ ! -f "$LOG_FILE" ]; then + echo "OK - syslog not found" + exit 0 +fi + +CURRENT_INODE=$(stat -f %i "$LOG_FILE" 2>/dev/null) +FILE_SIZE=$(stat -f %z "$LOG_FILE" 2>/dev/null) +if [ -z "$FILE_SIZE" ] || [ -z "$CURRENT_INODE" ]; then + echo "OK - cannot stat syslog" + exit 0 +fi + +LAST_OFFSET=0 +LAST_INODE=0 +if [ -f "$STATE_FILE" ]; then + LAST_OFFSET=$(sed -n '1p' "$STATE_FILE") + LAST_INODE=$(sed -n '2p' "$STATE_FILE") + # Handle log rotation: reset if inode changed or file shrank + if [ "$CURRENT_INODE" != "$LAST_INODE" ] || [ "$FILE_SIZE" -lt "$LAST_OFFSET" ]; then + LAST_OFFSET=0 + fi +fi + +# Save current position for next run +printf '%s\n%s\n' "$FILE_SIZE" "$CURRENT_INODE" > "$STATE_FILE" + +BYTES_TO_READ=$((FILE_SIZE - LAST_OFFSET)) +if [ "$BYTES_TO_READ" -le 0 ]; then + echo "OK - No new data" + exit 0 +fi + +NEW_DATA=$(tail -c "$BYTES_TO_READ" "$LOG_FILE") +SLOT_COUNT=$(echo "$NEW_DATA" | grep -c "$PATTERN") + +if [ "$SLOT_COUNT" -gt 0 ]; then + LAST_LINE=$(echo "$NEW_DATA" | grep "$PATTERN" | tail -1) + echo "CRITICAL - $SLOT_COUNT slot exhaustion events since last check" + echo " Last: $LAST_LINE" + exit 1 +else + echo "OK - No slot errors" + exit 0 +fi diff --git a/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py b/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py index d78fd233c..b2daec6db 100755 --- a/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py +++ b/net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py @@ -7,10 +7,14 @@ Returns JSON status for the OPNsense dashboard widget and API. import json import os +import re import subprocess +from datetime import datetime PID_FILE = '/var/run/avahi-daemon/pid' CONF_FILE = '/usr/local/etc/avahi/avahi-daemon.conf' +SYSLOG_FILE = '/var/log/system/latest.log' +SLOT_ERROR_PATTERN = 'No slot available for legacy unicast reflection' def _read_pid(): @@ -92,25 +96,60 @@ def _parse_conf(): return conf -def _port_conflict(): - """Check for non-avahi processes bound to UDP 5353.""" +def _mdns_repeater_running(): try: result = subprocess.run( - ['sockstat', '-4', '-6', '-l', '-p', '5353', '-P', 'udp'], + ['pgrep', '-x', 'mdns-repeater'], + capture_output=True, timeout=5 + ) + return result.returncode == 0 + except Exception: + return False + + +def _process_start_time(pid): + try: + result = subprocess.run( + ['ps', '-o', 'lstart=', '-p', str(pid)], capture_output=True, text=True, timeout=5 ) - if result.returncode != 0: - return None - conflicts = [] - for line in result.stdout.splitlines()[1:]: - fields = line.split() - if len(fields) >= 2 and fields[1] != 'avahi-daem': - name = fields[1] - if name not in conflicts: - conflicts.append(name) - return ', '.join(conflicts) if conflicts else None + if result.returncode == 0: + raw = result.stdout.strip() + dt = datetime.strptime(raw, '%a %b %d %H:%M:%S %Y') + return dt.strftime('%Y-%m-%d %H:%M:%S') except Exception: - return None + pass + return None + + +def _slot_error_summary(): + summary = { + 'status': 'healthy', + 'slot_errors_today': 0, + 'last_slot_error': None, + } + try: + now = datetime.now() + # Syslog uses space-padded day: "Feb 4" or "Feb 14" + today_prefix = now.strftime('%b ') + '{:>2d}'.format(now.day) + with open(SYSLOG_FILE, 'r') as fh: + for line in fh: + if SLOT_ERROR_PATTERN not in line: + continue + # Extract timestamp from syslog line (e.g. "Feb 14 10:30:45") + match = re.match(r'^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})', line) + if match: + timestamp = match.group(1) + if line.startswith(today_prefix): + summary['slot_errors_today'] += 1 + summary['last_slot_error'] = timestamp + except FileNotFoundError: + pass + if summary['slot_errors_today'] > 0: + summary['status'] = 'degraded' + elif summary['last_slot_error'] is not None: + summary['status'] = 'warning' + return summary def main(): @@ -130,9 +169,13 @@ def main(): 'use_ipv6': conf['use_ipv6'], 'reflect_ipv': conf['reflect_ipv'], 'reflect_filters': conf['reflect_filters'], - 'port_conflict': _port_conflict(), + 'mdns_repeater_running': _mdns_repeater_running(), } + health = _slot_error_summary() + health['last_restart'] = _process_start_time(pid) if running else None + status['health'] = health + print(json.dumps(status)) diff --git a/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf b/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf index 80beffe41..6d270dada 100644 --- a/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf +++ b/net/avahi-reflector/src/opnsense/service/conf/actions.d/actions_avahireflector.conf @@ -1,5 +1,5 @@ [start] -command:/bin/sh -c "service dbus onestart 2>/dev/null; service avahi-daemon onestart 2>&1" +command:/bin/sh -c "service mdns-repeater onestop 2>/dev/null; service dbus onestart 2>/dev/null; service avahi-daemon onestart 2>&1" parameters: type:script message:starting avahi-daemon @@ -11,7 +11,7 @@ type:script message:stopping avahi-daemon [restart] -command:/bin/sh -c "service dbus onestart 2>/dev/null; service avahi-daemon onerestart 2>&1" +command:/bin/sh -c "service mdns-repeater onestop 2>/dev/null; service dbus onestart 2>/dev/null; service avahi-daemon onerestart 2>&1" parameters: type:script message:restarting avahi-daemon @@ -27,3 +27,9 @@ command:/usr/local/opnsense/scripts/OPNsense/AvahiReflector/status.py parameters: type:script_output message:requesting avahi-daemon diagnostics + +[healthcheck] +command:/usr/local/opnsense/scripts/OPNsense/AvahiReflector/avahi_slot_check.sh +parameters: +type:script_output +message:checking avahi-daemon slot health diff --git a/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js b/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js index 6c60cb5db..70631b89f 100644 --- a/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js +++ b/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2026 cayossarian (Bill Flood) + * Copyright (C) 2024 OPNsense Community * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -12,7 +12,7 @@ * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * - * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + * THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, @@ -32,8 +32,7 @@ export default class AvahiReflector extends BaseTableWidget { getGridOptions() { return { - sizeToContent: 650, - minW: 2 + sizeToContent: 650 }; } @@ -56,20 +55,36 @@ export default class AvahiReflector extends BaseTableWidget { const rows = []; - const statusColor = response.running ? 'text-success' : 'text-danger'; - const statusText = response.running ? this.translations['running'] : this.translations['stopped']; - rows.push([this.translations['status'], ` ${statusText}`]); + const statusBadge = response.running + ? `${this.translations['running']}` + : `${this.translations['stopped']}`; + rows.push([this.translations['status'], statusBadge]); - if (response.running) { - if (response.pid !== null) { - rows.push([this.translations['pid'], response.pid]); + if (response.health) { + const h = response.health; + let healthBadge; + if (h.status === 'healthy') { + healthBadge = `${this.translations['healthy']}`; + } else if (h.status === 'degraded') { + healthBadge = `${this.translations['degraded']}`; + } else { + healthBadge = `${this.translations['warning']}`; } - if (response.uptime !== null) { - rows.push([this.translations['uptime'], response.uptime]); + rows.push([this.translations['health'], healthBadge]); + + if (h.slot_errors_today > 0) { + rows.push([this.translations['slot_errors_today'], h.slot_errors_today]); } - if (response.memory_mb !== null) { - rows.push([this.translations['memory'], `${response.memory_mb} MB`]); + if (h.last_slot_error) { + rows.push([this.translations['last_slot_error'], h.last_slot_error]); } + if (h.last_restart) { + rows.push([this.translations['last_restart'], h.last_restart]); + } + } + + if (response.running && response.uptime !== null) { + rows.push([this.translations['uptime'], response.uptime]); } rows.push([this.translations['domain'], response.domain || '-']); @@ -78,18 +93,19 @@ export default class AvahiReflector extends BaseTableWidget { rows.push([this.translations['interfaces'], response.interfaces]); } - const reflectorColor = response.reflector_enabled ? 'text-success' : 'text-danger'; - const reflectorText = response.reflector_enabled ? this.translations['enabled'] : this.translations['disabled']; - rows.push([this.translations['reflector'], ` ${reflectorText}`]); + const reflectorLabel = response.reflector_enabled + ? `${this.translations['enabled']}` + : `${this.translations['disabled']}`; + rows.push([this.translations['reflector'], reflectorLabel]); if (response.reflect_filters) { rows.push([this.translations['reflect_filters'], response.reflect_filters]); } - if (response.port_conflict) { + if (response.mdns_repeater_running) { rows.push([ - ` ${this.translations['conflict']}`, - `${response.port_conflict} ${this.translations['conflict_detail']}` + `${this.translations['conflict']}`, + this.translations['conflict_detail'] ]); } diff --git a/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml b/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml index c8d342107..d629eea67 100644 --- a/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml +++ b/net/avahi-reflector/src/opnsense/www/js/widgets/Metadata/AvahiReflector.xml @@ -18,8 +18,15 @@ Enabled Disabled Conflict - is bound to UDP port 5353 — disable it and any Monit check for it to avoid interference. + mdns-repeater is also running - disable it to avoid interference. Reflect Filters + Health + Healthy + Degraded + Warning + Slot Errors Today + Last Slot Error + Last Restart Service not configured From 40c5000f2a4028eb9a620fc1d54533618fc6ab1f Mon Sep 17 00:00:00 2001 From: cayossarian Date: Sat, 14 Feb 2026 20:36:44 -0800 Subject: [PATCH 4/4] net/avahi-reflector: restyle widget to match OPNsense conventions Use colored circle indicator with combined status/health line instead of badge labels. Remove uptime row (redundant with last restart). Show reflector as plain text instead of highlighted badge. --- .../opnsense/www/js/widgets/AvahiReflector.js | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js b/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js index 70631b89f..384fb8a5d 100644 --- a/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js +++ b/net/avahi-reflector/src/opnsense/www/js/widgets/AvahiReflector.js @@ -55,23 +55,26 @@ export default class AvahiReflector extends BaseTableWidget { const rows = []; - const statusBadge = response.running - ? `${this.translations['running']}` - : `${this.translations['stopped']}`; - rows.push([this.translations['status'], statusBadge]); + // Status + Health on one line with colored circle indicator + let statusColor = 'text-success'; + let statusText = `${this.translations['running']} / ${this.translations['healthy']}`; + if (!response.running) { + statusColor = 'text-danger'; + statusText = this.translations['stopped']; + } else if (response.health && response.health.status === 'degraded') { + statusColor = 'text-danger'; + statusText = `${this.translations['running']} / ${this.translations['degraded']}`; + } else if (response.health && response.health.status === 'warning') { + statusColor = 'text-warning'; + statusText = `${this.translations['running']} / ${this.translations['warning']}`; + } + rows.push([ + `
${this.translations['status']}
`, + `
${statusText}
` + ]); if (response.health) { const h = response.health; - let healthBadge; - if (h.status === 'healthy') { - healthBadge = `${this.translations['healthy']}`; - } else if (h.status === 'degraded') { - healthBadge = `${this.translations['degraded']}`; - } else { - healthBadge = `${this.translations['warning']}`; - } - rows.push([this.translations['health'], healthBadge]); - if (h.slot_errors_today > 0) { rows.push([this.translations['slot_errors_today'], h.slot_errors_today]); } @@ -83,20 +86,15 @@ export default class AvahiReflector extends BaseTableWidget { } } - if (response.running && response.uptime !== null) { - rows.push([this.translations['uptime'], response.uptime]); - } - rows.push([this.translations['domain'], response.domain || '-']); if (response.interfaces) { rows.push([this.translations['interfaces'], response.interfaces]); } - const reflectorLabel = response.reflector_enabled - ? `${this.translations['enabled']}` - : `${this.translations['disabled']}`; - rows.push([this.translations['reflector'], reflectorLabel]); + rows.push([this.translations['reflector'], response.reflector_enabled + ? this.translations['enabled'] + : this.translations['disabled']]); if (response.reflect_filters) { rows.push([this.translations['reflect_filters'], response.reflect_filters]);