feat: configurable server aliases for dashboard API URL hints#634
feat: configurable server aliases for dashboard API URL hints#634jundot merged 2 commits intojundot:mainfrom
Conversation
Users can now configure multiple server hostnames/IPs (e.g. Tailscale mDNS, LAN IP, localhost) and pick which one the admin dashboard uses when rendering API URL hints. This makes the dashboard usable on multi-interface hosts without forcing users to memorize the right address for each network surface. - Add ServerSettings.server_aliases (persisted, validated) - New utility omlx/utils/network.py: detects hostname, FQDN, mDNS, and non-loopback IPv4 addresses; filters PTR/arpa noise; validates aliases - New /admin/api/server-info endpoint returns host/port/aliases, falling back to live detection when the persisted list is empty - GlobalSettingsRequest accepts server_aliases (validated server-side) - Lifespan auto-populates aliases on startup when the list is empty, preserving user-curated values across restarts - Dashboard adds a host selector in the API Endpoints section; the displayHost getter honors the selection so all <code> URL blocks update reactively via Alpine
There was a problem hiding this comment.
Pull request overview
Adds server “alias” support so the admin dashboard can render API URL hints (and the Claude Code launch command) using a user-selectable host on multi-interface machines.
Changes:
- Introduces alias detection + validation utilities and persists aliases in
ServerSettings.server_aliases. - Adds
/admin/api/server-infoand supports savingserver_aliasesvia the existing global settings update route. - Updates the dashboard to load aliases, show a host dropdown when applicable, and bind all displayed URLs to the selected host.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
omlx/utils/network.py |
New helpers for alias detection/validation. |
omlx/settings.py |
Adds server_aliases to persisted server settings. |
omlx/admin/routes.py |
Adds server-info endpoint + server_aliases save/validate path. |
omlx/server.py |
Auto-populates aliases on first run when none are persisted. |
omlx/admin/static/js/dashboard.js |
Loads server info, stores alias selection, and uses it for displayed hosts. |
omlx/admin/templates/dashboard/_status.html |
Adds the alias dropdown UI in the API Endpoints header. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
omlx/admin/static/js/dashboard.js
Outdated
| get displayHost() { | ||
| if (this.selectedAlias) return this.selectedAlias; | ||
| const host = this.stats.host || '127.0.0.1'; | ||
| if (host === '0.0.0.0') return 'your-ip-address'; | ||
| if (host === 'localhost') return '127.0.0.1'; | ||
| return host; |
There was a problem hiding this comment.
displayHost returns selectedAlias verbatim, but the template builds URLs as 'http://' + displayHost + ':' + port. If a user supplies an IPv6 alias (allowed by the current validation), the resulting URL will be invalid unless the host is wrapped in brackets (e.g., http://[::1]:8000). Also, allowing selectedAlias to be 0.0.0.0/:: bypasses the existing placeholder mapping and will render non-routable URLs. Consider normalizing/formatting the selected alias (IPv6 bracket wrapping + mapping/rejecting unspecified bind addresses) before using it in URL strings.
| get displayHost() { | |
| if (this.selectedAlias) return this.selectedAlias; | |
| const host = this.stats.host || '127.0.0.1'; | |
| if (host === '0.0.0.0') return 'your-ip-address'; | |
| if (host === 'localhost') return '127.0.0.1'; | |
| return host; | |
| formatDisplayHost(host) { | |
| const value = (host || '').trim(); | |
| if (!value) return '127.0.0.1'; | |
| const unwrapped = value.startsWith('[') && value.endsWith(']') | |
| ? value.slice(1, -1) | |
| : value; | |
| if (unwrapped === '0.0.0.0' || unwrapped === '::') return 'your-ip-address'; | |
| if (unwrapped === 'localhost') return '127.0.0.1'; | |
| if (unwrapped.includes(':')) return `[${unwrapped}]`; | |
| return unwrapped; | |
| }, | |
| get displayHost() { | |
| const host = this.selectedAlias || this.stats.host || '127.0.0.1'; | |
| return this.formatDisplayHost(host); |
There was a problem hiding this comment.
Addressed in a7b2202. Refactored displayHost into a shared formatDisplayHost(host) helper that runs uniformly on both selectedAlias and the stats.host fallback. Handles bracket-wrapping IPv6 per RFC 3986, unwraps already-bracketed input so we don't double-wrap, and maps 0.0.0.0/:: to your-ip-address (defense in depth alongside the backend rejection in network.py). Verified all 10 edge cases (empty, localhost, 0.0.0.0, ::, ::1, fe80::1, [::1], IPv4, hostname, whitespace) map correctly.
omlx/admin/routes.py
Outdated
| seen: set[str] = set() | ||
| for alias in request.server_aliases: | ||
| if not isinstance(alias, str): | ||
| continue |
There was a problem hiding this comment.
server_aliases validation silently drops non-string entries (continue), which can make a request appear to succeed while losing user input. Since this is an API contract/validation path, it should return HTTP 400 when any entry is not a string (or otherwise invalid) so clients get deterministic feedback.
| continue | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Invalid server alias: each alias must be a string", | |
| ) |
There was a problem hiding this comment.
Addressed in a7b2202. Non-string entries now raise HTTPException(status_code=400, detail="Invalid server alias: each alias must be a string") instead of being silently dropped. Test coverage in tests/test_admin_server_aliases.py.
omlx/utils/network.py
Outdated
| """Return True if ``value`` is a parseable IPv4 or IPv6 address.""" | ||
| try: | ||
| ipaddress.ip_address(value) | ||
| return True |
There was a problem hiding this comment.
is_valid_ip() accepts unspecified bind addresses like 0.0.0.0 and ::, which are not usable as client-facing URL hosts but can be saved via server_aliases and then rendered by the dashboard. Consider treating these as invalid aliases (e.g., reject ip.is_unspecified, and potentially other non-routable categories as appropriate) to prevent the UI from presenting unusable URL hints.
| """Return True if ``value`` is a parseable IPv4 or IPv6 address.""" | |
| try: | |
| ipaddress.ip_address(value) | |
| return True | |
| """Return True if ``value`` is a usable IPv4 or IPv6 alias address.""" | |
| try: | |
| ip = ipaddress.ip_address(value) | |
| return not ip.is_unspecified |
There was a problem hiding this comment.
Addressed in a7b2202. is_valid_ip() now returns not ip.is_unspecified so 0.0.0.0 and :: are rejected at input time.
Also tightened is_valid_alias() to make IP validation authoritative when the input is IP-shaped. Without this guard, is_valid_alias("0.0.0.0") would still return True via the digit-only-label fallback in the hostname regex (each 0 matches ^[A-Za-z0-9-]{1,63}$). The new code path is: parse as IP first → if it parses, use is_valid_ip result → only fall through to is_valid_hostname for non-IP-shaped strings. The new test suite caught this — thanks for the nudge to add it.
| @router.get("/api/server-info") | ||
| async def get_server_info(is_admin: bool = Depends(require_admin)): | ||
| """Return server connectivity metadata for the dashboard. | ||
|
|
||
| Provides the configured host, port, and the list of user-facing | ||
| aliases (hostnames/IPs) that the dashboard can use to render | ||
| selectable API URL hints. | ||
|
|
||
| Returns: | ||
| JSON object with ``host``, ``port``, and ``aliases``. | ||
|
|
||
| Raises: | ||
| HTTPException: 401 if not authenticated, 503 if server not initialized. | ||
| """ | ||
| from ..utils.network import detect_server_aliases | ||
|
|
||
| global_settings = _get_global_settings() | ||
| if global_settings is None: | ||
| raise HTTPException(status_code=503, detail="Server not initialized") | ||
|
|
||
| configured = list(global_settings.server.server_aliases) | ||
| if configured: | ||
| aliases = configured | ||
| else: | ||
| # Fall back to live detection if persisted list is empty. | ||
| aliases = detect_server_aliases(host=global_settings.server.host) | ||
|
|
||
| return { | ||
| "host": global_settings.server.host, | ||
| "port": global_settings.server.port, | ||
| "aliases": aliases, | ||
| } |
There was a problem hiding this comment.
New behavior is added for /admin/api/server-info and for server_aliases validation/persistence in update_global_settings, but there are no automated tests covering these code paths. The repository already has unit tests for admin route logic (e.g., tests/test_admin_api_key.py), so adding similar tests for: (1) returning persisted aliases, (2) fallback detection when empty, and (3) rejecting invalid aliases with 400 would help prevent regressions.
There was a problem hiding this comment.
Addressed in a7b2202. Added tests/test_admin_server_aliases.py with 23 tests covering:
Network validation primitives — IPv4/IPv6 acceptance, unspecified address rejection, hostname rules, the IP-shape-as-hostname guard, non-string rejection.
Auto-detection guarantees — detect_server_aliases always includes loopback when bound to localhost, never returns 0.0.0.0/::, output is deduped.
GET /admin/api/server-info — returns persisted aliases, falls back to live detection when empty, returns 503 when settings unavailable.
POST /admin/api/global-settings server_aliases save path — saves valid input, strips whitespace + dedupes, rejects invalid hostnames with 400, rejects 0.0.0.0/:: with 400, accepts ::1, empty list clears existing aliases.
Follows the tests/test_admin_reload.py pattern of monkey-patching admin_routes._get_global_settings and calling route functions directly via asyncio.run — same as the existing admin test conventions. All 23 pass; no regressions in the existing 57 admin tests.
Addresses 4 review comments on jundot#634: 1. Reject unspecified bind addresses in is_valid_ip (network.py) 0.0.0.0 and :: parse as valid IPs but are bind addresses, not routable URL hosts. Reject them at input time so the dashboard never has to render an unusable URL hint. Also tightens is_valid_alias so an IP-shaped string can't slip through as a "valid hostname" via the digit-only-label fallback — if the value parses as an IP at all, the IP validity check is authoritative. Previously is_valid_alias("0.0.0.0") returned True because "0.0.0.0" matched the hostname regex after failing IP validation. 2. Return HTTP 400 on non-string alias entries (admin/routes.py) Silently dropping non-string entries via `continue` made the API contract opaque — clients could send malformed payloads and get a 200 with their bad data missing. Now raises HTTPException(400) with a clear message so clients get deterministic feedback. 3. Bracket-wrap IPv6 aliases in displayed URLs (dashboard.js) `'http://' + host + ':' + port` produces invalid URLs when host is an IPv6 address — the literal colons in `::1` collide with the port separator. Per RFC 3986, IPv6 in URLs must be wrapped in brackets: `http://[::1]:8000/v1`. Extracted formatDisplayHost(host) helper so the same normalization runs on both selectedAlias and the stats.host fallback. Handles: - empty → 127.0.0.1 - existing brackets → unwrap then re-bracket consistently - 0.0.0.0 / :: → your-ip-address (defense in depth alongside jundot#1) - localhost → 127.0.0.1 - any value containing ':' → bracket-wrap as IPv6 4. Add automated tests (tests/test_admin_server_aliases.py) 23 new tests covering: - Network validation primitives (IPv4/IPv6, hostnames, unspecified rejection, IP-shape-as-hostname guard) - detect_server_aliases output guarantees (no unspecified, dedup) - GET /admin/api/server-info: persisted aliases, fallback to auto-detection, 503 when settings unavailable - POST /admin/api/global-settings server_aliases save path: valid save, whitespace strip + dedup, invalid hostname → 400, 0.0.0.0 → 400, :: → 400, ::1 accepted, empty list clears Follows the existing tests/test_admin_reload.py pattern of monkey-patching admin_routes._get_global_settings and calling route functions directly via asyncio.run.
jundot
left a comment
There was a problem hiding this comment.
Solid work, everything looks clean. I went through all 7 files and here's my quick summary:
What i like:
- Alias detection chain is well thought out (localhost → hostname → mDNS → FQDN → psutil IPs, with getaddrinfo fallback)
- The
is_valid_aliasguard against IP-shaped strings falling through to hostname validation is a nice catch - IPv6 bracket wrapping per RFC 3986, 0.0.0.0/:: rejection on both server and client side
- 23 tests following the existing admin test patterns
- No breaking changes,
server_aliasesdefaults to[]and old configs just work
Minor suggestion (not blocking):
- Could persist
selectedAliasinlocalStorageso the dropdown selection survives page reloads
Merging, thanks for the contribution!
Summary
Adds a configurable list of server hostnames/IPs (aliases) to the admin dashboard so users on multi-interface hosts can pick which address the displayed API URLs should use, instead of editing them by hand.
Why
Today the Status tab shows
http://<host>:<port>/...for the OpenAI/Claude endpoints and the Claude Code with oMLX launch command. When the server is bound to127.0.0.1(or0.0.0.0), that single rendered host doesn't necessarily match the address you actually want to connect from — laptop on Tailscale, phone on the LAN, another container on*.local, etc. The previous workflow was: copy theclaudelaunch command into a text file and hand-edit theANTHROPIC_BASE_URLto whatever interface was needed. Repeated for every endpoint, every network change.This PR replaces that with a small dropdown in the API Endpoints card that drives every URL block on the page reactively.
What it does
localhost/127.0.0.1,gethostname(),<hostname>.local(mDNS/Bonjour),getfqdn(), and non-loopback IPv4 addresses frompsutil(withgetaddrinfofallback). PTR/*.arparecords are filtered out.ServerSettings.server_aliases. Auto-detection only runs when the list is empty, so manually configured values are never clobbered on restart.GET /admin/api/server-inforeturns{host, port, aliases}and falls back to live detection if the persisted list is empty.GlobalSettingsRequestacceptsserver_aliases, with hostname/IP validation that rejects invalid entries with HTTP 400.<code>URL blocks bind to a single `displayHost` getter that honors the selection — OpenAI URL, Claude URL, and the full `ANTHROPIC_BASE_URL` inside the Claude Code launch command all update in lockstep.Files
267 lines added, 0 removed. No breaking changes — `server_aliases` defaults to `[]`, and existing settings files are read with `.get()` so old configs are forward-compatible.
Test plan
Verified end-to-end against a live FastAPI test client: