#!/bin/bash
set -euo pipefail

DOMAIN="${DOMAIN:-}"
ALIYUN_DOMAIN="${ALIYUN_DOMAIN:-$DOMAIN}"
CLOUDFLARE_DOMAIN="${CLOUDFLARE_DOMAIN:-$DOMAIN}"
TYPE="${TYPE:-A}"
ALIYUN_TTL="${ALIYUN_TTL:-${TTL:-1}}"
CLOUDFLARE_TTL="${CLOUDFLARE_TTL:-${TTL:-60}}"
DNS_PROVIDER="${DNS_PROVIDER:-aliyun}" # aliyun, cloudflare, or both

IP_INTERVAL="30"
PING_INTERVAL="60"
PING_TIMEOUT="60"
PING_TARGET="${PING_TARGET:-8.138.252.89}"
PING_FALLBACK_TARGET="${PING_FALLBACK_TARGET:-47.112.196.110}"
PING_FALLBACK_TIMEOUT="20"

ALIYUN_ACCESS_KEY_ID="${ALIYUN_ACCESS_KEY_ID:-}"
ALIYUN_ACCESS_KEY_SECRET="${ALIYUN_ACCESS_KEY_SECRET:-}"

# Token needs Zone / DNS / Edit; add Zone / Zone / Read when Zone ID is omitted.
CLOUDFLARE_API_TOKEN="${CLOUDFLARE_API_TOKEN:-}"
CLOUDFLARE_ZONE_ID="${CLOUDFLARE_ZONE_ID:-}"
CLOUDFLARE_PROXIED="${CLOUDFLARE_PROXIED:-false}"
DDNS_CREDENTIALS_FILE="${DDNS_CREDENTIALS_FILE:-/etc/cadu-ddns/credentials}"
DDNS_CREDENTIALS_EXPLICIT=0

RECORD_ID="${RECORD_ID:-}"
RR="${RR:-sup01}"
ALIYUN_RR="${ALIYUN_RR:-$RR}"
CLOUDFLARE_RR="${CLOUDFLARE_RR:-$RR}"

usage() {
  cat <<'USAGE'
Usage:
  cadu-ddns [options]
  cadu-ddns [aliyun-record-id] [rr]

Options:
  --provider aliyun|cloudflare|both
  --domain DOMAIN               Set the same domain for both providers
  --aliyun-domain DOMAIN
  --cloudflare-domain DOMAIN
  --rr RR                       Set the same RR for both providers
  --aliyun-rr RR                Use @ for the zone apex
  --cloudflare-rr RR            Use @ for the zone apex
  --host RR.DOMAIN              Set the same full record name for both providers
  --aliyun-host RR.DOMAIN
  --cloudflare-host RR.DOMAIN
  --type A
  --ttl SECONDS                 Set the same TTL for both providers
  --aliyun-ttl SECONDS          Defaults to 1
  --cloudflare-ttl SECONDS      Defaults to 60
  --record-id ID                Optional Aliyun RecordId
  --cf-zone-id ID               Optional Cloudflare Zone ID
  --cf-proxied true|false
  --credentials-file PATH       Root-owned file with mode 600 or stricter
  -h, --help

Credentials are read from --credentials-file or environment variables. Secret
values are deliberately not accepted as command arguments.
USAGE
}

need_value() {
  if [ "$#" -lt 2 ] || [ -z "$2" ]; then
    echo "$1 requires a value" >&2
    exit 2
  fi
}

split_host() {
  local provider="$1" fqdn="$2" rr domain
  case "$fqdn" in
    *.*) ;;
    *) echo "$provider host must be RR.DOMAIN" >&2; exit 2 ;;
  esac
  rr="${fqdn%%.*}"
  domain="${fqdn#*.}"
  if [ -z "$rr" ] || [ -z "$domain" ]; then
    echo "$provider host must be RR.DOMAIN" >&2
    exit 2
  fi
  case "$provider" in
    aliyun) ALIYUN_RR="$rr"; ALIYUN_DOMAIN="$domain" ;;
    cloudflare) CLOUDFLARE_RR="$rr"; CLOUDFLARE_DOMAIN="$domain" ;;
  esac
}

