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
9 changes: 8 additions & 1 deletion config/clawbox-gateway.service
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
Description=ClawBox OpenClaw Gateway
After=network-online.target
Wants=network-online.target
# A cold Jetson can legitimately spend up to TimeoutStartSec in
# gateway-pre-start.sh. Keep the limiter window long enough to contain several
# complete slow failures; inherited 5-in-10s defaults never tripped when one
# failed cycle already took longer than ten seconds (issue #284).
StartLimitIntervalSec=3600
StartLimitBurst=5

[Service]
Type=simple
Expand All @@ -13,7 +19,8 @@ ExecStartPre=/home/clawbox/clawbox/scripts/gateway-pre-start.sh
# gateway-proxy.ts injects into the SPA). Passing a literal here would override
# that and reintroduce the shared-token / UI-drift bug (issues #149, #150).
ExecStart=/home/clawbox/.npm-global/bin/openclaw gateway --allow-unconfigured --bind lan
Restart=always
# Retry crashes and rejected startups, but leave deliberate clean stops alone.
Restart=on-failure
RestartSec=5
# gateway-pre-start.sh runs as a blocking ExecStartPre. The default
# ~90s start timeout could kill a legitimately slow first-boot pre-start
Expand Down
5 changes: 5 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1404,6 +1404,7 @@ step_gateway_legacy_state_recovery() {

echo " Gateway is not listening on ${gw_port}; running OpenClaw doctor recovery"
as_clawbox "$OPENCLAW_BIN" doctor --fix --yes --non-interactive || true
systemctl reset-failed clawbox-gateway.service 2>/dev/null || true
systemctl restart clawbox-gateway.service || true
sleep 8
if gateway_port_listening; then
Expand Down Expand Up @@ -1440,6 +1441,7 @@ step_gateway_legacy_state_recovery() {
fi

as_clawbox "$OPENCLAW_BIN" doctor --fix --yes --non-interactive || true
systemctl reset-failed clawbox-gateway.service 2>/dev/null || true
systemctl start clawbox-gateway.service || true
sleep 12

Expand Down Expand Up @@ -1842,6 +1844,9 @@ CONF

systemctl daemon-reload
systemctl enable clawbox-gateway.service
# Clear any tripped start-limit (breaker) state so the restart isn't refused
# on a box whose gateway had been crash-looping (issue #284 breaker).
systemctl reset-failed clawbox-gateway.service 2>/dev/null || true
systemctl restart clawbox-gateway.service
}

Expand Down
29 changes: 25 additions & 4 deletions src/app/setup-api/gateway/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getGatewayToken } from "@/lib/gateway-proxy";
import { getGatewayServiceHealth, type GatewayServiceHealth } from "@/lib/gateway-health";

export const dynamic = "force-dynamic";

Expand All @@ -15,7 +16,7 @@ export async function GET(request: NextRequest) {
getGatewayToken(),
]);
if (!res.ok) {
return gatewayOfflineResponse();
return gatewayOfflineResponse(await getGatewayServiceHealth());
}
let html = await res.text();
// Use the request hostname so WebSocket connects to the right address
Expand Down Expand Up @@ -61,33 +62,53 @@ export async function GET(request: NextRequest) {
},
});
} catch {
return gatewayOfflineResponse();
return gatewayOfflineResponse(await getGatewayServiceHealth());
}
}

function gatewayOfflineResponse() {
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[character] ?? character);
}

