# 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`.