From f8603692cd78c460cc120d2a0daa2ac42051c0b9 Mon Sep 17 00:00:00 2001 From: fqx Date: Tue, 2 Jun 2026 15:55:13 +0800 Subject: [PATCH 1/3] fix: support comma-separated bind addresses in host setting Fixes the regression introduced when #1526 switched from uvicorn.run() to uvicorn.Config + bind_socket() + Server.run(sockets=[...]): the old approach silently accepted a list of hosts via asyncio.create_server(), but bind_socket() calls sock.bind((host, port)) which requires a string. Changes: - settings.py: normalise legacy YAML list values (host: [a, b]) to a comma-separated string on load so the web UI and server both see a str - cli.py (serve_command): parse comma-separated hosts, bind one socket per address before model preload (preserving the fail-fast guarantee from #1526), then pass all sockets to Server.run() - cli.py (launch_command): pick the first bind address as the connect host, normalising wildcards (0.0.0.0, ::) to 127.0.0.1 - utils/network.py: add is_valid_bind_host() which accepts 0.0.0.0 and :: unlike is_valid_alias() which rejects unspecified addresses - admin/routes.py: validate each comma-separated part on save, returning a readable 400 before an invalid value can crash the server on restart - admin/static/js/dashboard.js: extract .msg from Pydantic error objects instead of joining raw objects (fixes [object Object] display), two sites - admin/i18n/{en,zh,ja,ko,zh-TW}.json: add host_multi_hint string and drop "(caution)" from host_custom label now that multi-address is valid - admin/templates/dashboard/_settings.html: show host_multi_hint hint below the custom host input when the custom mode is active Co-Authored-By: Claude Sonnet 4.6 --- omlx/admin/i18n/en.json | 3 +- omlx/admin/i18n/ja.json | 3 +- omlx/admin/i18n/ko.json | 3 +- omlx/admin/i18n/zh-TW.json | 3 +- omlx/admin/i18n/zh.json | 3 +- omlx/admin/routes.py | 11 ++++++ omlx/admin/static/js/dashboard.js | 4 +-- omlx/admin/templates/dashboard/_settings.html | 2 ++ omlx/cli.py | 36 +++++++++++++------ omlx/settings.py | 3 +- omlx/utils/network.py | 19 ++++++++++ 11 files changed, 72 insertions(+), 18 deletions(-) diff --git a/omlx/admin/i18n/en.json b/omlx/admin/i18n/en.json index 54c253de5..c59867305 100644 --- a/omlx/admin/i18n/en.json +++ b/omlx/admin/i18n/en.json @@ -276,8 +276,9 @@ "settings.server.host": "Host", "settings.server.host_localhost": "Localhost only (127.0.0.1)", "settings.server.host_public": "Open to all (0.0.0.0)", - "settings.server.host_custom": "Custom (caution)", + "settings.server.host_custom": "Custom", "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..9dee164ba 100644 --- a/omlx/admin/i18n/ja.json +++ b/omlx/admin/i18n/ja.json @@ -276,8 +276,9 @@ "settings.server.host": "ホスト", "settings.server.host_localhost": "ローカルのみ (127.0.0.1)", "settings.server.host_public": "全体公開 (0.0.0.0)", - "settings.server.host_custom": "カスタム(注意)", + "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..cd65aac10 100644 --- a/omlx/admin/i18n/ko.json +++ b/omlx/admin/i18n/ko.json @@ -276,8 +276,9 @@ "settings.server.host": "호스트", "settings.server.host_localhost": "로컬만 (127.0.0.1)", "settings.server.host_public": "전체 공개 (0.0.0.0)", - "settings.server.host_custom": "사용자 지정 (주의)", + "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..ba2cf92cf 100644 --- a/omlx/admin/i18n/zh-TW.json +++ b/omlx/admin/i18n/zh-TW.json @@ -276,8 +276,9 @@ "settings.server.host": "主機", "settings.server.host_localhost": "僅本機(127.0.0.1)", "settings.server.host_public": "對外開放(0.0.0.0)", - "settings.server.host_custom": "自訂(謹慎)", + "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..0a6ea1bf7 100644 --- a/omlx/admin/i18n/zh.json +++ b/omlx/admin/i18n/zh.json @@ -276,8 +276,9 @@ "settings.server.host": "主机", "settings.server.host_localhost": "仅本地(127.0.0.1)", "settings.server.host_public": "对外开放(0.0.0.0)", - "settings.server.host_custom": "自定义(谨慎)", + "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..fda8fb931 100644 --- a/omlx/admin/templates/dashboard/_settings.html +++ b/omlx/admin/templates/dashboard/_settings.html @@ -235,6 +235,8 @@

