Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions omlx/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)):
"""
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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:
Expand Down
54 changes: 50 additions & 4 deletions omlx/admin/static/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -355,6 +359,7 @@
await Promise.all([
this.loadGlobalSettings(),
this.loadModels(),
this.loadServerInfo(),
this.checkForUpdate()
]);

Expand Down Expand Up @@ -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() {
Expand Down
10 changes: 10 additions & 0 deletions omlx/admin/templates/dashboard/_status.html
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,16 @@ <h3 class="text-2xl font-bold tracking-tight text-neutral-900">{{ t('status.head
<i data-lucide="link" class="w-4 h-4 text-neutral-500"></i>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-600">{{ t('status.api.section_label') }}</span>
</div>
<div x-show="serverAliases.length > 1" class="flex items-center gap-2">
<label for="server-alias-select" class="text-[11px] font-medium uppercase tracking-wider text-neutral-500">Host</label>
<select id="server-alias-select"
x-model="selectedAlias"
class="text-xs font-mono bg-white border border-neutral-300 rounded-md px-2 py-1 text-neutral-700 focus:outline-none focus:ring-2 focus:ring-neutral-400">
<template x-for="alias in serverAliases" :key="alias">
<option :value="alias" x-text="alias"></option>
</template>
</select>
</div>
</div>
<div class="divide-y divide-neutral-100">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-4 sm:px-6 py-4">
Expand Down
27 changes: 27 additions & 0 deletions omlx/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions omlx/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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", []),
)


Expand Down
158 changes: 158 additions & 0 deletions omlx/utils/network.py
Original file line number Diff line number Diff line change
@@ -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}(?<!-)$")


def is_valid_hostname(value: str) -> 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)
Loading