diff --git a/omlx/admin/routes.py b/omlx/admin/routes.py index 020a7170..3f5a77d6 100644 --- a/omlx/admin/routes.py +++ b/omlx/admin/routes.py @@ -113,6 +113,7 @@ class GlobalSettingsRequest(BaseModel): host: Optional[str] = None port: Optional[int] = None log_level: Optional[str] = None + server_aliases: Optional[List[str]] = None # Model settings model_dirs: Optional[List[str]] = None @@ -1684,6 +1685,40 @@ async def get_generation_config( # ============================================================================= +@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, + } + + @router.get("/api/global-settings") async def get_global_settings(is_admin: bool = Depends(require_admin)): """ @@ -1718,6 +1753,7 @@ async def get_global_settings(is_admin: bool = Depends(require_admin)): "host": global_settings.server.host, "port": global_settings.server.port, "log_level": global_settings.server.log_level, + "server_aliases": list(global_settings.server.server_aliases), }, "model": { "model_dirs": [ @@ -1840,6 +1876,30 @@ async def update_global_settings( _apply_log_level_runtime(request.log_level) runtime_applied.append("log_level") + if request.server_aliases is not None: + from ..utils.network import is_valid_alias + + cleaned: list[str] = [] + seen: set[str] = set() + for alias in request.server_aliases: + if not isinstance(alias, str): + raise HTTPException( + status_code=400, + detail="Invalid server alias: each alias must be a string", + ) + value = alias.strip() + if not value or value in seen: + continue + if not is_valid_alias(value): + raise HTTPException( + status_code=400, + detail=f"Invalid server alias: {value!r} (must be a hostname or IP address)", + ) + seen.add(value) + cleaned.append(value) + global_settings.server.server_aliases = cleaned + runtime_applied.append("server_aliases") + # Apply model settings new_dirs = None if request.model_dirs is not None: diff --git a/omlx/admin/static/js/dashboard.js b/omlx/admin/static/js/dashboard.js index 7a07cad8..9c348d54 100644 --- a/omlx/admin/static/js/dashboard.js +++ b/omlx/admin/static/js/dashboard.js @@ -140,6 +140,10 @@ avg_generation_tps: 0.0, total_requests: 0, }, + // Server connectivity info (from /admin/api/server-info) + serverAliases: [], + selectedAlias: '', + statsScope: 'session', selectedStatsModel: '', showClearStatsConfirm: false, @@ -355,6 +359,7 @@ await Promise.all([ this.loadGlobalSettings(), this.loadModels(), + this.loadServerInfo(), this.checkForUpdate() ]); @@ -1064,11 +1069,52 @@ }, // Status tab functions + // Normalizes a host string for safe URL embedding: + // - unwraps existing IPv6 brackets so we can re-bracket consistently + // - maps unspecified bind addresses (0.0.0.0, ::) to a placeholder + // since they are not routable from a client + // - maps `localhost` to 127.0.0.1 for consistency with other URLs + // - bracket-wraps IPv6 addresses per RFC 3986 (`http://[::1]:8000/v1`) + 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.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; + const host = this.selectedAlias || this.stats.host || '127.0.0.1'; + return this.formatDisplayHost(host); + }, + + async loadServerInfo() { + try { + const response = await fetch('/admin/api/server-info'); + if (response.ok) { + const data = await response.json(); + const aliases = Array.isArray(data.aliases) ? data.aliases : []; + this.serverAliases = aliases; + // Preserve user selection across reloads if still valid; + // otherwise default to the first alias when available. + if (this.selectedAlias && !aliases.includes(this.selectedAlias)) { + this.selectedAlias = ''; + } + if (!this.selectedAlias && aliases.length > 0) { + this.selectedAlias = aliases[0]; + } + } else if (response.status === 401) { + window.location.href = '/admin'; + } + } catch (err) { + console.error('Failed to load server info:', err); + } }, get llmModels() { diff --git a/omlx/admin/templates/dashboard/_status.html b/omlx/admin/templates/dashboard/_status.html index 4640420f..3eac22d6 100644 --- a/omlx/admin/templates/dashboard/_status.html +++ b/omlx/admin/templates/dashboard/_status.html @@ -336,6 +336,16 @@

