Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[flake8]
# Black owns formatting and line wrapping (88 cols). Long, unsplittable lines
# (inline HTML templates, URLs) are left to Black, so E501 is not enforced here;
# E203 and W503 are disabled for Black compatibility.
max-line-length = 88
extend-ignore = E203, W503, E501
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,12 @@ updates:
labels:
- "dependencies"
- "docker"

# Python (pinned runtime deps in requirements.txt and dev tools)
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "python"
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@ jobs:
- name: Run Flake8 (style guide)
run: flake8 wanwatcher tests scripts --count --select=E9,F63,F7,F82 --show-source --statistics

- name: Run Flake8 (complexity)
run: flake8 wanwatcher tests scripts --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Run Flake8 (full, config-driven)
run: flake8 wanwatcher tests scripts --count --exit-zero --max-complexity=10 --statistics

- name: Run Pylint
run: pylint wanwatcher --exit-zero --max-line-length=127
run: pylint wanwatcher --exit-zero --max-line-length=88

- name: Run MyPy (type checking)
run: mypy wanwatcher --ignore-missing-imports --no-strict-optional
run: mypy wanwatcher --ignore-missing-imports

# ============================================================================
# Security Scanning
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.5.0] - 2026-06-13

Reliability and code-quality hardening from the comprehensive review. No
configuration changes are required.

### Changed

- `/healthz` now returns 503 when the monitor loop has gone stale (no successful
check within a generous multiple of `CHECK_INTERVAL`) instead of always 200,
so a wedged loop is reported unhealthy. `/api/status` gains
`seconds_since_last_check` and `check_interval`.
- Notification, DDNS, and MQTT failures during a check are now logged with stack
traces and no longer counted as check failures; only detection failures drive
outage detection and the adaptive backoff.
- The status snapshot served to the API is taken under a lock, preventing
inconsistent reads while the loop updates state.

### Fixed

- A failed geo lookup no longer overwrites previously known geographic data.

### Internal

- mypy now runs with strict optional checking (removed `--no-strict-optional`);
flake8 line length is aligned to 88 via a `.flake8` config; Dependabot now
tracks pip dependencies.

## [2.4.1] - 2026-06-13

### Security
Expand Down
12 changes: 7 additions & 5 deletions DOCKER_HUB_DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ aimed at homelabs and small servers on connections where the ISP changes your IP

## Recent releases

- 2.5.0: reliability hardening (stuck-loop /healthz, isolated notifier failures, locked status reads).
- 2.4.1: security fix - escape untrusted geo/release-note strings in notifications.
- 2.3.0: AWS Route53 DDNS provider (SigV4, no AWS SDK bundled).
- 2.2.0: secrets from files (`<NAME>_FILE`), plus Trivy scanning, CycloneDX SBOM and Cosign keyless image signing.
Expand Down Expand Up @@ -67,8 +68,8 @@ Operations

| Architecture | Tags | Status |
|--------------|------|--------|
| x86-64 (AMD64) | `latest`, `2.4.1` | Supported |
| ARM64 (aarch64) | `latest`, `2.4.1` | Supported |
| x86-64 (AMD64) | `latest`, `2.5.0` | Supported |
| ARM64 (aarch64) | `latest`, `2.5.0` | Supported |

Docker pulls the correct image for your platform automatically. ARM64 covers
Raspberry Pi 4 and newer, Apple Silicon, and AWS Graviton.
Expand All @@ -95,15 +96,15 @@ docker run -d \
-e SERVER_NAME="My Server" \
-v ./data:/data \
-v ./logs:/logs \
noxied/wanwatcher:2.4.1
noxied/wanwatcher:2.5.0
```

### docker compose

```yaml
services:
wanwatcher:
image: noxied/wanwatcher:2.4.1
image: noxied/wanwatcher:2.5.0
container_name: wanwatcher
restart: unless-stopped
environment:
Expand Down Expand Up @@ -345,7 +346,7 @@ docker buildx build \

| Tag | Meaning |
|-----|---------|
| `2.4.1` | This exact release |
| `2.5.0` | This exact release |
| `2.0` | Latest 2.0.x patch |
| `2` | Latest 2.x release |
| `latest` | Latest stable release |
Expand All @@ -356,6 +357,7 @@ docker buildx build \

| Version | Date | Highlights |
|---------|------|------------|
| 2.5.0 | 2026-06-13 | Reliability hardening (/healthz staleness, isolated side-effect failures) |
| 2.4.1 | 2026-06-13 | Security: escape untrusted strings in notifications |
| 2.3.0 | 2026-06-13 | AWS Route53 DDNS provider |
| 2.2.0 | 2026-06-13 | Secrets from files, Trivy/SBOM/Cosign supply-chain security |
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

LABEL maintainer="noxied"
LABEL description="WAN IP monitoring with notifications, DDNS updates and Home Assistant integration"
LABEL version="2.4.1"
LABEL version="2.5.0"
LABEL org.opencontainers.image.title="WANwatcher"
LABEL org.opencontainers.image.description="Monitor WAN IPv4/IPv6 addresses with notifications, DDNS and MQTT"
LABEL org.opencontainers.image.version="2.4.1"
LABEL org.opencontainers.image.version="2.5.0"
LABEL org.opencontainers.image.authors="noxied"
LABEL org.opencontainers.image.url="https://github.com/noxied/wanwatcher"
LABEL org.opencontainers.image.source="https://github.com/noxied/wanwatcher"
Expand All @@ -26,7 +26,7 @@
&& mkdir -p /data /logs \
&& chown -R wanwatcher:wanwatcher /data /logs /app

ENV DISCORD_ENABLED="false" \

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build Docker Image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "DYNDNS2_PASSWORD") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build Docker Image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "DUCKDNS_TOKEN") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build Docker Image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "TELEGRAM_BOT_TOKEN") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build Docker Image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "IPINFO_TOKEN") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build Docker Image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "MQTT_PASSWORD") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build Docker Image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "ROUTE53_SECRET_ACCESS_KEY") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build Docker Image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "ROUTE53_ACCESS_KEY_ID") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build Docker Image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "CLOUDFLARE_API_TOKEN") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / Build Docker Image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "EMAIL_SMTP_PASSWORD") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
DISCORD_WEBHOOK_URL="" \
DISCORD_AVATAR_URL="" \
TELEGRAM_ENABLED="false" \
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ docker run -d \
-e SERVER_NAME="My Server" \
-v ./data:/data \
-v ./logs:/logs \
noxied/wanwatcher:2.4.1
noxied/wanwatcher:2.5.0
```

Or with compose:

```yaml
services:
wanwatcher:
image: noxied/wanwatcher:2.4.1
image: noxied/wanwatcher:2.5.0
container_name: wanwatcher
restart: unless-stopped
environment:
Expand Down Expand Up @@ -214,7 +214,7 @@ Supported: `DISCORD_WEBHOOK_URL_FILE`, `TELEGRAM_BOT_TOKEN_FILE`,
```yaml
services:
wanwatcher:
image: noxied/wanwatcher:2.4.1
image: noxied/wanwatcher:2.5.0
environment:
DISCORD_ENABLED: "true"
DISCORD_WEBHOOK_URL_FILE: /run/secrets/discord_webhook
Expand Down Expand Up @@ -316,15 +316,17 @@ ROUTE53_TTL: "300"

Set `API_ENABLED=true` and publish the port (`-p 8080:8080`). Endpoints:

- `GET /healthz` returns `{"status": "ok", ...}` when the loop is healthy
- `GET /api/status` returns the full state: current IPs, last check, last change, uptime
- `GET /healthz` returns `{"status": "ok", ...}` (200) when the loop is healthy, or `{"status": "stale", ...}` (503) if no successful check has happened within a generous multiple of `CHECK_INTERVAL`, so a wedged loop is detectable
- `GET /api/status` returns the full state: current IPs, last check, `seconds_since_last_check`, `check_interval`, last change, uptime, and recent change history
- `GET /metrics` returns Prometheus metrics

```bash
curl http://localhost:8080/api/status
curl http://localhost:8080/metrics
```

Geographic data in `/api/status` (and over MQTT) reflects the most recent IP change, since the lookup only runs when the address changes; it is null until the first change is recorded.

Exported metrics include `wanwatcher_checks_total`, `wanwatcher_check_failures_total`, `wanwatcher_ip_changes_total`, `wanwatcher_notifications_total`, `wanwatcher_ddns_updates_total`, `wanwatcher_last_change_timestamp_seconds`, `wanwatcher_last_check_timestamp_seconds`, and `wanwatcher_up`. A Prometheus scrape job pointed at `wanwatcher:8080` works as-is; no extra exporter needed.

When the API is enabled, the container healthcheck queries `/healthz`. Otherwise it verifies that the state file exists, is valid JSON, and was refreshed recently.
Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Environment variables:

```bash
export DISCORD_WEBHOOK_URL="your_webhook"
docker run -e DISCORD_WEBHOOK_URL ... noxied/wanwatcher:2.4.1
docker run -e DISCORD_WEBHOOK_URL ... noxied/wanwatcher:2.5.0
```

A `.env` file (add it to `.gitignore`):
Expand Down Expand Up @@ -71,7 +71,7 @@ Published images are signed with Cosign using keyless signing. You can verify
that an image came from this repository's CI before running it:

```bash
cosign verify noxied/wanwatcher:2.4.1 \
cosign verify noxied/wanwatcher:2.5.0 \
--certificate-identity-regexp "https://github.com/noxied/wanwatcher/.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
```
Expand Down Expand Up @@ -105,7 +105,7 @@ Reasonable extras for the paranoid:
```yaml
services:
wanwatcher:
image: noxied/wanwatcher:2.4.1 # pin a version, avoid :latest
image: noxied/wanwatcher:2.5.0 # pin a version, avoid :latest
security_opt:
- no-new-privileges:true
deploy:
Expand Down
20 changes: 20 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

