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
3 changes: 3 additions & 0 deletions config/clawbox-sudoers
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ clawbox ALL=(root) NOPASSWD: /usr/bin/systemctl restart clawbox-ap.service
clawbox ALL=(root) NOPASSWD: /usr/bin/systemctl stop clawbox-ap.service
clawbox ALL=(root) NOPASSWD: /usr/bin/systemctl start clawbox-browser.service
clawbox ALL=(root) NOPASSWD: /usr/bin/systemctl stop clawbox-browser.service
clawbox ALL=(root) NOPASSWD: /usr/bin/systemctl start clawbox-tunnel.service
clawbox ALL=(root) NOPASSWD: /usr/bin/systemctl stop clawbox-tunnel.service
clawbox ALL=(root) NOPASSWD: /usr/bin/systemctl restart clawbox-tunnel.service
clawbox ALL=(root) NOPASSWD: /usr/bin/apt-get update *
clawbox ALL=(root) NOPASSWD: /usr/bin/apt-get install *
clawbox ALL=(root) NOPASSWD: /usr/bin/snap install chromium
38 changes: 38 additions & 0 deletions config/clawbox-tunnel.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[Unit]
Description=ClawBox Cloudflare Quick Tunnel
After=network-online.target clawbox-setup.service
Wants=network-online.target

[Service]
Type=simple
User=clawbox
WorkingDirectory=/home/clawbox/clawbox
ExecStart=/home/clawbox/clawbox/scripts/run-tunnel.sh
Restart=on-failure
RestartSec=5
TimeoutStartSec=30
TimeoutStopSec=15
StandardOutput=journal
StandardError=journal
Environment=CLAWBOX_ROOT=/home/clawbox/clawbox
Environment=LOCAL_SERVICE_URL=http://localhost:80

# ── Sandboxing ─────────────────────────────────────────────────────────────
# Light-touch hardening that works with the bash-wrapped cloudflared process.
# Heavier restrictions (ProtectSystem=strict, namespacing) conflict with the
# shell pipeline setup on this Jetson kernel.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes

# ── Resource caps ──────────────────────────────────────────────────────────
CPUAccounting=yes
MemoryAccounting=yes
MemoryMax=256M
CPUQuota=50%

[Install]
WantedBy=multi-user.target
Comment on lines +1 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the tunnel service won't be auto-enabled at boot
# The install.sh loop should skip clawbox-tunnel.service

grep -n "clawbox-tunnel" install.sh | head -20

# Check if there's explicit logic to skip enabling tunnel service
rg -n -B2 -A2 'clawbox-tunnel|tunnel.service' install.sh

Repository: ID-Robots/clawbox

Length of output: 840


🏁 Script executed:

# Show the complete loop structure in install.sh around lines 814-913
sed -n '813,914p' install.sh

Repository: ID-Robots/clawbox

Length of output: 4325


The tunnel service will be incorrectly enabled at boot, contradicting the on-demand requirement.

The service unit has good security properties (sandboxing, resource limits, unprivileged user, proper dependencies), but there is a critical gap in the install logic:

The systemctl enable loop in install.sh (lines 825–829) skips only template services (@*) and clawbox-browser.service. Since clawbox-tunnel.service matches neither condition, it will be enabled at boot despite the stated goal of on-demand-only operation. The comment at line 911–912 reflects the intended behavior, but the enable logic doesn't enforce it.

Add clawbox-tunnel.service to the skip conditions:

