Skip to content

noxied/wanwatcher

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

93 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

WANwatcher Banner

WANwatcher

Monitors your WAN IPv4/IPv6 addresses and tells you when they change.

Docker Hub Docker Pulls GitHub release GitHub Stars OpenSSF Scorecard License: MIT

WANwatcher is a small Docker container that periodically checks your public IPv4 and IPv6 addresses against several detection services. When an address changes it can notify you (Discord, Telegram, email, or anything Apprise supports), update DNS records (Cloudflare, DuckDNS, Route53, dyndns2), publish the state over MQTT for Home Assistant, and expose a status API with Prometheus metrics. It is aimed at homelabs and small servers on residential connections where the ISP changes your IP whenever it feels like it.

Features

  • IPv4 and IPv6 monitoring, each can be turned off independently
  • Multiple IP detection sources, tried in rotating order so one broken service never blocks detection
  • Change confirmation: a new IP is verified against a second source before you get notified
  • Notifications via Discord webhooks, Telegram bots, SMTP email, and Apprise (100+ services: ntfy, Gotify, Pushover, Slack, Matrix, ...)
  • Built-in dynamic DNS updates for Cloudflare, DuckDNS, AWS Route53, and any dyndns2-compatible provider (No-IP, Dynu, ...)
  • HTTP status API with /healthz, /api/status, and Prometheus /metrics
  • MQTT publishing with Home Assistant auto-discovery
  • Startup notice, optional heartbeat, and internet outage detection with a recovery notification
  • Optional geographic info for the new IP via ipinfo.io
  • Graceful shutdown on SIGTERM, atomic state file writes, retries with backoff, change history in the state file
  • Runs as a non-root user (uid 1000), multi-arch image (AMD64 and ARM64)

Quick start

You need at least one notification method enabled. The minimal Discord setup:

docker run -d \
  --name wanwatcher \
  --restart unless-stopped \
  -e DISCORD_ENABLED="true" \
  -e DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN" \
  -e SERVER_NAME="My Server" \
  -v ./data:/data \
  -v ./logs:/logs \
  noxied/wanwatcher:2.5.0

Or with compose:

services:
  wanwatcher:
    image: noxied/wanwatcher:2.5.0
    container_name: wanwatcher
    restart: unless-stopped
    environment:
      SERVER_NAME: "My Server"
      CHECK_INTERVAL: "900"
      DISCORD_ENABLED: "true"
      DISCORD_WEBHOOK_URL: "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"
    volumes:
      - ./data:/data
      - ./logs:/logs

Note: the container runs as uid 1000, so the host directories mounted on /data and /logs must be writable by that user:

mkdir -p data logs
sudo chown -R 1000:1000 data logs

The repository's docker-compose.yml lists every option with comments.

Configuration

Everything is configured through environment variables. Booleans are the string "true" (anything else means false). Lists are comma separated.

General

Variable Default Description
SERVER_NAME WANwatcher Docker Name shown in notifications
BOT_NAME WANwatcher Display name used by notifiers
CHECK_INTERVAL 900 Seconds between IP checks
MONITOR_IPV4 true Monitor the public IPv4 address
MONITOR_IPV6 true Monitor the public IPv6 address
IP_CHANGE_CONFIRMATION true Confirm a detected change with a second source before acting on it
IPINFO_TOKEN (empty) Optional ipinfo.io token for geographic data
HTTP_TIMEOUT 10 Timeout in seconds for outbound HTTP requests
IP_DB_FILE /data/ipinfo.db State file path
LOG_FILE /logs/wanwatcher.log Log file path
LOG_FORMAT text text for human-readable logs, or json for structured logs (one JSON object per line, UTC timestamps) for log aggregators like Loki or Datadog

Discord

Variable Default Description
DISCORD_ENABLED false Enable Discord notifications
DISCORD_WEBHOOK_URL (empty) Webhook URL
DISCORD_AVATAR_URL (empty) Optional custom avatar; the webhook's own avatar is used when empty

Telegram

