Custom DDNS for Your Home Lab with OPNsense BIND (Python + cron)

TL;DR: This guide shows a small Python script that discovers your public IPv4, updates an A record in the OPNsense BIND plugin (creates it if missing), and then triggers reconfigure, no nsupdate, no SSH; just HTTPS to the API.

New to the OPNsense API? See my primer: Using the OPNsense API with Python.

See also: Prefer Cloudflare-managed DNS? Check out my earlier guide: Custom DDNS for Your Home Lab with Cloudflare (Python + cron).


Tested environment

  • OPNsense 25.7.3
  • BIND plugin: os-bind 1.34_2

What you’ll build

  • An IPv4-only DDNS updater for OPNsense BIND
  • Automatically looks up the zone UUID, adds/updates the A record, then calls /api/bind/service/reconfigure
  • Runs from any host that can reach your OPNsense

Prerequisites

  • OPNsense with the BIND plugin enabled and serving your zone
  • API user with Effective Privileges → Services: BIND
  • Valid HTTPS certificate on OPNsense (or set the script to skip verification if you must)

The script (IPv4-only, auto-discovers zone UUID)

Save as bind_ddns.py and adjust the Settings section to your environment.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import requests, ipaddress, sys

# ===== Settings =====
HOST        = "https://opnsense.maksonlee.com:8444"  # OPNsense base URL
API_KEY     = "OPNSENSE_API_KEY_HERE"
API_SECRET  = "OPNSENSE_API_SECRET_HERE"
ZONE        = "maksonlee.com"                        # primary zone name
NAME        = "home"                                 # "@", "home", or "home.maksonlee.com"
TTL         = 300                                    # seconds
VERIFY_TLS  = True                                    # set False if self-signed
# ====================

def get_public_ipv4():
    for u in ("https://api.ipify.org","https://ipv4.icanhazip.com","https://ifconfig.me/ip"):
        try:
            ip = requests.get(u, timeout=6).text.strip()
            if ip and ipaddress.ip_address(ip).version == 4:
                return ip
        except Exception:
            pass
    sys.exit("Failed to determine public IPv4.")

def api_req(method, path, json=None):
    url = f"{HOST}{path}"
    r = requests.request(method, url, json=json, auth=(API_KEY, API_SECRET), timeout=15, verify=VERIFY_TLS)
    if r.status_code == 401:
        raise PermissionError(f"401 Unauthorized for {method} {path}")
    r.raise_for_status()
    return r.json()

def rows_of(d):
    return d.get("rows") or d.get("result") or d.get("data") or (d if isinstance(d, list) else [])

def to_label(name, zone):
    n = name.strip().rstrip(".").lower()
    z = zone.strip().rstrip(".").lower()
    if n in ("", "@", z): return "@"
    if n.endswith("." + z): return n[:-(len(z)+1)]
    if "." not in n: return n
    sys.exit(f"NAME '{name}' is not in zone '{zone}'")

def fetch_primary_zone_rows():
    for path in (
        "/api/bind/domain/search_primary_domain",
        "/api/bind/domain/search_master_domain",
    ):
        r = rows_of(api_req("GET", path))
        if r: return r
    return []

def find_zone_uuid(zone):
    z = zone.strip().rstrip(".").lower()
    # 1) Normal path: query domain list
    try:
        rows = fetch_primary_zone_rows()
        if not rows:
            print("No zones returned by search_*_domain (check privileges / click Apply in UI).")
        else:
            for row in rows:
                if str(row.get("domainname","")).strip().rstrip(".").lower() == z and row.get("uuid"):
                    return row["uuid"]
    except PermissionError as e:
        print(str(e))
    # 2) Fallback: derive from existing records if the zone already contains entries
    try:
        rec_rows = rows_of(api_req("GET", "/api/bind/record/search_record"))
        for r in rec_rows:
            if str(r.get("domainname","")).strip().rstrip(".").lower() == z and r.get("domain"):
                return r["domain"]
    except PermissionError as e:
        print(str(e))
    sys.exit(f"Unable to determine zone UUID for '{zone}'. Ensure the user has Services: BIND or the zone already has records.")

def find_a_records(domain_uuid, label):
    rows = rows_of(api_req("GET", "/api/bind/record/search_record"))
    out = []
    for r in rows:
        if (str(r.get("type","")).upper() == "A"
            and str(r.get("name","")).strip().rstrip(".").lower() == label
            and str(r.get("domain") or "").strip().lower() == str(domain_uuid).lower()):
            out.append(r)
    return out

def get_record(uuid):
    d = api_req("GET", f"/api/bind/record/get_record/{uuid}")
    return d.get("record") or d

def add_a_record(domain_uuid, label, ip, ttl):
    rec = {
        "type": "A",
        "name": label,
        "ttl": str(ttl),
        "enabled": "1",
        "value": ip,           # content
        "domain": domain_uuid  # zone UUID
    }
    return api_req("POST", "/api/bind/record/add_record", {"record": rec})

def set_a_record(uuid, domain_uuid, label, ip, ttl):
    rec = {
        "type": "A",
        "name": label,
        "ttl": str(ttl),
        "enabled": "1",
        "value": ip,
        "domain": domain_uuid
    }
    return api_req("POST", f"/api/bind/record/set_record/{uuid}", {"record": rec})

def reconfigure():
    return api_req("POST", "/api/bind/service/reconfigure")

def main():
    ip = get_public_ipv4()
    print("IPv4:", ip)
    label = to_label(NAME, ZONE)

    zone_uuid = find_zone_uuid(ZONE)
    print("Zone UUID:", zone_uuid)

    recs = find_a_records(zone_uuid, label)
    changed = False

    if not recs:
        resp = add_a_record(zone_uuid, label, ip, TTL)
        if resp.get("validations"):
            print("Add failed:", resp["validations"]); sys.exit(1)
        print("Add response:", resp)
        changed = True
    else:
        for r in recs:
            rid = r.get("uuid")
            cur = get_record(rid)
            cur_ip  = str(cur.get("value") or "")
            cur_ttl = str(cur.get("ttl") or "")
            cur_en  = str(cur.get("enabled") or "1") in ("1","true","True")
            if cur_ip == ip and cur_ttl == str(TTL) and cur_en:
                print(f"No change (uuid={rid})")
                continue
            resp = set_a_record(rid, zone_uuid, label, ip, TTL)
            if resp.get("validations"):
                print("Update failed:", resp["validations"]); sys.exit(1)
            print("Update response:", resp)
            changed = True

    if changed:
        reconfigure()
        # Confirm
        recs = find_a_records(zone_uuid, label)
        print("Post-reconfigure records found:", recs)
        if not recs:
            sys.exit("Record not visible after reconfigure. Check privileges or UI Save/Apply.")
        print("Applied changes.")
    else:
        print("No updates were necessary.")

if __name__ == "__main__":
    main()

Schedule it with cron

Run every 5 minutes as user administrator:

crontab -e
# add:
*/5 * * * * /home/administrator/scripts/bind_ddns.py >/dev/null 2>&1

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top