Version-specific upgrade notes. The newest upgrade path is at the top.

## 2.4.x to 2.5.0

No configuration changes. Pull the new image and restart:

```bash
docker compose pull
docker compose up -d
```

What changed:

- `/healthz` now reports 503 when the monitoring loop is stale (no successful
check within a generous multiple of `CHECK_INTERVAL`). If you run with
`API_ENABLED=true`, the container healthcheck will now correctly mark a wedged
loop as unhealthy instead of healthy. `/api/status` adds
`seconds_since_last_check` and `check_interval`.
- Notifier, DDNS, and MQTT errors are no longer counted as check failures, so
`wanwatcher_check_failures_total` now reflects only IP-detection failures.
- Everything else is internal hardening; there is nothing to change.

## 2.4.0 to 2.4.1

A security patch with no configuration changes. Pull the new image and restart:
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
wanwatcher:
image: noxied/wanwatcher:2.4.1
image: noxied/wanwatcher:2.5.0
container_name: wanwatcher
restart: unless-stopped

Expand Down
2 changes: 1 addition & 1 deletion docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ creating datasets for `/data` and `/logs`.
Synology: create the shared folders in File Station first, give uid 1000
write access, and use absolute paths in Container Manager.

Raspberry Pi: the published image is multi-arch; `noxied/wanwatcher:2.4.1`
Raspberry Pi: the published image is multi-arch; `noxied/wanwatcher:2.5.0`
pulls the ARM64 variant automatically on a 64-bit OS. 32-bit ARM is not
published; build locally if you need it.

Expand Down
51 changes: 51 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,54 @@ def test_stop_after_failed_start_is_safe():
srv.stop() # must not raise
finally:
blocker.close()


# -- /healthz staleness -------------------------------------------------------


def _serve(provider):
port = _free_port()
srv = StatusServer(
APIConfig(enabled=True, bind="127.0.0.1", port=port),
status_provider=provider,
metrics=Metrics(),
)
srv.start()
time.sleep(0.3)
return srv, port


