init: DDNS client + Cloudflare Worker + sing-box chunked download
This commit is contained in:
commit
d02cb2bedd
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal 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
266
README.md
Normal 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
158
client/ddns-report.sh
Normal 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
78
client/ddns-runner.sh
Normal 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
20
client/ddns.rc
Normal 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
256
client/install.sh
Normal 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
1606
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
server/package.json
Normal file
18
server/package.json
Normal 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
395
server/src/index.ts
Normal 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
19
server/tsconfig.json
Normal 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
41
server/wrangler.toml
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user