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
Diagnose von der Firewall aus sowie VXLAN und GIF. Gegen die OPNsense-Core-Quellen geprüft und live auf den Test-Firewalls getestet, siehe Handbuch „Erweiterung 1.14.0“: - **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung und DNS-Abfrage, ausgeführt von der ausgewählten Firewall. Nur für Admins, strenge Eingabeprüfung, jeder Lauf im Audit-Log, Ping-Jobs werden immer wieder aufgeräumt, der Banner der Gegenstelle wird nicht weitergegeben. Packet-Capture gibt es bewusst nicht (Mitschnitte enthalten fremde Nutzdaten). - **Netzwerk › Geräte:** VXLAN und GIF-Tunnel (anlegen, ändern, löschen) über dieselbe abgesicherte Engine wie die übrigen Geräte. - **Fehlerkorrektur:** Auf den Host-System-Seiten war „Übersicht › Hosts“ markiert und der Seitentitel falsch, weil `/hostsystem` mit `/hosts` beginnt.
143 lines
7.0 KiB
Python
143 lines
7.0 KiB
Python
"""Network probes run FROM a firewall: ping, traceroute, TCP port probe, DNS lookup.
|
|
|
|
Admin-only by construction (every call is a POST, which the read-only role is
|
|
refused for) and audited with the target, because a firewall that probes
|
|
arbitrary addresses on request is also a scanning tool. Inputs are checked
|
|
here before they are sent (the firewall validates them again); results are
|
|
shown and never stored. Ping runs as a short job that is always stopped and
|
|
removed again, even when polling fails.
|
|
"""
|
|
import asyncio
|
|
import ipaddress
|
|
import re
|
|
import time
|
|
|
|
from app.errors import ErrorCode, api_error
|
|
from app.models.host import OpnsenseHost
|
|
from app.services import audit_service, opnsense_client
|
|
from app.services.opnsense_client import OpnsenseAPIError, OpnsenseDiagnosticRejected
|
|
|
|
TARGET = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.:_-]{0,251}[A-Za-z0-9])?$")
|
|
# At most a few probes at a time per OPNexus process, so the page cannot be used to hammer a firewall.
|
|
_SLOTS = asyncio.Semaphore(4)
|
|
|
|
|
|
def clean_target(value: str, field: str = "target") -> str:
|
|
value = (value or "").strip()
|
|
if not TARGET.match(value):
|
|
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=field)
|
|
return value
|
|
|
|
|
|
def clean_source(value: str | None) -> str:
|
|
value = (value or "").strip()
|
|
if not value:
|
|
return ""
|
|
try:
|
|
return str(ipaddress.ip_address(value))
|
|
except ValueError:
|
|
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field="source") from None
|
|
|
|
|
|
async def _audit(host: OpnsenseHost, tool: str, target: str, ok: bool, details: dict, message: str | None = None) -> None:
|
|
await audit_service.persist(kind="diagnostic", action=f"diag.{tool}", status="ok" if ok else "failed",
|
|
entity_name=target, host_id=str(host.id), host_name=host.name, message=message, details=details)
|
|
|
|
|
|
def _rejected(exc: OpnsenseDiagnosticRejected):
|
|
field = next(iter(exc.validations), "target").split(".")[-1]
|
|
return api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=field)
|
|
|
|
|
|
async def _guarded(host: OpnsenseHost, tool: str, target: str, params: dict, call):
|
|
async with _SLOTS:
|
|
try:
|
|
result = await call()
|
|
except OpnsenseDiagnosticRejected as exc:
|
|
await _audit(host, tool, target, False, params, "rejected: " + ", ".join(exc.validations))
|
|
raise _rejected(exc) from exc
|
|
except OpnsenseAPIError as exc:
|
|
await _audit(host, tool, target, False, params, str(exc)[:300])
|
|
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
|
await _audit(host, tool, target, True, params)
|
|
return result
|
|
|
|
|
|
async def traceroute(host: OpnsenseHost, target: str, ipproto: str, protocol: str, source: str | None) -> dict:
|
|
target, source = clean_target(target), clean_source(source)
|
|
params = {"target": target, "ipproto": ipproto, "protocol": protocol, "source": source}
|
|
|
|
async def call():
|
|
data = await opnsense_client.diagnostic_set(
|
|
host, "/diagnostics/traceroute/set", "traceroute",
|
|
{"hostname": target, "ipproto": ipproto, "protocol": protocol, "source_address": source})
|
|
resp = data.get("response") or {}
|
|
hops = [{"ttl": str(r.get("ttl", "")), "address": str(r.get("address", "")), "host": str(r.get("host", "")),
|
|
"as": str(r.get("AS", "")), "probes": str(r.get("probes", ""))} for r in resp.get("rows", []) if isinstance(r, dict)]
|
|
return {"target": target, "notice": str(resp.get("notice", "")), "hops": hops}
|
|
return await _guarded(host, "traceroute", target, params, call)
|
|
|
|
|
|
async def portprobe(host: OpnsenseHost, target: str, port: int, ipproto: str, source: str | None) -> dict:
|
|
target, source = clean_target(target), clean_source(source)
|
|
params = {"target": target, "port": port, "ipproto": ipproto, "source": source}
|
|
|
|
async def call():
|
|
data = await opnsense_client.diagnostic_set(
|
|
host, "/diagnostics/portprobe/set", "portprobe",
|
|
{"hostname": target, "port": str(port), "ipproto": ipproto, "source_address": source, "source_port": "", "showtext": "0"})
|
|
message = str((data.get("response") or {}).get("message", ""))
|
|
# Never forward the remote payload (a service banner): only whether the connection worked.
|
|
return {"target": target, "port": port, "open": "succeeded" in message.lower(), "message": message}
|
|
return await _guarded(host, "portprobe", target, params, call)
|
|
|
|
|
|
async def dns_lookup(host: OpnsenseHost, name: str, server: str | None) -> dict:
|
|
name = clean_target(name, "name")
|
|
server = clean_source(server)
|
|
params = {"name": name, "server": server}
|
|
|
|
async def call():
|
|
data = await opnsense_client.diagnostic_set(host, "/diagnostics/dns_diagnostics/set", "dns", {"hostname": name, "server": server})
|
|
out = {}
|
|
for rtype, rec in (data.get("response") or {}).items():
|
|
if isinstance(rec, dict):
|
|
out[str(rtype)] = {"answers": [str(a) for a in rec.get("answers", [])], "query_time": str(rec.get("query_time", "")),
|
|
"server": str(rec.get("server", ""))}
|
|
return {"name": name, "records": out}
|
|
return await _guarded(host, "dns", name, params, call)
|
|
|
|
|
|
async def ping(host: OpnsenseHost, target: str, ipproto: str, count: int, packetsize: int | None, source: str | None) -> dict:
|
|
target, source = clean_target(target), clean_source(source)
|
|
params = {"target": target, "ipproto": ipproto, "count": count, "packetsize": packetsize, "source": source}
|
|
|
|
async def call():
|
|
job = await opnsense_client.ping_job_start(host, {
|
|
"hostname": target, "fam": "ip6" if ipproto == "inet6" else "ip", "source_address": source,
|
|
"packetsize": str(packetsize or ""), "disable_frag": "0", "interval": "1", "description": "OPNexus"})
|
|
try:
|
|
state = None
|
|
deadline = time.monotonic() + count + 6
|
|
while time.monotonic() < deadline:
|
|
await asyncio.sleep(1)
|
|
state = await opnsense_client.ping_job_state(host, job)
|
|
if state and int(state.get("send") or 0) >= count:
|
|
break
|
|
await opnsense_client.ping_job_stop(host, job)
|
|
await asyncio.sleep(0.5)
|
|
state = await opnsense_client.ping_job_state(host, job) or state
|
|
finally:
|
|
# Never leave a job running on the firewall.
|
|
try:
|
|
await opnsense_client.ping_job_stop(host, job)
|
|
await opnsense_client.ping_job_remove(host, job)
|
|
except OpnsenseAPIError:
|
|
pass
|
|
if not state:
|
|
raise OpnsenseAPIError("Ping: kein Ergebnis von der Firewall")
|
|
return {"target": target, "sent": int(state.get("send") or 0), "received": int(state.get("received") or 0),
|
|
"loss": str(state.get("loss", "")), "min": state.get("min"), "avg": state.get("avg"), "max": state.get("max"),
|
|
"stddev": state.get("std-dev"), "error": state.get("last_error")}
|
|
return await _guarded(host, "ping", target, params, call)
|