load_credentials_file() {
  local path="$1" owner mode line key value current
  [ -f "$path" ] || { echo "Credentials file not found: $path" >&2; exit 2; }
  owner="$(stat -c '%u' "$path")"
  mode="$(stat -c '%a' "$path")"
  [ "$owner" = "0" ] || { echo "Credentials file must be owned by root: $path" >&2; exit 2; }
  (( (8#$mode & 077) == 0 )) || {
    echo "Credentials file must not be accessible by group or others: $path" >&2
    exit 2
  }

  while IFS= read -r line || [ -n "$line" ]; do
    line="${line%$'\r'}"
    if [ -z "$line" ] || [[ "$line" == \#* ]]; then
      continue
    fi
    [[ "$line" == *=* ]] || { echo "Invalid credentials line in $path" >&2; exit 2; }
    key="${line%%=*}"
    value="${line#*=}"
    case "$key" in
      ALIYUN_ACCESS_KEY_ID|ALIYUN_ACCESS_KEY_SECRET|CLOUDFLARE_API_TOKEN|CLOUDFLARE_ZONE_ID)
        current="${!key}"
        [ -n "$current" ] || printf -v "$key" '%s' "$value"
        ;;
      *) echo "Unsupported credentials key: $key" >&2; exit 2 ;;
    esac
  done <"$path"
}

if [ "$#" -gt 0 ] && [[ "$1" != --* && "$1" != "-h" ]]; then
  [ "$#" -le 2 ] || {
    echo "Legacy positional mode only accepts RecordId and RR; move credentials to --credentials-file" >&2
    exit 2
  }
  RECORD_ID="${1:-$RECORD_ID}"
  RR="${2:-$RR}"
  ALIYUN_RR="$RR"
  CLOUDFLARE_RR="$RR"
  shift "$#"
fi

while [ "$#" -gt 0 ]; do
  case "$1" in
    --provider) need_value "$@"; DNS_PROVIDER="$2"; shift 2 ;;
    --domain)
      need_value "$@"
      DOMAIN="$2"
      ALIYUN_DOMAIN="$2"
      CLOUDFLARE_DOMAIN="$2"
      shift 2
      ;;
    --aliyun-domain) need_value "$@"; ALIYUN_DOMAIN="$2"; shift 2 ;;
    --cloudflare-domain) need_value "$@"; CLOUDFLARE_DOMAIN="$2"; shift 2 ;;
    --rr)
      need_value "$@"
      RR="$2"
      ALIYUN_RR="$2"
      CLOUDFLARE_RR="$2"
      shift 2
      ;;
    --aliyun-rr) need_value "$@"; ALIYUN_RR="$2"; shift 2 ;;
    --cloudflare-rr) need_value "$@"; CLOUDFLARE_RR="$2"; shift 2 ;;
    --host)
      need_value "$@"
      split_host aliyun "$2"
      split_host cloudflare "$2"
      shift 2
      ;;
    --aliyun-host) need_value "$@"; split_host aliyun "$2"; shift 2 ;;
    --cloudflare-host) need_value "$@"; split_host cloudflare "$2"; shift 2 ;;
    --type) need_value "$@"; TYPE="$2"; shift 2 ;;
    --ttl)
      need_value "$@"
      ALIYUN_TTL="$2"
      CLOUDFLARE_TTL="$2"
      shift 2
      ;;
    --aliyun-ttl) need_value "$@"; ALIYUN_TTL="$2"; shift 2 ;;
    --cloudflare-ttl) need_value "$@"; CLOUDFLARE_TTL="$2"; shift 2 ;;
    --record-id) need_value "$@"; RECORD_ID="$2"; shift 2 ;;
    --cf-zone-id) need_value "$@"; CLOUDFLARE_ZONE_ID="$2"; shift 2 ;;
    --cf-proxied) need_value "$@"; CLOUDFLARE_PROXIED="$2"; shift 2 ;;
    --credentials-file)
      need_value "$@"
      DDNS_CREDENTIALS_FILE="$2"
      DDNS_CREDENTIALS_EXPLICIT=1
      shift 2
      ;;
    -h|--help) usage; exit 0 ;;
    *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
  esac
