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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,20 @@ If a mounted custom JSON already defines `models.providers["openai-compatible"]`

| Variable | Default | Description |
|---|---|---|
| `AUTH_PASSWORD` | *(none)* | If set, nginx enforces HTTP basic auth on all routes except `/healthz` and the hooks path (when hooks are enabled). If unset, no auth — gateway is open. |
| `AUTH_PASSWORD` | *(none)* | If set, nginx enforces HTTP basic auth on all routes except `/healthz`, the hooks path (when hooks are enabled), and `Authorization: Bearer` requests to the Control UI routes the gateway itself token-checks. If unset, the generated nginx config is unchanged and no auth is required. |
| `AUTH_USERNAME` | `admin` | Username for basic auth. |

When `AUTH_PASSWORD` is set, the Control UI's own `Authorization: Bearer` fetches (device token, gateway token, or password) would otherwise replace the browser's Basic credentials and make nginx re-prompt. nginx skips basic auth only for those exact paths, forwards the client's `Authorization` header, and does not append its own `token=` query parameter. `/`, the WebSocket, and `/browser/` stay on basic auth. An invalid or empty Bearer token is rejected by the gateway.

### Gateway

| Variable | Default | Description |
|---|---|---|
| `OPENCLAW_GATEWAY_TOKEN` | *(auto-generated)* | Bearer token for gateway auth. Auto-generated and persisted to `<STATE_DIR>/gateway.token` if not set. |
| `OPENCLAW_GATEWAY_PORT` | `18789` | Internal port the gateway binds to. |
| `OPENCLAW_GATEWAY_BIND` | `loopback` | Gateway bind mode. `loopback` = 127.0.0.1 only (nginx proxies LAN traffic). `lan` = 0.0.0.0 (direct access, bypasses nginx auth). Also: `tailnet`, `auto`, `custom`. |

`configure.js` sets `gateway.trustedProxies` to `127.0.0.1` and `::1` when the key is not already in the config. nginx overwrites `X-Forwarded-For` with the connecting client when basic auth is on, so OpenClaw's auth rate limit sees that client instead of treating everyone as loopback (which is exempt from lockout). This does not enable trusted-proxy authentication; `gateway.auth.mode` stays `token`. Set your own `gateway.trustedProxies` in custom JSON to override. With Docker port publishing, that client is often the bridge address shared by every visitor, and current OpenClaw builds may ask for a one-time device pairing for a non-loopback address.
| `OPENCLAW_STATE_DIR` | `/data/.openclaw` | Persistent state directory. Mount a volume here. |
| `OPENCLAW_WORKSPACE_DIR` | `/data/workspace` | Workspace directory for openclaw projects. |
| `OPENCLAW_CONFIG_PATH` | `<STATE_DIR>/openclaw.json` | Override path to the config file. |
Expand Down
11 changes: 11 additions & 0 deletions scripts/configure.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ if (config.gateway.bind === undefined) {
config.gateway.bind = process.env.OPENCLAW_GATEWAY_BIND || "loopback";
}

// nginx is the only process that should connect to the loopback gateway, so
// every browser otherwise shares 127.0.0.1. OpenClaw exempts loopback from
// auth lockout. Trust that hop and let nginx set X-Forwarded-For to the TCP
// peer (entrypoint.sh) so failures are counted per client. This does not
// change gateway.auth.mode — token auth stays in place. A list already set
// by custom JSON or a previous run is kept (trusted-proxy deployments).
if (!Array.isArray(config.gateway.trustedProxies)) {
config.gateway.trustedProxies = ["127.0.0.1", "::1"];
console.log("[configure] gateway.trustedProxies: 127.0.0.1, ::1 (nginx)");
}

// ── Agents defaults ─────────────────────────────────────────────────────────

ensure(config, "agents", "defaults");
Expand Down
110 changes: 100 additions & 10 deletions scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,105 @@ AUTH_USERNAME="${AUTH_USERNAME:-admin}"
NGINX_CONF="/etc/nginx/conf.d/openclaw.conf"

AUTH_BLOCK=""
BROWSER_AUTH_BLOCK=""
# Bearer-aware pieces. Empty unless basic auth is on, so the no-auth path below
# generates a byte-identical config to before.
BEARER_MAPS=""
UPSTREAM_AUTH="\"Bearer ${GATEWAY_TOKEN}\""
PROXY_ARGS_MAP="map \"\$ocw_has_token:\$args\" \$ocw_proxy_args {
~^1: \$args;
~^0:.+ \"\$args&token=${GATEWAY_TOKEN}\";
default \"token=${GATEWAY_TOKEN}\";
}"
# Inserted directly into the unquoted nginx heredoc (expanded once, not rescanned).
XFF_DIRECT='$proxy_add_x_forwarded_for'
# Hooks block is expanded again by that heredoc, so its dollar signs stay escaped.
HOOKS_XFF='\$proxy_add_x_forwarded_for'

if [ -n "$AUTH_PASSWORD" ]; then
echo "[entrypoint] setting up nginx basic auth (user: $AUTH_USERNAME)"
htpasswd -bc /etc/nginx/.htpasswd "$AUTH_USERNAME" "$AUTH_PASSWORD" 2>/dev/null
AUTH_BLOCK='auth_basic "Openclaw";
# /browser/ never consults the bearer map. It is the VNC sidecar, not a gateway route.
BROWSER_AUTH_BLOCK='auth_basic "Openclaw";
auth_basic_user_file /etc/nginx/.htpasswd;'
AUTH_BLOCK='auth_basic $ocw_auth_realm;
auth_basic_user_file /etc/nginx/.htpasswd;'

