#!/usr/bin/env bash # One-time host setup for rootless OpenClaw in Podman. Uses the current # non-root user throughout, builds or pulls the image into that user's Podman # store, writes config under ~/.openclaw by default, and uses the repo-local # launch script at ./scripts/run-openclaw-podman.sh. # # Usage: ./scripts/podman/setup.sh [--quadlet|--container] # --quadlet Install a Podman Quadlet as the current user's systemd service # --container Only install image + config; you start the container manually (default) # Or set OPENCLAW_PODMAN_QUADLET=1 (or 0) to choose without a flag. # # After this, start the gateway manually: # ./scripts/run-openclaw-podman.sh launch # ./scripts/run-openclaw-podman.sh launch setup # Or, if you used --quadlet: # systemctl --user start openclaw.service
set -euo pipefail
require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then echo"Missing dependency: $1" >&2
exit 1 fi
}
is_root() { [[ "$(id -u)" -eq 0 ]]; }
fail() { echo"$*" >&2
exit 1
}
validate_single_line_value() {
local label="$1"
local value="$2" if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then
fail "Invalid $label: control characters are not allowed." fi
}
validate_absolute_path() {
local label="$1"
local value="$2"
validate_single_line_value "$label""$value"
[[ "$value" == /* ]] || fail "Invalid $label: expected an absolute path."
[[ "$value" != *"//"* ]] || fail "Invalid $label: repeated slashes are not allowed."
[[ "$value" != *"/./"* && "$value" != */. && "$value" != *"/../"* && "$value" != */.. ]] ||
fail "Invalid $label: dot path segments are not allowed."
}
validate_mount_source_path() {
local label="$1"
local value="$2"
validate_absolute_path "$label""$value"
[[ "$value" != *:* ]] || fail "Invalid $label: ':' is not allowed in Podman bind-mount source paths."
}
validate_image_name() {
local value="$1"
validate_single_line_value "image name""$value"
case "$value" in
oci-archive:*|docker-archive:*|dir:*|oci:*|containers-storage:*|docker-daemon:*|archive:* )
fail "Invalid image name: transport prefixes are not allowed: $value"
;;
esac
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._/:@-]*$ ]] ||
fail "Invalid image name: $value"
}
ensure_safe_existing_dir() {
local label="$1"
local dir="$2"
validate_absolute_path "$label""$dir"
[[ -d "$dir" ]] || fail "Missing $label: $dir"
[[ ! -L "$dir" ]] || fail "Unsafe $label: symlinks are not allowed ($dir)"
}
stat_uid() {
local path="$1" if stat -f '%u'"$path" >/dev/null 2>&1; then
stat -f '%u'"$path" else
stat -Lc '%u'"$path" fi
}
stat_mode() {
local path="$1" if stat -f '%Lp'"$path" >/dev/null 2>&1; then
stat -f '%Lp'"$path" else
stat -Lc '%a'"$path" fi
}
ensure_private_existing_dir_owned_by_user() {
local label="$1"
local dir="$2"
local uid=""
local mode=""
ensure_safe_existing_dir "$label""$dir"
uid="$(stat_uid "$dir")"
[[ "$uid" == "$(id -u)" ]] || fail "Unsafe $label: not owned by current user ($dir)"
mode="$(stat_mode "$dir")"
(( (8#$mode & 0022) == 0 )) || fail "Unsafe $label: group/other writable ($dir)"
}
ensure_safe_write_file_path() {
local label="$1"
local file="$2"
local dir
validate_absolute_path "$label""$file" if [[ -e "$file" ]]; then
[[ ! -L "$file" ]] || fail "Unsafe $label: symlinks are not allowed ($file)"
[[ -f "$file" ]] || fail "Unsafe $label: expected a regular file ($file)" fi
dir="$(dirname "$file")"
ensure_safe_existing_dir "${label} parent directory""$dir"
}
write_file_atomically() {
local file="$1"
local mode="$2"
local dir=""
local tmp=""
ensure_safe_write_file_path "output file""$file"
dir="$(dirname "$file")"
tmp="$(mktemp "$dir/.tmp.XXXXXX")" cat >"$tmp"
chmod "$mode""$tmp"
mv -f "$tmp""$file"
}
validate_port() {
local label="$1"
local value="$2"
local numeric=""
[[ "$value" =~ ^[0-9]{1,5}$ ]] || fail "Invalid $label: must be numeric."
numeric=$((10#$value))
(( numeric >= 1 && numeric <= 65535 )) || fail "Invalid $label: out of range."
}
escape_sed_replacement_pipe_delim() {
printf '%s'"$1" | sed -e 's/[\\&|]/\\&/g'
}
resolve_user_home() {
local user="$1"
local home="" if command -v getent >/dev/null 2>&1; then
home="$(getent passwd "$user" 2>/dev/null | cut -d: -f6 || true)" fi if [[ -z "$home" && -f /etc/passwd ]]; then
home="$(awk -F: -v u="$user" '$1==u {print $6}' /etc/passwd 2>/dev/null || true)" fi if [[ -z "$home" ]]; then
home="/home/$user" fi
printf '%s'"$home"
}
generate_token_hex_32() { if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
return 0 fi if command -v python3 >/dev/null 2>&1; then
python3 - <<'PY' import secrets
print(secrets.token_hex(32))
PY
return 0 fi if command -v od >/dev/null 2>&1; then
od -An -N32 -tx1 /dev/urandom | tr -d " \n"
return 0 fi echo"Missing dependency: need openssl or python3 (or od) to generate OPENCLAW_GATEWAY_TOKEN." >&2
exit 1
}
seed_local_control_ui_origins() {
local file="$1"
local port="$2"
local dir=""
local tmp=""
ensure_safe_write_file_path "config file""$file" if ! command -v python3 >/dev/null 2>&1; then echo"Warning: python3 not found; unable to seed gateway.controlUi.allowedOrigins in $file." >&2
return 0 fi
dir="$(dirname "$file")"
tmp="$(mktemp "$dir/.config.tmp.XXXXXX")" if ! python3 - "$file""$port""$tmp" <<'PY' import json import sys
path = sys.argv[1]
port = sys.argv[2]
tmp = sys.argv[3]
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except json.JSONDecodeError as exc:
print(
f"Warning: unable to seed gateway.controlUi.allowedOrigins in {path}: existing config is not strict JSON ({exc}). Leaving file unchanged.",
file=sys.stderr,
)
raise SystemExit(1) if not isinstance(data, dict):
raise SystemExit(f"{path}: expected top-level object")
gateway = data.setdefault("gateway", {}) if not isinstance(gateway, dict):
raise SystemExit(f"{path}: expected gateway object")
gateway.setdefault("mode", "local")
control_ui = gateway.setdefault("controlUi", {}) if not isinstance(control_ui, dict):
raise SystemExit(f"{path}: expected gateway.controlUi object")
allowed = control_ui.get("allowedOrigins")
managed_localhosts = {"127.0.0.1", "localhost"}
desired = [
f"http://127.0.0.1:{port}",
f"http://localhost:{port}",
] if not isinstance(allowed, list):
allowed = []
cleaned = [] for origin in allowed: if not isinstance(origin, str):
continue
normalized = origin.strip() if not normalized:
continue if normalized.startswith("http://"):
host_port = normalized[len("http://") :]
host = host_port.split(":", 1)[0] if host in managed_localhosts:
continue
cleaned.append(normalized)
control_ui["allowedOrigins"] = cleaned + desired
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
fh.write("\n")
PY then rm -f "$tmp"
return 0 fi
[[ -s "$tmp" ]] || { rm -f "$tmp"
return 0
}
chmod 600 "$tmp" 2>/dev/null || true
mv -f "$tmp""$file"
}
upsert_env_var() {
local file="$1"
local key="$2"
local value="$3"
local tmp
local dir
ensure_safe_write_file_path "env file""$file"
dir="$(dirname "$file")"
tmp="$(mktemp "$dir/.env.tmp.XXXXXX")" if [[ -f "$file" ]]; then
awk -v k="$key" -v v="$value"' BEGIN { found = 0 }
$0 ~ ("^" k "=") { print k "=" v; found = 1; next }
{ print } END { if (!found) print k "=" v } ' "$file" >"$tmp" else
printf '%s=%s\n'"$key""$value" >"$tmp" fi
mv "$tmp""$file"
chmod 600 "$file" 2>/dev/null || true
}
INSTALL_QUADLET=false for arg in "$@"; do
case "$arg" in
--quadlet) INSTALL_QUADLET=true ;;
--container) INSTALL_QUADLET=false ;;
esac done if [[ -n "${OPENCLAW_PODMAN_QUADLET:-}" ]]; then
case "${OPENCLAW_PODMAN_QUADLET,,}" in
1|yes|true) INSTALL_QUADLET=true ;;
0|no|false) INSTALL_QUADLET=false ;;
esac fi if [[ "$INSTALL_QUADLET" == true && "$PLATFORM_NAME" != "Linux" ]]; then
fail "--quadlet is only supported on Linux with systemd user services." fi
SEED_GATEWAY_PORT="$HOST_GATEWAY_PORT" if [[ "$INSTALL_QUADLET" == true ]]; then
SEED_GATEWAY_PORT="$QUADLET_GATEWAY_PORT" fi
require_cmd podman if is_root; then echo"Run scripts/podman/setup.sh as your normal user so Podman stays rootless." >&2
exit 1 fi if [[ "$OPENCLAW_IMAGE" == "openclaw:local" ]] && [[ ! -f "$REPO_PATH/Dockerfile" ]]; then echo"Dockerfile not found at $REPO_PATH. Set OPENCLAW_REPO_PATH to the repo root." >&2
exit 1 fi if [[ ! -f "$RUN_SCRIPT_SRC" ]]; then echo"Launch script not found at $RUN_SCRIPT_SRC." >&2
exit 1 fi
if [[ -z "$OPENCLAW_HOME" ]]; then
OPENCLAW_HOME="$(resolve_user_home "$OPENCLAW_USER")" fi if [[ -z "$OPENCLAW_HOME" ]]; then echo"Unable to resolve HOME for user $OPENCLAW_USER." >&2
exit 1 fi if [[ -z "$OPENCLAW_CONFIG_DIR" ]]; then
OPENCLAW_CONFIG_DIR="$OPENCLAW_HOME/.openclaw" fi if [[ -z "$OPENCLAW_WORKSPACE_DIR" ]]; then
OPENCLAW_WORKSPACE_DIR="$OPENCLAW_CONFIG_DIR/workspace" fi
validate_absolute_path "home directory""$OPENCLAW_HOME"
validate_mount_source_path "config directory""$OPENCLAW_CONFIG_DIR"
validate_mount_source_path "workspace directory""$OPENCLAW_WORKSPACE_DIR"
validate_container_name "$OPENCLAW_CONTAINER_NAME"
validate_image_name "$OPENCLAW_IMAGE"
validate_port "gateway host port""$HOST_GATEWAY_PORT"
validate_port "seed gateway port""$SEED_GATEWAY_PORT"
BUILD_ARGS=() if [[ -n "${OPENCLAW_DOCKER_APT_PACKAGES:-}" ]]; then
BUILD_ARGS+=(--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}") fi if [[ -n "${OPENCLAW_EXTENSIONS:-}" ]]; then
BUILD_ARGS+=(--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}") fi
if [[ "$OPENCLAW_IMAGE" == "openclaw:local" ]]; then echo"Building image $OPENCLAW_IMAGE ..."
podman build -t "$OPENCLAW_IMAGE" -f "$REPO_PATH/Dockerfile""${BUILD_ARGS[@]+"${BUILD_ARGS[@]}"}""$REPO_PATH" else if podman image exists "$OPENCLAW_IMAGE" >/dev/null 2>&1; then echo"Using existing image $OPENCLAW_IMAGE" else echo"Pulling image $OPENCLAW_IMAGE ..."
podman pull "$OPENCLAW_IMAGE" fi fi
ENV_FILE="$OPENCLAW_CONFIG_DIR/.env" if [[ ! -f "$ENV_FILE" ]]; then
TOKEN="$(generate_token_hex_32)"
(
umask 077
write_file_atomically "$ENV_FILE" 600 <<EOF
OPENCLAW_GATEWAY_TOKEN=$TOKEN
EOF
) echo"Generated OPENCLAW_GATEWAY_TOKEN and wrote it to $ENV_FILE" fi
upsert_env_var "$ENV_FILE""OPENCLAW_PODMAN_CONTAINER""$OPENCLAW_CONTAINER_NAME"
upsert_env_var "$ENV_FILE""OPENCLAW_PODMAN_IMAGE""$OPENCLAW_IMAGE"
CONFIG_JSON="$OPENCLAW_CONFIG_DIR/openclaw.json" if [[ ! -f "$CONFIG_JSON" ]]; then
(
umask 077
write_file_atomically "$CONFIG_JSON" 600 <<JSON
{ "gateway": { "mode": "local", "controlUi": { "allowedOrigins": [ "http://127.0.0.1:${SEED_GATEWAY_PORT}", "http://localhost:${SEED_GATEWAY_PORT}"
]
}
}
}
JSON
) echo"Wrote minimal config to $CONFIG_JSON" fi
seed_local_control_ui_origins "$CONFIG_JSON""$SEED_GATEWAY_PORT"
if command -v systemctl >/dev/null 2>&1; then echo"Reloading and starting user service..." if systemctl --user daemon-reload && systemctl --user start openclaw.service; then echo"Quadlet installed and service started." else echo"Quadlet installed, but automatic start failed." >&2 echo"Try: systemctl --user daemon-reload && systemctl --user start openclaw.service" >&2 if command -v loginctl >/dev/null 2>&1; then echo"For boot persistence on headless hosts, you may also need: sudo loginctl enable-linger $(whoami)" >&2 fi fi else echo"systemctl not found; Quadlet installed but not started." >&2 fi else echo"Container setup complete." fi
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.