init: DDNS client + Cloudflare Worker + sing-box chunked download

This commit is contained in:
chunzhimoe 2026-04-25 20:47:10 +08:00
commit d02cb2bedd
11 changed files with 2865 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
.wrangler/
dist/
.DS_Store
# Secrets written by install.sh cache
ddns.env
checksums-*.txt
extracted-*/

266
README.md Normal file
View File

@ -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 <token>" \
"https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/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.<your-account>.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 <SHARED_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"ip":"203.0.113.42"}' \
https://android-ddns.<account>.workers.dev/update
# Via HTTP + GET (simulating what httpurl does):
curl -fsS "http://ddns-api.example.com/update?t=<SHARED_TOKEN>&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 <SHARED_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 <serial>` — 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`.

158
client/ddns-report.sh Normal file
View File

@ -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

78
client/ddns-runner.sh Normal file
View File

@ -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

20
client/ddns.rc Normal file
View File

@ -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

256
client/install.sh Normal file
View File

@ -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"

1606
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
server/package.json Normal file
View File

@ -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"
}
}

395
server/src/index.ts Normal file
View File

@ -0,0 +1,395 @@
/**
* Android DDNS Worker
*
* Endpoints:
* GET /update?t=<SHARED_TOKEN>&ip=<IPv4>&name=<subdomain-prefix>
* For HTTP-only clients (Android httpurl) that cannot send headers.
*
* POST /update
* Header: Authorization: Bearer <SHARED_TOKEN>
* 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<T> {
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<T>(
env: Env,
path: string,
init: RequestInit = {},
): Promise<CfApiResponse<T>> {
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<T>;
} 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<Response> {
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<string, string> = {
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<CfDnsRecord[]>(
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<CfDnsRecord>(
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<CfDnsRecord>(
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<Env>;

19
server/tsconfig.json Normal file
View File

@ -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"]
}

41
server/wrangler.toml Normal file
View File

@ -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