From bcb8ec0f81f4a5d835ffa332306ec4e5a18bd912 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Thu, 16 Jul 2026 22:53:57 +0200 Subject: [PATCH] update roxmenux-monitor-v2 --- AppImage/public/sw.js | 29 +++++++++----- AppImage/scripts/flask_notification_routes.py | 40 ++++++++++++++++++- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/AppImage/public/sw.js b/AppImage/public/sw.js index 324873bf..2e1d5c9e 100644 --- a/AppImage/public/sw.js +++ b/AppImage/public/sw.js @@ -8,12 +8,14 @@ // so we do NOT cache pages or API responses — caching them // would only cause stale-data bugs after an AppImage update. // -// The install / activate handlers clean up old SW caches from -// previous Monitor versions so a beta-to-stable upgrade never -// strands the browser on a cached old shell. +// The install / activate handlers wipe every cache in Cache +// Storage (no allow-list) so stale entries from any previous +// Monitor version — including ones that shared this SW's +// version name — cannot survive an upgrade and haunt the +// dashboard as frozen `/api/*` responses. // ========================================================== -const SW_VERSION = 'proxmenux-monitor-v1'; +const SW_VERSION = 'proxmenux-monitor-v2'; self.addEventListener('install', (event) => { // Take over as soon as installed; no skipWaiting handshake. @@ -22,12 +24,19 @@ self.addEventListener('install', (event) => { self.addEventListener('activate', (event) => { event.waitUntil((async () => { - // Wipe any cache name that isn't ours — survives renames / - // version bumps without piling up stale entries. - const names = await caches.keys(); - await Promise.all( - names.filter((n) => n !== SW_VERSION).map((n) => caches.delete(n)) - ); + // Scorched earth: wipe EVERY cache, regardless of name. This + // SW never writes to Cache Storage, so nothing here should + // ever be authoritative. The previous version kept caches + // that matched SW_VERSION exactly, which stranded old cached + // /api/* responses across upgrades and manifested as a + // dashboard that never refreshed until the user manually + // cleared Cache Storage from DevTools. + try { + const names = await caches.keys(); + await Promise.all(names.map((n) => caches.delete(n))); + } catch (_) { + // caches API unavailable — nothing to clean. + } await self.clients.claim(); })()); }); diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 940ebeda..cb445d4b 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -214,6 +214,41 @@ def _is_loopback_addr(value: str) -> bool: return value == 'localhost' +def _is_own_host_ip(value: str) -> bool: + """Return True when ``value`` is loopback OR an IP bound to any local iface. + + ``_pve_webhook_url()`` may register a URL that resolves to the host's + LAN/VPN IP when SSL is on and a hostname cert is loaded (issue #239). + In that case PVE POSTs to e.g. ``https://:8008`` and — on Linux — + the connection is routed to the local interface holding that IP; the + Flask socket sees the peer as the interface IP, NOT ``127.0.0.1``. The + request is still coming from THIS host, so the loopback trust path + should extend to any of this host's own interface IPs (Tailscale/Zerotier + CGNAT, WireGuard, LAN, IPv6 GUA…). Without this, PVE hits the layer 3 + ``X-ProxMenux-Timestamp`` check — a header PVE cannot inject dynamically — + and every notification target test returns ``401 missing_timestamp``. + """ + if _is_loopback_addr(value): + return True + try: + import ipaddress + import socket + import psutil + client = ipaddress.ip_address(value).compressed + for _iface, addrs in psutil.net_if_addrs().items(): + for a in addrs: + if a.family in (socket.AF_INET, socket.AF_INET6): + ip_str = a.address.split('%')[0] # strip IPv6 zone id + try: + if ipaddress.ip_address(ip_str).compressed == client: + return True + except ValueError: + continue + except Exception: + pass + return False + + def _validate_event_type(value: str) -> bool: return isinstance(value, str) and bool(_EVENT_TYPE_RE.match(value)) @@ -1407,7 +1442,10 @@ def proxmox_webhook(): _reject = lambda code, error, status: (jsonify({'accepted': False, 'error': error}), status) client_ip = request.remote_addr or '' - is_localhost = _is_loopback_addr(client_ip) + # Trust loopback AND any IP bound to a local interface — see + # `_is_own_host_ip` for the FQDN/CGNAT rationale. Layer 1 rate + # limiting still applies to every request. + is_localhost = _is_own_host_ip(client_ip) # CSRF defence-in-depth: reject `application/x-www-form-urlencoded` # bodies. PVE always sends `application/json`; form-encoded bodies