@forgezero/agent 0.1.34 → 0.1.36
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/README.md +50 -0
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.d.ts +133 -0
- package/dist/bootstrap.js +3416 -0
- package/dist/cli/agent-install.d.ts +3 -0
- package/dist/cli/cloudflare-bootstrap.d.ts +11 -0
- package/dist/cloudflare-bootstrap.d.ts +218 -0
- package/dist/cloudflare-bootstrap.js +1196 -0
- package/dist/cloudflare-edge.d.ts +265 -0
- package/dist/cloudflare-edge.js +424 -0
- package/dist/definition.js +9 -2
- package/dist/deploy-file.js +9 -2
- package/dist/fz-agent.js +492 -150
- package/dist/fz.js +3833 -262
- package/dist/index.d.ts +2 -0
- package/dist/metal-bootstrap.d.ts +52 -0
- package/dist/metal-bootstrap.js +942 -0
- package/dist/platform-bootstrap-runtime.d.ts +161 -0
- package/dist/platform-bootstrap-runtime.js +462 -0
- package/dist/provision.d.ts +4 -0
- package/dist/provision.js +34 -6
- package/dist/software-helper.js +9 -2
- package/dist/software.d.ts +3 -1
- package/dist/software.js +12 -3
- package/dist/ssh-bootstrap.d.ts +72 -0
- package/dist/version.d.ts +1 -1
- package/package.json +22 -2
- package/schema/deploy-v2.json +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
export interface CloudflareEdgeConfig {
|
|
2
|
+
accountId: string;
|
|
3
|
+
zoneId: string;
|
|
4
|
+
tunnelId: string;
|
|
5
|
+
hostname: string;
|
|
6
|
+
service: string;
|
|
7
|
+
apiToken: string;
|
|
8
|
+
/** Optional least-privilege overrides. `apiToken` remains the single-token path. */
|
|
9
|
+
tunnelApiToken?: string;
|
|
10
|
+
dnsApiToken?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CloudflarePrivateRouteConfig {
|
|
13
|
+
accountId: string;
|
|
14
|
+
tunnelId: string;
|
|
15
|
+
network: string;
|
|
16
|
+
virtualNetworkId?: string;
|
|
17
|
+
comment: string;
|
|
18
|
+
apiToken: string;
|
|
19
|
+
}
|
|
20
|
+
export interface CloudflarePrivateRoute {
|
|
21
|
+
id: string;
|
|
22
|
+
network: string;
|
|
23
|
+
tunnel_id: string;
|
|
24
|
+
virtual_network_id?: string;
|
|
25
|
+
comment?: string;
|
|
26
|
+
deleted_at?: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Idempotently advertise a private CIDR through a Tunnel.
|
|
30
|
+
*
|
|
31
|
+
* This is Cloudflare One routing only: it creates no DNS record and therefore
|
|
32
|
+
* cannot accidentally turn a database address into a public hostname.
|
|
33
|
+
*/
|
|
34
|
+
export declare function ensureCloudflarePrivateRoute(config: CloudflarePrivateRouteConfig, fetcher?: typeof fetch): Promise<{
|
|
35
|
+
route: CloudflarePrivateRoute;
|
|
36
|
+
created: boolean;
|
|
37
|
+
}>;
|
|
38
|
+
/**
|
|
39
|
+
* Advertise exactly one database member, never its surrounding subnet. The
|
|
40
|
+
* caller still selects a VNET when private ranges overlap between sites.
|
|
41
|
+
*/
|
|
42
|
+
export declare function ensureCloudflarePrivateDatabaseRoute(config: Omit<CloudflarePrivateRouteConfig, 'network'> & {
|
|
43
|
+
privateAddress: string;
|
|
44
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
45
|
+
route: CloudflarePrivateRoute;
|
|
46
|
+
created: boolean;
|
|
47
|
+
}>;
|
|
48
|
+
/**
|
|
49
|
+
* Remove one private route only while it is still owned by the expected
|
|
50
|
+
* Tunnel/VNET. Migration cleanup must never delete a route that another
|
|
51
|
+
* controller has deliberately reassigned since this operation was planned.
|
|
52
|
+
*/
|
|
53
|
+
export declare function removeCloudflarePrivateRoute(config: Pick<CloudflarePrivateRouteConfig, 'accountId' | 'tunnelId' | 'network' | 'virtualNetworkId' | 'apiToken'>, fetcher?: typeof fetch): Promise<{
|
|
54
|
+
removed: boolean;
|
|
55
|
+
route?: CloudflarePrivateRoute;
|
|
56
|
+
}>;
|
|
57
|
+
export declare function removeCloudflarePrivateDatabaseRoute(config: Omit<CloudflarePrivateRouteConfig, 'network' | 'comment'> & {
|
|
58
|
+
privateAddress: string;
|
|
59
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
60
|
+
removed: boolean;
|
|
61
|
+
route?: CloudflarePrivateRoute;
|
|
62
|
+
}>;
|
|
63
|
+
export interface CloudflareWarpIncludeConfig {
|
|
64
|
+
accountId: string;
|
|
65
|
+
/** Omit only when the account default device profile is intentionally dedicated to ForgeZero computes. */
|
|
66
|
+
policyId?: string;
|
|
67
|
+
privateAddress: string;
|
|
68
|
+
description: string;
|
|
69
|
+
apiToken: string;
|
|
70
|
+
}
|
|
71
|
+
interface CloudflareSplitTunnelEntry {
|
|
72
|
+
address?: string;
|
|
73
|
+
host?: string;
|
|
74
|
+
description?: string;
|
|
75
|
+
}
|
|
76
|
+
/** Preserve the profile's existing entries while including one exact database host route. */
|
|
77
|
+
export declare function ensureCloudflareWarpDatabaseInclude(config: CloudflareWarpIncludeConfig, fetcher?: typeof fetch): Promise<{
|
|
78
|
+
entries: readonly CloudflareSplitTunnelEntry[];
|
|
79
|
+
created: boolean;
|
|
80
|
+
}>;
|
|
81
|
+
/** Remove only one exact host include and preserve the rest of the profile. */
|
|
82
|
+
export declare function removeCloudflareWarpDatabaseInclude(config: Omit<CloudflareWarpIncludeConfig, 'description'>, fetcher?: typeof fetch): Promise<{
|
|
83
|
+
entries: readonly CloudflareSplitTunnelEntry[];
|
|
84
|
+
removed: boolean;
|
|
85
|
+
}>;
|
|
86
|
+
/** Reconcile one remotely-managed tunnel route and its proxied DNS record. */
|
|
87
|
+
export declare function configureCloudflareEdge(config: CloudflareEdgeConfig, fetcher?: typeof fetch): Promise<void>;
|
|
88
|
+
export interface CloudflareApiTokens {
|
|
89
|
+
/** One scoped token may perform every operation. */
|
|
90
|
+
apiToken?: string;
|
|
91
|
+
/** Or split tokens may constrain each operation independently. */
|
|
92
|
+
tunnelApiToken?: string;
|
|
93
|
+
dnsApiToken?: string;
|
|
94
|
+
kvApiToken?: string;
|
|
95
|
+
accessApiToken?: string;
|
|
96
|
+
workerApiToken?: string;
|
|
97
|
+
}
|
|
98
|
+
export interface CloudflareKvNamespace {
|
|
99
|
+
id: string;
|
|
100
|
+
title: string;
|
|
101
|
+
}
|
|
102
|
+
export interface CloudflareAccessServiceToken {
|
|
103
|
+
id: string;
|
|
104
|
+
name: string;
|
|
105
|
+
client_id: string;
|
|
106
|
+
client_secret?: string;
|
|
107
|
+
duration?: string;
|
|
108
|
+
}
|
|
109
|
+
export interface CloudflareAccessCredentials {
|
|
110
|
+
tokenId: string;
|
|
111
|
+
clientId: string;
|
|
112
|
+
clientSecret: string;
|
|
113
|
+
}
|
|
114
|
+
export interface CloudflareAccountRuntimeToken {
|
|
115
|
+
id: string;
|
|
116
|
+
value: string;
|
|
117
|
+
name: string;
|
|
118
|
+
permissionNames: string[];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Create one account-owned API token from exact, dynamically resolved account
|
|
122
|
+
* permission groups. The value is returned once for immediate 0600 checkpoint.
|
|
123
|
+
*/
|
|
124
|
+
export declare function createCloudflareAccountRuntimeToken(config: {
|
|
125
|
+
accountId: string;
|
|
126
|
+
name: string;
|
|
127
|
+
permissionNames: readonly string[];
|
|
128
|
+
apiToken: string;
|
|
129
|
+
}, fetcher?: typeof fetch): Promise<CloudflareAccountRuntimeToken>;
|
|
130
|
+
/**
|
|
131
|
+
* Create the Worker's Access service token, or prove the caller supplied the
|
|
132
|
+
* sealed secret for the existing token. Cloudflare intentionally returns the
|
|
133
|
+
* client secret only once, so silently "reusing" an existing row without its
|
|
134
|
+
* secret would configure a Worker that can never authenticate.
|
|
135
|
+
*/
|
|
136
|
+
export declare function ensureCloudflareAccessServiceToken(config: {
|
|
137
|
+
accountId: string;
|
|
138
|
+
name: string;
|
|
139
|
+
duration?: string;
|
|
140
|
+
apiToken: string;
|
|
141
|
+
existing?: CloudflareAccessCredentials;
|
|
142
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
143
|
+
credentials: CloudflareAccessCredentials;
|
|
144
|
+
created: boolean;
|
|
145
|
+
}>;
|
|
146
|
+
interface CloudflareAccessPolicy {
|
|
147
|
+
id: string;
|
|
148
|
+
name: string;
|
|
149
|
+
decision?: string;
|
|
150
|
+
include?: unknown[];
|
|
151
|
+
}
|
|
152
|
+
/** Create or update one reusable Service Auth policy for the Worker token. */
|
|
153
|
+
export declare function ensureCloudflareAccessPolicy(config: {
|
|
154
|
+
accountId: string;
|
|
155
|
+
name: string;
|
|
156
|
+
serviceTokenId: string;
|
|
157
|
+
apiToken: string;
|
|
158
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
159
|
+
policy: CloudflareAccessPolicy;
|
|
160
|
+
created: boolean;
|
|
161
|
+
}>;
|
|
162
|
+
interface CloudflareAccessApplication {
|
|
163
|
+
id: string;
|
|
164
|
+
name: string;
|
|
165
|
+
type?: string;
|
|
166
|
+
domain?: string;
|
|
167
|
+
self_hosted_domains?: string[];
|
|
168
|
+
}
|
|
169
|
+
/** Protect one node hostname so only the public Worker can reach it. */
|
|
170
|
+
export declare function ensureCloudflareAccessApplication(config: {
|
|
171
|
+
accountId: string;
|
|
172
|
+
name: string;
|
|
173
|
+
hostname: string;
|
|
174
|
+
policyId: string;
|
|
175
|
+
apiToken: string;
|
|
176
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
177
|
+
application: CloudflareAccessApplication;
|
|
178
|
+
created: boolean;
|
|
179
|
+
}>;
|
|
180
|
+
/** Attach the same non-interactive service-auth policy to WARP enrollment. */
|
|
181
|
+
export declare function ensureCloudflareWarpEnrollmentApplication(config: {
|
|
182
|
+
accountId: string;
|
|
183
|
+
name: string;
|
|
184
|
+
policyId: string;
|
|
185
|
+
apiToken: string;
|
|
186
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
187
|
+
application: CloudflareAccessApplication;
|
|
188
|
+
created: boolean;
|
|
189
|
+
}>;
|
|
190
|
+
export interface CloudflareVirtualNetwork {
|
|
191
|
+
id: string;
|
|
192
|
+
name: string;
|
|
193
|
+
comment?: string;
|
|
194
|
+
is_default_network?: boolean;
|
|
195
|
+
deleted_at?: string | null;
|
|
196
|
+
}
|
|
197
|
+
/** Create or reuse one named VNET; never silently adopt an ambiguous/deleted row. */
|
|
198
|
+
export declare function ensureCloudflareVirtualNetwork(config: {
|
|
199
|
+
accountId: string;
|
|
200
|
+
name: string;
|
|
201
|
+
comment: string;
|
|
202
|
+
apiToken: string;
|
|
203
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
204
|
+
virtualNetwork: CloudflareVirtualNetwork;
|
|
205
|
+
created: boolean;
|
|
206
|
+
}>;
|
|
207
|
+
interface CloudflareDevicePolicy {
|
|
208
|
+
id: string;
|
|
209
|
+
name: string;
|
|
210
|
+
match?: string;
|
|
211
|
+
precedence?: number;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Create a locked WARP device profile selected only by this service token.
|
|
215
|
+
* Split-tunnel includes are reconciled separately so existing owned entries
|
|
216
|
+
* remain visible and reviewable.
|
|
217
|
+
*/
|
|
218
|
+
export declare function ensureCloudflareWarpDevicePolicy(config: {
|
|
219
|
+
accountId: string;
|
|
220
|
+
name: string;
|
|
221
|
+
serviceTokenId: string;
|
|
222
|
+
virtualNetworkId: string;
|
|
223
|
+
precedence?: number;
|
|
224
|
+
apiToken: string;
|
|
225
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
226
|
+
policy: CloudflareDevicePolicy;
|
|
227
|
+
created: boolean;
|
|
228
|
+
}>;
|
|
229
|
+
/** Atomically replace both Worker-side Access credentials. */
|
|
230
|
+
export declare function configureCloudflareWorkerAccessSecrets(config: {
|
|
231
|
+
accountId: string;
|
|
232
|
+
scriptName: string;
|
|
233
|
+
credentials: CloudflareAccessCredentials;
|
|
234
|
+
apiToken: string;
|
|
235
|
+
}, fetcher?: typeof fetch): Promise<void>;
|
|
236
|
+
/** Create or reuse exactly one named Worker KV namespace. */
|
|
237
|
+
export declare function ensureCloudflareKvNamespace(config: {
|
|
238
|
+
accountId: string;
|
|
239
|
+
title: string;
|
|
240
|
+
apiToken: string;
|
|
241
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
242
|
+
namespace: CloudflareKvNamespace;
|
|
243
|
+
created: boolean;
|
|
244
|
+
}>;
|
|
245
|
+
export interface CloudflareTunnel {
|
|
246
|
+
id: string;
|
|
247
|
+
name: string;
|
|
248
|
+
status?: string;
|
|
249
|
+
deleted_at?: string | null;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Create or reuse one remotely-managed Tunnel and obtain its connector token.
|
|
253
|
+
* The token is returned once to the caller so it can be PQ-delivered to the
|
|
254
|
+
* Agent and sealed as a systemd credential; it is never persisted in ArangoDB.
|
|
255
|
+
*/
|
|
256
|
+
export declare function ensureCloudflareTunnel(config: {
|
|
257
|
+
accountId: string;
|
|
258
|
+
name: string;
|
|
259
|
+
apiToken: string;
|
|
260
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
261
|
+
tunnel: CloudflareTunnel;
|
|
262
|
+
connectorToken: string;
|
|
263
|
+
created: boolean;
|
|
264
|
+
}>;
|
|
265
|
+
export {};
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/cloudflare-edge.ts
|
|
3
|
+
import { isIP } from "net";
|
|
4
|
+
function isPrivateDatabaseAddress(value) {
|
|
5
|
+
const address = value.trim().toLowerCase();
|
|
6
|
+
const family = isIP(address);
|
|
7
|
+
if (family === 4) {
|
|
8
|
+
const [a, b] = address.split(".").map(Number);
|
|
9
|
+
return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
10
|
+
}
|
|
11
|
+
if (family === 6) {
|
|
12
|
+
const first = Number.parseInt(address.split(":", 1)[0], 16);
|
|
13
|
+
return Number.isFinite(first) && (first & 65024) === 64512;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function privateDatabaseHostRoute(value) {
|
|
18
|
+
const address = value.trim().toLowerCase();
|
|
19
|
+
if (!isPrivateDatabaseAddress(address)) {
|
|
20
|
+
throw new Error("database address must be an RFC 1918 IPv4 or unique-local IPv6 address");
|
|
21
|
+
}
|
|
22
|
+
return `${address}/${isIP(address) === 4 ? 32 : 128}`;
|
|
23
|
+
}
|
|
24
|
+
var endpoint = "https://api.cloudflare.com/client/v4";
|
|
25
|
+
async function cf(config, path, init = {}, fetcher = fetch) {
|
|
26
|
+
const response = await fetcher(`${endpoint}${path}`, {
|
|
27
|
+
...init,
|
|
28
|
+
headers: {
|
|
29
|
+
authorization: `Bearer ${config.apiToken}`,
|
|
30
|
+
"content-type": "application/json",
|
|
31
|
+
...init.headers
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const body = await response.json();
|
|
35
|
+
if (!response.ok || body.success !== true) {
|
|
36
|
+
throw new Error(body.errors?.map(({ message }) => message).filter(Boolean).join("; ") || `Cloudflare returned HTTP ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
return body.result;
|
|
39
|
+
}
|
|
40
|
+
async function ensureCloudflarePrivateRoute(config, fetcher = fetch) {
|
|
41
|
+
const [address, prefixText, ...extra] = config.network.split("/");
|
|
42
|
+
const family = isIP(address ?? "");
|
|
43
|
+
const prefix = Number(prefixText);
|
|
44
|
+
if (extra.length > 0 || !family || !Number.isInteger(prefix) || prefix < 0 || prefix > (family === 4 ? 32 : 128)) {
|
|
45
|
+
throw new Error("Cloudflare private route must be an explicit IPv4 or IPv6 CIDR");
|
|
46
|
+
}
|
|
47
|
+
const path = `/accounts/${config.accountId}/teamnet/routes`;
|
|
48
|
+
const routes = await cf(config, path, {}, fetcher);
|
|
49
|
+
const current = routes.find((route2) => !route2.deleted_at && route2.network === config.network && (route2.virtual_network_id ?? "") === (config.virtualNetworkId ?? ""));
|
|
50
|
+
if (current) {
|
|
51
|
+
if (current.tunnel_id !== config.tunnelId) {
|
|
52
|
+
throw new Error(`private route ${config.network} already belongs to another Tunnel`);
|
|
53
|
+
}
|
|
54
|
+
return { route: current, created: false };
|
|
55
|
+
}
|
|
56
|
+
const route = await cf(config, path, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
network: config.network,
|
|
60
|
+
tunnel_id: config.tunnelId,
|
|
61
|
+
comment: config.comment.slice(0, 100),
|
|
62
|
+
...config.virtualNetworkId ? { virtual_network_id: config.virtualNetworkId } : {}
|
|
63
|
+
})
|
|
64
|
+
}, fetcher);
|
|
65
|
+
return { route, created: true };
|
|
66
|
+
}
|
|
67
|
+
async function ensureCloudflarePrivateDatabaseRoute(config, fetcher = fetch) {
|
|
68
|
+
return ensureCloudflarePrivateRoute({
|
|
69
|
+
...config,
|
|
70
|
+
network: privateDatabaseHostRoute(config.privateAddress)
|
|
71
|
+
}, fetcher);
|
|
72
|
+
}
|
|
73
|
+
async function removeCloudflarePrivateRoute(config, fetcher = fetch) {
|
|
74
|
+
const [address, prefixText, ...extra] = config.network.split("/");
|
|
75
|
+
const family = isIP(address ?? "");
|
|
76
|
+
const prefix = Number(prefixText);
|
|
77
|
+
if (extra.length > 0 || !family || !Number.isInteger(prefix) || prefix < 0 || prefix > (family === 4 ? 32 : 128)) {
|
|
78
|
+
throw new Error("Cloudflare private route must be an explicit IPv4 or IPv6 CIDR");
|
|
79
|
+
}
|
|
80
|
+
const path = `/accounts/${config.accountId}/teamnet/routes`;
|
|
81
|
+
const routes = await cf(config, path, {}, fetcher);
|
|
82
|
+
const current = routes.find((route) => !route.deleted_at && route.network === config.network && (route.virtual_network_id ?? "") === (config.virtualNetworkId ?? ""));
|
|
83
|
+
if (!current)
|
|
84
|
+
return { removed: false };
|
|
85
|
+
if (current.tunnel_id !== config.tunnelId) {
|
|
86
|
+
throw new Error(`private route ${config.network} belongs to another Tunnel`);
|
|
87
|
+
}
|
|
88
|
+
await cf(config, `${path}/${encodeURIComponent(current.id)}`, { method: "DELETE" }, fetcher);
|
|
89
|
+
return { removed: true, route: current };
|
|
90
|
+
}
|
|
91
|
+
async function removeCloudflarePrivateDatabaseRoute(config, fetcher = fetch) {
|
|
92
|
+
return removeCloudflarePrivateRoute({
|
|
93
|
+
...config,
|
|
94
|
+
network: privateDatabaseHostRoute(config.privateAddress)
|
|
95
|
+
}, fetcher);
|
|
96
|
+
}
|
|
97
|
+
async function ensureCloudflareWarpDatabaseInclude(config, fetcher = fetch) {
|
|
98
|
+
if (config.policyId && !/^[A-Za-z0-9-]{1,64}$/.test(config.policyId))
|
|
99
|
+
throw new Error("Cloudflare WARP policy id is invalid");
|
|
100
|
+
const route = privateDatabaseHostRoute(config.privateAddress);
|
|
101
|
+
const policy = config.policyId ? `/${config.policyId}` : "";
|
|
102
|
+
const path = `/accounts/${config.accountId}/devices/policy${policy}/include`;
|
|
103
|
+
const entries = await cf(config, path, {}, fetcher);
|
|
104
|
+
if (entries.some((entry) => entry.address === route))
|
|
105
|
+
return { entries, created: false };
|
|
106
|
+
const next = [...entries, { address: route, description: config.description.slice(0, 100) }];
|
|
107
|
+
const updated = await cf(config, path, {
|
|
108
|
+
method: "PUT",
|
|
109
|
+
body: JSON.stringify(next)
|
|
110
|
+
}, fetcher);
|
|
111
|
+
return { entries: updated, created: true };
|
|
112
|
+
}
|
|
113
|
+
async function removeCloudflareWarpDatabaseInclude(config, fetcher = fetch) {
|
|
114
|
+
if (config.policyId && !/^[A-Za-z0-9-]{1,64}$/.test(config.policyId))
|
|
115
|
+
throw new Error("Cloudflare WARP policy id is invalid");
|
|
116
|
+
const route = privateDatabaseHostRoute(config.privateAddress);
|
|
117
|
+
const policy = config.policyId ? `/${config.policyId}` : "";
|
|
118
|
+
const path = `/accounts/${config.accountId}/devices/policy${policy}/include`;
|
|
119
|
+
const entries = await cf(config, path, {}, fetcher);
|
|
120
|
+
const next = entries.filter((entry) => entry.address !== route);
|
|
121
|
+
if (next.length === entries.length)
|
|
122
|
+
return { entries, removed: false };
|
|
123
|
+
const updated = await cf(config, path, {
|
|
124
|
+
method: "PUT",
|
|
125
|
+
body: JSON.stringify(next)
|
|
126
|
+
}, fetcher);
|
|
127
|
+
return { entries: updated, removed: true };
|
|
128
|
+
}
|
|
129
|
+
async function configureCloudflareEdge(config, fetcher = fetch) {
|
|
130
|
+
const tunnelAuth = { apiToken: config.tunnelApiToken?.trim() || config.apiToken };
|
|
131
|
+
const dnsAuth = { apiToken: config.dnsApiToken?.trim() || config.apiToken };
|
|
132
|
+
const tunnelPath = `/accounts/${config.accountId}/cfd_tunnel/${config.tunnelId}/configurations`;
|
|
133
|
+
const current = await cf(tunnelAuth, tunnelPath, {}, fetcher);
|
|
134
|
+
const existing = current.config?.ingress ?? [];
|
|
135
|
+
const catchAll = existing.filter((rule) => !("hostname" in rule));
|
|
136
|
+
const otherHosts = existing.filter((rule) => ("hostname" in rule) && rule.hostname !== config.hostname);
|
|
137
|
+
await cf(tunnelAuth, tunnelPath, {
|
|
138
|
+
method: "PUT",
|
|
139
|
+
body: JSON.stringify({ config: { ingress: [
|
|
140
|
+
{ hostname: config.hostname, service: config.service },
|
|
141
|
+
...otherHosts,
|
|
142
|
+
...catchAll.length > 0 ? catchAll : [{ service: "http_status:404" }]
|
|
143
|
+
] } })
|
|
144
|
+
}, fetcher);
|
|
145
|
+
const dnsPath = `/zones/${config.zoneId}/dns_records`;
|
|
146
|
+
const records = await cf(dnsAuth, `${dnsPath}?type=CNAME&name=${encodeURIComponent(config.hostname)}&per_page=1000`, {}, fetcher);
|
|
147
|
+
if (records.length > 1) {
|
|
148
|
+
throw new Error(`Cloudflare DNS record for ${config.hostname} is ambiguous`);
|
|
149
|
+
}
|
|
150
|
+
const record = {
|
|
151
|
+
type: "CNAME",
|
|
152
|
+
name: config.hostname,
|
|
153
|
+
content: `${config.tunnelId}.cfargotunnel.com`,
|
|
154
|
+
proxied: true,
|
|
155
|
+
ttl: 1
|
|
156
|
+
};
|
|
157
|
+
await cf(dnsAuth, records[0] ? `${dnsPath}/${records[0].id}` : dnsPath, {
|
|
158
|
+
method: records[0] ? "PUT" : "POST",
|
|
159
|
+
body: JSON.stringify(record)
|
|
160
|
+
}, fetcher);
|
|
161
|
+
}
|
|
162
|
+
var exactAccountPermissionGroup = async (config, name, fetcher) => {
|
|
163
|
+
const groups = await cf(config, `/accounts/${config.accountId}/tokens/permission_groups?name=${encodeURIComponent(name)}` + "&scope=com.cloudflare.api.account", {}, fetcher);
|
|
164
|
+
const matches = groups.filter((group) => group.name === name && group.scopes?.includes("com.cloudflare.api.account") && Boolean(group.id && /^[a-f0-9]{32}$/i.test(group.id)));
|
|
165
|
+
if (matches.length !== 1) {
|
|
166
|
+
throw new Error(`Cloudflare account token permission group ${name} is ${matches.length === 0 ? "missing" : "ambiguous"}`);
|
|
167
|
+
}
|
|
168
|
+
return { id: matches[0].id, name };
|
|
169
|
+
};
|
|
170
|
+
async function createCloudflareAccountRuntimeToken(config, fetcher = fetch) {
|
|
171
|
+
if (!/^[a-f0-9]{32}$/i.test(config.accountId))
|
|
172
|
+
throw new Error("Cloudflare account id is invalid");
|
|
173
|
+
const name = config.name.trim();
|
|
174
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,119}$/.test(name)) {
|
|
175
|
+
throw new Error("Cloudflare account runtime-token name is invalid");
|
|
176
|
+
}
|
|
177
|
+
const permissionNames = [...new Set(config.permissionNames)];
|
|
178
|
+
if (permissionNames.length === 0)
|
|
179
|
+
throw new Error("Cloudflare account runtime token needs a permission group");
|
|
180
|
+
const permissionGroups = await Promise.all(permissionNames.map((permissionName) => exactAccountPermissionGroup(config, permissionName, fetcher)));
|
|
181
|
+
const body = {
|
|
182
|
+
name,
|
|
183
|
+
policies: [{
|
|
184
|
+
effect: "allow",
|
|
185
|
+
permission_groups: permissionGroups.map(({ id }) => ({ id })),
|
|
186
|
+
resources: { [`com.cloudflare.api.account.${config.accountId}`]: "*" }
|
|
187
|
+
}]
|
|
188
|
+
};
|
|
189
|
+
const created = await cf(config, `/accounts/${config.accountId}/tokens`, { method: "POST", body: JSON.stringify(body) }, fetcher);
|
|
190
|
+
if (!created.id || !/^[a-f0-9]{32}$/i.test(created.id) || !created.value || !/^[A-Za-z0-9._-]{40,80}$/.test(created.value)) {
|
|
191
|
+
throw new Error("Cloudflare did not return the one-time account runtime-token id and value");
|
|
192
|
+
}
|
|
193
|
+
return { id: created.id, value: created.value, name, permissionNames };
|
|
194
|
+
}
|
|
195
|
+
async function ensureCloudflareAccessServiceToken(config, fetcher = fetch) {
|
|
196
|
+
const name = config.name.trim();
|
|
197
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name)) {
|
|
198
|
+
throw new Error("Cloudflare Access service-token name is invalid");
|
|
199
|
+
}
|
|
200
|
+
const path = `/accounts/${config.accountId}/access/service_tokens`;
|
|
201
|
+
const tokens = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
202
|
+
const matches = tokens.filter((token) => token.name === name);
|
|
203
|
+
if (matches.length > 1)
|
|
204
|
+
throw new Error(`Cloudflare Access service token ${name} is ambiguous`);
|
|
205
|
+
if (matches[0]) {
|
|
206
|
+
if (!config.existing || config.existing.tokenId !== matches[0].id || config.existing.clientId !== matches[0].client_id || !config.existing.clientSecret) {
|
|
207
|
+
throw new Error(`Cloudflare Access service token ${name} exists but its one-time client secret was not supplied`);
|
|
208
|
+
}
|
|
209
|
+
return { credentials: config.existing, created: false };
|
|
210
|
+
}
|
|
211
|
+
const created = await cf(config, path, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
body: JSON.stringify({ name, duration: config.duration ?? "8760h" })
|
|
214
|
+
}, fetcher);
|
|
215
|
+
if (!created.id || !created.client_id || !created.client_secret) {
|
|
216
|
+
throw new Error("Cloudflare did not return the new Access service-token secret");
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
credentials: {
|
|
220
|
+
tokenId: created.id,
|
|
221
|
+
clientId: created.client_id,
|
|
222
|
+
clientSecret: created.client_secret
|
|
223
|
+
},
|
|
224
|
+
created: true
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async function ensureCloudflareAccessPolicy(config, fetcher = fetch) {
|
|
228
|
+
const path = `/accounts/${config.accountId}/access/policies`;
|
|
229
|
+
const policies = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
230
|
+
const matches = policies.filter((policy2) => policy2.name === config.name);
|
|
231
|
+
if (matches.length > 1)
|
|
232
|
+
throw new Error(`Cloudflare Access policy ${config.name} is ambiguous`);
|
|
233
|
+
const desired = {
|
|
234
|
+
name: config.name,
|
|
235
|
+
decision: "non_identity",
|
|
236
|
+
include: [{ service_token: { token_id: config.serviceTokenId } }]
|
|
237
|
+
};
|
|
238
|
+
const policy = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, {
|
|
239
|
+
method: matches[0] ? "PUT" : "POST",
|
|
240
|
+
body: JSON.stringify(desired)
|
|
241
|
+
}, fetcher);
|
|
242
|
+
return { policy, created: !matches[0] };
|
|
243
|
+
}
|
|
244
|
+
async function ensureCloudflareAccessApplication(config, fetcher = fetch) {
|
|
245
|
+
const path = `/accounts/${config.accountId}/access/apps`;
|
|
246
|
+
const applications = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
247
|
+
const matches = applications.filter((application2) => application2.domain === config.hostname || application2.self_hosted_domains?.includes(config.hostname));
|
|
248
|
+
if (matches.length > 1)
|
|
249
|
+
throw new Error(`Cloudflare Access application for ${config.hostname} is ambiguous`);
|
|
250
|
+
const desired = {
|
|
251
|
+
name: config.name,
|
|
252
|
+
type: "self_hosted",
|
|
253
|
+
domain: config.hostname,
|
|
254
|
+
session_duration: "24h",
|
|
255
|
+
service_auth_401_redirect: true,
|
|
256
|
+
policies: [{ id: config.policyId, precedence: 1 }]
|
|
257
|
+
};
|
|
258
|
+
const application = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PUT" : "POST", body: JSON.stringify(desired) }, fetcher);
|
|
259
|
+
return { application, created: !matches[0] };
|
|
260
|
+
}
|
|
261
|
+
async function ensureCloudflareWarpEnrollmentApplication(config, fetcher = fetch) {
|
|
262
|
+
const name = config.name.trim();
|
|
263
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name)) {
|
|
264
|
+
throw new Error("Cloudflare WARP enrollment application name is invalid");
|
|
265
|
+
}
|
|
266
|
+
const path = `/accounts/${config.accountId}/access/apps`;
|
|
267
|
+
const applications = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
268
|
+
const matches = applications.filter((application2) => application2.type === "warp" || application2.name === name);
|
|
269
|
+
if (matches.length > 1)
|
|
270
|
+
throw new Error(`Cloudflare WARP enrollment application ${name} is ambiguous`);
|
|
271
|
+
if (matches[0] && (matches[0].type !== "warp" || matches[0].name !== name)) {
|
|
272
|
+
throw new Error(`Cloudflare Access application ${name} is not the owned WARP enrollment application`);
|
|
273
|
+
}
|
|
274
|
+
const desired = {
|
|
275
|
+
name,
|
|
276
|
+
type: "warp",
|
|
277
|
+
policies: [{ id: config.policyId, precedence: 1 }]
|
|
278
|
+
};
|
|
279
|
+
const application = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PUT" : "POST", body: JSON.stringify(desired) }, fetcher);
|
|
280
|
+
if (!application.id || application.type && application.type !== "warp") {
|
|
281
|
+
throw new Error("Cloudflare did not return the WARP enrollment application");
|
|
282
|
+
}
|
|
283
|
+
return { application, created: !matches[0] };
|
|
284
|
+
}
|
|
285
|
+
async function ensureCloudflareVirtualNetwork(config, fetcher = fetch) {
|
|
286
|
+
const name = config.name.trim();
|
|
287
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name))
|
|
288
|
+
throw new Error("Cloudflare VNET name is invalid");
|
|
289
|
+
const path = `/accounts/${config.accountId}/teamnet/virtual_networks`;
|
|
290
|
+
const networks = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
291
|
+
const matches = networks.filter((network) => !network.deleted_at && network.name === name);
|
|
292
|
+
if (matches.length > 1)
|
|
293
|
+
throw new Error(`Cloudflare VNET ${name} is ambiguous`);
|
|
294
|
+
if (matches[0])
|
|
295
|
+
return { virtualNetwork: matches[0], created: false };
|
|
296
|
+
const virtualNetwork = await cf(config, path, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
body: JSON.stringify({ name, comment: config.comment.slice(0, 256), is_default_network: false })
|
|
299
|
+
}, fetcher);
|
|
300
|
+
if (!virtualNetwork.id || !/^[0-9a-f-]{36}$/i.test(virtualNetwork.id)) {
|
|
301
|
+
throw new Error("Cloudflare did not return the VNET id");
|
|
302
|
+
}
|
|
303
|
+
return { virtualNetwork, created: true };
|
|
304
|
+
}
|
|
305
|
+
async function ensureCloudflareWarpDevicePolicy(config, fetcher = fetch) {
|
|
306
|
+
const name = config.name.trim();
|
|
307
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name))
|
|
308
|
+
throw new Error("Cloudflare WARP device profile name is invalid");
|
|
309
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(config.serviceTokenId))
|
|
310
|
+
throw new Error("Cloudflare service-token id is invalid");
|
|
311
|
+
if (!/^[0-9a-f-]{36}$/i.test(config.virtualNetworkId))
|
|
312
|
+
throw new Error("Cloudflare VNET id is invalid");
|
|
313
|
+
const precedence = config.precedence ?? 100;
|
|
314
|
+
if (!Number.isInteger(precedence) || precedence < 1 || precedence > 999999) {
|
|
315
|
+
throw new Error("Cloudflare WARP device profile precedence is invalid");
|
|
316
|
+
}
|
|
317
|
+
const match = `identity.service_token_uuid == "${config.serviceTokenId}"`;
|
|
318
|
+
const listPath = `/accounts/${config.accountId}/devices/policies`;
|
|
319
|
+
const path = `/accounts/${config.accountId}/devices/policy`;
|
|
320
|
+
const policies = await cf(config, `${listPath}?per_page=1000`, {}, fetcher);
|
|
321
|
+
const matches = policies.filter((policy2) => policy2.name === name);
|
|
322
|
+
if (matches.length > 1)
|
|
323
|
+
throw new Error(`Cloudflare WARP device profile ${name} is ambiguous`);
|
|
324
|
+
if (matches[0]?.match && matches[0].match !== match) {
|
|
325
|
+
throw new Error(`Cloudflare WARP device profile ${name} belongs to another enrollment identity`);
|
|
326
|
+
}
|
|
327
|
+
const desired = {
|
|
328
|
+
name,
|
|
329
|
+
match,
|
|
330
|
+
precedence,
|
|
331
|
+
description: "ForgeZero non-interactive compute enrollment",
|
|
332
|
+
enabled: true,
|
|
333
|
+
allow_mode_switch: false,
|
|
334
|
+
allowed_to_leave: false,
|
|
335
|
+
auto_connect: 0,
|
|
336
|
+
switch_locked: true,
|
|
337
|
+
service_mode_v2: { mode: "warp" },
|
|
338
|
+
virtual_networks: { allowed: [config.virtualNetworkId], default: config.virtualNetworkId }
|
|
339
|
+
};
|
|
340
|
+
const policy = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PATCH" : "POST", body: JSON.stringify(desired) }, fetcher);
|
|
341
|
+
if (!policy.id)
|
|
342
|
+
throw new Error("Cloudflare did not return the WARP device profile id");
|
|
343
|
+
return { policy, created: !matches[0] };
|
|
344
|
+
}
|
|
345
|
+
async function configureCloudflareWorkerAccessSecrets(config, fetcher = fetch) {
|
|
346
|
+
if (!/^[a-z][a-z0-9-]{0,62}$/.test(config.scriptName)) {
|
|
347
|
+
throw new Error("Cloudflare Worker script name is invalid");
|
|
348
|
+
}
|
|
349
|
+
await cf(config, `/accounts/${config.accountId}/workers/scripts/${config.scriptName}/secrets-bulk`, {
|
|
350
|
+
method: "PATCH",
|
|
351
|
+
body: JSON.stringify({
|
|
352
|
+
secrets: {
|
|
353
|
+
CF_ACCESS_CLIENT_ID: {
|
|
354
|
+
name: "CF_ACCESS_CLIENT_ID",
|
|
355
|
+
type: "secret_text",
|
|
356
|
+
text: config.credentials.clientId
|
|
357
|
+
},
|
|
358
|
+
CF_ACCESS_CLIENT_SECRET: {
|
|
359
|
+
name: "CF_ACCESS_CLIENT_SECRET",
|
|
360
|
+
type: "secret_text",
|
|
361
|
+
text: config.credentials.clientSecret
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
}, fetcher);
|
|
366
|
+
}
|
|
367
|
+
async function ensureCloudflareKvNamespace(config, fetcher = fetch) {
|
|
368
|
+
const title = config.title.trim();
|
|
369
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(title)) {
|
|
370
|
+
throw new Error("Cloudflare KV namespace title is invalid");
|
|
371
|
+
}
|
|
372
|
+
const path = `/accounts/${config.accountId}/storage/kv/namespaces`;
|
|
373
|
+
const namespaces = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
374
|
+
const matches = namespaces.filter((namespace2) => namespace2.title === title);
|
|
375
|
+
if (matches.length > 1)
|
|
376
|
+
throw new Error(`Cloudflare KV namespace ${title} is ambiguous`);
|
|
377
|
+
if (matches[0])
|
|
378
|
+
return { namespace: matches[0], created: false };
|
|
379
|
+
const namespace = await cf(config, path, {
|
|
380
|
+
method: "POST",
|
|
381
|
+
body: JSON.stringify({ title })
|
|
382
|
+
}, fetcher);
|
|
383
|
+
return { namespace, created: true };
|
|
384
|
+
}
|
|
385
|
+
async function ensureCloudflareTunnel(config, fetcher = fetch) {
|
|
386
|
+
const name = config.name.trim();
|
|
387
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
|
|
388
|
+
throw new Error("Cloudflare Tunnel name is invalid");
|
|
389
|
+
}
|
|
390
|
+
const path = `/accounts/${config.accountId}/cfd_tunnel`;
|
|
391
|
+
const tunnels = await cf(config, `${path}?is_deleted=false&name=${encodeURIComponent(name)}&per_page=1000`, {}, fetcher);
|
|
392
|
+
const matches = tunnels.filter((tunnel2) => tunnel2.name === name && !tunnel2.deleted_at);
|
|
393
|
+
if (matches.length > 1)
|
|
394
|
+
throw new Error(`Cloudflare Tunnel ${name} is ambiguous`);
|
|
395
|
+
const created = !matches[0];
|
|
396
|
+
const tunnel = matches[0] ?? await cf(config, path, {
|
|
397
|
+
method: "POST",
|
|
398
|
+
body: JSON.stringify({ name, config_src: "cloudflare" })
|
|
399
|
+
}, fetcher);
|
|
400
|
+
const connectorToken = await cf(config, `${path}/${encodeURIComponent(tunnel.id)}/token`, {}, fetcher);
|
|
401
|
+
if (!connectorToken || connectorToken.length > 16384) {
|
|
402
|
+
throw new Error("Cloudflare returned an invalid Tunnel connector token");
|
|
403
|
+
}
|
|
404
|
+
return { tunnel, connectorToken, created };
|
|
405
|
+
}
|
|
406
|
+
export {
|
|
407
|
+
removeCloudflareWarpDatabaseInclude,
|
|
408
|
+
removeCloudflarePrivateRoute,
|
|
409
|
+
removeCloudflarePrivateDatabaseRoute,
|
|
410
|
+
ensureCloudflareWarpEnrollmentApplication,
|
|
411
|
+
ensureCloudflareWarpDevicePolicy,
|
|
412
|
+
ensureCloudflareWarpDatabaseInclude,
|
|
413
|
+
ensureCloudflareVirtualNetwork,
|
|
414
|
+
ensureCloudflareTunnel,
|
|
415
|
+
ensureCloudflarePrivateRoute,
|
|
416
|
+
ensureCloudflarePrivateDatabaseRoute,
|
|
417
|
+
ensureCloudflareKvNamespace,
|
|
418
|
+
ensureCloudflareAccessServiceToken,
|
|
419
|
+
ensureCloudflareAccessPolicy,
|
|
420
|
+
ensureCloudflareAccessApplication,
|
|
421
|
+
createCloudflareAccountRuntimeToken,
|
|
422
|
+
configureCloudflareWorkerAccessSecrets,
|
|
423
|
+
configureCloudflareEdge
|
|
424
|
+
};
|