# The Control UI authenticates to the gateway with `Authorization: Bearer
# <token>` (a per-device token, the stored gateway token, or the password).
# A request carries only one Authorization header, so that Bearer replaces the
# browser's Basic credentials, auth_basic returns 401 + WWW-Authenticate, and
# the browser prompts again. Skip basic auth only for the exact paths the UI
# fetches that way, and only when the header is Bearer, then forward the
# client's header unchanged so the gateway — not nginx — accepts or rejects it.
#
# "/" and the WebSocket stay on basic auth: the gateway serves the UI shell
# without its own token check, so a bogus Bearer there must not walk past nginx.
# Every pattern is anchored. Prefixes such as /api/ or /__openclaw__/ are not
# used, and /rpc is not included: gateway RPC is the WebSocket, and an
# unanchored ^/rpc match would also exempt /rpcanything.
#
# $ocw_path is the raw request path with the query stripped, so percent-encoding
# stays intact (catalog icons encode slashes) and ?token= cannot change the match.
BEARER_MAPS="map \$http_authorization \$ocw_is_bearer {
default 0;
\"~*^Bearer\$\" 1;
\"~*^Bearer[[:space:]]\" 1;
}

map \$request_uri \$ocw_path {
\"~^([^?]*)\" \$1;
default \$uri;
}

map \$ocw_path \$ocw_is_gateway_route {
default 0;
\"~^/control-ui-config\\.json\$\" 1;
\"~^/__openclaw__/control-ui-config\\.json\$\" 1;
\"~^/api/users/[^/]+/avatar\$\" 1;
\"~^/avatar/[^/]+\$\" 1;
\"~^/__openclaw__/assistant-media\$\" 1;
\"~^/__openclaw__/workspace-icon/[^/]+\$\" 1;
\"~^/__openclaw__/plugin-icon/[^/]+\$\" 1;
\"~^/__openclaw__/plugin-activity-icon/[^/]+\$\" 1;
\"~^/__openclaw__/catalog-icon/[^/]+\$\" 1;
\"~^/__openclaw__/link-favicon/[^/]+\$\" 1;
\"~^/__openclaw__/channel-avatar/[^/]+\$\" 1;
\"~^/__openclaw__/plugin-theme-art/[^/]+/[^/]+/[^/]+/[^/]+\$\" 1;
\"~^/__openclaw__/plugins/control-ui/[^/]+/[0-9a-fA-F]{64}/.+\$\" 1;
\"~^/api/chat/media/outgoing/[^/]+/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/(full|thumbnail)\$\" 1;
}

map \"\$ocw_is_bearer\$ocw_is_gateway_route\" \$ocw_bearer_passthru {
default 0;
\"11\" 1;
}

map \$ocw_bearer_passthru \$ocw_auth_realm {
default \"Openclaw\";
1 \"off\";
}

map \$ocw_bearer_passthru \$ocw_upstream_auth {
default \"Bearer ${GATEWAY_TOKEN}\";
1 \$http_authorization;
}

"
UPSTREAM_AUTH="\$ocw_upstream_auth"
# Never append nginx's own token to a passthrough request either, or the
# carve-out would hand out authenticated access instead of delegating it.
PROXY_ARGS_MAP="map \"\$ocw_bearer_passthru:\$ocw_has_token:\$args\" \$ocw_proxy_args {
\"~^1:[01]:(?<ocw_bargs>.*)\$\" \$ocw_bargs;
\"~^0:1:\" \$args;
\"~^0:0:.+\" \"\$args&token=${GATEWAY_TOKEN}\";
default \"token=${GATEWAY_TOKEN}\";
}"
# Overwrite, do not append. The gateway trusts 127.0.0.1 and reads the
# rightmost untrusted X-Forwarded-For hop; a client-supplied value must not
# be that hop when the TCP peer is loopback.
XFF_DIRECT='$remote_addr'
HOOKS_XFF='\$remote_addr'
else
echo "[entrypoint] no AUTH_PASSWORD set, nginx will not require authentication"
fi
Expand All @@ -174,7 +268,7 @@ if [ -n "$HOOKS_PATH" ]; then

proxy_set_header Host \\\$host;
proxy_set_header X-Real-IP \\\$remote_addr;
proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For ${HOOKS_XFF};
proxy_set_header X-Forwarded-Proto \\\$scheme;

proxy_http_version 1.1;
Expand Down Expand Up @@ -230,11 +324,7 @@ map \$arg_token \$ocw_has_token {
default 1;
}

map "\$ocw_has_token:\$args" \$ocw_proxy_args {
~^1: \$args;
~^0:.+ "\$args&token=${GATEWAY_TOKEN}";
default "token=${GATEWAY_TOKEN}";
}
${BEARER_MAPS}${PROXY_ARGS_MAP}

server {
listen ${PORT:-8080} default_server;
Expand All @@ -260,11 +350,11 @@ server {
${AUTH_BLOCK}

proxy_pass http://127.0.0.1:${GATEWAY_PORT}\$uri?\$ocw_proxy_args;
proxy_set_header Authorization "Bearer ${GATEWAY_TOKEN}";
proxy_set_header Authorization ${UPSTREAM_AUTH};

proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For ${XFF_DIRECT};
proxy_set_header X-Forwarded-Proto \$scheme;

proxy_http_version 1.1;
Expand All @@ -284,7 +374,7 @@ server {

# Browser sidecar proxy (VNC web UI)
location /browser/ {
${AUTH_BLOCK}
${BROWSER_AUTH_BLOCK}

proxy_pass http://browser:3000/;
proxy_set_header Host \$host;
Expand Down
Loading
Loading