Variable Default Description
TELEGRAM_ENABLED false Enable Telegram notifications
TELEGRAM_BOT_TOKEN (empty) Bot token from @BotFather
TELEGRAM_CHAT_ID (empty) Chat or channel id
TELEGRAM_PARSE_MODE HTML HTML or Markdown

Email (SMTP)

Variable Default Description
EMAIL_ENABLED false Enable email notifications
EMAIL_SMTP_HOST (empty) SMTP server, e.g. smtp.gmail.com
EMAIL_SMTP_PORT 587 587 for TLS, 465 for SSL
EMAIL_SMTP_USER (empty) SMTP username
EMAIL_SMTP_PASSWORD (empty) SMTP password (use an app password for Gmail)
EMAIL_FROM (empty) Sender address
EMAIL_TO (empty) Recipients, comma separated
EMAIL_USE_TLS true STARTTLS
EMAIL_USE_SSL false Implicit SSL (do not enable both TLS and SSL)
EMAIL_SUBJECT_PREFIX [WANwatcher] Subject prefix

Apprise

Variable Default Description
APPRISE_ENABLED false Enable Apprise notifications
APPRISE_URLS (empty) Comma-separated Apprise URLs

Dynamic DNS

Variable Default Description
DDNS_ENABLED false Enable DNS updates on IP change
DDNS_PROVIDER (empty) cloudflare, duckdns, dyndns2, or route53
CLOUDFLARE_API_TOKEN (empty) API token with Zone.DNS edit permission
CLOUDFLARE_ZONE (empty) Zone name, e.g. example.com
CLOUDFLARE_RECORDS (empty) Records to update, e.g. home.example.com,vpn.example.com
CLOUDFLARE_PROXIED false Whether updated records go through the Cloudflare proxy
CLOUDFLARE_TTL 1 Record TTL; 1 means automatic
DUCKDNS_TOKEN (empty) DuckDNS account token
DUCKDNS_DOMAINS (empty) Subdomain names, comma separated
DYNDNS2_SERVER (empty) Update server, e.g. https://dynupdate.no-ip.com
DYNDNS2_USERNAME (empty) Username
DYNDNS2_PASSWORD (empty) Password
DYNDNS2_HOSTNAMES (empty) Hostnames, comma separated
ROUTE53_ACCESS_KEY_ID (empty) AWS access key id
ROUTE53_SECRET_ACCESS_KEY (empty) AWS secret access key
ROUTE53_HOSTED_ZONE_ID (empty) Hosted zone id, e.g. Z1234567890ABC
ROUTE53_RECORDS (empty) Records to update, comma separated
ROUTE53_TTL 300 Record TTL in seconds

Status API

Variable Default Description
API_ENABLED false Enable the HTTP status API
API_BIND 0.0.0.0 Bind address
API_PORT 8080 Port (remember to publish it)

MQTT

Variable Default Description
MQTT_ENABLED false Enable MQTT publishing
MQTT_HOST (empty) Broker hostname
MQTT_PORT 1883 Broker port
MQTT_USERNAME (empty) Username
MQTT_PASSWORD (empty) Password
MQTT_CLIENT_ID wanwatcher Client id
MQTT_TOPIC_PREFIX wanwatcher Topic prefix
MQTT_TLS false TLS connection to the broker
MQTT_HA_DISCOVERY true Publish Home Assistant discovery configs
MQTT_HA_DISCOVERY_PREFIX homeassistant HA discovery prefix

Events

Variable Default Description
NOTIFY_ON_STARTUP true Send a message when the container starts
HEARTBEAT_ENABLED false Periodic "still alive" message
HEARTBEAT_INTERVAL 86400 Seconds between heartbeats
OUTAGE_DETECTION_ENABLED true Notify when connectivity drops and when it returns
OUTAGE_THRESHOLD 3 Consecutive failed checks before declaring an outage

Update check

Variable Default Description
UPDATE_CHECK_ENABLED true Check GitHub for new releases
UPDATE_CHECK_INTERVAL 86400 Seconds between checks
UPDATE_CHECK_ON_STARTUP true Also check at startup

Secrets from files

