码桶

发现社区成员的开源项目

dns_import.js6.3 KB
import { logAudit } from '../../_audit.js';
import { saveSnapshot } from '../../_snapshot.js';
import { fireWebhook } from '../../_webhook.js';

const VALID_DNS_TYPES = new Set([
    'A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'SRV', 'CAA', 'PTR', 'SPF',
    'LOC', 'NAPTR', 'CERT', 'DNSKEY', 'DS', 'HTTPS', 'SSHFP', 'SVCB', 'TLSA', 'URI'
]);

function validateDnsRecord(record) {
    const errors = [];

    if (!record.type || !VALID_DNS_TYPES.has(record.type)) {
        errors.push(`Invalid record type: "${record.type || ''}". Must be one of: ${[...VALID_DNS_TYPES].join(', ')}`);
    }

    if (!record.name || typeof record.name !== 'string' || record.name.trim().length === 0) {
        errors.push('Record name is required.');
    } else if (record.name.length > 253) {
        errors.push(`Record name exceeds maximum length of 253 characters (got ${record.name.length}).`);
    }

    if (!record.content || typeof record.content !== 'string' || record.content.trim().length === 0) {
        errors.push('Record content is required.');
    } else if (record.content.length > 4096) {
        errors.push(`Record content exceeds maximum length of 4096 characters (got ${record.content.length}).`);
    }

    if (record.ttl !== undefined && record.ttl !== null) {
        const ttl = Number(record.ttl);
        if (!Number.isInteger(ttl) || ttl < 1) {
            errors.push('TTL must be a positive integer (use 1 for automatic).');
        }
    }

    if (record.priority !== undefined && record.priority !== null) {
        const priority = Number(record.priority);
        if (!Number.isInteger(priority) || priority < 0 || priority > 65535) {
            errors.push('Priority must be an integer between 0 and 65535.');
        }
    }

    return errors;
}

export async function onRequestPost(context) {
    const { cfHeaders } = context.data;
    const { zoneId } = context.params;
    const username = context.data.user?.username || 'client';
    const kv = context.env.CF_DNS_KV;

    // Check Content-Type to determine mode
    const contentType = context.request.headers.get('Content-Type') || '';

    // JSON bulk import mode
    if (contentType.includes('application/json')) {
        let body;
        try {
            body = await context.request.json();
        } catch {
            return new Response(JSON.stringify({
                success: false,
                error: 'Invalid JSON body'
            }), { status: 400, headers: { 'Content-Type': 'application/json' } });
        }

        const records = body.records;
        if (!Array.isArray(records) || records.length === 0) {
            return new Response(JSON.stringify({
                success: false,
                error: 'No records provided. Expected { records: [...] }'
            }), { status: 400, headers: { 'Content-Type': 'application/json' } });
        }

        if (records.length > 100) {
            return new Response(JSON.stringify({
                success: false,
                error: 'Maximum 100 records per import'
            }), { status: 400, headers: { 'Content-Type': 'application/json' } });
        }

        // Snapshot before mutation
        await saveSnapshot(kv, zoneId, username, 'dns.bulk_import', cfHeaders);

        let created = 0;
        const errors = [];

        // Create records in sequence to avoid rate limits
        for (let i = 0; i < records.length; i++) {
            const record = records[i];

            // Validate
            const validationErrors = validateDnsRecord(record);
            if (validationErrors.length > 0) {
                errors.push({ index: i, error: validationErrors.join('; ') });
                continue;
            }

            // Build payload
            const payload = {
                type: record.type,
                name: record.name,
                content: record.content,
                ttl: record.ttl || 1
            };

            if (record.priority !== undefined && record.priority !== null) {
                payload.priority = Number(record.priority);
            }

            if (record.proxied !== undefined) {
                // Only A, AAAA, CNAME support proxied
                if (['A', 'AAAA', 'CNAME'].includes(record.type)) {
                    payload.proxied = Boolean(record.proxied);
                }
            }

            if (record.comment) {
                payload.comment = record.comment;
            }

            try {
                const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
                    method: 'POST',
                    headers: {
                        ...cfHeaders,
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify(payload)
                });

                const data = await response.json();
                if (data.success) {
                    created++;
                } else {
                    const errMsg = data.errors?.map(e => e.message).join('; ') || 'Unknown error';
                    errors.push({ index: i, error: errMsg });
                }
            } catch (err) {
                errors.push({ index: i, error: err.message || 'Network error' });
            }
        }

        // Audit log
        await logAudit(kv, username, 'dns.bulk_import', `Imported ${created}/${records.length} records (zone: ${zoneId})`);
        await fireWebhook(kv, {
            type: 'dns.bulk_import',
            username,
            detail: `Imported ${created}/${records.length} records (zone: ${zoneId})`
        });

        return new Response(JSON.stringify({
            success: true,
            created,
            total: records.length,
            errors
        }), {
            status: 200,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    // Fallback: Proxy the multipart form data request (existing BIND file import)
    const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/import`, {
        method: 'POST',
        headers: cfHeaders,
        // Don't set Content-Type, let the browser/fetch handle it for multipart
        body: context.request.body
    });

    const data = await response.json();
    return new Response(JSON.stringify(data), {
        status: response.status,
        headers: { 'Content-Type': 'application/json' }
    });
}