#!/usr/bin/env bash# Dynamic DNS kit (euler-ddns): keep the edge's A record on the host's public IP.# ===============================================================================## Keeps the edge's A record (e.g. euler.vikasmunshi.com) pointed at the host's current# public IP, so the Caddy edge (scripts/setup/frontend.sh) stays reachable behind a# changing ISP address. Replaces the "external updater" hand-wave in docs/web-server-guide.md# with a repo-scripted updater on a systemd timer.## This is the INSTALLER half of the kit. The updater it deploys is a separate file,# scripts/setup/euler-ddns.sh, which holds everything that talks to name.com —# mirroring smtp.sh / euler-smtp.py. The two halves are split because they have# different privileges and different dependencies: this one runs from the checkout as# the operator and reads the (possibly vault-encrypted) authoring env; the updater runs# as a locked-down uid from /usr/local/bin and reads only the scoped plaintext copy.# Deploying THIS file as the updater is what previously carried the installer's sibling# helpers to a directory without them, killing the timer silently.## Model (the updater runs as the non-root euler-ddns user):# - `update` reads the public IP (api.ipify.org) and PUTs/creates the name.com A# record only when it has changed (idempotent, quiet on no-change).# - The updater is deployed to /usr/local/bin/euler-ddns and run by a root-owned# `euler-ddns.timer` as the dedicated `euler-ddns` user. It is **infra egress** —# like acme.sh, it does NOT go through the Squid proxy.# - Config is the scoped /etc/euler/ddns.env (root:euler-ddns 0640): just the FQDN# (EULER_TLS_DOMAIN) + the name.com token, deployed by the installer from ~/.euler/env# (the authoring source). euler-ddns never reads the full ~/.euler/env.## Actions: deploy | update | status | remove | help## Author: Vikas Munshi <vikas.munshi@gmail.com># Copyright (c) 2026. All rights reserved.# Licensed under the MIT License.set-euopipefailSCRIPT_DIR="$(cd"$(dirname"${BASH_SOURCE[0]}")"&&pwd)"PROJECT_ROOT="$(cd"${SCRIPT_DIR}/../.."&&pwd)"UPDATER_SRC="${SCRIPT_DIR}/euler-ddns.sh"# the runtime updater this kit deploys# The authoring env (~/.euler/env, possibly vault-encrypted): ENV_FILE +# load_authoring_env, shared so every kit reads it one way. Unconditional: this file is# the installer and never leaves the checkout, so its helpers are always beside it.# shellcheck source=scripts/setup/authoring_env.sh."${SCRIPT_DIR}/authoring_env.sh"SYS_DIR="/etc/euler"DDNS_ENV="${SYS_DIR}/ddns.env"# scoped runtime config (root:euler-ddns 0640)DDNS_BIN="/usr/local/bin/euler-ddns"# deployed updater (euler-ddns runs this)DDNS_USER="euler-ddns"DDNS_GROUP="euler-ddns"SERVICE_NAME="euler-ddns.service"TIMER_NAME="euler-ddns.timer"SERVICE_DEST="/etc/systemd/system/${SERVICE_NAME}"TIMER_DEST="/etc/systemd/system/${TIMER_NAME}"# Set by load_config.FQDN=""# full deployment FQDN (EULER_TLS_DOMAIN)NAMECOM_USER=""NAMECOM_TOKEN=""usage(){cat<<USAGEUsage: $0 [deploy|update|status|remove|help] deploy Install the euler-ddns systemd timer (root) that periodically updates the A record for the FQDN from ~/.euler/env. Idempotent — it is also its own upgrade path. update Update the name.com A record now if the public IP changed. status Show the timer state, the host's public IP, and the live A record. remove Remove the timer + service (name.com records left untouched). Authoring config in ~/.euler/env: EULER_TLS_DOMAIN (the FQDN) and NAMEDOTCOM_USERNAME / NAMEDOTCOM_TOKEN. deploy installs a scoped copy to /etc/euler/ddns.env for euler-ddns; commands fail if the FQDN is unset.USAGE}check_can_sudo(){if!command-vsudo&>/dev/null;thenecho"Error: sudo is not installed or not found in PATH">&2return1fiif!sudo-v2>/dev/null;thenecho"Error: current user does not have sudo privileges">&2return1fi}require_systemd(){if[!-d/run/systemd/system]||!command-vsystemctl&>/dev/null;thenecho"Error: systemd is required for the DDNS timer; it is not active here.">&2return1fi}ensure_deps(){if!command-vjq&>/dev/null;thenecho"Installing jq..."sudoapt-getinstall-yjqfi}# Source the config and resolve the FQDN + name.com creds. Prefers the deployed scoped# /etc/euler/ddns.env (readable by euler-ddns / root); falls back to the repo ~/.euler/env# (operator). Fails if the FQDN is set in neither.## The precedence resolves itself by permission: ddns.env is root:euler-ddns 0640, so the# operator running an install cannot read it and lands on the authoring env, while root# re-reads the scoped copy it already deployed.## The two sources take different paths on purpose: ddns.env is generated by# deploy_ddns_env and is therefore ALWAYS plaintext, while only ~/.euler/env can be vault# ciphertext and need the reader. `set -a` in both branches is what lets do_update simply# exec the updater — the config is already in the environment.load_config(){if[-r"${DDNS_ENV}"];thenset-a# shellcheck disable=SC1090."${DDNS_ENV}"set+aelif[-r"${ENV_FILE}"];thenload_authoring_env"${ENV_FILE}"||return1fiFQDN="${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 ${ENV_FILE}">&2return1fi}# Assert the name.com credentials were found (needed for update + deploying ddns.env).require_creds(){if[-z"${NAMECOM_USER}"]||[-z"${NAMECOM_TOKEN}"];thenecho"Error: name.com credentials not found (NAMEDOTCOM_USERNAME / NAMEDOTCOM_TOKEN)">&2return1fi}# Create the dedicated euler-ddns user (own group, nologin).ensure_ddns_user(){if!getentgroup"${DDNS_GROUP}">/dev/null;thensudogroupadd--system"${DDNS_GROUP}"fiif!getentpasswd"${DDNS_USER}">/dev/null;thenecho"Creating system user ${DDNS_USER}..."sudouseradd--system--no-create-home--shell/usr/sbin/nologin\-g"${DDNS_GROUP}""${DDNS_USER}"fi}# Deploy the scoped runtime config euler-ddns reads (FQDN + name.com creds only).deploy_ddns_env(){sudomkdir-p"${SYS_DIR}"sudotee"${DDNS_ENV}">/dev/null<<EOF# GENERATED by scripts/setup/ddns.sh — scoped runtime config for euler-ddns.# Authoring source: ~/.euler/env.EULER_TLS_DOMAIN=${FQDN}NAMEDOTCOM_USERNAME=${NAMECOM_USER}NAMEDOTCOM_TOKEN=${NAMECOM_TOKEN}EOFsudochownroot:"${DDNS_GROUP}""${DDNS_ENV}"sudochmod0640"${DDNS_ENV}"}# ── update ────────────────────────────────────────────────────────────────────────# Run the updater from the REPO, not ${DDNS_BIN}: an operator running this wants the# current source exercised, not yesterday's deployed copy. load_config exports the# config (`set -a`), and the operator cannot read the root-owned scoped ddns.env, so# the updater picks up the authoring values from the environment — the vault decrypt# happens here, in the one place that is allowed to do it.do_update(){load_config||return1require_creds||return1"${UPDATER_SRC}"update}# ── deploy / remove (root timer) ──────────────────────────────────────────────────do_deploy(){check_can_sudo||return1require_systemd||return1load_config||return1require_creds||return1# fail early if the token is missingensure_depsensure_ddns_userdeploy_ddns_envecho"Deploying updater to ${DDNS_BIN}..."sudoinstall-m0755"${UPDATER_SRC}""${DDNS_BIN}"echo"Installing ${TIMER_NAME} for ${FQDN} (every 5 min, as ${DDNS_USER})..."sudotee"${SERVICE_DEST}">/dev/null<<EOF[Unit]Description=euler dynamic DNS updater (name.com A record for ${FQDN})Documentation=https://github.com/vikasmunshi/euler/blob/master/docs/web-server-guide.mdAfter=network-online.targetWants=network-online.target[Service]Type=oneshotUser=${DDNS_USER}Group=${DDNS_GROUP}ExecStart=${DDNS_BIN} update# Hardening. The updater reads its env and calls two HTTPS APIs with curl; it writes nothing.NoNewPrivileges=trueProtectSystem=strictProtectHome=truePrivateTmp=truePrivateDevices=trueProtectKernelModules=trueCapabilityBoundingSet=RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6ProtectKernelTunables=trueProtectKernelLogs=trueProtectControlGroups=trueProtectClock=trueProtectHostname=trueRestrictNamespaces=trueRestrictSUIDSGID=trueRestrictRealtime=trueLockPersonality=trueMemoryDenyWriteExecute=trueSystemCallArchitectures=nativeSystemCallFilter=@system-serviceSystemCallErrorNumber=EPERMEOFsudotee"${TIMER_DEST}">/dev/null<<EOF[Unit]Description=Periodic euler DDNS update[Timer]OnBootSec=1minOnUnitActiveSec=5minPersistent=true[Install]WantedBy=timers.targetEOFsudosystemctldaemon-reloadsudosystemctlenable--now"${TIMER_NAME}"# The initial update is the ONLY check that the deployed updater actually runs as# euler-ddns — do_status below answers from the operator's checkout and stays green# even when ${DDNS_BIN} is broken, so a failure swallowed here is a timer that has# been failing every 5 minutes in silence.echo"Running an initial update..."if!sudosystemctlstart"${SERVICE_NAME}";thenecho"Error: the initial update failed — the timer is deployed but the updater at">&2echo" ${DDNS_BIN} is not working, so the A record will go stale.">&2echo" Diagnose with: journalctl -xeu ${SERVICE_NAME}">&2do_status||truereturn1fido_status}do_remove(){check_can_sudo||return1if[-f"${TIMER_DEST}"];thensudosystemctldisable--now"${TIMER_NAME}"2>/dev/null||truesudorm-f"${TIMER_DEST}"fiif[-f"${SERVICE_DEST}"];thensudosystemctldisable"${SERVICE_NAME}"2>/dev/null||truesudorm-f"${SERVICE_DEST}"fisudosystemctldaemon-reloadsudorm-f"${DDNS_BIN}"localreplyread-r-p"Remove ${DDNS_ENV} and the ${DDNS_USER} user? [y/N] "replyif[["${reply}"=~^[Yy]$]];thensudorm-f"${DDNS_ENV}"ifgetentpasswd"${DDNS_USER}">/dev/null;thensudouserdel"${DDNS_USER}"2>/dev/null||true;fiifgetentgroup"${DDNS_GROUP}">/dev/null;thensudogroupdel"${DDNS_GROUP}"2>/dev/null||true;fifiecho"DDNS uninstall complete (name.com records left untouched)."}do_status(){load_config||return1echo"DDNS target: ${FQDN}"if[-f"${TIMER_DEST}"]&&command-vsystemctl&>/dev/null;then# State, then the next run, in the shape frontend.sh uses for euler-acme.timer. Read# as JSON rather than off the aligned table: its six columns (NEXT LEFT LAST PASSED# UNIT ACTIVATES) ran together into one unreadable line, under a `next:` label that# described only the first two. A monotonic timer publishes no `next` while it sits# between elapses, so name the last run instead rather than mislabelling it.localwhen=''ifcommand-vjq&>/dev/null;thenwhen="$(systemctllist-timers"${TIMER_NAME}"--no-pager--output=json2>/dev/null|jq-r' first | if .next then "next: " + (.next / 1000000 | strflocaltime("%Y-%m-%d %H:%M:%S %Z")) elif .last then "last: " + (.last / 1000000 | strflocaltime("%Y-%m-%d %H:%M:%S %Z")) else empty end'2>/dev/null)"fiecho"${TIMER_NAME}: $(systemctlis-active"${TIMER_NAME}"2>/dev/null)/$(systemctlis-enabled"${TIMER_NAME}"2>/dev/null)${when:+ — ${when}}"elseecho"${TIMER_NAME}: ✗ not installed"fi# The public IP + live A record: the updater owns every name.com call, so status# asks it rather than keeping a second copy of the query. Best-effort — a name.com# hiccup must not fail a status report."${UPDATER_SRC}"show||true}# ── Dispatch ──────────────────────────────────────────────────────────────────────ACTION="${1:-status}"case"${ACTION}"indeploy)do_deploy;;update)do_update;;status)do_status;;remove)do_remove;;-h|--help|help)usage;;*)echo"Unknown action: ${ACTION}";usage;exit1;;esac