Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/browser-launch-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": patch
---

Add timeout to browser-rendering browser launch to prevent infinite hangs

The browser-rendering plugin's `launchBrowser()` function now passes a 5-minute timeout to `waitForLineOutput()` when waiting for Chrome to print its DevTools WebSocket URL. Previously, if Chrome failed to start or crashed before printing the URL, the promise would hang forever. This could cause CI pipelines and local dev sessions to get stuck indefinitely.
9 changes: 9 additions & 0 deletions .changeset/fix-cloudflared-macos-checksum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@cloudflare/workers-utils": patch
---

Fix cloudflared SHA256 checksum mismatch on macOS

The update service (`update.argotunnel.com`) returns a checksum for the extracted binary, not the `.tgz` tarball. We were computing the SHA256 of the tarball itself, which always mismatched on macOS where cloudflared is distributed as a compressed archive.

This aligns with cloudflared's own auto-updater (`cmd/cloudflared/updater/workers_update.go`), which decompresses the tarball first, then checksums the resulting binary. We now do the same: extract, then verify.
7 changes: 7 additions & 0 deletions .changeset/sharp-containers-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": minor
---

Add ProxyCommand support for `wrangler containers ssh`

`wrangler containers ssh` now automatically switches to a stdio proxy when invoked by OpenSSH's `ProxyCommand`, and `--stdio` can force this mode. This lets users connect with `ssh <instance_id>` when their SSH config uses Wrangler as the proxy command.
12 changes: 12 additions & 0 deletions .github/workflows/test-and-check-other-node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ jobs:
turbo-token: ${{ secrets.TURBO_TOKEN }}
turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}

# The miniflare browser-rendering tests download Chrome (~150 MB) via
# @puppeteer/browsers at runtime. Caching the binary avoids repeat
# downloads and reduces the chance of tests hanging while waiting for
# the download to finish within their timeout window.
- name: Restore Chrome browser cache (Browser Run)
if: steps.should_run.outputs.result == 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
key: chrome-${{ runner.os }}-${{ hashFiles('packages/miniflare/src/plugins/browser-rendering/browser-version.ts') }}
path: |
~/.cache/.wrangler/chrome
- name: Run tests (packages)
# We are running the package tests first be able to get early feedback on changes.
# There is no point in running the fixtures if a package is broken.
Expand Down
7 changes: 6 additions & 1 deletion packages/miniflare/src/plugins/browser-rendering/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,12 @@ export async function launchBrowser({
},
});
const wsEndpoint = await browserProcess.waitForLineOutput(
CDP_WEBSOCKET_ENDPOINT_REGEX
CDP_WEBSOCKET_ENDPOINT_REGEX,
// Note: we pass an explicit timeout so the promise rejects instead of hanging forever
// when Chrome fails to start or crashes before printing the DevTools URL.
// Five minutes is generous enough to cover on-demand browser downloads on slow
// connections while still failing within a reasonable window.
5 * 60 * 1_000
);
// On Windows in particular, Chrome may print the DevTools URL slightly
// before its listening socket is fully ready to accept connections.
Expand Down
38 changes: 26 additions & 12 deletions packages/workers-utils/src/cloudflared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
constants,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
Expand Down Expand Up @@ -516,7 +517,13 @@ async function downloadCloudflared(
}

