mirror of
https://github.com/opnsense/plugins.git
synced 2026-05-28 04:34:15 -04:00
Merge 40c5000f2a into cb9a5d6d69
This commit is contained in:
commit
c8d4f99094
20 changed files with 937 additions and 0 deletions
7
net/avahi-reflector/Makefile
Normal file
7
net/avahi-reflector/Makefile
Normal 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"
|
||||
16
net/avahi-reflector/pkg-descr
Normal file
16
net/avahi-reflector/pkg-descr
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<menu>
|
||||
<Services>
|
||||
<AvahiReflector VisibleName="Avahi mDNS Reflector" cssClass="fa fa-podcast fa-fw">
|
||||
<Settings url="/ui/avahireflector"/>
|
||||
</AvahiReflector>
|
||||
</Services>
|
||||
</menu>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
183
net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py
Executable file
183
net/avahi-reflector/src/opnsense/scripts/OPNsense/AvahiReflector/status.py
Executable 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()
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
avahidaemon.conf:/usr/local/etc/avahi/avahi-daemon.conf
|
||||
rc.conf.d:/etc/rc.conf.d/avahi_daemon
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{% if OPNsense.AvahiReflector.enabled|default("0") == "1" %}
|
||||
avahi_daemon_enable="YES"
|
||||
{% else %}
|
||||
avahi_daemon_enable="NO"
|
||||
{% endif %}
|
||||
|
|
@ -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, '']]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue