diff --git a/omlx/admin/i18n/en.json b/omlx/admin/i18n/en.json index 54c253de5..ffb0953de 100644 --- a/omlx/admin/i18n/en.json +++ b/omlx/admin/i18n/en.json @@ -278,6 +278,7 @@ "settings.server.host_public": "Open to all (0.0.0.0)", "settings.server.host_custom": "Custom (caution)", "settings.server.host_placeholder": "e.g. 192.168.1.100", + "settings.server.host_multi_hint": "Separate multiple addresses with commas", "settings.server.port": "Port", "settings.server.log_level": "Log Level", "settings.server.log_level_error": "error", diff --git a/omlx/admin/i18n/ja.json b/omlx/admin/i18n/ja.json index e7795f8f1..6145d83b2 100644 --- a/omlx/admin/i18n/ja.json +++ b/omlx/admin/i18n/ja.json @@ -278,6 +278,7 @@ "settings.server.host_public": "全体公開 (0.0.0.0)", "settings.server.host_custom": "カスタム(注意)", "settings.server.host_placeholder": "例: 192.168.1.100", + "settings.server.host_multi_hint": "複数アドレスはカンマで区切る", "settings.server.port": "ポート", "settings.server.log_level": "ログレベル", "settings.server.log_level_error": "error", diff --git a/omlx/admin/i18n/ko.json b/omlx/admin/i18n/ko.json index c9b8c8476..afc2a192c 100644 --- a/omlx/admin/i18n/ko.json +++ b/omlx/admin/i18n/ko.json @@ -278,6 +278,7 @@ "settings.server.host_public": "전체 공개 (0.0.0.0)", "settings.server.host_custom": "사용자 지정 (주의)", "settings.server.host_placeholder": "예: 192.168.1.100", + "settings.server.host_multi_hint": "여러 주소는 쉼표로 구분", "settings.server.port": "포트", "settings.server.log_level": "로그 레벨", "settings.server.log_level_error": "error", diff --git a/omlx/admin/i18n/zh-TW.json b/omlx/admin/i18n/zh-TW.json index 025f347f6..43af9a9d0 100644 --- a/omlx/admin/i18n/zh-TW.json +++ b/omlx/admin/i18n/zh-TW.json @@ -278,6 +278,7 @@ "settings.server.host_public": "對外開放(0.0.0.0)", "settings.server.host_custom": "自訂(謹慎)", "settings.server.host_placeholder": "例如 192.168.1.100", + "settings.server.host_multi_hint": "多個位址以逗號分隔", "settings.server.port": "連接埠", "settings.server.log_level": "日誌層級", "settings.server.log_level_error": "error", diff --git a/omlx/admin/i18n/zh.json b/omlx/admin/i18n/zh.json index eccb801f3..ede1c03df 100644 --- a/omlx/admin/i18n/zh.json +++ b/omlx/admin/i18n/zh.json @@ -278,6 +278,7 @@ "settings.server.host_public": "对外开放(0.0.0.0)", "settings.server.host_custom": "自定义(谨慎)", "settings.server.host_placeholder": "例如 192.168.1.100", + "settings.server.host_multi_hint": "多个地址用逗号分隔", "settings.server.port": "端口", "settings.server.log_level": "日志级别", "settings.server.log_level_error": "error", diff --git a/omlx/admin/routes.py b/omlx/admin/routes.py index 2d3fe50f6..cdcd9c995 100644 --- a/omlx/admin/routes.py +++ b/omlx/admin/routes.py @@ -2840,6 +2840,17 @@ async def update_global_settings( # Apply server settings if request.host is not None: + from ..utils.network import is_valid_bind_host + + parts = [h.strip() for h in request.host.split(",") if h.strip()] + if not parts: + raise HTTPException(status_code=400, detail="Host cannot be empty") + for part in parts: + if not is_valid_bind_host(part): + raise HTTPException( + status_code=400, + detail=f"Invalid host: {part!r} (must be a hostname or IP address)", + ) global_settings.server.host = request.host if request.port is not None: global_settings.server.port = request.port diff --git a/omlx/admin/static/js/dashboard.js b/omlx/admin/static/js/dashboard.js index e2fb11935..8698e442d 100644 --- a/omlx/admin/static/js/dashboard.js +++ b/omlx/admin/static/js/dashboard.js @@ -808,7 +808,7 @@ window.location.href = '/admin'; } else { const data = await response.json(); - this.saveError = Array.isArray(data.detail) ? data.detail.join(', ') : (data.detail || window.t('js.error.save_settings_failed')); + this.saveError = Array.isArray(data.detail) ? data.detail.map(e => (e && typeof e === 'object') ? (e.msg || JSON.stringify(e)) : String(e)).join(', ') : (data.detail || window.t('js.error.save_settings_failed')); // Reload settings to revert to server values await this.loadGlobalSettings(); } @@ -3728,7 +3728,7 @@ window.location.href = '/admin'; } else { const data = await response.json(); - alert(Array.isArray(data.detail) ? data.detail.join(', ') : (data.detail || 'Failed to save')); + alert(Array.isArray(data.detail) ? data.detail.map(e => (e && typeof e === 'object') ? (e.msg || JSON.stringify(e)) : String(e)).join(', ') : (data.detail || 'Failed to save')); } } catch (err) { console.error('Failed to save HF mirror endpoint:', err); diff --git a/omlx/admin/templates/dashboard/_settings.html b/omlx/admin/templates/dashboard/_settings.html index 876911539..a702c75b3 100644 --- a/omlx/admin/templates/dashboard/_settings.html +++ b/omlx/admin/templates/dashboard/_settings.html @@ -223,17 +223,21 @@

{{ t('settings.gl {{ t('settings.global.restart_badge') }} -
- - +
+
+ + +
+

{{ t('settings.server.host_multi_hint') }}

diff --git a/omlx/cli.py b/omlx/cli.py index 10d2460f1..10cd0e4ab 100644 --- a/omlx/cli.py +++ b/omlx/cli.py @@ -168,19 +168,32 @@ def serve_command(args): # Bind the socket before importing/initializing the server. Uvicorn's # normal startup runs ASGI lifespan before binding host/port, which means # pinned models can be preloaded before a port conflict is detected. - print(f"Binding server at http://{settings.server.host}:{settings.server.port}") + bind_hosts = [h.strip() for h in settings.server.host.split(",") if h.strip()] + for h in bind_hosts: + print(f"Binding server at http://{h}:{settings.server.port}") # uvicorn does not support "trace" — map to "debug" for its internal logging uvicorn_level = "debug" if settings.server.log_level == "trace" else settings.server.log_level # Only show access logs at trace level show_access_log = settings.server.log_level == "trace" uvicorn_config = uvicorn.Config( "omlx.server:app", - host=settings.server.host, + host=bind_hosts[0], port=settings.server.port, log_level=uvicorn_level, access_log=show_access_log, ) - serve_socket = uvicorn_config.bind_socket() + # Bind a socket per host so an occupied port fails fast before model preload. + # uvicorn.Server.run(sockets=[...]) accepts a list and listens on all of them. + serve_sockets = [uvicorn_config.bind_socket()] + for h in bind_hosts[1:]: + extra_cfg = uvicorn.Config( + "omlx.server:app", + host=h, + port=settings.server.port, + log_level=uvicorn_level, + access_log=show_access_log, + ) + serve_sockets.append(extra_cfg.bind_socket()) try: # Import server and config after the port is known to be available. @@ -276,15 +289,17 @@ def serve_command(args): global_settings=settings, ) - print(f"Starting server at http://{settings.server.host}:{settings.server.port}") + for h in bind_hosts: + print(f"Starting server at http://{h}:{settings.server.port}") try: - uvicorn.Server(uvicorn_config).run(sockets=[serve_socket]) + uvicorn.Server(uvicorn_config).run(sockets=serve_sockets) except KeyboardInterrupt: pass finally: # Uvicorn closes sockets during normal shutdown; this covers failures # after bind succeeds but before the server takes ownership. - serve_socket.close() + for sock in serve_sockets: + sock.close() def launch_command(args, extra_args: list[str] | None = None): @@ -321,10 +336,11 @@ def _optional_str(value) -> str | None: host = args.host or settings.server.host port = args.port or settings.server.port - # 0.0.0.0 is a valid bind address but not a valid connect address. - # Fall back to localhost so launch can reach the server regardless - # of which interface it was bound to. - connect_host = host if host and host != "0.0.0.0" else "127.0.0.1" + # host may be a comma-separated list of bind addresses; pick the first one + # for connecting. Wildcard addresses (0.0.0.0, ::) are valid bind targets + # but not connectable — fall back to localhost in that case. + first_bind = [h.strip() for h in host.split(",") if h.strip()][0] if host else "" + connect_host = first_bind if first_bind not in ("", "0.0.0.0", "::") else "127.0.0.1" # Check if oMLX server is running base_url = f"http://{connect_host}:{port}" diff --git a/omlx/settings.py b/omlx/settings.py index 6181e732f..6b595c222 100644 --- a/omlx/settings.py +++ b/omlx/settings.py @@ -116,8 +116,9 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> ServerSettings: """Create from dictionary.""" + _host = data.get("host", data.get("bind_address", "127.0.0.1")) return cls( - host=data.get("host", data.get("bind_address", "127.0.0.1")), + host=", ".join(_host) if isinstance(_host, list) else str(_host), port=data.get("port", 8000), log_level=data.get("log_level", "info"), cors_origins=data.get("cors_origins", ["*"]), diff --git a/omlx/utils/network.py b/omlx/utils/network.py index 272cdc64f..3412617db 100644 --- a/omlx/utils/network.py +++ b/omlx/utils/network.py @@ -64,6 +64,25 @@ def is_valid_alias(value: str) -> bool: return is_valid_ip(value) +def is_valid_bind_host(value: str) -> bool: + """Return True if ``value`` is a valid host to bind a server socket to. + + Accepts any parseable IP address (including ``0.0.0.0`` and ``::``) and + valid DNS hostnames. Unlike :func:`is_valid_alias`, unspecified addresses + are allowed because they are legitimate bind targets. + """ + if not isinstance(value, str): + return False + value = value.strip() + if not value: + return False + try: + ipaddress.ip_address(value) + return True + except ValueError: + return is_valid_hostname(value) + + def _local_ipv4_addresses() -> list[str]: """Best-effort enumeration of non-loopback IPv4 addresses.