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 == []