diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index beba107..2eebdeb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,24 +20,16 @@ repos: args: ["--fix"] files: "\\.py$" - # Use a local pylint hook that runs the pylint executable from your active - # Python environment (system or virtualenv). This avoids pre-commit needing - # to fetch and checkout a remote mirrors-pylint tag which can fail in some - # environments. If you later want pre-commit to manage pylint, run - # `pre-commit autoupdate --repo https://github.com/pre-commit/mirrors-pylint` - # locally and update this config with the pinned rev it produces. - - repo: local + # Use a pre-commit managed pylint environment. This is more robust than + # relying on a system-installed pylint. + - repo: https://github.com/pylint-dev/pylint + rev: v3.2.2 hooks: - id: pylint - name: pylint (system) - entry: pylint - language: system - types: [python] + # Add dependencies from requirements.txt so pylint can find them. + additional_dependencies: ["requests", "apprise"] # ShellCheck pre-commit hook (koalaman). Pin to a branch or tag you prefer. - # Pin the koalaman shellcheck pre-commit hook to a concrete tag. If this - # rev doesn't exist for you, run `pre-commit autoupdate --repo https://github.com/koalaman/shellcheck-precommit` - # locally to find a valid immutable tag and commit the updated rev. - repo: local hooks: - id: shellcheck @@ -45,12 +37,3 @@ repos: entry: shellcheck language: system files: "\\.(sh|bash|ksh)$" - - # NOTE: shellcheck hook removed because mirrors for shellcheck can sometimes - # be unavailable in some environments; please install `shellcheck` locally - # (brew install shellcheck) and run `shellcheck` in CI if desired. - - # NOTE: pylint and mypy were removed from this default config to avoid - # heavy environment setup in `pre-commit` on initial install. If you want - # stricter static checks enable them and pin correct `rev` values, e.g. - # mirrors-pylint at a valid pylint release. diff --git a/README.md b/README.md index 8224982..dbc4450 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # DDNS updater for Cloudflare -This repository contains a small DDNS updater script that updates A records in -a Cloudflare zone to match the host's public IP address. +This repository contains a robust DDNS updater script that updates A records +in a Cloudflare zone to match the host's public IP address. It includes a +feature-rich runner script with venv management, locking, and email +notifications. Rationale --------- -If you host a web server and proxy the site through Cloudflare, DNS records -point to the server's public IP. When the server IP changes (reboot, DHCP, -cloud VM migration), DNS can become stale. This script queries the current -public IP and updates all matching A records for a given name in a Cloudflare -zone so DNS stays correct. +When self-hosting services behind Cloudflare, DNS records must point to your +server's public IP. If this IP changes (e.g., due to a DHCP lease renewal or +server migration), DNS becomes stale. This script automates the process of +keeping your Cloudflare DNS records in sync with your dynamic public IP. Security -------- @@ -21,77 +22,96 @@ Security Files ----- - `ddns.py` — main Python script (dry-run by default; reads token from env). -- `run_ddns.sh` — helper that creates/activates a `venv` and runs `ddns.py`. +- `run_ddns.sh` — Self-contained runner that manages a venv, installs dependencies, handles locking, and sends notifications. +- `requirements.txt` — Python dependencies (`requests`, `apprise`). - `cron.example` — suggested crontab line. - `ddns.service` / `ddns.timer` — systemd unit and timer examples. -Quick start +Quick Start ----------- -1. Create a virtual environment and install dependencies: - -```bash -cd ddns -python3 -m venv venv -venv/bin/python -m pip install --upgrade pip requests -chmod +x run_ddns.sh -``` - -2. Run the updater (dry-run default): - -```bash -export CLOUDFLARE_API_TOKEN="" -./run_ddns.sh --zone example.com --name host.example.com -``` - -To perform a real update (be careful): - -```bash -DDNS_DRY_RUN=0 ./run_ddns.sh --zone example.com --name host.example.com -``` +The `run_ddns.sh` script is designed to be self-contained. It will automatically +create a Python virtual environment (`venv`) and install dependencies on its +first run. + +1. Make the script executable: + ```bash + chmod +x run_ddns.sh + ``` + +2. Run the updater (this is a **dry-run** by default). Provide your token, zone, and record name. + ```bash + # Set your token in the environment + export CLOUDFLARE_API_TOKEN="" + + # Run the script with your zone and record name + ./run_ddns.sh --zone example.com --name host.example.com + ``` + +3. To perform a **real update**, set the `DDNS_DRY_RUN` environment variable to `0`. + ```bash + DDNS_DRY_RUN=0 ./run_ddns.sh --zone example.com --name host.example.com + ``` + +Usage +----- +The `run_ddns.sh` script accepts the following options. It supports both `--option value` and `--option=value` formats, making it resilient to different shell environments. -Usage and flags ---------------- -`run_ddns.sh` accepts these options (and forwards `--zone/--name` to `ddns.py`): -- `--token` / `-t` : Cloudflare API token (fallback: `CLOUDFLARE_API_TOKEN` env) -- `--zone` / `-z` : Cloudflare zone name (fallback: `DDNS_ZONE_NAME` env) -- `--name` / `-n` : DNS record name to update (fallback: `DDNS_DNS_NAME` env) +#### DDNS Options -The script supports both `--option value` and `--option=value` formats, making it resilient to different shell environments, including restrictive cron job runners. +| Flag | Environment Variable | Description | +|---|---|---| +| `--token, -t` | `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token. | +| `--zone, -z` | `DDNS_ZONE_NAME` | The Cloudflare zone name (e.g., `example.com`). | +| `--name, -n` | `DDNS_DNS_NAME` | The DNS record name to update (e.g., `host.example.com`). | -Examples --------- +#### Notification Options -Using CLI flags (preferred): +| Flag | Description | +|---|---| +| `--smtp HOST` | SMTP server for email notifications (e.g., `smtp.example.com:587`). | +| `--username EMAIL` | Username for SMTP authentication. | +| `--password PASS` | Password for SMTP authentication (use quotes for special characters). | +| `--recipient EMAIL` | Recipient's email address (if omitted, defaults to the `--username` email). | -```bash -./run_ddns.sh --token xxxxx --zone example.com --name host.example.com -``` +Email Notifications +------------------- +The script can send email notifications upon success or failure using Apprise. -Using environment variables: +**Notifications are sent only when:** +- A DNS record is successfully updated. +- An error occurs during the update process. -```bash -DDNS_ZONE_NAME=example.com DDNS_DNS_NAME=host.example.com CLOUDFLARE_API_TOKEN=xxxx ./run_ddns.sh -``` +**No notification is sent if the script runs and finds the IP address is already up-to-date.** -Backwards compatibility (positional token): +To enable notifications, provide the SMTP server, username, and password. +#### Example with Notifications ```bash -./run_ddns.sh xxxxx -# with env vars for zone/name -DDNS_ZONE_NAME=example.com DDNS_DNS_NAME=host.example.com ./run_ddns.sh xxxxx +DDNS_DRY_RUN=0 ./run_ddns.sh \ + --zone example.com \ + --name host.example.com \ + --smtp smtp.gmail.com:587 \ + --username my-email@gmail.com \ + --password "my-app-password" \ + --recipient notifications@example.com ``` -Tests ------ -There is a small test suite that validates argument/env validation for -`ddns.py` without making network calls. Run it with: +Exit Codes +---------- +The `ddns.py` script returns specific exit codes to indicate the outcome: -```bash -python3 -m unittest discover -v -``` +| Code | Meaning | Notification Sent? | +|---|---|---| +| `0` | Success (record was updated). | Yes (Success) | +| `2` | Configuration Error (API token missing). | Yes (Failure) | +| `3` | Network Error (failed to contact Cloudflare or IP service). | Yes (Failure) | +| `4` | Configuration Error (Zone not found). | Yes (Failure) | +| `6` | Configuration Error (Zone or DNS name missing). | Yes (Failure) | +| `7` | No Action Needed (IP address was already up-to-date). | **No** | Deployment notes ---------------- +- The `run_ddns.sh` script will automatically create a `venv` and install dependencies from `requirements.txt` if they are missing. - Prefer injecting `CLOUDFLARE_API_TOKEN` from your host's environment/secret store rather than embedding it in crontab or files. - Use the included `ddns.timer` / `ddns.service` example or `cron.example` to @@ -99,8 +119,12 @@ Deployment notes Token file option ----------------- -If you store the token on disk (less recommended), create `~/.cloudflare_token` -with strict permissions and ensure only the first line contains the token: +As an alternative to the environment variable, `run_ddns.sh` can read the token +from a file. The script checks these locations in order: +1. `$CLOUDFLARE_TOKEN_FILE` (if the environment variable is set) +2. `~/.cloudflare_token` + +Create the file with strict permissions: ```bash echo "" > ~/.cloudflare_token diff --git a/ddns.py b/ddns.py index 1697d80..0ec3869 100644 --- a/ddns.py +++ b/ddns.py @@ -144,73 +144,52 @@ def main() -> int: Returns an exit code suitable for `sys.exit`. """ + exit_code = 0 parser = argparse.ArgumentParser(description='Simple DDNS updater for Cloudflare') parser.add_argument('--zone', '-z', help='Cloudflare zone name (e.g. example.com)') parser.add_argument('--name', '-n', help='DNS record name to update (e.g. host.example.com)') args = parser.parse_args() - # Determine zone and dns name: CLI args preferred, then environment variables - zone_name = args.zone or os.getenv('DDNS_ZONE_NAME') - dns_name = args.name or os.getenv('DDNS_DNS_NAME') - - exit_code = 0 + try: + # Determine zone and dns name: CLI args preferred, then environment variables + zone_name = args.zone or os.getenv('DDNS_ZONE_NAME') + dns_name = args.name or os.getenv('DDNS_DNS_NAME') - if not CLOUDFLARE_API_TOKEN: - logger.error('CLOUDFLARE_API_TOKEN not set') - return 2 + # --- Validation Guard Clauses --- + if not CLOUDFLARE_API_TOKEN: + logger.error('CLOUDFLARE_API_TOKEN not set') + return 2 - if not zone_name or not dns_name: - logger.error('Zone and DNS name must be provided') - return 6 + if not zone_name or not dns_name: + logger.error('Zone and DNS name must be provided') + return 6 - try: zone_id = get_zone_id(zone_name) - except requests.exceptions.RequestException as e: - logger.error('Error querying zones: %s', e) - return 3 - - if not zone_id: - logger.error('Zone not found') - return 4 + if not zone_id: + logger.error('Zone not found') + return 4 - try: + # --- Main Logic --- records = get_dns_records(zone_id, dns_name, 'A') - except requests.exceptions.RequestException as e: - logger.error('Error querying DNS records: %s', e) - return 5 - - new_ip = get_public_ip() + if not records: + logger.info('No A record found for %s in zone %s', dns_name, zone_name) + return 0 - any_updated = False - if not records: - logger.info( - 'No A record found for %s in zone %s', - dns_name, - zone_name, - ) - else: + new_ip = get_public_ip() + any_updated = False for record in records: - current = record.get('content') - record_name = record.get('name') or dns_name - logger.info( - 'Found record id=%s name=%s current=%s new=%s', - record.get('id'), - record_name, - current, - new_ip, - ) - if current == new_ip: - logger.info('Record %s already up-to-date', record.get('id')) + if record.get('content') == new_ip: + logger.info('Record %s already up-to-date - IP: %s', record.get('id'), new_ip) continue - update_a_record( - zone_id, - record['id'], - new_ip, - ) + update_a_record(zone_id, record['id'], new_ip) any_updated = True - if not any_updated: - logger.info('No records needed update') + if not any_updated: + logger.info('No records needed update') + return 7 # Special exit code for "up-to-date" + except requests.exceptions.RequestException as e: + logger.error('A network error occurred: %s', e) + exit_code = 3 # Generic network error exit code return exit_code diff --git a/requirements.txt b/requirements.txt index d51edac..28163a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ requests>=2.31.0,<3.0.0 +apprise>=1.7.1,<2.0.0 diff --git a/run_ddns.sh b/run_ddns.sh index 1aafef2..98f7cd3 100755 --- a/run_ddns.sh +++ b/run_ddns.sh @@ -23,12 +23,18 @@ SCRIPT="$HERE/ddns.py" usage() { cat <<'USAGE' >&2 -Usage: run_ddns.sh [--token TOKEN] [--zone ZONE] [--name NAME] +Usage: run_ddns.sh [options] -Options: +DDNS Options: --token, -t TOKEN Cloudflare API token (falls back to CLOUDFLARE_API_TOKEN env) --zone, -z ZONE Cloudflare zone name (falls back to DDNS_ZONE_NAME env) --name, -n NAME DNS record name to update (falls back to DDNS_DNS_NAME env) + +Notification Options: + --smtp HOST SMTP server for email notifications. + --username EMAIL Username for SMTP authentication. + --password PASS Password for SMTP authentication. + --recipient EMAIL Recipient's email address (defaults to username). --help, -h Show this help Examples: @@ -55,6 +61,10 @@ fi TOKEN_ARG="" ZONE_ARG="" NAME_ARG="" +SMTP_HOST_ARG="" +SMTP_USER_ARG="" +SMTP_PASS_ARG="" +RECIPIENT_ARG="" # More robust argument parsing to handle --opt=val and "--opt val" as a single arg ARGS=() @@ -81,6 +91,14 @@ while [ ${#} -gt 0 ]; do --name|-n) NAME_ARG="$2"; shift 2;; --help|-h) usage; exit 0;; + --smtp=*) SMTP_HOST_ARG="${1#*=}"; shift 1;; + --smtp) SMTP_HOST_ARG="$2"; shift 2;; + --username=*) SMTP_USER_ARG="${1#*=}"; shift 1;; + --username) SMTP_USER_ARG="$2"; shift 2;; + --password=*) SMTP_PASS_ARG="${1#*=}"; shift 1;; + --password) SMTP_PASS_ARG="$2"; shift 2;; + --recipient=*) RECIPIENT_ARG="${1#*=}"; shift 1;; + --recipient) RECIPIENT_ARG="$2"; shift 2;; --*) echo "Unknown option: $1" >&2; usage; exit 2;; *) @@ -129,7 +147,26 @@ if [ -f "$VENV_DIR/bin/activate" ]; then # Install/upgrade packages after activation python -m pip install --upgrade pip - if ! python -c "import requests" >/dev/null 2>&1; then + + # Dynamically check if all packages from requirements.txt are installed. + # This is more robust than hardcoding package names. + NEEDS_INSTALL=false + while IFS= read -r requirement || [ -n "$requirement" ]; do + # Skip empty lines and comments + [[ -z "$requirement" || "$requirement" =~ ^\s*# ]] && continue + + # Extract package name (part before any version specifier) + package_name=$(echo "$requirement" | sed -E 's/[<>=!~].*//') + + # Check if the package can be imported. If not, flag for installation. + if ! python -c "import $package_name" >/dev/null 2>&1; then + echo "Package '$package_name' not found." + NEEDS_INSTALL=true + break # No need to check further + fi + done < "$HERE/requirements.txt" + + if [ "$NEEDS_INSTALL" = true ]; then echo "Installing required Python packages into venv..." python -m pip install -r "$HERE/requirements.txt" fi @@ -156,4 +193,74 @@ if [ -n "$NAME_ARG" ]; then fi echo "Running $SCRIPT ${ARGS[*]}" -python "$SCRIPT" "${ARGS[@]}" +# Capture stdout and stderr from the python script into a variable. +# We temporarily disable 'set -e' to capture the python script's real exit code, +# even if it's non-zero. The `|| true` is no longer needed. +set +e +DDNS_OUTPUT=$(python "$SCRIPT" "${ARGS[@]}" 2>&1) +DDNS_EXIT_CODE=$? +set -e + +# Echo the captured output so it's still visible in the console logs. +echo "$DDNS_OUTPUT" + +echo "DDNS script finished with exit code $DDNS_EXIT_CODE." + +# Handle the special exit code for "up-to-date" +if [ "$DDNS_EXIT_CODE" -eq 7 ]; then + echo "IP address is already up-to-date. No notification will be sent." + exit 0 +fi + +# --- Notification Logic --- +if [[ -n "$SMTP_HOST_ARG" && -n "$SMTP_USER_ARG" && -n "$SMTP_PASS_ARG" ]]; then + echo "SMTP parameters provided. Preparing to send notification..." + + # URL-encode username and password to handle special characters like '@', '#', etc. + ENCODED_USER=$(python -c "from urllib.parse import quote; print(quote('''${SMTP_USER_ARG}'''))") + ENCODED_PASS=$(python -c "from urllib.parse import quote; print(quote('''${SMTP_PASS_ARG}'''))") + + # Construct the Apprise mailtos:// URL. + # To handle usernames containing '@', we explicitly set the 'from' address. + # This makes the URL structure unambiguous for the parser. + MAIL_URL="mailtos://${ENCODED_USER}:${ENCODED_PASS}@${SMTP_HOST_ARG}?from=${ENCODED_USER}" + + # If a recipient is specified, add it to the URL. Otherwise, Apprise defaults + # to sending the email to the user specified in the URL (SMTP_USER_ARG). + if [ -n "$RECIPIENT_ARG" ]; then + # Append with '&' since 'from=' already exists + MAIL_URL="${MAIL_URL}&to=${RECIPIENT_ARG}" + fi + # Check if we are in dry-run mode. The python script defaults to dry-run. + # The env var DDNS_DRY_RUN must be '0' or 'false' to disable it. + _env_dry=${DDNS_DRY_RUN:-1} + if [[ "${_env_dry,,}" == "0" || "${_env_dry,,}" == "false" || "${_env_dry,,}" == "no" ]]; then + RUN_MODE="LIVE MODE" + else + RUN_MODE="DRY RUN" + fi + + # Determine notification content based on ddns.py exit code + if [ "$DDNS_EXIT_CODE" -eq 0 ]; then + NOTIFY_TITLE="✅ DDNS Update Successful ($RUN_MODE)" + else + NOTIFY_TITLE="❌ DDNS Update Failed ($RUN_MODE)" + fi + + # Construct the full email body, including the captured log. + NOTIFY_BODY=$(printf "DDNS update for %s finished with exit code %s.\n\n--- Execution Log ---\n%s" "${NAME_ARG:-$DDNS_DNS_NAME}" "$DDNS_EXIT_CODE" "$DDNS_OUTPUT") + + # Use the Apprise CLI to send the notification. + # Add verbosity (-v) to see connection details and check the exit code. + echo "Attempting to send notification via Apprise..." + if notification_output=$(apprise -v --title "$NOTIFY_TITLE" --body "$NOTIFY_BODY" "$MAIL_URL" 2>&1); then + echo "Apprise notification sent successfully." + echo "$notification_output" + else + echo "ERROR: Apprise failed to send notification. See details below." >&2 + echo "$notification_output" >&2 + # Optionally exit with an error: exit 3 + fi +else + echo "No notification parameters provided. Skipping notification." +fi