Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Alarme für Gateways, Ressourcen und Dienste:** Neue Ereignisse in Einstellungen › Benachrichtigungen. **Gateway gestört** (standardmäßig **an**) meldet ein Gateway, das OPNsense als „down“ führt oder dessen Paketverlust (ab 20 %) bzw. Latenz (ab 500 ms) über der Schwelle liegt; bewusst abgeschaltete und nicht überwachte Gateways bleiben still. **Ressourcen-Schwelle** (standardmäßig aus) prüft Festplatte (ab 90 %), Arbeitsspeicher (ab 95 %), Swap (ab 50 %) und CPU-Last (ab 2 pro Thread), **Dienst ausgefallen** (standardmäßig aus) eine einstellbare Liste (Unbound, Dnsmasq, DHCP, NTP, OpenVPN, WireGuard, IPsec …) und meldet nur Dienste, die zuvor liefen. Alle drei stützen sich auf die Minutenmessung und lösen erst aus, wenn die letzten 3 (Ressourcen: 5) Messungen übereinstimmen, und enden erst, wenn alle wieder in Ordnung sind: Einzelspitzen und flackernde Verbindungen schicken nichts. Der Gateway-Status (Verlust, Latenz, Abweichung) steht außerdem auf der Host-Seite, als Kachel auf dem Dashboard und als Prometheus-Metriken (`opnexus_host_gateway_*`, `opnexus_host_swap_used_percent`, `opnexus_host_service_running`). Die Messung speichert dafür zusätzlich Swap, CPU-Threads, Gateways und Dienste in der vorhandenen Messreihe (keine neue Tabelle). - **Weitere Benachrichtigungskanäle:** ntfy, Gotify, Telegram, Microsoft Teams (Workflows-/Incoming-Webhook) und Matrix, beliebig viele je Typ, mit „Testen“-Knopf pro Kanal. Zugangsdaten (Tokens, die Teams-Adresse) liegen wie das SMTP-Passwort in der Geheimnis-Ablage, nie in der Datenbank oder in Antworten, und tauchen nicht in Fehlermeldungen oder Logs auf. Webhook und neue Kanäle prüfen die Zieladresse: nur http(s), keine Zugangsdaten in der Adresse, keine lokalen/Link-Local-/Metadaten-Adressen; Weiterleitungen werden nicht verfolgt. Betrifft die bestehende Webhook-Adresse: `localhost`, Loopback, 169.254.x.x und Zugangsdaten in der Adresse (`https://user:pass@…`) werden jetzt abgelehnt. Eine solche gespeicherte Adresse wird nicht mehr beliefert (nur ein Log-Eintrag), und die Benachrichtigungs-Einstellungen lassen sich erst wieder speichern, wenn sie korrigiert oder geleert ist (die Meldung nennt den Grund). Neue Einstellungen kommen mit Migration 0020. - **Bestand übernehmen für Aliase und NAT:** Auf den Seiten Aliase und NAT (Port Forward, 1:1, Outbound) gibt es jetzt **Von Firewall übernehmen** wie bei den Regeln. Der Dialog zeigt, was von Hand in OPNsense angelegt wurde, einzeln zum Übernehmen oder mit „Alle übernehmbaren“ in einem Schritt; verschachtelte Aliase werden in der richtigen Reihenfolge übernommen. Die Übernahme schreibt nichts auf die Firewall: Die Vorschau zeigt direkt danach „keine Änderung“, der nächste Rollout aktualisiert dasselbe Objekt (kein Duplikat). Nicht übernommen werden OPNsense-eigene Aliase, Typen, die OPNexus nicht kennt (z. B. URL, GeoIP), Aliase mit Verweis auf nicht übernommene Aliase sowie NAT-Regeln, die das Modell verfälschen würden (z. B. „Kein Redirect“, verknüpfte Filterregel, mehrere Schnittstellen, „Kein NAT“); die Oberfläche nennt den Grund je Objekt. Outbound-NAT lässt sich nur übernehmen, wenn der Modus der Firewall Hybrid oder Manuell ist. Einstellungen, die OPNexus nicht verwaltet (Protokollierung, Pool-Optionen, Tags, Kategorien), bleiben unverändert und werden angezeigt. - **Images für ARM:** Alle Release-Images (backend, frontend, proxy, updater) gibt es jetzt für `linux/amd64` und `linux/arm64` (Raspberry Pi 4/5, ARM-Server, Apple-Silicon-Docker).
95 lines
3.8 KiB
Python
95 lines
3.8 KiB
Python
"""Destination check for URLs OPNexus is told to call (notification channels, webhook).
|
|
|
|
Admins legitimately point these at servers on their own network (a self-hosted ntfy, Gotify or
|
|
Matrix), so private ranges are allowed. What is refused is what cannot be a notification target:
|
|
anything but http(s), credentials inside the URL, and addresses that only ever mean "this machine"
|
|
or "the cloud metadata service" (loopback, link-local such as 169.254.169.254, unspecified,
|
|
multicast). Redirects are never followed by the callers, so a public host cannot bounce a request
|
|
to such an address. The hostname is resolved at send time and every address is checked (a name that
|
|
does not resolve is let through; the request then fails on its own).
|
|
"""
|
|
import asyncio
|
|
import contextlib
|
|
import contextvars
|
|
import ipaddress
|
|
import logging
|
|
import socket
|
|
from urllib.parse import urlsplit
|
|
|
|
# httpx logs every request line ("HTTP Request: POST https://api.telegram.org/bot<token>/sendMessage"). For
|
|
# targets whose URL is the credential that line must not reach the logs: callers wrap the request in muted().
|
|
_muted = contextvars.ContextVar("url_guard_muted", default=False)
|
|
|
|
|
|
class _MuteFilter(logging.Filter):
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
return not _muted.get()
|
|
|
|
|
|
for _name in ("httpx", "httpcore"):
|
|
logging.getLogger(_name).addFilter(_MuteFilter())
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def muted():
|
|
token = _muted.set(True)
|
|
try:
|
|
yield
|
|
finally:
|
|
_muted.reset(token)
|
|
|
|
|
|
def _forbidden(ip: ipaddress._BaseAddress) -> bool:
|
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
ip = ip.ipv4_mapped
|
|
return ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast
|
|
|
|
|
|
def validate_url(url: str, *, max_length: int = 500) -> str:
|
|
"""Returns the cleaned URL or raises ValueError with a short reason (shown to the admin)."""
|
|
value = (url or "").strip()
|
|
if not value or len(value) > max_length or any(c.isspace() for c in value):
|
|
raise ValueError("Die Adresse ist leer, zu lang oder enthält Leerzeichen")
|
|
try:
|
|
parts = urlsplit(value)
|
|
host = parts.hostname
|
|
parts.port # noqa: B018 - raises ValueError for an invalid port
|
|
except ValueError:
|
|
raise ValueError("Die Adresse ist ungültig") from None
|
|
if parts.scheme not in ("http", "https") or not host:
|
|
raise ValueError("Die Adresse muss mit http:// oder https:// beginnen")
|
|
if parts.username or parts.password:
|
|
raise ValueError("Zugangsdaten gehören nicht in die Adresse")
|
|
try:
|
|
literal = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
literal = None
|
|
if literal is not None and _forbidden(literal):
|
|
raise ValueError("Diese Adresse (lokal, Link-Local oder Metadaten-Dienst) ist als Ziel nicht erlaubt")
|
|
if literal is None and host.lower() in ("localhost", "localhost.localdomain"):
|
|
raise ValueError("Diese Adresse (lokal, Link-Local oder Metadaten-Dienst) ist als Ziel nicht erlaubt")
|
|
return value
|
|
|
|
|
|
async def ensure_resolved_safe(url: str) -> None:
|
|
"""Send-time check against DNS answers pointing at forbidden addresses. Raises ValueError."""
|
|
host = urlsplit(url).hostname
|
|
if not host:
|
|
raise ValueError("Die Adresse ist ungültig")
|
|
try:
|
|
ipaddress.ip_address(host)
|
|
return # a literal was already checked by validate_url
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
infos = await asyncio.get_running_loop().getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
|
except OSError:
|
|
return
|
|
for info in infos:
|
|
try:
|
|
ip = ipaddress.ip_address(info[4][0].split("%")[0])
|
|
except ValueError:
|
|
continue
|
|
if _forbidden(ip):
|
|
raise ValueError("Der Name zeigt auf eine nicht erlaubte Adresse (lokal, Link-Local oder Metadaten-Dienst)")
|