#!/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
DDNS_UPDATE_URL="${DDNS_UPDATE_URL:-https://cadu.6201170.xyz/ddns-install.sh}"

IP_INTERVAL="30"

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
  --update-url URL              DDNS installer used by the auto-updater
  -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
      ;;
    --update-url) need_value "$@"; DDNS_UPDATE_URL="$2"; 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

case "$DDNS_UPDATE_URL" in
  https://*) ;;
  *) echo "DDNS_UPDATE_URL must use HTTPS" >&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

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}
DDNS_UPDATE_URL=${DDNS_UPDATE_URL}
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, sys, 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"))

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 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 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 repair_enabled_state():
    if DNS_PROVIDER not in {"aliyun", "both"}:
        return

    record_id = read_record_id()
    if not record_id:
        records = list_records()
        if len(records) != 1:
            raise RuntimeError(
                f"expected one Aliyun record for {ALIYUN_RR}.{ALIYUN_DOMAIN} {TYPE}, got {len(records)}"
            )
        record_id = str(records[0]["RecordId"])
        save_record_id(record_id)

    info = describe_record(record_id)
    if info.get("Status") == "ENABLE":
        log(f"migration check: RecordId={record_id} already ENABLE")
        return

    aliyun("SetDomainRecordStatus", {"RecordId": record_id, "Status": "ENABLE"})
    log(f"migration repaired RecordId={record_id}: status -> ENABLE")

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 main():
    while True:
        try:
            public_ip = get_public_ip()
            if DNS_PROVIDER in {"aliyun", "both"}:
                ensure_ip_record(public_ip)
            if DNS_PROVIDER in {"cloudflare", "both"}:
                ensure_cloudflare_record(public_ip)
        except Exception as e:
            log(f"ERROR {e}")

        time.sleep(IP_INTERVAL)

if __name__ == "__main__":
    if sys.argv[1:] == ["--repair-enabled-state"]:
        repair_enabled_state()
    else:
        main()
PY

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

# Old releases could pause Aliyun records after failed health probes. Repair
# that legacy state during installation/update; the long-running agent never
# changes record status.
if [ "$DNS_PROVIDER" = "aliyun" ] || [ "$DNS_PROVIDER" = "both" ]; then
  if ! /usr/local/libexec/cadu-ddns-agent.py --repair-enabled-state; then
    echo "Warning: could not repair legacy Aliyun record status; DDNS will continue." >&2
  fi
fi

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

cat >/usr/local/libexec/cadu-ddns-update <<'UPDATE'
#!/usr/bin/env bash
set -Eeuo pipefail

ENV_FILE=/etc/cadu-ddns/env
STATE_FILE=/etc/cadu-ddns/installer.sha256

env_value() {
  sed -n "s/^$1=//p" "$ENV_FILE" | head -1
}

[ -f "$ENV_FILE" ] || { echo "DDNS environment is missing" >&2; exit 1; }
update_url="$(env_value DDNS_UPDATE_URL)"
[ -n "$update_url" ] || update_url=https://cadu.6201170.xyz/ddns-install.sh

tmp="$(mktemp)"
backup="$(mktemp -d)"
trap 'rm -f "$tmp"; rm -rf "$backup"' EXIT

curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 5 --max-time 30 \
  "$update_url" -o "$tmp"
new_hash="$(sha256sum "$tmp" | awk '{print $1}')"
old_hash="$(cat "$STATE_FILE" 2>/dev/null || true)"
if [ "$new_hash" = "$old_hash" ]; then
  echo "DDNS is already up to date."
  exit 0
fi
bash -n "$tmp"

for path in \
  /etc/cadu-ddns/env \
  /etc/cadu-ddns/aliyun_record_id \
  /etc/cadu-ddns/cloudflare_record_id \
  /usr/local/libexec/cadu-ddns-agent.py \
  /usr/local/libexec/cadu-ddns-update \
  /etc/systemd/system/cadu-ddns.service \
  /etc/systemd/system/cadu-ddns-update.service \
  /etc/systemd/system/cadu-ddns-update.timer; do
  [ -e "$path" ] && cp -a "$path" "$backup/$(basename "$path")"
done

