diff --git a/.env.example b/.env.example index 2b46b9f..cbdd717 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,23 @@ -# Existing FRITZ!Box user. A dedicated account is optional, not required. +# Router integration mode: auto, generic, or fritz. +# auto = use FRITZ!Box enhanced diagnostics when credentials are present, +# otherwise run as a generic Linux Internet monitor. +LINEWATCH_ROUTER_MODE=auto + +# Optional FRITZ!Box enhanced diagnostics (TR-064). +# Leave blank for generic mode. A dedicated FRITZ!Box account is optional. FRITZ_USER= FRITZ_PASSWORD= -# Leave empty to auto-detect the default IPv4 gateway. +# Optional FRITZ!Box IPv4 address. Leave blank to use the default gateway. FRITZ_HOST= -# Raspberry Pi Ethernet is normally eth0. -LINEWATCH_INTERFACE=eth0 +# Leave blank to auto-detect the interface from the Linux default route. +LINEWATCH_INTERFACE= + +# Gateway ICMP classification: auto, on, or off. +# auto disables gateway-based outage classification when the router does not +# answer ping while the Internet is otherwise healthy. +LINEWATCH_GATEWAY_PROBE=auto LINEWATCH_POLL_SECONDS=2 LINEWATCH_HEALTHY_PERSIST_SECONDS=30 @@ -17,3 +28,4 @@ LINEWATCH_RING_SECONDS=120 LINEWATCH_PING_TARGETS=1.1.1.1,8.8.8.8 LINEWATCH_DNS_NAME=www.cloudflare.com LINEWATCH_HTTP_URL=https://connectivitycheck.gstatic.com/generate_204 +LINEWATCH_PUBLIC_IP_URL=https://api.ipify.org diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5d8acc1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Report a reproducible LineWatch problem +title: "[Bug] " +labels: bug +--- + +## What happened? + +Describe the problem and what you expected instead. + +## Environment + +- Linux distribution/version: +- Architecture (`x86_64`, `aarch64`, ...): +- Wired or wireless: +- `LINEWATCH_ROUTER_MODE`: +- Router model: +- FRITZ!OS version, if applicable: +- LineWatch version/commit: + +## Reproduction steps + +1. +2. +3. + +## Relevant logs + +Please remove credentials, public IP addresses and other private network data before posting logs. + +```text +journalctl -u linewatch --since "10 minutes ago" +``` diff --git a/.github/ISSUE_TEMPLATE/compatibility-report.md b/.github/ISSUE_TEMPLATE/compatibility-report.md new file mode 100644 index 0000000..4ba33c2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility-report.md @@ -0,0 +1,33 @@ +--- +name: Compatibility report +about: Report a host/router combination that works or needs support +title: "Compatibility: " +labels: "" +assignees: "" +--- + +## Host + +- OS/distribution: +- Architecture: +- Installation method: +- Ethernet or Wi-Fi: + +## Router + +- Vendor: +- Model: +- Firmware/version: +- Mode: generic / FRITZ!Box + +## Result + +- Basic Internet monitoring works: yes/no +- Gateway monitoring works: yes/no +- Router telemetry works: yes/no/not applicable +- Reboot detection works: yes/no/not applicable +- WAN/session reset detection works: yes/no/not applicable + +## Notes + +Please remove credentials, public IP addresses and private network data before posting logs or screenshots. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..9681e9a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature request +about: Suggest an improvement or router integration +title: "[Feature] " +labels: enhancement +--- + +## Problem / use case + +What problem would this solve? + +## Proposed behavior + +Describe the smallest useful version of the feature. + +## Environment or router, if relevant + +- Linux distribution: +- Router/vendor/model: +- Available local API or protocol (TR-064, ubus, SNMP, RouterOS API, etc.), if known: + +## Notes + +For new router integrations, prefer an adapter that keeps the generic monitoring core vendor-neutral. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..019a375 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## What changed + +Describe the problem and the smallest useful change. + +## Validation + +Describe the host/router combination and checks used to validate the change. + +## Checklist + +- [ ] I did not include credentials, public IP addresses or private network data. +- [ ] Generic monitoring still works without vendor-specific telemetry where applicable. +- [ ] User-facing changes are documented. +- [ ] Compatibility claims are limited to what was actually validated. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cac1b9..cc40160 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: pull_request: jobs: - syntax: + test: runs-on: ubuntu-latest strategy: matrix: @@ -15,4 +15,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - run: python -m py_compile monitor.py dashboard.py + - name: Install dependencies + run: python -m pip install -r requirements.txt + - name: Compile Python sources + run: python -m py_compile monitor.py dashboard.py + - name: Run unit tests + run: python -m unittest discover -s tests -v + - name: Check shell scripts + run: bash -n install.sh configure.sh run_monitor.sh run_dashboard.sh diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000..e08d2da --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,13 @@ +# Authors + +LineWatch was created by **Luca Serioli** ([@LucaXTech](https://github.com/LucaXTech)). + +## Original author and maintainer + +- Luca Serioli — project creator, original architecture and maintainer + +## Contributors + +Community contributions are welcome and remain attributed through the Git history and GitHub contributor graph. + +LineWatch is released under the MIT License; see [LICENSE](LICENSE). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6f393e3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing to LineWatch + +Thanks for helping improve LineWatch. + +The project aims to keep a small, dependable generic Internet-monitoring core while adding deeper router diagnostics through optional adapters. + +## Useful contributions + +Especially useful contributions include: + +- Linux distribution and hardware compatibility reports +- FRITZ!Box model / FRITZ!OS compatibility reports +- reproducible bug fixes +- unit and integration tests +- documentation improvements +- router adapters that preserve the vendor-neutral core + +## Before opening a pull request + +1. Open or reference an issue when the change is substantial. +2. Keep credentials, public IP addresses, event logs and other private network data out of commits. +3. Keep changes focused; avoid unrelated formatting or refactors in the same PR. +4. Run: + +```bash +python -m unittest discover -s tests -v +python -m py_compile monitor.py dashboard.py +bash -n install.sh configure.sh run_monitor.sh run_dashboard.sh +``` + +5. Explain how the change was tested. + +## Router integrations + +New vendor integrations should not make the generic monitor depend on that vendor. Prefer an adapter boundary that exposes optional router/WAN telemetry to the existing classifier and dashboard. + +Do not claim compatibility with hardware you have not tested or for which there is no reliable external report. + +## Compatibility reports + +Please include: + +- Linux distribution and version +- CPU architecture +- wired or wireless connection +- router model +- router firmware, if relevant +- generic or enhanced mode +- LineWatch version/commit +- what worked and what failed + +See [docs/TESTING.md](docs/TESTING.md) for the validation checklist. + +## License + +By contributing, you agree that your contribution will be distributed under the project's MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e3d37d3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Luca Serioli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 60a7f60..7d178d1 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,17 @@ [![CI](https://github.com/LucaXTech/LineWatch/actions/workflows/ci.yml/badge.svg)](https://github.com/LucaXTech/LineWatch/actions/workflows/ci.yml) ![Python](https://img.shields.io/badge/Python-3.11%2B-blue) -![Raspberry Pi](https://img.shields.io/badge/Raspberry%20Pi-tested-c51a4a) -![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success) +![Linux](https://img.shields.io/badge/Linux-self--hosted-success) +![Raspberry Pi](https://img.shields.io/badge/Raspberry%20Pi-recommended-c51a4a) +![License](https://img.shields.io/badge/license-MIT-green) -**Self-hosted Internet connection monitor for Raspberry Pi and FRITZ!Box routers.** +> **Know what actually went down.** -LineWatch turns a Raspberry Pi into a 24/7 **Internet connection monitor**, **network outage monitor** and small diagnostic black box. It continuously checks router reachability, Internet connectivity, latency, DNS and HTTP availability, while reading FRITZ!Box telemetry over TR-064 to distinguish a **router reboot** from a **WAN/PPPoE reset** or a generic ISP outage. +**LineWatch is a self-hosted Internet connection black box for Linux.** It continuously records gateway reachability, Internet connectivity, latency, DNS, HTTP availability, public-IP changes, outages and downtime. -It is useful when you need objective evidence for intermittent Internet problems: random modem reboots, short disconnections, unstable PPPoE sessions, latency spikes, DNS failures or recurring ISP outages that are difficult to reproduce while support is looking at the line. +It works as a **generic Linux connection monitor with ordinary routers** and becomes more diagnostic when connected to a **FRITZ!Box**: TR-064 telemetry lets LineWatch distinguish a real router reboot from a WAN/PPPoE session reset or a wider ISP outage. -The project was developed and validated on a **FRITZ!Box 5530 Fiber running FRITZ!OS 8.20**, but the core TR-064 services used by LineWatch (`DeviceInfo` and `WANPPPConnection` / `WANIPConnection`) are common across many FRITZ!Box models. +A Raspberry Pi is a convenient always-on deployment target, **not a requirement**. ## Screenshots @@ -25,86 +26,112 @@ The project was developed and validated on a **FRITZ!Box 5530 Fiber running FRIT LineWatch mobile dashboard

