Hoppa till innehållet

Dynadot: Skillnad mellan sidversioner

Från Plutten
Rad 78: Rad 78:


<pre>
<pre>
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail


# ===== CONFIG =====
# ===== PATHS / CONFIG (override via env) =====
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_DDNS="$SCRIPT_DIR/.env"                # Dynadot settings
ENV_DISCORD="$SCRIPT_DIR/../bots/discord/.env"  # Discord bot settings
IP_FILE="$SCRIPT_DIR/dynadot_last_ip.txt"
LOG_FILE="$SCRIPT_DIR/log.txt"
LOG_ROTATED="$SCRIPT_DIR/log.txt.1"


# ===== LOG ROTATION =====
ENV_LOCAL="${ENV_LOCAL:-$SCRIPT_DIR/.env}"
if [ -f "$LOG_FILE" ]; then
ENV_DISCORD="${ENV_DISCORD:-$SCRIPT_DIR/../bots/discord/.env}"
    mv -f "$LOG_FILE" "$LOG_ROTATED"
 
fi
LOG_FILE="${LOG_FILE:-$SCRIPT_DIR/log.txt}"
LOG_KEEP="${LOG_KEEP:-5}" # keep log.txt.1 .. log.txt.$LOG_KEEP
 
STATE_DIR="${STATE_DIR:-$SCRIPT_DIR/state}"
IP_FILE="${IP_FILE:-$STATE_DIR/dynadot_last_ip.txt}"
SSL_NOTIFY_STATE="${SSL_NOTIFY_STATE:-$STATE_DIR/ssl_expiry_last_notified.txt}"
 
DISCORD_API_VERSION="${DISCORD_API_VERSION:-9}"


# ===== LOAD ENV =====
ENABLE_DDNS="${ENABLE_DDNS:-1}"
# Dynadot settings
ENABLE_SSL="${ENABLE_SSL:-1}"
if [ -f "$ENV_DDNS" ]; then
    export $(grep -v '^#' "$ENV_DDNS" | xargs)
else
    echo "ERROR: Dynadot .env not found at $ENV_DDNS"
    exit 1
fi


# Discord bot settings
# ===== ARGS =====
if [ -f "$ENV_DISCORD" ]; then
FORCE=0
    export $(grep -v '^#' "$ENV_DISCORD" | xargs)
DDNS_ONLY=0
else
SSL_ONLY=0
    echo "ERROR: Discord .env not found at $ENV_DISCORD"
    exit 1
fi


# ===== CHECK REQUIRED ENV VARIABLES =====
while [[ $# -gt 0 ]]; do
for var in DOMAIN PASSWORD TTL DISCORD_BOT_TOKEN LOGS_CHANNEL_ID; do
  case "$1" in
     if [ -z "${!var}" ]; then
    --force) FORCE=1; shift ;;
        echo "ERROR: $var not set in .env files"
    --ddns-only) DDNS_ONLY=1; shift ;;
        exit 1
    --ssl-only) SSL_ONLY=1; shift ;;
    fi
     -h|--help)
      cat <<EOF
Usage: $0 [--force] [--ddns-only|--ssl-only]
  --force      Force update/reload even if unchanged
  --ddns-only  Run only DDNS task
  --ssl-only  Run only SSL task
EOF
      exit 0
      ;;
    *)
      echo "Unknown arg: $1" >&2
      exit 2
      ;;
  esac
done
done


CHANNEL_ID="$LOGS_CHANNEL_ID"
# ===== HELPERS =====
mkdir -p "$STATE_DIR"
 
rotate_logs() {
  local i
  for ((i=LOG_KEEP; i>=1; i--)); do
    if [[ -f "${LOG_FILE}.${i}" ]]; then
      if (( i == LOG_KEEP )); then
        rm -f "${LOG_FILE}.${i}"
      else
        mv -f "${LOG_FILE}.${i}" "${LOG_FILE}.$((i+1))"
      fi
    fi
  done
  [[ -f "$LOG_FILE" ]] && mv -f "$LOG_FILE" "${LOG_FILE}.1"
}


