From 39b069fe9211af4502b52bcbcbca3f9f1d07bb6b Mon Sep 17 00:00:00 2001 From: Soos3D <99700157+soos3d@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:18:39 -0400 Subject: [PATCH 1/2] feat: Phase 0 security hardening on current layout Add session cookie and bearer API auth, remove the unauthenticated /paths side-server, default bind to loopback, redact CLI stream secrets, kill error-detail leaks and template XSS, and land CI plus characterization tests without torch. Closes the critical/high findings from the modernization plan while staying on video-feed/videofeed/. --- .github/workflows/ci.yml | 57 ++ .grok/workflows/plan-next-phase.rhai | 705 ++++++++++++++++++ docs/PLAN.md | 369 +++++++++ ruff.toml | 29 + video-feed/config/surveillance.yml | 12 +- video-feed/pytest.ini | 1 + video-feed/requirements-dev.txt | 6 + video-feed/requirements-web-test.txt | 14 + video-feed/requirements.txt | 3 + video-feed/tests/conftest.py | 266 ++++++- video-feed/tests/test_api_characterization.py | 132 ++++ video-feed/tests/test_auth.py | 188 +++++ video-feed/tests/test_config_security.py | 54 ++ video-feed/ui/README.md | 48 +- video-feed/videofeed/auth_gate.py | 286 +++++++ video-feed/videofeed/config.py | 17 +- video-feed/videofeed/credentials.py | 237 +++++- video-feed/videofeed/routes/auth.py | 91 ++- video-feed/videofeed/routes/files.py | 50 +- video-feed/videofeed/routes/pages.py | 18 +- video-feed/videofeed/routes/recordings.py | 206 ++--- video-feed/videofeed/routes/statistics.py | 139 ++-- video-feed/videofeed/routes/video.py | 27 +- video-feed/videofeed/surveillance.py | 247 +++--- video-feed/videofeed/templates/login.html | 59 ++ .../videofeed/templates/recordings.html | 212 +++--- video-feed/videofeed/templates/viewer.html | 8 +- video-feed/videofeed/utils.py | 119 +-- video-feed/videofeed/visualizer.py | 61 +- 29 files changed, 3068 insertions(+), 593 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .grok/workflows/plan-next-phase.rhai create mode 100644 docs/PLAN.md create mode 100644 ruff.toml create mode 100644 video-feed/requirements-dev.txt create mode 100644 video-feed/requirements-web-test.txt create mode 100644 video-feed/tests/test_api_characterization.py create mode 100644 video-feed/tests/test_auth.py create mode 100644 video-feed/tests/test_config_security.py create mode 100644 video-feed/videofeed/auth_gate.py create mode 100644 video-feed/videofeed/templates/login.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a011a63 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + api-tests: + name: API tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + # macOS matrix deferred until Phase 0 bootstrap is stable (see docs/PLAN.md) + + defaults: + run: + working-directory: video-feed + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install web-test + dev dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-web-test.txt -r requirements-dev.txt + + - name: Ruff (Phase 0 paths — correctness rules) + run: | + # Full style cleanup deferred (repo predates ruff); gate on bugs only + ruff check --select E,F,B \ + videofeed/auth_gate.py \ + videofeed/credentials.py \ + videofeed/routes/auth.py \ + tests/test_api_characterization.py \ + tests/test_auth.py \ + tests/test_config_security.py \ + tests/conftest.py + + - name: Pytest (API / unit, no torch, no MediaMTX) + env: + PYTHONPATH: . + run: | + 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" diff --git a/.grok/workflows/plan-next-phase.rhai b/.grok/workflows/plan-next-phase.rhai new file mode 100644 index 0000000..db89e96 --- /dev/null +++ b/.grok/workflows/plan-next-phase.rhai @@ -0,0 +1,705 @@ +// Plan the next modernization phase for SpectraX. +// Reads docs/PLAN.md + current code, picks the phase, researches it in parallel, +// synthesizes a shippable implementation plan, adversarially challenges it. +// +// Args (all optional): +// phase: integer 0-4 to force a phase, or omit/"auto" to detect from code +// depth: "quick" | "full" (default full) — quick skips adversarial challenge + +let meta = #{ + name: "plan-next-phase", + description: "Survey SpectraX docs/code and produce a shippable implementation plan for the next modernization phase", + when_to_use: "Before starting work on the next PLAN.md phase, or to re-plan a phase after the codebase has moved", + phases: [ + #{ title: "Survey", detail: "read PLAN.md + detect which phase is next" }, + #{ title: "Research", detail: "parallel deep-dives scoped to that phase" }, + #{ title: "Draft", detail: "synthesize implementation plan" }, + #{ title: "Challenge", detail: "adversarial review of the plan against code" }, + #{ title: "Finalize", detail: "write polished plan artifact" }, + ], +}; + +// --- schemas ----------------------------------------------------------------- + +let survey_schema = #{ + "type": "object", + "required": ["next_phase", "phase_title", "rationale", "already_done", "open_questions", "blockers"], + "properties": #{ + "next_phase": #{ "type": "integer", "minimum": 0, "maximum": 4 }, + "phase_title": #{ "type": "string" }, + "rationale": #{ "type": "string" }, + "already_done": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 20, + }, + "open_questions": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 10, + }, + "blockers": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 10, + }, + "plan_status": #{ "type": "string" }, + }, +}; + +let research_schema = #{ + "type": "object", + "required": ["dimension", "findings", "files_touched", "risks", "test_gaps"], + "properties": #{ + "dimension": #{ "type": "string" }, + "findings": #{ + "type": "array", + "maxItems": 12, + "items": #{ + "type": "object", + "required": ["claim", "evidence", "path"], + "properties": #{ + "claim": #{ "type": "string" }, + "evidence": #{ "type": "string" }, + "path": #{ "type": "string" }, + }, + }, + }, + "files_touched": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 30, + }, + "risks": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 8, + }, + "test_gaps": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 8, + }, + "recommendations": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 8, + }, + }, +}; + +let draft_schema = #{ + "type": "object", + "required": ["title", "summary", "pr_plan", "success_criteria", "out_of_scope", "open_decisions"], + "properties": #{ + "title": #{ "type": "string" }, + "summary": #{ "type": "string" }, + "pr_plan": #{ + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": #{ + "type": "object", + "required": ["id", "title", "goal", "tasks", "files", "tests", "depends_on"], + "properties": #{ + "id": #{ "type": "string" }, + "title": #{ "type": "string" }, + "goal": #{ "type": "string" }, + "tasks": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 12, + }, + "files": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 20, + }, + "tests": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 10, + }, + "depends_on": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 5, + }, + "risk": #{ "type": "string" }, + }, + }, + }, + "success_criteria": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 12, + }, + "out_of_scope": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 10, + }, + "open_decisions": #{ + "type": "array", + "items": #{ + "type": "object", + "required": ["question", "recommendation"], + "properties": #{ + "question": #{ "type": "string" }, + "recommendation": #{ "type": "string" }, + "impact_if_wrong": #{ "type": "string" }, + }, + }, + "maxItems": 8, + }, + "risks": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 8, + }, + "estimated_effort": #{ "type": "string" }, + }, +}; + +let challenge_schema = #{ + "type": "object", + "required": ["verdict", "issues", "missing", "over_scoped"], + "properties": #{ + "verdict": #{ "type": "string", "enum": ["approve", "revise"] }, + "issues": #{ + "type": "array", + "maxItems": 10, + "items": #{ + "type": "object", + "required": ["severity", "detail", "evidence"], + "properties": #{ + "severity": #{ "type": "string", "enum": ["blocker", "major", "nit"] }, + "detail": #{ "type": "string" }, + "evidence": #{ "type": "string" }, + }, + }, + }, + "missing": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 10, + }, + "over_scoped": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 8, + }, + "suggested_edits": #{ + "type": "array", + "items": #{ "type": "string" }, + "maxItems": 10, + }, + }, +}; + +// --- args -------------------------------------------------------------------- + +let force_phase = (); +let depth = "full"; +if args != () { + if args.phase != () { force_phase = args.phase; } + if args.depth != () { depth = args.depth; } +} + +// --- Phase: Survey ----------------------------------------------------------- + +phase("Survey"); +log("Surveying docs/PLAN.md and codebase to identify the next phase"); + +let survey_prompt = ""; +survey_prompt += "You are surveying the SpectraX repo to decide which modernization phase is next.\n"; +survey_prompt += "Repo root is the current workspace. Python package lives under video-feed/ (import: videofeed).\n\n"; +survey_prompt += "REQUIRED reads (use read_file / list_dir / grep — do not answer from memory):\n"; +survey_prompt += "1. docs/PLAN.md — full document, especially §4 Phases and §7 Open questions\n"; +survey_prompt += "2. CLAUDE.md or Claude.md — layout/gotchas\n"; +survey_prompt += "3. video-feed/videofeed/ structure (list_dir)\n"; +survey_prompt += "4. Grep for Phase-0 markers already landed: auth middleware, bearer, create_app, "; +survey_prompt += "api/v1, FileSecretsStore, secrets.compare_digest, slowapi, TestClient auth tests, "; +survey_prompt += ".github/workflows, pyproject.toml, src/spectrax\n"; +survey_prompt += "5. Spot-check routes/auth.py, visualizer.py, surveillance.py for current security posture\n\n"; +survey_prompt += "Phase detection rules (apply in order):\n"; +survey_prompt += "- Phase 0 incomplete if: no real API auth middleware, DELETE unauthenticated, "; +survey_prompt += "no CI, /paths side-server still present, detail=str(e) still leaks, no characterization tests.\n"; +survey_prompt += "- Phase 1 incomplete if: still video-feed/ + setup.py, no pyproject.toml src layout, sys.path hack remains.\n"; +survey_prompt += "- Phase 2 incomplete if: set_* setters still wire routes, no create_app lifespan, no SecretsStore.\n"; +survey_prompt += "- Phase 3 incomplete if: no /api/v1, no EventBus/SSE, no MediaMTX HTTP auth callback.\n"; +survey_prompt += "- Phase 4 incomplete if: docs still stale vs code, no external module proof-of-life.\n\n"; + +if force_phase != () { + survey_prompt += "OVERRIDE: args.phase is set to "; + survey_prompt += json_encode(force_phase); + survey_prompt += ". Still survey, but set next_phase to that value and note the override in rationale.\n\n"; +} else { + survey_prompt += "No phase override — detect the earliest incomplete phase (usually 0).\n\n"; +} + +survey_prompt += "Return structured JSON only. already_done = concrete checklist items from PLAN.md that ARE already true in code. "; +survey_prompt += "open_questions = PLAN §7 items still relevant to next_phase. blockers = things that would prevent starting.\n"; +survey_prompt += "Empty arrays are valid only after you inspected the code."; + +let survey = agent(survey_prompt, #{ + label: "survey", + capability_mode: "read-only", + agent_type: "explore", + output_schema: survey_schema, +}); + +if survey == () || !survey.success || survey.output == () { + pause("infra", "Survey agent failed — resume to retry, or pass args.phase explicitly."); +} + +let next_phase = survey.output.next_phase; +let phase_title = survey.output.phase_title; +let survey_rationale = survey.output.rationale; +let already_done = survey.output.already_done; +let open_qs = survey.output.open_questions; +let blockers = survey.output.blockers; + +// Defaults for canned validate_only / partial schema fills +if next_phase == () { next_phase = 0; } +if phase_title == () { phase_title = "Stop the bleeding (security)"; } +if survey_rationale == () { survey_rationale = "defaulted: survey field missing"; } +if already_done == () { already_done = []; } +if open_qs == () { open_qs = []; } +if blockers == () { blockers = []; } + +log("Next phase: " + next_phase.to_string() + " — " + phase_title); + +// --- Phase: Research --------------------------------------------------------- + +phase("Research"); +log("Parallel research for Phase " + next_phase.to_string()); + +// Dimension packs per phase (fixed lists — no discovery agent) +let dimensions = []; +if next_phase == 0 { + dimensions = [ + "security-auth", + "security-hardening", + "tests-and-ci", + "dashboard-and-mjpeg-auth", + ]; +} else if next_phase == 1 { + dimensions = [ + "packaging-layout", + "dependency-refresh", + "import-and-cli-migration", + "deprecation-cleanup", + ]; +} else if next_phase == 2 { + dimensions = [ + "process-inversion-lifespan", + "di-routers", + "config-and-secrets", + "deploy-systemd-macos", + ]; +} else if next_phase == 3 { + dimensions = [ + "api-v1-contract", + "eventbus-sse", + "mediamtx-auth-callback", + "integration-tests", + ]; +} else { + dimensions = [ + "docs-rewrite", + "first-external-module", + "dashboard-api-only", + "success-criteria-audit", + ]; +} + +let research_base = ""; +research_base += "You are researching Phase "; +research_base += next_phase.to_string(); +research_base += " ("; +research_base += phase_title; +research_base += ") of the SpectraX modernization plan for ONE dimension only.\n\n"; +research_base += "Read docs/PLAN.md section for this phase AND the listed files in the live codebase under video-feed/.\n"; +research_base += "Use read_file, grep, list_dir — never invent file contents.\n"; +research_base += "Survey rationale (context only): "; +research_base += json_encode(survey_rationale); +research_base += "\nAlready done (do not re-plan these): "; +research_base += json_encode(already_done); +research_base += "\nOpen questions from plan: "; +research_base += json_encode(open_qs); +research_base += "\n\n"; + +// Dimension-specific instructions +fn dimension_brief(dim, phase_n) { + if dim == "security-auth" { + let s = ""; + s += "DIMENSION: security-auth. Focus on PLAN Phase 0 items 1-2,7: bearer+session auth over existing app, "; + s += "admin scope on DELETE, delete /auth/verify, secrets.compare_digest, rate-limit login, bind 127.0.0.1 default.\n"; + s += "Read: video-feed/videofeed/routes/auth.py, routes/recordings.py, visualizer.py, credentials.py, surveillance.py (bind host).\n"; + s += "Map exact insertion points for middleware/deps without inventing a rewrite of the topology.\n"; + return s; + } + if dim == "security-hardening" { + let s = ""; + s += "DIMENSION: security-hardening. Focus on PLAN Phase 0 items 3-6: global exception handler, kill detail=str(e), "; + s += "XSS escape in templates, rtspEncryption strict, stop printing/embedding plaintext creds, delete /paths http.server.\n"; + s += "Read: routes/recordings.py, routes/statistics.py, templates/recordings.html, templates/viewer.html, "; + s += "config.py, utils.py, surveillance.py (search for http.server, /paths, os._exit).\n"; + return s; + } + if dim == "tests-and-ci" { + let s = ""; + s += "DIMENSION: tests-and-ci. Focus on PLAN Phase 0 item 8: characterization tests with httpx TestClient, "; + s += "auth tests, CI workflow, requirements-dev.txt (pytest, pytest-cov, pytest-asyncio, httpx, ruff).\n"; + s += "Read: video-feed/tests/, pytest.ini, requirements.txt, check for .github/workflows, ruff.toml.\n"; + s += "List which endpoints need characterization fixtures and how to boot the app without MediaMTX for unit tests.\n"; + return s; + } + if dim == "dashboard-and-mjpeg-auth" { + let s = ""; + s += "DIMENSION: dashboard-and-mjpeg-auth. PLAN risk: Auth breaks MJPEG/dashboard because cannot send headers.\n"; + s += "Read: templates/viewer.html, recordings.html, routes/video.py, routes/pages.py, ui/dashboard.html if present.\n"; + s += "Recommend cookie-session strategy for browser paths vs bearer for API clients. Note PLAN open Q1 (TLS/Secure cookie).\n"; + return s; + } + if dim == "packaging-layout" { + let s = ""; + s += "DIMENSION: packaging-layout. Phase 1: pyproject.toml, git mv to src/spectrax/, kill setup.py/cli shim/sys.path hack.\n"; + s += "Read: video-feed/setup.py, videofeed/cli.py, videofeed/surveillance.py (sys.path), scripts/surveillance.sh, ruff.toml.\n"; + return s; + } + if dim == "dependency-refresh" { + let s = ""; + s += "DIMENSION: dependency-refresh. Phase 1 tiered upgrades (web → CV → utils), lockfile, re-verify avc1 playback after OpenCV bump.\n"; + s += "Read: video-feed/requirements.txt, recorder codec usage, test_supervision_integration.py.\n"; + return s; + } + if dim == "import-and-cli-migration" { + let s = ""; + s += "DIMENSION: import-and-cli-migration. Phase 1 package rename videofeed→spectrax, console scripts, import graph.\n"; + s += "Grep for videofeed imports, PYTHONPATH, scripts that hardcode paths.\n"; + return s; + } + if dim == "deprecation-cleanup" { + let s = ""; + s += "DIMENSION: deprecation-cleanup. Phase 1: @app.on_event → lifespan, pydantic v2 warnings, dead .enc allowlist (PLAN Q3).\n"; + s += "Read: visualizer.py, routes/files.py allowlist, any on_event usage.\n"; + return s; + } + if dim == "process-inversion-lifespan" { + let s = ""; + s += "DIMENSION: process-inversion-lifespan. Phase 2: create_app + lifespan owns detector/MediaMTX/DB; CLI becomes serve/apikey/admin/reset/doctor.\n"; + s += "Read: surveillance.py, visualizer.py startup/shutdown, MediaMTX spawn paths.\n"; + return s; + } + if dim == "di-routers" { + let s = ""; + s += "DIMENSION: di-routers. Phase 2: Depends() from api/deps.py; delete every set_* setter; split visualizer.\n"; + s += "Grep set_ in routes/ and visualizer; list each router dependency.\n"; + return s; + } + if dim == "config-and-secrets" { + let s = ""; + s += "DIMENSION: config-and-secrets. Phase 2: pydantic-settings, SecretsStore File/Keyring, migrate keychain.\n"; + s += "Read: config.py, credentials.py, detector_config.py, config/surveillance.yml.\n"; + return s; + } + if dim == "deploy-systemd-macos" { + let s = ""; + s += "DIMENSION: deploy-systemd-macos. Phase 2 systemd units, launchd/dev docs; PLAN Q4 MediaMTX ownership.\n"; + s += "Read: scripts/surveillance.service, scripts/surveillance.sh.\n"; + return s; + } + if dim == "api-v1-contract" { + let s = ""; + s += "DIMENSION: api-v1-contract. Phase 3: /api/v1 prefix, pydantic response models, OpenAPI as frozen contract.\n"; + s += "List current routes vs PLAN §3.1 endpoint groups; note auth scopes per group.\n"; + return s; + } + if dim == "eventbus-sse" { + let s = ""; + s += "DIMENSION: eventbus-sse. Phase 3: events table, EventBus, SSE Last-Event-ID, retention cleanup.\n"; + s += "Read: recorder.py / api.py DB patterns for where events table should live.\n"; + return s; + } + if dim == "mediamtx-auth-callback" { + let s = ""; + s += "DIMENSION: mediamtx-auth-callback. Phase 3: authMethod http, remove HLS user:pass URLs.\n"; + s += "Read: config.py MediaMTX config generation, utils.py HLS URL building.\n"; + return s; + } + if dim == "integration-tests" { + let s = ""; + s += "DIMENSION: integration-tests. Phase 3: detection → event row → SSE delivery without real MediaMTX where possible.\n"; + s += "Read existing tests and recorder hooks for emission points.\n"; + return s; + } + if dim == "docs-rewrite" { + let s = ""; + s += "DIMENSION: docs-rewrite. Phase 4: ARCHITECTURE, README, API from OpenAPI, CONFIGURATION_GUIDE, routes/README.\n"; + s += "Read docs/ and flag every contradiction with code.\n"; + return s; + } + if dim == "first-external-module" { + let s = ""; + s += "DIMENSION: first-external-module. Phase 4: notifier consuming SSE as contract proof-of-life in separate repo design.\n"; + s += "Specify minimal module contract surface and auth.\n"; + return s; + } + if dim == "dashboard-api-only" { + let s = ""; + s += "DIMENSION: dashboard-api-only. Phase 4: dashboard talks only to /api/v1.\n"; + s += "Inventory template/JS fetch paths and non-API shortcuts.\n"; + return s; + } + if dim == "success-criteria-audit" { + let s = ""; + s += "DIMENSION: success-criteria-audit. Map PLAN §6 checkboxes to current code truth and remaining work.\n"; + return s; + } + return "DIMENSION: " + dim + ". Research thoroughly for Phase " + phase_n.to_string() + ".\n"; +} + +let jobs = []; +for d in dimensions { + let p = research_base; + p += dimension_brief(d, next_phase); + p += "\nReturn findings with concrete path+evidence. files_touched = paths an implementer will edit. "; + p += "test_gaps and recommendations must be actionable. Empty findings only if you read the code and found nothing."; + jobs.push(#{ + prompt: p, + label: "research:" + d, + capability_mode: "read-only", + agent_type: "explore", + output_schema: research_schema, + }); +} +let research_results = parallel(jobs); + +let research_bundle = []; +let i = 0; +for r in research_results { + let dim_name = dimensions[i]; + if r != () && r.success && r.output != () { + research_bundle.push(r.output); + log("Research OK: " + dim_name); + } else { + log("Research FAILED (continuing): " + dim_name); + research_bundle.push(#{ + dimension: dim_name, + findings: [], + files_touched: [], + risks: ["research agent failed — treat this dimension as incomplete"], + test_gaps: [], + recommendations: ["Re-run research for " + dim_name], + }); + } + i += 1; +} + +// --- Phase: Draft ------------------------------------------------------------ + +phase("Draft"); +log("Synthesizing implementation plan"); + +let draft_prompt = ""; +draft_prompt += "You are writing the implementation plan for SpectraX Phase "; +draft_prompt += next_phase.to_string(); +draft_prompt += " — "; +draft_prompt += phase_title; +draft_prompt += ".\n\n"; +draft_prompt += "You MUST re-read docs/PLAN.md for this phase and spot-check key files before writing.\n"; +draft_prompt += "Do not invent files that do not exist. Prefer the CURRENT layout (video-feed/videofeed/) "; +draft_prompt += "unless this phase is the repackage (Phase 1+).\n\n"; +draft_prompt += "Survey output:\n"; +draft_prompt += json_encode(survey.output); +draft_prompt += "\n\nResearch bundle (one object per dimension):\n"; +draft_prompt += json_encode(research_bundle); +draft_prompt += "\n\nConstraints from project rules:\n"; +draft_prompt += "- Branch off main with type prefix (feat/, fix/, docs/); never commit to main.\n"; +draft_prompt += "- TDD for new code; characterization tests before risky refactors.\n"; +draft_prompt += "- No hardcoded secrets; validate at boundaries.\n"; +draft_prompt += "- Each PR independently reviewable; Phase 0 is security on CURRENT layout (no big restructure).\n"; +draft_prompt += "- PLAN open questions: decide with a recommendation, not a shrug.\n\n"; +draft_prompt += "Produce a PR plan DAG: 2-6 PRs max for this phase. Each PR needs concrete tasks, files, tests, depends_on.\n"; +draft_prompt += "success_criteria must be checkable. out_of_scope must explicitly exclude later phases.\n"; +draft_prompt += "estimated_effort: days or 1-2 weeks matching PLAN.\n"; + +let draft = agent(draft_prompt, #{ + label: "draft-plan", + capability_mode: "read-only", + agent_type: "plan", + output_schema: draft_schema, +}); + +if draft == () || !draft.success || draft.output == () { + pause("infra", "Draft agent failed — resume to retry."); +} + +// --- Phase: Challenge (optional) --------------------------------------------- + +let challenge_output = #{ + verdict: "approve", + issues: [], + missing: [], + over_scoped: [], + suggested_edits: [], +}; + +if depth != "quick" { + phase("Challenge"); + log("Adversarial challenge of the draft plan"); + + let challenge_prompt = ""; + challenge_prompt += "You are an adversarial reviewer. Your job is to FIND problems in this Phase "; + challenge_prompt += next_phase.to_string(); + challenge_prompt += " implementation plan — not to rubber-stamp it.\n\n"; + challenge_prompt += "Independently re-read docs/PLAN.md for this phase and the code paths the plan claims to touch.\n"; + challenge_prompt += "Use read_file/grep. Default to verdict=revise unless the plan is tight.\n\n"; + challenge_prompt += "Check specifically:\n"; + challenge_prompt += "1. Scope creep into later phases\n"; + challenge_prompt += "2. Tasks that assume files/APIs that do not exist\n"; + challenge_prompt += "3. Missing tests for the risky bits (auth on MJPEG/cookies is the classic miss)\n"; + challenge_prompt += "4. Ordering bugs in the PR DAG (depends_on wrong)\n"; + challenge_prompt += "5. Open decisions left undecided without a recommendation\n"; + challenge_prompt += "6. Security items from PLAN that were silently dropped\n\n"; + challenge_prompt += "Draft plan:\n"; + challenge_prompt += json_encode(draft.output); + challenge_prompt += "\n\nResearch bundle:\n"; + challenge_prompt += json_encode(research_bundle); + challenge_prompt += "\n\nFor every issue, evidence must cite a path or PLAN section you inspected. "; + challenge_prompt += "If you cannot verify a claim in code, mark it as an issue."; + + let challenge = agent(challenge_prompt, #{ + label: "challenge", + capability_mode: "read-only", + agent_type: "architect", + output_schema: challenge_schema, + }); + + if challenge != () && challenge.success && challenge.output != () { + challenge_output = challenge.output; + let v = challenge_output.verdict; + if v == () { v = "unknown"; } + log("Challenge verdict: " + v); + } else { + log("Challenge agent failed — finalizing with unchallenged draft"); + challenge_output = #{ + verdict: "revise", + issues: [#{ + severity: "major", + detail: "Challenge agent failed; plan was not adversarially reviewed", + evidence: "workflow infrastructure", + }], + missing: [], + over_scoped: [], + suggested_edits: ["Re-run with depth=full after fixing challenge agent"], + }; + } +} else { + phase("Challenge"); + log("depth=quick — skipping adversarial challenge"); +} + +// --- Phase: Finalize --------------------------------------------------------- + +phase("Finalize"); +log("Writing plan artifact"); + +let final_prompt = ""; +final_prompt += "Write the final implementation plan markdown for SpectraX Phase "; +final_prompt += next_phase.to_string(); +final_prompt += " — "; +final_prompt += phase_title; +final_prompt += ".\n\n"; +final_prompt += "Incorporate the challenge feedback. If verdict is revise, fix every blocker and major issue "; +final_prompt += "in the plan text (do not just list them). Nits may become footnotes.\n\n"; +final_prompt += "Draft plan (JSON):\n"; +final_prompt += json_encode(draft.output); +final_prompt += "\n\nChallenge (JSON):\n"; +final_prompt += json_encode(challenge_output); +final_prompt += "\n\nSurvey rationale: "; +final_prompt += json_encode(survey_rationale); +final_prompt += "\nBlockers noted at survey: "; +final_prompt += json_encode(blockers); +final_prompt += "\n\nMarkdown structure (use exactly these H2 headings):\n"; +final_prompt += "# Phase N — Title\n"; +final_prompt += "## Summary\n"; +final_prompt += "## Why this phase next\n"; +final_prompt += "## Decisions (with recommendations)\n"; +final_prompt += "## PR plan\n"; +final_prompt += " For each PR: goal, tasks (checkbox list), files, tests, depends on, risk\n"; +final_prompt += "## Success criteria\n"; +final_prompt += "## Out of scope\n"; +final_prompt += "## Risks & mitigations\n"; +final_prompt += "## How to start (first commit / first PR)\n"; +final_prompt += "\nBe concrete: real paths under video-feed/videofeed/ (or target paths if Phase 1+).\n"; +final_prompt += "No filler. No inventing APIs. Match PLAN.md intent for this phase only.\n"; +final_prompt += "Return ONLY the markdown document as your final message (no JSON wrapper, no preamble)."; + +let final_agent = agent(final_prompt, #{ + label: "finalize", + capability_mode: "read-only", + agent_type: "plan", +}); + +let report_md = ""; +if final_agent != () && final_agent.success && final_agent.output != () { + // output may be string (no schema) — coerce carefully + if type_of(final_agent.output) == "string" { + report_md = final_agent.output; + } else { + report_md = json_encode(final_agent.output); + } +} else { + // Fallback: render from draft JSON + let fb_summary = ""; + if draft.output.summary != () { fb_summary = draft.output.summary; } + let fb_prs = []; + if draft.output.pr_plan != () { fb_prs = draft.output.pr_plan; } + report_md = "# Phase " + next_phase.to_string() + " — " + phase_title + "\n\n"; + report_md += "## Summary\n\n"; + report_md += fb_summary + "\n\n"; + report_md += "## PR plan\n\n"; + report_md += "```json\n" + json_encode(fb_prs) + "\n```\n\n"; + report_md += "## Challenge notes\n\n"; + report_md += "```json\n" + json_encode(challenge_output) + "\n```\n"; + log("Finalize agent failed — wrote structured fallback from draft"); +} + +// Header banner +let banner_verdict = "unknown"; +if challenge_output.verdict != () { banner_verdict = challenge_output.verdict; } +let banner = ""; +banner += "\n"; +banner += "\n\n"; +let full_report = banner + report_md; + +let path = write_scratch_file("phase-plan.md", full_report); +log("Wrote plan to " + path); + +// Guard fields that may be unit under validate_only canned outputs +let pr_count = 0; +if draft.output.pr_plan != () { + pr_count = draft.output.pr_plan.len(); +} +let summary_out = ""; +if draft.output.summary != () { summary_out = draft.output.summary; } +let effort_out = ""; +if draft.output.estimated_effort != () { effort_out = draft.output.estimated_effort; } +let decisions_out = []; +if draft.output.open_decisions != () { decisions_out = draft.output.open_decisions; } +let verdict_out = "unknown"; +if challenge_output.verdict != () { verdict_out = challenge_output.verdict; } + +complete(#{ + path: path, + next_phase: next_phase, + phase_title: phase_title, + challenge_verdict: verdict_out, + pr_count: pr_count, + summary: summary_out, + estimated_effort: effort_out, + open_decisions: decisions_out, +}); diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..2652361 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,369 @@ +# SpectraX Modernization & Re-architecture Plan + +> Status: **Phase 0 implemented on `feat/phase-0-security`** · Written 2026-08-09 · +> Phase 0 detail confirmed 2026-08-10 · Supersedes nothing (first plan doc) +> +> Sources: full-codebase survey, security audit, and architecture design produced 2026-08-09; +> Phase 0 PR DAG refined by `plan-next-phase` workflow 2026-08-10. + +## 1. Vision + +SpectraX becomes the **core service** of a local surveillance system: + +- Camera ingest (MediaMTX/RTSP), YOLO detection, recording, and storage in one service. +- **Modular by design**: future modules (notifiers, analytics, integrations — any language) + are separate processes that consume the core's data through a **secure, versioned API**. +- The dashboard is "module #1" — a client of that same API, secured behind login. +- Deployment targets: **macOS and Linux** (including headless Linux servers). + Raspberry Pi / ARM is explicitly **out of scope** (dropped 2026-08-09); it survives only + as a cheap extension point (see §4.5). + +## 2. Current state (survey findings, verified against code) + +### Structure +- One process, three servers: `surveillance.py` (Typer CLI, 717 lines, `sys.path` hack) + spawns MediaMTX as a subprocess, runs the FastAPI dashboard (`visualizer.py` + `routes/`) + in a **daemon thread**, plus a second raw `http.server` `/paths` endpoint (port 3333, + unauthenticated, CORS `*`). Shutdown relies on daemon-thread death and an `os._exit(0)` + watchdog. +- Routes are wired by **module-level globals via setters** (`set_detector_manager(...)` etc.) + — miss one and the route 500s. This is the main structural liability. +- Clean, reusable cores worth keeping: `api.py` (RecordingsAPI — parameterized SQLite), + `recorder.py` (buffering, cooldown, thumbnails, cleanup), `detector.py` + (supervision-based detection), `detector_config.py`, path-traversal-safe `routes/files.py`. +- `cli.py` is a deprecated shim. `setup.py` claims Python ≥3.8 (EOL, fiction). + Deps pinned ~Apr 2025 (fastapi 0.115.12, torch 2.7.0, etc.). Deprecated + `@app.on_event("shutdown")`. Tests cover only DB/recording/storage. No CI. + +### Security audit — prioritized findings + +| ID | Sev | Finding | Where | +|----|-----|---------|-------| +| C1 | CRITICAL | Entire API unauthenticated, binds `0.0.0.0`; `/auth/verify` exists but nothing enforces it | `visualizer.py:39-71`, `surveillance.py` | +| C2 | CRITICAL | Unauthenticated `DELETE /api/recordings/{id}` → `os.remove()`; LAN attacker can wipe all recordings | `routes/recordings.py:183-200`, `api.py:221-254` | +| H1 | HIGH | `/auth/verify` brute-force oracle: plaintext `==` compare, no rate limit, fixed usernames | `routes/auth.py:17-42`, `credentials.py:27-30` | +| H2 | HIGH | `detail=str(e)` leaks DB errors / absolute paths to clients | `routes/recordings.py`, `routes/statistics.py` | +| H3 | HIGH | `rtspEncryption: "optional"` → creds negotiable to cleartext; HLS URLs embed `user:pass` over HTTP | `config.py:61`, `utils.py:116-120` | +| M1 | MEDIUM | Stored XSS: stream names interpolated into `innerHTML` | `templates/recordings.html`, `viewer.html` | +| M2 | MEDIUM | `/paths` mini-server: unauthenticated, `0.0.0.0`, CORS `*` | `surveillance.py:147,600-606` | +| M3 | MEDIUM | Self-signed RTSPS cert, no client verification — LAN MITM possible; document as threat | `surveillance.py:77-83` | +| L1–L4 | LOW | Creds printed to terminal; fixed usernames; CORS `allow_credentials` pre-auth; YOLO pickle load (keep config-only) | various | + +**Checked and clean** (do not re-litigate): no SQL injection (parameterized + whitelisted sort), +no command injection (arg-list `Popen`, no shell), path traversal defense in `files.py` is solid, +`yaml.safe_load` everywhere, no secrets in code or git history, dep pins have no known high CVEs. + +## 3. Target architecture + +**Modular monolith core + out-of-process modules.** + +``` + cameras ──rtsps──▶ MediaMTX ◀── auth callback (authMethod: http) ──┐ + (systemd / launchd or CLI-spawned in dev) │ + │ rtsp(s) pull │ + ▼ │ + spectrax service (one Python process) │ + ┌─────────────────────────────────────────────┐ │ + │ DetectionEngine (threads per stream) │ │ + │ backend: ultralytics (default) │ │ + │ │ sv.Detections │ │ + │ ├──▶ RecordingManager ──▶ mp4/thumbs │ │ + │ ▼ │ │ + │ EventBus ──▶ SQLite (recordings, events, │ │ + │ │ api_keys) │ │ + │ ▼ │ │ + │ FastAPI /api/v1 (bearer key | session) ─────┼─────┘ + │ streams · recordings · events(SSE) · │ + │ stats · system · dashboard │ + └──────┬──────────────────┬───────────────────┘ + browser (cookie) modules (API key, any language) +``` + +### 3.1 Module contract (the one hard-to-reverse decision) + +- **REST under `/api/v1`** + **SSE at `/api/v1/events/stream`** is the *only* module contract. + No in-process plugin API, ever — modules are separate processes; a bad module cannot take + down the core, and modules can be written in any language. +- SSE over WebSocket (one-directional suffices, plain HTTP, `curl`-debuggable, and + `Last-Event-ID` + the SQLite events table gives replay-after-disconnect nearly free). + MQTT rejected (broker + second auth system for zero current benefit; a bridge module can + republish later if needed). +- OpenAPI spec (FastAPI-generated) **is** the documented contract. Additive changes free; + breaking changes → `/api/v2` alongside `/api/v1` with a deprecation window. Event payloads + carry `"v": 1`. Pydantic response models on every endpoint — schema enforced, not aspirational. + +**Endpoint groups** + +| Group | Endpoints | Auth | +|---|---|---| +| auth | `POST /auth/login`, `POST /auth/logout` | rate-limited | +| streams | `GET /streams`, `/streams/{id}`, `/streams/{id}/live.mjpeg`, `/streams/{id}/snapshot.jpg` | read | +| recordings | `GET /recordings` (filter/page), `/recordings/{id}`, `/{id}/video`, `/{id}/thumbnail`, `DELETE /recordings/{id}` | read; **admin** for DELETE | +| events | `GET /events` (history), `GET /events/stream` (SSE, `Last-Event-ID` replay) | read | +| stats | `GET /stats/objects`, `/stats/time`, `/stats/streams/{id}`, `/stats/summary` | read | +| system | `GET /system/health`, `/system/status`, `/system/config` (redacted) | read | +| internal | `POST /internal/mediamtx-auth` (MediaMTX HTTP auth callback) | localhost-only | + +Event envelope: +`{"v":1, "id":"", "type":"detection.started|detection.updated|recording.completed|stream.online|stream.offline", "ts":…, "stream_id":…, "data":{…}}` — +persisted to an `events` table (retention cleanup alongside existing storage cleanup) so SSE +replay and `GET /events` share one source. + +### 3.2 Auth design + +- **Machine clients (modules)**: per-module bearer API keys (`Authorization: Bearer sx_`), + stored **SHA-256-hashed** (fine for 32-byte random secrets) in an `api_keys` table + (name, created_at, revoked_at, scopes). Managed via CLI: + `spectrax apikey create|list|revoke`. Constant-time compare. Two scopes only: + `read` (default) and `admin` (delete recordings, config changes). No OAuth/JWT ceremony. +- **Browser dashboard**: one admin password (`spectrax admin set-password`, argon2/bcrypt hash) + → rate-limited login (in-memory token bucket or `slowapi`) → signed session cookie + (`HttpOnly`, `SameSite=Strict`, `Secure` when TLS). `itsdangerous`-style signing; no + server-side session store. Delete `routes/auth.py` `/verify` entirely (it verified MediaMTX + stream creds, not API access). +- **Secrets on headless Linux** (keychain unavailable): `SecretsStore` protocol with two impls — + `FileSecretsStore` (default: `$SPECTRAX_STATE_DIR/secrets.yml`, mode `0600`, fail fast if + wider) and `KeyringSecretsStore` (macOS/desktop dev). Env vars rejected (leak into `/proc` + and the MediaMTX child process); systemd `LoadCredential` deferred as optional third impl. +- **MediaMTX**: switch stream auth to its native HTTP callback (`authMethod: http` → core's + `/internal/mediamtx-auth`); `rtspEncryption: strict`; stop embedding `user:pass` in HLS URLs + (serve HLS through the authenticated core or via the callback). +- API may bind `0.0.0.0` **only with auth in place**; until then default `127.0.0.1`. + +### 3.3 Process topology + +- Two services: `mediamtx` + `spectrax` (one Python process: uvicorn + detector threads + + recording manager). Threads are fine — OpenCV/torch release the GIL; frame-sharing between + detector and recorder makes a process split pure cost. The `DetectionEngine` interface is the + pre-cut seam if a split is ever needed. +- **Inversion vs today**: the API server becomes the main process (FastAPI lifespan owns + startup/shutdown of detector threads and the MediaMTX child); the CLI becomes a thin client + (`spectrax serve` in dev; systemd/launchd in production). Delete the `os._exit(0)` watchdog + and the `/paths` http.server; `run`/`detect`/`quick` collapse into `serve`. +- systemd unit for Linux (`Restart=on-failure`, `After=mediamtx.service`, journald); + running `spectrax serve` in a terminal remains the macOS/dev path. + +### 3.4 Repo structure + +``` +spectrax/ +├── pyproject.toml # replaces setup.py; requires-python >=3.11; ruff config moves here +├── config/spectrax.yml # was surveillance.yml (example, committed; no secrets) +├── src/spectrax/ +│ ├── cli.py # thin Typer app: serve, apikey, admin, reset, doctor +│ ├── app.py # FastAPI factory + lifespan (owns startup wiring) +│ ├── config.py # pydantic-settings model (validated, env-overridable) +│ ├── secrets.py # SecretsStore protocol + File/Keyring impls +│ ├── auth.py # api-key + session dependencies, key hashing +│ ├── events.py # EventBus + events table + SSE plumbing +│ ├── mediamtx/ # config writer, launcher/health, auth callback handler +│ ├── detection/ # engine.py, backends/ (extension point), config.py, stream.py +│ ├── recording/ # recorder.py, storage.py, db.py (was api.py) +│ └── api/ # routers: streams, recordings, events, stats, system, dashboard +│ └── deps.py # real FastAPI Depends() — replaces all set_* setters +├── dashboard/ # templates + static; talks only to /api/v1 (module #1) +└── tests/ +``` + +- Kills the `sys.path` hack and `PYTHONPATH=video-feed` gotcha permanently + (editable install via pyproject). +- Future modules: **separate repos** consuming the versioned API — not namespace packages + (those would couple modules to the core's Python env, contradicting the contract). + +### 3.5 Config + +- Single `spectrax.yml` → nested **pydantic-settings** model (`CamerasConfig`, + `DetectionConfig`, `RecordingConfig`, `ApiConfig`, `MediamtxConfig`). Invalid config fails + at startup with field-level errors. Env overrides native (`SPECTRAX_API__PORT=8080`). +- Secrets never in the config file — only in the `SecretsStore`. +- Fix the lookalike-filter ambiguity: type `detection.filters.classes` and + `recording.record_objects` as `list[str] | None` where `None` = "all" — the ambiguous `[]` + state becomes unrepresentable. +- Fixes the hidden re-load of a hardcoded detector-config path inside `start_detector`: + config loads once in `app.py` and is passed down. + +### 3.6 Cross-cutting + +- **Errors**: one hierarchy (`SpectraxError` → `NotFound`, `AuthError`, `ConfigError`, + `BackendError`) mapped by a single exception handler to `{"error": {"code", "message"}}` — + no stack traces or paths to clients (closes H2); internals logged server-side at ERROR. + Detector threads report into `/system/health` instead of dying silently. +- **SQLite**: WAL mode + single writer connection owned by the core (pattern already exists in + `recorder.get_database_connection`) to avoid contention between recorder, events, and API. +- **Testing**: the DI move is what makes the untested surface testable — + `create_app(config, fake_engine, tmp_db)` fixtures + httpx TestClient, no MediaMTX needed. + Auth unit tests first (hashing, revocation, cookie tamper); events integration test + (detection → row → SSE). Existing DB/recording tests carry over nearly unchanged. + Coverage ratchet to 80% as surface becomes testable. TDD for all new code. +- **CI**: GitHub Actions — ruff check + pytest on Python 3.11 and 3.12, macOS + Ubuntu runners. + +## 4. Phases (each independently shippable, one PR-branch per phase off `main`) + +### Phase 0 — Stop the bleeding (security, on the CURRENT layout) — 4–7 days + +**Status: implemented on branch `feat/phase-0-security` (merge pending).** + +Fixes both CRITICALs and the HIGHs before any restructuring, so security never waits on +architecture. Work stays on `video-feed/videofeed/` — **no** `create_app`, `src/spectrax/`, +or `/api/v1` in this phase. + +#### Phase 0 decisions (locked 2026-08-10) + +| Decision | Choice | +|---|---| +| Dashboard TLS / cookie `Secure` | Plain HTTP on trusted LAN; `Secure=False` by default. Always `HttpOnly` + `SameSite=Strict`. `Secure=True` only with explicit TLS/config flag. | +| Admin / API key storage | OS keyring only (`KEYCHAIN_SERVICE`). Labels: `admin_password_hash`, `session_signing_key`, `api_keys` (JSON blob). No mode-0600 file store (Phase 2). Never reuse MediaMTX stream secrets for API login. | +| Fail-closed bootstrap | No admin hash → login returns **503**; API is not open. Operator runs `surveillance admin set-password` first. | +| `/paths` removal | Delete unauthenticated side-server. Standalone `ui/dashboard.html` discovery is **unsupported** until same-origin dashboard (Phase 4). Do **not** treat `GET /api/streams` as a drop-in. | +| Bind default | `127.0.0.1`; explicit `0.0.0.0` only after auth lands. | +| CI bootstrap | Ubuntu + Python 3.11/3.12 required; macOS matrix deferred. Ruff scoped to new/touched paths. | +| Stream password reveal | After CLI redaction, `surveillance credentials show-stream` (TTY-only) prints publisher/viewer secrets once. | + +#### PR DAG + +``` +p0-ci ──┬──► p0-errors ──┐ + └──► p0-network ──┴──► p0-auth +``` + +1. **p0-ci** — `requirements-dev.txt` + slim `requirements-web-test.txt` (no torch); router-only + TestClient harness; characterization tests; `.github/workflows/ci.yml`. +2. **p0-errors** — global exception handler; kill `detail=str(e)`; XSS-safe templates; + fix recordings UI to call `/api/recordings`. +3. **p0-network** — bind `127.0.0.1`; delete `/paths` HTTPServer; `rtspEncryption: strict`; + redact CLI secrets; `credentials show-stream`. +4. **p0-auth** — session cookie + bearer `sx_` keys; rate-limited login/logout; admin DELETE; + gate all media paths; fail-closed if admin unset. + +Checklist (maps to original items): + +1. Bearer-key + session auth over the whole existing app; admin scope on + `DELETE /api/recordings/{id}` (C1, C2, H1) — **p0-auth**. +2. Delete `routes/auth.py` `/verify`; `secrets.compare_digest`; rate-limit login (H1) — **p0-auth**. +3. Global exception handler; kill every `detail=str(e)` (H2) — **p0-errors**. +4. Escape template interpolation — `textContent`/`createElement` (M1) — **p0-errors**. +5. `rtspEncryption: strict`; stop printing/embedding plaintext creds (H3, L1) — **p0-network**. +6. Delete the `/paths` `http.server` side-door (M2) — **p0-network**. +7. Default bind `127.0.0.1`; `0.0.0.0` only after auth — **p0-network** + **p0-auth**. +8. Characterization tests + auth tests + CI + `requirements-dev.txt` — **p0-ci** + **p0-auth**. + +*Ships: today's app, secured and under CI.* + +### Phase 1 — Repackage — days +Mechanical only, no logic changes. + +1. `pyproject.toml` (setuptools backend, `requires-python >= 3.11`, both console scripts); + delete `setup.py`, `cli.py` shim, and the `sys.path` hack. +2. `git mv` to `src/spectrax/` layout; fix imports; ruff config moves into pyproject. +3. Dependency refresh in tiers (web stack → CV stack → utilities), tests between tiers; + compiled lockfile (`uv pip compile`). **Re-verify `avc1` clip playback in-browser after + the OpenCV bump.** +4. `@app.on_event` → lifespan context; clear pydantic v2 deprecation warnings. + +*Ships: identical behavior, proper installable package, current deps.* + +### Phase 2 — Invert the process + DI — 1–2 weeks +The core re-architecture. Riskiest phase; mitigations: DI router-by-router behind unchanged +URLs, Phase 0 tests as the regression net. + +1. `create_app()` factory + lifespan owns startup (detector engine, MediaMTX child, DB); + routers take `Depends()` from `api/deps.py`; delete every `set_*` setter and `visualizer.py` + (logic splits into `app.py` + `detection/engine.py`). +2. CLI collapses to `serve` / `apikey` / `admin` / `reset` / `doctor`. +3. pydantic-settings config model (§3.5). +4. `SecretsStore` protocol: `FileSecretsStore` (Linux default) + `KeyringSecretsStore` (macOS); + migration path from existing keychain entries. +5. systemd unit files (replacing the stale `scripts/surveillance.service`); doc for launchd/dev + on macOS. +6. **Refactor-and-move** (keep): `api.py`→`recording/db.py`, `recorder.py`, `detector.py` + internals, `routes/files.py`. **Rewrite** (encode old topology): `surveillance.py`, + `visualizer.py`, `config.py`, `credentials.py`. +7. Route/config/credentials/detector-manager/CLI tests as each piece lands (TDD); + raise coverage ratchet toward 80%. + +*Ships: the headless-Linux-deployable core; macOS dev flow intact.* + +### Phase 3 — Module contract — 1 week +1. `/api/v1` prefix; pydantic response models on every endpoint. +2. `events` table + `EventBus` + SSE endpoint with `Last-Event-ID` replay; retention cleanup. +3. MediaMTX auth callback (`authMethod: http`); HLS creds-in-URL removed. +4. Publish the OpenAPI spec as the documented, frozen v1 contract. +5. Integration test: detection → event row → SSE delivery. + +*Ships: the first version modules can build against — freeze here.* + +### Phase 4 — Docs & first module — 1 week +1. Rewrite `docs/ARCHITECTURE.md` against the new reality; fix `README.md` (dead + `RECORDING_SETUP.md` link, auth setup, install flow, Python floor); regenerate + `docs/API.md` from OpenAPI with per-route auth requirements; verify + `CONFIGURATION_GUIDE.md`; update `routes/README.md` (or delete — DI makes it moot). +2. First real external module — e.g. a notifier consuming SSE — as the contract's + proof-of-life, in its own repo. +3. Dashboard incrementally rewritten to consume only `/api/v1`. + +*Ships: docs that match the code; a working example module.* + +### Deferred / extension points (explicitly NOT planned) +- **ARM/Pi support & `DetectionBackend` abstraction** (ONNX Runtime/NCNN backends, + lazy MJPEG encoding): dropped with the Pi requirement 2026-08-09. The + `detection/backends/` directory is kept as the seam; build it only if the target returns + or someone wants a lighter-than-torch install. +- Coral/Hailo accelerators, MQTT bridge, systemd `LoadCredential` secrets impl, + Playwright E2E for the dashboard (two templates today; revisit if the UI grows). + +## 5. Risks + +| Risk | Sev | Mitigation | +|---|---|---| +| Auth breaks MJPEG/dashboard (`` can't send headers) | HIGH | Cookie sessions from the start; browser-test before merging Phase 0 | +| CV-stack upgrade silently breaks detection/recording | HIGH | Tiered upgrades; `test_supervision_integration.py` gate; manual `quick` smoke; re-verify `avc1` playback | +| Phase 2 big-bang regressions in untested code | MED | Characterization tests first (Phase 0); DI router-by-router; one router per PR | +| SQLite write contention (recorder + events + API) | MED | WAL mode; single writer connection owned by core | +| Solo-maintainer stall | MED | Every phase independently shippable; Phase 0 alone materially improves security posture | +| Keyring→file secrets migration loses existing creds | LOW | `spectrax reset` regenerates; document migration in Phase 2 | + +## 6. Success criteria + +- [ ] Every endpoint requires auth (bearer key or session) except login; login rate-limited; + all secret comparisons constant-time; DELETE requires admin scope. +- [ ] No error response leaks internals; no XSS via stream names; `rtspEncryption: strict`; + no plaintext creds in URLs; `/paths` side-server gone. +- [ ] `pip install -e .` works from repo root; no `sys.path`/`PYTHONPATH` hacks; + Python ≥3.11; deps current with lockfile; CI green on macOS + Ubuntu. +- [ ] Zero mutable module globals in routers; `create_app()` factory; config validated at + startup; secrets work on headless Linux via `FileSecretsStore`. +- [ ] `/api/v1` versioned contract published (OpenAPI); SSE events with replay; + one external module consuming it. +- [ ] `pytest --cov=spectrax` ≥ 80%, enforced in CI. +- [ ] Docs match the code; `surveillance.service` replaced with a valid unit. + +## 7. Open questions + +1. ~~**Dashboard TLS**~~ — **Decided (Phase 0):** plain HTTP on trusted LAN; + `Secure=False` by default; `HttpOnly` + `SameSite=Strict` always. Self-signed dashboard + HTTPS deferred. +2. **Rename**: package becomes `spectrax` (from `videofeed`) in Phase 1 — confirm the name + before the `git mv`. +3. **`.enc` extension** in the file-serving allowlist looks like an abandoned encryption + feature — confirm dead and remove in Phase 1. +4. **MediaMTX ownership**: keep spawning it as a child of the core (simplest, current + behavior) vs separate systemd unit on Linux (survives core restarts)? Plan assumes + child-process in dev, separate unit in production — confirm in Phase 2. + +## 8. Phase 0 success criteria (ship gate) + +- [ ] Unauthenticated requests to `/status`, `/api/recordings`, `/video/stream`, + `/video/jpeg/{id}`, `/recordings/{file}`, and `DELETE /api/recordings/{id}` return + 401/403, not 2xx. +- [ ] Read session cookie or Bearer allows GETs (JSON + MJPEG + file media); only admin + can DELETE. +- [ ] `POST /auth/verify` is gone; login rate-limited; secret compares use + `secrets.compare_digest`. +- [ ] No 500 body leaks paths/exception text; missing recording → 404 not 500. +- [ ] Templates do not interpolate untrusted names into `innerHTML`; list/DELETE use + `/api/recordings`. +- [ ] `rtspEncryption: strict` with TLS; no `user:pass@` in CLI URLs; reveal command exists. +- [ ] `/paths` side-server gone; default bind `127.0.0.1`. +- [ ] CI green on Ubuntu 3.11/3.12 (web-test stack, no torch). +- [ ] `reset` wipes stream secrets **and** admin hash, API keys, session signing key. +- [ ] Fail-closed when admin password unset. diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..a4f377c --- /dev/null +++ b/ruff.toml @@ -0,0 +1,29 @@ +# Ruff configuration for SpectraX. +# Run from the repo root: `ruff check .` and `ruff format .` + +target-version = "py38" +line-length = 100 +extend-exclude = ["venv", ".venv", "video-feed/models"] + +[lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort (import ordering) + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line length is enforced by the formatter, not the linter + "B008", # FastAPI/Typer rely on function calls in argument defaults +] + +[lint.per-file-ignores] +"video-feed/tests/*" = ["F401", "F811"] # pytest fixtures look like unused/shadowed imports + +[format] +quote-style = "double" +indent-style = "space" diff --git a/video-feed/config/surveillance.yml b/video-feed/config/surveillance.yml index 5915820..25bd670 100644 --- a/video-feed/config/surveillance.yml +++ b/video-feed/config/surveillance.yml @@ -15,14 +15,12 @@ cameras: # NETWORK SETTINGS # ============================================================ network: - # Bind address for all services - # "127.0.0.1" = localhost only (secure, local access) - # "0.0.0.0" = all interfaces (LAN access, less secure) + # Bind address for MediaMTX and the dashboard + # "127.0.0.1" = localhost only (default after Phase 0) + # "0.0.0.0" = all interfaces (LAN access — only after admin password + auth are configured) bind: "127.0.0.1" - - # API port for camera path discovery - # Use for custom web app - api_port: 3333 + + # NOTE: api_port /paths side-server was removed in Phase 0 (unauthenticated). # ============================================================ # OBJECT DETECTION SETTINGS diff --git a/video-feed/pytest.ini b/video-feed/pytest.ini index 508d1e4..d3671a9 100644 --- a/video-feed/pytest.ini +++ b/video-feed/pytest.ini @@ -25,6 +25,7 @@ markers = detection: Object detection tests slow: Tests that take significant time to run requires_mediamtx: Tests that require MediaMTX to be installed + api: API/route characterization and auth tests (no torch/MediaMTX) # Logging log_cli = false diff --git a/video-feed/requirements-dev.txt b/video-feed/requirements-dev.txt new file mode 100644 index 0000000..206f421 --- /dev/null +++ b/video-feed/requirements-dev.txt @@ -0,0 +1,6 @@ +# Development / test dependencies (install on top of requirements-web-test.txt for API CI) +pytest==8.3.5 +pytest-cov==6.1.1 +pytest-asyncio==0.26.0 +httpx==0.28.1 +ruff==0.11.6 diff --git a/video-feed/requirements-web-test.txt b/video-feed/requirements-web-test.txt new file mode 100644 index 0000000..f1bb1b0 --- /dev/null +++ b/video-feed/requirements-web-test.txt @@ -0,0 +1,14 @@ +# Slim web stack for API/route tests (no torch / ultralytics / opencv). +# Full runtime install remains requirements.txt. + +typer==0.15.3 +PyYAML==6.0.2 +keyring==25.6.0 +fastapi==0.115.12 +uvicorn==0.34.2 +Jinja2==3.1.6 +starlette==0.46.2 +pydantic==2.11.4 +itsdangerous==2.2.0 +argon2-cffi==23.1.0 +python-multipart==0.0.20 diff --git a/video-feed/requirements.txt b/video-feed/requirements.txt index 6a8cab3..0ff48bc 100644 --- a/video-feed/requirements.txt +++ b/video-feed/requirements.txt @@ -15,6 +15,9 @@ fastapi==0.115.12 # Web framework uvicorn==0.34.2 # ASGI server Jinja2==3.1.6 # Template engine starlette==0.46.2 # FastAPI dependency +itsdangerous==2.2.0 # Signed session cookies (Phase 0 auth) +argon2-cffi==23.1.0 # Admin password hashing +python-multipart==0.0.20 # Form/body parsing # ============================================================ # Computer Vision & Object Detection diff --git a/video-feed/tests/conftest.py b/video-feed/tests/conftest.py index 7fc46d4..b64515d 100644 --- a/video-feed/tests/conftest.py +++ b/video-feed/tests/conftest.py @@ -1,10 +1,14 @@ """Pytest configuration and shared fixtures for tests.""" +from __future__ import annotations + import os import sys import tempfile -import pytest from pathlib import Path +from typing import Generator, Optional + +import pytest # Add parent directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -35,33 +39,241 @@ def test_recordings_dir(temp_dir): def sample_config(): """Provide sample configuration for testing.""" return { - 'cameras': ['video/test-camera'], - 'network': { - 'bind': '127.0.0.1', - 'api_port': 3333 + "cameras": ["video/test-camera"], + "network": { + "bind": "127.0.0.1", + }, + "detection": { + "enabled": True, + "port": 8080, + "model": "yolov8n.pt", + "confidence": 0.4, + "resolution": { + "width": 960, + "height": 540, + }, }, - 'detection': { - 'enabled': True, - 'port': 8080, - 'model': 'yolov8n.pt', - 'confidence': 0.4, - 'resolution': { - 'width': 960, - 'height': 540 - } + "recording": { + "enabled": True, + "min_confidence": 0.5, + "pre_buffer_seconds": 5, + "post_buffer_seconds": 5, + "max_storage_gb": 1.0, + "recordings_dir": "~/test-recordings", + "record_objects": [], }, - 'recording': { - 'enabled': True, - 'min_confidence': 0.5, - 'pre_buffer_seconds': 5, - 'post_buffer_seconds': 5, - 'max_storage_gb': 1.0, - 'recordings_dir': '~/test-recordings', - 'record_objects': [] + "security": { + "use_tls": False, + "tls_key": "", + "tls_cert": "", }, - 'security': { - 'use_tls': False, - 'tls_key': '', - 'tls_cert': '' - } } + + +def _reset_route_globals() -> None: + """Clear module-level set_* globals used by routers.""" + import videofeed.routes.files as files_routes + import videofeed.routes.recordings as recordings_routes + import videofeed.routes.statistics as statistics_routes + import videofeed.routes.video as video_routes + from videofeed import credentials as creds_mod + from videofeed.auth_gate import reset_auth_state, set_secure_cookie, set_signing_key_override + + if hasattr(files_routes, "reset_files_state"): + files_routes.reset_files_state() + else: + files_routes.recordings_directory = None + + if hasattr(recordings_routes, "reset_recordings_state"): + recordings_routes.reset_recordings_state() + else: + recordings_routes.recordings_api = None + recordings_routes.recordings_directory = None + + if hasattr(statistics_routes, "reset_statistics_state"): + statistics_routes.reset_statistics_state() + else: + statistics_routes.recordings_api = None + statistics_routes.detector_manager = None + + if hasattr(video_routes, "reset_video_state"): + video_routes.reset_video_state() + else: + video_routes.detector_manager = None + + creds_mod.reset_memory_store() + reset_auth_state() + set_signing_key_override(None) + set_secure_cookie(None) + + +def create_test_app( + *, + recordings_dir: Optional[str] = None, + recordings_api=None, + with_auth: bool = True, + admin_password: Optional[str] = "test-password-123", +): + """Build a FastAPI app with routers only (no visualizer/torch). + + Args: + recordings_dir: Path for file serving and DB. + recordings_api: Optional RecordingsAPI instance. + with_auth: Install AuthMiddleware. + admin_password: If set, store admin hash in memory secrets store. + """ + from fastapi import FastAPI, HTTPException, Request + from fastapi.responses import JSONResponse + + from videofeed.auth_gate import AuthMiddleware, set_secure_cookie, set_signing_key_override + from videofeed import credentials as creds_mod + from videofeed.routes import ( + auth_router, + files_router, + pages_router, + recordings_router, + statistics_router, + video_router, + ) + import videofeed.routes.files as files_routes + import videofeed.routes.recordings as recordings_routes + import videofeed.routes.statistics as statistics_routes + + # In-memory secrets for CI (no OS keyring) + creds_mod.use_memory_store(True) + set_signing_key_override("test-session-signing-key-32bytes!!") + set_secure_cookie(False) + + if admin_password: + creds_mod.set_admin_password(admin_password) + + app = FastAPI(title="Video Feed API Test") + app.state.secure_cookies = False + + if with_auth: + app.add_middleware(AuthMiddleware) + + @app.exception_handler(Exception) + async def unhandled_exception_handler(request: Request, exc: Exception): + if isinstance(exc, HTTPException): + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + return JSONResponse( + status_code=500, + content={"error": {"code": "internal", "message": "Internal server error"}}, + ) + + app.include_router(video_router) + app.include_router(pages_router) + app.include_router(files_router) + app.include_router(recordings_router) + app.include_router(statistics_router) + app.include_router(auth_router) + + if recordings_dir: + files_routes.set_recordings_directory(recordings_dir) + recordings_routes.set_recordings_directory(recordings_dir) + + if recordings_api is not None: + recordings_routes.set_recordings_api(recordings_api) + statistics_routes.set_recordings_api(recordings_api) + + return app + + +@pytest.fixture +def memory_secrets(): + """Enable in-memory keyring for a test, then reset.""" + from videofeed import credentials as creds_mod + from videofeed.auth_gate import reset_auth_state, set_signing_key_override + + store = creds_mod.use_memory_store(True) + set_signing_key_override("test-session-signing-key-32bytes!!") + yield store + _reset_route_globals() + + +@pytest.fixture +def api_client(test_recordings_dir, test_db_path) -> Generator: + """Authenticated TestClient with empty recordings DB.""" + from fastapi.testclient import TestClient + from videofeed.api import RecordingsAPI + + _reset_route_globals() + + # Create empty DB schema via RecordingsAPI if it has init; otherwise empty file + api = RecordingsAPI(db_path=test_db_path) + # Ensure tables if API exposes init — many code paths expect a real schema + if hasattr(api, "db_conn") and api.db_conn is not None: + try: + api.db_conn.execute( + """ + CREATE TABLE IF NOT EXISTS recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stream_id TEXT, + stream_name TEXT, + timestamp TEXT, + duration REAL, + confidence REAL, + objects_detected TEXT, + file_path TEXT, + thumbnail_path TEXT + ) + """ + ) + api.db_conn.commit() + except Exception: + pass + + app = create_test_app( + recordings_dir=test_recordings_dir, + recordings_api=api, + with_auth=True, + admin_password="test-password-123", + ) + with TestClient(app) as client: + yield client + + api.close() + _reset_route_globals() + + +@pytest.fixture +def api_client_no_auth(test_recordings_dir, test_db_path) -> Generator: + """TestClient without auth middleware (characterization of route bodies).""" + from fastapi.testclient import TestClient + from videofeed.api import RecordingsAPI + + _reset_route_globals() + api = RecordingsAPI(db_path=test_db_path) + if hasattr(api, "db_conn") and api.db_conn is not None: + try: + api.db_conn.execute( + """ + CREATE TABLE IF NOT EXISTS recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stream_id TEXT, + stream_name TEXT, + timestamp TEXT, + duration REAL, + confidence REAL, + objects_detected TEXT, + file_path TEXT, + thumbnail_path TEXT + ) + """ + ) + api.db_conn.commit() + except Exception: + pass + + app = create_test_app( + recordings_dir=test_recordings_dir, + recordings_api=api, + with_auth=False, + admin_password=None, + ) + with TestClient(app) as client: + yield client + + api.close() + _reset_route_globals() diff --git a/video-feed/tests/test_api_characterization.py b/video-feed/tests/test_api_characterization.py new file mode 100644 index 0000000..b6946cf --- /dev/null +++ b/video-feed/tests/test_api_characterization.py @@ -0,0 +1,132 @@ +"""API characterization tests (router-only app, no MediaMTX/torch).""" + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from tests.conftest import create_test_app, _reset_route_globals +from videofeed.api import RecordingsAPI + + +pytestmark = pytest.mark.api + + +@pytest.fixture +def client_with_files(test_recordings_dir, test_db_path): + _reset_route_globals() + api = RecordingsAPI(db_path=test_db_path) + if api.db_conn is not None: + api.db_conn.execute( + """ + CREATE TABLE IF NOT EXISTS recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stream_id TEXT, + stream_name TEXT, + timestamp TEXT, + duration REAL, + confidence REAL, + objects_detected TEXT, + file_path TEXT, + thumbnail_path TEXT + ) + """ + ) + api.db_conn.commit() + + # Seed a safe file and a disallowed extension + rec_dir = Path(test_recordings_dir) + (rec_dir / "clip.mp4").write_bytes(b"fake-mp4") + (rec_dir / "notes.txt").write_text("nope") + + app = create_test_app( + recordings_dir=test_recordings_dir, + recordings_api=api, + with_auth=True, + admin_password="test-password-123", + ) + with TestClient(app) as client: + # Login for authorized requests + r = client.post("/auth/login", json={"password": "test-password-123"}) + assert r.status_code == 200 + yield client + + api.close() + _reset_route_globals() + + +def test_path_traversal_blocked(client_with_files, test_recordings_dir): + # URL-encoded traversal stays on the /recordings route (raw ".." is normalized away) + r = client_with_files.get("/recordings/%2e%2e/%2e%2e/%2e%2e/etc/passwd") + assert r.status_code in (403, 404) + # Nested relative escape from a real prefix + r2 = client_with_files.get("/recordings/subdir/../../clip.mp4") + # Either denied as traversal or resolved within dir to clip — never leak outside + assert r2.status_code in (200, 403, 404) + + +def test_disallowed_extension_blocked(client_with_files): + r = client_with_files.get("/recordings/notes.txt") + assert r.status_code == 403 + + +def test_allowed_file_served(client_with_files): + r = client_with_files.get("/recordings/clip.mp4") + assert r.status_code == 200 + + +def test_empty_recordings_list(client_with_files): + r = client_with_files.get("/api/recordings") + assert r.status_code == 200 + data = r.json() + assert data["total"] == 0 + assert data["recordings"] == [] + assert "offset" in data and "limit" in data + + +def test_missing_recording_404(client_with_files): + r = client_with_files.get("/api/recordings/99999") + assert r.status_code == 404 + assert "not found" in r.json()["detail"].lower() + + +def test_delete_missing_recording_404(client_with_files): + r = client_with_files.delete("/api/recordings/99999") + assert r.status_code == 404 + + +def test_video_stream_503_without_detector(client_with_files): + r = client_with_files.get("/video/stream") + assert r.status_code == 503 + + +def test_pages_render(client_with_files): + for path in ("/", "/recordings.html", "/login"): + r = client_with_files.get(path) + assert r.status_code == 200 + assert "text/html" in r.headers.get("content-type", "") + + +def test_error_body_has_no_path_leak(client_with_files, monkeypatch, test_recordings_dir): + """Forced failure must not return absolute paths or exception strings.""" + import videofeed.routes.recordings as rec + + def boom(**kwargs): + raise RuntimeError(f"sqlite failed at {test_recordings_dir}/secret.db") + + monkeypatch.setattr(rec.recordings_api, "get_recordings", boom) + r = client_with_files.get("/api/recordings") + assert r.status_code == 500 + body = r.text + assert test_recordings_dir not in body + assert "secret.db" not in body + assert "sqlite failed" not in body + assert "Internal server error" in body + + +def test_verify_endpoint_gone(client_with_files): + r = client_with_files.post( + "/auth/verify", + json={"username": "viewer", "password": "x"}, + ) + assert r.status_code == 404 diff --git a/video-feed/tests/test_auth.py b/video-feed/tests/test_auth.py new file mode 100644 index 0000000..fa78812 --- /dev/null +++ b/video-feed/tests/test_auth.py @@ -0,0 +1,188 @@ +"""Auth gate tests: session cookie, bearer keys, rate limit, scopes.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from tests.conftest import create_test_app, _reset_route_globals +from videofeed import credentials as creds_mod +from videofeed.api import RecordingsAPI +from videofeed.auth_gate import ( + COOKIE_NAME, + LOGIN_RATE_LIMIT, + hash_api_key, +) + + +pytestmark = [pytest.mark.api, pytest.mark.unit] + + +@pytest.fixture +def auth_env(test_recordings_dir, test_db_path): + _reset_route_globals() + api = RecordingsAPI(db_path=test_db_path) + if api.db_conn is not None: + api.db_conn.execute( + """ + CREATE TABLE IF NOT EXISTS recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stream_id TEXT, + stream_name TEXT, + timestamp TEXT, + duration REAL, + confidence REAL, + objects_detected TEXT, + file_path TEXT, + thumbnail_path TEXT + ) + """ + ) + api.db_conn.commit() + + app = create_test_app( + recordings_dir=test_recordings_dir, + recordings_api=api, + with_auth=True, + admin_password="correct-horse-battery", + ) + with TestClient(app) as client: + yield client, api + + api.close() + _reset_route_globals() + + +def test_unauthenticated_api_returns_401(auth_env): + client, _ = auth_env + for path in ( + "/api/recordings", + "/video/stream", + "/video/jpeg/cam1", + ): + r = client.get(path) + assert r.status_code == 401, path + + +def test_login_success_sets_httponly_cookie(auth_env): + client, _ = auth_env + r = client.post("/auth/login", json={"password": "correct-horse-battery"}) + assert r.status_code == 200 + assert r.json()["authenticated"] is True + cookie = r.cookies.get(COOKIE_NAME) + assert cookie + # Subsequent request works + r2 = client.get("/api/recordings") + assert r2.status_code == 200 + + +def test_login_wrong_password(auth_env): + client, _ = auth_env + r = client.post("/auth/login", json={"password": "wrong-password"}) + assert r.status_code == 401 + + +def test_login_503_when_admin_unset(test_recordings_dir, test_db_path): + _reset_route_globals() + api = RecordingsAPI(db_path=test_db_path) + app = create_test_app( + recordings_dir=test_recordings_dir, + recordings_api=api, + with_auth=True, + admin_password=None, + ) + with TestClient(app) as client: + r = client.post("/auth/login", json={"password": "anything-long"}) + assert r.status_code == 503 + api.close() + _reset_route_globals() + + +def test_login_rate_limit(auth_env): + client, _ = auth_env + for _ in range(LOGIN_RATE_LIMIT): + r = client.post("/auth/login", json={"password": "wrong-password-xx"}) + assert r.status_code == 401 + r = client.post("/auth/login", json={"password": "wrong-password-xx"}) + assert r.status_code == 429 + + +def test_logout_clears_session(auth_env): + client, _ = auth_env + assert client.post("/auth/login", json={"password": "correct-horse-battery"}).status_code == 200 + assert client.get("/api/recordings").status_code == 200 + assert client.post("/auth/logout").status_code == 200 + assert client.get("/api/recordings").status_code == 401 + + +def test_bearer_read_key(auth_env): + client, _ = auth_env + raw = creds_mod.create_api_key("reader", scope="read") + r = client.get( + "/api/recordings", + headers={"Authorization": f"Bearer {raw}"}, + ) + assert r.status_code == 200 + + +def test_bearer_read_cannot_delete(auth_env): + client, _ = auth_env + raw = creds_mod.create_api_key("reader", scope="read") + r = client.delete( + "/api/recordings/1", + headers={"Authorization": f"Bearer {raw}"}, + ) + assert r.status_code == 403 + + +def test_bearer_admin_can_delete_missing(auth_env): + client, _ = auth_env + raw = creds_mod.create_api_key("admin-key", scope="admin") + r = client.delete( + "/api/recordings/99999", + headers={"Authorization": f"Bearer {raw}"}, + ) + # Admin authenticated; missing id → 404 + assert r.status_code == 404 + + +def test_revoked_key_rejected(auth_env): + client, _ = auth_env + raw = creds_mod.create_api_key("temp", scope="read") + keys = creds_mod.list_api_keys(include_revoked=True) + kid = keys[-1]["id"] + assert creds_mod.revoke_api_key(kid) + r = client.get( + "/api/recordings", + headers={"Authorization": f"Bearer {raw}"}, + ) + assert r.status_code == 401 + + +def test_tampered_cookie_rejected(auth_env): + client, _ = auth_env + client.cookies.set(COOKIE_NAME, "not-a-valid-signature") + r = client.get("/api/recordings") + assert r.status_code == 401 + + +def test_login_page_public(auth_env): + client, _ = auth_env + r = client.get("/login") + assert r.status_code == 200 + + +def test_password_hash_not_plaintext(memory_secrets): + creds_mod.set_admin_password("super-secret-password") + stored = creds_mod.get_admin_password_hash() + assert stored + assert "super-secret-password" not in stored + assert stored.startswith("$argon2") + + +def test_api_key_hash_storage(memory_secrets): + raw = creds_mod.create_api_key("x", scope="read") + entries = creds_mod.list_api_keys() + assert len(entries) == 1 + assert entries[0]["hash"] == hash_api_key(raw) + assert raw not in str(entries) diff --git a/video-feed/tests/test_config_security.py b/video-feed/tests/test_config_security.py new file mode 100644 index 0000000..0bc655b --- /dev/null +++ b/video-feed/tests/test_config_security.py @@ -0,0 +1,54 @@ +"""Unit tests for Phase 0 network/security config helpers.""" + +from io import StringIO +from contextlib import redirect_stdout + +import pytest + +from videofeed.config import create_config, SurveillanceConfig +from videofeed.utils import print_urls + + +pytestmark = pytest.mark.unit + + +def test_rtsp_encryption_strict_when_tls_present(): + cfg = create_config( + "127.0.0.1", + ["video/cam"], + { + "publish_user": "publisher", + "publish_pass": "pub-secret", + "read_user": "viewer", + "read_pass": "view-secret", + }, + tls_key="/tmp/key.pem", + tls_cert="/tmp/cert.pem", + ) + assert cfg["rtspEncryption"] == "strict" + + +def test_default_bind_is_loopback(): + sc = SurveillanceConfig() + assert sc.get_bind_address() == "127.0.0.1" + + +def test_print_urls_redacts_passwords(): + creds = { + "publish_user": "publisher", + "publish_pass": "super-pub-pass-XYZ", + "read_user": "viewer", + "read_pass": "super-view-pass-ABC", + } + buf = StringIO() + with redirect_stdout(buf): + print_urls("127.0.0.1", ["video/cam"], creds, rtsps=True) + # Ensure secrets are not interpolated into URLs in the function body + import inspect + + from videofeed import utils as u + + src = inspect.getsource(u.print_urls) + assert "user:pass@" not in src + assert "creds['publish_pass']" not in src + assert "creds['read_pass']" not in src diff --git a/video-feed/ui/README.md b/video-feed/ui/README.md index cbbc01a..6cf2157 100644 --- a/video-feed/ui/README.md +++ b/video-feed/ui/README.md @@ -1,43 +1,29 @@ # Basic UI - Standalone Dashboard -This directory contains a standalone HTML dashboard for quick access to your surveillance feeds. +This directory contains a standalone HTML dashboard for quick access to surveillance feeds. -## Usage +## Status (Phase 0) -### Option 1: Quick Launch (Recommended) -```bash -# From project root -./scripts/surveillance.sh dashboard -``` +**Unsupported for production use.** The unauthenticated `/paths` discovery server +(`localhost:3333`) was removed in Phase 0. Opening `dashboard.html` via `file://` or +cross-origin auto-discovery no longer works. -### Option 2: Direct Access -Open `dashboard.html` directly in your browser: -```bash -# From project root -open video-feed/ui/dashboard.html +Use the **integrated dashboard** served by the FastAPI app (same origin, session cookie): -# Or on Linux -xdg-open video-feed/ui/dashboard.html +```bash +# After: surveillance admin set-password +./scripts/surveillance.sh config +# Open http://127.0.0.1:8080/login ``` -## Features +A same-origin rewrite of this standalone UI is deferred to Phase 4. -- **Standalone**: No server required, works directly in browser -- **Multi-camera grid view**: View all cameras simultaneously -- **Auto-discovery**: Automatically detects available camera paths via API -- **Manual configuration**: Fallback to manual path entry if API unavailable +## Historical notes -## Configuration - -The dashboard connects to: -- **Paths API**: `http://localhost:3333/paths` (auto-discovery) -- **Video streams**: `http://localhost:8080/video/stream?feed={id}` - -Make sure your surveillance system is running before opening the dashboard: -```bash -./scripts/surveillance.sh config -``` +Previously this dashboard used: -## Note +- **Paths API**: `http://localhost:3333/paths` (removed) +- **Video streams**: `http://localhost:8080/video/stream?feed={id}` (now requires auth) -This is a simplified standalone version. For the full-featured web interface with recordings browser and advanced features, use the integrated dashboard at `http://localhost:8080` when running the surveillance system. +Do not point tools at `GET /api/streams` as a drop-in for `/paths` — the response shape differs +and the endpoint requires authentication. diff --git a/video-feed/videofeed/auth_gate.py b/video-feed/videofeed/auth_gate.py new file mode 100644 index 0000000..8d3f936 --- /dev/null +++ b/video-feed/videofeed/auth_gate.py @@ -0,0 +1,286 @@ +"""API authentication: signed session cookies and bearer API keys. + +Phase 0 auth for the existing FastAPI app. Secrets live in the OS keyring +(see credentials.py). Browser clients use HttpOnly session cookies so MJPEG + tags work without Authorization headers. Machine clients use +Authorization: Bearer sx_. +""" + +from __future__ import annotations + +import hashlib +import logging +import secrets +import time +from dataclasses import dataclass +from typing import Any, Callable, Optional + +from fastapi import HTTPException, Request, Response +from fastapi.responses import JSONResponse, RedirectResponse +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from starlette.middleware.base import BaseHTTPMiddleware + +from . import credentials as creds_mod + +logger = logging.getLogger(__name__) + +COOKIE_NAME = "spectrax_session" +SESSION_MAX_AGE = 60 * 60 * 12 # 12 hours +LOGIN_RATE_LIMIT = 10 # attempts +LOGIN_RATE_WINDOW = 60 # seconds + +# Paths that do not require authentication +PUBLIC_PATHS = frozenset({ + "/auth/login", + "/auth/logout", + "/login", + "/docs", + "/openapi.json", + "/redoc", +}) + +# In-memory login rate limiter: key -> list of attempt timestamps +_login_attempts: dict[str, list[float]] = {} + +# Test/override hooks +_force_secure_cookie: Optional[bool] = None +_signing_key_override: Optional[str] = None + + +@dataclass(frozen=True) +class AuthPrincipal: + """Authenticated caller.""" + + subject: str # "admin" or api key id/name + scope: str # "read" | "admin" + via: str # "session" | "bearer" + + +def reset_auth_state() -> None: + """Reset module state between tests.""" + global _login_attempts, _force_secure_cookie, _signing_key_override + _login_attempts = {} + _force_secure_cookie = None + _signing_key_override = None + + +def set_signing_key_override(key: Optional[str]) -> None: + """Inject session signing key for tests (bypasses keyring).""" + global _signing_key_override + _signing_key_override = key + + +def set_secure_cookie(secure: Optional[bool]) -> None: + """Override Secure cookie flag (None = derive from request).""" + global _force_secure_cookie + _force_secure_cookie = secure + + +def _get_signing_key() -> str: + if _signing_key_override is not None: + return _signing_key_override + return creds_mod.get_or_create_session_signing_key() + + +def _serializer() -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(_get_signing_key(), salt="spectrax-session-v1") + + +def create_session_token(scope: str = "admin") -> str: + """Create a signed session payload for the dashboard admin.""" + return _serializer().dumps({"sub": "admin", "scope": scope}) + + +def read_session_token(token: str) -> Optional[dict[str, Any]]: + """Validate and decode a session token. Returns None if invalid/expired.""" + try: + return _serializer().loads(token, max_age=SESSION_MAX_AGE) + except (BadSignature, SignatureExpired): + return None + + +def hash_api_key(raw_key: str) -> str: + """SHA-256 hex digest of a raw API key.""" + return hashlib.sha256(raw_key.encode("utf-8")).hexdigest() + + +def verify_api_key(raw_key: str) -> Optional[AuthPrincipal]: + """Look up a bearer key against the keyring blob. Constant-time compare.""" + entries = creds_mod.list_api_keys(include_revoked=True) + candidate = hash_api_key(raw_key) + for entry in entries: + stored = entry.get("hash", "") + if not stored: + continue + if secrets.compare_digest(candidate, stored): + if entry.get("revoked_at"): + return None + scope = entry.get("scope", "read") + if scope not in ("read", "admin"): + scope = "read" + return AuthPrincipal( + subject=entry.get("name") or entry.get("id", "apikey"), + scope=scope, + via="bearer", + ) + return None + + +def authenticate_request(request: Request) -> Optional[AuthPrincipal]: + """Resolve principal from Bearer header or session cookie.""" + auth_header = request.headers.get("Authorization") or "" + if auth_header.lower().startswith("bearer "): + token = auth_header[7:].strip() + if token: + principal = verify_api_key(token) + if principal is not None: + return principal + # Invalid bearer — do not fall through to cookie (explicit auth attempt) + return None + + cookie = request.cookies.get(COOKIE_NAME) + if cookie: + payload = read_session_token(cookie) + if payload and payload.get("sub"): + scope = payload.get("scope", "admin") + if scope not in ("read", "admin"): + scope = "read" + return AuthPrincipal( + subject=str(payload["sub"]), + scope=scope, + via="session", + ) + return None + + +def require_scope(principal: Optional[AuthPrincipal], needed: str) -> AuthPrincipal: + """Raise 401/403 if principal missing or under-scoped.""" + if principal is None: + raise HTTPException(status_code=401, detail="Authentication required") + if needed == "admin" and principal.scope != "admin": + raise HTTPException(status_code=403, detail="Admin scope required") + return principal + + +def check_login_rate_limit(client_key: str) -> None: + """Raise 429 if client has exceeded login attempt budget.""" + now = time.time() + window_start = now - LOGIN_RATE_WINDOW + attempts = [t for t in _login_attempts.get(client_key, []) if t >= window_start] + _login_attempts[client_key] = attempts + if len(attempts) >= LOGIN_RATE_LIMIT: + raise HTTPException(status_code=429, detail="Too many login attempts") + + +def record_login_attempt(client_key: str) -> None: + """Record a failed login attempt for rate limiting.""" + now = time.time() + attempts = _login_attempts.setdefault(client_key, []) + attempts.append(now) + + +def clear_login_attempts(client_key: str) -> None: + """Clear rate-limit history after a successful login.""" + _login_attempts.pop(client_key, None) + + +def cookie_secure_flag(request: Request) -> bool: + """Whether Set-Cookie should include Secure.""" + if _force_secure_cookie is not None: + return _force_secure_cookie + # Explicit config flag via request app state if present + flag = getattr(request.app.state, "secure_cookies", None) + if flag is not None: + return bool(flag) + return request.url.scheme == "https" + + +def set_session_cookie(response: Response, token: str, request: Request) -> None: + """Attach session cookie to a response.""" + response.set_cookie( + key=COOKIE_NAME, + value=token, + httponly=True, + samesite="strict", + secure=cookie_secure_flag(request), + max_age=SESSION_MAX_AGE, + path="/", + ) + + +def clear_session_cookie(response: Response) -> None: + """Expire the session cookie.""" + response.delete_cookie(key=COOKIE_NAME, path="/") + + +def is_public_path(path: str) -> bool: + """Return True if path may be accessed without auth.""" + if path in PUBLIC_PATHS: + return True + # Static-ish openapi assets under /docs + if path.startswith("/docs/") or path.startswith("/redoc"): + return True + return False + + +def wants_html(request: Request) -> bool: + """Heuristic: browser navigation wants HTML redirect to login.""" + accept = (request.headers.get("accept") or "").lower() + if "text/html" in accept and "application/json" not in accept.split(",")[0]: + return True + # Page routes without Accept still often navigated by browser + if request.method == "GET" and ( + path_is_page(request.url.path) + ): + return True + return False + + +def path_is_page(path: str) -> bool: + return path in ("/", "/recordings.html", "/login") + + +class AuthMiddleware(BaseHTTPMiddleware): + """Reject unauthenticated requests except public auth/login routes.""" + + async def dispatch(self, request: Request, call_next: Callable): + path = request.url.path + if is_public_path(path): + return await call_next(request) + + principal = authenticate_request(request) + if principal is None: + # Invalid bearer present + auth_header = request.headers.get("Authorization") or "" + if auth_header.lower().startswith("bearer "): + return JSONResponse( + status_code=401, + content={"detail": "Invalid or revoked API key"}, + ) + if wants_html(request) and request.method == "GET": + return RedirectResponse(url="/login", status_code=303) + return JSONResponse( + status_code=401, + content={"detail": "Authentication required"}, + ) + + request.state.principal = principal + return await call_next(request) + + +def get_principal(request: Request) -> Optional[AuthPrincipal]: + """Read principal attached by middleware (or re-authenticate).""" + principal = getattr(request.state, "principal", None) + if principal is not None: + return principal + return authenticate_request(request) + + +async def require_read(request: Request) -> AuthPrincipal: + """FastAPI dependency: any authenticated principal.""" + return require_scope(get_principal(request), "read") + + +async def require_admin(request: Request) -> AuthPrincipal: + """FastAPI dependency: admin scope only.""" + return require_scope(get_principal(request), "admin") diff --git a/video-feed/videofeed/config.py b/video-feed/videofeed/config.py index 7eb948d..fb300de 100644 --- a/video-feed/videofeed/config.py +++ b/video-feed/videofeed/config.py @@ -58,7 +58,8 @@ def create_config( } if tls_key and tls_cert: - config["rtspEncryption"] = "optional" + # Phase 0: strict RTSPS only — clients must use rtsps://:8322 + config["rtspEncryption"] = "strict" config["rtspServerKey"] = tls_key config["rtspServerCert"] = tls_cert @@ -137,8 +138,8 @@ def load_defaults(self): self.config_data = { 'cameras': DEFAULT_PATHS, 'network': { - 'bind': '0.0.0.0', - 'api_port': 3333 + 'bind': '127.0.0.1', + # api_port removed: unauthenticated /paths side-server deleted (Phase 0) }, 'detection': { 'enabled': True, @@ -188,12 +189,12 @@ def get_recording_config(self) -> Dict[str, Any]: return self.config_data.get('recording', {}) def get_bind_address(self) -> str: - """Get bind address.""" - return self.get_network_config().get('bind', '0.0.0.0') + """Get bind address (default loopback until explicitly opened).""" + return self.get_network_config().get('bind', '127.0.0.1') - def get_api_port(self) -> int: - """Get API port.""" - return self.get_network_config().get('api_port', 3333) + def get_api_port(self) -> Optional[int]: + """Legacy paths API port — always None after Phase 0 removal of /paths.""" + return self.get_network_config().get('api_port') def is_detection_enabled(self) -> bool: """Check if detection is enabled.""" diff --git a/video-feed/videofeed/credentials.py b/video-feed/videofeed/credentials.py index a9e450f..55f1397 100644 --- a/video-feed/videofeed/credentials.py +++ b/video-feed/videofeed/credentials.py @@ -1,10 +1,80 @@ -"""Credential management functionality for video-feed.""" +"""Credential management for stream (MediaMTX) and API (dashboard) secrets. +Stream publisher/viewer passwords and API admin/API-key material all live in the +OS keychain under KEYCHAIN_SERVICE. They are never mixed: stream secrets must +not be used for API login. +""" + +from __future__ import annotations + +import json import secrets +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + import keyring -from typing import Dict -from .constants import APP_NAME, KEYCHAIN_SERVICE +from .constants import KEYCHAIN_SERVICE + +# Keyring labels +LABEL_PUBLISHER = "publisher" +LABEL_VIEWER = "viewer" +LABEL_ADMIN_HASH = "admin_password_hash" +LABEL_SESSION_KEY = "session_signing_key" +LABEL_API_KEYS = "api_keys" + +# All labels wiped by reset +ALL_SECRET_LABELS = ( + LABEL_PUBLISHER, + LABEL_VIEWER, + LABEL_ADMIN_HASH, + LABEL_SESSION_KEY, + LABEL_API_KEYS, +) + +# In-memory store for tests (when set, keyring is bypassed) +_memory_store: Optional[Dict[str, str]] = None + + +def use_memory_store(enabled: bool = True) -> Dict[str, str]: + """Enable an in-memory secrets backend for tests. Returns the store dict.""" + global _memory_store + if enabled: + if _memory_store is None: + _memory_store = {} + return _memory_store + _memory_store = None + return {} + + +def reset_memory_store() -> None: + """Clear and disable the in-memory store.""" + global _memory_store + _memory_store = None + + +def _get_password(label: str) -> Optional[str]: + if _memory_store is not None: + return _memory_store.get(label) + return keyring.get_password(KEYCHAIN_SERVICE, label) + + +def _set_password(label: str, value: str) -> None: + if _memory_store is not None: + _memory_store[label] = value + return + keyring.set_password(KEYCHAIN_SERVICE, label, value) + + +def _delete_password(label: str) -> None: + if _memory_store is not None: + _memory_store.pop(label, None) + return + try: + keyring.delete_password(KEYCHAIN_SERVICE, label) + except keyring.errors.PasswordDeleteError: + pass def rand_secret() -> str: @@ -14,51 +84,169 @@ def rand_secret() -> str: def get_secret(label: str) -> str: """Fetch or generate a secret stored in the OS keychain.""" - secret = keyring.get_password(KEYCHAIN_SERVICE, label) + secret = _get_password(label) if not secret: secret = rand_secret() - keyring.set_password(KEYCHAIN_SERVICE, label, secret) + _set_password(label, secret) return secret def get_credentials() -> Dict[str, str]: - """Return a dictionary with publisher and viewer credentials.""" + """Return publisher and viewer stream credentials (MediaMTX).""" return { "publish_user": "publisher", - "publish_pass": get_secret("publisher"), + "publish_pass": get_secret(LABEL_PUBLISHER), "read_user": "viewer", - "read_pass": get_secret("viewer"), + "read_pass": get_secret(LABEL_VIEWER), } def reset_creds() -> None: - """Clear stored publisher/viewer credentials.""" - for label in ("publisher", "viewer"): - try: - keyring.delete_password(KEYCHAIN_SERVICE, label) - except keyring.errors.PasswordDeleteError: - pass + """Clear all stored secrets (stream + API + session).""" + for label in ALL_SECRET_LABELS: + _delete_password(label) + + +# --------------------------------------------------------------------------- +# Session signing key +# --------------------------------------------------------------------------- + +def get_or_create_session_signing_key() -> str: + """Return the session cookie signing key, generating once if missing.""" + existing = _get_password(LABEL_SESSION_KEY) + if existing: + return existing + key = secrets.token_urlsafe(32) + _set_password(LABEL_SESSION_KEY, key) + return key + + +# --------------------------------------------------------------------------- +# Admin password (argon2 hash) +# --------------------------------------------------------------------------- + +def hash_password(password: str) -> str: + """Hash a password with argon2.""" + from argon2 import PasswordHasher + + return PasswordHasher().hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + """Verify password against argon2 hash. Never raises for bad password.""" + from argon2 import PasswordHasher + from argon2.exceptions import VerifyMismatchError, InvalidHashError + + try: + return PasswordHasher().verify(password_hash, password) + except (VerifyMismatchError, InvalidHashError): + return False + + +def set_admin_password(password: str) -> None: + """Store argon2 hash of the admin dashboard password.""" + if not password or len(password) < 8: + raise ValueError("Admin password must be at least 8 characters") + _set_password(LABEL_ADMIN_HASH, hash_password(password)) + + +def get_admin_password_hash() -> Optional[str]: + """Return admin password hash, or None if not configured.""" + return _get_password(LABEL_ADMIN_HASH) + + +def verify_admin_password(password: str) -> bool: + """Check password against stored admin hash. False if unset or wrong.""" + stored = get_admin_password_hash() + if not stored: + return False + return verify_password(password, stored) + + +# --------------------------------------------------------------------------- +# API keys +# --------------------------------------------------------------------------- + +def _load_api_keys() -> List[Dict[str, Any]]: + raw = _get_password(LABEL_API_KEYS) + if not raw: + return [] + try: + data = json.loads(raw) + if isinstance(data, list): + return data + except json.JSONDecodeError: + pass + return [] + + +def _save_api_keys(entries: List[Dict[str, Any]]) -> None: + _set_password(LABEL_API_KEYS, json.dumps(entries)) + + +def create_api_key(name: str, scope: str = "read") -> str: + """Create an API key. Returns the raw key once (sx_...). Stores only the hash.""" + if scope not in ("read", "admin"): + raise ValueError("scope must be 'read' or 'admin'") + from .auth_gate import hash_api_key + + raw = "sx_" + secrets.token_urlsafe(32) + entry = { + "id": str(uuid.uuid4()), + "name": name, + "hash": hash_api_key(raw), + "scope": scope, + "created_at": datetime.now(timezone.utc).isoformat(), + "revoked_at": None, + } + entries = _load_api_keys() + entries.append(entry) + _save_api_keys(entries) + return raw + + +def list_api_keys(include_revoked: bool = False) -> List[Dict[str, Any]]: + """List API key metadata (never includes raw secrets).""" + entries = _load_api_keys() + if include_revoked: + return list(entries) + return [e for e in entries if not e.get("revoked_at")] + + +def revoke_api_key(key_id: str) -> bool: + """Revoke an API key by id. Returns True if found.""" + entries = _load_api_keys() + found = False + now = datetime.now(timezone.utc).isoformat() + for entry in entries: + if entry.get("id") == key_id or entry.get("name") == key_id: + if not entry.get("revoked_at"): + entry["revoked_at"] = now + found = True + if found: + _save_api_keys(entries) + return found def load_config_credentials(config_path) -> Dict[str, str]: """Load credentials from an existing mediamtx.yml file. - + Args: config_path: Path to existing mediamtx.yml file - + Returns: Dictionary of credentials - + Raises: typer.Exit: If configuration cannot be loaded """ import yaml import typer - + try: with open(config_path, "r") as f: config = yaml.safe_load(f) - + creds = {} if "authInternalUsers" in config: for user_info in config["authInternalUsers"]: @@ -70,14 +258,15 @@ def load_config_credentials(config_path) -> Dict[str, str]: elif perm.get("action") == "read": creds["read_user"] = user_info["user"] creds["read_pass"] = user_info["pass"] - - # Validate we have all required credentials + required_keys = ["publish_user", "publish_pass", "read_user", "read_pass"] if not all(k in creds for k in required_keys): - typer.secho(f"Missing required credentials in config", fg=typer.colors.RED) + typer.secho("Missing required credentials in config", fg=typer.colors.RED) raise typer.Exit(1) - + return creds + except typer.Exit: + raise except Exception as e: typer.secho(f"Failed to load credentials: {e}", fg=typer.colors.RED) - raise typer.Exit(1) + raise typer.Exit(1) from e diff --git a/video-feed/videofeed/routes/auth.py b/video-feed/videofeed/routes/auth.py index cecad4c..9cd67f8 100644 --- a/video-feed/videofeed/routes/auth.py +++ b/video-feed/videofeed/routes/auth.py @@ -1,42 +1,61 @@ -"""Authentication routes.""" +"""Authentication routes: login / logout (session cookie).""" -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from __future__ import annotations -from videofeed.credentials import get_credentials +from fastapi import APIRouter, HTTPException, Request, Response +from pydantic import BaseModel, Field + +from videofeed import credentials as creds_mod +from videofeed.auth_gate import ( + check_login_rate_limit, + clear_login_attempts, + clear_session_cookie, + create_session_token, + record_login_attempt, + set_session_cookie, +) router = APIRouter(prefix="/auth", tags=["authentication"]) -class UserCredentials(BaseModel): - """User credentials for authentication.""" - username: str - password: str - - -@router.post("/verify") -async def verify_credentials(user_creds: UserCredentials): - """Verify if credentials match those in the system keychain.""" - creds = get_credentials() - - # Check publisher credentials - if user_creds.username == creds["publish_user"] and user_creds.password == creds["publish_pass"]: - return { - "authenticated": True, - "user_type": "publisher", - "username": creds["publish_user"] - } - - # Check viewer credentials - if user_creds.username == creds["read_user"] and user_creds.password == creds["read_pass"]: - return { - "authenticated": True, - "user_type": "viewer", - "username": creds["read_user"] - } - - # Invalid credentials - raise HTTPException( - status_code=401, - detail="Invalid credentials" - ) +class LoginRequest(BaseModel): + """Dashboard login body.""" + + password: str = Field(..., min_length=1) + + +class LoginResponse(BaseModel): + """Successful login.""" + + authenticated: bool = True + scope: str = "admin" + + +@router.post("/login", response_model=LoginResponse) +async def login(body: LoginRequest, request: Request, response: Response): + """Authenticate admin password and set session cookie.""" + client_key = request.client.host if request.client else "unknown" + check_login_rate_limit(client_key) + + admin_hash = creds_mod.get_admin_password_hash() + if not admin_hash: + raise HTTPException( + status_code=503, + detail="Admin password not configured. Run: surveillance admin set-password", + ) + + if not creds_mod.verify_admin_password(body.password): + record_login_attempt(client_key) + raise HTTPException(status_code=401, detail="Invalid credentials") + + clear_login_attempts(client_key) + token = create_session_token(scope="admin") + set_session_cookie(response, token, request) + return LoginResponse(authenticated=True, scope="admin") + + +@router.post("/logout") +async def logout(response: Response): + """Clear the session cookie.""" + clear_session_cookie(response) + return {"authenticated": False} diff --git a/video-feed/videofeed/routes/files.py b/video-feed/videofeed/routes/files.py index 664352c..2f4ad4f 100644 --- a/video-feed/videofeed/routes/files.py +++ b/video-feed/videofeed/routes/files.py @@ -4,9 +4,11 @@ import os from pathlib import Path -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse +from videofeed.auth_gate import AuthPrincipal, require_read + router = APIRouter(prefix="/recordings", tags=["files"]) logger = logging.getLogger(__name__) @@ -21,58 +23,56 @@ def set_recordings_directory(directory: str): recordings_directory = directory +def reset_files_state(): + """Reset module globals (tests).""" + global recordings_directory + recordings_directory = None + + @router.get("/{file_path:path}") -async def serve_recording_file(file_path: str): +async def serve_recording_file( + file_path: str, + _principal: AuthPrincipal = Depends(require_read), +): """Serve a recording file (video or thumbnail) with security checks.""" global recordings_directory - - # Initialize recordings directory if not set + if not recordings_directory: - # Try to use the default location default_path = os.path.expanduser("~/video-feed-recordings") if os.path.exists(default_path): recordings_directory = default_path logger.info(f"Auto-initialized recordings directory to: {recordings_directory}") else: raise HTTPException(status_code=404, detail="Recording directory not configured") - + try: - # Convert to Path objects and resolve to absolute paths recordings_path = Path(recordings_directory).resolve() requested_path = (recordings_path / file_path).resolve() - - # ✅ CRITICAL: Ensure requested path is within recordings directory - # This prevents path traversal attacks like "../../../etc/passwd" + if not requested_path.is_relative_to(recordings_path): logger.warning(f"Path traversal attempt blocked: {file_path}") raise HTTPException(status_code=403, detail="Access denied") - - # Check if file exists + if not requested_path.exists(): - raise HTTPException(status_code=404, detail=f"File not found: {file_path}") - - # Check if it's a file (not a directory) + raise HTTPException(status_code=404, detail="File not found") + if not requested_path.is_file(): raise HTTPException(status_code=403, detail="Not a file") - - # ✅ Validate file extension (only allow expected types) - allowed_extensions = {'.mp4', '.jpg', '.jpeg', '.png', '.webm', '.enc'} + + allowed_extensions = {".mp4", ".jpg", ".jpeg", ".png", ".webm", ".enc"} if requested_path.suffix.lower() not in allowed_extensions: logger.warning(f"Unauthorized file type access attempt: {requested_path.suffix}") raise HTTPException(status_code=403, detail="File type not allowed") - - # Log access for audit + logger.info(f"File access: {file_path}") - + return FileResponse(requested_path) - + except ValueError as e: - # is_relative_to can raise ValueError logger.error(f"Path validation error: {e}") raise HTTPException(status_code=403, detail="Invalid path") except HTTPException: - # Re-raise HTTP exceptions raise except Exception as e: - logger.error(f"Error serving file {file_path}: {e}") + logger.error(f"Error serving file {file_path}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") diff --git a/video-feed/videofeed/routes/pages.py b/video-feed/videofeed/routes/pages.py index deac5d0..af6d573 100644 --- a/video-feed/videofeed/routes/pages.py +++ b/video-feed/videofeed/routes/pages.py @@ -12,10 +12,26 @@ templates = Jinja2Templates(directory=templates_path) +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + """Render the login page (public).""" + return templates.TemplateResponse("login.html", {"request": request}) + + @router.get("/", response_class=HTMLResponse) async def index(request: Request): """Render the main viewer page.""" - return templates.TemplateResponse("viewer.html", {"request": request}) + # Template expects feed context; empty defaults until detector injects real feeds + # (Phase 2 will pass detector state via Depends). + context = { + "request": request, + "feeds": {}, + "active_feed_id": None, + "active_feed_name": "No feed", + "active_feed_source": "", + "model": "", + } + return templates.TemplateResponse("viewer.html", context) @router.get("/recordings.html", response_class=HTMLResponse) diff --git a/video-feed/videofeed/routes/recordings.py b/video-feed/videofeed/routes/recordings.py index 876276b..0379320 100644 --- a/video-feed/videofeed/routes/recordings.py +++ b/video-feed/videofeed/routes/recordings.py @@ -4,7 +4,9 @@ import os from typing import Optional -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query + +from videofeed.auth_gate import AuthPrincipal, require_admin, require_read router = APIRouter(prefix="/api/recordings", tags=["recordings"]) @@ -27,51 +29,55 @@ def set_recordings_directory(directory: str): recordings_directory = directory +def reset_recordings_state(): + """Reset module globals (tests).""" + global recordings_api, recordings_directory + recordings_api = None + recordings_directory = None + + def initialize_recordings_api(): """Initialize the recordings API if not already initialized. - + Returns: bool: True if initialization was successful, False otherwise """ global recordings_api, recordings_directory - + if recordings_api is not None: return True - + try: from videofeed.api import RecordingsAPI - - # First check if recordings_directory is set + if recordings_directory: - # Ensure path is expanded properly expanded_dir = os.path.expanduser(recordings_directory) db_path = os.path.join(expanded_dir, "recordings.db") logger.info(f"Looking for database at: {db_path}") - + if os.path.exists(db_path): logger.info(f"Initializing recordings API with database: {db_path}") recordings_api = RecordingsAPI(db_path=db_path) logger.info("Successfully initialized recordings API") return True - - # If not found, try the default location in user's home directory + home_db_path = os.path.expanduser("~/video-feed-recordings/recordings.db") logger.info(f"Looking for database at home path: {home_db_path}") - + if os.path.exists(home_db_path): - logger.info(f"Initializing recordings API with database from home directory: {home_db_path}") + logger.info( + f"Initializing recordings API with database from home directory: {home_db_path}" + ) recordings_api = RecordingsAPI(db_path=home_db_path) - - # Also set the recordings_directory if it wasn't set before + if not recordings_directory: recordings_directory = os.path.dirname(home_db_path) logger.info(f"Setting recordings directory to: {recordings_directory}") - + logger.info("Successfully initialized recordings API from home directory") return True - - # If we get here, we couldn't find the database - logger.error(f"Database file not found in configured directory or home directory") + + logger.error("Database file not found in configured directory or home directory") return False except Exception as e: logger.error(f"Failed to initialize recordings API: {e}") @@ -88,17 +94,16 @@ async def get_recordings( object_type: Optional[str] = None, min_confidence: Optional[float] = None, sort_by: str = Query("timestamp", regex=r"^(timestamp|confidence|duration)$"), - sort_order: str = Query("desc", regex=r"^(asc|desc)$") + sort_order: str = Query("desc", regex=r"^(asc|desc)$"), + _principal: AuthPrincipal = Depends(require_read), ): """Get list of recordings from the database with filtering and sorting options.""" global recordings_api, recordings_directory - - # Try to initialize recordings API if needed + if not initialize_recordings_api(): raise HTTPException(status_code=503, detail="Recording API not initialized") - + try: - # Get recordings recordings = recordings_api.get_recordings( stream_id=stream_id, limit=limit, @@ -108,138 +113,165 @@ async def get_recordings( object_type=object_type, min_confidence=min_confidence, sort_by=sort_by, - sort_order=sort_order + sort_order=sort_order, ) - - # Get total count for pagination + total = recordings_api.get_recordings_count( stream_id=stream_id, start_date=start_date, end_date=end_date, object_type=object_type, - min_confidence=min_confidence + min_confidence=min_confidence, ) - - # Transform file paths to URLs + for rec in recordings: - if rec.get('file_path'): + if rec.get("file_path"): try: - # Ensure both paths are absolute before computing relative path - abs_file_path = os.path.abspath(os.path.expanduser(rec['file_path'])) - abs_recordings_dir = os.path.abspath(os.path.expanduser(recordings_directory)) + abs_file_path = os.path.abspath(os.path.expanduser(rec["file_path"])) + abs_recordings_dir = os.path.abspath( + os.path.expanduser(recordings_directory) + ) rel_path = os.path.relpath(abs_file_path, abs_recordings_dir) - rec['file_url'] = f"/recordings/{rel_path}" + rec["file_url"] = f"/recordings/{rel_path}" except Exception as e: logger.error(f"Error creating file URL: {e}") - rec['file_url'] = None - - if rec.get('thumbnail_path'): + rec["file_url"] = None + + if rec.get("thumbnail_path"): try: - # Ensure both paths are absolute before computing relative path - abs_thumb_path = os.path.abspath(os.path.expanduser(rec['thumbnail_path'])) - abs_recordings_dir = os.path.abspath(os.path.expanduser(recordings_directory)) + abs_thumb_path = os.path.abspath( + os.path.expanduser(rec["thumbnail_path"]) + ) + abs_recordings_dir = os.path.abspath( + os.path.expanduser(recordings_directory) + ) rel_path = os.path.relpath(abs_thumb_path, abs_recordings_dir) - rec['thumbnail_url'] = f"/recordings/{rel_path}" + rec["thumbnail_url"] = f"/recordings/{rel_path}" except Exception as e: logger.error(f"Error creating thumbnail URL: {e}") - rec['thumbnail_url'] = None - + rec["thumbnail_url"] = None + return { "total": total, "offset": offset, "limit": limit, - "recordings": recordings + "recordings": recordings, } + except HTTPException: + raise except Exception as e: - logger.error(f"Error retrieving recordings: {e}") - raise HTTPException(status_code=500, detail=str(e)) + logger.error(f"Error retrieving recordings: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") @router.get("/stats") async def get_recording_stats( stream_id: Optional[str] = None, start_date: Optional[str] = None, - end_date: Optional[str] = None + end_date: Optional[str] = None, + _principal: AuthPrincipal = Depends(require_read), ): """Get comprehensive statistics about recordings.""" global recordings_api - - # Try to initialize recordings API if needed + if not initialize_recordings_api(): raise HTTPException(status_code=503, detail="Recording API not initialized") - + try: stats = recordings_api.get_comprehensive_stats( stream_id=stream_id, start_date=start_date, - end_date=end_date + end_date=end_date, ) return stats + except HTTPException: + raise except Exception as e: - logger.error(f"Error retrieving recording statistics: {e}") - raise HTTPException(status_code=500, detail=str(e)) + logger.error(f"Error retrieving recording statistics: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") @router.delete("/{recording_id}") -async def delete_recording(recording_id: int): - """Delete a recording by ID.""" +async def delete_recording( + recording_id: int, + _principal: AuthPrincipal = Depends(require_admin), +): + """Delete a recording by ID. Requires admin scope.""" global recordings_api - - # Try to initialize recordings API if needed + if not initialize_recordings_api(): raise HTTPException(status_code=503, detail="Recording API not initialized") - + try: success = recordings_api.delete_recording(recording_id) if not success: - raise HTTPException(status_code=404, detail=f"Recording {recording_id} not found") - + raise HTTPException( + status_code=404, detail=f"Recording {recording_id} not found" + ) + return {"success": True, "message": f"Recording {recording_id} deleted"} + except HTTPException: + raise except Exception as e: - logger.error(f"Error deleting recording {recording_id}: {e}") - raise HTTPException(status_code=500, detail=str(e)) + logger.error(f"Error deleting recording {recording_id}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") @router.get("/{recording_id}") -async def get_recording_detail(recording_id: int): +async def get_recording_detail( + recording_id: int, + _principal: AuthPrincipal = Depends(require_read), +): """Get detailed information about a specific recording.""" global recordings_api, recordings_directory - - # Try to initialize recordings API if needed + if not initialize_recordings_api(): raise HTTPException(status_code=503, detail="Recording API not initialized") - + try: recording = recordings_api.get_recording_by_id(recording_id) if not recording: - raise HTTPException(status_code=404, detail=f"Recording {recording_id} not found") - - # Transform file paths to URLs - if recording.get('file_path'): + raise HTTPException( + status_code=404, detail=f"Recording {recording_id} not found" + ) + + if recording.get("file_path"): try: - # Ensure both paths are absolute before computing relative path - abs_file_path = os.path.abspath(os.path.expanduser(recording['file_path'])) - abs_recordings_dir = os.path.abspath(os.path.expanduser(recordings_directory)) + abs_file_path = os.path.abspath( + os.path.expanduser(recording["file_path"]) + ) + abs_recordings_dir = os.path.abspath( + os.path.expanduser(recordings_directory) + ) rel_path = os.path.relpath(abs_file_path, abs_recordings_dir) - recording['file_url'] = f"/recordings/{rel_path}" - logger.info(f"Created file URL: {recording['file_url']} from {recording['file_path']}") + recording["file_url"] = f"/recordings/{rel_path}" + logger.info( + f"Created file URL: {recording['file_url']} from {recording['file_path']}" + ) except Exception as e: logger.error(f"Error creating file URL: {e}") - recording['file_url'] = None - - if recording.get('thumbnail_path'): + recording["file_url"] = None + + if recording.get("thumbnail_path"): try: - # Ensure both paths are absolute before computing relative path - abs_thumb_path = os.path.abspath(os.path.expanduser(recording['thumbnail_path'])) - abs_recordings_dir = os.path.abspath(os.path.expanduser(recordings_directory)) + abs_thumb_path = os.path.abspath( + os.path.expanduser(recording["thumbnail_path"]) + ) + abs_recordings_dir = os.path.abspath( + os.path.expanduser(recordings_directory) + ) rel_path = os.path.relpath(abs_thumb_path, abs_recordings_dir) - recording['thumbnail_url'] = f"/recordings/{rel_path}" - logger.info(f"Created thumbnail URL: {recording['thumbnail_url']} from {recording['thumbnail_path']}") + recording["thumbnail_url"] = f"/recordings/{rel_path}" + logger.info( + f"Created thumbnail URL: {recording['thumbnail_url']} from {recording['thumbnail_path']}" + ) except Exception as e: logger.error(f"Error creating thumbnail URL: {e}") - recording['thumbnail_url'] = None - + recording["thumbnail_url"] = None + return recording + except HTTPException: + raise except Exception as e: - logger.error(f"Error retrieving recording {recording_id}: {e}") - raise HTTPException(status_code=500, detail=str(e)) + logger.error(f"Error retrieving recording {recording_id}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/video-feed/videofeed/routes/statistics.py b/video-feed/videofeed/routes/statistics.py index 62b0e39..12ac6d5 100644 --- a/video-feed/videofeed/routes/statistics.py +++ b/video-feed/videofeed/routes/statistics.py @@ -3,7 +3,9 @@ import logging from typing import Optional -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query + +from videofeed.auth_gate import AuthPrincipal, require_read router = APIRouter(prefix="/api", tags=["statistics"]) @@ -26,33 +28,36 @@ def set_detector_manager(manager): detector_manager = manager +def reset_statistics_state(): + """Reset module globals (tests).""" + global recordings_api, detector_manager + recordings_api = None + detector_manager = None + + def initialize_recordings_api(): - """Initialize the recordings API if not already initialized. - - Returns: - bool: True if initialization was successful, False otherwise - """ + """Initialize the recordings API if not already initialized.""" global recordings_api - + if recordings_api is not None: return True - + try: from videofeed.api import RecordingsAPI import os - - # Try the default location in user's home directory + home_db_path = os.path.expanduser("~/video-feed-recordings/recordings.db") logger.info(f"Looking for database at home path: {home_db_path}") - + if os.path.exists(home_db_path): - logger.info(f"Initializing recordings API with database from home directory: {home_db_path}") + logger.info( + f"Initializing recordings API with database from home directory: {home_db_path}" + ) recordings_api = RecordingsAPI(db_path=home_db_path) logger.info("Successfully initialized recordings API from home directory") return True - - # If we get here, we couldn't find the database - logger.error(f"Database file not found in home directory") + + logger.error("Database file not found in home directory") return False except Exception as e: logger.error(f"Failed to initialize recordings API: {e}") @@ -66,135 +71,143 @@ async def get_alerts( start_date: Optional[str] = None, end_date: Optional[str] = None, object_type: Optional[str] = None, - min_confidence: float = Query(0.5, ge=0, le=1.0) + min_confidence: float = Query(0.5, ge=0, le=1.0), + _principal: AuthPrincipal = Depends(require_read), ): """Get detection alerts from recordings, for event monitoring.""" global recordings_api - - # Try to initialize recordings API if needed + if not initialize_recordings_api(): raise HTTPException(status_code=503, detail="Recording API not initialized") - + try: - # Get alerts alerts = recordings_api.get_alerts( limit=limit, offset=offset, start_date=start_date, end_date=end_date, object_type=object_type, - min_confidence=min_confidence + min_confidence=min_confidence, ) - - # Get total count for pagination + total = recordings_api.get_alerts_count( start_date=start_date, end_date=end_date, object_type=object_type, - min_confidence=min_confidence + min_confidence=min_confidence, ) - - # Transform file paths to URLs + import os + recordings_directory = os.path.expanduser("~/video-feed-recordings") - + for alert in alerts: - if alert.get('thumbnail_path'): + if alert.get("thumbnail_path"): try: - # Ensure both paths are absolute before computing relative path - abs_thumb_path = os.path.abspath(os.path.expanduser(alert['thumbnail_path'])) - abs_recordings_dir = os.path.abspath(os.path.expanduser(recordings_directory)) + abs_thumb_path = os.path.abspath( + os.path.expanduser(alert["thumbnail_path"]) + ) + abs_recordings_dir = os.path.abspath( + os.path.expanduser(recordings_directory) + ) rel_path = os.path.relpath(abs_thumb_path, abs_recordings_dir) - alert['thumbnail_url'] = f"/recordings/{rel_path}" + alert["thumbnail_url"] = f"/recordings/{rel_path}" except Exception as e: logger.error(f"Error creating thumbnail URL for alert: {e}") - alert['thumbnail_url'] = None - + alert["thumbnail_url"] = None + return { "total": total, "offset": offset, "limit": limit, - "alerts": alerts + "alerts": alerts, } + except HTTPException: + raise except Exception as e: - logger.error(f"Error retrieving alerts: {e}") - raise HTTPException(status_code=500, detail=str(e)) + logger.error(f"Error retrieving alerts: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") @router.get("/stats/objects") async def get_object_stats( start_date: Optional[str] = None, end_date: Optional[str] = None, - stream_id: Optional[str] = None + stream_id: Optional[str] = None, + _principal: AuthPrincipal = Depends(require_read), ): """Get statistics about detected objects over time.""" global recordings_api - - # Try to initialize recordings API if needed + if not initialize_recordings_api(): raise HTTPException(status_code=503, detail="Recording API not initialized") - + try: stats = recordings_api.get_object_stats( start_date=start_date, end_date=end_date, - stream_id=stream_id + stream_id=stream_id, ) return {"stats": stats} + except HTTPException: + raise except Exception as e: - logger.error(f"Error retrieving object statistics: {e}") - raise HTTPException(status_code=500, detail=str(e)) + logger.error(f"Error retrieving object statistics: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") @router.get("/stats/times") async def get_time_stats( object_type: Optional[str] = None, days: int = Query(7, gt=0, le=90), - stream_id: Optional[str] = None + stream_id: Optional[str] = None, + _principal: AuthPrincipal = Depends(require_read), ): """Get detection statistics by time of day.""" global recordings_api - - # Try to initialize recordings API if needed + if not initialize_recordings_api(): raise HTTPException(status_code=503, detail="Recording API not initialized") - + try: stats = recordings_api.get_time_stats( object_type=object_type, days=days, - stream_id=stream_id + stream_id=stream_id, ) return {"stats": stats} + except HTTPException: + raise except Exception as e: - logger.error(f"Error retrieving time statistics: {e}") - raise HTTPException(status_code=500, detail=str(e)) + logger.error(f"Error retrieving time statistics: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") @router.get("/streams") -async def get_streams(): +async def get_streams( + _principal: AuthPrincipal = Depends(require_read), +): """Get list of all video streams with recording statistics.""" global detector_manager, recordings_api - + if detector_manager is None: raise HTTPException(status_code=503, detail="Detector manager not initialized") - - # Try to initialize recordings API if needed + if not initialize_recordings_api(): - # If we can't initialize, just return streams without recording stats streams = detector_manager.get_detector_status() for stream in streams.values(): - stream['recording_stats'] = None + stream["recording_stats"] = None return {"streams": list(streams.values())} - + try: streams = detector_manager.get_detector_status() for stream_id, stream in streams.items(): - # Get stats for this stream stats = recordings_api.get_stream_stats(stream_id) - stream['recording_stats'] = stats - + stream["recording_stats"] = stats + return {"streams": list(streams.values())} + except HTTPException: + raise except Exception as e: - logger.error(f"Error retrieving streams: {e}") - raise HTTPException(status_code=500, detail=str(e)) + logger.error(f"Error retrieving streams: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/video-feed/videofeed/routes/video.py b/video-feed/videofeed/routes/video.py index f8ca323..47101a7 100644 --- a/video-feed/videofeed/routes/video.py +++ b/video-feed/videofeed/routes/video.py @@ -4,9 +4,11 @@ import io from typing import Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse +from videofeed.auth_gate import AuthPrincipal, require_read + router = APIRouter(prefix="/video", tags=["video"]) # Global detector manager reference (set by visualizer) @@ -19,27 +21,38 @@ def set_detector_manager(manager): detector_manager = manager +def reset_video_state(): + """Reset module globals (tests).""" + global detector_manager + detector_manager = None + + @router.get("/stream") -async def video_feed(feed: Optional[str] = None): +async def video_feed( + feed: Optional[str] = None, + _principal: AuthPrincipal = Depends(require_read), +): """Stream MJPEG video feed with object detection overlay.""" global detector_manager if detector_manager is None: raise HTTPException(status_code=503, detail="Detector manager not initialized") - + return StreamingResponse( generate_frames(feed), - media_type="multipart/x-mixed-replace; boundary=frame" + media_type="multipart/x-mixed-replace; boundary=frame", ) @router.get("/jpeg/{detector_id}") -async def video_frame(detector_id: str): +async def video_frame( + detector_id: str, + _principal: AuthPrincipal = Depends(require_read), +): """Get a single frame as JPEG from a specific detector.""" global detector_manager if detector_manager is None: raise HTTPException(status_code=503, detail="Detector manager not initialized") - - # Get a single frame as JPEG + frame_bytes = detector_manager.get_frame_jpeg(detector_id) return StreamingResponse(content=io.BytesIO(frame_bytes), media_type="image/jpeg") diff --git a/video-feed/videofeed/surveillance.py b/video-feed/videofeed/surveillance.py index c4d814c..55b4c5d 100644 --- a/video-feed/videofeed/surveillance.py +++ b/video-feed/videofeed/surveillance.py @@ -7,12 +7,10 @@ import time import sys import os -import json from pathlib import Path from typing import List, Optional, Dict import typer import yaml -from http.server import HTTPServer, BaseHTTPRequestHandler # Add the parent directory to sys.path to make videofeed importable parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -20,34 +18,33 @@ sys.path.insert(0, parent_dir) # Now import from videofeed -from videofeed.credentials import get_credentials, load_config_credentials, reset_creds +from videofeed.credentials import ( + get_credentials, + load_config_credentials, + reset_creds, + set_admin_password, + create_api_key, + list_api_keys, + revoke_api_key, +) from videofeed.config import write_cfg, load_config_paths, SurveillanceConfig -from videofeed.utils import detect_host_ip, check_mediamtx_installed, launch_mediamtx, print_urls +from videofeed.utils import ( + detect_host_ip, + check_mediamtx_installed, + launch_mediamtx, + print_urls, + show_stream_credentials, +) from videofeed.visualizer import start_visualizer from videofeed.constants import DEFAULT_PATHS app = typer.Typer(add_completion=False) - -class PathsAPIHandler(BaseHTTPRequestHandler): - """Simple HTTP handler for paths API.""" - - def __init__(self, *args, paths=None, **kwargs): - self.paths = paths - super().__init__(*args, **kwargs) - - def do_GET(self): - """Handle GET requests.""" - if self.path == "/paths": - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - - data = {"count": len(self.paths), "paths": self.paths} - self.wfile.write(json.dumps(data).encode()) - else: - self.send_response(404) - self.end_headers() +admin_app = typer.Typer(help="Admin dashboard password management") +apikey_app = typer.Typer(help="API key management for machine clients") +credentials_app = typer.Typer(help="Reveal stream credentials (TTY)") +app.add_typer(admin_app, name="admin") +app.add_typer(apikey_app, name="apikey") +app.add_typer(credentials_app, name="credentials") class SurveillanceSystem: @@ -56,8 +53,6 @@ class SurveillanceSystem: def __init__(self): self.mediamtx_process = None self.detector_thread = None - self.api_server = None - self.api_thread = None self.running = False self.config = {} @@ -68,7 +63,6 @@ def start_streaming_server( config_path: Optional[Path], tls_key: Optional[Path], tls_cert: Optional[Path], - api_port: Optional[int] ) -> Dict: """Start the MediaMTX streaming server.""" check_mediamtx_installed("mediamtx") @@ -124,35 +118,13 @@ def start_streaming_server( self.config = { "creds": creds, "paths": config_paths, - "host_ip": detect_host_ip(), - "api_port": api_port, + "host_ip": detect_host_ip() if bind in ("0.0.0.0", "::") else bind, + "bind": bind, "use_rtsps": tls_key is not None and tls_cert is not None } - # Start API server if port is specified (silent) - if api_port: - self.start_api_server(api_port) - return self.config - def start_api_server(self, port: int): - """Start the API server for path discovery.""" - # Create a handler class with access to paths - paths = self.config["paths"] - - def handler_factory(*args, **kwargs): - return PathsAPIHandler(*args, paths=paths, **kwargs) - - # Start the server in a separate thread - self.api_server = HTTPServer(("0.0.0.0", port), handler_factory) - self.api_thread = threading.Thread(target=self.api_server.serve_forever, daemon=True) - self.api_thread.start() - - # API server starts silently - will be shown in final status - # typer.echo(f"🔍 API server started on port {port}") - # typer.echo(f" • Local: http://127.0.0.1:{port}/paths") - # typer.echo(f" • Network: http://{self.config['host_ip']}:{port}/paths") - def start_detector( self, host: str, @@ -298,14 +270,21 @@ def print_status(self): typer.echo(f" Username: {self.config['creds']['read_user']}") typer.echo(f" Password: {self.config['creds']['read_pass']}") typer.echo() - # Show example for first camera + # Show example for first camera (no embedded passwords) if self.config["paths"]: camera_name = self.config["paths"][0].split('/')[-1] if self.config["use_rtsps"]: - viewer_url = f"rtsps://{self.config['creds']['read_user']}:{self.config['creds']['read_pass']}@{self.config['host_ip']}:8322/{self.config['paths'][0]}" + viewer_url = f"rtsps://{self.config['host_ip']}:8322/{self.config['paths'][0]}" else: - viewer_url = f"rtsp://{self.config['creds']['read_user']}:{self.config['creds']['read_pass']}@{self.config['host_ip']}:8554/{self.config['paths'][0]}" - typer.secho(f" Example ({camera_name}): {viewer_url}", fg=typer.colors.BRIGHT_BLACK) + viewer_url = f"rtsp://{self.config['host_ip']}:8554/{self.config['paths'][0]}" + typer.secho( + f" Example ({camera_name}): {viewer_url}", + fg=typer.colors.BRIGHT_BLACK, + ) + typer.secho( + " Passwords: surveillance credentials show-stream", + fg=typer.colors.BRIGHT_BLACK, + ) typer.echo() # Print recording info if enabled @@ -327,24 +306,19 @@ def print_status(self): # Advanced URLs (collapsed) typer.secho("🔗 ADVANCED", fg=typer.colors.BRIGHT_BLACK, bold=True) - # All stream URLs with credentials if len(self.config["paths"]) > 1: - typer.echo(f" All stream URLs:") + typer.echo(" All stream URLs (no passwords embedded):") for path in self.config["paths"]: camera_name = path.split('/')[-1] if self.config["use_rtsps"]: - url = f"rtsps://{self.config['creds']['read_user']}:{self.config['creds']['read_pass']}@{self.config['host_ip']}:8322/{path}" + url = f"rtsps://{self.config['host_ip']}:8322/{path}" else: - url = f"rtsp://{self.config['creds']['read_user']}:{self.config['creds']['read_pass']}@{self.config['host_ip']}:8554/{path}" + url = f"rtsp://{self.config['host_ip']}:8554/{path}" typer.echo(f" • {camera_name}: {url}") typer.echo() - # HLS streaming typer.echo(f" HLS streaming: http://{self.config['host_ip']}:8888/[stream-path]/index.m3u8") - - # API endpoint - if self.config.get("api_port"): - typer.echo(f" Paths API: http://{self.config['host_ip']}:{self.config['api_port']}/paths") + typer.echo(" Stream passwords: surveillance credentials show-stream") typer.echo("\n" + "="*70) typer.secho("Press Ctrl+C to stop", fg=typer.colors.BRIGHT_BLACK) @@ -360,14 +334,8 @@ def shutdown(self): typer.echo(" ✓ Streaming server stopped") # Detector thread will stop automatically as it's daemon - # We don't need to join it since it's a daemon thread typer.echo(" ✓ Object detection stopped") - # Shutdown API server if running - if self.api_server: - self.api_server.shutdown() - typer.echo(" ✓ API server stopped") - # Clean up temp directory if it exists if hasattr(self, 'temp_dir'): import shutil @@ -403,7 +371,6 @@ def config( confidence=config.get_detection_confidence(), width=config.get_detection_resolution()[0], height=config.get_detection_resolution()[1], - api_port=config.get_api_port(), tls_key=tls_key, tls_cert=tls_cert, recording=config.is_recording_enabled(), @@ -423,7 +390,10 @@ def start( "--path", "-p", help="Camera stream paths" ), - bind: str = typer.Option("0.0.0.0", help="Bind IP address"), + bind: str = typer.Option( + "127.0.0.1", + help="Bind IP for MediaMTX (default loopback; use 0.0.0.0 for LAN after auth is configured)", + ), config: Optional[Path] = typer.Option(None, "--config", "-c", help="Custom config file"), detector: bool = typer.Option(True, "--detector/--no-detector", help="Enable object detection"), detector_port: int = typer.Option(8080, "--detector-port", help="Object detection web port"), @@ -431,7 +401,6 @@ def start( confidence: float = typer.Option(0.4, "--confidence", help="Detection confidence"), width: int = typer.Option(960, "--width", help="Video width"), height: int = typer.Option(540, "--height", help="Video height"), - api_port: Optional[int] = typer.Option(3333, "--api-port", help="API port for paths"), tls_key: Optional[Path] = typer.Option(None, help="TLS key path"), tls_cert: Optional[Path] = typer.Option(None, help="TLS certificate path"), recording: bool = typer.Option(True, "--recording/--no-recording", help="Enable recording"), @@ -458,14 +427,13 @@ def start( config_path=config, tls_key=tls_key, tls_cert=tls_cert, - api_port=api_port ) # Start detector if enabled if detector: time.sleep(1) # Give server a moment to stabilize system.start_detector( - host="0.0.0.0", + host=bind, port=detector_port, model=model, confidence=confidence, @@ -504,27 +472,31 @@ def quick( typer.secho(f"🚀 Quick starting surveillance with {cameras} camera(s)...", fg=typer.colors.GREEN, bold=True) - # Call start with defaults + # Call start with defaults (loopback bind; no /paths side-server) start( paths=paths, - bind="0.0.0.0", + bind="127.0.0.1", detector=detector, detector_port=8080, - api_port=3333 ) @app.command() def run( paths: List[str] = typer.Option(DEFAULT_PATHS, "--path", "-p", help="Logical RTSP path(s) to publish/view. Can be specified multiple times."), - bind: str = typer.Option("0.0.0.0", help="Bind IP (default), listens on all interfaces (LAN + localhost); use 127.0.0.1 to restrict to local only."), + bind: str = typer.Option( + "127.0.0.1", + help="Bind IP (default loopback; use 0.0.0.0 for LAN).", + ), config: Optional[Path] = typer.Option(None, "--config", "-c", help="Path to pre-made mediamtx.yml"), tls_key: Optional[Path] = typer.Option(None, help="Path to TLS private key for RTSPS."), tls_cert: Optional[Path] = typer.Option(None, help="Path to TLS certificate for RTSPS."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Show server configuration details."), - api_port: Optional[int] = typer.Option(None, "--api-port", "-a", help="Port for JSON status API."), ) -> None: """Start RTSP/HLS micro-server and display connection info (no object detection).""" + import contextlib + import tempfile + check_mediamtx_installed("mediamtx") # Use default TLS paths if not provided @@ -562,8 +534,6 @@ def run( config_paths = load_config_paths(cfg_path) temp_context = contextlib.nullcontext() else: - import tempfile - import contextlib temp_context = tempfile.TemporaryDirectory(prefix="video-feed-") with temp_context as tmpdir: @@ -576,7 +546,12 @@ def run( if verbose: typer.secho("MediaMTX Configuration:", fg=typer.colors.BRIGHT_BLUE, bold=True) typer.secho(f"Config file: {cfg_path}", fg=typer.colors.BLUE) - typer.echo(cfg_path.read_text()) + # Redact passwords from verbose dump + redacted = cfg_path.read_text() + for secret in (creds.get("publish_pass"), creds.get("read_pass")): + if secret: + redacted = redacted.replace(secret, "***") + typer.echo(redacted) server = launch_mediamtx(cfg_path) typer.echo("⏳ Starting MediaMTX ...") @@ -585,31 +560,9 @@ def run( except subprocess.TimeoutExpired: pass # Expected: server is running - host_ip = detect_host_ip() + host_ip = detect_host_ip() if bind in ("0.0.0.0", "::") else bind print_urls(host_ip, config_paths, creds, rtsps=use_rtsps) - # JSON status API endpoint - if api_port: - class StatusHandler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path == "/paths": - data = {"count": len(config_paths), "paths": config_paths} - resp = json.dumps(data) - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(resp.encode()) - else: - self.send_response(404) - self.end_headers() - api_server = HTTPServer(("0.0.0.0", api_port), StatusHandler) - - threading.Thread(target=api_server.serve_forever, daemon=True).start() - typer.echo(f"\n 🔍 Paths API: Use this URL in the UI to auto-detect available paths \n") - typer.echo(f"🖥️ If your UI is running on the same device as this server: http://127.0.0.1:{api_port}/paths") - typer.echo(f"🌐 If your UI is running on a different device: http://{host_ip}:{api_port}/paths") - typer.secho("Press Ctrl+C to quit.\n", fg=typer.colors.BRIGHT_BLACK) try: signal.pause() @@ -621,9 +574,85 @@ def do_GET(self): @app.command() def reset(): - """Clear stored publisher/viewer credentials.""" + """Clear all stored secrets (stream + admin + API keys + session signing key).""" reset_creds() - typer.echo("🔑 Credentials reset; regenerated on next run.") + typer.echo("🔑 All credentials reset; stream secrets regenerate on next run.") + typer.echo(" Re-set admin password: surveillance admin set-password") + + +@admin_app.command("set-password") +def admin_set_password( + password: Optional[str] = typer.Option( + None, + "--password", + "-p", + help="Admin password (prompted if omitted)", + hide_input=True, + ), +): + """Set the dashboard admin password (argon2-hashed in keyring).""" + if not password: + password = typer.prompt("Admin password", hide_input=True, confirmation_prompt=True) + try: + set_admin_password(password) + except ValueError as e: + typer.secho(str(e), fg=typer.colors.RED) + raise typer.Exit(1) + typer.secho("Admin password saved.", fg=typer.colors.GREEN) + + +@apikey_app.command("create") +def apikey_create( + name: str = typer.Option(..., "--name", "-n", help="Key name/label"), + scope: str = typer.Option("read", "--scope", "-s", help="read or admin"), +): + """Create an API key. Raw key is printed once.""" + try: + raw = create_api_key(name=name, scope=scope) + except ValueError as e: + typer.secho(str(e), fg=typer.colors.RED) + raise typer.Exit(1) + typer.secho("API key created. Store it now — it will not be shown again:", fg=typer.colors.YELLOW) + typer.echo(raw) + + +@apikey_app.command("list") +def apikey_list(): + """List API keys (metadata only; no secrets).""" + keys = list_api_keys(include_revoked=True) + if not keys: + typer.echo("No API keys.") + return + for k in keys: + status = "revoked" if k.get("revoked_at") else "active" + typer.echo( + f" {k.get('id')} name={k.get('name')} scope={k.get('scope')} " + f"status={status} created={k.get('created_at')}" + ) + + +@apikey_app.command("revoke") +def apikey_revoke( + key_id: str = typer.Argument(..., help="Key id or name to revoke"), +): + """Revoke an API key by id or name.""" + if revoke_api_key(key_id): + typer.secho(f"Revoked: {key_id}", fg=typer.colors.GREEN) + else: + typer.secho(f"Key not found: {key_id}", fg=typer.colors.RED) + raise typer.Exit(1) + + +@credentials_app.command("show-stream") +def credentials_show_stream( + force: bool = typer.Option( + False, + "--force", + help="Allow printing secrets when stdout is not a TTY", + ), +): + """Print MediaMTX publisher/viewer passwords (TTY only by default).""" + show_stream_credentials(force=force) @app.command() diff --git a/video-feed/videofeed/templates/login.html b/video-feed/videofeed/templates/login.html new file mode 100644 index 0000000..0942830 --- /dev/null +++ b/video-feed/videofeed/templates/login.html @@ -0,0 +1,59 @@ + + + + + + SpectraX Login + + + + +
+
+
+
+

