mirror of
https://codeberg.org/privacy1st/netcup-dns
synced 2024-12-22 23:36:04 +01:00
feat: caching
This commit is contained in:
parent
93b20ba4dc
commit
cebca53cb7
@ -24,12 +24,6 @@ There is an [example configuration](cfg/example.json).
|
|||||||
|
|
||||||
## TODOs
|
## TODOs
|
||||||
|
|
||||||
Caching:
|
|
||||||
|
|
||||||
- Add parameter `--cache` which saves retrieved DNS records as JSON files.
|
|
||||||
- The method `update_record_destination` will then check if the arguments are the same as the saved JSON record.
|
|
||||||
- Only if they differ, any requests to the API endpoint will be made.
|
|
||||||
|
|
||||||
Alternative external IP detection:
|
Alternative external IP detection:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
49
src/netcup_dns/datetime_util.py
Executable file
49
src/netcup_dns/datetime_util.py
Executable file
@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def test():
|
||||||
|
dt = datetime.now()
|
||||||
|
|
||||||
|
print('non UTC:')
|
||||||
|
print(dt)
|
||||||
|
|
||||||
|
print('\nUTC:')
|
||||||
|
print(now())
|
||||||
|
print(to_str(now()))
|
||||||
|
print(now_str())
|
||||||
|
print(from_str(to_str(now())))
|
||||||
|
|
||||||
|
print('\nlocalized:')
|
||||||
|
print(dt.tzinfo)
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
print(dt)
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def now_str() -> str:
|
||||||
|
return to_str(now())
|
||||||
|
|
||||||
|
|
||||||
|
def to_str(dt: datetime) -> str:
|
||||||
|
return dt.strftime(fmt())
|
||||||
|
|
||||||
|
|
||||||
|
def from_str(dt_str: str) -> datetime:
|
||||||
|
dt = datetime.strptime(dt_str, fmt())
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt() -> str:
|
||||||
|
return '%Y%m%dT%H%M%S'
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_len() -> int:
|
||||||
|
return 13
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
test()
|
@ -11,6 +11,7 @@ import requests
|
|||||||
from nc_dnsapi import Client, DNSRecord
|
from nc_dnsapi import Client, DNSRecord
|
||||||
|
|
||||||
from netcup_dns.exception import UnknownIPException, MultipleRecordsException
|
from netcup_dns.exception import UnknownIPException, MultipleRecordsException
|
||||||
|
from netcup_dns.record_dst_cache import RecordDestinationCache
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@ -19,6 +20,7 @@ def main():
|
|||||||
"""
|
"""
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
cfg_dir: Path = args.cfg_dir
|
cfg_dir: Path = args.cfg_dir
|
||||||
|
cache: RecordDestinationCache | None = args.cache
|
||||||
|
|
||||||
cfg_files = [file for file in cfg_dir.iterdir() if file.name.endswith('.json') and file.is_file()]
|
cfg_files = [file for file in cfg_dir.iterdir() if file.name.endswith('.json') and file.is_file()]
|
||||||
for cfg_file in cfg_files:
|
for cfg_file in cfg_files:
|
||||||
@ -48,7 +50,7 @@ def main():
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f'DNS record type {type_} is not supported.')
|
raise ValueError(f'DNS record type {type_} is not supported.')
|
||||||
|
|
||||||
if update_record_destination(api, domain, hostname, type_, destination=destination):
|
if update_record_destination(api, domain, hostname, type_, destination, cache):
|
||||||
print(f'Set {hostname}.{domain} {type_} record to {destination}')
|
print(f'Set {hostname}.{domain} {type_} record to {destination}')
|
||||||
else:
|
else:
|
||||||
print(f'The {hostname}.{domain} {type_} record points already to {destination}')
|
print(f'The {hostname}.{domain} {type_} record points already to {destination}')
|
||||||
@ -62,12 +64,35 @@ def parse_args():
|
|||||||
dest='cfg_dir',
|
dest='cfg_dir',
|
||||||
default=Path('/etc/netcup-dns'),
|
default=Path('/etc/netcup-dns'),
|
||||||
type=Path)
|
type=Path)
|
||||||
|
parser.add_argument('--cache-directory',
|
||||||
|
help='Path to cache directory. Retrieved/Set DNS records are cached there.',
|
||||||
|
dest='cache_dir',
|
||||||
|
default=Path.home().joinpath('.netcup-dns/cache'),
|
||||||
|
type=Path)
|
||||||
|
parser.add_argument('--cache-validity-seconds',
|
||||||
|
help='Value in seconds for how long cached DNS records are valid.'
|
||||||
|
' Set to `0` to disable caching.',
|
||||||
|
dest='cache_validity_seconds',
|
||||||
|
default=7200,
|
||||||
|
type=int)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not args.cfg_dir.exists():
|
if not args.cfg_dir.exists():
|
||||||
raise Exception(f'The given config directory does not exist: {args.cfg_dir}')
|
raise Exception(f'The given config directory does not exist: {args.cfg_dir}')
|
||||||
|
if args.cache_validity_seconds < 0:
|
||||||
|
raise Exception(f'A negative cache validity duration is not allowed: {args.cache_validity_seconds}')
|
||||||
|
|
||||||
def update_record_destination(api: Client, domain: str, hostname: str, type_: str, destination: str) -> bool:
|
if args.cache_validity_seconds == 0:
|
||||||
|
# Disable caching.
|
||||||
|
args.cache = None
|
||||||
|
else:
|
||||||
|
args.cache = RecordDestinationCache(args.cache_dir, args.cache_validity_seconds)
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def update_record_destination(api: Client, domain: str, hostname: str, type_: str, destination: str,
|
||||||
|
cache: RecordDestinationCache = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Updates the `destination` of the DNS record identified by `domain`, `hostname` and `type`.
|
Updates the `destination` of the DNS record identified by `domain`, `hostname` and `type`.
|
||||||
|
|
||||||
@ -76,8 +101,16 @@ def update_record_destination(api: Client, domain: str, hostname: str, type_: st
|
|||||||
:param hostname:
|
:param hostname:
|
||||||
:param type_:
|
:param type_:
|
||||||
:param destination:
|
:param destination:
|
||||||
|
:param cache:
|
||||||
:return: True if `destination` differs from the old destination.
|
:return: True if `destination` differs from the old destination.
|
||||||
"""
|
"""
|
||||||
|
# If caching is enabled.
|
||||||
|
if cache is not None:
|
||||||
|
# If a valid cache entry is available, check if the destination is still the same.
|
||||||
|
# If this is the case, we do not need to do anything else.
|
||||||
|
if cache.get(domain, hostname, type_) == destination:
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
record = get_record(api, domain, hostname, type_)
|
record = get_record(api, domain, hostname, type_)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -86,10 +119,15 @@ def update_record_destination(api: Client, domain: str, hostname: str, type_: st
|
|||||||
if record.destination == destination:
|
if record.destination == destination:
|
||||||
# The new destination is identical with the current one.
|
# The new destination is identical with the current one.
|
||||||
# Thus, we don't need to call the api.update_dns_record() method.
|
# Thus, we don't need to call the api.update_dns_record() method.
|
||||||
|
cache.set(domain, hostname, type_, destination)
|
||||||
|
cache.write_to_file()
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
record.destination = destination
|
record.destination = destination
|
||||||
api.update_dns_record(domain, record)
|
api.update_dns_record(domain, record)
|
||||||
|
# After a successful API call, update the cached destination.
|
||||||
|
cache.set(domain, hostname, type_, destination)
|
||||||
|
cache.write_to_file()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
69
src/netcup_dns/record_dst_cache.py
Normal file
69
src/netcup_dns/record_dst_cache.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from netcup_dns import datetime_util
|
||||||
|
|
||||||
|
|
||||||
|
class RecordDestinationCache:
|
||||||
|
def __init__(self, cache_dir: Path, cache_validity_seconds: int):
|
||||||
|
self.cache_dir = cache_dir
|
||||||
|
self.cache_file = cache_dir.joinpath('record_destinations.json')
|
||||||
|
|
||||||
|
self.cache_validity_seconds = cache_validity_seconds
|
||||||
|
|
||||||
|
self.data: dict[str, tuple[str, str]] | None = None
|
||||||
|
|
||||||
|
def get(self, domain: str, hostname: str, type_: str) -> str | None:
|
||||||
|
data = self.read_from_file()
|
||||||
|
key = self._data_key(domain, hostname, type_)
|
||||||
|
date_str, destination = data.get(key, (None, None))
|
||||||
|
|
||||||
|
if destination is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if cached destination is still valid.
|
||||||
|
dt = datetime_util.from_str(date_str)
|
||||||
|
time_difference = datetime_util.now() - dt
|
||||||
|
zero = timedelta()
|
||||||
|
max_difference = timedelta(seconds=self.cache_validity_seconds)
|
||||||
|
#
|
||||||
|
if time_difference <= zero:
|
||||||
|
raise ValueError('Invalid dates')
|
||||||
|
if time_difference >= max_difference:
|
||||||
|
# This cache entry is outdated.
|
||||||
|
return None
|
||||||
|
|
||||||
|
return destination
|
||||||
|
|
||||||
|
def set(self, domain: str, hostname: str, type_: str, destination: str):
|
||||||
|
if self.data is None:
|
||||||
|
raise Exception('Can only modify data after it has been read first.')
|
||||||
|
|
||||||
|
key = self._data_key(domain, hostname, type_)
|
||||||
|
self.data[key] = (datetime_util.now_str(), destination)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _data_key(domain, hostname, type_) -> str:
|
||||||
|
return f'{hostname}.{domain}.{type_}'
|
||||||
|
|
||||||
|
def read_from_file(self) -> dict[str, tuple[str, str]]:
|
||||||
|
if self.data is not None:
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
if self.cache_file.exists():
|
||||||
|
data = json.loads(self.cache_file.read_text(encoding='utf-8'))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f'Expected to read a dict from json file, but got {type(data)} instead.')
|
||||||
|
self.data = data
|
||||||
|
else:
|
||||||
|
self.data = {}
|
||||||
|
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
def write_to_file(self):
|
||||||
|
if self.data is None:
|
||||||
|
raise Exception('Can only write data after it has been read first.')
|
||||||
|
|
||||||
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.cache_file.write_text(json.dumps(self.data), encoding='utf-8')
|
Loading…
Reference in New Issue
Block a user