Files
zpan/scripts/cloudflare-webdav.mjs

260 lines
9.2 KiB
JavaScript

import { createHash } from 'node:crypto'
const API_BASE = 'https://api.cloudflare.com/client/v4'
const TRANSFORM_PHASE = 'http_request_transform'
const MANAGED_REF_PREFIX = 'zpan_webdav_'
const RULESET_NAME = 'ZPan WebDAV URL Rewrite'
const RULE_DESCRIPTION_PREFIX = 'Managed by ZPan WebDAV:'
const DEFAULT_WORKER_NAME = 'zpan'
const VERIFY_ATTEMPTS = 30
const VERIFY_DELAY_MS = 10_000
export function webDavHostname(primaryHostname) {
return `dav.${primaryHostname}`
}
export function findPrimaryWorkerDomain(domains, managedHostnames = new Set()) {
const candidates = domains.filter((domain) => !managedHostnames.has(domain.hostname))
const primary = candidates.filter(
(candidate) => !candidates.some((other) => candidate.hostname === webDavHostname(other.hostname)),
)
if (primary.length === 0) return null
if (primary.length > 1) {
throw new Error(`Multiple primary Worker Custom Domains found: ${primary.map((domain) => domain.hostname).join(', ')}`)
}
if (primary[0].hostname.startsWith('dav.')) {
throw new Error(`Cannot infer a primary Worker Custom Domain from ${primary[0].hostname}`)
}
return primary[0]
}
export function managedRuleRef(hostname) {
return `${MANAGED_REF_PREFIX}${createHash('sha256').update(hostname).digest('hex').slice(0, 16)}`
}
export function buildRewriteRule(hostname) {
return {
ref: managedRuleRef(hostname),
description: `${RULE_DESCRIPTION_PREFIX} ${hostname}`,
expression: `http.host eq "${hostname}"`,
action: 'rewrite',
action_parameters: {
uri: {
path: {
expression: 'concat("/dav", http.request.uri.path)',
},
},
},
enabled: true,
}
}
export async function syncCloudflareWebDav({
token,
accountId,
workerName = DEFAULT_WORKER_NAME,
apiFetch = fetch,
verifyFetch = fetch,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
verifyAttempts = VERIFY_ATTEMPTS,
verifyDelayMs = VERIFY_DELAY_MS,
}) {
if (!token) throw new Error('CLOUDFLARE_API_TOKEN is required')
if (!accountId) throw new Error('CLOUDFLARE_ACCOUNT_ID is required')
const client = createApiClient({ token, apiFetch })
const domains = await listWorkerDomains(client, accountId)
const workerDomains = domains.filter(
(domain) => domain.service === workerName && (!domain.environment || domain.environment === 'production'),
)
const zones = workerDomainZones(workerDomains)
const managedRules = await listManagedRules(client, zones)
const managedHostnames = new Set(managedRules.map(({ rule }) => managedRuleHostname(rule)))
const primaryDomain = findPrimaryWorkerDomain(workerDomains, managedHostnames)
const desiredUrl = primaryDomain ? new URL(`https://${webDavHostname(primaryDomain.hostname)}`) : null
let desiredRule = null
if (desiredUrl) {
const zone = { id: primaryDomain.zone_id, name: primaryDomain.zone_name }
desiredRule = await ensureRewriteRule(client, zone, managedRules, desiredUrl.hostname)
await ensureWorkerDomain(client, accountId, domains, {
hostname: desiredUrl.hostname,
service: workerName,
zone_id: zone.id,
zone_name: zone.name,
})
await verifyWebDavDomain(desiredUrl, { verifyFetch, sleep, verifyAttempts, verifyDelayMs })
}
const staleRules = managedRules.filter(
(managed) => !desiredRule || managed.zone.id !== desiredRule.zone.id || managed.rule.id !== desiredRule.rule.id,
)
const staleHostnames = new Set(
staleRules
.map(({ rule }) => managedRuleHostname(rule))
.filter((hostname) => hostname !== desiredUrl?.hostname),
)
let removedDomains = 0
for (const domain of domains) {
if (domain.service === workerName && staleHostnames.has(domain.hostname)) {
await client.request(`/accounts/${accountId}/workers/domains/${domain.id}`, { method: 'DELETE' })
removedDomains += 1
}
}
for (const managed of staleRules) {
await client.request(`/zones/${managed.zone.id}/rulesets/${managed.rulesetId}/rules/${managed.rule.id}`, {
method: 'DELETE',
})
}
return {
hostname: desiredUrl?.hostname ?? null,
removedRules: staleRules.length,
removedDomains,
}
}
function workerDomainZones(domains) {
const zones = new Map()
for (const domain of domains) zones.set(domain.zone_id, { id: domain.zone_id, name: domain.zone_name })
return [...zones.values()]
}
function createApiClient({ token, apiFetch }) {
return {
async request(path, { method = 'GET', body, allowNotFound = false } = {}) {
const response = await apiFetch(`${API_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
if (allowNotFound && response.status === 404) return null
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) {
const errors = Array.isArray(payload?.errors)
? payload.errors.map((error) => error.message || error.code).join('; ')
: `HTTP ${response.status}`
throw new Error(`Cloudflare API ${method} ${path} failed: ${errors}`)
}
return payload
},
}
}
async function getTransformRuleset(client, zoneId) {
const payload = await client.request(`/zones/${zoneId}/rulesets/phases/${TRANSFORM_PHASE}/entrypoint`, {
allowNotFound: true,
})
return payload?.result ?? null
}
async function listManagedRules(client, zones) {
const managed = []
for (const zone of zones) {
const ruleset = await getTransformRuleset(client, zone.id)
if (!ruleset) continue
for (const rule of ruleset.rules ?? []) {
if (rule.ref?.startsWith(MANAGED_REF_PREFIX)) {
managedRuleHostname(rule)
managed.push({ zone, rulesetId: ruleset.id, rule })
}
}
}
return managed
}
async function ensureTransformRuleset(client, zone) {
const existing = await getTransformRuleset(client, zone.id)
if (existing) return existing
const payload = await client.request(`/zones/${zone.id}/rulesets`, {
method: 'POST',
body: {
name: RULESET_NAME,
description: 'ZPan-managed request rewrites',
kind: 'zone',
phase: TRANSFORM_PHASE,
},
})
return payload.result
}
async function ensureRewriteRule(client, zone, managedRules, hostname) {
const definition = buildRewriteRule(hostname)
const existing = managedRules.find(
(managed) => managed.zone.id === zone.id && managed.rule.ref === definition.ref,
)
if (existing) {
if (!sameRule(existing.rule, definition)) {
const payload = await client.request(
`/zones/${zone.id}/rulesets/${existing.rulesetId}/rules/${existing.rule.id}`,
{ method: 'PATCH', body: definition },
)
existing.rule = payload.result.rules.find((rule) => rule.ref === definition.ref) ?? {
...definition,
id: existing.rule.id,
}
}
return existing
}
const ruleset = await ensureTransformRuleset(client, zone)
const payload = await client.request(`/zones/${zone.id}/rulesets/${ruleset.id}/rules`, {
method: 'POST',
body: definition,
})
const rule = payload.result.rules.find((candidate) => candidate.ref === definition.ref)
if (!rule?.id) throw new Error(`Cloudflare did not return the created WebDAV rule for ${hostname}`)
return { zone, rulesetId: ruleset.id, rule }
}
function sameRule(rule, definition) {
return (
rule.ref === definition.ref &&
rule.description === definition.description &&
rule.expression === definition.expression &&
rule.action === definition.action &&
rule.enabled !== false &&
rule.action_parameters?.uri?.path?.expression === definition.action_parameters.uri.path.expression
)
}
function managedRuleHostname(rule) {
const match = /^http\.host eq "([^"\\]+)"$/.exec(rule.expression ?? '')
if (!match) throw new Error(`Managed WebDAV rule ${rule.ref} has an unexpected expression`)
return match[1]
}
async function listWorkerDomains(client, accountId) {
const payload = await client.request(`/accounts/${accountId}/workers/domains`)
return payload.result
}
async function ensureWorkerDomain(client, accountId, domains, desired) {
const existing = domains.find((domain) => domain.hostname === desired.hostname)
if (existing?.service === desired.service && existing.zone_id === desired.zone_id) return
if (existing && existing.service !== desired.service) {
throw new Error(`${desired.hostname} is already attached to Worker ${existing.service}`)
}
await client.request(`/accounts/${accountId}/workers/domains`, { method: 'PUT', body: desired })
}
async function verifyWebDavDomain(url, { verifyFetch, sleep, verifyAttempts, verifyDelayMs }) {
for (let attempt = 1; attempt <= verifyAttempts; attempt += 1) {
try {
const response = await verifyFetch(url, { method: 'OPTIONS', redirect: 'manual' })
if (response.status === 401 && response.headers.get('WWW-Authenticate') === 'Basic realm="ZPan WebDAV"') {
return
}
} catch (error) {
if (attempt === verifyAttempts) throw error
}
if (attempt < verifyAttempts) await sleep(verifyDelayMs)
}
throw new Error(`WebDAV custom domain did not become ready: ${url.origin}`)
}