diff --git a/.env.example b/.env.example index bfe387a..ef8049e 100644 --- a/.env.example +++ b/.env.example @@ -19,30 +19,65 @@ FRIGATE_SHM_SIZE=256mb FRIGATE_RTSP_USER=admin FRIGATE_RTSP_PASSWORD=changeme -# Allow go2rtc `exec:` sources — needed only if you use GPU-enhanced "_hq" -# transcode streams (see frigate/config.yml). Off by default (security); fine to -# enable on a private LAN/tailnet box where the go2rtc config is yours. -GO2RTC_ALLOW_ARBITRARY_EXEC=true +# Allow go2rtc `exec:` sources. OFF by default, and the dashboard works fully +# without it. Turn it on ONLY for the optional GPU-enhanced "_hq" streams you +# write yourself in frigate/config.yml (see the commented example there). +# SECURITY: while this is true, anyone who can reach go2rtc's API (port 1984 on +# the host) can run commands inside the Frigate container. Keep 1984 off any +# untrusted network (firewall it, or use compose.bridge.yml which binds it to +# localhost). See SECURITY.md. +GO2RTC_ALLOW_ARBITRARY_EXEC=false # ---- Windows / bridge networking ONLY -------------------------------------- -# This machine's LAN IP, used for the go2rtc WebRTC candidate. Ignored on -# host-networking hosts (Unraid/Pi). +# This machine's LAN IP, used for the go2rtc WebRTC candidate (referenced in +# frigate/config.yml). Ignored on host-networking hosts (Unraid/Pi). FRIGATE_HOST_LAN_IP=192.168.1.10 # ---- Control dashboard ----------------------------------------------------- DASHBOARD_IMAGE=tinkerview:latest -# Optional shared bearer token gating the dashboard API + ntfy action buttons. -# Empty = open on the tailnet (Tailscale is the perimeter). When set, open the -# dashboard once as http://:8088/?token=YOURTOKEN — the browser stores it and -# uses it for the API + WebSocket thereafter. +# Shared bearer token gating the dashboard API + ntfy action buttons. SET ONE: +# with it empty, anyone who can reach port 8088 has full control (the dashboard +# logs a warning at startup). Generate one: openssl rand -hex 24 +# (install.sh does this for you.) When set, open the dashboard once as +# http://:8088/?token=; the browser stores it and uses it for +# the API + WebSocket thereafter. DASHBOARD_TOKEN= # Docker access for container health/restart — the docker-socket-proxy endpoint. -DOCKER_HOST=tcp://127.0.0.1:2375 +# (Named DASHBOARD_DOCKER_HOST so it cannot collide with the DOCKER_HOST your shell or +# `podman compose` sets for the compose tool itself. The dashboard sees it as DOCKER_HOST.) +DASHBOARD_DOCKER_HOST=tcp://127.0.0.1:2375 +# 0 (default) = read-only container health view; the Restart button is disabled. +# 1 = the dashboard may restart containers. The proxy cannot restrict POST to +# "restart" only, so 1 is root-equivalent for anything that can reach 127.0.0.1:2375 +# (loopback only). Opt in only if you want the button. See SECURITY.md. +DOCKERPROXY_POST=0 +# Host path of the container engine's API socket, mounted into docker-socket-proxy. +# Leave it unset on Docker. Rootless podman: install.sh sets it to your user socket, +# for example /run/user/1000/podman/podman.sock. +# DOCKER_SOCKET_PATH=/var/run/docker.sock +# Address the dashboard listens on (default ":8088" = all interfaces). Bind to a +# single IP (e.g. your tailnet or LAN address) to narrow it. +# DASHBOARD_LISTEN=:8088 + +# ---- MQTT (Mosquitto) ------------------------------------------------------ +# By default the broker is published on 127.0.0.1 only. For Home Assistant on +# another machine use compose.mqtt-lan.yml (password-protected, port 1884); +# this limits which interface that port binds to (default: all). +# MQTT_LAN_BIND=0.0.0.0 # ---- ntfy (push notifications) --------------------------------------------- NTFY_IMAGE=binwiederhier/ntfy:v2.11.0 NTFY_BASE_URL=http://127.0.0.1:8092 +# ntfy relays a content-free poll request (a hash of the topic, no message) to ntfy.sh so +# iOS gets instant push. Uncomment with an empty value to switch that off (Android is +# unaffected; iOS notifications then arrive late or not at all). +# NTFY_UPSTREAM_BASE_URL= # Publish token the dashboard uses (create it in ntfy after first start). NTFY_TOKEN= DOCKERPROXY_IMAGE=tecnativa/docker-socket-proxy:0.3.0 + +# ---- Tailscale sidecar (compose.tailscale.yml, optional) ------------------- +# Auth key from the Tailscale admin console (Settings > Keys). Without it the +# container prints a login URL in its logs instead. +# TS_AUTHKEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ae5cb3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,124 @@ +# Continuous integration: Go (format, vet, race tests), frontend (typecheck, +# build), compose file validation, and a no-push build of the dashboard image. +name: ci + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs on the same PR/branch; never cancel a run on main. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + go: + runs-on: ubuntu-latest + defaults: + run: + working-directory: dashboard + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: dashboard/go.mod + cache-dependency-path: dashboard/go.sum + + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: go.mod / go.sum are tidy + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: go vet + run: go vet ./... + + - name: go test -race + run: go test -race ./... + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: dashboard/web + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: dashboard/web/package-lock.json + + - run: npm ci + - run: npm run typecheck + - run: npm run build + + compose: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Every documented overlay combination must at least parse and interpolate. + - name: docker compose config + run: | + cp .env.example .env + for overlay in nvidia intel amd-rocm cpu pi bridge; do + echo "== $overlay" + docker compose -f docker-compose.yml -f "compose.${overlay}.yml" config --quiet + done + docker compose -f docker-compose.yml -f compose.cpu.yml -f compose.mqtt-lan.yml config --quiet + docker compose -f docker-compose.yml -f compose.intel.yml -f compose.coral.yml config --quiet + docker compose -f docker-compose.yml -f compose.cpu.yml -f compose.tailscale.yml config --quiet + docker compose -f docker-compose.yml -f compose.cpu.yml -f compose.selinux.yml config --quiet + + shell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # shellcheck is preinstalled on GitHub's ubuntu runners. + - name: shellcheck install.sh + run: | + bash -n install.sh + shellcheck install.sh + + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + # Build only (no push). Release images are built by release.yml on v* tags. + - name: Build dashboard image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ./dashboard + push: false + tags: tinkerview:ci + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e25427..44e26b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,20 +10,28 @@ on: permissions: contents: read - packages: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: image: runs-on: ubuntu-latest + permissions: + contents: read + packages: write # push to ghcr.io steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # QEMU lets the amd64 runner cross-build the arm64 (Pi) image. - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -31,7 +39,7 @@ jobs: - name: Image tags + labels id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: # GHCR repository names must be lowercase. images: ghcr.io/makerviking/tinkerview @@ -41,11 +49,14 @@ jobs: type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build + push (amd64 + arm64) - uses: docker/build-push-action@v6 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: ./dashboard platforms: linux/amd64,linux/arm64 - push: true + # Only tag pushes publish. A manual run from a branch builds but does not push. + push: ${{ startsWith(github.ref, 'refs/tags/v') }} + provenance: true + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | diff --git a/.gitignore b/.gitignore index d8f320d..bdc3b31 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dashboard/data/ # --- personal working notes + local dev scaffolding (contain real LAN IPs) --- handoff.md +REVIEW-*.md dashboard/dev/ # --- build artifacts --- @@ -25,6 +26,11 @@ frigate/clips/ frigate/go2rtc_homekit.yml frigate/backup_config.yaml frigate/*.bak +frigate/*.bak.yml +frigate/*.pre-*.bak.* +*.bak +*.bak.yml +*.pre-*.bak.* # --- recordings / media --- storage/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a9aff58 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,144 @@ +# Changelog + +All notable changes to TinkerView are recorded here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow +[Semantic Versioning](https://semver.org/). + +## [1.1.0] - 2026-10-23 + +The polish and launch release: more printers, a recognition and recording UI, a redesigned +interface, a hardened default configuration, and an installer so other people can run it. + +### Added + +- **Installer.** `install.sh` detects your hardware, suggests a preset, creates `.env` and + the data folders with the right owner, writes the Frigate preset into + `frigate/config.yml` between markers, starts the stack and waits for the dashboard to + answer. It supports `--dry-run`, `--yes`, `--build`, `--bridge`, `--tailscale`, + `--storage`, `--token` and more, and backs up anything it changes. +- **Platform guides.** `unraid/` (full stack through Compose Manager, plus a Docker + template for the dashboard alone) and `casaos/`. +- **Printers.** OctoPrint, PrusaLink (with fallback to the legacy API on older firmware) + and Bambu Lab (LAN mode) adapters beside Moonraker. Any camera can be attached to any + printer kind. +- **Edit flows.** Edit any camera or printer from the UI, including source URL changes that + apply live through go2rtc, camera credentials, camera rename that follows through to + printers, and rotate and flip controls. +- **Recording and recognition.** Record-on-detect, zones, a recordings browser, face and + licence-plate enrollment, and prioritised ntfy rules with a "specific name" match. +- **Detection hardware picker** and an inference-speed report after applying it, plus a + broader set of presets: Nvidia, Intel (iGPU, Arc, NPU), AMD ROCm, Raspberry Pi, Coral + and CPU. +- **Shared-token auth** (`DASHBOARD_TOKEN`) for the API, WebSocket and proxied endpoints. +- **Tailscale sidecar** (`compose.tailscale.yml`) that publishes only the dashboard. +- **`compose.mqtt-lan.yml`**: a password-protected MQTT listener on port 1884 for Home + Assistant on another machine. +- **Container healthcheck**: `/dashboard -healthcheck`, in the image and in compose. +- **CI** (`.github/workflows/ci.yml`): gofmt, `go mod tidy` check, `go vet`, + `go test -race`, `npm ci`, typecheck, production build, `docker compose config` for every + overlay combination, and a no-push image build. Actions are pinned to commit SHAs. +- **Documentation**: a rewritten README and `docs/` (install, configuration, cameras, + integrations, upgrading, troubleshooting, architecture), `SECURITY.md`, + `CODE_OF_CONDUCT.md`, and `NOTICE.md` and `CONTRIBUTING.md`. +- **Licence**: AGPL-3.0-only, with third-party notices. +- A startup warning when the dashboard runs without a token. +- `compose.selinux.yml` (`label=disable`), added by the installer on SELinux Enforcing + hosts. Written, not run on such a host. +- The installer finds a rootless Docker or Docker Desktop socket (as it does for podman), + offers the Restart opt-in, and defaults to building the dashboard image until the + prebuilt one can be pulled. + +### Changed + +- **Redesigned interface** ("quiet instrument"): a warm graphite palette with an amber + accent, camera tiles that fill the frame with controls on hover, a dense Server list, + grouped navigation and a mobile tab bar. The active tab is kept in the URL hash. +- **Dashboard state and config moved into `dashboard/data/`** (a directory mount) instead + of a bind-mounted single file. The demo cameras ship disabled. +- **Mosquitto is published on `127.0.0.1` only.** Home Assistant on another machine must + use `compose.mqtt-lan.yml` (port 1884). +- **`GO2RTC_ALLOW_ARBITRARY_EXEC` defaults to `false`.** The GPU "HQ" streams need it set to + `true`; see the security note. +- **Neither the dashboard nor Frigate receives the whole `.env` any more.** Each gets only + the variables it needs, so the dashboard token, ntfy token and Tailscale key stay out of + Frigate (which is on the host network with unauthenticated ports) and camera passwords + stay out of the dashboard. Extra `{FRIGATE_...}` variables go in the `frigate: + environment:` list. +- **`compose.bridge.yml`** (renamed from `compose.windows.yml`) now actually uses the + compose network, so service names resolve. It needs Docker Compose 2.24 or newer and + publishes Frigate 5000 and go2rtc 1984 on localhost only. +- **docker-socket-proxy is read-only by default.** `DOCKERPROXY_POST` defaults to `0`, so + the container panel shows health but the Restart button is disabled; `1` turns restart on + but also permits creating containers (root-equivalent). `install.sh --allow-restart` or a + prompt opts in. It stays on loopback. +- **Image build**: `npm ci` with the lockfile, `go build -mod=readonly` instead of + `go mod tidy`, and a `HEALTHCHECK`. +- **Camera tiles** tear down their WebSocket and peer connection on unmount and on the HQ + toggle, and reconnect after a stall watchdog fires (about 15 seconds). +- **HTTP hardening**: token gating for the go2rtc and Frigate proxies, same-origin checks on + mutating requests and WebSocket handshakes, gzip, immutable caching for hashed assets, and + `nosniff`, framing and referrer headers. +- The Tailscale image is pinned. + +### Fixed + +- Dashboard shutdown no longer hangs until SIGKILL when Mosquitto is unreachable (the MQTT + connect no longer blocks the shutdown path). +- The Restart button explains a proxy refusal instead of showing a bare HTTP 403. +- A CasaOS first-run failure: the dashboard could not write its data folder, which Docker + or CasaOS had created as root. +- The first-run crash when the config file was missing or a directory. +- The HQ NVENC recipe leaked encoder sessions when go2rtc restarted a stalled producer; it + now has `-timeout` so an orphaned ffmpeg ends itself. +- Face uploads are normalised to JPEG in the browser (registration returned HTTP 500), and + enrollment through the Frigate proxy no longer fails with 401 (Origin and Referer are + stripped). +- PrusaLink: tolerant parsing, MD5-sess digest authentication, and a workaround for a bogus + nozzle temperature on Prusa XL stock firmware. +- Hub and Moonraker robustness: a closed-channel panic, reconnect back-off that now resets, + log de-spam, a liveness timeout and a re-fire guard on print completion. +- Camera credential keep and clear semantics, and store changes roll back when a save fails. +- `compose.bridge.yml` could not resolve `mqtt`, `frigate` or `dockerproxy` on the legacy + bridge network. +- `config.example.yaml` no longer ships a literal `${VAR}` token that locked users out. +- Rootless podman: `docker compose up` failed outright because the socket proxy bind-mounts + `/var/run/docker.sock`. The mount is now `DOCKER_SOCKET_PATH` (default unchanged), and + `install.sh` sets it to the podman user socket. +- Under `podman compose` the dashboard received podman's own `DOCKER_HOST` instead of the + socket proxy address, so the container panel stayed empty. The `.env` key is now + `DASHBOARD_DOCKER_HOST`. +- `install.sh` left `dashboard/data/config.yaml` owned by uid 65532, so you needed root to + edit it. Only the folder changes owner now; the file stays yours. + +### Security + +- `exec:`, `echo:` and `expr:` sources are rejected in UI-entered camera sources. +- Secrets are never serialised back out of the API. +- Frigate's environment is an explicit list instead of `env_file: .env`. +- `install.sh` cleans up its temporary files (which can hold secrets) on interrupt. +- The Moonraker file/history proxy only allows the read endpoints the dashboard uses; + Moonraker's `/access/*` credential endpoints, config files and machine endpoints are + refused (previously any GET path was forwarded with the printer's API key). +- `golang.org/x/net` updated to v0.61.0; frontend build tooling (vite, postcss, + browserslist, nanoid) updated to clear advisories. The shipped frontend had none. +- See [SECURITY.md](SECURITY.md) for the trust model and how to report a vulnerability. + +### Known issues + +- A WebRTC camera tile can occasionally stall. The dashboard reconnects it automatically + after about 15 seconds; the root cause is under investigation. +- Bambu Lab printers: the MQTT TLS connection does not verify the printer's + certificate yet (see SECURITY.md). +- The `intel`, `amd-rocm`, `pi` and `coral` presets have not been run on real hardware, + and bridge mode has only run on Linux under podman, not on Windows. + +## [1.0.0] - 2026-06-06 + +First release: a Frigate and go2rtc camera hub with a hardware preset per host (Nvidia, +Intel, AMD, Raspberry Pi, CPU), Mosquitto, ntfy and docker-socket-proxy, and a single-binary +Go and React dashboard with a live camera wall, Klipper/Moonraker printer control, +container health and push notifications. Multi-arch (amd64 and arm64) images are published +to `ghcr.io/makerviking/tinkerview` by the release workflow. + +[1.1.0]: https://github.com/MakerViking/TinkerView/releases/tag/v1.1.0 +[1.0.0]: https://github.com/MakerViking/TinkerView/releases/tag/v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 9badc01..8ccb3c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,30 @@ -# TinkerView — guidance for Claude +# TinkerView — guidance for AI coding assistants -**Read `handoff.md` (repo root, gitignored) first** — it's the source of truth for -project state, ops, deploy, and backlog. +This repo is built with AI assistance (see "AI assistance" in `README.md`). This file +is the context an assistant should load before changing anything. -## MCP tool routing (two servers, namespaced — no collision) -- **`mcp__graphify__*`** (`query_graph`, `get_node`, `get_neighbors`, `shortest_path`, - + PR-impact tools): use for questions about **this repo's structure** — symbols, - dependencies, call paths, "what calls X / what breaks if I change Y". Backed by a - per-repo knowledge graph at `graphify-out/graph.json` (gitignored, served via a - local-scope MCP). Rebuild after code changes: - - `graphify update .` — AST-only, fast, **free** (no LLM). - - `graphify extract . --backend=claude-cli` — full semantic rebuild on the **Claude - Max subscription** (no `ANTHROPIC_API_KEY`; set `GRAPHIFY_CLAUDE_CLI_MODEL=sonnet`). -- **`mcp__openbrain__*`** (`search_thoughts`, `capture_thought`): use to recall/store - **cross-session, cross-project** decisions, gotchas, and rationale. +If a private, gitignored `handoff.md` exists at the repo root, read it first — it holds +the maintainer's local ops notes and backlog. It is never committed; do not copy its +contents into tracked files. -## Deploy (summary — details in handoff.md) -Redeploy = rsync the repo to `root@tower.local:/mnt/user/appdata/webcamserver` -(**must exclude tower-specific configs** — see handoff.md) then rebuild the dashboard. +## Layout +- `dashboard/` — Go backend (`cmd/dashboard`, `internal/**`) + React/Vite frontend (`dashboard/web`). +- `frigate/` — `config.yml` example and `host-presets/.yml` hardware presets. +- `compose.*.yml` — overlays on `docker-compose.yml` (hardware presets, bridge, tailscale, obico). + +## Gates (run before proposing a change) +- Go (from `dashboard/`): `go vet ./... && go test -race ./...` +- Frontend (from `dashboard/web/`): `npm run typecheck && npm run build` + +## Rules +- Examples, tests, placeholders, and docs use generic values only (`192.168.1.x`, + `nas.local`, `192.0.2.10`). Never commit real LAN IPs, hostnames, credentials, or + ntfy topics. +- Do not change the marker/section structure of `frigate/config.yml`; the installer and + README depend on it. +- Never commit `.env`, `dashboard/config.yaml`, `dashboard/data/`, or tailscale node state. + +## Code-intelligence tooling (optional) +If you use Mimir (`mcp__mimir__*`), its code graph answers structure questions +("what calls X", "what breaks if I change Y") more cheaply than reading files. The +older Graphify tooling is retired; `graphify-out/` is still gitignored. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..35ac8a7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement through GitHub's +private reporting form: +. Start the +report with "Code of Conduct" so it can be told apart from a vulnerability report. +Please do not use the public issue tracker for conduct reports. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +. Translations are available at +. + +[homepage]: https://www.contributor-covenant.org +[Mozilla CoC]: https://github.com/mozilla/diversity diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6bed573 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# Contributing to TinkerView + +Thanks for your interest in TinkerView! A few ground rules keep the project healthy +and its licensing clean. + +## License of your contributions + +TinkerView is released under **AGPL-3.0-only** (see [`LICENSE`](LICENSE)). By +submitting a contribution (a pull request, patch, or any code or content), you +agree that: + +1. Your contribution is licensed to the project and its users under + **AGPL-3.0-only**; and +2. You grant the project maintainer, **MakerViking** (the copyright holder), a + perpetual, irrevocable, worldwide, royalty-free right to **relicense your + contribution under other terms**, including a commercial license. + +This dual-licensing grant is what lets TinkerView stay fully open-source under the +AGPL while the maintainer can also offer a commercial license to organizations that +can't use AGPL software, the revenue that keeps the project maintained. It's the +same inbound-relicensable model used by projects like GitLab and Qt. + +You confirm you have the right to grant this (the work is yours, or your employer +has authorized it). + +> This is a lightweight contributor agreement, not legal advice; a formal +> CLA/DCO document may replace this note later. + +Third-party components and their licenses are listed in [`NOTICE.md`](NOTICE.md). +If you add a dependency, add it there — and note that AGPL-3.0 constrains what is +acceptable inbound: permissive licenses (MIT, ISC, BSD, Apache-2.0) are fine, GPL/AGPL +dependencies are not, and anything EPL-licensed needs a permissive dual-license branch. + +## Development + +For running the app locally, see [`dashboard/README.md`](dashboard/README.md) +(`./dashboard/run-local.sh` serves it in demo mode) and +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). Before opening a PR, these must pass; CI +runs the same checks: + +- From `dashboard/`: `gofmt -l .` (no output), `go mod tidy` (no diff), `go build ./...`, + `go vet ./...`, and `go test -race ./...`. +- From `dashboard/web/`: `npm ci`, `npm run typecheck` and `npm run build`. +- If you touch a compose file, `docker compose config -q` must still pass for the + overlay combinations in `.github/workflows/ci.yml`. + +Never put real IP addresses, hostnames, tokens, camera credentials or ntfy topics in tracked +files; use examples such as `192.168.1.50` and `nas.local`. Please follow the +[Code of Conduct](CODE_OF_CONDUCT.md), and report vulnerabilities privately as described in +[SECURITY.md](SECURITY.md). + +> A standalone `tsc` run in your editor may report `window` / `document` / `location` +> "not found" — that's a false alarm from ignoring the project's `lib: dom`. +> `npm run typecheck` is the source of truth. + +## Conventions + +- **Printer adapters** live in `internal/adapters//` behind the shared `Printer` + interface. Common logic (clamping, home G-code, macro allowlisting) belongs in + `internal/adapters/printerutil`, not copied per adapter. +- **Keep the safety gates.** Printer control is gated on an idle state for + home/move/extrude, a 170 °C minimum extrude temperature, a two-stage macro + allowlist plus injection guard, and absolute/feedrate clamping. New adapters mirror + these; don't loosen them for convenience. +- **Camera image transforms** (rotate/flip) are applied at the go2rtc source so the + live tile, recordings, snapshots, and detection all stay in agreement. + `CameraRecord.Src` stays the pristine base URL; the transform fields drive an + idempotent wrapper. +- **Never overwrite hand-authored `config.yml` camera blocks.** Adopted cameras stay + UI-read-only (`managed: false`). +- The vendored go2rtc player files in `dashboard/web/public/` are pinned by hand. + If you re-pin them, work through the checklist in that directory's `README.md` — + `VideoStream.tsx` depends on two player internals that are not public API. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..fe27dce --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,95 @@ +# Third-Party Notices + +TinkerView incorporates the following third-party components. This file satisfies the +attribution requirements of their licenses. TinkerView itself is licensed under +**AGPL-3.0-only** (see [LICENSE](LICENSE)). + +No dependency is under the GPL or AGPL, and every component below is either permissive +(MIT, ISC, BSD, Apache-2.0, OFL-1.1) or dual-licensed with a permissive option. All are +one-way compatible with AGPL-3.0, which permits distributing TinkerView under its own +terms. + +> **On the Eclipse components.** Eclipse Paho and Mosquitto are dual-licensed +> `EPL-2.0 OR EDL-1.0`. The **EPL-2.0 branch is *not* compatible with the AGPL** — +> TinkerView takes the **EDL-1.0** branch (the Eclipse Distribution License, which is +> BSD-3-Clause in substance) for both. This is a deliberate election, not an oversight. + +## Vendored source + +These files are copied into this repository and shipped in built artifacts. + +- **go2rtc web player** — `dashboard/web/public/video-rtc.js` (VideoRTC v1.6.0) and + `dashboard/web/public/video-stream.js` — **MIT**. + Copyright (c) 2022 Alexey Khit. Source: https://github.com/AlexxIT/go2rtc + Vendored **unmodified** and pinned by hand; there is no npm package, so they must be + re-pinned to match the go2rtc version bundled in your Frigate on upgrade (see + `dashboard/web/public/README.md`). + +## Bundled fonts + +Embedded into the dashboard build (and therefore into the single Go binary) via +`@fontsource`. All three are **SIL Open Font License 1.1 (OFL-1.1)**. + +- **Archivo** — Copyright 2020 The Archivo Project Authors. + https://github.com/Omnibus-Type/Archivo +- **JetBrains Mono** — Copyright 2020 The JetBrains Mono Project Authors. + https://github.com/JetBrains/JetBrainsMono +- **Chakra Petch** — Copyright 2018 The Chakra Petch Project Authors. + https://github.com/m4rc1e/Chakra-Petch + +## Go dependencies + +Linked into the `tinkerview` dashboard binary. Direct dependencies are deliberately +minimal (see the note in `dashboard/go.mod`). + +- **github.com/coder/websocket** v1.8.14 — **ISC**. Copyright (c) 2025 Coder. + The dashboard's WebSocket hub. +- **github.com/eclipse/paho.mqtt.golang** v1.5.1 — **EPL-2.0 OR EDL-1.0**; used under + **EDL-1.0** (see the note above). MQTT client for the Frigate event feed and the + Bambu printer adapter. +- **gopkg.in/yaml.v3** v3.0.1 — **Apache-2.0**, with eight files ported from libyaml + remaining under **MIT**. Copyright (c) 2011-2019 Canonical Ltd; MIT portions + Copyright (c) 2006-2011 Kirill Simonov. Config and Frigate `config.yml` handling. +- **github.com/gorilla/websocket** v1.5.3 (indirect) — **BSD-2-Clause**. + Copyright (c) 2013 The Gorilla WebSocket Authors. +- **golang.org/x/net** v0.44.0, **golang.org/x/sync** v0.17.0 (indirect) — + **BSD-3-Clause**. Copyright 2009 The Go Authors. + +## Frontend dependencies + +Bundled into the dashboard's JavaScript build. + +- **React** and **React DOM** v19 — **MIT**. Copyright (c) Meta Platforms, Inc. and + affiliates. +- **Zustand** v5 — **MIT**. Copyright (c) 2019 Paul Henschel. + +Build-time only (not shipped): TypeScript (Apache-2.0), Vite (MIT), Tailwind CSS (MIT), +`@vitejs/plugin-react` (MIT), and the `@types/*` packages (MIT, via DefinitelyTyped). + +## Container images + +TinkerView **orchestrates** these images — the compose files reference them and Docker +pulls them from their upstream registries at run time. They are **not redistributed by +this project**, are not modified, and each runs as a separate process under its own +license. This is mere aggregation; combining them with TinkerView via compose does not +create a combined work. + +- **Frigate NVR** (`ghcr.io/blakeblackshear/frigate`) — **MIT**. + Copyright (c) 2026 Frigate, Inc. (Frigate™). https://github.com/blakeblackshear/frigate + Also supplies the bundled **go2rtc** (MIT) that does the camera fan-out. +- **Eclipse Mosquitto** (`eclipse-mosquitto`) — **EPL-2.0 OR EDL-1.0**; see the note + above. https://github.com/eclipse-mosquitto/mosquitto +- **ntfy** (`binwiederhier/ntfy`) — **Apache-2.0**. + Copyright 2021 Philipp C. Heckel. https://github.com/binwiederhier/ntfy +- **Tecnativa docker-socket-proxy** (`tecnativa/docker-socket-proxy`) — **Apache-2.0**. + https://github.com/Tecnativa/docker-socket-proxy +- **Tailscale** (`tailscale/tailscale`) — **BSD-3-Clause**. + Copyright (c) 2020 Tailscale Inc & contributors. https://github.com/tailscale/tailscale + +## Trademarks + +Frigate™ is a trademark of Frigate, Inc. Home Assistant, Klipper, Moonraker, +OctoPrint, PrusaLink, Bambu Lab, Obico, Tailscale, and Coral are trademarks of their +respective owners. TinkerView is an independent project and is **not affiliated with, +endorsed by, or sponsored by** any of them; the names are used only to describe +interoperability. diff --git a/README.md b/README.md index 5bcdc70..5a5e83e 100644 --- a/README.md +++ b/README.md @@ -1,225 +1,448 @@ + +

