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 @@
[](https://github.com/LucaXTech/LineWatch/actions/workflows/ci.yml)

-
-
+
+
+
-**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
-## 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.
-
-
-
-
Quando
Evento
Durata
-
-
-
+
Cronologia eventi
Descrizioni leggibili + classificazione tecnica salvata nel database.