Euler source The code that runs the solver shell and this site, version 4.34.2 Repository on GitHub

The euler DDNS runtime updater — deployed to /usr/local/bin/euler-ddns

184 lines of Bash Raw file Open on GitHub

#!/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 -euo pipefail

DDNS_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 <<USAGE
Usage: $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}" ]; then
        set -a
        # shellcheck disable=SC1090
        . "${DDNS_ENV}"
        set +a
    fi
    FQDN="${EULER_TLS_DOMAIN:-}"
    NAMECOM_USER="${Namecom_Username:-${NAMEDOTCOM_USERNAME:-}}"
    NAMECOM_TOKEN="${Namecom_Token:-${NAMEDOTCOM_TOKEN:-}}"
    if [ -z "${FQDN}" ]; then
        echo "Error: EULER_TLS_DOMAIN not set in ${DDNS_ENV} or the environment" >&2
        return 1
    fi
}

require_creds() {
    if [ -z "${NAMECOM_USER}" ] || [ -z "${NAMECOM_TOKEN}" ]; then
        echo "Error: name.com credentials not found (NAMEDOTCOM_USERNAME / NAMEDOTCOM_TOKEN)" >&2
        return 1
    fi
}

# 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() {
    local fqdn="$1"
    DOMAIN="$(printf '%s' "${fqdn}" | awk -F. '{ if (NF>=2) print $(NF-1)"."$NF; else print $0 }')"
    if [ "${fqdn}" = "${DOMAIN}" ]; then HOST=""; else HOST="${fqdn%".${DOMAIN}"}"; fi
}

# Print the host's current public IPv4, or fail.
get_public_ip() {
    local ip
    ip="$(curl -sS --max-time 10 "${IP_SERVICE}" 2>/dev/null || true)"
    [[ "${ip}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
    printf '%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-time 15 \
        "${API}/domains/${DOMAIN}/records" 2>/dev/null \
        | jq -r --arg h "${HOST}" 'first(.records[]? | select(.host==$h and .type=="A") | .answer) // "none"' \
        2>/dev/null || true
}

do_update() {
    load_config || return 1
    require_creds || return 1
    split_fqdn "${FQDN}"

    local ip
    if ! ip="$(get_public_ip)"; then
        echo "DDNS: could not determine public IP (via ${IP_SERVICE})" >&2
        return 1
    fi

    local records
    if ! records="$(curl -sS -u "${NAMECOM_USER}:${NAMECOM_TOKEN}" --max-time 20 \
            "${API}/domains/${DOMAIN}/records")"; then
        echo "DDNS: name.com records query failed for ${DOMAIN}" >&2
        return 1
    fi

    local id cur
    id="$(printf '%s' "${records}" | jq -r --arg h "${HOST}" \
        'first(.records[]? | select(.host==$h and .type=="A") | .id) // empty')"
    cur="$(printf '%s' "${records}" | jq -r --arg h "${HOST}" \
        'first(.records[]? | select(.host==$h and .type=="A") | .answer) // empty')"

    if [ "${cur}" = "${ip}" ]; then
        echo "DDNS: ${FQDN} already ${ip} (no change)"
        return 0
    fi

    local body
    body="$(jq -nc --arg h "${HOST}" --arg a "${ip}" --argjson t "${TTL}" \
        '{host:$h, type:"A", answer:$a, ttl:$t}')"

    if [ -n "${id}" ]; then
        echo "DDNS: updating ${FQDN} ${cur:-<none>} -> ${ip} (record ${id})"
        curl -sS -u "${NAMECOM_USER}:${NAMECOM_TOKEN}" --max-time 20 -X PUT \
            -H 'Content-Type: application/json' -d "${body}" \
            "${API}/domains/${DOMAIN}/records/${id}" > /dev/null
    else
        echo "DDNS: creating ${FQDN} A ${ip}"
        curl -sS -u "${NAMECOM_USER}:${NAMECOM_TOKEN}" --max-time 20 -X POST \
            -H 'Content-Type: application/json' -d "${body}" \
            "${API}/domains/${DOMAIN}/records" > /dev/null
    fi
    echo "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 || return 1
    local ip
    ip="$(get_public_ip 2>/dev/null || true)"
    echo "Public IP:   ${ip:-unknown}"
    if require_creds 2>/dev/null; then
        split_fqdn "${FQDN}"
        local cur
        cur="$(get_a_record)"
        if [ -n "${ip}" ] && [ "${cur}" = "${ip}" ]; then
            echo "A record:    ${cur} ✓ in sync"
        else
            echo "A record:    ${cur:-unknown} (public IP ${ip:-?})"
        fi
    fi
}

# ── Dispatch ──────────────────────────────────────────────────────────────────────
ACTION="${1:-update}"
case "${ACTION}" in
    update) do_update ;;
    show)   do_show ;;
    -h | --help | help) usage ;;
    *) echo "Unknown action: ${ACTION}"; usage; exit 1 ;;
esac