openclaw-cloudflare 0.1.0
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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/changeset-check.yml +25 -0
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/release.yml +39 -0
- package/README.md +142 -0
- package/openclaw.plugin.json +40 -0
- package/package.json +50 -0
- package/src/index.test.ts +395 -0
- package/src/index.ts +107 -0
- package/src/tunnel/access.test.ts +280 -0
- package/src/tunnel/access.ts +210 -0
- package/src/tunnel/cloudflared.test.ts +176 -0
- package/src/tunnel/cloudflared.ts +209 -0
- package/src/tunnel/exposure.test.ts +112 -0
- package/src/tunnel/exposure.ts +44 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3
|
+
|
|
4
|
+
const createCloudflareAccessVerifierMock = vi.fn();
|
|
5
|
+
const startGatewayCloudflareExposureMock = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock("./tunnel/access.js", () => ({
|
|
8
|
+
createCloudflareAccessVerifier: (...args: unknown[]) =>
|
|
9
|
+
createCloudflareAccessVerifierMock(...args),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("./tunnel/exposure.js", () => ({
|
|
13
|
+
startGatewayCloudflareExposure: (...args: unknown[]) =>
|
|
14
|
+
startGatewayCloudflareExposureMock(...args),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
function createMockApi(pluginConfig?: Record<string, unknown>) {
|
|
18
|
+
return {
|
|
19
|
+
pluginConfig,
|
|
20
|
+
logger: {
|
|
21
|
+
info: vi.fn(),
|
|
22
|
+
warn: vi.fn(),
|
|
23
|
+
error: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
registerService: vi.fn(),
|
|
26
|
+
registerHttpHandler: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createMockReq(headers: Record<string, string | string[] | undefined> = {}): IncomingMessage {
|
|
31
|
+
return { headers } as unknown as IncomingMessage;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createMockRes(): ServerResponse {
|
|
35
|
+
return {} as unknown as ServerResponse;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("cloudflare plugin", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
createCloudflareAccessVerifierMock.mockReset();
|
|
42
|
+
startGatewayCloudflareExposureMock.mockReset();
|
|
43
|
+
delete process.env.OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does nothing when mode is off", async () => {
|
|
47
|
+
const { default: plugin } = await import("./index.js");
|
|
48
|
+
const api = createMockApi({ tunnel: { mode: "off" } });
|
|
49
|
+
|
|
50
|
+
plugin.register(api);
|
|
51
|
+
|
|
52
|
+
expect(api.registerService).not.toHaveBeenCalled();
|
|
53
|
+
expect(api.registerHttpHandler).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does nothing when no tunnel config (defaults to off)", async () => {
|
|
57
|
+
const { default: plugin } = await import("./index.js");
|
|
58
|
+
const api = createMockApi({});
|
|
59
|
+
|
|
60
|
+
plugin.register(api);
|
|
61
|
+
|
|
62
|
+
expect(api.registerService).not.toHaveBeenCalled();
|
|
63
|
+
expect(api.registerHttpHandler).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("logs error and returns when managed mode has no token", async () => {
|
|
67
|
+
const { default: plugin } = await import("./index.js");
|
|
68
|
+
const api = createMockApi({ tunnel: { mode: "managed" } });
|
|
69
|
+
|
|
70
|
+
plugin.register(api);
|
|
71
|
+
|
|
72
|
+
expect(api.logger.error).toHaveBeenCalledWith(
|
|
73
|
+
expect.stringContaining("managed mode requires tunnelToken"),
|
|
74
|
+
);
|
|
75
|
+
expect(api.registerService).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("reads tunnel token from env var when not in config", async () => {
|
|
79
|
+
process.env.OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN = "env-token";
|
|
80
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
81
|
+
|
|
82
|
+
const { default: plugin } = await import("./index.js");
|
|
83
|
+
const api = createMockApi({
|
|
84
|
+
tunnel: { mode: "managed", teamDomain: "myteam" },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
plugin.register(api);
|
|
88
|
+
|
|
89
|
+
expect(api.registerService).toHaveBeenCalled();
|
|
90
|
+
// Extract and run the service start to verify token is passed through
|
|
91
|
+
const service = api.registerService.mock.calls[0][0];
|
|
92
|
+
await service.start();
|
|
93
|
+
|
|
94
|
+
expect(startGatewayCloudflareExposureMock).toHaveBeenCalledWith(
|
|
95
|
+
expect.objectContaining({ tunnelToken: "env-token" }),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("warns when mode is active but no teamDomain is configured", async () => {
|
|
100
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
101
|
+
|
|
102
|
+
const { default: plugin } = await import("./index.js");
|
|
103
|
+
const api = createMockApi({ tunnel: { mode: "access-only" } });
|
|
104
|
+
|
|
105
|
+
plugin.register(api);
|
|
106
|
+
|
|
107
|
+
expect(api.logger.warn).toHaveBeenCalledWith(
|
|
108
|
+
expect.stringContaining("no teamDomain configured"),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("warns when managed mode has no teamDomain", async () => {
|
|
113
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
114
|
+
|
|
115
|
+
const { default: plugin } = await import("./index.js");
|
|
116
|
+
const api = createMockApi({
|
|
117
|
+
tunnel: { mode: "managed", tunnelToken: "tok" },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
plugin.register(api);
|
|
121
|
+
|
|
122
|
+
expect(api.logger.warn).toHaveBeenCalledWith(
|
|
123
|
+
expect.stringContaining("no teamDomain configured"),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("registers service and HTTP handler in managed mode", async () => {
|
|
128
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
129
|
+
|
|
130
|
+
const { default: plugin } = await import("./index.js");
|
|
131
|
+
const api = createMockApi({
|
|
132
|
+
tunnel: { mode: "managed", tunnelToken: "tok", teamDomain: "myteam" },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
plugin.register(api);
|
|
136
|
+
|
|
137
|
+
expect(api.registerService).toHaveBeenCalledTimes(1);
|
|
138
|
+
expect(api.registerHttpHandler).toHaveBeenCalledTimes(1);
|
|
139
|
+
|
|
140
|
+
const service = api.registerService.mock.calls[0][0];
|
|
141
|
+
expect(service.id).toBe("cloudflare-tunnel");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("service start creates verifier when teamDomain is set", async () => {
|
|
145
|
+
const mockVerifier = { verify: vi.fn() };
|
|
146
|
+
createCloudflareAccessVerifierMock.mockReturnValue(mockVerifier);
|
|
147
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
148
|
+
|
|
149
|
+
const { default: plugin } = await import("./index.js");
|
|
150
|
+
const api = createMockApi({
|
|
151
|
+
tunnel: {
|
|
152
|
+
mode: "managed",
|
|
153
|
+
tunnelToken: "tok",
|
|
154
|
+
teamDomain: "myteam",
|
|
155
|
+
audience: "my-aud",
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
plugin.register(api);
|
|
160
|
+
|
|
161
|
+
const service = api.registerService.mock.calls[0][0];
|
|
162
|
+
await service.start();
|
|
163
|
+
|
|
164
|
+
expect(createCloudflareAccessVerifierMock).toHaveBeenCalledWith({
|
|
165
|
+
teamDomain: "myteam",
|
|
166
|
+
audience: "my-aud",
|
|
167
|
+
});
|
|
168
|
+
expect(api.logger.info).toHaveBeenCalledWith(
|
|
169
|
+
expect.stringContaining("myteam.cloudflareaccess.com"),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("service start does not create verifier when teamDomain is unset", async () => {
|
|
174
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
175
|
+
|
|
176
|
+
const { default: plugin } = await import("./index.js");
|
|
177
|
+
const api = createMockApi({
|
|
178
|
+
tunnel: { mode: "managed", tunnelToken: "tok" },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
plugin.register(api);
|
|
182
|
+
|
|
183
|
+
const service = api.registerService.mock.calls[0][0];
|
|
184
|
+
await service.start();
|
|
185
|
+
|
|
186
|
+
expect(createCloudflareAccessVerifierMock).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("service stop calls tunnel stop and clears verifier", async () => {
|
|
190
|
+
const tunnelStopFn = vi.fn();
|
|
191
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(tunnelStopFn);
|
|
192
|
+
createCloudflareAccessVerifierMock.mockReturnValue({ verify: vi.fn() });
|
|
193
|
+
|
|
194
|
+
const { default: plugin } = await import("./index.js");
|
|
195
|
+
const api = createMockApi({
|
|
196
|
+
tunnel: { mode: "managed", tunnelToken: "tok", teamDomain: "myteam" },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
plugin.register(api);
|
|
200
|
+
|
|
201
|
+
const service = api.registerService.mock.calls[0][0];
|
|
202
|
+
await service.start();
|
|
203
|
+
await service.stop();
|
|
204
|
+
|
|
205
|
+
expect(tunnelStopFn).toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("service stop is safe when no tunnel was started", async () => {
|
|
209
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
210
|
+
|
|
211
|
+
const { default: plugin } = await import("./index.js");
|
|
212
|
+
const api = createMockApi({
|
|
213
|
+
tunnel: { mode: "access-only", teamDomain: "myteam" },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
plugin.register(api);
|
|
217
|
+
|
|
218
|
+
const service = api.registerService.mock.calls[0][0];
|
|
219
|
+
await service.start();
|
|
220
|
+
// Should not throw
|
|
221
|
+
await service.stop();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("HTTP handler", () => {
|
|
225
|
+
it("strips spoofed identity headers even when no verifier is active", async () => {
|
|
226
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
227
|
+
|
|
228
|
+
const { default: plugin } = await import("./index.js");
|
|
229
|
+
const api = createMockApi({
|
|
230
|
+
tunnel: { mode: "managed", tunnelToken: "tok" },
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
plugin.register(api);
|
|
234
|
+
|
|
235
|
+
const handler = api.registerHttpHandler.mock.calls[0][0];
|
|
236
|
+
const req = createMockReq({
|
|
237
|
+
"x-openclaw-user-email": "spoofed@evil.com",
|
|
238
|
+
"x-openclaw-auth-source": "spoofed",
|
|
239
|
+
});
|
|
240
|
+
await handler(req, createMockRes());
|
|
241
|
+
|
|
242
|
+
expect(req.headers["x-openclaw-user-email"]).toBeUndefined();
|
|
243
|
+
expect(req.headers["x-openclaw-auth-source"]).toBeUndefined();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("strips spoofed identity headers before setting verified ones", async () => {
|
|
247
|
+
const mockVerifier = {
|
|
248
|
+
verify: vi.fn().mockResolvedValue({ email: "real@example.com" }),
|
|
249
|
+
};
|
|
250
|
+
createCloudflareAccessVerifierMock.mockReturnValue(mockVerifier);
|
|
251
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
252
|
+
|
|
253
|
+
const { default: plugin } = await import("./index.js");
|
|
254
|
+
const api = createMockApi({
|
|
255
|
+
tunnel: { mode: "managed", tunnelToken: "tok", teamDomain: "myteam" },
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
plugin.register(api);
|
|
259
|
+
|
|
260
|
+
const service = api.registerService.mock.calls[0][0];
|
|
261
|
+
await service.start();
|
|
262
|
+
|
|
263
|
+
const handler = api.registerHttpHandler.mock.calls[0][0];
|
|
264
|
+
const req = createMockReq({
|
|
265
|
+
"cf-access-jwt-assertion": "valid-jwt",
|
|
266
|
+
"x-openclaw-user-email": "spoofed@evil.com",
|
|
267
|
+
"x-openclaw-auth-source": "spoofed",
|
|
268
|
+
});
|
|
269
|
+
await handler(req, createMockRes());
|
|
270
|
+
|
|
271
|
+
expect(req.headers["x-openclaw-user-email"]).toBe("real@example.com");
|
|
272
|
+
expect(req.headers["x-openclaw-auth-source"]).toBe("cloudflare-access");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("returns false when no verifier is active", async () => {
|
|
276
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
277
|
+
|
|
278
|
+
const { default: plugin } = await import("./index.js");
|
|
279
|
+
const api = createMockApi({
|
|
280
|
+
tunnel: { mode: "managed", tunnelToken: "tok" },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
plugin.register(api);
|
|
284
|
+
|
|
285
|
+
// Service not started yet, so no verifier
|
|
286
|
+
const handler = api.registerHttpHandler.mock.calls[0][0];
|
|
287
|
+
const req = createMockReq({ "cf-access-jwt-assertion": "some-jwt" });
|
|
288
|
+
const result = await handler(req, createMockRes());
|
|
289
|
+
|
|
290
|
+
expect(result).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("returns false when no JWT header is present", async () => {
|
|
294
|
+
const mockVerifier = { verify: vi.fn() };
|
|
295
|
+
createCloudflareAccessVerifierMock.mockReturnValue(mockVerifier);
|
|
296
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
297
|
+
|
|
298
|
+
const { default: plugin } = await import("./index.js");
|
|
299
|
+
const api = createMockApi({
|
|
300
|
+
tunnel: { mode: "managed", tunnelToken: "tok", teamDomain: "myteam" },
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
plugin.register(api);
|
|
304
|
+
|
|
305
|
+
const service = api.registerService.mock.calls[0][0];
|
|
306
|
+
await service.start();
|
|
307
|
+
|
|
308
|
+
const handler = api.registerHttpHandler.mock.calls[0][0];
|
|
309
|
+
const req = createMockReq({});
|
|
310
|
+
const result = await handler(req, createMockRes());
|
|
311
|
+
|
|
312
|
+
expect(result).toBe(false);
|
|
313
|
+
expect(mockVerifier.verify).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("sets identity headers on valid JWT", async () => {
|
|
317
|
+
const mockVerifier = {
|
|
318
|
+
verify: vi.fn().mockResolvedValue({ email: "alice@example.com" }),
|
|
319
|
+
};
|
|
320
|
+
createCloudflareAccessVerifierMock.mockReturnValue(mockVerifier);
|
|
321
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
322
|
+
|
|
323
|
+
const { default: plugin } = await import("./index.js");
|
|
324
|
+
const api = createMockApi({
|
|
325
|
+
tunnel: { mode: "managed", tunnelToken: "tok", teamDomain: "myteam" },
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
plugin.register(api);
|
|
329
|
+
|
|
330
|
+
const service = api.registerService.mock.calls[0][0];
|
|
331
|
+
await service.start();
|
|
332
|
+
|
|
333
|
+
const handler = api.registerHttpHandler.mock.calls[0][0];
|
|
334
|
+
const req = createMockReq({ "cf-access-jwt-assertion": "valid-jwt" });
|
|
335
|
+
const result = await handler(req, createMockRes());
|
|
336
|
+
|
|
337
|
+
expect(result).toBe(false);
|
|
338
|
+
expect(mockVerifier.verify).toHaveBeenCalledWith("valid-jwt");
|
|
339
|
+
expect(req.headers["x-openclaw-user-email"]).toBe("alice@example.com");
|
|
340
|
+
expect(req.headers["x-openclaw-auth-source"]).toBe("cloudflare-access");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("does not set headers on invalid JWT", async () => {
|
|
344
|
+
const mockVerifier = {
|
|
345
|
+
verify: vi.fn().mockResolvedValue(null),
|
|
346
|
+
};
|
|
347
|
+
createCloudflareAccessVerifierMock.mockReturnValue(mockVerifier);
|
|
348
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
349
|
+
|
|
350
|
+
const { default: plugin } = await import("./index.js");
|
|
351
|
+
const api = createMockApi({
|
|
352
|
+
tunnel: { mode: "managed", tunnelToken: "tok", teamDomain: "myteam" },
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
plugin.register(api);
|
|
356
|
+
|
|
357
|
+
const service = api.registerService.mock.calls[0][0];
|
|
358
|
+
await service.start();
|
|
359
|
+
|
|
360
|
+
const handler = api.registerHttpHandler.mock.calls[0][0];
|
|
361
|
+
const req = createMockReq({ "cf-access-jwt-assertion": "bad-jwt" });
|
|
362
|
+
const result = await handler(req, createMockRes());
|
|
363
|
+
|
|
364
|
+
expect(result).toBe(false);
|
|
365
|
+
expect(req.headers["x-openclaw-user-email"]).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("handles array JWT header (takes first value)", async () => {
|
|
369
|
+
const mockVerifier = {
|
|
370
|
+
verify: vi.fn().mockResolvedValue({ email: "bob@example.com" }),
|
|
371
|
+
};
|
|
372
|
+
createCloudflareAccessVerifierMock.mockReturnValue(mockVerifier);
|
|
373
|
+
startGatewayCloudflareExposureMock.mockResolvedValue(null);
|
|
374
|
+
|
|
375
|
+
const { default: plugin } = await import("./index.js");
|
|
376
|
+
const api = createMockApi({
|
|
377
|
+
tunnel: { mode: "managed", tunnelToken: "tok", teamDomain: "myteam" },
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
plugin.register(api);
|
|
381
|
+
|
|
382
|
+
const service = api.registerService.mock.calls[0][0];
|
|
383
|
+
await service.start();
|
|
384
|
+
|
|
385
|
+
const handler = api.registerHttpHandler.mock.calls[0][0];
|
|
386
|
+
const req = createMockReq({
|
|
387
|
+
"cf-access-jwt-assertion": ["first-jwt", "second-jwt"] as unknown as string,
|
|
388
|
+
});
|
|
389
|
+
const result = await handler(req, createMockRes());
|
|
390
|
+
|
|
391
|
+
expect(result).toBe(false);
|
|
392
|
+
expect(mockVerifier.verify).toHaveBeenCalledWith("first-jwt");
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { createCloudflareAccessVerifier, type CloudflareAccessVerifier } from "./tunnel/access.js";
|
|
3
|
+
import { startGatewayCloudflareExposure } from "./tunnel/exposure.js";
|
|
4
|
+
|
|
5
|
+
type TunnelConfig = {
|
|
6
|
+
mode?: "off" | "managed" | "access-only";
|
|
7
|
+
tunnelToken?: string;
|
|
8
|
+
teamDomain?: string;
|
|
9
|
+
audience?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type PluginConfig = {
|
|
13
|
+
tunnel?: TunnelConfig;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
id: "cloudflare",
|
|
18
|
+
name: "Cloudflare",
|
|
19
|
+
|
|
20
|
+
register(api: {
|
|
21
|
+
pluginConfig?: PluginConfig;
|
|
22
|
+
logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
23
|
+
registerService(service: { id: string; start: () => Promise<void>; stop: () => Promise<void> }): void;
|
|
24
|
+
registerHttpHandler(handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean> | boolean): void;
|
|
25
|
+
}) {
|
|
26
|
+
const config = api.pluginConfig?.tunnel;
|
|
27
|
+
const mode = config?.mode ?? "off";
|
|
28
|
+
if (mode === "off") return;
|
|
29
|
+
|
|
30
|
+
const tunnelToken =
|
|
31
|
+
config?.tunnelToken ?? process.env.OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN;
|
|
32
|
+
const teamDomain = config?.teamDomain;
|
|
33
|
+
|
|
34
|
+
// Validate config
|
|
35
|
+
if (mode === "managed" && !tunnelToken) {
|
|
36
|
+
api.logger.error(
|
|
37
|
+
"[cloudflare] managed mode requires tunnelToken config or OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN env var",
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (teamDomain === undefined) {
|
|
42
|
+
api.logger.warn(
|
|
43
|
+
"[cloudflare] no teamDomain configured — JWT verification will be skipped",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let verifier: CloudflareAccessVerifier | null = null;
|
|
48
|
+
let stopTunnel: (() => Promise<void>) | null = null;
|
|
49
|
+
|
|
50
|
+
// Register background service for tunnel lifecycle
|
|
51
|
+
api.registerService({
|
|
52
|
+
id: "cloudflare-tunnel",
|
|
53
|
+
async start() {
|
|
54
|
+
// Create JWT verifier if teamDomain is set
|
|
55
|
+
if (teamDomain) {
|
|
56
|
+
verifier = createCloudflareAccessVerifier({
|
|
57
|
+
teamDomain,
|
|
58
|
+
audience: config?.audience,
|
|
59
|
+
});
|
|
60
|
+
api.logger.info(
|
|
61
|
+
`[cloudflare] Access JWT verifier active for ${teamDomain}.cloudflareaccess.com`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Start tunnel exposure (managed mode)
|
|
66
|
+
stopTunnel = await startGatewayCloudflareExposure({
|
|
67
|
+
cloudflareMode: mode,
|
|
68
|
+
tunnelToken,
|
|
69
|
+
logCloudflare: {
|
|
70
|
+
info: (msg) => api.logger.info(`[cloudflare] ${msg}`),
|
|
71
|
+
warn: (msg) => api.logger.warn(`[cloudflare] ${msg}`),
|
|
72
|
+
error: (msg) => api.logger.error(`[cloudflare] ${msg}`),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
async stop() {
|
|
77
|
+
if (stopTunnel) {
|
|
78
|
+
await stopTunnel();
|
|
79
|
+
stopTunnel = null;
|
|
80
|
+
}
|
|
81
|
+
verifier = null;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Register HTTP handler for JWT auth
|
|
86
|
+
api.registerHttpHandler(async (req: IncomingMessage, _res: ServerResponse) => {
|
|
87
|
+
// Always strip identity headers to prevent spoofing from untrusted clients
|
|
88
|
+
delete req.headers["x-openclaw-user-email"];
|
|
89
|
+
delete req.headers["x-openclaw-auth-source"];
|
|
90
|
+
|
|
91
|
+
if (!verifier) return false;
|
|
92
|
+
|
|
93
|
+
const jwtHeader = req.headers["cf-access-jwt-assertion"];
|
|
94
|
+
const token = Array.isArray(jwtHeader) ? jwtHeader[0] : jwtHeader;
|
|
95
|
+
if (!token) return false;
|
|
96
|
+
|
|
97
|
+
const user = await verifier.verify(token);
|
|
98
|
+
if (user) {
|
|
99
|
+
// Set identity headers for gateway auth flow
|
|
100
|
+
req.headers["x-openclaw-user-email"] = user.email;
|
|
101
|
+
req.headers["x-openclaw-auth-source"] = "cloudflare-access";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return false; // don't consume the request
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
};
|