refactor(desktop): use port 0 for ephemeral port discovery instead of PortPool reservation
Replace the PortPool-based port reservation system (9120-9199 range) with OS-assigned ephemeral ports via --port 0. Before: Desktop probed a hardcoded port range, reserved ports in-process to close TOCTOU races, and passed the chosen port to the dashboard via CLI arg. After: Desktop spawns dashboard with --port 0, parses the actual port from a stdout announcement line (HERMES_DASHBOARD_READY port=<N>), and uses that for WebSocket connections. Changes: - web_server.py: add --port 0 support with SO_REUSEADDR pre-bind + announcement; add EADDRINUSE preflight for explicit ports - main.cjs: remove PortPool, PORT_FLOOR/CEILING, pickPort(), isPortAvailable(); add waitForDashboardPort() stdout parser - Delete port-pool.cjs and port-pool.test.cjs (106 lines removed) Net effect: eliminates the entire TOCTOU-mitigation reservation infrastructure and arbitrary port range constraints. OS handles port allocation natively.
This commit is contained in:
+106
-50
@@ -11568,6 +11568,62 @@ app.include_router(_dashboard_auth_router)
|
||||
mount_spa(app)
|
||||
|
||||
|
||||
def _read_bound_port(server: "uvicorn.Server", fallback: int) -> int:
|
||||
"""Read the OS-assigned port from a live uvicorn server socket.
|
||||
|
||||
After ``server.startup()`` the socket is bound. Returns the actual
|
||||
port so ephemeral (port-0) discovery works without a pre-bind TOCTOU.
|
||||
Falls back to *fallback* if the socket list is empty (shouldn't happen
|
||||
but guards against uvicorn internals changing).
|
||||
"""
|
||||
if server.servers and server.servers[0].sockets:
|
||||
return server.servers[0].sockets[0].getsockname()[1]
|
||||
return fallback
|
||||
|
||||
|
||||
def _maybe_open_browser(
|
||||
host: str, actual_port: int, open_browser: bool, initial_profile: str
|
||||
) -> None:
|
||||
"""Open the dashboard URL in the user's browser if appropriate.
|
||||
|
||||
Skips on headless Linux (no ``DISPLAY`` / ``WAYLAND_DISPLAY``) to avoid
|
||||
TUI browsers (links, lynx) that would SIGHUP the server process.
|
||||
Maps ``0.0.0.0`` / ``::`` binds to ``127.0.0.1`` so the browser opens
|
||||
a reachable URL.
|
||||
"""
|
||||
if not open_browser:
|
||||
return
|
||||
|
||||
import webbrowser
|
||||
|
||||
_has_display = (
|
||||
sys.platform != "linux"
|
||||
or bool(os.environ.get("DISPLAY"))
|
||||
or bool(os.environ.get("WAYLAND_DISPLAY"))
|
||||
)
|
||||
if not _has_display:
|
||||
_log.debug(
|
||||
"Skipping browser-open: no DISPLAY or WAYLAND_DISPLAY detected "
|
||||
"(headless Linux). Pass --no-open to suppress this detection."
|
||||
)
|
||||
return
|
||||
|
||||
_display_host = host if host not in ("0.0.0.0", "::") else "127.0.0.1"
|
||||
_open_url = f"http://{_display_host}:{actual_port}"
|
||||
if initial_profile:
|
||||
from urllib.parse import quote
|
||||
_open_url += f"/?profile={quote(initial_profile)}"
|
||||
|
||||
def _open():
|
||||
try:
|
||||
time.sleep(1.0)
|
||||
webbrowser.open(_open_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
|
||||
def start_server(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 9119,
|
||||
@@ -11650,60 +11706,60 @@ def start_server(
|
||||
|
||||
# Record the bound host so host_header_middleware can validate incoming
|
||||
# Host headers against it. Defends against DNS rebinding (GHSA-ppp5-vxwm-4cf7).
|
||||
# bound_port is also stashed so /api/pty can build the back-WS URL the
|
||||
# PTY child uses to publish events to the dashboard sidebar.
|
||||
app.state.bound_host = host
|
||||
app.state.bound_port = port
|
||||
|
||||
if open_browser:
|
||||
import webbrowser
|
||||
|
||||
# On headless Linux (no DISPLAY or WAYLAND_DISPLAY) some registered
|
||||
# browsers are TUI programs (links, lynx, www-browser) that try to
|
||||
# take over the terminal. That can send SIGHUP to the server process
|
||||
# and cause an immediate exit even though uvicorn bound successfully.
|
||||
# Skip the auto-open attempt on headless systems and let the user
|
||||
# open the URL manually. macOS and Windows are always considered
|
||||
# display-capable.
|
||||
_has_display = (
|
||||
sys.platform != "linux"
|
||||
or bool(os.environ.get("DISPLAY"))
|
||||
or bool(os.environ.get("WAYLAND_DISPLAY"))
|
||||
)
|
||||
|
||||
if _has_display:
|
||||
_open_url = f"http://{host}:{port}"
|
||||
if initial_profile:
|
||||
from urllib.parse import quote
|
||||
_open_url += f"/?profile={quote(initial_profile)}"
|
||||
|
||||
def _open():
|
||||
try:
|
||||
time.sleep(1.0)
|
||||
webbrowser.open(_open_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
else:
|
||||
_log.debug(
|
||||
"Skipping browser-open: no DISPLAY or WAYLAND_DISPLAY detected "
|
||||
"(headless Linux). Pass --no-open to suppress this detection."
|
||||
)
|
||||
|
||||
print(f" Hermes Web UI → http://{host}:{port}")
|
||||
# proxy_headers defaults to False so _ws_client_is_allowed sees the real
|
||||
# connection peer rather than X-Forwarded-For's rewritten value (which
|
||||
# would defeat the loopback gate when behind a reverse proxy). When the
|
||||
# OAuth gate is active we are explicitly running behind a TLS terminator
|
||||
# (Fly.io) and need X-Forwarded-Proto to decide cookie Secure flags, so
|
||||
# we flip proxy_headers on for that mode.
|
||||
uvicorn.run(
|
||||
# ── Start uvicorn with direct Server API ─────────────────────────
|
||||
# We use uvicorn.Server directly (not uvicorn.run) so we can split
|
||||
# startup from the main loop. After startup() the socket is actually
|
||||
# bound — we read the OS-assigned port from the live socket, print
|
||||
# HERMES_DASHBOARD_READY, open the browser, *then* serve.
|
||||
#
|
||||
# This eliminates the TOCTOU of the old pre-bind-then-close approach
|
||||
# (bind port 0 → close → uvicorn rebind): the socket is held by
|
||||
# uvicorn the entire time, so no other process can steal the port.
|
||||
#
|
||||
# For explicit non-zero ports, if the port is taken uvicorn catches
|
||||
# OSError inside create_server() and exits with a clear error — no
|
||||
# separate preflight probe needed.
|
||||
config = uvicorn.Config(
|
||||
app, host=host, port=port, log_level="warning",
|
||||
# proxy_headers defaults to False so _ws_client_is_allowed sees
|
||||
# the real connection peer rather than X-Forwarded-For's rewritten
|
||||
# value (which would defeat the loopback gate when behind a reverse
|
||||
# proxy). When the OAuth gate is active we are explicitly running
|
||||
# behind a TLS terminator (Fly.io) and need X-Forwarded-Proto to
|
||||
# decide cookie Secure flags, so we flip proxy_headers on for that
|
||||
# mode.
|
||||
proxy_headers=bool(app.state.auth_required),
|
||||
# Detect half-open WS connections (reverse-proxy 524, dropped tunnels)
|
||||
# within ~20-40s so WebSocketDisconnect fires the disconnect→reap path.
|
||||
# 20s stays under Cloudflare Tunnel's idle timeout, keeping it warm.
|
||||
# Detect half-open WS connections (reverse-proxy 524, dropped
|
||||
# tunnels) within ~20-40s so WebSocketDisconnect fires the
|
||||
# disconnect→reap path. 20s stays under Cloudflare Tunnel's idle
|
||||
# timeout, keeping it warm.
|
||||
ws_ping_interval=20.0,
|
||||
ws_ping_timeout=20.0,
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
async def _serve():
|
||||
# Split startup from main_loop so we can read the bound port
|
||||
# after the socket is live (ephemeral port discovery).
|
||||
if not config.loaded:
|
||||
config.load()
|
||||
server.lifespan = config.lifespan_class(config)
|
||||
with server.capture_signals():
|
||||
await server.startup()
|
||||
if server.should_exit:
|
||||
return
|
||||
|
||||
actual_port = _read_bound_port(server, fallback=port)
|
||||
app.state.bound_port = actual_port
|
||||
|
||||
print(f"HERMES_DASHBOARD_READY port={actual_port}", flush=True)
|
||||
print(f" Hermes Web UI → http://{host}:{actual_port}")
|
||||
_maybe_open_browser(host, actual_port, open_browser, initial_profile)
|
||||
|
||||
await server.main_loop()
|
||||
if server.started:
|
||||
await server.shutdown()
|
||||
|
||||
asyncio.run(_serve())
|
||||
|
||||
Reference in New Issue
Block a user