init: DDNS client + Cloudflare Worker + sing-box chunked download
This commit is contained in:
Generated
+1606
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user