done

case "$DNS_PROVIDER" in
  aliyun|cloudflare|both) ;;
  *) echo "DNS_PROVIDER must be aliyun, cloudflare, or both" >&2; exit 2 ;;
esac

if { [ "$DNS_PROVIDER" = "aliyun" ] || [ "$DNS_PROVIDER" = "both" ]; } &&
   [ -z "$ALIYUN_DOMAIN" ]; then
  echo "ALIYUN_DOMAIN or --aliyun-domain is required" >&2
  exit 2
fi

if { [ "$DNS_PROVIDER" = "cloudflare" ] || [ "$DNS_PROVIDER" = "both" ]; } &&
   [ -z "$CLOUDFLARE_DOMAIN" ]; then
  echo "CLOUDFLARE_DOMAIN or --cloudflare-domain is required" >&2
  exit 2
fi

if { [ "$DNS_PROVIDER" = "aliyun" ] || [ "$DNS_PROVIDER" = "both" ]; } &&
   [ -z "$ALIYUN_RR" ]; then
  echo "ALIYUN_RR or --aliyun-rr is required" >&2
  exit 2
fi

if { [ "$DNS_PROVIDER" = "cloudflare" ] || [ "$DNS_PROVIDER" = "both" ]; } &&
   [ -z "$CLOUDFLARE_RR" ]; then
  echo "CLOUDFLARE_RR or --cloudflare-rr is required" >&2
  exit 2
fi

