Fix Docker local controls and setup guidance

Allow the bundled Docker frontend proxy to reach local-operator endpoints through the private compose bridge without trusting LAN clients. This restores Time Machine, MeshChat key creation, AI pins/layers, and related local controls in Docker installs. Refresh first-run guidance so Docker users know to configure OpenSky and AIS keys through .env.
This commit is contained in:
BigBodyCobain
2026-05-02 20:18:46 -06:00
parent 8d3c7a51b7
commit eb0288ee4e
4 changed files with 51 additions and 8 deletions
+30 -3
View File
@@ -13,6 +13,7 @@ import hmac
import asyncio
import hmac as _hmac_mod
import hashlib as _hashlib_mod
import ipaddress
import json as json_mod
import logging
import time
@@ -235,10 +236,36 @@ def _is_local_or_docker(host: str) -> bool:
return host in {"127.0.0.1", "::1", "localhost"}
def _docker_bridge_local_operator_enabled() -> bool:
return str(os.environ.get("SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR", "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _is_docker_bridge_host(host: str) -> bool:
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
# Docker Desktop and the default compose bridge normally sit inside
# 172.16.0.0/12. Keep this narrower than "any private IP" so a user who
# intentionally binds the backend to LAN does not silently trust LAN clients.
return ip in ipaddress.ip_network("172.16.0.0/12")
def _is_trusted_local_runtime_host(host: str) -> bool:
if _is_local_or_docker(host):
return True
return _docker_bridge_local_operator_enabled() and _is_docker_bridge_host(host)
def require_local_operator(request: Request):
"""Allow local tooling on loopback / Docker internal network, or a valid admin key."""
host = (request.client.host or "").lower() if request.client else ""
if _is_local_or_docker(host) or (_debug_mode_enabled() and host == "test"):
if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"):
return
admin_key = _current_admin_key()
presented = str(request.headers.get("X-Admin-Key", "") or "").strip()
@@ -362,8 +389,8 @@ async def require_openclaw_or_local(request: Request):
"""
host = (request.client.host or "").lower() if request.client else ""
# 1. Local loopback — always allowed
if _is_local_or_docker(host) or (_debug_mode_enabled() and host == "test"):
# 1. Local runtime path — loopback, plus bundled Docker bridge when compose opts in
if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"):
return
# 2. Admin key — full trust
+12
View File
@@ -86,6 +86,18 @@ class TestRequireLocalOperator:
def test_rfc1918_172_blocked_without_key(self):
assert self._call_with_host("172.16.0.5") == 403
def test_docker_bridge_blocked_without_compose_opt_in(self):
with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": ""}):
assert self._call_with_host("172.18.0.3") == 403
def test_docker_bridge_passes_with_compose_opt_in(self):
with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": "1"}):
assert self._call_with_host("172.18.0.3") == 200
def test_lan_ip_still_blocked_with_compose_opt_in(self):
with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": "1"}):
assert self._call_with_host("192.168.1.100") == 403
def test_rfc1918_192168_blocked_without_key(self):
assert self._call_with_host("192.168.1.100") == 403