This commit is contained in:
Bill Flood 2026-05-25 09:38:17 +08:00 committed by GitHub
commit c8d4f99094
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 937 additions and 0 deletions

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,59 @@
<?php
/*
* 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.
*/
function avahireflector_services()
{
$services = [];
$mdl = new \OPNsense\AvahiReflector\AvahiReflector();
if ((string)$mdl->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;
}

View file

@ -0,0 +1,51 @@
<?php
/*
* 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.
*/
namespace OPNsense\AvahiReflector\Api;
use OPNsense\Base\ApiMutableServiceControllerBase;
use OPNsense\Core\Backend;
class ServiceController extends ApiMutableServiceControllerBase
{
protected static $internalServiceClass = '\OPNsense\AvahiReflector\AvahiReflector';
protected static $internalServiceTemplate = 'OPNsense/AvahiReflector';
protected static $internalServiceEnabled = 'enabled';
protected static $internalServiceName = 'avahireflector';
public function diagnosticsAction()
{
$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;
}
}

View file

@ -0,0 +1,37 @@
<?php
/*
* 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.
*/
namespace OPNsense\AvahiReflector\Api;
use OPNsense\Base\ApiMutableModelControllerBase;
class SettingsController extends ApiMutableModelControllerBase
{
protected static $internalModelName = 'avahireflector';
protected static $internalModelClass = '\OPNsense\AvahiReflector\AvahiReflector';
}

View file

@ -0,0 +1,38 @@
<?php
/*
* 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.
*/
namespace OPNsense\AvahiReflector;
class IndexController extends \OPNsense\Base\IndexController
{
public function indexAction()
{
$this->view->generalForm = $this->getForm('general');
$this->view->pick('OPNsense/AvahiReflector/index');
}
}

View file

@ -0,0 +1,65 @@
<form>
<field>
<type>header</type>
<label>General</label>
</field>
<field>
<id>avahireflector.enabled</id>
<label>Enable</label>
<type>checkbox</type>
<help><![CDATA[Enable the Avahi mDNS/DNS-SD reflector service. The avahi-app package is installed automatically as a plugin dependency.]]></help>
</field>
<field>
<id>avahireflector.interfaces</id>
<label>Interfaces</label>
<type>select_multiple</type>
<help><![CDATA[Select the interfaces to reflect mDNS traffic between. Avahi binds to UDP port 5353 on each selected interface. <br/><br/><b>Firewall requirement:</b> Each selected interface needs a pass rule for UDP port 5353 destined to the mDNS multicast addresses: <b>224.0.0.251</b> (IPv4) and <b>ff02::fb</b> (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. <br/><br/><b>Service traffic:</b> 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.]]></help>
</field>
<field>
<type>header</type>
<label>Protocol</label>
</field>
<field>
<id>avahireflector.domain_name</id>
<label>Domain Name</label>
<type>text</type>
<help><![CDATA[The mDNS domain name to announce. The standard mDNS top-level domain is "local" — changing this is rarely needed and may break service discovery for clients that only query .local.]]></help>
</field>
<field>
<id>avahireflector.use_ipv4</id>
<label>Use IPv4</label>
<type>checkbox</type>
<help>Enable IPv4 mDNS traffic.</help>
</field>
<field>
<id>avahireflector.use_ipv6</id>
<label>Use IPv6</label>
<type>checkbox</type>
<help>Enable IPv6 mDNS traffic.</help>
</field>
<field>
<type>header</type>
<label>Reflector</label>
</field>
<field>
<id>avahireflector.enable_reflector</id>
<label>Enable Reflector</label>
<type>checkbox</type>
<help><![CDATA[Enable the mDNS reflector to proxy mDNS/DNS-SD between interfaces. Without this, Avahi runs as a local responder only and will not forward service announcements across subnets.]]></help>
</field>
<field>
<id>avahireflector.reflect_ipv</id>
<label>Reflect across IP versions</label>
<type>checkbox</type>
<help><![CDATA[Reflect mDNS traffic between IPv4 and IPv6. This is rarely needed and requires both IPv4 and IPv6 to be enabled above.]]></help>
</field>
<field>
<id>avahireflector.reflect_filters</id>
<label>Reflect Filters</label>
<type>select_multiple</type>
<style>tokenize</style>
<allownew>true</allownew>
<advanced>true</advanced>
<help><![CDATA[DNS-SD service types to reflect. When set, only services matching an entry (via substring match) are forwarded across interfaces — all others are silently dropped. Leave empty to reflect all services. <br/><br/>The <b>.local</b> suffix is not required — Avahi uses substring matching, so <b>_ipp._tcp</b> will match <b>_ipp._tcp.local</b>. Omitting the suffix is recommended to stay within the 239-character limit imposed by the avahi-daemon config parser. <br/><br/>Examples: <b>_airplay._tcp</b>, <b>_raop._tcp</b>, <b>_ipp._tcp</b>, <b>_hap._tcp</b>, <b>_matter._tcp</b>]]></help>
</field>
</form>

View file

@ -0,0 +1,9 @@
<acl>
<page-services-avahireflector>
<name>Services: Avahi mDNS Reflector</name>
<patterns>
<pattern>ui/avahireflector/*</pattern>
<pattern>api/avahireflector/*</pattern>
</patterns>
</page-services-avahireflector>
</acl>

View file

@ -0,0 +1,70 @@
<?php
/*
* Copyright (C) 2024 OPNsense Community
* 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.
*/
namespace OPNsense\AvahiReflector;
use OPNsense\Base\BaseModel;
use OPNsense\Base\Messages\Message;
class AvahiReflector extends BaseModel
{
/**
* The avahi-daemon INI parser uses a fixed-size line buffer (256 bytes on
* FreeBSD). Lines that exceed this limit are silently truncated, causing
* the remainder to be parsed as a separate (malformed) line and the daemon
* to refuse to start. The config line is:
*
* reflect-filters=<value>\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;
}
}

View file

@ -0,0 +1,44 @@
<model>
<mount>//OPNsense/AvahiReflector</mount>
<version>1.0.0</version>
<description>Avahi mDNS/DNS-SD reflector settings</description>
<items>
<enabled type="BooleanField">
<Default>0</Default>
<Required>Y</Required>
</enabled>
<interfaces type="InterfaceField">
<Default>lan</Default>
<Required>Y</Required>
<Multiple>Y</Multiple>
</interfaces>
<domain_name type="TextField">
<Default>local</Default>
<Required>Y</Required>
<Mask>/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/</Mask>
<ValidationMessage>Enter a valid domain name (e.g. "local" or "home.local").</ValidationMessage>
</domain_name>
<use_ipv4 type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</use_ipv4>
<use_ipv6 type="BooleanField">
<Default>0</Default>
<Required>Y</Required>
</use_ipv6>
<enable_reflector type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</enable_reflector>
<reflect_ipv type="BooleanField">
<Default>0</Default>
<Required>Y</Required>
</reflect_ipv>
<reflect_filters type="CSVListField">
<Required>N</Required>
<Mask>/^[a-zA-Z0-9_][a-zA-Z0-9._-]*$/</Mask>
<MaskPerItem>Y</MaskPerItem>
<ValidationMessage>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.</ValidationMessage>
</reflect_filters>
</items>
</model>

View file

@ -0,0 +1,7 @@
<menu>
<Services>
<AvahiReflector VisibleName="Avahi mDNS Reflector" cssClass="fa fa-podcast fa-fw">
<Settings url="/ui/avahireflector"/>
</AvahiReflector>
</Services>
</menu>

View file

@ -0,0 +1,69 @@
{#
# Copyright (C) 2024 OPNsense Community
# 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.
#}
<script>
$(document).ready(function () {
mapDataToFormUI({'frm_GeneralSettings': '/api/avahireflector/settings/get'}).done(function () {
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({
onPreAction: function () {
const dfObj = new $.Deferred();
saveFormToEndpoint('/api/avahireflector/settings/set', 'frm_GeneralSettings', function () {
dfObj.resolve();
});
return dfObj;
},
onAction: function (data, status) {
updateServiceControlUI('avahireflector');
}
});
});
</script>
<div class="content-box" style="padding-bottom: 1.5em;">
{{ partial("layout_partials/base_form", ['fields': generalForm, 'id': 'frm_GeneralSettings']) }}
<div class="col-md-12">
<button class="btn btn-primary" id="reconfigureAct"
data-endpoint="/api/avahireflector/service/reconfigure"
data-label="{{ lang._('Apply') }}"
data-error-title="{{ lang._('Error reconfiguring Avahi Reflector') }}"
type="button">
</button>
</div>
</div>

View file

@ -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

View file

@ -0,0 +1,183 @@
#!/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 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():
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 _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:
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:
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():
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(),
}
health = _slot_error_summary()
health['last_restart'] = _process_start_time(pid) if running else None
status['health'] = health
print(json.dumps(status))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,35 @@
[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
[healthcheck]
command:/usr/local/opnsense/scripts/OPNsense/AvahiReflector/avahi_slot_check.sh
parameters:
type:script_output
message:checking avahi-daemon slot health

View file

@ -0,0 +1,2 @@
avahidaemon.conf:/usr/local/etc/avahi/avahi-daemon.conf
rc.conf.d:/etc/rc.conf.d/avahi_daemon

View file

@ -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 %}

View file

@ -0,0 +1,5 @@
{% if OPNsense.AvahiReflector.enabled|default("0") == "1" %}
avahi_daemon_enable="YES"
{% else %}
avahi_daemon_enable="NO"
{% endif %}

View file

@ -0,0 +1,116 @@
/*
* Copyright (C) 2024 OPNsense Community
* 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.
*/
export default class AvahiReflector extends BaseTableWidget {
constructor() {
super();
this.tickTimeout = 30;
}
getGridOptions() {
return {
sizeToContent: 650
};
}
getMarkup() {
let $container = $('<div></div>');
let $table = this.createTable('avahiReflectorTable', {
headerPosition: 'left'
});
$container.append($table);
return $container;
}
async onWidgetTick() {
const response = await this.ajaxCall('/api/avahireflector/service/diagnostics');
if (!response || response.status === 'error') {
this.displayError(this.translations['unconfigured']);
return;
}
const rows = [];
// 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([
`<div><i class="fa fa-circle ${statusColor}"></i> ${this.translations['status']}</div>`,
`<div>${statusText}</div>`
]);
if (response.health) {
const h = response.health;
if (h.slot_errors_today > 0) {
rows.push([this.translations['slot_errors_today'], h.slot_errors_today]);
}
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]);
}
}
rows.push([this.translations['domain'], response.domain || '-']);
if (response.interfaces) {
rows.push([this.translations['interfaces'], response.interfaces]);
}
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]);
}
if (response.mdns_repeater_running) {
rows.push([
`<span class="label label-opnsense label-opnsense-xs label-warning">${this.translations['conflict']}</span>`,
this.translations['conflict_detail']
]);
}
super.updateTable('avahiReflectorTable', rows);
}
displayError(message) {
super.updateTable('avahiReflectorTable', [[message, '']]);
}
}

View file

@ -0,0 +1,33 @@
<metadata>
<AvahiReflector>
<filename>AvahiReflector.js</filename>
<endpoints>
<endpoint>/api/avahireflector/service/diagnostics</endpoint>
</endpoints>
<translations>
<title>Avahi mDNS Reflector</title>
<status>Status</status>
<running>Running</running>
<stopped>Stopped</stopped>
<pid>PID</pid>
<uptime>Uptime</uptime>
<memory>Memory</memory>
<domain>Domain</domain>
<interfaces>Interfaces</interfaces>
<reflector>Reflector</reflector>
<enabled>Enabled</enabled>
<disabled>Disabled</disabled>
<conflict>Conflict</conflict>
<conflict_detail>mdns-repeater is also running - disable it to avoid interference.</conflict_detail>
<reflect_filters>Reflect Filters</reflect_filters>
<health>Health</health>
<healthy>Healthy</healthy>
<degraded>Degraded</degraded>
<warning>Warning</warning>
<slot_errors_today>Slot Errors Today</slot_errors_today>
<last_slot_error>Last Slot Error</last_slot_error>
<last_restart>Last Restart</last_restart>
<unconfigured>Service not configured</unconfigured>
</translations>
</AvahiReflector>
</metadata>