if { [ -n "$ALIYUN_DOMAIN" ] && [[ ! "$ALIYUN_DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]]; } ||
   { [ -n "$CLOUDFLARE_DOMAIN" ] && [[ ! "$CLOUDFLARE_DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]]; } ||
   { [ -n "$ALIYUN_RR" ] && [[ ! "$ALIYUN_RR" =~ ^(@|[A-Za-z0-9_-]+)$ ]]; } ||
   { [ -n "$CLOUDFLARE_RR" ] && [[ ! "$CLOUDFLARE_RR" =~ ^(@|[A-Za-z0-9_-]+)$ ]]; }; then
  echo "Domain or RR contains unsupported characters" >&2
  exit 2
fi

case "$TYPE" in
  A) ;;
  *) echo "TYPE must be A (public IP discovery is IPv4-only)" >&2; exit 2 ;;
esac

case "$CLOUDFLARE_PROXIED" in
  true|false) ;;
  *) echo "CLOUDFLARE_PROXIED must be true or false" >&2; exit 2 ;;
esac

if ! [[ "$ALIYUN_TTL" =~ ^[0-9]+$ ]] ||
   [ "$ALIYUN_TTL" -lt 1 ] || [ "$ALIYUN_TTL" -gt 86400 ]; then
  echo "ALIYUN_TTL must be an integer from 1 to 86400 seconds" >&2
  exit 2
fi

if ! [[ "$CLOUDFLARE_TTL" =~ ^[0-9]+$ ]] ||
   { [ "$CLOUDFLARE_TTL" -ne 1 ] &&
     { [ "$CLOUDFLARE_TTL" -lt 60 ] || [ "$CLOUDFLARE_TTL" -gt 86400 ]; }; }; then
  echo "CLOUDFLARE_TTL must be 1 (automatic) or an integer from 60 to 86400 seconds" >&2
  exit 2
fi

if [ "$(id -u)" -ne 0 ]; then
  echo "This installer must run as root" >&2
  exit 1
fi

if [ -f "$DDNS_CREDENTIALS_FILE" ]; then
  load_credentials_file "$DDNS_CREDENTIALS_FILE"
elif [ "$DDNS_CREDENTIALS_EXPLICIT" -eq 1 ]; then
  echo "Credentials file not found: $DDNS_CREDENTIALS_FILE" >&2
  exit 2
fi

if { [ "$DNS_PROVIDER" = "aliyun" ] || [ "$DNS_PROVIDER" = "both" ]; } &&
   { [ -z "$ALIYUN_ACCESS_KEY_ID" ] || [ -z "$ALIYUN_ACCESS_KEY_SECRET" ]; }; then
  echo "ALIYUN_ACCESS_KEY_ID and ALIYUN_ACCESS_KEY_SECRET are required" >&2
  exit 2
fi

if { [ "$DNS_PROVIDER" = "cloudflare" ] || [ "$DNS_PROVIDER" = "both" ]; } &&
   [ -z "$CLOUDFLARE_API_TOKEN" ]; then
  echo "CLOUDFLARE_API_TOKEN is required" >&2
  exit 2
fi

if [ "${CADU_SKIP_APT_UPDATE:-0}" != "1" ]; then
  DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
    apt-get -o DPkg::Lock::Timeout=120 update -y
fi
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
  apt-get -o DPkg::Lock::Timeout=120 install -y --no-install-recommends \
    python3 curl ca-certificates iputils-ping

mkdir -p /etc/cadu-ddns /usr/local/libexec
chmod 700 /etc/cadu-ddns

cat >/etc/cadu-ddns/env <<ENV
ALIYUN_ACCESS_KEY_ID=${ALIYUN_ACCESS_KEY_ID}
ALIYUN_ACCESS_KEY_SECRET=${ALIYUN_ACCESS_KEY_SECRET}
CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
CLOUDFLARE_ZONE_ID=${CLOUDFLARE_ZONE_ID}
CLOUDFLARE_PROXIED=${CLOUDFLARE_PROXIED}
DNS_PROVIDER=${DNS_PROVIDER}
ALIYUN_DOMAIN=${ALIYUN_DOMAIN}
CLOUDFLARE_DOMAIN=${CLOUDFLARE_DOMAIN}
ALIYUN_RR=${ALIYUN_RR}
CLOUDFLARE_RR=${CLOUDFLARE_RR}
TYPE=${TYPE}
ALIYUN_TTL=${ALIYUN_TTL}
CLOUDFLARE_TTL=${CLOUDFLARE_TTL}
IP_INTERVAL=${IP_INTERVAL}
PING_INTERVAL=${PING_INTERVAL}
PING_TIMEOUT=${PING_TIMEOUT}
PING_TARGET=${PING_TARGET}
PING_FALLBACK_TARGET=${PING_FALLBACK_TARGET}
PING_FALLBACK_TIMEOUT=${PING_FALLBACK_TIMEOUT}
RECORD_ID=${RECORD_ID}
ENV

chmod 600 /etc/cadu-ddns/env

if [ -n "$RECORD_ID" ]; then
  echo "$RECORD_ID" >/etc/cadu-ddns/aliyun_record_id
  chmod 600 /etc/cadu-ddns/aliyun_record_id
fi

cat >/usr/local/libexec/cadu-ddns-agent.py <<'PY'
#!/usr/bin/env python3
import base64, hashlib, hmac, json, os, random, socket, string, subprocess, time
import urllib.error, urllib.parse, urllib.request
from datetime import datetime, timezone

ENV_FILE = "/etc/cadu-ddns/env"
STATE_FILE = "/etc/cadu-ddns/aliyun_record_id"
CF_STATE_FILE = "/etc/cadu-ddns/cloudflare_record_id"

def load_env(path):
    data = {}
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line and "=" in line and not line.startswith("#"):
                k, v = line.split("=", 1)
                data[k] = v
    return data

CFG = load_env(ENV_FILE)

ACCESS_KEY_ID = CFG.get("ALIYUN_ACCESS_KEY_ID", "").strip()
ACCESS_KEY_SECRET = CFG.get("ALIYUN_ACCESS_KEY_SECRET", "").strip()
CLOUDFLARE_API_TOKEN = CFG.get("CLOUDFLARE_API_TOKEN", "").strip()
CLOUDFLARE_ZONE_ID = CFG.get("CLOUDFLARE_ZONE_ID", "").strip()
CLOUDFLARE_PROXIED = CFG.get("CLOUDFLARE_PROXIED", "false").lower() == "true"
DNS_PROVIDER = CFG.get("DNS_PROVIDER", "aliyun").strip().lower()
ALIYUN_DOMAIN = CFG.get("ALIYUN_DOMAIN", CFG.get("DOMAIN", ""))
CLOUDFLARE_DOMAIN = CFG.get("CLOUDFLARE_DOMAIN", CFG.get("DOMAIN", ""))
ALIYUN_RR = CFG.get("ALIYUN_RR", CFG.get("RR", "sup01"))
CLOUDFLARE_RR = CFG.get("CLOUDFLARE_RR", CFG.get("RR", "sup01"))
TYPE = CFG.get("TYPE", "A")
ALIYUN_TTL = CFG.get("ALIYUN_TTL", CFG.get("TTL", "1"))
CLOUDFLARE_TTL = CFG.get("CLOUDFLARE_TTL", CFG.get("TTL", "60"))

IP_INTERVAL = int(CFG.get("IP_INTERVAL", "30"))
PING_INTERVAL = int(CFG.get("PING_INTERVAL", "10"))
PING_TIMEOUT = int(CFG.get("PING_TIMEOUT", "5"))
PING_TARGET = CFG.get("PING_TARGET", "8.138.252.89")
PING_FALLBACK_TARGET = CFG.get("PING_FALLBACK_TARGET", "47.112.196.110")
PING_FALLBACK_TIMEOUT = int(CFG.get("PING_FALLBACK_TIMEOUT", "20"))

RECORD_ID = CFG.get("RECORD_ID", "").strip()

ALIYUN_API = "https://alidns.aliyuncs.com/"
CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"

if DNS_PROVIDER not in {"aliyun", "cloudflare", "both"}:
    raise RuntimeError("DNS_PROVIDER must be aliyun, cloudflare, or both")
if DNS_PROVIDER in {"aliyun", "both"} and (not ACCESS_KEY_ID or not ACCESS_KEY_SECRET):
    raise RuntimeError("Aliyun credentials are required")
if DNS_PROVIDER in {"cloudflare", "both"} and not CLOUDFLARE_API_TOKEN:
    raise RuntimeError("CLOUDFLARE_API_TOKEN is required")

def log(msg):
    print(datetime.now().strftime("%F %T") + " " + msg, flush=True)

def percent_encode(s):
    return urllib.parse.quote(str(s), safe="~")

def nonce():
    return "".join(random.choice(string.ascii_letters + string.digits) for _ in range(32))

def aliyun(action, params=None):
    params = params or {}
    common = {
        "Format": "JSON",
        "Version": "2015-01-09",
        "AccessKeyId": ACCESS_KEY_ID,
        "SignatureMethod": "HMAC-SHA1",
        "Timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "SignatureVersion": "1.0",
        "SignatureNonce": nonce(),
        "Action": action,
    }
    all_params = {**common, **params}
    canonical = "&".join(percent_encode(k) + "=" + percent_encode(v) for k, v in sorted(all_params.items()))
    string_to_sign = "GET&%2F&" + percent_encode(canonical)
    sig = base64.b64encode(hmac.new((ACCESS_KEY_SECRET + "&").encode(), string_to_sign.encode(), hashlib.sha1).digest()).decode()
    all_params["Signature"] = sig
    url = ALIYUN_API + "?" + urllib.parse.urlencode(all_params)

    try:
        with urllib.request.urlopen(url, timeout=20) as r:
            return json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        body = e.read().decode(errors="replace")
        raise RuntimeError(f"aliyun api http {e.code}: {body}")

def http_json(url, method="GET", body=None, headers=None, timeout=60):
    data = None
    if body is not None:
        data = json.dumps(body).encode()
    req_headers = {
        "Accept": "application/json",
        "User-Agent": "Mozilla/5.0",
    }
    if body is not None:
        req_headers["Content-Type"] = "application/json"
    if headers:
        req_headers.update(headers)

    req = urllib.request.Request(url, data=data, method=method, headers=req_headers)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            text = r.read().decode(errors="replace")
            return json.loads(text) if text else {}
    except urllib.error.HTTPError as e:
        text = e.read().decode(errors="replace")
        raise RuntimeError(f"http {e.code}: {text}")

def cloudflare(path, method="GET", body=None, query=None):
    url = CLOUDFLARE_API + path
    if query:
        url += "?" + urllib.parse.urlencode(query)
    response = http_json(
        url,
        method=method,
        body=body,
        headers={"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"},
        timeout=30,
    )
    if not response.get("success"):
        raise RuntimeError(f"cloudflare api failed: {response.get('errors', response)}")
    return response.get("result")

def cloudflare_zone_id():
    if CLOUDFLARE_ZONE_ID:
        return CLOUDFLARE_ZONE_ID
    zones = cloudflare("/zones", query={"name": CLOUDFLARE_DOMAIN, "status": "active"})
    if len(zones) != 1:
        raise RuntimeError(f"expected one active Cloudflare zone for {CLOUDFLARE_DOMAIN}, got {len(zones)}")
    return str(zones[0]["id"])

def cloudflare_record_name():
    return CLOUDFLARE_DOMAIN if CLOUDFLARE_RR == "@" else f"{CLOUDFLARE_RR}.{CLOUDFLARE_DOMAIN}"

def read_cf_record_id():
    if os.path.exists(CF_STATE_FILE):
        return open(CF_STATE_FILE, "r", encoding="utf-8").read().strip()
    return ""

def save_cf_record_id(record_id):
    with open(CF_STATE_FILE, "w", encoding="utf-8") as f:
        f.write(str(record_id) + "\n")
    os.chmod(CF_STATE_FILE, 0o600)

def clear_cf_record_id():
    try:
        os.unlink(CF_STATE_FILE)
    except FileNotFoundError:
        pass

def list_cloudflare_records(zone_id):
    records = cloudflare(
        f"/zones/{zone_id}/dns_records",
        query={"name": cloudflare_record_name(), "type": TYPE, "per_page": "100"},
    )
    return [
        record for record in records
        if record.get("name") == cloudflare_record_name() and record.get("type") == TYPE
    ]

def cloudflare_record_body(ip):
    return {
        "type": TYPE,
        "name": cloudflare_record_name(),
        "content": ip,
        "ttl": 1 if CLOUDFLARE_PROXIED else int(CLOUDFLARE_TTL),
        "proxied": CLOUDFLARE_PROXIED,
    }

def ensure_cloudflare_record(ip):
    zone_id = cloudflare_zone_id()
    record_id = read_cf_record_id()
    record = None

    if record_id:
        try:
            record = cloudflare(f"/zones/{zone_id}/dns_records/{record_id}")
        except RuntimeError as e:
            if "81044" not in str(e):
                raise
            clear_cf_record_id()
            record_id = ""

    if not record_id:
        records = list_cloudflare_records(zone_id)
        if len(records) > 1:
            raise RuntimeError(f"multiple Cloudflare records found for {cloudflare_record_name()} {TYPE}")
        if records:
            record = records[0]
            record_id = str(record["id"])
            save_cf_record_id(record_id)

    desired = cloudflare_record_body(ip)
    if not record_id:
        created = cloudflare(f"/zones/{zone_id}/dns_records", method="POST", body=desired)
        record_id = str(created["id"])
        save_cf_record_id(record_id)
        log(f"cloudflare created {cloudflare_record_name()} {TYPE} {ip}, RecordId={record_id}")
    elif any(record.get(key) != value for key, value in desired.items()):
        cloudflare(f"/zones/{zone_id}/dns_records/{record_id}", method="PUT", body=desired)
        log(f"cloudflare updated {cloudflare_record_name()} {TYPE} {ip}, RecordId={record_id}")
    else:
        log(f"cloudflare ok {cloudflare_record_name()} {TYPE} {ip}, RecordId={record_id}")
    return record_id

def disable_cloudflare_record():
    record_id = read_cf_record_id()
    if not record_id:
        zone_id = cloudflare_zone_id()
        records = list_cloudflare_records(zone_id)
        if len(records) > 1:
            raise RuntimeError(f"multiple Cloudflare records found for {cloudflare_record_name()} {TYPE}")
        if not records:
            return
        record_id = str(records[0]["id"])
    else:
        zone_id = cloudflare_zone_id()
    try:
        cloudflare(f"/zones/{zone_id}/dns_records/{record_id}", method="DELETE")
        log(f"cloudflare disabled {cloudflare_record_name()} by deleting RecordId={record_id}")
    except RuntimeError as e:
        if "81044" not in str(e):
            raise
    clear_cf_record_id()

def get_public_ip():
    for url in ["https://api.ipify.org", "https://checkip.amazonaws.com", "https://ipv4.icanhazip.com"]:
        try:
            with urllib.request.urlopen(url, timeout=15) as r:
                ip = r.read().decode().strip()
            socket.inet_aton(ip)
            return ip
        except Exception:
            pass
    raise RuntimeError("failed to get public ip")

def read_record_id():
    if RECORD_ID:
        return RECORD_ID
    if os.path.exists(STATE_FILE):
        return open(STATE_FILE, "r", encoding="utf-8").read().strip()
    return ""

def save_record_id(record_id):
    with open(STATE_FILE, "w", encoding="utf-8") as f:
        f.write(str(record_id) + "\n")
    os.chmod(STATE_FILE, 0o600)

def describe_record(record_id):
    return aliyun("DescribeDomainRecordInfo", {"RecordId": record_id})

def add_record(ip):
    res = aliyun("AddDomainRecord", {
        "DomainName": ALIYUN_DOMAIN,
        "RR": ALIYUN_RR,
        "Type": TYPE,
        "Value": ip,
        "TTL": ALIYUN_TTL,
    })
    return str(res["RecordId"])

def update_record(record_id, ip):
    return aliyun("UpdateDomainRecord", {
        "RecordId": record_id,
        "RR": ALIYUN_RR,
        "Type": TYPE,
        "Value": ip,
        "TTL": ALIYUN_TTL,
    })

def set_record_status(record_id, status):
    return aliyun("SetDomainRecordStatus", {
        "RecordId": record_id,
        "Status": status,
    })

def list_records():
    res = aliyun("DescribeDomainRecords", {
        "DomainName": ALIYUN_DOMAIN,
        "RRKeyWord": ALIYUN_RR,
        "TypeKeyWord": TYPE,
        "PageSize": "100",
    })
    return [
        r for r in res.get("DomainRecords", {}).get("Record", [])
        if r.get("RR") == ALIYUN_RR and r.get("Type") == TYPE
    ]

def ensure_ip_record(ip=None):
    ip = ip or get_public_ip()
    record_id = read_record_id()

    if not record_id:
        records = list_records()
        if len(records) > 1:
            raise RuntimeError(f"multiple Aliyun records found for {ALIYUN_RR}.{ALIYUN_DOMAIN} {TYPE}; set RECORD_ID explicitly")
        if records:
            record_id = str(records[0]["RecordId"])
            save_record_id(record_id)
            log(f"bound existing Aliyun record {ALIYUN_RR}.{ALIYUN_DOMAIN} {TYPE}, RecordId={record_id}")

        if not record_id:
            record_id = add_record(ip)
            save_record_id(record_id)
            log(f"created record {ALIYUN_RR}.{ALIYUN_DOMAIN} {TYPE} {ip}, RecordId={record_id}")
            return record_id

    info = describe_record(record_id)
    old_ip = info.get("Value", "")
    old_rr = info.get("RR", "")
    status = info.get("Status", "")

    if old_ip != ip or old_rr != ALIYUN_RR:
        update_record(record_id, ip)
        log(f"updated RecordId={record_id}: {old_rr}.{ALIYUN_DOMAIN}/{old_ip} -> {ALIYUN_RR}.{ALIYUN_DOMAIN}/{ip}, status_before={status}")
    else:
        log(f"ok RecordId={record_id}: {ALIYUN_RR}.{ALIYUN_DOMAIN} aliyun_ip={old_ip}, public_ip={ip}, status={status}")

    return record_id

def ping_host(target, timeout):
    r = subprocess.run(
        ["ping", "-c", "1", "-W", str(timeout), target],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    return r.returncode == 0

def ping_ok():
    if ping_host(PING_TARGET, PING_TIMEOUT):
        return True, PING_TARGET
    if PING_FALLBACK_TARGET and ping_host(PING_FALLBACK_TARGET, PING_FALLBACK_TIMEOUT):
        return True, f"{PING_FALLBACK_TARGET}(fallback)"
    return False, f"{PING_TARGET}+{PING_FALLBACK_TARGET}"

def main():
    last_ip_check = 0
    last_desired_status = ""

    while True:
        try:
            now = time.time()
            aliyun_record_id = read_record_id()
            ok, ping_label = ping_ok()
            desired = "ENABLE" if ok else "DISABLE"
            ip_check_due = now - last_ip_check >= IP_INTERVAL
            cloudflare_checked = False

            if ip_check_due:
                public_ip = get_public_ip()
                if DNS_PROVIDER in {"aliyun", "both"}:
                    aliyun_record_id = ensure_ip_record(public_ip)
                if DNS_PROVIDER in {"cloudflare", "both"} and ok:
                    ensure_cloudflare_record(public_ip)
                    cloudflare_checked = True
                last_ip_check = now

            if DNS_PROVIDER in {"aliyun", "both"} and aliyun_record_id:
                if desired != last_desired_status:
                    set_record_status(aliyun_record_id, desired)
                    log(f"ping {ping_label} {'ok' if ok else 'failed'}, set {ALIYUN_RR}.{ALIYUN_DOMAIN} Aliyun RecordId={aliyun_record_id} {desired}")
                else:
                    log(f"ping {ping_label} {'ok' if ok else 'failed'}, {ALIYUN_RR}.{ALIYUN_DOMAIN} Aliyun status unchanged {desired}")

            if DNS_PROVIDER in {"cloudflare", "both"} and desired != last_desired_status:
                if ok:
                    if not cloudflare_checked:
                        ensure_cloudflare_record(get_public_ip())
                else:
                    # Never delete a DDNS record on a transient health-check failure.
                    # Recreating it changes RecordId and breaks external failover tools
                    # that track the existing Cloudflare record by ID.
                    log(
                        f"ping {ping_label} failed, retaining "
                        f"{cloudflare_record_name()} Cloudflare record"
                    )

            if desired != last_desired_status:
                last_desired_status = desired

        except Exception as e:
            log(f"ERROR {e}")

        time.sleep(PING_INTERVAL)

if __name__ == "__main__":
    main()
PY

chmod 700 /usr/local/libexec/cadu-ddns-agent.py

cat >/etc/systemd/system/cadu-ddns.service <<'SERVICE'
[Unit]
Description=Aliyun/Cloudflare DDNS
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/libexec/cadu-ddns-agent.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
SERVICE

systemctl daemon-reload
systemctl enable cadu-ddns.service
systemctl restart cadu-ddns.service

echo "DDNS installed. Provider: ${DNS_PROVIDER}"
echo "Aliyun RecordId (when enabled):"
cat /etc/cadu-ddns/aliyun_record_id 2>/dev/null || true
echo "Cloudflare RecordId (when enabled):"
cat /etc/cadu-ddns/cloudflare_record_id 2>/dev/null || true
echo "Check log: journalctl -u cadu-ddns.service -f"