[[ "$svc" == "clawbox-tunnel.service" ]] && continue
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/clawbox-tunnel.service` around lines 1 - 38, The systemctl enable loop
in install.sh currently skips only templates and clawbox-browser.service, so
clawbox-tunnel.service will be enabled at boot; modify the enable loop in
install.sh (the systemctl enable loop around the referenced lines) to explicitly
skip the tunnel service by adding a condition that checks if the current svc
equals "clawbox-tunnel.service" and continue if so, ensuring the tunnel remains
on-demand only.

22 changes: 19 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,7 @@ step_system_config() {
}

step_systemd_services() {
local ALL_SERVICES=(clawbox-ap.service clawbox-setup.service clawbox-gateway.service clawbox-performance.service "clawbox-root-update@.service" clawbox-browser.service)
local ALL_SERVICES=(clawbox-ap.service clawbox-setup.service clawbox-gateway.service clawbox-performance.service "clawbox-root-update@.service" clawbox-browser.service clawbox-tunnel.service)
Comment thread
yalexx marked this conversation as resolved.
local svc
for svc in "${ALL_SERVICES[@]}"; do
local src="$PROJECT_DIR/config/$svc"
Expand Down Expand Up @@ -908,9 +908,22 @@ step_start_services() {
for svc in clawbox-ap clawbox-setup clawbox-gateway clawbox-performance; do
systemctl restart "$svc.service"
done
# clawbox-tunnel.service is started on-demand from Settings → Remote Control,
# not at boot — skip it here.
echo " Services started"
}

step_cloudflared_install() {
if [ ! -f "$PROJECT_DIR/scripts/setup-tunnel.sh" ]; then
echo " setup-tunnel.sh missing — skipping cloudflared install"
return 0
fi
bash "$PROJECT_DIR/scripts/setup-tunnel.sh" || {
echo " WARNING: cloudflared install failed; remote control will be unavailable until reinstalled"
return 0
}
}

# ── Update-only steps (called from dashboard System Update) ──────────────────

step_nvidia_jetpack() {
Expand Down Expand Up @@ -1355,7 +1368,7 @@ DISPATCH_STEPS=(
git_pull build rebuild rebuild_reboot restart restart_ap recover
chpasswd gateway_setup ffmpeg_install polkit_rules systemd_services
directories_permissions captive_portal_dns desktop_theme
fix_git_perms browser_launch
fix_git_perms browser_launch cloudflared_install
nm_dispatcher sysctl_linkdown post_update
)

Expand All @@ -1380,7 +1393,7 @@ fi

# ── Full Install Mode ───────────────────────────────────────────────────────

TOTAL_STEPS=19
TOTAL_STEPS=20
step=0
log() {
step=$((step + 1))
Expand Down Expand Up @@ -1438,6 +1451,9 @@ step_llamacpp_install
log "Installing Chromium..."
step_chromium_install

log "Installing Cloudflare Tunnel (cloudflared)..."
step_cloudflared_install

log "Installing AI coding tools (Claude Code, Codex, Gemini)..."
step_ai_tools_install

Expand Down
15 changes: 13 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,23 @@ const nextConfig: NextConfig = {
};
},
async headers() {
// Origins allowed to embed this ClawBox in an iframe. The portal
// (openclawhardware.dev) mounts each linked device in an iframe on its
// dashboard; extend via PORTAL_EMBED_ORIGINS=https://a,https://b.
const portalEmbed = (process.env.PORTAL_EMBED_ORIGINS
?? "https://openclawhardware.dev https://*.openclawhardware.dev")
.split(/[\s,]+/)
.filter(Boolean);
const frameAncestors = ["'self'", ...portalEmbed].join(" ");

return [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
// X-Frame-Options is obsoleted by CSP frame-ancestors and only
// understands a single origin, which can't express "self + portal".
// We rely on frame-ancestors below to gate iframe embedding.
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
Expand All @@ -82,7 +93,7 @@ const nextConfig: NextConfig = {
"connect-src 'self' ws: wss: http://*.local http://*.local:* https://*.local https://*.local:*",
// Allow code-server iframe and webapp iframes (same origin)
`frame-src 'self' blob:`,
"frame-ancestors 'self'",
`frame-ancestors ${frameAncestors}`,
].join("; "),
},
],
Expand Down
55 changes: 44 additions & 11 deletions production-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,33 @@ const path = require("path");
const WebSocket = require("ws");

const GATEWAY_PORT = parseInt(process.env.GATEWAY_PORT || "18789", 10);
const TERMINAL_WS_PORT = parseInt(process.env.TERMINAL_WS_PORT || "3006", 10);
const NOVNC_WS_PORT = parseInt(process.env.NOVNC_WS_PORT || "6080", 10);
const IS_DEV = process.env.NODE_ENV === "development";

// Path prefixes that the production server routes to a non-gateway upstream.
// Keep entries in sync with any new WebSocket-only services added behind :80.
//
// /terminal-ws → xterm/PTY WebSocket (scripts/terminal-server.ts)
// /novnc-ws → noVNC / websockify for the remote desktop app
const UPGRADE_ROUTES = [
{ prefix: "/terminal-ws", targetPort: TERMINAL_WS_PORT, stripPrefix: true },
{ prefix: "/novnc-ws", targetPort: NOVNC_WS_PORT, stripPrefix: true },
];

function resolveUpgradeTarget(reqUrl) {
const path = reqUrl.split("?")[0];
for (const r of UPGRADE_ROUTES) {
if (path === r.prefix || path.startsWith(r.prefix + "/")) {
const rewritten = r.stripPrefix
? (reqUrl.slice(r.prefix.length) || "/")
: reqUrl;
return { targetPort: r.targetPort, url: rewritten };
}
}
return { targetPort: GATEWAY_PORT, url: reqUrl };
Comment thread
yalexx marked this conversation as resolved.
}

// ─── Session secret ───
// Generate or load a persistent secret for signing session cookies.
// Must be set before Next.js server starts so middleware can access it.
Expand All @@ -33,20 +58,27 @@ try {
console.warn("[production-server] Failed to set up session secret:", err.message);
}

// HTTP upgrade proxy — raw TCP pipe (works fine with bun's http.Server)
// HTTP upgrade proxy — raw TCP pipe (works fine with bun's http.Server).
// Routes by path: UPGRADE_ROUTES entries (e.g. /terminal-ws) go to their
// configured port; everything else goes to the OpenClaw gateway.
//
// We rewrite Origin to `http://127.0.0.1` (no port) so the gateway's
// controlUi.allowedOrigins check passes — the allowlist uses port-less
// entries and a port-suffixed origin would be rejected. Host is rewritten
// to 127.0.0.1:<port> since upstream does need the port for Host routing.
function attachUpgradeProxy(server) {
server.on("upgrade", (req, socket, head) => {
const targetPort = GATEWAY_PORT;
const { targetPort, url } = resolveUpgradeTarget(req.url);
const upstream = net.connect(targetPort, "127.0.0.1", () => {
const localhost = `127.0.0.1:${targetPort}`;
const url = req.url;
const hostHeader = `127.0.0.1:${targetPort}`;
const originHeader = "http://127.0.0.1";
let raw = `${req.method} ${url} HTTP/${req.httpVersion}\r\n`;
for (let i = 0; i < req.rawHeaders.length; i += 2) {
const name = req.rawHeaders[i];
const lc = name.toLowerCase();
const value =
lc === "origin" ? `http://${localhost}` :
lc === "host" ? localhost :
lc === "origin" ? originHeader :
lc === "host" ? hostHeader :
req.rawHeaders[i + 1];
raw += `${name}: ${value}\r\n`;
}
Expand Down Expand Up @@ -89,12 +121,13 @@ function startHttpsServer(httpServer) {

httpsServer.on("upgrade", (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (clientWs) => {
// Connect to the gateway as a plain WS client
const gatewayUrl = `ws://127.0.0.1:${GATEWAY_PORT}${req.url || "/"}`;
const upstream = new WebSocket(gatewayUrl, {
const { targetPort, url } = resolveUpgradeTarget(req.url || "/");
const upstreamUrl = `ws://127.0.0.1:${targetPort}${url}`;
const upstream = new WebSocket(upstreamUrl, {
headers: {
origin: `http://127.0.0.1:${GATEWAY_PORT}`,
host: `127.0.0.1:${GATEWAY_PORT}`,
// Port-less to satisfy the gateway's strict origin allowlist.
origin: "http://127.0.0.1",
host: `127.0.0.1:${targetPort}`,
},
});

Expand Down
Loading
Loading