# ===== LOG FUNCTION =====
log() {
log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
  local msg="${1:-}"
  printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$msg" >> "$LOG_FILE"
}
 
die() {
  local msg="${1:-}"
  log "ERROR: $msg"
  echo "ERROR: $msg" >&2
  exit 1
}
}


# ===== DISCORD FUNCTION =====
require_vars() {
send_discord() {
  local v
    local result="$1"
  for v in "$@"; do
     local ip_old="$2"
     [[ -n "${!v:-}" ]] || die "Missing required env var: $v"
    local ip_new="$3"
  done
    local timestamp
}
    timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")  # ISO 8601 UTC


     local json_payload=$(cat <<EOF
has_vars() {
{
  local v
   "content": "The domain name #${DOMAIN} dynamically resolves to ${result}.",
  for v in "$@"; do
   "embeds": [
     [[ -n "${!v:-}" ]] || return 1
    {
  done
      "description": "Domains: ${DOMAIN}\nResult: ${result}\nOld IP: ${ip_old}\nNew IP: ${ip_new}\nTime: ${timestamp} UTC",
  return 0
      "color": 15258703,
}
      "author": { "name": "DDNS" },
 
      "footer": { "text": "DDNS ${result}" }
load_env_files_shell_safe() {
     }
  [[ -f "$ENV_LOCAL" ]] || die "Missing local .env: $ENV_LOCAL"
  ]
  [[ -f "$ENV_DISCORD" ]] || die "Missing discord .env: $ENV_DISCORD"
 
  # Only use with trusted, shell-safe KEY=value files.
  set -a
  # shellcheck source=/dev/null
  source "$ENV_LOCAL"
  # shellcheck source=/dev/null
  source "$ENV_DISCORD"
  set +a
}
 
curl_get() {
  curl --fail --show-error --silent --location "$1"
}
 
curl_get_to_file() {
   local url="$1"
  local out="$2"
  curl --fail --show-error --silent --location -o "$out" "$url"
}
 
run_root() {
  if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
    "$@"
   else
    command -v sudo >/dev/null 2>&1 || die "sudo not found (need root for: $*)"
    sudo -n "$@" || die "sudo requires a password (configure non-interactive sudo or run as root): $*"
  fi
}
 
discord_send_embed() {
  local author="${1:-Monitor}"
  local title="${2:-}"
  local desc="${3:-}"
  local color="${4:-16753920}"
 
  require_vars DISCORD_BOT_TOKEN LOGS_CHANNEL_ID
  local channel_id="$LOGS_CHANNEL_ID"
 
  local payload
  payload="$(python3 - "$author" "$title" "$desc" "$color" <<'PY'
import json, sys
author, title, desc, color = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
print(json.dumps({
  "embeds": [{
    "title": title,
    "description": desc,
    "color": color,
    "author": {"name": author},
  }]
}))
PY
)"
 
  curl --fail --show-error --silent \
    -H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \
    -H "Content-Type: application/json" \
    -X POST \
    -d "$payload" \
    "https://discord.com/api/v${DISCORD_API_VERSION}/channels/${channel_id}/messages" \
     > /dev/null || true
}
}
EOF
)


    curl -s -H "Authorization: Bot $DISCORD_BOT_TOKEN" \
sha256_file() {
        -H "Content-Type: application/json" \
  local f="$1"
        -X POST \
  if command -v sha256sum >/dev/null 2>&1; then
        -d "$json_payload" \
    sha256sum "$f" | awk '{print $1}'
        "https://discord.com/api/v9/channels/${CHANNEL_ID}/messages" > /dev/null
  else
    shasum -a 256 "$f" | awk '{print $1}'
  fi
}
}