/**
* Download and extract a tarball (for macOS)
* Download and extract a tarball (for macOS).
*
* The update service checksum is for the extracted binary, not the tarball
* itself. This matches cloudflared's own auto-update behavior — see
* cloudflared/cmd/cloudflared/updater/workers_update.go: the download()
* function decompresses .tgz into the raw binary, then Apply() checksums
* the resulting file. We do the same: extract first, then verify.
*/
async function downloadAndExtractTarball(
response: Response,
Expand All @@ -527,17 +534,6 @@ async function downloadAndExtractTarball(
const tempTarPath = join(cacheDir, "cloudflared.tgz");

const buffer = Buffer.from(await response.arrayBuffer());
if (expectedChecksum) {
const actualSha256 = sha256Hex(buffer);
if (actualSha256 !== expectedChecksum) {
throw new UserError(
`[cloudflared] SHA256 mismatch for downloaded cloudflared tarball.\n\n` +
`Expected: ${expectedChecksum}\n` +
`Actual: ${actualSha256}`,
{ telemetryMessage: "tunnel cloudflared tarball checksum mismatch" }
);
}
}
writeFileSync(tempTarPath, buffer);

try {
Expand All @@ -549,6 +545,24 @@ async function downloadAndExtractTarball(
if (extractedPath !== binPath && existsSync(extractedPath)) {
renameSync(extractedPath, binPath);
}

// Verify checksum against the extracted binary, not the tarball.
// The update service provides checksums for the uncompressed binary.
if (expectedChecksum) {
const extractedBinary = readFileSync(binPath);
const actualSha256 = sha256Hex(extractedBinary);
if (actualSha256 !== expectedChecksum) {
throw new UserError(
`[cloudflared] SHA256 mismatch for downloaded cloudflared binary.\n\n` +
`Expected: ${expectedChecksum}\n` +
`Actual: ${actualSha256}`,
{
telemetryMessage:
"tunnel cloudflared extracted binary checksum mismatch",
}
);
}
}
} finally {
try {
if (existsSync(tempTarPath)) {
Expand Down
203 changes: 190 additions & 13 deletions packages/wrangler/src/__tests__/containers/ssh.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,65 @@
import { PassThrough } from "node:stream";
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, describe, it } from "vitest";
import MockWebSocketServer from "vitest-websocket-mock";
import { afterEach, beforeEach, describe, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import { mockAccount, setWranglerConfig } from "../cloudchamber/utils";
import { mockAccountId, mockApiToken } from "../helpers/mock-account-id";
import { mockConsoleMethods } from "../helpers/mock-console";
import { msw } from "../helpers/msw";
import { runWrangler } from "../helpers/run-wrangler";
import type * as childProcess from "node:child_process";
import type * as nodeEvents from "node:events";
import type { WebSocket } from "ws";

vi.mock("node:child_process", async () => {
const actual =
await vi.importActual<typeof childProcess>("node:child_process");
const { EventEmitter } =
await vi.importActual<typeof nodeEvents>("node:events");

return {
...actual,
spawn: vi.fn((...args: Parameters<typeof actual.spawn>) => {
const [command, commandArgs] = args;
if (command !== "ssh") {
return actual.spawn(...args);
}

const child = new EventEmitter() as ReturnType<typeof actual.spawn>;
const sshArgs = Array.isArray(commandArgs)
? commandArgs.map((arg) => arg.toString())
: [];
const exitCode = sshArgs.length === 1 && sshArgs[0] === "-V" ? 0 : 255;

setImmediate(() => {
child.emit("exit", exitCode, null);
child.emit("close", exitCode, null);
});

return child;
}),
};
});

describe("containers ssh", () => {
const std = mockConsoleMethods();
const originalStdin = process.stdin;
const originalStdout = process.stdout;

mockAccountId();
mockApiToken();
beforeEach(mockAccount);

afterEach(() => {
msw.resetHandlers();
Object.defineProperty(process, "stdin", {
value: originalStdin,
configurable: true,
});
Object.defineProperty(process, "stdout", {
value: originalStdout,
configurable: true,
});
});

it("should help", async ({ expect }) => {
Expand All @@ -36,7 +80,10 @@ describe("containers ssh", () => {
--env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array]
-h, --help Show help [boolean]
--install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false]
-v, --version Show version number [boolean]"
-v, --version Show version number [boolean]

OPTIONS
--stdio Proxy SSH traffic over stdin/stdout [boolean]"
`);
});

Expand Down Expand Up @@ -79,27 +126,157 @@ describe("containers ssh", () => {
// against, but everything up until that point is covered.
it("should try ssh'ing into a container", async ({ expect }) => {
const instanceId = "a".repeat(64);
const wsUrl = "ws://localhost:1234";
const sshJwt = "asd";
const server = await createWsServer();
mockStdio({ stdinIsTTY: true, stdoutIsTTY: true });

try {
setWranglerConfig({});
msw.use(
http.get(`*/instances/:instanceId/ssh`, async () => {
return HttpResponse.json(
{ success: true, result: { url: server.url, token: sshJwt } },
{ status: 200 }
);
})
);

const wrangler = runWrangler(`containers ssh ${instanceId}`);
const expectedFailure = expect(wrangler).rejects.toMatchInlineSnapshot(`
[Error: SSH exited unsuccessfully. Is the container running?
NOTE: SSH does not automatically wake a container or count as activity to keep a container alive]
`);
const socket = await server.connection;
socket.close();
await expectedFailure;
} finally {
server.ws.close();
}
});

it("should proxy stdin and stdout when stdio is forced", async ({
expect,
}) => {
const instanceId = "a".repeat(64);
const sshJwt = "asd";
const server = await createWsServer();
const { stdin, stdout } = mockStdio({
stdinIsTTY: true,
stdoutIsTTY: true,
});

setWranglerConfig({});
msw.use(
http.get(`*/instances/:instanceId/ssh`, async () => {
return HttpResponse.json(
{ success: true, result: { url: wsUrl, token: sshJwt } },
{ success: true, result: { url: server.url, token: sshJwt } },
{ status: 200 }
);
})
);

const mockWebSocket = new MockWebSocketServer(wsUrl);
await expect(runWrangler(`containers ssh ${instanceId}`)).rejects
.toMatchInlineSnapshot(`
[Error: SSH exited unsuccessfully. Is the container running?
NOTE: SSH does not automatically wake a container or count as activity to keep a container alive]
`);
const stdoutData = new Promise<string>((resolve) => {
stdout.on("data", (chunk: Buffer) => resolve(chunk.toString()));
});
const serverMessage = new Promise<string>((resolve) => {
void server.connection.then((socket) => {
socket.on("message", (chunk) => resolve(chunk.toString()));
});
});
const wrangler = runWrangler(`containers ssh --stdio ${instanceId}`);

const socket = await server.connection;
stdin.write("client-data");
await expect(serverMessage).resolves.toBe("client-data");

// We got a connection
expect(mockWebSocket.connected).toBeTruthy();
socket.send("server-data");
await expect(stdoutData).resolves.toBe("server-data");

stdin.end();
socket.close();
await wrangler;
server.ws.close();
expect(std.out).toBe("");
expect(stdin.listenerCount("data")).toBe(0);
expect(stdin.listenerCount("end")).toBe(0);
expect(stdin.listenerCount("error")).toBe(0);
});

it("should auto-detect proxy mode with extra args when stdin and stdout are not TTYs", async ({
expect,
}) => {
const instanceId = "a".repeat(64);
const sshJwt = "asd";
const server = await createWsServer();
const { stdin, stdout } = mockStdio({});

setWranglerConfig({});
msw.use(
http.get(`*/instances/:instanceId/ssh`, async () => {
return HttpResponse.json(
{ success: true, result: { url: server.url, token: sshJwt } },
{ status: 200 }
);
})
);

const stdoutData = new Promise<string>((resolve) => {
stdout.on("data", (chunk: Buffer) => resolve(chunk.toString()));
});
const serverMessage = new Promise<string>((resolve) => {
void server.connection.then((socket) => {
socket.on("message", (chunk) => resolve(chunk.toString()));
});
});
const wrangler = runWrangler(`containers ssh ${instanceId} -- 22`);

const socket = await server.connection;
stdin.write("client-data");
await expect(serverMessage).resolves.toBe("client-data");

socket.send("server-data");
await expect(stdoutData).resolves.toBe("server-data");

stdin.end();
socket.close();
await wrangler;
server.ws.close();
expect(std.out).toBe("");
});
});

async function createWsServer() {
const ws = new WebSocketServer({ port: 0 });
await new Promise<void>((resolve) => ws.on("listening", resolve));
const address = ws.address();
if (address === null || typeof address === "string") {
throw new Error("Expected WebSocket server to listen on a TCP port");
}
const connection = new Promise<WebSocket>((resolve) => {
ws.on("connection", resolve);
});
return { ws, connection, url: `ws://127.0.0.1:${address.port}` };
}

function mockStdio({
stdinIsTTY,
stdoutIsTTY,
}: {
stdinIsTTY?: boolean;
stdoutIsTTY?: boolean;
}) {
const stdin = new PassThrough();
const stdout = new PassThrough();
if (stdinIsTTY !== undefined) {
Object.defineProperty(stdin, "isTTY", { value: stdinIsTTY });
}
if (stdoutIsTTY !== undefined) {
Object.defineProperty(stdout, "isTTY", { value: stdoutIsTTY });
}
Object.defineProperty(process, "stdin", { value: stdin, configurable: true });
Object.defineProperty(process, "stdout", {
value: stdout,
configurable: true,
});
return { stdin, stdout };
}
Loading
Loading