function gatewayOfflineResponse(health: GatewayServiceHealth) {
const breaker = health.breakerActive
? `<p class="breaker" role="alert"><strong>Automatic restart breaker activated.</strong> Repair the configuration, then run <code>sudo systemctl reset-failed clawbox-gateway &amp;&amp; sudo systemctl restart clawbox-gateway</code>.</p>`
: "";
const finalError = health.finalStartupError
? `<pre>${escapeHtml(health.finalStartupError)}</pre>`
: "";
const html = `<!DOCTYPE html>
<html><head><style>
body { margin:0; height:100vh; display:flex; align-items:center; justify-content:center;
background:#0a0f1a; color:#94a3b8; font-family:system-ui,sans-serif; }
.box { text-align:center; }
h2 { color:#e2e8f0; margin:0 0 8px; font-size:18px; }
p { margin:0; font-size:14px; }
.box { max-width:720px; padding:24px; }
.breaker { margin-top:14px; color:#fbbf24; line-height:1.5; }
code, pre { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
code { color:#fed7aa; }
pre { margin:14px 0 0; padding:12px; text-align:left; white-space:pre-wrap; overflow-wrap:anywhere;
border:1px solid #7f1d1d; border-radius:8px; background:#1f1115; color:#fecaca; font-size:12px; }
button { margin-top:16px; padding:8px 20px; border:1px solid #334155; border-radius:8px;
background:#1e293b; color:#e2e8f0; cursor:pointer; font-size:13px; }
button:hover { background:#334155; }
</style></head><body>
<div class="box">
<h2>OpenClaw Gateway Offline</h2>
<p>The gateway service is not running on port ${GATEWAY_PORT}.</p>
${breaker}
${finalError}
<button onclick="location.reload()">Retry</button>
</div>
</body></html>`;
return new NextResponse(html, {
status: 503,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache",
"Cache-Control": "no-store, no-cache",
},
});
}
129 changes: 129 additions & 0 deletions src/lib/gateway-health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { execFile } from "child_process";
import { promisify } from "util";

const exec = promisify(execFile);
const GATEWAY_UNIT = "clawbox-gateway.service";

export interface GatewayServiceHealth {
active: boolean;
breakerActive: boolean;
activeState: string | null;
subState: string | null;
result: string | null;
restartCount: number | null;
finalStartupError: string | null;
}

export function parseGatewaySystemctlProperties(output: string): Omit<GatewayServiceHealth, "finalStartupError"> {
const properties: Record<string, string> = Object.fromEntries(
output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const split = line.indexOf("=");
return split < 0 ? [line, ""] : [line.slice(0, split), line.slice(split + 1)];
}),
);
const restartCount = Number.parseInt(properties.NRestarts ?? "", 10);
return {
active: properties.ActiveState === "active",
// systemd 249 (Ubuntu 22.04) exposes rate limiting through the documented
// service Result enum. This remains set after the unit enters failed.
breakerActive: properties.Result === "start-limit-hit",
activeState: properties.ActiveState || null,
subState: properties.SubState || null,
result: properties.Result || null,
restartCount: Number.isFinite(restartCount) ? restartCount : null,
};
}

function sanitizeJournalLine(line: string): string {
return line
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ")
.replace(/\b\d{6,12}:[A-Za-z0-9_-]{20,}\b/g, "[redacted-telegram-token]")
.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}/gi, "$1[redacted]")
.replace(/(["']?(?:token|secret|credential|password|passwd|pwd|api[_-]?key)["']?\s*[:=]\s*["']?)[^\s,"'}]{6,}/gi, "$1[redacted]")
.replace(/\bsk-(?:ant-)?[A-Za-z0-9._-]{16,}\b/g, "[redacted-key]")
.replace(/\s+/g, " ")
.trim()
.slice(0, 1_000);
}

export function lastUsefulJournalLine(output: string): string | null {
const lines = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !/^clawbox-gateway\.service: (Scheduled restart job|Main process exited|Failed with result|Start request repeated too quickly)/i.test(line))
.filter((line) => !/^(Stopped|Started|Failed to start) ClawBox OpenClaw Gateway/i.test(line));
const finalLine = lines.at(-1);
return finalLine ? sanitizeJournalLine(finalLine) || null : null;
}

export function gatewayJournalArgs(systemctlOutput: string): string[] | null {
const invocationId = /^InvocationID=([0-9a-f]{32})$/im.exec(systemctlOutput)?.[1];
if (!invocationId) return null;

// Scope strictly to the current failed activation. Combining `-u UNIT` with a
// field match doesn't AND as intended (`-u` expands to OR match-groups), so
// match on the invocation id alone — it's globally unique to this activation.
return [
`_SYSTEMD_INVOCATION_ID=${invocationId}`,
"-n",
"40",
"--no-pager",
"-o",
"cat",
];
}