{{ t('settings.gl type="text" x-model="globalSettings.server.host" placeholder="{{ t('settings.server.host_placeholder') }}" class="w-full sm:w-40 px-3 py-2 text-sm text-right border border-neutral-200 rounded-lg focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-all"> +

{{ 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. From 063b16fbb90e8bb9cc4b10f0c2a355e5497f546d Mon Sep 17 00:00:00 2001 From: fqx Date: Tue, 2 Jun 2026 15:59:57 +0800 Subject: [PATCH 2/3] revert: restore "(caution)" label on host_custom in all languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caution warning is still valid — manually entering a bind address can inadvertently expose the server, and supporting multiple addresses makes that risk more rather than less relevant. --- omlx/admin/i18n/en.json | 2 +- omlx/admin/i18n/ja.json | 2 +- omlx/admin/i18n/ko.json | 2 +- omlx/admin/i18n/zh-TW.json | 2 +- omlx/admin/i18n/zh.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/omlx/admin/i18n/en.json b/omlx/admin/i18n/en.json index c59867305..ffb0953de 100644 --- a/omlx/admin/i18n/en.json +++ b/omlx/admin/i18n/en.json @@ -276,7 +276,7 @@ "settings.server.host": "Host", "settings.server.host_localhost": "Localhost only (127.0.0.1)", "settings.server.host_public": "Open to all (0.0.0.0)", - "settings.server.host_custom": "Custom", + "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", diff --git a/omlx/admin/i18n/ja.json b/omlx/admin/i18n/ja.json index 9dee164ba..6145d83b2 100644 --- a/omlx/admin/i18n/ja.json +++ b/omlx/admin/i18n/ja.json @@ -276,7 +276,7 @@ "settings.server.host": "ホスト", "settings.server.host_localhost": "ローカルのみ (127.0.0.1)", "settings.server.host_public": "全体公開 (0.0.0.0)", - "settings.server.host_custom": "カスタム", + "settings.server.host_custom": "カスタム(注意)", "settings.server.host_placeholder": "例: 192.168.1.100", "settings.server.host_multi_hint": "複数アドレスはカンマで区切る", "settings.server.port": "ポート", diff --git a/omlx/admin/i18n/ko.json b/omlx/admin/i18n/ko.json index cd65aac10..afc2a192c 100644 --- a/omlx/admin/i18n/ko.json +++ b/omlx/admin/i18n/ko.json @@ -276,7 +276,7 @@ "settings.server.host": "호스트", "settings.server.host_localhost": "로컬만 (127.0.0.1)", "settings.server.host_public": "전체 공개 (0.0.0.0)", - "settings.server.host_custom": "사용자 지정", + "settings.server.host_custom": "사용자 지정 (주의)", "settings.server.host_placeholder": "예: 192.168.1.100", "settings.server.host_multi_hint": "여러 주소는 쉼표로 구분", "settings.server.port": "포트", diff --git a/omlx/admin/i18n/zh-TW.json b/omlx/admin/i18n/zh-TW.json index ba2cf92cf..43af9a9d0 100644 --- a/omlx/admin/i18n/zh-TW.json +++ b/omlx/admin/i18n/zh-TW.json @@ -276,7 +276,7 @@ "settings.server.host": "主機", "settings.server.host_localhost": "僅本機(127.0.0.1)", "settings.server.host_public": "對外開放(0.0.0.0)", - "settings.server.host_custom": "自訂", + "settings.server.host_custom": "自訂(謹慎)", "settings.server.host_placeholder": "例如 192.168.1.100", "settings.server.host_multi_hint": "多個位址以逗號分隔", "settings.server.port": "連接埠", diff --git a/omlx/admin/i18n/zh.json b/omlx/admin/i18n/zh.json index 0a6ea1bf7..ede1c03df 100644 --- a/omlx/admin/i18n/zh.json +++ b/omlx/admin/i18n/zh.json @@ -276,7 +276,7 @@ "settings.server.host": "主机", "settings.server.host_localhost": "仅本地(127.0.0.1)", "settings.server.host_public": "对外开放(0.0.0.0)", - "settings.server.host_custom": "自定义", + "settings.server.host_custom": "自定义(谨慎)", "settings.server.host_placeholder": "例如 192.168.1.100", "settings.server.host_multi_hint": "多个地址用逗号分隔", "settings.server.port": "端口", From 0ac279845f86b431dcb4035167c463bd2f2e5cd7 Mon Sep 17 00:00:00 2001 From: fqx Date: Tue, 2 Jun 2026 16:02:47 +0800 Subject: [PATCH 3/3] fix: put host_multi_hint inside right-side flex column, below the input --- omlx/admin/templates/dashboard/_settings.html | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/omlx/admin/templates/dashboard/_settings.html b/omlx/admin/templates/dashboard/_settings.html index fda8fb931..a702c75b3 100644 --- a/omlx/admin/templates/dashboard/_settings.html +++ b/omlx/admin/templates/dashboard/_settings.html @@ -223,20 +223,22 @@

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

-
- - +
+
+ + +
+

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

-

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