Initial development commit
This commit is contained in:
commit
859cffe9a0
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
build
|
||||||
|
dist
|
||||||
|
venv
|
||||||
|
*.egg-info
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
120
README.md
Normal file
120
README.md
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
DynDNS Helper
|
||||||
|
=============
|
||||||
|
|
||||||
|
Self-hosted python web application based on [FastAPI](https://github.com/tiangolo/fastapi) to update DNS records via API.
|
||||||
|
|
||||||
|
Vendor backends are easily extensible and include currently:
|
||||||
|
- [Hetzner DNS API](https://dns.hetzner.com/api-docs/)
|
||||||
|
|
||||||
|
Current use-cases for this application:
|
||||||
|
- Updating DNS records based on data received via DynDNS compatible requests from e.g. routers such as AVM Fritzbox
|
||||||
|
- Support for IPv4, IPv6 and IPv6 LAN Prefix
|
||||||
|
- IPv4 will result in updated A record
|
||||||
|
- IPv6 will result in updated AAAA record
|
||||||
|
- IPv6 LAN Prefix will update all AAAA records defined in an config mapping of FQDN to IPv6 Interface IDs
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
- No support for DNS round robin or the like
|
||||||
|
- No updating a single FQDN across multiple vendors
|
||||||
|
- No cleanup of DNS records
|
||||||
|
- No updating data other than values of DNS records
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
- docs
|
||||||
|
- tests
|
||||||
|
|
||||||
|
# Router DynDNS Examples
|
||||||
|
## AVM Fritzbox
|
||||||
|
Fritzbox DynDNS Settings:
|
||||||
|
```
|
||||||
|
Update-URL: https://192.168.178.10/fqdns/<domain>/dyndns?ip=<ipaddr>&ip=<ip6addr>&ipv6lanprefix=<ip6lanprefix>&dualstack=<dualstack>
|
||||||
|
Domainname: test1.domain.tld
|
||||||
|
Username: testuser
|
||||||
|
Password: testpassword
|
||||||
|
```
|
||||||
|
|
||||||
|
App config:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fqdns": {
|
||||||
|
"test1.domain.tld": {
|
||||||
|
"vendor": "...",
|
||||||
|
"dyndns_credentials": {
|
||||||
|
"testuser": "9f735e0df9a1ddc702bf0a1a7b83033f9f7153a00c29de82cedadc9957289b05"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# IPv6 LAN Prefix & Interface ID (IID)
|
||||||
|
Providers will assign an publicly routed IPv6 Prefix for a client. Normally a `/48` or `/64`.
|
||||||
|
|
||||||
|
Depending on the clients router configuration, devices behind it will use this prefix to assign themselves a public IPv6.
|
||||||
|
|
||||||
|
These devices append an Interface ID (or: host part) to the prefix. How this IID is generated depends on the device configuration.
|
||||||
|
|
||||||
|
It may be generated by using its MAC Address, it may be generated randomly for privacy reasons.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This helper supports updating DNS records for IIDs when the IPv6 LAN Prefix changes:
|
||||||
|
|
||||||
|
If a DynDNS API call contains an `ipv6lanprefix`, the FQDN settings of the given `fqdn` will be used to find other FQDNs to update, e.g.:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fqdns": {
|
||||||
|
"test1.domain.tld": {
|
||||||
|
"vendor": "...",
|
||||||
|
"dyndns_credentials": {
|
||||||
|
"testuser": "9f735e0df9a1ddc702bf0a1a7b83033f9f7153a00c29de82cedadc9957289b05"
|
||||||
|
},
|
||||||
|
"ipv6_lan_prefix_iid_map": {
|
||||||
|
"test2.domain.tld": "::2a0:00ff:111c:1234"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test2.domain.tld": {
|
||||||
|
"vendor": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If for this configuration a DynDNS API call for `test1.domain.tld` is received, that contains `ipv6lanprefix`, an AAAA DNS record for `test2.domain.tld` is ensured.
|
||||||
|
|
||||||
|
For the above example: An `ipv6lanprefix` of `2001:db8:85a3::/48` will result in:
|
||||||
|
```
|
||||||
|
test2.domaintld AAAA 2001:db8:85a3::2a0:00ff:111c:1234
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
- Larger IIDs than a prefix allows will be shortened
|
||||||
|
- e.g.: An `ipv6lanprefix` of `2001:db8:85a3:1234::/64` with an IID `::9876:2a0:00ff:111c:1234` will result in an IPv6 address `2001:db8:85a3:1234:2a0:00ff:111c:1234`
|
||||||
|
|
||||||
|
# Vendors
|
||||||
|
All vendor config is defined under a key `vendors` and the lowercase name of the vendor, e.g.:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vendors": {
|
||||||
|
"hetzner": {
|
||||||
|
"enabled": true,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The following common options are available for every vendor:
|
||||||
|
| Option | Default | Required | Description | Example |
|
||||||
|
|:-------|:--------|:---------|:------------|:--------|
|
||||||
|
| enabled | false | false | if this vendor is enabled | true |
|
||||||
|
| create_zone_if_missing | false | false | if a zone is created for a FQDN if its missing - otherwise raises an error | true |
|
||||||
|
| default_zone_ttl | 86400 | false | default TTL to set for created zones | 7200 |
|
||||||
|
| default_record_ttl | 600 | false | default TTL to set for created records - if not defined in individual FQDN settings | 300 |
|
||||||
|
|
||||||
|
## Hetzner
|
||||||
|
Additional options:
|
||||||
|
| Option | Default | Required | Description | Example |
|
||||||
|
|:-------|:--------|:---------|:------------|:--------|
|
||||||
|
| api_url | 'https://dns.hetzner.com/api/v1' | false | Hetzner DNS API URL | |
|
||||||
|
| api_token | | true | default TTL to set for created records - if not defined in individual FQDN settings | 'secretapitoken' |
|
||||||
0
dyndnshelper/__init__.py
Normal file
0
dyndnshelper/__init__.py
Normal file
6
dyndnshelper/exceptions.py
Normal file
6
dyndnshelper/exceptions.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
class VendorError(Exception):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ZoneMissingError(Exception):
|
||||||
|
...
|
||||||
31
dyndnshelper/logging.py
Normal file
31
dyndnshelper/logging.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
|
|
||||||
|
def set_logging_config(app_log_level: int, root_log_level: int):
|
||||||
|
"""
|
||||||
|
Sets Application logging defaults
|
||||||
|
Differentiates between Application Log Level and root Log Level
|
||||||
|
"""
|
||||||
|
logging.basicConfig(
|
||||||
|
level=root_log_level,
|
||||||
|
format='%(asctime)s %(levelname)-8s %(message)s ',
|
||||||
|
datefmt='%Y-%m-%dT%H:%M:%SZ',
|
||||||
|
force=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# configure individual logger for application
|
||||||
|
LOGGER.setLevel(app_log_level)
|
||||||
|
LOGGER.propagate = False
|
||||||
|
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(app_log_level)
|
||||||
|
console_handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
fmt='%(asctime)s %(levelname)-8s %(message)s ',
|
||||||
|
datefmt='%Y-%m-%dT%H:%M:%SZ'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LOGGER.addHandler(console_handler)
|
||||||
26
dyndnshelper/main.py
Normal file
26
dyndnshelper/main.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from importlib.metadata import metadata
|
||||||
|
from .routers import fqdn_router
|
||||||
|
from .settings import CONFIG
|
||||||
|
|
||||||
|
pkg_metadata = metadata(__package__)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=pkg_metadata.get('name'),
|
||||||
|
summary=pkg_metadata.get('summary'),
|
||||||
|
description=pkg_metadata.get('description'),
|
||||||
|
version=pkg_metadata.get('version'),
|
||||||
|
)
|
||||||
|
app.include_router(fqdn_router)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
config = uvicorn.Config(
|
||||||
|
app=app,
|
||||||
|
port=CONFIG.server.port,
|
||||||
|
log_level=CONFIG.server.app_log_level
|
||||||
|
)
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
server.run()
|
||||||
5
dyndnshelper/routers/__init__.py
Normal file
5
dyndnshelper/routers/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from .fqdns import fqdn_router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'fqdn_router'
|
||||||
|
]
|
||||||
112
dyndnshelper/routers/fqdns.py
Normal file
112
dyndnshelper/routers/fqdns.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
from fastapi import APIRouter, Query, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
from ipaddress import IPv4Address, IPv6Address, IPv6Network
|
||||||
|
from hashlib import sha256
|
||||||
|
from netaddr import IPAddress, IPNetwork
|
||||||
|
from typing import Annotated
|
||||||
|
from ..logging import LOGGER
|
||||||
|
from ..settings import CONFIG, EmptyStr, FQDNSettings
|
||||||
|
|
||||||
|
|
||||||
|
fqdn_router = APIRouter(
|
||||||
|
prefix='/fqdns',
|
||||||
|
tags=['FQDN']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
security = HTTPBasic()
|
||||||
|
|
||||||
|
|
||||||
|
def get_fqdn_settings(fqdn: str) -> FQDNSettings:
|
||||||
|
fqdn_settings = CONFIG.fqdns.get(fqdn)
|
||||||
|
if not fqdn_settings:
|
||||||
|
# fqdn is not configured
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="FQDN not found",
|
||||||
|
headers={"WWW-Authenticate": "Basic"}
|
||||||
|
)
|
||||||
|
return fqdn_settings
|
||||||
|
|
||||||
|
|
||||||
|
def check_credentials_for_fqdn(
|
||||||
|
fqdn_settings: Annotated[FQDNSettings, Depends(get_fqdn_settings)],
|
||||||
|
credentials: Annotated[HTTPBasicCredentials, Depends(security)]
|
||||||
|
) -> str:
|
||||||
|
auth_error = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Basic"}
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_password_sha256 = fqdn_settings.dyndns_credentials.get(credentials.username)
|
||||||
|
if not expected_password_sha256:
|
||||||
|
# user is not configured
|
||||||
|
raise auth_error
|
||||||
|
|
||||||
|
password_sha256 = sha256(credentials.password.encode("utf8")).hexdigest()
|
||||||
|
if expected_password_sha256.get_secret_value() != password_sha256:
|
||||||
|
# incorrect password
|
||||||
|
raise auth_error
|
||||||
|
|
||||||
|
return credentials.username
|
||||||
|
|
||||||
|
|
||||||
|
@fqdn_router.get('/{fqdn}/dyndns')
|
||||||
|
async def dyndns(
|
||||||
|
username: Annotated[str, Depends(check_credentials_for_fqdn)],
|
||||||
|
fqdn_settings: Annotated[FQDNSettings, Depends(get_fqdn_settings)],
|
||||||
|
ip: Annotated[list[IPv4Address | IPv6Address | EmptyStr], Query()] = None,
|
||||||
|
ipv6lanprefix: Annotated[IPv6Network, Query()] = None,
|
||||||
|
dualstack: Annotated[bool, Query()] = None
|
||||||
|
):
|
||||||
|
if ip:
|
||||||
|
# remove empty string items
|
||||||
|
# as senders may work with simple str replace on update urls - there may be empty strings
|
||||||
|
ip = [i for i in ip if i]
|
||||||
|
|
||||||
|
if ipv6lanprefix and ipv6lanprefix.version != 6:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="ipv6lanprefix is not valid IPv6"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ip:
|
||||||
|
LOGGER.debug(f'DynDNS FQDN {fqdn_settings.fqdn} with IPs {", ".join(map(str, ip))}')
|
||||||
|
vendor = CONFIG.vendors.get_vendor(fqdn_settings.vendor)
|
||||||
|
await vendor.ensure_fqdn(
|
||||||
|
fqdn=fqdn_settings.fqdn,
|
||||||
|
zone=fqdn_settings.zone,
|
||||||
|
ip_addresses=ip,
|
||||||
|
ttl=fqdn_settings.record_ttl
|
||||||
|
)
|
||||||
|
|
||||||
|
if ipv6lanprefix and fqdn_settings.ipv6_lan_prefix_iid_map:
|
||||||
|
# we'll do a little stunt via using netaddr as it already provides bitwise operations
|
||||||
|
# and we have to bitwise OR network address with iid address
|
||||||
|
netaddr_ipv6lanprefix_address = IPNetwork(str(ipv6lanprefix)).network
|
||||||
|
for iid_fqdn, iid in fqdn_settings.ipv6_lan_prefix_iid_map.items():
|
||||||
|
LOGGER.debug(f'DynDNS IID FQDN {iid_fqdn} {iid} with IPv6 LAN Prefix {ipv6lanprefix}')
|
||||||
|
netaddr_iid_address = IPAddress(str(iid))
|
||||||
|
combined_address = IPv6Address(str(netaddr_ipv6lanprefix_address | netaddr_iid_address))
|
||||||
|
|
||||||
|
iid_fqdn_settings = get_fqdn_settings(iid_fqdn)
|
||||||
|
vendor = CONFIG.vendors.get_vendor(iid_fqdn_settings.vendor)
|
||||||
|
await vendor.ensure_fqdn(
|
||||||
|
fqdn=iid_fqdn_settings.fqdn,
|
||||||
|
zone=iid_fqdn_settings.zone,
|
||||||
|
ip_addresses=[combined_address],
|
||||||
|
ttl=iid_fqdn_settings.record_ttl
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
# get vendor
|
||||||
|
# ensure zone, if allowed, otherwise fail
|
||||||
|
# ensure records for all IPs (A, AAAA)
|
||||||
|
# if ipv6lanprefix is provided:
|
||||||
|
# for each fqdn with iid
|
||||||
|
# get fqdn_settings
|
||||||
|
# get vendor
|
||||||
|
# ensure zone, if allowed, otherwise fail
|
||||||
|
# ensure AAAA record for iid
|
||||||
|
return 'OK'
|
||||||
192
dyndnshelper/settings.py
Normal file
192
dyndnshelper/settings.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from ipaddress import IPv4Address, IPv6Address
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic import BaseModel, ConfigDict, FieldValidationInfo, SecretStr, Field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
|
||||||
|
from typing import Annotated, Type
|
||||||
|
from .logging import set_logging_config
|
||||||
|
from .vendors import Vendor, VendorNames, VendorByName, HetznerSettings
|
||||||
|
|
||||||
|
|
||||||
|
FqdnStr = Annotated[str, Field(pattern=r'^(([a-z0-9][a-z0-9\-]+[a-z0-9])|[a-z0-9]+\.)+([a-z]+|xn\-\-[a-z0-9]+)\.?$', strict=True)]
|
||||||
|
EmptyStr = Annotated[str, Field(max_length=0)]
|
||||||
|
|
||||||
|
|
||||||
|
class FQDNSettings(BaseModel):
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
fqdn: FqdnStr = None
|
||||||
|
"""
|
||||||
|
Is set automatically in validation
|
||||||
|
"""
|
||||||
|
vendor: VendorNames
|
||||||
|
zone: FqdnStr = None
|
||||||
|
"""
|
||||||
|
Zone to add record to - defaults to root zone
|
||||||
|
"""
|
||||||
|
record_ttl: int | None = None
|
||||||
|
"""
|
||||||
|
Overrides default vendor ttl, if set
|
||||||
|
"""
|
||||||
|
dyndns_credentials: dict[str, SecretStr] = Field(default_factory=dict)
|
||||||
|
"""
|
||||||
|
A FQDN can have multiple credentials that are allowed to update the records
|
||||||
|
|
||||||
|
Each credential is a mapping of an unique username & a SHA256 password hash
|
||||||
|
|
||||||
|
Example:
|
||||||
|
{
|
||||||
|
"user1": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
ipv6_lan_prefix_iid_map: dict[FqdnStr, IPv4Address | IPv6Address] = Field(default_factory=dict)
|
||||||
|
"""
|
||||||
|
In addition to the regular logic for updating a FQDN, an IPv6 LAN Prefix can be used to update other FQDNs, even across vendors
|
||||||
|
|
||||||
|
Example:
|
||||||
|
{
|
||||||
|
"host1.test.domain.tld": "::1",
|
||||||
|
"serviceX.anotherdomain.tld": "::22"
|
||||||
|
}
|
||||||
|
Where "host1..." is of vendor X and "serviceX..." is of vendor Y
|
||||||
|
|
||||||
|
With a new prefix of "2001:db8:85a3::/48" the following AAAA records will be set:
|
||||||
|
Vendor X host1.test.domain.tld 2001:db8:85a3::1
|
||||||
|
Vendor Y serviceX.anotherdomain.tld 2001:db8:85a3::22
|
||||||
|
|
||||||
|
NOTE: If prefix is smaller than IID requires, parts of IID will be omitted. E.g.:
|
||||||
|
Prefix 2001:db8:85a3::/48 and IID ::1111:2222:3333:4444:5555:6666 results in 2001:db8:85a3:2222:3333:4444:5555:6666
|
||||||
|
"""
|
||||||
|
@field_validator('dyndns_credentials')
|
||||||
|
def validate_dyndns_credentials(cls, v, info: FieldValidationInfo):
|
||||||
|
if isinstance(v, dict):
|
||||||
|
for username, password_sha256 in v.items():
|
||||||
|
if isinstance(password_sha256, SecretStr) and len(password_sha256.get_secret_value()) == 64:
|
||||||
|
try:
|
||||||
|
int(password_sha256.get_secret_value(), 16)
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
raise ValueError(f'`{info.field_name}` {username} password value is not valid SHA256')
|
||||||
|
return v
|
||||||
|
|
||||||
|
def ensure_fallback_root_zone(self):
|
||||||
|
"""
|
||||||
|
ensures at least root zone is set, for example:
|
||||||
|
|
||||||
|
test.domain.tld -> domain.tld
|
||||||
|
host.test.domain.tld -> domain.tld
|
||||||
|
...
|
||||||
|
|
||||||
|
raises ValueError if no domain is extractable - e.g. if an fqdn is invalid
|
||||||
|
"""
|
||||||
|
if self.zone:
|
||||||
|
return
|
||||||
|
parts = self.fqdn.rsplit('.', maxsplit=2)
|
||||||
|
domain_tld_parts = parts[-2:]
|
||||||
|
if len(domain_tld_parts) == 1:
|
||||||
|
raise ValueError(f'Unable to extract domain with tld from FQDN {self.fqdn}')
|
||||||
|
self.zone = '.'.join(domain_tld_parts)
|
||||||
|
|
||||||
|
|
||||||
|
class Server(BaseModel):
|
||||||
|
port: int = 8000
|
||||||
|
app_log_level: int = logging.INFO
|
||||||
|
root_log_level: int = logging.WARNING
|
||||||
|
|
||||||
|
@field_validator('app_log_level', 'root_log_level', mode='before')
|
||||||
|
def validate_log_level(cls, v, info: FieldValidationInfo):
|
||||||
|
log_level_map = logging.getLevelNamesMapping()
|
||||||
|
if isinstance(v, str):
|
||||||
|
log_level = log_level_map.get(v.upper())
|
||||||
|
if log_level is not None:
|
||||||
|
return log_level
|
||||||
|
elif isinstance(v, int) and v in log_level_map.values():
|
||||||
|
return v
|
||||||
|
|
||||||
|
raise ValueError(f'`{info.field_name}` {v} is not a valid log level')
|
||||||
|
|
||||||
|
|
||||||
|
class Vendors(BaseModel):
|
||||||
|
hetzner: HetznerSettings = Field(default_factory=HetznerSettings)
|
||||||
|
|
||||||
|
def get_vendor(self, name: VendorNames) -> Vendor:
|
||||||
|
vendor_settings = getattr(self, name, None)
|
||||||
|
if not vendor_settings:
|
||||||
|
raise ValueError(f'No vendor settings found for name `{name}`')
|
||||||
|
vendor_cls = VendorByName.get(name)
|
||||||
|
if not vendor_cls:
|
||||||
|
raise ValueError(f'No vendor found for name `{name}`')
|
||||||
|
return vendor_cls(settings=vendor_settings)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonConfigSettingsSource(PydanticBaseSettingsSource):
|
||||||
|
def __call__(self) -> dict:
|
||||||
|
config = dict()
|
||||||
|
for config_file in self.settings_cls.config_files:
|
||||||
|
try:
|
||||||
|
data = json.loads(config_file.read_text('utf-8'))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
config.update(data)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f'Unable to read config file {config_file}: {e}')
|
||||||
|
return config
|
||||||
|
|
||||||
|
def get_field_value(self, field, field_name):
|
||||||
|
# abstractmethod but seems to be not used... yet?
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
fqdns: dict[FqdnStr, FQDNSettings] = Field(default_factory=dict)
|
||||||
|
server: Server = Field(default_factory=Server)
|
||||||
|
vendors: Vendors = Field(default_factory=Vendors)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def settings_customise_sources(
|
||||||
|
cls,
|
||||||
|
settings_cls: Type[BaseSettings],
|
||||||
|
init_settings: PydanticBaseSettingsSource,
|
||||||
|
env_settings: PydanticBaseSettingsSource,
|
||||||
|
dotenv_settings: PydanticBaseSettingsSource,
|
||||||
|
file_secret_settings: PydanticBaseSettingsSource,
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
init_settings,
|
||||||
|
JsonConfigSettingsSource(settings_cls)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@property
|
||||||
|
def config_files(cls) -> list[str]:
|
||||||
|
config_files = list()
|
||||||
|
config_root = Path(f'/etc/{__package__}')
|
||||||
|
if config_root.exists():
|
||||||
|
# read all json files recursively
|
||||||
|
for root, dirs, files in os.walk(config_root):
|
||||||
|
for filename in files:
|
||||||
|
if filename.endswith('.json'):
|
||||||
|
config_files.append(
|
||||||
|
config_root.joinpath(filename)
|
||||||
|
)
|
||||||
|
return config_files
|
||||||
|
|
||||||
|
@field_validator('fqdns', mode='after')
|
||||||
|
def validate_fqdns(cls, v, info: FieldValidationInfo):
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError(f'{info.field_name} should be a valid dict but is: {type(v)}')
|
||||||
|
|
||||||
|
for fqdn, fqdn_settings in v.items():
|
||||||
|
fqdn_settings.fqdn = fqdn
|
||||||
|
fqdn_settings.ensure_fallback_root_zone()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG = Settings()
|
||||||
|
set_logging_config(
|
||||||
|
app_log_level=CONFIG.server.app_log_level,
|
||||||
|
root_log_level=CONFIG.server.root_log_level
|
||||||
|
)
|
||||||
21
dyndnshelper/vendors/__init__.py
vendored
Normal file
21
dyndnshelper/vendors/__init__.py
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from enum import StrEnum
|
||||||
|
from ._base import VendorSettings, Vendor
|
||||||
|
from .hetzner import HetznerSettings, Hetzner
|
||||||
|
|
||||||
|
|
||||||
|
class VendorNames(StrEnum):
|
||||||
|
HETZNER = HetznerSettings.vendor_name
|
||||||
|
|
||||||
|
|
||||||
|
VendorByName: dict[VendorNames, VendorSettings] = {
|
||||||
|
VendorNames.HETZNER: Hetzner
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'HetznerSettings',
|
||||||
|
'Vendor',
|
||||||
|
'VendorNames',
|
||||||
|
'VendorByName',
|
||||||
|
'VendorSettings'
|
||||||
|
]
|
||||||
195
dyndnshelper/vendors/_base.py
vendored
Normal file
195
dyndnshelper/vendors/_base.py
vendored
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import httpx
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from ipaddress import IPv4Address, IPv6Address
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
from typing import ClassVar, Union, get_origin, get_args
|
||||||
|
from ..logging import LOGGER
|
||||||
|
from ..exceptions import VendorError
|
||||||
|
|
||||||
|
|
||||||
|
class VendorSettings(BaseModel):
|
||||||
|
vendor_name: ClassVar[str] = None
|
||||||
|
enabled: bool = Field(False, description="if this vendor is enabled and its settings are required")
|
||||||
|
create_zone_if_missing: bool = False
|
||||||
|
default_zone_ttl: int = 86400
|
||||||
|
"""
|
||||||
|
used for creating zones - is not applied to existing zones
|
||||||
|
"""
|
||||||
|
default_record_ttl: int = 600
|
||||||
|
"""
|
||||||
|
used for creating records - is not applied to existing records
|
||||||
|
|
||||||
|
NOTE: may be overridden by individual FQDN settings
|
||||||
|
"""
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def validate_required_fields_if_enabled(cls, data):
|
||||||
|
"""
|
||||||
|
All fields not allowing `None` via type annotation will be required, if `enabled` is `True`
|
||||||
|
|
||||||
|
This validator enforces it after individual field validation
|
||||||
|
"""
|
||||||
|
if not data.enabled:
|
||||||
|
# disabled - no need to validate
|
||||||
|
return data
|
||||||
|
|
||||||
|
for name, info in cls.model_fields.items():
|
||||||
|
if getattr(data, name, None) is not None:
|
||||||
|
# value is set
|
||||||
|
continue
|
||||||
|
if get_origin(info.annotation) is Union:
|
||||||
|
# maybe None is allowed?
|
||||||
|
if type(None) in get_args(info.annotation):
|
||||||
|
# None is allowed
|
||||||
|
continue
|
||||||
|
raise ValueError(f'{cls.vendor_name} setting `{name}` is required')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class Vendor(BaseModel, ABC):
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
settings: VendorSettings | None = None
|
||||||
|
httpx_client: httpx.AsyncClient | None = None
|
||||||
|
httpx_auth: httpx.Auth | None = None
|
||||||
|
"""
|
||||||
|
Can be used to supply authentication data such as auth headers
|
||||||
|
|
||||||
|
See https://www.python-httpx.org/advanced/#customizing-authentication
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def ensure_fqdn(self, fqdn: str, zone: str, ip_addresses: list[IPv4Address | IPv6Address], ttl: int = None):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def create_httpx_client(self):
|
||||||
|
"""
|
||||||
|
Create a new httpx.AsyncClient to be used via e.g. `async with self.httpx_client:`
|
||||||
|
"""
|
||||||
|
self.httpx_client = httpx.AsyncClient(
|
||||||
|
auth=self.httpx_auth
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_record_type_for_ip(cls, ip_address: IPv4Address | IPv6Address):
|
||||||
|
if ip_address.version == 4:
|
||||||
|
return 'A'
|
||||||
|
elif ip_address.version == 6:
|
||||||
|
return 'AAAA'
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f'Unsupported IP version: {ip_address.version}')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logger(self):
|
||||||
|
return LOGGER
|
||||||
|
|
||||||
|
async def _api_request(self, coroutine, raise_for_status: bool = True) -> httpx.Response:
|
||||||
|
response = await coroutine
|
||||||
|
if raise_for_status and response.status_code >= 400:
|
||||||
|
raise VendorError(f'Received HTTP {response.status_code} from Vendor API: {response.text}')
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def api_get(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
query_parameters: dict = None,
|
||||||
|
headers: dict = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
raise_for_status: bool = True
|
||||||
|
) -> httpx.Response:
|
||||||
|
return await self._api_request(
|
||||||
|
coroutine=self.httpx_client.get(
|
||||||
|
auth=self.httpx_auth,
|
||||||
|
url=url,
|
||||||
|
params=query_parameters,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout
|
||||||
|
),
|
||||||
|
raise_for_status=raise_for_status
|
||||||
|
)
|
||||||
|
|
||||||
|
async def api_post(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
query_parameters: dict = None,
|
||||||
|
headers: dict = None,
|
||||||
|
data=None,
|
||||||
|
json: dict = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
raise_for_status: bool = True
|
||||||
|
) -> httpx.Response:
|
||||||
|
return await self._api_request(
|
||||||
|
coroutine=self.httpx_client.post(
|
||||||
|
url=url,
|
||||||
|
params=query_parameters,
|
||||||
|
headers=headers,
|
||||||
|
data=data,
|
||||||
|
json=json,
|
||||||
|
timeout=timeout
|
||||||
|
),
|
||||||
|
raise_for_status=raise_for_status
|
||||||
|
)
|
||||||
|
|
||||||
|
async def api_put(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
query_parameters: dict = None,
|
||||||
|
headers: dict = None,
|
||||||
|
data=None,
|
||||||
|
json: dict = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
raise_for_status: bool = True
|
||||||
|
) -> httpx.Response:
|
||||||
|
return await self._api_request(
|
||||||
|
coroutine=self.httpx_client.put(
|
||||||
|
url=url,
|
||||||
|
params=query_parameters,
|
||||||
|
headers=headers,
|
||||||
|
data=data,
|
||||||
|
json=json,
|
||||||
|
timeout=timeout
|
||||||
|
),
|
||||||
|
raise_for_status=raise_for_status
|
||||||
|
)
|
||||||
|
|
||||||
|
async def api_patch(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
query_parameters: dict = None,
|
||||||
|
headers: dict = None,
|
||||||
|
data=None,
|
||||||
|
json: dict = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
raise_for_status: bool = True
|
||||||
|
) -> httpx.Response:
|
||||||
|
return await self._api_request(
|
||||||
|
coroutine=self.httpx_client.patch(
|
||||||
|
url=url,
|
||||||
|
params=query_parameters,
|
||||||
|
headers=headers,
|
||||||
|
data=data,
|
||||||
|
json=json,
|
||||||
|
timeout=timeout
|
||||||
|
),
|
||||||
|
raise_for_status=raise_for_status
|
||||||
|
)
|
||||||
|
|
||||||
|
async def api_delete(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
query_parameters: dict = None,
|
||||||
|
headers: dict = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
raise_for_status: bool = True
|
||||||
|
) -> httpx.Response:
|
||||||
|
return await self._api_request(
|
||||||
|
coroutine=self.httpx_client.delete(
|
||||||
|
auth=self.httpx_auth,
|
||||||
|
url=url,
|
||||||
|
params=query_parameters,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout
|
||||||
|
),
|
||||||
|
raise_for_status=raise_for_status
|
||||||
|
)
|
||||||
186
dyndnshelper/vendors/hetzner.py
vendored
Normal file
186
dyndnshelper/vendors/hetzner.py
vendored
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
from httpx import Auth
|
||||||
|
from ipaddress import IPv4Address, IPv6Address
|
||||||
|
from pydantic import BaseModel, ConfigDict, SecretStr, Field, model_validator
|
||||||
|
from ._base import VendorSettings, Vendor
|
||||||
|
from ..exceptions import VendorError, ZoneMissingError
|
||||||
|
|
||||||
|
|
||||||
|
class HetznerSettings(VendorSettings):
|
||||||
|
vendor_name = 'hetzner'
|
||||||
|
api_url: str = Field('https://dns.hetzner.com/api/v1')
|
||||||
|
api_token: SecretStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HetznerAuth(Auth):
|
||||||
|
def __init__(self, api_token: str):
|
||||||
|
self.api_token = api_token
|
||||||
|
|
||||||
|
def auth_flow(self, request):
|
||||||
|
request.headers['Auth-API-Token'] = self.api_token
|
||||||
|
yield request
|
||||||
|
|
||||||
|
|
||||||
|
class HetznerZone(BaseModel):
|
||||||
|
model_config = ConfigDict(extra='allow')
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
ttl: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_dict(self):
|
||||||
|
return self.model_dump(
|
||||||
|
mode='json',
|
||||||
|
include=('name', 'ttl')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HetznerRecord(BaseModel):
|
||||||
|
model_config = ConfigDict(extra='allow')
|
||||||
|
|
||||||
|
id: str
|
||||||
|
zone_id: str
|
||||||
|
type: str
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
ttl: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_dict(self):
|
||||||
|
return self.model_dump(
|
||||||
|
mode='json',
|
||||||
|
include=('zone_id', 'type', 'name', 'value', 'ttl')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Hetzner(Vendor):
|
||||||
|
settings: HetznerSettings
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def setup_httpx_auth(cls, data: Vendor):
|
||||||
|
if data.httpx_auth is None:
|
||||||
|
data.httpx_auth = HetznerAuth(
|
||||||
|
api_token=data.settings.api_token.get_secret_value()
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_zone(self, name: str) -> HetznerZone | None:
|
||||||
|
self.logger.debug(f'Fetching zone {name}')
|
||||||
|
response = await self.api_get(
|
||||||
|
url=f'{self.settings.api_url}/zones',
|
||||||
|
query_parameters={
|
||||||
|
'name': name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
zones = response.json().get('zones')
|
||||||
|
if not isinstance(zones, list):
|
||||||
|
raise VendorError(f'Hetzner API did not return zones, but: {type(zones)} {zones}')
|
||||||
|
elif len(zones) == 1:
|
||||||
|
return HetznerZone(**zones[0])
|
||||||
|
|
||||||
|
async def create_zone(self, name: str, ttl: int = None) -> HetznerZone:
|
||||||
|
self.logger.info(f'Creating zone {name}')
|
||||||
|
response = await self.api_post(
|
||||||
|
url=f'{self.settings.api_url}/zones',
|
||||||
|
json={
|
||||||
|
'name': name,
|
||||||
|
'ttl': ttl or self.settings.default_zone_ttl
|
||||||
|
}
|
||||||
|
)
|
||||||
|
zone = response.json().get('zone')
|
||||||
|
if not isinstance(zone, dict):
|
||||||
|
raise VendorError(f'Hetzner API did not return zone on creation, but: {type(zone)} {zone}')
|
||||||
|
|
||||||
|
return HetznerZone(**zone)
|
||||||
|
|
||||||
|
async def get_records(self, zone_id: str, name: str = None) -> list[HetznerRecord]:
|
||||||
|
"""
|
||||||
|
get records of a specific zone
|
||||||
|
|
||||||
|
zone_id -- id of the zone as per API
|
||||||
|
|
||||||
|
name -- record name to filter for (local filtering, API does not provide a search function)
|
||||||
|
"""
|
||||||
|
self.logger.debug(f'Fetching records of zone {zone_id}')
|
||||||
|
response = await self.api_get(
|
||||||
|
url=f'{self.settings.api_url}/records',
|
||||||
|
query_parameters={
|
||||||
|
'zone_id': zone_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
records = response.json().get('records')
|
||||||
|
if not isinstance(records, list):
|
||||||
|
raise VendorError(f'Hetzner API did not return records, but: {type(records)} {records}')
|
||||||
|
|
||||||
|
parsed_records: list[HetznerRecord] = list()
|
||||||
|
for record in records:
|
||||||
|
if name and not record.get('name') == name:
|
||||||
|
continue
|
||||||
|
parsed_records.append(HetznerRecord(**record))
|
||||||
|
|
||||||
|
return parsed_records
|
||||||
|
|
||||||
|
async def create_record(self, zone_id: str, name: str, record_type: str, value: str, ttl: int = None) -> HetznerRecord:
|
||||||
|
self.logger.info(f'Creating {record_type} record {name} in zone {zone_id}: {value}')
|
||||||
|
response = await self.api_post(
|
||||||
|
url=f'{self.settings.api_url}/records',
|
||||||
|
json={
|
||||||
|
'zone_id': zone_id,
|
||||||
|
'name': name,
|
||||||
|
'type': record_type,
|
||||||
|
'ttl': ttl or self.settings.default_record_ttl,
|
||||||
|
'value': value
|
||||||
|
}
|
||||||
|
)
|
||||||
|
record = response.json().get('record')
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
raise VendorError(f'Hetzner API did not return record on creation, but: {type(record)} {record}')
|
||||||
|
|
||||||
|
return HetznerRecord(**record)
|
||||||
|
|
||||||
|
async def update_record(self, record: HetznerRecord) -> HetznerRecord:
|
||||||
|
self.logger.info(f'Updating {record.id} / {record.type} record {record.name} in zone {record.zone_id}: {record.value}')
|
||||||
|
response = await self.api_put(
|
||||||
|
url=f'{self.settings.api_url}/records/{record.id}',
|
||||||
|
json=record.update_dict
|
||||||
|
)
|
||||||
|
record = response.json().get('record')
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
raise VendorError(f'Hetzner API did not return record on creation, but: {type(record)} {record}')
|
||||||
|
|
||||||
|
return HetznerRecord(**record)
|
||||||
|
|
||||||
|
async def ensure_fqdn(self, fqdn: str, zone: str, ip_addresses: list[IPv4Address | IPv6Address], ttl: int = None):
|
||||||
|
self.create_httpx_client()
|
||||||
|
async with self.httpx_client:
|
||||||
|
hetzner_zone = await self.get_zone(name=zone)
|
||||||
|
if not hetzner_zone:
|
||||||
|
if not self.settings.create_zone_if_missing:
|
||||||
|
raise ZoneMissingError(f'Hetzner Zone {zone} is missing')
|
||||||
|
hetzner_zone = await self.create_zone(name=zone)
|
||||||
|
|
||||||
|
fqdn_record_name = fqdn.replace(f'.{hetzner_zone.name}', '')
|
||||||
|
hetzner_records = await self.get_records(
|
||||||
|
zone_id=hetzner_zone.id,
|
||||||
|
name=fqdn_record_name
|
||||||
|
)
|
||||||
|
for ip_address in ip_addresses:
|
||||||
|
record_type = self.get_record_type_for_ip(ip_address)
|
||||||
|
matching_records = list(filter(lambda r: r.type == record_type, hetzner_records))
|
||||||
|
if len(matching_records) == 0:
|
||||||
|
await self.create_record(
|
||||||
|
zone_id=hetzner_zone.id,
|
||||||
|
name=fqdn_record_name,
|
||||||
|
record_type=record_type,
|
||||||
|
value=str(ip_address.compressed),
|
||||||
|
ttl=ttl
|
||||||
|
)
|
||||||
|
elif len(matching_records) == 1:
|
||||||
|
if matching_records[0].value == str(ip_address.compressed):
|
||||||
|
continue
|
||||||
|
matching_records[0].value = str(ip_address)
|
||||||
|
await self.update_record(
|
||||||
|
record=matching_records[0]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError('Unable to handle multiple records with the same name, such as round robin')
|
||||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools", "setuptools-scm"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = [
|
||||||
|
"dyndnshelper",
|
||||||
|
"dyndnshelper.routers",
|
||||||
|
"dyndnshelper.vendors"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools-scm]
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "dyndnshelper"
|
||||||
|
description = "Self-hosted dyndns service with support for different vendors"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
keywords = ["dyndns", "dns"]
|
||||||
|
license = {text = "BSD 3-Clause License"}
|
||||||
|
dependencies = [
|
||||||
|
"httpx",
|
||||||
|
"fastapi>=0.100.0",
|
||||||
|
"netaddr",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
"pydantic-settings",
|
||||||
|
"uvicorn[standard]"
|
||||||
|
]
|
||||||
|
dynamic = ["version"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
dyndnshelper = "dyndnshelper.main:run"
|
||||||
Loading…
x
Reference in New Issue
Block a user