码桶
发现社区成员的开源项目
dns_bulk.js9.7 KB
import { logAudit } from './_audit.js';
import { validateDnsRecord } from './_validate.js';
import { getUserAllowedZones, isZoneAllowed } from './_permissions.js';
export async function onRequestPost(context) {
const { cfHeaders } = context.data;
const username = context.data.user?.username || 'client';
const env = context.env;
const headers = {
...cfHeaders,
'Content-Type': 'application/json'
};
let body;
try {
body = await context.request.json();
} catch (e) {
return new Response(JSON.stringify({ error: 'Invalid JSON body.' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
const { operation, zones, record } = body;
// Validate operation
if (!operation || !['create', 'delete_matching'].includes(operation)) {
return new Response(JSON.stringify({ error: "operation must be 'create' or 'delete_matching'." }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
// Validate zones
if (!zones || (zones !== 'all' && !Array.isArray(zones))) {
return new Response(JSON.stringify({ error: "zones must be 'all' or an array of zone IDs." }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
// Limit zones array size to prevent abuse
if (Array.isArray(zones) && zones.length > 100) {
return new Response(JSON.stringify({ error: 'zones array must not exceed 100 items.' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
// Validate record
if (!record || typeof record !== 'object') {
return new Response(JSON.stringify({ error: 'record object is required.' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
if (operation === 'create' && (!record.type || !record.name || !record.content)) {
return new Response(JSON.stringify({ error: "record must include type, name, and content for 'create' operation." }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
// Validate DNS record fields
const requireAll = operation === 'create';
const recordErrors = validateDnsRecord(record, requireAll);
if (recordErrors.length > 0) {
return new Response(JSON.stringify({ error: 'Invalid record fields.', details: recordErrors }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
try {
// Resolve target zones
let targetZones;
if (zones === 'all') {
const allZones = [];
let page = 1;
let totalPages = 1;
while (page <= totalPages) {
const zonesRes = await fetch(
`https://api.cloudflare.com/client/v4/zones?per_page=50&page=${page}`,
{ headers }
);
const zonesData = await zonesRes.json();
if (!zonesData.success) {
return new Response(JSON.stringify({ error: 'Failed to fetch zones from Cloudflare API.', details: zonesData.errors }), {
status: 502, headers: { 'Content-Type': 'application/json' }
});
}
allZones.push(...(zonesData.result || []));
totalPages = zonesData.result_info?.total_pages || 1;
page++;
}
targetZones = allZones.map(z => ({ id: z.id, name: z.name }));
} else {
// Fetch zone details for each provided zone ID to get zone names
const zonePromises = zones.map(async (zoneId) => {
const zoneRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}`,
{ headers }
);
const zoneData = await zoneRes.json();
if (zoneData.success && zoneData.result) {
return { id: zoneData.result.id, name: zoneData.result.name };
}
return { id: zoneId, name: null };
});
targetZones = await Promise.all(zonePromises);
}
// Filter target zones based on user permissions
const user = context.data.user;
if (user && user.role !== 'admin') {
const allowedZones = await getUserAllowedZones(env.CF_DNS_KV, user.username);
if (allowedZones.length > 0) {
targetZones = targetZones.filter(z => z.name && isZoneAllowed(allowedZones, z.name));
}
}
if (targetZones.length === 0) {
return new Response(JSON.stringify({ results: [], message: 'No zones found.' }), {
status: 200, headers: { 'Content-Type': 'application/json' }
});
}
// Execute operation across all target zones in parallel
const resultPromises = targetZones.map(async (zone) => {
const result = { zoneId: zone.id, zoneName: zone.name, success: true, count: 0, errors: [] };
if (!zone.name) {
result.success = false;
result.errors.push('Failed to resolve zone details.');
return result;
}
try {
if (operation === 'create') {
return await createRecord(zone, record, headers, result);
} else {
return await deleteMatchingRecords(zone, record, headers, result);
}
} catch (e) {
result.success = false;
result.errors.push(e.message);
return result;
}
});
const results = await Promise.all(resultPromises);
// Audit log
const totalCount = results.reduce((sum, r) => sum + r.count, 0);
const successCount = results.filter(r => r.success).length;
await logAudit(
env.CF_DNS_KV,
username,
`dns.bulk.${operation}`,
`Bulk ${operation}: ${totalCount} records across ${successCount}/${results.length} zones`
);
return new Response(JSON.stringify({ results }), {
status: 200, headers: { 'Content-Type': 'application/json' }
});
} catch (e) {
return new Response(JSON.stringify({ error: 'Bulk operation failed.', message: e.message }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
/**
* Build the fully qualified domain name for a record.
* If the record name already ends with the zone name, use it as-is.
* Otherwise, append the zone name (e.g. `_dmarc` -> `_dmarc.example.com`).
*/
function resolveFQDN(name, zoneName) {
if (name === '@' || name === zoneName) {
return zoneName;
}
if (name.endsWith(`.${zoneName}`)) {
return name;
}
return `${name}.${zoneName}`;
}
/**
* Create a single DNS record in a zone.
*/
async function createRecord(zone, record, headers, result) {
const fqdn = resolveFQDN(record.name, zone.name);
const payload = {
type: record.type,
name: fqdn,
content: record.content,
};
if (record.ttl !== undefined) {
payload.ttl = record.ttl;
}
if (record.proxied !== undefined) {
payload.proxied = record.proxied;
}
const res = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zone.id}/dns_records`,
{
method: 'POST',
headers,
body: JSON.stringify(payload)
}
);
const data = await res.json();
if (data.success) {
result.count = 1;
} else {
result.success = false;
result.errors = (data.errors || []).map(e => e.message || JSON.stringify(e));
}
return result;
}
/**
* Delete all DNS records in a zone that match the given criteria.
* Any field (type, name, content) can be omitted to match all.
*/
async function deleteMatchingRecords(zone, record, headers, result) {
// Fetch all DNS records for the zone (paginated)
const allRecords = [];
let page = 1;
let totalPages = 1;
while (page <= totalPages) {
const res = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zone.id}/dns_records?per_page=100&page=${page}`,
{ headers }
);
const data = await res.json();
if (!data.success) {
result.success = false;
result.errors.push('Failed to fetch DNS records for zone.');
return result;
}
allRecords.push(...(data.result || []));
totalPages = data.result_info?.total_pages || 1;
page++;
}
// Filter records by matching criteria
const matchName = record.name ? resolveFQDN(record.name, zone.name) : null;
const matches = allRecords.filter(r => {
if (record.type && r.type !== record.type) return false;
if (matchName && r.name !== matchName) return false;
if (record.content && r.content !== record.content) return false;
return true;
});
// Delete each matching record
const deletePromises = matches.map(async (r) => {
const res = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zone.id}/dns_records/${r.id}`,
{ method: 'DELETE', headers }
);
const data = await res.json();
if (!data.success) {
result.errors.push(`Failed to delete ${r.type} ${r.name}: ${(data.errors || []).map(e => e.message).join(', ')}`);
}
return data.success;
});
const deleteResults = await Promise.all(deletePromises);
const deletedCount = deleteResults.filter(Boolean).length;
result.count = deletedCount;
if (result.errors.length > 0) {
result.success = false;
}
return result;
}