# ===== START =====
rotate_logs
log "---- Script started ----"
log "---- Script started ----"
load_env_files_shell_safe
require_vars DISCORD_BOT_TOKEN LOGS_CHANNEL_ID
# ===== TASK: DDNS =====
ddns_task() {
  require_vars DOMAIN PASSWORD TTL
  local current_ip last_ip subdomain type update_url response
  current_ip="$(curl_get "https://checkip.amazonaws.com" | tr -d '[:space:]')" || {
    local desc
    printf -v desc "Could not fetch public IP."
    discord_send_embed "DDNS" "DDNS ERROR" "$desc" 15158332
    die "Could not fetch public IP"
  }
  [[ -f "$IP_FILE" ]] || : > "$IP_FILE"
  last_ip="$(cat "$IP_FILE" 2>/dev/null || true)"
  log "DDNS current IP: $current_ip"
  log "DDNS last IP: ${last_ip:-<empty>}"
  if [[ "$current_ip" == "$last_ip" && "$FORCE" -ne 1 ]]; then
    log "DDNS: IP unchanged; no update."
    return 0
  fi
  log "DDNS: proceeding (force=$FORCE)."
  subdomain="${SUBDOMAIN:-}"
  type="${TYPE:-A}"
  update_url="https://www.dynadot.com/set_ddns?containRoot=true&domain=${DOMAIN}&subDomain=${subdomain}&type=${type}&ip=${current_ip}&pwd=${PASSWORD}&ttl=${TTL}"
  response="$(curl_get "$update_url")" || {
    local desc
    printf -v desc "Dynadot request failed.\nDomain: %s\nOld IP: %s\nNew IP: %s\nForced: %s" \
      "$DOMAIN" "${last_ip:-N/A}" "$current_ip" "$([[ "$FORCE" -eq 1 ]] && echo yes || echo no)"
    discord_send_embed "DDNS" "DDNS FAIL" "$desc" 15158332
    die "Dynadot request failed"
  }
  log "DDNS Dynadot response: $response"
  if echo "$response" | grep -qi "success"; then
    echo "$current_ip" > "$IP_FILE"
    log "DDNS: IP updated and saved."
    local desc
    printf -v desc "Domain: %s\nOld IP: %s\nNew IP: %s\nForced: %s" \
      "$DOMAIN" "${last_ip:-N/A}" "$current_ip" "$([[ "$FORCE" -eq 1 ]] && echo yes || echo no)"
    discord_send_embed "DDNS" "DDNS UPDATED" "$desc" 3066993
  else
    log "DDNS: update failed."
    local desc
    printf -v desc "Domain: %s\nOld IP: %s\nNew IP: %s\nForced: %s\nResponse: %s" \
      "$DOMAIN" "${last_ip:-N/A}" "$current_ip" "$([[ "$FORCE" -eq 1 ]] && echo yes || echo no)" "$response"
    discord_send_embed "DDNS" "DDNS FAIL" "$desc" 15158332
  fi
}
# ===== TASK: SSL =====
ssl_task() {
  require_vars DYNADOT_API_KEY DYNADOT_DOMAIN SSL_DIR WEB_SERVER EXPIRY_WARN_DAYS
  local cert_file key_file tmp_file url new_hash old_hash
  cert_file="$SSL_DIR/fullchain.pem"
  key_file="$SSL_DIR/privkey.pem"
  [[ -f "$key_file" ]] || die "Missing private key at $key_file"
  run_root mkdir -p "$SSL_DIR"
  tmp_file="$(mktemp)"
  trap 'rm -f "$tmp_file"' RETURN


# ===== GET CURRENT IP =====
  url="https://www.dynadot.com/letsencrypt/download_cert?key=${DYNADOT_API_KEY}&domain=${DYNADOT_DOMAIN}"
CURRENT_IP=$(curl -s https://checkip.amazonaws.com)
  if ! curl_get_to_file "$url" "$tmp_file"; then
if [ -z "$CURRENT_IP" ]; then
    local desc
    log "ERROR: Could not fetch public IP"
    printf -v desc "Failed to download certificate.\nDomain: %s" "$DYNADOT_DOMAIN"
     send_discord "ERROR" "N/A" "N/A"
    discord_send_embed "SSL Monitor" "SSL ERROR" "$desc" 15158332
    exit 1
    die "Failed to download certificate"
  fi
 
  if ! openssl x509 -noout -in "$tmp_file" >/dev/null 2>&1; then
    local desc
    printf -v desc "Downloaded file is not a valid x509 cert.\nDomain: %s" "$DYNADOT_DOMAIN"
    discord_send_embed "SSL Monitor" "SSL ERROR" "$desc" 15158332
    die "Downloaded file invalid"
  fi
 
  new_hash="$(sha256_file "$tmp_file")"
  old_hash=""
  [[ -f "$cert_file" ]] && old_hash="$(sha256_file "$cert_file")"
 
  if [[ -n "$old_hash" && "$new_hash" == "$old_hash" && "$FORCE" -ne 1 ]]; then
    log "SSL: cert unchanged; no install/reload."
  else
    log "SSL: proceeding (force=$FORCE)."
    run_root install -o root -g root -m 644 "$tmp_file" "$cert_file"
    log "SSL: cert installed."
 
    if systemctl is-active --quiet "$WEB_SERVER"; then
      run_root systemctl reload "$WEB_SERVER"
      log "SSL: reloaded service: $WEB_SERVER"
      local desc
      printf -v desc "Installed certificate.\nDomain: %s\nService: %s reloaded.\nForced: %s" \
        "$DYNADOT_DOMAIN" "$WEB_SERVER" "$([[ "$FORCE" -eq 1 ]] && echo yes || echo no)"
      discord_send_embed "SSL Monitor" "SSL UPDATED" "$desc" 3066993
    else
      log "SSL: WARNING - service not active: $WEB_SERVER"
      local desc
      printf -v desc "%s not running after SSL update.\nDomain: %s" "$WEB_SERVER" "$DYNADOT_DOMAIN"
      discord_send_embed "SSL Monitor" "SSL WARNING" "$desc" 16753920
    fi
  fi
 
  local expiry_date expiry_epoch now_epoch days_left last_notified
  expiry_date="$(openssl x509 -enddate -noout -in "$cert_file" | cut -d= -f2 || true)"
  [[ -n "$expiry_date" ]] || die "Could not read cert expiry from: $cert_file"
 
  expiry_epoch="$(date -d "$expiry_date" +%s)"
  now_epoch="$(date +%s)"
  days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
 
  log "SSL: expiry: $expiry_date (${days_left} days left)"
 
  last_notified="$(cat "$SSL_NOTIFY_STATE" 2>/dev/null || true)"
  if (( days_left <= EXPIRY_WARN_DAYS )); then
    if [[ "$last_notified" != "$expiry_date" ]]; then
      local desc
      printf -v desc "Domain: %s\nExpires in: %s days\nExpiry: %s" "$DYNADOT_DOMAIN" "$days_left" "$expiry_date"
      discord_send_embed "SSL Monitor" "SSL EXPIRY WARNING" "$desc" 16753920
      echo "$expiry_date" > "$SSL_NOTIFY_STATE"
      log "SSL: expiry warning sent (state updated)."
     else
      log "SSL: expiry warning already sent for this expiry date."
    fi
  fi
}
 
# ===== RUN =====
if [[ "$DDNS_ONLY" -eq 1 ]]; then
  ENABLE_SSL=0
fi
if [[ "$SSL_ONLY" -eq 1 ]]; then
  ENABLE_DDNS=0
fi
fi
CURRENT_IP=$(echo "$CURRENT_IP" | tr -d '[:space:]')
log "Current IP: $CURRENT_IP"


# ===== READ LAST IP =====
if [[ "$ENABLE_DDNS" == "1" ]]; then
if [ -f "$IP_FILE" ]; then
  ddns_task
    LAST_IP=$(cat "$IP_FILE")
else
else
    LAST_IP=""
  log "DDNS: disabled"
    touch "$IP_FILE"
    log "Created IP file"
fi
fi
log "Last IP: $LAST_IP"
# ===== COMPARE =====
if [ "$CURRENT_IP" = "$LAST_IP" ]; then
    log "IP unchanged. No update needed."
    exit 0  # No Discord notification if unchanged
fi
# ===== UPDATE DYNDOT =====
SUBDOMAIN=${SUBDOMAIN:-""}  # default to empty if not set
TYPE=${TYPE:-"A"}          # default to A record
UPDATE_URL="https://www.dynadot.com/set_ddns?containRoot=true&domain=${DOMAIN}&subDomain=${SUBDOMAIN}&type=${TYPE}&ip=${CURRENT_IP}&pwd=${PASSWORD}&ttl=${TTL}"
RESPONSE=$(curl -s "$UPDATE_URL")
log "Dynadot response: $RESPONSE"


# ===== SAVE IP & DISCORD =====
if [[ "$ENABLE_SSL" == "1" ]]; then
if echo "$RESPONSE" | grep -qi "success"; then
  # Skip SSL gracefully if env vars aren't present
     echo "$CURRENT_IP" > "$IP_FILE"
  if has_vars DYNADOT_API_KEY DYNADOT_DOMAIN SSL_DIR WEB_SERVER EXPIRY_WARN_DAYS; then
     log "IP updated and saved."
     ssl_task
    send_discord "SUCCESS" "$LAST_IP" "$CURRENT_IP"
  else
     log "SSL: skipped (missing SSL env vars). Tip: add SSL vars or run with --ddns-only."
  fi
else
else
    log "ERROR: Dynadot update failed."
  log "SSL: disabled"
    send_discord "FAIL" "$LAST_IP" "$CURRENT_IP"
fi
fi



Versionen från 21 januari 2026 kl. 08.34

Mall:Short description

Dynadot Dynamic DNS (DDNS) Setup

This page documents how to configure Dynamic DNS for Dynadot and automatically update the A record when the public IP address changes.

Requirements

  • Dynadot account
  • Domain using Dynadot DNS
  • DDNS password generated in Dynadot
  • Ubuntu server with outbound HTTPS access

Dynadot configuration

1. Enable Dynadot DNS

  1. Log in to Dynadot
  2. Go to My Domains
  3. Click the domain
  4. Set DNSDynadot DNS

2. Enable Dynamic DNS

  1. Domain settings → Dynamic DNS
  2. Enable DDNS
  3. Generate a DDNS password
  4. Save it securely

3. Required values

Field Example
Domain example.com
Subdomain @ or www
Record type A (IPv4) or AAAA (IPv6)
DDNS password (generated in Dynadot)

Dynadot update endpoint

Dynadot accepts HTTP GET updates:

https://www.dynadot.com/set_ddns

Parameters:

  • domain
  • subDomain
  • type
  • ip
  • pwd
  • ttl (optional)

Ubuntu auto-update script

Script: dynadot-ddns.sh

nano .env

Add this:

# SUBDOMAIN should be left empty if root domain.
DOMAIN=<domain>
SUBDOMAIN=
TYPE=A
PASSWORD=YOUR_DYNADOT_PASSWORD
TTL=300
chmod 600 .env
#!/usr/bin/env bash
set -euo pipefail

# ===== PATHS / CONFIG (override via env) =====
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

ENV_LOCAL="${ENV_LOCAL:-$SCRIPT_DIR/.env}"
ENV_DISCORD="${ENV_DISCORD:-$SCRIPT_DIR/../bots/discord/.env}"

LOG_FILE="${LOG_FILE:-$SCRIPT_DIR/log.txt}"
LOG_KEEP="${LOG_KEEP:-5}" # keep log.txt.1 .. log.txt.$LOG_KEEP

STATE_DIR="${STATE_DIR:-$SCRIPT_DIR/state}"
IP_FILE="${IP_FILE:-$STATE_DIR/dynadot_last_ip.txt}"
SSL_NOTIFY_STATE="${SSL_NOTIFY_STATE:-$STATE_DIR/ssl_expiry_last_notified.txt}"

DISCORD_API_VERSION="${DISCORD_API_VERSION:-9}"

ENABLE_DDNS="${ENABLE_DDNS:-1}"
ENABLE_SSL="${ENABLE_SSL:-1}"

# ===== ARGS =====
FORCE=0
DDNS_ONLY=0
SSL_ONLY=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    --force) FORCE=1; shift ;;
    --ddns-only) DDNS_ONLY=1; shift ;;
    --ssl-only) SSL_ONLY=1; shift ;;
    -h|--help)
      cat <<EOF
