fix: NVR go2rtc WS mixed-content, K2 재연결 레이스, 시그널링 타임아웃 + WS 프록시 공통화

- nvr-app.html: go2rtc WS URL을 페이지 오리진 기준 상대경로로 (하드코딩된 ws://LAN IP가
  HTTPS 페이지에서 mixed-content로 막히던 문제)
- k2-app.html: 카메라 재연결 시 오래된 연결의 실패 콜백이 새 연결 상태를 덮어쓰던 레이스 수정
- routes-k2.ts: K2 시그널링 fetch에 타임아웃 추가(프린터 응답없음 시 무한대기 방지)
- nvr.ts/routes-comfyui.ts/routes-android.ts에 중복돼있던 raw WebSocket 업그레이드
  프록시(connect→pipe→cleanup)를 ws-proxy-util.ts의 공통 헬퍼로 통합
This commit is contained in:
kim
2026-09-19 23:46:53 +09:00
parent d65e8d958d
commit 5eed244340
7 changed files with 111 additions and 90 deletions
+16 -26
View File
@@ -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 {} });
});
}
+22 -32
View File
@@ -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 {} });
});
}
+19 -29
View File
@@ -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 {} });
});
}
+1
View File
@@ -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);
+48
View File
@@ -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 */ } });
}
+2 -2
View File
@@ -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();
+3 -1
View File
@@ -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){