Skip to content

Commit c191abb

Browse files
committed
Add switch entity to enable/disable backend servers
1 parent 3b980f7 commit c191abb

3 files changed

Lines changed: 186 additions & 7 deletions

File tree

README.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Monitor every proxy, surface aggregated insights, and control dnsdist safely thr
5252
- **UI-only setup** with zero YAML required
5353
- **Multiple hosts**, each dnsdist endpoint becomes its own device
5454
- **Aggregated groups** with smart rollups (sum counters, average CPU, max uptime, priority security status)
55+
- **Backend monitoring** with per-backend health (binary sensor), query counters, and enable/disable switches
5556
- **Filtering rule sensors** for per-rule match counts with idle/active icons
5657
- **Dynamic rule sensors** for temporary blocks (dynblocks) from rate limiting and DoS protection
5758
- **Custom Lovelace card** with gauges, counters, filtering rules, and dynamic rules
@@ -117,10 +118,15 @@ Each host or group creates a Home Assistant device with these sensors:
117118
| `req_per_hour`, `req_per_day` | count | `MEASUREMENT` |
118119
| `security_status` | string | - |
119120

120-
Additional dynamic sensors:
121+
Additional dynamic entities (created per backend / per rule):
121122

122-
- **Filtering rule sensors** (`Filter <rule name>`) with per-rule match counts and idle/active icons
123-
- **Dynamic rule sensors** (`Dynblock <network>`) with reason, action, time remaining, and eBPF status
123+
| Entity | Type | Description |
124+
|---|---|---|
125+
| `Backend <address>` | binary sensor | Backend health (up/down) |
126+
| `Backend <address> Queries` | sensor | Per-backend query counter (`TOTAL_INCREASING`) |
127+
| `Backend <address>` | switch | Enable/disable backend via REST API |
128+
| `Filter <rule name>` | sensor | Per-rule match count with idle/active icons |
129+
| `Dynblock <network>` | sensor | Block count with reason, action, time remaining |
124130

125131
> Rate sensors are extrapolated from available history until enough data is collected (1h / 24h), then switch to actual measured values.
126132
@@ -250,9 +256,10 @@ custom_components/dnsdist/
250256
## Changelog
251257
252258
### 1.4.1
259+
- Add backend monitoring: per-backend health binary sensor, query counter sensor, and enable/disable switch
253260
- Add reconfigure flow to change host, port, API key, and SSL settings without removing the entry
254-
- Fix empty "Fetch error:" log on timeout — `TimeoutError.str()` is empty in Python; now logs a proper message
255-
- Add 10s timeout to service API calls (clear_cache, enable/disable_server, get_backends) to prevent hanging
261+
- Fix empty "Fetch error:" log on timeout, now logs a proper message
262+
- Add 10s timeout to service API calls to prevent hanging
256263
- Use consistent 10s timeout in config flow connection validation (was 5s)
257264
- Cap rolling history deque to 24 hours of samples to prevent unbounded memory growth
258265
- Add comprehensive unit tests for coordinator normalization, group aggregation, and service encoding

custom_components/dnsdist/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from . import binary_sensor # noqa: F401 # pylint: disable=unused-import
6161
from . import button # noqa: F401 # pylint: disable=unused-import
6262
from . import sensor # noqa: F401 # pylint: disable=unused-import
63+
from . import switch # noqa: F401 # pylint: disable=unused-import
6364
from .coordinator import DnsdistCoordinator
6465
from .group_coordinator import DnsdistGroupCoordinator
6566
from .services import register_dnsdist_services
@@ -234,7 +235,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
234235

235236
await coordinator.async_config_entry_first_refresh()
236237

237-
platforms = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.BUTTON]
238+
platforms = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
238239
await hass.config_entries.async_forward_entry_setups(entry, platforms)
239240

240241
# Clean up dispatcher listener for group coordinators on unload
@@ -247,7 +248,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
247248