Usage: $0 [--force] [--ddns-only|--ssl-only]
  --force      Force update/reload even if unchanged
  --ddns-only  Run only DDNS task
  --ssl-only   Run only SSL task
EOF
      exit 0
      ;;
    *)
      echo "Unknown arg: $1" >&2
      exit 2
      ;;
  esac
done

# ===== HELPERS =====
mkdir -p "$STATE_DIR"

rotate_logs() {
  local i
  for ((i=LOG_KEEP; i>=1; i--)); do
    if [[ -f "${LOG_FILE}.${i}" ]]; then
      if (( i == LOG_KEEP )); then
        rm -f "${LOG_FILE}.${i}"
      else
        mv -f "${LOG_FILE}.${i}" "${LOG_FILE}.$((i+1))"
      fi
    fi
  done
  [[ -f "$LOG_FILE" ]] && mv -f "$LOG_FILE" "${LOG_FILE}.1"
}

log() {
  local msg="${1:-}"
  printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$msg" >> "$LOG_FILE"
}

die() {
  local msg="${1:-}"
  log "ERROR: $msg"
  echo "ERROR: $msg" >&2
  exit 1
}

require_vars() {
  local v
  for v in "$@"; do
    [[ -n "${!v:-}" ]] || die "Missing required env var: $v"
  done
}