Every sensitive value can be read from a file instead of a plain environment variable, using the <NAME>_FILE convention. This is how Docker secrets and Kubernetes secret mounts are meant to be consumed. Set <NAME>_FILE to the path of a file and its contents are used (trailing whitespace is trimmed); a direct <NAME> variable, if also set, takes precedence. A _FILE path that does not exist stops the container at startup with a clear error.

Supported: DISCORD_WEBHOOK_URL_FILE, TELEGRAM_BOT_TOKEN_FILE, EMAIL_SMTP_PASSWORD_FILE, CLOUDFLARE_API_TOKEN_FILE, DUCKDNS_TOKEN_FILE, DYNDNS2_PASSWORD_FILE, MQTT_PASSWORD_FILE, IPINFO_TOKEN_FILE, APPRISE_URLS_FILE.

services:
  wanwatcher:
    image: noxied/wanwatcher:2.5.0
    environment:
      DISCORD_ENABLED: "true"
      DISCORD_WEBHOOK_URL_FILE: /run/secrets/discord_webhook
    secrets:
      - discord_webhook
secrets:
  discord_webhook:
    file: ./secrets/discord_webhook.txt

Notifications

At least one notifier must be enabled or the container refuses to start.

Discord

Create a webhook under Server Settings > Integrations > Webhooks and set DISCORD_ENABLED=true and DISCORD_WEBHOOK_URL. Messages are sent as embeds. To customize the avatar, set it on the webhook in Discord or point DISCORD_AVATAR_URL at a public image.

Telegram

Create a bot with @BotFather, get your chat id (for example from @userinfobot), send /start to your bot once, then set TELEGRAM_ENABLED, TELEGRAM_BOT_TOKEN, and TELEGRAM_CHAT_ID.

Email

Standard SMTP. For Gmail, enable 2FA and generate an app password, then:

EMAIL_ENABLED: "true"
EMAIL_SMTP_HOST: "smtp.gmail.com"
EMAIL_SMTP_PORT: "587"
EMAIL_SMTP_USER: "you@gmail.com"
EMAIL_SMTP_PASSWORD: "your-app-password"
EMAIL_FROM: "you@gmail.com"
EMAIL_TO: "you@gmail.com"

Apprise

One setting covers everything Apprise supports. Some example URLs:

APPRISE_ENABLED: "true"
# ntfy topic
APPRISE_URLS: "ntfy://ntfy.sh/my-topic"
# multiple services, comma separated
# APPRISE_URLS: "pover://USER_KEY@APP_TOKEN,gotify://gotify.example.com/APP_TOKEN"

See the Apprise URL list for every supported service.

Dynamic DNS

When DDNS_ENABLED=true, WANwatcher updates your DNS records whenever the IP changes (and retries failed updates on the next check). One provider at a time, selected by DDNS_PROVIDER.

Cloudflare (token needs Zone.DNS edit permission for the zone):

DDNS_ENABLED: "true"
DDNS_PROVIDER: "cloudflare"
CLOUDFLARE_API_TOKEN: "your-token"
CLOUDFLARE_ZONE: "example.com"
CLOUDFLARE_RECORDS: "home.example.com"

DuckDNS:

DDNS_ENABLED: "true"
DDNS_PROVIDER: "duckdns"
DUCKDNS_TOKEN: "your-token"
DUCKDNS_DOMAINS: "myhome"

Generic dyndns2 (No-IP, Dynu, and most router-supported providers):

DDNS_ENABLED: "true"
DDNS_PROVIDER: "dyndns2"
DYNDNS2_SERVER: "https://dynupdate.no-ip.com"
DYNDNS2_USERNAME: "user"
DYNDNS2_PASSWORD: "pass"
DYNDNS2_HOSTNAMES: "home.example.com"

AWS Route53 (the credentials need route53:ChangeResourceRecordSets on the hosted zone; signed with SigV4, no AWS SDK is bundled):