248249
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
249250
"""Unload a dnsdist entry."""
250-
platforms = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.BUTTON]
251+
platforms = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
251252
unloaded = await hass.config_entries.async_unload_platforms(entry, platforms)
252253
if unloaded:
253254
hass.data[DOMAIN].pop(entry.entry_id, None)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Copyright (c) 2025, Renaud Allard <renaud@allard.it>
2+
# All rights reserved.
3+
#
4+
# Redistribution and use in source and binary forms, with or without
5+
# modification, are permitted provided that the following conditions are met:
6+
#
7+
# 1. Redistributions of source code must retain the above copyright notice,
8+
# this list of conditions and the following disclaimer.
9+
#
10+
# 2. Redistributions in binary form must reproduce the above copyright notice,
11+
# this list of conditions and the following disclaimer in the documentation
12+
# and/or other materials provided with the distribution.
13+
#
14+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
17+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
18+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
19+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
20+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
21+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
22+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
23+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
24+
# POSSIBILITY OF SUCH DAMAGE.
25+
26+
"""Switch entities for enabling/disabling dnsdist backend servers."""
27+
28+
from __future__ import annotations
29+
30+
import logging
31+
from typing import Any
32+
33+
from homeassistant.components.switch import SwitchEntity
34+
from homeassistant.config_entries import ConfigEntry
35+
from homeassistant.core import HomeAssistant, callback
36+
from homeassistant.helpers.device_registry import DeviceInfo
37+
from homeassistant.helpers.entity import EntityCategory
38+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
39+
from homeassistant.helpers import entity_registry as er
40+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
41+
42+
from .const import ATTR_BACKENDS, CONF_IS_GROUP, DOMAIN
43+
from .services import _call_dnsdist_api, _encode_backend_segment
44+
from .utils import build_device_info
45+
46+
_LOGGER = logging.getLogger(__name__)
47+
48+
49+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
50+
"""Set up dnsdist backend switches for a host."""
51+
coordinator = hass.data[DOMAIN][entry.entry_id]
52+
is_group = bool(entry.data.get(CONF_IS_GROUP))
53+
54+
if is_group:
55+
return
56+
57+
switch_entities: dict[str, DnsdistBackendSwitch] = {}
58+
59+
@callback
60+
def _async_sync_switches() -> None:
61+
if not coordinator.data:
62+
return
63+
backends = coordinator.data.get(ATTR_BACKENDS)
64+
if not isinstance(backends, dict):
65+
backends = {}
66+
67+
current_slugs = set(backends.keys())
68+
known_slugs = set(switch_entities.keys())
69+
70+
removed_slugs = known_slugs - current_slugs
71+
ent_reg = er.async_get(hass)
72+
for slug in removed_slugs:
73+
entity = switch_entities.pop(slug, None)
74+
if entity:
75+
if entity.entity_id and ent_reg.async_get(entity.entity_id):
76+
ent_reg.async_remove(entity.entity_id)
77+
else:
78+
hass.async_create_task(entity.async_remove())
79+
80+
new_entities: list[DnsdistBackendSwitch] = []
81+
for slug in current_slugs:
82+
if slug in switch_entities:
83+
continue
84+
entity = DnsdistBackendSwitch(
85+
coordinator=coordinator,
86+
entry_id=entry.entry_id,
87+
backend_slug=slug,
88+
)
89+
switch_entities[slug] = entity
90+
new_entities.append(entity)
91+
92+
if new_entities:
93+
async_add_entities(new_entities)
94+
95+
_async_sync_switches()
96+
entry.async_on_unload(coordinator.async_add_listener(_async_sync_switches))
97+
98+
99+
class DnsdistBackendSwitch(CoordinatorEntity, SwitchEntity):
100+
"""Switch to enable/disable a dnsdist backend server."""
101+
102+
_attr_has_entity_name = False
103+
_attr_should_poll = False
104+
_attr_entity_category = EntityCategory.CONFIG
105+
_attr_icon = "mdi:server-network"
106+
107+
def __init__(self, *, coordinator, entry_id: str, backend_slug: str) -> None:
108+
super().__init__(coordinator)
109+
self._slug = backend_slug
110+
self._attr_unique_id = f"{entry_id}:backend_switch:{backend_slug}"
111+
112+
def _backend_data(self) -> dict[str, Any]:
113+
data = self.coordinator.data or {}
114+
backends = data.get(ATTR_BACKENDS, {}) if isinstance(data, dict) else {}
115+
if isinstance(backends, dict):
116+
return backends.get(self._slug, {})
117+
return {}
118+
119+
@property
120+
def name(self) -> str:
121+
host = getattr(self.coordinator, "_name", "dnsdist")
122+
backend = self._backend_data()
123+
address = backend.get("address") or self._slug
124+
name = backend.get("name")
125+
if name:
126+
return f"{host} Backend {name}"
127+
return f"{host} Backend {address}"
128+
129+
@property
130+
def is_on(self) -> bool | None:
131+
backend = self._backend_data()
132+
if not backend:
133+
return None
134+
state = str(backend.get("state", "")).lower()
135+
# "off" means manually disabled via the API
136+
return state != "off"
137+
138+
async def async_turn_on(self, **kwargs: Any) -> None:
139+
"""Enable the backend server."""
140+
backend = self._backend_data()
141+
address = backend.get("address", "")
142+
encoded = _encode_backend_segment(address)
143+
if not encoded:
144+
_LOGGER.warning("Cannot enable backend: invalid address %s", address)
145+
return
146+
await _call_dnsdist_api(self.coordinator, "PUT", f"/api/v1/servers/{encoded}/enable")
147+
await self.coordinator.async_request_refresh()
148+
149+
async def async_turn_off(self, **kwargs: Any) -> None:
150+
"""Disable the backend server."""
151+
backend = self._backend_data()
152+
address = backend.get("address", "")
153+
encoded = _encode_backend_segment(address)
154+
if not encoded:
155+
_LOGGER.warning("Cannot disable backend: invalid address %s", address)
156+
return
157+
await _call_dnsdist_api(self.coordinator, "PUT", f"/api/v1/servers/{encoded}/disable")
158+
await self.coordinator.async_request_refresh()
159+
160+
@property
161+
def extra_state_attributes(self) -> dict[str, Any]:
162+
backend = self._backend_data()
163+
attrs: dict[str, Any] = {}
164+
for key in ("address", "name", "state", "order", "weight", "pools"):
165+
if key in backend and backend[key] is not None:
166+
attrs[key] = backend[key]
167+
return attrs
168+
169+
@property
170+
def device_info(self) -> DeviceInfo:
171+
return build_device_info(self.coordinator, False)

0 commit comments

Comments
 (0)