has_vars() {
  local v
  for v in "$@"; do
    [[ -n "${!v:-}" ]] || return 1
  done
  return 0
}

load_env_files_shell_safe() {
  [[ -f "$ENV_LOCAL" ]] || die "Missing local .env: $ENV_LOCAL"
  [[ -f "$ENV_DISCORD" ]] || die "Missing discord .env: $ENV_DISCORD"

  # Only use with trusted, shell-safe KEY=value files.
  set -a
  # shellcheck source=/dev/null
  source "$ENV_LOCAL"
  # shellcheck source=/dev/null
  source "$ENV_DISCORD"
  set +a
}

curl_get() {
  curl --fail --show-error --silent --location "$1"
}

curl_get_to_file() {
  local url="$1"
  local out="$2"
  curl --fail --show-error --silent --location -o "$out" "$url"
}

run_root() {
  if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
    "$@"
  else
    command -v sudo >/dev/null 2>&1 || die "sudo not found (need root for: $*)"
    sudo -n "$@" || die "sudo requires a password (configure non-interactive sudo or run as root): $*"
  fi
}

discord_send_embed() {
  local author="${1:-Monitor}"
  local title="${2:-}"
  local desc="${3:-}"
  local color="${4:-16753920}"

  require_vars DISCORD_BOT_TOKEN LOGS_CHANNEL_ID
  local channel_id="$LOGS_CHANNEL_ID"

  local payload
  payload="$(python3 - "$author" "$title" "$desc" "$color" <<'PY'
import json, sys
author, title, desc, color = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
print(json.dumps({
  "embeds": [{
    "title": title,
    "description": desc,
    "color": color,
    "author": {"name": author},
  }]
}))
PY
)"

  curl --fail --show-error --silent \
    -H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \
    -H "Content-Type: application/json" \
    -X POST \
    -d "$payload" \
    "https://discord.com/api/v${DISCORD_API_VERSION}/channels/${channel_id}/messages" \
    > /dev/null || true
}

