Unbound DNS API Endpoints

Started by BertFLL, June 14, 2025, 12:22:49 PM

Previous topic - Next topic
Greetings,

I am attempting to Linux shell script Unbound DNS Host Overrides as part of a larger automation configuration project. I've searched for documentation and really did not find much useful information besides the XML model. Inspecting web gui pages looking for valid endpoints, I have discovered the following:

search:  /api/unbound/settings/searchHostOverride/
get:     /api/unbound/settings/getHostOverride/
set:     /api/unbound/settings/setHostOverride/
add:     /api/unbound/settings/addHostOverride/
del:     /api/unbound/settings/delHostOverride/
toggle:  /api/unbound/settings/toggleHostOverride/
reconfigure: /api/unbound/service/reconfigure

The only three valid endpoints are searchHostOverride, addHostOverride and reconfigure. Calling any of the other endpoints produce an error {"errorMessage":"Endpoint not found"}. I can search, add and reconfigure without issues.

The following script has the dependancies of curl and jq. I've tried many variations to update a record, this being the last attempt, to delete and then recreate but to no avail. A sample script follows:

#!/bin/bash
set -euo pipefail

# ========= Configuration =========
OPNSENSE_HOST="192.168.1.1"  # e.g., 192.168.1.1
API_KEY="your_api_key"
API_SECRET="your_api_secrete"
VERIFY_TLS=false
CURL_TLS_FLAG=$([[ "$VERIFY_TLS" == "true" ]] && echo "" || echo "-k")
API_BASE="https://${OPNSENSE_HOST}/api/unbound/settings"
UNBOUND_RECONF_API="https://${OPNSENSE_HOST}/api/unbound/service/reconfigure"
CURL_AUTH="-u ${API_KEY}:${API_SECRET}"

# ========= Arguments =========
BASE_HOST="$1"             # e.g., (hostname -s)
HOST_DOMAIN="$2"           # e.g., domain.com
HOST_IP="$3"               # e.g., 192.168.100.100
DESCRIPTION="${BASE_HOST}.${HOST_DOMAIN}"

# ========= Step 1: Search for Existing Override =========
echo "[INFO] Checking for existing DNS override: ${DESCRIPTION}..."

SEARCH_RESPONSE=$(curl -sS $CURL_TLS_FLAG $CURL_AUTH "${API_BASE}/searchHostOverride")
UUID=$(echo "$SEARCH_RESPONSE" | jq -r \
  --arg host "$BASE_HOST" --arg domain "$HOST_DOMAIN" \
  '.rows[] | select(.hostname == $host and .domain == $domain) | .uuid')

# ========= Step 2: If exists, delete =========
if [[ -n "$UUID" && "$UUID" != "null" ]]; then
  echo "[INFO] Existing override found (UUID: $UUID). Deleting before re-adding..."
  DELETE_RESPONSE=$(curl -sS $CURL_TLS_FLAG $CURL_AUTH \
    -X POST "${API_BASE}/delHostOverride" \
    -H "Content-Type: application/json" \
    -d "{\"uuid\":\"$UUID\"}")
  echo -e "[DEBUG] Delete response:\n$DELETE_RESPONSE"
  sleep 1
else
  echo "[INFO] No existing override found. Creating new one for ${DESCRIPTION} -> ${HOST_IP}..."
fi

# ========= Step 3: Add new override =========
ADD_PAYLOAD=$(jq -n \
  --arg hostname "$BASE_HOST" \
  --arg domain "$HOST_DOMAIN" \
  --arg server "$HOST_IP" \
  --arg description "$DESCRIPTION" \
  '{
    host: {
      enabled: "1",
      hostname: $hostname,
      domain: $domain,
      rr: "A",
      mxprio: "",
      mx: "",
      server: $server,
      description: $description
    }
  }')

ADD_RESPONSE=$(curl -sS $CURL_TLS_FLAG $CURL_AUTH \
  -X POST "${API_BASE}/addHostOverride" \
  -H "Content-Type: application/json" \
  -d "$ADD_PAYLOAD")

echo "[DEBUG] Add response: $ADD_RESPONSE"

# ========= Step 4: Reconfigure Unbound =========
echo "[INFO] Applying DNS configuration..."
RECONF_RESPONSE=$(curl -sS $CURL_TLS_FLAG $CURL_AUTH \
  -X POST "${UNBOUND_RECONF_API}")

if echo "$RECONF_RESPONSE" | grep -qi '"status":"ok"'; then
  echo -e "[SUCCESS] DNS configuration applied.\n$RECONF_RESPONSE"
else
  echo "[WARN] Reconfigure response: $RECONF_RESPONSE"
fi

I find it hard to believe that the endpoints are nonexistent because the web gui does complete the task. Any additional insight/help is greatly appreciated.

Thanks.

I got this working on 25.7.

https://docs.opnsense.org/development/api.html#required-parameters-and-expected-responses
https://docs.opnsense.org/development/api/core/unbound.html
https://192.168.1.1/ui/unbound/overrides

Code below will set a new override, save changes then print all the current overrides.

import requests
from tabulate import tabulate

HOST = "192.168.1.1"
API_KEY = "xxx"
API_SECRET = "xxx"

def record_value(record, rr_type):
  if rr_type == "TXT":
    return record.get("txtdata", "")
  if rr_type == "MX":
    mx = record.get("mx", "")
    prio = record.get("mxprio", "")
    return f"{prio} {mx}".strip()
  return record.get("server", "")


def fqdn(record):
  hostname = record.get("hostname", "")
  domain = record.get("domain", "")
  return ".".join(part for part in [hostname, domain] if part)


def dns_rows(data):
  hosts = data.get("rows")

  rows = []
  for record in hosts:
    rows.append({
      "enabled": "yes" if str(record.get("enabled", "0")) == "1" else "no",
      "type": record.get("rr", ""),
      "name": fqdn(record),
      "value": record_value(record, record.get("rr", "")),
      "ttl": record.get("ttl", ""),
      "description": record.get("description", ""),
      "uuid": record.get("uuid", ""),
    })

  return sorted(rows, key=lambda row: (row["name"], row["type"], row["value"]))

# https://docs.opnsense.org/development/api/core/unbound.html

# set a record
resp_set = requests.post(
  url=f"https://{HOST}/api/unbound/settings/add_host_override",
  auth=(API_KEY, API_SECRET),
  data={
    "host": {
      "description": "description_here",
      "domain": "domain.com",
      "enabled": "1",
      "hostname": "test123",
      "mx": "",
      "mxprio": "",
      "rr": "A",
      "server": "127.0.0.69",
      "ttl": "60",
      "txtdata": ""
    }
  }
)
resp_set.raise_for_status()

# apply/save a record
resp_save = requests.post(
  url=f"https://{HOST}/api/unbound/service/reconfigure/",
  auth=(API_KEY, API_SECRET),
  data={}
)
resp_save.raise_for_status()

# get a list of current records
resp_get = requests.post(
  url=f"https://{HOST}/api/unbound/settings/search_host_override/",
  auth=(API_KEY, API_SECRET),
  data={"current":1,"rowCount":-1,"sort":{}}
)
resp_get.raise_for_status()

print(tabulate(dns_rows(resp_get.json()), headers="keys", tablefmt="github"))