SpectraX

+

Sign in to the dashboard

+ +
+
+ + +
+ +
+
+
+
+
+ + + diff --git a/video-feed/videofeed/templates/recordings.html b/video-feed/videofeed/templates/recordings.html index 1aa62cb..1f88e46 100644 --- a/video-feed/videofeed/templates/recordings.html +++ b/video-feed/videofeed/templates/recordings.html @@ -175,10 +175,8 @@
Detection Details:
function loadRecordings(resetContent = true) { showLoading(true); - // Prepare API URL with filters - let apiUrl = `/recordings?limit=${currentLimit}&offset=${currentOffset}`; + let apiUrl = `/api/recordings?limit=${currentLimit}&offset=${currentOffset}`; - // Add date filters if specified if (startDateInput.value) { apiUrl += `&start_date=${startDateInput.value}T00:00:00`; } @@ -187,8 +185,12 @@
Detection Details:
apiUrl += `&end_date=${endDateInput.value}T23:59:59`; } - fetch(apiUrl) + fetch(apiUrl, { credentials: 'include' }) .then(response => { + if (response.status === 401) { + window.location.href = '/login'; + throw new Error('Unauthorized'); + } if (!response.ok) { throw new Error('Failed to load recordings'); } @@ -198,30 +200,28 @@
Detection Details:
showLoading(false); if (resetContent) { - recordingsContainer.innerHTML = ''; + recordingsContainer.replaceChildren(); recordingsMap = {}; } - const recordings = data.recordings; + const recordings = data.recordings || []; if (recordings.length === 0 && currentOffset === 0) { - recordingsContainer.innerHTML = ` -
-

No recordings found matching your criteria.

-
- `; + const empty = document.createElement('div'); + empty.className = 'col-12 text-center'; + const p = document.createElement('p'); + p.textContent = 'No recordings found matching your criteria.'; + empty.appendChild(p); + recordingsContainer.appendChild(empty); loadMoreBtn.classList.add('d-none'); return; } - // Hide the "no recordings" message if we have recordings noRecordings.classList.add('d-none'); - // Determine if we should show "load more" button hasMoreRecordings = recordings.length === currentLimit; loadMoreBtn.classList.toggle('d-none', !hasMoreRecordings); - // Render recordings recordings.forEach(recording => { recordingsMap[recording.id] = recording; renderRecordingCard(recording); @@ -230,70 +230,90 @@
Detection Details:
.catch(error => { showLoading(false); console.error('Error:', error); - alert('Failed to load recordings. See console for details.'); + if (error.message !== 'Unauthorized') { + alert('Failed to load recordings. See console for details.'); + } }); } - // Render a recording card + // Render a recording card without interpolating untrusted data into HTML function renderRecordingCard(recording) { const dateTime = new Date(recording.timestamp); const formattedDate = dateTime.toLocaleDateString(); const formattedTime = dateTime.toLocaleTimeString(); - // Extract object types for labels - const objects = JSON.parse(typeof recording.objects_detected === 'string' - ? recording.objects_detected - : JSON.stringify(recording.objects_detected)); + let objects = []; + try { + objects = JSON.parse(typeof recording.objects_detected === 'string' + ? recording.objects_detected + : JSON.stringify(recording.objects_detected || [])); + } catch (e) { + objects = []; + } - // Count occurrences of each object type const objectCounts = {}; objects.forEach(obj => { - const className = obj.class; + const className = obj.class || 'unknown'; objectCounts[className] = (objectCounts[className] || 0) + 1; }); - // Create labels for each object type - const objectLabels = Object.entries(objectCounts) - .map(([className, count]) => ` - - ${className} (${count}) - - `).join(''); - - // Generate card HTML const card = document.createElement('div'); card.className = 'col-lg-4 col-md-6'; - card.innerHTML = ` -
-
- Recording thumbnail -
- ${Math.round(recording.duration)}s -
-
-
-
${recording.stream_name}
-

- ${formattedDate} at ${formattedTime} -

-
- ${objectLabels} -
- -
-
- `; - // Add click event to the "Watch" button - card.querySelector('.watch-btn').addEventListener('click', () => { - openVideoModal(recording.id); + const cardInner = document.createElement('div'); + cardInner.className = 'card recording-card shadow-sm'; + + const thumbWrap = document.createElement('div'); + thumbWrap.className = 'position-relative'; + const img = document.createElement('img'); + img.className = 'thumbnail'; + img.alt = 'Recording thumbnail'; + const thumbName = (recording.thumbnail_path || recording.thumbnail_url || '').split('/').pop(); + if (thumbName) { + img.src = recording.thumbnail_url || `/recordings/${thumbName}`; + } + thumbWrap.appendChild(img); + const durationBadge = document.createElement('div'); + durationBadge.className = 'position-absolute bottom-0 end-0 p-2 bg-dark text-white rounded-start'; + durationBadge.textContent = `${Math.round(recording.duration || 0)}s`; + thumbWrap.appendChild(durationBadge); + cardInner.appendChild(thumbWrap); + + const body = document.createElement('div'); + body.className = 'card-body'; + const title = document.createElement('h5'); + title.className = 'card-title'; + title.textContent = recording.stream_name || 'Unknown stream'; + body.appendChild(title); + + const meta = document.createElement('p'); + meta.className = 'card-text'; + const small = document.createElement('small'); + small.className = 'text-muted'; + small.textContent = `${formattedDate} at ${formattedTime}`; + meta.appendChild(small); + body.appendChild(meta); + + const labels = document.createElement('div'); + labels.className = 'mb-2'; + Object.entries(objectCounts).forEach(([className, count]) => { + const pill = document.createElement('span'); + pill.className = 'label-pill'; + pill.style.backgroundColor = '#007bff'; + pill.textContent = `${className} (${count})`; + labels.appendChild(pill); }); + body.appendChild(labels); + + const watchBtn = document.createElement('button'); + watchBtn.className = 'btn btn-primary btn-sm w-100 mt-2 watch-btn'; + watchBtn.dataset.recordingId = String(recording.id); + watchBtn.textContent = 'Watch'; + watchBtn.addEventListener('click', () => openVideoModal(recording.id)); + body.appendChild(watchBtn); - // Add card to container + cardInner.appendChild(body); + card.appendChild(cardInner); recordingsContainer.appendChild(card); } @@ -308,21 +328,22 @@
${recording.stream_name}
currentRecordingId = recordingId; - // Set video source - const fileName = recording.file_path.split('/').pop(); - videoPlayer.src = `/recordings/${fileName}`; + const fileName = (recording.file_path || recording.file_url || '').split('/').pop(); + videoPlayer.src = recording.file_url || `/recordings/${fileName}`; videoPlayer.load(); - // Update modal title document.getElementById('videoModalLabel').textContent = - `${recording.stream_name} - ${new Date(recording.timestamp).toLocaleString()}`; + `${recording.stream_name || 'Recording'} - ${new Date(recording.timestamp).toLocaleString()}`; - // Display detection details - const objects = JSON.parse(typeof recording.objects_detected === 'string' - ? recording.objects_detected - : JSON.stringify(recording.objects_detected)); + let objects = []; + try { + objects = JSON.parse(typeof recording.objects_detected === 'string' + ? recording.objects_detected + : JSON.stringify(recording.objects_detected || [])); + } catch (e) { + objects = []; + } - // Group by class and show confidence const detectionsByClass = {}; objects.forEach(obj => { if (!detectionsByClass[obj.class]) { @@ -331,24 +352,22 @@
${recording.stream_name}
detectionsByClass[obj.class].push(obj.confidence); }); - // Format detection details HTML - let detailsHtml = '
    '; + modalDetectionDetails.replaceChildren(); + const ul = document.createElement('ul'); + ul.className = 'list-group'; Object.entries(detectionsByClass).forEach(([className, confidences]) => { - // Get max confidence for this class const maxConfidence = Math.max(...confidences); - detailsHtml += ` -
  • - ${className} - - ${confidences.length}× (max conf: ${maxConfidence.toFixed(2)}) - -
  • - `; + const li = document.createElement('li'); + li.className = 'list-group-item d-flex justify-content-between align-items-center'; + li.appendChild(document.createTextNode(className)); + const badge = document.createElement('span'); + badge.className = 'badge bg-primary rounded-pill'; + badge.textContent = `${confidences.length}× (max conf: ${maxConfidence.toFixed(2)})`; + li.appendChild(badge); + ul.appendChild(li); }); - detailsHtml += '
'; - modalDetectionDetails.innerHTML = detailsHtml; + modalDetectionDetails.appendChild(ul); - // Show modal videoModal.show(); } @@ -360,20 +379,23 @@
${recording.stream_name}
return; } - fetch(`/recordings/${currentRecordingId}`, { - method: 'DELETE' + fetch(`/api/recordings/${currentRecordingId}`, { + method: 'DELETE', + credentials: 'include', }) .then(response => { + if (response.status === 401) { + window.location.href = '/login'; + throw new Error('Unauthorized'); + } if (!response.ok) { throw new Error('Failed to delete recording'); } return response.json(); }) .then(data => { - // Close modal and reload recordings videoModal.hide(); - // Remove the deleted recording from the DOM const elements = document.querySelectorAll(`[data-recording-id="${currentRecordingId}"]`); elements.forEach(el => { const card = el.closest('.col-lg-4.col-md-6'); @@ -382,13 +404,13 @@
${recording.stream_name}
} }); - // Check if we need to load more or show "no recordings" message if (recordingsContainer.children.length === 0) { - recordingsContainer.innerHTML = ` -
-

No recordings available.

-
- `; + const empty = document.createElement('div'); + empty.className = 'col-12 text-center'; + const p = document.createElement('p'); + p.textContent = 'No recordings available.'; + empty.appendChild(p); + recordingsContainer.appendChild(empty); loadMoreBtn.classList.add('d-none'); } @@ -396,7 +418,9 @@
${recording.stream_name}
}) .catch(error => { console.error('Error:', error); - alert('Failed to delete recording'); + if (error.message !== 'Unauthorized') { + alert('Failed to delete recording'); + } }); } diff --git a/video-feed/videofeed/templates/viewer.html b/video-feed/videofeed/templates/viewer.html index 8b97e02..51386be 100644 --- a/video-feed/videofeed/templates/viewer.html +++ b/video-feed/videofeed/templates/viewer.html @@ -240,9 +240,13 @@

Status Information

// Fetch status info every 2 seconds setInterval(async function() { try { - const response = await fetch('/status'); + const response = await fetch('/status', { credentials: 'include' }); + if (response.status === 401) { window.location.href = '/login'; return; } const data = await response.json(); - document.getElementById('statusInfo').innerHTML = `
${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