From 8f8503d4f7fc57c7daeba51fc53833c8cea8ce2c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 14 Jan 2021 11:51:31 +0100 Subject: [PATCH 1/9] Add the gist of the application --- cloudflare-ddns/__main__.py | 39 +++++++++++++++++++++++++++++++++++ cloudflare-ddns/utils.py | 41 +++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 3 files changed, 81 insertions(+) create mode 100644 cloudflare-ddns/utils.py diff --git a/cloudflare-ddns/__main__.py b/cloudflare-ddns/__main__.py index e69de29..5311fc8 100644 --- a/cloudflare-ddns/__main__.py +++ b/cloudflare-ddns/__main__.py @@ -0,0 +1,39 @@ +import logging + +import click +from email_validator import EmailNotValidError, validate_email + +from .utils import parse_duration, validate_bearer + +DEFAULT_DELAY = "5 minutes" + + +@click.command() +@click.option('--delay', '-d', default=DEFAULT_DELAY, show_default=True) +@click.option('--email', '-u', prompt="Enter your Cloudflare Email address") +@click.option('--key', '-k', prompt="Enter your Cloudflare Auth key", hide_input=True) +def start(delay: str, email: str, key: str) -> None: + """Main application entrypoint.""" + try: + duration = parse_duration(delay) + except ValueError as e: + logging.error(f"Failed to parse delay: {e}") + logging.error("Exiting with code 64.") + exit(64) + + try: + validate_email(email) + except EmailNotValidError: + logging.warning(f"The email address {email} don't seem valid. Do you have a typo?") + + try: + validate_bearer(key) + except ...: + ... + + +# Main entrypoint +if __name__ == "__main__": + logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s") + + start(auto_envvar_prefix="CF_DDNS") diff --git a/cloudflare-ddns/utils.py b/cloudflare-ddns/utils.py new file mode 100644 index 0000000..a34a46b --- /dev/null +++ b/cloudflare-ddns/utils.py @@ -0,0 +1,41 @@ +import re + +DURATION_REGEX = re.compile( + r"((?P\d+?) ?(days|day|D|d) ?)?" + r"((?P\d+?) ?(hours|hour|H|h) ?)?" + r"((?P\d+?) ?(minutes|minute|min|M|m) ?)?" + r"((?P\d+?) ?(seconds|second|sec|S|s))?" +) +UNIT_TO_SECONDS = { + "days": 86400, + "hours": 3600, + "minutes": 60, + "seconds": 1 +} + + +def parse_duration(duration: str) -> int: + """ + Parameter type for durations. + + The converter supports the following symbols for each unit of time: + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `m`, `minute`, `minutes`, `min` + - seconds: `S`, `s`, `second`, `seconds`, `sec` + The units need to be provided in descending order of magnitude. + """ + match = DURATION_REGEX.fullmatch(duration) + if not match: + raise ValueError(f"{duration} isn't a valid duration.") + + duration = 0 + for unit, time_value in match.groupdict().items(): + if time_value: + duration += int(time_value) * UNIT_TO_SECONDS[unit] + + return duration + + +def validate_bearer(bearer: str) -> None: + ... diff --git a/requirements.txt b/requirements.txt index 0ef32ad..70a9287 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ requests~=2.25.1 click8~=8.0.1 +email-validator~=1.1.2 From b8c4a114f6a42324963c07132c28aeb1d38e7882 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 14 Jan 2021 16:08:24 +0100 Subject: [PATCH 2/9] Add bearer validation --- cloudflare-ddns/__main__.py | 36 ++++++++++++++++++++---------------- cloudflare-ddns/constants.py | 7 +++++++ cloudflare-ddns/utils.py | 28 ++++++++++++++++++++++++++-- requirements.txt | 1 - 4 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 cloudflare-ddns/constants.py diff --git a/cloudflare-ddns/__main__.py b/cloudflare-ddns/__main__.py index 5311fc8..ab4033a 100644 --- a/cloudflare-ddns/__main__.py +++ b/cloudflare-ddns/__main__.py @@ -1,39 +1,43 @@ import logging import click -from email_validator import EmailNotValidError, validate_email +from .constants import DEFAULT_DELAY from .utils import parse_duration, validate_bearer -DEFAULT_DELAY = "5 minutes" +log = logging.getLogger("ddns") @click.command() @click.option('--delay', '-d', default=DEFAULT_DELAY, show_default=True) -@click.option('--email', '-u', prompt="Enter your Cloudflare Email address") -@click.option('--key', '-k', prompt="Enter your Cloudflare Auth key", hide_input=True) -def start(delay: str, email: str, key: str) -> None: +@click.option('--token', '-k', prompt="Enter your Cloudflare Token", hide_input=True, show_envvar=True) +@click.option('-v', '--verbose', is_flag=True, default=False) +def start(delay: str, token: str, verbose: int) -> None: """Main application entrypoint.""" + logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.DEBUG if verbose else logging.INFO + ) + try: duration = parse_duration(delay) except ValueError as e: - logging.error(f"Failed to parse delay: {e}") - logging.error("Exiting with code 64.") + log.error(f"Failed to parse delay: {e}") + log.error("Exiting with code 64.") exit(64) try: - validate_email(email) - except EmailNotValidError: - logging.warning(f"The email address {email} don't seem valid. Do you have a typo?") + log.debug("Validating bearer token.") - try: - validate_bearer(key) - except ...: - ... + validate_bearer(token) + except ValueError as e: + log.error(f"Failed to valid bearer token: {e}") + log.error("Exiting with code 64.") + exit(64) + else: + log.info("Successfully validated the bearer token.") # Main entrypoint if __name__ == "__main__": - logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s") - start(auto_envvar_prefix="CF_DDNS") diff --git a/cloudflare-ddns/constants.py b/cloudflare-ddns/constants.py new file mode 100644 index 0000000..03d2fcd --- /dev/null +++ b/cloudflare-ddns/constants.py @@ -0,0 +1,7 @@ +# App defaults +DEFAULT_DELAY = "5 minutes" + +# Endpoints +BASE_ENDPOINT = "https://api.cloudflare.com/client/v4/" + +VERIFY_TOKEN = BASE_ENDPOINT + "user/tokens/verify" diff --git a/cloudflare-ddns/utils.py b/cloudflare-ddns/utils.py index a34a46b..487f48e 100644 --- a/cloudflare-ddns/utils.py +++ b/cloudflare-ddns/utils.py @@ -1,5 +1,11 @@ import re +import requests +from requests import Request +from requests.auth import AuthBase + +from .constants import VERIFY_TOKEN + DURATION_REGEX = re.compile( r"((?P\d+?) ?(days|day|D|d) ?)?" r"((?P\d+?) ?(hours|hour|H|h) ?)?" @@ -37,5 +43,23 @@ def parse_duration(duration: str) -> int: return duration -def validate_bearer(bearer: str) -> None: - ... +class BearerAuth(AuthBase): + """Bearer based authentication.""" + + def __init__(self, token: str) -> None: + self.token = token + + def __call__(self, r: Request) -> Request: + """Attach the Authorization header to the request.""" + r.headers["Authorization"] = f"Bearer {self.token}" + return r + + +def validate_bearer(token: str) -> None: + """Utility method to validate a CF bearer token.""" + bearer = BearerAuth(token) + r = requests.get(VERIFY_TOKEN, auth=bearer) + + if not r.json()["success"]: + error_message = ' / '.join(error["message"] for error in r.json()["errors"]) + raise ValueError(error_message) diff --git a/requirements.txt b/requirements.txt index 70a9287..0ef32ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ requests~=2.25.1 click8~=8.0.1 -email-validator~=1.1.2 From d44c8d0a71ece79eb097edefbcf981abdd05dc08 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 14 Jan 2021 16:39:26 +0100 Subject: [PATCH 3/9] Switching to an ApplicationJob based app --- Dockerfile | 2 +- cloudflare-ddns/__main__.py | 43 ------------ .../__init__.py | 0 cloudflare_ddns/__main__.py | 30 +++++++++ cloudflare_ddns/app.py | 67 +++++++++++++++++++ .../constants.py | 0 {cloudflare-ddns => cloudflare_ddns}/utils.py | 10 --- 7 files changed, 98 insertions(+), 54 deletions(-) delete mode 100644 cloudflare-ddns/__main__.py rename {cloudflare-ddns => cloudflare_ddns}/__init__.py (100%) create mode 100644 cloudflare_ddns/__main__.py create mode 100644 cloudflare_ddns/app.py rename {cloudflare-ddns => cloudflare_ddns}/constants.py (100%) rename {cloudflare-ddns => cloudflare_ddns}/utils.py (81%) diff --git a/Dockerfile b/Dockerfile index 47c2486..136079d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.9-slim WORKDIR /app -ENTRYPOINT ["python3", "-m", "cloudflare-ddns"] +ENTRYPOINT ["python3", "-m", "cloudflare_ddns"] CMD [] # Install requirements in a separate step to not rebuild everything when the requirements are updated. diff --git a/cloudflare-ddns/__main__.py b/cloudflare-ddns/__main__.py deleted file mode 100644 index ab4033a..0000000 --- a/cloudflare-ddns/__main__.py +++ /dev/null @@ -1,43 +0,0 @@ -import logging - -import click - -from .constants import DEFAULT_DELAY -from .utils import parse_duration, validate_bearer - -log = logging.getLogger("ddns") - - -@click.command() -@click.option('--delay', '-d', default=DEFAULT_DELAY, show_default=True) -@click.option('--token', '-k', prompt="Enter your Cloudflare Token", hide_input=True, show_envvar=True) -@click.option('-v', '--verbose', is_flag=True, default=False) -def start(delay: str, token: str, verbose: int) -> None: - """Main application entrypoint.""" - logging.basicConfig( - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - level=logging.DEBUG if verbose else logging.INFO - ) - - try: - duration = parse_duration(delay) - except ValueError as e: - log.error(f"Failed to parse delay: {e}") - log.error("Exiting with code 64.") - exit(64) - - try: - log.debug("Validating bearer token.") - - validate_bearer(token) - except ValueError as e: - log.error(f"Failed to valid bearer token: {e}") - log.error("Exiting with code 64.") - exit(64) - else: - log.info("Successfully validated the bearer token.") - - -# Main entrypoint -if __name__ == "__main__": - start(auto_envvar_prefix="CF_DDNS") diff --git a/cloudflare-ddns/__init__.py b/cloudflare_ddns/__init__.py similarity index 100% rename from cloudflare-ddns/__init__.py rename to cloudflare_ddns/__init__.py diff --git a/cloudflare_ddns/__main__.py b/cloudflare_ddns/__main__.py new file mode 100644 index 0000000..f113635 --- /dev/null +++ b/cloudflare_ddns/__main__.py @@ -0,0 +1,30 @@ +import logging + +import click +from typing import Tuple + +from cloudflare_ddns.app import ApplicationJob +from cloudflare_ddns.constants import DEFAULT_DELAY + +log = logging.getLogger("ddns") + + +@click.command() +@click.option('--delay', '-d', default=DEFAULT_DELAY, show_default=True) +@click.option('--token', '-k', prompt="Enter your Cloudflare Token", hide_input=True, show_envvar=True) +@click.option('-v', '--verbose', is_flag=True, default=False) +@click.option('--ipv6/--ipv4', '-6/-4') +@click.argument("domain", nargs=-1) +def start(delay: str, token: str, verbose: int, domain: Tuple[str], ipv6: bool) -> None: + """Main application entrypoint.""" + logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.DEBUG if verbose else logging.INFO + ) + + ApplicationJob(delay, token, domain, ipv6).launch() + + +# Main entrypoint +if __name__ == "__main__": + start(auto_envvar_prefix="CF_DDNS") diff --git a/cloudflare_ddns/app.py b/cloudflare_ddns/app.py new file mode 100644 index 0000000..32af2ab --- /dev/null +++ b/cloudflare_ddns/app.py @@ -0,0 +1,67 @@ +import logging +import threading +from dataclasses import dataclass +from typing import Tuple + +import requests + +from cloudflare_ddns.constants import VERIFY_TOKEN +from cloudflare_ddns.utils import parse_duration, BearerAuth + +log = logging.getLogger("ddns") + + +@dataclass +class Domain: + domain: str + record_type: str + + +class ApplicationJob(threading.Thread): + def __init__(self, delay: str, token: str, raw_domains: Tuple[str], default_ipv6: bool): + super().__init__() + + self.stop_signal = threading.Event() + + self.delay = delay + self.auth = BearerAuth(token) + self.raw_domains = raw_domains + self.default_ipv6 = default_ipv6 + + def launch(self) -> None: + self.validate_arguments() + + def validate_arguments(self): + failed = False + + if not self.raw_domains: + log.error("Please provide at least one domain.") + failed = True + + try: + self.delay = parse_duration(self.delay) + except ValueError as e: + log.error(f"Failed to parse delay: {e}") + failed = True + + try: + log.debug("Validating bearer token.") + + self.validate_bearer() + except ValueError as e: + log.error(f"Failed to validate bearer token: {e}") + failed = True + else: + log.info("Successfully validated the bearer token.") + + if failed: + log.info("Exiting with code 64.") + exit(64) + + def validate_bearer(self) -> None: + """Utility method to validate a CF bearer token.""" + r = requests.get(VERIFY_TOKEN, auth=self.auth) + + if not r.json()["success"]: + error_message = ' / '.join(error["message"] for error in r.json()["errors"]) + raise ValueError(error_message) diff --git a/cloudflare-ddns/constants.py b/cloudflare_ddns/constants.py similarity index 100% rename from cloudflare-ddns/constants.py rename to cloudflare_ddns/constants.py diff --git a/cloudflare-ddns/utils.py b/cloudflare_ddns/utils.py similarity index 81% rename from cloudflare-ddns/utils.py rename to cloudflare_ddns/utils.py index 487f48e..49b8d2a 100644 --- a/cloudflare-ddns/utils.py +++ b/cloudflare_ddns/utils.py @@ -53,13 +53,3 @@ class BearerAuth(AuthBase): """Attach the Authorization header to the request.""" r.headers["Authorization"] = f"Bearer {self.token}" return r - - -def validate_bearer(token: str) -> None: - """Utility method to validate a CF bearer token.""" - bearer = BearerAuth(token) - r = requests.get(VERIFY_TOKEN, auth=bearer) - - if not r.json()["success"]: - error_message = ' / '.join(error["message"] for error in r.json()["errors"]) - raise ValueError(error_message) From ad68d13078e26b1ce6f928f66fb414114be18da0 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 14 Jan 2021 17:29:14 +0100 Subject: [PATCH 4/9] Add domain search --- cloudflare_ddns/app.py | 107 ++++++++++++++++++++++++++++++----- cloudflare_ddns/constants.py | 6 ++ 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/cloudflare_ddns/app.py b/cloudflare_ddns/app.py index 32af2ab..fc9d3cc 100644 --- a/cloudflare_ddns/app.py +++ b/cloudflare_ddns/app.py @@ -1,11 +1,11 @@ import logging import threading from dataclasses import dataclass -from typing import Tuple +from typing import Tuple, List, Dict, NoReturn import requests -from cloudflare_ddns.constants import VERIFY_TOKEN +from cloudflare_ddns.constants import VERIFY_TOKEN, LIST_ZONES, LIST_DNS, ACCEPTED_RECORDS from cloudflare_ddns.utils import parse_duration, BearerAuth log = logging.getLogger("ddns") @@ -15,21 +15,99 @@ log = logging.getLogger("ddns") class Domain: domain: str record_type: str + zone: str + id: str class ApplicationJob(threading.Thread): - def __init__(self, delay: str, token: str, raw_domains: Tuple[str], default_ipv6: bool): + def __init__(self, raw_delay: str, token: str, raw_domains: Tuple[str], default_ipv6: bool): super().__init__() self.stop_signal = threading.Event() - self.delay = delay + self.delay = None + self.domains: List[Domain] = [] + self.found_domains: Dict[str, Domain] = {} + self.auth = BearerAuth(token) - self.raw_domains = raw_domains self.default_ipv6 = default_ipv6 + self.raw_domains = raw_domains + self.raw_delay = raw_delay + def launch(self) -> None: self.validate_arguments() + log.debug("Starting job.") + self.start() + + def run(self) -> None: + log.debug("Parsing domains.") + self.parse_domains() + log.debug(f"Using domains: {', '.join(f'{domain.record_type}:{domain.domain}' for domain in self.domains)}") + + log.info("Starting app.") + self.update_records() + while not self.stop_signal.wait(self.delay): + self.update_records() + + def update_records(self): + ... + + def parse_domains(self): + type_1 = "A" if not self.default_ipv6 else "AAAA" + type_2 = "A" if self.default_ipv6 else "AAAA" + + for zone_json in requests.get(LIST_ZONES, auth=self.auth).json()["result"]: + for record_json in requests.get( + LIST_DNS.format(zone_identifier=zone_json["id"]), + auth=self.auth + ).json()["result"]: + if record_json["type"] in ACCEPTED_RECORDS: + domain = Domain( + record_json["name"], + record_json["type"], + record_json["zone_id"], + record_json["id"] + ) + self.found_domains[f'{record_json["name"]}-{record_json["type"]}'] = domain + + log.debug( + f"Found domains: " + f"{', '.join(f'{domain.record_type}:{domain.domain}' for domain in self.found_domains.values())}" + ) + for domain in self.raw_domains: + if ':' in domain: + type_, domain = domain.split(':', maxsplit=1) + + if type_ not in ACCEPTED_RECORDS: + log.error(f"Invalid record type {type_}. Must be one of {', '.join(ACCEPTED_RECORDS)}.") + log.info(f"Exiting with code 65.") + exit(65) + + if f"{domain}-{type_}" not in self.found_domains: + log.error( + f"Cannot find an {type_} record for the domain {domain} in your Cloudflare settings. " + f"Have you defined this record yet?" + ) + log.info(f"Exiting with code 65.") + exit(65) + else: + if f"{domain}-{type_1}" in self.found_domains: + type_ = type_1 + elif f"{domain}-{type_2}" in self.found_domains: + type_ = type_2 + else: + log.error( + f"Cannot find the domain {domain} in your Cloudflare settings. " + f"Have you defined this record yet?" + ) + log.info(f"Exiting with code 65.") + exit(65) + + self.domains.append(self.found_domains[f"{domain}-{type_}"]) + + + def validate_arguments(self): failed = False @@ -39,20 +117,21 @@ class ApplicationJob(threading.Thread): failed = True try: - self.delay = parse_duration(self.delay) + self.delay = parse_duration(self.raw_delay) except ValueError as e: log.error(f"Failed to parse delay: {e}") failed = True - try: - log.debug("Validating bearer token.") + if not failed: + try: + log.debug("Validating bearer token.") - self.validate_bearer() - except ValueError as e: - log.error(f"Failed to validate bearer token: {e}") - failed = True - else: - log.info("Successfully validated the bearer token.") + self.validate_bearer() + except ValueError as e: + log.error(f"Failed to validate bearer token: {e}") + failed = True + else: + log.info("Successfully validated the bearer token.") if failed: log.info("Exiting with code 64.") diff --git a/cloudflare_ddns/constants.py b/cloudflare_ddns/constants.py index 03d2fcd..cf2d5d0 100644 --- a/cloudflare_ddns/constants.py +++ b/cloudflare_ddns/constants.py @@ -5,3 +5,9 @@ DEFAULT_DELAY = "5 minutes" BASE_ENDPOINT = "https://api.cloudflare.com/client/v4/" VERIFY_TOKEN = BASE_ENDPOINT + "user/tokens/verify" + +LIST_ZONES = BASE_ENDPOINT + "zones" +LIST_DNS = BASE_ENDPOINT + "zones/{zone_identifier}/dns_records" + +# Utilities +ACCEPTED_RECORDS = ('A', 'AAAA') From 7202473af05902be0f18f579ee9f74a427200e7e Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 14 Jan 2021 21:34:14 +0100 Subject: [PATCH 5/9] Add domain update --- .flake8 | 4 +- cloudflare_ddns/__main__.py | 5 +-- cloudflare_ddns/app.py | 74 ++++++++++++++++++++++-------------- cloudflare_ddns/constants.py | 4 ++ cloudflare_ddns/utils.py | 8 +++- 5 files changed, 62 insertions(+), 33 deletions(-) diff --git a/.flake8 b/.flake8 index 208d99a..10a18e9 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,5 @@ [flake8] -max-line-length=110 +max-line-length=120 docstring-convention=all import-order-style=pycharm exclude=constants.py,__pycache__,.cache,.git,.md,.svg,.png,tests,venv,.venv @@ -18,3 +18,5 @@ ignore= ANN002,ANN003,ANN101,ANN102,ANN204,ANN206 # Whitespace Before E203 + # F-strings + F541 diff --git a/cloudflare_ddns/__main__.py b/cloudflare_ddns/__main__.py index f113635..b1d2a3b 100644 --- a/cloudflare_ddns/__main__.py +++ b/cloudflare_ddns/__main__.py @@ -13,16 +13,15 @@ log = logging.getLogger("ddns") @click.option('--delay', '-d', default=DEFAULT_DELAY, show_default=True) @click.option('--token', '-k', prompt="Enter your Cloudflare Token", hide_input=True, show_envvar=True) @click.option('-v', '--verbose', is_flag=True, default=False) -@click.option('--ipv6/--ipv4', '-6/-4') @click.argument("domain", nargs=-1) -def start(delay: str, token: str, verbose: int, domain: Tuple[str], ipv6: bool) -> None: +def start(delay: str, token: str, verbose: int, domain: Tuple[str]) -> None: """Main application entrypoint.""" logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.DEBUG if verbose else logging.INFO ) - ApplicationJob(delay, token, domain, ipv6).launch() + ApplicationJob(delay, token, domain).launch() # Main entrypoint diff --git a/cloudflare_ddns/app.py b/cloudflare_ddns/app.py index fc9d3cc..f04a3b0 100644 --- a/cloudflare_ddns/app.py +++ b/cloudflare_ddns/app.py @@ -1,12 +1,11 @@ import logging import threading from dataclasses import dataclass -from typing import Tuple, List, Dict, NoReturn +from typing import Dict, List, Tuple import requests - -from cloudflare_ddns.constants import VERIFY_TOKEN, LIST_ZONES, LIST_DNS, ACCEPTED_RECORDS -from cloudflare_ddns.utils import parse_duration, BearerAuth +from cloudflare_ddns.constants import ACCEPTED_RECORDS, LIST_DNS, LIST_ZONES, VERIFY_TOKEN, PATCH_DNS +from cloudflare_ddns.utils import BearerAuth, parse_duration, get_ip log = logging.getLogger("ddns") @@ -20,17 +19,16 @@ class Domain: class ApplicationJob(threading.Thread): - def __init__(self, raw_delay: str, token: str, raw_domains: Tuple[str], default_ipv6: bool): + def __init__(self, raw_delay: str, token: str, raw_domains: Tuple[str]): super().__init__() self.stop_signal = threading.Event() self.delay = None self.domains: List[Domain] = [] - self.found_domains: Dict[str, Domain] = {} + self.current_ip = None self.auth = BearerAuth(token) - self.default_ipv6 = default_ipv6 self.raw_domains = raw_domains self.raw_delay = raw_delay @@ -45,17 +43,33 @@ class ApplicationJob(threading.Thread): self.parse_domains() log.debug(f"Using domains: {', '.join(f'{domain.record_type}:{domain.domain}' for domain in self.domains)}") - log.info("Starting app.") - self.update_records() - while not self.stop_signal.wait(self.delay): + log.info(f"Starting app. Records will be updated every {self.delay} seconds.") + try: self.update_records() + except Exception: + log.exception("Error while updating records for the first time, aborting.") + log.info("Exiting with code 70.") + exit(70) - def update_records(self): - ... + while not self.stop_signal.wait(self.delay): + try: + self.update_records() + except Exception: + log.exception(f"Error while updating records. Retrying in {self.delay} seconds.") - def parse_domains(self): - type_1 = "A" if not self.default_ipv6 else "AAAA" - type_2 = "A" if self.default_ipv6 else "AAAA" + def update_records(self) -> None: + log.info("Starting record update.") + for record in self.domains: + log.debug(f"Updating record for {record.domain}.") + requests.patch( + PATCH_DNS.format(zone_identifier=record.zone, identifier=record.id), + json={"content": get_ip(record.record_type == 'AAAA')}, + auth=self.auth + ).raise_for_status() + log.info("Successfully updated records.") + + def parse_domains(self) -> None: + found_domains = {} for zone_json in requests.get(LIST_ZONES, auth=self.auth).json()["result"]: for record_json in requests.get( @@ -69,11 +83,11 @@ class ApplicationJob(threading.Thread): record_json["zone_id"], record_json["id"] ) - self.found_domains[f'{record_json["name"]}-{record_json["type"]}'] = domain + found_domains[f'{record_json["name"]}-{record_json["type"]}'] = domain log.debug( f"Found domains: " - f"{', '.join(f'{domain.record_type}:{domain.domain}' for domain in self.found_domains.values())}" + f"{', '.join(f'{domain.record_type}:{domain.domain}' for domain in found_domains.values())}" ) for domain in self.raw_domains: if ':' in domain: @@ -84,19 +98,28 @@ class ApplicationJob(threading.Thread): log.info(f"Exiting with code 65.") exit(65) - if f"{domain}-{type_}" not in self.found_domains: + if f"{domain}-{type_}" not in found_domains: log.error( f"Cannot find an {type_} record for the domain {domain} in your Cloudflare settings. " f"Have you defined this record yet?" ) log.info(f"Exiting with code 65.") exit(65) + + self.domains.append(found_domains[f"{domain}-{type_}"]) + else: - if f"{domain}-{type_1}" in self.found_domains: - type_ = type_1 - elif f"{domain}-{type_2}" in self.found_domains: - type_ = type_2 - else: + found = False + + if f"{domain}-A" in found_domains: + self.domains.append(found_domains[f"{domain}-A"]) + found = True + + if f"{domain}-AAAA" in found_domains: + self.domains.append(found_domains[f"{domain}-AAAA"]) + found = True + + if not found: log.error( f"Cannot find the domain {domain} in your Cloudflare settings. " f"Have you defined this record yet?" @@ -104,11 +127,6 @@ class ApplicationJob(threading.Thread): log.info(f"Exiting with code 65.") exit(65) - self.domains.append(self.found_domains[f"{domain}-{type_}"]) - - - - def validate_arguments(self): failed = False diff --git a/cloudflare_ddns/constants.py b/cloudflare_ddns/constants.py index cf2d5d0..99ca433 100644 --- a/cloudflare_ddns/constants.py +++ b/cloudflare_ddns/constants.py @@ -8,6 +8,10 @@ VERIFY_TOKEN = BASE_ENDPOINT + "user/tokens/verify" LIST_ZONES = BASE_ENDPOINT + "zones" LIST_DNS = BASE_ENDPOINT + "zones/{zone_identifier}/dns_records" +PATCH_DNS = BASE_ENDPOINT + "zones/{zone_identifier}/dns_records/{identifier}" # Utilities ACCEPTED_RECORDS = ('A', 'AAAA') + +IP_API_URL_IPV4 = "https://api.ipify.org/" +IP_API_URL_IPV6 = "https://api6.ipify.org/" diff --git a/cloudflare_ddns/utils.py b/cloudflare_ddns/utils.py index 49b8d2a..4e25fd3 100644 --- a/cloudflare_ddns/utils.py +++ b/cloudflare_ddns/utils.py @@ -4,7 +4,7 @@ import requests from requests import Request from requests.auth import AuthBase -from .constants import VERIFY_TOKEN +from cloudflare_ddns.constants import IP_API_URL_IPV4, IP_API_URL_IPV6 DURATION_REGEX = re.compile( r"((?P\d+?) ?(days|day|D|d) ?)?" @@ -53,3 +53,9 @@ class BearerAuth(AuthBase): """Attach the Authorization header to the request.""" r.headers["Authorization"] = f"Bearer {self.token}" return r + + +def get_ip(ipv6: bool) -> str: + """Return the host public IP as detected by ipify.org.""" + r = requests.get(IP_API_URL_IPV4 if not ipv6 else IP_API_URL_IPV6) + return r.text From ac98bf182b480c4d6c5f493550421dae51dec9c4 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 20 Jan 2021 11:27:36 +0100 Subject: [PATCH 6/9] Add proper status code handling --- cloudflare_ddns/app.py | 27 ++++++++++++++++++++------- cloudflare_ddns/utils.py | 24 +++++++++++++++++++++--- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/cloudflare_ddns/app.py b/cloudflare_ddns/app.py index f04a3b0..a2890e2 100644 --- a/cloudflare_ddns/app.py +++ b/cloudflare_ddns/app.py @@ -1,11 +1,13 @@ import logging import threading from dataclasses import dataclass -from typing import Dict, List, Tuple +from typing import List, Tuple import requests +from requests import HTTPError + from cloudflare_ddns.constants import ACCEPTED_RECORDS, LIST_DNS, LIST_ZONES, VERIFY_TOKEN, PATCH_DNS -from cloudflare_ddns.utils import BearerAuth, parse_duration, get_ip +from cloudflare_ddns.utils import BearerAuth, parse_duration, get_ip, check_status, CloudflareHTTPError log = logging.getLogger("ddns") @@ -46,6 +48,15 @@ class ApplicationJob(threading.Thread): log.info(f"Starting app. Records will be updated every {self.delay} seconds.") try: self.update_records() + + except HTTPError as e: + log.error( + f"HTTP error {'from Cloudflare' if isinstance(e, CloudflareHTTPError) else ''} " + f"while updating records for the first time, aborting." + ) + log.error(e) + log.info("Exiting with code 70.") + except Exception: log.exception("Error while updating records for the first time, aborting.") log.info("Exiting with code 70.") @@ -61,21 +72,23 @@ class ApplicationJob(threading.Thread): log.info("Starting record update.") for record in self.domains: log.debug(f"Updating record for {record.domain}.") - requests.patch( + + check_status(requests.patch( PATCH_DNS.format(zone_identifier=record.zone, identifier=record.id), json={"content": get_ip(record.record_type == 'AAAA')}, auth=self.auth - ).raise_for_status() + )) + log.info("Successfully updated records.") def parse_domains(self) -> None: found_domains = {} - for zone_json in requests.get(LIST_ZONES, auth=self.auth).json()["result"]: - for record_json in requests.get( + for zone_json in check_status(requests.get(LIST_ZONES, auth=self.auth)).json()["result"]: + for record_json in check_status(requests.get( LIST_DNS.format(zone_identifier=zone_json["id"]), auth=self.auth - ).json()["result"]: + )).json()["result"]: if record_json["type"] in ACCEPTED_RECORDS: domain = Domain( record_json["name"], diff --git a/cloudflare_ddns/utils.py b/cloudflare_ddns/utils.py index 4e25fd3..b72900a 100644 --- a/cloudflare_ddns/utils.py +++ b/cloudflare_ddns/utils.py @@ -1,10 +1,10 @@ import re import requests -from requests import Request +from requests import Request, Response, codes, HTTPError from requests.auth import AuthBase -from cloudflare_ddns.constants import IP_API_URL_IPV4, IP_API_URL_IPV6 +from cloudflare_ddns.constants import IP_API_URL_IPV4, IP_API_URL_IPV6, BASE_ENDPOINT DURATION_REGEX = re.compile( r"((?P\d+?) ?(days|day|D|d) ?)?" @@ -57,5 +57,23 @@ class BearerAuth(AuthBase): def get_ip(ipv6: bool) -> str: """Return the host public IP as detected by ipify.org.""" - r = requests.get(IP_API_URL_IPV4 if not ipv6 else IP_API_URL_IPV6) + r = check_status(requests.get(IP_API_URL_IPV4 if not ipv6 else IP_API_URL_IPV6)) return r.text + + +class CloudflareHTTPError(HTTPError): + """HTTPError coming from a Cloudflare endpoint.""" + pass + + +def check_status(r: Response) -> Response: + """Check the status code of a response and return it.""" + if not r.status_code == codes.ok: + if r.url.startswith(BASE_ENDPOINT) and not r.json()["success"]: + errors = "\n".join(f"{err['code']}: {err['message']}" for err in r.json()["errors"]) + + raise CloudflareHTTPError(f"{r.status_code} {r.reason} while querying {r.url}: {errors}") + else: + r.raise_for_status() + + return r From 8edab54df1177d09dfb9baa6ebc86c7e64bf011a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 20 Jan 2021 11:30:43 +0100 Subject: [PATCH 7/9] Fix import ordering --- cloudflare_ddns/__main__.py | 3 +-- cloudflare_ddns/app.py | 5 ++--- cloudflare_ddns/utils.py | 5 ++--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/cloudflare_ddns/__main__.py b/cloudflare_ddns/__main__.py index b1d2a3b..eda937b 100644 --- a/cloudflare_ddns/__main__.py +++ b/cloudflare_ddns/__main__.py @@ -1,8 +1,7 @@ import logging - -import click from typing import Tuple +import click from cloudflare_ddns.app import ApplicationJob from cloudflare_ddns.constants import DEFAULT_DELAY diff --git a/cloudflare_ddns/app.py b/cloudflare_ddns/app.py index a2890e2..6b295fd 100644 --- a/cloudflare_ddns/app.py +++ b/cloudflare_ddns/app.py @@ -4,11 +4,10 @@ from dataclasses import dataclass from typing import List, Tuple import requests +from cloudflare_ddns.constants import ACCEPTED_RECORDS, LIST_DNS, LIST_ZONES, PATCH_DNS, VERIFY_TOKEN +from cloudflare_ddns.utils import BearerAuth, CloudflareHTTPError, check_status, get_ip, parse_duration from requests import HTTPError -from cloudflare_ddns.constants import ACCEPTED_RECORDS, LIST_DNS, LIST_ZONES, VERIFY_TOKEN, PATCH_DNS -from cloudflare_ddns.utils import BearerAuth, parse_duration, get_ip, check_status, CloudflareHTTPError - log = logging.getLogger("ddns") diff --git a/cloudflare_ddns/utils.py b/cloudflare_ddns/utils.py index b72900a..5f2e24e 100644 --- a/cloudflare_ddns/utils.py +++ b/cloudflare_ddns/utils.py @@ -1,11 +1,10 @@ import re import requests -from requests import Request, Response, codes, HTTPError +from cloudflare_ddns.constants import BASE_ENDPOINT, IP_API_URL_IPV4, IP_API_URL_IPV6 +from requests import HTTPError, Request, Response, codes from requests.auth import AuthBase -from cloudflare_ddns.constants import IP_API_URL_IPV4, IP_API_URL_IPV6, BASE_ENDPOINT - DURATION_REGEX = re.compile( r"((?P\d+?) ?(days|day|D|d) ?)?" r"((?P\d+?) ?(hours|hour|H|h) ?)?" From e62aae67771de7cf1663e4fa3ece2d15b0f68ff5 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 20 Jan 2021 11:36:15 +0100 Subject: [PATCH 8/9] Document all the public functions --- cloudflare_ddns/app.py | 19 ++++++++++++++++++- cloudflare_ddns/utils.py | 1 - 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cloudflare_ddns/app.py b/cloudflare_ddns/app.py index 6b295fd..d353006 100644 --- a/cloudflare_ddns/app.py +++ b/cloudflare_ddns/app.py @@ -13,6 +13,16 @@ log = logging.getLogger("ddns") @dataclass class Domain: + """ + Dataclass representing one domain record to update. + + Args: + domain: The domain name. + record_type: The type of the record to update. + zone: The ID of the Cloudflare zone it belongs to. + id: The Cloudflare ID of this record. + """ + domain: str record_type: str zone: str @@ -20,6 +30,8 @@ class Domain: class ApplicationJob(threading.Thread): + """Main application class.""" + def __init__(self, raw_delay: str, token: str, raw_domains: Tuple[str]): super().__init__() @@ -35,11 +47,13 @@ class ApplicationJob(threading.Thread): self.raw_delay = raw_delay def launch(self) -> None: + """Launch the application by validating arguments and starting the thread.""" self.validate_arguments() log.debug("Starting job.") self.start() def run(self) -> None: + """Main application function, in charge of controlling the periodic updates.""" log.debug("Parsing domains.") self.parse_domains() log.debug(f"Using domains: {', '.join(f'{domain.record_type}:{domain.domain}' for domain in self.domains)}") @@ -68,6 +82,7 @@ class ApplicationJob(threading.Thread): log.exception(f"Error while updating records. Retrying in {self.delay} seconds.") def update_records(self) -> None: + """Update all the registered records.""" log.info("Starting record update.") for record in self.domains: log.debug(f"Updating record for {record.domain}.") @@ -81,6 +96,7 @@ class ApplicationJob(threading.Thread): log.info("Successfully updated records.") def parse_domains(self) -> None: + """Parse the domain in `raw_domains` and populate the `domains` array with `Domain` objects.""" found_domains = {} for zone_json in check_status(requests.get(LIST_ZONES, auth=self.auth)).json()["result"]: @@ -139,7 +155,8 @@ class ApplicationJob(threading.Thread): log.info(f"Exiting with code 65.") exit(65) - def validate_arguments(self): + def validate_arguments(self) -> None: + """Validate the provided arguments.""" failed = False if not self.raw_domains: diff --git a/cloudflare_ddns/utils.py b/cloudflare_ddns/utils.py index 5f2e24e..fa25848 100644 --- a/cloudflare_ddns/utils.py +++ b/cloudflare_ddns/utils.py @@ -62,7 +62,6 @@ def get_ip(ipv6: bool) -> str: class CloudflareHTTPError(HTTPError): """HTTPError coming from a Cloudflare endpoint.""" - pass def check_status(r: Response) -> Response: From 24f1a56ce8291292860b8d252c272efa93fc79e4 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 20 Jan 2021 18:14:17 +0100 Subject: [PATCH 9/9] Add command line document --- cloudflare_ddns/__main__.py | 51 +++++++++++++++++++++++++++++------- cloudflare_ddns/app.py | 4 +-- cloudflare_ddns/constants.py | 4 +++ 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/cloudflare_ddns/__main__.py b/cloudflare_ddns/__main__.py index eda937b..e3e4908 100644 --- a/cloudflare_ddns/__main__.py +++ b/cloudflare_ddns/__main__.py @@ -1,28 +1,61 @@ import logging +from os import environ from typing import Tuple import click from cloudflare_ddns.app import ApplicationJob -from cloudflare_ddns.constants import DEFAULT_DELAY +from cloudflare_ddns.constants import BASE_ENV_VAR, DEFAULT_DELAY, DOMAINS_ENV_VAR log = logging.getLogger("ddns") @click.command() -@click.option('--delay', '-d', default=DEFAULT_DELAY, show_default=True) -@click.option('--token', '-k', prompt="Enter your Cloudflare Token", hide_input=True, show_envvar=True) -@click.option('-v', '--verbose', is_flag=True, default=False) -@click.argument("domain", nargs=-1) -def start(delay: str, token: str, verbose: int, domain: Tuple[str]) -> None: - """Main application entrypoint.""" +@click.option( + '--delay', '-d', + default=DEFAULT_DELAY, + show_default=True, + show_envvar=True, + help="Time to wait between each update." +) +@click.option( + '--token', '-k', + prompt="Enter your Cloudflare Token", + hide_input=True, + show_envvar=True, + help="Your Cloudflare Bearer token." +) +@click.option('-v', '--verbose', is_flag=True, default=False, help="Show debug logging.") +@click.argument("domains", nargs=-1) +def start(delay: str, token: str, verbose: int, domains: Tuple[str]) -> None: + """ + Update Cloudflare DNS RECORDS to your current IP every (default: 5 minutes). + + The domains can be passed either as command line options, or as 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. + + \b + The duration supports the following symbols for each unit of time: + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `m`, `minute`, `minutes`, `min` + - seconds: `S`, `s`, `second`, `seconds`, `sec` + The units need to be provided in descending order of magnitude. + """ + # Configure logging. logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.DEBUG if verbose else logging.INFO ) - ApplicationJob(delay, token, domain).launch() + # Get domains from the environment variable. + if domain_var := environ.get(DOMAINS_ENV_VAR, None): + domains = list(domains) + domains.extend(domain_var.split(" ")) + + ApplicationJob(delay, token, domains).launch() # Main entrypoint if __name__ == "__main__": - start(auto_envvar_prefix="CF_DDNS") + start(auto_envvar_prefix=BASE_ENV_VAR) diff --git a/cloudflare_ddns/app.py b/cloudflare_ddns/app.py index d353006..2187df2 100644 --- a/cloudflare_ddns/app.py +++ b/cloudflare_ddns/app.py @@ -1,7 +1,7 @@ import logging import threading from dataclasses import dataclass -from typing import List, Tuple +from typing import List import requests from cloudflare_ddns.constants import ACCEPTED_RECORDS, LIST_DNS, LIST_ZONES, PATCH_DNS, VERIFY_TOKEN @@ -32,7 +32,7 @@ class Domain: class ApplicationJob(threading.Thread): """Main application class.""" - def __init__(self, raw_delay: str, token: str, raw_domains: Tuple[str]): + def __init__(self, raw_delay: str, token: str, raw_domains: List[str]): super().__init__() self.stop_signal = threading.Event() diff --git a/cloudflare_ddns/constants.py b/cloudflare_ddns/constants.py index 99ca433..87a5bfb 100644 --- a/cloudflare_ddns/constants.py +++ b/cloudflare_ddns/constants.py @@ -1,6 +1,10 @@ # App defaults DEFAULT_DELAY = "5 minutes" +# App constants +BASE_ENV_VAR = "CF_DDNS" +DOMAINS_ENV_VAR = BASE_ENV_VAR + "_DOMAINS" + # Endpoints BASE_ENDPOINT = "https://api.cloudflare.com/client/v4/"