{{ t('status.head {{ t('status.api.section_label') }} +
+ + +
diff --git a/omlx/server.py b/omlx/server.py index 716bdbd0..8094cdf7 100644 --- a/omlx/server.py +++ b/omlx/server.py @@ -293,6 +293,33 @@ async def verify_api_key( @asynccontextmanager async def lifespan(app: FastAPI): """FastAPI lifespan for startup/shutdown events.""" + # Startup: Auto-populate server aliases for the admin dashboard + # so users get sensible hostname/IP options for API URL hints + # without manual configuration. Only runs when the persisted list + # is empty so user-curated aliases are never overwritten. + if ( + _server_state.global_settings is not None + and not _server_state.global_settings.server.server_aliases + ): + try: + from .utils.network import detect_server_aliases + + detected = detect_server_aliases( + host=_server_state.global_settings.server.host + ) + if detected: + _server_state.global_settings.server.server_aliases = detected + try: + _server_state.global_settings.save() + except Exception as save_exc: # pragma: no cover - filesystem race + logger.warning( + "Auto-detected server aliases but could not persist: %s", + save_exc, + ) + logger.info("Auto-detected server aliases: %s", detected) + except Exception as exc: # pragma: no cover - never block startup + logger.warning("Server alias auto-detection failed: %s", exc) + # Startup: Preload pinned models if _server_state.engine_pool is not None: await _server_state.engine_pool.preload_pinned_models() diff --git a/omlx/settings.py b/omlx/settings.py index d0483304..a30baeee 100644 --- a/omlx/settings.py +++ b/omlx/settings.py @@ -114,6 +114,7 @@ class ServerSettings: port: int = 8000 log_level: str = "info" cors_origins: list[str] = field(default_factory=lambda: ["*"]) + server_aliases: list[str] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" @@ -127,6 +128,7 @@ def from_dict(cls, data: dict[str, Any]) -> ServerSettings: port=data.get("port", 8000), log_level=data.get("log_level", "info"), cors_origins=data.get("cors_origins", ["*"]), + server_aliases=data.get("server_aliases", []), ) diff --git a/omlx/utils/network.py b/omlx/utils/network.py new file mode 100644 index 00000000..272cdc64 --- /dev/null +++ b/omlx/utils/network.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Network interface and hostname detection utilities. + +Used to auto-populate ``ServerSettings.server_aliases`` so the admin +dashboard can offer dynamic API URL hints (Tailscale mDNS, LAN IP, +localhost, etc.) without manual configuration. +""" + +from __future__ import annotations + +import ipaddress +import logging +import re +import socket +from collections.abc import Iterable + +logger = logging.getLogger(__name__) + +# RFC 1123 hostname label: letters, digits, hyphens; 1-63 chars per label. +# Allows trailing dot. Total length capped at 253. +_HOSTNAME_LABEL = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(? bool: + """Return True if ``value`` looks like a valid DNS hostname.""" + if not value or len(value) > 253: + return False + candidate = value[:-1] if value.endswith(".") else value + return all(_HOSTNAME_LABEL.match(label) for label in candidate.split(".")) + + +def is_valid_ip(value: str) -> bool: + """Return True if ``value`` is a usable IPv4 or IPv6 alias address. + + Rejects unspecified bind addresses (``0.0.0.0`` and ``::``) since they + are not routable as client-facing URL hosts even though they parse as + valid IP addresses. + """ + try: + ip = ipaddress.ip_address(value) + except ValueError: + return False + return not ip.is_unspecified + + +def is_valid_alias(value: str) -> bool: + """Validate that ``value`` is a hostname or routable IP address. + + If the value parses as an IP address at all, the IP validity check is + authoritative — we do not silently fall through to hostname matching. + Without this guard, an IP-shaped string like ``0.0.0.0`` would slip + through as a "valid hostname" (digit-only labels are legal) even after + being rejected as an unspecified bind address by :func:`is_valid_ip`. + """ + if not isinstance(value, str): + return False + value = value.strip() + if not value: + return False + try: + ipaddress.ip_address(value) + except ValueError: + return is_valid_hostname(value) + return is_valid_ip(value) + + +def _local_ipv4_addresses() -> list[str]: + """Best-effort enumeration of non-loopback IPv4 addresses. + + Tries ``psutil`` (most accurate, multi-interface) first, then falls + back to ``socket.getaddrinfo`` against the local hostname. + """ + addresses: list[str] = [] + + try: + import psutil # type: ignore + + for iface_addrs in psutil.net_if_addrs().values(): + for addr in iface_addrs: + if getattr(addr, "family", None) == socket.AF_INET: + ip = addr.address + try: + if not ipaddress.ip_address(ip).is_loopback: + addresses.append(ip) + except ValueError: + continue + except Exception as exc: # pragma: no cover - psutil unavailable + logger.debug("psutil unavailable for IP discovery: %s", exc) + + if not addresses: + try: + host = socket.gethostname() + for info in socket.getaddrinfo(host, None, family=socket.AF_INET): + ip = info[4][0] + try: + if not ipaddress.ip_address(ip).is_loopback: + addresses.append(ip) + except ValueError: + continue + except OSError as exc: + logger.debug("getaddrinfo fallback failed: %s", exc) + + return addresses + + +def _dedupe_preserve_order(items: Iterable[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for item in items: + if item and item not in seen: + seen.add(item) + result.append(item) + return result + + +def detect_server_aliases(host: str = "127.0.0.1") -> list[str]: + """Detect candidate aliases for the running server. + + Args: + host: The configured server bind host. Used to decide whether + to include ``localhost``/``127.0.0.1`` first. + + Returns: + Ordered, de-duplicated list of valid aliases. Order favors + commonly accessible names: localhost, hostname, mDNS (.local), + FQDN, then any non-loopback IPv4 addresses. + """ + candidates: list[str] = [] + + # Always offer loopback first when bound to localhost or all interfaces. + if host in ("127.0.0.1", "localhost", "0.0.0.0", "::"): + candidates.append("localhost") + candidates.append("127.0.0.1") + + try: + hostname = socket.gethostname() + if hostname: + candidates.append(hostname) + # Add Bonjour/mDNS form if not already present. + if not hostname.endswith(".local"): + candidates.append(f"{hostname}.local") + except OSError as exc: + logger.debug("gethostname failed: %s", exc) + + try: + fqdn = socket.getfqdn() + # Skip reverse-DNS PTR records (e.g. "...ip6.arpa", "...in-addr.arpa") + # which are not user-friendly and not routable as URLs. + if fqdn and not fqdn.endswith((".ip6.arpa", ".in-addr.arpa")): + candidates.append(fqdn) + except OSError as exc: + logger.debug("getfqdn failed: %s", exc) + + candidates.extend(_local_ipv4_addresses()) + + # Filter to valid aliases only and dedupe while preserving order. + valid = [c for c in candidates if is_valid_alias(c)] + return _dedupe_preserve_order(valid) diff --git a/tests/test_admin_server_aliases.py b/tests/test_admin_server_aliases.py new file mode 100644 index 00000000..05f04ab8 --- /dev/null +++ b/tests/test_admin_server_aliases.py @@ -0,0 +1,260 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for server alias support: /admin/api/server-info endpoint and +``server_aliases`` save/validate path in /admin/api/global-settings.""" + +import asyncio +from contextlib import contextmanager +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +import omlx.server # noqa: F401 — ensure server module is imported first (triggers set_admin_getters) +import omlx.admin.routes as admin_routes +from omlx.admin.routes import GlobalSettingsRequest +from omlx.utils.network import ( + detect_server_aliases, + is_valid_alias, + is_valid_hostname, + is_valid_ip, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def _make_global_settings(server_aliases: list[str] | None = None, host: str = "127.0.0.1"): + """Build a MagicMock GlobalSettings with the fields the alias paths touch.""" + gs = MagicMock() + gs.server.host = host + gs.server.port = 8000 + gs.server.log_level = "info" + gs.server.server_aliases = list(server_aliases or []) + # Validation is invoked at the end of update_global_settings; return no errors. + gs.validate.return_value = [] + gs.save.return_value = None + return gs + + +@contextmanager +def _patched_global_settings(gs): + """Patch the module-level _get_global_settings getter without disturbing others.""" + original = admin_routes._get_global_settings + admin_routes._get_global_settings = lambda: gs + try: + yield + finally: + admin_routes._get_global_settings = original + + +# ============================================================================= +# Unit tests for omlx.utils.network +# ============================================================================= + + +class TestNetworkValidation: + """Validation primitives used by the alias save path.""" + + def test_valid_ipv4(self): + assert is_valid_ip("192.168.1.10") + assert is_valid_ip("127.0.0.1") + + def test_valid_ipv6(self): + assert is_valid_ip("::1") + assert is_valid_ip("fe80::1") + + def test_rejects_unspecified_ipv4(self): + """0.0.0.0 parses as a valid IP but is not routable as an alias.""" + assert not is_valid_ip("0.0.0.0") + + def test_rejects_unspecified_ipv6(self): + """:: is the IPv6 unspecified address — also not usable as an alias.""" + assert not is_valid_ip("::") + + def test_rejects_garbage(self): + assert not is_valid_ip("not-an-ip") + assert not is_valid_ip("999.999.999.999") + + def test_valid_hostname(self): + assert is_valid_hostname("example.local") + assert is_valid_hostname("my-mac") + assert is_valid_hostname("a.b.c.d") + + def test_rejects_invalid_hostname(self): + assert not is_valid_hostname("") + assert not is_valid_hostname("with space") + assert not is_valid_hostname("-leading-dash") + assert not is_valid_hostname("a" * 300) + + def test_alias_accepts_either(self): + assert is_valid_alias("localhost") + assert is_valid_alias("192.168.1.10") + assert is_valid_alias("foo.local") + assert is_valid_alias("::1") + + def test_alias_rejects_unspecified(self): + assert not is_valid_alias("0.0.0.0") + assert not is_valid_alias("::") + + def test_alias_rejects_non_string(self): + assert not is_valid_alias(None) # type: ignore[arg-type] + assert not is_valid_alias(123) # type: ignore[arg-type] + + +class TestDetectServerAliases: + """Auto-detection should always return at least loopback when bound to localhost.""" + + def test_localhost_includes_loopback(self): + aliases = detect_server_aliases(host="127.0.0.1") + assert "localhost" in aliases + assert "127.0.0.1" in aliases + + def test_no_unspecified_in_output(self): + """Even when bound to 0.0.0.0, detection should not return 0.0.0.0 itself.""" + aliases = detect_server_aliases(host="0.0.0.0") + assert "0.0.0.0" not in aliases + assert "::" not in aliases + + def test_returns_unique_values(self): + aliases = detect_server_aliases() + assert len(aliases) == len(set(aliases)) + + +# ============================================================================= +# /admin/api/server-info endpoint +# ============================================================================= + + +class TestServerInfoEndpoint: + """get_server_info: returns persisted aliases or falls back to detection.""" + + def test_returns_persisted_aliases(self): + gs = _make_global_settings( + server_aliases=["my-mac.local", "192.168.1.10", "localhost"], + host="127.0.0.1", + ) + with _patched_global_settings(gs): + result = asyncio.run(admin_routes.get_server_info(is_admin=True)) + + assert result["host"] == "127.0.0.1" + assert result["port"] == 8000 + assert result["aliases"] == ["my-mac.local", "192.168.1.10", "localhost"] + + def test_falls_back_to_detection_when_empty(self): + """Empty persisted list → live auto-detection kicks in.""" + gs = _make_global_settings(server_aliases=[], host="127.0.0.1") + with _patched_global_settings(gs): + result = asyncio.run(admin_routes.get_server_info(is_admin=True)) + + # Auto-detection always returns at least the loopback pair. + assert "localhost" in result["aliases"] + assert "127.0.0.1" in result["aliases"] + + def test_returns_503_when_settings_unavailable(self): + with _patched_global_settings(None): + with pytest.raises(HTTPException) as exc_info: + asyncio.run(admin_routes.get_server_info(is_admin=True)) + assert exc_info.value.status_code == 503 + + +# ============================================================================= +# /admin/api/global-settings save path for server_aliases +# ============================================================================= + + +class TestUpdateGlobalSettingsAliases: + """update_global_settings: saving server_aliases with validation.""" + + def test_saves_valid_aliases(self): + gs = _make_global_settings(server_aliases=[]) + request = GlobalSettingsRequest(server_aliases=["custom.local", "10.0.0.5"]) + + with _patched_global_settings(gs): + result = asyncio.run( + admin_routes.update_global_settings(request=request, is_admin=True) + ) + + assert result["success"] is True + assert "server_aliases" in result["runtime_applied"] + assert gs.server.server_aliases == ["custom.local", "10.0.0.5"] + gs.save.assert_called_once() + + def test_strips_whitespace_and_dedupes(self): + gs = _make_global_settings(server_aliases=[]) + request = GlobalSettingsRequest( + server_aliases=[" foo.local ", "foo.local", "10.0.0.5", " "], + ) + + with _patched_global_settings(gs): + asyncio.run( + admin_routes.update_global_settings(request=request, is_admin=True) + ) + + assert gs.server.server_aliases == ["foo.local", "10.0.0.5"] + + def test_rejects_invalid_alias_with_400(self): + gs = _make_global_settings(server_aliases=[]) + request = GlobalSettingsRequest( + server_aliases=["valid.local", "not valid!!!"], + ) + + with _patched_global_settings(gs): + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + admin_routes.update_global_settings(request=request, is_admin=True) + ) + + assert exc_info.value.status_code == 400 + assert "not valid!!!" in exc_info.value.detail + gs.save.assert_not_called() + + def test_rejects_unspecified_address_with_400(self): + """0.0.0.0 must be rejected — bind address, not a routable URL host.""" + gs = _make_global_settings(server_aliases=[]) + request = GlobalSettingsRequest(server_aliases=["0.0.0.0"]) + + with _patched_global_settings(gs): + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + admin_routes.update_global_settings(request=request, is_admin=True) + ) + + assert exc_info.value.status_code == 400 + assert "0.0.0.0" in exc_info.value.detail + gs.save.assert_not_called() + + def test_rejects_ipv6_unspecified_with_400(self): + gs = _make_global_settings(server_aliases=[]) + request = GlobalSettingsRequest(server_aliases=["::"]) + + with _patched_global_settings(gs): + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + admin_routes.update_global_settings(request=request, is_admin=True) + ) + + assert exc_info.value.status_code == 400 + + def test_accepts_ipv6_loopback(self): + gs = _make_global_settings(server_aliases=[]) + request = GlobalSettingsRequest(server_aliases=["::1"]) + + with _patched_global_settings(gs): + asyncio.run( + admin_routes.update_global_settings(request=request, is_admin=True) + ) + + assert gs.server.server_aliases == ["::1"] + + def test_empty_list_clears_aliases(self): + gs = _make_global_settings(server_aliases=["existing.local"]) + request = GlobalSettingsRequest(server_aliases=[]) + + with _patched_global_settings(gs): + asyncio.run( + admin_routes.update_global_settings(request=request, is_admin=True) + ) + + assert gs.server.server_aliases == []