diff --git a/src/gateway/routes/nvr.ts b/src/gateway/routes/nvr.ts index 99ad7bc..46da210 100644 --- a/src/gateway/routes/nvr.ts +++ b/src/gateway/routes/nvr.ts @@ -1,8 +1,8 @@ import { Express, Request, Response } from 'express'; import { spawn, ChildProcess } from 'child_process'; import http from 'http'; -import net from 'net'; import { getConfig } from '../../config/config.js'; +import { bridgeWsToBackend } from './ws-proxy-util.js'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { Cam: OnvifCam } = require('onvif'); @@ -906,31 +906,21 @@ export function attachGo2rtcWsProxy(server: http.Server, getSessionUserFromUpgra return; } - const backendSocket = net.connect({ host: GO2RTC_HOST, port: GO2RTC_PORT }, () => { - const qs = url.includes('?') ? url.slice(url.indexOf('?')) : ''; - const backendHost = `${GO2RTC_HOST}:${GO2RTC_PORT}`; - const headerLines = [`${req.method} /api/ws${qs} HTTP/1.1`]; - for (let i = 0; i < req.rawHeaders.length; i += 2) { - const name = req.rawHeaders[i]; - if (/^host$/i.test(name)) { headerLines.push(`Host: ${backendHost}`); continue; } - if (/^origin$/i.test(name)) { headerLines.push(`Origin: http://${backendHost}`); continue; } - headerLines.push(`${name}: ${req.rawHeaders[i + 1]}`); - } - backendSocket.write(headerLines.join('\r\n') + '\r\n\r\n'); - if (head && head.length) backendSocket.write(head); - - clientSocket.pipe(backendSocket); - backendSocket.pipe(clientSocket); - - const cleanup = () => { - try { clientSocket.destroy(); } catch {} - try { backendSocket.destroy(); } catch {} - }; - clientSocket.on('close', cleanup); - clientSocket.on('error', cleanup); - backendSocket.on('close', cleanup); - backendSocket.on('error', cleanup); + const qs = url.includes('?') ? url.slice(url.indexOf('?')) : ''; + const backendHost = `${GO2RTC_HOST}:${GO2RTC_PORT}`; + const headerLines = [`${req.method} /api/ws${qs} HTTP/1.1`]; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const name = req.rawHeaders[i]; + if (/^host$/i.test(name)) { headerLines.push(`Host: ${backendHost}`); continue; } + if (/^origin$/i.test(name)) { headerLines.push(`Origin: http://${backendHost}`); continue; } + headerLines.push(`${name}: ${req.rawHeaders[i + 1]}`); + } + bridgeWsToBackend({ + clientSocket, + head, + backendHost: GO2RTC_HOST, + backendPort: GO2RTC_PORT, + requestHead: headerLines.join('\r\n') + '\r\n\r\n', }); - backendSocket.on('error', () => { try { clientSocket.destroy(); } catch {} }); }); } diff --git a/src/gateway/routes/routes-android.ts b/src/gateway/routes/routes-android.ts index 32d89cd..db9239e 100644 --- a/src/gateway/routes/routes-android.ts +++ b/src/gateway/routes/routes-android.ts @@ -6,6 +6,7 @@ import path from 'path'; import { execFile } from 'child_process'; import { WebSocketServer } from 'ws'; import { getUserWorkspace } from '../../config/config'; +import { bridgeWsToBackend } from './ws-proxy-util.js'; // ── Android emulator sessions (Docker + noVNC) ────────────────────────────── // One container per session, running budtmo/docker-android with noVNC exposed @@ -383,39 +384,28 @@ export function attachAndroidWsProxy(server: http.Server, getSessionUser: (req: return; } - const backendSocket = net.connect({ host: '127.0.0.1', port: s.vncPort }, () => { - // websockify inside the container only recognizes the WS upgrade at the - // fixed path /websockify (everything else on this port is its static - // file server) — rewrite regardless of the external proxy path. - const headerLines = [`${req.method} /websockify HTTP/1.1`]; - for (let i = 0; i < req.rawHeaders.length; i += 2) { - const key = req.rawHeaders[i]; - const val = key.toLowerCase() === 'host' ? `127.0.0.1:${s.vncPort}` : req.rawHeaders[i + 1]; - headerLines.push(`${key}: ${val}`); - } - backendSocket.write(headerLines.join('\r\n') + '\r\n\r\n'); - if (head && head.length) backendSocket.write(head); - - s.viewerConns.add(clientSocket); - - clientSocket.pipe(backendSocket); - backendSocket.pipe(clientSocket); - - const cleanup = () => { - s.viewerConns.delete(clientSocket); - try { clientSocket.destroy(); } catch {} - try { backendSocket.destroy(); } catch {} - // No idle-based auto-kill anymore — a session with zero viewers stays - // up until HARD_TTL_MS or an explicit /reset. The previous 5-minute - // idle kill wiped out in-progress work (installed apps, logins) the - // moment the viewer tab lost focus or the user looked away. - }; - clientSocket.on('close', cleanup); - clientSocket.on('error', cleanup); - backendSocket.on('close', cleanup); - backendSocket.on('error', cleanup); + // websockify inside the container only recognizes the WS upgrade at the + // fixed path /websockify (everything else on this port is its static + // file server) — rewrite regardless of the external proxy path. + const headerLines = [`${req.method} /websockify HTTP/1.1`]; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const key = req.rawHeaders[i]; + const val = key.toLowerCase() === 'host' ? `127.0.0.1:${s.vncPort}` : req.rawHeaders[i + 1]; + headerLines.push(`${key}: ${val}`); + } + bridgeWsToBackend({ + clientSocket, + head, + backendHost: '127.0.0.1', + backendPort: s.vncPort, + requestHead: headerLines.join('\r\n') + '\r\n\r\n', + onConnected: () => { s.viewerConns.add(clientSocket); }, + // No idle-based auto-kill anymore — a session with zero viewers stays + // up until HARD_TTL_MS or an explicit /reset. The previous 5-minute + // idle kill wiped out in-progress work (installed apps, logins) the + // moment the viewer tab lost focus or the user looked away. + onCleanup: () => { s.viewerConns.delete(clientSocket); }, }); - backendSocket.on('error', () => { try { clientSocket.destroy(); } catch {} }); }); } diff --git a/src/gateway/routes/routes-comfyui.ts b/src/gateway/routes/routes-comfyui.ts index 3042485..787bddf 100644 --- a/src/gateway/routes/routes-comfyui.ts +++ b/src/gateway/routes/routes-comfyui.ts @@ -1,6 +1,6 @@ import express from 'express'; import http from 'http'; -import net from 'net'; +import { bridgeWsToBackend } from './ws-proxy-util.js'; // Reverse-proxies ComfyUI's own web UI (node-graph editor) into the studio app, // so "the real ComfyUI screen" can live under our own domain instead of a @@ -97,34 +97,24 @@ export function attachComfyUIWsProxy(server: http.Server, getSessionUserFromUpgr return; } - const backendSocket = net.connect({ host: COMFY_HOST, port: COMFY_PORT }, () => { - // ComfyUI's own WS endpoint is fixed at /ws — rewrite the request line's - // path back to that (dropping our /api/comfyui prefix) while preserving - // the query string (?clientId=...) ComfyUI's frontend appends. - const qs = url.includes('?') ? url.slice(url.indexOf('?')) : ''; - const headerLines = [`${req.method} /ws${qs} HTTP/1.1`]; - // Host deliberately left as the original browser-sent value here too — - // see the matching comment on the HTTP proxy above (ComfyUI 403s when - // Host and Origin don't match, and this is a raw TCP connect so nothing - // needs it to equal the actual backend address). - for (let i = 0; i < req.rawHeaders.length; i += 2) { - headerLines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`); - } - backendSocket.write(headerLines.join('\r\n') + '\r\n\r\n'); - if (head && head.length) backendSocket.write(head); - - clientSocket.pipe(backendSocket); - backendSocket.pipe(clientSocket); - - const cleanup = () => { - try { clientSocket.destroy(); } catch {} - try { backendSocket.destroy(); } catch {} - }; - clientSocket.on('close', cleanup); - clientSocket.on('error', cleanup); - backendSocket.on('close', cleanup); - backendSocket.on('error', cleanup); + // ComfyUI's own WS endpoint is fixed at /ws — rewrite the request line's + // path back to that (dropping our /api/comfyui prefix) while preserving + // the query string (?clientId=...) ComfyUI's frontend appends. + const qs = url.includes('?') ? url.slice(url.indexOf('?')) : ''; + const headerLines = [`${req.method} /ws${qs} HTTP/1.1`]; + // Host deliberately left as the original browser-sent value here too — + // see the matching comment on the HTTP proxy above (ComfyUI 403s when + // Host and Origin don't match, and this is a raw TCP connect so nothing + // needs it to equal the actual backend address). + for (let i = 0; i < req.rawHeaders.length; i += 2) { + headerLines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`); + } + bridgeWsToBackend({ + clientSocket, + head, + backendHost: COMFY_HOST, + backendPort: COMFY_PORT, + requestHead: headerLines.join('\r\n') + '\r\n\r\n', }); - backendSocket.on('error', () => { try { clientSocket.destroy(); } catch {} }); }); } diff --git a/src/gateway/routes/routes-k2.ts b/src/gateway/routes/routes-k2.ts index fb2c5f4..6b31031 100644 --- a/src/gateway/routes/routes-k2.ts +++ b/src/gateway/routes/routes-k2.ts @@ -21,6 +21,7 @@ export function registerK2Routes(app: Express): void { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body, + signal: AbortSignal.timeout(10_000), }); const text = await r.text(); res.status(r.status).type('text/plain').send(text); diff --git a/src/gateway/routes/ws-proxy-util.ts b/src/gateway/routes/ws-proxy-util.ts new file mode 100644 index 0000000..18e5fc6 --- /dev/null +++ b/src/gateway/routes/ws-proxy-util.ts @@ -0,0 +1,48 @@ +import net from 'net'; +import type { Duplex } from 'stream'; + +// Shared tail of the hand-rolled raw-byte WebSocket upgrade proxies in +// nvr.ts (go2rtc), routes-comfyui.ts, and routes-android.ts. Each of those +// still does its own upgrade-path matching, auth check, and request-head +// construction (their backend path/Host/Origin rewrite rules differ enough +// that unifying those too would obscure more than it'd save) — this only +// covers the identical connect → write → pipe-both-ways → cleanup-on-close +// sequence that used to be copy-pasted three times. +export function bridgeWsToBackend(opts: { + // The upgrade event's socket is typed as the generic Duplex by @types/node + // (it's really always a net.Socket at runtime) — kept loose here since all + // we do with it is pipe/on/destroy, which Duplex already covers. + clientSocket: Duplex; + head: Buffer; + backendHost: string; + backendPort: number; + // Full raw request text the caller already built: "METHOD path HTTP/1.1\r\n + // Header: value\r\n...\r\n\r\n" — kept as a caller-built string rather than + // a headers map because each proxy's Host/Origin/path rewrite rules differ. + requestHead: string; + // Android's proxy tracks live viewer sockets on its session object; the + // other two have nothing to do here — both hooks are optional. + onConnected?: (backendSocket: net.Socket) => void; + onCleanup?: () => void; +}): void { + const { clientSocket, head, backendHost, backendPort, requestHead, onConnected, onCleanup } = opts; + const backendSocket = net.connect({ host: backendHost, port: backendPort }, () => { + backendSocket.write(requestHead); + if (head && head.length) backendSocket.write(head); + onConnected?.(backendSocket); + + clientSocket.pipe(backendSocket); + backendSocket.pipe(clientSocket); + + const cleanup = () => { + onCleanup?.(); + try { clientSocket.destroy(); } catch { /* noop */ } + try { backendSocket.destroy(); } catch { /* noop */ } + }; + clientSocket.on('close', cleanup); + clientSocket.on('error', cleanup); + backendSocket.on('close', cleanup); + backendSocket.on('error', cleanup); + }); + backendSocket.on('error', () => { try { clientSocket.destroy(); } catch { /* noop */ } }); +} diff --git a/web-ui/html/k2-app.html b/web-ui/html/k2-app.html index b7181a6..318976b 100644 --- a/web-ui/html/k2-app.html +++ b/web-ui/html/k2-app.html @@ -102,11 +102,11 @@ function connectCam(){ if(res.type==='answer' && pc===camPC){ pc.setRemoteDescription(new RTCSessionDescription(res)); } - }).catch(()=>{ setStatus('연결 실패'); }); + }).catch(()=>{ if(pc===camPC) setStatus('연결 실패'); }); } }; pc.addTransceiver('video',{direction:'sendrecv'}); - pc.createOffer().then(d=>pc.setLocalDescription(d)).catch(()=>{ setStatus('연결 실패'); }); + pc.createOffer().then(d=>pc.setLocalDescription(d)).catch(()=>{ if(pc===camPC) setStatus('연결 실패'); }); } function reconnectCam(){ connectCam(); } connectCam(); diff --git a/web-ui/html/nvr-app.html b/web-ui/html/nvr-app.html index 2bcadb9..afb1cab 100644 --- a/web-ui/html/nvr-app.html +++ b/web-ui/html/nvr-app.html @@ -296,7 +296,9 @@ let currentLiveStream=0; let currentLiveMode='mjpeg'; // 'mjpeg' | 'go2rtc' — go2rtcSrc 있는 카메라는 video-stream(WebRTC→MSE 자동폴백)으로 대역폭 절약 // go2rtc(1985)에 브라우저가 직접 붙으면 Origin 불일치로 거부당해(CheckOrigin) 게이트웨이의 // 프록시(/api/nvr/go2rtc-ws)를 거친다 — 같은 오리진으로 붙게 한 뒤 서버에서 Origin을 다시 씀. -const GO2RTC_WS='ws://192.168.0.5:18789/api/nvr/go2rtc-ws'; +// 반드시 현재 페이지 오리진 기준 상대경로로 만들어야 함 — LAN IP를 ws://로 하드코딩하면 +// 이 페이지를 https://ai.applecherry.net으로 열었을 때 mixed-content로 막힘(K2 카메라와 동일 패턴). +const GO2RTC_WS=(location.protocol==='https:'?'wss://':'ws://')+location.host+'/api/nvr/go2rtc-ws'; // 채널별로 강제 지정된 화질(preferredStream)이 있으면 그걸 쓰고, 없으면 null(화면 기본값 사용). // 신호 약한 카메라는 메인이 계속 끊겨서 서브로 고정해두는 용도(2026-07-21). function chnStreamOverride(channelId){