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:
+16
-26
@@ -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 {} });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {} });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {} });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */ } });
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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){
|
||||
|
||||
Reference in New Issue
Block a user