-## Typical use cases +> The screenshots currently show the FRITZ!Box-enhanced dashboard. In generic mode, FRITZ-specific cards are replaced by gateway and generic connectivity diagnostics. -- monitor an Internet connection 24/7 from a Raspberry Pi -- detect modem/router reboots automatically -- record Internet outages and total downtime -- monitor PPPoE disconnects and WAN-session resets -- measure home-network and ISP latency over time -- collect evidence before opening an ISP support ticket -- keep a self-hosted connection uptime monitor on the local network -- remotely check connection health through a private VPN such as Tailscale +## Why LineWatch? -### Italiano +A normal uptime check can tell you that a target stopped answering. That is useful, but it often does not tell you **which part of a home Internet connection failed**. -LineWatch serve per **monitorare la connessione Internet 24/7 con Raspberry Pi**, rilevare **riavvii del modem**, **disconnessioni Internet**, reset **PPPoE**, problemi DNS/HTTP, latenza e downtime. Con FRITZ!Box usa TR-064 per capire se è realmente ripartito il router oppure se è caduta soltanto la sessione Internet. +LineWatch combines multiple signals and keeps the evidence locally: + +- Linux network-link state when available +- default-gateway reachability +- multiple Internet ICMP targets +- DNS resolution +- HTTP connectivity +- public IP changes +- outage duration and availability +- optional router/WAN telemetry + +This is particularly useful for short or intermittent faults that disappear before ISP support looks at the line. + +## Two operating modes + +| Capability | Generic Linux | FRITZ!Box enhanced | +| --- | :---: | :---: | +| Internet reachability | ✅ | ✅ | +| DNS / HTTP checks | ✅ | ✅ | +| Latency history | ✅ | ✅ | +| Gateway monitoring | ✅ | ✅ | +| Public IP changes | ✅ | ✅ | +| Outage / downtime history | ✅ | ✅ | +| ISP report + CSV export | ✅ | ✅ | +| Router model / firmware | — | ✅ | +| Router uptime / reboot detection | — | ✅ | +| WAN-session uptime | — | ✅ | +| WAN / PPPoE reset detection | — | ✅ | +| FRITZ!Box event log around incidents | — | ✅ | + +`LINEWATCH_ROUTER_MODE=auto` selects FRITZ!Box enhanced mode when credentials are configured; otherwise it runs generically. ## What it detects -- FRITZ!Box reboot via router uptime reset -- WAN / PPPoE session reset without router reboot -- router unreachable -- physical Ethernet link down -- Internet unreachable while the router is reachable -- DNS failure -- HTTP connectivity failure -- WAN IP changes -- latency trends and packet-level reachability +### On any supported Linux host + +- local network-link loss when exposed by Linux sysfs +- gateway reachability changes +- complete Internet loss +- DNS failures +- HTTP connectivity failures +- public IP changes +- latency trends +- outage duration, total downtime and observed-period availability + +LineWatch does **not** assume that every router or Internet path answers ICMP. In automatic gateway-probe mode, a router that drops ping while DNS/HTTP remain healthy is not incorrectly classified as down. -It also stores FRITZ!Box event logs around incidents when available. +### With FRITZ!Box telemetry + +- FRITZ!Box reboot through router-uptime reset +- WAN / PPPoE session reset without a router reboot +- WAN connection state +- WAN IP and transport details +- PPPoE access concentrator when exposed +- FRITZ!Box device logs around incidents when available ## Dashboard The responsive local dashboard provides: -- current connection status -- router and WAN uptime -- estimated router boot time -- current latency plus 24 h min / average / P95 / max -- reboot, WAN reset and outage counters +- current connection health +- automatic generic / FRITZ-enhanced presentation +- latency with 24 h min / average / P95 / max +- outage counters and downtime statistics - observed-period availability -- downtime and outage duration statistics - event timeline - Italian / English UI -- CSV export +- CSV event export - human-readable ISP diagnostic report -The availability percentage is calculated only over the time LineWatch has actually been monitoring, rather than pretending that a new installation already has 30 days of observations. +Availability is calculated only over the period LineWatch has actually observed. A new installation does not pretend to have 30 days of monitoring history. + +## Where it can run + +The monitor is designed for an **always-on Linux host**. Good deployment targets include: + +- Raspberry Pi +- Debian / Ubuntu mini-PC +- home server +- Linux VM +- other ARM or x86 Linux systems with the required networking tools -## Compatibility +For meaningful line diagnostics, an **Ethernet-connected always-on machine is recommended**. Wi-Fi can work, but then local wireless problems become part of what LineWatch observes. -### Full functionality +The automatic installer currently targets systems with `apt` and `systemd`, including Debian, Ubuntu and Raspberry Pi OS. Other Linux distributions can use the manual setup path once Python, `iproute2` and `ping` are available. -LineWatch is intended for **FRITZ!Box routers with TR-064 enabled** and an account allowed to access FRITZ!Box settings. +## Compatibility status -Tested: +The original release was developed and validated on: - FRITZ!Box 5530 Fiber - FRITZ!OS 8.20 - Raspberry Pi 3 - Raspberry Pi OS Lite 64-bit -Other FRITZ!Box models should work when they expose the standard `DeviceInfo` service and an active `WANPPPConnection` or `WANIPConnection` service. - -### Other modem/router brands - -The network probes themselves are generic, but the current release is **not a universal modem monitor**. Reboot detection, WAN-session telemetry and router event logs depend on FRITZ!Box TR-064. Supporting other vendors would require vendor-specific adapters. - -## FRITZ!Box setup - -In the FRITZ!Box interface: - -1. Enable local application access / TR-064 under the local-network settings. -2. Use a FRITZ!Box user with permission to access FRITZ!Box settings. - -**You do not need to create a new user specifically for LineWatch.** An existing account works. A dedicated account is optional if you prefer separate credentials for the monitor. - -Remote/Internet access for that FRITZ!Box account is not required. +Generic Linux support is being expanded conservatively. Compatibility reports from other Linux machines and FRITZ!Box models are especially useful; see [CONTRIBUTING.md](CONTRIBUTING.md). ## Quick install -Recommended: Raspberry Pi OS Lite, connected to the FRITZ!Box by **Ethernet**. +Recommended: an always-on Debian/Ubuntu/Raspberry Pi OS machine connected by Ethernet. ```bash git clone https://github.com/LucaXTech/LineWatch.git @@ -113,33 +140,70 @@ chmod +x install.sh ./install.sh ``` -The installer asks for the existing FRITZ!Box username and password, creates a private `.env`, installs the Python environment and registers both systemd services. +The installer asks whether you want FRITZ!Box enhanced diagnostics. + +### Generic mode + +Choose **No** when asked about FRITZ!Box integration. No router credentials are required. + +LineWatch will monitor the Linux default gateway and external connectivity probes. -Then open: +### FRITZ!Box enhanced mode + +Choose **Yes** and provide a FRITZ!Box account allowed to access router settings through TR-064. + +A dedicated account is optional. Remote/Internet access for that account is not required. + +After installation, open: ```text http://linewatch.local:8080 ``` -If mDNS is unavailable, use the Raspberry Pi's LAN IP with port `8080`. +If mDNS is unavailable, use the Linux host's LAN IP with port `8080`. ## Manual configuration -Copy: - ```bash cp .env.example .env chmod 600 .env ``` -At minimum set: +The most relevant options are: ```text -FRITZ_USER=your-user -FRITZ_PASSWORD=your-password +LINEWATCH_ROUTER_MODE=auto +LINEWATCH_INTERFACE= +LINEWATCH_GATEWAY_PROBE=auto + +FRITZ_USER= +FRITZ_PASSWORD= +FRITZ_HOST= ``` -`FRITZ_HOST` may be left empty: LineWatch uses the default IPv4 gateway automatically. +### Router modes + +`auto` +: Use FRITZ!Box telemetry when credentials are present; otherwise generic mode. + +`generic` +: Never use router-specific telemetry. + +`fritz` +: Require FRITZ!Box credentials and enable TR-064 diagnostics. + +### Gateway probe modes + +`auto` +: Learn whether the gateway responds to ICMP. If Internet/DNS/HTTP work while gateway ping does not, gateway ICMP is excluded from outage classification. + +`on` +: Always use gateway ICMP for incident classification. + +`off` +: Never use gateway ICMP to classify an outage. + +Leaving `LINEWATCH_INTERFACE` empty lets LineWatch detect the interface associated with the Linux IPv4 default route. ## Services @@ -156,48 +220,77 @@ journalctl -u linewatch -f ## Remote access -The dashboard deliberately has no public-Internet authentication layer. Do **not** expose port 8080 with router port forwarding. +The dashboard deliberately has no public-Internet authentication layer. **Do not expose port 8080 with router port forwarding.** -For private remote access, a mesh VPN such as Tailscale is a good fit: - -```bash -curl -fsSL https://tailscale.com/install.sh | sh -sudo tailscale up -tailscale ip -4 -``` - -Then open `http://:8080` from another device in the same tailnet. +For private remote access, use a VPN or mesh VPN such as Tailscale. ## Data and privacy -Runtime data stays on the Raspberry Pi: +Runtime data stays on the machine running LineWatch: - `data/linewatch.sqlite3` — SQLite database -- `data/events/` — incident bundles and FRITZ!Box logs +- `data/events/` — incident bundles and optional FRITZ!Box logs -The repository ignores `.env`, runtime databases and logs. Do not commit real router credentials, event logs, WAN/public IP addresses or personal network data. +The repository ignores `.env`, runtime databases and logs. Do not commit real router credentials, event logs, public IP addresses or personal network data. ## Architecture ```text -FRITZ!Box - │ - ├── Ethernet carrier / gateway ping - ├── TR-064: uptime, WAN state, WAN IP, device log - │ -Raspberry Pi ── Internet probes - │ ├── ICMP - │ ├── DNS - │ └── HTTP connectivity - │ - ├── SQLite event store - └── Web dashboard :8080 + LineWatch Core + │ + ┌────────────────┼────────────────┐ + │ │ │ + Linux / gateway Internet probes Router adapter + │ ICMP · DNS · HTTP │ + │ │ └── FRITZ!Box / TR-064 + │ │ + └────────────────┴───────────────┐ + │ + Incident classifier + │ + SQLite + event bundles + │ + Web dashboard :8080 ``` -## Notes +The router integration boundary is intentionally narrow so additional router adapters can be added in the future without replacing the generic monitoring core. + +Potential future integrations include OpenWrt, MikroTik, UniFi and standards-based telemetry where reliable interfaces exist. They are **not currently advertised as supported**. + +## Development + +CI currently checks Python 3.11 and 3.13, compiles the monitor/dashboard, runs unit tests and validates the shell scripts. + +Run tests locally with: + +```bash +python -m unittest discover -s tests -v +``` + +## Contributing + +Bug reports, Linux compatibility results, FRITZ!Box model reports, tests and router-adapter contributions are welcome. + +Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. + +If you test LineWatch on different hardware, include: + +- Linux distribution and version +- architecture (`x86_64`, `aarch64`, etc.) +- wired or wireless connection +- router model +- whether generic or FRITZ-enhanced mode was used + +## Author + +Created and maintained by **Luca Serioli** ([@LucaXTech](https://github.com/LucaXTech)). + +See [AUTHORS.md](AUTHORS.md) for project attribution and contributor information. + +## License -Some FRITZ!Box models expose additional vendor-specific TR-064 services. For example, the tested 5530 Fiber exposes `X_AVM-DE_WANFiber`, but on the tested firmware its optical values were not populated. LineWatch therefore does not rely on those values for incident classification. +LineWatch is open-source software released under the **MIT License**. See [LICENSE](LICENSE). ## Status -Early public release. The monitor is already useful for long-running home/ISP diagnostics, but more FRITZ!Box models should be validated before claiming universal FRITZ!Box compatibility. +Active early-stage open-source project. The goal is to keep the generic monitoring core small and dependable while adding deeper router diagnostics through optional adapters. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0135ec5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security + +LineWatch is designed to run on a trusted local network. + +## Dashboard exposure + +The dashboard does not provide a public-Internet authentication layer. Do not expose port `8080` directly through router port forwarding. + +For remote access, use a private VPN or mesh VPN. + +## Sensitive data + +Never commit or publish: + +- FRITZ!Box credentials +- `.env` files containing secrets +- unredacted router event logs +- public IP addresses tied to a private installation +- other personal network information + +## Reporting a vulnerability + +If a security issue can be described without exposing user secrets, open a GitHub issue with enough information to reproduce it and clearly mark it as security-related. + +For reports that necessarily contain sensitive information, do not post the sensitive material publicly; contact the maintainer through an appropriate private channel listed on the maintainer's GitHub profile. diff --git a/configure.sh b/configure.sh old mode 100644 new mode 100755 index 1199c3e..a9cef79 --- a/configure.sh +++ b/configure.sh @@ -6,37 +6,53 @@ cd "$APP_DIR" echo "LineWatch configuration" echo -echo "Use any existing FRITZ!Box user that has permission to access FRITZ!Box settings." -echo "Creating a dedicated user is optional." +echo "LineWatch can run with any ordinary Linux Internet connection." +echo "FRITZ!Box credentials are only needed for enhanced TR-064 diagnostics" +echo "such as router reboot and WAN/PPPoE session reset detection." echo -read -r -p "FRITZ!Box username: " FRITZ_USER_INPUT -if [ -z "$FRITZ_USER_INPUT" ]; then - echo "Username cannot be empty." - exit 1 -fi +read -r -p "Enable FRITZ!Box enhanced diagnostics? [y/N]: " FRITZ_ENABLE +FRITZ_ENABLE="${FRITZ_ENABLE:-N}" -read -r -s -p "FRITZ!Box password: " FRITZ_PASSWORD_INPUT -echo -if [ -z "$FRITZ_PASSWORD_INPUT" ]; then - echo "Password cannot be empty." - exit 1 -fi +MODE="generic" +FRITZ_USER_INPUT="" +FRITZ_PASSWORD_INPUT="" +FRITZ_HOST_INPUT="" -read -r -p "FRITZ!Box IPv4 address [auto-detect]: " FRITZ_HOST_INPUT +case "$FRITZ_ENABLE" in + y|Y|yes|YES|Yes) + MODE="fritz" + read -r -p "FRITZ!Box username: " FRITZ_USER_INPUT + if [ -z "$FRITZ_USER_INPUT" ]; then + echo "Username cannot be empty in FRITZ mode." + exit 1 + fi -python3 - "$FRITZ_USER_INPUT" "$FRITZ_PASSWORD_INPUT" "$FRITZ_HOST_INPUT" <<'PY' + read -r -s -p "FRITZ!Box password: " FRITZ_PASSWORD_INPUT + echo + if [ -z "$FRITZ_PASSWORD_INPUT" ]; then + echo "Password cannot be empty in FRITZ mode." + exit 1 + fi + + read -r -p "FRITZ!Box IPv4 address [default gateway]: " FRITZ_HOST_INPUT + ;; +esac + +python3 - "$MODE" "$FRITZ_USER_INPUT" "$FRITZ_PASSWORD_INPUT" "$FRITZ_HOST_INPUT" <<'PY' import shlex import sys from pathlib import Path -user, password, host = sys.argv[1:4] +mode, user, password, host = sys.argv[1:5] path = Path(".env") defaults = { + "LINEWATCH_ROUTER_MODE": mode, "FRITZ_USER": user, "FRITZ_PASSWORD": password, "FRITZ_HOST": host, - "LINEWATCH_INTERFACE": "eth0", + "LINEWATCH_INTERFACE": "", + "LINEWATCH_GATEWAY_PROBE": "auto", "LINEWATCH_POLL_SECONDS": "2", "LINEWATCH_HEALTHY_PERSIST_SECONDS": "30", "LINEWATCH_FRITZ_POLL_SECONDS": "10", @@ -45,6 +61,7 @@ defaults = { "LINEWATCH_PING_TARGETS": "1.1.1.1,8.8.8.8", "LINEWATCH_DNS_NAME": "www.cloudflare.com", "LINEWATCH_HTTP_URL": "https://connectivitycheck.gstatic.com/generate_204", + "LINEWATCH_PUBLIC_IP_URL": "https://api.ipify.org", } with path.open("w", encoding="utf-8") as f: for key, value in defaults.items(): @@ -54,3 +71,8 @@ PY chmod 600 .env echo echo "Configuration saved to $APP_DIR/.env" +if [ "$MODE" = "fritz" ]; then + echo "Mode: FRITZ!Box enhanced diagnostics" +else + echo "Mode: generic Linux Internet monitoring" +fi diff --git a/dashboard.py b/dashboard.py index 95f25f3..7c1df64 100644 --- a/dashboard.py +++ b/dashboard.py @@ -14,18 +14,22 @@ app = Flask(__name__) + @app.after_request def disable_browser_cache(response): - # LineWatch is a live dashboard. Avoid stale HTML/inline-JS and stale JSON - # after software updates, especially on mobile browsers. response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response + +# Keep the v1.0 event names for backwards-compatible history while v1.1 uses +# router-agnostic names for newly recorded generic Linux incidents. OUTAGE_TYPES = { "ETHERNET_LINK_DOWN", "ROUTER_UNREACHABLE", + "NETWORK_LINK_DOWN", + "GATEWAY_UNREACHABLE", "WAN_SESSION_DOWN", "INTERNET_UNREACHABLE", "DNS_FAILURE", @@ -33,11 +37,13 @@ def disable_browser_cache(response): } LABELS_IT = { - "FRITZBOX_REBOOT_DETECTED": "Riavvio modem rilevato", - "WAN_SESSION_RESET_DETECTED": "Reset connessione Internet (PPPoE)", - "WAN_IP_CHANGED": "Cambio IP WAN", + "FRITZBOX_REBOOT_DETECTED": "Riavvio FRITZ!Box rilevato", + "WAN_SESSION_RESET_DETECTED": "Reset sessione WAN/PPPoE", + "WAN_IP_CHANGED": "Cambio IP WAN/pubblico", "ETHERNET_LINK_DOWN": "Collegamento Ethernet caduto", - "ROUTER_UNREACHABLE": "FRITZ!Box non raggiungibile", + "ROUTER_UNREACHABLE": "Router non raggiungibile", + "NETWORK_LINK_DOWN": "Collegamento di rete caduto", + "GATEWAY_UNREACHABLE": "Gateway non raggiungibile", "WAN_SESSION_DOWN": "Sessione Internet disconnessa", "INTERNET_UNREACHABLE": "Internet non raggiungibile", "DNS_FAILURE": "Problema DNS", @@ -45,11 +51,13 @@ def disable_browser_cache(response): } LABELS_EN = { - "FRITZBOX_REBOOT_DETECTED": "Modem reboot detected", - "WAN_SESSION_RESET_DETECTED": "Internet session reset (PPPoE)", - "WAN_IP_CHANGED": "WAN IP changed", + "FRITZBOX_REBOOT_DETECTED": "FRITZ!Box reboot detected", + "WAN_SESSION_RESET_DETECTED": "WAN/PPPoE session reset", + "WAN_IP_CHANGED": "WAN/public IP changed", "ETHERNET_LINK_DOWN": "Ethernet link down", - "ROUTER_UNREACHABLE": "FRITZ!Box unreachable", + "ROUTER_UNREACHABLE": "Router unreachable", + "NETWORK_LINK_DOWN": "Network link down", + "GATEWAY_UNREACHABLE": "Gateway unreachable", "WAN_SESSION_DOWN": "Internet session disconnected", "INTERNET_UNREACHABLE": "Internet unreachable", "DNS_FAILURE": "DNS failure", @@ -88,14 +96,14 @@ def since_iso(days): ).astimezone().isoformat(timespec="seconds") -def event_row(r): +def event_row(row): return { - "id": r["id"], - "start_ts": r["start_ts"], - "end_ts": r["end_ts"], - "duration_s": r["duration_s"], - "duration_human": fmt_duration(r["duration_s"]), - "event_type": r["event_type"], + "id": row["id"], + "start_ts": row["start_ts"], + "end_ts": row["end_ts"], + "duration_s": row["duration_s"], + "duration_human": fmt_duration(row["duration_s"]), + "event_type": row["event_type"], } @@ -114,11 +122,14 @@ def percentile(sorted_values, p): if len(sorted_values) == 1: return sorted_values[0] k = (len(sorted_values) - 1) * p - f = int(k) - c = min(f + 1, len(sorted_values) - 1) - if f == c: - return sorted_values[f] - return sorted_values[f] * (c - k) + sorted_values[c] * (k - f) + floor_index = int(k) + ceil_index = min(floor_index + 1, len(sorted_values) - 1) + if floor_index == ceil_index: + return sorted_values[floor_index] + return ( + sorted_values[floor_index] * (ceil_index - k) + + sorted_values[ceil_index] * (k - floor_index) + ) def latency_stats(conn, hours=24): @@ -134,7 +145,7 @@ def latency_stats(conn, hours=24): """, (since,), ).fetchall() - vals = sorted(float(r["internet_ms"]) for r in rows if r["internet_ms"] is not None) + vals = sorted(float(row["internet_ms"]) for row in rows if row["internet_ms"] is not None) if not vals: return {"min": None, "avg": None, "max": None, "p95": None, "samples": 0} return { @@ -146,6 +157,23 @@ def latency_stats(conn, hours=24): } +def fritz_telemetry_present(sample): + if sample is None: + return False + return any( + sample[key] not in (None, "") + for key in ( + "router_model", + "fritzos", + "router_uptime_s", + "wan_status", + "wan_uptime_s", + "wan_ip", + "fritz_error", + ) + ) + + @app.route("/") def index(): return render_template("index.html") @@ -157,26 +185,19 @@ def api_status(): return jsonify({"ready": False, "reason": "Database not created yet."}) conn = db() - sample = conn.execute( - "SELECT * FROM samples ORDER BY id DESC LIMIT 1" - ).fetchone() + sample = conn.execute("SELECT * FROM samples ORDER BY id DESC LIMIT 1").fetchone() if sample is None: conn.close() return jsonify({"ready": False, "reason": "No samples available yet."}) - first_sample = conn.execute( - "SELECT ts FROM samples ORDER BY id ASC LIMIT 1" - ).fetchone() + first_sample = conn.execute("SELECT ts FROM samples ORDER BY id ASC LIMIT 1").fetchone() first_sample_dt = parse_iso(first_sample["ts"]) if first_sample else None now_dt = datetime.now(timezone.utc).astimezone() windows = {} for days in (1, 7, 30): requested_start = now_dt - timedelta(days=days) - observed_start = max( - requested_start, - first_sample_dt or requested_start, - ) + observed_start = max(requested_start, first_sample_dt or requested_start) observed_s = max(1.0, (now_dt - observed_start).total_seconds()) rows = conn.execute( @@ -193,11 +214,11 @@ def api_status(): ).fetchall() downtime_s = 0.0 - for r in rows: - if r["event_type"] not in OUTAGE_TYPES: + for row in rows: + if row["event_type"] not in OUTAGE_TYPES: continue - start = parse_iso(r["start_ts"]) - end = parse_iso(r["end_ts"]) if r["end_ts"] else now_dt + start = parse_iso(row["start_ts"]) + end = parse_iso(row["end_ts"]) if row["end_ts"] else now_dt if not start or not end: continue overlap_start = max(start, observed_start) @@ -212,19 +233,18 @@ def api_status(): ) windows[str(days)] = { "reboots": sum( - 1 for r in rows - if r["event_type"] == "FRITZBOX_REBOOT_DETECTED" - and (parse_iso(r["start_ts"]) or observed_start) >= observed_start + 1 + for row in rows + if row["event_type"] == "FRITZBOX_REBOOT_DETECTED" + and (parse_iso(row["start_ts"]) or observed_start) >= observed_start ), "wan_resets": sum( - 1 for r in rows - if r["event_type"] == "WAN_SESSION_RESET_DETECTED" - and (parse_iso(r["start_ts"]) or observed_start) >= observed_start - ), - "outages": sum( - 1 for r in rows - if r["event_type"] in OUTAGE_TYPES + 1 + for row in rows + if row["event_type"] == "WAN_SESSION_RESET_DETECTED" + and (parse_iso(row["start_ts"]) or observed_start) >= observed_start ), + "outages": sum(1 for row in rows if row["event_type"] in OUTAGE_TYPES), "downtime_s": downtime_s, "availability_pct": round(availability, 5), "observed_s": round(observed_s, 1), @@ -239,38 +259,30 @@ def api_status(): """ ).fetchone() + outage_placeholders = ",".join("?" for _ in OUTAGE_TYPES) last_problem = conn.execute( - """ - SELECT * FROM events - WHERE event_type IN ( - 'ETHERNET_LINK_DOWN','ROUTER_UNREACHABLE','WAN_SESSION_DOWN', - 'INTERNET_UNREACHABLE','DNS_FAILURE','HTTP_CONNECTIVITY_FAILURE' - ) - ORDER BY start_ts DESC LIMIT 1 - """ + f"SELECT * FROM events WHERE event_type IN ({outage_placeholders}) ORDER BY start_ts DESC LIMIT 1", + tuple(OUTAGE_TYPES), ).fetchone() - outage_rows = conn.execute( - """ - SELECT duration_s FROM events - WHERE event_type IN ( - 'ETHERNET_LINK_DOWN','ROUTER_UNREACHABLE','WAN_SESSION_DOWN', - 'INTERNET_UNREACHABLE','DNS_FAILURE','HTTP_CONNECTIVITY_FAILURE' - ) - AND duration_s IS NOT NULL - """ + f"SELECT duration_s FROM events WHERE event_type IN ({outage_placeholders}) AND duration_s IS NOT NULL", + tuple(OUTAGE_TYPES), ).fetchall() - outage_durations = [float(r["duration_s"]) for r in outage_rows if r["duration_s"] is not None] + outage_durations = [ + float(row["duration_s"]) for row in outage_rows if row["duration_s"] is not None + ] outage_stats = { "count": len(outage_durations), - "avg_s": round(sum(outage_durations)/len(outage_durations), 1) if outage_durations else 0, + "avg_s": round(sum(outage_durations) / len(outage_durations), 1) + if outage_durations + else 0, "max_s": round(max(outage_durations), 1) if outage_durations else 0, } + # A failed ICMP probe alone does not make the connection unhealthy. Many + # routers or upstream networks intentionally drop ping while DNS/HTTP work. current_ok = ( sample["carrier"] != 0 - and sample["gateway_ok"] == 1 - and sample["internet_ok"] == 1 and sample["dns_ok"] == 1 and sample["http_ok"] == 1 and (sample["wan_status"] in (None, "", "Connected")) @@ -289,20 +301,21 @@ def api_status(): wan_start_iso = ( sample_dt - timedelta(seconds=int(sample["wan_uptime_s"])) ).isoformat(timespec="seconds") - if ( - sample["router_uptime_s"] is not None - and sample["wan_uptime_s"] is not None - ): + if sample["router_uptime_s"] is not None and sample["wan_uptime_s"] is not None: reconnect_delay_s = max( 0, - int(sample["router_uptime_s"]) - int(sample["wan_uptime_s"]) + int(sample["router_uptime_s"]) - int(sample["wan_uptime_s"]), ) + fritz_enhanced = fritz_telemetry_present(sample) payload = { "ready": True, "current_ok": current_ok, + "router_mode": "fritz" if fritz_enhanced else "generic", + "fritz_enhanced": fritz_enhanced, "monitoring_since": first_sample["ts"] if first_sample else None, "ts": sample["ts"], + "gateway": sample["gateway"], "router_model": sample["router_model"], "fritzos": sample["fritzos"], "router_uptime_s": sample["router_uptime_s"], @@ -314,6 +327,7 @@ def api_status(): "wan_start_iso": wan_start_iso, "reconnect_delay_s": reconnect_delay_s, "wan_ip": sample["wan_ip"], + "effective_wan_ip": sample["wan_ip"] or sample["public_ip"], "public_ip": sample["public_ip"], "wan_last_error": sample["wan_last_error"], "wan_transport": sample["wan_transport"], @@ -343,11 +357,10 @@ def api_events(): limit = min(max(int(request.args.get("limit", "50")), 1), 200) conn = db() rows = conn.execute( - "SELECT * FROM events ORDER BY start_ts DESC LIMIT ?", - (limit,), + "SELECT * FROM events ORDER BY start_ts DESC LIMIT ?", (limit,) ).fetchall() conn.close() - return jsonify([event_row(r) for r in rows]) + return jsonify([event_row(row) for row in rows]) @app.route("/api/history") @@ -373,7 +386,7 @@ def api_history(): step = max(1, len(rows) // 1200) rows = rows[::step] - return jsonify([dict(r) for r in rows]) + return jsonify([dict(row) for row in rows]) @app.route("/export/events.csv") @@ -391,26 +404,38 @@ def export_events(): conn.close() buf = io.StringIO() - w = csv.writer(buf, delimiter=";") - w.writerow([ - "ID", "Start", "End", "Duration_s", - "Technical_type", "Description_IT", "Description_EN", "Details" - ]) - for r in rows: - w.writerow([ - r["id"], r["start_ts"], r["end_ts"], r["duration_s"], - r["event_type"], - LABELS_IT.get(r["event_type"], r["event_type"]), - LABELS_EN.get(r["event_type"], r["event_type"]), - r["details_json"], - ]) + writer = csv.writer(buf, delimiter=";") + writer.writerow( + [ + "ID", + "Start", + "End", + "Duration_s", + "Technical_type", + "Description_IT", + "Description_EN", + "Details", + ] + ) + for row in rows: + writer.writerow( + [ + row["id"], + row["start_ts"], + row["end_ts"], + row["duration_s"], + row["event_type"], + LABELS_IT.get(row["event_type"], row["event_type"]), + LABELS_EN.get(row["event_type"], row["event_type"]), + row["details_json"], + ] + ) return Response( "\ufeff" + buf.getvalue(), mimetype="text/csv; charset=utf-8", headers={ - "Content-Disposition": - f'attachment; filename="linewatch_events_{days}days.csv"' + "Content-Disposition": f'attachment; filename="linewatch_events_{days}days.csv"' }, ) @@ -427,39 +452,47 @@ def export_isp(): "SELECT * FROM events WHERE start_ts >= ? ORDER BY start_ts ASC", (since_iso(days),), ).fetchall() - sample = conn.execute( - "SELECT * FROM samples ORDER BY id DESC LIMIT 1" - ).fetchone() + sample = conn.execute("SELECT * FROM samples ORDER BY id DESC LIMIT 1").fetchone() conn.close() - reboots = [r for r in rows if r["event_type"] == "FRITZBOX_REBOOT_DETECTED"] - wan_resets = [r for r in rows if r["event_type"] == "WAN_SESSION_RESET_DETECTED"] - outages = [r for r in rows if r["event_type"] in OUTAGE_TYPES] - downtime = sum(float(r["duration_s"] or 0) for r in outages) + reboots = [row for row in rows if row["event_type"] == "FRITZBOX_REBOOT_DETECTED"] + wan_resets = [row for row in rows if row["event_type"] == "WAN_SESSION_RESET_DETECTED"] + outages = [row for row in rows if row["event_type"] in OUTAGE_TYPES] + downtime = sum(float(row["duration_s"] or 0) for row in outages) + enhanced = fritz_telemetry_present(sample) + generated = datetime.now().astimezone().isoformat(timespec="seconds") if lang == "en": lines = [ "LINEWATCH - ISP CONNECTION DIAGNOSTIC REPORT", "=" * 44, f"Period analysed: last {days} days", - f"Generated: {datetime.now().astimezone().isoformat(timespec='seconds')}", + f"Generated: {generated}", "", ] if sample: lines += [ - f"Router: {sample['router_model'] or '-'}", - f"FRITZ!OS: {sample['fritzos'] or '-'}", - f"Current WAN status: {sample['wan_status'] or '-'}", - f"Current router uptime: {fmt_duration(sample['router_uptime_s'])}", - f"Current WAN uptime: {fmt_duration(sample['wan_uptime_s'])}", - f"Current WAN IP: {sample['wan_ip'] or '-'}", - f"Transport: {sample['wan_transport'] or '-'}", - f"PPPoE AC/PoP: {sample['pppoe_ac_name'] or '-'}", - "", + f"Gateway: {sample['gateway'] or '-'}", + f"Public IP: {sample['public_ip'] or '-'}", + ] + if enhanced: + lines += [ + f"Router: {sample['router_model'] or '-'}", + f"FRITZ!OS: {sample['fritzos'] or '-'}", + f"Current WAN status: {sample['wan_status'] or '-'}", + f"Current router uptime: {fmt_duration(sample['router_uptime_s'])}", + f"Current WAN uptime: {fmt_duration(sample['wan_uptime_s'])}", + f"Current WAN IP: {sample['wan_ip'] or '-'}", + f"Transport: {sample['wan_transport'] or '-'}", + f"PPPoE AC/PoP: {sample['pppoe_ac_name'] or '-'}", + ] + lines.append("") + if enhanced: + lines += [ + f"FRITZ!Box reboots detected: {len(reboots)}", + f"WAN/PPPoE session resets: {len(wan_resets)}", ] lines += [ - f"FRITZ!Box reboots detected: {len(reboots)}", - f"WAN/PPPoE session resets: {len(wan_resets)}", f"Recorded outages: {len(outages)}", f"Total recorded downtime: {fmt_duration(downtime)}", "", @@ -471,24 +504,32 @@ def export_isp(): "LINEWATCH - REPORT DIAGNOSTICO CONNESSIONE / ISP", "=" * 42, f"Periodo analizzato: ultimi {days} giorni", - f"Generato: {datetime.now().astimezone().isoformat(timespec='seconds')}", + f"Generato: {generated}", "", ] if sample: lines += [ - f"Router: {sample['router_model'] or '-'}", - f"FRITZ!OS: {sample['fritzos'] or '-'}", - f"Stato WAN attuale: {sample['wan_status'] or '-'}", - f"Uptime router attuale: {fmt_duration(sample['router_uptime_s'])}", - f"Uptime WAN attuale: {fmt_duration(sample['wan_uptime_s'])}", - f"IP WAN attuale: {sample['wan_ip'] or '-'}", - f"Trasporto: {sample['wan_transport'] or '-'}", - f"PPPoE AC/PoP: {sample['pppoe_ac_name'] or '-'}", - "", + f"Gateway: {sample['gateway'] or '-'}", + f"IP pubblico: {sample['public_ip'] or '-'}", + ] + if enhanced: + lines += [ + f"Router: {sample['router_model'] or '-'}", + f"FRITZ!OS: {sample['fritzos'] or '-'}", + f"Stato WAN attuale: {sample['wan_status'] or '-'}", + f"Uptime router attuale: {fmt_duration(sample['router_uptime_s'])}", + f"Uptime WAN attuale: {fmt_duration(sample['wan_uptime_s'])}", + f"IP WAN attuale: {sample['wan_ip'] or '-'}", + f"Trasporto: {sample['wan_transport'] or '-'}", + f"PPPoE AC/PoP: {sample['pppoe_ac_name'] or '-'}", + ] + lines.append("") + if enhanced: + lines += [ + f"Riavvii FRITZ!Box rilevati: {len(reboots)}", + f"Reset sessione WAN/PPPoE: {len(wan_resets)}", ] lines += [ - f"Riavvii FRITZ!Box rilevati: {len(reboots)}", - f"Reset sessione WAN/PPPoE: {len(wan_resets)}", f"Interruzioni registrate: {len(outages)}", f"Downtime totale registrato: {fmt_duration(downtime)}", "", @@ -503,10 +544,10 @@ def export_isp(): else "Nessun evento nel periodo selezionato." ) else: - for r in rows: - line = f"{r['start_ts']} | {labels.get(r['event_type'], r['event_type'])}" - if r["duration_s"]: - line += f" | {fmt_duration(r['duration_s'])}" + for row in rows: + line = f"{row['start_ts']} | {labels.get(row['event_type'], row['event_type'])}" + if row["duration_s"]: + line += f" | {fmt_duration(row['duration_s'])}" lines.append(line) suffix = "en" if lang == "en" else "it" @@ -514,8 +555,7 @@ def export_isp(): "\n".join(lines) + "\n", mimetype="text/plain; charset=utf-8", headers={ - "Content-Disposition": - f'attachment; filename="linewatch_isp_report_{suffix}_{days}days.txt"' + "Content-Disposition": f'attachment; filename="linewatch_isp_report_{suffix}_{days}days.txt"' }, ) diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..3486d55 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,66 @@ +# Linux validation checklist + +Use this checklist before claiming a Linux distribution or device class as tested. + +## Fresh install + +- Start from a clean Linux installation. +- Connect the test machine by Ethernet when possible. +- Run `./install.sh` as a normal user. +- Verify both systemd services start and remain active. + +```bash +systemctl status linewatch +systemctl status linewatch-dashboard +journalctl -u linewatch -n 100 --no-pager +``` + +## Generic mode + +Configure `LINEWATCH_ROUTER_MODE=generic` and verify: + +- default IPv4 gateway is detected +- active network interface is detected +- dashboard loads on port 8080 +- Internet latency samples appear when ICMP is available +- DNS and HTTP checks are healthy +- public IP is populated +- FRITZ-specific fields are not presented as available + +## Gateway ICMP behavior + +If the gateway answers ping, verify LineWatch logs that gateway ICMP is supported. + +If the gateway does not answer ping while Internet access works, verify automatic mode disables gateway-based outage classification instead of reporting a false outage. + +## Controlled network faults + +Where safe and practical, test one fault at a time and restore connectivity after each test: + +- disconnect the Ethernet cable briefly -> `NETWORK_LINK_DOWN` +- disable upstream Internet while keeping the LAN/router available -> `INTERNET_UNREACHABLE` +- configure or simulate a DNS failure -> `DNS_FAILURE` +- block the configured HTTP connectivity endpoint -> `HTTP_CONNECTIVITY_FAILURE` + +Confirm each event opens and closes with a sensible duration and appears in the dashboard/export. + +## FRITZ!Box enhanced mode + +On a compatible FRITZ!Box, configure TR-064 credentials and verify: + +- router model and FRITZ!OS appear +- router uptime and WAN uptime are populated +- WAN status is available +- generic Internet probes continue to work when TR-064 temporarily fails + +Do not deliberately reboot production networking equipment just to complete this checklist unless disruption is acceptable. + +## Persistence + +Reboot the Linux host and verify the monitor/dashboard return automatically and the previous SQLite history is preserved. + +## Report compatibility + +Export both CSV and ISP text reports. Check that generic mode does not invent FRITZ-specific telemetry and enhanced mode includes it when available. + +When opening a compatibility issue, include distribution/version, architecture, network interface type, router model, LineWatch commit/version, and which checklist sections passed. diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 781a3c8..09f222e --- a/install.sh +++ b/install.sh @@ -9,6 +9,12 @@ fi APP_DIR="$(cd "$(dirname "$0")" && pwd)" USER_NAME="$(id -un)" +if ! command -v apt-get >/dev/null 2>&1; then + echo "Automatic installation currently supports Debian/Ubuntu/Raspberry Pi OS systems with apt." + echo "For other Linux distributions, install Python 3, venv, iproute2 and ping manually, then use the manual setup instructions." + exit 1 +fi + sudo apt-get update sudo apt-get install -y python3-venv iproute2 iputils-ping @@ -24,13 +30,13 @@ if [ ! -f "$APP_DIR/.env" ]; then else cp "$APP_DIR/.env.example" "$APP_DIR/.env" chmod 600 "$APP_DIR/.env" - echo "Created .env from .env.example; configure it before starting LineWatch." + echo "Created .env from .env.example. LineWatch will start in generic mode unless FRITZ credentials are added." fi fi sudo tee /etc/systemd/system/linewatch.service >/dev/null </dev/null; then - echo - echo "Configure .env, then run:" - echo " sudo systemctl start linewatch linewatch-dashboard" -else - sudo systemctl restart linewatch linewatch-dashboard - echo - echo "LineWatch is running." - echo "Open: http://$(hostname).local:8080" -fi +echo +echo "LineWatch is running." +echo "Open: http://$(hostname).local:8080" +echo "If mDNS is unavailable, use this machine's LAN IP with port 8080." diff --git a/monitor.py b/monitor.py index 367a3e9..69e88ef 100644 --- a/monitor.py +++ b/monitor.py @@ -1,200 +1,563 @@ #!/usr/bin/env python3 from __future__ import annotations -import json, os, re, signal, socket, sqlite3, subprocess, time, urllib.request +import json +import os +import re +import signal +import socket +import sqlite3 +import subprocess +import time +import urllib.request from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path from typing import Optional -from fritzconnection import FritzConnection +try: + from fritzconnection import FritzConnection +except ImportError: # Generic mode can run without the optional FRITZ!Box adapter. + FritzConnection = None ROOT = Path(__file__).resolve().parent DATA = ROOT / "data" EVENTS = DATA / "events" DB = DATA / "linewatch.sqlite3" -DATA.mkdir(exist_ok=True); EVENTS.mkdir(exist_ok=True) +DATA.mkdir(exist_ok=True) +EVENTS.mkdir(exist_ok=True) POLL = float(os.getenv("LINEWATCH_POLL_SECONDS", "2")) SAVE_EVERY = float(os.getenv("LINEWATCH_HEALTHY_PERSIST_SECONDS", "30")) FRITZ_EVERY = float(os.getenv("LINEWATCH_FRITZ_POLL_SECONDS", "10")) +PUBLIC_IP_EVERY = float(os.getenv("LINEWATCH_PUBLIC_IP_SECONDS", "300")) +RING_SECONDS = float(os.getenv("LINEWATCH_RING_SECONDS", "120")) +ROUTER_MODE = os.getenv("LINEWATCH_ROUTER_MODE", "auto").strip().lower() or "auto" +GATEWAY_PROBE = os.getenv("LINEWATCH_GATEWAY_PROBE", "auto").strip().lower() or "auto" FRITZ_HOST = os.getenv("FRITZ_HOST", "").strip() FRITZ_USER = os.getenv("FRITZ_USER", "").strip() FRITZ_PASSWORD = os.getenv("FRITZ_PASSWORD", "") -IFACE = os.getenv("LINEWATCH_INTERFACE", "eth0").strip() or "eth0" -PING_TARGETS = [x.strip() for x in os.getenv("LINEWATCH_PING_TARGETS", "1.1.1.1,8.8.8.8").split(",") if x.strip()] +IFACE = os.getenv("LINEWATCH_INTERFACE", "").strip() +PING_TARGETS = [ + x.strip() + for x in os.getenv("LINEWATCH_PING_TARGETS", "1.1.1.1,8.8.8.8").split(",") + if x.strip() +] DNS_NAME = os.getenv("LINEWATCH_DNS_NAME", "www.cloudflare.com") -HTTP_URL = os.getenv("LINEWATCH_HTTP_URL", "https://connectivitycheck.gstatic.com/generate_204") +HTTP_URL = os.getenv( + "LINEWATCH_HTTP_URL", "https://connectivitycheck.gstatic.com/generate_204" +) PUBLIC_IP_URL = os.getenv("LINEWATCH_PUBLIC_IP_URL", "https://api.ipify.org") STOP = False +ROUTER_MODES = {"auto", "generic", "fritz"} +GATEWAY_PROBE_MODES = {"auto", "on", "off"} -def now(): return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") -def gateway(): - if FRITZ_HOST: return FRITZ_HOST +def now(): + return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + + +def default_route(): + """Return (gateway IPv4, interface) for the host default route.""" + try: + out = subprocess.check_output( + ["ip", "-4", "route", "show", "default"], text=True, timeout=2 + ) + line = next((line for line in out.splitlines() if line.strip()), "") + gw_match = re.search(r"\bvia\s+(\d+\.\d+\.\d+\.\d+)", line) + dev_match = re.search(r"\bdev\s+(\S+)", line) + return ( + gw_match.group(1) if gw_match else None, + dev_match.group(1) if dev_match else None, + ) + except Exception: + return None, None + + +def carrier(interface): + """Read Linux link carrier when sysfs exposes it; otherwise return unknown.""" + if not interface: + return None try: - out = subprocess.check_output(["ip", "-4", "route", "show", "default"], text=True, timeout=2) - m = re.search(r"\bvia\s+(\d+\.\d+\.\d+\.\d+)", out) - return m.group(1) if m else None - except Exception: return None + return int( + Path(f"/sys/class/net/{interface}/carrier").read_text().strip() == "1" + ) + except Exception: + return None -def carrier(): - try: return int((Path(f"/sys/class/net/{IFACE}/carrier").read_text().strip() == "1")) - except Exception: return None def ping(host): + if not host: + return 0, None try: - p = subprocess.run(["ping", "-n", "-c", "1", "-W", "1", host], capture_output=True, text=True, timeout=2.5) - if p.returncode: return 0, None + p = subprocess.run( + ["ping", "-n", "-c", "1", "-W", "1", host], + capture_output=True, + text=True, + timeout=2.5, + ) + if p.returncode: + return 0, None m = re.search(r"time[=<]([\d.]+)\s*ms", p.stdout) return 1, float(m.group(1)) if m else None - except Exception: return 0, None + except Exception: + return 0, None + def dns_check(): t = time.monotonic() try: socket.getaddrinfo(DNS_NAME, 443, type=socket.SOCK_STREAM) - return 1, round((time.monotonic()-t)*1000, 2) - except Exception: return 0, None + return 1, round((time.monotonic() - t) * 1000, 2) + except Exception: + return 0, None + def http_check(): t = time.monotonic() try: - req = urllib.request.Request(HTTP_URL, headers={"User-Agent":"LineWatch/1.0"}) - with urllib.request.urlopen(req, timeout=3) as r: r.read(32) - return 1, round((time.monotonic()-t)*1000, 2) - except Exception: return 0, None + req = urllib.request.Request(HTTP_URL, headers={"User-Agent": "LineWatch/1.1"}) + with urllib.request.urlopen(req, timeout=3) as response: + response.read(32) + return 1, round((time.monotonic() - t) * 1000, 2) + except Exception: + return 0, None + def public_ip(): try: - req = urllib.request.Request(PUBLIC_IP_URL, headers={"User-Agent":"LineWatch/1.0"}) - with urllib.request.urlopen(req, timeout=3) as r: return r.read(128).decode().strip() or None - except Exception: return None + req = urllib.request.Request( + PUBLIC_IP_URL, headers={"User-Agent": "LineWatch/1.1"} + ) + with urllib.request.urlopen(req, timeout=3) as response: + return response.read(128).decode().strip() or None + except Exception: + return None + + +def resolve_router_mode(mode=None, user=None, password=None): + mode = ROUTER_MODE if mode is None else mode.strip().lower() + user = FRITZ_USER if user is None else user + password = FRITZ_PASSWORD if password is None else password + if mode not in ROUTER_MODES: + raise ValueError( + f"Invalid LINEWATCH_ROUTER_MODE={mode!r}; expected auto, generic or fritz" + ) + if mode == "generic": + return "generic" + if mode == "fritz": + if not user or not password: + raise ValueError("FRITZ mode requires FRITZ_USER and FRITZ_PASSWORD") + return "fritz" + return "fritz" if user and password else "generic" + + +def resolve_gateway_probe(mode=None): + mode = GATEWAY_PROBE if mode is None else mode.strip().lower() + if mode not in GATEWAY_PROBE_MODES: + raise ValueError( + f"Invalid LINEWATCH_GATEWAY_PROBE={mode!r}; expected auto, on or off" + ) + if mode == "on": + return True + if mode == "off": + return False + return None @dataclass class Sample: - ts: str; carrier: Optional[int]; gateway: str; gateway_ok: int; gateway_ms: Optional[float] - internet_ok: int; internet_ms: Optional[float]; dns_ok: int; dns_ms: Optional[float] - http_ok: int; http_ms: Optional[float]; public_ip: Optional[str] - router_uptime_s: Optional[int]; router_model: Optional[str]; fritzos: Optional[str] - wan_status: Optional[str]; wan_uptime_s: Optional[int]; wan_ip: Optional[str] - wan_last_error: Optional[str]; wan_transport: Optional[str]; pppoe_ac_name: Optional[str] + ts: str + carrier: Optional[int] + gateway: str + gateway_ok: int + gateway_ms: Optional[float] + internet_ok: int + internet_ms: Optional[float] + dns_ok: int + dns_ms: Optional[float] + http_ok: int + http_ms: Optional[float] + public_ip: Optional[str] + router_uptime_s: Optional[int] + router_model: Optional[str] + fritzos: Optional[str] + wan_status: Optional[str] + wan_uptime_s: Optional[int] + wan_ip: Optional[str] + wan_last_error: Optional[str] + wan_transport: Optional[str] + pppoe_ac_name: Optional[str] fritz_error: Optional[str] def connect_db(): - c = sqlite3.connect(DB, timeout=30); c.execute("PRAGMA journal_mode=WAL") - c.execute("""CREATE TABLE IF NOT EXISTS samples( - id INTEGER PRIMARY KEY, ts TEXT, carrier INTEGER, gateway TEXT, gateway_ok INTEGER, gateway_ms REAL, - internet_ok INTEGER, internet_ms REAL, dns_ok INTEGER, dns_ms REAL, http_ok INTEGER, http_ms REAL, - public_ip TEXT, router_uptime_s INTEGER, router_model TEXT, fritzos TEXT, wan_status TEXT, - wan_uptime_s INTEGER, wan_ip TEXT, wan_last_error TEXT, wan_transport TEXT, pppoe_ac_name TEXT, fritz_error TEXT)""") - c.execute("""CREATE TABLE IF NOT EXISTS events( - id INTEGER PRIMARY KEY, start_ts TEXT, end_ts TEXT, duration_s REAL, event_type TEXT, details_json TEXT)""") - c.commit(); return c + conn = sqlite3.connect(DB, timeout=30) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute( + """CREATE TABLE IF NOT EXISTS samples( + id INTEGER PRIMARY KEY, ts TEXT, carrier INTEGER, gateway TEXT, gateway_ok INTEGER, gateway_ms REAL, + internet_ok INTEGER, internet_ms REAL, dns_ok INTEGER, dns_ms REAL, http_ok INTEGER, http_ms REAL, + public_ip TEXT, router_uptime_s INTEGER, router_model TEXT, fritzos TEXT, wan_status TEXT, + wan_uptime_s INTEGER, wan_ip TEXT, wan_last_error TEXT, wan_transport TEXT, pppoe_ac_name TEXT, fritz_error TEXT)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS events( + id INTEGER PRIMARY KEY, start_ts TEXT, end_ts TEXT, duration_s REAL, event_type TEXT, details_json TEXT)""" + ) + conn.commit() + return conn + + +def save_sample(conn, sample): + data = asdict(sample) + conn.execute( + f"INSERT INTO samples({','.join(data)}) VALUES({','.join('?' for _ in data)})", + list(data.values()), + ) + conn.commit() -def save_sample(c, s): - d=asdict(s); c.execute(f"INSERT INTO samples({','.join(d)}) VALUES({','.join('?' for _ in d)})", list(d.values())); c.commit() -def add_event(c, kind, details, start=None, end=None, duration=None): - start=start or now(); cur=c.execute("INSERT INTO events(start_ts,end_ts,duration_s,event_type,details_json) VALUES(?,?,?,?,?)", - (start,end,duration,kind,json.dumps(details,ensure_ascii=False))); c.commit(); return cur.lastrowid +def add_event(conn, kind, details, start=None, end=None, duration=None): + start = start or now() + cur = conn.execute( + "INSERT INTO events(start_ts,end_ts,duration_s,event_type,details_json) VALUES(?,?,?,?,?)", + (start, end, duration, kind, json.dumps(details, ensure_ascii=False)), + ) + conn.commit() + return cur.lastrowid -def close_event(c, event_id, details, duration): - c.execute("UPDATE events SET end_ts=?,duration_s=?,details_json=? WHERE id=?", (now(),round(duration,2),json.dumps(details,ensure_ascii=False),event_id)); c.commit() + +def close_event(conn, event_id, details, duration, end=None): + conn.execute( + "UPDATE events SET end_ts=?,duration_s=?,details_json=? WHERE id=?", + ( + end or now(), + round(duration, 2), + json.dumps(details, ensure_ascii=False), + event_id, + ), + ) + conn.commit() class Fritz: - def __init__(self, host): self.host=host; self.fc=None; self.wan=None + def __init__(self, host): + self.host = host + self.fc = None + self.wan = None + def _connect(self): - self.fc=FritzConnection(address=self.host,user=FRITZ_USER,password=FRITZ_PASSWORD,timeout=4) - candidates=[s for s in self.fc.services if "WANPPPConnection" in s or "WANIPConnection" in s] - self.wan=None - for s in candidates: + if FritzConnection is None: + raise RuntimeError( + "FRITZ!Box support requires the 'fritzconnection' Python package" + ) + self.fc = FritzConnection( + address=self.host, + user=FRITZ_USER, + password=FRITZ_PASSWORD, + timeout=4, + ) + candidates = [ + service + for service in self.fc.services + if "WANPPPConnection" in service or "WANIPConnection" in service + ] + self.wan = None + for service in candidates: try: - x=self.fc.call_action(s,"GetInfo") - if x.get("NewEnable") and str(x.get("NewName","")).lower()=="internet": self.wan=s; break - if x.get("NewEnable") and not self.wan: self.wan=s - except Exception: pass + info = self.fc.call_action(service, "GetInfo") + if info.get("NewEnable") and str(info.get("NewName", "")).lower() == "internet": + self.wan = service + break + if info.get("NewEnable") and not self.wan: + self.wan = service + except Exception: + pass + def snapshot(self): try: - if not self.fc: self._connect() - d=self.fc.call_action("DeviceInfo1","GetInfo") - out={"router_uptime_s":d.get("NewUpTime"),"router_model":d.get("NewModelName"),"fritzos":d.get("NewSoftwareVersion")} + if not self.fc: + self._connect() + device = self.fc.call_action("DeviceInfo1", "GetInfo") + out = { + "router_uptime_s": device.get("NewUpTime"), + "router_model": device.get("NewModelName"), + "fritzos": device.get("NewSoftwareVersion"), + } if self.wan: - w=self.fc.call_action(self.wan,"GetInfo") - out.update(wan_status=w.get("NewConnectionStatus"),wan_uptime_s=w.get("NewUptime"),wan_ip=w.get("NewExternalIPAddress"), - wan_last_error=w.get("NewLastConnectionError"),wan_transport=w.get("NewTransportType"),pppoe_ac_name=w.get("NewPPPoEACName")) + wan = self.fc.call_action(self.wan, "GetInfo") + out.update( + wan_status=wan.get("NewConnectionStatus"), + wan_uptime_s=wan.get("NewUptime"), + wan_ip=wan.get("NewExternalIPAddress"), + wan_last_error=wan.get("NewLastConnectionError"), + wan_transport=wan.get("NewTransportType"), + pppoe_ac_name=wan.get("NewPPPoEACName"), + ) try: - log=self.fc.call_action("DeviceInfo1","GetDeviceLog").get("NewDeviceLog") - except Exception: log=None - out["fritz_error"]=None; return out,log - except Exception as e: - self.fc=None; self.wan=None; return {"fritz_error":f"{type(e).__name__}: {e}"},None - - -def classify(s): - if s.carrier == 0: return "ETHERNET_LINK_DOWN" - if not s.gateway_ok: return "ROUTER_UNREACHABLE" - if s.wan_status and s.wan_status != "Connected": return "WAN_SESSION_DOWN" - if not s.internet_ok: return "INTERNET_UNREACHABLE" - if not s.dns_ok: return "DNS_FAILURE" - if not s.http_ok: return "HTTP_CONNECTIVITY_FAILURE" + log = self.fc.call_action("DeviceInfo1", "GetDeviceLog").get( + "NewDeviceLog" + ) + except Exception: + log = None + out["fritz_error"] = None + return out, log + except Exception as exc: + self.fc = None + self.wan = None + return {"fritz_error": f"{type(exc).__name__}: {exc}"}, None + + +def classify(sample, gateway_probe_active=True): + """Classify an incident without assuming every network permits ICMP.""" + if sample.carrier == 0: + return "NETWORK_LINK_DOWN" + + internet_paths_ok = bool(sample.internet_ok or sample.dns_ok or sample.http_ok) + + if gateway_probe_active and not sample.gateway_ok and not internet_paths_ok: + return "GATEWAY_UNREACHABLE" + if sample.wan_status and sample.wan_status != "Connected": + return "WAN_SESSION_DOWN" + if not sample.internet_ok and not sample.dns_ok and not sample.http_ok: + return "INTERNET_UNREACHABLE" + if not sample.dns_ok: + return "DNS_FAILURE" + if not sample.http_ok: + return "HTTP_CONNECTIVITY_FAILURE" + + # ICMP may be blocked even when DNS and HTTP are healthy. Do not declare an + # outage from a failed Internet ping alone. return "OK" + def bundle(event_id, samples, log, details): - d=EVENTS/f"event_{event_id:05d}"; d.mkdir(exist_ok=True) - (d/"details.json").write_text(json.dumps(details,indent=2,ensure_ascii=False),encoding="utf-8") - (d/"samples.jsonl").write_text("".join(json.dumps(asdict(s),ensure_ascii=False)+"\n" for s in samples[-60:]),encoding="utf-8") - if log: (d/"fritz_device_log.txt").write_text(log,encoding="utf-8") + directory = EVENTS / f"event_{event_id:05d}" + directory.mkdir(exist_ok=True) + (directory / "details.json").write_text( + json.dumps(details, indent=2, ensure_ascii=False), encoding="utf-8" + ) + (directory / "samples.jsonl").write_text( + "".join( + json.dumps(asdict(sample), ensure_ascii=False) + "\n" for sample in samples + ), + encoding="utf-8", + ) + if log: + (directory / "fritz_device_log.txt").write_text(log, encoding="utf-8") def main(): global STOP - signal.signal(signal.SIGINT,lambda *_: globals().__setitem__("STOP",True)); signal.signal(signal.SIGTERM,lambda *_: globals().__setitem__("STOP",True)) - host=gateway() - if not host or not FRITZ_USER or not FRITZ_PASSWORD: raise SystemExit("Configure FRITZ_USER/FRITZ_PASSWORD and ensure a default gateway is available.") - print(f"[LineWatch] FRITZ!Box/gateway: {host}",flush=True) - c=connect_db(); fritz=Fritz(host); state={}; last_log=None; last_fritz=last_save=last_ip=0.0; pub=None - prev_router=prev_wan=None; prev_wan_ip=None; open_event=None; open_kind=None; open_started=None; open_details={}; history=[] + signal.signal(signal.SIGINT, lambda *_: globals().__setitem__("STOP", True)) + signal.signal(signal.SIGTERM, lambda *_: globals().__setitem__("STOP", True)) + + try: + router_mode = resolve_router_mode() + gateway_probe_active = resolve_gateway_probe() + except ValueError as exc: + raise SystemExit(str(exc)) + + route_gateway, route_iface = default_route() + if not route_gateway: + raise SystemExit( + "No IPv4 default gateway found. Ensure the host has an active network connection." + ) + + interface = IFACE or route_iface + router_host = FRITZ_HOST or route_gateway + fritz = Fritz(router_host) if router_mode == "fritz" else None + ring_samples = max(1, int(max(RING_SECONDS, POLL) / max(POLL, 0.1))) + + probe_label = ( + "auto" + if gateway_probe_active is None + else ("on" if gateway_probe_active else "off") + ) + print( + f"[LineWatch] gateway: {route_gateway}; interface: {interface or 'unknown'}; " + f"router mode: {router_mode}; gateway probe: {probe_label}", + flush=True, + ) + if fritz: + print(f"[LineWatch] FRITZ!Box/TR-064 host: {router_host}", flush=True) + + conn = connect_db() + state = {} + last_log = None + last_fritz = last_save = last_ip = 0.0 + pub = None + prev_router = prev_wan = None + prev_wan_ip = None + open_event = None + open_kind = None + open_started = None + open_details = {} + history = [] + while not STOP: - cycle=time.monotonic(); ts=now(); host=gateway() or host - car=carrier(); gok,gms=ping(host) - iok=0; ims=None + cycle = time.monotonic() + ts = now() + current_gateway, current_iface = default_route() + gateway = current_gateway or route_gateway + if current_gateway: + route_gateway = current_gateway + if not IFACE and current_iface: + interface = current_iface + + car = carrier(interface) + gok, gms = ping(gateway) + + iok = 0 + ims = None for target in PING_TARGETS: - iok,ims=ping(target) - if iok: break - dok,dms=dns_check(); hok,hms=http_check(); mono=time.monotonic() - if mono-last_ip>=300 or pub is None: pub=public_ip() or pub; last_ip=mono - if mono-last_fritz>=FRITZ_EVERY: state,log=fritz.snapshot(); last_log=log or last_log; last_fritz=mono - s=Sample(ts,car,host,gok,gms,iok,ims,dok,dms,hok,hms,pub,state.get("router_uptime_s"),state.get("router_model"),state.get("fritzos"), - state.get("wan_status"),state.get("wan_uptime_s"),state.get("wan_ip"),state.get("wan_last_error"),state.get("wan_transport"),state.get("pppoe_ac_name"),state.get("fritz_error")) - history.append(s); history=history[-120:] - router_reboot=False - if s.router_uptime_s is not None: - ru=int(s.router_uptime_s) - if prev_router is not None and ru+30= PUBLIC_IP_EVERY or pub is None: + pub = public_ip() or pub + last_ip = mono + + if fritz and mono - last_fritz >= FRITZ_EVERY: + state, log = fritz.snapshot() + last_log = log or last_log + last_fritz = mono + elif not fritz: + state = {} + + sample = Sample( + ts, + car, + gateway, + gok, + gms, + iok, + ims, + dok, + dms, + hok, + hms, + pub, + state.get("router_uptime_s"), + state.get("router_model"), + state.get("fritzos"), + state.get("wan_status"), + state.get("wan_uptime_s"), + state.get("wan_ip"), + state.get("wan_last_error"), + state.get("wan_transport"), + state.get("pppoe_ac_name"), + state.get("fritz_error"), + ) + history.append(sample) + history = history[-ring_samples:] + + router_reboot = False + if sample.router_uptime_s is not None: + router_uptime = int(sample.router_uptime_s) + if prev_router is not None and router_uptime + 30 < prev_router: + router_reboot = True + details = { + "previous_router_uptime_s": prev_router, + "current_router_uptime_s": router_uptime, + "wan_status": sample.wan_status, + "wan_ip": sample.wan_ip, + } + event_id = add_event( + conn, + "FRITZBOX_REBOOT_DETECTED", + details, + start=ts, + end=ts, + duration=0, + ) + bundle(event_id, history, last_log, details) + prev_router = router_uptime + + if sample.wan_uptime_s is not None: + wan_uptime = int(sample.wan_uptime_s) + if prev_wan is not None and wan_uptime + 30 < prev_wan and not router_reboot: + details = { + "previous_wan_uptime_s": prev_wan, + "current_wan_uptime_s": wan_uptime, + "router_uptime_s": sample.router_uptime_s, + "wan_ip": sample.wan_ip, + } + event_id = add_event( + conn, + "WAN_SESSION_RESET_DETECTED", + details, + start=ts, + end=ts, + duration=0, + ) + bundle(event_id, history, last_log, details) + prev_wan = wan_uptime + + observed_wan_ip = sample.wan_ip or sample.public_ip + if prev_wan_ip and observed_wan_ip and observed_wan_ip != prev_wan_ip: + add_event( + conn, + "WAN_IP_CHANGED", + { + "previous": prev_wan_ip, + "new": observed_wan_ip, + "source": "router" if sample.wan_ip else "public_probe", + }, + start=ts, + end=ts, + duration=0, + ) + if observed_wan_ip: + prev_wan_ip = observed_wan_ip + + kind = classify(sample, gateway_probe_active is True) + unhealthy = kind != "OK" if unhealthy and open_event is None: - open_kind=kind; open_started=mono; open_details={"start_state":asdict(s)}; open_event=add_event(c,kind,open_details,start=ts); bundle(open_event,history,last_log,open_details) + open_kind = kind + open_started = cycle + open_details = {"start_state": asdict(sample)} + open_event = add_event(conn, kind, open_details, start=ts) + bundle(open_event, history, last_log, open_details) elif not unhealthy and open_event is not None: - duration=mono-open_started; open_details.update(end_state=asdict(s),duration_s=round(duration,2)); close_event(c,open_event,open_details,duration); bundle(open_event,history,last_log,open_details) - open_event=open_kind=open_started=None; open_details={} - if unhealthy or mono-last_save>=SAVE_EVERY: save_sample(c,s); last_save=mono - time.sleep(max(.1,POLL-(time.monotonic()-cycle))) - c.close() + duration = cycle - open_started + open_details.update( + end_state=asdict(sample), duration_s=round(duration, 2) + ) + close_event(conn, open_event, open_details, duration, end=ts) + bundle(open_event, history, last_log, open_details) + open_event = open_kind = open_started = None + open_details = {} + + if unhealthy or mono - last_save >= SAVE_EVERY: + save_sample(conn, sample) + last_save = mono + + time.sleep(max(0.1, POLL - (time.monotonic() - cycle))) + + conn.close() + -if __name__ == "__main__": main() +if __name__ == "__main__": + main() diff --git a/run_dashboard.sh b/run_dashboard.sh old mode 100644 new mode 100755 diff --git a/run_monitor.sh b/run_monitor.sh old mode 100644 new mode 100755 diff --git a/templates/index.html b/templates/index.html index fecdc69..2266bd4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,46 +7,22 @@
-

