Compare commits

4 Commits

3 changed files with 94 additions and 9 deletions

View File

@ -1,2 +1,75 @@
# cloudflare-ddns-docker # Cloudflare DDNS
A Docker service updating your CloudFlare DNS records periodically.
![Linting](https://github.com/Akarys42/cloudflare-ddns-docker/workflows/Linting/badge.svg)
![Push Container](https://github.com/Akarys42/cloudflare-ddns-docker/workflows/Push%20Container/badge.svg)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
Cloudflare DDNS is a configurable Docker service updating your CloudFlare DNS records periodically
to match your local IP address.
# Table of Content
- [Installation](#installation)
- [Using a Pre-built Container](#using-a-pre-built-container)
- [Building the Container Yourself](#building-the-container-yourself)
- [Running on the Host](#running-on-the-host)
- [Configuration](#configuration)
- [Getting a Cloudflare Token](#getting-a-cloudflare-token)
- [Supported Options](#supported-options)
- [Contributing](#contributing)
## Installation
While this project is intended to be ran as a Docker container, it can also be ran on the host directly.
### Using a Pre-built Container
This project is available on the GitHub Container Registry.
```
docker pull ghcr.io/akarys42/cloudflare-ddns-docker
```
### Building the Container Yourself
There are no special requirements when building this container! Simply use `docker build` in this folder.
### Running on the Host
In order to run this project on the host, you'll need Python > 3.8, and an environment containing
the dependencies listed in [`requirements.txt`](requirements.txt).
The project can then by launched by running the `cloudflare_ddns` module, usually using `python -m cloudflare_ddns`.
## Configuration
This project will accept parameters through environment variables or command line argument.
Feel free to select the method fitting your setup the best!
### Getting a Cloudflare Token
The first step will be to create an API token with the following scopes:
- `Zone:Read`
- `DNS:Edit`
### Supported Options
We currently support following settings:
| Parameter Name | Short Command Line Option | Long Command Line Option | Environment Variable | Description |
|----------------------------|---------------------------|--------------------------|----------------------|---------------------------------------------------------------------------------------------|
| Token [mandatory] | `-k` | `--token` | `CF_DDNS_TOKEN` | Your Cloudflare token created in the previous step. |
| Delay [default: 5 minutes] | `-d` | `--delay` | `CF_DDNS_DELAY` | The time to wait between each update. It is parsed per [`strftime`](https://strftime.org/). |
The domains to update will have to either be passed as command line arguments after the options
or with a space separated `CF_DDNS_DOMAINS` environment variable.
Each domain can be preceded by the record type, either A or AAAA, followed by a colon.
Otherwise any found A or AAAA record pointing to this domain found will be used.
## Contributing
Any help would be greatly appreciated!
Feel free to check our [open issues](https://github.com/Akarys42/cloudflare-ddns-docker/issues) and send us a Pull Request!

View File

@ -1,6 +1,7 @@
import logging import logging
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
from signal import SIGINT, SIGTERM, signal
from typing import List from typing import List
import requests import requests
@ -46,9 +47,14 @@ class ApplicationJob(threading.Thread):
self.raw_domains = raw_domains self.raw_domains = raw_domains
self.raw_delay = raw_delay self.raw_delay = raw_delay
self.session = requests.session()
def launch(self) -> None: def launch(self) -> None:
"""Launch the application by validating arguments and starting the thread.""" """Launch the application by validating arguments and starting the thread."""
self.validate_arguments() self.validate_arguments()
log.debug("Registering exit hooks.")
signal(SIGINT, self.exit)
signal(SIGTERM, self.exit)
log.debug("Starting job.") log.debug("Starting job.")
self.start() self.start()
@ -87,9 +93,9 @@ class ApplicationJob(threading.Thread):
for record in self.domains: for record in self.domains:
log.debug(f"Updating record for {record.domain}.") log.debug(f"Updating record for {record.domain}.")
check_status(requests.patch( check_status(self.session.patch(
PATCH_DNS.format(zone_identifier=record.zone, identifier=record.id), PATCH_DNS.format(zone_identifier=record.zone, identifier=record.id),
json={"content": get_ip(record.record_type == 'AAAA')}, json={"content": get_ip(record.record_type == 'AAAA', self.session)},
auth=self.auth auth=self.auth
)) ))
@ -99,8 +105,8 @@ class ApplicationJob(threading.Thread):
"""Parse the domain in `raw_domains` and populate the `domains` array with `Domain` objects.""" """Parse the domain in `raw_domains` and populate the `domains` array with `Domain` objects."""
found_domains = {} found_domains = {}
for zone_json in check_status(requests.get(LIST_ZONES, auth=self.auth)).json()["result"]: for zone_json in check_status(self.session.get(LIST_ZONES, auth=self.auth)).json()["result"]:
for record_json in check_status(requests.get( for record_json in check_status(self.session.get(
LIST_DNS.format(zone_identifier=zone_json["id"]), LIST_DNS.format(zone_identifier=zone_json["id"]),
auth=self.auth auth=self.auth
)).json()["result"]: )).json()["result"]:
@ -186,8 +192,14 @@ class ApplicationJob(threading.Thread):
def validate_bearer(self) -> None: def validate_bearer(self) -> None:
"""Utility method to validate a CF bearer token.""" """Utility method to validate a CF bearer token."""
r = requests.get(VERIFY_TOKEN, auth=self.auth) r = self.session.get(VERIFY_TOKEN, auth=self.auth)
if not r.json()["success"]: if not r.json()["success"]:
error_message = ' / '.join(error["message"] for error in r.json()["errors"]) error_message = ' / '.join(error["message"] for error in r.json()["errors"])
raise ValueError(error_message) raise ValueError(error_message)
def exit(self, *_) -> None:
"""Gracefully exit the application."""
log.info("Exiting application.")
self.stop_signal.set()
self.join()

View File

@ -54,9 +54,9 @@ class BearerAuth(AuthBase):
return r return r
def get_ip(ipv6: bool) -> str: def get_ip(ipv6: bool, session: requests.Session) -> str:
"""Return the host public IP as detected by ipify.org.""" """Return the host public IP as detected by ipify.org."""
r = check_status(requests.get(IP_API_URL_IPV4 if not ipv6 else IP_API_URL_IPV6)) r = check_status(session.get(IP_API_URL_IPV4 if not ipv6 else IP_API_URL_IPV6))
return r.text return r.text