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:

    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:

    openssl rand -hex 32
    

1.2 Configure and deploy

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

# 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:

./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

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

# 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

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

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.

Description
No description provided
Readme 54 KiB
Languages
Shell 53.8%
TypeScript 46.2%