- TinkerView — Cameras + 3D printers + TinkerView, cameras and 3D printers

-# TinkerView · by TinkerAtlas +

+ One self-hosted pane for your cameras and your 3D printers.
+ Frigate and go2rtc for the video, a small Go and React dashboard on top, and one command to install it. +

-> A self-hosted hub that puts your **cameras (NVR)** and your **3D printers** in one -> pane — your homelab observatory. +

+ CI + Release + Licence: AGPL-3.0 + Platforms: linux/amd64 and linux/arm64 +

-A self-hosted, multi-camera **restream / NVR** server for **3D-printer cams (MJPEG)** -and **RTSP/ONVIF security cams**, plus a single-binary **Go + React control dashboard** -for those cameras and your **Klipper/Moonraker printers** (temps, fans, speed/flow, -babystep, move, macros, files, history), container health, and notifications. One -Frigate container does the camera plumbing: it bundles **go2rtc**, which opens **a -single upstream connection per camera and fans it out to unlimited viewers** — so -cameras that allow only one client at a time can now be watched everywhere. On top: -a live dashboard grid, NVR recording with tiered retention, object detection, -low-latency WebRTC, and a Home Assistant feed. +

+ TinkerView camera wall: eight live tiles from printer cameras, two marked as recording
+ The camera wall (demo data and a looping test video). +