sha256_file() {
  local f="$1"
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$f" | awk '{print $1}'
  else
    shasum -a 256 "$f" | awk '{print $1}'
  fi
}

# ===== START =====
rotate_logs
log "---- Script started ----"
load_env_files_shell_safe
require_vars DISCORD_BOT_TOKEN LOGS_CHANNEL_ID

# ===== TASK: DDNS =====
ddns_task() {
  require_vars DOMAIN PASSWORD TTL

  local current_ip last_ip subdomain type update_url response
  current_ip="$(curl_get "https://checkip.amazonaws.com" | tr -d '[:space:]')" || {
    local desc
    printf -v desc "Could not fetch public IP."
    discord_send_embed "DDNS" "DDNS ERROR" "$desc" 15158332
    die "Could not fetch public IP"
  }

  [[ -f "$IP_FILE" ]] || : > "$IP_FILE"
  last_ip="$(cat "$IP_FILE" 2>/dev/null || true)"

  log "DDNS current IP: $current_ip"
  log "DDNS last IP: ${last_ip:-<empty>}"

  if [[ "$current_ip" == "$last_ip" && "$FORCE" -ne 1 ]]; then
    log "DDNS: IP unchanged; no update."
    return 0
  fi
  log "DDNS: proceeding (force=$FORCE)."

  subdomain="${SUBDOMAIN:-}"
  type="${TYPE:-A}"

  update_url="https://www.dynadot.com/set_ddns?containRoot=true&domain=${DOMAIN}&subDomain=${subdomain}&type=${type}&ip=${current_ip}&pwd=${PASSWORD}&ttl=${TTL}"
  response="$(curl_get "$update_url")" || {
    local desc
    printf -v desc "Dynadot request failed.\nDomain: %s\nOld IP: %s\nNew IP: %s\nForced: %s" \
      "$DOMAIN" "${last_ip:-N/A}" "$current_ip" "$([[ "$FORCE" -eq 1 ]] && echo yes || echo no)"
    discord_send_embed "DDNS" "DDNS FAIL" "$desc" 15158332
    die "Dynadot request failed"
  }

  log "DDNS Dynadot response: $response"

  if echo "$response" | grep -qi "success"; then
    echo "$current_ip" > "$IP_FILE"
    log "DDNS: IP updated and saved."
    local desc
    printf -v desc "Domain: %s\nOld IP: %s\nNew IP: %s\nForced: %s" \
      "$DOMAIN" "${last_ip:-N/A}" "$current_ip" "$([[ "$FORCE" -eq 1 ]] && echo yes || echo no)"
    discord_send_embed "DDNS" "DDNS UPDATED" "$desc" 3066993
  else
    log "DDNS: update failed."
    local desc
    printf -v desc "Domain: %s\nOld IP: %s\nNew IP: %s\nForced: %s\nResponse: %s" \
      "$DOMAIN" "${last_ip:-N/A}" "$current_ip" "$([[ "$FORCE" -eq 1 ]] && echo yes || echo no)" "$response"
    discord_send_embed "DDNS" "DDNS FAIL" "$desc" 15158332
  fi
}

