Compare commits
8 Commits
9754ea32a6
...
671154c5c2
Author | SHA1 | Date | |
---|---|---|---|
671154c5c2 | |||
be54a045f3 | |||
f7d5a7d59f | |||
993c960aea | |||
63fcfdaf3a | |||
83c79df6b9 | |||
b3a5e6ce46 | |||
b5ab25940a |
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,4 +3,5 @@ config.json
|
|||||||
log.txt
|
log.txt
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.vscode/
|
.vscode/
|
||||||
.direnv/
|
.direnv/
|
||||||
|
.mypy_cache/
|
91
README.md
91
README.md
@ -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
|
||||||
* Per subdomain TTL
|
|
||||||
* Nix Flake support with exported config and service options
|
|
||||||
* Better documentation
|
|
||||||
* Better logging
|
|
||||||
* Support IPv6
|
* Support IPv6
|
||||||
* Remember other record types (TXT, etc.)
|
* Per subdomain TTL
|
||||||
* Detect TTL change and update even when the IP is the same
|
* Better documentation
|
||||||
|
* Better logging
|
@ -3,27 +3,33 @@ 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:
|
||||||
# TODO: check integrity of the config file
|
path = f"{os.path.expanduser('~')}/.config/dyn-gandi/config.json"
|
||||||
try:
|
|
||||||
with open(path, 'r') as file:
|
if not os.path.exists(path):
|
||||||
# Read if the API keys are path to files
|
path = '/etc/dyn-gandi.json'
|
||||||
config = json.load(file)
|
|
||||||
keys = config['api'].keys()
|
if not os.path.exists(path):
|
||||||
mask = dict()
|
|
||||||
for key in keys:
|
|
||||||
api_key = key
|
|
||||||
if os.path.exists(key):
|
|
||||||
with open(key, 'r') as file:
|
|
||||||
api_key = file.readline().strip()
|
|
||||||
mask[key] = api_key
|
|
||||||
config['api'] = {mask[key]: value for key,
|
|
||||||
value in config['api'].items()}
|
|
||||||
except FileNotFoundError:
|
|
||||||
quit()
|
quit()
|
||||||
# TODO log error and remove code below (do not create an unwanted config)
|
|
||||||
|
# TODO: check integrity of the config file
|
||||||
|
with open(path, 'r') as file:
|
||||||
|
# Read if the API keys are path to files
|
||||||
|
config = json.load(file)
|
||||||
|
keys = config['api'].keys()
|
||||||
|
mask = dict()
|
||||||
|
for key in keys:
|
||||||
|
api_key = key
|
||||||
|
if os.path.exists(key):
|
||||||
|
with open(key, 'r') as file:
|
||||||
|
api_key = file.readline().strip()
|
||||||
|
mask[key] = api_key
|
||||||
|
config['api'] = {mask[key]: value for key,
|
||||||
|
value in config['api'].items()}
|
||||||
|
return 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_values': [current_ip],
|
||||||
'rrset_type': 'A',
|
'rrset_ttl': ttl
|
||||||
'rrset_values': [current_ip],
|
|
||||||
'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__':
|
||||||
|
@ -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
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -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";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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";
|
||||||
|
Loading…
Reference in New Issue
Block a user