Compare commits

..

8 Commits

6 changed files with 126 additions and 83 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ log.txt
__pycache__/ __pycache__/
.vscode/ .vscode/
.direnv/ .direnv/
.mypy_cache/

View File

@ -3,58 +3,99 @@ A DNS record updater for [Gandi's LiveDNS](https://api.gandi.net/docs/livedns/)
This script is heavily inspired by [dyn-gandi](https://github.com/Danamir/dyn-gandi). This script is heavily inspired by [dyn-gandi](https://github.com/Danamir/dyn-gandi).
## How it works ## How it works
This script determines the your server's current IP by querying the resolvers defined in the config file. This script determines the the current IP address 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. It then queries the subdomains' A records off of Gandi and compares their IP addresses to the current IP address.
Should the IP address of a subdomain not match your current IP it will be updated. Should the IP address of a subdomain's A record not match your current IP address it will be updated. The subdomain's A record will be created should it not already exist.
The subdomain will be created should it not already exist.
## Note ## Notes
The current implementation only allows for one entry per subdomain. Every invocation of the script causes at least 1 request to a resolver specified and 1 API call to Gandi per domain.
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. Updating a subdomain's A record is 1 API request per subdomain, even if they share the same domain.
Resolvers are queried in the order specified until one returns a valid IP address.
It is also possible to define a path to a file with the API key written in it. This is good for environments where the config file has to be shared like in a nix project.
## How to use ## How to use
First, get your API key from https://account.gandi.net/en/users/USER/security where `USER` is your Gandi username. 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` The script looks for a config file at `$HOME/.config/dyn-gandi/config.log` or `/etc/dyn-gandi.conf` in that order. So create a file at one of these locations according to the schema below.
```json ```json
{ {
"api": { "api": {
"<Your-API-Key>": { "<Your-API-Key>": {
"example.com": [ "@", "www", "sub1" ], "example.com": [ "@", "www", "sub1" ],
"example.org": [ "@", "www", "sub1", "sub2" ] "example.org": [ "@", "www", "sub1", "sub2" ]
},
"/path/to/a/file/containing/api_key": {
"example.at": [ "sub1" ],
"example.au": [ "sub1" "sub2" ]
} }
}, },
"ttl": 3600,
"resolvers": [ "resolvers": [
"https://ifconfig.me/ip", "https://ifconfig.me/ip",
"https://me.gandi.net" "https://me.gandi.net"
], ],
"ttl": 3600,
"log_path": "./log.txt" "log_path": "./log.txt"
} }
``` ```
## Nix
Add this to the modules.
```nix
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05";
dyn-gandi.url = "git+https://git.krsnik.at/Kristian/dyn-gandi.git";
};
outputs = {
self,
nixpkgs,
dyn-gandi
}: {
...
modules = [
dyn-gandi.nixosModules.default
{
dyn-gandi.enable = true;
dyn-gandi.settings = {
api = {
"/path/to/a/file/containing/api_key" = {
"example.com" = ["@" "www"];
};
};
resolvers = [
"https://ifconfig.me/ip"
"https://me.gandi.net"
];
ttl = 3600;
log_path = "/path/to/log/file";
};
dyn-gandi.timer = 300;
}
...
];
...
}
```
Use `dyn-gandi.nixosModules.default` for a NixOs module and `dyn-gandi.homeManagerModules.default` for home-manager
`dyn-gandi.timer` specifies a timer in seconds when the script should be repeated.
## Features ## Features
* Support for arbitrarily many domains and records through a nested data structure. * Support for arbitrarily many domains and subdomains through a nested data structure.
* Small codebase. * Small codebase
* Logging * Logging
* NixOS and home-manager modules
## Limitations ## Limitations
* Right now only IPv4 addresses are supported * 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 ## TODO
* Testing * Testing
* Command line options controlling: dry-run, config, log, verbosity, force * Command line options controlling: dry-run, config, log, verbosity, force
* Support IPv6
* Per subdomain TTL * Per subdomain TTL
* Nix Flake support with exported config and service options
* Better documentation * Better documentation
* Better logging * Better logging
* Support IPv6
* Remember other record types (TXT, etc.)
* Detect TTL change and update even when the IP is the same

View File

@ -3,11 +3,19 @@ import json
import re import re
import os import os
from datetime import datetime from datetime import datetime
from typing import Callable, Any
def loadSettings(path: str = f"{os.path.expanduser('~')}/.config/dyn-gandi/config.json") -> dict: def loadSettings() -> dict:
path = f"{os.path.expanduser('~')}/.config/dyn-gandi/config.json"
if not os.path.exists(path):
path = '/etc/dyn-gandi.json'
if not os.path.exists(path):
quit()
# TODO: check integrity of the config file # TODO: check integrity of the config file
try:
with open(path, 'r') as file: with open(path, 'r') as file:
# Read if the API keys are path to files # Read if the API keys are path to files
config = json.load(file) config = json.load(file)
@ -21,9 +29,7 @@ def loadSettings(path: str = f"{os.path.expanduser('~')}/.config/dyn-gandi/confi
mask[key] = api_key mask[key] = api_key
config['api'] = {mask[key]: value for key, config['api'] = {mask[key]: value for key,
value in config['api'].items()} value in config['api'].items()}
except FileNotFoundError: return config
quit()
# TODO log error and remove code below (do not create an unwanted config)
def log(logline: str, path: str = './log.txt') -> None: def log(logline: str, path: str = './log.txt') -> None:
@ -33,12 +39,13 @@ def log(logline: str, path: str = './log.txt') -> None:
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]{logline}\n") f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]{logline}\n")
def getCurrentIP(resolvers: list[str], log: callable) -> None | str: def getCurrentIP(resolvers: list[str], log: Callable[[str], Any]) -> str | None:
# Go through resolvers until one retuns an IP # Go through resolvers until one retuns an IP
for resolver in resolvers: for resolver in resolvers:
response = requests.get(resolver) response = requests.get(resolver)
if response.status_code == 200: if response.status_code == 200:
current_ip = response.text.strip() current_ip = response.text.strip()
# it suffices to check whether the search is not None since the regex is matches from beginning to end
is_ipv4 = re.search( is_ipv4 = re.search(
'^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\\b){4}$', current_ip) is not None '^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\\b){4}$', current_ip) is not None
if is_ipv4: if is_ipv4:
@ -51,35 +58,32 @@ def getCurrentIP(resolvers: list[str], log: callable) -> None | str:
return None return None
def checkRecords(api: dict, current_ip: str, log: callable) -> dict: def checkRecords(api: dict[str, dict[str, list[str]]], current_ip: str, log: Callable[[str], Any]) -> dict[str, dict[str, list[str]]]:
# Returns the records that need to be renewed # Returns the records that need to be renewed
# TODO: Also detect TTL change # TODO: Also detect TTL change
records_to_renew = api records_to_renew = api
for key, domains in api.items(): for key, domains in api.items():
headers = {'authorization': f"Apikey {key}"} headers = {'authorization': f"Apikey {key}"}
for domain, records in domains.items(): for domain, _ in domains.items():
response = requests.get( response = requests.get(
f"https://api.gandi.net/v5/livedns/domains/{domain}/records?rrset_type=A", headers=headers) f"https://api.gandi.net/v5/livedns/domains/{domain}/records?rrset_type=A", headers=headers)
if response.status_code == 200: if response.status_code == 200:
records_to_renew[key][domain] = list(set(records_to_renew[key][domain]) - set(record['rrset_name'] for record in ( records_to_renew[key][domain] = list(
filter(lambda x: current_ip in x['rrset_values'], json.loads(response.json()))))) set(records_to_renew[key][domain]) - set(record['rrset_name'] for record in (filter(lambda x: current_ip in x['rrset_values'], response.json()))))
else: else:
log(f"[ERROR][{domain}][{response.status_code}] {response.json()}") log(f"[ERROR][{domain}][{response.status_code}] {response.json()}")
return records_to_renew return records_to_renew
def renewRecords(records, current_ip, log, ttl=3600): def renewRecords(api: dict[str, dict[str, list[str]]], current_ip: str, log: Callable[[str], Any], ttl) -> None:
# Updates the records and reates them if they don't exist # Updates the records and reates them if they don't exist
payload = json.dumps({ payload = json.dumps({
'items': [{
'rrset_type': 'A',
'rrset_values': [current_ip], 'rrset_values': [current_ip],
'rrset_ttl': ttl 'rrset_ttl': ttl
}]
}) })
for key, domains in records.items(): for key, domains in api.items():
headers = { headers = {
'authorization': f"Apikey {key}", 'authorization': f"Apikey {key}",
'content-type': 'application/json', 'content-type': 'application/json',
@ -88,7 +92,7 @@ def renewRecords(records, current_ip, log, ttl=3600):
for domain, records in domains.items(): for domain, records in domains.items():
for record in records: for record in records:
response = requests.put( response = requests.put(
f"https://api.gandi.net/v5/livedns/domains/{domain}/records/{record}", data=payload, headers=headers) f"https://api.gandi.net/v5/livedns/domains/{domain}/records/{record}/A", data=payload, headers=headers)
if response.status_code == 201: if response.status_code == 201:
log(f"[OK][{domain}][{record}] Renewed with IP '{current_ip}'") log(f"[OK][{domain}][{record}] Renewed with IP '{current_ip}'")
else: else:
@ -97,13 +101,13 @@ def renewRecords(records, current_ip, log, ttl=3600):
def main(): def main():
settings = loadSettings() settings = loadSettings()
log_path = settings['log_path'] log_path, ttl = settings['log_path'], settings['ttl']
ttl = settings['ttl'] def logger(x: str) -> Any: return log(x, log_path)
def myLog(x): return log(x, log_path)
current_ip = getCurrentIP(settings['resolvers'], myLog) current_ip = getCurrentIP(settings['resolvers'], logger)
records_to_renew = checkRecords(settings['api'], current_ip, myLog) if current_ip is not None:
renewRecords(records_to_renew, current_ip, myLog, ttl=ttl) records_to_renew = checkRecords(settings['api'], current_ip, logger)
renewRecords(records_to_renew, current_ip, logger, ttl=ttl)
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -27,6 +27,8 @@
packages = with pkgs.${system}; [ packages = with pkgs.${system}; [
(poetry2nix.mkPoetryEnv {projectDir = self;}) (poetry2nix.mkPoetryEnv {projectDir = self;})
poetry poetry
mypy
python310Packages.types-requests
]; ];
}; };
}); });

