码桶

发现社区成员的开源项目

dns_history.js15.8 KB
import { logAudit } from '../../_audit.js';
import { saveSnapshot } from '../../_snapshot.js';
import { fireWebhook } from '../../_webhook.js';

// Compute diff between two sets of DNS records.
// Records are keyed by type+name. Returns { added, removed, modified }.
function computeDiff(fromRecords, toRecords) {
    const makeKey = (r) => `${r.type}::${r.name}`;

    // Build maps: key -> array of records (multiple records can share type+name)
    const fromMap = new Map();
    for (const rec of fromRecords) {
        const k = makeKey(rec);
        if (!fromMap.has(k)) fromMap.set(k, []);
        fromMap.get(k).push(rec);
    }

    const toMap = new Map();
    for (const rec of toRecords) {
        const k = makeKey(rec);
        if (!toMap.has(k)) toMap.set(k, []);
        toMap.get(k).push(rec);
    }

    const added = [];
    const removed = [];
    const modified = [];

    const allKeys = new Set([...fromMap.keys(), ...toMap.keys()]);

    for (const key of allKeys) {
        const fromRecs = fromMap.get(key) || [];
        const toRecs = toMap.get(key) || [];

        if (fromRecs.length === 0) {
            // All records with this key are new
            for (const rec of toRecs) {
                added.push({ type: rec.type, name: rec.name, content: rec.content, ttl: rec.ttl, proxied: rec.proxied, priority: rec.priority });
            }
        } else if (toRecs.length === 0) {
            // All records with this key were removed
            for (const rec of fromRecs) {
                removed.push({ type: rec.type, name: rec.name, content: rec.content, ttl: rec.ttl, proxied: rec.proxied, priority: rec.priority });
            }
        } else {
            // Match records by content to find modifications
            const fromContents = new Map();
            for (const rec of fromRecs) {
                fromContents.set(rec.content, rec);
            }
            const toContents = new Map();
            for (const rec of toRecs) {
                toContents.set(rec.content, rec);
            }

            // Records in 'to' but not in 'from' by content
            for (const [content, rec] of toContents) {
                if (!fromContents.has(content)) {
                    // Check if there's an unmatched 'from' record to pair as modified
                    let paired = false;
                    for (const [fContent, fRec] of fromContents) {
                        if (!toContents.has(fContent)) {
                            // Pair as modified
                            const pick = (r) => ({ type: r.type, name: r.name, content: r.content, ttl: r.ttl, proxied: r.proxied, priority: r.priority });
                            modified.push({ before: pick(fRec), after: pick(rec) });
                            fromContents.delete(fContent);
                            paired = true;
                            break;
                        }
                    }
                    if (!paired) {
                        added.push({ type: rec.type, name: rec.name, content: rec.content, ttl: rec.ttl, proxied: rec.proxied, priority: rec.priority });
                    }
                } else {
                    // Same content exists in both — check if ttl/proxied differ
                    const fRec = fromContents.get(content);
                    if (fRec.ttl !== rec.ttl || fRec.proxied !== rec.proxied || fRec.priority !== rec.priority) {
                        const pick = (r) => ({ type: r.type, name: r.name, content: r.content, ttl: r.ttl, proxied: r.proxied, priority: r.priority });
                        modified.push({ before: pick(fRec), after: pick(rec) });
                    }
                    // else identical, skip
                }
            }

            // Records in 'from' not matched at all
            for (const [fContent, fRec] of fromContents) {
                if (!toContents.has(fContent)) {
                    // Check it wasn't already paired above
                    const alreadyPaired = modified.some(m => m.before.content === fContent && m.before.type === fRec.type && m.before.name === fRec.name);
                    if (!alreadyPaired) {
                        removed.push({ type: fRec.type, name: fRec.name, content: fRec.content, ttl: fRec.ttl, proxied: fRec.proxied, priority: fRec.priority });
                    }
                }
            }
        }
    }

    return { added, removed, modified };
}

