diff --git a/.env.example b/.env.example index 00f2e655..65d6f493 100644 --- a/.env.example +++ b/.env.example @@ -42,8 +42,8 @@ ALLOWED_GITHUB_USERS= # 0.0.0.0/0; inspect the Docker/ingress network and list only its gateway CIDR. TRUSTED_PROXY_CIDRS= -# Public WebSocket URL the Next.js client connects to. -NEXT_PUBLIC_WS_URL=wss://dashboard.example.com/ui/ws +# Browser controls use same-origin /ui/ws. Route that path to the server with +# WebSocket upgrades enabled; there is no build-time dashboard URL to keep in sync. # Optional legacy shared agent token. Leave empty to disable; the server # rejects an empty AGENT_SECRET so a default value cannot become a diff --git a/docker-compose.yml b/docker-compose.yml index d9e3c3cc..a113a33a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -141,8 +141,6 @@ services: ports: # Loopback only — reached via nginx proxy_pass to 127.0.0.1:3000. - "127.0.0.1:3000:3000" - environment: - - NEXT_PUBLIC_WS_URL=${NEXT_PUBLIC_WS_URL:-wss://dashboard.example.com/ui/ws} restart: unless-stopped networks: - shellfleet_net diff --git a/web/src/components/providers/WebSocketProvider.tsx b/web/src/components/providers/WebSocketProvider.tsx index 1c1be471..f5ab5307 100644 --- a/web/src/components/providers/WebSocketProvider.tsx +++ b/web/src/components/providers/WebSocketProvider.tsx @@ -40,26 +40,15 @@ const CONNECT_TIMEOUT_MS = 12_000; const DIRECTORY_SYNC_INTERVAL_MS = 15_000; const DIRECTORY_STALE_AFTER_MS = 45_000; -// Resolve the WS URL once on import. Order of precedence: -// 1. NEXT_PUBLIC_WS_URL — explicit override baked at build time, used -// when web and server live on different hosts. -// 2. window.location — same-origin /ui/ws, derived per request. This -// makes a fresh deploy "just work" wherever it's hosted, no env -// var or rebuild needed. -// 3. SSR placeholder — never actually reached by the browser, but -// keeps TypeScript happy and avoids accidental crashes if the -// provider is ever evaluated outside a browser. +// Resolve at connection time, in the browser, from the page's current +// origin. NEXT_PUBLIC_* values are frozen into Next.js client bundles at build +// time, so a runtime container environment variable can silently point the +// dashboard at a stale or placeholder host. ShellFleet deliberately exposes +// /ui/ws on the same public origin as the dashboard and API. function resolveWsUrl(): string { - if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_WS_URL) { - return process.env.NEXT_PUBLIC_WS_URL; - } - if (typeof window !== 'undefined') { - const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${proto}//${window.location.host}/ui/ws`; - } - return 'wss://dashboard.example.com/ui/ws'; + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${window.location.host}/ui/ws`; } -const WS_URL = resolveWsUrl(); export function WebSocketProvider({ children }: { children: React.ReactNode }) { const { status } = useSession(); @@ -207,7 +196,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { let ws: WebSocket; try { - ws = new WebSocket(WS_URL); + ws = new WebSocket(resolveWsUrl()); } catch (error) { console.error('[shellfleet] failed to create UI WebSocket:', error); scheduleReconnect(); @@ -255,7 +244,12 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { }, DIRECTORY_SYNC_INTERVAL_MS); }; - ws.onclose = () => { + ws.onclose = (event) => { + if (!disposed && event.code !== 1000) { + console.warn( + `[shellfleet] UI WebSocket closed (code=${event.code}, reason=${event.reason || 'none'})`, + ); + } if (!disposed) retire(ws, false); }; diff --git a/web/src/components/providers/__tests__/WebSocketProvider.test.tsx b/web/src/components/providers/__tests__/WebSocketProvider.test.tsx index de799233..25db00d6 100644 --- a/web/src/components/providers/__tests__/WebSocketProvider.test.tsx +++ b/web/src/components/providers/__tests__/WebSocketProvider.test.tsx @@ -175,6 +175,15 @@ describe('WebSocketProvider', () => { expect(MockWebSocket.instances[0].url).toBe('ws://localhost:3000/ui/ws'); }); + it('does not let a stale build-time URL override the dashboard origin', () => { + vi.stubEnv('NEXT_PUBLIC_WS_URL', 'wss://stale.example.com/ui/ws'); + + renderProvider(); + + expect(MockWebSocket.instances).toHaveLength(1); + expect(MockWebSocket.instances[0].url).toBe('ws://localhost:3000/ui/ws'); + }); + it('ignores late messages from a retired socket generation', () => { renderProvider(); const first = MockWebSocket.instances[0];