Skip to content

Conversation

@ArcanConsulting
Copy link
Contributor

Summary

New plugin for comprehensive Hetzner DNS management in OPNsense.

Features

  • Multi-account support - Manage multiple Hetzner API tokens
  • Multi-zone DNS management - Handle all your domains from one interface
  • Dynamic DNS with automatic failover between WAN gateways
  • Dual-Stack support - IPv4 (A) and IPv6 (AAAA) records
  • Full DNS record management - Create, edit, delete all record types (A, AAAA, CNAME, MX, TXT, SRV, CAA, etc.)
  • Change history - Track all DNS changes with revert/undo functionality
  • Notifications - Email, Webhook, and Ntfy support
  • Configuration backup/restore

Supports both Hetzner Cloud API and legacy DNS Console API.

Screenshots
dyndns
dnsmanagement
gateways
gateway fail state
gateway failure
gateway recovered
history
settings
spf helper

Technical Details

  • Location: dns/hclouddns
  • Backend: Python 3 with Hetzner DNS API integration
  • Frontend: OPNsense MVC (Volt templates, PHP controllers)
  • Service: Configurable update intervals via cron

Testing

  • Tested on OPNsense 24.7
  • Multiple Hetzner accounts and zones
  • Failover between multiple WAN gateways verified
  • IPv4 and IPv6 record updates confirmed

Checklist

  • Code follows OPNsense plugin conventions
  • Makefile with proper versioning
  • ACL definitions included
  • Menu integration

  Add native support for Hetzner Cloud DNS API (api.hetzner.cloud).
  Hetzner is migrating from dns.hetzner.com to Cloud Console,
  with the old API shutting down in May 2026.

  Features:
  - Bearer token authentication
  - A and AAAA record support
  - Multiple hostnames (comma-separated)
  - Configurable TTL
  ## Features
  - Multi-account support (multiple Hetzner API tokens)
  - Multi-zone DNS management
  - Dynamic DNS with automatic failover between WAN interfaces
  - IPv4 and IPv6 (Dual-Stack) support
  - Direct DNS management (view/edit/delete records)
  - Change history with undo functionality
  - Notifications (Email, Webhook, Ntfy)
  - Configuration backup/restore

  Supports both Hetzner Cloud API and legacy DNS Console API.
  Bugfixes:
  - Fix DNS record edit mode (editing TTL no longer fails with "Failed to create record")
  - Fix error dialog titles showing "Danger" instead of "Error"
  - Add detailed error messages with record info for debugging
  - Fix TTL dropdown not being populated

  Improvements:
  - Integrate notifications into automatic DNS update flow
  - Add global default TTL setting for DynDNS entries (60s default)
  - Add "Save & Apply TTL to All Entries" button
  - Move DynDNS TTL settings from Scheduled to DNS Entries tab
  - Simplified TTL settings UI
  - Fix TTL updates and add TTL dropdown selector
  - Add plugin concepts for UniFi and MikroTik integration
  - Integrate notifications into automatic DNS update flow
  - Add global default TTL setting for DynDNS entries
  - Add "Apply TTL to All Entries" button
  - Fix DNS record edit mode and improve error dialogs
  - Move DynDNS TTL settings from Scheduled to DNS Entries tab
  - Fix TTL dropdown and simplify settings layout
  - Auto-save TTL before applying to all entries
  - Simplify TTL UI to single Save & Apply button
  - Move Import to DNS Entries, add DynDNS button to DNS Management
  - Add grouped record display with filter and search in DNS Management
  ### DNS Management Improvements
  - Sort zone groups alphabetically
  - Persist collapsed group state in localStorage
  - Fix zone search visibility - move to separate container
  - Preserve collapsed group state on refresh
  - Add DNS Management improvements: sorting, search, zone groups

  ### Import & Account Handling
  - Fix Import from Hetzner account dropdown

  ### DKIM & TXT Records
  - Fix DKIM wizard field sizes
  - Improve TXT record value display with smart formatting
  - Show TXT record subtypes (SPF, DKIM, DMARC, Google, MS) in DNS Management

  ### DynDNS Entries
  - Fix default TTL loading from settings API
  - Use default DynDNS TTL when creating entries from DNS Management
  - Mark A/AAAA records already configured as DynDNS with green bolt icon

  ### Record Edit/Delete
  - Add synthetic record IDs for rrsets API
  - Fetch record data live from API for edit/delete
  - Refactor edit/delete to lookup record data from cache
  - Fix edit/delete handlers for new TXT value display

  ### UI/UX
  - Fix gateway auto-selection to use sorted order instead of exact priority values
  - Add grouped record display with filter and search in DNS Management
  - Move Import to DNS Entries, add DynDNS button to DNS Management
  - Simplify TTL UI to single Save & Apply button
@AdSchellevis
Copy link
Member

