diff --git a/packages/dns/cloudflare/src/index.test.ts b/packages/dns/cloudflare/src/index.test.ts index 1d9b776e..8c6c2fed 100644 --- a/packages/dns/cloudflare/src/index.test.ts +++ b/packages/dns/cloudflare/src/index.test.ts @@ -1,4 +1,212 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; import { smokeTest } from '@profullstack/sh1pt-core/testing'; import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'dns' }); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const ctx = (secrets: Record = {}) => ({ + secret: (key: string) => secrets[key], + log: vi.fn(), +}); + +function cfResponse(result: unknown, resultInfo: Record = { total_pages: 1 }) { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify({ success: true, result, result_info: resultInfo }), + } as Response; +} + +describe('dns-cloudflare', () => { + it('requires an API token on connect', async () => { + await expect(adapter.connect(ctx() as any, {})).rejects.toThrow('CLOUDFLARE_API_TOKEN'); + }); + + it('lists zones and includes the bearer token', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(cfResponse([ + { id: 'zone-1', name: 'example.com' }, + ])); + + await adapter.connect(ctx({ CLOUDFLARE_API_TOKEN: 'cf-token' }) as any, {}); + const zones = await adapter.listZones({ accountId: 'acct-1' }); + + expect(zones).toEqual([{ id: 'zone-1', name: 'example.com' }]); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://api.cloudflare.com/client/v4/zones?per_page=100&account.id=acct-1&page=1'); + expect((init as RequestInit).headers).toMatchObject({ Authorization: 'Bearer cf-token' }); + }); + + it('maps Cloudflare DNS records into the shared shape', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(cfResponse([ + { + id: 'rec-1', + zone_id: 'zone-1', + zone_name: 'example.com', + name: 'api.example.com', + type: 'A', + content: '192.0.2.10', + ttl: 120, + proxied: true, + }, + ])); + + await adapter.connect(ctx({ CLOUDFLARE_API_TOKEN: 'cf-token' }) as any, {}); + const records = await adapter.listRecords('zone-1', {}); + + expect(records).toEqual([{ + id: 'rec-1', + zone: 'zone-1', + name: 'api.example.com', + type: 'A', + value: '192.0.2.10', + ttl: 120, + proxied: true, + }]); + }); + + it('creates a DNS record when no existing record matches', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(cfResponse([])) + .mockResolvedValueOnce(cfResponse({ id: 'zone-1', name: 'example.com' })) + .mockResolvedValueOnce(cfResponse({ + id: 'rec-created', + zone_id: 'zone-1', + zone_name: 'example.com', + name: 'api.example.com', + type: 'A', + content: '192.0.2.44', + ttl: 60, + proxied: false, + })); + + await adapter.connect(ctx({ CLOUDFLARE_API_TOKEN: 'cf-token' }) as any, {}); + const record = await adapter.upsertRecord('zone-1', { + zone: 'zone-1', + name: 'api', + type: 'A', + value: '192.0.2.44', + ttl: 60, + }, { defaultProxied: false }); + + expect(record.id).toBe('rec-created'); + expect(record.name).toBe('api.example.com'); + const createCall = fetchMock.mock.calls[2]!; + expect(createCall[0]).toBe('https://api.cloudflare.com/client/v4/zones/zone-1/dns_records'); + expect(JSON.parse(String((createCall[1] as RequestInit).body))).toEqual({ + type: 'A', + name: 'api.example.com', + content: '192.0.2.44', + ttl: 60, + proxied: false, + }); + }); + + it('updates a DNS record when name and type already exist', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(cfResponse([{ + id: 'rec-1', + zone_id: 'zone-1', + zone_name: 'example.com', + name: 'api.example.com', + type: 'A', + content: '192.0.2.10', + ttl: 120, + }])) + .mockResolvedValueOnce(cfResponse({ + id: 'rec-1', + zone_id: 'zone-1', + zone_name: 'example.com', + name: 'api.example.com', + type: 'A', + content: '192.0.2.11', + ttl: 300, + })); + + await adapter.connect(ctx({ CLOUDFLARE_API_TOKEN: 'cf-token' }) as any, {}); + const record = await adapter.upsertRecord('zone-1', { + zone: 'zone-1', + name: 'api', + type: 'A', + value: '192.0.2.11', + ttl: 300, + }, {}); + + expect(record.value).toBe('192.0.2.11'); + const updateCall = fetchMock.mock.calls[1]!; + expect(updateCall[0]).toBe('https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/rec-1'); + expect((updateCall[1] as RequestInit).method).toBe('PUT'); + }); + + it('diffs Cloudflare A records for round-robin sync', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(cfResponse([ + { + id: 'keep', + zone_id: 'zone-1', + zone_name: 'example.com', + name: 'api.example.com', + type: 'A', + content: '192.0.2.10', + ttl: 120, + }, + { + id: 'delete-me', + zone_id: 'zone-1', + zone_name: 'example.com', + name: 'api.example.com', + type: 'A', + content: '192.0.2.99', + ttl: 120, + }, + ])) + .mockResolvedValueOnce(cfResponse({ id: 'delete-me' })) + .mockResolvedValueOnce(cfResponse({ + id: 'created', + zone_id: 'zone-1', + zone_name: 'example.com', + name: 'api.example.com', + type: 'A', + content: '192.0.2.11', + ttl: 60, + proxied: true, + })); + + await adapter.connect(ctx({ CLOUDFLARE_API_TOKEN: 'cf-token' }) as any, {}); + const records = await adapter.syncRoundRobin({ + zoneId: 'zone-1', + name: 'api', + ips: ['192.0.2.10', '192.0.2.11'], + ttl: 60, + proxied: true, + }, {}); + + expect(records.map((record) => record.value)).toEqual(['192.0.2.10', '192.0.2.11']); + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/delete-me'); + expect((fetchMock.mock.calls[1]?.[1] as RequestInit).method).toBe('DELETE'); + const createCall = fetchMock.mock.calls[2]!; + expect(JSON.parse(String((createCall[1] as RequestInit).body))).toMatchObject({ + type: 'A', + name: 'api.example.com', + content: '192.0.2.11', + ttl: 60, + proxied: true, + }); + }); + + it('surfaces Cloudflare API errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + text: async () => JSON.stringify({ success: false, errors: [{ message: 'token does not have permission' }] }), + } as Response); + + await adapter.connect(ctx({ CLOUDFLARE_API_TOKEN: 'cf-token' }) as any, {}); + await expect(adapter.listRecords('zone-1', {})).rejects.toThrow('Cloudflare 403: token does not have permission'); + }); +}); diff --git a/packages/dns/cloudflare/src/index.ts b/packages/dns/cloudflare/src/index.ts index b2dcdf37..da33f3f4 100644 --- a/packages/dns/cloudflare/src/index.ts +++ b/packages/dns/cloudflare/src/index.ts @@ -2,61 +2,151 @@ import { defineDns, tokenSetup, type DnsRecord } from '@profullstack/sh1pt-core' // Cloudflare DNS API v4. Auth: Bearer token scoped to Zone.DNS:Edit. // Endpoints: /client/v4/zones, /client/v4/zones/:id/dns_records -// Cloudflare's 'orange cloud' (proxied=true) routes traffic through the -// CF edge — great default for waitlist pages, but disable it for -// round-robin to VPS backends that can't terminate TLS themselves. +// Cloudflare's "orange cloud" (proxied=true) routes traffic through the +// CF edge. Disable it for raw VPS round-robin records unless the fleet +// can terminate TLS behind Cloudflare. interface Config { accountId?: string; defaultTtl?: number; // 1 = auto; otherwise >= 60 defaultProxied?: boolean; } +interface CloudflareError { + code?: number; + message?: string; +} + +interface CloudflareResponse { + success: boolean; + errors?: CloudflareError[]; + messages?: CloudflareError[]; + result: T; + result_info?: { + page?: number; + total_pages?: number; + }; +} + +interface CloudflareZone { + id: string; + name: string; +} + +interface CloudflareRecord { + id: string; + zone_id?: string; + zone_name?: string; + name: string; + type: DnsRecord['type']; + content: string; + ttl: number; + proxied?: boolean; +} + const API = 'https://api.cloudflare.com/client/v4'; +let _secret: (k: string) => string | undefined = () => undefined; +const _zoneNames = new Map(); + +function authHeaders(): Record { + return { Authorization: `Bearer ${_secret('CLOUDFLARE_API_TOKEN')}` }; +} export default defineDns({ id: 'dns-cloudflare', label: 'Cloudflare DNS', async connect(ctx) { - if (!ctx.secret('CLOUDFLARE_API_TOKEN')) throw new Error('CLOUDFLARE_API_TOKEN not set'); + _secret = (k) => ctx.secret(k); + if (!ctx.secret('CLOUDFLARE_API_TOKEN')) { + throw new Error('CLOUDFLARE_API_TOKEN not set - run `sh1pt secret set CLOUDFLARE_API_TOKEN ...`'); + } + ctx.log('cloudflare dns connected'); return { accountId: 'cloudflare' }; }, - async listZones() { - // TODO: GET ${API}/zones → { result: [{ id, name }] } - return []; + async listZones(config) { + const account = config.accountId ? `&account.id=${encodeURIComponent(config.accountId)}` : ''; + const zones = await listAll(`/zones?per_page=100${account}`); + for (const zone of zones) _zoneNames.set(zone.id, zone.name); + return zones.map((zone) => ({ id: zone.id, name: zone.name })); }, async listRecords(zoneId) { - // TODO: GET ${API}/zones/${zoneId}/dns_records - return []; + const records = await listAll(`/zones/${encodeURIComponent(zoneId)}/dns_records?per_page=100`); + return records.map((record) => toDnsRecord(zoneId, record)); }, async upsertRecord(zoneId, record, config) { - // TODO: POST ${API}/zones/${zoneId}/dns_records (or PUT /:recordId for update) - const proxied = record.proxied ?? config.defaultProxied ?? false; - return { id: 'stub', ...record, zone: zoneId, proxied }; + const existing = await this.listRecords(zoneId, config); + const zoneName = _zoneNames.get(zoneId) ?? await resolveZoneName(zoneId); + const targetName = formatRecordName(record.name, zoneName); + const match = existing.find((candidate) => + candidate.type === record.type && sameRecordName(candidate.name, record.name, zoneName) + ); + const payload = toCloudflarePayload({ + ...record, + name: targetName, + ttl: record.ttl ?? config.defaultTtl ?? 1, + proxied: record.proxied ?? config.defaultProxied, + }); + + if (match) { + const updated = await request( + `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(match.id)}`, + { method: 'PUT', body: JSON.stringify(payload) }, + ); + return toDnsRecord(zoneId, updated.result); + } + + const created = await createRecord(zoneId, payload); + return toDnsRecord(zoneId, created); }, async deleteRecord(zoneId, recordId) { - // TODO: DELETE ${API}/zones/${zoneId}/dns_records/${recordId} + await request<{ id: string }>( + `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, + { method: 'DELETE' }, + { allowNotFound: true }, + ); }, async syncRoundRobin({ zoneId, name, ips, ttl, proxied }, config) { - const ttlFinal = ttl ?? config.defaultTtl ?? 60; - const proxiedFinal = proxied ?? config.defaultProxied ?? false; - // TODO: listRecords() → diff → create missing, delete extras, leave matching. - // Orange-cloud mode (proxied=true) makes CF the A record publicly; use only - // when backends are sh1pt-deployed VPSes with CF-issued origin certs. - return ips.map((ip, i) => ({ - id: `cf-stub-${i}`, - zone: zoneId, - name, - type: 'A' as const, - value: ip, - ttl: ttlFinal, - proxied: proxiedFinal, - })) satisfies DnsRecord[]; + const existing = await this.listRecords(zoneId, config); + const zoneName = _zoneNames.get(zoneId) ?? await resolveZoneName(zoneId); + const desired = new Set(ips); + const seen = new Set(); + const kept: DnsRecord[] = []; + const current = existing.filter((record) => + record.type === 'A' && sameRecordName(record.name, name, zoneName) + ); + + for (const record of current) { + if (desired.has(record.value) && !seen.has(record.value)) { + seen.add(record.value); + kept.push(record); + } else { + await this.deleteRecord(zoneId, record.id, config); + } + } + + const targetName = formatRecordName(name, zoneName); + const ttlFinal = ttl ?? config.defaultTtl ?? 1; + const proxiedFinal = proxied ?? config.defaultProxied; + const created: DnsRecord[] = []; + for (const ip of ips) { + if (seen.has(ip)) continue; + const record = await createRecord(zoneId, toCloudflarePayload({ + zone: zoneId, + name: targetName, + type: 'A', + value: ip, + ttl: ttlFinal, + proxied: proxiedFinal, + })); + created.push(toDnsRecord(zoneId, record)); + } + + return [...kept, ...created]; }, setup: tokenSetup({ @@ -64,9 +154,95 @@ export default defineDns({ label: 'Cloudflare DNS', vendorDocUrl: 'https://dash.cloudflare.com/profile/api-tokens', steps: [ - 'Open dash.cloudflare.com → My Profile → API Tokens → Create Token', + 'Open dash.cloudflare.com -> My Profile -> API Tokens -> Create Token', 'Use the "Edit zone DNS" template (or custom with Zone.DNS:Edit)', - 'Scope to the zones sh1pt should manage → Continue → Create → copy the token', + 'Scope to the zones sh1pt should manage -> Continue -> Create -> copy the token', ], }), }); + +async function listAll(path: string): Promise { + const items: T[] = []; + let page = 1; + let totalPages = 1; + do { + const sep = path.includes('?') ? '&' : '?'; + const response = await request(`${path}${sep}page=${page}`); + items.push(...response.result); + totalPages = response.result_info?.total_pages ?? page; + page += 1; + } while (page <= totalPages); + return items; +} + +async function request( + path: string, + init: RequestInit = {}, + opts: { allowNotFound?: boolean } = {}, +): Promise> { + const headers = { + ...authHeaders(), + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + ...(init.headers as Record | undefined), + }; + const res = await fetch(`${API}${path}`, { ...init, headers }); + if (opts.allowNotFound && res.status === 404) { + return { success: true, result: undefined as T }; + } + const text = await res.text(); + const data = text ? JSON.parse(text) as CloudflareResponse : { success: res.ok, result: undefined as T }; + if (!res.ok || data.success === false) { + const message = data.errors?.map((err) => err.message).filter(Boolean).join('; ') || res.statusText; + throw new Error(`Cloudflare ${res.status}: ${message}`); + } + return data; +} + +async function resolveZoneName(zoneId: string): Promise { + if (_zoneNames.has(zoneId)) return _zoneNames.get(zoneId); + const zone = await request(`/zones/${encodeURIComponent(zoneId)}`); + if (zone.result?.name) _zoneNames.set(zoneId, zone.result.name); + return zone.result?.name; +} + +async function createRecord(zoneId: string, payload: Record): Promise { + const created = await request( + `/zones/${encodeURIComponent(zoneId)}/dns_records`, + { method: 'POST', body: JSON.stringify(payload) }, + ); + return created.result; +} + +function toCloudflarePayload(record: Omit): Record { + return { + type: record.type, + name: record.name, + content: record.value, + ttl: record.ttl, + ...(record.proxied !== undefined ? { proxied: record.proxied } : {}), + }; +} + +function toDnsRecord(zoneId: string, record: CloudflareRecord): DnsRecord { + if (record.zone_id && record.zone_name) _zoneNames.set(record.zone_id, record.zone_name); + return { + id: record.id, + zone: record.zone_id ?? zoneId, + name: record.name, + type: record.type, + value: record.content, + ttl: record.ttl, + proxied: record.proxied, + }; +} + +function formatRecordName(name: string, zoneName?: string): string { + if (name === '@') return zoneName ?? name; + if (!zoneName || name.includes('.')) return name; + return `${name}.${zoneName}`; +} + +function sameRecordName(recordName: string, requestedName: string, zoneName?: string): boolean { + return recordName === requestedName || recordName === formatRecordName(requestedName, zoneName); +} +