#!/usr/bin/env bash # # setup-openclaw-lxc.sh — Automated OpenClaw AI assistant setup in a Proxmox LXC container # # Run this script directly on the Proxmox host as root. # It creates a Debian 13/12 LXC, installs OpenClaw + LXQt desktop + Google Chrome + VNC/noVNC, # and prints connection info when done. # set -euo pipefail # ─── Colors ─────────────────────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' info() { echo -e "${CYAN}[INFO]${NC} $*"; } ok() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } fatal() { err "$@"; exit 1; } # Trap unexpected errors to give actionable advice trap 'err "An error occurred on line $LINENO. Container VMID: ${VMID:-none}. To clean up: pct destroy ${VMID:-}"' ERR # ─── Pre-flight checks ─────────────────────────────────────────────────────── command -v pct >/dev/null 2>&1 || fatal "pct not found — this script must run on a Proxmox host." command -v pvesh >/dev/null 2>&1 || fatal "pvesh not found — this script must run on a Proxmox host." command -v pveam >/dev/null 2>&1 || fatal "pveam not found — this script must run on a Proxmox host." [[ $(id -u) -eq 0 ]] || fatal "This script must be run as root." [[ $(uname -m) == "x86_64" ]] || fatal "This script currently supports x86_64 (amd64) architecture only." # ─── Auto-detection ────────────────────────────────────────────────────────── info "Detecting next available VMID and storage pools..." # Detect next available VMID DETECTED_VMID=$(pvesh get /cluster/nextid 2>/dev/null || true) if [[ -z "$DETECTED_VMID" || ! "$DETECTED_VMID" =~ ^[0-9]+$ ]]; then DETECTED_VMID=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \ | python3 -c ' import json, sys data = json.load(sys.stdin) ids = [r["vmid"] for r in data if "vmid" in r] print(max(ids) + 1 if ids else 100) ' 2>/dev/null || echo 100) fi # Find template storage (enabled and supports vztmpl content) DETECTED_TMPL_STORAGE=$(pvesh get /storage --output-format json 2>/dev/null \ | python3 -c ' import json, sys stores = json.load(sys.stdin) for s in stores: if s.get("disable", 0) != 1 and s.get("enabled", 1) == 1: if "vztmpl" in s.get("content", ""): print(s["storage"]) break ' 2>/dev/null || echo "local") # Find rootdir storage (prefer local-lvm, local-zfs, local-btrfs, then any with rootdir/images) DETECTED_ROOT_STORAGE=$(pvesh get /storage --output-format json 2>/dev/null \ | python3 -c ' import json, sys stores = json.load(sys.stdin) candidates = [] for s in stores: if s.get("disable", 0) != 1 and s.get("enabled", 1) == 1: content = s.get("content", "") if "rootdir" in content or "images" in content: candidates.append(s["storage"]) for preferred in ["local-lvm", "local-zfs", "local-btrfs"]: if preferred in candidates: print(preferred) sys.exit(0) if candidates: print(candidates[0]) else: print("local-lvm") ' 2>/dev/null || echo "local-lvm") # ─── User prompts ──────────────────────────────────────────────────────────── echo -e "${BOLD}╔═══════════════════════════════════════════════════╗${NC}" echo -e "${BOLD}║ OpenClaw LXC Setup for Proxmox ║${NC}" echo -e "${BOLD}╚═══════════════════════════════════════════════════╝${NC}" echo read -rp "Container VMID [${DETECTED_VMID}]: " VMID_INPUT VMID="${VMID_INPUT:-$DETECTED_VMID}" [[ "$VMID" =~ ^[0-9]+$ ]] || fatal "VMID must be an integer." if pct status "$VMID" >/dev/null 2>&1 || qm status "$VMID" >/dev/null 2>&1; then fatal "VMID $VMID is already in use by another container or VM!" fi read -rp "Container password: " -s CT_PASSWORD echo [[ -n "$CT_PASSWORD" ]] || fatal "Password cannot be empty." read -rp "Rootfs storage [${DETECTED_ROOT_STORAGE}]: " ROOT_STORAGE_INPUT ROOT_STORAGE="${ROOT_STORAGE_INPUT:-$DETECTED_ROOT_STORAGE}" read -rp "Template storage [${DETECTED_TMPL_STORAGE}]: " TMPL_STORAGE_INPUT TMPL_STORAGE="${TMPL_STORAGE_INPUT:-$DETECTED_TMPL_STORAGE}" read -rp "Disk size in GB [32]: " DISK_SIZE DISK_SIZE="${DISK_SIZE:-32}" read -rp "Memory in MB [4096]: " MEMORY MEMORY="${MEMORY:-4096}" read -rp "CPU cores [4]: " CORES CORES="${CORES:-4}" read -rp "VNC resolution [1920x1080]: " VNC_RES VNC_RES="${VNC_RES:-1920x1080}" echo ok "Will create container $VMID on storage $ROOT_STORAGE (template on $TMPL_STORAGE)" # ─── Download Debian template (Debian 13 or 12) ─────────────────────────────── info "Checking for Debian template..." pveam update >/dev/null 2>&1 || true # Check if an existing Debian 13 or 12 template is already downloaded on storage TEMPLATE=$(pveam list "$TMPL_STORAGE" 2>/dev/null | grep -oP 'debian-(13|12)-standard_\S+' | head -1 || true) if [[ -n "$TEMPLATE" ]]; then ok "Found locally downloaded template: $TEMPLATE" else # Search available templates, prioritizing Debian 13 then Debian 12 TEMPLATE=$(pveam available --section system 2>/dev/null | grep -oP 'debian-13-standard_\S+' | head -1 || true) if [[ -z "$TEMPLATE" ]]; then TEMPLATE=$(pveam available --section system 2>/dev/null | grep -oP 'debian-12-standard_\S+' | head -1 || true) fi if [[ -z "$TEMPLATE" ]]; then TEMPLATE=$(pveam available 2>/dev/null | grep -oP 'debian-(13|12)-standard_\S+' | head -1 || true) fi [[ -n "$TEMPLATE" ]] || fatal "Could not find Debian 13 or Debian 12 template in 'pveam available'." info "Downloading $TEMPLATE to $TMPL_STORAGE..." pveam download "$TMPL_STORAGE" "$TEMPLATE" ok "Template downloaded: $TEMPLATE" fi # ─── Create the LXC container ──────────────────────────────────────────────── info "Creating LXC container $VMID..." pct create "$VMID" "${TMPL_STORAGE}:vztmpl/${TEMPLATE}" \ --hostname openclaw \ --password "$CT_PASSWORD" \ --rootfs "${ROOT_STORAGE}:${DISK_SIZE}" \ --memory "$MEMORY" \ --cores "$CORES" \ --net0 name=eth0,bridge=vmbr0,ip=dhcp \ --unprivileged 0 \ --features nesting=1 \ --onboot 1 \ --start 0 ok "Container $VMID created." info "Starting container $VMID..." pct start "$VMID" ok "Container started." info "Waiting for container to boot..." sleep 3 # Helper: run a command inside the container ct_exec() { pct exec "$VMID" -- bash -c "$1" } # ─── Wait for network ──────────────────────────────────────────────────────── info "Waiting for DHCP lease (up to 30s)..." CT_IP="" for i in $(seq 1 30); do CT_IP=$(ct_exec "hostname -I 2>/dev/null" | grep -oP '\b(?:\d{1,3}\.){3}\d{1,3}\b' | grep -v '^127\.' | head -n1 || true) if [[ -n "$CT_IP" ]]; then break fi sleep 1 done [[ -n "$CT_IP" ]] || warn "Could not detect container IPv4 — continuing anyway." ok "Container IP: ${CT_IP:-unknown}" info "Verifying internet connectivity inside container..." for i in $(seq 1 15); do if ct_exec "curl -s --connect-timeout 2 https://deb.debian.org >/dev/null 2>&1 || ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1"; then break fi sleep 1 done # ─── Install packages inside the container ──────────────────────────────────── info "Updating packages and installing prerequisites..." ct_exec " export DEBIAN_FRONTEND=noninteractive # Fix locale warnings apt-get update && apt-get install -y locales 2>&1 | grep -v 'Failed to write' sed -i 's/^# *en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen locale-gen en_US.UTF-8 >/dev/null 2>&1 update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 apt-get upgrade -y 2>&1 | grep -v 'Failed to write' apt-get install -y curl ca-certificates gnupg git sudo openssh-server 2>&1 | grep -v 'Failed to write' " ok "Prerequisites installed." # ─── Configure SSH for root access ─────────────────────────────────────────── info "Configuring SSH for root password login..." ct_exec " mkdir -p /etc/ssh/sshd_config.d echo 'PermitRootLogin yes' > /etc/ssh/sshd_config.d/permit-root-login.conf systemctl restart ssh 2>/dev/null || systemctl restart sshd 2>/dev/null || true " ok "SSH configured." info "Installing Node.js 22..." ct_exec " export DEBIAN_FRONTEND=noninteractive curl -fsSL https://deb.nodesource.com/setup_22.x | bash - 2>&1 | tail -3 apt-get install -y nodejs 2>&1 | grep -v 'Failed to write' " ok "Node.js installed." info "Installing Homebrew (required for OpenClaw skills)..." ct_exec " export DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential procps file sudo 2>&1 | grep -v -E 'Failed to write|Permission denied' # Create dedicated brewuser and configure passwordless sudo useradd -m -s /bin/bash brewuser 2>/dev/null || true mkdir -p /etc/sudoers.d echo 'brewuser ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/brewuser chmod 0440 /etc/sudoers.d/brewuser # Pre-create /home/linuxbrew with correct ownership mkdir -p /home/linuxbrew/.linuxbrew chown -R brewuser:brewuser /home/linuxbrew # Install Homebrew as brewuser su - brewuser -c 'NONINTERACTIVE=1 /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"' 2>&1 | tail -5 # Configure brew environment for brewuser echo 'eval \"\$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)\"' >> /home/brewuser/.bashrc echo 'eval \"\$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)\"' >> /home/brewuser/.profile # Configure brew environment for root echo '[ -x /home/linuxbrew/.linuxbrew/bin/brew ] && eval \"\$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)\"' >> /root/.bashrc # Safe wrapper so root and OpenClaw can seamlessly execute brew without root errors cat > /usr/local/bin/brew << 'BREWWRAPPER' #!/bin/bash if [[ \$(id -u) -eq 0 ]]; then exec sudo -u brewuser -i /home/linuxbrew/.linuxbrew/bin/brew \"\$@\" else exec /home/linuxbrew/.linuxbrew/bin/brew \"\$@\" fi BREWWRAPPER chmod +x /usr/local/bin/brew " ok "Homebrew installed." info "Installing OpenClaw..." ct_exec "npm install -g openclaw@latest 2>&1 | tail -5" ok "OpenClaw installed." info "Installing LXQt, TigerVNC, noVNC (this takes a few minutes)..." ct_exec " export DEBIAN_FRONTEND=noninteractive export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 apt-get install -y lxqt openbox lxterminal tigervnc-standalone-server tigervnc-common novnc websockify dbus-x11 fonts-noto-color-emoji x11-xserver-utils 2>&1 \ | grep -v -E 'Failed to write|Failed to send reload|Permission denied|Cannot set LC_' " ok "Desktop environment installed." info "Installing Google Chrome..." ct_exec " export DEBIAN_FRONTEND=noninteractive curl -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o /tmp/chrome.deb apt-get install -y /tmp/chrome.deb 2>&1 | grep -v -E 'Failed to write|Permission denied' rm -f /tmp/chrome.deb " ok "Google Chrome installed." # ─── Configure OpenClaw ────────────────────────────────────────────────────── info "Configuring OpenClaw..." AUTH_TOKEN=$(openssl rand -hex 16) ct_exec " openclaw config set gateway.mode local openclaw config set gateway.bind lan openclaw config set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback true openclaw config set gateway.auth.mode token openclaw config set gateway.auth.token '${AUTH_TOKEN}' " ok "OpenClaw configured (token: $AUTH_TOKEN)." info "Installing OpenClaw browser extension..." ct_exec "openclaw browser extension install 2>&1 | tail -5 || true" EXTENSION_DIR=$(ct_exec "openclaw browser extension path 2>/dev/null" || echo "/root/.openclaw/browser/extension") ok "OpenClaw browser extension installed." # ─── Configure VNC ─────────────────────────────────────────────────────────── info "Configuring VNC..." # Safely set VNC password via stdin to prevent shell escape issues with special characters printf '%s\n' "$CT_PASSWORD" | pct exec "$VMID" -- sh -c ' mkdir -p /root/.config/tigervnc /root/.vnc /etc/tigervnc vncpasswd -f > /root/.config/tigervnc/passwd chmod 600 /root/.config/tigervnc/passwd ln -sf /root/.config/tigervnc/passwd /root/.vnc/passwd echo ":1=root" > /etc/tigervnc/vncserver.users ' ct_exec "cat > /root/.config/tigervnc/xstartup << 'XSTARTUP' #!/bin/bash unset SESSION_MANAGER unset DBUS_SESSION_BUS_ADDRESS xset s off xset s noblank xset -dpms exec dbus-launch --exit-with-session startlxqt XSTARTUP chmod +x /root/.config/tigervnc/xstartup ln -sf /root/.config/tigervnc/xstartup /root/.vnc/xstartup" # Configure lxterminal with dark theme ct_exec " mkdir -p /root/.config/lxterminal cat > /root/.config/lxterminal/lxterminal.conf << 'CONF' [general] fontname=Monospace 12 bgcolor=#1e1e2e fgcolor=#cdd6f4 palette_color_0=#45475a palette_color_1=#f38ba8 palette_color_2=#a6e3a1 palette_color_3=#f9e2af palette_color_4=#89b4fa palette_color_5=#f5c2e7 palette_color_6=#94e2d5 palette_color_7=#bac2de palette_color_8=#585b70 palette_color_9=#f38ba8 palette_color_10=#a6e3a1 palette_color_11=#f9e2af palette_color_12=#89b4fa palette_color_13=#f5c2e7 palette_color_14=#94e2d5 palette_color_15=#a6adc8 scrollback=10000 CONF " # Set noVNC scaling to auto by default ct_exec "sed -i \"s/UI.initSetting('resize', 'off')/UI.initSetting('resize', 'scale')/\" /usr/share/novnc/app/ui.js" ok "VNC configured (auto-scaling enabled)." # ─── Disable LXC-incompatible LXQt components ─────────────────────────────── info "Disabling LXC-incompatible LXQt components..." ct_exec " mkdir -p /root/.config/autostart # Disable power management (no hardware in LXC) cat > /root/.config/autostart/lxqt-powermanagement.desktop << 'NOAUTO' [Desktop Entry] Type=Application Name=LXQt Power Management Hidden=true NOAUTO # Disable screen saver (not needed in VNC) cat > /root/.config/autostart/lxqt-xscreensaver-autostart.desktop << 'NOAUTO' [Desktop Entry] Type=Application Name=LXQt Screen Saver Hidden=true NOAUTO " ok "LXC-incompatible components disabled." # ─── Configure Google Chrome for root user ─────────────────────────────────── info "Configuring Google Chrome..." ct_exec " # Chrome requires --no-sandbox when running as root sed -i 's|Exec=/usr/bin/google-chrome-stable|Exec=/usr/bin/google-chrome-stable --no-sandbox|g' /usr/share/applications/google-chrome.desktop mkdir -p /root/Desktop cp /usr/share/applications/google-chrome.desktop /root/Desktop/ chmod +x /root/Desktop/google-chrome.desktop # Wrapper so CLI calls also get --no-sandbox cat > /usr/local/bin/google-chrome << 'WRAPPER' #!/bin/bash exec /usr/bin/google-chrome-stable --no-sandbox \"\$@\" WRAPPER chmod +x /usr/local/bin/google-chrome ln -sf /usr/local/bin/google-chrome /usr/local/bin/google-chrome-stable update-alternatives --install /usr/bin/x-www-browser x-www-browser /usr/local/bin/google-chrome 200 2>/dev/null || true update-alternatives --set x-www-browser /usr/local/bin/google-chrome 2>/dev/null || true update-alternatives --set gnome-www-browser /usr/local/bin/google-chrome 2>/dev/null || true xdg-mime default google-chrome.desktop x-scheme-handler/http 2>/dev/null || true xdg-mime default google-chrome.desktop x-scheme-handler/https 2>/dev/null || true xdg-mime default google-chrome.desktop text/html 2>/dev/null || true " ok "Google Chrome configured." # ─── Configure LXQt default terminal ───────────────────────────────────────── info "Setting lxterminal as default terminal..." ct_exec " mkdir -p /root/.config/lxqt cat > /root/.config/lxqt/session.conf << 'CONF' [General] __userfile__=true [Environment] TERM=xterm-256color [Preferred Applications] terminal_emulator=lxterminal CONF " ok "Default terminal configured." # ─── Create desktop shortcuts ──────────────────────────────────────────────── info "Creating desktop shortcuts..." # Terminal shortcut ct_exec "cat > /root/Desktop/terminal.desktop << 'SHORTCUT' [Desktop Entry] Version=1.0 Type=Application Name=Terminal Comment=Open a terminal Exec=lxterminal Icon=utilities-terminal Terminal=false Categories=System;TerminalEmulator; StartupNotify=true SHORTCUT chmod +x /root/Desktop/terminal.desktop" # OpenClaw Onboarding wizard (runs in terminal with --skip-daemon) ct_exec "cat > /root/Desktop/openclaw-onboard.desktop << 'SHORTCUT' [Desktop Entry] Version=1.0 Type=Application Name=OpenClaw Setup Wizard Comment=Run the OpenClaw onboarding wizard to configure your AI assistant Exec=lxterminal --title='OpenClaw Onboarding' -e sh -c 'openclaw onboard --skip-daemon; exec bash' Icon=utilities-terminal Terminal=false Categories=Utility; StartupNotify=true SHORTCUT chmod +x /root/Desktop/openclaw-onboard.desktop" # OpenClaw Dashboard (opens in Chrome with token pre-filled) ct_exec "cat > /root/Desktop/openclaw-dashboard.desktop << SHORTCUT [Desktop Entry] Version=1.0 Type=Application Name=OpenClaw Dashboard Comment=Open the OpenClaw Control UI in Google Chrome Exec=google-chrome-stable --no-sandbox http://127.0.0.1:18789/#token=${AUTH_TOKEN} Icon=web-browser Terminal=false Categories=Network;WebBrowser; StartupNotify=true SHORTCUT chmod +x /root/Desktop/openclaw-dashboard.desktop" ok "Desktop shortcuts created." # ─── Create systemd services ───────────────────────────────────────────────── info "Creating systemd services..." OPENCLAW_BIN=$(ct_exec "command -v openclaw" || echo "/usr/bin/openclaw") WEBSOCKIFY_BIN=$(ct_exec "command -v websockify" || echo "/usr/bin/websockify") VNC_BIN=$(ct_exec "command -v vncserver" || echo "/usr/bin/vncserver") ct_exec "cat > /etc/systemd/system/openclaw-gateway.service << SVC [Unit] Description=OpenClaw Gateway After=network.target [Service] Type=simple ExecStart=${OPENCLAW_BIN} gateway run --bind lan --auth token --token ${AUTH_TOKEN} Restart=always RestartSec=5 Environment=HOME=/root Environment=NODE_ENV=production Environment=OPENCLAW_GATEWAY_TOKEN=${AUTH_TOKEN} Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin WorkingDirectory=/root [Install] WantedBy=multi-user.target SVC" ct_exec "cat > /etc/systemd/system/vncserver.service << SVC [Unit] Description=TigerVNC Server After=network.target [Service] Type=forking Environment=HOME=/root ExecStartPre=/bin/sh -c \"${VNC_BIN} -kill :1 >/dev/null 2>&1 || :; rm -f /tmp/.X1-lock /tmp/.X11-unix/X1; mkdir -p /tmp/.X11-unix; chmod 1777 /tmp/.X11-unix\" ExecStart=${VNC_BIN} :1 -geometry ${VNC_RES} -depth 24 -localhost yes ExecStop=${VNC_BIN} -kill :1 Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target SVC" ct_exec "cat > /etc/systemd/system/novnc.service << SVC [Unit] Description=noVNC WebSocket Proxy After=vncserver.service Requires=vncserver.service [Service] Type=simple ExecStart=${WEBSOCKIFY_BIN} --web=/usr/share/novnc/ 6080 localhost:5901 Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target SVC" ct_exec " systemctl daemon-reload systemctl enable --now openclaw-gateway.service systemctl enable --now vncserver.service systemctl enable --now novnc.service " ok "All services enabled and started." sleep 3 # ─── Verify services ───────────────────────────────────────────────────────── info "Verifying services..." for svc in openclaw-gateway vncserver novnc; do if ct_exec "systemctl is-active --quiet $svc"; then ok "$svc is running." else warn "$svc failed to start. Check with: pct exec $VMID -- systemctl status $svc" fi done # Re-detect IP in case it changed CT_IP=$(ct_exec "hostname -I 2>/dev/null" | grep -oP '\b(?:\d{1,3}\.){3}\d{1,3}\b' | grep -v '^127\.' | head -n1 || echo "unknown") # ─── Print connection info ──────────────────────────────────────────────────── echo echo -e "${BOLD}╔═══════════════════════════════════════════════════╗${NC}" echo -e "${BOLD}║ OpenClaw LXC Setup Complete! ║${NC}" echo -e "${BOLD}╚═══════════════════════════════════════════════════╝${NC}" echo echo -e " ${BOLD}Container ID:${NC} $VMID" echo -e " ${BOLD}Container IP:${NC} $CT_IP" echo echo -e " ${BOLD}OpenClaw Dashboard (Direct Link):${NC}" echo -e " ${CYAN}http://${CT_IP}:18789/#token=${AUTH_TOKEN}${NC}" echo -e " Auth token: ${AUTH_TOKEN}" echo echo -e " ${BOLD}noVNC Remote Desktop (Auto-connect):${NC}" echo -e " ${CYAN}http://${CT_IP}:6080/vnc.html?autoconnect=true&resize=scale${NC}" if [[ ${#CT_PASSWORD} -gt 8 ]]; then echo -e " ${YELLOW}Note on VNC password:${NC} VNC protocol uses first 8 characters: ${BOLD}${CT_PASSWORD:0:8}${NC}" else echo -e " VNC password: (same as container password)" fi echo echo -e " ${BOLD}Browser Extension:${NC}" echo -e " Directory: ${CYAN}${EXTENSION_DIR}${NC}" echo -e " Open Chrome → chrome://extensions → Enable Developer Mode" echo -e " Click 'Load unpacked' and select the directory above" echo echo -e " ${BOLD}SSH:${NC}" echo -e " ssh root@${CT_IP}" echo echo -e " ${BOLD}Manage container:${NC}" echo -e " pct enter $VMID" echo -e " pct stop $VMID" echo -e " pct start $VMID" echo