Compare commits
5 Commits
859cffe9a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e40ecae05 | ||
|
|
0cd6d48bdd | ||
|
|
4d5569b156 | ||
|
|
ff8433dfe9 | ||
|
|
ff0e2bd8d6 |
79
README.md
79
README.md
@@ -1,6 +1,19 @@
|
|||||||
DynDNS Helper
|
DynDNS Helper
|
||||||
=============
|
=============
|
||||||
|
|
||||||
|
- [DynDNS Helper](#dyndns-helper)
|
||||||
|
- [TODO](#todo)
|
||||||
|
- [Router DynDNS Examples](#router-dyndns-examples)
|
||||||
|
- [AVM Fritzbox](#avm-fritzbox)
|
||||||
|
- [IPv6 LAN Prefix \& Interface ID (IID)](#ipv6-lan-prefix--interface-id-iid)
|
||||||
|
- [Caveats](#caveats)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Server](#server)
|
||||||
|
- [Vendors](#vendors)
|
||||||
|
- [Hetzner](#hetzner)
|
||||||
|
- [FQDNs](#fqdns)
|
||||||
|
|
||||||
|
|
||||||
Self-hosted python web application based on [FastAPI](https://github.com/tiangolo/fastapi) to update DNS records via API.
|
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:
|
Vendor backends are easily extensible and include currently:
|
||||||
@@ -27,7 +40,7 @@ Limitations:
|
|||||||
## AVM Fritzbox
|
## AVM Fritzbox
|
||||||
Fritzbox DynDNS Settings:
|
Fritzbox DynDNS Settings:
|
||||||
```
|
```
|
||||||
Update-URL: https://192.168.178.10/fqdns/<domain>/dyndns?ip=<ipaddr>&ip=<ip6addr>&ipv6lanprefix=<ip6lanprefix>&dualstack=<dualstack>
|
Update-URL: https://192.168.178.10/fqdns/<domain>/dyndns?ip=<ipaddr>&ip=<ip6addr>&ipv6lanprefix=<ip6lanprefix>
|
||||||
Domainname: test1.domain.tld
|
Domainname: test1.domain.tld
|
||||||
Username: testuser
|
Username: testuser
|
||||||
Password: testpassword
|
Password: testpassword
|
||||||
@@ -38,7 +51,7 @@ App config:
|
|||||||
{
|
{
|
||||||
"fqdns": {
|
"fqdns": {
|
||||||
"test1.domain.tld": {
|
"test1.domain.tld": {
|
||||||
"vendor": "...",
|
"vendor": "hetzner",
|
||||||
"dyndns_credentials": {
|
"dyndns_credentials": {
|
||||||
"testuser": "9f735e0df9a1ddc702bf0a1a7b83033f9f7153a00c29de82cedadc9957289b05"
|
"testuser": "9f735e0df9a1ddc702bf0a1a7b83033f9f7153a00c29de82cedadc9957289b05"
|
||||||
}
|
}
|
||||||
@@ -65,7 +78,7 @@ If a DynDNS API call contains an `ipv6lanprefix`, the FQDN settings of the given
|
|||||||
{
|
{
|
||||||
"fqdns": {
|
"fqdns": {
|
||||||
"test1.domain.tld": {
|
"test1.domain.tld": {
|
||||||
"vendor": "...",
|
"vendor": "hetzner",
|
||||||
"dyndns_credentials": {
|
"dyndns_credentials": {
|
||||||
"testuser": "9f735e0df9a1ddc702bf0a1a7b83033f9f7153a00c29de82cedadc9957289b05"
|
"testuser": "9f735e0df9a1ddc702bf0a1a7b83033f9f7153a00c29de82cedadc9957289b05"
|
||||||
},
|
},
|
||||||
@@ -91,7 +104,39 @@ test2.domaintld AAAA 2001:db8:85a3::2a0:00ff:111c:1234
|
|||||||
- Larger IIDs than a prefix allows will be shortened
|
- 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`
|
- 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
|
|
||||||
|
# Configuration
|
||||||
|
All configuration options should be supplied as JSON files in `/etc/dyndnshelper/`.
|
||||||
|
|
||||||
|
Files are merged by their top level key. With multiple files with the same top level key (e.g. `server`) only the last read will be used.
|
||||||
|
|
||||||
|
Example structure:
|
||||||
|
```
|
||||||
|
/etc/dyndnshelper/
|
||||||
|
fqdns.json
|
||||||
|
server.json
|
||||||
|
vendors.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server
|
||||||
|
All server config is defined under a key `server`, e.g.:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"host": "0.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The following options are available:
|
||||||
|
| Option | Default | Required | Description | Example |
|
||||||
|
|:-------|:--------|:---------|:------------|:--------|
|
||||||
|
| host | '127.0.0.1' | false | host to listen on | '0.0.0.0' |
|
||||||
|
| port | 8000 | false | port to listen on | 8080 |
|
||||||
|
| app_log_level | 'INFO' | false | log level for the application | 'DEBUG' |
|
||||||
|
| root_log_level | 'WARNING' | false | log level for all other applications | 'INFO' |
|
||||||
|
|
||||||
|
## Vendors
|
||||||
All vendor config is defined under a key `vendors` and the lowercase name of the vendor, e.g.:
|
All vendor config is defined under a key `vendors` and the lowercase name of the vendor, e.g.:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -112,9 +157,31 @@ The following common options are available for every vendor:
|
|||||||
| default_zone_ttl | 86400 | false | default TTL to set for created zones | 7200 |
|
| 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 |
|
| default_record_ttl | 600 | false | default TTL to set for created records - if not defined in individual FQDN settings | 300 |
|
||||||
|
|
||||||
## Hetzner
|
### Hetzner
|
||||||
Additional options:
|
Additional options for Hetzner:
|
||||||
| Option | Default | Required | Description | Example |
|
| Option | Default | Required | Description | Example |
|
||||||
|:-------|:--------|:---------|:------------|:--------|
|
|:-------|:--------|:---------|:------------|:--------|
|
||||||
| api_url | 'https://dns.hetzner.com/api/v1' | false | Hetzner DNS API URL | |
|
| 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' |
|
| api_token | | true | default TTL to set for created records - if not defined in individual FQDN settings | 'secretapitoken' |
|
||||||
|
|
||||||
|
## FQDNs
|
||||||
|
All FQDN config is defined under a key `fqdns` and the FQDN as a key, e.g.:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fqdns": {
|
||||||
|
"test1.domain.tld": {
|
||||||
|
"vendor": "hetzner",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The following options are available for each FQDN:
|
||||||
|
| Option | Default | Required | Description | Example |
|
||||||
|
|:-------|:--------|:---------|:------------|:--------|
|
||||||
|
| vendor | | true | vendor to use for the FQDN | 'hetzner' |
|
||||||
|
| zone | the root zone of the FQDN | false | DNS zone to handle DNS records in | 'myservers.domain.tld' |
|
||||||
|
| record_ttl | | false | DNS record TTL to use, when creating records. Overrides vendor `default_record_ttl` | 600 |
|
||||||
|
| dyndns_credentials | | false | Users and their SHA256 hashed passwords that are allowed to send DynDNS updates for this FQDN | {"testuser": "9f735e0df9a1ddc702bf0a1a7b83033f9f7153a00c29de82cedadc9957289b05"} |
|
||||||
|
| ipv6_lan_prefix_iid_map | | false | FQDNs and their IIDs to be updates if a DynDNS update for this FQDN contains an IPv6 LAN Prefix | {"test2.domain.tld": "::2a0:00ff:111c:1234"} |
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ app.include_router(fqdn_router)
|
|||||||
def run():
|
def run():
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
app=app,
|
app=app,
|
||||||
|
host=CONFIG.server.host,
|
||||||
port=CONFIG.server.port,
|
port=CONFIG.server.port,
|
||||||
log_level=CONFIG.server.app_log_level
|
log_level=CONFIG.server.app_log_level
|
||||||
)
|
)
|
||||||
server = uvicorn.Server(config)
|
server = uvicorn.Server(config)
|
||||||
server.run()
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == 'main':
|
||||||
|
run()
|
||||||
|
|||||||
@@ -56,9 +56,8 @@ def check_credentials_for_fqdn(
|
|||||||
async def dyndns(
|
async def dyndns(
|
||||||
username: Annotated[str, Depends(check_credentials_for_fqdn)],
|
username: Annotated[str, Depends(check_credentials_for_fqdn)],
|
||||||
fqdn_settings: Annotated[FQDNSettings, Depends(get_fqdn_settings)],
|
fqdn_settings: Annotated[FQDNSettings, Depends(get_fqdn_settings)],
|
||||||
ip: Annotated[list[IPv4Address | IPv6Address | EmptyStr], Query()] = None,
|
ip: Annotated[list[IPv4Address | IPv6Address | EmptyStr], Query(description='IPv4 or IPv6 addresses')] = None,
|
||||||
ipv6lanprefix: Annotated[IPv6Network, Query()] = None,
|
ipv6lanprefix: Annotated[IPv6Network, Query(description='IPv6 Network with Prefix. Will be used to update AAAA records for configured IIDs')] = None
|
||||||
dualstack: Annotated[bool, Query()] = None
|
|
||||||
):
|
):
|
||||||
if ip:
|
if ip:
|
||||||
# remove empty string items
|
# remove empty string items
|
||||||
@@ -72,7 +71,7 @@ async def dyndns(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ip:
|
if ip:
|
||||||
LOGGER.debug(f'DynDNS FQDN {fqdn_settings.fqdn} with IPs {", ".join(map(str, ip))}')
|
LOGGER.debug(f'{username} -DynDNS FQDN {fqdn_settings.fqdn} with IPs {", ".join(map(str, ip))}')
|
||||||
vendor = CONFIG.vendors.get_vendor(fqdn_settings.vendor)
|
vendor = CONFIG.vendors.get_vendor(fqdn_settings.vendor)
|
||||||
await vendor.ensure_fqdn(
|
await vendor.ensure_fqdn(
|
||||||
fqdn=fqdn_settings.fqdn,
|
fqdn=fqdn_settings.fqdn,
|
||||||
@@ -86,7 +85,7 @@ async def dyndns(
|
|||||||
# and we have to bitwise OR network address with iid address
|
# and we have to bitwise OR network address with iid address
|
||||||
netaddr_ipv6lanprefix_address = IPNetwork(str(ipv6lanprefix)).network
|
netaddr_ipv6lanprefix_address = IPNetwork(str(ipv6lanprefix)).network
|
||||||
for iid_fqdn, iid in fqdn_settings.ipv6_lan_prefix_iid_map.items():
|
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}')
|
LOGGER.debug(f'{username} - DynDNS IID FQDN {iid_fqdn} {iid} with IPv6 LAN Prefix {ipv6lanprefix}')
|
||||||
netaddr_iid_address = IPAddress(str(iid))
|
netaddr_iid_address = IPAddress(str(iid))
|
||||||
combined_address = IPv6Address(str(netaddr_ipv6lanprefix_address | netaddr_iid_address))
|
combined_address = IPv6Address(str(netaddr_ipv6lanprefix_address | netaddr_iid_address))
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class FQDNSettings(BaseModel):
|
|||||||
NOTE: If prefix is smaller than IID requires, parts of IID will be omitted. E.g.:
|
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
|
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')
|
@field_validator('dyndns_credentials')
|
||||||
def validate_dyndns_credentials(cls, v, info: FieldValidationInfo):
|
def validate_dyndns_credentials(cls, v, info: FieldValidationInfo):
|
||||||
if isinstance(v, dict):
|
if isinstance(v, dict):
|
||||||
@@ -93,6 +94,7 @@ class FQDNSettings(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Server(BaseModel):
|
class Server(BaseModel):
|
||||||
|
host: str = '127.0.0.1'
|
||||||
port: int = 8000
|
port: int = 8000
|
||||||
app_log_level: int = logging.INFO
|
app_log_level: int = logging.INFO
|
||||||
root_log_level: int = logging.WARNING
|
root_log_level: int = logging.WARNING
|
||||||
@@ -114,6 +116,11 @@ class Vendors(BaseModel):
|
|||||||
hetzner: HetznerSettings = Field(default_factory=HetznerSettings)
|
hetzner: HetznerSettings = Field(default_factory=HetznerSettings)
|
||||||
|
|
||||||
def get_vendor(self, name: VendorNames) -> Vendor:
|
def get_vendor(self, name: VendorNames) -> Vendor:
|
||||||
|
"""
|
||||||
|
returns initialized vendor class
|
||||||
|
|
||||||
|
raises ValueError if vendor is not defined
|
||||||
|
"""
|
||||||
vendor_settings = getattr(self, name, None)
|
vendor_settings = getattr(self, name, None)
|
||||||
if not vendor_settings:
|
if not vendor_settings:
|
||||||
raise ValueError(f'No vendor settings found for name `{name}`')
|
raise ValueError(f'No vendor settings found for name `{name}`')
|
||||||
|
|||||||
8
dyndnshelper/vendors/hetzner.py
vendored
8
dyndnshelper/vendors/hetzner.py
vendored
@@ -27,6 +27,9 @@ class HetznerZone(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
ttl: int
|
ttl: int
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.id}/{self.name}'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def update_dict(self):
|
def update_dict(self):
|
||||||
return self.model_dump(
|
return self.model_dump(
|
||||||
@@ -45,6 +48,9 @@ class HetznerRecord(BaseModel):
|
|||||||
value: str
|
value: str
|
||||||
ttl: int
|
ttl: int
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.id}/{self.name}/{self.type}'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def update_dict(self):
|
def update_dict(self):
|
||||||
return self.model_dump(
|
return self.model_dump(
|
||||||
@@ -139,7 +145,7 @@ class Hetzner(Vendor):
|
|||||||
return HetznerRecord(**record)
|
return HetznerRecord(**record)
|
||||||
|
|
||||||
async def update_record(self, record: HetznerRecord) -> HetznerRecord:
|
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}')
|
self.logger.info(f'Updating {record} in zone {record.zone_id}: {record.value}')
|
||||||
response = await self.api_put(
|
response = await self.api_put(
|
||||||
url=f'{self.settings.api_url}/records/{record.id}',
|
url=f'{self.settings.api_url}/records/{record.id}',
|
||||||
json=record.update_dict
|
json=record.update_dict
|
||||||
|
|||||||
Reference in New Issue
Block a user