@bakers-build/addon-ports-cloudflare 0.0.0-stage → 0.1.0-next.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/src/cloudflare.d.ts +57 -0
- package/dist/src/cloudflare.js +92 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +69 -0
- package/dist/src/reconcile.d.ts +10 -0
- package/dist/src/reconcile.js +200 -0
- package/package.json +23 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 keytec GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @bakers-build/addon-ports-cloudflare
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
bakers.build ports transport: a named Cloudflare Tunnel, reconciled via the Cloudflare API (DNS + ingress). Implements @bakers-build/addon-ports's PortsTransport contract.
|
|
4
|
+
|
|
5
|
+
Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface CloudflareConfig {
|
|
2
|
+
readonly accountId: string;
|
|
3
|
+
readonly tunnelId: string;
|
|
4
|
+
readonly zoneId: string;
|
|
5
|
+
/** The bare zone hostname (e.g. `preview.example`) — carried here so
|
|
6
|
+
* `index.ts` can surface it as `PortsTransport.zone` for hostname
|
|
7
|
+
* construction; `cloudflare.ts`'s own API calls use `zoneId`, not this. */
|
|
8
|
+
readonly zone: string;
|
|
9
|
+
readonly apiToken: string;
|
|
10
|
+
}
|
|
11
|
+
export interface CfOriginRequest {
|
|
12
|
+
readonly httpHostHeader?: string;
|
|
13
|
+
readonly noTLSVerify?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface CfIngressRule {
|
|
16
|
+
readonly hostname?: string;
|
|
17
|
+
readonly service: string;
|
|
18
|
+
readonly originRequest?: CfOriginRequest;
|
|
19
|
+
}
|
|
20
|
+
export interface CfFetchResponse {
|
|
21
|
+
readonly ok: boolean;
|
|
22
|
+
readonly status: number;
|
|
23
|
+
json(): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
/** The HTTP seam, injected — a test fakes it, nothing here calls real `fetch`. */
|
|
26
|
+
export interface CloudflareDeps {
|
|
27
|
+
fetch(url: string, init: {
|
|
28
|
+
readonly method: string;
|
|
29
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
30
|
+
readonly body?: string;
|
|
31
|
+
readonly signal?: AbortSignal;
|
|
32
|
+
}): Promise<CfFetchResponse>;
|
|
33
|
+
readonly timeoutMs: number;
|
|
34
|
+
}
|
|
35
|
+
export declare class CloudflareApiError extends Error {
|
|
36
|
+
constructor(message: string);
|
|
37
|
+
}
|
|
38
|
+
/** The tunnel's currently uploaded ingress — read before replacing it, so an
|
|
39
|
+
* unexpected unmanaged rule set can block the replace rather than vanish. */
|
|
40
|
+
export declare function getIngressConfig(cfg: CloudflareConfig, deps: CloudflareDeps): Promise<{
|
|
41
|
+
readonly ingress: readonly CfIngressRule[];
|
|
42
|
+
}>;
|
|
43
|
+
/** Replace the WHOLE ingress list — Cloudflare's configuration API has no
|
|
44
|
+
* partial update, so every call uploads the complete desired set. */
|
|
45
|
+
export declare function putIngressConfig(cfg: CloudflareConfig, ingress: readonly CfIngressRule[], deps: CloudflareDeps): Promise<void>;
|
|
46
|
+
export interface CfDnsRecord {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly content: string;
|
|
49
|
+
}
|
|
50
|
+
/** The exact-name CNAME, if any — used both to detect a foreign-owned
|
|
51
|
+
* conflict and to adopt a record this tunnel already created. */
|
|
52
|
+
export declare function findDnsRecord(cfg: CloudflareConfig, hostname: string, deps: CloudflareDeps): Promise<CfDnsRecord | null>;
|
|
53
|
+
/** This tunnel's own target for a proxied CNAME — the only content a record
|
|
54
|
+
* this reconciliation may delete is allowed to carry (ownership check). */
|
|
55
|
+
export declare function tunnelCnameTarget(cfg: CloudflareConfig): string;
|
|
56
|
+
export declare function createDnsRecord(cfg: CloudflareConfig, hostname: string, deps: CloudflareDeps): Promise<CfDnsRecord>;
|
|
57
|
+
export declare function deleteDnsRecord(cfg: CloudflareConfig, recordId: string, deps: CloudflareDeps): Promise<void>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// BB-676 Task 3, moved to its own package by BB-681 Task 2 — the
|
|
2
|
+
// Cloudflare REST calls a reconciliation needs: upload the tunnel's ingress
|
|
3
|
+
// configuration and own the proxied CNAME per hostname. See
|
|
4
|
+
// https://developers.cloudflare.com/api/resources/zero_trust/subresources/tunnels/subresources/cloudflared/subresources/configurations/
|
|
5
|
+
// and https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel-api/
|
|
6
|
+
// for the API shapes this wraps; locking/ownership/retry semantics are this
|
|
7
|
+
// ticket's own decisions, not the API's.
|
|
8
|
+
export class CloudflareApiError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'CloudflareApiError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Replace the token with a fixed marker wherever it would otherwise leak
|
|
15
|
+
* into a thrown message, a log line or a CLI argument. */
|
|
16
|
+
function redact(token, message) {
|
|
17
|
+
return token ? message.split(token).join('[REDACTED]') : message;
|
|
18
|
+
}
|
|
19
|
+
async function call(cfg, deps, path, init) {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
|
|
22
|
+
try {
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await deps.fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
26
|
+
method: init.method,
|
|
27
|
+
headers: {
|
|
28
|
+
authorization: `Bearer ${cfg.apiToken}`,
|
|
29
|
+
'content-type': 'application/json',
|
|
30
|
+
},
|
|
31
|
+
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37
|
+
throw new CloudflareApiError(redact(cfg.apiToken, `${init.method} ${path} failed: ${msg}`));
|
|
38
|
+
}
|
|
39
|
+
const body = (await res.json().catch(() => ({})));
|
|
40
|
+
if (!res.ok || body.success === false) {
|
|
41
|
+
const detail = (body.errors ?? []).map((e) => e.message).join('; ') ||
|
|
42
|
+
`HTTP ${res.status}`;
|
|
43
|
+
throw new CloudflareApiError(redact(cfg.apiToken, `${init.method} ${path} failed: ${detail}`));
|
|
44
|
+
}
|
|
45
|
+
return body.result;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** The tunnel's currently uploaded ingress — read before replacing it, so an
|
|
52
|
+
* unexpected unmanaged rule set can block the replace rather than vanish. */
|
|
53
|
+
export async function getIngressConfig(cfg, deps) {
|
|
54
|
+
const result = await call(cfg, deps, `/accounts/${cfg.accountId}/cfd_tunnel/${cfg.tunnelId}/configurations`, { method: 'GET' });
|
|
55
|
+
const config = result
|
|
56
|
+
?.config;
|
|
57
|
+
return { ingress: config?.ingress ?? [] };
|
|
58
|
+
}
|
|
59
|
+
/** Replace the WHOLE ingress list — Cloudflare's configuration API has no
|
|
60
|
+
* partial update, so every call uploads the complete desired set. */
|
|
61
|
+
export async function putIngressConfig(cfg, ingress, deps) {
|
|
62
|
+
await call(cfg, deps, `/accounts/${cfg.accountId}/cfd_tunnel/${cfg.tunnelId}/configurations`, { method: 'PUT', body: { config: { ingress } } });
|
|
63
|
+
}
|
|
64
|
+
/** The exact-name CNAME, if any — used both to detect a foreign-owned
|
|
65
|
+
* conflict and to adopt a record this tunnel already created. */
|
|
66
|
+
export async function findDnsRecord(cfg, hostname, deps) {
|
|
67
|
+
const result = await call(cfg, deps, `/zones/${cfg.zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(hostname)}`, { method: 'GET' });
|
|
68
|
+
const records = result ?? [];
|
|
69
|
+
return records[0] ?? null;
|
|
70
|
+
}
|
|
71
|
+
/** This tunnel's own target for a proxied CNAME — the only content a record
|
|
72
|
+
* this reconciliation may delete is allowed to carry (ownership check). */
|
|
73
|
+
export function tunnelCnameTarget(cfg) {
|
|
74
|
+
return `${cfg.tunnelId}.cfargotunnel.com`;
|
|
75
|
+
}
|
|
76
|
+
export async function createDnsRecord(cfg, hostname, deps) {
|
|
77
|
+
const result = await call(cfg, deps, `/zones/${cfg.zoneId}/dns_records`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
body: {
|
|
80
|
+
type: 'CNAME',
|
|
81
|
+
name: hostname,
|
|
82
|
+
content: tunnelCnameTarget(cfg),
|
|
83
|
+
proxied: true,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
export async function deleteDnsRecord(cfg, recordId, deps) {
|
|
89
|
+
await call(cfg, deps, `/zones/${cfg.zoneId}/dns_records/${recordId}`, {
|
|
90
|
+
method: 'DELETE',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PortsTransport } from '@bakers-build/addon-ports/transport';
|
|
2
|
+
export type { CloudflareConfig, CloudflareDeps } from './cloudflare.js';
|
|
3
|
+
export { reconcileCloudflare } from './reconcile.js';
|
|
4
|
+
/** The `@bakers-build/addon-ports` entry's `with.transport.with` for this transport. */
|
|
5
|
+
export interface CloudflareSettings {
|
|
6
|
+
readonly accountId: string;
|
|
7
|
+
readonly tunnelId: string;
|
|
8
|
+
readonly zoneId: string;
|
|
9
|
+
readonly zone: string;
|
|
10
|
+
readonly apiToken: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Default export factory consumed by `@bakers-build/addon-ports`'s
|
|
14
|
+
* `loadPortsTransport`. `identity` deliberately excludes `apiToken` — a
|
|
15
|
+
* rotated token with the same account/tunnel/zone is NOT an identity change.
|
|
16
|
+
*/
|
|
17
|
+
export default function createTransport(settings: unknown, _context: {
|
|
18
|
+
readonly home: string;
|
|
19
|
+
}): Promise<PortsTransport>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// BB-681 Task 2 — Named Cloudflare as a `PortsTransport`. The connector
|
|
2
|
+
// (`cloudflared` daemon) is operator-managed: this package never starts or
|
|
3
|
+
// stops it, only reconciles DNS + ingress via the Cloudflare REST API.
|
|
4
|
+
import { reconcileCloudflare } from './reconcile.js';
|
|
5
|
+
export { reconcileCloudflare } from './reconcile.js';
|
|
6
|
+
const REQUIRED_FIELDS = [
|
|
7
|
+
'accountId',
|
|
8
|
+
'tunnelId',
|
|
9
|
+
'zoneId',
|
|
10
|
+
'zone',
|
|
11
|
+
'apiToken',
|
|
12
|
+
];
|
|
13
|
+
function validateSettings(settings) {
|
|
14
|
+
if (typeof settings !== 'object' || settings === null) {
|
|
15
|
+
throw new Error("the @bakers-build/addon-ports entry's with.transport.with for 'cloudflare' must be an object with accountId/tunnelId/zoneId/zone/apiToken");
|
|
16
|
+
}
|
|
17
|
+
const obj = settings;
|
|
18
|
+
const missing = REQUIRED_FIELDS.filter((f) => typeof obj[f] !== 'string' || obj[f] === '');
|
|
19
|
+
if (missing.length > 0) {
|
|
20
|
+
throw new Error(`the @bakers-build/addon-ports entry's with.transport.with for 'cloudflare' is missing: ${missing.join(', ')}`);
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
accountId: obj.accountId,
|
|
24
|
+
tunnelId: obj.tunnelId,
|
|
25
|
+
zoneId: obj.zoneId,
|
|
26
|
+
zone: obj.zone,
|
|
27
|
+
apiToken: obj.apiToken,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Real `fetch` — the only seam into the actual network, injected into
|
|
31
|
+
* `reconcileCloudflare`/`cloudflare.ts`'s calls, never used directly by the
|
|
32
|
+
* generic ports layer. */
|
|
33
|
+
const nodeCloudflareDeps = {
|
|
34
|
+
fetch: async (url, init) => {
|
|
35
|
+
const res = await fetch(url, init);
|
|
36
|
+
return { ok: res.ok, status: res.status, json: () => res.json() };
|
|
37
|
+
},
|
|
38
|
+
timeoutMs: 10_000,
|
|
39
|
+
};
|
|
40
|
+
/** Allowlisted provider state for this transport: only a DNS record id. */
|
|
41
|
+
function validateData(data) {
|
|
42
|
+
if (typeof data !== 'object' || data === null) {
|
|
43
|
+
throw new Error('cloudflare transport data must be an object');
|
|
44
|
+
}
|
|
45
|
+
const obj = data;
|
|
46
|
+
const keys = Object.keys(obj);
|
|
47
|
+
if (keys.some((k) => k !== 'dnsRecordId')) {
|
|
48
|
+
throw new Error(`cloudflare transport data has unknown field(s): ${keys.filter((k) => k !== 'dnsRecordId').join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
if (obj.dnsRecordId !== undefined && typeof obj.dnsRecordId !== 'string') {
|
|
51
|
+
throw new Error('cloudflare transport data.dnsRecordId must be a string');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Default export factory consumed by `@bakers-build/addon-ports`'s
|
|
56
|
+
* `loadPortsTransport`. `identity` deliberately excludes `apiToken` — a
|
|
57
|
+
* rotated token with the same account/tunnel/zone is NOT an identity change.
|
|
58
|
+
*/
|
|
59
|
+
export default async function createTransport(settings, _context) {
|
|
60
|
+
const cfg = validateSettings(settings);
|
|
61
|
+
const deps = nodeCloudflareDeps;
|
|
62
|
+
return {
|
|
63
|
+
id: 'cloudflare',
|
|
64
|
+
identity: `${cfg.accountId}/${cfg.tunnelId}/${cfg.zoneId}`,
|
|
65
|
+
zone: cfg.zone,
|
|
66
|
+
validateData,
|
|
67
|
+
reconcile: (records, { checkpoint }) => reconcileCloudflare(records, cfg, deps, { checkpoint }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TunnelDesiredRecord } from '@bakers-build/addon-ports/transport';
|
|
2
|
+
import { type CloudflareConfig, type CloudflareDeps } from './cloudflare.js';
|
|
3
|
+
export interface ReconcileContext {
|
|
4
|
+
readonly signal?: AbortSignal;
|
|
5
|
+
checkpoint(records: readonly TunnelDesiredRecord[]): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function reconcileCloudflare(records: readonly TunnelDesiredRecord[], cfg: CloudflareConfig, deps: CloudflareDeps, context: ReconcileContext): Promise<{
|
|
8
|
+
readonly records: readonly TunnelDesiredRecord[];
|
|
9
|
+
readonly ok: boolean;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// BB-676 Task 3, generalized by BB-681 Task 2 into this package's own
|
|
2
|
+
// `PortsTransport.reconcile` — DNS create/adopt + the complete ingress
|
|
3
|
+
// upload, in the order a crash can recover from: an active record without
|
|
4
|
+
// an owned CNAME gets one BEFORE the ingress that names its hostname is
|
|
5
|
+
// uploaded (so a hostname never resolves before its route exists); a
|
|
6
|
+
// reduced ingress is uploaded BEFORE an absent record's CNAME is deleted
|
|
7
|
+
// (so the public URL stops routing before the DNS name that reached it
|
|
8
|
+
// disappears). Returns the records with updated `applied`/`data.dnsRecordId`
|
|
9
|
+
// /`error` — the generic layer (`tunnel-ops.ts`) persists them.
|
|
10
|
+
import { upsertRecord } from '@bakers-build/addon-ports/transport';
|
|
11
|
+
import { createDnsRecord, deleteDnsRecord, findDnsRecord, getIngressConfig, putIngressConfig, tunnelCnameTarget, } from './cloudflare.js';
|
|
12
|
+
function dnsRecordId(record) {
|
|
13
|
+
const v = record.data?.dnsRecordId;
|
|
14
|
+
return typeof v === 'string' ? v : undefined;
|
|
15
|
+
}
|
|
16
|
+
function withDnsRecordId(record, id) {
|
|
17
|
+
return {
|
|
18
|
+
...record,
|
|
19
|
+
data: id === undefined ? undefined : { dnsRecordId: id },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export async function reconcileCloudflare(records, cfg, deps, context) {
|
|
23
|
+
let next = records;
|
|
24
|
+
let ok = true;
|
|
25
|
+
const active = next.filter((r) => r.desired === 'present');
|
|
26
|
+
// Records whose DNS create/adopt itself failed (e.g. a foreign-owned exact
|
|
27
|
+
// name): they still ride along in the ingress upload below so one bad
|
|
28
|
+
// record can't drop its siblings, but they must not be reported `applied`
|
|
29
|
+
// just because the PUT happened to succeed — there's no DNS pointing at
|
|
30
|
+
// them. Track their ids to preserve that `error` through the PUT phase.
|
|
31
|
+
const dnsErrorIds = new Set();
|
|
32
|
+
for (const record of active) {
|
|
33
|
+
if (dnsRecordId(record))
|
|
34
|
+
continue;
|
|
35
|
+
try {
|
|
36
|
+
const existing = await findDnsRecord(cfg, record.hostname, deps);
|
|
37
|
+
if (existing && existing.content !== tunnelCnameTarget(cfg)) {
|
|
38
|
+
throw new Error(`${record.hostname} already has a DNS record this tunnel does not own`);
|
|
39
|
+
}
|
|
40
|
+
let id = existing?.id;
|
|
41
|
+
if (id === undefined) {
|
|
42
|
+
try {
|
|
43
|
+
id = (await createDnsRecord(cfg, record.hostname, deps)).id;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
// Uncertain create (e.g. the request succeeded remotely but the
|
|
47
|
+
// response was ambiguous, or this is a retry after a timeout):
|
|
48
|
+
// reconcile against the live state by exact hostname+content match
|
|
49
|
+
// rather than assuming failure (which would otherwise risk a
|
|
50
|
+
// duplicate record on the next attempt).
|
|
51
|
+
const recheck = await findDnsRecord(cfg, record.hostname, deps).catch(() => null);
|
|
52
|
+
if (recheck && recheck.content === tunnelCnameTarget(cfg)) {
|
|
53
|
+
id = recheck.id;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
next = upsertRecord(next, withDnsRecordId({ ...record, applied: 'pending', error: undefined }, id));
|
|
61
|
+
// Checkpoint DNS ownership immediately after acquiring/confirming it —
|
|
62
|
+
// don't wait until the whole reconcile finishes to record that this
|
|
63
|
+
// process now owns a DNS record (so a crash before the ingress PUT
|
|
64
|
+
// still leaves ownership discoverable on the next reconcile).
|
|
65
|
+
await context.checkpoint(next);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
ok = false;
|
|
69
|
+
dnsErrorIds.add(`${record.worktree}:${record.key}`);
|
|
70
|
+
next = upsertRecord(next, {
|
|
71
|
+
...record,
|
|
72
|
+
applied: 'error',
|
|
73
|
+
// CloudflareApiError messages are already redacted (`cloudflare.ts`);
|
|
74
|
+
// a non-Cloudflare error (e.g. a thrown plain Error) carries only its
|
|
75
|
+
// own message, never a raw fetch payload or token.
|
|
76
|
+
error: err instanceof Error ? err.message : String(err),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// The ingress is built from every `present` record, including ones whose
|
|
81
|
+
// DNS/apply state is `error` — otherwise one failed record would silently
|
|
82
|
+
// drop every other worktree's already-working route from the next upload.
|
|
83
|
+
const stillActive = next.filter((r) => r.desired === 'present');
|
|
84
|
+
// Remote config check first (I1): refuse to replace rules this tunnel does
|
|
85
|
+
// not own, rather than silently overwriting an out-of-band dashboard edit.
|
|
86
|
+
try {
|
|
87
|
+
const remote = await getIngressConfig(cfg, deps);
|
|
88
|
+
// Every hostname this tunnel identity has ever recorded is "managed",
|
|
89
|
+
// present or absent (N1): an `absent` record's hostname can still be in
|
|
90
|
+
// the remote ingress for one more reconcile (the PUT below removes it),
|
|
91
|
+
// and treating only `present` names as known wedges every later up/down
|
|
92
|
+
// the moment a single `down` runs, because the just-removed hostname then
|
|
93
|
+
// reads as a foreign unmanaged rule.
|
|
94
|
+
const expectedHostnames = new Set(next.map((r) => r.hostname).filter((h) => h !== undefined));
|
|
95
|
+
const unmanaged = remote.ingress.filter((rule) => typeof rule.hostname === 'string' &&
|
|
96
|
+
!expectedHostnames.has(rule.hostname));
|
|
97
|
+
if (unmanaged.length > 0) {
|
|
98
|
+
throw new Error(`refusing to replace unmanaged ingress rule(s): ${unmanaged.map((r) => r.hostname).join(', ')}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
ok = false;
|
|
103
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
104
|
+
for (const r of stillActive) {
|
|
105
|
+
next = upsertRecord(next, { ...r, applied: 'pending', error: message });
|
|
106
|
+
}
|
|
107
|
+
return { records: next, ok };
|
|
108
|
+
}
|
|
109
|
+
// BB-681 Task 2 — translate the neutral record (`hostHeader`/
|
|
110
|
+
// `insecureTls`) into Cloudflare's own `originRequest` shape HERE, the only
|
|
111
|
+
// place that knows Cloudflare's field names. Omission of `insecureTls`
|
|
112
|
+
// NEVER disables TLS verification (absent/false means verified) — only an
|
|
113
|
+
// explicit `true` sets `noTLSVerify: true`.
|
|
114
|
+
const ingress = [
|
|
115
|
+
...stillActive
|
|
116
|
+
.slice()
|
|
117
|
+
.sort((a, b) => (a.hostname ?? '').localeCompare(b.hostname ?? ''))
|
|
118
|
+
.map((r) => ({
|
|
119
|
+
...(r.hostname !== undefined ? { hostname: r.hostname } : {}),
|
|
120
|
+
service: r.service,
|
|
121
|
+
...(r.hostHeader || r.insecureTls
|
|
122
|
+
? {
|
|
123
|
+
originRequest: {
|
|
124
|
+
...(r.hostHeader ? { httpHostHeader: r.hostHeader } : {}),
|
|
125
|
+
...(r.insecureTls ? { noTLSVerify: true } : {}),
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
})),
|
|
130
|
+
{ service: 'http_status:404' },
|
|
131
|
+
];
|
|
132
|
+
try {
|
|
133
|
+
await putIngressConfig(cfg, ingress, deps);
|
|
134
|
+
for (const r of stillActive) {
|
|
135
|
+
if (dnsErrorIds.has(`${r.worktree}:${r.key}`))
|
|
136
|
+
continue;
|
|
137
|
+
// Applied only when BOTH the ingress route and the owned DNS record are
|
|
138
|
+
// confirmed — the DNS phase above already confirmed ownership for
|
|
139
|
+
// every id that isn't in `dnsErrorIds`.
|
|
140
|
+
next = upsertRecord(next, {
|
|
141
|
+
...r,
|
|
142
|
+
applied: 'applied',
|
|
143
|
+
error: undefined,
|
|
144
|
+
url: r.hostname ? `https://${r.hostname}` : r.url,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
ok = false;
|
|
150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
151
|
+
for (const r of stillActive) {
|
|
152
|
+
// 'pending', not 'error': an upload failure is retryable and must not
|
|
153
|
+
// permanently drop this record from the next reconcile's ingress.
|
|
154
|
+
next = upsertRecord(next, { ...r, applied: 'pending', error: message });
|
|
155
|
+
}
|
|
156
|
+
// The ingress upload failed: nothing routes yet, so deleting an absent
|
|
157
|
+
// record's DNS now would be deleting the name for a route that is still
|
|
158
|
+
// live. Stop before the DNS-deletion phase.
|
|
159
|
+
return { records: next, ok };
|
|
160
|
+
}
|
|
161
|
+
// A desired-absent record with no DNS record ever created has nothing to
|
|
162
|
+
// delete remotely (M3) — drop its tombstone immediately rather than only
|
|
163
|
+
// ones that went through the create/adopt path.
|
|
164
|
+
const absent = next.filter((r) => r.desired === 'absent' && dnsRecordId(r));
|
|
165
|
+
const noOpAbsentIds = new Set(next
|
|
166
|
+
.filter((r) => r.desired === 'absent' && !dnsRecordId(r))
|
|
167
|
+
.map((r) => `${r.worktree}:${r.key}`));
|
|
168
|
+
next = next.filter((r) => !(r.desired === 'absent' && noOpAbsentIds.has(`${r.worktree}:${r.key}`)));
|
|
169
|
+
const kept = [];
|
|
170
|
+
for (const record of absent) {
|
|
171
|
+
const id = dnsRecordId(record);
|
|
172
|
+
try {
|
|
173
|
+
const existing = await findDnsRecord(cfg, record.hostname, deps);
|
|
174
|
+
if (existing &&
|
|
175
|
+
existing.id === id &&
|
|
176
|
+
existing.content === tunnelCnameTarget(cfg)) {
|
|
177
|
+
await deleteDnsRecord(cfg, id, deps);
|
|
178
|
+
}
|
|
179
|
+
// Tombstone cleared — drop it from the returned set entirely.
|
|
180
|
+
await context.checkpoint(next.filter((r) => `${r.worktree}:${r.key}` !== `${record.worktree}:${record.key}`));
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
// Failed-cleanup tombstone: teardown partially failed, so this record
|
|
184
|
+
// stays (with its DNS id preserved) recording what's left to clean up,
|
|
185
|
+
// rather than the state losing track of it. Sibling records are
|
|
186
|
+
// unaffected — this loop never touches any key but its own.
|
|
187
|
+
ok = false;
|
|
188
|
+
kept.push({
|
|
189
|
+
...record,
|
|
190
|
+
applied: 'error',
|
|
191
|
+
error: err instanceof Error ? err.message : String(err),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const absentIds = new Set(absent.map((r) => `${r.worktree}:${r.key}`));
|
|
196
|
+
next = next
|
|
197
|
+
.filter((r) => !(r.desired === 'absent' && absentIds.has(`${r.worktree}:${r.key}`)))
|
|
198
|
+
.concat(kept);
|
|
199
|
+
return { records: next, ok };
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakers-build/addon-ports-cloudflare",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-next.42",
|
|
4
|
+
"description": "bakers.build ports transport: a named Cloudflare Tunnel, reconciled via the Cloudflare API (DNS + ingress). Implements @bakers-build/addon-ports's PortsTransport contract.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/src"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
|
|
19
|
+
"directory": "bakers-cli/addons/ports-cloudflare"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@bakers-build/addon-ports": "^0.1.0-next.42",
|
|
23
|
+
"@bakers-build/core": "^0.1.0-next.42"
|
|
24
|
+
}
|
|
25
|
+
}
|