// GET: List snapshots for a zone, or return full snapshot for rollback preview
export async function onRequestGet(context) {
    const { zoneId } = context.params;
    const kv = context.env.CF_DNS_KV;

    if (!kv) {
        return new Response(JSON.stringify({ error: 'KV storage not configured.' }), {
            status: 500,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    const url = new URL(context.request.url);
    const fullKey = url.searchParams.get('full');
    const action = url.searchParams.get('action');

    // Handle diff action: ?action=diff&from=KEY1&to=KEY2
    // 'to' can be the literal string "live" to compare against current live records
    if (action === 'diff') {
        const fromKey = url.searchParams.get('from');
        const toKey = url.searchParams.get('to');

        if (!fromKey || !toKey) {
            return new Response(JSON.stringify({ error: 'Both "from" and "to" parameters are required.' }), {
                status: 400,
                headers: { 'Content-Type': 'application/json' }
            });
        }

        // Load 'from' snapshot
        const fromRaw = await kv.get(fromKey);
        if (!fromRaw) {
            return new Response(JSON.stringify({ error: 'Source snapshot not found.' }), {
                status: 404,
                headers: { 'Content-Type': 'application/json' }
            });
        }
        const fromSnapshot = JSON.parse(fromRaw);
        const fromRecords = fromSnapshot.records || [];

        let toRecords;
        if (toKey === 'live') {
            // Fetch current live DNS records from Cloudflare
            const { cfHeaders } = context.data;
            const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?per_page=1000`, {
                headers: {
                    ...cfHeaders,
                    'Content-Type': 'application/json'
                }
            });
            const data = await res.json();
            if (!data.success) {
                return new Response(JSON.stringify({ error: 'Failed to fetch current DNS records.', details: data.errors }), {
                    status: 502,
                    headers: { 'Content-Type': 'application/json' }
                });
            }
            toRecords = data.result || [];
        } else {
            const toRaw = await kv.get(toKey);
            if (!toRaw) {
                return new Response(JSON.stringify({ error: 'Target snapshot not found.' }), {
                    status: 404,
                    headers: { 'Content-Type': 'application/json' }
                });
            }
            const toSnapshot = JSON.parse(toRaw);
            toRecords = toSnapshot.records || [];
        }

        const diff = computeDiff(fromRecords, toRecords);

        return new Response(JSON.stringify({ diff }), {
            headers: { 'Content-Type': 'application/json' }
        });
    }

    // If ?full={key} is provided, return the complete snapshot with records
    if (fullKey) {
        const raw = await kv.get(fullKey);
        if (!raw) {
            return new Response(JSON.stringify({ error: 'Snapshot not found.' }), {
                status: 404,
                headers: { 'Content-Type': 'application/json' }
            });
        }
        const snapshot = JSON.parse(raw);
        return new Response(JSON.stringify({ snapshot }), {
            headers: { 'Content-Type': 'application/json' }
        });
    }

    // Parse pagination params
    const page = Math.max(1, parseInt(url.searchParams.get('page')) || 1);
    const per_page = Math.min(100, Math.max(1, parseInt(url.searchParams.get('per_page')) || 10));

    // Try to read from the snapshot list index first
    const listKey = `DNS_SNAPSHOTS:${zoneId}`;
    let snapshots = [];
    const rawList = await kv.get(listKey);

    if (rawList) {
        try {
            snapshots = JSON.parse(rawList);
        } catch (e) { snapshots = []; }
    }

    // Fallback: if no index exists, build from KV prefix scan
    if (snapshots.length === 0) {
        const list = await kv.list({ prefix: `DNS_SNAPSHOT:${zoneId}:` });
        for (const key of list.keys) {
            const parts = key.name.split(':');
            const timestamp = parts.slice(2).join(':');
            const raw = await kv.get(key.name);
            if (raw) {
                try {
                    const data = JSON.parse(raw);
                    snapshots.push({
                        key: key.name,
                        timestamp: data.timestamp,
                        username: data.username,
                        action: data.action
                    });
                } catch (e) {
                    snapshots.push({ key: key.name, timestamp, username: 'unknown', action: 'unknown' });
                }
            }
        }
    }

    // Sort by timestamp descending (newest first)
    snapshots.sort((a, b) => b.timestamp.localeCompare(a.timestamp));

    const total = snapshots.length;
    const total_pages = Math.max(1, Math.ceil(total / per_page));
    const start = (page - 1) * per_page;
    const paginated = snapshots.slice(start, start + per_page);

    return new Response(JSON.stringify({ snapshots: paginated, total, page, per_page, total_pages }), {
        headers: { 'Content-Type': 'application/json' }
    });
}

// POST: Rollback to a snapshot
export async function onRequestPost(context) {
    const { cfHeaders } = context.data;
    const { zoneId } = context.params;
    const kv = context.env.CF_DNS_KV;
    const username = context.data.user?.username || 'client';

    if (!kv) {
        return new Response(JSON.stringify({ error: 'KV storage not configured.' }), {
            status: 500,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    const body = await context.request.json();
    const { snapshotKey } = body;

    if (!snapshotKey) {
        return new Response(JSON.stringify({ error: 'snapshotKey is required.' }), {
            status: 400,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    // Fetch the target snapshot
    const raw = await kv.get(snapshotKey);
    if (!raw) {
        return new Response(JSON.stringify({ error: 'Snapshot not found.' }), {
            status: 404,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    const snapshot = JSON.parse(raw);
    const targetRecords = snapshot.records || [];

    // Snapshot current state before rollback
    await saveSnapshot(kv, zoneId, username, 'dns.rollback', cfHeaders);

    // Fetch current records
    const currentRes = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?per_page=1000`, {
        headers: {
            ...cfHeaders,
            'Content-Type': 'application/json'
        }
    });
    const currentData = await currentRes.json();
    if (!currentData.success) {
        return new Response(JSON.stringify({ error: 'Failed to fetch current DNS records.', details: currentData.errors }), {
            status: 502,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    const currentRecords = currentData.result || [];

    // Build maps for diffing
    const currentMap = new Map();
    for (const rec of currentRecords) {
        currentMap.set(rec.id, rec);
    }

    const targetMap = new Map();
    for (const rec of targetRecords) {
        targetMap.set(rec.id, rec);
    }

    const results = { deleted: 0, created: 0, updated: 0, errors: [] };
    const cfHeadersWithCt = {
        ...cfHeaders,
        'Content-Type': 'application/json'
    };

    // Delete records that exist now but not in the snapshot
    for (const [id, rec] of currentMap) {
        if (!targetMap.has(id)) {
            const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${id}`, {
                method: 'DELETE',
                headers: cfHeadersWithCt
            });
            const d = await res.json();
            if (d.success) {
                results.deleted++;
            } else {
                results.errors.push({ action: 'delete', id, errors: d.errors });
            }
        }
    }

    // Create records that exist in snapshot but not currently
    for (const [id, rec] of targetMap) {
        if (!currentMap.has(id)) {
            const createBody = {
                type: rec.type,
                name: rec.name,
                content: rec.content,
                ttl: rec.ttl,
                proxied: rec.proxied
            };
            // Include priority for MX/SRV records
            if (rec.priority !== undefined) {
                createBody.priority = rec.priority;
            }
            // Include data for SRV/CAA/etc records
            if (rec.data) {
                createBody.data = rec.data;
            }
            const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
                method: 'POST',
                headers: cfHeadersWithCt,
                body: JSON.stringify(createBody)
            });
            const d = await res.json();
            if (d.success) {
                results.created++;
            } else {
                results.errors.push({ action: 'create', record: createBody, errors: d.errors });
            }
        }
    }

    // Update records that exist in both but differ
    for (const [id, targetRec] of targetMap) {
        if (currentMap.has(id)) {
            const currentRec = currentMap.get(id);
            // Check if record needs updating
            const needsUpdate =
                currentRec.type !== targetRec.type ||
                currentRec.name !== targetRec.name ||
                currentRec.content !== targetRec.content ||
                currentRec.ttl !== targetRec.ttl ||
                currentRec.proxied !== targetRec.proxied ||
                currentRec.priority !== targetRec.priority;

            if (needsUpdate) {
                const updateBody = {
                    type: targetRec.type,
                    name: targetRec.name,
                    content: targetRec.content,
                    ttl: targetRec.ttl,
                    proxied: targetRec.proxied
                };
                if (targetRec.priority !== undefined) {
                    updateBody.priority = targetRec.priority;
                }
                if (targetRec.data) {
                    updateBody.data = targetRec.data;
                }
                const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${id}`, {
                    method: 'PUT',
                    headers: cfHeadersWithCt,
                    body: JSON.stringify(updateBody)
                });
                const d = await res.json();
                if (d.success) {
                    results.updated++;
                } else {
                    results.errors.push({ action: 'update', id, errors: d.errors });
                }
            }
        }
    }

    await logAudit(kv, username, 'dns.rollback', `Rolled back zone ${zoneId} to snapshot ${snapshot.timestamp} (deleted: ${results.deleted}, created: ${results.created}, updated: ${results.updated})`);
    await fireWebhook(kv, {
        type: 'dns.rollback',
        username,
        detail: `Rolled back zone ${zoneId} to snapshot ${snapshot.timestamp} (deleted: ${results.deleted}, created: ${results.created}, updated: ${results.updated})`
    });

    return new Response(JSON.stringify({ success: true, results }), {
        headers: { 'Content-Type': 'application/json' }
    });
}