DDNS_ENABLED: "true"
DDNS_PROVIDER: "route53"
ROUTE53_ACCESS_KEY_ID: "AKIA..."
ROUTE53_SECRET_ACCESS_KEY: "your-secret"
ROUTE53_HOSTED_ZONE_ID: "Z1234567890ABC"
ROUTE53_RECORDS: "home.example.com"
ROUTE53_TTL: "300"

Status API

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

  • 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
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.

MQTT and Home Assistant

With MQTT_ENABLED=true the current state is published as retained messages under the topic prefix (default wanwatcher):

  • wanwatcher/ipv4, wanwatcher/ipv6, wanwatcher/last_change
  • wanwatcher/state (JSON with IPs, geo data, and server name)
  • wanwatcher/availability (online/offline, also set as the MQTT will)

With MQTT_HA_DISCOVERY=true (the default) Home Assistant discovery configs are published, so a device with WAN IPv4, WAN IPv6, and last-change sensors shows up automatically once HA is connected to the same broker.

Events

Besides IP change notifications, WANwatcher can send:

  • a startup notice when the container starts (NOTIFY_ON_STARTUP, on by default)
  • a periodic heartbeat so you know the monitor itself is alive (HEARTBEAT_ENABLED, off by default)
  • an outage notice after OUTAGE_THRESHOLD consecutive failed checks, and a recovery notice when connectivity returns (OUTAGE_DETECTION_ENABLED, on by default; the outage notice is delivered once the connection is back, since nothing can be sent during the outage)
  • an update notice when a new WANwatcher release is available (UPDATE_CHECK_ENABLED)

Volumes and ports

Mount Purpose
/data State file with the current IPs and change history (persistent)
/logs Log files

Both must be writable by uid 1000 (the user the container runs as). Port 8080 only needs publishing when API_ENABLED=true.

The image is built for linux/amd64 and linux/arm64, so it runs on regular servers as well as a Raspberry Pi 4 or newer, Apple Silicon, and AWS Graviton. Docker pulls the right variant automatically.

Monitoring and logs

Follow the logs:

docker logs -f wanwatcher

Check the container health (the image ships a healthcheck):

docker inspect --format='{{json .State.Health}}' wanwatcher

Read the current state directly:

docker exec wanwatcher cat /data/ipinfo.db

With API_ENABLED=true you can also poll /api/status and scrape /metrics (see the Status API section).

Troubleshooting

The container exits right after starting

  • Configuration validation failed. Run docker logs wanwatcher and read the validation errors. Common causes are no notifier enabled, an invalid webhook URL, or a missing required variable.

Permission denied on /data or /logs (after upgrading to 2.0)

  • The container runs as uid 1000. Fix the host directories with sudo chown -R 1000:1000 ./data ./logs.

Notifications are not arriving

  • Failed sends are retried three times with backoff. Check docker logs wanwatcher | grep -i "notification\|retry" and confirm the credentials.

IPv6 is never detected

  • Confirm the host actually has IPv6 connectivity and that MONITOR_IPV6="true".

DNS records are not updating

  • Confirm DDNS_ENABLED=true, the provider is spelled correctly, and the token or credentials are valid. The logs show the result of each update attempt.

The full guide is in docs/TROUBLESHOOTING.md.

Upgrading from 1.x

All v1 environment variables keep working and the state file is migrated automatically. The one breaking change: the container now runs as uid 1000, so your /data and /logs volumes must be writable by that user (sudo chown -R 1000:1000 ./data ./logs). See UPGRADING.md for details.

Development

git clone https://github.com/noxied/wanwatcher.git
cd wanwatcher
pip install -r requirements-dev.txt

# tests
pytest tests/ -v --cov

# lint and format
black wanwatcher/ tests/
isort wanwatcher/ tests/
flake8 wanwatcher/
mypy wanwatcher/ --ignore-missing-imports

# run locally
python -m wanwatcher

# build the image
docker build -t wanwatcher:dev .

See CONTRIBUTING.md for guidelines, including how to add a notification or DDNS provider.

Documentation

License

MIT, see LICENSE.

About

WAN IP monitoring with Discord, Telegram, Email alerts. Lightweight & Docker-ready for homelabs!

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Contributors