openclaw-cloudflare 0.2.1 → 0.3.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/README.md +105 -83
- package/openclaw.plugin.json +3 -14
- package/package.json +6 -1
- package/src/index.ts +14 -68
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.github/workflows/changeset-check.yml +0 -25
- package/.github/workflows/ci.yml +0 -25
- package/.github/workflows/release.yml +0 -39
- package/CHANGELOG.md +0 -17
- package/CLAUDE.md +0 -81
- package/src/index.test.ts +0 -395
- package/src/tunnel/access.test.ts +0 -280
- package/src/tunnel/cloudflared.test.ts +0 -324
- package/src/tunnel/cloudflared.ts +0 -273
- package/src/tunnel/exposure.test.ts +0 -113
- package/src/tunnel/exposure.ts +0 -45
- package/tsconfig.json +0 -16
- package/vitest.config.ts +0 -7
|
@@ -1,324 +0,0 @@
|
|
|
1
|
-
import type { ChildProcess } from "node:child_process";
|
|
2
|
-
import type { Readable } from "node:stream";
|
|
3
|
-
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
|
4
|
-
|
|
5
|
-
// Mock spawn and execFile (promisified via util.promisify)
|
|
6
|
-
const spawnMock = vi.fn();
|
|
7
|
-
const execFileMock = vi.fn();
|
|
8
|
-
vi.mock("node:child_process", () => ({
|
|
9
|
-
execFile: (...args: unknown[]) => execFileMock(...args),
|
|
10
|
-
spawn: (...args: unknown[]) => spawnMock(...args),
|
|
11
|
-
}));
|
|
12
|
-
|
|
13
|
-
// Mock existsSync
|
|
14
|
-
const existsSyncMock = vi.fn<(p: string) => boolean>(() => true);
|
|
15
|
-
vi.mock("node:fs", () => ({
|
|
16
|
-
existsSync: (p: string) => existsSyncMock(p),
|
|
17
|
-
}));
|
|
18
|
-
|
|
19
|
-
// Mock fs/promises
|
|
20
|
-
const mkdirMock = vi.fn().mockResolvedValue(undefined);
|
|
21
|
-
const writeFileMock = vi.fn().mockResolvedValue(undefined);
|
|
22
|
-
const chmodMock = vi.fn().mockResolvedValue(undefined);
|
|
23
|
-
const unlinkMock = vi.fn().mockResolvedValue(undefined);
|
|
24
|
-
vi.mock("node:fs/promises", () => ({
|
|
25
|
-
mkdir: (...args: unknown[]) => mkdirMock(...args),
|
|
26
|
-
writeFile: (...args: unknown[]) => writeFileMock(...args),
|
|
27
|
-
chmod: (...args: unknown[]) => chmodMock(...args),
|
|
28
|
-
unlink: (...args: unknown[]) => unlinkMock(...args),
|
|
29
|
-
}));
|
|
30
|
-
|
|
31
|
-
// Mock os.homedir
|
|
32
|
-
const homedirMock = vi.fn(() => "/home/testuser");
|
|
33
|
-
vi.mock("node:os", () => ({
|
|
34
|
-
default: { homedir: () => homedirMock() },
|
|
35
|
-
homedir: () => homedirMock(),
|
|
36
|
-
}));
|
|
37
|
-
|
|
38
|
-
// Shared exec mock for findCloudflaredBinary
|
|
39
|
-
const execMock = vi.fn();
|
|
40
|
-
|
|
41
|
-
describe("findCloudflaredBinary", () => {
|
|
42
|
-
beforeEach(() => {
|
|
43
|
-
vi.resetModules();
|
|
44
|
-
vi.restoreAllMocks();
|
|
45
|
-
delete process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY;
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("returns environment override when set", async () => {
|
|
49
|
-
process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY = "/custom/cloudflared";
|
|
50
|
-
const { findCloudflaredBinary } = await import("./cloudflared.js");
|
|
51
|
-
const result = await findCloudflaredBinary(execMock);
|
|
52
|
-
expect(result).toBe("/custom/cloudflared");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("finds cloudflared via which", async () => {
|
|
56
|
-
execMock.mockImplementation((cmd: string, _args: string[]) => {
|
|
57
|
-
if (cmd === "which") {
|
|
58
|
-
return Promise.resolve({ stdout: "/usr/local/bin/cloudflared\n", stderr: "" });
|
|
59
|
-
}
|
|
60
|
-
// --version check
|
|
61
|
-
return Promise.resolve({ stdout: "cloudflared version 2024.1.0\n", stderr: "" });
|
|
62
|
-
});
|
|
63
|
-
existsSyncMock.mockReturnValue(true);
|
|
64
|
-
|
|
65
|
-
const { findCloudflaredBinary } = await import("./cloudflared.js");
|
|
66
|
-
const result = await findCloudflaredBinary(execMock);
|
|
67
|
-
expect(result).toBe("/usr/local/bin/cloudflared");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("falls back to known paths when which fails", async () => {
|
|
71
|
-
execMock.mockImplementation((cmd: string, _args: string[]) => {
|
|
72
|
-
if (cmd === "which") {
|
|
73
|
-
return Promise.reject(new Error("not found"));
|
|
74
|
-
}
|
|
75
|
-
// --version check for known path
|
|
76
|
-
return Promise.resolve({ stdout: "cloudflared version 2024.1.0\n", stderr: "" });
|
|
77
|
-
});
|
|
78
|
-
existsSyncMock.mockImplementation((p: string) => p === "/usr/local/bin/cloudflared");
|
|
79
|
-
|
|
80
|
-
const { findCloudflaredBinary } = await import("./cloudflared.js");
|
|
81
|
-
const result = await findCloudflaredBinary(execMock);
|
|
82
|
-
expect(result).toBe("/usr/local/bin/cloudflared");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("returns null when binary is not found", async () => {
|
|
86
|
-
execMock.mockRejectedValue(new Error("not found"));
|
|
87
|
-
existsSyncMock.mockReturnValue(false);
|
|
88
|
-
|
|
89
|
-
const { findCloudflaredBinary } = await import("./cloudflared.js");
|
|
90
|
-
const result = await findCloudflaredBinary(execMock);
|
|
91
|
-
expect(result).toBeNull();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("finds cloudflared in ~/.openclaw/bin", async () => {
|
|
95
|
-
const openclawBinPath = "/home/testuser/.openclaw/bin/cloudflared";
|
|
96
|
-
execMock.mockImplementation((cmd: string, _args: string[]) => {
|
|
97
|
-
if (cmd === "which") {
|
|
98
|
-
return Promise.reject(new Error("not found"));
|
|
99
|
-
}
|
|
100
|
-
return Promise.resolve({ stdout: "cloudflared version 2024.1.0\n", stderr: "" });
|
|
101
|
-
});
|
|
102
|
-
existsSyncMock.mockImplementation((p: string) => p === openclawBinPath);
|
|
103
|
-
|
|
104
|
-
const { findCloudflaredBinary } = await import("./cloudflared.js");
|
|
105
|
-
const result = await findCloudflaredBinary(execMock);
|
|
106
|
-
expect(result).toBe(openclawBinPath);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe("installCloudflared", () => {
|
|
111
|
-
const originalPlatform = process.platform;
|
|
112
|
-
const originalArch = process.arch;
|
|
113
|
-
|
|
114
|
-
beforeEach(() => {
|
|
115
|
-
vi.resetModules();
|
|
116
|
-
vi.restoreAllMocks();
|
|
117
|
-
mkdirMock.mockResolvedValue(undefined);
|
|
118
|
-
writeFileMock.mockResolvedValue(undefined);
|
|
119
|
-
chmodMock.mockResolvedValue(undefined);
|
|
120
|
-
unlinkMock.mockResolvedValue(undefined);
|
|
121
|
-
homedirMock.mockReturnValue("/home/testuser");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
afterEach(() => {
|
|
125
|
-
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
126
|
-
Object.defineProperty(process, "arch", { value: originalArch });
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("downloads and installs linux binary", async () => {
|
|
130
|
-
Object.defineProperty(process, "platform", { value: "linux" });
|
|
131
|
-
Object.defineProperty(process, "arch", { value: "x64" });
|
|
132
|
-
|
|
133
|
-
const binaryData = new Uint8Array([1, 2, 3]);
|
|
134
|
-
const fetchMock = vi.fn().mockResolvedValue({
|
|
135
|
-
ok: true,
|
|
136
|
-
arrayBuffer: () => Promise.resolve(binaryData.buffer),
|
|
137
|
-
});
|
|
138
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
139
|
-
|
|
140
|
-
const { installCloudflared } = await import("./cloudflared.js");
|
|
141
|
-
const logger = { info: vi.fn() };
|
|
142
|
-
const result = await installCloudflared(logger);
|
|
143
|
-
|
|
144
|
-
expect(result).toBe("/home/testuser/.openclaw/bin/cloudflared");
|
|
145
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
146
|
-
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
|
|
147
|
-
{ redirect: "follow" },
|
|
148
|
-
);
|
|
149
|
-
expect(mkdirMock).toHaveBeenCalledWith("/home/testuser/.openclaw/bin", { recursive: true });
|
|
150
|
-
expect(writeFileMock).toHaveBeenCalledWith(
|
|
151
|
-
"/home/testuser/.openclaw/bin/cloudflared",
|
|
152
|
-
expect.any(Uint8Array),
|
|
153
|
-
);
|
|
154
|
-
expect(chmodMock).toHaveBeenCalledWith("/home/testuser/.openclaw/bin/cloudflared", 0o755);
|
|
155
|
-
expect(logger.info).toHaveBeenCalledTimes(2);
|
|
156
|
-
|
|
157
|
-
vi.unstubAllGlobals();
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("downloads and extracts macOS tgz", async () => {
|
|
161
|
-
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
162
|
-
Object.defineProperty(process, "arch", { value: "arm64" });
|
|
163
|
-
|
|
164
|
-
// Make execFile (used by promisify) call the callback immediately.
|
|
165
|
-
// promisify looks for the last function argument as the callback.
|
|
166
|
-
execFileMock.mockImplementation((...args: unknown[]) => {
|
|
167
|
-
const cb = args[args.length - 1];
|
|
168
|
-
if (typeof cb === "function") (cb as (err: Error | null, stdout: string, stderr: string) => void)(null, "", "");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
const binaryData = new Uint8Array([1, 2, 3]);
|
|
172
|
-
const fetchMock = vi.fn().mockResolvedValue({
|
|
173
|
-
ok: true,
|
|
174
|
-
arrayBuffer: () => Promise.resolve(binaryData.buffer),
|
|
175
|
-
});
|
|
176
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
177
|
-
|
|
178
|
-
const { installCloudflared } = await import("./cloudflared.js");
|
|
179
|
-
const logger = { info: vi.fn() };
|
|
180
|
-
const result = await installCloudflared(logger);
|
|
181
|
-
|
|
182
|
-
expect(result).toBe("/home/testuser/.openclaw/bin/cloudflared");
|
|
183
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
184
|
-
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz",
|
|
185
|
-
{ redirect: "follow" },
|
|
186
|
-
);
|
|
187
|
-
// tgz should be written, tar extracted, then tgz deleted
|
|
188
|
-
expect(writeFileMock).toHaveBeenCalledWith(
|
|
189
|
-
"/home/testuser/.openclaw/bin/cloudflared.tgz",
|
|
190
|
-
expect.any(Uint8Array),
|
|
191
|
-
);
|
|
192
|
-
expect(unlinkMock).toHaveBeenCalledWith("/home/testuser/.openclaw/bin/cloudflared.tgz");
|
|
193
|
-
expect(chmodMock).toHaveBeenCalledWith("/home/testuser/.openclaw/bin/cloudflared", 0o755);
|
|
194
|
-
|
|
195
|
-
vi.unstubAllGlobals();
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("throws on unsupported platform", async () => {
|
|
199
|
-
Object.defineProperty(process, "platform", { value: "win32" });
|
|
200
|
-
Object.defineProperty(process, "arch", { value: "x64" });
|
|
201
|
-
|
|
202
|
-
const { installCloudflared } = await import("./cloudflared.js");
|
|
203
|
-
await expect(installCloudflared()).rejects.toThrow(/Unsupported platform/);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it("throws on download failure", async () => {
|
|
207
|
-
Object.defineProperty(process, "platform", { value: "linux" });
|
|
208
|
-
Object.defineProperty(process, "arch", { value: "x64" });
|
|
209
|
-
|
|
210
|
-
const fetchMock = vi.fn().mockResolvedValue({
|
|
211
|
-
ok: false,
|
|
212
|
-
status: 404,
|
|
213
|
-
});
|
|
214
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
215
|
-
|
|
216
|
-
const { installCloudflared } = await import("./cloudflared.js");
|
|
217
|
-
await expect(installCloudflared()).rejects.toThrow(/Failed to download cloudflared: HTTP 404/);
|
|
218
|
-
|
|
219
|
-
vi.unstubAllGlobals();
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
describe("startCloudflaredTunnel", () => {
|
|
224
|
-
beforeEach(() => {
|
|
225
|
-
vi.resetModules();
|
|
226
|
-
vi.restoreAllMocks();
|
|
227
|
-
delete process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY;
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
function createMockProcess(): ChildProcess & {
|
|
231
|
-
_emit: (event: string, ...args: unknown[]) => void;
|
|
232
|
-
_emitStderr: (data: string) => void;
|
|
233
|
-
} {
|
|
234
|
-
const events: Record<string, Array<(...args: unknown[]) => void>> = {};
|
|
235
|
-
const stdoutEvents: Record<string, Array<(...args: unknown[]) => void>> = {};
|
|
236
|
-
const stderrEvents: Record<string, Array<(...args: unknown[]) => void>> = {};
|
|
237
|
-
|
|
238
|
-
const mockStdout = {
|
|
239
|
-
setEncoding: vi.fn(),
|
|
240
|
-
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
241
|
-
stdoutEvents[event] = stdoutEvents[event] ?? [];
|
|
242
|
-
stdoutEvents[event].push(cb);
|
|
243
|
-
}),
|
|
244
|
-
};
|
|
245
|
-
const mockStderr = {
|
|
246
|
-
setEncoding: vi.fn(),
|
|
247
|
-
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
248
|
-
stderrEvents[event] = stderrEvents[event] ?? [];
|
|
249
|
-
stderrEvents[event].push(cb);
|
|
250
|
-
}),
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
return {
|
|
254
|
-
pid: 12345,
|
|
255
|
-
killed: false,
|
|
256
|
-
stdout: mockStdout as unknown as Readable,
|
|
257
|
-
stderr: mockStderr as unknown as Readable,
|
|
258
|
-
kill: vi.fn(),
|
|
259
|
-
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
260
|
-
events[event] = events[event] ?? [];
|
|
261
|
-
events[event].push(cb);
|
|
262
|
-
}),
|
|
263
|
-
once: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
264
|
-
events[event] = events[event] ?? [];
|
|
265
|
-
events[event].push(cb);
|
|
266
|
-
}),
|
|
267
|
-
_emit: (event: string, ...args: unknown[]) => {
|
|
268
|
-
for (const cb of events[event] ?? []) {
|
|
269
|
-
cb(...args);
|
|
270
|
-
}
|
|
271
|
-
},
|
|
272
|
-
_emitStderr: (data: string) => {
|
|
273
|
-
for (const cb of stderrEvents.data ?? []) {
|
|
274
|
-
cb(data);
|
|
275
|
-
}
|
|
276
|
-
},
|
|
277
|
-
} as unknown as ChildProcess & {
|
|
278
|
-
_emit: (event: string, ...args: unknown[]) => void;
|
|
279
|
-
_emitStderr: (data: string) => void;
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
it("starts tunnel and parses connector ID", async () => {
|
|
284
|
-
const mockChild = createMockProcess();
|
|
285
|
-
|
|
286
|
-
spawnMock.mockReturnValue(mockChild);
|
|
287
|
-
process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY = "/usr/local/bin/cloudflared";
|
|
288
|
-
|
|
289
|
-
const { startCloudflaredTunnel } = await import("./cloudflared.js");
|
|
290
|
-
|
|
291
|
-
const tunnelPromise = startCloudflaredTunnel({
|
|
292
|
-
token: "test-token",
|
|
293
|
-
timeoutMs: 5000,
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
// Simulate cloudflared registering a connection
|
|
297
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
298
|
-
mockChild._emitStderr("INF Registered tunnel connection connectorID=abc123-def456");
|
|
299
|
-
|
|
300
|
-
const tunnel = await tunnelPromise;
|
|
301
|
-
expect(tunnel.connectorId).toBe("abc123-def456");
|
|
302
|
-
expect(tunnel.pid).toBe(12345);
|
|
303
|
-
expect(typeof tunnel.stop).toBe("function");
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("throws when tunnel exits before registering", async () => {
|
|
307
|
-
const mockChild = createMockProcess();
|
|
308
|
-
|
|
309
|
-
spawnMock.mockReturnValue(mockChild);
|
|
310
|
-
process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY = "/usr/local/bin/cloudflared";
|
|
311
|
-
|
|
312
|
-
const { startCloudflaredTunnel } = await import("./cloudflared.js");
|
|
313
|
-
|
|
314
|
-
const tunnelPromise = startCloudflaredTunnel({
|
|
315
|
-
token: "bad-token",
|
|
316
|
-
timeoutMs: 5000,
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
320
|
-
mockChild._emit("exit", 1, null);
|
|
321
|
-
|
|
322
|
-
await expect(tunnelPromise).rejects.toThrow(/cloudflared exited/);
|
|
323
|
-
});
|
|
324
|
-
});
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { chmod, mkdir, unlink, writeFile } from "node:fs/promises";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
|
-
|
|
8
|
-
const execFilePromise = promisify(execFile);
|
|
9
|
-
|
|
10
|
-
type ExecResult = { stdout: string; stderr: string };
|
|
11
|
-
type ExecFn = (cmd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<ExecResult>;
|
|
12
|
-
|
|
13
|
-
/** Simple execFile wrapper to avoid depending on openclaw core's runExec. */
|
|
14
|
-
function defaultExec(cmd: string, args: string[], opts?: { timeoutMs?: number }): Promise<ExecResult> {
|
|
15
|
-
return new Promise((resolve, reject) => {
|
|
16
|
-
execFile(cmd, args, { timeout: opts?.timeoutMs ?? 5000 }, (err, stdout, stderr) => {
|
|
17
|
-
if (err) {
|
|
18
|
-
reject(err);
|
|
19
|
-
} else {
|
|
20
|
-
resolve({ stdout, stderr });
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Locate the cloudflared binary using multiple strategies:
|
|
28
|
-
* 1. Environment override (for testing)
|
|
29
|
-
* 2. PATH lookup (via `which`)
|
|
30
|
-
* 3. Known install paths
|
|
31
|
-
*/
|
|
32
|
-
export async function findCloudflaredBinary(
|
|
33
|
-
exec: ExecFn = defaultExec,
|
|
34
|
-
): Promise<string | null> {
|
|
35
|
-
const forced = process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY?.trim();
|
|
36
|
-
if (forced) {
|
|
37
|
-
return forced;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const checkBinary = async (path: string): Promise<boolean> => {
|
|
41
|
-
if (!path || !existsSync(path)) {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
try {
|
|
45
|
-
await exec(path, ["--version"], { timeoutMs: 3000 });
|
|
46
|
-
return true;
|
|
47
|
-
} catch {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// Strategy 1: which command
|
|
53
|
-
try {
|
|
54
|
-
const { stdout } = await exec("which", ["cloudflared"]);
|
|
55
|
-
const fromPath = stdout.trim();
|
|
56
|
-
if (fromPath && (await checkBinary(fromPath))) {
|
|
57
|
-
return fromPath;
|
|
58
|
-
}
|
|
59
|
-
} catch {
|
|
60
|
-
// which failed, continue
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Strategy 2: Known install paths
|
|
64
|
-
const knownPaths = [
|
|
65
|
-
path.join(os.homedir(), ".openclaw", "bin", "cloudflared"),
|
|
66
|
-
"/usr/local/bin/cloudflared",
|
|
67
|
-
"/usr/bin/cloudflared",
|
|
68
|
-
"/opt/homebrew/bin/cloudflared",
|
|
69
|
-
];
|
|
70
|
-
for (const candidate of knownPaths) {
|
|
71
|
-
if (await checkBinary(candidate)) {
|
|
72
|
-
return candidate;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Download and install the cloudflared binary from GitHub releases to ~/.openclaw/bin/.
|
|
81
|
-
*/
|
|
82
|
-
export async function installCloudflared(logger?: {
|
|
83
|
-
info: (msg: string) => void;
|
|
84
|
-
}): Promise<string> {
|
|
85
|
-
const platform = process.platform;
|
|
86
|
-
const arch = process.arch;
|
|
87
|
-
|
|
88
|
-
const archMap: Record<string, string> = {
|
|
89
|
-
x64: "amd64",
|
|
90
|
-
arm64: "arm64",
|
|
91
|
-
arm: "arm",
|
|
92
|
-
ia32: "386",
|
|
93
|
-
};
|
|
94
|
-
const cfArch = archMap[arch];
|
|
95
|
-
if (!cfArch) throw new Error(`Unsupported architecture: ${arch}`);
|
|
96
|
-
|
|
97
|
-
let filename: string;
|
|
98
|
-
if (platform === "linux") {
|
|
99
|
-
filename = `cloudflared-linux-${cfArch}`;
|
|
100
|
-
} else if (platform === "darwin") {
|
|
101
|
-
filename = `cloudflared-darwin-${cfArch}.tgz`;
|
|
102
|
-
} else {
|
|
103
|
-
throw new Error(`Unsupported platform for auto-install: ${platform}`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${filename}`;
|
|
107
|
-
|
|
108
|
-
const installDir = path.join(os.homedir(), ".openclaw", "bin");
|
|
109
|
-
await mkdir(installDir, { recursive: true });
|
|
110
|
-
const installPath = path.join(installDir, "cloudflared");
|
|
111
|
-
|
|
112
|
-
logger?.info(`cloudflared not found, downloading from ${url}...`);
|
|
113
|
-
|
|
114
|
-
const res = await fetch(url, { redirect: "follow" });
|
|
115
|
-
if (!res.ok) throw new Error(`Failed to download cloudflared: HTTP ${res.status}`);
|
|
116
|
-
const data = new Uint8Array(await res.arrayBuffer());
|
|
117
|
-
|
|
118
|
-
if (platform === "darwin") {
|
|
119
|
-
const tgzPath = installPath + ".tgz";
|
|
120
|
-
await writeFile(tgzPath, data);
|
|
121
|
-
await execFilePromise("tar", ["-xzf", tgzPath, "-C", installDir]);
|
|
122
|
-
await unlink(tgzPath);
|
|
123
|
-
} else {
|
|
124
|
-
await writeFile(installPath, data);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
await chmod(installPath, 0o755);
|
|
128
|
-
logger?.info(`cloudflared installed to ${installPath}`);
|
|
129
|
-
return installPath;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
let cachedCloudflaredBinary: string | null = null;
|
|
133
|
-
|
|
134
|
-
export async function getCloudflaredBinary(exec: ExecFn = defaultExec): Promise<string> {
|
|
135
|
-
const forced = process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY?.trim();
|
|
136
|
-
if (forced) {
|
|
137
|
-
cachedCloudflaredBinary = forced;
|
|
138
|
-
return forced;
|
|
139
|
-
}
|
|
140
|
-
if (cachedCloudflaredBinary) {
|
|
141
|
-
return cachedCloudflaredBinary;
|
|
142
|
-
}
|
|
143
|
-
cachedCloudflaredBinary = await findCloudflaredBinary(exec);
|
|
144
|
-
return cachedCloudflaredBinary ?? "cloudflared";
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export type CloudflaredTunnel = {
|
|
148
|
-
/** Connector ID parsed from cloudflared output, if available. */
|
|
149
|
-
connectorId: string | null;
|
|
150
|
-
pid: number | null;
|
|
151
|
-
stderr: string[];
|
|
152
|
-
stop: () => Promise<void>;
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Start a cloudflared tunnel process using a pre-configured tunnel token.
|
|
157
|
-
*
|
|
158
|
-
* The token encodes the tunnel UUID, account tag, and tunnel secret — cloudflared
|
|
159
|
-
* connects to the Cloudflare edge and routes traffic to the local origin.
|
|
160
|
-
*/
|
|
161
|
-
export async function startCloudflaredTunnel(opts: {
|
|
162
|
-
token: string;
|
|
163
|
-
timeoutMs?: number;
|
|
164
|
-
exec?: ExecFn;
|
|
165
|
-
logger?: { info: (msg: string) => void };
|
|
166
|
-
}): Promise<CloudflaredTunnel> {
|
|
167
|
-
const exec = opts.exec ?? defaultExec;
|
|
168
|
-
let bin = await findCloudflaredBinary(exec);
|
|
169
|
-
if (!bin) {
|
|
170
|
-
bin = await installCloudflared(opts.logger);
|
|
171
|
-
}
|
|
172
|
-
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
173
|
-
const stderr: string[] = [];
|
|
174
|
-
|
|
175
|
-
// Pass the token via TUNNEL_TOKEN env var rather than --token CLI arg
|
|
176
|
-
// to avoid leaking the secret in `ps` output.
|
|
177
|
-
const args = ["tunnel", "run"];
|
|
178
|
-
const child: ChildProcess = spawn(bin, args, {
|
|
179
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
180
|
-
env: { ...process.env, TUNNEL_TOKEN: opts.token },
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
child.stdout?.setEncoding("utf8");
|
|
184
|
-
child.stderr?.setEncoding("utf8");
|
|
185
|
-
|
|
186
|
-
let connectorId: string | null = null;
|
|
187
|
-
|
|
188
|
-
const collectOutput = (stream: NodeJS.ReadableStream | null) => {
|
|
189
|
-
stream?.on("data", (chunk: string) => {
|
|
190
|
-
const lines = String(chunk)
|
|
191
|
-
.split("\n")
|
|
192
|
-
.map((l) => l.trim())
|
|
193
|
-
.filter(Boolean);
|
|
194
|
-
stderr.push(...lines);
|
|
195
|
-
|
|
196
|
-
// Parse connector ID from cloudflared output
|
|
197
|
-
for (const line of lines) {
|
|
198
|
-
const match = line.match(/Registered tunnel connection\s+connectorID=([a-f0-9-]+)/i);
|
|
199
|
-
if (match) {
|
|
200
|
-
connectorId = match[1];
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
});
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
collectOutput(child.stdout);
|
|
207
|
-
collectOutput(child.stderr);
|
|
208
|
-
|
|
209
|
-
const stop = async () => {
|
|
210
|
-
if (child.killed) {
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
child.kill("SIGTERM");
|
|
214
|
-
await new Promise<void>((resolve) => {
|
|
215
|
-
const t = setTimeout(() => {
|
|
216
|
-
try {
|
|
217
|
-
child.kill("SIGKILL");
|
|
218
|
-
} finally {
|
|
219
|
-
resolve();
|
|
220
|
-
}
|
|
221
|
-
}, 1500);
|
|
222
|
-
child.once("exit", () => {
|
|
223
|
-
clearTimeout(t);
|
|
224
|
-
resolve();
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
// Wait for the tunnel to register at least one connection, or timeout.
|
|
230
|
-
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
231
|
-
try {
|
|
232
|
-
await Promise.race([
|
|
233
|
-
new Promise<void>((resolve) => {
|
|
234
|
-
const check = setInterval(() => {
|
|
235
|
-
if (connectorId) {
|
|
236
|
-
clearInterval(check);
|
|
237
|
-
resolve();
|
|
238
|
-
}
|
|
239
|
-
}, 100);
|
|
240
|
-
// Clean up interval on process exit
|
|
241
|
-
child.once("exit", () => clearInterval(check));
|
|
242
|
-
}),
|
|
243
|
-
new Promise<void>((_, reject) => {
|
|
244
|
-
timeoutHandle = setTimeout(
|
|
245
|
-
() => reject(new Error("cloudflared tunnel did not register within timeout")),
|
|
246
|
-
timeoutMs,
|
|
247
|
-
);
|
|
248
|
-
}),
|
|
249
|
-
new Promise<void>((_, reject) => {
|
|
250
|
-
child.once("exit", (code, signal) => {
|
|
251
|
-
reject(
|
|
252
|
-
new Error(
|
|
253
|
-
`cloudflared exited before tunnel registered (${code ?? "null"}${signal ? `/${signal}` : ""})`,
|
|
254
|
-
),
|
|
255
|
-
);
|
|
256
|
-
});
|
|
257
|
-
}),
|
|
258
|
-
]);
|
|
259
|
-
} catch (err) {
|
|
260
|
-
await stop();
|
|
261
|
-
const suffix = stderr.length > 0 ? `\n${stderr.join("\n")}` : "";
|
|
262
|
-
throw new Error(`${err instanceof Error ? err.message : String(err)}${suffix}`, { cause: err });
|
|
263
|
-
} finally {
|
|
264
|
-
clearTimeout(timeoutHandle);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return {
|
|
268
|
-
connectorId,
|
|
269
|
-
pid: typeof child.pid === "number" ? child.pid : null,
|
|
270
|
-
stderr,
|
|
271
|
-
stop,
|
|
272
|
-
};
|
|
273
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
const startCloudflaredTunnelMock = vi.fn();
|
|
4
|
-
vi.mock("./cloudflared.js", () => ({
|
|
5
|
-
startCloudflaredTunnel: (...args: unknown[]) => startCloudflaredTunnelMock(...args),
|
|
6
|
-
}));
|
|
7
|
-
|
|
8
|
-
function createMockLogger() {
|
|
9
|
-
return {
|
|
10
|
-
info: vi.fn(),
|
|
11
|
-
warn: vi.fn(),
|
|
12
|
-
error: vi.fn(),
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
describe("startGatewayCloudflareExposure", () => {
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
vi.resetModules();
|
|
19
|
-
vi.restoreAllMocks();
|
|
20
|
-
startCloudflaredTunnelMock.mockReset();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("returns null when mode is off", async () => {
|
|
24
|
-
const { startGatewayCloudflareExposure } = await import("./exposure.js");
|
|
25
|
-
const log = createMockLogger();
|
|
26
|
-
|
|
27
|
-
const result = await startGatewayCloudflareExposure({
|
|
28
|
-
cloudflareMode: "off",
|
|
29
|
-
logCloudflare: log,
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
expect(result).toBeNull();
|
|
33
|
-
expect(log.info).not.toHaveBeenCalled();
|
|
34
|
-
expect(log.error).not.toHaveBeenCalled();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("returns null and logs info in access-only mode", async () => {
|
|
38
|
-
const { startGatewayCloudflareExposure } = await import("./exposure.js");
|
|
39
|
-
const log = createMockLogger();
|
|
40
|
-
|
|
41
|
-
const result = await startGatewayCloudflareExposure({
|
|
42
|
-
cloudflareMode: "access-only",
|
|
43
|
-
logCloudflare: log,
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
expect(result).toBeNull();
|
|
47
|
-
expect(log.info).toHaveBeenCalledWith(
|
|
48
|
-
expect.stringContaining("access-only mode"),
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("returns null and logs error in managed mode without token", async () => {
|
|
53
|
-
const { startGatewayCloudflareExposure } = await import("./exposure.js");
|
|
54
|
-
const log = createMockLogger();
|
|
55
|
-
|
|
56
|
-
const result = await startGatewayCloudflareExposure({
|
|
57
|
-
cloudflareMode: "managed",
|
|
58
|
-
logCloudflare: log,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
expect(result).toBeNull();
|
|
62
|
-
expect(log.error).toHaveBeenCalledWith(
|
|
63
|
-
expect.stringContaining("no tunnel token provided"),
|
|
64
|
-
);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("starts tunnel and returns stop function in managed mode", async () => {
|
|
68
|
-
const stopFn = vi.fn();
|
|
69
|
-
startCloudflaredTunnelMock.mockResolvedValue({
|
|
70
|
-
connectorId: "abc-123",
|
|
71
|
-
pid: 9999,
|
|
72
|
-
stderr: [],
|
|
73
|
-
stop: stopFn,
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const { startGatewayCloudflareExposure } = await import("./exposure.js");
|
|
77
|
-
const log = createMockLogger();
|
|
78
|
-
|
|
79
|
-
const result = await startGatewayCloudflareExposure({
|
|
80
|
-
cloudflareMode: "managed",
|
|
81
|
-
tunnelToken: "test-token",
|
|
82
|
-
logCloudflare: log,
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
expect(result).toBe(stopFn);
|
|
86
|
-
expect(startCloudflaredTunnelMock).toHaveBeenCalledWith({
|
|
87
|
-
token: "test-token",
|
|
88
|
-
timeoutMs: 30_000,
|
|
89
|
-
logger: log,
|
|
90
|
-
});
|
|
91
|
-
expect(log.info).toHaveBeenCalledWith(
|
|
92
|
-
expect.stringContaining("connectorId=abc-123"),
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("returns null and logs error when tunnel start fails", async () => {
|
|
97
|
-
startCloudflaredTunnelMock.mockRejectedValue(new Error("connection refused"));
|
|
98
|
-
|
|
99
|
-
const { startGatewayCloudflareExposure } = await import("./exposure.js");
|
|
100
|
-
const log = createMockLogger();
|
|
101
|
-
|
|
102
|
-
const result = await startGatewayCloudflareExposure({
|
|
103
|
-
cloudflareMode: "managed",
|
|
104
|
-
tunnelToken: "bad-token",
|
|
105
|
-
logCloudflare: log,
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
expect(result).toBeNull();
|
|
109
|
-
expect(log.error).toHaveBeenCalledWith(
|
|
110
|
-
expect.stringContaining("connection refused"),
|
|
111
|
-
);
|
|
112
|
-
});
|
|
113
|
-
});
|