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
29 changes: 6 additions & 23 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,37 +20,20 @@ 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
name: shellcheck (system)
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.
144 changes: 84 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
--------
Expand All @@ -21,86 +22,109 @@ 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="<your_token_here>"
./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="<your_scoped_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
schedule periodic runs (every 15 minutes recommended).

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 "<your_token>" > ~/.cloudflare_token
Expand Down
81 changes: 30 additions & 51 deletions ddns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
requests>=2.31.0,<3.0.0
apprise>=1.7.1,<2.0.0
Loading