fix: 밀링캠 PTZ가 만료된 세션 토큰 때문에 조용히 죽어있던 문제
옥토프린트(octoprint-mill) 웹캠 오버레이(control.jinja2, 컨테이너 안에 정적 스크립트로
박혀있음, homeclaw repo엔 없음)가 /api/nvr/ptz를 부를 때 로그인 세션 토큰을 그대로 박아
써왔다. 세션은 7일 지나면 만료되는데(SESSION_MAX_AGE_MS) 09-17에 만든 토큰이 09-24경
만료되며 PTZ 전체(상하좌우 다)가 401로 죽었다 — 오버레이 JS가 fetch 에러를 그냥 삼켜서
화면엔 아무 표시도 없었다. 사용자는 틸트만 시도해서 '틸팅이 안 된다'로 신고했지만 실은
전 방향이 죽어 있었다(2026-09-28 실측).
고친 내용:
- gateway.auth_token(vault 저장, 안 만료되는 고정 토큰)을 새로 발급하고, /api/nvr/ptz와
/api/nvr/mjpeg-health 두 라우트가 이걸 세션 대신/추가로 받아들이게 함(nvr.ts,
requireGatewayAuth 적용).
- 그런데 requireGatewayAuth의 Bearer/X-Gateway-Token 분기는 사실 이전부터 죽은 코드였다 —
이보다 먼저 도는 전역 인증 미들웨어(server.ts)가 activeSessions(로그인 세션)만 알고
gateway.auth_token 자체는 몰라서 그 앞에서 무조건 401을 내고 있었다. 전역 미들웨어에
/api/nvr/ptz, /api/nvr/mjpeg-health 두 경로만 gateway.auth_token도 인정하는 예외를
추가(req.user는 안 심음 — 다른 라우트의 role 체크에 영향 없게).
- auth_token은 config.json에 평문으로 안 두고 다른 시크릿처럼 vault:gateway.auth_token
참조로 저장(resolveSecret으로 해석).
옥토프린트 컨테이너의 control.jinja2도 새 토큰으로 패치(런타임 파일이라 이 커밋엔 안
잡힘 — 백업은 옥토서버 /home/kim/octoprint-mill-overlay/). 실제 카메라(ONVIF)까지
Up/Down/Left/Right 전부 {ok:true} 확인.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
"auth": {
|
||||
"enabled": true,
|
||||
"multiUser": true
|
||||
}
|
||||
},
|
||||
"auth_token": "vault:gateway.auth_token"
|
||||
},
|
||||
"ollama": {
|
||||
"endpoint": "http://localhost:11434",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Express, Request, Response } from 'express';
|
||||
import { Express, Request, Response, NextFunction } from 'express';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import http from 'http';
|
||||
import { getConfig } from '../../config/config.js';
|
||||
@@ -201,7 +201,11 @@ export async function nvrFetch(nvrPath: string, opts: { method?: string; body?:
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export function registerNvrRoutes(app: Express): void {
|
||||
export function registerNvrRoutes(
|
||||
app: Express,
|
||||
deps: { requireGatewayAuth: (req: Request, res: Response, next: NextFunction) => void }
|
||||
): void {
|
||||
const { requireGatewayAuth } = deps;
|
||||
app.get('/api/nvr/config', (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
@@ -837,9 +841,14 @@ export function registerNvrRoutes(app: Express): void {
|
||||
'FocusNear': 13, 'FocusFar': 14,
|
||||
'Stop': 15,
|
||||
};
|
||||
app.post('/api/nvr/ptz', async (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
// requireGatewayAuth: 세션 쿠키 OR gateway.auth_token(Bearer/X-Gateway-Token) OR localhost.
|
||||
// 옥토프린트 오버레이(control.jinja2, 컨테이너 안에 정적 토큰으로 박혀있음)가 이 엔드포인트를
|
||||
// 부르는데, 예전엔 로그인 세션 토큰을 그대로 박아놔서 7일 뒤 세션이 만료되며 PTZ 전체가
|
||||
// 조용히 죽었다(2026-09-28 실측 — "틸팅이 안 되네"로 신고됐지만 실은 상하좌우 다 401이었음,
|
||||
// 오버레이 JS의 fetch().catch(()=>{})가 에러를 삼켜서 겉으론 티가 안 남). auth_token은
|
||||
// 안 만료되는 고정 토큰이라 이 용도(브라우저 세션이 아니라 정적 페이지에 박아두는 자격증명)에
|
||||
// 맞다.
|
||||
app.post('/api/nvr/ptz', requireGatewayAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { channel, command, speed } = req.body || {};
|
||||
const ch = Number(channel);
|
||||
@@ -873,9 +882,7 @@ export function registerNvrRoutes(app: Express): void {
|
||||
// 멈춰있으면 그 프로세스만 죽여서 go2rtc가 새로 띄우게 한다. 아무도 안 보고 있어서 프로듀서가
|
||||
// 아예 없으면 아무 것도 안 함(불필요하게 인코딩을 깨우지 않음). 이미 정상 수신 중이면 그대로 둠
|
||||
// (여러 명이 동시에 봐도 공유 스트림이라 매번 재시작하지 않음).
|
||||
app.post('/api/nvr/mjpeg-health', async (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
app.post('/api/nvr/mjpeg-health', requireGatewayAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const src = String(req.body?.src || 'cam_mjpeg').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const container = String(req.body?.container || 'go2rtc-printroom').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
|
||||
+13
-2
@@ -2652,6 +2652,17 @@ app.use((req, _res, next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 밀링캠(옥토프린트 오버레이) PTZ — control.jinja2가 컨테이너 안에 정적 gateway.auth_token을
|
||||
// 박아두고 브라우저에서 직접 이 도메인으로 fetch한다(로그인 세션이 없음). 예전엔 로그인 세션
|
||||
// 토큰을 박아놨다가 7일 뒤 만료되어 PTZ 전체가 조용히 죽었다(2026-09-28). 이 위의 activeSessions
|
||||
// 체크는 세션 토큰만 알아서 여기까지 못 왔었는데, gateway.auth_token(설정된 경우)도 유효한
|
||||
// 자격증명으로 인정 — req.user는 안 심는다(진짜 로그인 사용자가 아니므로 다른 라우트의 role
|
||||
// 체크에 실수로 걸리지 않게).
|
||||
if (req.path === '/api/nvr/ptz' || req.path === '/api/nvr/mjpeg-health') {
|
||||
const cfg = getConfig().getConfig() as any;
|
||||
const configuredToken = String(getConfig().resolveSecret(cfg?.gateway?.auth_token) || '').trim();
|
||||
if (configuredToken && token === configuredToken) return next();
|
||||
}
|
||||
// HLS 프록시: 브라우저 hls.js가 헤더 추가 불가 → 도메인 화이트리스트로 보안 유지
|
||||
if (req.path === '/api/traffic/hls-proxy') return next();
|
||||
// Static assets (CSS, JS, images, fonts) are public — but token check runs first above
|
||||
@@ -3553,7 +3564,7 @@ async function runTaskHeartbeat(): Promise<void> {
|
||||
// ── Settings routes (extracted to routes/settings.ts) ──────────────────────────
|
||||
registerSettingsRoutes(app);
|
||||
registerImagegenRoutes(app);
|
||||
registerNvrRoutes(app);
|
||||
registerNvrRoutes(app, { requireGatewayAuth });
|
||||
registerK2Routes(app);
|
||||
|
||||
// Fetch available Ollama models (proxies Ollama /api/tags), with vision capability flag
|
||||
@@ -3959,7 +3970,7 @@ function requireGatewayAuth(
|
||||
}
|
||||
|
||||
const cfg = getConfig().getConfig() as any;
|
||||
const configuredToken = String(cfg?.gateway?.auth_token || '').trim();
|
||||
const configuredToken = String(getConfig().resolveSecret(cfg?.gateway?.auth_token) || '').trim();
|
||||
|
||||
// 2. If no token is configured, fall back to localhost-only access.
|
||||
if (!configuredToken) {
|
||||
|
||||
Reference in New Issue
Block a user