openclaw-cloudflare 0.1.0 → 0.2.1
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/config.json +1 -1
- package/CHANGELOG.md +17 -0
- package/CLAUDE.md +81 -0
- package/LICENSE +21 -0
- package/README.md +2 -1
- package/package.json +5 -3
- package/src/tunnel/cloudflared.test.ts +151 -3
- package/src/tunnel/cloudflared.ts +65 -1
- package/src/tunnel/exposure.test.ts +1 -0
- package/src/tunnel/exposure.ts +1 -0
package/.changeset/config.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
|
|
3
|
-
"changelog": ["@changesets/changelog-github", { "repo": "G4brym/openclaw-
|
|
3
|
+
"changelog": ["@changesets/changelog-github", { "repo": "G4brym/openclaw-cloudflare" }],
|
|
4
4
|
"commit": false,
|
|
5
5
|
"fixed": [],
|
|
6
6
|
"linked": [],
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# openclaw-cloudflare
|
|
2
|
+
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#4](https://github.com/G4brym/openclaw-cloudflare/pull/4) [`a363c11`](https://github.com/G4brym/openclaw-cloudflare/commit/a363c119aa808284762306a529e0572496735684) Thanks [@G4brym](https://github.com/G4brym)! - Add MIT LICENSE file
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- [#1](https://github.com/G4brym/openclaw-cloudflare/pull/1) [`cea0045`](https://github.com/G4brym/openclaw-cloudflare/commit/cea00459c619b0f75b51bc21d31f4f428c970cd3) Thanks [@G4brym](https://github.com/G4brym)! - Auto-install cloudflared binary when not found in managed mode
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- [#2](https://github.com/G4brym/openclaw-cloudflare/pull/2) [`f395045`](https://github.com/G4brym/openclaw-cloudflare/commit/f395045742bb31c55c0d4d953f8c79f5fb7ac478) Thanks [@G4brym](https://github.com/G4brym)! - Update GitHub repository references to match renamed repo
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
OpenClaw plugin that integrates Cloudflare Tunnel and Cloudflare Access. It spawns/manages a `cloudflared` process for tunnel mode and verifies Cloudflare Access JWTs to identify users. Published to npm as `openclaw-cloudflare`.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
- **Run all tests:** `npm test` (runs `vitest run`)
|
|
12
|
+
- **Run a single test file:** `npx vitest run src/tunnel/access.test.ts`
|
|
13
|
+
- **Run tests matching a name:** `npx vitest run -t "pattern"`
|
|
14
|
+
- **Typecheck:** `npm run typecheck` (runs `tsc --noEmit`)
|
|
15
|
+
|
|
16
|
+
## Architecture
|
|
17
|
+
|
|
18
|
+
ES module TypeScript project (`"type": "module"`) with no build/bundle step for development — the entry point is `./src/index.ts` directly.
|
|
19
|
+
|
|
20
|
+
### Module Layers
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
src/index.ts Plugin interface — exports {id, name, register()}
|
|
24
|
+
register() receives OpenClaw API (logger, registerService, registerHttpHandler)
|
|
25
|
+
Validates config, wires service + HTTP handler
|
|
26
|
+
|
|
27
|
+
src/tunnel/exposure.ts Orchestration — routes to correct mode (off / managed / access-only)
|
|
28
|
+
Returns a stop function or null
|
|
29
|
+
|
|
30
|
+
src/tunnel/cloudflared.ts Process management — finds/installs binary, spawns `cloudflared tunnel run`
|
|
31
|
+
Binary auto-install downloads from GitHub to ~/.openclaw/bin/
|
|
32
|
+
Passes token via TUNNEL_TOKEN env var (not CLI args)
|
|
33
|
+
Waits for connector registration on stderr, with timeout
|
|
34
|
+
|
|
35
|
+
src/tunnel/access.ts JWT verification — JWKS fetching with 10min cache, RS256/ES256
|
|
36
|
+
Uses Node.js WebCrypto (no external crypto deps)
|
|
37
|
+
Returns {email} or null on failure (never throws)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Plugin Registration Flow
|
|
41
|
+
|
|
42
|
+
1. `register(api)` validates config and exits early if mode is `"off"`
|
|
43
|
+
2. Registers a **service** (`cloudflare-tunnel`) that on `start()`:
|
|
44
|
+
- Creates a JWT verifier if `teamDomain` is configured
|
|
45
|
+
- Calls `startGatewayCloudflareExposure()` which spawns cloudflared in managed mode
|
|
46
|
+
3. Registers an **HTTP handler** that on every request:
|
|
47
|
+
- Strips `x-openclaw-user-email` and `x-openclaw-auth-source` headers (anti-spoofing)
|
|
48
|
+
- If verifier exists, reads `Cf-Access-Jwt-Assertion` header, verifies JWT, sets identity headers
|
|
49
|
+
|
|
50
|
+
### Key Design Patterns
|
|
51
|
+
|
|
52
|
+
- **Dependency injection everywhere** — logger, exec functions, fetch are all injected, making every module fully testable with mocks
|
|
53
|
+
- **Graceful degradation** — functions return null instead of throwing; errors are logged and the plugin continues
|
|
54
|
+
- **Anti-spoofing** — identity headers are always stripped before verification, then re-set only after successful JWT validation
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
Three modes configured via `tunnel.mode`:
|
|
59
|
+
- `"off"` (default) — plugin does nothing
|
|
60
|
+
- `"managed"` — spawns cloudflared, requires `tunnelToken` (config or `OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN` env var)
|
|
61
|
+
- `"access-only"` — JWT verification only, expects external cloudflared
|
|
62
|
+
|
|
63
|
+
Config schema is defined in `openclaw.plugin.json`.
|
|
64
|
+
|
|
65
|
+
## Workflow
|
|
66
|
+
|
|
67
|
+
The `main` branch is protected. All code changes must go through a pull request — never commit directly to main.
|
|
68
|
+
|
|
69
|
+
## Versioning and Releases
|
|
70
|
+
|
|
71
|
+
Uses [changesets](https://github.com/changesets/changesets). PRs to main require a changeset file (enforced by CI). Merging a changeset to main triggers automated npm publish via GitHub Actions with OIDC provenance.
|
|
72
|
+
|
|
73
|
+
**Every PR to main must include a changeset.** Add one with `npx changeset` or manually create a file in `.changeset/` with this format:
|
|
74
|
+
|
|
75
|
+
```md
|
|
76
|
+
---
|
|
77
|
+
"openclaw-cloudflare": patch # or minor/major
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
Description of the change
|
|
81
|
+
```
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Gabriel Massadas
|
|
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
|
@@ -42,9 +42,10 @@ Cloudflare integration is disabled.
|
|
|
42
42
|
OpenClaw spawns and manages a `cloudflared` tunnel process automatically.
|
|
43
43
|
|
|
44
44
|
**Requirements:**
|
|
45
|
-
- `cloudflared` binary installed and in PATH (or at a known location)
|
|
46
45
|
- A pre-configured tunnel token from the Cloudflare Zero Trust dashboard
|
|
47
46
|
|
|
47
|
+
> **Auto-install:** If `cloudflared` is not found in PATH or known locations, the plugin automatically downloads the latest release from GitHub to `~/.openclaw/bin/cloudflared`. No manual installation required.
|
|
48
|
+
|
|
48
49
|
**Setup:**
|
|
49
50
|
|
|
50
51
|
1. In the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com/), create a tunnel under **Networks > Tunnels**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-cloudflare",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Cloudflare integration plugin for OpenClaw (Tunnel, Access, and more)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
"vitest": "^3.0.0"
|
|
25
25
|
},
|
|
26
26
|
"openclaw": {
|
|
27
|
-
"extensions": [
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./src/index.ts"
|
|
29
|
+
],
|
|
28
30
|
"install": {
|
|
29
31
|
"npmSpec": "openclaw-cloudflare",
|
|
30
32
|
"defaultChoice": "npm"
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
],
|
|
41
43
|
"repository": {
|
|
42
44
|
"type": "git",
|
|
43
|
-
"url": "git+https://github.com/G4brym/openclaw-
|
|
45
|
+
"url": "git+https://github.com/G4brym/openclaw-cloudflare.git"
|
|
44
46
|
},
|
|
45
47
|
"publishConfig": {
|
|
46
48
|
"provenance": true,
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
2
|
import type { Readable } from "node:stream";
|
|
3
|
-
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3
|
+
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
|
4
4
|
|
|
5
|
-
// Mock spawn
|
|
5
|
+
// Mock spawn and execFile (promisified via util.promisify)
|
|
6
6
|
const spawnMock = vi.fn();
|
|
7
|
+
const execFileMock = vi.fn();
|
|
7
8
|
vi.mock("node:child_process", () => ({
|
|
8
|
-
execFile:
|
|
9
|
+
execFile: (...args: unknown[]) => execFileMock(...args),
|
|
9
10
|
spawn: (...args: unknown[]) => spawnMock(...args),
|
|
10
11
|
}));
|
|
11
12
|
|
|
@@ -15,6 +16,25 @@ vi.mock("node:fs", () => ({
|
|
|
15
16
|
existsSync: (p: string) => existsSyncMock(p),
|
|
16
17
|
}));
|
|
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
|
+
|
|
18
38
|
// Shared exec mock for findCloudflaredBinary
|
|
19
39
|
const execMock = vi.fn();
|
|
20
40
|
|
|
@@ -70,6 +90,134 @@ describe("findCloudflaredBinary", () => {
|
|
|
70
90
|
const result = await findCloudflaredBinary(execMock);
|
|
71
91
|
expect(result).toBeNull();
|
|
72
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
|
+
});
|
|
73
221
|
});
|
|
74
222
|
|
|
75
223
|
describe("startCloudflaredTunnel", () => {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
2
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);
|
|
3
9
|
|
|
4
10
|
type ExecResult = { stdout: string; stderr: string };
|
|
5
11
|
type ExecFn = (cmd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<ExecResult>;
|
|
@@ -56,6 +62,7 @@ export async function findCloudflaredBinary(
|
|
|
56
62
|
|
|
57
63
|
// Strategy 2: Known install paths
|
|
58
64
|
const knownPaths = [
|
|
65
|
+
path.join(os.homedir(), ".openclaw", "bin", "cloudflared"),
|
|
59
66
|
"/usr/local/bin/cloudflared",
|
|
60
67
|
"/usr/bin/cloudflared",
|
|
61
68
|
"/opt/homebrew/bin/cloudflared",
|
|
@@ -69,6 +76,59 @@ export async function findCloudflaredBinary(
|
|
|
69
76
|
return null;
|
|
70
77
|
}
|
|
71
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
|
+
|
|
72
132
|
let cachedCloudflaredBinary: string | null = null;
|
|
73
133
|
|
|
74
134
|
export async function getCloudflaredBinary(exec: ExecFn = defaultExec): Promise<string> {
|
|
@@ -102,9 +162,13 @@ export async function startCloudflaredTunnel(opts: {
|
|
|
102
162
|
token: string;
|
|
103
163
|
timeoutMs?: number;
|
|
104
164
|
exec?: ExecFn;
|
|
165
|
+
logger?: { info: (msg: string) => void };
|
|
105
166
|
}): Promise<CloudflaredTunnel> {
|
|
106
167
|
const exec = opts.exec ?? defaultExec;
|
|
107
|
-
|
|
168
|
+
let bin = await findCloudflaredBinary(exec);
|
|
169
|
+
if (!bin) {
|
|
170
|
+
bin = await installCloudflared(opts.logger);
|
|
171
|
+
}
|
|
108
172
|
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
109
173
|
const stderr: string[] = [];
|
|
110
174
|
|
|
@@ -86,6 +86,7 @@ describe("startGatewayCloudflareExposure", () => {
|
|
|
86
86
|
expect(startCloudflaredTunnelMock).toHaveBeenCalledWith({
|
|
87
87
|
token: "test-token",
|
|
88
88
|
timeoutMs: 30_000,
|
|
89
|
+
logger: log,
|
|
89
90
|
});
|
|
90
91
|
expect(log.info).toHaveBeenCalledWith(
|
|
91
92
|
expect.stringContaining("connectorId=abc-123"),
|
package/src/tunnel/exposure.ts
CHANGED
|
@@ -30,6 +30,7 @@ export async function startGatewayCloudflareExposure(params: {
|
|
|
30
30
|
const tunnel = await startCloudflaredTunnel({
|
|
31
31
|
token: params.tunnelToken,
|
|
32
32
|
timeoutMs: 30_000,
|
|
33
|
+
logger: params.logCloudflare,
|
|
33
34
|
});
|
|
34
35
|
params.logCloudflare.info(
|
|
35
36
|
`managed tunnel running (connectorId=${tunnel.connectorId ?? "unknown"}, pid=${tunnel.pid ?? "unknown"})`,
|