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,209 @@
|
|
|
1
|
+
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
type ExecResult = { stdout: string; stderr: string };
|
|
5
|
+
type ExecFn = (cmd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<ExecResult>;
|
|
6
|
+
|
|
7
|
+
/** Simple execFile wrapper to avoid depending on openclaw core's runExec. */
|
|
8
|
+
function defaultExec(cmd: string, args: string[], opts?: { timeoutMs?: number }): Promise<ExecResult> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
execFile(cmd, args, { timeout: opts?.timeoutMs ?? 5000 }, (err, stdout, stderr) => {
|
|
11
|
+
if (err) {
|
|
12
|
+
reject(err);
|
|
13
|
+
} else {
|
|
14
|
+
resolve({ stdout, stderr });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Locate the cloudflared binary using multiple strategies:
|
|
22
|
+
* 1. Environment override (for testing)
|
|
23
|
+
* 2. PATH lookup (via `which`)
|
|
24
|
+
* 3. Known install paths
|
|
25
|
+
*/
|
|
26
|
+
export async function findCloudflaredBinary(
|
|
27
|
+
exec: ExecFn = defaultExec,
|
|
28
|
+
): Promise<string | null> {
|
|
29
|
+
const forced = process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY?.trim();
|
|
30
|
+
if (forced) {
|
|
31
|
+
return forced;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const checkBinary = async (path: string): Promise<boolean> => {
|
|
35
|
+
if (!path || !existsSync(path)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await exec(path, ["--version"], { timeoutMs: 3000 });
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Strategy 1: which command
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await exec("which", ["cloudflared"]);
|
|
49
|
+
const fromPath = stdout.trim();
|
|
50
|
+
if (fromPath && (await checkBinary(fromPath))) {
|
|
51
|
+
return fromPath;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// which failed, continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Strategy 2: Known install paths
|
|
58
|
+
const knownPaths = [
|
|
59
|
+
"/usr/local/bin/cloudflared",
|
|
60
|
+
"/usr/bin/cloudflared",
|
|
61
|
+
"/opt/homebrew/bin/cloudflared",
|
|
62
|
+
];
|
|
63
|
+
for (const candidate of knownPaths) {
|
|
64
|
+
if (await checkBinary(candidate)) {
|
|
65
|
+
return candidate;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let cachedCloudflaredBinary: string | null = null;
|
|
73
|
+
|
|
74
|
+
export async function getCloudflaredBinary(exec: ExecFn = defaultExec): Promise<string> {
|
|
75
|
+
const forced = process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY?.trim();
|
|
76
|
+
if (forced) {
|
|
77
|
+
cachedCloudflaredBinary = forced;
|
|
78
|
+
return forced;
|
|
79
|
+
}
|
|
80
|
+
if (cachedCloudflaredBinary) {
|
|
81
|
+
return cachedCloudflaredBinary;
|
|
82
|
+
}
|
|
83
|
+
cachedCloudflaredBinary = await findCloudflaredBinary(exec);
|
|
84
|
+
return cachedCloudflaredBinary ?? "cloudflared";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type CloudflaredTunnel = {
|
|
88
|
+
/** Connector ID parsed from cloudflared output, if available. */
|
|
89
|
+
connectorId: string | null;
|
|
90
|
+
pid: number | null;
|
|
91
|
+
stderr: string[];
|
|
92
|
+
stop: () => Promise<void>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Start a cloudflared tunnel process using a pre-configured tunnel token.
|
|
97
|
+
*
|
|
98
|
+
* The token encodes the tunnel UUID, account tag, and tunnel secret — cloudflared
|
|
99
|
+
* connects to the Cloudflare edge and routes traffic to the local origin.
|
|
100
|
+
*/
|
|
101
|
+
export async function startCloudflaredTunnel(opts: {
|
|
102
|
+
token: string;
|
|
103
|
+
timeoutMs?: number;
|
|
104
|
+
exec?: ExecFn;
|
|
105
|
+
}): Promise<CloudflaredTunnel> {
|
|
106
|
+
const exec = opts.exec ?? defaultExec;
|
|
107
|
+
const bin = await getCloudflaredBinary(exec);
|
|
108
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
109
|
+
const stderr: string[] = [];
|
|
110
|
+
|
|
111
|
+
// Pass the token via TUNNEL_TOKEN env var rather than --token CLI arg
|
|
112
|
+
// to avoid leaking the secret in `ps` output.
|
|
113
|
+
const args = ["tunnel", "run"];
|
|
114
|
+
const child: ChildProcess = spawn(bin, args, {
|
|
115
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
116
|
+
env: { ...process.env, TUNNEL_TOKEN: opts.token },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
child.stdout?.setEncoding("utf8");
|
|
120
|
+
child.stderr?.setEncoding("utf8");
|
|
121
|
+
|
|
122
|
+
let connectorId: string | null = null;
|
|
123
|
+
|
|
124
|
+
const collectOutput = (stream: NodeJS.ReadableStream | null) => {
|
|
125
|
+
stream?.on("data", (chunk: string) => {
|
|
126
|
+
const lines = String(chunk)
|
|
127
|
+
.split("\n")
|
|
128
|
+
.map((l) => l.trim())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
stderr.push(...lines);
|
|
131
|
+
|
|
132
|
+
// Parse connector ID from cloudflared output
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
const match = line.match(/Registered tunnel connection\s+connectorID=([a-f0-9-]+)/i);
|
|
135
|
+
if (match) {
|
|
136
|
+
connectorId = match[1];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
collectOutput(child.stdout);
|
|
143
|
+
collectOutput(child.stderr);
|
|
144
|
+
|
|
145
|
+
const stop = async () => {
|
|
146
|
+
if (child.killed) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
child.kill("SIGTERM");
|
|
150
|
+
await new Promise<void>((resolve) => {
|
|
151
|
+
const t = setTimeout(() => {
|
|
152
|
+
try {
|
|
153
|
+
child.kill("SIGKILL");
|
|
154
|
+
} finally {
|
|
155
|
+
resolve();
|
|
156
|
+
}
|
|
157
|
+
}, 1500);
|
|
158
|
+
child.once("exit", () => {
|
|
159
|
+
clearTimeout(t);
|
|
160
|
+
resolve();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Wait for the tunnel to register at least one connection, or timeout.
|
|
166
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
167
|
+
try {
|
|
168
|
+
await Promise.race([
|
|
169
|
+
new Promise<void>((resolve) => {
|
|
170
|
+
const check = setInterval(() => {
|
|
171
|
+
if (connectorId) {
|
|
172
|
+
clearInterval(check);
|
|
173
|
+
resolve();
|
|
174
|
+
}
|
|
175
|
+
}, 100);
|
|
176
|
+
// Clean up interval on process exit
|
|
177
|
+
child.once("exit", () => clearInterval(check));
|
|
178
|
+
}),
|
|
179
|
+
new Promise<void>((_, reject) => {
|
|
180
|
+
timeoutHandle = setTimeout(
|
|
181
|
+
() => reject(new Error("cloudflared tunnel did not register within timeout")),
|
|
182
|
+
timeoutMs,
|
|
183
|
+
);
|
|
184
|
+
}),
|
|
185
|
+
new Promise<void>((_, reject) => {
|
|
186
|
+
child.once("exit", (code, signal) => {
|
|
187
|
+
reject(
|
|
188
|
+
new Error(
|
|
189
|
+
`cloudflared exited before tunnel registered (${code ?? "null"}${signal ? `/${signal}` : ""})`,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
}),
|
|
194
|
+
]);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
await stop();
|
|
197
|
+
const suffix = stderr.length > 0 ? `\n${stderr.join("\n")}` : "";
|
|
198
|
+
throw new Error(`${err instanceof Error ? err.message : String(err)}${suffix}`, { cause: err });
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(timeoutHandle);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
connectorId,
|
|
205
|
+
pid: typeof child.pid === "number" ? child.pid : null,
|
|
206
|
+
stderr,
|
|
207
|
+
stop,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
});
|
|
90
|
+
expect(log.info).toHaveBeenCalledWith(
|
|
91
|
+
expect.stringContaining("connectorId=abc-123"),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns null and logs error when tunnel start fails", async () => {
|
|
96
|
+
startCloudflaredTunnelMock.mockRejectedValue(new Error("connection refused"));
|
|
97
|
+
|
|
98
|
+
const { startGatewayCloudflareExposure } = await import("./exposure.js");
|
|
99
|
+
const log = createMockLogger();
|
|
100
|
+
|
|
101
|
+
const result = await startGatewayCloudflareExposure({
|
|
102
|
+
cloudflareMode: "managed",
|
|
103
|
+
tunnelToken: "bad-token",
|
|
104
|
+
logCloudflare: log,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(result).toBeNull();
|
|
108
|
+
expect(log.error).toHaveBeenCalledWith(
|
|
109
|
+
expect.stringContaining("connection refused"),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { startCloudflaredTunnel } from "./cloudflared.js";
|
|
2
|
+
|
|
3
|
+
export async function startGatewayCloudflareExposure(params: {
|
|
4
|
+
cloudflareMode: "off" | "managed" | "access-only";
|
|
5
|
+
tunnelToken?: string;
|
|
6
|
+
logCloudflare: {
|
|
7
|
+
info: (msg: string) => void;
|
|
8
|
+
warn: (msg: string) => void;
|
|
9
|
+
error: (msg: string) => void;
|
|
10
|
+
};
|
|
11
|
+
}): Promise<(() => Promise<void>) | null> {
|
|
12
|
+
if (params.cloudflareMode === "off") {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (params.cloudflareMode === "access-only") {
|
|
17
|
+
params.logCloudflare.info(
|
|
18
|
+
"access-only mode: Cloudflare Access JWT verification active (external cloudflared expected)",
|
|
19
|
+
);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// managed mode — spawn cloudflared tunnel run
|
|
24
|
+
if (!params.tunnelToken) {
|
|
25
|
+
params.logCloudflare.error("managed mode: no tunnel token provided, skipping tunnel start");
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const tunnel = await startCloudflaredTunnel({
|
|
31
|
+
token: params.tunnelToken,
|
|
32
|
+
timeoutMs: 30_000,
|
|
33
|
+
});
|
|
34
|
+
params.logCloudflare.info(
|
|
35
|
+
`managed tunnel running (connectorId=${tunnel.connectorId ?? "unknown"}, pid=${tunnel.pid ?? "unknown"})`,
|
|
36
|
+
);
|
|
37
|
+
return tunnel.stop;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
params.logCloudflare.error(
|
|
40
|
+
`managed tunnel failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
41
|
+
);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"sourceMap": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"],
|
|
15
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
16
|
+
}
|