View File

@ -4,11 +4,11 @@ self: {
pkgs, pkgs,
... ...
}: let }: let
cfg = config.services.dyn-gandi; cfg = config.dyn-gandi;
package = self.packages.${pkgs.stdenv.hostPlatform.system}.default; package = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
inherit (lib) mkIf mkEnableOption mkOption types; inherit (lib) mkIf mkEnableOption mkOption types;
in { in {
options.services.dyn-gandi = { options.dyn-gandi = {
enable = mkEnableOption "dyn-gandi"; enable = mkEnableOption "dyn-gandi";
timer = lib.mkOption { timer = lib.mkOption {
@ -33,7 +33,7 @@ in {
]); ]);
in in
valueType; valueType;
default = throw "Please specify services.dyn-gandi.settings"; default = throw "Please specify dyn-gandi.settings";
}; };
}; };

View File

@ -1,15 +1,15 @@
self: { inputs: {
config, config,
lib, lib,
pkgs, pkgs,
system, system,
... ...
}: let }: let
cfg = config.services.dyn-gandi; cfg = config.dyn-gandi;
package = self.packages.${pkgs.stdenv.hostPlatform.system}.default; package = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
inherit (lib) mkIf mkEnableOption mkOption types; inherit (lib) mkIf mkEnableOption mkOption types;
in { in {
options.services.dyn-gandi = { options.dyn-gandi = {
enable = mkEnableOption "dyn-gandi"; enable = mkEnableOption "dyn-gandi";
timer = lib.mkOption { timer = lib.mkOption {
@ -34,37 +34,32 @@ in {
]); ]);
in in
valueType; valueType;
default = throw "Please specify services.dyn-gandi.settings"; default = throw "Please specify dyn-gandi.settings";
}; };
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
environment = { environment = {
systemPackages = [package]; systemPackages = [package];
}; # TODO: make this architecture independent
xdg.configFile."dyn-gandi/config.json" = { etc."dyn-gandi.json" = {
text = builtins.toJSON cfg.settings; text = builtins.toJSON cfg.settings;
}; };
};
systemd.services.dyn-gandi = mkIf (cfg.timer systemd.services.dyn-gandi = mkIf (cfg.timer
!= null) { != null) {
Unit = { script = "${package}/bin/dyn_gandi"; # TODO: add config file via command line options
After = ["network.target"];
};
Install = {WantedBy = ["default.target"];}; serviceConfig = {
Service = {
Type = "oneshot"; Type = "oneshot";
ExecStart = "${package}/bin/dyn_gandi"; # TODO: add config file via command line options
}; };
}; };
systemd.timers.dyn-gandi = mkIf (cfg.timer systemd.timers.dyn-gandi = mkIf (cfg.timer
!= null) { != null) {
Install = {WantedBy = ["timers.target"];}; wantedBy = ["timers.target"];
Timer = { timerConfig = {
OnBootSec = "0s"; OnBootSec = "0s";
OnUnitActiveSec = "${toString cfg.timer}s"; OnUnitActiveSec = "${toString cfg.timer}s";
Unit = "dyn-gandi.service"; Unit = "dyn-gandi.service";