feat(FN-2977): harden cloudflared install with fallbacks and arch detection
The merge hardens the cloudflared installation backend with fallback logic and architecture detection, updates the SettingsModal UI with clearer manual installation guidance, and adds comprehensive test coverage for the remote access routes including the new memory-based settings registration flow. Fusion-Task-Id: FN-2977
This commit is contained in:
@@ -1590,10 +1590,30 @@ export function SettingsModal({
|
||||
if (typeof navigator !== "undefined" && navigator.userAgent.includes("Windows")) {
|
||||
return "winget install Cloudflare.cloudflared";
|
||||
}
|
||||
if (typeof navigator !== "undefined" && /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform)) {
|
||||
|
||||
const platform = typeof navigator !== "undefined" ? navigator.platform : "";
|
||||
const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
||||
const isMac = /(Mac|iPhone|iPad|iPod)/i.test(platform);
|
||||
const isArm = /(arm64|aarch64)/i.test(`${platform} ${userAgent}`);
|
||||
|
||||
if (isMac) {
|
||||
return "brew install cloudflared";
|
||||
}
|
||||
return "curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 && chmod +x /usr/local/bin/cloudflared";
|
||||
|
||||
const linuxArch = isArm ? "arm64" : "amd64";
|
||||
return `curl -L --output /tmp/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${linuxArch} && chmod +x /tmp/cloudflared && sudo mv /tmp/cloudflared /usr/local/bin/cloudflared # If sudo is unavailable, use: mkdir -p ~/.local/bin && mv /tmp/cloudflared ~/.local/bin/cloudflared`;
|
||||
}, []);
|
||||
|
||||
const cloudflaredMacFallbackCommand = useCallback(() => {
|
||||
if (typeof navigator === "undefined") {
|
||||
return null;
|
||||
}
|
||||
if (!/(Mac|iPhone|iPad|iPod)/i.test(navigator.platform)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const arch = /(arm64|aarch64)/i.test(`${navigator.platform} ${navigator.userAgent}`) ? "arm64" : "amd64";
|
||||
return `curl -L --output /tmp/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${arch} && chmod +x /tmp/cloudflared && sudo mv /tmp/cloudflared /usr/local/bin/cloudflared`;
|
||||
}, []);
|
||||
|
||||
const handleInstallCloudflared = useCallback(async () => {
|
||||
@@ -4172,6 +4192,9 @@ export function SettingsModal({
|
||||
</button>
|
||||
{cloudflaredInstallError && <small className="remote-cli-install-error">{cloudflaredInstallError}</small>}
|
||||
<small className="remote-cli-manual">Manual install: <code>{cloudflaredManualInstallCommand()}</code></small>
|
||||
{cloudflaredMacFallbackCommand()
|
||||
? <small className="remote-cli-manual">If Homebrew is unavailable: <code>{cloudflaredMacFallbackCommand()}</code></small>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
|
||||
@@ -96,6 +96,23 @@ async function REQUEST(app: express.Express, method: string, path: string, body?
|
||||
);
|
||||
}
|
||||
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalArchDescriptor = Object.getOwnPropertyDescriptor(process, "arch");
|
||||
|
||||
function setProcessRuntime(platform: NodeJS.Platform, arch: string): void {
|
||||
Object.defineProperty(process, "platform", { value: platform, configurable: true });
|
||||
Object.defineProperty(process, "arch", { value: arch, configurable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, "platform", originalPlatformDescriptor);
|
||||
}
|
||||
if (originalArchDescriptor) {
|
||||
Object.defineProperty(process, "arch", originalArchDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecFile.mockReset();
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
@@ -295,6 +312,7 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
});
|
||||
|
||||
it("installs cloudflared via endpoint and returns install command metadata", async () => {
|
||||
setProcessRuntime("linux", "x64");
|
||||
const { app } = createApp();
|
||||
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
@@ -302,20 +320,83 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
command: expect.any(String),
|
||||
command: expect.stringContaining("cloudflared-linux-amd64"),
|
||||
}));
|
||||
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-linux-amd64"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns install failure details when cloudflared installation command fails", async () => {
|
||||
it("uses arm64 cloudflared binary on Linux arm64", async () => {
|
||||
setProcessRuntime("linux", "arm64");
|
||||
const { app } = createApp();
|
||||
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
command: expect.stringContaining("cloudflared-linux-arm64"),
|
||||
}));
|
||||
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-linux-arm64"))).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to ~/.local/bin when /usr/local/bin move fails with permission error", async () => {
|
||||
setProcessRuntime("linux", "x64");
|
||||
mockExecFile.mockImplementation((command: string, args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
: maybeCallback;
|
||||
if (command === "mv" && args[1] === "/usr/local/bin/cloudflared") {
|
||||
callback?.(new Error("EPERM"), "", "EPERM");
|
||||
return;
|
||||
}
|
||||
callback?.(null, "", "");
|
||||
});
|
||||
|
||||
const { app } = createApp();
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual(expect.objectContaining({ success: true }));
|
||||
expect(mockExecFile.mock.calls.some(([command, args]) => command === "mkdir" && Array.isArray(args) && args[0] === "-p")).toBe(true);
|
||||
expect(mockExecFile.mock.calls.some(([command, args]) => command === "mv" && Array.isArray(args) && String(args[1]).includes("/.local/bin/cloudflared"))).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to direct download on macOS when brew is unavailable", async () => {
|
||||
setProcessRuntime("darwin", "arm64");
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
: maybeCallback;
|
||||
if (command === "sh" || command === "cmd") {
|
||||
if (command === "which") {
|
||||
callback?.(new Error("brew not found"), "", "brew not found");
|
||||
return;
|
||||
}
|
||||
callback?.(null, "", "");
|
||||
});
|
||||
|
||||
const { app } = createApp();
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
command: expect.stringContaining("cloudflared-darwin-arm64"),
|
||||
}));
|
||||
expect(mockExecFile.mock.calls.some(([command]) => command === "brew")).toBe(false);
|
||||
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-darwin-arm64"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns install failure details when cloudflared installation command fails", async () => {
|
||||
setProcessRuntime("linux", "x64");
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
: maybeCallback;
|
||||
if (command === "curl") {
|
||||
callback?.(new Error("Command failed"), "", "Command failed");
|
||||
return;
|
||||
}
|
||||
callback?.(null, "/usr/local/bin/cloudflared", "");
|
||||
callback?.(null, "", "");
|
||||
});
|
||||
|
||||
const { app } = createApp();
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
||||
import QRCode from "qrcode";
|
||||
import { execFile } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
import { ApiError, badRequest } from "../api-error.js";
|
||||
import { generateRemoteToken, issueRemoteAuthToken, maskRemoteToken } from "../remote-auth.js";
|
||||
@@ -95,6 +96,35 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCloudflaredBinaryName(): string {
|
||||
if (process.platform === "linux") {
|
||||
if (process.arch === "arm") {
|
||||
return "cloudflared-linux-armhf";
|
||||
}
|
||||
if (process.arch === "arm64") {
|
||||
return "cloudflared-linux-arm64";
|
||||
}
|
||||
if (process.arch === "x64") {
|
||||
return "cloudflared-linux-amd64";
|
||||
}
|
||||
console.warn(`[remote-access] Unsupported Linux architecture '${process.arch}' for cloudflared; falling back to amd64`);
|
||||
return "cloudflared-linux-amd64";
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
if (process.arch === "arm64") {
|
||||
return "cloudflared-darwin-arm64";
|
||||
}
|
||||
if (process.arch === "x64") {
|
||||
return "cloudflared-darwin-amd64";
|
||||
}
|
||||
console.warn(`[remote-access] Unsupported macOS architecture '${process.arch}' for cloudflared; falling back to amd64`);
|
||||
return "cloudflared-darwin-amd64";
|
||||
}
|
||||
|
||||
return "cloudflared-linux-amd64";
|
||||
}
|
||||
|
||||
function resolveCloudflaredInstallCommand(): string {
|
||||
if (process.platform === "darwin") {
|
||||
return "brew install cloudflared";
|
||||
@@ -102,23 +132,89 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
if (process.platform === "win32") {
|
||||
return "winget install Cloudflare.cloudflared";
|
||||
}
|
||||
return "curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 && chmod +x /usr/local/bin/cloudflared";
|
||||
|
||||
const binaryName = resolveCloudflaredBinaryName();
|
||||
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/${binaryName}`;
|
||||
return `curl -L --output /tmp/cloudflared ${downloadUrl} && chmod +x /tmp/cloudflared && mv /tmp/cloudflared /usr/local/bin/cloudflared`;
|
||||
}
|
||||
|
||||
function formatExecError(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
const stdout = (error as Error & { stdout?: string }).stdout?.trim();
|
||||
const stderr = (error as Error & { stderr?: string }).stderr?.trim();
|
||||
return [error.message, stderr, stdout].filter(Boolean).join(" | ");
|
||||
}
|
||||
|
||||
async function installCloudflared(): Promise<{ success: boolean; command: string; error?: string }> {
|
||||
const command = resolveCloudflaredInstallCommand();
|
||||
const shell = process.platform === "win32" ? "cmd" : "sh";
|
||||
const shellArgs = process.platform === "win32" ? ["/c", command] : ["-c", command];
|
||||
if (process.platform === "win32") {
|
||||
const command = resolveCloudflaredInstallCommand();
|
||||
try {
|
||||
await execFileAsync("winget", ["install", "Cloudflare.cloudflared"], { timeout: 120_000 });
|
||||
return { success: true, command };
|
||||
} catch (error) {
|
||||
return { success: false, command, error: formatExecError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
const attemptedCommands: string[] = [];
|
||||
const downloadBinaryName = process.platform === "darwin" || process.platform === "linux"
|
||||
? resolveCloudflaredBinaryName()
|
||||
: "cloudflared-linux-amd64";
|
||||
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/${downloadBinaryName}`;
|
||||
const tempPath = "/tmp/cloudflared";
|
||||
|
||||
const installFromDirectDownload = async (): Promise<void> => {
|
||||
attemptedCommands.push(`curl -L --output ${tempPath} ${downloadUrl}`);
|
||||
await execFileAsync("curl", ["-L", "--output", tempPath, downloadUrl], { timeout: 120_000 });
|
||||
|
||||
attemptedCommands.push(`chmod +x ${tempPath}`);
|
||||
await execFileAsync("chmod", ["+x", tempPath], { timeout: 30_000 });
|
||||
|
||||
const globalInstallPath = "/usr/local/bin/cloudflared";
|
||||
attemptedCommands.push(`mv ${tempPath} ${globalInstallPath}`);
|
||||
try {
|
||||
await execFileAsync("mv", [tempPath, globalInstallPath], { timeout: 30_000 });
|
||||
} catch (error) {
|
||||
const localBinDir = `${homedir()}/.local/bin`;
|
||||
const localInstallPath = `${localBinDir}/cloudflared`;
|
||||
attemptedCommands.push(`mkdir -p ${localBinDir}`);
|
||||
attemptedCommands.push(`mv ${tempPath} ${localInstallPath}`);
|
||||
await execFileAsync("mkdir", ["-p", localBinDir], { timeout: 30_000 });
|
||||
try {
|
||||
await execFileAsync("mv", [tempPath, localInstallPath], { timeout: 30_000 });
|
||||
} catch (fallbackError) {
|
||||
throw new Error(
|
||||
`Failed to install cloudflared to /usr/local/bin and ~/.local/bin (${formatExecError(error)}; fallback: ${formatExecError(fallbackError)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
attemptedCommands.push("which brew");
|
||||
try {
|
||||
await execFileAsync("which", ["brew"], { timeout: 15_000 });
|
||||
attemptedCommands.push("brew install cloudflared");
|
||||
await execFileAsync("brew", ["install", "cloudflared"], { timeout: 120_000 });
|
||||
return { success: true, command: attemptedCommands.join(" && ") };
|
||||
} catch {
|
||||
try {
|
||||
await installFromDirectDownload();
|
||||
return { success: true, command: attemptedCommands.join(" && ") };
|
||||
} catch (error) {
|
||||
return { success: false, command: attemptedCommands.join(" && "), error: formatExecError(error) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync(shell, shellArgs, { timeout: 120_000 });
|
||||
return { success: true, command };
|
||||
await installFromDirectDownload();
|
||||
return { success: true, command: attemptedCommands.join(" && ") };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
command,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
return { success: false, command: attemptedCommands.join(" && "), error: formatExecError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user