init: DDNS client + Cloudflare Worker + sing-box chunked download

This commit is contained in:
chunzhimoe
2026-04-25 20:47:10 +08:00
commit d02cb2bedd
11 changed files with 2865 additions and 0 deletions
+1606
View File
File diff suppressed because it is too large Load Diff
+18
View 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
View 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
View 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
View 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