feat(dashboard): add settings dashboard for proxy configuration#2101
feat(dashboard): add settings dashboard for proxy configuration#2101nangsontay wants to merge 7 commits into
Conversation
d9e28cd to
a346c21
Compare
|
note: force push because I forgot to sign the previous commit with my new key (changed but forgot to assign to Github) |
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because no GitHub Actions runner was available. Make sure your repository has a runner available to run Copilot's review, or add a copilot-setup-steps.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a local web settings dashboard for configuring safe proxy settings, persisting them to a file-backed store, and supporting apply/restart flows across deployment modes.
Changes:
- Introduces
headroom/settings_store.pyfor validated, file-backed settings persisted under the workspace and applied toos.environearly. - Adds dashboard UI + new loopback-gated settings endpoints, plus same-origin protection for mutating routes.
- Adds support for provider “extra forwarded headers” via CLI/env/config and merges them into upstream requests.
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| wiki/proxy.md | Documents new CLI flags for extra forwarded headers. |
| wiki/configuration.md | Adds “Settings GUI” documentation (persistence/precedence/CSRF/apply modes). |
| wiki/cli.md | Documents new flags and env vars for extra headers. |
| tests/test_proxy_settings_endpoints.py | Covers settings endpoints, origin guard, masking, and apply/restart behavior. |
| tests/test_proxy/test_settings_store.py | Covers settings store validation, atomic save, masking, and precedence. |
| tests/test_proxy/test_settings_fresh_process_precedence.py | Verifies precedence in a real subprocess. |
| tests/test_provider_registry.py | Adds tests for resolve_extra_headers behavior and errors. |
| tests/test_install/test_runtime.py | Adds tests for deployment detection and restart dispatch. |
| tests/test_header_isolation.py | Adds tests validating extra headers are forwarded/overriding behavior. |
| tests/test_cli_proxy_env.py | Verifies settings file applies before Click envvar parsing. |
| headroom/settings_store.py | Implements curated registry, validation/coercion, masking, load/save, schema. |
| headroom/proxy/server.py | Applies stored settings defensively; adds /settings* API + settings page route. |
| headroom/proxy/models.py | Extends ProxyConfig with anthropic_extra_headers / openai_extra_headers. |
| headroom/proxy/loopback_guard.py | Adds require_same_origin guard for mutating admin routes. |
| headroom/proxy/helpers.py | Adds merge_extra_headers utility for forwarding overrides. |
| headroom/proxy/handlers/openai.py | Merges configured extra headers into OpenAI forwarded requests (incl WS). |
| headroom/proxy/handlers/anthropic.py | Merges configured extra headers into Anthropic forwarded requests and redaction set. |
| headroom/providers/registry.py | Adds resolve_extra_headers JSON parsing/validation (CLI over env). |
| headroom/paths.py | Adds settings path resolution (HEADROOM_SETTINGS_PATH_ENV, settings_path). |
| headroom/install/runtime.py | Adds deployment detection + restart dispatch for apply/restart. |
| headroom/dashboard/templates/settings.html | New settings UI (tabs, save/apply, masking/clear). |
| headroom/dashboard/templates/dashboard.html | Adds link to settings page from dashboard. |
| headroom/dashboard/init.py | Adds get_settings_html() template loader. |
| headroom/cli/proxy.py | Adds CLI options and wiring to resolve extra headers into ProxyConfig. |
| headroom/cli/main.py | Applies stored settings to env before Click parses envvar-backed options. |
| Emitted once per outbound request (paired with ``log_outbound_request``). | ||
| Per realignment build constraint #8 we log every cache-affecting | ||
| decision; per #8/#11 we never log header values, only the count of | ||
| stripped internal headers. | ||
| """ | ||
| logger.info( | ||
| "event=outbound_headers forwarder=%s stripped_count=%d request_id=%s", | ||
| forwarder, | ||
| stripped_count, | ||
| request_id or "", |
| if field.type in ("int", "float"): | ||
| if isinstance(value, bool): # bool is an int subclass — reject explicitly | ||
| raise ValueError(f"expected a number, got {value!r}") | ||
| if field.type == "int": | ||
| if isinstance(value, float) and not value.is_integer(): | ||
| raise ValueError(f"expected an integer, got {value!r}") | ||
| number = int(value) | ||
| else: | ||
| number = float(value) | ||
| if field.minimum is not None and number < field.minimum: | ||
| raise ValueError(f"must be >= {field.minimum}") | ||
| if field.maximum is not None and number > field.maximum: | ||
| raise ValueError(f"must be <= {field.maximum}") | ||
| return number |
| class SettingsStore: | ||
| """Instance-based settings store for a workspace directory.""" | ||
|
|
||
| def __init__(self, workspace_dir: str | Path) -> None: | ||
| """Initialize settings store for a specific workspace directory.""" | ||
| self.workspace_dir = Path(workspace_dir) | ||
| self.settings_file = self.workspace_dir / "settings.json" | ||
|
|
||
| def load(self) -> dict[str, Any]: | ||
| """Load settings from workspace settings.json.""" | ||
| if not self.settings_file.exists(): | ||
| return {} | ||
| try: | ||
| return json.loads(self.settings_file.read_text()) | ||
| except (json.JSONDecodeError, OSError): | ||
| return {} | ||
|
|
||
| def save(self, values: dict[str, Any]) -> None: | ||
| """Save settings to workspace settings.json.""" | ||
| validated = validate(values) | ||
| self.workspace_dir.mkdir(parents=True, exist_ok=True) | ||
| serialized = {k: _serialize(f, validated[k]) for k, f in _BY_KEY.items() if k in validated} | ||
| _atomic_write_text(self.settings_file, json.dumps(serialized, indent=2)) | ||
|
|
||
| def validate(self, values: dict[str, Any]) -> dict[str, Any]: | ||
| """Validate and coerce settings values.""" | ||
| return validate(values) | ||
|
|
||
| def load_schema_with_secrets_masked(self) -> dict[str, Any]: | ||
| """Load stored values with secrets masked for display.""" | ||
| stored = self.load() | ||
| result = {} | ||
| for field in SETTINGS: | ||
| if field.key in stored: | ||
| result[field.key] = _mask(field, stored[field.key]) | ||
| return result |
| status_code=422, | ||
| content={"error": "invalid settings value(s)", "field_errors": exc.field_errors}, | ||
| ) | ||
| _manifest, mode = install_runtime.detect_current_deployment() | ||
| record_admin_action( | ||
| request=request, |
| <script src="https://cdn.tailwindcss.com"></script> | ||
| <script src="https://unpkg.com/alpinejs@3.13.3/dist/cdn.min.js" defer></script> |
| this.status = 'Saved.'; | ||
| this.banner = 'Restart required to apply: ' + (data.changed_keys || []).join(', '); | ||
| this.applyMode = ''; this.applyCommand = ''; |
| def _coerce(field: SettingField, value: Any) -> Any: | ||
| """Coerce a raw JSON/env value to the field's Python type. | ||
|
|
||
| Returns ``None`` for null/empty. Raises ``ValueError`` on bad input so | ||
| callers can surface a per-field message. | ||
| """ | ||
| if value is None: | ||
| return None | ||
| if field.type in ("bool", "optional-bool"): | ||
| if isinstance(value, bool): | ||
| return value | ||
| token = str(value).strip().lower() | ||
| if token in ("1", "true", "yes", "on"): | ||
| return True | ||
| if token in ("0", "false", "no", "off", ""): | ||
| return False |
- merge_extra_headers: override client headers case-insensitively so a configured gateway header never ships upstream alongside the client's differently-cased duplicate - settings_store: reject non-finite floats (NaN/Infinity) in coercion, which previously slipped past min/max bounds on unbounded fields - settings_store: coerce an empty optional-bool to None (inherit) instead of False, and correct the _coerce docstring - settings_store: drop the unused SettingsStore class that diverged from the module-level load/save API - server: loopback-gate GET /dashboard/settings to match its APIs - settings.html: don't render an empty "Restart required to apply:" banner when no keys changed Add regression tests for each behavioral fix.
PR governanceThis PR does not yet satisfy the required template fields:
Please update the PR body, or move the PR back to draft while it is still in progress. |
JerrettDavis
left a comment
There was a problem hiding this comment.
Reviewed current head 0d31939. The settings store/API surfaces are loopback/same-origin guarded, secret header fields stay masked, stored settings preserve explicit env precedence, and the extra-header plumbing is scoped to upstream forwarding. I fixed the post-main registry drift for retry delay settings, corrected the settings UI so no-op saves don't persist every default value, and removed unrelated dependency/Cargo churn from the diff. Focused ruff, format, pytest, pre-commit hooks, and whitespace checks passed locally.
|
@JerrettDavis is it safe to skip the security issue of deps? |
Description
Adds a loopback-only dashboard settings panel at
/dashboard/settingsfor a curated, safe subset of Headroom runtime knobs. Settings persist tosettings.json, are applied before Click resolvesenvvar=options, and keep precedence predictable: explicit shell export > stored settings > code default.The panel also adds an Endpoints group for custom Anthropic/OpenAI upstream base URLs and optional extra forwarded headers for gateway deployments. Mutating routes are loopback-gated and same-origin guarded; secret header values are masked and admin audit records only changed key names.
Changes Made
headroom/settings_store.pywith validation, masking, atomic save, partial-update merge semantics, and env application./settings/schema,/settings,/settings/apply, and/dashboard/settingsroutes.main, added missing retry-delay settings registry entries, fixed the UI so no-op saves do not persist every default, and removed unrelated dependency/Cargo churn from the PR diff.Testing
The pushed cleanup commits also passed local pre-commit hooks.
Review Readiness