export async function getGatewayServiceHealth(): Promise<GatewayServiceHealth> {
try {
const { stdout } = await exec(
"/usr/bin/systemctl",
[
"show",
GATEWAY_UNIT,
"--property=ActiveState,SubState,Result,NRestarts,InvocationID",
"--no-pager",
],
{ timeout: 2_000 },
);
const parsed = parseGatewaySystemctlProperties(stdout);
const breakerActive = parsed.breakerActive;
let finalStartupError: string | null = null;

if (parsed.activeState === "failed" || breakerActive) {
const journalArgs = gatewayJournalArgs(stdout);
if (journalArgs) {
try {
const journal = await exec(
"/usr/bin/journalctl",
journalArgs,
{ timeout: 2_500, maxBuffer: 512 * 1024 },
);
finalStartupError = lastUsefulJournalLine(journal.stdout);
} catch {
// The state itself is still useful on restricted/container installs.
}
}
}

return {
...parsed,
finalStartupError,
};
} catch {
return {
active: false,
breakerActive: false,
activeState: null,
subState: null,
result: null,
restartCount: null,
finalStartupError: null,
};
}
}
47 changes: 47 additions & 0 deletions src/tests/routes/gateway/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,20 @@ vi.mock("@/lib/gateway-proxy", () => ({
getGatewayToken: vi.fn().mockResolvedValue("test-token"),
}));

vi.mock("@/lib/gateway-health", () => ({
getGatewayServiceHealth: vi.fn().mockResolvedValue({
active: false,
breakerActive: false,
activeState: "failed",
subState: "failed",
result: "exit-code",
restartCount: 2,
finalStartupError: "Config validation failed",
}),
}));

import { getGatewayToken } from "@/lib/gateway-proxy";
import { getGatewayServiceHealth } from "@/lib/gateway-health";

describe("/setup-api/gateway", () => {
let GET: (req: NextRequest) => Promise<Response>;
Expand All @@ -17,6 +30,15 @@ describe("/setup-api/gateway", () => {
vi.resetModules();
vi.clearAllMocks();
vi.mocked(getGatewayToken).mockResolvedValue("test-token");
vi.mocked(getGatewayServiceHealth).mockResolvedValue({
active: false,
breakerActive: false,
activeState: "failed",
subState: "failed",
result: "exit-code",
restartCount: 2,
finalStartupError: "Config validation failed",
});
const mod = await import("@/app/setup-api/gateway/route");
GET = mod.GET;
});
Expand All @@ -39,8 +61,10 @@ describe("/setup-api/gateway", () => {
const req = new NextRequest(new URL("http://clawbox.local/setup-api/gateway"));
const res = await GET(req);
expect(res.status).toBe(503);
expect(res.headers.get("Cache-Control")).toContain("no-store");
const html = await res.text();
expect(html).toContain("Gateway Offline");
expect(html).toContain("Config validation failed");
});

it("returns offline HTML when gateway responds with error", async () => {
Expand All @@ -50,4 +74,27 @@ describe("/setup-api/gateway", () => {
const html = await res.text();
expect(html).toContain("Gateway Offline");
});

it("reports an activated breaker with safe actionable recovery", async () => {
mockFetch.mockRejectedValue(new Error("Connection refused"));
vi.mocked(getGatewayServiceHealth).mockResolvedValue({
active: false,
breakerActive: true,
activeState: "failed",
subState: "failed",
result: "start-limit-hit",
restartCount: 3,
finalStartupError: "bad config <script>alert(1)</script>",
});

const req = new NextRequest(new URL("http://clawbox.local/setup-api/gateway"));
const res = await GET(req);
const html = await res.text();

expect(html).toContain("Automatic restart breaker activated");
expect(html.match(/role="alert"/g)).toHaveLength(1);
expect(html).toContain("systemctl reset-failed clawbox-gateway");
expect(html).toContain("bad config &lt;script&gt;alert(1)&lt;/script&gt;");
expect(html).not.toContain("<script>alert(1)</script>");
});
});
Loading
Loading