cf/README.md

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