diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e8abb7378..e95ddfa14 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -346,6 +346,29 @@ function Get-PersistentDockerArgs { return ,$args.ToArray() } +function Add-DashboardGatewayEnv { + param([System.Collections.Generic.List[string]]$ArgsList) + + # This default is safe only because the published dashboard port is bound + # to the host loopback interface below. A host request published through + # Docker's default bridge reaches the + # container from the bridge gateway (for example, 172.17.0.1), not from + # 127.0.0.1. Trust only that exact gateway by default so the dashboard's + # metadata gate works for the first-party persistent Docker preset while + # preserving an explicitly configured allowlist. + if (Test-Path Env:HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS) { + return + } + + $gateway = (& docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}' 2>$null | Out-String).Trim() + if ($LASTEXITCODE -eq 0 -and $gateway) { + $ArgsList.Add('--env') + $ArgsList.Add("HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=$gateway/32") + } else { + Write-Warning 'Could not determine Docker bridge gateway; dashboard metadata remains restricted' + } +} + function Get-ManifestProxyArgs { param( [int]$Port, @@ -492,8 +515,9 @@ function Start-PersistentDockerInstall { docker rm -f $containerName | Out-Null 2>$null $dockerArgs = New-Object System.Collections.Generic.List[string] - $dockerArgs.AddRange([string[]]@('run','-d','--restart','unless-stopped','--name',$containerName,'-p',"$Port`:$Port")) + $dockerArgs.AddRange([string[]]@('run','-d','--restart','unless-stopped','--name',$containerName,'-p',"127.0.0.1`:$Port`:$Port")) $dockerArgs.AddRange((Get-PersistentDockerArgs)) + Add-DashboardGatewayEnv -ArgsList $dockerArgs $dockerArgs.AddRange([string[]]@( '--env',"HEADROOM_DEPLOYMENT_PROFILE=$Profile", '--env','HEADROOM_DEPLOYMENT_PRESET=persistent-docker', diff --git a/scripts/install.sh b/scripts/install.sh index cfd2259df..d65a6db59 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -292,6 +292,29 @@ append_persistent_container_args() { append_passthrough_envs "$1" } +append_dashboard_gateway_env() { + local -n ref=$1 + + # This default is safe only because the published dashboard port is bound + # to the host loopback interface below. A host request published through + # Docker's default bridge reaches the + # container from the bridge gateway (for example, 172.17.0.1), not from + # 127.0.0.1. Trust only that exact gateway by default so the dashboard's + # metadata gate works for the first-party persistent Docker preset while + # preserving an explicitly configured allowlist. + if [[ -n "${HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS+x}" ]]; then + return + fi + + local gateway + gateway="$(docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null || true)" + if [[ -n "${gateway}" ]]; then + ref+=(--env "HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=${gateway}/32") + else + warn "Could not determine Docker bridge gateway; dashboard metadata remains restricted" + fi +} + build_manifest_proxy_args() { local -n out_args=$1 local port="$2" @@ -466,8 +489,9 @@ start_persistent_docker_install() { docker rm -f "${container_name}" >/dev/null 2>&1 || true - args=(docker run -d --restart unless-stopped --name "${container_name}" -p "${port}:${port}") + args=(docker run -d --restart unless-stopped --name "${container_name}" -p "127.0.0.1:${port}:${port}") append_persistent_container_args args + append_dashboard_gateway_env args args+=( --env "HEADROOM_DEPLOYMENT_PROFILE=${profile}" --env "HEADROOM_DEPLOYMENT_PRESET=persistent-docker" diff --git a/tests/test_install/test_native_installers.py b/tests/test_install/test_native_installers.py index bbad56621..94fee0ba9 100644 --- a/tests/test_install/test_native_installers.py +++ b/tests/test_install/test_native_installers.py @@ -105,6 +105,16 @@ def main() -> int: if command == "pull": return 0 + if command == "network" and len(args) > 1 and args[1] == "inspect": + # Match the default bridge gateway used by the native installer when + # it configures the dashboard metadata allowlist. + gateway = os.environ.get("FAKE_DOCKER_GATEWAY", "172.17.0.1") + if gateway == "FAIL": + return 1 + if "--format" in args and gateway: + print(gateway) + return 0 + if command == "run": detached = "-d" in args if not detached: @@ -112,17 +122,29 @@ def main() -> int: name = None publish = None + container_env = {} for index, arg in enumerate(args): if arg == "--name": name = args[index + 1] elif arg == "-p": publish = args[index + 1] + elif arg == "--env": + spec = args[index + 1] + if "=" in spec: + env_name, value = spec.split("=", 1) + container_env[env_name] = value + elif spec in os.environ: + container_env[spec] = os.environ[spec] if name is None or publish is None: raise SystemExit("missing --name or -p in fake docker run") port = host_port_from_publish(publish) - state["containers"][name] = {"pid": start_server(port), "port": port} + state["containers"][name] = { + "pid": start_server(port), + "port": port, + "env": container_env, + } save_state(state) print(name) return 0 @@ -225,6 +247,84 @@ def _read_fake_docker_log(env: dict[str, str]) -> list[list[str]]: return [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line] +def _persistent_run_call(env: dict[str, str], profile: str) -> list[str]: + container_name = f"headroom-{profile}" + return next( + call + for call in _read_fake_docker_log(env) + if call[:2] == ["run", "-d"] + and "--name" in call + and call[call.index("--name") + 1] == container_name + ) + + +def _persistent_container_env(env: dict[str, str], profile: str) -> dict[str, str]: + state = json.loads(Path(env["FAKE_DOCKER_STATE"]).read_text(encoding="utf-8")) + return state["containers"][f"headroom-{profile}"]["env"] + + +def _exercise_dashboard_gateway_overrides(wrapper_command: list[str], env: dict[str, str]) -> None: + trusted_cidrs = "HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS" + + try: + for profile, configured_value in (("configured", "10.20.0.0/16"), ("empty", "")): + env[trusted_cidrs] = configured_value + port = _free_port() + _run( + [ + *wrapper_command, + "install", + "apply", + "--profile", + profile, + "--port", + str(port), + "--image", + "fake/headroom:test", + ], + env=env, + ) + + install_call = _persistent_run_call(env, profile) + assert install_call[install_call.index("-p") + 1] == f"127.0.0.1:{port}:{port}" + # Docker's name-only --env form preserves the caller's value, + # including an explicitly empty value, instead of installing the + # discovered bridge gateway default. + assert trusted_cidrs in install_call + assert not any(arg.startswith(f"{trusted_cidrs}=") for arg in install_call) + assert _persistent_container_env(env, profile)[trusted_cidrs] == configured_value + _run([*wrapper_command, "install", "remove", "--profile", profile], env=env) + + env.pop(trusted_cidrs, None) + env["FAKE_DOCKER_GATEWAY"] = "FAIL" + port = _free_port() + result = _run( + [ + *wrapper_command, + "install", + "apply", + "--profile", + "no-gateway", + "--port", + str(port), + "--image", + "fake/headroom:test", + ], + env=env, + ) + install_call = _persistent_run_call(env, "no-gateway") + assert install_call[install_call.index("-p") + 1] == f"127.0.0.1:{port}:{port}" + assert not any( + arg == trusted_cidrs or arg.startswith(f"{trusted_cidrs}=") for arg in install_call + ) + assert trusted_cidrs not in _persistent_container_env(env, "no-gateway") + assert "dashboard metadata remains restricted" in (result.stdout + result.stderr) + _run([*wrapper_command, "install", "remove", "--profile", "no-gateway"], env=env) + finally: + env.pop(trusted_cidrs, None) + env.pop("FAKE_DOCKER_GATEWAY", None) + + def _run( command: list[str], *, @@ -402,11 +502,19 @@ def test_bash_native_installer_supports_persistent_docker_lifecycle(tmp_path: Pa install_call = next( call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call ) + assert install_call[install_call.index("-p") + 1] == f"127.0.0.1:{port}:{port}" assert "/tmp/headroom-home/.headroom/memory.db" in install_call # Canonical filesystem contract env vars (issue #175) forwarded into # the container so the proxy resolves state/config to the bind mount. assert "HEADROOM_WORKSPACE_DIR=/tmp/headroom-home/.headroom" in install_call assert "HEADROOM_CONFIG_DIR=/tmp/headroom-home/.headroom/config" in install_call + assert "HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32" in install_call + assert ( + _persistent_container_env(env, "smoke")["HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS"] + == "172.17.0.1/32" + ) + + _exercise_dashboard_gateway_overrides([str(wrapper)], env) status_result = _run( [str(wrapper), "install", "status", "--profile", "smoke"], @@ -654,10 +762,21 @@ def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_pa install_call = next( call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call ) + assert install_call[install_call.index("-p") + 1] == f"127.0.0.1:{port}:{port}" assert "/tmp/headroom-home/.headroom/memory.db" in install_call # Canonical filesystem contract env vars (issue #175). assert "HEADROOM_WORKSPACE_DIR=/tmp/headroom-home/.headroom" in install_call assert "HEADROOM_CONFIG_DIR=/tmp/headroom-home/.headroom/config" in install_call + assert "HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32" in install_call + assert ( + _persistent_container_env(env, "smoke")["HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS"] + == "172.17.0.1/32" + ) + + _exercise_dashboard_gateway_overrides( + [powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(wrapper)], + env, + ) status_result = _run( [