def test_healthz_ok_when_fresh():
srv, port = _serve(
lambda: {"seconds_since_last_check": 12.0, "check_interval": 900}
)
try:
status, _, body = _get(port, "/healthz")
assert status == 200
assert json.loads(body)["status"] == "ok"
finally:
srv.stop()


def test_healthz_ok_before_first_check():
srv, port = _serve(
lambda: {"seconds_since_last_check": None, "check_interval": 900}
)
try:
status, _, _ = _get(port, "/healthz")
assert status == 200
finally:
srv.stop()


def test_healthz_stale_returns_503():
srv, port = _serve(
lambda: {"seconds_since_last_check": 999999, "check_interval": 900}
)
try:
with pytest.raises(urllib.error.HTTPError) as exc:
_get(port, "/healthz")
assert exc.value.code == 503
assert json.loads(exc.value.read())["status"] == "stale"
finally:
srv.stop()
65 changes: 65 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,68 @@ def test_heartbeat_emitted_when_due(config):
app.maybe_heartbeat()
app.notifications.notify_event.assert_called_once()
assert app.notifications.notify_event.call_args.args[0] == "Heartbeat"


def test_status_snapshot_has_freshness_fields(app):
app.detector.get_ipv4.return_value = "1.2.3.4"
app.check_ip()
snap = app.status_snapshot()
assert snap["check_interval"] == app.config.check_interval
assert isinstance(snap["seconds_since_last_check"], float)
assert snap["seconds_since_last_check"] >= 0


def test_notification_exception_is_isolated(app):
# A notifier blowing up must not count as a check failure or trigger outage.
app.detector.get_ipv4.return_value = "1.2.3.4"
app.notifications.send_to_all.side_effect = RuntimeError("notifier exploded")
result = app.check_ip()
assert result is True # detection succeeded
assert app.consecutive_failures == 0 # no false outage
counters = app.metrics._counters
failures = sum(
v for (n, _), v in counters.items() if n == "wanwatcher_check_failures_total"
)
assert failures == 0


def test_geo_not_clobbered_by_failed_lookup(app, monkeypatch):
app.detector.get_ipv4.return_value = "1.2.3.4"
monkeypatch.setattr(
"wanwatcher.app.get_geo_data", lambda *a, **k: {"city": "Lisbon"}
)
app.check_ip() # first run, geo set
assert app.geo_data == {"city": "Lisbon"}
# A later change whose geo lookup fails must keep the previous geo.
monkeypatch.setattr("wanwatcher.app.get_geo_data", lambda *a, **k: None)
app.detector.get_ipv4.return_value = "5.6.7.8"
app.check_ip()
assert app.geo_data == {"city": "Lisbon"}


def test_status_snapshot_concurrent_with_check_is_consistent(app):
import threading

app.detector.get_ipv4.return_value = "1.2.3.4"
app.check_ip()
errors = []

def reader():
for _ in range(100):
try:
snap = app.status_snapshot()
assert isinstance(snap["history"], list)
except Exception as exc: # noqa: BLE001
errors.append(exc)

def writer():
for i in range(100):
app.detector.get_ipv4.return_value = f"1.2.3.{i % 254 + 1}"
app.check_ip()

t1, t2 = threading.Thread(target=reader), threading.Thread(target=writer)
t1.start()
t2.start()
t1.join(5)
t2.join(5)
assert not errors
2 changes: 0 additions & 2 deletions tests/test_notifier_escaping.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
from email.message import Message
from unittest.mock import Mock, patch

import pytest

from wanwatcher.notifiers import DiscordNotifier, EmailNotifier, TelegramNotifier
from wanwatcher.notifiers._escape import (
discord_escape,
Expand Down
2 changes: 1 addition & 1 deletion wanwatcher/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""WANwatcher - WAN IP address monitor with notifications, DDNS and integrations."""

VERSION = "2.4.1"
VERSION = "2.5.0"

__version__ = VERSION
Loading
Loading