args=(
  --provider "$(env_value DNS_PROVIDER || true)"
  --type "$(env_value TYPE || true)"
  --aliyun-ttl "$(env_value ALIYUN_TTL || true)"
  --cloudflare-ttl "$(env_value CLOUDFLARE_TTL || true)"
  --cf-proxied "$(env_value CLOUDFLARE_PROXIED || true)"
  --update-url "$update_url"
)
aliyun_domain="$(env_value ALIYUN_DOMAIN)"
cloudflare_domain="$(env_value CLOUDFLARE_DOMAIN)"
aliyun_rr="$(env_value ALIYUN_RR)"
cloudflare_rr="$(env_value CLOUDFLARE_RR)"
cf_zone_id="$(env_value CLOUDFLARE_ZONE_ID)"
[ -n "${args[1]}" ] || args[1]=aliyun
[ -n "${args[3]}" ] || args[3]=A
[ -n "${args[5]}" ] || args[5]="$(env_value TTL || true)"
[ -n "${args[5]}" ] || args[5]=1
[ -n "${args[7]}" ] || args[7]="$(env_value TTL || true)"
[ -n "${args[7]}" ] || args[7]=60
[ -n "${args[9]}" ] || args[9]=false
[ -n "$aliyun_domain" ] || aliyun_domain="$(env_value DOMAIN || true)"
[ -n "$cloudflare_domain" ] || cloudflare_domain="$(env_value DOMAIN || true)"
[ -n "$aliyun_rr" ] || aliyun_rr="$(env_value RR || true)"
[ -n "$cloudflare_rr" ] || cloudflare_rr="$(env_value RR || true)"
[ -z "$aliyun_domain" ] || args+=(--aliyun-domain "$aliyun_domain")
[ -z "$cloudflare_domain" ] || args+=(--cloudflare-domain "$cloudflare_domain")
[ -z "$aliyun_rr" ] || args+=(--aliyun-rr "$aliyun_rr")
[ -z "$cloudflare_rr" ] || args+=(--cloudflare-rr "$cloudflare_rr")
[ -z "$cf_zone_id" ] || args+=(--cf-zone-id "$cf_zone_id")
record_id="$(env_value RECORD_ID)"
[ -n "$record_id" ] || record_id="$(cat /etc/cadu-ddns/aliyun_record_id 2>/dev/null || true)"
[ -z "$record_id" ] || args+=(--record-id "$record_id")

if CADU_SKIP_APT_UPDATE=1 \
  ALIYUN_ACCESS_KEY_ID="$(env_value ALIYUN_ACCESS_KEY_ID)" \
  ALIYUN_ACCESS_KEY_SECRET="$(env_value ALIYUN_ACCESS_KEY_SECRET)" \
  CLOUDFLARE_API_TOKEN="$(env_value CLOUDFLARE_API_TOKEN)" \
  bash "$tmp" "${args[@]}" && systemctl is-active --quiet cadu-ddns.service; then
  printf '%s\n' "$new_hash" >"$STATE_FILE"
  chmod 600 "$STATE_FILE"
  echo "DDNS updated successfully."
  exit 0
fi

echo "DDNS update failed; restoring the previous version." >&2
for path in \
  /etc/cadu-ddns/env \
  /etc/cadu-ddns/aliyun_record_id \
  /etc/cadu-ddns/cloudflare_record_id \
  /usr/local/libexec/cadu-ddns-agent.py \
  /usr/local/libexec/cadu-ddns-update \
  /etc/systemd/system/cadu-ddns.service \
  /etc/systemd/system/cadu-ddns-update.service \
  /etc/systemd/system/cadu-ddns-update.timer; do
  saved="$backup/$(basename "$path")"
  [ -e "$saved" ] && cp -a "$saved" "$path"
done
systemctl daemon-reload
systemctl restart cadu-ddns.service
exit 1
UPDATE
chmod 700 /usr/local/libexec/cadu-ddns-update

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

[Service]
Type=oneshot
ExecStart=/usr/local/libexec/cadu-ddns-update
SERVICE

cat >/etc/systemd/system/cadu-ddns-update.timer <<'TIMER'
[Unit]
Description=Check for CADU DDNS updates every 5 minutes

[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
RandomizedDelaySec=30
Persistent=true

[Install]
WantedBy=timers.target
TIMER

systemctl daemon-reload
systemctl enable cadu-ddns.service
systemctl enable --now cadu-ddns-update.timer
systemctl restart cadu-ddns.service

current_installer="$(mktemp)"
if curl -fsSL --connect-timeout 5 --max-time 30 "$DDNS_UPDATE_URL" -o "$current_installer"; then
  sha256sum "$current_installer" | awk '{print $1}' >/etc/cadu-ddns/installer.sha256
  chmod 600 /etc/cadu-ddns/installer.sha256
fi
rm -f "$current_installer"

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"
echo "Auto-update: cadu-ddns-update.timer (every 5 minutes)"
