#!/usr/bin/env bash# The euler DDNS runtime updater — deployed to /usr/local/bin/euler-ddns# ================================================================================## Keeps the edge's A record pointed at the host's current public IP. This is the# RUNTIME half of the DDNS kit: euler-ddns.timer runs `euler-ddns update` as the# unprivileged euler-ddns user every 5 minutes. The installer half — the user, the# scoped config, the unit + timer — is scripts/setup/ddns.sh, which deploys this# file verbatim.## Why it is its own file: ddns.sh used to install a copy of ITSELF as the updater,# so the deployed copy carried the installer's dependencies (a sibling# authoring_env.sh) to a directory where they do not exist, and died on startup.# The two roles have different dependencies and different privileges, so they are# different files — the same split smtp.sh already has with euler-smtp.py.## Config, in order:# - the scoped plaintext /etc/euler/ddns.env (root:euler-ddns 0640) when readable# — the runtime's own source, generated by ddns.sh;# - otherwise whatever the caller already exported — `ddns.sh update` run by the# operator, which decrypts the vault-encrypted ~/.euler/env first.# It deliberately never reads ~/.euler/env itself: euler-ddns is a locked-down uid# that must not have access to the operator's authoring env, and the scoped copy it# does read is plaintext by construction, so it needs no vault reader.## name.com v4 REST API, HTTP Basic auth (username : API token).## Actions: update | show | help## Author: Vikas Munshi <vikas.munshi@gmail.com># Copyright (c) 2026. All rights reserved.# Licensed under the MIT License.set-euopipefailDDNS_ENV="${DDNS_ENV:-/etc/euler/ddns.env}"API="https://api.name.com/v4"IP_SERVICE="${EULER_DDNS_IP_SERVICE:-https://api.ipify.org}"TTL="${EULER_DDNS_TTL:-300}"usage(){cat<<USAGEUsage: $0 [update|show|help] update Update the name.com A record now if the public IP changed (the timer's action). show Print the host's public IP and the live A record (no changes made). Config: ${DDNS_ENV} when readable, else EULER_TLS_DOMAIN + NAMEDOTCOM_USERNAME / NAMEDOTCOM_TOKEN from the environment. Deployed by scripts/setup/ddns.sh.USAGE}# Set by load_config / split_fqdn.FQDN=""# full deployment FQDN (EULER_TLS_DOMAIN)NAMECOM_USER=""NAMECOM_TOKEN=""DOMAIN=""# registered domain (last two labels of FQDN)HOST=""# subdomain prefix# The scoped runtime config is plaintext by construction (ddns.sh generates it), so# it is sourced directly. Anything already in the environment wins only when the# file is unreadable — the operator path, where the values came from the vault.load_config(){if[-r"${DDNS_ENV}"];thenset-a# shellcheck disable=SC1090."${DDNS_ENV}"set+afiFQDN="${EULER_TLS_DOMAIN:-}"NAMECOM_USER="${Namecom_Username:-${NAMEDOTCOM_USERNAME:-}}"NAMECOM_TOKEN="${Namecom_Token:-${NAMEDOTCOM_TOKEN:-}}"if[-z"${FQDN}"];thenecho"Error: EULER_TLS_DOMAIN not set in ${DDNS_ENV} or the environment">&2return1fi}require_creds(){if[-z"${NAMECOM_USER}"]||[-z"${NAMECOM_TOKEN}"];thenecho"Error: name.com credentials not found (NAMEDOTCOM_USERNAME / NAMEDOTCOM_TOKEN)">&2return1fi}# Split a FQDN into the registered domain (last two labels) and the host prefix.# e.g. euler.vikasmunshi.com -> DOMAIN=vikasmunshi.com, HOST=euler.split_fqdn(){localfqdn="$1"DOMAIN="$(printf'%s'"${fqdn}"|awk-F.'{ if (NF>=2) print $(NF-1)"."$NF; else print $0 }')"if["${fqdn}"="${DOMAIN}"];thenHOST="";elseHOST="${fqdn%".${DOMAIN}"}";fi}# Print the host's current public IPv4, or fail.get_public_ip(){localipip="$(curl-sS--max-time10"${IP_SERVICE}"2>/dev/null||true)"[["${ip}"=~^([0-9]{1,3}\.){3}[0-9]{1,3}$]]||return1printf'%s'"${ip}"}# Echo the live A record's answer for ${HOST}.${DOMAIN}, or "none". Requires split_fqdn.get_a_record(){curl-sS-u"${NAMECOM_USER}:${NAMECOM_TOKEN}"--max-time15\"${API}/domains/${DOMAIN}/records"2>/dev/null\|jq-r--argh"${HOST}"'first(.records[]? | select(.host==$h and .type=="A") | .answer) // "none"'\2>/dev/null||true}do_update(){load_config||return1require_creds||return1split_fqdn"${FQDN}"localipif!ip="$(get_public_ip)";thenecho"DDNS: could not determine public IP (via ${IP_SERVICE})">&2return1filocalrecordsif!records="$(curl-sS-u"${NAMECOM_USER}:${NAMECOM_TOKEN}"--max-time20\"${API}/domains/${DOMAIN}/records")";thenecho"DDNS: name.com records query failed for ${DOMAIN}">&2return1filocalidcurid="$(printf'%s'"${records}"|jq-r--argh"${HOST}"\'first(.records[]? | select(.host==$h and .type=="A") | .id) // empty')"cur="$(printf'%s'"${records}"|jq-r--argh"${HOST}"\'first(.records[]? | select(.host==$h and .type=="A") | .answer) // empty')"if["${cur}"="${ip}"];thenecho"DDNS: ${FQDN} already ${ip} (no change)"return0filocalbodybody="$(jq-nc--argh"${HOST}"--arga"${ip}"--argjsont"${TTL}"\'{host:$h, type:"A", answer:$a, ttl:$t}')"if[-n"${id}"];thenecho"DDNS: updating ${FQDN}${cur:-<none>} -> ${ip} (record ${id})"curl-sS-u"${NAMECOM_USER}:${NAMECOM_TOKEN}"--max-time20-XPUT\-H'Content-Type: application/json'-d"${body}"\"${API}/domains/${DOMAIN}/records/${id}">/dev/nullelseecho"DDNS: creating ${FQDN} A ${ip}"curl-sS-u"${NAMECOM_USER}:${NAMECOM_TOKEN}"--max-time20-XPOST\-H'Content-Type: application/json'-d"${body}"\"${API}/domains/${DOMAIN}/records">/dev/nullfiecho"DDNS: ${FQDN} -> ${ip} done"}# The read-only half of the picture ddns.sh's `status` prints. Best-effort: a missing# credential or a name.com hiccup must not fail a status report.do_show(){load_config||return1localipip="$(get_public_ip2>/dev/null||true)"echo"Public IP: ${ip:-unknown}"ifrequire_creds2>/dev/null;thensplit_fqdn"${FQDN}"localcurcur="$(get_a_record)"if[-n"${ip}"]&&["${cur}"="${ip}"];thenecho"A record: ${cur} ✓ in sync"elseecho"A record: ${cur:-unknown} (public IP ${ip:-?})"fifi}# ── Dispatch ──────────────────────────────────────────────────────────────────────ACTION="${1:-update}"case"${ACTION}"inupdate)do_update;;show)do_show;;-h|--help|help)usage;;*)echo"Unknown action: ${ACTION}";usage;exit1;;esac