@ArcanConsulting We're not really sure yet if this fits our plugin scope, reviewing these large amounts of code takes a lot of time and we're not sure about the number of users interested in it. Keeping a project like this alive, also requires a time investment in the long run from your end. Are there already people using this?

In some cases it's better to offer a package from your own infrastructure to avoid maintenance issues in the long run from our end which also makes clearer for the user that this isn't a part of our distribution.

@ArcanConsulting
Copy link
Contributor Author

@ArcanConsulting We're not really sure yet if this fits our plugin scope, reviewing these large amounts of code takes a lot of time and we're not sure about the number of users interested in it. Keeping a project like this alive, also requires a time investment in the long run from your end. Are there already people using this?

In some cases it's better to offer a package from your own infrastructure to avoid maintenance issues in the long run from our end which also makes clearer for the user that this isn't a part of our distribution.

I built this primarily for my own infrastructure - I manage ~30 domains on Hetzner and needed proper multi-zone DynDNS with failover support. The existing solutions didn't cover my requirements.

I understand the review burden for a plugin this size. I'm happy to maintain it as a community package from my own repo - that removes the long-term maintenance concern from your side. If it gains traction and proves stable, we can revisit official inclusion.

Could you point me to docs on setting up a community package repo?

@AdSchellevis
Copy link
Member

building the index is part of the package manager pkg repo is probably what you are looking for (man pkg).

@fichtner
Copy link
Member

The rough sequence is as seen in the tools repo:

https://github.com/opnsense/tools/blob/6890a15b051471dfb82c897a5d15b496c42cab71/build/common.sh#L998-L1098

From a project perspective it's not useful to document how all of this works. Other documentation about it exists in FreeBSD.

Cheers,
Franco

Comment on lines 119 to 120
"""Get existing record by name and type"""
url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the API support using the zone name as path parameter, so you would have the following:

Suggested change
"""Get existing record by name and type"""
url = f"{self._api_base}/zones/{zone_name}/rrsets/{record_name}/{record_type}"

response = requests.put(url, headers=headers, json=data)
NOTE: Hetzner Cloud API has a bug where PUT returns 200 but doesn't update.
Workaround: DELETE old record, then POST new record.
"""
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating records cannot be done with PUT /.../rrsets/..., you must use one of the following endpoint:

Also note, that those endpoint return "Actions", which describe async tasks that should be waited upon.

'ttl': int(self.settings.get('ttl', 300))
}

response = requests.post(url, headers=headers, json=data)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response holds an "Action", that should be waited upon.

See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-create-an-rrset

Comment on lines 142 to 161
"""Update existing record with new address
NOTE: Hetzner Cloud API has a bug where PUT returns 200 but doesn't update.
Workaround: DELETE old record, then POST new record.
"""
# DELETE old record first
delete_url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}"
delete_response = requests.delete(delete_url, headers=headers)

if delete_response.status_code not in [200, 201, 204]:
syslog.syslog(
syslog.LOG_ERR,
"Account %s error deleting record for update: HTTP %d - %s" % (
self.description, delete_response.status_code, delete_response.text
)
)
return False

# CREATE new record
return self._create_record(headers, zone_id, record_name, record_type, address)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating records cannot be done with PUT /.../rrsets/..., you must use one of the following endpoint:

Also note, that those endpoint return "Actions", which describe async tasks that should be waited upon.

Comment on lines +44 to +48
class HetznerCloudAPI:
"""
Hetzner Cloud DNS API (api.hetzner.cloud)
Uses Bearer token authentication and rrsets endpoints
"""
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already a https://pypi.org/project/hcloud/ API client that can speak to the DNS API.

  API Changes (based on Hetzner feedback):
  - Migrate to proper rrset-actions endpoints for record updates
  - Use POST /zones/{zone_id}/rrsets/{name}/{type}/actions/set_records
  - Add async action polling - wait for success/error status before continuing

  Performance:
  - Switch from sequential to parallel DNS update processing (ThreadPoolExecutor)
  - Deduplicate entries by (zone_id, record_name, record_type) before processing
  - Thread-safe state access with locks

  Notifications:
  - Single batch notification per update run instead of per-entry
  - Clean title format with gateway names:
    "HCloudDNS: Failover WAN_Primary → WAN_Backup"
    "HCloudDNS: Failback WAN_Backup → WAN_Primary"
    "HCloudDNS: DynIP Update on WAN_Primary"
  - Records listed once in body (no duplication)
  - Grouped by domain with proper spacing
if delete_response.status_code not in [200, 201, 204]:
data = {
'records': [{'value': str(address)}],
'ttl': int(self.settings.get('ttl', 300))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import requests
from . import BaseAccount

ACTION_POLL_INTERVAL = 0.5 # seconds between action status polls
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is rather short, what about 1 second ? Ideally you would use an exponential backoff function.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants