Compare commits

..

No commits in common. "671154c5c2bbe7cfe4350935a0b5b5ad5b897a50" and "9754ea32a6cc69b08af6a5a40fc6b4aa488b4bf8" have entirely different histories.

6 changed files with 82 additions and 125 deletions

3
.gitignore vendored
View File

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

View File

@ -3,99 +3,58 @@ 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 the current IP address by querying the resolvers defined in the config file. This script determines the your server's current IP by querying the resolvers defined in the config file.
It then queries the subdomains' A records off of Gandi and compares their IP addresses to the current IP address. After that it reads the current state of the domains and subdomains you specified of Gandi.
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. 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.
## Notes ## Note
Every invocation of the script causes at least 1 request to a resolver specified and 1 API call to Gandi per domain. The current implementation only allows for one entry per subdomain.
Updating a subdomain's A record is 1 API request per subdomain, even if they share the same 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.
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.
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. `config.json`
```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 subdomains through a nested data structure. * Support for arbitrarily many domains and records through a nested data structure.
* Small codebase * Small codebase.
* Logging * Logging
* NixOS and home-manager modules
## Limitations ## Limitations
* Only IPv4 addresses are supported * 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 ## 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,33 +3,27 @@ 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() -> dict: def loadSettings(path: str = f"{os.path.expanduser('~')}/.config/dyn-gandi/config.json") -> 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
with open(path, 'r') as file: try:
# Read if the API keys are path to files with open(path, 'r') as file:
config = json.load(file) # Read if the API keys are path to files
keys = config['api'].keys() config = json.load(file)
mask = dict() keys = config['api'].keys()
for key in keys: mask = dict()
api_key = key for key in keys:
if os.path.exists(key): api_key = key
with open(key, 'r') as file: if os.path.exists(key):
api_key = file.readline().strip() with open(key, 'r') as file:
mask[key] = api_key api_key = file.readline().strip()
config['api'] = {mask[key]: value for key, mask[key] = api_key
value in config['api'].items()} config['api'] = {mask[key]: value for key,
return config value in config['api'].items()}
except FileNotFoundError:
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:
@ -39,13 +33,12 @@ 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[[str], Any]) -> str | None: def getCurrentIP(resolvers: list[str], log: callable) -> None | str:
# 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:
@ -58,32 +51,35 @@ def getCurrentIP(resolvers: list[str], log: Callable[[str], Any]) -> str | None:
return None return None
def checkRecords(api: dict[str, dict[str, list[str]]], current_ip: str, log: Callable[[str], Any]) -> dict[str, dict[str, list[str]]]: def checkRecords(api: dict, current_ip: str, log: callable) -> dict:
# 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, _ in domains.items(): for domain, records 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( records_to_renew[key][domain] = list(set(records_to_renew[key][domain]) - set(record['rrset_name'] for record in (
set(records_to_renew[key][domain]) - set(record['rrset_name'] for record in (filter(lambda x: current_ip in x['rrset_values'], response.json())))) filter(lambda x: current_ip in x['rrset_values'], json.loads(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(api: dict[str, dict[str, list[str]]], current_ip: str, log: Callable[[str], Any], ttl) -> None: def renewRecords(records, current_ip, log, ttl=3600):
# 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({
'rrset_values': [current_ip], 'items': [{
'rrset_ttl': ttl 'rrset_type': 'A',
'rrset_values': [current_ip],
'rrset_ttl': ttl
}]
}) })
for key, domains in api.items(): for key, domains in records.items():
headers = { headers = {
'authorization': f"Apikey {key}", 'authorization': f"Apikey {key}",
'content-type': 'application/json', 'content-type': 'application/json',
@ -92,7 +88,7 @@ def renewRecords(api: dict[str, dict[str, list[str]]], current_ip: str, log: Cal
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}/A", data=payload, headers=headers) f"https://api.gandi.net/v5/livedns/domains/{domain}/records/{record}", 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:
@ -101,13 +97,13 @@ def renewRecords(api: dict[str, dict[str, list[str]]], current_ip: str, log: Cal
def main(): def main():
settings = loadSettings() settings = loadSettings()
log_path, ttl = settings['log_path'], settings['ttl'] log_path = settings['log_path']
def logger(x: str) -> Any: return log(x, log_path) ttl = settings['ttl']
def myLog(x): return log(x, log_path)
current_ip = getCurrentIP(settings['resolvers'], logger) current_ip = getCurrentIP(settings['resolvers'], myLog)
if current_ip is not None: records_to_renew = checkRecords(settings['api'], current_ip, myLog)
records_to_renew = checkRecords(settings['api'], current_ip, logger) renewRecords(records_to_renew, current_ip, myLog, ttl=ttl)
renewRecords(records_to_renew, current_ip, logger, ttl=ttl)
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -27,8 +27,6 @@
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.dyn-gandi; cfg = config.services.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.dyn-gandi = { options.services.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 dyn-gandi.settings"; default = throw "Please specify services.dyn-gandi.settings";
}; };
}; };

View File

@ -1,15 +1,15 @@
inputs: { self: {
config, config,
lib, lib,
pkgs, pkgs,
system, system,
... ...
}: let }: let
cfg = config.dyn-gandi; cfg = config.services.dyn-gandi;
package = inputs.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.dyn-gandi = { options.services.dyn-gandi = {
enable = mkEnableOption "dyn-gandi"; enable = mkEnableOption "dyn-gandi";
timer = lib.mkOption { timer = lib.mkOption {
@ -34,32 +34,37 @@ in {
]); ]);
in in
valueType; valueType;
default = throw "Please specify dyn-gandi.settings"; default = throw "Please specify services.dyn-gandi.settings";
}; };
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
environment = { environment = {
systemPackages = [package]; systemPackages = [package];
}; # TODO: make this architecture independent
etc."dyn-gandi.json" = { xdg.configFile."dyn-gandi/config.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) {
script = "${package}/bin/dyn_gandi"; # TODO: add config file via command line options Unit = {
After = ["network.target"];
};
serviceConfig = { Install = {WantedBy = ["default.target"];};
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) {
wantedBy = ["timers.target"]; Install = {WantedBy = ["timers.target"];};
timerConfig = { Timer = {
OnBootSec = "0s"; OnBootSec = "0s";
OnUnitActiveSec = "${toString cfg.timer}s"; OnUnitActiveSec = "${toString cfg.timer}s";
Unit = "dyn-gandi.service"; Unit = "dyn-gandi.service";