Dynamic DNS with Hetzner & Opnsense


DynDNS

Dynamic DNS helps turning dynamic IP adresses into static DNS Names and thus making systems permanently available. This page gives an insight into how to set things up with Hetzner DNS console and Opnsense. Even though Opnsense has a DynDNS client included, it does currently not support the Hetzner DNS API keys.

Hetzner

Step one is to create the actual record we went to regularly update. The easiest way is to do this via the DNS console.

Warning

The Hetzner DNS API does not support any ACLs at his point (January 2024), thus having access to the access token exposes ALL DNS settings you have in your Hetzner DNS console!

Hetzner DNS API

The Hetzner DNS API is documented here . The API tokens can be created in the DNS Console. The first code snippet gives you a list of all your zones in Hetzner’s DNS Console. The only necessary setting is the API Token. We use this to get the Zone ID.

# Install the Python Requests library:
# pip install requests

import requests


def send_request():
    # Get Zones
    # GET https://dns.hetzner.com/api/v1/zones

    try:
        response = requests.get(
            url="https://dns.hetzner.com/api/v1/zones",
            headers={
                "Auth-API-Token": "**API TOKEN**",
            },
        )
        print('Response HTTP Status Code: {status_code}'.format(
            status_code=response.status_code))
        print('Response HTTP Response Body: {content}'.format(
            content=response.content))
    except requests.exceptions.RequestException:
        print('HTTP Request failed')



send_request()

The second snippet is used to fetch the Record ID.


# Install the Python Requests library:
# pip install requests

import requests


def send_request():
    # Get Records
    # GET https://dns.hetzner.com/api/v1/records

    try:
        response = requests.get(
            url="https://dns.hetzner.com/api/v1/records",
            params={
                "zone_id": "**Zone ID**",
            },
            headers={
                "Auth-API-Token": "**API Token**",
            },
        )
        print('Response HTTP Status Code: {status_code}'.format(
            status_code=response.status_code))
        print('Response HTTP Response Body: {content}'.format(
            content=response.content))
    except requests.exceptions.RequestException:
        print('HTTP Request failed')



send_request()

The third snippet is just for verification, that we have the correct ID. Now, this request is a little mean, as the Record ID has to be passed as part of the URL!


# Install the Python Requests library:
# pip install requests

import requests


def send_request():
    # Get Record
    # GET https://dns.hetzner.com/api/v1/records/{RecordID}

    try:
        response = requests.get(
            url="https://dns.hetzner.com/api/v1/records/**Record ID**",
            headers={
                "Auth-API-Token": "**API TOKEN**",
            },
        )
        print('Response HTTP Status Code: {status_code}'.format(
            status_code=response.status_code))
        print('Response HTTP Response Body: {content}'.format(
            content=response.content))
    except requests.exceptions.RequestException:
        print('HTTP Request failed')



send_request()

Set Record

In the final snippet we need all parameters together: API Token, Zone ID, Record ID, the target DNS Name and the target IP.


import requests
import json


def send_request():
    # Update Record
    # PUT https://dns.hetzner.com/api/v1/records/{RecordID}

    try:
        response = requests.put(
            url="https://dns.hetzner.com/api/v1/records/**Record ID**",
            headers={
                "Content-Type": "application/json",
                "Auth-API-Token": "**API Token**",
            },
            data=json.dumps({
                "value": "**IP**",
                "ttl": 300,
                "type": "A",
                "name": "**DNS Name**",
                "zone_id": "**Zone ID**"
            })
        )
        print('Response HTTP Status Code: {status_code}'.format(
            status_code=response.status_code))
        print('Response HTTP Response Body: {content}'.format(
            content=response.content))
    except requests.exceptions.RequestException:
        print('HTTP Request failed')


send_request()

Opnsense

Opnsense exposes various functions, not all, some sligthly strange, via the API. Access is possible with individual API keys, which can be created on the users page. It also supports ACLs, which are more or less good.

Warning

The ACL for /diagnostics/interface/getinterfaceconfig seems to be slightly broken. It only seems to be accessible when the API key has ALL permissions.

Fetching the Interface IP

The following snippet requires the API key and secret and returns the IP address of ppppoe0. The interface name will have to be adjusted. Also ipv4 can be replaced with ipv6 if necessary. Don’t forget to import the server’s certificate beforehand, for testing purposes verify=False can be addedd to the request.

import json
import requests

key='**Key**'
secret='**Secret**'
url='https://**IP**:**Port**/api/diagnostics/interface/getinterfaceconfig'

r = requests.get(url, auth=(key,secret))

if r.status_code == 200:
    response = json.loads(r.text)
    print(response["pppoe0"]["ipv4"][0]["ipaddr"])

The Full Script

The full script will fetch the IP from the local Opnsense instance and then pass it on to Hetzner.

import json
import requests


opnsense_key = ''
opnsense_secret = ''
opnsense_url = 'https://ip:port'
opnsense_api_endpoint = '/api/diagnostics/interface/getinterfaceconfig'
opnsense_api_url = opnsense_url+opnsense_api_endpoint

hetzner_api_key = ''
hetzner_api_url = 'https://dns.hetzner.com/api/v1/records/'
hetzner_record_id = ''
hetzner_url = hetzner_api_url + hetzner_record_id

hetzner_zone_id = ''
domain_record_name = 'dyndns.system'

dns_ttl = 300



def get_external_ip():
    try: 
        r = requests.get(opnsense_api_url, auth=(opnsense_key,opnsense_secret))
        if r.status_code == 200:
            response = json.loads(r.text)
            return response["pppoe0"]["ipv4"][0]["ipaddr"]
    except requests.exceptions.RequestException:
        print('Opnsense: HTTP Request failed')
        

def set_ip(ip):
    try:
        response = requests.put(
            url = hetzner_url,
            headers={
                "Content-Type": "application/json",
                "Auth-API-Token": hetzner_api_key,
            },
            data=json.dumps({
                "value": ip,
                "ttl": dns_ttl,
                "type": "A",
                "name": domain_record_name,
                "zone_id": hetzner_zone_id
            })
        )
        print('Response HTTP Status Code: {status_code}'.format(
            status_code=response.status_code))
        print('Response HTTP Response Body: {content}'.format(
            content=response.content))
    except requests.exceptions.RequestException:
        print('Hetzner: HTTP Request failed')
        
        
set_ip(get_external_ip())