commit d02cb2bedd21407e190389518e50a7986519a649 Author: chunzhimoe <60135925+chunzhimoe@users.noreply.github.com> Date: Sat Apr 25 20:47:10 2026 +0800 init: DDNS client + Cloudflare Worker + sing-box chunked download diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48d3f0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.wrangler/ +dist/ +.DS_Store +# Secrets written by install.sh cache +ddns.env +checksums-*.txt +extracted-*/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1d43cb --- /dev/null +++ b/README.md @@ -0,0 +1,266 @@ +# Android → Cloudflare DDNS + +Lightweight dynamic-DNS for a rooted Android device (originally built for an +armv7 IPTV box with no `curl` / `wget` / `cron`). The device reports its public +IPv4 to a Cloudflare Worker; the Worker authenticates the request and updates a +single Cloudflare DNS `A` record via the CF REST API. + +``` + ┌─────────────────┐ HTTP GET /update?t=…&ip=… ┌────────────────────┐ + │ Android rooted │ ─────────────────────────▶ │ Cloudflare Worker │ + │ (/system/xbin/ │ via CF custom domain │ (auth + update) │ + │ httpurl) │ (HTTP allowed) └─────────┬──────────┘ + └─────────────────┘ │ CF API (PATCH) + ▼ + ┌───────────────┐ + │ CF DNS zone │ + └───────────────┘ +``` + +No VPS, no Docker, **no extra binary** — CF Workers free tier is plenty. + +### How it avoids HTTPS on the device + +The target device (an armv7 Android IPTV box) ships without `curl`, `wget`, +`busybox`, or `openssl`. The only HTTP client is `/system/xbin/httpurl` which +only speaks **plaintext HTTP**: + +``` +$ /system/xbin/httpurl "https://cloudflare.com" +Only http:// URLs supported. +``` + +`*.workers.dev` forces HTTPS, but a **custom domain** with "Always Use HTTPS" +disabled accepts HTTP. The client calls `httpurl` with +`http://ddns-api.example.com/update?t=TOKEN&ip=IP` — Cloudflare's edge proxy +terminates the connection and hands the request to the Worker internally. + +## Repository layout + +``` +android-ddns/ +├── README.md this file +├── client/ +│ ├── ddns-report.sh main poller (runs on Android) +│ ├── ddns-runner.sh start/stop/status wrapper (runs on Android) +│ ├── ddns.rc Android init service definition (optional autostart) +│ └── install.sh host-side installer: adb push scripts + env +└── server/ + ├── wrangler.toml Worker configuration + ├── package.json + ├── tsconfig.json + └── src/index.ts Worker handler (single file) +``` + +## Prerequisites + +- A Cloudflare account with the target zone already added +- `wrangler` CLI installed locally (`npm i -g wrangler` or use `npx wrangler`) +- `adb` installed on the host PC +- The Android device: + - rooted (`adb root` or `su` in shell) + - has `/system/xbin/httpurl` (test: `httpurl "http://captive.apple.com" | tail -1`) + - `/data/local/tmp` executable + - connected via USB or `adb connect` + +## 1. Deploy the Worker + +### 1.1 Prepare Cloudflare + +1. Log into the CF dashboard and open the target zone (e.g. `example.com`). +2. Create **two** `A` records (both proxied, any placeholder IP `1.1.1.1`): + - `home.example.com` — the DDNS target record that will be updated + - `ddns-api.example.com` — the Worker's HTTP entry point +3. **Disable "Always Use HTTPS" for the API subdomain.** Two ways: + - **Zone-wide** (simplest): SSL/TLS → Edge Certificates → turn off "Always + Use HTTPS" (affects all subdomains — OK if you only use this zone for the + box). + - **Per-subdomain** (recommended): Rules → Configuration Rules → Add Rule → + When: `Hostname equals ddns-api.example.com` → Then: SSL → Automatic HTTPS + Rewrites: Off, Always Use HTTPS: Off. +4. Note the **Zone ID** (shown on the zone overview page). +5. Fetch the **Record ID** of `home.example.com` using the API: + + ```sh + curl -s -H "Authorization: Bearer " \ + "https://api.cloudflare.com/client/v4/zones//dns_records?type=A&name=home.example.com" \ + | jq -r '.result[0].id' + ``` + +6. Create an API token with **only** `Zone:DNS:Edit` scoped to this zone + (Profile → API Tokens → Create Token → Edit zone DNS template). +7. Generate a strong pre-shared token for the Android client: + + ```sh + openssl rand -hex 32 + ``` + +### 1.2 Configure and deploy + +```sh +cd server +npm install + +# Edit wrangler.toml: +# 1. Fill in ZONE_ID, RECORD_ID, RECORD_NAME under [vars] +# 2. Uncomment and fill in the routes line with your zone name +$EDITOR wrangler.toml + +# Store secrets (you'll be prompted for each value): +npx wrangler secret put CF_API_TOKEN # the zone-scoped API token +npx wrangler secret put SHARED_TOKEN # the pre-shared bearer token + +npx wrangler deploy +``` + +The Worker will be available at both: +- `https://android-ddns..workers.dev/update` (HTTPS, for PC testing with curl) +- `http://ddns-api.example.com/update` (HTTP, for the Android httpurl client) + +### 1.3 Smoke-test from the host + +```sh +# Via HTTPS + POST (from your PC with curl): +curl -fsS -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"ip":"203.0.113.42"}' \ + https://android-ddns..workers.dev/update + +# Via HTTP + GET (simulating what httpurl does): +curl -fsS "http://ddns-api.example.com/update?t=&ip=203.0.113.42" +``` + +Expected JSON: `{"ok":true,"changed":true,"old":"1.1.1.1","new":"203.0.113.42",…}`. +Replay immediately and you should get `{"ok":true,"changed":false,…}` (idempotent). + +## 2. Install the Android client + +From the repo root: + +```sh +./client/install.sh \ + --url http://ddns-api.example.com/update \ + --token \ + --iface eth0 \ + --interval 60 +``` + +What it does: + +1. `adb push` to the device: + - `/data/local/tmp/ddns-report.sh` (main loop) + - `/data/local/tmp/ddns-runner.sh` (start/stop wrapper) + - `/data/local/tmp/ddns.env` (contains your token — `chmod 600`) +2. Starts the daemon via `nohup` and prints status. + +No extra binary download — the client uses the built-in `/system/xbin/httpurl`. + +Optional flags: + +- `--install-initrc` — also push `ddns.rc` to `/system/etc/init/` so the + service auto-starts on boot. Requires `/system` to be re-mountable `rw`. +- `--device ` — pick a specific adb device when multiple are connected. +- `--skip-start` — install but don't launch (useful for staged rollouts). + +### Manual control on the device + +```sh +adb shell /data/local/tmp/ddns-runner.sh start +adb shell /data/local/tmp/ddns-runner.sh status +adb shell /data/local/tmp/ddns-runner.sh stop +adb shell /data/local/tmp/ddns-runner.sh restart +adb shell tail -f /data/local/tmp/ddns.log +``` + +## 3. How it works + +- `ddns-report.sh` loops every `DDNS_INTERVAL` seconds (default 60). +- Reads the first IPv4 on `$DDNS_IFACE` via `toybox ip -4 addr show`. +- Rejects private / CGNAT / link-local / multicast addresses client-side so a + temporary DHCP blip does not corrupt DNS. +- Compares against the last successfully-reported IP (cached at + `/data/local/tmp/ddns.last`). Only GETs on change → zero API calls when + the IP is stable. +- Sends `GET /update?t=TOKEN&ip=IP` via `/system/xbin/httpurl` (HTTP-only). +- Parses the JSON response from stdout; considers `"ok":true` as success. +- On HTTP failure, applies exponential backoff up to `DDNS_BACKOFF_MAX` + (default 600 s). On success, resets backoff. +- The Worker re-validates the IP, fetches the current DNS record, and issues a + `PATCH` only when it differs (double-defence against unnecessary API calls). + +## 4. Security notes + +> **HTTP plaintext**: the client → CF edge path is **unencrypted**. The token +> and IP travel in the clear (URL query string). This is a deliberate trade-off +> for zero-binary-dependency on the device. See mitigations below. + +- **Token exposure**: the pre-shared token appears in the URL and is visible to + any entity on the network path (ISP, local router). Rotate it periodically. +- **Blast radius**: the CF API token is scoped to `Zone:DNS:Edit` on a single + zone. If the shared token or API token leaks, the attacker can only modify + DNS records in that one zone. +- `ddns.env` on the device contains the token; installer sets `chmod 600`. +- The Worker rejects private-range IPs even if the client (or an attacker) tries + to submit them, preventing DNS poisoning. +- **Mitigations**: + - Use a **CF WAF rule** to allow `GET /update` only from your ISP's ASN or + country, blocking random internet probes. + - CF edge → Worker is always internal + encrypted; the token is not exposed + to the public internet once it reaches CF. + - If you later obtain a TLS-capable client (static curl, Termux), switch + the URL to `https://` and re-enable "Always Use HTTPS" for full E2E + encryption — no other changes needed, the Worker supports both. + +## 5. Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| `fatal: httpurl binary not found` in log | device doesn't have `/system/xbin/httpurl` | check `ls -l /system/xbin/httpurl`; if missing, fall back to static curl approach | +| `skip: no public ipv4 on eth0` | interface name wrong, or device is behind NAT | run `adb shell ip -4 addr` and adjust `DDNS_IFACE`; or omit `ip=` in the URL to let Worker use `CF-Connecting-IP` | +| HTTP 401 from Worker | `SHARED_TOKEN` mismatch | `wrangler secret put SHARED_TOKEN` again and re-run installer with the new value | +| HTTP 502 `cf_api_get_failed` | API token lacks `Zone:DNS:Edit`, wrong zone, or wrong record id | double-check `ZONE_ID`/`RECORD_ID` in `wrangler.toml`, regenerate token | +| Daemon dies after reboot | init.rc not loaded or blocked by SELinux | check `adb logcat -s init`; fall back to running `ddns-runner.sh start` from a post-boot script | +| `ddns-runner.sh start` returns "already running" but nothing in log | stale pidfile | `adb shell rm /data/local/tmp/ddns.pid && ddns-runner.sh start` | + +### Verify on the wire + +```sh +# Watch the actual HTTP request from the device +adb shell tcpdump -i eth0 -n 'port 80 and host ddns-api.example.com' -c 10 +``` + +### Live Worker logs + +```sh +cd server +npx wrangler tail +``` + +## 6. Extending + +- **IPv6**: duplicate the flow for an `AAAA` record (second Worker route or a + second record id in env). The client script can be extended to also read + `ip -6 addr`. +- **Multiple devices**: have each client include a `device_id` in the body; + the Worker maps `device_id → RECORD_ID` via a JSON map stored as a secret or + a Workers KV namespace. +- **Change notifications**: on a successful PATCH, have the Worker POST to a + webhook (Telegram / Bark / Discord) before returning. +- **Rate-limiting**: bind a Workers Rate Limit binding keyed on the token hash + to cap requests/minute. + +## 7. Uninstall + +```sh +adb shell /data/local/tmp/ddns-runner.sh stop +adb shell rm -f /data/local/tmp/ddns-report.sh \ + /data/local/tmp/ddns-runner.sh /data/local/tmp/ddns.env \ + /data/local/tmp/ddns.last /data/local/tmp/ddns.pid \ + /data/local/tmp/ddns.log + +# If installed with --install-initrc: +adb shell 'mount -o remount,rw /system && rm /system/etc/init/ddns.rc && mount -o remount,ro /system' +``` + +To remove the Worker: `cd server && npx wrangler delete`. diff --git a/client/ddns-report.sh b/client/ddns-report.sh new file mode 100644 index 0000000..a3f034a --- /dev/null +++ b/client/ddns-report.sh @@ -0,0 +1,158 @@ +#!/system/bin/sh +# Android DDNS reporter +# +# Reads the current IPv4 address of $IFACE and GETs the Cloudflare Worker +# endpoint whenever the IP changes. Uses the system-provided httpurl binary +# (HTTP-only, no curl/wget/busybox needed). Token and IP are passed as +# query-string parameters. +# +# Configure via environment variables or by editing the constants below. +# Defaults are suitable for /data/local/tmp installation on an Android IPTV box. + +set -u + +# Load optional env file (created by install.sh). This is sourced *before* +# the defaults below so values from the file act as overrides rather than +# hard-coded constants. +DDNS_ENV_FILE="${DDNS_ENV_FILE:-/data/local/tmp/ddns.env}" +if [ -f "$DDNS_ENV_FILE" ]; then + # shellcheck disable=SC1090 + . "$DDNS_ENV_FILE" +fi + +# ---- Configuration (override by exporting env vars before calling) ------------ +WORKER_URL="${DDNS_WORKER_URL:-http://ddns.eachtime.me/update}" +TOKEN="${DDNS_TOKEN:-REPLACE_ME_SHARED_TOKEN}" +NAME="${DDNS_NAME:-}" +IFACE="${DDNS_IFACE:-eth0}" +INTERVAL="${DDNS_INTERVAL:-60}" +HTTPURL="${DDNS_HTTPURL:-/system/xbin/httpurl}" +STATE_FILE="${DDNS_STATE_FILE:-/data/local/tmp/ddns.last}" +LOG_FILE="${DDNS_LOG_FILE:-/data/local/tmp/ddns.log}" +MAX_LOG_BYTES="${DDNS_MAX_LOG_BYTES:-524288}" # 512 KiB rotation threshold +BACKOFF_MAX="${DDNS_BACKOFF_MAX:-600}" # max backoff seconds on failure +ONESHOT="${DDNS_ONESHOT:-0}" # 1 = run once and exit +# ------------------------------------------------------------------------------ + +DATE_CMD="/system/bin/toybox date" + +log() { + # Log with timestamp; rotate if the log exceeds MAX_LOG_BYTES. + ts=$($DATE_CMD '+%Y-%m-%d %H:%M:%S') + printf '%s %s\n' "$ts" "$*" >>"$LOG_FILE" 2>/dev/null + if [ -f "$LOG_FILE" ]; then + size=$(/system/bin/toybox stat -c '%s' "$LOG_FILE" 2>/dev/null || echo 0) + if [ "${size:-0}" -gt "$MAX_LOG_BYTES" ] 2>/dev/null; then + mv "$LOG_FILE" "${LOG_FILE}.1" 2>/dev/null + fi + fi +} + +get_ip() { + # Auto-detect outbound source IP via routing table (no awk needed). + /system/bin/ip -4 route get 8.8.8.8 2>/dev/null \ + | /system/bin/toybox sed -n 's/.*src \([0-9.]*\).*/\1/p' \ + | /system/bin/toybox head -n1 +} + +is_public_ipv4() { + # Reject empty, private, CGNAT, link-local, loopback, multicast/reserved. + case "$1" in + ""|0.*|127.*|169.254.*|10.*|192.168.*) return 1 ;; + 172.16.*|172.17.*|172.18.*|172.19.*|172.2[0-9].*|172.3[0-1].*) return 1 ;; + 100.6[4-9].*|100.[7-9][0-9].*|100.1[0-1][0-9].*|100.12[0-7].*) return 1 ;; + 22[4-9].*|23[0-9].*|24[0-9].*|25[0-5].*) return 1 ;; + esac + # Must match rough IPv4 shape (4 dotted numbers). + case "$1" in + *.*.*.*) return 0 ;; + *) return 1 ;; + esac +} + +send_update() { + # $1 = ip. Echoes the JSON response body on stdout. + # Exit status: 0 if httpurl succeeded AND response contains "ok":true. + ip="$1" + url="${WORKER_URL}?t=${TOKEN}&ip=${ip}&name=${NAME}" + # httpurl outputs diagnostic lines (Resolving/Connecting) then HTTP headers + # then a blank line then the response body, all on stdout. + raw=$("$HTTPURL" "$url" 2>&1) || { echo "$raw"; return 1; } + # Extract JSON body (first line that starts with '{'). + body=$(echo "$raw" | grep '^{' | head -n1) + echo "$body" + # Check for success. + echo "$body" | grep -q '"ok":true' +} + +run_once() { + ip=$(get_ip) + if ! is_public_ipv4 "$ip"; then + log "skip: no public ipv4 detected (got '$ip')" + return 2 + fi + + last="" + [ -f "$STATE_FILE" ] && last=$(cat "$STATE_FILE" 2>/dev/null) + + if [ "$ip" = "$last" ]; then + # No change, no request. Keep log quiet. + return 0 + fi + + resp=$(send_update "$ip" 2>&1) + rc=$? + if [ $rc -eq 0 ]; then + printf '%s' "$ip" >"$STATE_FILE" + log "ok: $last -> $ip :: $resp" + return 0 + else + log "fail($rc): attempt $ip :: $resp" + return 1 + fi +} + +main_loop() { + backoff=0 + trap 'log "stopping (signal)"; exit 0' TERM INT + log "starting: iface=$IFACE interval=${INTERVAL}s url=$WORKER_URL" + while :; do + if run_once; then + backoff=0 + sleep "$INTERVAL" + else + # Exponential backoff capped at BACKOFF_MAX. + if [ "$backoff" -eq 0 ]; then + backoff="$INTERVAL" + else + backoff=$((backoff * 2)) + [ "$backoff" -gt "$BACKOFF_MAX" ] && backoff="$BACKOFF_MAX" + fi + log "retry in ${backoff}s" + sleep "$backoff" + fi + done +} + +# Sanity checks +if [ ! -x "$HTTPURL" ]; then + log "fatal: httpurl binary not found at $HTTPURL" + exit 127 +fi +if [ "$TOKEN" = "REPLACE_ME_SHARED_TOKEN" ]; then + log "fatal: DDNS_TOKEN is unset; edit this script or export DDNS_TOKEN" + exit 2 +fi +if [ -z "$NAME" ]; then + # Auto-derive from device hostname, truncated to first segment. + NAME=$(cat /proc/sys/kernel/hostname 2>/dev/null | /system/bin/toybox cut -d_ -f1) + [ -z "$NAME" ] && NAME="dd" + log "auto name=$NAME (set DDNS_NAME to override)" +fi + +if [ "$ONESHOT" = "1" ]; then + run_once + exit $? +fi + +main_loop diff --git a/client/ddns-runner.sh b/client/ddns-runner.sh new file mode 100644 index 0000000..c5820e3 --- /dev/null +++ b/client/ddns-runner.sh @@ -0,0 +1,78 @@ +#!/system/bin/sh +# Foreground wrapper that daemonizes ddns-report.sh. +# +# Usage: +# /data/local/tmp/ddns-runner.sh start # start in background (nohup) +# /data/local/tmp/ddns-runner.sh stop # stop the running instance +# /data/local/tmp/ddns-runner.sh status # print pid + last log lines +# /data/local/tmp/ddns-runner.sh restart +# +# The main script path and pid file can be overridden via env vars. + +SCRIPT="${DDNS_SCRIPT:-/data/local/tmp/ddns-report.sh}" +PIDFILE="${DDNS_PIDFILE:-/data/local/tmp/ddns.pid}" +LOG_FILE="${DDNS_LOG_FILE:-/data/local/tmp/ddns.log}" + +is_running() { + [ -f "$PIDFILE" ] || return 1 + pid=$(cat "$PIDFILE" 2>/dev/null) + [ -n "$pid" ] || return 1 + # kill -0 tests existence without signaling. + kill -0 "$pid" 2>/dev/null +} + +cmd_start() { + if is_running; then + echo "already running (pid=$(cat "$PIDFILE"))" + return 0 + fi + if [ ! -x "$SCRIPT" ]; then + echo "script not executable: $SCRIPT" >&2 + return 127 + fi + # nohup detaches from the shell; setsid would be cleaner but toybox does + # not guarantee it on every Android build. + nohup "$SCRIPT" >/dev/null 2>&1 & + pid=$! + echo "$pid" >"$PIDFILE" + echo "started pid=$pid" +} + +cmd_stop() { + if ! is_running; then + echo "not running" + rm -f "$PIDFILE" + return 0 + fi + pid=$(cat "$PIDFILE") + kill "$pid" 2>/dev/null + # Wait up to 5s, then SIGKILL. + i=0 + while kill -0 "$pid" 2>/dev/null && [ $i -lt 5 ]; do + sleep 1 + i=$((i + 1)) + done + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null + rm -f "$PIDFILE" + echo "stopped pid=$pid" +} + +cmd_status() { + if is_running; then + echo "running pid=$(cat "$PIDFILE")" + else + echo "stopped" + fi + if [ -f "$LOG_FILE" ]; then + echo "--- last 10 log lines ---" + /system/bin/toybox tail -n 10 "$LOG_FILE" + fi +} + +case "${1:-}" in + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_stop; cmd_start ;; + status) cmd_status ;; + *) echo "usage: $0 {start|stop|restart|status}" >&2; exit 2 ;; +esac diff --git a/client/ddns.rc b/client/ddns.rc new file mode 100644 index 0000000..cb1bbe4 --- /dev/null +++ b/client/ddns.rc @@ -0,0 +1,20 @@ +# Android init service definition for the DDNS reporter. +# +# Install: push this file to /system/etc/init/ddns.rc (chmod 644, chown root:root) +# and reboot the device. `sys.boot_completed=1` triggers the service after the +# boot animation finishes. +# +# NOTE: Android's init enforces SELinux. On many devices the `su` domain cannot +# be entered by init. If `logcat -s init:*` shows a denial, use the nohup +# fallback documented in README.md instead. + +service ddns /system/bin/sh /data/local/tmp/ddns-report.sh + class late_start + user root + group root + oneshot + disabled + seclabel u:r:su:s0 + +on property:sys.boot_completed=1 + start ddns diff --git a/client/install.sh b/client/install.sh new file mode 100644 index 0000000..f307bdc --- /dev/null +++ b/client/install.sh @@ -0,0 +1,256 @@ +#!/system/bin/sh +# Android DDNS client installer. +# +# Run directly on the Android device (via telnet / serial / adb shell). +# Writes ddns-report.sh, ddns-runner.sh, and ddns.env to $PREFIX, +# then starts the daemon. +# +# Usage (on the box): +# sh install.sh --token SECRET [--name p291] [--url http://...] [--iface eth0] + +PREFIX="/data/local/tmp" + +WORKER_URL="http://ddns.eachtime.me/update" +TOKEN="123456789" +NAME="p291" +IFACE="eth0" +INTERVAL="60" +SKIP_START=0 + +usage() { + echo "usage: install.sh [options]" + echo " --url URL Worker HTTP URL (default: http://ddns.eachtime.me/update)" + echo " --token TOKEN shared bearer token (required)" + echo " --name PREFIX subdomain prefix (e.g. p291 -> p291.eachtime.me)" + echo " --iface NAME network interface (default: eth0)" + echo " --interval SECS poll interval (default: 60)" + echo " --skip-start install but don't start daemon" + echo " -h, --help show this help" +} + +while [ $# -gt 0 ]; do + case "$1" in + --url) WORKER_URL="$2"; shift 2 ;; + --token) TOKEN="$2"; shift 2 ;; + --name) NAME="$2"; shift 2 ;; + --iface) IFACE="$2"; shift 2 ;; + --interval) INTERVAL="$2"; shift 2 ;; + --skip-start) SKIP_START=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown option: $1" >&2; usage; exit 2 ;; + esac +done + +if [ -z "$TOKEN" ]; then + echo "error: --token is required" >&2 + usage + exit 2 +fi + +echo "[1/3] writing config" +cat > "$PREFIX/ddns.env" << ENVEOF +DDNS_WORKER_URL='$WORKER_URL' +DDNS_TOKEN='$TOKEN' +DDNS_NAME='$NAME' +DDNS_IFACE='$IFACE' +DDNS_INTERVAL='$INTERVAL' +ENVEOF +chmod 600 "$PREFIX/ddns.env" + +echo "[2/3] writing scripts" +cat > "$PREFIX/ddns-report.sh" << 'REPORTEOF' +#!/system/bin/sh +set -u +DDNS_ENV_FILE="${DDNS_ENV_FILE:-/data/local/tmp/ddns.env}" +if [ -f "$DDNS_ENV_FILE" ]; then + . "$DDNS_ENV_FILE" +fi +WORKER_URL="${DDNS_WORKER_URL:-http://ddns.eachtime.me/update}" +TOKEN="${DDNS_TOKEN:-REPLACE_ME_SHARED_TOKEN}" +NAME="${DDNS_NAME:-}" +IFACE="${DDNS_IFACE:-eth0}" +INTERVAL="${DDNS_INTERVAL:-60}" +HTTPURL="${DDNS_HTTPURL:-/system/xbin/httpurl}" +STATE_FILE="${DDNS_STATE_FILE:-/data/local/tmp/ddns.last}" +LOG_FILE="${DDNS_LOG_FILE:-/data/local/tmp/ddns.log}" +MAX_LOG_BYTES="${DDNS_MAX_LOG_BYTES:-524288}" +BACKOFF_MAX="${DDNS_BACKOFF_MAX:-600}" +ONESHOT="${DDNS_ONESHOT:-0}" +DATE_CMD="/system/bin/toybox date" +log() { + ts=$($DATE_CMD '+%Y-%m-%d %H:%M:%S') + printf '%s %s\n' "$ts" "$*" >>"$LOG_FILE" 2>/dev/null + if [ -f "$LOG_FILE" ]; then + size=$(/system/bin/toybox stat -c '%s' "$LOG_FILE" 2>/dev/null || echo 0) + if [ "${size:-0}" -gt "$MAX_LOG_BYTES" ] 2>/dev/null; then + mv "$LOG_FILE" "${LOG_FILE}.1" 2>/dev/null + fi + fi +} +get_ip() { + /system/bin/ip -4 route get 8.8.8.8 2>/dev/null \ + | /system/bin/toybox sed -n 's/.*src \([0-9.]*\).*/\1/p' \ + | /system/bin/toybox head -n1 +} +is_public_ipv4() { + case "$1" in + ""|0.*|127.*|169.254.*|10.*|192.168.*) return 1 ;; + 172.16.*|172.17.*|172.18.*|172.19.*|172.2[0-9].*|172.3[0-1].*) return 1 ;; + 100.6[4-9].*|100.[7-9][0-9].*|100.1[0-1][0-9].*|100.12[0-7].*) return 1 ;; + 22[4-9].*|23[0-9].*|24[0-9].*|25[0-5].*) return 1 ;; + esac + case "$1" in + *.*.*.*) return 0 ;; + *) return 1 ;; + esac +} +send_update() { + ip="$1" + url="${WORKER_URL}?t=${TOKEN}&ip=${ip}&name=${NAME}" + raw=$("$HTTPURL" "$url" 2>&1) || { echo "$raw"; return 1; } + body=$(echo "$raw" | grep '^{' | head -n1) + echo "$body" + echo "$body" | grep -q '"ok":true' +} +run_once() { + ip=$(get_ip) + if ! is_public_ipv4 "$ip"; then + log "skip: no public ipv4 detected (got '$ip')" + return 2 + fi + last="" + [ -f "$STATE_FILE" ] && last=$(cat "$STATE_FILE" 2>/dev/null) + if [ "$ip" = "$last" ]; then + return 0 + fi + resp=$(send_update "$ip" 2>&1) + rc=$? + if [ $rc -eq 0 ]; then + printf '%s' "$ip" >"$STATE_FILE" + log "ok: $last -> $ip :: $resp" + return 0 + else + log "fail($rc): attempt $ip :: $resp" + return 1 + fi +} +main_loop() { + backoff=0 + trap 'log "stopping (signal)"; exit 0' TERM INT + log "starting: iface=$IFACE interval=${INTERVAL}s url=$WORKER_URL" + while :; do + if run_once; then + backoff=0 + sleep "$INTERVAL" + else + if [ "$backoff" -eq 0 ]; then + backoff="$INTERVAL" + else + backoff=$((backoff * 2)) + [ "$backoff" -gt "$BACKOFF_MAX" ] && backoff="$BACKOFF_MAX" + fi + log "retry in ${backoff}s" + sleep "$backoff" + fi + done +} +if [ ! -x "$HTTPURL" ]; then + log "fatal: httpurl binary not found at $HTTPURL" + exit 127 +fi +if [ "$TOKEN" = "REPLACE_ME_SHARED_TOKEN" ]; then + log "fatal: DDNS_TOKEN is unset" + exit 2 +fi +if [ -z "$NAME" ]; then + NAME=$(cat /proc/sys/kernel/hostname 2>/dev/null | /system/bin/toybox cut -d_ -f1) + [ -z "$NAME" ] && NAME="dd" + log "auto name=$NAME (set DDNS_NAME to override)" +fi +if [ "$ONESHOT" = "1" ]; then + run_once + exit $? +fi +main_loop +REPORTEOF + +cat > "$PREFIX/ddns-runner.sh" << 'RUNEOF' +#!/system/bin/sh +SCRIPT="${DDNS_SCRIPT:-/data/local/tmp/ddns-report.sh}" +PIDFILE="${DDNS_PIDFILE:-/data/local/tmp/ddns.pid}" +LOG_FILE="${DDNS_LOG_FILE:-/data/local/tmp/ddns.log}" +is_running() { + [ -f "$PIDFILE" ] || return 1 + pid=$(cat "$PIDFILE" 2>/dev/null) + [ -n "$pid" ] || return 1 + kill -0 "$pid" 2>/dev/null +} +cmd_start() { + if is_running; then + echo "already running (pid=$(cat "$PIDFILE"))" + return 0 + fi + if [ ! -x "$SCRIPT" ]; then + echo "script not executable: $SCRIPT" >&2 + return 127 + fi + nohup "$SCRIPT" >/dev/null 2>&1 & + pid=$! + echo "$pid" >"$PIDFILE" + echo "started pid=$pid" +} +cmd_stop() { + if ! is_running; then + echo "not running" + rm -f "$PIDFILE" + return 0 + fi + pid=$(cat "$PIDFILE") + kill "$pid" 2>/dev/null + i=0 + while kill -0 "$pid" 2>/dev/null && [ $i -lt 5 ]; do + sleep 1 + i=$((i + 1)) + done + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null + rm -f "$PIDFILE" + echo "stopped pid=$pid" +} +cmd_status() { + if is_running; then + echo "running pid=$(cat "$PIDFILE")" + else + echo "stopped" + fi + if [ -f "$LOG_FILE" ]; then + echo "--- last 10 log lines ---" + /system/bin/toybox tail -n 10 "$LOG_FILE" + fi +} +case "${1:-}" in + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_stop; cmd_start ;; + status) cmd_status ;; + *) echo "usage: $0 {start|stop|restart|status}" >&2; exit 2 ;; +esac +RUNEOF + +chmod 755 "$PREFIX/ddns-report.sh" "$PREFIX/ddns-runner.sh" + +echo "[3/3] testing & starting" +# One-shot test first +DDNS_ONESHOT=1 "$PREFIX/ddns-report.sh" +echo "" +cat "$PREFIX/ddns.log" 2>/dev/null | /system/bin/toybox tail -n 5 +echo "" + +if [ "$SKIP_START" = "1" ]; then + echo "skipped start. run: $PREFIX/ddns-runner.sh start" +else + "$PREFIX/ddns-runner.sh" start + "$PREFIX/ddns-runner.sh" status +fi + +echo "" +echo "done. logs: cat $PREFIX/ddns.log" diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..2f55d99 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1606 @@ +{ + "name": "android-ddns-worker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "android-ddns-worker", + "version": "1.0.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20250101.0", + "typescript": "^5.6.0", + "wrangler": "^3.95.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260423.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260423.1.tgz", + "integrity": "sha512-SHIc0NeJMtn0sW043eWtMxYFbJ9VPSLkcx+FEqCk0uZLD3HrWT+5xWhm6EYiOYDg0vnrlXNHcu2ly/01zDh3bw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..b8c6639 --- /dev/null +++ b/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "android-ddns-worker", + "version": "1.0.0", + "private": true, + "description": "Cloudflare Worker receiving IP reports from Android and updating Cloudflare DNS A records.", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "tail": "wrangler tail" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250101.0", + "typescript": "^5.6.0", + "wrangler": "^3.95.0" + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..9d8c9b1 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,395 @@ +/** + * Android DDNS Worker + * + * Endpoints: + * GET /update?t=&ip=&name= + * For HTTP-only clients (Android httpurl) that cannot send headers. + * + * POST /update + * Header: Authorization: Bearer + * Body: {"ip":"1.2.3.4", "name":"box1"} + * For curl/PC testing. + * + * The `name` parameter is the subdomain prefix. The full DNS record will be + * `{name}.{DOMAIN}`. If the A record does not exist it is created; if it + * exists but the IP differs it is updated; if unchanged it short-circuits. + */ + +export interface Env { + // Secrets + CF_API_TOKEN: string; + SHARED_TOKEN: string; + // Vars + ZONE_ID: string; + DOMAIN: string; // e.g. "eachtime.me" + ALLOW_IP_FALLBACK?: string; // default "true" + RECORD_TTL?: string; // default "60" + RECORD_PROXIED?: string; // default "false" (new records) +} + +interface CfDnsRecord { + id: string; + type: string; + name: string; + content: string; + ttl: number; + proxied: boolean; +} + +interface CfApiError { + code: number; + message: string; +} + +interface CfApiResponse { + success: boolean; + errors: CfApiError[]; + messages: CfApiError[]; + result: T; +} + +const IPV4_RE = + /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; + +const DNS_LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + +function isPrivateOrReservedIPv4(ip: string): boolean { + const parts = ip.split(".").map((n) => Number.parseInt(n, 10)); + if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true; + const a = parts[0] ?? 0; + const b = parts[1] ?? 0; + // 0.0.0.0/8, 127.0.0.0/8 + if (a === 0 || a === 127) return true; + // RFC1918 + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + // Link-local 169.254/16 + if (a === 169 && b === 254) return true; + // CGNAT 100.64/10 + if (a === 100 && b >= 64 && b <= 127) return true; + // Multicast 224-239, Reserved 240-255 + if (a >= 224) return true; + return false; +} + +/** + * Constant-time string comparison. Returns true iff strings are identical. + * Avoids leaking length only when used on inputs of the same length. + */ +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }, + }); +} + +async function cfApi( + env: Env, + path: string, + init: RequestInit = {}, +): Promise> { + const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, { + ...init, + headers: { + authorization: `Bearer ${env.CF_API_TOKEN}`, + "content-type": "application/json", + ...(init.headers ?? {}), + }, + }); + const text = await res.text(); + try { + return JSON.parse(text) as CfApiResponse; + } catch { + return { + success: false, + errors: [{ code: res.status, message: text.slice(0, 500) }], + messages: [], + result: null as unknown as T, + }; + } +} + +function requiredEnv(env: Env): string | null { + const missing: string[] = []; + if (!env.CF_API_TOKEN) missing.push("CF_API_TOKEN"); + if (!env.SHARED_TOKEN) missing.push("SHARED_TOKEN"); + if (!env.ZONE_ID || env.ZONE_ID.startsWith("REPLACE_")) missing.push("ZONE_ID"); + if (!env.DOMAIN) missing.push("DOMAIN"); + return missing.length ? missing.join(",") : null; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // Health check + if (request.method === "GET" && url.pathname === "/health") { + return jsonResponse({ ok: true, service: "android-ddns" }); + } + + // Chunked base64 download for HTTP-only clients. + // Known files are mapped by short ID to avoid long URLs. + // GET /dl?t=TOKEN&id=singbox → file info JSON + // GET /dl?t=TOKEN&id=singbox&chunk=0 → base64 text with markers + const DOWNLOADS: Record = { + singbox: "https://github.com/SagerNet/sing-box/releases/download/v1.13.11/sing-box-1.13.11-linux-armv7.tar.gz", + "singbox-arm64": "https://github.com/SagerNet/sing-box/releases/download/v1.13.11/sing-box-1.13.11-linux-arm64.tar.gz", + }; + const DL_CHUNK_SIZE = 131072; // 128 KB + + if (request.method === "GET" && url.pathname === "/dl") { + const dlToken = url.searchParams.get("t") ?? ""; + if (!timingSafeEqual(dlToken, env.SHARED_TOKEN)) { + return jsonResponse({ ok: false, error: "unauthorized" }, 401); + } + const id = url.searchParams.get("id") ?? ""; + const dlUrl = DOWNLOADS[id]; + if (!dlUrl) { + return jsonResponse({ ok: false, error: "unknown_id", available: Object.keys(DOWNLOADS) }, 400); + } + + const chunkStr = url.searchParams.get("chunk"); + + if (chunkStr === null) { + // Info mode: HEAD to get content-length + const head = await fetch(dlUrl, { + method: "HEAD", + redirect: "follow", + headers: { "user-agent": "android-ddns-proxy/1.0" }, + }); + const size = Number.parseInt(head.headers.get("content-length") ?? "0", 10); + return jsonResponse({ + ok: true, + id, + size, + chunk_size: DL_CHUNK_SIZE, + chunks: Math.ceil(size / DL_CHUNK_SIZE), + }); + } + + // Chunk mode: fetch byte range, return base64 text + const chunkN = Number.parseInt(chunkStr, 10); + const offset = chunkN * DL_CHUNK_SIZE; + + try { + const resp = await fetch(dlUrl, { + headers: { + Range: `bytes=${offset}-${offset + DL_CHUNK_SIZE - 1}`, + "user-agent": "android-ddns-proxy/1.0", + }, + redirect: "follow", + }); + + let buf: ArrayBuffer; + if (resp.status === 206) { + buf = await resp.arrayBuffer(); + } else { + // Range not supported — download all and slice + const full = await resp.arrayBuffer(); + buf = full.slice(offset, Math.min(offset + DL_CHUNK_SIZE, full.byteLength)); + } + + // Base64 encode + const bytes = new Uint8Array(buf); + let binary = ""; + for (let i = 0; i < bytes.length; i += 4096) { + const sub = bytes.subarray(i, Math.min(i + 4096, bytes.length)); + binary += String.fromCharCode.apply(null, Array.from(sub)); + } + const b64 = btoa(binary); + + // Multi-line with markers (easy to extract with sed/grep) + let out = "###B64START###\n"; + for (let i = 0; i < b64.length; i += 76) { + out += b64.slice(i, i + 76) + "\n"; + } + out += "###B64END###\n"; + + return new Response(out, { + status: 200, + headers: { "content-type": "text/plain; charset=ascii" }, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return new Response(`dl chunk failed: ${msg}`, { status: 502 }); + } + } + + if (url.pathname !== "/update") { + return jsonResponse({ ok: false, error: "not_found" }, 404); + } + + // Config sanity + const missing = requiredEnv(env); + if (missing) { + console.error(`missing_env: ${missing}`); + return jsonResponse({ ok: false, error: "server_misconfigured", missing }, 500); + } + + // Extract token, IP, and name based on HTTP method. + // GET /update?t=TOKEN&ip=IP&name=PREFIX (httpurl / HTTP-only clients) + // POST /update Authorization: Bearer TOKEN body: {"ip":"...","name":"..."} + let authToken: string; + let rawIp: string; + let rawName: string; + + if (request.method === "POST") { + const auth = request.headers.get("authorization") ?? ""; + authToken = auth.replace(/^Bearer\s+/i, ""); + + let body: { ip?: unknown; name?: unknown } = {}; + const raw = await request.text(); + if (raw.length > 0) { + try { + body = JSON.parse(raw) as { ip?: unknown; name?: unknown }; + } catch { + return jsonResponse({ ok: false, error: "invalid_json" }, 400); + } + } + rawIp = typeof body.ip === "string" ? body.ip.trim() : ""; + rawName = typeof body.name === "string" ? body.name.trim() : ""; + } else if (request.method === "GET") { + authToken = url.searchParams.get("t") ?? ""; + rawIp = (url.searchParams.get("ip") ?? "").trim(); + rawName = (url.searchParams.get("name") ?? "").trim(); + } else { + return jsonResponse({ ok: false, error: "method_not_allowed" }, 405); + } + + // Auth (constant-time comparison) + if (!timingSafeEqual(authToken, env.SHARED_TOKEN)) { + return jsonResponse({ ok: false, error: "unauthorized" }, 401); + } + + // Validate name (subdomain prefix) + const name = rawName.toLowerCase(); + if (!DNS_LABEL_RE.test(name)) { + return jsonResponse({ ok: false, error: "invalid_name", name: rawName }, 400); + } + const fullName = `${name}.${env.DOMAIN}`; + + // IP resolution with optional CF-Connecting-IP fallback + let ip = rawIp; + const allowFallback = (env.ALLOW_IP_FALLBACK ?? "true").toLowerCase() === "true"; + if (!ip && allowFallback) { + ip = (request.headers.get("cf-connecting-ip") ?? "").trim(); + } + + if (!IPV4_RE.test(ip)) { + return jsonResponse({ ok: false, error: "invalid_ip", ip }, 400); + } + if (isPrivateOrReservedIPv4(ip)) { + return jsonResponse({ ok: false, error: "private_or_reserved_ip", ip }, 400); + } + + // Search for existing A record by name + const search = await cfApi( + env, + `/zones/${env.ZONE_ID}/dns_records?type=A&name=${encodeURIComponent(fullName)}`, + ); + if (!search.success) { + console.error("cf_api_search_failed", JSON.stringify(search.errors)); + return jsonResponse( + { ok: false, error: "cf_api_search_failed", detail: search.errors }, + 502, + ); + } + + const ttl = Number.parseInt(env.RECORD_TTL ?? "60", 10); + const safeTtl = Number.isFinite(ttl) && ttl > 0 ? ttl : 60; + const existing = + Array.isArray(search.result) && search.result.length > 0 + ? search.result[0] + : null; + + // --- Record exists: check if update is needed --- + if (existing) { + if (existing.content === ip) { + return jsonResponse({ + ok: true, + changed: false, + ip, + name: fullName, + }); + } + + const updated = await cfApi( + env, + `/zones/${env.ZONE_ID}/dns_records/${existing.id}`, + { + method: "PATCH", + body: JSON.stringify({ + type: "A", + name: fullName, + content: ip, + ttl: safeTtl, + proxied: existing.proxied, + }), + }, + ); + if (!updated.success || !updated.result) { + console.error("cf_api_patch_failed", JSON.stringify(updated.errors)); + return jsonResponse( + { ok: false, error: "cf_api_patch_failed", detail: updated.errors }, + 502, + ); + } + + console.log(`updated ${fullName} ${existing.content} -> ${ip}`); + return jsonResponse({ + ok: true, + changed: true, + action: "updated", + old: existing.content, + new: ip, + name: fullName, + }); + } + + // --- Record does not exist: create it --- + const proxied = (env.RECORD_PROXIED ?? "false").toLowerCase() === "true"; + const created = await cfApi( + env, + `/zones/${env.ZONE_ID}/dns_records`, + { + method: "POST", + body: JSON.stringify({ + type: "A", + name: fullName, + content: ip, + ttl: safeTtl, + proxied, + }), + }, + ); + if (!created.success || !created.result) { + console.error("cf_api_create_failed", JSON.stringify(created.errors)); + return jsonResponse( + { ok: false, error: "cf_api_create_failed", detail: created.errors }, + 502, + ); + } + + console.log(`created ${fullName} -> ${ip}`); + return jsonResponse({ + ok: true, + changed: true, + action: "created", + new: ip, + name: fullName, + }); + }, +} satisfies ExportedHandler; diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..9470faa --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "lib": ["es2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/server/wrangler.toml b/server/wrangler.toml new file mode 100644 index 0000000..db965f0 --- /dev/null +++ b/server/wrangler.toml @@ -0,0 +1,41 @@ +name = "android-ddns" +main = "src/index.ts" +compatibility_date = "2024-11-06" +account_id = "8a531676b3bee8cf9d26eca6a5ea817a" + +# ---- Custom domain route (REQUIRED for HTTP access from httpurl) ---- +# The Android client uses httpurl which only speaks HTTP. Workers on +# *.workers.dev force HTTPS, so you MUST bind a custom domain route here +# and disable "Always Use HTTPS" for that subdomain in CF Dashboard. +# +# Before deploying: +# 1. In CF Dashboard, add a proxied A record for ddns.eachtime.me -> 1.1.1.1 +# 2. SSL/TLS → Edge Certificates → turn off "Always Use HTTPS" +# OR create a Configuration Rule: hostname = ddns.eachtime.me → OFF +routes = [ + { pattern = "ddns.eachtime.me/*", zone_name = "eachtime.me" } +] + +# ---- Non-secret configuration ---- +[vars] +ZONE_ID = "324fa9007d22b749e4ca36bff126d038" +DOMAIN = "eachtime.me" +# Set to "true" to allow the Worker to fall back to CF-Connecting-IP +# when the client does not supply an ip in the JSON body. +ALLOW_IP_FALLBACK = "true" +# Minimum TTL in seconds to set on the record when updating (1 = automatic). +RECORD_TTL = "60" +# Whether newly-created A records should be proxied through Cloudflare. +RECORD_PROXIED = "false" + +# ---- Secrets (set via `wrangler secret put`) ---- +# CF_API_TOKEN -> Cloudflare API token with Zone:DNS:Edit on the target zone +# SHARED_TOKEN -> Pre-shared bearer token used by the Android client +# +# Example: +# wrangler secret put CF_API_TOKEN +# wrangler secret put SHARED_TOKEN + +# Observability (Workers logs) +[observability] +enabled = true