-Designed to be handed to a friend: one shared stack + a per-host overlay they pick. +Printer cameras are often cheap MJPEG streams that allow one viewer at a time, and +security cameras want RTSP, an NVR and a detector. TinkerView puts both on one screen. +[Frigate](https://frigate.video) and its bundled go2rtc open **one connection per +camera** and fan it out to as many viewers as you like. A single dashboard binary adds +the printers, the server health and the alerts. + +It is a Docker Compose stack plus an installer that picks the right setup for your +hardware. It runs on a NAS or a small Linux box on your own network. Part of [TinkerAtlas](https://tinkeratlas.com). + +- **A live camera wall** over low-latency WebRTC, with recording state and a per-tile menu (copy the RTSP or player URL, HQ, fullscreen) +- **GPU "HQ" mode** per tile: denoise, sharpen and upscale a weak camera on an Nvidia encoder, only while you watch it +- **Printers in the same view**: Klipper/Moonraker, OctoPrint, PrusaLink and Bambu Lab, with temperatures, progress, files, history and pause, resume and cancel +- **Add and edit cameras and printers in the UI**, with no config file required to start +- **NVR through Frigate**: tiered retention, object detection, face and licence-plate recognition, a recordings browser +- **Detection hardware picker** and presets for Nvidia, Intel, AMD, Raspberry Pi, Coral and plain CPU +- **Push alerts through ntfy** (print completion, detections), with rules you edit in the UI +- **Server health**: container status and restart, and recording-disk usage +- **Home Assistant** through the standard Frigate and MQTT integrations +- **One small image**: a distroless, non-root container, multi-arch (amd64 and arm64) ---- +

+ v1.1.0. Daily use on one Nvidia setup; other hardware presets are untested.
+ The Status section says exactly what has been run and what has not. + The dashboard has no user accounts: read Security before you + put it on a network you do not fully trust. +

-## What runs +

+ Why TinkerView · + Status · + Get it · + Updating · + Configuration · + Security
+ Troubleshooting · + How it fits together · + Checking it · + AI assistance · + Layout · + Docs · + Licensing · + Support +

+ +## Why TinkerView + +1. **Cameras that allow one viewer stop being a problem.** go2rtc holds the single + upstream connection and serves everyone else from it, so a printer camera can be on + the wall, in a second tab and in the printer's own UI at once. +2. **Printers and cameras share a screen.** A print's camera, progress and controls sit + together, and the same alerts cover both. +3. **It installs itself.** `install.sh` detects your hardware, writes the right + Frigate preset, creates `.env` and the data folders with the right owner, and starts + the stack. There are also paths for Unraid, CasaOS and a dashboard-only container. +4. **It stays on your network.** No account and no cloud service of mine. Remote + access is a Tailscale sidecar you opt into, not an open port. (Frigate and ntfy have + their own outbound behaviour; see [SECURITY.md](SECURITY.md) and + [docs/CONFIGURATION.md](docs/CONFIGURATION.md#ntfy-and-ntfysh).) +5. **You can read it.** AGPL-3.0, one Go module, one React app, and + [SECURITY.md](SECURITY.md) lists every port and what protects it. + +## Status + +**v1.1.0, used daily on one setup.** Since mid-2026 the full stack has run on my own +Unraid server with an Nvidia GPU: the `nvidia` preset, NVENC "HQ" streams, a mix of RTSP +and MJPEG cameras, and Moonraker, OctoPrint, PrusaLink and Bambu printers. + +**Proven:** + +- the full stack on Unraid with an Nvidia GPU, day to day +- the dashboard, its API and its adapters under the test suite and CI gates in + [Checking it](#checking-it) +- a CasaOS first-run bug (the data folder was created as root) found and fixed +- `install.sh` against a stand-in `docker` command, and every compose overlay + combination for syntax and merging + +**Tested on a clean machine:** + +- A fresh install on a Linux workstation with rootless podman 6.1 and the standalone + docker-compose 5.6, no TinkerView state on it: `./install.sh --yes --preset cpu --build`, + then the dashboard, Frigate, go2rtc, Mosquitto, ntfy and the docker-socket-proxy all came + up, the dashboard showed Frigate online and the container list, and a camera added through + the UI restreamed. The same stack also ran with `compose.bridge.yml`. This used the CPU + preset and a dashboard image built from the checkout. I did not test the prebuilt + `ghcr.io` image path, Docker Engine proper, or any GPU preset there. +- The installer picks the prebuilt image only when it can pull the matching `1.1.0` tag + without logging in; otherwise it builds from the checkout. Until that image is published, + `--build` is what actually gets used and tested. + +**Not run on real hardware:** + +- the `intel`, `amd-rocm`, `pi` and `coral` presets +- SELinux hosts (Fedora, RHEL): `compose.selinux.yml` and the installer's detection are + written but I have not run them on one +- rootless Docker and Docker Desktop socket detection in the installer (read, not run) +- `compose.bridge.yml` on Windows with Docker Desktop (it has run on Linux under podman) +- the Unraid dashboard-only template and the Compose Manager wrapper, and the CasaOS + import route (the steps in [unraid/](unraid/README.md) and [casaos/](casaos/README.md) + are the expected path, not a recording of one) + +**Known issues:** + +- A WebRTC camera tile can occasionally stall. The dashboard notices and reconnects it + by itself after about 15 seconds. The root cause is still under investigation. +- Frigate and go2rtc ports (5000, 1984) have no authentication of their own, so on a + shared LAN they need a firewall (see [Security](#security)). + +

+ Printers view: four printers (OctoPrint, PrusaLink, Klipper/Moonraker, Bambu) with live cameras, progress, temperatures and pause, resume and cancel controls
+ Four printer brands side by side (demo data). +

-| Container | Role | When | -|---|---|---| -| **frigate** | The hub: bundled go2rtc fan-out + dashboard + NVR + detection + WebRTC + HA feed | always | -| **mqtt** (Mosquitto) | Message bus for the Frigate ⇄ Home Assistant integration | for full HA | -| Obico (separate) | Print-failure detection on printer cams | optional, later | -| Tailscale (host-level) | Secure remote access, no exposed ports | recommended | -| Caddy | Auth + TLS, **only** if exposing a public URL | optional | +## Get it ---- +You need a Linux machine (or a NAS) with Docker and Docker Compose v2, and the camera +URLs you want to watch. Rootless Podman with `docker-compose` also works (it is what I +tested the clean install on); see [docs/INSTALL.md](docs/INSTALL.md#podman). Pick one +path. -## Prerequisites +### 1. The installer (recommended) + +```sh +git clone https://github.com/MakerViking/TinkerView.git +cd TinkerView +./install.sh --dry-run # shows what it would do, changes nothing +./install.sh # guided +``` -- Docker + Docker Compose. -- **Unraid:** install the *Nvidia Driver* plugin (for the GTX 1060) and Docker Compose Manager. -- Camera URLs reachable from the Docker host. +The installer detects your GPU, suggests a preset, asks before it changes or starts +anything, and prints the exact `docker compose` command it used. It creates `.env` (mode +600), generates a dashboard token, writes the matching detector block into +`frigate/config.yml`, creates the data folders with the owner the dashboard needs and +waits for `http://127.0.0.1:8088/healthz`. Re-running is safe: it backs up what it +changes to `storage/install-backups/`. ---- +Unattended, with a safe first boot: `./install.sh --yes --preset cpu`. Every option is +in `./install.sh --help` and in [docs/INSTALL.md](docs/INSTALL.md). -## Quick start +Then open **`http://:8088`**. If the installer generated a token it prints a link +ending in `/?token=...`; open that once and the browser remembers it. -```bash -cp .env.example .env # then edit: camera creds, TZ, STORAGE_PATH -# Edit frigate/config.yml: set your real camera URLs, then flip that camera's -# `enabled:` to true (the two worked examples ship disabled on purpose) -# Paste your host preset into the HOST-SPECIFIC block of frigate/config.yml: -# frigate/host-presets/nvidia.yml (your Unraid GTX 1060) -mkdir -p dashboard/data && sudo chown 65532:65532 dashboard/data # dashboard state +### 2. Docker Compose by hand -# Start with the matching overlay (pick ONE): -docker compose -f docker-compose.yml -f compose.nvidia.yml up -d # Unraid (GTX 1060) -docker compose -f docker-compose.yml -f compose.intel.yml up -d # Intel iGPU -docker compose -f docker-compose.yml -f compose.amd-rocm.yml up -d # AMD GPU -docker compose -f docker-compose.yml -f compose.cpu.yml up -d # safe CPU-only -docker compose -f docker-compose.yml -f compose.pi.yml up -d # Raspberry Pi -docker compose -f docker-compose.yml -f compose.bridge.yml up -d # Windows, or a host with a port conflict -# A Coral EdgeTPU stacks on any host for detection (host GPU still decodes): -docker compose -f docker-compose.yml -f compose.intel.yml -f compose.coral.yml up -d +```sh +git clone https://github.com/MakerViking/TinkerView.git && cd TinkerView +cp .env.example .env # edit TZ, STORAGE_PATH, camera credentials +openssl rand -hex 24 # put the output in DASHBOARD_TOKEN in .env +mkdir -p dashboard/data && chown 65532:65532 dashboard/data # the dashboard runs as uid 65532 +# paste your host preset into the HOST-SPECIFIC block of frigate/config.yml +docker compose -f docker-compose.yml -f compose.cpu.yml up -d ``` -Open the UI: `http://:5000` (Frigate) and `http://:1984` (go2rtc, per-camera health). +Without a `DASHBOARD_TOKEN`, anyone on your network can use the dashboard, so set one +before the first start (the installer does it for you). Swap `compose.cpu.yml` for `compose.nvidia.yml`, `compose.intel.yml`, +`compose.amd-rocm.yml` or `compose.pi.yml`. Stack `-f compose.coral.yml` for a Coral +accelerator. Start with `cpu` to confirm cameras connect, then move to your real +hardware. Which preset fits which hardware is in +[`frigate/host-presets/README.md`](frigate/host-presets/README.md). -> **Tip:** start with `compose.cpu.yml` to confirm cameras connect and the dashboard -> loads, then switch to your real GPU overlay + preset. +### 3. Unraid -> **Which detector/decoder for my hardware?** See the full matrix — -> Intel iGPU/Arc/NPU, Nvidia (Pascal→RTX), AMD ROCm, Coral, Pi, CPU — in -> [`frigate/host-presets/README.md`](frigate/host-presets/README.md). Intel `device:` -> note: `GPU` (Gen9+), `NPU` (Core Ultra), or `CPU` (older chips — `device: GPU` -> crashes where there's no OpenVINO GPU plugin). +Unraid has no native Compose, so install **Docker Compose Manager** from the Apps tab, +download a release into `/mnt/user/appdata/tinkerview` and run `bash install.sh` there. +There is also a template for running only the dashboard next to a Frigate you already +have. Step by step, with GPU driver notes: [unraid/README.md](unraid/README.md). ---- +### 4. CasaOS + +Run the installer over SSH in `/DATA/AppData/tinkerview`, or merge the compose files and +use CasaOS's custom install. [casaos/README.md](casaos/README.md) has both. + +### 5. Dashboard only + +Already running Frigate and go2rtc? Run just the dashboard from +`ghcr.io/makerviking/tinkerview:latest` on the host network, with a data folder owned by +uid 65532 mounted at `/data`, and point `data/config.yaml` at your Frigate. The +container command and the settings are in +[docs/INSTALL.md](docs/INSTALL.md#dashboard-only). + +### First steps + +1. In `frigate/config.yml`, point the example `front_door` and `printer1` go2rtc streams + at your real cameras and set each camera's `enabled:` to `true`. They ship disabled + on purpose. Put camera credentials in `.env` (`FRIGATE_RTSP_USER`, + `FRIGATE_RTSP_PASSWORD`). Then `docker compose restart frigate`. +2. Add printers and extra cameras from the dashboard (**Add printer** on the Printers + tab, **Add camera** on the Cameras tab). +3. Set up push alerts (Notifications tab): [docs/CONFIGURATION.md](docs/CONFIGURATION.md#notifications-ntfy). + +Camera preparation (H.264 main stream, substream for detection, which codecs to avoid) is +in [docs/CAMERAS.md](docs/CAMERAS.md). ## Updating -Your config + data are **not** in the repo (gitignored / in Docker volumes), so an -update never touches your settings or recordings. +Your settings and recordings live in `.env`, `dashboard/data/`, `frigate/`, `ntfy/` and +`STORAGE_PATH`, none of which an update touches. + +```sh +git pull # new compose files, presets, docs +docker compose build dashboard && docker compose up -d # dashboard built from source (the default) +docker compose pull && docker compose up -d # only if DASHBOARD_IMAGE in .env is the ghcr.io image +``` + +Which one applies depends on `DASHBOARD_IMAGE` in `.env`: `tinkerview:latest` is built +locally and cannot be pulled; `ghcr.io/makerviking/tinkerview:latest` is pulled. + +`install.sh` records your overlay files in `.env` as `COMPOSE_FILE`, so plain `docker +compose` works from the folder. If you started by hand, repeat your `-f` flags. The +running version shows in the dashboard header and at `GET /api/version`. Details, and +what changed between versions: [docs/UPGRADING.md](docs/UPGRADING.md) and +[CHANGELOG.md](CHANGELOG.md). + +## Configuration + +Most things are set from the dashboard itself. The rest live in three places: + +| What | Where | +|---|---| +| Secrets, images, ports, storage path, timezone | `.env` (copy of `.env.example`) | +| Cameras, go2rtc streams, retention, detector | `frigate/config.yml` | +| Dashboard settings and UI-added printers and cameras | `dashboard/data/config.yaml` and `dashboard/data/devices.yaml` | + +The dashboard reads these environment variables: `DASHBOARD_TOKEN`, `DASHBOARD_LISTEN`, +`DASHBOARD_LOG_LEVEL`, `DASHBOARD_WS_ORIGINS`, `DOCKER_HOST`, `NTFY_BASE_URL` and +`NTFY_TOKEN`. The full reference, with every `.env` variable, the YAML options, MQTT for +Home Assistant, Tailscale and Obico, is in +[docs/CONFIGURATION.md](docs/CONFIGURATION.md) and +[docs/INTEGRATIONS.md](docs/INTEGRATIONS.md). + +

+ TinkerView on a phone: printer cards with camera, progress and controls, with a bottom tab bar
+ The same dashboard on a phone. +

+ +## Security + +TinkerView is an appliance for a network you control: a home LAN or a tailnet. It is not +hardened for the open internet. **Do not forward its ports from your router.** + +- **The dashboard has no accounts.** Anyone who can reach port 8088 can see the cameras + and control the printers. Set a shared token: `openssl rand -hex 24` into + `DASHBOARD_TOKEN` in `.env` (the installer does this for you), recreate the dashboard, + and open `http://:8088/?token=` once. Without a token the dashboard logs + a warning at every start. +- **MQTT is on loopback.** Mosquitto's anonymous port 1883 is published on `127.0.0.1` + only. Home Assistant on another machine uses the password-protected port 1884 from + `compose.mqtt-lan.yml`. +- **Frigate (5000) and go2rtc (1984) are unauthenticated** and, in host-network mode, + reachable from your LAN. Firewall them to machines you trust. +- **`GO2RTC_ALLOW_ARBITRARY_EXEC` is `false` by default.** The GPU "HQ" streams need + `true`. While it is true, anyone who can reach port 1984 can run commands inside the + Frigate container. Turn it on only behind a firewall. +- **Docker access goes through docker-socket-proxy**, bound to `127.0.0.1:2375` and + **read-only by default**: the container panel shows health and the Restart button is + disabled. Setting `DOCKERPROXY_POST=1` in `.env` turns the button on, but that proxy + setting also permits creating containers, which is root-equivalent for anything that can + reach that port. +- **Frigate only receives the variables it needs** (camera credentials, timezone, the + go2rtc exec switch, the LAN IP), not the whole `.env`, so your dashboard token and + Tailscale key are not in its environment. +- **One outbound call by default:** ntfy relays a content-free poll request to `ntfy.sh` + so iOS gets instant push. Set `NTFY_UPSTREAM_BASE_URL=` (empty) in `.env` if you do + not use iOS. Frigate has its own update check; see `telemetry:` in its documentation + if you want it off. +- **Credentials you enter** (printer API keys, Bambu access codes, camera passwords) are + stored in plain text under `dashboard/data/` and, for promoted cameras, in + `frigate/config.yml`. Protect those folders and any backups. + +[SECURITY.md](SECURITY.md) has the full port table, the hardening checklist and the +private way to report a vulnerability. + +## Troubleshooting + +The ones people hit first. [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) has the rest. + +- **Frigate restarts with `[Errno 98] ... ('127.0.0.1', 5001)`.** Another process on the + host already owns a port Frigate needs (5000, 5001, 1984, 8554 or 8555). Find it with + `ss -lntp | grep :5001`, stop it, or add `compose.bridge.yml`. +- **`Unable to read frames from ffmpeg process` on a fresh install.** A demo camera was + enabled before its go2rtc stream pointed at a real camera. Fix the URL or set + `enabled: false`. +- **The dashboard cannot save printers or cameras.** `dashboard/data` is not owned by + uid 65532: `sudo chown 65532:65532 dashboard/data` (without `-R`, so `config.yaml` + stays editable by you). +- **Black camera tiles.** An H.265 or "smart codec" stream. Use the plain H.264 + substream for live view. +- **A tile freezes.** It reconnects by itself after about 15 seconds; reload the tab if + it does not. This is the known issue under [Status](#status). + +## How it fits together + +

+ Server view: container health (Frigate, dashboard, Mosquitto, ntfy, docker-socket-proxy) and storage gauges
+ The Server view, with a docker-socket-proxy attached. +

-**Build-from-source (works everywhere, incl. arm64/Pi):** -```bash -git pull -docker compose -f docker-compose.yml -f compose..yml up -d --build dashboard ``` + cameras (RTSP / MJPEG) ──> Frigate + go2rtc ──> recordings, detection, restream :8554, WebRTC :8555 + printers (Moonraker, OctoPrint, PrusaLink, Bambu) ─┐ + ├──> dashboard :8088 ──> browser + Frigate events <── MQTT (Mosquitto :1883) ─────────┤ └──> ntfy :8092 ──> phone + docker-socket-proxy :2375 ─────────────────────────┘ +``` + +- **Frigate** runs the NVR, the detector and go2rtc. It uses host networking so WebRTC + works without extra configuration. +- **The dashboard** is one Go binary that embeds the React app. It talks to Frigate, + go2rtc, the printers and Docker, and pushes everything to the browser over one + WebSocket. It proxies camera video, so the browser only ever talks to port 8088. +- **Mosquitto** carries Frigate's events to the dashboard and to Home Assistant. +- **ntfy** delivers push notifications. **docker-socket-proxy** gives the dashboard + read access (and, optionally, restart) to Docker without handing it the socket. +- **Tailscale** is an optional sidecar that publishes only the dashboard on your tailnet. + +More detail: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +## Checking it -**Prebuilt image (no local build) —** the `release` workflow publishes a multi-arch -image (`amd64` + `arm64`) to `ghcr.io/makerviking/tinkerview` on each `v*` tag. Point -the dashboard at it and pull: -```bash -echo 'DASHBOARD_IMAGE=ghcr.io/makerviking/tinkerview:latest' >> .env -docker compose -f docker-compose.yml -f compose..yml pull dashboard -docker compose -f docker-compose.yml -f compose..yml up -d dashboard +From `dashboard/` (Go 1.25 or newer): + +```sh +go vet ./... && go test -race ./... ``` -The running version shows in the dashboard header (and at `GET /api/version`). - ---- - -## Step 0 — Camera inventory & codec prep (do this first) - -1. List every camera URL: - - **Printer (MJPEG):** confirm the real endpoint — - Mainsail/Fluidd → `http://printer/webcam/?action=stream`, - raw OctoPi → `http://printer/?action=stream`, - ustreamer/camera-streamer → `http://printer:8080/stream`. Strip trailing slashes. - - **Security (RTSP):** the main `rtsp://…` URL and a low-res substream URL. -2. On each **security** cam: set the main stream to **H.264 + AAC**, create a - **~1280×720 H.264 substream** for detection, set **keyframe interval = fps**, and - **disable "smart"/H.264+/H.265+** codecs for anything you'll view in a browser. -3. Note which printer cams genuinely cap at one client (those are the win). - ---- - -## Fill in the config - -Edit `frigate/config.yml`: - -- **go2rtc streams** — one entry per camera. Security cams = native RTSP (no - re-encode). Printer cams = `ffmpeg:#video=h264#hardware` (transcode to - H.264 on the GPU so they can be viewed via WebRTC and recorded). -- **cameras** — clone the `front_door` (security) and `printer1` (printer) examples. - The **stream name must match the camera name** (and match in Home Assistant). -- **Secrets** — credentials come from `.env` via `{FRIGATE_RTSP_PASSWORD}`. - ---- - -## Verify it works - -- **Fan-out fix:** open the same printer cam from 3+ browser tabs *and* its printer - UI at once — all stay live. On `:1984` confirm a single upstream consumer. -- **Low latency:** in the go2rtc stream page confirm the transport says **webrtc** - (~0.5 s). *Video playing ≠ WebRTC active.* -- **Live wall:** in Frigate create a **Camera Group → "Continuous Streaming"** (the - default "All Cameras" page is 1 fps stills and never WebRTC). -- **Detection:** walk past a security cam → object event + clip in Frigate. -- **Recording:** segments appear under `STORAGE_PATH` (`/media/frigate/recordings`). - ---- - -## Home Assistant (full integration) - -1. Mosquitto is already running. Point HA's **MQTT** integration at the same broker - (`:1883`). Frigate's `mqtt:` is enabled in `config.yml`. -2. Install the **Frigate** integration (HACS) and HA's built-in **go2rtc** - integration. (`RTSPtoWebRTC` is deprecated.) -3. Ensure each go2rtc **stream name exactly matches** the Frigate camera name, or HA - falls back to jsmpeg (~5 fps). -4. Add the **advanced-camera-card** (grid mode) for a dashboard; enable - `media_source` for clip playback. - ---- - -## Print-failure detection (Obico) — optional, later - -See `compose.obico.yml`. Deploy Obico from its upstream project, point it at each -printer's **snapshot** URL, and start in **notify-only** mode. Kept separate from -Frigate on purpose (don't run printer cams through Frigate's person/car detector). - ---- - -## Remote access (Tailscale) - -Install **Tailscale on the host** (Unraid plugin / Windows app / Pi package) — not as -a container, so Frigate keeps host networking for WebRTC. Reach `http://:5000` -from anywhere. No port-forwarding, no public exposure. Only add Caddy if you want a -real public URL. - ---- - -## Troubleshooting / known gotchas - -- **Frigate crash-loops with `[Errno 98] ... bind on ('127.0.0.1', 5001)`** — that's - Frigate's *internal* API port, and nothing you configured. On host networking the - container shares the host's loopback, so a port busy on the host is busy for - Frigate. Find the other listener with `ss -lntp | grep :5001` (on NAS platforms - it's often a second Frigate from the app catalog), then either stop it or switch - to `compose.bridge.yml`. The same applies to 5000, 1984, 8554 and 8555. -- **`Unable to read frames from ffmpeg process` on a fresh install** — the two demo - cameras in `frigate/config.yml` ship **disabled** with placeholder URLs. If you - enabled one before pointing its `go2rtc:` stream at a real camera, this is the log - you get every few seconds. Fix the URL, or set `enabled: false` again. -- **WebRTC quietly becomes MSE (higher latency)** — Docker hides `172.16/12` - candidates. On host networking (Unraid/Pi) it's automatic; on **Windows/bridge** - set `FRIGATE_HOST_LAN_IP` in `.env` and uncomment the `go2rtc: webrtc: candidates:` - block in `config.yml`. Always verify the transport on `:1984`. -- **`address already in use` on 8555** — only **one** go2rtc may own it. Don't run a - separate go2rtc or an HA go2rtc add-on on the same host as Frigate. -- **Dashboard says "no config file" / `dashboard/config.yaml` is a folder** — older - versions bind-mounted config.yaml as a single file, and Docker creates a - *directory* when that file doesn't exist on the host. The dashboard no longer - mounts it: config lives in the `dashboard/data/` directory mount, and a commented - `dashboard/data/config.yaml` is written on first run. Delete the stray folder - (`rm -rf dashboard/config.yaml`) and drop the old mount line if you still have it. - Nothing else is needed — printers and cameras can be added from the UI. -- **Black tiles** — an H.265 / "smart" codec stream. Point the live view at the plain - H.264 substream. -- **Detector won't start / wrong host** — the detector + `ffmpeg.hwaccel_args` are - host-specific (no CPU fallback for hwaccel; OpenVINO is amd64-only). Use the - matching `host-presets/` file; fall back to `cpu.yml` to get booting. -- **HA entities missing** — the Frigate HA integration **requires MQTT** on both - sides pointed at the same broker. Raw RTSP live still works without it. -- **Windows MQTT** — on the bridge overlay, set `mqtt: host:` to `host.docker.internal`. -- **Pin your tags** — no `latest`. Verify the Frigate tag in `.env` against current - releases; note 0.16+ disabled detection by default and moved Nvidia to ONNX. -- **VRAM (GTX 1060, 3 GB)** — keep the detect model at 320×320 and watch `nvidia-smi`. -- **Recording isn't a sole backup** — monitor disk; keep a second copy for footage you can't lose. - ---- - -## File map +From `dashboard/web/` (Node 22): + +```sh +npm ci && npm run typecheck && npm run build ``` -docker-compose.yml base: frigate + mosquitto -compose..yml host overlay: nvidia / intel / amd-rocm / cpu / pi -compose.bridge.yml published ports instead of host networking (Windows, port conflicts) -compose.obico.yml optional print-failure detection (deferred) -.env.example copy to .env: camera creds, TZ, storage, image tags -frigate/config.yml cameras, go2rtc streams, record/detect, mqtt -frigate/host-presets/ detector + hwaccel block to paste per host -dashboard/data/ dashboard state: config.yaml, devices.yaml, Frigate backups -mosquitto/config/ broker config -Caddyfile optional public-exposure proxy + +On every push and pull request, CI runs `gofmt`, a `go mod tidy` diff check, `go vet`, +`go test -race`, `npm ci`, `tsc --noEmit`, `vite build`, `docker compose config` for every +overlay combination, and a no-push build of the dashboard image. Releases are built for +amd64 and arm64 by a separate workflow on `v*` tags. All GitHub Actions are pinned to +full commit SHAs. `./dashboard/run-local.sh` serves the UI in demo mode on +`http://127.0.0.1:8088` without any cameras or printers. + +## AI assistance + +I build this with AI assistance. Code, tests, docs. + +I direct it and review what comes out. Every change passes the same gates whatever wrote +it: `gofmt`, `go vet`, `go test -race`, `tsc`, the production build, and the compose and +image checks in CI. Anything that touches a camera, a printer or the stack is tried on +the running setup before it counts as working, and the [Status](#status) section keeps +"run on hardware" separate from "tested". + +The reasoning lives in the repo rather than in my head. Commit messages record what was +tried and why, and [`docs/`](docs/) carries the design. Judge it on that. + +## Layout + +``` +docker-compose.yml base stack: Frigate, Mosquitto, dashboard, ntfy, docker-socket-proxy +compose..yml hardware overlays: nvidia, intel, amd-rocm, cpu, pi, coral +compose.bridge.yml published ports instead of host networking +compose.mqtt-lan.yml password-protected MQTT for other machines +compose.tailscale.yml Tailscale sidecar for the dashboard +compose.selinux.yml label=disable for SELinux Enforcing hosts (untested) +compose.obico.yml notes for optional print-failure detection +install.sh the installer +.env.example copy to .env +frigate/ config.yml and host-presets/ (detector and decode blocks) +dashboard/ the Go service (cmd/, internal/) and the React app (web/) +mosquitto/ broker config +unraid/ casaos/ platform guides, plus the Unraid dashboard template +tailscale/ Serve config for the sidecar +docs/ install, configuration, upgrading, troubleshooting, architecture ``` + +## Documentation + +- [INSTALL.md](docs/INSTALL.md): installer options, manual setup, bridge networking, dashboard only +- [CONFIGURATION.md](docs/CONFIGURATION.md): `.env`, `config.yaml`, Frigate config, notifications +- [CAMERAS.md](docs/CAMERAS.md): camera inventory, codec preparation, GPU "HQ" streams +- [INTEGRATIONS.md](docs/INTEGRATIONS.md): Home Assistant, Tailscale, Obico, Caddy +- [UPGRADING.md](docs/UPGRADING.md): updating and migrating between versions +- [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md): symptoms and fixes +- [ARCHITECTURE.md](docs/ARCHITECTURE.md): services, ports, data layout, the dashboard's internals +- [unraid/README.md](unraid/README.md) and [casaos/README.md](casaos/README.md): platform guides +- [`frigate/host-presets/README.md`](frigate/host-presets/README.md): which preset for which hardware +- [SECURITY.md](SECURITY.md) and [CHANGELOG.md](CHANGELOG.md) + +## Licensing + +TinkerView is licensed under the GNU Affero General Public License v3.0 +(`AGPL-3.0-only`); see [LICENSE](LICENSE). Third-party components and their licences are +listed in [NOTICE.md](NOTICE.md). + +- Running TinkerView on your own network is **not** distribution. The AGPL's source + obligation applies only if you hand the software to someone else, or offer a modified + version to others over a network. +- Contributions come in under the AGPL, with a short grant that lets the maintainer also + offer commercial terms; see [CONTRIBUTING.md](CONTRIBUTING.md). +- This is how the project treats its material, not legal advice. + +### Trademarks and non-affiliation + +TinkerView is an independent project. It is **not affiliated with, endorsed by, or +sponsored by** the owners of any product it works with. Frigate, go2rtc, Moonraker, +Klipper, Mainsail, Fluidd, OctoPrint, PrusaLink and Prusa, Bambu Lab, Unraid, CasaOS, +Home Assistant, Tailscale, ntfy, Mosquitto, Nvidia, Intel, AMD, Raspberry Pi, Google +Coral and the other names mentioned here belong to their respective owners and are used +only to say what TinkerView works with. + +## Contributing, security, and conduct + +- [CONTRIBUTING.md](CONTRIBUTING.md): how to propose changes, the checks a pull request must pass, and the contributor grant +- [SECURITY.md](SECURITY.md): the trust model and how to report a vulnerability privately, rather than in the issue tracker +- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md): expected community behaviour + +Please use the issue tracker for bugs and questions. Include your preset, the compose +command you ran and the output of `docker compose logs --tail 100 frigate dashboard` +with credentials removed. + +## Support + +TinkerView is free and open source. If you would like to back its development, the money +covers the servers, domains, and tooling behind this and my other projects. + +- [PayPal](https://paypal.me/makerviking), what most people pick +- [Ko-fi](https://ko-fi.com/tinkeratlas) +- [TinkerAtlas+](https://tinkeratlas.com/pricing), a monthly plan on the site + +You can also back it on [Patreon (MuninWorks)](https://www.patreon.com/MuninWorks). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..39d6893 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,198 @@ +# Security + +TinkerView is built to run as an appliance on a network you control: a home LAN +or a tailnet. It is not hardened to be put on the open internet, and I do not +recommend doing that. This page says what is exposed, what protects it, what the +known limits are, and how to report a problem. + +## Reporting a vulnerability + +Please report privately through GitHub Security Advisories: + + +Do not open a public issue for a security problem. Include the version +(`/api/version` or the container image tag), how you run it, and the steps to +reproduce. I will acknowledge a report within a few days and keep you posted. This +is a one-person project, so I cannot promise a fixed timeline, but security fixes +come before features. + +Only the latest release is supported. + +## Trust model + +- **The network is the perimeter.** Everything below assumes only people and + devices you trust can reach the host. Do not forward these ports from your + router. +- **The dashboard is full control.** Anyone who can open it can see every camera, + pause or cancel a print, restart containers (if the Docker proxy allows it) and + add or remove devices. There are no user accounts or roles. There is one shared + token, or none. +- **Frigate and go2rtc keep their own rules.** Frigate's port 5000 and go2rtc's + 1984 are unauthenticated by design. Frigate's authenticated UI is 8971. + +## What listens where + +The stack uses host networking for Frigate, the dashboard and ntfy, so those +services bind the host's interfaces directly. + +| Service | Port | Reachable from | Purpose | +| --- | --- | --- | --- | +| Dashboard | 8088 | LAN (all interfaces) | The web UI and API. Set `DASHBOARD_LISTEN` to narrow it. | +| ntfy | 8092 | LAN (all interfaces) | Push notifications. Default access is deny-all; you create users and tokens. | +| Frigate UI (authenticated) | 8971 | LAN | Frigate's login-protected UI. | +| Frigate UI (internal) | 5000 | Host network: LAN. Bridge overlay: localhost. | Unauthenticated Frigate API. Keep it off untrusted networks. | +| go2rtc API | 1984 | Host network: LAN. Bridge overlay: localhost. | Unauthenticated stream API. See the exec warning below. | +| RTSP restream | 8554 | LAN | Camera streams for VLC, Home Assistant and the like. Unauthenticated video. | +| WebRTC | 8555 tcp+udp | LAN | Low-latency live view. | +| Mosquitto | 1883 | **localhost only** | Anonymous MQTT for Frigate, the dashboard and same-host Home Assistant. | +| Mosquitto (optional) | 1884 | LAN, password required | Only with `compose.mqtt-lan.yml`. | +| docker-socket-proxy | 2375 | **localhost only** | Docker API for container health and restart. | + +In host-network mode Frigate decides how it binds ports 5000 and 1984, and +`docker-compose.yml` cannot narrow them. If your host has a firewall, allow 5000 +and 1984 only from machines you trust. + +## Dashboard token + +Set a shared token and the REST API, the WebSocket and the proxied +Moonraker and go2rtc endpoints require it. + +1. Generate one: `openssl rand -hex 24` +2. Put it in `.env` as `DASHBOARD_TOKEN=...` (or `auth.token` in + `dashboard/data/config.yaml`). +3. Recreate the dashboard container, then open + `http://:8088/?token=` once. The browser stores it. + +With no token set the dashboard still works exactly as before, and logs this +warning at every start: + +``` +dashboard is open to anyone who can reach port 8088; set auth.token or DASHBOARD_TOKEN - see SECURITY.md +``` + +Limits to know about: + +- The token is sent over plain HTTP unless you put TLS in front. On a tailnet + that traffic is already encrypted; on a LAN it is not. Tailscale Serve + (`compose.tailscale.yml`) gives you HTTPS on your tailnet. +- It is one shared secret. Anyone who has it has full control. Rotate it by + changing the value and recreating the container. +- Printer API keys, printer access codes and camera credentials you enter are + stored in plain text in `dashboard/data/` and, for cameras you promote to + Frigate, in `frigate/config.yml`. Protect those directories and any backups. + +## Docker access (docker-socket-proxy) + +The dashboard never touches `/var/run/docker.sock`. It talks to +`tecnativa/docker-socket-proxy`, which is bound to `127.0.0.1:2375`. + +Be clear about what that buys you. The dashboard needs list, stats and restart. +This proxy version can allow `POST` on containers only as a whole, so with +`POST=1` it also allows creating containers, and a container created with a host +mount is root on the host. So: + +- Treat anything that can reach `127.0.0.1:2375` (and the dashboard itself) as + holding root-equivalent access to the host. +- Reading container details also reveals each container's environment variables. +- The proxy is **read-only by default** (`DOCKERPROXY_POST=0`): the health view works + and the Restart button is disabled. Set `DOCKERPROXY_POST=1` in `.env` only if you want + the button and accept the above. `install.sh --allow-restart` does the same. +- Frigate, ntfy and the dashboard all share the host network, so a compromise of any of + them can reach `127.0.0.1:2375`. That is the reason for the read-only default. +- With rootless podman the proxy mounts your user's engine socket (`:ro` on a socket + does not restrict API calls). The same reasoning applies. + +## What each container sees of `.env` + +Neither container is given the whole file. + +- **Frigate** gets `TZ`, `FRIGATE_RTSP_USER`, `FRIGATE_RTSP_PASSWORD`, + `FRIGATE_HOST_LAN_IP` and `GO2RTC_ALLOW_ARBITRARY_EXEC`. It sits on the host network + with unauthenticated ports, so `DASHBOARD_TOKEN`, `NTFY_TOKEN` and `TS_AUTHKEY` are + deliberately not in its environment. Your camera passwords are, because it needs them. +- **The dashboard** gets only `TZ`, `DASHBOARD_TOKEN`, `DASHBOARD_LISTEN`, + `DASHBOARD_LOG_LEVEL`, `DASHBOARD_WS_ORIGINS`, the proxy address, `NTFY_BASE_URL` and + `NTFY_TOKEN`. No camera passwords. + +## Outbound connections + +TinkerView has no account system and does not report to me. What leaves your network: + +- **ntfy to `ntfy.sh`**: a poll request containing a hash of the topic and no message + text, so iOS gets instant push. Auth stays deny-all. Switch it off with + `NTFY_UPSTREAM_BASE_URL=` (empty) in `.env`; Android does not need it. +- **Frigate** is its own project with its own outbound behaviour (for example an + update check). Read its documentation and adjust its config if that matters to you. +- **Image pulls** from the registries named in the compose files (ghcr.io and Docker Hub). +- **Tailscale**, only if you add `compose.tailscale.yml`. + +The dashboard itself makes no outbound calls beyond the devices and services you +configure on your network. + +## go2rtc `exec:` sources + +`GO2RTC_ALLOW_ARBITRARY_EXEC` is `false` in `.env.example`. The dashboard does not +need it. It exists for the optional GPU-enhanced `_hq` streams that you +write yourself in `frigate/config.yml`. + +If you turn it on, anyone who can reach go2rtc's API on port 1984 can run +commands inside the Frigate container, which holds your camera credentials and +recordings. Only enable it on a host where port 1984 is firewalled to trusted +machines or bound to localhost (the bridge overlay does that). The dashboard +itself refuses `exec:`, `echo:` and `expr:` sources, and exposes go2rtc +read-only. + +## MQTT + +Mosquitto is published on the host's loopback only (`127.0.0.1:1883`), with +anonymous access, because Frigate and the dashboard run on the host network and +the dashboard has no MQTT credential setting. + +For Home Assistant on another machine, do not publish 1883 to the LAN. Use the +password-protected second listener: + +```sh +docker exec -it mosquitto mosquitto_passwd -c /mosquitto/data/passwd homeassistant +docker compose -f docker-compose.yml -f compose..yml -f compose.mqtt-lan.yml up -d mqtt +``` + +Then point Home Assistant at `:1884` with that user. Add more users with +the same `mosquitto_passwd` command without `-c`. This is plain MQTT without TLS, +so keep it on a trusted network. + +## Printer connections + +The dashboard talks to printers on your LAN with the credentials you give it. + +- **Moonraker (Klipper):** the dashboard's read-only file and history browser goes + through a proxy that adds the printer's API key. That proxy only allows the + endpoints the browser needs (file list, file metadata, G-code thumbnails, print + history). Everything else, including Moonraker's `/access/*` credential endpoints, + config files and machine endpoints, is refused. +- **Bambu Lab:** the printer's MQTT connection uses TLS, but the dashboard does not + verify the printer's certificate yet (Bambu printers present a certificate from + Bambu's own CA, which is not in the system trust store). Someone who can intercept + traffic on your LAN could impersonate the printer and capture its access code. + Keep Bambu printers on a trusted network segment. Proper certificate pinning is + planned. + +## Hardening checklist + +- [ ] Set `DASHBOARD_TOKEN`. +- [ ] Do not forward any of these ports from your router. +- [ ] Keep `GO2RTC_ALLOW_ARBITRARY_EXEC=false` unless you use `_hq` streams. +- [ ] Leave `DOCKERPROXY_POST=0` (the default) unless you want the Restart button. +- [ ] Firewall ports 5000 and 1984 to trusted machines (host-network mode). +- [ ] Create ntfy users and tokens; leave its default access at deny-all. +- [ ] Use Tailscale (or another VPN) for remote access instead of opening ports. + `compose.tailscale.yml` is an optional sidecar for that. +- [ ] Give your cameras their own VLAN, or block their internet access. +- [ ] Keep images current: `docker compose pull && docker compose up -d`. +- [ ] Back up `dashboard/data/`, `frigate/` and `ntfy/`, and treat the backups as secrets. + +## Supply chain + +Container images are pinned to specific versions in `docker-compose.yml`. The +GitHub Actions workflows pin every action to a full commit SHA and run with +read-only permissions, except the release job, which can push to GHCR. The +dashboard image is distroless and runs as a non-root user. diff --git a/assets/screenshots/cameras.png b/assets/screenshots/cameras.png new file mode 100644 index 0000000..d11a0a8 Binary files /dev/null and b/assets/screenshots/cameras.png differ diff --git a/assets/screenshots/mobile.png b/assets/screenshots/mobile.png new file mode 100644 index 0000000..9c84312 Binary files /dev/null and b/assets/screenshots/mobile.png differ diff --git a/assets/screenshots/printers.png b/assets/screenshots/printers.png new file mode 100644 index 0000000..9535c47 Binary files /dev/null and b/assets/screenshots/printers.png differ diff --git a/assets/screenshots/server.png b/assets/screenshots/server.png new file mode 100644 index 0000000..8017e3d Binary files /dev/null and b/assets/screenshots/server.png differ diff --git a/casaos/README.md b/casaos/README.md new file mode 100644 index 0000000..47d9ccb --- /dev/null +++ b/casaos/README.md @@ -0,0 +1,82 @@ +# TinkerView on CasaOS + +CasaOS is Debian with Docker underneath, and it can import a Docker Compose file. Two +routes work; the first is simpler and the one I recommend. + +Honest status: a CasaOS first-run bug (the dashboard could not write its data folder) +was found and fixed during development. I have not re-run the steps below on a fresh +CasaOS install for this release, so treat them as the expected path and report +anything that differs. + +## Route 1: installer over SSH (recommended) + +CasaOS lists containers started outside its UI as external apps, and they keep +running and restart with the machine. + +```bash +ssh @casaos.local +sudo mkdir -p /DATA/AppData/tinkerview && sudo chown "$USER" /DATA/AppData/tinkerview +cd /DATA/AppData/tinkerview +curl -fsSL https://github.com/MakerViking/TinkerView/archive/refs/tags/v1.1.0.tar.gz \ + | tar xz --strip-components=1 +bash install.sh +``` + +The installer detects your hardware, creates `.env`, creates the data folders with the +right owner (the dashboard runs as uid 65532, which is exactly what CasaOS's own volume +creation gets wrong: it creates folders as root), configures Frigate, and offers to +start the stack. Add `--dry-run` first to preview. If `sudo` asks for a password, the +installer prints the exact `chown` command instead of failing silently. + +Then open `http://:8088`. + +## Route 2: import a compose file in the CasaOS UI + +CasaOS imports one compose file, and TinkerView is a base file plus a hardware +overlay. Merge them first, on the machine: + +```bash +cd /DATA/AppData/tinkerview # after steps 1-4 of Route 1, using --no-start +docker compose -f docker-compose.yml -f compose.cpu.yml config > /tmp/tinkerview-casaos.yml +``` + +(Use your preset's overlay instead of `compose.cpu.yml`.) The merged file has absolute +paths. It also has the values from your `.env` written into it, including camera +passwords, so do not share it. In CasaOS: App Store, **Custom Install**, **Import**, +paste the file, then **Install**. Before you start it, make sure the data folder is +owned by uid 65532: + +```bash +sudo chown 65532:65532 /DATA/AppData/tinkerview/dashboard/data +``` + +(No `-R`: only the folder needs the owner, so you can still edit `config.yaml` there.) +The merged file also bind-mounts the engine socket (`/var/run/docker.sock`, or your +`DOCKER_SOCKET_PATH`) into docker-socket-proxy, and it contains your `DASHBOARD_TOKEN` +if you set one. Set a token in `.env` before you merge: the CasaOS import does not ask +for one. + +I have not verified how every CasaOS version treats the `build:` key in the merged file; +if the import complains, remove the `build:` lines and set +`DASHBOARD_IMAGE=ghcr.io/makerviking/tinkerview:latest` in `.env` before running +`docker compose config` again (the installer's `--prebuilt` default does this). + +## Updating + +```bash +cd /DATA/AppData/tinkerview +docker compose pull && docker compose up -d # DASHBOARD_IMAGE is the ghcr.io image +docker compose build dashboard && docker compose up -d # dashboard built from the checkout (default) +``` + +To pick up newer compose files, download the new release over the folder and run +`bash install.sh` again (it keeps `.env` and `dashboard/data/`; it re-applies the Frigate +preset block, but copy `frigate/config.yml` aside first, because the download replaces it +with the shipped example). + +## Troubleshooting + +- **Dashboard cannot save printers or cameras:** the data folder is owned by root. + `sudo chown 65532:65532 /DATA/AppData/tinkerview/dashboard/data` +- **Frigate crash-loops on port 5001 or 5000:** CasaOS and its apps use many ports. + Find the owner with `ss -lntp | grep :5001`, or install with `--bridge`. diff --git a/compose.bridge.yml b/compose.bridge.yml index ade6f98..50812a3 100644 --- a/compose.bridge.yml +++ b/compose.bridge.yml @@ -18,13 +18,17 @@ # `ss -lntp | grep :5001` — since bridge networking hides the conflict rather # than resolving it, and you may prefer to stop the other service. # +# Needs Docker Compose v2.24 or newer (the `!reset` tag). Services join the compose +# network, so they reach each other by service name (frigate, mqtt, dockerproxy). +# # Cost of bridge networking: no ONVIF camera autodiscovery, and WebRTC needs the # explicit candidate below or it silently falls back to MSE (~1s instead of ~0.5s). # # On Windows you MUST ALSO: -# 1) In .env set HOST_LAN_IP to the Windows machine's LAN IP. +# 1) In .env set FRIGATE_HOST_LAN_IP to the Windows machine's LAN IP. # 2) In frigate/config.yml uncomment the `go2rtc: webrtc: candidates:` block. -# 3) In frigate/config.yml set mqtt host to `host.docker.internal`. +# 3) In frigate/config.yml set mqtt host to `mqtt` (the compose service name; +# Frigate shares the compose network in bridge mode). # # Apply: docker compose -f docker-compose.yml -f compose.bridge.yml up -d # Then: paste frigate/host-presets/cpu.yml (or nvidia if a GPU is exposed) into config. @@ -32,26 +36,33 @@ services: frigate: - network_mode: "bridge" # override the base's host networking + network_mode: !reset null # drop the base's host networking: join the compose network + # 5000 (Frigate's UNAUTHENTICATED internal UI/API) and 1984 (go2rtc API, which + # can run commands when GO2RTC_ALLOW_ARBITRARY_EXEC=true) are published on this + # machine's loopback only. The dashboard reaches them by service name, so it + # doesn't need them on the LAN. Use 8971 for the authenticated Frigate UI. ports: - - "5000:5000" # Frigate UI + - "127.0.0.1:5000:5000" # Frigate UI (unauthenticated) - localhost only - "8971:8971" # Frigate authenticated UI - - "1984:1984" # go2rtc UI - - "8554:8554" # RTSP restream + - "127.0.0.1:1984:1984" # go2rtc UI/API - localhost only + - "8554:8554" # RTSP restream (unauthenticated video; LAN only) - "8555:8555/tcp" # WebRTC - "8555:8555/udp" # WebRTC - # Dashboard on bridge networking with a published port. You MUST also edit - # dashboard/data/config.yaml to reach the other services by their compose service - # names instead of 127.0.0.1, e.g.: - # frigate.base_url: http://frigate:5000 mqtt_addr: tcp://mqtt:1883 - # go2rtc_url: http://frigate:1984 docker.host: tcp://dockerproxy:2375 - # ntfy.base_url: http://ntfy:8092 + # Dashboard on the compose network with a published port. The overlay sets the + # Docker-proxy and ntfy addresses for you (env vars win over config.yaml). You + # MUST also edit dashboard/data/config.yaml so the rest reaches the other + # services by their compose service names instead of 127.0.0.1: + # frigate.base_url: http://frigate:5000 frigate.mqtt_addr: tcp://mqtt:1883 + # go2rtc_url: http://frigate:1984 dashboard: - network_mode: "bridge" + network_mode: !reset null + environment: + DOCKER_HOST: tcp://dockerproxy:2375 + NTFY_BASE_URL: http://ntfy:8092 ports: - "8088:8088" ntfy: - network_mode: "bridge" + network_mode: !reset null ports: - "8092:8092" diff --git a/compose.mqtt-lan.yml b/compose.mqtt-lan.yml new file mode 100644 index 0000000..2106a45 --- /dev/null +++ b/compose.mqtt-lan.yml @@ -0,0 +1,24 @@ +# ----------------------------------------------------------------------------- +# OPTIONAL: password-protected MQTT for other machines on your LAN/tailnet +# (e.g. Home Assistant on a different host, or in a bridge container). +# +# The default stack publishes Mosquitto on this host's loopback only. This +# overlay ADDS a second listener on port 1884 that requires a username and +# password; the anonymous 1883 listener stays on loopback for Frigate and the +# dashboard. +# +# 1) docker exec -it mosquitto mosquitto_passwd -c /mosquitto/data/passwd homeassistant +# 2) docker compose -f docker-compose.yml -f compose..yml \ +# -f compose.mqtt-lan.yml up -d mqtt +# 3) In Home Assistant: MQTT -> broker , port 1884, that user. +# +# Set MQTT_LAN_BIND in .env to a single interface IP (e.g. your tailnet IP) to +# limit where 1884 listens; the default is every interface. Plain MQTT, no TLS: +# trusted networks only. See SECURITY.md. +# ----------------------------------------------------------------------------- +services: + mqtt: + ports: + - "${MQTT_LAN_BIND:-0.0.0.0}:1884:1884" + volumes: + - ./mosquitto/mosquitto.lan.conf:/mosquitto/config/mosquitto.conf:ro diff --git a/compose.nvidia.yml b/compose.nvidia.yml index 5cded0b..82e95ef 100644 --- a/compose.nvidia.yml +++ b/compose.nvidia.yml @@ -1,5 +1,5 @@ # ----------------------------------------------------------------------------- -# Nvidia GPU overlay — YOUR Unraid host (GTX 1060): NVDEC decode, NVENC transcode +# Nvidia GPU overlay (Unraid, Linux): NVDEC decode, NVENC transcode # for the printer-cam MJPEG->H.264 path, and ONNX/TensorRT object detection. # # Requires the Unraid "Nvidia Driver" plugin (or nvidia-container-toolkit on Linux). diff --git a/compose.selinux.yml b/compose.selinux.yml new file mode 100644 index 0000000..cb79c09 --- /dev/null +++ b/compose.selinux.yml @@ -0,0 +1,29 @@ +# ----------------------------------------------------------------------------- +# SELinux overlay (Fedora, RHEL, Rocky, Alma and other hosts where `getenforce` +# prints Enforcing). Without it the containers are denied access to the bind +# mounts (./frigate, ./dashboard/data, ./ntfy, ./mosquitto/*) and to the engine +# socket that docker-socket-proxy reads. +# +# `label=disable` turns SELinux confinement off for these containers only; the +# host stays Enforcing. It is the blunt fix. The finer one is to add :z to each +# bind mount (it relabels the host folder), which you can do yourself if you +# prefer; do not do that for a large recordings folder, the relabel is slow. +# +# install.sh adds this overlay automatically when SELinux is Enforcing. By hand, +# stack it after your host overlay: +# docker compose -f docker-compose.yml -f compose.cpu.yml -f compose.selinux.yml up -d +# +# NOT TESTED by the author on a real SELinux host. Reports welcome. +# ----------------------------------------------------------------------------- + +services: + frigate: + security_opt: ["label=disable"] + mqtt: + security_opt: ["label=disable"] + dashboard: + security_opt: ["label=disable"] + ntfy: + security_opt: ["label=disable"] + dockerproxy: + security_opt: ["label=disable"] diff --git a/compose.tailscale.yml b/compose.tailscale.yml index a2ab9f7..702153b 100644 --- a/compose.tailscale.yml +++ b/compose.tailscale.yml @@ -1,7 +1,7 @@ # ----------------------------------------------------------------------------- # Tailscale sidecar — exposes ONLY the dashboard (:8088) on your tailnet via -# Tailscale Serve (private to the tailnet; NOT a public Funnel). Mirrors the -# userspace-mode pattern of the existing openbrain-tailscale container. +# Tailscale Serve (private to the tailnet; NOT a public Funnel). Uses Tailscale's +# userspace mode. # # Setup: # 1) Generate a Tailscale auth key: admin console > Settings > Keys > @@ -12,6 +12,8 @@ # 2) Apply (add this overlay to the usual command): # docker compose -f docker-compose.yml -f compose.nvidia.yml \ # -f compose.tailscale.yml --env-file .env up -d tailscale +# 2b) Set DASHBOARD_TOKEN in .env too: anyone on your tailnet can reach the +# dashboard through this, so decide who that should be (ACLs) and gate it. # 3) The dashboard is then reachable from any device on your tailnet at # https://tinkerview..ts.net # @@ -21,7 +23,7 @@ # ----------------------------------------------------------------------------- services: tailscale: - image: tailscale/tailscale:latest + image: ${TAILSCALE_IMAGE:-tailscale/tailscale:v1.104.2} container_name: tinkerview-tailscale hostname: tinkerview network_mode: host diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore index 9952341..b459bbf 100644 --- a/dashboard/.dockerignore +++ b/dashboard/.dockerignore @@ -4,3 +4,8 @@ internal/web/dist config.yaml *.md .git +bin +data +dev +.env +*.env diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile index e4e7bef..8870aa1 100644 --- a/dashboard/Dockerfile +++ b/dashboard/Dockerfile @@ -1,14 +1,14 @@ # syntax=docker/dockerfile:1 # Multi-stage, multi-arch: builds the React SPA, cross-compiles the Go binary, # and ships a tiny static distroless image. Build for your hosts with: -# docker buildx build --platform linux/amd64,linux/arm64 -t webcam-dashboard:latest ./dashboard +# docker buildx build --platform linux/amd64,linux/arm64 -t tinkerview:latest ./dashboard # (or just `docker compose build dashboard` for the local arch). # --- stage 1: build the React SPA --- FROM node:22-alpine AS web WORKDIR /web -COPY web/package.json ./ -RUN npm install +COPY web/package.json web/package-lock.json ./ +RUN npm ci COPY web/ ./ RUN npm run build # -> /web/dist (vendored go2rtc player included if present) @@ -20,16 +20,20 @@ WORKDIR /src COPY . . # Inject the built SPA so `//go:embed all:dist` picks up the real assets. COPY --from=web /web/dist ./internal/web/dist -# No committed go.sum — resolve deps reproducibly at build time. -RUN go mod tidy +# Dependencies are pinned by the committed go.mod + go.sum; -mod=readonly makes +# the build fail loudly if they drift instead of silently rewriting them. +RUN go mod download RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ - go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" \ + go build -mod=readonly -trimpath -ldflags "-s -w -X main.Version=${VERSION}" \ -o /out/dashboard ./cmd/dashboard # --- stage 3: runtime --- FROM gcr.io/distroless/static-debian12:nonroot COPY --from=go /out/dashboard /dashboard EXPOSE 8088 +# Distroless has no shell or curl, so the binary probes its own /healthz. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["/dashboard", "-healthcheck"] # No -config: the binary resolves it (/config.yaml, else the legacy # /config.yaml mount). Passing it explicitly would pin old installs to the legacy # path forever. Override with DASHBOARD_CONFIG if you mount it somewhere else. diff --git a/dashboard/README.md b/dashboard/README.md index 3eaf0e2..a42a216 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -31,10 +31,11 @@ vendor go2rtc's player for WebRTC — see `web/public/README.md`). ./dashboard/run-local.sh # http://127.0.0.1:8088 ``` -No Go? Install it without root first: +Needs Go 1.25 or newer (`go.mod` says `go 1.25`) and Node 22. No Go? Download a +release from and unpack it without root: ```sh -curl -fsSL https://go.dev/dl/go1.23.4.linux-amd64.tar.gz | tar -C "$HOME/.local" -xz +tar -C "$HOME/.local" -xzf go.linux-amd64.tar.gz export PATH="$HOME/.local/go/bin:$PATH" ``` @@ -89,8 +90,9 @@ env: `DASHBOARD_TOKEN`, `DOCKER_HOST`, `NTFY_BASE_URL`, `NTFY_TOKEN`. Restart to - Designed for **Tailscale-only** access; auth is light by default. Set `DASHBOARD_TOKEN` to require a bearer token on the API + ntfy action buttons. - **No raw Docker socket.** The dashboard talks to **docker-socket-proxy** - (`CONTAINERS=1, POST=1` only); container restart is allow-listed to the - configured `watch` names. + (`CONTAINERS=1`; `POST=0` by default, so the health view is read-only and the + Restart button is off; `POST=1` enables restart but also permits container creation); + restart is allow-listed to the configured `watch` names. - Frigate `:5000`, go2rtc `:1984`, and Mosquitto are unauthenticated — never expose them publicly; the dashboard re-proxies their media behind its own origin. - Bambu control needs LAN + Developer Mode; until then the panel is read-only with diff --git a/dashboard/cmd/dashboard/healthcheck.go b/dashboard/cmd/dashboard/healthcheck.go new file mode 100644 index 0000000..1725034 --- /dev/null +++ b/dashboard/cmd/dashboard/healthcheck.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "time" + + "github.com/MakerViking/TinkerView/internal/config" +) + +// healthcheck probes the running dashboard's /healthz and returns a process exit +// code (0 healthy, 1 not). It exists because the runtime image is distroless: +// there is no shell, curl or wget for a Docker HEALTHCHECK to call, so the binary +// checks itself. It resolves the listen address exactly like the server does +// (config file, then DASHBOARD_LISTEN), so a custom port keeps working. +func healthcheck(configPath string) int { + // config.Load logs at info level; keep the probe silent unless it fails. + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) + cfg, err := config.Load(config.Resolve(configPath)) + slog.SetDefault(prev) + if err != nil { + fmt.Fprintln(os.Stderr, "healthcheck: load config:", err) + return 1 + } + url := "http://" + probeAddr(cfg.Listen) + "/healthz" + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + fmt.Fprintln(os.Stderr, "healthcheck:", err) + return 1 + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Fprintln(os.Stderr, "healthcheck:", err) + return 1 + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<10)) + if resp.StatusCode != http.StatusOK { + fmt.Fprintln(os.Stderr, "healthcheck: unexpected status", resp.Status) + return 1 + } + return 0 +} + +// probeAddr turns a listen address (":8088", "0.0.0.0:8088", "[::]:8088", +// "10.0.0.5:8088") into a host:port the probe can dial on the same machine. +func probeAddr(listen string) string { + host, port, err := net.SplitHostPort(listen) + if err != nil { + return "127.0.0.1:8088" + } + switch host { + case "", "0.0.0.0", "::": + host = "127.0.0.1" + } + return net.JoinHostPort(host, port) +} + +// warnIfOpen logs one clear warning when no auth token is configured. Auth +// behaviour is unchanged (an empty token means open access); this only makes the +// consequence visible in `docker logs`. +func warnIfOpen(cfg *config.Config) { + if cfg.Auth.Token != "" { + return + } + slog.Warn(fmt.Sprintf("dashboard is open to anyone who can reach port %s; set auth.token or DASHBOARD_TOKEN - see SECURITY.md", listenPort(cfg.Listen))) +} + +func listenPort(listen string) string { + if _, port, err := net.SplitHostPort(listen); err == nil && port != "" { + return port + } + return listen +} diff --git a/dashboard/cmd/dashboard/healthcheck_test.go b/dashboard/cmd/dashboard/healthcheck_test.go new file mode 100644 index 0000000..438ae7b --- /dev/null +++ b/dashboard/cmd/dashboard/healthcheck_test.go @@ -0,0 +1,28 @@ +package main + +import "testing" + +func TestProbeAddr(t *testing.T) { + cases := map[string]string{ + ":8088": "127.0.0.1:8088", + "0.0.0.0:9000": "127.0.0.1:9000", + "[::]:8088": "127.0.0.1:8088", + "10.0.0.5:8088": "10.0.0.5:8088", + "127.0.0.1:8088": "127.0.0.1:8088", + "garbage": "127.0.0.1:8088", + } + for in, want := range cases { + if got := probeAddr(in); got != want { + t.Errorf("probeAddr(%q) = %q, want %q", in, got, want) + } + } +} + +func TestListenPort(t *testing.T) { + if got := listenPort(":8088"); got != "8088" { + t.Errorf("listenPort(:8088) = %q", got) + } + if got := listenPort("1.2.3.4:99"); got != "99" { + t.Errorf("listenPort(1.2.3.4:99) = %q", got) + } +} diff --git a/dashboard/cmd/dashboard/main.go b/dashboard/cmd/dashboard/main.go index 519e97d..dcfcd2a 100644 --- a/dashboard/cmd/dashboard/main.go +++ b/dashboard/cmd/dashboard/main.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -39,20 +40,33 @@ var Version = "dev" var logFile *os.File func main() { + // serve returns the exit code so its defers (log file, signal handler) run + // before the process exits; os.Exit inside it would skip them. + os.Exit(serve()) +} + +func serve() int { var configPath string flag.StringVar(&configPath, "config", "", "path to config.yaml (default: /config.yaml, else the legacy /config.yaml mount)") + var doHealthcheck bool + flag.BoolVar(&doHealthcheck, "healthcheck", false, "probe the running dashboard's /healthz and exit 0 (healthy) or 1; used by the container HEALTHCHECK") flag.Parse() + if doHealthcheck { + return healthcheck(configPath) + } + configPath = config.Resolve(configPath) cfg, err := config.Load(configPath) if err != nil { slog.Error("load config", "err", err) - os.Exit(1) + return 1 } setupLogging(cfg) if logFile != nil { defer logFile.Close() } + warnIfOpen(cfg) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -63,18 +77,25 @@ func main() { // Defined up front because the device manager launches adapter goroutines // through it (at startup and at runtime when devices are added from the UI). var wg sync.WaitGroup - run := func(name string, fn func(context.Context)) { + var fatal atomic.Bool // a critical goroutine panicked: exit non-zero so the supervisor restarts us + launch := func(name string, critical bool, fn func(context.Context)) { wg.Add(1) go func() { defer wg.Done() defer func() { if r := recover(); r != nil { slog.Error("goroutine panic recovered", "name", name, "recover", r) + if critical { + // Staying up with a dead hub would serve frozen tiles silently. + fatal.Store(true) + stop() + } } }() fn(ctx) }() } + run := func(name string, fn func(context.Context)) { launch(name, false, fn) } // --- writable device store (UI-managed printers/cameras + Frigate backups) --- // UI changes persist here rather than in config.yaml, which is never written. @@ -86,7 +107,7 @@ func main() { store, err := devices.Open(filepath.Join(cfg.DataDir, "devices.yaml")) if err != nil { slog.Error("open device store", "err", err) - os.Exit(1) + return 1 } // First run: import the static config.yaml printers into the store so they // become editable/removable from the UI. Thereafter the store is authoritative. @@ -125,10 +146,12 @@ func main() { // Notification rules: prioritize/suppress Frigate detections (known/unknown // face/plate + zone + time of day → push/silent/ignore). Seeded with sensible // defaults on first run; editable from the Recognition tab. + // An unwritable data dir is not fatal (it falls back to in-memory defaults); + // only a malformed existing file is. ruleStore, err := rules.Open(filepath.Join(cfg.DataDir, "notify-rules.json")) if err != nil { slog.Error("open notification rules", "err", err) - os.Exit(1) + return 1 } extraRunners = append(extraRunners, automation.New(b, notifier, ruleStore).Run) @@ -161,6 +184,18 @@ func main() { cmd := &commandRouter{printers: mgr, cameras: cameraMgr, docker: dockerSrc, frigate: frigateAdapter} h := hub.New(b, cmd, cfg.Auth.Token) h.SetDataWritable(dataWritable) + // WS handshakes are same-origin only; this adds extra allowed Origin hosts + // (comma-separated patterns, e.g. "localhost:5173") for dev or odd proxies. + if v := os.Getenv("DASHBOARD_WS_ORIGINS"); v != "" { + var pats []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + pats = append(pats, p) + } + } + h.SetAllowedOrigins(pats) + slog.Info("extra websocket origins allowed", "DASHBOARD_WS_ORIGINS", pats) + } // cameraMgr edits Frigate's global config (known plates + detection hardware) // only when Frigate config editing is available; nil otherwise so the API // reports those endpoints as unavailable. @@ -169,16 +204,17 @@ func main() { frigCfgMgr = cameraMgr } api := web.NewAPI(Version, h, cmd, notifier, frigCfgMgr, ruleStore) + api.SetDevices(mgr, cameraMgr) router, err := web.Router(cfg, h, api, mgr) if err != nil { slog.Error("build router", "err", err) - os.Exit(1) + return 1 } // --- start goroutines --- // Start the hub first so it captures adapter state as printers come up. - run("hub", h.Run) + launch("hub", true, h.Run) for _, r := range store.Printers() { if err := mgr.RestorePrinter(r); err != nil { // launches the adapter via run() slog.Warn("restore printer", "id", r.ID, "err", err) @@ -198,7 +234,9 @@ func main() { // --- HTTP server --- srv := &http.Server{Addr: cfg.Listen, Handler: router, ReadHeaderTimeout: 10 * time.Second} + shutdownDone := make(chan struct{}) go func() { + defer close(shutdownDone) <-ctx.Done() sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -208,13 +246,23 @@ func main() { }() slog.Info("dashboard listening", "addr", cfg.Listen, "version", Version) + exitCode := 0 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - slog.Error("http server", "err", err) + slog.Error("http server", "err", err) // e.g. port in use + exitCode = 1 } - stop() // ensure all goroutines see cancellation - wg.Wait() // graceful drain + stop() // ensure all goroutines see cancellation + <-shutdownDone // let in-flight requests drain + wg.Wait() // graceful drain + if fatal.Load() { + exitCode = 1 + } slog.Info("shutdown complete") + if exitCode != 0 { + return exitCode + } + return 0 } // commandRouter implements hub.CommandHandler by delegating to the printer manager @@ -349,7 +397,7 @@ func setupLogging(cfg *config.Config) { if err != nil { slog.Error("cannot open log file; logging to stdout only", "file", cfg.Log.File, "err", err) } else { - logFile = f // closed in main via defer + logFile = f // closed in serve via defer w = io.MultiWriter(os.Stdout, f) } } diff --git a/dashboard/go.mod b/dashboard/go.mod index d22c6d5..c475272 100644 --- a/dashboard/go.mod +++ b/dashboard/go.mod @@ -1,9 +1,9 @@ module github.com/MakerViking/TinkerView -go 1.25 +go 1.26.0 -// NOTE: `require` directives and go.sum are generated by `go mod tidy` (the -// Docker build runs it; see Dockerfile). Direct dependencies are deliberately +// NOTE: `require` directives and go.sum are maintained with `go mod tidy`, and +// are committed; the Docker build uses them as-is (-mod=readonly). Direct dependencies are deliberately // minimal — only github.com/coder/websocket (WS hub) and gopkg.in/yaml.v3 // (config). The Docker health adapter speaks the Engine REST API over plain // net/http and disk usage uses stdlib syscall, so no docker/docker or x/sys @@ -18,6 +18,6 @@ require ( require ( github.com/gorilla/websocket v1.5.3 // indirect - golang.org/x/net v0.44.0 // indirect + golang.org/x/net v0.61.0 // indirect golang.org/x/sync v0.17.0 // indirect ) diff --git a/dashboard/go.sum b/dashboard/go.sum index 0fb52c7..e4a10c2 100644 --- a/dashboard/go.sum +++ b/dashboard/go.sum @@ -4,8 +4,8 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/dashboard/internal/adapters/docker/docker.go b/dashboard/internal/adapters/docker/docker.go index 75d0ce4..dea4ed8 100644 --- a/dashboard/internal/adapters/docker/docker.go +++ b/dashboard/internal/adapters/docker/docker.go @@ -14,6 +14,7 @@ import ( "net" "net/http" "strings" + "sync" "time" "github.com/MakerViking/TinkerView/internal/adapter" @@ -26,8 +27,14 @@ type Source struct { client *http.Client watch map[string]bool // allow-list of container names; empty = all diskPaths []string + + listFailing bool // last container list failed (log only on transitions); Run goroutine only } +// statsWorkers bounds concurrent /stats requests: each takes ~1s (Docker samples +// CPU), so a serial loop over many containers overran the 5s poll interval. +const statsWorkers = 4 + // New builds the Docker source. host is a Docker endpoint: tcp://host:2375, // http://host:2375, or unix:///var/run/docker.sock. func New(b *bus.Bus, host string, watch, diskPaths []string) (*Source, error) { @@ -121,11 +128,25 @@ type dockerStats struct { func (s *Source) pollContainers(ctx context.Context) { var list []apiContainer if err := s.getJSON(ctx, "/containers/json?all=true", &list); err != nil { - slog.Warn("docker: container list failed", "err", err) + // Log only the transition into failure; a down proxy would otherwise warn every tick. + if !s.listFailing { + slog.Warn("docker: container list failed", "err", err) + s.listFailing = true + } else { + slog.Debug("docker: container list failed", "err", err) + } return } + if s.listFailing { + slog.Info("docker: container list recovered") + s.listFailing = false + } now := time.Now().Unix() out := make([]adapter.Container, 0, len(list)) + var ( + wg sync.WaitGroup + sem = make(chan struct{}, statsWorkers) + ) for _, c := range list { name := strings.TrimPrefix(firstName(c.Names), "/") if len(s.watch) > 0 && !s.watch[name] { @@ -136,10 +157,23 @@ func (s *Source) pollContainers(ctx context.Context) { if c.Created > 0 { row.UptimeSec = now - c.Created } - s.addStats(ctx, c.ID, &row) } out = append(out, row) } + // Stats are fetched concurrently (each writes only its own row in out). + for i := range out { + if out[i].State != "running" { + continue + } + wg.Add(1) + sem <- struct{}{} + go func(row *adapter.Container) { + defer wg.Done() + defer func() { <-sem }() + s.addStats(ctx, row.ID, row) + }(&out[i]) + } + wg.Wait() s.bus.Publish(bus.Event{Source: bus.SourceDocker, Kind: bus.KindContainers, Ts: time.Now(), Payload: out}) } @@ -191,6 +225,9 @@ func (s *Source) Restart(ctx context.Context, idOrName string) error { return err } defer resp.Body.Close() + if resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("restart %s: refused by the docker proxy (restart is off by default; set DOCKERPROXY_POST=1 in .env to allow it, see SECURITY.md)", name) + } if resp.StatusCode >= 300 { return fmt.Errorf("restart %s: HTTP %d", name, resp.StatusCode) } diff --git a/dashboard/internal/adapters/frigate/frigate.go b/dashboard/internal/adapters/frigate/frigate.go index d7c5ce4..56f2956 100644 --- a/dashboard/internal/adapters/frigate/frigate.go +++ b/dashboard/internal/adapters/frigate/frigate.go @@ -16,9 +16,9 @@ import ( "sync" "time" - mqtt "github.com/eclipse/paho.mqtt.golang" "github.com/MakerViking/TinkerView/internal/adapter" "github.com/MakerViking/TinkerView/internal/bus" + mqtt "github.com/eclipse/paho.mqtt.golang" ) type Adapter struct { @@ -62,9 +62,15 @@ func (a *Adapter) Run(ctx context.Context) { a.mu.Unlock() defer cl.Disconnect(200) - if t := cl.Connect(); t.Wait() && t.Error() != nil { - slog.Error("frigate: initial mqtt connect failed (will retry)", "err", t.Error()) - } + // Do not block on the connect token: with ConnectRetry it only completes once the + // broker is reachable, so waiting here would make shutdown hang (until SIGKILL) + // whenever Mosquitto is down. + t := cl.Connect() + go func() { + if t.Wait() && t.Error() != nil { + slog.Error("frigate: initial mqtt connect failed (will retry)", "err", t.Error()) + } + }() // Periodically re-fetch /api/config so record ceilings recover from a slow // Frigate start and pick up mid-session config changes. diff --git a/dashboard/internal/adapters/go2rtc/patch.go b/dashboard/internal/adapters/go2rtc/patch.go new file mode 100644 index 0000000..65b2394 --- /dev/null +++ b/dashboard/internal/adapters/go2rtc/patch.go @@ -0,0 +1,16 @@ +package go2rtc + +import ( + "context" + "net/http" + "net/url" +) + +// PatchStream updates the source of an existing stream in place (PATCH +// /api/streams?name=&src=), keeping the stream object and its consumers. The +// dashboard uses it to apply an edited Frigate-config stream live, without a +// Frigate restart. (PutStream, by contrast, replaces the stream.) +func (c *Client) PatchStream(ctx context.Context, name, src string) error { + q := url.Values{"name": {name}, "src": {src}} + return c.do(ctx, http.MethodPatch, q) +} diff --git a/dashboard/internal/adapters/moonraker/moonraker.go b/dashboard/internal/adapters/moonraker/moonraker.go index 920e155..f0533e8 100644 --- a/dashboard/internal/adapters/moonraker/moonraker.go +++ b/dashboard/internal/adapters/moonraker/moonraker.go @@ -10,17 +10,18 @@ import ( "encoding/json" "fmt" "log/slog" + "math/rand/v2" "net/http" "net/url" "strings" "sync" "time" - "github.com/coder/websocket" - "github.com/coder/websocket/wsjson" "github.com/MakerViking/TinkerView/internal/adapter" "github.com/MakerViking/TinkerView/internal/adapters/printerutil" "github.com/MakerViking/TinkerView/internal/bus" + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" ) // defaultMacros is the safe set advertised when a printer has no curated macros @@ -240,28 +241,59 @@ func (p *Printer) Run(ctx context.Context) { }() backoff := time.Second + lastErr := "" // last logged failure; repeats of it are demoted to Debug for { if ctx.Err() != nil { return } - err := p.session(ctx) + var upAt time.Time + err := p.session(ctx, func() { + upAt = time.Now() + }) if ctx.Err() != nil { return } if err != nil { - slog.Warn("moonraker session ended", "printer", p.id, "err", err) + // Log the first failure (or a changed one) at WARN; an unreachable + // printer would otherwise warn every retry forever. + if msg := err.Error(); msg != lastErr { + slog.Warn("moonraker session ended", "printer", p.id, "err", err) + lastErr = msg + } else { + slog.Debug("moonraker session ended", "printer", p.id, "err", err) + } } p.setOffline() + // A session that stayed up for a while was healthy: retry fast next time + // instead of inheriting the backoff grown during an earlier outage. Only + // then is the failure memory cleared too, so a printer that flaps (connects, + // subscribes, drops with the same error) stays demoted to Debug. + if !upAt.IsZero() && time.Since(upAt) >= stableSession { + backoff = time.Second + if lastErr != "" { + slog.Info("moonraker reconnected", "printer", p.id) + lastErr = "" + } + } + wait := backoff + time.Duration(rand.Int64N(int64(backoff)/5+1)) // +0-20% jitter select { case <-ctx.Done(): return - case <-time.After(backoff): + case <-time.After(wait): } backoff = min(backoff*2, 30*time.Second) } } -func (p *Printer) session(ctx context.Context) error { +const ( + stableSession = 30 * time.Second // a session this long resets the reconnect backoff + pingEvery = 15 * time.Second // WS liveness probe interval + pingTimeout = 10 * time.Second // an unanswered ping this long = dead connection +) + +// session runs one WebSocket session until it fails. onUp is called once the +// subscription is established. +func (p *Printer) session(ctx context.Context, onUp func()) error { dctx, cancel := context.WithTimeout(ctx, 10*time.Second) header := http.Header{} if p.apiKey != "" { @@ -279,12 +311,42 @@ func (p *Printer) session(ctx context.Context) error { if err := p.subscribe(ctx, conn); err != nil { return err } + onUp() + + // Liveness: a half-open TCP connection (printer power loss, Wi-Fi drop without a + // FIN/RST) never errors the read below, so ping periodically and abort the + // session when a ping goes unanswered (pongs are processed inside Read, so this + // starts only once the read loop is about to run). + sctx, abort := context.WithCancelCause(ctx) + defer abort(nil) + go func() { + t := time.NewTicker(pingEvery) + defer t.Stop() + for { + select { + case <-sctx.Done(): + return + case <-t.C: + pctx, pcancel := context.WithTimeout(sctx, pingTimeout) + err := conn.Ping(pctx) + pcancel() + if err != nil && sctx.Err() == nil { + abort(fmt.Errorf("websocket ping failed: %w", err)) + return + } + } + } + }() + for { var msg rpcMsg - if err := wsjson.Read(ctx, conn, &msg); err != nil { + if err := wsjson.Read(sctx, conn, &msg); err != nil { + if c := context.Cause(sctx); c != nil && ctx.Err() == nil { + return c + } return err } - p.handle(ctx, conn, &msg) + p.handle(sctx, conn, &msg) } } diff --git a/dashboard/internal/adapters/octoprint/octoprint.go b/dashboard/internal/adapters/octoprint/octoprint.go index 897d647..5169aac 100644 --- a/dashboard/internal/adapters/octoprint/octoprint.go +++ b/dashboard/internal/adapters/octoprint/octoprint.go @@ -29,16 +29,16 @@ import ( const pollInterval = 3 * time.Second type Printer struct { - bus *bus.Bus - id string - name string - camera string + bus *bus.Bus + id string + name string + camera string httpBase string // http://ip (OctoPrint root) - uiURL string // web UI to open on click; defaults to httpBase - apiKey string - managed bool - macros []string // curated macro allowlist (gcode sent verbatim); empty = none - client *http.Client + uiURL string // web UI to open on click; defaults to httpBase + apiKey string + managed bool + macros []string // curated macro allowlist (gcode sent verbatim); empty = none + client *http.Client mu sync.Mutex online bool diff --git a/dashboard/internal/adapters/prusalink/prusalink.go b/dashboard/internal/adapters/prusalink/prusalink.go index 2a961dc..87d3054 100644 --- a/dashboard/internal/adapters/prusalink/prusalink.go +++ b/dashboard/internal/adapters/prusalink/prusalink.go @@ -88,20 +88,20 @@ type Printer struct { dNC uint64 dHave bool - mu sync.Mutex - apiMode string // "" (undetected) | "v1" (/api/v1 Digest) | "legacy" (/api/printer X-Api-Key) - v1TempsBad bool // sticky: this printer's /api/v1 temp_nozzle is bogus; read temps from legacy - online bool - offlineMsg string - pstate adapter.PState - nozzle adapter.Heater - bed adapter.Heater + mu sync.Mutex + apiMode string // "" (undetected) | "v1" (/api/v1 Digest) | "legacy" (/api/printer X-Api-Key) + v1TempsBad bool // sticky: this printer's /api/v1 temp_nozzle is bogus; read temps from legacy + online bool + offlineMsg string + pstate adapter.PState + nozzle adapter.Heater + bed adapter.Heater progressPct float64 - etaSec int - elapsedSec int - jobID int - lastJobID int // job id whose filename is cached (avoid re-fetching /job each poll) - filename string + etaSec int + elapsedSec int + jobID int + lastJobID int // job id whose filename is cached (avoid re-fetching /job each poll) + filename string } func New(b *bus.Bus, id, name, baseURL, apiKey, camera, uiURL string, managed bool, _ []string) *Printer { diff --git a/dashboard/internal/automation/automation.go b/dashboard/internal/automation/automation.go index 5428a26..e76a73f 100644 --- a/dashboard/internal/automation/automation.go +++ b/dashboard/internal/automation/automation.go @@ -84,6 +84,11 @@ func (e *Engine) handle(ctx context.Context, ev bus.Event) { if !ok { return } + // An Offline blip is not a state change: remember the last real state so + // Complete -> Offline -> Complete (a Wi-Fi drop) doesn't re-fire "Print finished". + if ps.State == adapter.Offline { + return + } e.mu.Lock() prev, had := e.lastState[ps.ID] e.lastState[ps.ID] = ps.State diff --git a/dashboard/internal/automation/automation_test.go b/dashboard/internal/automation/automation_test.go new file mode 100644 index 0000000..fce5bd4 --- /dev/null +++ b/dashboard/internal/automation/automation_test.go @@ -0,0 +1,44 @@ +package automation + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/MakerViking/TinkerView/internal/adapter" + "github.com/MakerViking/TinkerView/internal/bus" + "github.com/MakerViking/TinkerView/internal/notify" +) + +type countNotifier struct { + mu sync.Mutex + n int +} + +func (c *countNotifier) Send(context.Context, notify.Notification) error { + c.mu.Lock() + c.n++ + c.mu.Unlock() + return nil +} + +func TestOfflineBlipDoesNotRefire(t *testing.T) { + cn := &countNotifier{} + e := New(bus.New(), cn, nil) + send := func(s adapter.PState) { + e.handle(context.Background(), bus.Event{Kind: bus.KindPrinterState, Payload: adapter.PrinterState{ID: "p", Name: "P", State: s}}) + } + send(adapter.Printing) + send(adapter.Complete) // fires once + if cn.n != 1 { + t.Fatalf("after completion: %d notifications, want 1", cn.n) + } + // Wi-Fi blip longer than the cooldown must not re-announce the same print. + e.lastFired = map[string]time.Time{} + send(adapter.Offline) + send(adapter.Complete) + if cn.n != 1 { + t.Fatalf("offline blip re-fired: %d notifications, want 1", cn.n) + } +} diff --git a/dashboard/internal/camera/edit.go b/dashboard/internal/camera/edit.go new file mode 100644 index 0000000..03cbbab --- /dev/null +++ b/dashboard/internal/camera/edit.go @@ -0,0 +1,402 @@ +package camera + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "strings" + + "github.com/MakerViking/TinkerView/internal/devices" +) + +// EditRequest is a camera edit. Every field is optional: blank NewName/NewSrc* +// keep the current value, and a blank Username/Password keeps the stored one +// (they are applied independently). ClearCredentials removes both and wins over +// any username/password sent with it. +type EditRequest struct { + NewName string + NewSrcKind string + NewSrcInput string + Username string + Password string + ClearCredentials bool +} + +// Info is the client-facing description of a camera for the edit form. The +// password is never included (HasPassword only) and SrcInput has any URL +// credentials stripped. +type Info struct { + Name string `json:"name"` + SrcKind string `json:"srcKind"` + SrcInput string `json:"srcInput"` + Username string `json:"username"` + HasPassword bool `json:"hasPassword"` + Managed bool `json:"managed"` + Promoted bool `json:"promoted"` + ConfigStream bool `json:"configStream"` + // Editable: the source/credentials can be edited here (UI-managed cameras and + // Frigate-config go2rtc streams). The name is only editable when Managed && + // !Promoted. + Editable bool `json:"editable"` + Rotate int `json:"rotate"` + FlipH bool `json:"flipH"` + FlipV bool `json:"flipV"` +} + +// Edit changes a camera's name, source and/or credentials. It returns async=true +// when the change was queued as a Frigate-restarting background job (a promoted +// camera's source/credentials; progress/outcome come via notices). +// +// A rename needs a non-promoted, UI-managed camera. The source of a camera that +// lives in the Frigate config's go2rtc.streams (not UI-managed) is rewritten in +// that config and applied live to go2rtc, with no Frigate restart; its name stays +// read-only. +func (m *Manager) Edit(name string, req EditRequest) (async bool, err error) { + newName := strings.TrimSpace(req.NewName) + renaming := newName != "" && newName != name + if renaming { + if err := devices.ValidateCameraName(newName); err != nil { + return false, err + } + } + + var asyncWork func(context.Context) + err = func() error { + ctx, cancel := context.WithTimeout(m.base, opTimeout) + defer cancel() + m.mu.Lock() + defer m.mu.Unlock() + rec, ok := m.store.Camera(name) + if !ok || rec.ConfigStream { + if renaming { + return fmt.Errorf("camera %q is defined in the Frigate config; its name can't be changed here", name) + } + var cur *devices.CameraRecord + if ok { + cur = &rec + } + return m.editConfigStreamLocked(ctx, name, cur, req) + } + + newRec := rec + curKind := rec.SrcKind + if curKind == "" { + curKind = devices.InferSrcKind(rec.Src) + } + credsChanged, err := applyCredentials(&newRec, req) + if err != nil { + return err + } + + // Source: a new URL, and/or a new kind (which re-wraps the current URL). + kind := normKind(req.NewSrcKind) + if strings.TrimSpace(req.NewSrcKind) == "" { + kind = curKind + } + input := strings.TrimSpace(req.NewSrcInput) + if input == "" && kind != curKind { + input = devices.SrcInputOf(curKind, rec.Src) + } + srcChanged := false + if input != "" { + newSrc, err := m.resolveSrc(kind, input) + if err != nil { + return err + } + // The edit form shows the URL with credentials stripped; when the + // stored URL embeds them (legacy) and none are supplied, keep them. + if !req.ClearCredentials && rec.Username == "" { + if _, ui, _ := devices.SplitUserinfo(rec.Src); ui != "" { + if _, nui, _ := devices.SplitUserinfo(newSrc); nui == "" { + newSrc = devices.WithUserinfo(newSrc, ui) + } + } + } + newRec.SrcKind = kind // also backfills legacy records that lack it + if newSrc != rec.Src { + newRec.Src, srcChanged = newSrc, true + } + } + if renaming { + newRec.Name = newName + } + + // Promoted cameras are owned by Frigate: rename needs a demote, but a + // source/credential change is re-applied to Frigate asynchronously. + if rec.Promoted { + if renaming { + return fmt.Errorf("camera %q is promoted to Frigate; demote it before renaming", name) + } + if !srcChanged && !credsChanged { + return nil + } + asyncWork = func(ctx context.Context) { + if e := m.editPromotedSource(ctx, newRec); e != nil { + m.notice("error", e.Error()) + } + } + return nil + } + + // Non-promoted: apply synchronously. Store first (rolled back if go2rtc + // then rejects the stream), so a failed edit leaves store == live. + if renaming { + if _, exists := m.store.Camera(newName); exists { + return fmt.Errorf("a camera named %q already exists", newName) + } + if err := m.checkStreamCollision(ctx, newName); err != nil { + return err + } + if _, err := m.store.RenameCamera(name, newRec); err != nil { + return fmt.Errorf("save camera: %w", err) + } + if err := m.g2.PutStream(ctx, newName, m.sourceWithTransform(newRec)); err != nil { + if _, rerr := m.store.RenameCamera(newName, rec); rerr != nil { + slog.Error("camera rename rollback failed", "from", newName, "to", name, "err", rerr) + } + _ = m.g2.DeleteStream(ctx, newName) + return fmt.Errorf("go2rtc rejected the renamed stream (nothing changed): %w", err) + } + if err := m.g2.DeleteStream(ctx, name); err != nil { + slog.Warn("camera rename: old go2rtc stream not removed", "name", name, "err", err) + } + m.publishDeleted(name) + m.publishManaged(newRec) + } else { + if !srcChanged && !credsChanged { + return nil + } + if err := m.store.UpsertCamera(newRec); err != nil { + return fmt.Errorf("save camera: %w", err) + } + if err := m.g2.PutStream(ctx, name, m.sourceWithTransform(newRec)); err != nil { // PUT replaces the source + if rerr := m.store.UpsertCamera(rec); rerr != nil { + slog.Error("camera edit rollback failed", "name", name, "err", rerr) + } + return fmt.Errorf("go2rtc rejected the stream (nothing changed): %w", err) + } + m.publishManaged(newRec) + } + slog.Info("camera edited", "name", newRec.Name) + return nil + }() + if err != nil { + return false, err + } + if asyncWork != nil { + m.async(asyncWork) + return true, nil + } + return false, nil +} + +// applyCredentials applies the request's credential changes to rec and reports +// whether anything changed. Username and password are independent: each is only +// replaced when non-blank. ClearCredentials removes both (and any credentials +// embedded in the source URL) and overrides the other fields. +func applyCredentials(rec *devices.CameraRecord, req EditRequest) (bool, error) { + if req.ClearCredentials { + stripped := devices.StripUserinfo(rec.Src) + changed := rec.Username != "" || rec.Password != "" || stripped != rec.Src + rec.Username, rec.Password, rec.Src = "", "", stripped + return changed, nil + } + changed := false + if u := strings.TrimSpace(req.Username); u != "" && u != rec.Username { + rec.Username, changed = u, true + } + if req.Password != "" && req.Password != rec.Password { + rec.Password, changed = req.Password, true + } + if rec.Username == "" && rec.Password != "" { + return false, fmt.Errorf("a password needs a username") + } + return changed, nil +} + +// Info describes a camera for the edit form (nil-safe on unknown names). It +// covers UI-managed cameras from the store and, when Frigate config editing is +// available, hand-authored cameras restreamed through go2rtc.streams. +func (m *Manager) Info(ctx context.Context, name string) (Info, bool) { + if rec, ok := m.store.Camera(name); ok { + kind := rec.SrcKind + if kind == "" { + kind = devices.InferSrcKind(rec.Src) + } + user, hasPass := rec.Username, rec.Password != "" + if user == "" { + _, ui, _ := devices.SplitUserinfo(rec.Src) + user, hasPass = splitUserPass(ui) + } + return Info{ + Name: rec.Name, SrcKind: kind, SrcInput: devices.SrcInputOf(kind, rec.Src), + Username: user, HasPassword: hasPass, + Managed: !rec.ConfigStream, Promoted: rec.Promoted, ConfigStream: rec.ConfigStream, + Editable: true, Rotate: rec.Rotate, FlipH: rec.FlipH, FlipV: rec.FlipV, + }, true + } + if m.frigate == nil || strings.HasSuffix(name, "_hq") { + return Info{}, false + } + raw, err := m.frigate.GetRaw(ctx) + if err != nil { + return Info{}, false + } + base, isCam, err := readConfigStream(raw, name) + if err != nil { + return Info{}, false + } + kind := devices.InferSrcKind(base) + _, ui, _ := devices.SplitUserinfo(base) + user, hasPass := splitUserPass(ui) + return Info{ + Name: name, SrcKind: kind, SrcInput: devices.SrcInputOf(kind, base), + Username: user, HasPassword: hasPass, + Promoted: isCam, ConfigStream: true, Editable: true, + }, true +} + +// splitUserPass splits "user[:pass]" userinfo. +func splitUserPass(ui string) (user string, hasPass bool) { + if i := strings.IndexByte(ui, ':'); i >= 0 { + return ui[:i], ui[i+1:] != "" + } + return ui, false +} + +// editConfigStreamLocked edits the source/credentials of a camera whose go2rtc +// stream lives in the Frigate config: rec is its adopted store record (image +// transform) or nil for a plain hand-authored camera. The stream entry is +// rewritten in the Frigate config (backup + validate/save WITHOUT restart) and +// the new source is patched into the running go2rtc, so no restart is needed. +// *_hq exec streams are never touched. Caller holds m.mu. +func (m *Manager) editConfigStreamLocked(ctx context.Context, name string, rec *devices.CameraRecord, req EditRequest) error { + if m.frigate == nil { + return fmt.Errorf("unknown camera %q (Frigate config editing is not available)", name) + } + if strings.HasSuffix(name, "_hq") { + return fmt.Errorf("%q is a generated GPU-enhanced stream and can't be edited", name) + } + raw, err := m.frigate.GetRaw(ctx) + if err != nil { + return fmt.Errorf("read Frigate config: %w", err) + } + cfgSrc, _, err := readConfigStream(raw, name) + if err != nil { + return err + } + // The pristine base source: the adopted record's, else what the config holds. + base := cfgSrc + if rec != nil { + base = rec.Src + } + curKind := devices.InferSrcKind(base) + if rec != nil && rec.SrcKind != "" { + curKind = rec.SrcKind + } + kind := normKind(req.NewSrcKind) + if strings.TrimSpace(req.NewSrcKind) == "" { + kind = curKind + } + + newBase := base + input := strings.TrimSpace(req.NewSrcInput) + if input == "" && kind != curKind { + input = devices.SrcInputOf(curKind, base) + } + if input != "" { + newBase, err = m.resolveSrc(kind, input) + if err != nil { + return err + } + // Credentials stripped from the form stay with the stream unless cleared. + if _, ui, _ := devices.SplitUserinfo(base); ui != "" && !req.ClearCredentials { + if _, nui, _ := devices.SplitUserinfo(newBase); nui == "" { + newBase = devices.WithUserinfo(newBase, ui) + } + } + } + // Credentials live in the URL for these streams (the Frigate config has no + // separate fields). Existing parts (possibly {ENV} placeholders) are kept + // verbatim; new parts are escaped. + if req.ClearCredentials { + newBase = devices.StripUserinfo(newBase) + } else if u, p := strings.TrimSpace(req.Username), req.Password; u != "" || p != "" { + if !strings.Contains(newBase, "://") { + return fmt.Errorf("this stream's source has no URL to carry credentials") + } + _, ui, _ := devices.SplitUserinfo(newBase) + eu, ep, _ := strings.Cut(ui, ":") + if u != "" { + eu = escapeUserinfo(u) + } + if p != "" { + ep = escapeUserinfo(p) + } + if eu == "" { + return fmt.Errorf("a password needs a username") + } + nui := eu + if ep != "" { + nui += ":" + ep + } + newBase = devices.WithUserinfo(newBase, nui) + } + if newBase == base { + return nil // nothing to change + } + + next := devices.CameraRecord{Name: name, Src: newBase} + if rec != nil { + next = *rec + next.Src = newBase + } + next.SrcKind = kind + live := newBase + if rec != nil { + var terr error + if live, terr = devices.BuildTransformSource(next.Source(), next.Rotate, next.FlipH, next.FlipV, m.hardware); terr != nil { + return terr + } + } + + backupPath, err := m.frigate.backup(raw) + if err != nil { + return fmt.Errorf("back up Frigate config (aborting): %w", err) + } + newRaw, err := updateStreamSourceYAML(raw, name, live) + if err != nil { + return fmt.Errorf("build Frigate config: %w", err) + } + // saveonly validates AND writes the config without restarting Frigate. + if err := m.frigate.Validate(ctx, newRaw); err != nil { + return fmt.Errorf("Frigate rejected the config: %w", err) + } + if rec != nil { + if err := m.store.UpsertCamera(next); err != nil { + if rerr := m.frigate.Validate(ctx, raw); rerr != nil { + slog.Error("camera edit: could not restore Frigate config", "backup", backupPath, "err", rerr) + } + return fmt.Errorf("save camera: %w", err) + } + m.publishManaged(next) + } + + // Apply live. {ENV} placeholders in the credentials are expanded by Frigate at + // start-up only, so a source carrying them can't be patched into go2rtc. + if _, ui, _ := devices.SplitUserinfo(live); strings.ContainsAny(ui, "{}") { + m.notice("info", fmt.Sprintf("Camera %q saved to the Frigate config; it keeps {ENV} credentials, so the change applies on the next Frigate restart", name)) + return nil + } + if err := m.g2.PatchStream(ctx, name, live); err != nil { + return fmt.Errorf("saved to the Frigate config (backup: %s) but go2rtc rejected the live update, so it will apply on the next Frigate restart: %w", backupPath, err) + } + slog.Info("config-stream camera source edited", "name", name) + return nil +} + +// escapeUserinfo percent-encodes one part of URL userinfo (user or password). +func escapeUserinfo(s string) string { + return strings.TrimSuffix(url.UserPassword(s, "").String(), ":") +} diff --git a/dashboard/internal/camera/edit_test.go b/dashboard/internal/camera/edit_test.go new file mode 100644 index 0000000..d6e29fe --- /dev/null +++ b/dashboard/internal/camera/edit_test.go @@ -0,0 +1,367 @@ +package camera + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/MakerViking/TinkerView/internal/adapters/go2rtc" + "github.com/MakerViking/TinkerView/internal/bus" + "github.com/MakerViking/TinkerView/internal/devices" +) + +// fakeG2 is a minimal go2rtc streams API. +type fakeG2 struct { + mu sync.Mutex + streams map[string]string + calls []string // "METHOD name" + failPut map[string]bool +} + +func newFakeG2(t *testing.T) (*fakeG2, *go2rtc.Client) { + f := &fakeG2{streams: map[string]string{}, failPut: map[string]bool{}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + q := r.URL.Query() + switch r.Method { + case http.MethodGet: + out := map[string]any{} + for n := range f.streams { + out[n] = map[string]any{} + } + _ = json.NewEncoder(w).Encode(out) + case http.MethodPut, http.MethodPatch: + name := q.Get("name") + f.calls = append(f.calls, r.Method+" "+name) + if f.failPut[name] { + http.Error(w, "nope", http.StatusBadRequest) + return + } + f.streams[name] = q.Get("src") + case http.MethodDelete: + f.calls = append(f.calls, "DELETE "+q.Get("src")) + delete(f.streams, q.Get("src")) + } + })) + t.Cleanup(srv.Close) + return f, go2rtc.NewClient(srv.URL) +} + +func (f *fakeG2) src(name string) string { + f.mu.Lock() + defer f.mu.Unlock() + return f.streams[name] +} + +func newTestManager(t *testing.T, fr *FrigateConfig) (*Manager, *devices.Store, *fakeG2) { + t.Helper() + store, err := devices.Open(filepath.Join(t.TempDir(), "devices.yaml")) + if err != nil { + t.Fatal(err) + } + f, client := newFakeG2(t) + return NewManager(context.Background(), store, client, bus.New(), fr, nil, false), store, f +} + +func TestEditCameraCredentialSemantics(t *testing.T) { + m, store, g := newTestManager(t, nil) + if err := m.AddCamera("door", "rtsp", "rtsp://10.0.0.5/s", "admin", "pw1"); err != nil { + t.Fatal(err) + } + if got := g.src("door"); got != "rtsp://admin:pw1@10.0.0.5/s" { + t.Fatalf("add src = %q", got) + } + rec := func() devices.CameraRecord { r, _ := store.Camera("door"); return r } + + // Username only (blank password): the password must be KEPT, not wiped. + if _, err := m.Edit("door", EditRequest{Username: "root"}); err != nil { + t.Fatal(err) + } + if r := rec(); r.Username != "root" || r.Password != "pw1" { + t.Fatalf("username-only edit: %+v", r) + } + // Same username re-typed, blank password: nothing wiped. + if _, err := m.Edit("door", EditRequest{Username: "root"}); err != nil { + t.Fatal(err) + } + if r := rec(); r.Password != "pw1" { + t.Fatalf("re-typed username wiped password: %+v", r) + } + // Password only (blank username) is applied. + if _, err := m.Edit("door", EditRequest{Password: "pw2"}); err != nil { + t.Fatal(err) + } + if r := rec(); r.Username != "root" || r.Password != "pw2" { + t.Fatalf("password-only edit: %+v", r) + } + if got := g.src("door"); got != "rtsp://root:pw2@10.0.0.5/s" { + t.Fatalf("live src = %q", got) + } + // Source change with the credentials-stripped URL keeps credentials. + if _, err := m.Edit("door", EditRequest{NewSrcKind: "rtsp", NewSrcInput: "rtsp://10.0.0.6/s2"}); err != nil { + t.Fatal(err) + } + if got := g.src("door"); got != "rtsp://root:pw2@10.0.0.6/s2" { + t.Fatalf("live src after source edit = %q", got) + } + // Clear removes both. + if _, err := m.Edit("door", EditRequest{ClearCredentials: true, Username: "ignored"}); err != nil { + t.Fatal(err) + } + if r := rec(); r.Username != "" || r.Password != "" { + t.Fatalf("clear failed: %+v", r) + } + if got := g.src("door"); got != "rtsp://10.0.0.6/s2" { + t.Fatalf("live src after clear = %q", got) + } + // A password with no username anywhere is rejected, store untouched. + if _, err := m.Edit("door", EditRequest{Password: "x"}); err == nil { + t.Fatal("password without username must be rejected") + } + if r := rec(); r.Password != "" { + t.Fatalf("rejected edit mutated store: %+v", r) + } +} + +func TestInfoInfersSrcKindAndRedacts(t *testing.T) { + m, store, _ := newTestManager(t, nil) + // Legacy records: no SrcKind, credentials embedded in the URL. + _ = store.UpsertCamera(devices.CameraRecord{Name: "legacy", Src: "ffmpeg:http://u:secret@h/cam#video=h264#hardware"}) + _ = store.UpsertCamera(devices.CameraRecord{Name: "withcreds", Src: "rtsp://h/s", SrcKind: "rtsp", Username: "a", Password: "b"}) + info, ok := m.Info(context.Background(), "legacy") + if !ok || info.SrcKind != "mjpeg" || info.SrcInput != "http://h/cam" || info.Username != "u" || !info.HasPassword || !info.Editable || !info.Managed { + t.Fatalf("legacy info: %+v", info) + } + b, _ := json.Marshal(info) + if strings.Contains(string(b), "secret") { + t.Fatalf("password leaked: %s", b) + } + info, _ = m.Info(context.Background(), "withcreds") + if info.Username != "a" || !info.HasPassword || info.SrcInput != "rtsp://h/s" { + t.Fatalf("withcreds info: %+v", info) + } + if _, ok := m.Info(context.Background(), "ghost"); ok { + t.Fatal("unknown camera reported") + } + + // Editing a legacy record without touching credentials keeps the embedded ones. + m2, store2, g2 := newTestManager(t, nil) + _ = store2.UpsertCamera(devices.CameraRecord{Name: "legacy", Src: "rtsp://u:secret@h/s"}) + if _, err := m2.Edit("legacy", EditRequest{NewSrcKind: "rtsp", NewSrcInput: "rtsp://h/s2"}); err != nil { + t.Fatal(err) + } + if got := g2.src("legacy"); got != "rtsp://u:secret@h/s2" { + t.Fatalf("embedded creds lost: %q", got) + } + if r, _ := store2.Camera("legacy"); r.SrcKind != "rtsp" { + t.Fatalf("SrcKind not backfilled: %+v", r) + } +} + +func TestSourceCheckRejectsProcessSchemes(t *testing.T) { + m, store, _ := newTestManager(t, nil) + bad := []struct{ kind, in string }{ + {"advanced", "exec:sh -c id"}, + {"raw", "EXEC:id"}, + {"", "echo:foo"}, + {"advanced", "expr:1"}, + {"advanced", "ffmpeg:/etc/passwd"}, + {"advanced", "ffmpeg:http://h/x#input=-f lavfi -i testsrc"}, + {"mjpeg", "/etc/passwd"}, + {"h264", "exec:id"}, + {"advanced", "rtsp://h/s\nexec:id"}, + } + for _, c := range bad { + if err := m.AddCamera("x", c.kind, c.in, "", ""); err == nil { + t.Errorf("AddCamera accepted %q (%s)", c.in, c.kind) + } + } + good := []struct{ kind, in string }{ + {"advanced", "rtsp://h/s"}, + {"advanced", "ffmpeg:http://h/x#video=h264#raw=-vf hflip"}, + {"advanced", "ffmpeg:rtsp://h/x#input=rtsp/udp"}, + {"advanced", "onvif://u:p@h:80"}, + {"mjpeg", "http://h/cam"}, + } + for i, c := range good { + if err := m.AddCamera("ok"+string(rune('a'+i)), c.kind, c.in, "", ""); err != nil { + t.Errorf("AddCamera rejected %q: %v", c.in, err) + } + } + // Edit path too. + if _, err := m.Edit("oka", EditRequest{NewSrcKind: "advanced", NewSrcInput: "exec:id"}); err == nil { + t.Fatal("Edit accepted exec:") + } + if r, _ := store.Camera("oka"); r.Src != "rtsp://h/s" { + t.Fatalf("store mutated: %+v", r) + } +} + +func TestRenameCascadesAndRollsBack(t *testing.T) { + m, store, g := newTestManager(t, nil) + _ = m.AddCamera("old", "rtsp", "rtsp://h/s", "", "") + _ = store.UpsertPrinter(devices.PrinterRecord{ID: "p", Kind: "moonraker", Name: "P", BaseURL: "http://x", Camera: "old"}) + + // go2rtc rejecting the new stream: nothing changes. + g.mu.Lock() + g.failPut["new"] = true + g.mu.Unlock() + if _, err := m.Edit("old", EditRequest{NewName: "new"}); err == nil { + t.Fatal("expected go2rtc failure") + } + if _, ok := store.Camera("old"); !ok { + t.Fatal("camera lost after failed rename") + } + if _, ok := store.Camera("new"); ok { + t.Fatal("new name persisted after failed rename") + } + if p, _ := store.Printer("p"); p.Camera != "old" { + t.Fatalf("printer ref = %q after rollback", p.Camera) + } + if g.src("old") == "" { + t.Fatal("old go2rtc stream was removed") + } + + g.mu.Lock() + g.failPut["new"] = false + g.mu.Unlock() + if _, err := m.Edit("old", EditRequest{NewName: "new"}); err != nil { + t.Fatal(err) + } + if _, ok := store.Camera("old"); ok { + t.Fatal("old record remains") + } + if r, ok := store.Camera("new"); !ok || r.Src != "rtsp://h/s" { + t.Fatalf("new record: %+v %v", r, ok) + } + if p, _ := store.Printer("p"); p.Camera != "new" { + t.Fatalf("printer ref not cascaded: %q", p.Camera) + } + if g.src("new") == "" || g.src("old") != "" { + t.Fatalf("go2rtc streams: new=%q old=%q", g.src("new"), g.src("old")) + } + + // Removing clears the reference. + if err := m.RemoveCamera("new"); err != nil { + t.Fatal(err) + } + if p, _ := store.Printer("p"); p.Camera != "" { + t.Fatalf("printer ref after delete: %q", p.Camera) + } +} + +// fakeFrigate serves GET /api/config/raw and records POST /api/config/save. +type fakeFrigate struct { + mu sync.Mutex + cfg string + saves []string // save_option per POST +} + +func newFakeFrigate(t *testing.T, cfg string) (*fakeFrigate, *FrigateConfig) { + f := &fakeFrigate{cfg: cfg} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/config/raw": + _ = json.NewEncoder(w).Encode(f.cfg) + case r.Method == http.MethodPost && r.URL.Path == "/api/config/save": + b, _ := io.ReadAll(r.Body) + f.cfg = string(b) + f.saves = append(f.saves, r.URL.Query().Get("save_option")) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return f, NewFrigateConfig(srv.URL, t.TempDir()) +} + +func TestEditFrigateConfigStreamSource(t *testing.T) { + fr, fc := newFakeFrigate(t, sampleConfig) + m, store, g := newTestManager(t, fc) + + // Name is read-only. + if _, err := m.Edit("front_door", EditRequest{NewName: "x"}); err == nil { + t.Fatal("rename of a config stream must be rejected") + } + // Unknown stream. + if _, err := m.Edit("ghost", EditRequest{NewSrcKind: "rtsp", NewSrcInput: "rtsp://h/s"}); err == nil { + t.Fatal("unknown stream must error") + } + // *_hq is never touched. + if _, err := m.Edit("front_door_hq", EditRequest{NewSrcKind: "rtsp", NewSrcInput: "rtsp://h/s"}); err == nil { + t.Fatal("_hq stream must be rejected") + } + + // Info reads it from the Frigate config, credentials stripped. + info, ok := m.Info(context.Background(), "front_door") + if !ok || !info.ConfigStream || info.Managed || !info.Editable || info.SrcKind != "rtsp" || + info.SrcInput != "rtsp://192.168.1.50:554/Streaming/Channels/101" || !info.HasPassword { + t.Fatalf("config-stream info: %+v ok=%v", info, ok) + } + + // Edit the source URL, keep the credentials ({ENV} placeholders). + async, err := m.Edit("front_door", EditRequest{NewSrcKind: "rtsp", NewSrcInput: "rtsp://192.168.1.51:554/Streaming/Channels/101"}) + if err != nil || async { + t.Fatalf("edit: async=%v err=%v", async, err) + } + fr.mu.Lock() + cfg, saves := fr.cfg, append([]string(nil), fr.saves...) + fr.mu.Unlock() + if !strings.Contains(cfg, "@192.168.1.51:554/Streaming/Channels/101") || !strings.Contains(cfg, "{FRIGATE_RTSP_USER}") { + t.Fatalf("config not updated / creds lost:\n%s", cfg) + } + if !strings.Contains(cfg, "# security cam") { + t.Fatalf("comments lost:\n%s", cfg) + } + for _, s := range saves { + if s == "restart" { + t.Fatalf("a restart was triggered: %v", saves) + } + } + // Placeholders can't be expanded by go2rtc, so no live patch -> next restart. + if got := g.src("front_door"); got != "" { + t.Fatalf("placeholder source must not be pushed live: %q", got) + } + if _, ok := store.Camera("front_door"); ok { + t.Fatal("plain config stream must not be adopted into the store") + } + + // A stream without placeholders is patched live (PATCH, no restart). + _, err = m.Edit("printer1", EditRequest{NewSrcKind: "mjpeg", NewSrcInput: "http://192.168.1.81/webcam/?action=stream"}) + if err != nil { + t.Fatal(err) + } + if got := g.src("printer1"); got != "ffmpeg:http://192.168.1.81/webcam/?action=stream#video=h264" { + t.Fatalf("live src = %q", got) + } + g.mu.Lock() + calls := strings.Join(g.calls, ",") + g.mu.Unlock() + if !strings.Contains(calls, "PATCH printer1") { + t.Fatalf("expected PATCH, got %s", calls) + } + fr.mu.Lock() + hasNew := strings.Contains(fr.cfg, "192.168.1.81") + fr.mu.Unlock() + if !hasNew { + t.Fatal("config not updated for printer1") + } + + // New credentials are escaped and injected. + if _, err := m.Edit("printer1", EditRequest{NewSrcKind: "rtsp", NewSrcInput: "rtsp://192.168.1.81/s", Username: "me", Password: "p@ss"}); err != nil { + t.Fatal(err) + } + if got, want := g.src("printer1"), "rtsp://me:"+url.QueryEscape("p@ss")+"@192.168.1.81/s"; got != want && got != "rtsp://me:p%40ss@192.168.1.81/s" { + t.Fatalf("live src = %q", got) + } +} diff --git a/dashboard/internal/camera/manager.go b/dashboard/internal/camera/manager.go index 4f8f032..d0a5652 100644 --- a/dashboard/internal/camera/manager.go +++ b/dashboard/internal/camera/manager.go @@ -81,7 +81,7 @@ func (m *Manager) AddCamera(name, srcKind, srcInput, username, password string) if err := m.checkStreamCollision(ctx, name); err != nil { return err } - rec := devices.CameraRecord{Name: name, Src: src, Username: strings.TrimSpace(username), Password: password, Record: true} + rec := devices.CameraRecord{Name: name, Src: src, SrcKind: normKind(srcKind), Username: strings.TrimSpace(username), Password: password, Record: true} if err := m.store.UpsertCamera(rec); err != nil { return fmt.Errorf("save camera: %w", err) } @@ -94,102 +94,15 @@ func (m *Manager) AddCamera(name, srcKind, srcInput, username, password string) return nil } -// EditCamera changes a live-tile camera's name, source and/or credentials. A -// rename still requires a non-promoted camera, but the source URL and -// credentials of a PROMOTED camera are editable in place: the go2rtc stream in -// Frigate's config is rewritten and Frigate restarts (asynchronously, reported -// via notices) — so a security cam can be given credentials without a demote/ -// re-promote dance. Empty username and empty newSrcInput each mean "keep the -// current value". +// EditCamera is the positional form of Edit used by the WebSocket command: a +// blank username/password keeps the stored value (there is no way to clear +// credentials through it; the REST edit exposes ClearCredentials). func (m *Manager) EditCamera(name, newName, newSrcKind, newSrcInput, username, password string) error { - // Resolve/validate inputs that don't need the lock first. - var newSrc string - if strings.TrimSpace(newSrcInput) != "" { - s, err := m.resolveSrc(newSrcKind, newSrcInput) - if err != nil { - return err - } - newSrc = s - } - renaming := newName != "" && newName != name - if renaming { - if err := devices.ValidateCameraName(newName); err != nil { - return err - } - } - - asyncWork, err := func() (func(context.Context), error) { - ctx, cancel := context.WithTimeout(m.base, opTimeout) - defer cancel() - m.mu.Lock() - defer m.mu.Unlock() - rec, ok := m.store.Camera(name) - if !ok { - return nil, fmt.Errorf("unknown camera %q", name) - } - - newRec := rec - credsChanged := false - if u := strings.TrimSpace(username); u != "" && (u != rec.Username || password != rec.Password) { - newRec.Username, newRec.Password, credsChanged = u, password, true - } - srcChanged := false - if newSrc != "" && newSrc != rec.Src { - newRec.Src, srcChanged = newSrc, true - } - - // Promoted cameras are owned by Frigate: rename needs a demote, but a - // source/credential change is re-applied to Frigate asynchronously. - if rec.Promoted { - if renaming { - return nil, fmt.Errorf("camera %q is promoted to Frigate; demote it before renaming", name) - } - if !srcChanged && !credsChanged { - return nil, nil - } - return func(ctx context.Context) { - if e := m.editPromotedSource(ctx, newRec); e != nil { - m.notice("error", e.Error()) - } - }, nil - } - - // Non-promoted: apply synchronously. - if renaming { - if _, exists := m.store.Camera(newName); exists { - return nil, fmt.Errorf("a camera named %q already exists", newName) - } - if err := m.checkStreamCollision(ctx, newName); err != nil { - return nil, err - } - newRec.Name = newName - if _, err := m.store.DeleteCamera(name); err != nil { - return nil, fmt.Errorf("save camera: %w", err) - } - if err := m.store.UpsertCamera(newRec); err != nil { - return nil, fmt.Errorf("save camera: %w", err) - } - _ = m.g2.DeleteStream(ctx, name) - _ = m.g2.PutStream(ctx, newRec.Name, m.sourceWithTransform(newRec)) - m.publishDeleted(name) - m.publishManaged(newRec) - } else { - if err := m.store.UpsertCamera(newRec); err != nil { - return nil, fmt.Errorf("save camera: %w", err) - } - _ = m.g2.PutStream(ctx, newRec.Name, m.sourceWithTransform(newRec)) // PUT replaces the source - m.publishManaged(newRec) - } - slog.Info("camera edited", "name", newRec.Name) - return nil, nil - }() - if err != nil { - return err - } - if asyncWork != nil { - m.async(asyncWork) - } - return nil + _, err := m.Edit(name, EditRequest{ + NewName: newName, NewSrcKind: newSrcKind, NewSrcInput: newSrcInput, + Username: username, Password: password, + }) + return err } // RemoveCamera deletes a camera. A live-tile camera is removed synchronously; a @@ -233,7 +146,9 @@ func (m *Manager) removeCamera(ctx context.Context, name string) error { return fmt.Errorf("remove from Frigate failed; camera kept: %w", err) } } - if _, err := m.store.DeleteCamera(name); err != nil { + // Also blank any printer's camera reference to it (one save), so a printer + // panel doesn't keep embedding a dead stream. + if _, _, err := m.store.DeleteCameraClearRefs(name); err != nil { return fmt.Errorf("save camera store: %w", err) } _ = m.g2.DeleteStream(ctx, name) // best-effort; reconcile won't re-add (gone from store) @@ -330,19 +245,20 @@ func (m *Manager) resolveSrc(kind, input string) (string, error) { if !strings.HasPrefix(input, "rtsp://") && !strings.HasPrefix(input, "rtsps://") { return "", fmt.Errorf("RTSP source must start with rtsp://") } - return input, nil + return input, checkUserSource(input) case "h264": // raw H.264 over HTTP (e.g. Snapmaker .h264) - return "ffmpeg:" + input + "#video=h264", nil + src := "ffmpeg:" + input + "#video=h264" + return src, checkUserSource(src) case "mjpeg": // MJPEG printer cam → transcode to H.264 for WebRTC s := "ffmpeg:" + input + "#video=h264" if m.hardware { s += "#hardware" } - return s, nil + return s, checkUserSource(s) case "mjpeg_raw": // cheap MJPEG passthrough (MSE/MJPEG fallback only) - return input, nil + return input, checkUserSource(input) case "raw", "advanced", "": - return input, nil // power-user verbatim go2rtc source + return input, checkUserSource(input) // power-user go2rtc source, minus process-spawning schemes default: return "", fmt.Errorf("unknown source kind %q", kind) } diff --git a/dashboard/internal/camera/promote.go b/dashboard/internal/camera/promote.go index 33d880f..6304820 100644 --- a/dashboard/internal/camera/promote.go +++ b/dashboard/internal/camera/promote.go @@ -107,7 +107,7 @@ func (m *Manager) promoteWork(ctx context.Context, name string, opts PromoteOpts // credentials) to Frigate: it rewrites only the go2rtc stream entry and restarts // Frigate, leaving the camera block and NVR options untouched. The store is // updated before the restart (so a crash heals to the new state) and reverted if -// Frigate fails to come back. `want` carries the desired Src/Username/Password. +// Frigate fails to come back. `want` carries the desired Src/SrcKind/Username/Password. func (m *Manager) editPromotedSource(ctx context.Context, want devices.CameraRecord) error { m.mu.Lock() defer m.mu.Unlock() @@ -132,7 +132,7 @@ func (m *Manager) editPromotedSource(ctx context.Context, want devices.CameraRec } // Carry the new source/credentials onto the current record (preserve NVR options). next := cur - next.Src, next.Username, next.Password = want.Src, want.Username, want.Password + next.Src, next.SrcKind, next.Username, next.Password = want.Src, want.SrcKind, want.Username, want.Password newRaw, err := updateStreamSourceYAML(raw, next.Name, m.sourceWithTransform(next)) if err != nil { return fmt.Errorf("build Frigate config: %w", err) @@ -222,21 +222,23 @@ func (m *Manager) EditCameraSettings(name string, s devices.CameraSettings) erro if !rec.Promoted { // UI-added live tile: persist the settings (detect/record take effect on the // next promote) and re-PUT the runtime go2rtc stream so the transform applies - // to the live tile immediately. + // to the live tile immediately. The record is re-read under the same lock + // that guards the upsert, so a concurrent EditCamera isn't reverted by a + // stale snapshot. + ctx, cancel := context.WithTimeout(m.base, opTimeout) + defer cancel() m.mu.Lock() - applySettings(&rec, s) - err := m.store.UpsertCamera(rec) - if err == nil { - m.publishManaged(rec) + defer m.mu.Unlock() + cur, ok := m.store.Camera(name) + if !ok || cur.Promoted || cur.ConfigStream { + return fmt.Errorf("camera %q changed while saving; please retry", name) } - src := m.sourceWithTransform(rec) - m.mu.Unlock() - if err != nil { + applySettings(&cur, s) + if err := m.store.UpsertCamera(cur); err != nil { return fmt.Errorf("save camera: %w", err) } - ctx, cancel := context.WithTimeout(m.base, opTimeout) - defer cancel() - if err := m.g2.PutStream(ctx, name, src); err != nil { + m.publishManaged(cur) + if err := m.g2.PutStream(ctx, name, m.sourceWithTransform(cur)); err != nil { return fmt.Errorf("update live stream: %w", err) } return nil diff --git a/dashboard/internal/camera/srccheck.go b/dashboard/internal/camera/srccheck.go new file mode 100644 index 0000000..e201bd8 --- /dev/null +++ b/dashboard/internal/camera/srccheck.go @@ -0,0 +1,103 @@ +package camera + +import ( + "fmt" + "strings" +) + +// netSchemes are the transport schemes an ffmpeg: source may read from. Anything +// else after "ffmpeg:" (a bare path, device, lavfi graph, file:, concat:, ...) +// would let a UI-supplied source read local files or synthesize input. +var netSchemes = []string{"http://", "https://", "rtsp://", "rtsps://", "rtmp://", "rtmps://"} + +// checkUserSource rejects UI-provided go2rtc sources that spawn a process or read +// local resources inside the go2rtc container: the exec:, echo: and expr: +// schemes, and ffmpeg: sources whose input isn't a network URL or that pass +// raw input/global ffmpeg arguments. Other schemes (onvif://, tapo://, ...) are +// left to the power user. This only guards sources typed into the dashboard; a +// stream already in the Frigate config is never re-checked. +func checkUserSource(src string) error { + for _, r := range src { + if r < 0x20 || r == 0x7f { + return fmt.Errorf("source contains a control character") + } + } + lower := strings.ToLower(strings.TrimSpace(src)) + switch { + case strings.HasPrefix(lower, "exec:"), strings.HasPrefix(lower, "echo:"), strings.HasPrefix(lower, "expr:"): + return fmt.Errorf("exec:, echo: and expr: sources are not allowed (they run commands inside go2rtc)") + case strings.HasPrefix(lower, "ffmpeg:"): + inner := strings.TrimPrefix(lower, "ffmpeg:") + ok := false + for _, s := range netSchemes { + if strings.HasPrefix(inner, s) { + ok = true + break + } + } + if !ok { + return fmt.Errorf("ffmpeg: sources must read from an http(s), rtsp(s) or rtmp(s) URL") + } + // go2rtc splits the ffmpeg command line on spaces, so the URL part must be a + // single token: whitespace or quotes would smuggle in extra arguments. + urlPart, params, _ := strings.Cut(inner, "#") + if strings.ContainsAny(urlPart, " \t\"'`\\") { + return fmt.Errorf("ffmpeg: source URL may not contain spaces, quotes or backslashes") + } + // Fragment params: custom input=/global= args replace or prepend ffmpeg's own. + for _, p := range strings.Split(params, "#") { + custom := func(prefix string) bool { // named presets (rtsp/udp) are fine; raw args are not + v, found := strings.CutPrefix(p, prefix) + return found && (strings.ContainsAny(v, " \t") || strings.HasPrefix(v, "-")) + } + if custom("input=") || custom("global=") { + return fmt.Errorf("ffmpeg: sources may not override ffmpeg input/global arguments") + } + if v, found := strings.CutPrefix(p, "raw="); found { + if err := checkRawArgs(v); err != nil { + return err + } + } + } + } + return nil +} + +// normKind canonicalises a UI source kind for storage ("" and "raw" are the same +// verbatim power-user form as "advanced"). +func normKind(kind string) string { + switch kind { + case "", "raw": + return "advanced" + } + return kind +} + +// rawFlags are the only ffmpeg output flags a raw= fragment may carry; anything +// else (-i, -f, -filter_complex, -protocol_whitelist, ...) could read or write +// local files. +var rawFlags = map[string]bool{ + "-vf": true, "-af": true, "-r": true, "-s": true, "-g": true, "-bf": true, + "-b:v": true, "-b:a": true, "-c:v": true, "-c:a": true, "-q:v": true, "-q:a": true, + "-an": true, "-vn": true, "-ar": true, "-ac": true, "-crf": true, + "-maxrate": true, "-bufsize": true, "-preset": true, "-tune": true, + "-profile:v": true, "-pix_fmt": true, "-rtsp_transport": true, +} + +// checkRawArgs allow-lists the flags in an ffmpeg: raw= fragment and rejects any +// value that looks like a path, file/lavfi input or quoted argument. +func checkRawArgs(v string) error { + for _, tok := range strings.Fields(v) { + if strings.HasPrefix(tok, "-") && len(tok) > 1 && !(tok[1] >= '0' && tok[1] <= '9') { + if !rawFlags[tok] { + return fmt.Errorf("ffmpeg: raw= flag %q is not allowed", tok) + } + continue + } + if strings.ContainsAny(tok, "/\\\"'`") || strings.Contains(tok, "file:") || + strings.Contains(tok, "lavfi") || strings.Contains(tok, "movie") { + return fmt.Errorf("ffmpeg: raw= value %q is not allowed", tok) + } + } + return nil +} diff --git a/dashboard/internal/camera/srccheck_test.go b/dashboard/internal/camera/srccheck_test.go new file mode 100644 index 0000000..8115778 --- /dev/null +++ b/dashboard/internal/camera/srccheck_test.go @@ -0,0 +1,35 @@ +package camera + +import "testing" + +func TestCheckUserSource(t *testing.T) { + bad := []string{ + "exec:touch /tmp/x", + "ffmpeg:/etc/passwd#video=h264", + "ffmpeg:http://x -i /etc/passwd#video=h264", + "ffmpeg:rtsp://x#raw=-i /etc/passwd -f mpegts", + "ffmpeg:rtsp://x#video=h264#raw=-f segment /tmp/a", + "ffmpeg:rtsp://x#raw=-vf movie=/etc/passwd", + "ffmpeg:rtsp://x#input=-re -i foo", + "ffmpeg:rtsp://x#global=-y", + "ffmpeg:rtsp://x#raw=-vf scale=1:1 -filter_complex x", + } + for _, s := range bad { + if checkUserSource(s) == nil { + t.Errorf("checkUserSource(%q) = nil, want error", s) + } + } + good := []string{ + "rtsp://user:pw@10.0.0.2:554/stream", + "ffmpeg:http://10.0.0.5/cam.h264#video=h264", + "ffmpeg:http://10.0.0.5/cam#video=h264#hardware", + "ffmpeg:rtsp://x#video=h264#raw=-r 15 -b:v 2M", + "ffmpeg:rtsp://x#input=rtsp/udp", + "onvif://user:pw@10.0.0.2", + } + for _, s := range good { + if err := checkUserSource(s); err != nil { + t.Errorf("checkUserSource(%q) = %v, want nil", s, err) + } + } +} diff --git a/dashboard/internal/devices/edit_test.go b/dashboard/internal/devices/edit_test.go new file mode 100644 index 0000000..7fe6f88 --- /dev/null +++ b/dashboard/internal/devices/edit_test.go @@ -0,0 +1,238 @@ +package devices + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/MakerViking/TinkerView/internal/bus" +) + +func TestStoreRollsBackWhenSaveFails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "devices.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + if err := s.UpsertPrinter(PrinterRecord{ID: "p1", Kind: "moonraker", Name: "P1", BaseURL: "http://x", Camera: "cam"}); err != nil { + t.Fatal(err) + } + if err := s.UpsertCamera(CameraRecord{Name: "cam", Src: "rtsp://h/s"}); err != nil { + t.Fatal(err) + } + + // Make every further save fail (the temp file can't be created). + if err := os.RemoveAll(filepath.Dir(path)); err != nil { + t.Fatal(err) + } + + if err := s.UpsertPrinter(PrinterRecord{ID: "p2", Kind: "moonraker", Name: "P2", BaseURL: "http://y"}); err == nil { + t.Fatal("expected save error") + } + if err := s.UpsertPrinter(PrinterRecord{ID: "p1", Kind: "moonraker", Name: "Changed", BaseURL: "http://x"}); err == nil { + t.Fatal("expected save error") + } + if err := s.UpsertCamera(CameraRecord{Name: "cam2", Src: "rtsp://h/2"}); err == nil { + t.Fatal("expected save error") + } + if _, err := s.DeleteCamera("cam"); err == nil { + t.Fatal("expected save error") + } + if _, err := s.DeletePrinter("p1"); err == nil { + t.Fatal("expected save error") + } + if _, err := s.RenameCamera("cam", CameraRecord{Name: "renamed", Src: "rtsp://h/s"}); err == nil { + t.Fatal("expected save error") + } + if _, _, err := s.DeleteCameraClearRefs("cam"); err == nil { + t.Fatal("expected save error") + } + + ps, cs := s.Printers(), s.Cameras() + if len(ps) != 1 || ps[0].ID != "p1" || ps[0].Name != "P1" || ps[0].Camera != "cam" { + t.Fatalf("printers not rolled back: %+v", ps) + } + if len(cs) != 1 || cs[0].Name != "cam" { + t.Fatalf("cameras not rolled back: %+v", cs) + } +} + +func TestRenameCameraCascadesAndDeleteClearsRefs(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "devices.yaml")) + if err != nil { + t.Fatal(err) + } + _ = s.UpsertCamera(CameraRecord{Name: "mk4cam", Src: "rtsp://h/1"}) + _ = s.UpsertCamera(CameraRecord{Name: "other", Src: "rtsp://h/2"}) + _ = s.UpsertPrinter(PrinterRecord{ID: "mk4", Kind: "moonraker", Name: "MK4", BaseURL: "http://x", Camera: "mk4cam"}) + _ = s.UpsertPrinter(PrinterRecord{ID: "q", Kind: "moonraker", Name: "Q", BaseURL: "http://y", Camera: "other"}) + + var hooked []string + s.SetPrinterRefHook(func(ids []string) { hooked = append(hooked, ids...) }) + + if _, err := s.RenameCamera("mk4cam", CameraRecord{Name: "other", Src: "x"}); err == nil { + t.Fatal("rename onto an existing name must fail") + } + if _, err := s.RenameCamera("nope", CameraRecord{Name: "new", Src: "x"}); err == nil { + t.Fatal("rename of an unknown camera must fail") + } + ids, err := s.RenameCamera("mk4cam", CameraRecord{Name: "garage", Src: "rtsp://h/1"}) + if err != nil || len(ids) != 1 || ids[0] != "mk4" { + t.Fatalf("rename: ids=%v err=%v", ids, err) + } + if _, ok := s.Camera("mk4cam"); ok { + t.Fatal("old name still present") + } + if p, _ := s.Printer("mk4"); p.Camera != "garage" { + t.Fatalf("printer ref not cascaded: %q", p.Camera) + } + if p, _ := s.Printer("q"); p.Camera != "other" { + t.Fatalf("unrelated printer touched: %q", p.Camera) + } + if len(hooked) != 1 || hooked[0] != "mk4" { + t.Fatalf("hook = %v", hooked) + } + + found, ids, err := s.DeleteCameraClearRefs("garage") + if err != nil || !found || len(ids) != 1 { + t.Fatalf("delete: found=%v ids=%v err=%v", found, ids, err) + } + if p, _ := s.Printer("mk4"); p.Camera != "" { + t.Fatalf("printer ref not cleared: %q", p.Camera) + } + // Persisted. + s2, _ := Open(s.path) + if p, _ := s2.Printer("mk4"); p.Camera != "" { + t.Fatal("cleared ref not persisted") + } +} + +func TestPrinterRecordJSONRedactsSecrets(t *testing.T) { + r := PrinterRecord{ID: "b", Kind: "bambu", Name: "B", IP: "1.2.3.4", Serial: "S", AccessCode: "sekret-code", APIKey: "sekret-key"} + b, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + out := string(b) + if strings.Contains(out, "sekret") || strings.Contains(out, "accessCode\"") || strings.Contains(out, "apiKey\"") { + t.Fatalf("secret leaked: %s", out) + } + var m map[string]any + _ = json.Unmarshal(b, &m) + if m["hasApiKey"] != true || m["hasAccessCode"] != true || m["managed"] != true { + t.Fatalf("flags missing: %s", out) + } + if _, ok := m["macros"].([]any); !ok { + t.Fatalf("macros must be a JSON array: %s", out) + } + // Request bodies must still be accepted with their secrets. + var in PrinterRecord + if err := json.Unmarshal([]byte(`{"id":"x","kind":"octoprint","apiKey":"k","accessCode":"c"}`), &in); err != nil { + t.Fatal(err) + } + if in.APIKey != "k" || in.AccessCode != "c" { + t.Fatalf("decode dropped secrets: %+v", in) + } +} + +func TestInferSrcKindAndInput(t *testing.T) { + cases := []struct{ src, kind, input string }{ + {"rtsp://u:p@h/stream", "rtsp", "rtsp://h/stream"}, + {"ffmpeg:http://h/cam#video=h264#hardware", "mjpeg", "http://h/cam"}, + {"ffmpeg:http://h/cam#video=h264", "h264", "http://h/cam"}, + {"http://h/mjpeg", "mjpeg_raw", "http://h/mjpeg"}, + {"exec:ffmpeg -i x", "advanced", "exec:ffmpeg -i x"}, + {"ffmpeg:http://h/cam#video=h264#raw=-vf hflip", "advanced", "ffmpeg:http://h/cam#video=h264#raw=-vf hflip"}, + {"rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.0.5/s", "rtsp", "rtsp://10.0.0.5/s"}, + } + for _, c := range cases { + k := InferSrcKind(c.src) + if k != c.kind { + t.Errorf("InferSrcKind(%q) = %q, want %q", c.src, k, c.kind) + } + if got := SrcInputOf(k, c.src); got != c.input { + t.Errorf("SrcInputOf(%q,%q) = %q, want %q", k, c.src, got, c.input) + } + } + if got := WithUserinfo("rtsp://h/s", "a:b"); got != "rtsp://a:b@h/s" { + t.Errorf("WithUserinfo = %q", got) + } + if got := WithUserinfo("ffmpeg:rtsp://x:y@h/s#video=h264", ""); got != "ffmpeg:rtsp://h/s#video=h264" { + t.Errorf("WithUserinfo strip = %q", got) + } +} + +// ----- EditPrinter ----- + +func newTestManager(t *testing.T) (*Manager, *Store) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + t.Cleanup(func() { cancel(); wg.Wait() }) + store, err := Open(filepath.Join(t.TempDir(), "devices.yaml")) + if err != nil { + t.Fatal(err) + } + run := func(_ string, fn func(context.Context)) { + wg.Add(1) + go func() { defer wg.Done(); fn(ctx) }() + } + return NewManager(ctx, bus.New(), store, run), store +} + +func TestEditPrinterBlankKeepsAndClears(t *testing.T) { + m, store := newTestManager(t) + if err := m.AddPrinter(PrinterRecord{ + ID: "op", Kind: "octoprint", Name: "Old", BaseURL: "http://127.0.0.1:1", APIKey: "KEY", + Camera: "cam", UIURL: "http://ui", Model: "m", + }); err != nil { + t.Fatal(err) + } + + // Blank secret / base URL keep; blank uiUrl and camera clear; name changes. + if err := m.EditPrinter("op", PrinterRecord{Kind: "octoprint", Name: "New"}); err != nil { + t.Fatal(err) + } + got, _ := store.Printer("op") + if got.Name != "New" || got.APIKey != "KEY" || got.BaseURL != "http://127.0.0.1:1" || got.Model != "m" { + t.Fatalf("blank fields not kept: %+v", got) + } + if got.UIURL != "" || got.Camera != "" { + t.Fatalf("blank uiUrl/camera must clear: %+v", got) + } + + // A new secret replaces. + if err := m.EditPrinter("op", PrinterRecord{Kind: "octoprint", Name: "New", APIKey: "KEY2"}); err != nil { + t.Fatal(err) + } + if got, _ := store.Printer("op"); got.APIKey != "KEY2" { + t.Fatalf("new key not saved: %+v", got) + } + + // Kind is immutable. + if err := m.EditPrinter("op", PrinterRecord{Kind: "moonraker", Name: "New", BaseURL: "http://127.0.0.1:1"}); err == nil { + t.Fatal("kind change must be rejected") + } + if got, _ := store.Printer("op"); got.Kind != "octoprint" { + t.Fatalf("kind changed despite rejection: %+v", got) + } + // Unknown printer. + if err := m.EditPrinter("nope", PrinterRecord{Kind: "octoprint", Name: "x"}); err == nil { + t.Fatal("unknown printer must error") + } + // A still-live adapter after edits. + m.mu.Lock() + _, running := m.printers["op"] + m.mu.Unlock() + if !running { + t.Fatal("printer not running after edit") + } +} diff --git a/dashboard/internal/devices/manager.go b/dashboard/internal/devices/manager.go index 75bc485..8e6de37 100644 --- a/dashboard/internal/devices/manager.go +++ b/dashboard/internal/devices/manager.go @@ -32,16 +32,55 @@ type Manager struct { mu sync.Mutex printers map[string]adapter.Printer cancels map[string]context.CancelFunc + // done[id] is closed when printer id's adapter goroutine has returned, so an + // edit can wait for the old adapter before starting its replacement. + done map[string]chan struct{} } +// adapterStopWait bounds how long EditPrinter waits for a cancelled adapter to +// exit before starting its replacement (an adapter stuck in blocking I/O must +// not wedge the edit). +const adapterStopWait = 2 * time.Second + // NewManager builds a Manager. parentCtx is the process root context; every // printer goroutine derives a child from it, so the existing stop()/wg.Wait() // shutdown drains runtime-added printers too. func NewManager(parentCtx context.Context, b *bus.Bus, store *Store, run RunFn) *Manager { - return &Manager{ + m := &Manager{ bus: b, store: store, run: run, parentCtx: parentCtx, printers: map[string]adapter.Printer{}, cancels: map[string]context.CancelFunc{}, + done: map[string]chan struct{}{}, + } + // A camera rename/delete rewrites printers' camera reference in the store; + // reload the affected live adapters so they pick up the new name. + store.SetPrinterRefHook(m.reloadPrinters) + return m +} + +// PrinterRecord returns the stored (unredacted) record for id. Callers that +// serve it to clients must use PrinterRecord.View(). +func (m *Manager) PrinterRecord(id string) (PrinterRecord, bool) { + return m.store.Printer(id) +} + +// reloadPrinters restarts the live adapters for ids from their (updated) store +// records. Called by the store after a camera reference changes. +func (m *Manager) reloadPrinters(ids []string) { + m.mu.Lock() + defer m.mu.Unlock() + for _, id := range ids { + if _, running := m.printers[id]; !running { + continue + } + rec, ok := m.store.Printer(id) + if !ok { + continue + } + m.waitStopped(m.stopLocked(id)) + if err := m.startLocked(rec); err != nil { + slog.Warn("printer reload after camera change failed", "id", id, "err", err) + } } } @@ -76,47 +115,58 @@ func (m *Manager) AddPrinter(r PrinterRecord) error { return m.startLocked(r) } -// EditPrinter re-initialises an existing printer in place (id is immutable). -// Blank connection/secret fields keep their stored value, so an edit form need -// not re-send (or expose) the API key / Bambu credentials to change just a name. +// EditPrinter re-initialises an existing printer in place (id and kind are +// immutable). Blank connection/secret fields keep their stored value, so an edit +// form need not re-send (or expose) the API key / Bambu credentials to change +// just a name; a blank ui_url or camera clears it. The whole read-modify-write +// runs under the lock, and the old adapter goroutine is awaited (bounded) before +// its replacement starts so a stale adapter can't publish over the new state. func (m *Manager) EditPrinter(id string, r PrinterRecord) error { - r.ID = id - if existing, ok := m.store.Printer(id); ok { - if r.BaseURL == "" { - r.BaseURL = existing.BaseURL - } - if r.APIKey == "" { - r.APIKey = existing.APIKey - } - if r.IP == "" { - r.IP = existing.IP - } - if r.Serial == "" { - r.Serial = existing.Serial - } - if r.AccessCode == "" { - r.AccessCode = existing.AccessCode - } - if r.Model == "" { - r.Model = existing.Model - } - if r.Macros == nil { - r.Macros = existing.Macros - } - } - if err := ValidatePrinter(r); err != nil { - return err - } m.mu.Lock() defer m.mu.Unlock() if _, ok := m.printers[id]; !ok { return fmt.Errorf("unknown printer %q", id) } + existing, ok := m.store.Printer(id) + if !ok { + return fmt.Errorf("unknown printer %q", id) + } + r.ID = id + if r.Kind == "" { + r.Kind = existing.Kind + } + if r.Kind != existing.Kind { + return fmt.Errorf("a printer's kind can't be changed (%s -> %s); remove it and add a new one", existing.Kind, r.Kind) + } + if r.BaseURL == "" { + r.BaseURL = existing.BaseURL + } + if r.APIKey == "" { + r.APIKey = existing.APIKey + } + if r.IP == "" { + r.IP = existing.IP + } + if r.Serial == "" { + r.Serial = existing.Serial + } + if r.AccessCode == "" { + r.AccessCode = existing.AccessCode + } + if r.Model == "" { + r.Model = existing.Model + } + if r.Macros == nil { + r.Macros = existing.Macros + } + if err := ValidatePrinter(r); err != nil { + return err + } if err := m.store.UpsertPrinter(r); err != nil { return fmt.Errorf("save printer: %w", err) } - m.stopLocked(id) // old adapter goroutine exits on ctx cancel - return m.startLocked(r) // start fresh with the new config + m.waitStopped(m.stopLocked(id)) // old adapter goroutine exits on ctx cancel + return m.startLocked(r) // start fresh with the new config } // RemovePrinter stops the adapter, removes it from the store, and tells clients @@ -151,9 +201,14 @@ func (m *Manager) AddRawAdapter(a adapter.Printer) { return } cctx, cancel := context.WithCancel(m.parentCtx) + done := make(chan struct{}) m.printers[id] = a m.cancels[id] = cancel - m.run("printer:"+id, func(context.Context) { a.Run(cctx) }) + m.done[id] = done + m.run("printer:"+id, func(context.Context) { + defer close(done) + a.Run(cctx) + }) } // startLocked builds the adapter for r and launches it on a per-printer child @@ -180,21 +235,43 @@ func (m *Manager) startLocked(r PrinterRecord) error { // ctx and starts its supervised goroutine. Caller holds m.mu. func (m *Manager) launchLocked(r PrinterRecord, p adapter.Printer) error { cctx, cancel := context.WithCancel(m.parentCtx) + done := make(chan struct{}) m.printers[r.ID] = p m.cancels[r.ID] = cancel - m.run("printer:"+r.ID, func(context.Context) { p.Run(cctx) }) + m.done[r.ID] = done + m.run("printer:"+r.ID, func(context.Context) { + defer close(done) + p.Run(cctx) + }) slog.Info("printer added", "id", r.ID, "kind", r.Kind) return nil } // stopLocked cancels a printer's ctx (its goroutine exits) and drops it from the -// live maps. Caller holds m.mu. -func (m *Manager) stopLocked(id string) { +// live maps. It returns the channel closed when that goroutine has returned (nil +// if the printer wasn't running). Caller holds m.mu. +func (m *Manager) stopLocked(id string) <-chan struct{} { if cancel, ok := m.cancels[id]; ok { cancel() delete(m.cancels, id) } delete(m.printers, id) + done := m.done[id] + delete(m.done, id) + return done +} + +// waitStopped waits (bounded by adapterStopWait) for a stopped adapter's +// goroutine to return. A nil channel is a no-op. +func (m *Manager) waitStopped(done <-chan struct{}) { + if done == nil { + return + } + select { + case <-done: + case <-time.After(adapterStopWait): + slog.Warn("printer adapter did not stop within the wait; starting replacement anyway") + } } // ----- hub.CommandHandler (printer portion) ----- diff --git a/dashboard/internal/devices/store.go b/dashboard/internal/devices/store.go index 58a42b4..15ac52b 100644 --- a/dashboard/internal/devices/store.go +++ b/dashboard/internal/devices/store.go @@ -103,6 +103,10 @@ type DetectionSettings struct { type CameraRecord struct { Name string `yaml:"name" json:"name"` Src string `yaml:"src" json:"src"` + // SrcKind is the UI source kind Src was resolved from (rtsp|h264|mjpeg| + // mjpeg_raw|advanced). Records written before it existed leave it empty; + // InferSrcKind recovers it from the Src prefix. + SrcKind string `yaml:"src_kind,omitempty" json:"srcKind,omitempty"` // Username/Password, when set, are injected into Src's stream URL by Source() // so the credentials live in one place instead of being embedded in the URL. // Stored in plaintext like printer API keys; Password is never sent to clients. @@ -259,6 +263,8 @@ type Store struct { path string mu sync.Mutex data StoreData + // refHook, when set, is told which printers' camera reference changed. + refHook func(ids []string) } // Open loads the store from path. A missing file yields an empty store (the @@ -328,58 +334,189 @@ func (s *Store) Camera(name string) (CameraRecord, bool) { return CameraRecord{}, false } +// mutateLocked applies fn to the data and persists. On a save failure the +// in-memory data is restored to its pre-call state, so memory never diverges +// from disk (a failed write must not leave a ghost device). Caller holds s.mu. +func (s *Store) mutateLocked(fn func(d *StoreData)) error { + prev := StoreData{ + Version: s.data.Version, + Printers: append([]PrinterRecord(nil), s.data.Printers...), + Cameras: append([]CameraRecord(nil), s.data.Cameras...), + } + fn(&s.data) + if err := s.saveLocked(); err != nil { + s.data = prev + return err + } + return nil +} + // UpsertPrinter inserts or replaces a printer by ID and persists. func (s *Store) UpsertPrinter(r PrinterRecord) error { s.mu.Lock() defer s.mu.Unlock() - for i := range s.data.Printers { - if s.data.Printers[i].ID == r.ID { - s.data.Printers[i] = r - return s.saveLocked() + return s.mutateLocked(func(d *StoreData) { + for i := range d.Printers { + if d.Printers[i].ID == r.ID { + d.Printers[i] = r + return + } } - } - s.data.Printers = append(s.data.Printers, r) - return s.saveLocked() + d.Printers = append(d.Printers, r) + }) } // DeletePrinter removes a printer by ID and persists. Returns whether it existed. func (s *Store) DeletePrinter(id string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() - for i := range s.data.Printers { - if s.data.Printers[i].ID == id { - s.data.Printers = append(s.data.Printers[:i], s.data.Printers[i+1:]...) - return true, s.saveLocked() + found := false + err := s.mutateLocked(func(d *StoreData) { + for i := range d.Printers { + if d.Printers[i].ID == id { + d.Printers = append(d.Printers[:i:i], d.Printers[i+1:]...) + found = true + return + } } + }) + if err != nil { + return false, err } - return false, nil + return found, nil } // UpsertCamera inserts or replaces a camera by Name and persists. func (s *Store) UpsertCamera(r CameraRecord) error { s.mu.Lock() defer s.mu.Unlock() - for i := range s.data.Cameras { - if s.data.Cameras[i].Name == r.Name { - s.data.Cameras[i] = r - return s.saveLocked() + return s.mutateLocked(func(d *StoreData) { + for i := range d.Cameras { + if d.Cameras[i].Name == r.Name { + d.Cameras[i] = r + return + } } - } - s.data.Cameras = append(s.data.Cameras, r) - return s.saveLocked() + d.Cameras = append(d.Cameras, r) + }) } // DeleteCamera removes a camera by Name and persists. Returns whether it existed. func (s *Store) DeleteCamera(name string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() - for i := range s.data.Cameras { - if s.data.Cameras[i].Name == name { - s.data.Cameras = append(s.data.Cameras[:i], s.data.Cameras[i+1:]...) - return true, s.saveLocked() + found := false + err := s.mutateLocked(func(d *StoreData) { + for i := range d.Cameras { + if d.Cameras[i].Name == name { + d.Cameras = append(d.Cameras[:i:i], d.Cameras[i+1:]...) + found = true + return + } + } + }) + if err != nil { + return false, err + } + return found, nil +} + +// SetPrinterRefHook registers fn to be called (outside the store lock) with the +// ids of printers whose Camera reference was rewritten by RenameCamera or +// DeleteCameraClearRefs, so the live adapters can be reloaded. +func (s *Store) SetPrinterRefHook(fn func(ids []string)) { + s.mu.Lock() + defer s.mu.Unlock() + s.refHook = fn +} + +func (s *Store) fireRefHook(ids []string) { + if len(ids) == 0 { + return + } + s.mu.Lock() + fn := s.refHook + s.mu.Unlock() + if fn != nil { + fn(ids) + } +} + +// RenameCamera replaces the record stored under oldName with rec (whose Name is +// the new name) and rewrites every printer's Camera reference oldName -> rec.Name, +// all in ONE save: either the whole rename lands or none of it does. It returns +// the ids of the printers whose reference changed. +func (s *Store) RenameCamera(oldName string, rec CameraRecord) ([]string, error) { + s.mu.Lock() + var affected []string + err := func() error { + defer s.mu.Unlock() + found := false + for _, c := range s.data.Cameras { + if c.Name == oldName { + found = true + } else if c.Name == rec.Name { + return fmt.Errorf("a camera named %q already exists", rec.Name) + } + } + if !found { + return fmt.Errorf("unknown camera %q", oldName) + } + return s.mutateLocked(func(d *StoreData) { + for i := range d.Cameras { + if d.Cameras[i].Name == oldName { + d.Cameras[i] = rec + break + } + } + affected = retargetPrinters(d, oldName, rec.Name) + }) + }() + if err != nil { + return nil, err + } + s.fireRefHook(affected) + return affected, nil +} + +// DeleteCameraClearRefs removes a camera and blanks the Camera reference of every +// printer that pointed at it, in one save. Returns whether the camera existed and +// the ids of the printers that were updated. +func (s *Store) DeleteCameraClearRefs(name string) (bool, []string, error) { + s.mu.Lock() + found := false + var affected []string + err := s.mutateLocked(func(d *StoreData) { + for i := range d.Cameras { + if d.Cameras[i].Name == name { + d.Cameras = append(d.Cameras[:i:i], d.Cameras[i+1:]...) + found = true + break + } + } + if found { + affected = retargetPrinters(d, name, "") + } + }) + s.mu.Unlock() + if err != nil { + return false, nil, err + } + s.fireRefHook(affected) + return found, affected, nil +} + +// retargetPrinters rewrites printers' Camera == from to `to` (copy-on-write so a +// rollback snapshot is unaffected) and returns the changed printer ids. +func retargetPrinters(d *StoreData, from, to string) []string { + var ids []string + for i := range d.Printers { + if d.Printers[i].Camera == from { + d.Printers[i].Camera = to + ids = append(ids, d.Printers[i].ID) } } - return false, nil + return ids } // saveLocked atomically writes the store: a temp file in the same directory is diff --git a/dashboard/internal/devices/store_test.go b/dashboard/internal/devices/store_test.go index 184e267..ceea5ee 100644 --- a/dashboard/internal/devices/store_test.go +++ b/dashboard/internal/devices/store_test.go @@ -17,7 +17,7 @@ func TestStoreRoundTrip(t *testing.T) { t.Fatal("expected empty store") } - if err := s.UpsertPrinter(PrinterRecord{ID: "qidi", Kind: "moonraker", Name: "Qidi", BaseURL: "http://192.168.0.76"}); err != nil { + if err := s.UpsertPrinter(PrinterRecord{ID: "qidi", Kind: "moonraker", Name: "Qidi", BaseURL: "http://192.168.1.50"}); err != nil { t.Fatalf("upsert printer: %v", err) } if err := s.UpsertCamera(CameraRecord{Name: "garage", Src: "rtsp://x/y", Record: true}); err != nil { @@ -29,7 +29,7 @@ func TestStoreRoundTrip(t *testing.T) { if err != nil { t.Fatalf("reopen: %v", err) } - if got := s2.Printers(); len(got) != 1 || got[0].ID != "qidi" || got[0].BaseURL != "http://192.168.0.76" { + if got := s2.Printers(); len(got) != 1 || got[0].ID != "qidi" || got[0].BaseURL != "http://192.168.1.50" { t.Fatalf("printers not persisted: %+v", got) } if got := s2.Cameras(); len(got) != 1 || got[0].Name != "garage" || !got[0].Record { @@ -37,7 +37,7 @@ func TestStoreRoundTrip(t *testing.T) { } // Upsert replaces by key. - if err := s2.UpsertPrinter(PrinterRecord{ID: "qidi", Kind: "moonraker", Name: "Qidi Plus 4", BaseURL: "http://192.168.0.76"}); err != nil { + if err := s2.UpsertPrinter(PrinterRecord{ID: "qidi", Kind: "moonraker", Name: "Qidi Plus 4", BaseURL: "http://192.168.1.50"}); err != nil { t.Fatalf("replace printer: %v", err) } if got := s2.Printers(); len(got) != 1 || got[0].Name != "Qidi Plus 4" { @@ -70,9 +70,9 @@ func TestValidatePrinter(t *testing.T) { {ID: "Bad Id", Kind: "moonraker", Name: "x", BaseURL: "http://x"}, {ID: "p1", Kind: "moonraker", Name: "", BaseURL: "http://x"}, {ID: "p1", Kind: "moonraker", Name: "x", BaseURL: "ftp://x"}, - {ID: "p1", Kind: "octoprint", Name: "x", BaseURL: "http://x"}, // missing api key - {ID: "p1", Kind: "prusalink", Name: "x", BaseURL: "http://x"}, // missing password - {ID: "p1", Kind: "prusalink", Name: "x", APIKey: "pw"}, // missing base_url + {ID: "p1", Kind: "octoprint", Name: "x", BaseURL: "http://x"}, // missing api key + {ID: "p1", Kind: "prusalink", Name: "x", BaseURL: "http://x"}, // missing password + {ID: "p1", Kind: "prusalink", Name: "x", APIKey: "pw"}, // missing base_url {ID: "p1", Kind: "bambu", Name: "x"}, // missing ip/serial/code {ID: "p1", Kind: "weird", Name: "x", BaseURL: "http://x"}, } diff --git a/dashboard/internal/devices/view.go b/dashboard/internal/devices/view.go new file mode 100644 index 0000000..9c6eae4 --- /dev/null +++ b/dashboard/internal/devices/view.go @@ -0,0 +1,132 @@ +package devices + +import ( + "encoding/json" + "strings" +) + +// PrinterView is the client-facing shape of a stored printer: every field of +// PrinterRecord EXCEPT the secrets (API key / Bambu access code), which are +// replaced by presence flags. It is what GET /api/printers/{id} returns and what +// PrinterRecord marshals to, so a secret can never leak by serializing a record. +type PrinterView struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + BaseURL string `json:"baseUrl"` + IP string `json:"ip"` + Serial string `json:"serial"` + Model string `json:"model"` + Camera string `json:"camera"` + UIURL string `json:"uiUrl"` + Macros []string `json:"macros"` + HasAPIKey bool `json:"hasApiKey"` + HasAccessCode bool `json:"hasAccessCode"` + Managed bool `json:"managed"` +} + +// View returns the redacted client-facing form of the record. A record that +// exists in the store is by definition UI-managed. +func (r PrinterRecord) View() PrinterView { + macros := r.Macros + if macros == nil { + macros = []string{} + } + return PrinterView{ + ID: r.ID, Kind: r.Kind, Name: r.Name, BaseURL: r.BaseURL, IP: r.IP, + Serial: r.Serial, Model: r.Model, Camera: r.Camera, UIURL: r.UIURL, + Macros: macros, + HasAPIKey: r.APIKey != "", + HasAccessCode: r.AccessCode != "", + Managed: true, + } +} + +// MarshalJSON never emits the API key or access code (REVIEW 1.2). Decoding is +// untouched: request bodies still populate apiKey/accessCode via the struct tags. +// (YAML persistence is separate and keeps the secrets.) +func (r PrinterRecord) MarshalJSON() ([]byte, error) { + return json.Marshal(r.View()) +} + +// InferSrcKind recovers the UI source kind of a stored go2rtc source string for +// records written before CameraRecord.SrcKind existed. It is the inverse of the +// camera manager's resolveSrc for the shapes that function emits; anything it +// does not recognise is "advanced" (edited verbatim). +func InferSrcKind(src string) string { + switch { + case strings.HasPrefix(src, "ffmpeg:"): + rest := strings.TrimPrefix(src, "ffmpeg:") + i := strings.IndexByte(rest, '#') + if i < 0 { + return "advanced" + } + switch rest[i:] { + case "#video=h264#hardware": + return "mjpeg" + case "#video=h264": + return "h264" + } + return "advanced" + case strings.HasPrefix(src, "rtsp://"), strings.HasPrefix(src, "rtsps://"): + return "rtsp" + case strings.HasPrefix(src, "http://"), strings.HasPrefix(src, "https://"): + return "mjpeg_raw" + } + return "advanced" +} + +// SrcInputOf extracts the user-facing source URL from a stored go2rtc source for +// the given kind: the ffmpeg: wrapper and its #video=h264[#hardware] suffix are +// removed for h264/mjpeg, and any URL userinfo (credentials) is stripped. +func SrcInputOf(kind, src string) string { + if kind == "h264" || kind == "mjpeg" { + src = strings.TrimPrefix(src, "ffmpeg:") + if i := strings.IndexByte(src, '#'); i >= 0 { + src = src[:i] + } + } + return StripUserinfo(src) +} + +// SplitUserinfo splits the first scheme://userinfo@host in src (also behind a +// leading "ffmpeg:") without URL parsing, so placeholders such as +// "{FRIGATE_RTSP_USER}" in a hand-authored Frigate config survive. head is the +// text up to and including "://", userinfo is the "user[:pass]" part ("" if +// none), tail is everything from the host on. +func SplitUserinfo(src string) (head, userinfo, tail string) { + i := strings.Index(src, "://") + if i < 0 { + return src, "", "" + } + head, rest := src[:i+3], src[i+3:] + end := len(rest) + if j := strings.IndexAny(rest, "/?#"); j >= 0 { + end = j + } + if at := strings.LastIndexByte(rest[:end], '@'); at >= 0 { + return head, rest[:at], rest[at+1:] + } + return head, "", rest +} + +// StripUserinfo removes any credentials embedded in the source URL. +func StripUserinfo(src string) string { + head, ui, tail := SplitUserinfo(src) + if ui == "" { + return src + } + return head + tail +} + +// WithUserinfo returns src with its userinfo replaced by ui (removed if empty). +func WithUserinfo(src, ui string) string { + head, _, tail := SplitUserinfo(src) + if tail == "" && !strings.Contains(src, "://") { + return src + } + if ui == "" { + return head + tail + } + return head + ui + "@" + tail +} diff --git a/dashboard/internal/hub/client.go b/dashboard/internal/hub/client.go index a5ed7fd..645c2c8 100644 --- a/dashboard/internal/hub/client.go +++ b/dashboard/internal/hub/client.go @@ -14,20 +14,25 @@ type client struct { hub *Hub conn *websocket.Conn send chan []byte - done chan struct{} // closed by removeClient; guards sends against a closed `send` + done chan struct{} // closed by removeClient; `send` itself is never closed (a send on a closed channel panics) } func (c *client) writeLoop(ctx context.Context) { - for data := range c.send { - wctx, cancel := context.WithTimeout(ctx, 10*time.Second) - err := c.conn.Write(wctx, websocket.MessageText, data) - cancel() - if err != nil { - c.conn.Close(websocket.StatusInternalError, "write error") + for { + select { + case <-c.done: + c.conn.Close(websocket.StatusNormalClosure, "") return + case data := <-c.send: + wctx, cancel := context.WithTimeout(ctx, 10*time.Second) + err := c.conn.Write(wctx, websocket.MessageText, data) + cancel() + if err != nil { + c.conn.Close(websocket.StatusInternalError, "write error") + return + } } } - c.conn.Close(websocket.StatusNormalClosure, "") } func (c *client) readLoop(ctx context.Context) { @@ -42,17 +47,31 @@ func (c *client) readLoop(ctx context.Context) { if json.Unmarshal(data, &peek) != nil { continue } - if peek.Type == "snapshot.request" { - // Guard against removeClient having closed c.send concurrently (a send - // on a closed channel panics; the default case does not prevent that). - select { - case c.send <- c.hub.snapshotBytes(): - case <-c.done: - return - default: - } + switch peek.Type { + case "snapshot.request": + c.enqueue(c.hub.snapshotBytes()) + continue + case "ping": + // Client keepalive (~25s): answer so it can detect a dead socket. + c.enqueue(pongBytes()) continue } c.hub.handleCommand(ctx, data) } } + +// enqueue queues data for the writer without blocking; it is dropped when the +// buffer is full or the client was removed. Safe against removeClient because +// c.send is never closed. +func (c *client) enqueue(data []byte) { + select { + case c.send <- data: + case <-c.done: + default: + } +} + +func pongBytes() []byte { + data, _ := json.Marshal(ServerMsg{Type: "pong", Ts: time.Now().UnixMilli()}) + return data +} diff --git a/dashboard/internal/hub/hub.go b/dashboard/internal/hub/hub.go index b20e621..5652d53 100644 --- a/dashboard/internal/hub/hub.go +++ b/dashboard/internal/hub/hub.go @@ -2,6 +2,7 @@ package hub import ( "context" + "crypto/subtle" "encoding/json" "log/slog" "maps" @@ -52,6 +53,9 @@ type Hub struct { bus *bus.Bus cmd CommandHandler token string + // originPatterns are extra Origin host patterns (path.Match style) accepted on + // the WS handshake besides the request's own Host. See SetAllowedOrigins. + originPatterns []string mu sync.RWMutex clients map[*client]struct{} @@ -212,16 +216,19 @@ type CameraStatus struct { // ServeWS upgrades the request and serves one client until it disconnects. func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) { - if h.token != "" && r.URL.Query().Get("token") != h.token { + if h.token != "" && subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("token")), []byte(h.token)) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - // Reached over the tailnet from the dashboard origin; same-origin check - // is unnecessary and would block the SPA served from a different port in dev. - InsecureSkipVerify: true, - }) + // Origin is verified: a browser Origin must match the request Host (same-origin; + // the reverse proxy preserves Host) or an operator-allowed pattern. Without this + // any web page could open /ws from the user's browser (cross-site WS hijacking). + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: h.originPatterns}) if err != nil { + // Accept already replied (403 on a bad Origin); log why so a proxy that + // rewrites Host is diagnosable (fix: DASHBOARD_WS_ORIGINS). + slog.Warn("hub: websocket handshake rejected", "err", err, + "origin", r.Header.Get("Origin"), "host", r.Host) return } c := &client{hub: h, conn: conn, send: make(chan []byte, 64), done: make(chan struct{})} @@ -229,12 +236,20 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) { h.clients[c] = struct{}{} h.mu.Unlock() + defer h.removeClient(c) + c.send <- h.snapshotBytes() // initial full state ctx := r.Context() go c.writeLoop(ctx) c.readLoop(ctx) // blocks until the conn closes - h.removeClient(c) +} + +// SetAllowedOrigins sets extra Origin host patterns (e.g. "localhost:5173", +// "*.example.com") accepted on the WS handshake in addition to same-host. Call +// before serving. For a dev server or a proxy that rewrites Host. +func (h *Hub) SetAllowedOrigins(patterns []string) { + h.originPatterns = patterns } // Snapshot returns a deep-enough copy of the current cached state (for the WS @@ -293,8 +308,7 @@ func (h *Hub) removeClient(c *client) { h.mu.Lock() if _, ok := h.clients[c]; ok { delete(h.clients, c) - close(c.done) // signal readLoop to stop sending before we close send - close(c.send) + close(c.done) // stops writeLoop (which closes the conn); c.send is never closed } h.mu.Unlock() } diff --git a/dashboard/internal/hub/hub_test.go b/dashboard/internal/hub/hub_test.go new file mode 100644 index 0000000..817beba --- /dev/null +++ b/dashboard/internal/hub/hub_test.go @@ -0,0 +1,114 @@ +package hub + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MakerViking/TinkerView/internal/bus" + "github.com/coder/websocket" +) + +func newTestServer(t *testing.T, token string, origins []string) (*Hub, string) { + t.Helper() + h := New(bus.New(), nil, token) + h.SetAllowedOrigins(origins) + srv := httptest.NewServer(http.HandlerFunc(h.ServeWS)) + t.Cleanup(srv.Close) + return h, "ws" + strings.TrimPrefix(srv.URL, "http") +} + +func readType(t *testing.T, ctx context.Context, c *websocket.Conn) string { + t.Helper() + _, data, err := c.Read(ctx) + if err != nil { + t.Fatalf("read: %v", err) + } + s := string(data) + switch { + case strings.Contains(s, `"type":"snapshot"`): + return "snapshot" + case strings.Contains(s, `"type":"pong"`): + return "pong" + } + return s +} + +func TestPingGetsPong(t *testing.T) { + _, url := newTestServer(t, "", nil) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + c, _, err := websocket.Dial(ctx, url, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + if got := readType(t, ctx, c); got != "snapshot" { + t.Fatalf("first message = %s, want snapshot", got) + } + if err := c.Write(ctx, websocket.MessageText, []byte(`{"type":"ping","ts":1}`)); err != nil { + t.Fatal(err) + } + if got := readType(t, ctx, c); got != "pong" { + t.Fatalf("reply = %s, want pong", got) + } +} + +func TestOriginCheck(t *testing.T) { + _, url := newTestServer(t, "", []string{"dev.example:5173"}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + dial := func(origin string) error { + c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: http.Header{"Origin": {origin}}}) + if err == nil { + c.CloseNow() + } + return err + } + if err := dial("http://evil.example"); err == nil { + t.Fatal("cross-site origin must be rejected") + } + if err := dial("http://dev.example:5173"); err != nil { + t.Fatalf("allow-listed origin rejected: %v", err) + } + // Same-host origin (what the SPA sends) is accepted. + host := strings.TrimPrefix(url, "ws://") + if err := dial("http://" + host); err != nil { + t.Fatalf("same-host origin rejected: %v", err) + } +} + +func TestTokenRequired(t *testing.T) { + _, url := newTestServer(t, "s3cret", nil) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, _, err := websocket.Dial(ctx, url+"?token=wrong", nil); err == nil { + t.Fatal("wrong token must be rejected") + } + c, _, err := websocket.Dial(ctx, url+"?token=s3cret", nil) + if err != nil { + t.Fatalf("right token rejected: %v", err) + } + c.CloseNow() +} + +// A slow client is dropped by broadcast while its readLoop keeps handling +// requests; neither side may panic on a send to a closed channel. +func TestRemoveClientConcurrentWithEnqueue(t *testing.T) { + h := New(bus.New(), nil, "") + c := &client{hub: h, send: make(chan []byte, 1), done: make(chan struct{})} + h.clients[c] = struct{}{} + go func() { + for i := 0; i < 1000; i++ { + c.enqueue([]byte("x")) + } + }() + h.removeClient(c) + h.removeClient(c) // idempotent + for i := 0; i < 1000; i++ { + c.enqueue([]byte("x")) + } +} diff --git a/dashboard/internal/rules/rules.go b/dashboard/internal/rules/rules.go index 99443a0..ee2976c 100644 --- a/dashboard/internal/rules/rules.go +++ b/dashboard/internal/rules/rules.go @@ -15,6 +15,7 @@ import ( "errors" "fmt" "io/fs" + "log/slog" "os" "path/filepath" "strings" @@ -94,8 +95,10 @@ func Open(path string) (*Store, error) { b, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { s.rules = DefaultRules() + // An unwritable data dir must not be fatal (the hub shows a banner and keeps + // running): serve the in-memory defaults; Set reports the error when edited. if err := s.persist(s.rules); err != nil { - return nil, fmt.Errorf("seed %s: %w", path, err) + slog.Warn("notification rules: could not seed default file; using in-memory defaults", "path", path, "err", err) } return s, nil } diff --git a/dashboard/internal/rules/rules_test.go b/dashboard/internal/rules/rules_test.go index 974a8f1..fab4807 100644 --- a/dashboard/internal/rules/rules_test.go +++ b/dashboard/internal/rules/rules_test.go @@ -69,13 +69,13 @@ func TestInWindow(t *testing.T) { t time.Time want bool }{ - {"", "", at(3, 0), true}, // all-day - {"21:00", "06:00", at(23, 30), true}, // overnight, late - {"21:00", "06:00", at(5, 0), true}, // overnight, early - {"21:00", "06:00", at(12, 0), false}, // overnight, midday excluded - {"09:00", "17:00", at(12, 0), true}, // daytime inside - {"09:00", "17:00", at(8, 0), false}, // before - {"09:00", "17:00", at(17, 0), false}, // end exclusive + {"", "", at(3, 0), true}, // all-day + {"21:00", "06:00", at(23, 30), true}, // overnight, late + {"21:00", "06:00", at(5, 0), true}, // overnight, early + {"21:00", "06:00", at(12, 0), false}, // overnight, midday excluded + {"09:00", "17:00", at(12, 0), true}, // daytime inside + {"09:00", "17:00", at(8, 0), false}, // before + {"09:00", "17:00", at(17, 0), false}, // end exclusive } for _, c := range cases { if got := inWindow(c.from, c.to, c.t); got != c.want { @@ -139,3 +139,18 @@ func TestOpenPersistRoundTrip(t *testing.T) { t.Fatalf("round trip mismatch: %+v", got) } } + +func TestOpenUnwritableDirKeepsDefaults(t *testing.T) { + // A directory that does not exist makes the seed write fail, like a read-only + // or wrong-uid data dir. Open must still succeed with the default rules. + s, err := Open(filepath.Join(t.TempDir(), "missing", "notify-rules.json")) + if err != nil { + t.Fatalf("Open on unwritable dir should not fail: %v", err) + } + if len(s.Get()) == 0 { + t.Fatal("expected in-memory default rules") + } + if err := s.Set(DefaultRules()); err == nil { + t.Fatal("Set should report the persist error") + } +} diff --git a/dashboard/internal/web/api.go b/dashboard/internal/web/api.go index 00ed3f9..3038c5c 100644 --- a/dashboard/internal/web/api.go +++ b/dashboard/internal/web/api.go @@ -34,6 +34,7 @@ type API struct { notifier notify.Notifier frig FrigateConfigManager // may be nil rules *rules.Store // may be nil + devs *deviceBackend // set by SetDevices; nil -> the edit/inspect endpoints degrade } func NewAPI(version string, h *hub.Hub, cmd hub.CommandHandler, notifier notify.Notifier, frig FrigateConfigManager, ruleStore *rules.Store) *API { @@ -52,9 +53,11 @@ func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("POST /api/containers/{id}/restart", a.restart) mux.HandleFunc("POST /api/notify/test", a.notifyTest) // Device CRUD mirror of the WS commands (scripting/debugging). + mux.HandleFunc("GET /api/printers/{id}", a.printerGet) mux.HandleFunc("POST /api/printers", a.printerAdd) mux.HandleFunc("PUT /api/printers/{id}", a.printerEdit) mux.HandleFunc("DELETE /api/printers/{id}", a.printerDelete) + mux.HandleFunc("GET /api/cameras/{name}", a.cameraGet) mux.HandleFunc("POST /api/cameras", a.cameraAdd) mux.HandleFunc("PUT /api/cameras/{name}", a.cameraEdit) mux.HandleFunc("DELETE /api/cameras/{name}", a.cameraDelete) @@ -128,86 +131,6 @@ func (a *API) restart(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func (a *API) printerAdd(w http.ResponseWriter, r *http.Request) { - var rec devices.PrinterRecord - if err := json.NewDecoder(r.Body).Decode(&rec); err != nil { - http.Error(w, "invalid body: "+err.Error(), http.StatusBadRequest) - return - } - if err := a.cmd.AddPrinter(rec); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (a *API) printerEdit(w http.ResponseWriter, r *http.Request) { - var rec devices.PrinterRecord - if err := json.NewDecoder(r.Body).Decode(&rec); err != nil { - http.Error(w, "invalid body: "+err.Error(), http.StatusBadRequest) - return - } - if err := a.cmd.EditPrinter(r.PathValue("id"), rec); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (a *API) printerDelete(w http.ResponseWriter, r *http.Request) { - if err := a.cmd.RemovePrinter(r.PathValue("id")); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (a *API) cameraAdd(w http.ResponseWriter, r *http.Request) { - var body struct { - Name string `json:"name"` - SrcKind string `json:"srcKind"` - SrcInput string `json:"srcInput"` - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid body: "+err.Error(), http.StatusBadRequest) - return - } - if err := a.cmd.AddCamera(body.Name, body.SrcKind, body.SrcInput, body.Username, body.Password); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (a *API) cameraEdit(w http.ResponseWriter, r *http.Request) { - var body struct { - NewName string `json:"newName"` - NewSrcKind string `json:"newSrcKind"` - NewSrcInput string `json:"newSrcInput"` - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid body: "+err.Error(), http.StatusBadRequest) - return - } - if err := a.cmd.EditCamera(r.PathValue("name"), body.NewName, body.NewSrcKind, body.NewSrcInput, body.Username, body.Password); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (a *API) cameraDelete(w http.ResponseWriter, r *http.Request) { - if err := a.cmd.RemoveCamera(r.PathValue("name")); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusNoContent) -} - func (a *API) cameraPromote(w http.ResponseWriter, r *http.Request) { var body struct { Detect bool `json:"detect"` diff --git a/dashboard/internal/web/api_devices.go b/dashboard/internal/web/api_devices.go new file mode 100644 index 0000000..d30d597 --- /dev/null +++ b/dashboard/internal/web/api_devices.go @@ -0,0 +1,196 @@ +package web + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/MakerViking/TinkerView/internal/camera" + "github.com/MakerViking/TinkerView/internal/devices" +) + +// deviceBackend gives the REST layer the richer device operations that the +// hub.CommandHandler interface (shared with the WebSocket) doesn't carry: reading +// a stored printer/camera for the edit forms, and the credential-clearing camera +// edit. +type deviceBackend struct { + printers *devices.Manager + cameras *camera.Manager +} + +// SetDevices enables GET /api/printers/{id}, GET /api/cameras/{name} and the +// full-featured camera edit. Without it those endpoints return 503 and +// PUT /api/cameras/{name} falls back to the WS-equivalent EditCamera. +func (a *API) SetDevices(p *devices.Manager, c *camera.Manager) { + a.devs = &deviceBackend{printers: p, cameras: c} +} + +// writeErr emits the standard error body {"error": ""}. +func writeErr(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +// writeJSONStatus is writeJSON with an explicit status code. +func writeJSONStatus(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +// printerGet returns a stored printer for the edit form. Secrets (API key, Bambu +// access code) are never returned, only hasApiKey / hasAccessCode. +func (a *API) printerGet(w http.ResponseWriter, r *http.Request) { + if a.devs == nil { + writeErr(w, http.StatusServiceUnavailable, "device editing is not available") + return + } + rec, ok := a.devs.printers.PrinterRecord(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusNotFound, "unknown or read-only printer") + return + } + writeJSON(w, rec.View()) +} + +func (a *API) printerAdd(w http.ResponseWriter, r *http.Request) { + var rec devices.PrinterRecord // decodes apiKey/accessCode; never marshalled back with them + if err := json.NewDecoder(r.Body).Decode(&rec); err != nil { + writeErr(w, http.StatusBadRequest, "invalid body: "+err.Error()) + return + } + if err := a.cmd.AddPrinter(rec); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, rec.View()) +} + +func (a *API) printerEdit(w http.ResponseWriter, r *http.Request) { + var rec devices.PrinterRecord + if err := json.NewDecoder(r.Body).Decode(&rec); err != nil { + writeErr(w, http.StatusBadRequest, "invalid body: "+err.Error()) + return + } + id := r.PathValue("id") + if err := a.cmd.EditPrinter(id, rec); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if a.devs != nil { + if cur, ok := a.devs.printers.PrinterRecord(id); ok { + writeJSON(w, cur.View()) + return + } + } + writeJSON(w, map[string]bool{"ok": true}) +} + +func (a *API) printerDelete(w http.ResponseWriter, r *http.Request) { + if err := a.cmd.RemovePrinter(r.PathValue("id")); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// cameraGet describes a camera for the edit form: source kind/URL (credentials +// stripped), username, whether a password is set, and what may be edited. +func (a *API) cameraGet(w http.ResponseWriter, r *http.Request) { + if a.devs == nil { + writeErr(w, http.StatusServiceUnavailable, "device editing is not available") + return + } + info, ok := a.devs.cameras.Info(r.Context(), r.PathValue("name")) + if !ok { + writeErr(w, http.StatusNotFound, "unknown camera, or it has no editable go2rtc stream") + return + } + writeJSON(w, info) +} + +func (a *API) cameraAdd(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + SrcKind string `json:"srcKind"` + SrcInput string `json:"srcInput"` + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "invalid body: "+err.Error()) + return + } + if err := a.cmd.AddCamera(body.Name, body.SrcKind, body.SrcInput, body.Username, body.Password); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if a.devs != nil { + if info, ok := a.devs.cameras.Info(r.Context(), body.Name); ok { + writeJSON(w, info) + return + } + } + writeJSON(w, map[string]bool{"ok": true}) +} + +// cameraEdit applies a camera edit. Omitted/empty username or password keep the +// stored value; clearCredentials removes both. A promoted camera's source change +// runs in the background (202); everything else is applied before returning (200). +func (a *API) cameraEdit(w http.ResponseWriter, r *http.Request) { + var body struct { + NewName string `json:"newName"` + NewSrcKind string `json:"newSrcKind"` + NewSrcInput string `json:"newSrcInput"` + Username string `json:"username"` + Password string `json:"password"` + ClearCredentials bool `json:"clearCredentials"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "invalid body: "+err.Error()) + return + } + name := r.PathValue("name") + if a.devs == nil { + if body.ClearCredentials { + writeErr(w, http.StatusServiceUnavailable, "clearing credentials is not available") + return + } + if err := a.cmd.EditCamera(name, body.NewName, body.NewSrcKind, body.NewSrcInput, body.Username, body.Password); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, map[string]bool{"ok": true}) + return + } + async, err := a.devs.cameras.Edit(name, camera.EditRequest{ + NewName: body.NewName, NewSrcKind: body.NewSrcKind, NewSrcInput: body.NewSrcInput, + Username: body.Username, Password: body.Password, ClearCredentials: body.ClearCredentials, + }) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + cur := name + if nn := strings.TrimSpace(body.NewName); !async && nn != "" { + cur = nn + } + code := http.StatusOK + if async { + code = http.StatusAccepted + } + if info, ok := a.devs.cameras.Info(r.Context(), cur); ok { + writeJSONStatus(w, code, info) + return + } + writeJSONStatus(w, code, map[string]bool{"ok": true}) +} + +func (a *API) cameraDelete(w http.ResponseWriter, r *http.Request) { + if err := a.cmd.RemoveCamera(r.PathValue("name")); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/dashboard/internal/web/api_devices_test.go b/dashboard/internal/web/api_devices_test.go new file mode 100644 index 0000000..89dcc1b --- /dev/null +++ b/dashboard/internal/web/api_devices_test.go @@ -0,0 +1,117 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/MakerViking/TinkerView/internal/adapters/go2rtc" + "github.com/MakerViking/TinkerView/internal/bus" + "github.com/MakerViking/TinkerView/internal/camera" + "github.com/MakerViking/TinkerView/internal/devices" +) + +func newDevAPI(t *testing.T) (*API, *devices.Store) { + t.Helper() + g2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _, _ = w.Write([]byte("{}")) + } + })) + t.Cleanup(g2.Close) + store, err := devices.Open(filepath.Join(t.TempDir(), "devices.yaml")) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + b := bus.New() + pm := devices.NewManager(ctx, b, store, func(_ string, fn func(context.Context)) { go fn(ctx) }) + cm := camera.NewManager(ctx, store, go2rtc.NewClient(g2.URL), b, nil, nil, false) + a := &API{cmd: nil} + a.SetDevices(pm, cm) + return a, store +} + +func serve(a *API, method, path, body string) *httptest.ResponseRecorder { + mux := http.NewServeMux() + a.Register(mux) + req := httptest.NewRequest(method, path, strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func TestPrinterGetRedactsSecrets(t *testing.T) { + a, store := newDevAPI(t) + _ = store.UpsertPrinter(devices.PrinterRecord{ + ID: "b1", Kind: "bambu", Name: "Bambu", IP: "10.0.0.9", Serial: "SER", AccessCode: "ACCESS-SECRET", + APIKey: "API-SECRET", Model: "x1c", Camera: "cam", UIURL: "http://ui", + }) + rec := serve(a, "GET", "/api/printers/b1", "") + if rec.Code != 200 { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + body := rec.Body.String() + if strings.Contains(body, "SECRET") || strings.Contains(body, `"apiKey"`) || strings.Contains(body, `"accessCode"`) { + t.Fatalf("secret leaked: %s", body) + } + var m map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatal(err) + } + for _, k := range []string{"id", "kind", "name", "baseUrl", "ip", "serial", "model", "camera", "uiUrl", "macros", "hasApiKey", "hasAccessCode", "managed"} { + if _, ok := m[k]; !ok { + t.Errorf("missing field %q in %s", k, body) + } + } + if m["hasApiKey"] != true || m["hasAccessCode"] != true || m["managed"] != true || m["serial"] != "SER" { + t.Fatalf("unexpected values: %s", body) + } + if rec := serve(a, "GET", "/api/printers/nope", ""); rec.Code != 404 || !strings.Contains(rec.Body.String(), `"error"`) { + t.Fatalf("unknown printer: %d %s", rec.Code, rec.Body) + } +} + +func TestCameraGetAndEditErrorShape(t *testing.T) { + a, store := newDevAPI(t) + _ = store.UpsertCamera(devices.CameraRecord{Name: "door", Src: "rtsp://h/s", Username: "u", Password: "TOPSECRET"}) + + rec := serve(a, "GET", "/api/cameras/door", "") + if rec.Code != 200 || strings.Contains(rec.Body.String(), "TOPSECRET") { + t.Fatalf("get: %d %s", rec.Code, rec.Body) + } + var m map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &m) + for _, k := range []string{"name", "srcKind", "srcInput", "username", "hasPassword", "managed", "promoted", "configStream", "editable", "rotate", "flipH", "flipV"} { + if _, ok := m[k]; !ok { + t.Errorf("missing field %q in %s", k, rec.Body) + } + } + if m["srcKind"] != "rtsp" || m["hasPassword"] != true || m["editable"] != true { + t.Fatalf("unexpected: %s", rec.Body) + } + if rec := serve(a, "GET", "/api/cameras/ghost", ""); rec.Code != 404 { + t.Fatalf("unknown camera: %d", rec.Code) + } + + // A rejected edit is a 4xx with a JSON error body. + rec = serve(a, "PUT", "/api/cameras/door", `{"newSrcKind":"advanced","newSrcInput":"exec:id"}`) + var e map[string]string + if rec.Code != 400 || json.Unmarshal(rec.Body.Bytes(), &e) != nil || e["error"] == "" { + t.Fatalf("edit error shape: %d %s", rec.Code, rec.Body) + } + // Clearing credentials works and is reflected. + rec = serve(a, "PUT", "/api/cameras/door", `{"clearCredentials":true}`) + if rec.Code != 200 { + t.Fatalf("clear: %d %s", rec.Code, rec.Body) + } + _ = json.Unmarshal(rec.Body.Bytes(), &m) + if m["username"] != "" || m["hasPassword"] != false { + t.Fatalf("credentials not cleared: %s", rec.Body) + } +} diff --git a/dashboard/internal/web/embed.go b/dashboard/internal/web/embed.go index a0130b1..1395973 100644 --- a/dashboard/internal/web/embed.go +++ b/dashboard/internal/web/embed.go @@ -1,10 +1,17 @@ package web import ( + "bytes" + "compress/gzip" + "crypto/sha256" "embed" + "encoding/hex" "io/fs" + "mime" "net/http" + "path" "strings" + "time" ) // dist holds the built React SPA. A placeholder index.html is committed so the @@ -21,39 +28,105 @@ Run pnpm build in dashboard/web (or build the Docker i

Backend API is live: /api/version · /healthz

` -// SPAHandler serves the embedded SPA: a real asset when present, else index.html -// (client-side routing fallback). +// spaFile is one embedded file with its precomputed validators and (for text-like +// types) gzip form. The embedded FS is immutable, so this is computed once. +type spaFile struct { + ctype string + etag string // strong, quoted + raw []byte + gz []byte // nil when not compressible / not smaller + cache string // Cache-Control +} + +func newSPAFile(name string, raw []byte, cache string) *spaFile { + ct := mime.TypeByExtension(path.Ext(name)) + if ct == "" { + ct = http.DetectContentType(raw) + } + sum := sha256.Sum256(raw) + f := &spaFile{ctype: ct, raw: raw, cache: cache, etag: `"` + hex.EncodeToString(sum[:8]) + `"`} + if len(raw) >= 256 && compressibleType(ct) { + var buf bytes.Buffer + zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression) + _, _ = zw.Write(raw) + if zw.Close() == nil && buf.Len() < len(raw) { + f.gz = buf.Bytes() + } + } + return f +} + +// SPAHandler serves the embedded SPA: a real file when present, else index.html +// (client-side routing fallback — also for directories like /assets/, which must not +// produce a file listing). Hashed /assets/* are immutable-cached; everything else +// (index.html, video-rtc.js, icons) is served with ETag + no-cache so a deploy takes +// effect immediately but unchanged files cost only a 304. Text-like files are served +// gzipped (precomputed) when the client accepts it. func SPAHandler() (http.Handler, error) { sub, err := fs.Sub(dist, "dist") if err != nil { return nil, err } - fileServer := http.FileServer(http.FS(sub)) - index, err := fs.ReadFile(sub, "index.html") + files := map[string]*spaFile{} + err = fs.WalkDir(sub, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + b, err := fs.ReadFile(sub, p) + if err != nil { + return err + } + cache := "no-cache" + if strings.HasPrefix(p, "assets/") { + cache = "public, max-age=31536000, immutable" + } + files[p] = newSPAFile(p, b, cache) + return nil + }) if err != nil { - index = []byte(placeholderHTML) + return nil, err } + // index.html must never be long-cached: it references hash-named bundles, and a + // stale copy would keep loading the OLD bundle after a deploy. + if b, ok := files["index.html"]; ok { + files["index.html"] = newSPAFile("index.html", b.raw, "no-cache") + } else { + files["index.html"] = newSPAFile("index.html", []byte(placeholderHTML), "no-cache") + } + index := files["index.html"] return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - p := strings.TrimPrefix(r.URL.Path, "/") - if p == "" { - serveHTML(w, index) + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if f, err := sub.Open(p); err == nil { - _ = f.Close() - fileServer.ServeHTTP(w, r) - return + f, ok := files[strings.TrimPrefix(r.URL.Path, "/")] + if !ok { + f = index // SPA fallback (unknown path or directory) } - serveHTML(w, index) // SPA fallback + serveSPAFile(w, r, f) }), nil } -func serveHTML(w http.ResponseWriter, b []byte) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - // index.html references hash-named bundles (index-.js). It must never be - // cached, or after a deploy the browser keeps loading a stale entry page that - // points at the OLD bundle until a manual hard-refresh. The hashed assets under - // /assets/ are immutable and may still be cached by the file server. - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - _, _ = w.Write(b) +func serveSPAFile(w http.ResponseWriter, r *http.Request, f *spaFile) { + h := w.Header() + h.Set("Content-Type", f.ctype) + h.Set("Cache-Control", f.cache) + body := f.raw + etag := f.etag + if f.gz != nil { + h.Add("Vary", "Accept-Encoding") + // Range over a gzip representation is pointless here; serve identity then. + if acceptsGzip(r) && r.Header.Get("Range") == "" { + body = f.gz + etag = strings.TrimSuffix(f.etag, `"`) + `-gz"` + h.Set("Content-Encoding", "gzip") + } + } + h.Set("ETag", etag) + // Zero modtime: validators come from the ETag only (embed.FS has no mtimes). + http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(body)) } diff --git a/dashboard/internal/web/guards.go b/dashboard/internal/web/guards.go new file mode 100644 index 0000000..99c3162 --- /dev/null +++ b/dashboard/internal/web/guards.go @@ -0,0 +1,197 @@ +package web + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "log/slog" + "net/http" + "net/url" + "strings" +) + +const ( + // authCookie carries a hash of the shared token so /