# ===== TASK: SSL =====
ssl_task() {
  require_vars DYNADOT_API_KEY DYNADOT_DOMAIN SSL_DIR WEB_SERVER EXPIRY_WARN_DAYS

  local cert_file key_file tmp_file url new_hash old_hash
  cert_file="$SSL_DIR/fullchain.pem"
  key_file="$SSL_DIR/privkey.pem"

  [[ -f "$key_file" ]] || die "Missing private key at $key_file"

  run_root mkdir -p "$SSL_DIR"

  tmp_file="$(mktemp)"
  trap 'rm -f "$tmp_file"' RETURN

  url="https://www.dynadot.com/letsencrypt/download_cert?key=${DYNADOT_API_KEY}&domain=${DYNADOT_DOMAIN}"
  if ! curl_get_to_file "$url" "$tmp_file"; then
    local desc
    printf -v desc "Failed to download certificate.\nDomain: %s" "$DYNADOT_DOMAIN"
    discord_send_embed "SSL Monitor" "SSL ERROR" "$desc" 15158332
    die "Failed to download certificate"
  fi

  if ! openssl x509 -noout -in "$tmp_file" >/dev/null 2>&1; then
    local desc
    printf -v desc "Downloaded file is not a valid x509 cert.\nDomain: %s" "$DYNADOT_DOMAIN"
    discord_send_embed "SSL Monitor" "SSL ERROR" "$desc" 15158332
    die "Downloaded file invalid"
  fi

  new_hash="$(sha256_file "$tmp_file")"
  old_hash=""
  [[ -f "$cert_file" ]] && old_hash="$(sha256_file "$cert_file")"

  if [[ -n "$old_hash" && "$new_hash" == "$old_hash" && "$FORCE" -ne 1 ]]; then
    log "SSL: cert unchanged; no install/reload."
  else
    log "SSL: proceeding (force=$FORCE)."
    run_root install -o root -g root -m 644 "$tmp_file" "$cert_file"
    log "SSL: cert installed."

    if systemctl is-active --quiet "$WEB_SERVER"; then
      run_root systemctl reload "$WEB_SERVER"
      log "SSL: reloaded service: $WEB_SERVER"
      local desc
      printf -v desc "Installed certificate.\nDomain: %s\nService: %s reloaded.\nForced: %s" \
        "$DYNADOT_DOMAIN" "$WEB_SERVER" "$([[ "$FORCE" -eq 1 ]] && echo yes || echo no)"
      discord_send_embed "SSL Monitor" "SSL UPDATED" "$desc" 3066993
    else
      log "SSL: WARNING - service not active: $WEB_SERVER"
      local desc
      printf -v desc "%s not running after SSL update.\nDomain: %s" "$WEB_SERVER" "$DYNADOT_DOMAIN"
      discord_send_embed "SSL Monitor" "SSL WARNING" "$desc" 16753920
    fi
  fi

  local expiry_date expiry_epoch now_epoch days_left last_notified
  expiry_date="$(openssl x509 -enddate -noout -in "$cert_file" | cut -d= -f2 || true)"
  [[ -n "$expiry_date" ]] || die "Could not read cert expiry from: $cert_file"

  expiry_epoch="$(date -d "$expiry_date" +%s)"
  now_epoch="$(date +%s)"
  days_left=$(( (expiry_epoch - now_epoch) / 86400 ))

  log "SSL: expiry: $expiry_date (${days_left} days left)"

  last_notified="$(cat "$SSL_NOTIFY_STATE" 2>/dev/null || true)"
  if (( days_left <= EXPIRY_WARN_DAYS )); then
    if [[ "$last_notified" != "$expiry_date" ]]; then
      local desc
      printf -v desc "Domain: %s\nExpires in: %s days\nExpiry: %s" "$DYNADOT_DOMAIN" "$days_left" "$expiry_date"
      discord_send_embed "SSL Monitor" "SSL EXPIRY WARNING" "$desc" 16753920
      echo "$expiry_date" > "$SSL_NOTIFY_STATE"
      log "SSL: expiry warning sent (state updated)."
    else
      log "SSL: expiry warning already sent for this expiry date."
    fi
  fi
}