LineWatch

-
FRITZ!Box · monitoraggio 24/7
+

LineWatch

...
+
Internet connection black box
@@ -58,264 +34,101 @@

LineWatch

Caricamento...
- Ultimo campione: - - - Aggiornamento automatico: 5 s + Ultimo campione: -Aggiornamento automatico: 5 s
-
-
-
Uptime modem
-
-
-
-
-
-
Uptime Internet
-
-
-
-
-
-
Ping Internet
-
-
-
-
-
-
Stato PPPoE
-
-
-
-
-
- -
-
Riavvii modem · 24h
-
-
Reset PPPoE · 30 giorni
-
-
Interruzioni · 30 giorni
-
-
Disponibilità · 30 giorni
-
-
+
+
-
-
Diagnosi automatica
-
-
-
-
-
Outage più lungo registrato
-
-
-
Durata media: -
-
-
-
Downtime · 30 giorni
-
-
-
Riavvii modem 30 giorni: -
-
+
Diagnosi automatica
-
+
Outage più lungo registrato
-
Durata media: -
+
Downtime · 30 giorni
-
Latenza ultime 24 ore
-
-
Min-
-
Media-
-
P95-
-
Max-
-
+
Min-
Media-
P95-
Max-
Dettagli connessione
- - - - - - - - - -
Router-
FRITZ!OS-
Monitoraggio attivo da-
IP WAN-
IP pubblico-
PoP / PPPoE AC-
Ultimo errore WAN-
Ultimo riavvio rilevato-
Ultimo problema-
+
-
-
Cronologia eventi
-
Descrizioni leggibili + classificazione tecnica salvata nel database.
-
-
- - - -
QuandoEventoDurata
-
+
Cronologia eventi
Descrizioni leggibili + classificazione tecnica salvata nel database.
+
QuandoEventoDurata
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..d7a12e0 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,20 @@ +import unittest + +import dashboard + + +class DashboardTests(unittest.TestCase): + def test_fmt_duration(self): + self.assertEqual(dashboard.fmt_duration(None), "-") + self.assertEqual(dashboard.fmt_duration(42), "42s") + self.assertEqual(dashboard.fmt_duration(125), "2m 5s") + self.assertEqual(dashboard.fmt_duration(3661), "1h 1m 1s") + + def test_percentile(self): + self.assertIsNone(dashboard.percentile([], 0.95)) + self.assertEqual(dashboard.percentile([10], 0.95), 10) + self.assertAlmostEqual(dashboard.percentile([1, 2, 3, 4], 0.5), 2.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..30f5a60 --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,162 @@ +import unittest +import sqlite3 +from unittest.mock import patch + +import monitor + + +class MonitorTests(unittest.TestCase): + def sample(self, **overrides): + values = dict( + ts="2026-09-05T12:00:00+00:00", + carrier=1, + gateway="192.168.1.1", + gateway_ok=1, + gateway_ms=1.2, + internet_ok=1, + internet_ms=10.0, + dns_ok=1, + dns_ms=8.0, + http_ok=1, + http_ms=30.0, + public_ip="203.0.113.10", + router_uptime_s=None, + router_model=None, + fritzos=None, + wan_status=None, + wan_uptime_s=None, + wan_ip=None, + wan_last_error=None, + wan_transport=None, + pppoe_ac_name=None, + fritz_error=None, + ) + values.update(overrides) + return monitor.Sample(**values) + + def test_auto_router_mode_defaults_to_generic_without_credentials(self): + self.assertEqual( + monitor.resolve_router_mode("auto", user="", password=""), "generic" + ) + + def test_auto_router_mode_enables_fritz_when_credentials_exist(self): + self.assertEqual( + monitor.resolve_router_mode("auto", user="user", password="secret"), + "fritz", + ) + + def test_explicit_fritz_mode_requires_credentials(self): + with self.assertRaises(ValueError): + monitor.resolve_router_mode("fritz", user="", password="") + + def test_gateway_probe_modes(self): + self.assertIsNone(monitor.resolve_gateway_probe("auto")) + self.assertTrue(monitor.resolve_gateway_probe("on")) + self.assertFalse(monitor.resolve_gateway_probe("off")) + with self.assertRaises(ValueError): + monitor.resolve_gateway_probe("invalid") + + def test_failed_gateway_ping_is_not_an_outage_when_internet_is_healthy(self): + sample = self.sample(gateway_ok=0, gateway_ms=None, internet_ok=0, internet_ms=None) + self.assertEqual(monitor.classify(sample, gateway_probe_active=True), "OK") + + def test_gateway_unreachable_when_probe_is_supported_and_all_paths_fail(self): + sample = self.sample( + gateway_ok=0, + gateway_ms=None, + internet_ok=0, + internet_ms=None, + dns_ok=0, + dns_ms=None, + http_ok=0, + http_ms=None, + ) + self.assertEqual( + monitor.classify(sample, gateway_probe_active=True), + "GATEWAY_UNREACHABLE", + ) + + def test_generic_outage_when_gateway_probe_is_disabled(self): + sample = self.sample( + gateway_ok=0, + gateway_ms=None, + internet_ok=0, + internet_ms=None, + dns_ok=0, + dns_ms=None, + http_ok=0, + http_ms=None, + ) + self.assertEqual( + monitor.classify(sample, gateway_probe_active=False), + "INTERNET_UNREACHABLE", + ) + + def test_link_down_has_highest_priority(self): + sample = self.sample(carrier=0) + self.assertEqual(monitor.classify(sample), "NETWORK_LINK_DOWN") + + def test_wan_session_down_is_detected_with_router_telemetry(self): + sample = self.sample(wan_status="Disconnected") + self.assertEqual(monitor.classify(sample), "WAN_SESSION_DOWN") + + def test_dns_failure_is_separate_from_internet_outage(self): + sample = self.sample(dns_ok=0, dns_ms=None) + self.assertEqual(monitor.classify(sample), "DNS_FAILURE") + + def test_http_failure_is_separate_from_internet_outage(self): + sample = self.sample(http_ok=0, http_ms=None) + self.assertEqual(monitor.classify(sample), "HTTP_CONNECTIVITY_FAILURE") + + @patch.object( + monitor.subprocess, + "check_output", + return_value="default via 192.168.178.1 dev enp3s0 proto dhcp src 192.168.178.20\n", + ) + def test_default_route_detects_gateway_and_interface(self, _): + self.assertEqual(monitor.default_route(), ("192.168.178.1", "enp3s0")) + + def test_close_event_uses_explicit_end_timestamp(self): + conn = sqlite3.connect(":memory:") + conn.execute(""" + CREATE TABLE events( + id INTEGER PRIMARY KEY, + start_ts TEXT, + end_ts TEXT, + duration_s REAL, + event_type TEXT, + details_json TEXT + ) + """) + + start = "2026-09-05T18:00:00+02:00" + end = "2026-09-05T18:00:15+02:00" + + event_id = monitor.add_event( + conn, + "NETWORK_LINK_DOWN", + {}, + start=start, + ) + + monitor.close_event( + conn, + event_id, + {"duration_s": 15.0}, + 15.0, + end=end, + ) + + row = conn.execute( + "SELECT start_ts, end_ts, duration_s FROM events WHERE id=?", + (event_id,), + ).fetchone() + + self.assertEqual(row[0], start) + self.assertEqual(row[1], end) + self.assertEqual(row[2], 15.0) + conn.close() + + +if __name__ == "__main__": + unittest.main()