267 lines
11 KiB
Markdown
267 lines
11 KiB
Markdown
# 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`.
|