From 7e8356a580226e043471431630116f41d0d2b955 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 26 Jul 2023 00:49:47 +0200 Subject: [PATCH] initial commit --- .gitignore | 2 ++ README.md | 60 +++++++++++++++++++++++++++++++++ dyn-gandi.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 dyn-gandi.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05e386e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.json +log.txt \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa25ce7 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Dyn-Gandi +A DNS record updater for [Gandi's LiveDNS](https://api.gandi.net/docs/livedns/) API. +This script is heavily inspired by [dyn-gandi](https://github.com/Danamir/dyn-gandi). + +## How it works +This script determines the your server's current IP by querying the resolvers defined in the config file. +After that it reads the current state of the domains and subdomains you specified of Gandi. +Should the IP address of a subdomain not match your current IP it will be updated. +The subdomain will be created should it not already exist. + +## Note +The current implementation only allows for one entry per subdomain. +Meaning that should you have a TXT and a CNAME record for a subdomain that is in the config file, then both these entries will be deleted and replaced by a single A name record. + +## How to use +First, get your API key from https://account.gandi.net/en/users/USER/security where `USER` is your Gandi username. +On first run the program will create a minimal, yet complete `config.json` in the same directory it is being run in. +Next, enter the API key into the configuration file and assign domains to it. +The domains are keys to a list of subdomain for a given domain you wish to monitor. +The below example is complete and should explain itself. +Resolvers are queried one after another until one returns a valid IP. + +`config.json` +```json +{ + "api": { + "": { + "example.com": [ "@", "www", "sub1" ], + "example.org": [ "@", "www", "sub1", "sub2" ] + } + }, + "ttl": 3600, + "resolvers": [ + "https://ifconfig.me/ip" + "https://me.gandi.net" + ], + "log_path": "./log.txt" +} +``` + +## Features +* Support for arbitrarily many domains and records through a nested data structure. +* Small codebase. +* Logging + +## Limitations +* Right now only IPv4 addresses are supported +* Every record is only allowed one A record. +* Extra records (TXT, CNAME and such) will get deleted on update. + +## TODO +* Testing +* Command line options controlling: dry-run, config, log, verbosity, force +* Per subdomain TTL +* Nix Flake support with exported config and service options +* Better documentation +* Better logging +* Support IPv6 +* Remember other record types (TXT, etc.) +* Detect TTL change and update even when the IP is the same \ No newline at end of file diff --git a/dyn-gandi.py b/dyn-gandi.py new file mode 100644 index 0000000..7f656b9 --- /dev/null +++ b/dyn-gandi.py @@ -0,0 +1,93 @@ +import requests, json, re +from datetime import datetime + +def loadSettings() -> dict: + # TODO: check integrity + try: + with open('config.json', 'r') as file: + return json.load(file) + except FileNotFoundError: + sample_config = { + 'api': {}, + 'ttl': 3600, + 'resolvers': [ 'https://ifconfig.me' ], + 'log_path': './log.txt' + } + + with open('config.json', 'w') as file: + json.dump(sample_config, file, indent = 2) + + return sample_config + +# Appends logline at the end of the file file with a timestamp +def log(logline: str, path: str = './log.txt') -> None: + with open(path, 'a') as file: + file.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]{logline}\n") + +# Go through resolvers until one retuns an IP +def getCurrentIP(resolvers: list[str], log: callable) -> None | str: + for resolver in resolvers: + response = requests.request('GET', resolver) + if response.status_code == 200: + current_ip = response.text.strip() + is_ipv4 = re.search('^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\\b){4}$', current_ip) is not None + if is_ipv4: + log(f"[OK][{resolver}] Current IP: '{current_ip}'") + return current_ip # Break if we have found our IP + else: + log(f"[ERROR][{resolver}] '{current_ip}' is not IPv4") + else: + log(f"[ERROR][{resolver}][{response.status_code}] {response.text}") + return None + +# Returns the records that need to be renewed +# TODO: Also detect TTL change +def checkRecords(api: dict, current_ip: str, log: callable) -> dict: + records_to_renew = api + for key, domains in api.items(): + headers = { 'authorization': f"Apikey {key}" } + for domain, records in domains.items(): + response = requests.request('GET', f"https://api.gandi.net/v5/livedns/domains/{domain}/records?rrset_type=A", headers = headers) + if response.status_code == 200: + records_to_renew[key][domain] = list(set(records_to_renew[key][domain]) - set(record['rrset_name'] for record in (filter(lambda x: current_ip in x['rrset_values'], json.loads(response.text))))) + else: + log(f"[ERROR][{domain}][{response.status_code}] {response.text}") + + return records_to_renew + +# Updates the records and reates them if they don't exist +def renewRecords(records, current_ip, log, ttl = 3600): + payload = json.dumps({ + 'items': [{ + 'rrset_type': 'A', + 'rrset_values': [ current_ip ], + 'rrset_ttl': ttl + }] + }) + + for key, domains in records.items(): + headers = { + 'authorization': f"Apikey {key}", + 'content-type': 'application/json', + } + + for domain, records in domains.items(): + for record in records: + response = requests.request('PUT', f"https://api.gandi.net/v5/livedns/domains/{domain}/records/{record}", data = payload, headers = headers) + if response.status_code == 201: + log(f"[OK][{domain}][{record}] Renewed with IP '{current_ip}'") + else: + log(f"[ERROR][{domain}][{record}][{response.status_code}] {response.text}") + +def main(): + settings = loadSettings() + log_path = settings['log_path'] + ttl = settings['ttl'] + log = lambda x: log(x, path = log_path) + + current_ip = getCurrentIP(settings['resolvers'], log) + records_to_renew = checkRecords(settings['api'], current_ip, log) + renewRecords(records_to_renew, current_ip, log, ttl = ttl) + +if __name__ == '__main__': + main() \ No newline at end of file