Sign in to the dashboard
+ + +No recordings found matching your criteria.
-- ${formattedDate} at ${formattedTime} -
-No recordings available.
-${JSON.stringify(data, null, 2)}`;
+ const pre = document.createElement('pre');
+ pre.textContent = JSON.stringify(data, null, 2);
+ const statusEl = document.getElementById('statusInfo');
+ statusEl.replaceChildren(pre);
// Update current feed info if we have active feed
const currentFeedId = new URLSearchParams(window.location.search).get('feed');
diff --git a/video-feed/videofeed/utils.py b/video-feed/videofeed/utils.py
index 3d5513c..1da7a5e 100644
--- a/video-feed/videofeed/utils.py
+++ b/video-feed/videofeed/utils.py
@@ -3,6 +3,7 @@
import socket
import shutil
import subprocess
+import sys
import typer
from pathlib import Path
from typing import Dict, List, Optional
@@ -12,51 +13,37 @@
def resolve_model_path(model_name: str) -> str:
"""Resolve YOLO model path to use package models directory.
-
+
Args:
model_name: Model filename (e.g., 'yolov8n.pt') or full path
-
+
Returns:
Full path to model file, or original if it's already a full path
"""
model_path = Path(model_name)
-
- # If it's already an absolute path or exists as-is, use it
+
if model_path.is_absolute() or model_path.exists():
return str(model_path)
-
- # Check in package models directory
+
package_models_dir = Path(__file__).parent.parent / "models"
package_model_path = package_models_dir / model_name
-
+
if package_model_path.exists():
return str(package_model_path)
-
- # If not found in package, return original (will trigger download)
+
return model_name
def launch_mediamtx(cfg_path: Path) -> subprocess.Popen:
- """Launch the MediaMTX server with the given configuration.
-
- Args:
- cfg_path: Path to mediamtx.yml configuration file
-
- Returns:
- Process object for the running server
- """
- # Silent launch - config path not needed in output
- # typer.echo(f"🔧 Launching MediaMTX with config: {cfg_path}")
-
- # Verify config file exists
+ """Launch the MediaMTX server with the given configuration."""
if not cfg_path.exists():
typer.secho(f"❌ Config file not found: {cfg_path}", fg=typer.colors.RED)
raise typer.Exit(1)
-
+
return subprocess.Popen(
[MEDIAMTX_BIN, str(cfg_path)],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
)
@@ -73,60 +60,86 @@ def detect_host_ip(prefer_iface: Optional[str] = None) -> str:
def check_mediamtx_installed(binary_name: str = MEDIAMTX_BIN) -> None:
"""Check if mediamtx binary is available and exit if not."""
if shutil.which(binary_name) is None:
- typer.secho(f"Error: '{binary_name}' binary not found.", fg=typer.colors.RED, bold=True)
- typer.echo("Please install MediaMTX from: https://github.com/bluenviron/mediamtx/releases")
+ typer.secho(
+ f"Error: '{binary_name}' binary not found.",
+ fg=typer.colors.RED,
+ bold=True,
+ )
+ typer.echo(
+ "Please install MediaMTX from: https://github.com/bluenviron/mediamtx/releases"
+ )
raise typer.Exit(1)
-def print_urls(host: str, paths: List[str], creds: Dict[str, str], rtsps: bool = False) -> None:
- """Print connection URLs for RTSP/HLS streams."""
+def print_urls(
+ host: str, paths: List[str], creds: Dict[str, str], rtsps: bool = False
+) -> None:
+ """Print connection URLs for RTSP/HLS streams without embedding passwords.
+
+ Passwords are never printed here. Operators retrieve them via:
+ `surveillance credentials show-stream`
+ """
for i, path in enumerate(paths):
if i > 0:
typer.echo("\n" + "-" * 50 + "\n")
-
+
typer.secho(f"\n📹 Stream Path: {path}", fg=typer.colors.YELLOW, bold=True)
base_url = f"rtsp://{host}:8554/{path}"
if rtsps:
- # For secure publishers like Larix
publish_url = f"rtsps://{host}:8322/{path}"
- typer.secho("\n📲 Encrypted RTSPS Publishing:", fg=typer.colors.CYAN, bold=True)
- typer.secho("Use in phone apps (e.g. Larix Broadcaster) or other cameras - encrypted", fg=typer.colors.CYAN, bold=True)
+ typer.secho(
+ "\n📲 Encrypted RTSPS Publishing:", fg=typer.colors.CYAN, bold=True
+ )
typer.echo(f" URL: {publish_url}")
typer.echo(f" User: {creds['publish_user']}")
- typer.echo(f" Pass: {creds['publish_pass']}")
+ typer.echo(" Pass: (use: surveillance credentials show-stream)")
else:
- # Standard RTSP publishing
typer.secho("\n📲 RTSP Publishing:", fg=typer.colors.CYAN, bold=True)
- typer.secho("Use in phone apps (e.g. Larix Broadcaster) or other cameras - unencrypted", fg=typer.colors.CYAN, bold=True)
typer.echo(f" URL: {base_url}")
typer.echo(f" User: {creds['publish_user']}")
- typer.echo(f" Pass: {creds['publish_pass']}")
+ typer.echo(" Pass: (use: surveillance credentials show-stream)")
- # Show viewing URLs - always the same regardless of rtsps/rtsp for publishing
typer.secho("\n📺 Encrypted RTSPS Viewing:", fg=typer.colors.GREEN, bold=True)
- typer.secho("Use in OBS or other video platform- encrypted", fg=typer.colors.GREEN, bold=True)
- view_url = f"rtsps://{creds['read_user']}:{creds['read_pass']}@{host}:8322/{path}"
+ view_url = f"rtsps://{host}:8322/{path}"
typer.echo(f" URL: {view_url}")
+ typer.echo(f" User: {creds['read_user']}")
+ typer.echo(" Pass: (use: surveillance credentials show-stream)")
typer.echo(f" • VLC: File > Open Network > {view_url}")
- typer.echo(f" • OBS: Souces > + > Media Source > Uncheck local File > add RTSP URL to input >\n {view_url}")
+ typer.echo(
+ " • OBS: Sources > + > Media Source > uncheck local file > paste URL"
+ )
typer.secho("\n🌐 HLS Viewing (browser):", fg=typer.colors.MAGENTA, bold=True)
- typer.secho("Use in OBS or other video platform- encrypted", fg=typer.colors.MAGENTA, bold=True)
hls_url = f"http://{host}:8888/{path}/index.m3u8"
- hls_auth_url = f"http://{creds['read_user']}:{creds['read_pass']}@{host}:8888/{path}/index.m3u8"
typer.echo(f" URL: {hls_url}")
- typer.echo(f" Auth: {creds['read_user']} / {creds['read_pass']}")
- typer.echo(f" Direct URL: {hls_auth_url}")
-
- # Unencrypted RTSP Connection Settings
- typer.secho("\n🎥 Unencrypted RTSP Connection Settings:", fg=typer.colors.GREEN, bold=True)
- typer.secho("Use in phone apps (e.g. Larix Broadcaster) or other cameras - unencrypted", fg=typer.colors.GREEN, bold=True)
+ typer.echo(f" Auth user: {creds['read_user']}")
+ typer.echo(" Auth pass: (use: surveillance credentials show-stream)")
+
+ typer.secho(
+ "\n🎥 Unencrypted RTSP Connection Settings:",
+ fg=typer.colors.GREEN,
+ bold=True,
+ )
typer.echo(f" URL: {base_url}")
typer.echo(f" Username: {creds['publish_user']}")
- typer.echo(f" Password: {creds['publish_pass']}")
+ typer.echo(" Password: (use: surveillance credentials show-stream)")
+
+
+def show_stream_credentials(force: bool = False) -> None:
+ """Print publisher/viewer stream passwords once (TTY only unless --force)."""
+ from .credentials import get_credentials
+
+ if not force and not sys.stdout.isatty():
+ typer.secho(
+ "Refusing to print secrets to a non-TTY. Pass --force to override.",
+ fg=typer.colors.RED,
+ )
+ raise typer.Exit(1)
- # Viewer URL (embedded credentials)
- typer.secho("\n👀 Viewer URL (embedded credentials):", fg=typer.colors.BLUE, bold=True)
- typer.secho("Use in OBS or other video platform- unencrypted", fg=typer.colors.BLUE, bold=True)
- typer.echo(f" {view_url}")
+ creds = get_credentials()
+ typer.secho("Stream credentials (MediaMTX) — treat as secrets", fg=typer.colors.YELLOW)
+ typer.echo(f" Publisher user: {creds['publish_user']}")
+ typer.echo(f" Publisher pass: {creds['publish_pass']}")
+ typer.echo(f" Viewer user: {creds['read_user']}")
+ typer.echo(f" Viewer pass: {creds['read_pass']}")
diff --git a/video-feed/videofeed/visualizer.py b/video-feed/videofeed/visualizer.py
index 6de3619..ab7797e 100644
--- a/video-feed/videofeed/visualizer.py
+++ b/video-feed/videofeed/visualizer.py
@@ -7,14 +7,20 @@
import time
from typing import List, Optional
-from fastapi import FastAPI, HTTPException
+from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
import uvicorn
from videofeed.detector import DetectorManager
from videofeed.recorder import RecordingManager
from videofeed.api import RecordingsAPI
from videofeed.utils import detect_host_ip
+from videofeed.auth_gate import (
+ AuthMiddleware,
+ AuthPrincipal,
+ require_read,
+)
# Import route modules
from videofeed.routes import (
@@ -38,29 +44,43 @@
# Create FastAPI app
app = FastAPI(title="Video Feed API")
+# Phase 0: Secure=False on plain HTTP (trusted LAN). Set True when TLS terminates here.
+app.state.secure_cookies = False
+
# Get host IP for CORS configuration
host_ip = detect_host_ip()
-# Configure CORS middleware with restricted origins
+# Configure CORS middleware with restricted origins (same-origin dashboard primary)
allowed_origins = [
"http://localhost:8080",
"http://127.0.0.1:8080",
f"http://{host_ip}:8080",
- "http://localhost:3000", # If you have a separate frontend
- "http://127.0.0.1:3000",
]
app.add_middleware(
CORSMiddleware,
- allow_origins=allowed_origins, # ✅ Restricted to specific origins
+ allow_origins=allowed_origins,
allow_credentials=True,
- allow_methods=["GET", "POST", "DELETE", "PUT"], # ✅ Specific methods only
- allow_headers=["Content-Type", "Authorization", "Cookie"], # ✅ Specific headers
- max_age=3600, # Cache preflight requests for 1 hour
+ allow_methods=["GET", "POST", "DELETE", "PUT"],
+ allow_headers=["Content-Type", "Authorization", "Cookie"],
+ max_age=3600,
)
-# CORS configured silently - no need to log on every startup
-# logger.info(f"CORS configured for origins: {allowed_origins}")
+# Auth gate (outermost after CORS — added last so it runs first on request)
+app.add_middleware(AuthMiddleware)
+
+
+@app.exception_handler(Exception)
+async def unhandled_exception_handler(request: Request, exc: Exception):
+ """Never leak stack traces or paths to clients (H2)."""
+ if isinstance(exc, HTTPException):
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
+ logger.error("Unhandled error on %s: %s", request.url.path, exc, exc_info=True)
+ return JSONResponse(
+ status_code=500,
+ content={"error": {"code": "internal", "message": "Internal server error"}},
+ )
+
# Include all route modules
app.include_router(video_router)
@@ -69,7 +89,6 @@
app.include_router(recordings_router)
app.include_router(statistics_router)
app.include_router(auth_router)
-
# Global instances
detector_manager = None
recordings_api = None
@@ -94,32 +113,36 @@ def set_detector_manager(manager):
@app.get("/status")
-async def get_status(feed: Optional[str] = None):
+async def get_status(
+ feed: Optional[str] = None,
+ _principal: AuthPrincipal = Depends(require_read),
+):
"""Get the detector status for one or all feeds."""
global detector_manager
if detector_manager is None:
raise HTTPException(status_code=503, detail="Detector manager not initialized")
-
+
return detector_manager.get_detector_status(feed)
@app.get("/feeds")
-async def get_feeds():
+async def get_feeds(
+ _principal: AuthPrincipal = Depends(require_read),
+):
"""Get information about all available feeds."""
global detector_manager
if detector_manager is None:
raise HTTPException(status_code=503, detail="Detector manager not initialized")
-
+
feeds = {}
for detector_id, detector in detector_manager.get_all_detectors().items():
feeds[detector_id] = {
"id": detector_id,
"name": detector.get_name(),
- "source": detector._mask_credentials(detector.source_url)
+ "source": detector._mask_credentials(detector.source_url),
}
-
- return {"feeds": feeds, "default": detector_manager.default_detector_id}
+ return {"feeds": feeds, "default": detector_manager.default_detector_id}
# Create a shutdown event to coordinate graceful shutdown
shutdown_requested = threading.Event()
@@ -144,7 +167,7 @@ def force_exit():
def start_visualizer(
rtsp_urls: List[str],
- host: str = "0.0.0.0",
+ host: str = "127.0.0.1",
port: int = 8000,
model_path: str = "yolov8n.pt",
confidence: float = 0.4,
From 9827201b5f0561646a383bdb44371d4db084d0e7 Mon Sep 17 00:00:00 2001
From: Soos3D <99700157+soos3d@users.noreply.github.com>
Date: Mon, 10 Aug 2026 13:21:47 -0400
Subject: [PATCH 2/2] fix: drop cv2-dependent DB tests from slim CI job
test_db_connection imports RecordingManager which requires opencv;
the API CI job only installs the web-test stack (no torch/cv2).
---
.github/workflows/ci.yml | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a011a63..0a15d1a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,10 +48,9 @@ jobs:
env:
PYTHONPATH: .
run: |
+ # Only Phase 0 API tests: test_db*.py import recorder → cv2 (full stack)
pytest \
tests/test_api_characterization.py \
tests/test_auth.py \
tests/test_config_security.py \
- tests/test_db.py \
- tests/test_db_connection.py \
- -m "not slow and not requires_mediamtx"
+ -m "not slow and not requires_mediamtx"
\ No newline at end of file