# ===== RUN =====
if [[ "$DDNS_ONLY" -eq 1 ]]; then
  ENABLE_SSL=0
fi
if [[ "$SSL_ONLY" -eq 1 ]]; then
  ENABLE_DDNS=0
fi

if [[ "$ENABLE_DDNS" == "1" ]]; then
  ddns_task
else
  log "DDNS: disabled"
fi

if [[ "$ENABLE_SSL" == "1" ]]; then
  # Skip SSL gracefully if env vars aren't present
  if has_vars DYNADOT_API_KEY DYNADOT_DOMAIN SSL_DIR WEB_SERVER EXPIRY_WARN_DAYS; then
    ssl_task
  else
    log "SSL: skipped (missing SSL env vars). Tip: add SSL vars or run with --ddns-only."
  fi
else
  log "SSL: disabled"
fi

log "---- Script finished ----"
echo

Installation

1. Save script

sudo nano /usr/local/bin/dynadot-ddns.sh

Paste script and edit variables.

2. Make executable

sudo chmod +x /usr/local/bin/dynadot-ddns.sh

3. Test manually

/usr/local/bin/dynadot-ddns.sh

4. Add cron job (every 5 minutes)

crontab -e

Add:

*/5 * * * * /usr/local/bin/dynadot-ddns.sh

Security notes

  • Store DDNS password securely
  • Limit file permissions
  • Do not commit the script to public repositories

References

  • Dynadot Help – Dynamic DNS
  • Dynadot API documentation