Compare commits

..
12 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub 9a0672a64d Harden container startup (#24718)
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* keep /config out of the root environment in the frigate run script

The script exported HOME=/config and prepended /config/.local/lib to LD_LIBRARY_PATH while still root, then ran python3, ffmpeg, grep, and opt_in_out as root. The runtime user owns /config, so it could plant a .pth file in the user site-packages or a library on the loader path and get code run as root on the next start. Root helpers now run with root's environment (and python3 -I), and HOME, LD_LIBRARY_PATH, and PATH reach only the dropped process through env on the s6-setuidgid line. opt_in_out runs as the runtime user, so the openvino marker no longer gets written into /config by root.

* don't follow a planted symlink when writing /dev/shm/go2rtc.yaml

After the first start the go2rtc user owned /dev/shm/go2rtc.yaml and could swap it for a dangling symlink. The run script's -f test was false for a dangling link, so the link survived, and root's create_config.py then created the link target and the chown handed it to go2rtc. The run script now removes any existing entry, create_config.py creates the file with O_EXCL and O_NOFOLLOW at mode 0640, and the chown uses -h.

* run root python helpers in isolated mode and keep HOME=/config for non-root containers

The previous commit only set HOME=/config when dropping from root, so a container started with --user lost it. The other root-run helpers in the go2rtc, nginx, and certsync scripts now use python3 -I too, so they never load user site-packages or PYTHON* variables.

* validate networking.listen values before rendering them into nginx config

ListenConfig and get_nginx_settings.py only checked that the text after the last colon parsed as an int, and tempio rendered the raw string into the listen directive, so a value like "5000; location /x/ { alias /; } #:5000" injected nginx directives. Both now go through parse_listen_address, which accepts only a port, IPv4:port, or [IPv6]:port with a 1 to 65535 port and rebuilds the value from the parsed parts. get_nginx_settings.py falls back to the default port on an invalid value so nginx still serves the safe-mode UI.

* tighten comments

* accept hostnames, wildcards, and padded values in networking.listen

The stricter parser broke localhost:5000, *:5000, and values with surrounding whitespace that the old int() parsing accepted. parse_listen_address now strips the value and accepts RFC 1123 hostnames and *, still rebuilding the rendered value from the parsed parts. The ListenConfig validator runs in before mode, because pydantic coerced true to port 1 while get_nginx_settings.py rejected it and fell back to 5000, so the auth internal port and nginx disagreed.

* always create go2rtc.yaml after removing it and don't let opt_in_out stop frigate

The go2rtc else-branch was only reachable when a regular file was planted between rm and the test, and then loaded that file as go2rtc's config. create_config.py now runs unconditionally and its exclusive create fails closed. opt_in_out runs after s6-svc -O, so a failure there was terminal under errexit.

* fall back to default listen ports when either is invalid or they collide

* keep valid listen values and refuse to start nginx when the ports collide
2026-10-19 04:57:35 -10:00
Josh HawkinsandGitHub 3d3f6a0678 Harden web push notifications (#24715)
* reject push endpoints that crash the vapid audience parse

The validator accepted hostnames like "." so https://./x passed, and check_registrations then raised ValueError slicing the endpoint, killing the dispatcher's IPC thread. The validator now rejects empty hostname labels and lives in webpush.py, the audience comes from urlparse, and _build_web_pushers skips stored rows whose endpoint fails validation.

* keep the ipc thread alive when a handler raises

An exception from any dispatcher handler escaped InterProcessCommunicator.read, which only caught ZMQError. The REP thread died owing a reply, so every InterProcessRequestor blocked forever. read now logs the error and replies with an empty response, and Dispatcher.publish and publish_local catch per communicator so one failing communicator can't take down the rest.

* stop push sends from reaching internal addresses

pywebpush posts through requests with redirects enabled, so an endpoint could answer 307 to http://127.0.0.1:5000/api/restart and the sender would follow it into the internal admin port on every alert. Pushers now use a session that never follows redirects, and each send first resolves the endpoint host and skips it unless every address is global.

* keep the previous region grid when the refresh request fails

The IPC reader now replies [] when a handler raises, and process_frames assigned that reply to region_grid unconditionally. The next region lookup then indexed into an empty list and killed the camera's detect process. An empty reply now keeps the current grid until the next daily refresh.

* drop the send-time public address check

Refusing non-global resolved addresses broke Tailscale (100.64.0.0/10), split-horizon DNS and HTTPS_PROXY-only setups, and added little once redirects are no longer followed and TLS verification stays on.

* only skip stored push rows that break the vapid audience

Running the full registration validator over stored rows dropped self-hosted endpoints registered before 0.18 with a non-443 port or a bare / path. _build_web_pushers now skips only non-dict rows, rows with no endpoint, unparsable endpoints and hosts with empty labels, and the warning names the host so the row can be found. Registration still uses the full validator.
2026-10-19 04:55:59 -10:00
Josh HawkinsandGitHub 53ca9c929b Mask all camera credentials in config (#24719)
* mask every credential form in camera and go2rtc urls

The credential mask only matched a userinfo with a [A-Za-z0-9_-] username or the exact user=..&password=.. query order, so dotted or email usernames, password-only userinfo (tapo://pw@host), empty usernames, reversed or renamed query credentials, and token query strings (ring refresh_token, nest client_secret, homekit client_private) reached every authenticated user through /config, /go2rtc/streams, /stats and /metrics. clean_camera_user_pass now masks any userinfo and any credential-named query parameter, and the config save restore and the settings UI detect masked paths with the same patterns so a save never writes the mask back over the real credential.

* redact go2rtc rtsp, api and webrtc credentials in /config

/config cleaned only go2rtc.streams, so go2rtc.rtsp and go2rtc.api usernames and passwords and the WebRTC ICE server credentials went back verbatim to any authenticated user. They now use the redaction sentinel like the MQTT and ONVIF passwords. Config save already dropped a field equal to the sentinel, but lists are leaf values after flattening, so an ice_servers list echoed back from /config would have written the sentinel to disk. Save now drops any value that contains it.

* bound the credential mask to the url authority

The userinfo pattern ran from any host:port to the last "@" in the token, so rtsp://host:554/live?x=a@b lost its host and a comma between two URLs was swallowed, which merged the paths /ffprobe splits on. It also backtracked quadratically on long lines with many "://", and every subprocess log line goes through it. A "user:" userinfo now runs to the last "@" before the next URL unless a port follows the colon, and any other userinfo stops at the end of the authority. Query masking now covers only real secret names, so auth, private and autoken are left alone.

* restore masked credentials by value on config save

Masked camera paths were restored by index, so deleting the first input wrote its credentials onto the next input. Each masked path now matches the saved input that masks to the same value, with roles breaking a tie, and an unmatched or ambiguous path is rejected instead of written. A redacted ICE server is filled from the saved server with the same urls, or rejected when its urls changed, instead of dropping the whole list. /config also redacts the go2rtc mqtt credentials, the ngrok command, homekit pins and pairings, and publish destinations.

* restore masked camera paths from the saved yaml

Masked paths were restored from the in-memory config, where {FRIGATE_*} placeholders are already resolved, so saving an untouched camera replaced the placeholder in config.yml with the real secret. Masked paths now restore from the YAML on disk, matched against each saved path with its placeholders resolved, so the path is written back as it was. In-memory saves still restore from the live config.

* mask passwords that start with digits

The port check in the userinfo pattern also matched passwords, so rtsp://admin:2024/pw@host and rtsp://admin:12#abc@host went out unmasked where the old regex masked them. The check is gone. Stopping at the next "://" already keeps the comma between two ffprobe paths, and a host:port/path@x is over-masked for display only, since saves restore it by value.

* reject config saves that still hold masked credentials

Index-keyed input maps, query-string saves and go2rtc stream lists echoed from /config bypassed the credential restore and could write a masked path or the redaction sentinel over the real value. Every save, body or query string, now rejects any value that still holds one after the restores run, naming the field.

* fix partial password masking and ambiguous ice server restore

- Mask userinfo before query params so a credential key inside a password can't leave the rest of it visible
- Reject restoring redacted ice servers when saved servers share urls but hold different accounts

* mask the whole url when userinfo and a query credential overlap

Masking userinfo first leaked the tail of a query password holding "@" on URLs with a port, like rtsp://10.0.0.2:554/live?password=pre@suf. When the userinfo span contains a credential query key, the mask now covers the rest of the URL in both clean_camera_user_pass and maskCredentials.

* resolve placeholders with dev's env module

* reveal camera paths by masked value instead of position

After deleting or reordering inputs in the camera ffmpeg form, the path widget looked up raw_paths by index and revealed another input's real path, which a save then wrote with the wrong roles. The widget now matches the saved path whose mask equals the field's value, the same rule the backend uses on save, and doesn't reveal when there is no single match.

* stop redacting ice servers and break camera path ties by role

The WebRTC player passes /config's ice_servers to RTCPeerConnection, so redacting them sent the placeholder as the TURN account. ICE servers are no longer redacted and the restore code for them is removed. The camera path widget now uses the input's roles to pick between saved paths that mask to the same value, as the backend does on save.
2026-10-19 04:54:28 -10:00
Josh HawkinsandGitHub 796aec7a4a Limit concurrent motion searches (#24717)
* limit concurrent motion search jobs and search range

Any viewer could start unlimited motion search jobs over an unbounded time range, and finished jobs were never dropped from memory. Each user now gets one active job (409), at most 3 run at once (429), the range is capped at 48 hours, and finished jobs are evicted 10 minutes after they end. Jobs are no longer filed in the shared job manager, which kept every finished job forever.

* show translated errors for motion search limits

* release the motion search slot if the runner fails to start

* keep wedged or shared-identity motion searches from locking out users

A stalled VOD read blocked the runner forever, so a wedged job held its user and global slot until restart, and on installs without per-user identity (internal port, or auth disabled without a proxy user header) everyone shared one per-user slot. VOD ffmpeg and ffprobe inputs now use a 30 s rw_timeout, a cancel also drops queued parallel runs, a cancelled job stops counting after 60 s, and the per-user limit only applies when the request carries a real username. A failed runner start also stops its requestor.

* treat timed-out VOD segments as motion search errors

With rw_timeout, the HLS demuxer skipped a timed-out segment and exited cleanly, so a run reported success with a hole in it, and when the keyframe probe and the decode lost different segments the equal frame counts paired frames with the wrong timestamps. The keyframe path now requires the probe to cover the whole run without a gap over 5 s, a decode whose stderr shows a skipped segment fails the run, and a cadence decode more than 5 s short of the run's duration fails it too, so these runs land in segments_with_errors. The keyframe probe polls should_stop and is killed on cancel, probe_video_dimensions gets the read timeout, and a user's own cancelled job no longer blocks their next search while it still counts toward the global cap.

* drop motion search stall detection and range cap

* remove motion search range check from the UI
2026-10-19 04:52:15 -10:00
Josh HawkinsandGitHub 7af29a5a53 only render same-origin images in chat replies (#24716)
Assistant markdown rendered any image URL, so a prompt injection in a GenAI description, transcription, or sub label could make the model emit an image pointing at an outside host and the browser would fetch it with chat context in the query string, without a click. Off-origin images now render as their alt text, and off-origin links open in a new tab with noopener noreferrer.
2026-10-19 04:47:05 -10:00
Josh HawkinsandGitHub 6ba3198232 Revoke stale sessions in /auth (#24713)
* take the jwt role from the user and revoke stale sessions

/auth trusted the role claim in the token, and only checked password_changed_at when refreshing a cookie. Demoting or deleting a user, changing a password, or resetting the admin password left existing cookies and bearer tokens working with their old role. The API now keeps each user's role and password change time in memory, loads it at startup, and updates it from the user endpoints. /auth rejects unknown users and tokens issued before the last password change, and authorizes with the stored role without touching the database. Resetting the admin password now sets password_changed_at.

* set the secure cookie flag when the request arrived over tls

The session cookie only got the Secure flag from auth.cookie_secure, which defaults to false even though TLS is on by default, so browsers would send it over plain HTTP. The cookie is now Secure whenever nginx reports an https scheme, and nginx passes that scheme to the /auth subrequest so refreshed cookies match. cookie_secure still forces the flag for a reverse proxy that terminates TLS in front of plain-HTTP Frigate.

* regenerate config schema mock data

* revoke tokens issued in the same second as a password change

/auth compared the token's whole-second iat against the truncated change time, so a token issued earlier in the same second as a password change stayed valid. The change time is now rounded up, and the cookie reissued to a user who changes their own password carries that rounded-up iat so it still passes.

* note that a proxy on the tls port must serve https to browsers

* reject users whose role was removed from the config

A user whose role was removed from the config fell back to viewer, which has no camera restrictions, so a restricted role removed from yaml gained access to every camera. /auth now rejects that user's tokens, and login refuses them until an admin assigns a valid role.

* floor password change time to match iat

The password change time was rounded up while the token iat was rounded down, so a login in the same second as a password change got a token that /auth rejected as revoked. Both are floored now, which leaves a sub-second window where a token issued just before the change stays valid.

* set password_changed_at on user creation

A recreated username had no password change cutoff, so an unexpired token from the deleted account passed /auth with the new account's role. Creating a user now sets the cutoff to the creation time.
2026-10-19 04:44:37 -10:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Josh Hawkins
ee2ae1a957 Bump the radix group in /web with 22 updates (#24702)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* Bump the radix group in /web with 22 updates

Bumps the radix group in /web with 22 updates:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.24` | `1.2.1` |
| [@radix-ui/react-aspect-ratio](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/aspect-ratio) | `1.1.16` | `1.2.0` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.12` | `1.4.1` |
| [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/collapsible) | `1.1.21` | `1.2.1` |
| [@radix-ui/react-context-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/context-menu) | `2.3.8` | `2.4.1` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.2.0` | `1.3.1` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.25` | `2.2.1` |
| [@radix-ui/react-hover-card](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/hover-card) | `1.1.24` | `1.2.1` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.16` | `2.1.17` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.2.0` | `1.3.1` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.17` | `1.2.1` |
| [@radix-ui/react-radio-group](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radio-group) | `1.4.8` | `1.5.1` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.3.0` | `1.4.0` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.8` | `2.4.1` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.16` | `1.2.0` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.5.0` | `1.6.1` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.4.0` | `1.5.0` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.8` | `1.4.1` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.22` | `1.2.1` |
| [@radix-ui/react-toggle](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toggle) | `1.1.19` | `1.2.1` |
| [@radix-ui/react-toggle-group](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toggle-group) | `1.1.20` | `1.2.1` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.3.0` | `1.4.1` |


Updates `@radix-ui/react-alert-dialog` from 1.1.24 to 1.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog)

Updates `@radix-ui/react-aspect-ratio` from 1.1.16 to 1.2.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/aspect-ratio/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/aspect-ratio)

Updates `@radix-ui/react-checkbox` from 1.3.12 to 1.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox)

Updates `@radix-ui/react-collapsible` from 1.1.21 to 1.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/collapsible/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/collapsible)

Updates `@radix-ui/react-context-menu` from 2.3.8 to 2.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/context-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/context-menu)

Updates `@radix-ui/react-dialog` from 1.2.0 to 1.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-dropdown-menu` from 2.1.25 to 2.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu)

Updates `@radix-ui/react-hover-card` from 1.1.24 to 1.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/hover-card/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/hover-card)

Updates `@radix-ui/react-label` from 2.1.16 to 2.1.17
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-popover` from 1.2.0 to 1.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover)

Updates `@radix-ui/react-progress` from 1.1.17 to 1.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

Updates `@radix-ui/react-radio-group` from 1.4.8 to 1.5.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radio-group/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radio-group)

Updates `@radix-ui/react-scroll-area` from 1.3.0 to 1.4.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area)

Updates `@radix-ui/react-select` from 2.3.8 to 2.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-separator` from 1.1.16 to 1.2.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slider` from 1.5.0 to 1.6.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider)

Updates `@radix-ui/react-slot` from 1.4.0 to 1.5.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-switch` from 1.3.8 to 1.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `@radix-ui/react-tabs` from 1.1.22 to 1.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs)

Updates `@radix-ui/react-toggle` from 1.1.19 to 1.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toggle/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toggle)

Updates `@radix-ui/react-toggle-group` from 1.1.20 to 1.2.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toggle-group/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toggle-group)

Updates `@radix-ui/react-tooltip` from 1.3.0 to 1.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-alert-dialog"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-aspect-ratio"
  dependency-version: 1.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-collapsible"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-context-menu"
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-hover-card"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-radio-group"
  dependency-version: 1.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-toggle"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-toggle-group"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix radix bump type error and drop popper override

Slider 1.6 types its root as a span, so the progress hover handlers now take HTMLElement. The react-popper 1.2.8 override pinned an older popper that nested its own copies of primitive, context and the use-* hooks; without it every radix package resolves to a single copy, and compose-refs stays on the patched 1.1.2 through its own override.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-10-18 12:50:05 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ac77d06d83 Bump the pydantic-genai group across 1 directory with 2 updates (#24700)
Updates the requirements on [pydantic](https://github.com/pydantic/pydantic) and [google-genai](https://github.com/googleapis/python-genai) to permit the latest version.

Updates `pydantic` to 2.14.1
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.13.0b1...v2.14.1)

Updates `google-genai` to 2.31.0
- [Release notes](https://github.com/googleapis/python-genai/releases)
- [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-genai/compare/v2.29.0...v2.31.0)

---
updated-dependencies:
- dependency-name: google-genai
  dependency-version: 2.31.0
  dependency-type: direct:production
  dependency-group: pydantic-genai
- dependency-name: pydantic
  dependency-version: 2.14.1
  dependency-type: direct:production
  dependency-group: pydantic-genai
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-18 12:19:14 -05:00
Nicolas MowenandGitHub d7bb780ff8 Upgrade to ROCm 10.1 (#24708)
* Update to ROCm 10.1

* Update docs for new setup
2026-10-18 12:13:32 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
893542f4ca Update openvino requirement in /docker/main (#24628)
Updates the requirements on [openvino](https://github.com/openvinotoolkit/openvino) to permit the latest version.
- [Release notes](https://github.com/openvinotoolkit/openvino/releases)
- [Changelog](https://github.com/openvinotoolkit/openvino/blob/master/docs/RELEASE.MD)
- [Commits](https://github.com/openvinotoolkit/openvino/commits/2026.4.1)

---
updated-dependencies:
- dependency-name: openvino
  dependency-version: 2026.4.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-18 12:12:38 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ecffe9ce25 Update scikit-build requirement in /docker/main (#24627)
Updates the requirements on [scikit-build](https://github.com/scikit-build/scikit-build) to permit the latest version.
- [Release notes](https://github.com/scikit-build/scikit-build/releases)
- [Changelog](https://github.com/scikit-build/scikit-build/blob/main/CHANGES.md)
- [Commits](https://github.com/scikit-build/scikit-build/compare/0.18.0...0.19.1)

---
updated-dependencies:
- dependency-name: scikit-build
  dependency-version: 0.19.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-18 12:11:27 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
303c6663c5 Bump py-vapid from 1.9.4 to 1.9.6 in /docker/main in the crypto group (#24699)
Bumps the crypto group in /docker/main with 1 update: [py-vapid](https://github.com/mozilla-services/vapid).


Updates `py-vapid` from 1.9.4 to 1.9.6
- [Release notes](https://github.com/mozilla-services/vapid/releases)
- [Commits](https://github.com/mozilla-services/vapid/commits)

---
updated-dependencies:
- dependency-name: py-vapid
  dependency-version: 1.9.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: crypto
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-18 12:07:03 -05:00
71 changed files with 2773 additions and 1231 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
numpy numpy
openvino >= 2026.2.0 openvino >= 2026.4.1
+4 -4
View File
@@ -23,7 +23,7 @@ pandas == 3.0.*
peewee == 4.5.* peewee == 4.5.*
peewee_migrate == 2.3.* peewee_migrate == 2.3.*
psutil == 7.2.* psutil == 7.2.*
pydantic == 2.13.* pydantic == 2.14.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml git+https://github.com/fbcotter/py3nvml#egg=py3nvml
tzdata == 2026.5 tzdata == 2026.5
pyzmq == 27.2.* pyzmq == 27.2.*
@@ -42,16 +42,16 @@ opencv-python-headless == 4.11.0.*
opencv-contrib-python == 4.11.0.* opencv-contrib-python == 4.11.0.*
scipy == 1.17.* scipy == 1.17.*
# OpenVino & ONNX # OpenVino & ONNX
openvino == 2025.4.* openvino == 2026.4.*
onnxruntime == 1.30.* onnxruntime == 1.30.*
# Embeddings # Embeddings
transformers == 5.19.* transformers == 5.19.*
# Generative AI # Generative AI
google-genai == 2.29.* google-genai == 2.31.*
ollama == 0.6.* ollama == 0.6.*
openai == 3.26.* openai == 3.26.*
# push notifications # push notifications
py-vapid == 1.9.4 py-vapid == 1.9.6
pywebpush == 2.5.* pywebpush == 2.5.*
# alpr # alpr
pyclipper == 1.4.* pyclipper == 1.4.*
+1 -1
View File
@@ -1 +1 @@
scikit-build == 0.18.* scikit-build == 0.19.*
@@ -36,8 +36,8 @@ else
lefile="/config/tls/fullchain.pem" lefile="/config/tls/fullchain.pem"
fi fi
tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled` tls_enabled=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port` listen_external_port=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
while true while true
do do
@@ -11,30 +11,13 @@ if [[ "$(id -u)" -eq 0 ]]; then
fi fi
fi fi
# /root survives s6-setuidgid and breaks cache writes after the drop; set
# before opt_in_out so the opt-out marker lands where the service will look
if [[ "$runs_as_root" -eq 0 ]]; then
export HOME=/config
fi
# detector runtimes installed at first start (pip install --user) live under
# $HOME/.local; the dynamic loader only reads LD_LIBRARY_PATH at exec time
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${HOME}/.local/lib"
export PATH="${PATH}:${HOME}/.local/bin"
# opt out of openvino telemetry
if [ -e /usr/local/bin/opt_in_out ]; then
/usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1
fi
# Logs should be sent to stdout so that s6 can collect them
# Tell S6-Overlay not to restart this service # Tell S6-Overlay not to restart this service
s6-svc -O . s6-svc -O .
# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages
function set_libva_version() { function set_libva_version() {
local ffmpeg_path local ffmpeg_path
ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py) ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+") LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
export LIBAVFORMAT_VERSION_MAJOR export LIBAVFORMAT_VERSION_MAJOR
} }
@@ -42,14 +25,32 @@ function set_libva_version() {
echo "[INFO] Preparing Frigate..." echo "[INFO] Preparing Frigate..."
set_libva_version set_libva_version
# HOME=/root survives s6-setuidgid, so any run that is not root gets
# HOME=/config explicitly. The runtime user owns /config, so these paths go
# only to the service process, never to root. Detector runtimes installed at
# first start (pip install --user) live under $HOME/.local.
run_as=()
runtime_home="${HOME}"
if [[ "$runs_as_root" -eq 0 ]]; then
runtime_home=/config
if [[ "$(id -u)" -eq 0 ]]; then
run_as=(s6-setuidgid frigate)
fi
fi
run_as+=(env
"HOME=${runtime_home}"
"LD_LIBRARY_PATH=${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${runtime_home}/.local/lib"
"PATH=${PATH}:${runtime_home}/.local/bin")
# opt out of openvino telemetry; the marker lands under the service's HOME
if [ -e /usr/local/bin/opt_in_out ]; then
"${run_as[@]}" /usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1 || true
fi
echo "[INFO] Starting Frigate..." echo "[INFO] Starting Frigate..."
cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate" cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate"
# Replace the bash process with the Frigate process, redirecting stderr to stdout # Replace the bash process with the Frigate process, redirecting stderr to stdout
exec 2>&1 exec 2>&1
if [[ "$(id -u)" -ne 0 || "$runs_as_root" -eq 1 ]]; then exec "${run_as[@]}" python3 -u -m frigate
exec python3 -u -m frigate
else
exec s6-setuidgid frigate python3 -u -m frigate
fi
@@ -59,38 +59,33 @@ function get_ip_and_port_from_supervisor() {
function set_libva_version() { function set_libva_version() {
local ffmpeg_path local ffmpeg_path
ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py) ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+") LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
export LIBAVFORMAT_VERSION_MAJOR export LIBAVFORMAT_VERSION_MAJOR
} }
set_libva_version set_libva_version
if [[ -f "/dev/shm/go2rtc.yaml" ]]; then # create_config.py creates the file exclusively and fails if anything
echo "[INFO] Removing stale config from last run..." # reappears at this path
rm /dev/shm/go2rtc.yaml rm -f /dev/shm/go2rtc.yaml
echo "[INFO] Preparing new go2rtc config..."
if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then
# Running as a Home Assistant Add-on, infer the IP address and port
get_ip_and_port_from_supervisor
fi fi
if [[ ! -f "/dev/shm/go2rtc.yaml" ]]; then python3 -I /usr/local/go2rtc/create_config.py
echo "[INFO] Preparing new go2rtc config..."
if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then
# Running as a Home Assistant Add-on, infer the IP address and port
get_ip_and_port_from_supervisor
fi
python3 /usr/local/go2rtc/create_config.py
else
echo "[WARNING] Unable to remove existing go2rtc config. Changes made to your frigate config file may not be recognized. Please remove the /dev/shm/go2rtc.yaml from your docker host manually."
fi
# HomeKit persistence. The helper is symlink-safe; hand off to go2rtc only when dropping. # HomeKit persistence. The helper is symlink-safe; hand off to go2rtc only when dropping.
readonly homekit_config_path="/config/go2rtc_homekit.yml" readonly homekit_config_path="/config/go2rtc_homekit.yml"
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown
chown go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true chown -h go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
else else
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}"
fi fi
readonly config_path="/config" readonly config_path="/config"
@@ -124,7 +124,7 @@ fi
mkdir -p /etc/letsencrypt/www 2>/dev/null || true mkdir -p /etc/letsencrypt/www 2>/dev/null || true
# nginx settings are read once; both templates consume them # nginx settings are read once; both templates consume them
nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py) nginx_settings=$(python3 -I /usr/local/nginx/get_nginx_settings.py)
# build templates for optional FRIGATE_BASE_PATH environment variable # build templates for optional FRIGATE_BASE_PATH environment variable
echo "$nginx_settings" | \ echo "$nginx_settings" | \
@@ -198,9 +198,13 @@ if config.get("birdseye", {}).get("restream", False):
else: else:
go2rtc_config["streams"] = {"birdseye": ffmpeg_cmd} go2rtc_config["streams"] = {"birdseye": ffmpeg_cmd}
# Write go2rtc_config to /dev/shm/go2rtc.yaml # The run script removes the old file first. Exclusive create never follows a
with open("/dev/shm/go2rtc.yaml", "w") as f: # link the go2rtc user planted in sticky /dev/shm. The config contains camera
# credentials, so it is not world-readable.
fd = os.open(
"/dev/shm/go2rtc.yaml",
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o640,
)
with os.fdopen(fd, "w") as f:
yaml.dump(go2rtc_config, f) yaml.dump(go2rtc_config, f)
# config contains camera credentials; do not leave it world-readable
os.chmod("/dev/shm/go2rtc.yaml", 0o640)
@@ -19,11 +19,7 @@ location /auth {
# this cannot be spoofed # this cannot be spoofed
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy; proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
proxy_set_header X-Server-Port $server_port; proxy_set_header X-Server-Port $server_port;
# inputs for the cross-origin check on websocket and go2rtc stream requests proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Origin $http_origin;
proxy_set_header X-Original-Host $http_host;
proxy_set_header X-Forwarded-Host $forwarded_host;
proxy_set_header X-Original-Upgrade $http_upgrade;
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
# Pass along auth related info # Pass along auth related info
proxy_set_header Authorization $http_authorization; proxy_set_header Authorization $http_authorization;
@@ -54,13 +54,6 @@ http {
default 1; default 1;
} }
# keep an outer proxy's X-Forwarded-Host so the websocket servers can
# match a browser's Origin when that proxy rewrites Host
map $http_x_forwarded_host $forwarded_host {
"" $http_host;
default $http_x_forwarded_host;
}
upstream frigate_api { upstream frigate_api {
server 127.0.0.1:5001; server 127.0.0.1:5001;
keepalive 1024; keepalive 1024;
@@ -4,7 +4,7 @@ proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade"; proxy_set_header Connection "Upgrade";
proxy_set_header X-Original-URL $request_uri; proxy_set_header X-Original-URL $request_uri;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $forwarded_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-URI $request_uri; proxy_set_header X-Forwarded-URI $request_uri;
proxy_set_header X-Forwarded-Ssl on; proxy_set_header X-Forwarded-Ssl on;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -8,7 +8,7 @@ from typing import Any
from ruamel.yaml import YAML from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate") sys.path.insert(0, "/opt/frigate")
from frigate.util.config import find_config_file from frigate.util.config import find_config_file, parse_listen_address
sys.path.remove("/opt/frigate") sys.path.remove("/opt/frigate")
@@ -35,20 +35,29 @@ ipv6_config: dict[str, Any] = networking_config.get("ipv6", {})
ipv6_config.setdefault("enabled", False) ipv6_config.setdefault("enabled", False)
listen_config: dict[str, Any] = networking_config.get("listen", {}) listen_config: dict[str, Any] = networking_config.get("listen", {})
listen_config.setdefault("internal", 5000)
listen_config.setdefault("external", 8971)
# handle case where internal port is a string with ip:port # Frigate's own validation runs elsewhere, and a bad value here must not stop nginx
internal_port = listen_config["internal"] # from serving the safe-mode UI. Valid values are kept so a fallback never widens
if type(internal_port) is str: # the unauthenticated internal listener beyond the configured address.
internal_port = int(internal_port.split(":")[-1]) for key, default in (("internal", 5000), ("external", 8971)):
listen_config["internal_port"] = internal_port try:
value, port = parse_listen_address(listen_config.get(key, default))
except ValueError:
print(
f"[WARN] Invalid networking.listen.{key}, using {default}",
file=sys.stderr,
)
value, port = default, default
# handle case where external port is a string with ip:port listen_config[key] = value
external_port = listen_config["external"] listen_config[f"{key}_port"] = port
if type(external_port) is str:
external_port = int(external_port.split(":")[-1]) if listen_config["internal_port"] == listen_config["external_port"]:
listen_config["external_port"] = external_port print(
"[ERROR] networking.listen.internal and external share a port",
file=sys.stderr,
)
sys.exit(1)
base_path = os.environ.get("FRIGATE_BASE_PATH", "") base_path = os.environ.get("FRIGATE_BASE_PATH", "")
+11 -50
View File
@@ -2,32 +2,9 @@
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ARG ROCM=1
ARG HSA_OVERRIDE_GFX_VERSION ARG HSA_OVERRIDE_GFX_VERSION
ARG HSA_OVERRIDE ARG HSA_OVERRIDE
#######################################################################
FROM wget AS rocm
ARG ROCM
RUN apt update -qq && \
apt install -y wget gpg && \
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2.3/ubuntu/jammy/amdgpu-install_7.2.3.70203-1_all.deb && \
apt install -y ./rocm.deb && \
apt update && \
apt install -qq -y rocm
RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib
RUN cd /opt/rocm-$ROCM/lib && \
cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocsolver*.so* librocfft*.so* librocprofiler*.so* libroctx*.so* librocroller.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ && \
mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib && \
cp -dpr migraphx/lib/* /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib
RUN cd /opt/rocm-dist/opt/ && ln -s rocm-$ROCM rocm
RUN mkdir -p /opt/rocm-dist/etc/ld.so.conf.d/
RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
####################################################################### #######################################################################
FROM deps AS deps-prelim FROM deps AS deps-prelim
@@ -42,51 +19,35 @@ RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.
rm -f /etc/apt/sources.list.d/trixie.list && \ rm -f /etc/apt/sources.list.d/trixie.list && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /opt/frigate
COPY --from=rootfs / /
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \ && sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
&& python3 get-pip.py "pip" --break-system-packages && python3 get-pip.py "pip" --break-system-packages
RUN python3 -m pip config set global.break-system-packages true RUN python3 -m pip config set global.break-system-packages true
# ROCm installs from AMD's wheels ahead of Frigate's code so code changes
# reuse this layer, and is pruned in the same layer so the removed files never
# reach the image. /opt/rocm links to the runtime for rocminfo and HIP headers.
COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt
RUN pip3 uninstall -y onnxruntime \ RUN --mount=type=bind,source=docker/rocm/prune_rocm.sh,target=/deps/prune_rocm.sh \
&& pip3 install -r /requirements.txt pip3 uninstall -y onnxruntime \
&& pip3 install --no-cache-dir -r /requirements.txt \
&& ROCM_SDK_CORE="$(python3 -c 'import _rocm_sdk_core, os; print(os.path.dirname(_rocm_sdk_core.__file__))')" \
&& /deps/prune_rocm.sh "$(dirname "${ROCM_SDK_CORE}")" \
&& ln -s "${ROCM_SDK_CORE}" /opt/rocm
####################################################################### WORKDIR /opt/frigate
FROM scratch AS rocm-dist COPY --from=rootfs / /
ARG ROCM
# Copy HIP headers required for MIOpen JIT (BuildHip) / HIPRTC at runtime
COPY --from=rocm /opt/rocm-${ROCM}/include/ /opt/rocm-${ROCM}/include/
COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/
# Copy MIOpen database files for gfx10xx, gfx11xx, and gfx12xx only (RDNA2/RDNA3/RDNA4)
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx11* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx12* /opt/rocm-$ROCM/share/miopen/db/
# Copy rocBLAS library files for gfx10xx, gfx11xx, and gfx12xx only
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx10* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx11* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx12* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-dist/ /
####################################################################### #######################################################################
FROM deps-prelim AS rocm-prelim-hsa-override0 FROM deps-prelim AS rocm-prelim-hsa-override0
ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1 ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1
ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1 ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1
ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1 ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1
ENV MIGRAPHX_ENABLE_HIPRTC_WORKAROUNDS=1
ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx
ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx
ENV AMD_COMGR_CACHE=1 ENV AMD_COMGR_CACHE=1
ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx
COPY --from=rocm-dist / /
RUN ldconfig
####################################################################### #######################################################################
FROM rocm-prelim-hsa-override0 as rocm-prelim-hsa-override1 FROM rocm-prelim-hsa-override0 as rocm-prelim-hsa-override1
+63
View File
@@ -0,0 +1,63 @@
#!/bin/bash
# Remove the parts of AMD's ROCm wheels that MIGraphX inference never loads:
# compilers, debuggers, profilers, management tools, and the math libraries
# (sparse, solver, FFT, random, RCCL, image processing) outside MIGraphX's
# dependencies. Runs in the same layer as the install so the files never
# reach the image.
set -euo pipefail
site_packages="$1"
core="${site_packages}/_rocm_sdk_core"
libraries="${site_packages}/_rocm_sdk_libraries"
# rocminfo stays for checking that the GPU is visible in the container
find "${core}/bin" -mindepth 1 -maxdepth 1 \
! -name rocminfo ! -name rocm_agent_enumerator -exec rm -rf {} +
# hiprtc compiles MIGraphX's kernels through comgr, which needs libLLVM,
# libclang-cpp, the clang resource headers, and the amdgcn device libraries
rm -rf "${core}/lib/llvm/bin" "${core}/lib/llvm/include"
find "${core}/lib/llvm/lib" -mindepth 1 -maxdepth 1 \
! -name 'libLLVM.so*' ! -name 'libclang-cpp.so*' ! -name clang \
-exec rm -rf {} +
rm -rf "${core}"/lib/llvm/lib/clang/*/lib
rm -rf \
"${core}/lib/host-math" \
"${core}"/lib/python3* \
"${core}/lib/rocprofiler-sdk" \
"${core}"/lib/librocprofiler-sdk*.so* \
"${core}"/lib/librocprof-trace-decoder.so* \
"${core}"/lib/librocm-dbgapi.so* \
"${core}"/lib/librocm-debug-agent.so* \
"${core}"/lib/libamd_smi*.so* \
"${core}"/lib/libgoamdsmi_shim64.so* \
"${core}"/lib/librocdecode.so* \
"${core}"/lib/librocjpeg.so* \
"${core}"/lib/librocdxg.so* \
"${core}"/lib/librocjitsu_*.so* \
"${core}"/lib/libhsa_hotswap_rocjitsu.so* \
"${core}"/lib/*.a
rm -rf \
"${libraries}"/lib/librpp.so* \
"${libraries}"/lib/librocsparse.so* \
"${libraries}"/lib/libhipsparse.so* \
"${libraries}"/lib/libhipsparselt.so* \
"${libraries}/lib/rocsparse" \
"${libraries}"/lib/librocsolver.so* \
"${libraries}"/lib/libhipsolver.so* \
"${libraries}"/lib/libhipblas.so* \
"${libraries}"/lib/librocrand.so* \
"${libraries}"/lib/libhiprand.so* \
"${libraries}"/lib/librocfft.so* \
"${libraries}"/lib/libhipfft.so* \
"${libraries}"/lib/libhipfftw.so* \
"${libraries}"/lib/librccl.so* \
"${libraries}"/.kpack/sparse_lib_*.kpack \
"${libraries}"/.kpack/solver_lib_*.kpack \
"${libraries}"/.kpack/rand_lib_*.kpack \
"${libraries}"/.kpack/fft_lib_*.kpack \
"${libraries}"/.kpack/rccl_lib_*.kpack
+19 -1
View File
@@ -1 +1,19 @@
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.3-1/onnxruntime_migraphx-1.24.4-cp311-cp311-linux_x86_64.whl # ROCm 10.1.0, MIGraphX, and its ONNX Runtime plugin provider from AMD's wheels.
onnxruntime == 1.29.*
onnxruntime-ep-migraphx @ https://stable.repo.amd.com/rocm/onnxruntime/whl-next/onnxruntime-ep-migraphx/onnxruntime_ep_migraphx-1.0.0%2Brocm10.1.0-cp311-cp311-manylinux_2_28_x86_64.whl
migraphx-libs @ https://stable.repo.amd.com/rocm/migraphx/whl-next/migraphx-libs/migraphx_libs-2.18.0%2Brocm10.1.0-py3-none-manylinux_2_28_x86_64.whl
rocm-sdk-core @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-core/rocm_sdk_core-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-libraries @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-libraries/rocm_sdk_libraries-10.1.0-py3-none-linux_x86_64.whl
# Kernels for each supported GPU target. MIGraphX has no gfx1031-gfx1036 kernels,
# so those GPUs run as gfx1030 with HSA_OVERRIDE_GFX_VERSION=10.3.0
rocm-sdk-device-gfx1030 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1030/rocm_sdk_device_gfx1030-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1100 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1100/rocm_sdk_device_gfx1100-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1101 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1101/rocm_sdk_device_gfx1101-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1102 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1102/rocm_sdk_device_gfx1102-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1103 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1103/rocm_sdk_device_gfx1103-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1150 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1150/rocm_sdk_device_gfx1150-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1151 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1151/rocm_sdk_device_gfx1151-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1152 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1152/rocm_sdk_device_gfx1152-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1153 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1153/rocm_sdk_device_gfx1153-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1200 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1200/rocm_sdk_device_gfx1200-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1201 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1201/rocm_sdk_device_gfx1201-10.1.0-py3-none-linux_x86_64.whl
-11
View File
@@ -1,6 +1,3 @@
variable "ROCM" {
default = "7.2.3"
}
variable "HSA_OVERRIDE_GFX_VERSION" { variable "HSA_OVERRIDE_GFX_VERSION" {
default = "" default = ""
} }
@@ -8,12 +5,6 @@ variable "HSA_OVERRIDE" {
default = "1" default = "1"
} }
target wget {
dockerfile = "docker/main/Dockerfile"
platforms = ["linux/amd64"]
target = "wget"
}
target deps { target deps {
dockerfile = "docker/main/Dockerfile" dockerfile = "docker/main/Dockerfile"
platforms = ["linux/amd64"] platforms = ["linux/amd64"]
@@ -30,12 +21,10 @@ target rocm {
dockerfile = "docker/rocm/Dockerfile" dockerfile = "docker/rocm/Dockerfile"
contexts = { contexts = {
deps = "target:deps", deps = "target:deps",
wget = "target:wget",
rootfs = "target:rootfs" rootfs = "target:rootfs"
} }
platforms = ["linux/amd64"] platforms = ["linux/amd64"]
args = { args = {
ROCM = ROCM,
HSA_OVERRIDE_GFX_VERSION = HSA_OVERRIDE_GFX_VERSION, HSA_OVERRIDE_GFX_VERSION = HSA_OVERRIDE_GFX_VERSION,
HSA_OVERRIDE = HSA_OVERRIDE HSA_OVERRIDE = HSA_OVERRIDE
} }
@@ -117,7 +117,9 @@ auth:
# Optional: Cookie to store the JWT token for native auth (default: shown below) # Optional: Cookie to store the JWT token for native auth (default: shown below)
cookie_name: frigate_token cookie_name: frigate_token
# Optional: Set secure flag on cookie. (default: shown below) # Optional: Set secure flag on cookie. (default: shown below)
# NOTE: This should be set to True if you are using TLS # NOTE: Frigate sets the flag automatically when serving TLS. Set this to True
# when a reverse proxy terminates TLS in front of Frigate. A proxy that connects
# to the TLS port must serve Frigate to browsers over HTTPS
cookie_secure: False cookie_secure: False
# Optional: Session length in seconds (default: shown below) # Optional: Session length in seconds (default: shown below)
session_length: 86400 # 24 hours session_length: 86400 # 24 hours
@@ -360,7 +360,6 @@ For example, in Nginx:
``` ```
location /frigate { location /frigate {
proxy_set_header Host $host;
proxy_set_header X-Ingress-Path /frigate; proxy_set_header X-Ingress-Path /frigate;
proxy_pass http://frigate_backend; proxy_pass http://frigate_backend;
} }
+26 -18
View File
@@ -394,6 +394,22 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
Support for AMD GPUs is provided using the [ONNX detector](#onnx). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`. Support for AMD GPUs is provided using the [ONNX detector](#onnx). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`.
### Supported GPUs {#supported-gpus-rocm}
The `-rocm` image includes ROCm support for these GPU chipsets, which work without any chipset override:
| Chipset | Example GPUs |
| -------------------------------------------- | ------------------------------------------------------------------------ |
| gfx1030 (RDNA2) | Radeon RX 6800 / 6800 XT / 6900 XT / 6950 XT, Radeon PRO W6800 |
| gfx1100, gfx1101, gfx1102 (RDNA3) | Radeon RX 7900 / 7800 / 7700 / 7600 series, Radeon PRO W7000 series |
| gfx1103 (RDNA3 integrated) | Radeon 780M / 760M in Ryzen 7040, 8040, 8000G, and 200 series processors |
| gfx1150, gfx1151, gfx1152, gfx1153 (RDNA3.5) | Integrated GPUs in Ryzen AI 300 / 400 series and Ryzen AI Max processors |
| gfx1200, gfx1201 (RDNA4) | Radeon RX 9060 / 9070 series, Radeon AI PRO R9700 |
Other RDNA2 GPUs (gfx1031 through gfx1036), such as the Radeon RX 6700 / 6600 / 6500 series and the Radeon 680M / 660M integrated GPUs, run on the gfx1030 support by [overriding the chipset](#docker-settings-for-overriding-the-gpu-chipset) to `10.3.0`.
Older GPUs, including RDNA1 (Radeon RX 5000 series) and Vega based dedicated and integrated GPUs, are not supported.
### Docker settings for GPU access ### Docker settings for GPU access
ROCm needs access to the `/dev/kfd` and `/dev/dri` devices. When docker or frigate is not run under root then also `video` (and possibly `render` and `ssl/_ssl`) groups should be added. ROCm needs access to the `/dev/kfd` and `/dev/dri` devices. When docker or frigate is not run under root then also `video` (and possibly `render` and `ssl/_ssl`) groups should be added.
@@ -420,19 +436,10 @@ For reference on recommended settings see [running ROCm/pytorch in Docker](https
### Docker settings for overriding the GPU chipset ### Docker settings for overriding the GPU chipset
Your GPU might work just fine without any special configuration but in many cases they need manual settings. AMD/ROCm software stack comes with a limited set of GPU drivers and for newer or missing models you will have to override the chipset version to an older/generic version to get things working. A GPU whose chipset isn't in the [supported GPUs](#supported-gpus-rocm) list can run on the support for a closely related chipset by setting the `HSA_OVERRIDE_GFX_VERSION` environment variable. RDNA2 GPUs other than gfx1030 (gfx1031 through gfx1036) need this, using `10.3.0`:
Also AMD/ROCm does not "officially" support integrated GPUs. It still does work with most of them just fine but requires special settings. One has to configure the `HSA_OVERRIDE_GFX_VERSION` environment variable. See the [ROCm bug report](https://github.com/ROCm/ROCm/issues/1743) for context and examples.
For the rocm frigate build there is some automatic detection:
- gfx1031 -> 10.3.0
- gfx1103 -> 11.0.0
If you have something else you might need to override the `HSA_OVERRIDE_GFX_VERSION` at Docker launch. Suppose the version you want is `10.0.0`, then you should configure it from command line as:
```bash ```bash
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \ $ docker run -e HSA_OVERRIDE_GFX_VERSION=10.3.0 \
... ...
``` ```
@@ -443,16 +450,17 @@ services:
frigate: frigate:
... ...
environment: environment:
HSA_OVERRIDE_GFX_VERSION: "10.0.0" HSA_OVERRIDE_GFX_VERSION: "10.3.0"
``` ```
Figuring out what version you need can be complicated as you can't tell the chipset name and driver from the AMD brand name. GPUs in the supported list don't need an override. An override that was set for the Radeon 780M / 760M (gfx1103) on an earlier version of Frigate is no longer needed and can be removed.
- first make sure that rocm environment is running properly by running `/opt/rocm/bin/rocminfo` in the frigate container -- it should list both the CPU and the GPU with their properties You can't tell the chipset from the AMD brand name, so check it in the container:
- find the chipset version you have (gfxNNN) from the output of the `rocminfo` (see below)
- use a search engine to query what `HSA_OVERRIDE_GFX_VERSION` you need for the given gfx name ("gfxNNN ROCm HSA_OVERRIDE_GFX_VERSION") - first make sure that the ROCm environment is running properly by running `/opt/rocm/bin/rocminfo` in the Frigate container. It should list both the CPU and the GPU with their properties
- override the `HSA_OVERRIDE_GFX_VERSION` with relevant value - find the chipset version you have (gfxNNN) from the output of `rocminfo` (see below)
- if things are not working check the frigate docker logs - if the chipset isn't in the supported list, set `HSA_OVERRIDE_GFX_VERSION` to the closest supported chipset, written as major.minor.stepping (for example gfx1030 is `10.3.0`)
- if things are not working check the Frigate docker logs
#### Figuring out if AMD/ROCm is working and found your GPU #### Figuring out if AMD/ROCm is working and found your GPU
+2 -2
View File
@@ -72,7 +72,7 @@ Frigate supports multiple different detectors that work on different types of ha
**AMD** **AMD**
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection - [ROCm](#rocm---amd-gpu): ROCm can run on AMD discrete and recent integrated GPUs to provide efficient object detection
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector) - [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
- Runs best on discrete AMD GPUs - Runs best on discrete AMD GPUs
- <CommunityBadge /> [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms - <CommunityBadge /> [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms
@@ -240,7 +240,7 @@ Apple Silicon can not run within a container, so a ZMQ proxy is utilized to comm
### ROCm - AMD GPU ### ROCm - AMD GPU
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs. With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many AMD discrete and integrated GPUs, see the [supported GPUs](../configuration/object_detectors.md#supported-gpus-rocm).
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time | | Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
| -------------- | --------------------------- | ------------------------- | ---------------------- | | -------------- | --------------------------- | ------------------------- | ---------------------- |
-3
View File
@@ -37,8 +37,6 @@ Your reverse proxy must be configured to forward the `Upgrade` and `Connection`
Note that some proxies disable WebSocket support by default. For example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled. Note that some proxies disable WebSocket support by default. For example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled.
Frigate rejects WebSocket connections opened from a page on another site. Your proxy must either preserve the original `Host` header or send it in `X-Forwarded-Host`, otherwise Frigate cannot tell the connection came from its own UI.
## Proxies ## Proxies
There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page. There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page.
@@ -163,7 +161,6 @@ The settings below enabled connection upgrade, sets up logging (optional) and pr
error_log /data/logs/proxy-host-40_error.log warn; error_log /data/logs/proxy-host-40_error.log warn;
location / { location / {
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection; proxy_set_header Connection $http_connection;
proxy_http_version 1.1; proxy_http_version 1.1;
-4
View File
@@ -55,10 +55,6 @@ To install the optional companion Lovelace card, please see the [separate
installation instructions](https://github.com/dermotduffy/frigate-hass-card) for installation instructions](https://github.com/dermotduffy/frigate-hass-card) for
that card. that card.
Frigate rejects browser connections to its live streams that come from another
origin, such as a Home Assistant dashboard. Configure cards to stream through the
integration's proxy rather than pointing them directly at Frigate's port.
## Configuration ## Configuration
When configuring the integration, you will be asked for the `URL` of your Frigate instance which can be pointed at the internal unauthenticated port (`5000`) or the authenticated port (`8971`) for your instance. This may look like `http://<host>:5000/`. When configuring the integration, you will be asked for the `URL` of your Frigate instance which can be pointed at the internal unauthenticated port (`5000`) or the authenticated port (`8971`) for your instance. This may look like `http://<host>:5000/`.
+172 -33
View File
@@ -61,6 +61,7 @@ from frigate.util.builtin import (
clean_camera_user_pass, clean_camera_user_pass,
deep_merge, deep_merge,
flatten_config_data, flatten_config_data,
is_masked_camera_path,
load_labels, load_labels,
process_config_query_string, process_config_query_string,
split_config_key_path, split_config_key_path,
@@ -71,6 +72,7 @@ from frigate.util.config import (
find_config_file, find_config_file,
redact_credential, redact_credential,
) )
from frigate.util.env import substitute_frigate_vars
from frigate.util.live_streams import ( from frigate.util.live_streams import (
generated_transcode_streams, generated_transcode_streams,
sync_transcode_streams, sync_transcode_streams,
@@ -402,6 +404,8 @@ def config(request: Request):
config["go2rtc"]["streams"][stream_name] = cleaned config["go2rtc"]["streams"][stream_name] = cleaned
_redact_go2rtc_credentials(config["go2rtc"])
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()} config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
for index, model in enumerate(config_obj.models): for index, model in enumerate(config_obj.models):
@@ -428,6 +432,28 @@ def config(request: Request):
return JSONResponse(content=config) return JSONResponse(content=config)
def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None:
"""Redact go2rtc secrets outside of stream URLs, in place."""
for section in ("rtsp", "api", "mqtt"):
if isinstance(go2rtc.get(section), dict):
redact_credential(go2rtc[section], "username")
redact_credential(go2rtc[section], "password")
if isinstance(go2rtc.get("ngrok"), dict):
redact_credential(go2rtc["ngrok"], "command")
if isinstance(go2rtc.get("homekit"), dict):
for accessory in go2rtc["homekit"].values():
if isinstance(accessory, dict):
for key in ("pin", "device_private", "pairings"):
redact_credential(accessory, key)
# publish destinations carry stream keys in the URL path
if isinstance(go2rtc.get("publish"), dict):
for name in list(go2rtc["publish"]):
redact_credential(go2rtc["publish"], name)
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())]) @router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
def get_profiles(request: Request): def get_profiles(request: Request):
"""List all available profiles and the currently active profile.""" """List all available profiles and the currently active profile."""
@@ -742,17 +768,24 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
) )
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None: def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
"""Substitute incoming `*:*` masked credentials with the in-memory ones. """Substitute incoming masked camera paths with the saved ones.
The /config response masks ffmpeg input credentials, so the settings UI The /config response masks ffmpeg input credentials, so the settings UI
sends the masked path back when sibling fields (e.g. hwaccel_args) are sends the masked path back when sibling fields (e.g. hwaccel_args) are
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose edited. Each masked path is matched to the saved input that masks to the
the real credentials. Mutates `config_data` in place. same value, with roles breaking a tie, so deleting or reordering inputs
keeps each path's own credentials. The saved path is restored as written,
keeping any {FRIGATE_*} placeholder. Mutates `config_data` in place and
returns an error message when a masked path has no single match.
""" """
cameras = config_data.get("cameras") cameras = config_data.get("cameras")
if not isinstance(cameras, dict): if not isinstance(cameras, dict):
return return None
saved_cameras = saved.get("cameras")
if not isinstance(saved_cameras, dict):
saved_cameras = {}
for camera_name, camera_data in cameras.items(): for camera_name, camera_data in cameras.items():
if not isinstance(camera_data, dict): if not isinstance(camera_data, dict):
@@ -760,20 +793,122 @@ def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> No
inputs = camera_data.get("ffmpeg", {}).get("inputs") inputs = camera_data.get("ffmpeg", {}).get("inputs")
if not isinstance(inputs, list): if not isinstance(inputs, list):
continue continue
existing = config.cameras.get(camera_name)
if existing is None: saved_camera = saved_cameras.get(camera_name)
continue saved_ffmpeg = (
existing_paths = [inp.path for inp in existing.ffmpeg.inputs] saved_camera.get("ffmpeg") if isinstance(saved_camera, dict) else None
)
saved_inputs = [
inp
for inp in (
saved_ffmpeg.get("inputs") if isinstance(saved_ffmpeg, dict) else None
)
or []
if isinstance(inp, dict) and isinstance(inp.get("path"), str)
]
for index, input_obj in enumerate(inputs): for index, input_obj in enumerate(inputs):
if not isinstance(input_obj, dict): if not isinstance(input_obj, dict):
continue continue
path = input_obj.get("path") path = input_obj.get("path")
if not isinstance(path, str): if not isinstance(path, str) or not is_masked_camera_path(path):
continue continue
if ("://*:*@" in path or "user=*&password=*" in path) and index < len( candidates = [
existing_paths inp
): for inp in saved_inputs
input_obj["path"] = existing_paths[index] if clean_camera_user_pass(_resolve_frigate_vars(inp["path"])) == path
]
if len({inp["path"] for inp in candidates}) > 1:
candidates = [
inp
for inp in candidates
if inp.get("roles") == input_obj.get("roles")
]
real_paths = {inp["path"] for inp in candidates}
if len(real_paths) != 1:
return (
f"Input {index} of camera {camera_name} has masked credentials "
"that could not be matched; resend it with real credentials"
)
input_obj["path"] = real_paths.pop()
return None
def _resolve_frigate_vars(value: str) -> str:
"""Resolve {FRIGATE_*} placeholders the way /config shows them."""
try:
return substitute_frigate_vars(value)
except ValueError:
return value
def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]:
"""Shape the in-memory camera inputs like the saved YAML."""
return {
"cameras": {
name: {
"ffmpeg": {
"inputs": [
{"path": inp.path, "roles": [role.value for role in inp.roles]}
for inp in camera.ffmpeg.inputs
]
}
}
for name, camera in config.cameras.items()
},
}
def _config_data_to_updates(
config_data: dict, saved: dict[str, Any]
) -> tuple[dict[str, Any], str | None]:
"""Flatten a /config/set payload after restoring masked credentials.
`saved` is the config the masked values were taken from. Returns the flat
updates, or an error message when a masked value can't be restored.
"""
error = _restore_masked_camera_paths(config_data, saved)
if error:
return {}, error
updates = flatten_config_data(config_data)
# Convert None values to empty strings for deletion (e.g., when deleting masks)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
# Drop sentinel-valued fields so untouched credential placeholders don't
# clobber the saved YAML value.
updates = {k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL}
error = _find_masked_update(updates)
if error:
return {}, error
return updates, None
def _holds_masked_value(value: Any) -> bool:
if isinstance(value, str):
return REDACTED_CREDENTIAL_SENTINEL in value or is_masked_camera_path(value)
if isinstance(value, dict):
return any(_holds_masked_value(v) for v in value.values())
if isinstance(value, list):
return any(_holds_masked_value(v) for v in value)
return False
def _find_masked_update(updates: dict[str, Any]) -> str | None:
"""Return an error naming the first update that still holds a masked value.
Runs after the restores so a masked path or redaction sentinel that could
not be restored is never written over the real credential.
"""
for key, value in updates.items():
if _holds_masked_value(value):
return (
f"{key} contains masked or saved credentials; "
"resend it with real values"
)
return None
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse: def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
@@ -786,13 +921,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
try: try:
updates = {} updates = {}
if body.config_data: if body.config_data:
_restore_masked_camera_paths(body.config_data, request.app.frigate_config) updates, error = _config_data_to_updates(
updates = flatten_config_data(body.config_data) body.config_data, _saved_config_from_memory(request.app.frigate_config)
updates = {k: ("" if v is None else v) for k, v in updates.items()} )
# Drop any field whose value is still the redaction sentinel if error:
updates = { return JSONResponse(
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL content={"success": False, "message": error}, status_code=400
} )
if not updates: if not updates:
return JSONResponse( return JSONResponse(
@@ -939,22 +1074,26 @@ def config_set(request: Request, body: AppConfigSetBody):
# Filter out empty keys but keep blank values for non-empty keys # Filter out empty keys but keep blank values for non-empty keys
query_string = {k: v for k, v in query_string.items() if k} query_string = {k: v for k, v in query_string.items() if k}
error = None
if query_string: if query_string:
updates = process_config_query_string(query_string) updates = process_config_query_string(query_string)
error = _find_masked_update(updates)
elif body.config_data: elif body.config_data:
_restore_masked_camera_paths( try:
body.config_data, request.app.frigate_config saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config)
except ruamel.yaml.YAMLError:
saved_config = None
updates, error = _config_data_to_updates(
body.config_data,
saved_config if isinstance(saved_config, dict) else {},
)
if error:
return JSONResponse(
content={"success": False, "message": error},
status_code=400,
) )
updates = flatten_config_data(body.config_data)
# Convert None values to empty strings for deletion (e.g., when deleting masks)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
# Drop sentinel-valued fields so untouched credential
# placeholders don't clobber the saved YAML value.
updates = {
k: v
for k, v in updates.items()
if v != REDACTED_CREDENTIAL_SENTINEL
}
if not updates: if not updates:
return JSONResponse( return JSONResponse(
+61 -65
View File
@@ -39,7 +39,6 @@ from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User from frigate.models import User
from frigate.notices import raise_notice from frigate.notices import raise_notice
from frigate.util.origin import origin_allowed
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -472,8 +471,37 @@ def create_encoded_jwt(user, role, expiration, secret):
) )
def _user_session(user: User) -> tuple[str, int | None]:
changed_at = user.password_changed_at
# floored like iat, so a login in the same second as the change stays valid
return user.role, int(changed_at.timestamp()) if changed_at else None
def load_user_sessions() -> dict[str, tuple[str, int | None]]:
"""Map each username to its role and password change time for /auth."""
return {user.username: _user_session(user) for user in User.select()}
def refresh_user_session(request: Request, username: str) -> None:
"""Sync the /auth session map after a user is created, changed or deleted."""
user = User.get_or_none(User.username == username)
if user is None:
request.app.user_sessions.pop(username, None)
else:
request.app.user_sessions[username] = _user_session(user)
def is_secure_request(request: Request) -> bool:
"""Check the cookie setting and nginx's forwarded scheme for HTTPS."""
# nginx sets X-Forwarded-Proto to its own scheme, replacing any client value
return (
request.app.frigate_config.auth.cookie_secure
or request.headers.get("x-forwarded-proto") == "https"
)
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure): def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
# TODO: ideally this would set secure as well, but that requires TLS
# Starlette sets SameSite=Lax by default. The cookie is still sent to # Starlette sets SameSite=Lax by default. The cookie is still sent to
# same-site iframes (e.g. Home Assistant on the same host or domain), but # same-site iframes (e.g. Home Assistant on the same host or domain), but
# not to cross-site ones. CSRF is also mitigated by requiring a custom # not to cross-site ones. CSRF is also mitigated by requiring a custom
@@ -690,17 +718,6 @@ def auth(request: Request):
success_response = Response("", status_code=202) success_response = Response("", status_code=202)
original_url = request.headers.get("x-original-url")
if _is_cross_origin_stream_request(original_url, request):
logger.warning(
"Rejected cross-origin request: origin=%s, host=%s, x-forwarded-host=%s",
request.headers.get("origin"),
request.headers.get("x-original-host"),
request.headers.get("x-forwarded-host"),
)
return Response("", status_code=403)
# dont require auth if the request is on the internal port # dont require auth if the request is on the internal port
# this header is set by Frigate's nginx proxy, so it cant be spoofed. # this header is set by Frigate's nginx proxy, so it cant be spoofed.
# the port is the boot-time snapshot rather than the live config value: # the port is the boot-time snapshot rather than the live config value:
@@ -724,6 +741,7 @@ def auth(request: Request):
logger.debug("X-Proxy-Secret header does not match configured secret value") logger.debug("X-Proxy-Secret header does not match configured secret value")
return fail_response return fail_response
original_url = request.headers.get("x-original-url")
frigate_config = request.app.frigate_config frigate_config = request.app.frigate_config
# if auth is disabled, just apply the proxy header map and return success # if auth is disabled, just apply the proxy header map and return success
@@ -761,7 +779,6 @@ def auth(request: Request):
fail_response.headers["location"] = "/login" fail_response.headers["location"] = "/login"
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_REFRESH = request.app.frigate_config.auth.refresh_time JWT_REFRESH = request.app.frigate_config.auth.refresh_time
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
@@ -787,20 +804,27 @@ def auth(request: Request):
if "sub" not in token.claims: if "sub" not in token.claims:
logger.debug("user not set in jwt token") logger.debug("user not set in jwt token")
return fail_response return fail_response
if "role" not in token.claims:
logger.debug("role not set in jwt token")
return fail_response
if "exp" not in token.claims: if "exp" not in token.claims:
logger.debug("exp not set in jwt token") logger.debug("exp not set in jwt token")
return fail_response return fail_response
user = token.claims.get("sub") user = token.claims.get("sub")
role = token.claims.get("role") session = request.app.user_sessions.get(user)
if session is None:
logger.debug("jwt user %s no longer exists", user)
return fail_response
role, password_changed_at = session
if password_changed_at is not None and (
int(token.claims.get("iat", 0)) < password_changed_at
):
logger.debug("jwt token issued before password change")
return fail_response
# the token keeps the role it was issued with, so a role removed from
# the config since then must send the user back through login
if role not in auth_config.roles: if role not in auth_config.roles:
logger.debug("jwt role %s is not in the config", role) logger.debug(
"jwt user %s has role %s, which is not in the config", user, role
)
return fail_response return fail_response
current_time = int(time.time()) current_time = int(time.time())
@@ -823,25 +847,6 @@ def auth(request: Request):
# if the jwt cookie is expiring soon # if the jwt cookie is expiring soon
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time: if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
logger.debug("jwt token expiring soon, refreshing cookie") logger.debug("jwt token expiring soon, refreshing cookie")
# Check if password has been changed since token was issued
# If so, force re-login by rejecting the refresh
try:
user_obj = User.get_by_id(user)
if user_obj.password_changed_at is not None:
token_iat = int(token.claims.get("iat", 0))
password_changed_timestamp = int(
user_obj.password_changed_at.timestamp()
)
if token_iat < password_changed_timestamp:
logger.debug(
"jwt token issued before password change, rejecting refresh"
)
return fail_response
except DoesNotExist:
logger.debug("user not found")
return fail_response
new_expiration = current_time + JWT_SESSION_LENGTH new_expiration = current_time + JWT_SESSION_LENGTH
new_encoded_jwt = create_encoded_jwt( new_encoded_jwt = create_encoded_jwt(
user, role, new_expiration, request.app.jwt_token user, role, new_expiration, request.app.jwt_token
@@ -851,7 +856,7 @@ def auth(request: Request):
JWT_COOKIE_NAME, JWT_COOKIE_NAME,
new_encoded_jwt, new_encoded_jwt,
JWT_SESSION_LENGTH, JWT_SESSION_LENGTH,
JWT_COOKIE_SECURE, is_secure_request(request),
) )
success_response.headers["remote-user"] = user success_response.headers["remote-user"] = user
@@ -940,7 +945,6 @@ def login(request: Request, body: AppPostLoginBody):
) )
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
user = body.user user = body.user
password = body.password password = body.password
@@ -956,13 +960,16 @@ def login(request: Request, body: AppPostLoginBody):
password_hash = db_user.password_hash password_hash = db_user.password_hash
if verify_password(password, password_hash): if verify_password(password, password_hash):
role = getattr(db_user, "role", "viewer") role = db_user.role
config_roles_set = set(request.app.frigate_config.auth.roles.keys()) if role not in request.app.frigate_config.auth.roles:
if role not in config_roles_set:
logger.warning( logger.warning(
f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'." "Login refused for user '%s' with role '%s', which is not in the config",
user,
role,
) )
role = "viewer" failed_logins.record(user, time.time(), known=True)
return JSONResponse(content={"message": "Login failed"}, status_code=401)
expiration = int(time.time()) + JWT_SESSION_LENGTH expiration = int(time.time()) + JWT_SESSION_LENGTH
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token) encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
response = Response("", 200) response = Response("", 200)
@@ -971,7 +978,7 @@ def login(request: Request, body: AppPostLoginBody):
JWT_COOKIE_NAME, JWT_COOKIE_NAME,
encoded_jwt, encoded_jwt,
JWT_SESSION_LENGTH, JWT_SESSION_LENGTH,
JWT_COOKIE_SECURE, is_secure_request(request),
) )
# Clear admin_first_time_login flag after successful admin login so the # Clear admin_first_time_login flag after successful admin login so the
# UI stops showing the first-time login documentation link. # UI stops showing the first-time login documentation link.
@@ -1037,9 +1044,11 @@ def create_user(
User.username: body.username, User.username: body.username,
User.password_hash: password_hash, User.password_hash: password_hash,
User.role: role, User.role: role,
User.password_changed_at: datetime.now(),
User.notification_tokens: [], User.notification_tokens: [],
} }
).execute() ).execute()
refresh_user_session(request, body.username)
request.app.config_publisher.publisher.publish("config/auth", None) request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"username": body.username}) return JSONResponse(content={"username": body.username})
@@ -1058,6 +1067,7 @@ def delete_user(request: Request, username: str):
) )
User.delete_by_id(username) User.delete_by_id(username)
refresh_user_session(request, username)
request.app.config_publisher.publisher.publish("config/auth", None) request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"success": True}) return JSONResponse(content={"success": True})
@@ -1125,13 +1135,13 @@ async def update_password(
User.password_changed_at: datetime.now(), User.password_changed_at: datetime.now(),
} }
).where(User.username == username).execute() ).where(User.username == username).execute()
refresh_user_session(request, username)
response = JSONResponse(content={"success": True}) response = JSONResponse(content={"success": True})
# If user changed their own password, issue a new JWT to keep them logged in # If user changed their own password, issue a new JWT to keep them logged in
if current_username == username: if current_username == username:
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
expiration = int(time.time()) + JWT_SESSION_LENGTH expiration = int(time.time()) + JWT_SESSION_LENGTH
@@ -1144,7 +1154,7 @@ async def update_password(
JWT_COOKIE_NAME, JWT_COOKIE_NAME,
encoded_jwt, encoded_jwt,
JWT_SESSION_LENGTH, JWT_SESSION_LENGTH,
JWT_COOKIE_SECURE, is_secure_request(request),
) )
return response return response
@@ -1184,6 +1194,7 @@ async def update_role(
) )
User.set_by_id(username, {User.role: body.role}) User.set_by_id(username, {User.role: body.role})
refresh_user_session(request, username)
request.app.config_publisher.publisher.publish("config/auth", None) request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"success": True}) return JSONResponse(content={"success": True})
@@ -1296,21 +1307,6 @@ def _is_go2rtc_stream_proxy_request(path: str, request: Request) -> bool:
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS) return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
def _is_cross_origin_stream_request(original_url: str | None, request: Request) -> bool:
# the internal port grants admin, so a browser on another site must be
# stopped before that early return
upgrade = request.headers.get("x-original-upgrade", "").lower()
path = split_request_uri(original_url or "")[0]
if upgrade != "websocket" and not _is_go2rtc_stream_proxy_request(path, request):
return False
return not origin_allowed(
request.headers.get("origin"),
request.headers.get("x-original-host"),
request.headers.get("x-forwarded-host"),
)
def deny_response_for_go2rtc_stream( def deny_response_for_go2rtc_stream(
original_url: str | None, role: str | None, request: Request original_url: str | None, role: str | None, request: Request
) -> int | None: ) -> int | None:
+2
View File
@@ -196,7 +196,9 @@ def create_fastapi_app(
key_bytes = str(secret).encode("utf-8") key_bytes = str(secret).encode("utf-8")
app.jwt_token = OctKey.import_key(key_bytes) app.jwt_token = OctKey.import_key(key_bytes)
app.user_sessions = auth.load_user_sessions()
else: else:
app.jwt_token = None app.jwt_token = None
app.user_sessions = {}
return app return app
+46 -12
View File
@@ -9,7 +9,10 @@ from pydantic import BaseModel, Field
from frigate.api.auth import require_camera_access from frigate.api.auth import require_camera_access
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig
from frigate.jobs.motion_search import ( from frigate.jobs.motion_search import (
MotionSearchCapacityError,
MotionSearchUserBusyError,
cancel_motion_search_job, cancel_motion_search_job,
get_motion_search_job, get_motion_search_job,
start_motion_search_job, start_motion_search_job,
@@ -95,6 +98,20 @@ class MotionSearchStatusResponse(BaseModel):
progress: float | None = None progress: float | None = None
def _per_user_identity(request: Request, config: FrigateConfig) -> str | None:
"""Return the requesting user, or None when callers share one identity.
Internal-port requests are all "anonymous", and with auth disabled and no
proxy user header every browser is "viewer".
"""
username = request.headers.get("remote-user")
if not username or username == "anonymous":
return None
if config.auth.enabled or config.proxy.header_map.user:
return username
return None
@router.post( @router.post(
"/{camera_name}/search/motion", "/{camera_name}/search/motion",
response_model=MotionSearchStartResponse, response_model=MotionSearchStartResponse,
@@ -138,18 +155,35 @@ async def start_motion_search(
status_code=400, status_code=400,
) )
# Start the job using the jobs module try:
job_id = start_motion_search_job( job_id = start_motion_search_job(
config=config, config=config,
camera_name=camera_name, camera_name=camera_name,
start_time=body.start_time, start_time=body.start_time,
end_time=body.end_time, end_time=body.end_time,
polygon_points=body.polygon_points, polygon_points=body.polygon_points,
threshold=body.threshold, threshold=body.threshold,
min_area=body.min_area, min_area=body.min_area,
parallel=body.parallel, parallel=body.parallel,
max_results=body.max_results, max_results=body.max_results,
) username=_per_user_identity(request, config),
)
except MotionSearchUserBusyError:
return JSONResponse(
content={
"success": False,
"message": "A motion search is already running for this user",
},
status_code=409,
)
except MotionSearchCapacityError:
return JSONResponse(
content={
"success": False,
"message": "Too many motion searches are running",
},
status_code=429,
)
return JSONResponse( return JSONResponse(
content={ content={
+2 -66
View File
@@ -1,10 +1,8 @@
"""Notification apis.""" """Notification apis."""
import ipaddress
import logging import logging
import os import os
from typing import Any from typing import Any
from urllib.parse import urlparse
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
@@ -13,6 +11,7 @@ from py_vapid import Vapid01, utils
from frigate.api.auth import allow_any_authenticated from frigate.api.auth import allow_any_authenticated
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.comms.webpush import validate_push_endpoint
from frigate.const import CONFIG_DIR from frigate.const import CONFIG_DIR
from frigate.models import User from frigate.models import User
@@ -20,76 +19,13 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.notifications]) router = APIRouter(tags=[Tags.notifications])
# Push endpoints are opaque URLs but stay well under this in practice
MAX_ENDPOINT_LENGTH = 2048
# Suffixes that only ever resolve on the local network
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
def _validate_push_endpoint(endpoint: Any) -> str | None:
"""Return a reason the endpoint is unusable, or None when it is valid.
Subscriptions are issued by the browser vendor's push service, so a valid
endpoint is always a public https URL. Anything else is either a broken
registration or an attempt to aim the notification sender somewhere it
should not reach.
"""
if not isinstance(endpoint, str) or not endpoint:
return "endpoint must be a url"
if len(endpoint) > MAX_ENDPOINT_LENGTH:
return "endpoint is too long"
try:
parsed = urlparse(endpoint)
port = parsed.port
except ValueError:
return "endpoint is not a valid url"
if parsed.scheme != "https":
return "endpoint must use https"
if parsed.username or parsed.password:
return "endpoint must not include credentials"
if port is not None and port != 443:
return "endpoint must use the default https port"
hostname = parsed.hostname
if not hostname:
return "endpoint must include a hostname"
try:
address = ipaddress.ip_address(hostname)
except ValueError:
address = None
if address is not None:
# A push service is never reachable at an address only this network can
# route, so anything non-global is a misconfiguration at best
if not address.is_global:
return "endpoint must not use a private address"
elif hostname == "localhost" or "." not in hostname:
return "endpoint must use a fully qualified hostname"
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
return "endpoint must not use an internal hostname"
# The subscription token lives in the path, and webpush.py assumes there is
# a separator after the host when it builds the VAPID audience
if len(parsed.path) <= 1:
return "endpoint must include a subscription path"
return None
def _validate_subscription(sub: Any) -> str | None: def _validate_subscription(sub: Any) -> str | None:
"""Return a reason the subscription is unusable, or None when it is valid.""" """Return a reason the subscription is unusable, or None when it is valid."""
if not isinstance(sub, dict): if not isinstance(sub, dict):
return "subscription must be an object" return "subscription must be an object"
reason = _validate_push_endpoint(sub.get("endpoint")) reason = validate_push_endpoint(sub.get("endpoint"))
if reason: if reason:
return reason return reason
+1
View File
@@ -622,6 +622,7 @@ class FrigateApp:
username="admin", username="admin",
role="admin", role="admin",
password_hash=password_hash, password_hash=password_hash,
password_changed_at=datetime.datetime.now(),
notification_tokens=[], notification_tokens=[],
).execute() ).execute()
+12 -2
View File
@@ -490,7 +490,12 @@ class Dispatcher:
def publish(self, topic: str, payload: Any, retain: bool = False) -> None: def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Handle publishing to communicators.""" """Handle publishing to communicators."""
for comm in self.comms: for comm in self.comms:
comm.publish(topic, payload, retain) try:
comm.publish(topic, payload, retain)
except Exception:
logger.exception(
"Error publishing %s to %s", topic, type(comm).__name__
)
def publish_local(self, topic: str, payload: Any) -> None: def publish_local(self, topic: str, payload: Any) -> None:
"""Publish to every communicator except MQTT. """Publish to every communicator except MQTT.
@@ -501,7 +506,12 @@ class Dispatcher:
if isinstance(comm, MqttClient): if isinstance(comm, MqttClient):
continue continue
comm.publish(topic, payload, False) try:
comm.publish(topic, payload, False)
except Exception:
logger.exception(
"Error publishing %s to %s", topic, type(comm).__name__
)
def _publish_notices(self) -> None: def _publish_notices(self) -> None:
if self.notice_registry is None: if self.notice_registry is None:
+7 -1
View File
@@ -50,7 +50,13 @@ class InterProcessCommunicator(Communicator):
if isinstance(raw, list): if isinstance(raw, list):
(topic, value) = raw (topic, value) = raw
response = self._dispatcher(topic, value)
# REP owes a reply, so a handler error must not end the thread
try:
response = self._dispatcher(topic, value)
except Exception:
logger.exception("Error handling IPC topic %s", topic)
response = None
else: else:
logging.warning( logging.warning(
f"Received unexpected data type in ZMQ recv_json: {type(raw)}" f"Received unexpected data type in ZMQ recv_json: {type(raw)}"
+117 -5
View File
@@ -1,6 +1,7 @@
"""Handle sending notifications for Frigate via Firebase.""" """Handle sending notifications for Frigate via Firebase."""
import datetime import datetime
import ipaddress
import json import json
import logging import logging
import os import os
@@ -10,7 +11,9 @@ from collections.abc import Callable, Iterator
from dataclasses import dataclass from dataclasses import dataclass
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from typing import Any from typing import Any
from urllib.parse import urlparse
import requests
from py_vapid import Vapid01 from py_vapid import Vapid01
from pywebpush import WebPusher from pywebpush import WebPusher
from titlecase import titlecase from titlecase import titlecase
@@ -41,14 +44,124 @@ class PushNotification:
ttl: int = 0 ttl: int = 0
# Push endpoints are opaque URLs but stay well under this in practice
MAX_ENDPOINT_LENGTH = 2048
# Suffixes that only ever resolve on the local network
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
def validate_push_endpoint(endpoint: Any) -> str | None:
"""Return a reason the endpoint is unusable, or None when it is valid.
Subscriptions are issued by the browser vendor's push service, so a valid
endpoint is always a public https URL. Anything else is either a broken
registration or an attempt to aim the notification sender somewhere it
should not reach.
"""
if not isinstance(endpoint, str) or not endpoint:
return "endpoint must be a url"
if len(endpoint) > MAX_ENDPOINT_LENGTH:
return "endpoint is too long"
try:
parsed = urlparse(endpoint)
port = parsed.port
except ValueError:
return "endpoint is not a valid url"
if parsed.scheme != "https":
return "endpoint must use https"
if parsed.username or parsed.password:
return "endpoint must not include credentials"
if port is not None and port != 443:
return "endpoint must use the default https port"
hostname = parsed.hostname
if not hostname:
return "endpoint must include a hostname"
try:
address = ipaddress.ip_address(hostname)
except ValueError:
address = None
if address is not None:
# A push service is never reachable at an address only this network can
# route, so anything non-global is a misconfiguration at best
if not address.is_global:
return "endpoint must not use a private address"
elif hostname == "localhost" or "." not in hostname:
return "endpoint must use a fully qualified hostname"
elif "" in hostname.split("."):
return "endpoint hostname must not contain empty labels"
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
return "endpoint must not use an internal hostname"
# The subscription token lives in the path
if len(parsed.path) <= 1:
return "endpoint must include a subscription path"
return None
class _NoRedirectSession(requests.Session):
"""Session that never follows redirects.
A push endpoint could otherwise redirect the send to an internal URL.
"""
def request(self, *args: Any, **kwargs: Any) -> requests.Response:
kwargs["allow_redirects"] = False
return super().request(*args, **kwargs)
_push_session = _NoRedirectSession()
def _vapid_audience(endpoint: str) -> str:
parsed = urlparse(endpoint)
return f"{parsed.scheme}://{parsed.netloc}"
def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]: def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]:
"""Build pushers for a user's stored subscriptions, skipping unusable ones.""" """Build pushers for a user's stored subscriptions, skipping unusable ones."""
pushers: list[WebPusher] = [] pushers: list[WebPusher] = []
for sub in subs: for sub in subs:
# Stored rows may predate registration validation, so only skip the
# endpoints that would break building the VAPID audience
endpoint = sub.get("endpoint") if isinstance(sub, dict) else None
if not isinstance(endpoint, str):
logger.warning(
"Skipping notification subscription for %s: no endpoint", user
)
continue
try:
hostname = urlparse(endpoint).hostname or ""
except ValueError:
logger.warning(
"Skipping notification subscription for %s: unparsable endpoint", user
)
continue
if "" in hostname.split("."):
logger.warning(
"Skipping notification subscription for %s: invalid endpoint host %r",
user,
hostname,
)
continue
# WebPusher decodes the stored keys and raises on malformed ones # WebPusher decodes the stored keys and raises on malformed ones
try: try:
pushers.append(WebPusher(sub)) pushers.append(WebPusher(sub, requests_session=_push_session))
except Exception: except Exception:
logger.warning("Skipping invalid notification subscription for %s", user) logger.warning("Skipping invalid notification subscription for %s", user)
@@ -131,7 +244,7 @@ class WebPushClient(Communicator):
for pushers in self.web_pushers.values(): for pushers in self.web_pushers.values():
for push in pushers: for push in pushers:
endpoint: str = push.subscription_info["endpoint"] endpoint: str = push.subscription_info["endpoint"]
endpoints.add(endpoint[0 : endpoint.index("/", 10)]) endpoints.add(_vapid_audience(endpoint))
# create new claim # create new claim
for endpoint in endpoints: for endpoint in endpoints:
@@ -320,9 +433,8 @@ class WebPushClient(Communicator):
for pusher in self.web_pushers[notification.user]: for pusher in self.web_pushers[notification.user]:
endpoint = pusher.subscription_info["endpoint"] endpoint = pusher.subscription_info["endpoint"]
headers = self.claim_headers[
endpoint[: endpoint.index("/", 10)] headers = self.claim_headers[_vapid_audience(endpoint)].copy()
].copy()
headers["urgency"] = "high" headers["urgency"] = "high"
resp = pusher.send( resp = pusher.send(
+1 -24
View File
@@ -40,7 +40,6 @@ from frigate.const import (
) )
from frigate.models import User from frigate.models import User
from frigate.output.ws_auth import ws_has_camera_access from frigate.output.ws_auth import ws_has_camera_access
from frigate.util.origin import origin_allowed
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -473,28 +472,6 @@ def _materialize_for_ws(
return None return None
class OriginCheckedWebSocketApplication(WebSocketWSGIApplication): # type: ignore[misc]
"""ws4py application that rejects cross-origin browser handshakes."""
def __call__(self, environ: dict[str, Any], start_response: Callable) -> Any:
if not origin_allowed(
environ.get("HTTP_ORIGIN"),
environ.get("HTTP_HOST"),
environ.get("HTTP_X_FORWARDED_HOST"),
):
logger.warning(
"Rejected cross-origin websocket handshake: origin=%s, host=%s, "
"x-forwarded-host=%s",
environ.get("HTTP_ORIGIN"),
environ.get("HTTP_HOST"),
environ.get("HTTP_X_FORWARDED_HOST"),
)
start_response("403 Forbidden", [("Content-Length", "0")])
return []
return super().__call__(environ, start_response)
class WebSocket(WebSocket_): # type: ignore[misc] class WebSocket(WebSocket_): # type: ignore[misc]
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -644,7 +621,7 @@ class WebSocketClient(Communicator):
5002, 5002,
server_class=WSGIServer, server_class=WSGIServer,
handler_class=WebSocketWSGIRequestHandler, handler_class=WebSocketWSGIRequestHandler,
app=OriginCheckedWebSocketApplication(handler_cls=_WebSocketHandler), app=WebSocketWSGIApplication(handler_cls=_WebSocketHandler),
) )
self.websocket_server.initialize_websockets_manager() self.websocket_server.initialize_websockets_manager()
self.websocket_thread = threading.Thread( self.websocket_thread = threading.Thread(
+1 -1
View File
@@ -27,7 +27,7 @@ class AuthConfig(FrigateBaseModel):
cookie_secure: bool = Field( cookie_secure: bool = Field(
default=False, default=False,
title="Secure cookie flag", title="Secure cookie flag",
description="Set the secure flag on the auth cookie; should be true when using TLS.", description="Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.",
) )
session_length: int = Field( session_length: int = Field(
default=86400, default=86400,
+12 -11
View File
@@ -1,18 +1,12 @@
from pydantic import Field, model_validator from pydantic import Field, field_validator, model_validator
from frigate.util.config import parse_listen_address
from .base import FrigateBaseModel from .base import FrigateBaseModel
__all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"] __all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"]
def parse_listen_port(value: int | str) -> int:
"""Return the port number from a bare port or an "address:port" value."""
if isinstance(value, str):
return int(value.split(":")[-1])
return value
class IPv6Config(FrigateBaseModel): class IPv6Config(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
@@ -33,13 +27,20 @@ class ListenConfig(FrigateBaseModel):
description="External listening port for Frigate (default 8971).", description="External listening port for Frigate (default 8971).",
) )
# Runs before pydantic coerces a bool to an int port, which
# get_nginx_settings.py rejects when it reads the raw YAML
@field_validator("internal", "external", mode="before")
@classmethod
def validate_listen(cls, value: int | str) -> int | str:
return parse_listen_address(value)[0]
@property @property
def internal_port(self) -> int: def internal_port(self) -> int:
return parse_listen_port(self.internal) return parse_listen_address(self.internal)[1]
@property @property
def external_port(self) -> int: def external_port(self) -> int:
return parse_listen_port(self.external) return parse_listen_address(self.external)[1]
@model_validator(mode="after") @model_validator(mode="after")
def validate_distinct_ports(self) -> "ListenConfig": def validate_distinct_ports(self) -> "ListenConfig":
+13 -1
View File
@@ -117,7 +117,19 @@ SUPPORTED_RK_SOCS = ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"]
REGEX_CAMERA_NAME = r"^[a-zA-Z0-9_-]+$" REGEX_CAMERA_NAME = r"^[a-zA-Z0-9_-]+$"
REGEX_RTSP_CAMERA_USER_PASS = r":\/\/[a-zA-Z0-9_-]+:[\S]+@" REGEX_RTSP_CAMERA_USER_PASS = r":\/\/[a-zA-Z0-9_-]+:[\S]+@"
REGEX_HTTP_CAMERA_USER_PASS = r"user=[a-zA-Z0-9_-]+&password=[\S]+" # A "user:" userinfo may hold "/", "#" or "@" in the password, so it runs to the
# last "@" before the next URL. Anything else stops at the authority end. Group 1
# is the userinfo and group 2 the rest of the URL.
REGEX_URL_USERINFO = (
r"://((?:[^\s/?#:\[\]]*:(?:(?!://)\S)*|[^\s/?#]+))@((?:(?!://)\S)*)"
)
_CREDENTIAL_QUERY_KEYS = (
r"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd"
r"|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)"
)
REGEX_CREDENTIAL_QUERY_KEY = rf"(?i)[?&]{_CREDENTIAL_QUERY_KEYS}="
REGEX_CREDENTIAL_QUERY_PARAM = rf"(?i)([?&]{_CREDENTIAL_QUERY_KEYS}=)[^&\s]*"
REGEX_MASKED_CREDENTIAL = rf"(?i)://\*:\*@|[?&]{_CREDENTIAL_QUERY_KEYS}=\*(?![^&\s])"
REGEX_JSON = re.compile(r"^\s*\{") REGEX_JSON = re.compile(r"^\s*\{")
# Known Driver Names # Known Driver Names
+71 -11
View File
@@ -10,6 +10,7 @@ from typing import Any
import numpy as np import numpy as np
import onnxruntime as ort import onnxruntime as ort
from frigate.const import MODEL_CACHE_DIR
from frigate.util.model import get_ort_providers from frigate.util.model import get_ort_providers
from frigate.util.rknn_converter import auto_convert_model, is_rknn_compatible from frigate.util.rknn_converter import auto_convert_model, is_rknn_compatible
@@ -55,6 +56,9 @@ _PROVIDER_LABELS = {
LIGHTER_ANE_EP_NAME = "LighterANE" LIGHTER_ANE_EP_NAME = "LighterANE"
LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so" LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so"
# AMD's MIGraphX plugin execution provider, installed in the ROCm image
MIGRAPHX_EP_NAME = "MIGraphXExecutionProvider"
def get_lighter_ane_devices() -> list[Any]: def get_lighter_ane_devices() -> list[Any]:
"""Get the Neural Engine devices lighter's provider offers, registering it once. """Get the Neural Engine devices lighter's provider offers, registering it once.
@@ -83,6 +87,42 @@ def get_lighter_ane_devices() -> list[Any]:
return devices return devices
def get_migraphx_devices() -> list[Any]:
"""Get the AMD GPU devices the MIGraphX provider offers, registering it once.
Returns:
The provider's ONNX Runtime devices, or an empty list outside the ROCm
image or without an AMD GPU
"""
# Import here to avoid circular imports
from frigate.detectors.hardware import detect_amd_gpu
try:
import onnxruntime_ep_migraphx
except ImportError:
return []
# ONNX Runtime finds the provider's GPUs in the same /sys/class/drm entries,
# so an AMD GPU here means the provider lists it once registered
if detect_amd_gpu() is None:
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == MIGRAPHX_EP_NAME]
if not devices:
try:
ort.register_execution_provider_library(
MIGRAPHX_EP_NAME, onnxruntime_ep_migraphx.get_library_path()
)
except Exception as e:
logger.warning("Failed to load the MIGraphX provider: %s", e)
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == MIGRAPHX_EP_NAME]
return devices
def is_arm64_platform() -> bool: def is_arm64_platform() -> bool:
"""Check if we're running on an ARM platform.""" """Check if we're running on an ARM platform."""
machine = platform.machine().lower() machine = platform.machine().lower()
@@ -756,12 +796,41 @@ def get_optimized_runner(
ONNXModelRunner(session, model_type=model_type), ONNXModelRunner(session, model_type=model_type),
) )
if (
device != "CPU"
and not ONNXModelRunner.is_migraphx_complex_model(model_type)
and (migraphx_devices := get_migraphx_devices())
):
migraphx_cache_dir = os.path.join(MODEL_CACHE_DIR, "migraphx/ort")
os.makedirs(migraphx_cache_dir, exist_ok=True)
sess_options = get_ort_session_options(model_type) or ort.SessionOptions()
# only the first AMD GPU is used, matching detect_amd_gpu
sess_options.add_provider_for_devices(
migraphx_devices[:1], {"cache_dir": migraphx_cache_dir}
)
try:
session = ort.InferenceSession(model_path, sess_options=sess_options)
except Exception as e:
logger.warning(
"Failed to load %s with MIGraphX, using the default providers: %s",
model_path,
e,
)
else:
return _record_runner(
model_path,
model_type,
ONNXModelRunner(session, model_type=model_type),
)
providers, options = get_ort_providers(device == "CPU", device, **kwargs) providers, options = get_ort_providers(device == "CPU", device, **kwargs)
if providers[0] == "CPUExecutionProvider": if providers[0] == "CPUExecutionProvider":
# In the default image, ONNXRuntime is used so we will only get CPUExecutionProvider # In the default image, ONNXRuntime is used so we will only get CPUExecutionProvider
# In other images we will get CUDA / ROCm which are preferred over OpenVINO # In other images we will get CUDA which is preferred over OpenVINO, while
# There is currently no way to prioritize OpenVINO over CUDA / ROCm in these images # MIGraphX in the ROCm image is already handled above
# There is currently no way to prioritize OpenVINO over CUDA in these images
if device != "CPU" and is_openvino_gpu_npu_available(): if device != "CPU" and is_openvino_gpu_npu_available():
return _record_runner( return _record_runner(
model_path, model_path,
@@ -790,15 +859,6 @@ def get_optimized_runner(
), ),
) )
if (
providers
and providers[0] == "MIGraphXExecutionProvider"
and ONNXModelRunner.is_migraphx_complex_model(model_type)
):
# Don't use MIGraphX for models that are not supported
providers.pop(0)
options.pop(0)
return _record_runner( return _record_runner(
model_path, model_path,
model_type, model_type,
+76 -19
View File
@@ -8,7 +8,7 @@ from collections.abc import Callable, Generator, Iterable
from concurrent.futures import Future, ThreadPoolExecutor, as_completed from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime from datetime import datetime
from typing import Any, cast from typing import Any
import cv2 import cv2
import numpy as np import numpy as np
@@ -17,10 +17,6 @@ from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE
from frigate.jobs.job import Job from frigate.jobs.job import Job
from frigate.jobs.manager import (
get_job_by_id,
set_current_job,
)
from frigate.jobs.motion_search_batch import ( from frigate.jobs.motion_search_batch import (
build_segment_time_map, build_segment_time_map,
coalesce_runs, coalesce_runs,
@@ -53,6 +49,12 @@ PROGRESS_BROADCAST_INTERVAL = 1.0
# Output frame rate for the fixed-cadence fallback used on long-GOP cameras # Output frame rate for the fixed-cadence fallback used on long-GOP cameras
# (where keyframe sampling is too sparse). Keyframe cameras ignore this. # (where keyframe sampling is too sparse). Keyframe cameras ignore this.
FALLBACK_SAMPLE_FPS = 2.0 FALLBACK_SAMPLE_FPS = 2.0
# Each parallel job runs up to 4 decode workers, so this bounds total ffmpeg load.
MAX_ACTIVE_JOBS = 3
# Finished jobs stay pollable for this long before eviction (seconds).
FINISHED_JOB_TTL = 600.0
# A cancelled job stops holding a slot after this long, even if its thread is wedged.
CANCEL_GRACE_SECONDS = 60.0
@dataclass @dataclass
@@ -98,6 +100,8 @@ class MotionSearchJob(Job):
min_area: float = 5.0 min_area: float = 5.0
parallel: bool = False parallel: bool = False
max_results: int = 25 max_results: int = 25
username: str | None = None
cancelled_at: float | None = None
# Track progress # Track progress
total_frames_processed: int = 0 total_frames_processed: int = 0
@@ -112,6 +116,8 @@ class MotionSearchJob(Job):
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for WebSocket transmission.""" """Convert to dictionary for WebSocket transmission."""
d = asdict(self) d = asdict(self)
d.pop("username")
d.pop("cancelled_at")
if self.metrics: if self.metrics:
d["metrics"] = self.metrics.to_dict() d["metrics"] = self.metrics.to_dict()
return d return d
@@ -579,7 +585,7 @@ class MotionSearchRunner(threading.Thread):
# Decide keyframe vs fixed-cadence sampling once from the first run's GOP # Decide keyframe vs fixed-cadence sampling once from the first run's GOP
# (keyframe structure is a per-camera constant). # (keyframe structure is a per-camera constant).
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url) first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url, self._should_stop)
self.use_keyframe = keyframe_sampling_eligible(first_pts) self.use_keyframe = keyframe_sampling_eligible(first_pts)
logger.debug( logger.debug(
@@ -641,7 +647,9 @@ class MotionSearchRunner(threading.Thread):
time_map = build_segment_time_map(run) time_map = build_segment_time_map(run)
if self.use_keyframe: if self.use_keyframe:
kf_pts = probe_vod_keyframe_pts(self.ffprobe_path, vod_url) kf_pts = probe_vod_keyframe_pts(
self.ffprobe_path, vod_url, self._should_stop
)
frames = list( frames = list(
iter_vod_frames( iter_vod_frames(
self.ffmpeg_path, self.ffmpeg_path,
@@ -777,13 +785,15 @@ class MotionSearchRunner(threading.Thread):
run_results, frames = completed.pop(next_idx) run_results, frames = completed.pop(next_idx)
if self._merge_run(runs[next_idx], run_results, frames, state): if self._merge_run(runs[next_idx], run_results, frames, state):
self.internal_stop_event.set() self.internal_stop_event.set()
for pending in futures:
pending.cancel()
break break
next_idx += 1 next_idx += 1
if self.internal_stop_event.is_set(): if self.internal_stop_event.is_set():
break break
# Queued runs would otherwise still execute when the pool exits.
for pending in futures:
pending.cancel()
else: else:
for run in runs: for run in runs:
if self._should_stop(): if self._should_stop():
@@ -833,11 +843,36 @@ class MotionSearchRunner(threading.Thread):
return deduplicated return deduplicated
# Module-level state for managing per-camera jobs class MotionSearchUserBusyError(Exception):
"""The user already has an active motion search job."""
class MotionSearchCapacityError(Exception):
"""The global limit of active motion search jobs is reached."""
_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {} _motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {}
_jobs_lock = threading.Lock() _jobs_lock = threading.Lock()
def _evict_finished_jobs(now: float) -> None:
"""Drop jobs that finished more than FINISHED_JOB_TTL ago. Caller holds the lock."""
expired = [
job_id
for job_id, (job, _) in _motion_search_jobs.items()
if job.end_time is not None and now - job.end_time > FINISHED_JOB_TTL
]
for job_id in expired:
del _motion_search_jobs[job_id]
def _is_active(job: MotionSearchJob, now: float) -> bool:
"""True while the job's thread may still be running and holding a slot."""
if job.end_time is not None:
return False
return job.cancelled_at is None or now - job.cancelled_at < CANCEL_GRACE_SECONDS
def stop_all_motion_search_jobs() -> None: def stop_all_motion_search_jobs() -> None:
"""Cancel all running motion search jobs for clean shutdown.""" """Cancel all running motion search jobs for clean shutdown."""
with _jobs_lock: with _jobs_lock:
@@ -857,10 +892,13 @@ def start_motion_search_job(
min_area: float = 5.0, min_area: float = 5.0,
parallel: bool = False, parallel: bool = False,
max_results: int = 25, max_results: int = 25,
username: str | None = None,
) -> str: ) -> str:
"""Start a new motion search job. """Start a new motion search job.
Returns the job ID. Returns the job ID. Raises MotionSearchUserBusyError when ``username`` already
has an active job and MotionSearchCapacityError when MAX_ACTIVE_JOBS are active.
Pass ``username=None`` when callers share an identity to skip the per-user check.
""" """
job = MotionSearchJob( job = MotionSearchJob(
camera=camera_name, camera=camera_name,
@@ -871,17 +909,38 @@ def start_motion_search_job(
min_area=min_area, min_area=min_area,
parallel=parallel, parallel=parallel,
max_results=max_results, max_results=max_results,
username=username,
) )
cancel_event = threading.Event() cancel_event = threading.Event()
with _jobs_lock: with _jobs_lock:
now = datetime.now().timestamp()
_evict_finished_jobs(now)
# A cancelled job keeps its thread until the runner sets end_time, so it
# still counts toward the global cap. Its user may start a new search.
active = [j for j, _ in _motion_search_jobs.values() if _is_active(j, now)]
if username is not None and any(
j.username == username and j.status != JobStatusTypesEnum.cancelled
for j in active
):
raise MotionSearchUserBusyError()
if len(active) >= MAX_ACTIVE_JOBS:
raise MotionSearchCapacityError()
_motion_search_jobs[job.id] = (job, cancel_event) _motion_search_jobs[job.id] = (job, cancel_event)
set_current_job(job) runner: MotionSearchRunner | None = None
try:
runner = MotionSearchRunner(job, config, cancel_event) runner = MotionSearchRunner(job, config, cancel_event)
runner.start() runner.start()
except Exception:
if runner is not None:
runner.requestor.stop()
with _jobs_lock:
del _motion_search_jobs[job.id]
raise
logger.debug( logger.debug(
"Started motion search job %s for camera %s: " "Started motion search job %s for camera %s: "
@@ -904,10 +963,7 @@ def get_motion_search_job(job_id: str) -> MotionSearchJob | None:
"""Get a motion search job by ID.""" """Get a motion search job by ID."""
with _jobs_lock: with _jobs_lock:
job_entry = _motion_search_jobs.get(job_id) job_entry = _motion_search_jobs.get(job_id)
if job_entry: return job_entry[0] if job_entry else None
return job_entry[0]
# Check completed jobs via manager
return cast(MotionSearchJob | None, get_job_by_id("motion_search", job_id))
def cancel_motion_search_job(job_id: str) -> bool: def cancel_motion_search_job(job_id: str) -> bool:
@@ -928,6 +984,7 @@ def cancel_motion_search_job(job_id: str) -> bool:
cancel_event.set() cancel_event.set()
job.status = JobStatusTypesEnum.cancelled job.status = JobStatusTypesEnum.cancelled
job.cancelled_at = datetime.now().timestamp()
job_payload = job.to_dict() job_payload = job.to_dict()
logger.info("Cancelled motion search job %s", job_id) logger.info("Cancelled motion search job %s", job_id)
+23 -6
View File
@@ -10,6 +10,7 @@ import json
import logging import logging
import subprocess as sp import subprocess as sp
import tempfile import tempfile
import time
from collections.abc import Callable, Generator from collections.abc import Callable, Generator
from typing import IO from typing import IO
@@ -264,11 +265,16 @@ def iter_vod_frames(
) )
def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]: KEYFRAME_PROBE_TIMEOUT = 120.0
def probe_vod_keyframe_pts(
ffprobe_path: str, vod_url: str, should_stop: Callable[[], bool]
) -> list[float]:
"""Return keyframe presentation timestamps (VOD stream time) in order. """Return keyframe presentation timestamps (VOD stream time) in order.
Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on
any failure so the caller can fall back. any failure, timeout, or stop request so the caller can fall back.
""" """
cmd = [ cmd = [
ffprobe_path, ffprobe_path,
@@ -286,16 +292,27 @@ def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]:
"json", "json",
] ]
try: try:
completed = sp.run(cmd, capture_output=True, text=True, timeout=120) proc = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.DEVNULL, text=True)
except (OSError, sp.SubprocessError): except OSError:
logger.warning("ffprobe failed for VOD keyframe probe") logger.warning("ffprobe failed for VOD keyframe probe")
return [] return []
if completed.returncode != 0 or not completed.stdout: deadline = time.monotonic() + KEYFRAME_PROBE_TIMEOUT
while True:
try:
stdout, _ = proc.communicate(timeout=0.5)
break
except sp.TimeoutExpired:
if should_stop() or time.monotonic() > deadline:
proc.kill()
proc.communicate()
return []
if proc.returncode != 0 or not stdout:
return [] return []
try: try:
packets = json.loads(completed.stdout).get("packets", []) packets = json.loads(stdout).get("packets", [])
except json.JSONDecodeError: except json.JSONDecodeError:
return [] return []
+4 -14
View File
@@ -14,11 +14,12 @@ from ws4py.server.wsgirefserver import (
WebSocketWSGIRequestHandler, WebSocketWSGIRequestHandler,
WSGIServer, WSGIServer,
) )
from ws4py.server.wsgiutils import WebSocketWSGIApplication
from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
from frigate.comms.ws import OriginCheckedWebSocketApplication, WebSocket from frigate.comms.ws import WebSocket
from frigate.config import AuthConfig, FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.updater import ( from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber, CameraConfigUpdateSubscriber,
@@ -135,7 +136,7 @@ class OutputProcess(FrigateProcess):
8082, 8082,
server_class=WSGIServer, server_class=WSGIServer,
handler_class=WebSocketWSGIRequestHandler, handler_class=WebSocketWSGIRequestHandler,
app=OriginCheckedWebSocketApplication(handler_cls=WebSocket), app=WebSocketWSGIApplication(handler_cls=WebSocket),
) )
websocket_server.initialize_websockets_manager() websocket_server.initialize_websockets_manager()
websocket_thread = threading.Thread(target=websocket_server.serve_forever) websocket_thread = threading.Thread(target=websocket_server.serve_forever)
@@ -153,7 +154,6 @@ class OutputProcess(FrigateProcess):
], ],
) )
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True) birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
auth_config_subscriber = ConfigSubscriber("config/auth", exact=True)
jsmpeg_cameras: dict[str, JsmpegCamera] = {} jsmpeg_cameras: dict[str, JsmpegCamera] = {}
birdseye: Birdseye | None = None birdseye: Birdseye | None = None
@@ -194,15 +194,6 @@ class OutputProcess(FrigateProcess):
self.config.birdseye = birdseye_config self.config.birdseye = birdseye_config
logger.debug("Applied dynamic birdseye config update") logger.debug("Applied dynamic birdseye config update")
# user endpoints publish None on this topic and role edits an AuthConfig
while True:
auth_topic, auth_config = auth_config_subscriber.check_for_update()
if auth_topic is None:
break
if isinstance(auth_config, AuthConfig):
self.config.auth = auth_config
logger.debug("Applied dynamic auth config update")
# drain review updates every iteration, not just when birdseye is # drain review updates every iteration, not just when birdseye is
# being consumed, so a dropped end never strands a camera # being consumed, so a dropped end never strands a camera
if birdseye is not None: if birdseye is not None:
@@ -339,7 +330,6 @@ class OutputProcess(FrigateProcess):
config_subscriber.stop() config_subscriber.stop()
birdseye_config_subscriber.stop() birdseye_config_subscriber.stop()
auth_config_subscriber.stop()
websocket_server.manager.close_all() websocket_server.manager.close_all()
websocket_server.manager.stop() websocket_server.manager.stop()
websocket_server.manager.join() websocket_server.manager.join()
@@ -101,71 +101,6 @@ class TestAuthInternalPort(BaseTestHttp):
self.assertEqual(resp.headers["remote-user"], "anonymous") self.assertEqual(resp.headers["remote-user"], "anonymous")
self.assertEqual(resp.headers["remote-role"], "admin") self.assertEqual(resp.headers["remote-role"], "admin")
def _internal_auth(self, original_url: str, **headers: str):
app = self._create_app()
with AuthTestClient(app) as client:
return client.get(
"/auth",
headers={
"x-server-port": "5000",
"x-original-url": original_url,
"x-original-host": "frigate.lan:5000",
**{k.replace("_", "-"): v for k, v in headers.items()},
},
)
def test_cross_origin_websocket_on_internal_port_denied(self):
resp = self._internal_auth(
"/ws", origin="https://evil.example", x_original_upgrade="websocket"
)
self.assertEqual(resp.status_code, 403)
def test_cross_origin_go2rtc_stream_denied(self):
resp = self._internal_auth(
"/live/mse/api/ws?src=front_door",
origin="https://evil.example",
x_original_upgrade="websocket",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 403)
def test_cross_origin_webrtc_post_denied(self):
# the offer POST is not an upgrade, so the stream marker alone gates it
resp = self._internal_auth(
"/api/go2rtc/webrtc?src=front_door",
origin="https://evil.example",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 403)
def test_same_origin_websocket_allowed(self):
resp = self._internal_auth(
"/live/mse/api/ws?src=front_door",
origin="http://frigate.lan:5000",
x_original_upgrade="websocket",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 202)
def test_websocket_without_origin_allowed(self):
resp = self._internal_auth("/ws", x_original_upgrade="websocket")
self.assertEqual(resp.status_code, 202)
def test_forwarded_host_origin_allowed(self):
# Home Assistant integration proxy: Host is Frigate, Origin is HA
resp = self._internal_auth(
"/api/go2rtc/webrtc?src=front_door",
origin="https://ha.example.com:8123",
x_forwarded_host="ha.example.com:8123",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 202)
def test_cross_origin_plain_http_not_checked(self):
resp = self._internal_auth("/api/events", origin="https://evil.example")
self.assertEqual(resp.status_code, 202)
def test_external_port_requires_auth(self): def test_external_port_requires_auth(self):
app = self._create_app() app = self._create_app()
+253 -17
View File
@@ -1,22 +1,29 @@
"""Tests that /auth only accepts a JWT whose role is still in the config.""" """Tests that /auth takes the role and revocation state from the user, not the JWT."""
import os import os
import time import time
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch from unittest.mock import MagicMock, Mock, patch
from frigate.api.auth import create_encoded_jwt from joserfc import jwt
from frigate.api.auth import create_encoded_jwt, hash_password
from frigate.api.fastapi_app import create_fastapi_app from frigate.api.fastapi_app import create_fastapi_app
from frigate.app import FrigateApp
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.const import JWT_SECRET_ENV_VAR from frigate.const import JWT_SECRET_ENV_VAR
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment, User
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
PASSWORD = "correct-horse-battery"
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"}) @patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
class TestAuthJwtRole(BaseTestHttp): class TestAuthJwtRole(BaseTestHttp):
def setUp(self): def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment]) super().setUp(models=[Event, Recordings, ReviewSegment, User])
self.minimal_config = { self.minimal_config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}}, "auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
@@ -36,6 +43,19 @@ class TestAuthJwtRole(BaseTestHttp):
} }
}, },
} }
User.delete().execute()
self._insert_user("admin", "admin")
self._insert_user("bob", "garage")
def _insert_user(self, username: str, role: str):
User.insert(
{
User.username: username,
User.role: role,
User.password_hash: hash_password(PASSWORD, iterations=1),
User.notification_tokens: [],
}
).execute()
def _create_app(self): def _create_app(self):
mock_publisher = Mock(spec=CameraConfigUpdatePublisher) mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
@@ -55,27 +75,243 @@ class TestAuthJwtRole(BaseTestHttp):
enforce_default_admin=False, enforce_default_admin=False,
) )
def _auth(self, app, role: str): def _token(self, app, user: str, role: str, iat: int | None = None) -> str:
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token) if iat is None:
return create_encoded_jwt(
with AuthTestClient(app) as client: user, role, int(time.time()) + 3600, app.jwt_token
return client.get(
"/auth",
headers={
"x-server-port": "8971",
"authorization": f"Bearer {token}",
},
) )
return jwt.encode(
{"alg": "HS256"},
{"sub": user, "role": role, "exp": int(time.time()) + 3600, "iat": iat},
app.jwt_token,
)
def _auth(self, client, token: str, source: str = "bearer"):
headers = {"x-server-port": "8971"}
if source == "bearer":
headers["authorization"] = f"Bearer {token}"
else:
client.cookies.set("frigate_token", token)
resp = client.get("/auth", headers=headers)
client.cookies.clear()
return resp
def test_configured_role_is_accepted(self): def test_configured_role_is_accepted(self):
resp = self._auth(self._create_app(), "garage") app = self._create_app()
with AuthTestClient(app) as client:
resp = self._auth(client, self._token(app, "bob", "garage"))
self.assertEqual(resp.status_code, 202) self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-user"], "bob") self.assertEqual(resp.headers["remote-user"], "bob")
self.assertEqual(resp.headers["remote-role"], "garage") self.assertEqual(resp.headers["remote-role"], "garage")
def test_role_claim_is_ignored(self):
app = self._create_app()
with AuthTestClient(app) as client:
resp = self._auth(client, self._token(app, "bob", "admin"))
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-role"], "garage")
def test_role_removed_from_config_is_rejected(self): def test_role_removed_from_config_is_rejected(self):
resp = self._auth(self._create_app(), "removed_role") User.update(role="removed_role").where(User.username == "bob").execute()
app = self._create_app()
with AuthTestClient(app) as client:
for source in ("bearer", "cookie"):
resp = self._auth(client, self._token(app, "bob", "garage"), source)
self.assertEqual(resp.status_code, 401)
self.assertNotIn("remote-role", resp.headers)
def test_login_refused_for_role_removed_from_config(self):
User.update(role="removed_role").where(User.username == "bob").execute()
with AuthTestClient(self._create_app()) as client:
resp = client.post("/login", json={"user": "bob", "password": PASSWORD})
self.assertEqual(resp.status_code, 401) self.assertEqual(resp.status_code, 401)
self.assertNotIn("remote-role", resp.headers) self.assertNotIn("set-cookie", resp.headers)
def test_demoted_user_gets_new_role_on_next_request(self):
app = self._create_app()
token = self._token(app, "bob", "admin")
User.update(role="admin").where(User.username == "bob").execute()
app.user_sessions["bob"] = ("admin", None)
with AuthTestClient(app) as client:
self.assertEqual(self._auth(client, token).headers["remote-role"], "admin")
resp = client.put("/users/bob/role", json={"role": "viewer"})
self.assertEqual(resp.status_code, 200)
for source in ("bearer", "cookie"):
resp = self._auth(client, token, source)
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-role"], "viewer")
def test_deleted_user_is_rejected(self):
app = self._create_app()
token = self._token(app, "bob", "garage")
with AuthTestClient(app) as client:
self.assertEqual(self._auth(client, token).status_code, 202)
self.assertEqual(client.delete("/users/bob").status_code, 200)
for source in ("bearer", "cookie"):
self.assertEqual(self._auth(client, token, source).status_code, 401)
def test_recreated_user_rejects_token_from_deleted_account(self):
app = self._create_app()
token = self._token(app, "bob", "garage", iat=int(time.time()) - 10)
with AuthTestClient(app) as client:
self.assertEqual(client.delete("/users/bob").status_code, 200)
resp = client.post(
"/users",
json={"username": "bob", "password": PASSWORD, "role": "viewer"},
)
self.assertEqual(resp.status_code, 200)
for source in ("bearer", "cookie"):
self.assertEqual(self._auth(client, token, source).status_code, 401)
def test_created_user_is_accepted(self):
app = self._create_app()
with AuthTestClient(app) as client:
resp = client.post(
"/users",
json={"username": "carol", "password": PASSWORD, "role": "viewer"},
)
self.assertEqual(resp.status_code, 200)
resp = self._auth(client, self._token(app, "carol", "viewer"))
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-role"], "viewer")
def test_password_change_revokes_older_tokens(self):
app = self._create_app()
old_token = self._token(app, "bob", "garage", iat=int(time.time()) - 10)
with AuthTestClient(app) as client:
self.assertEqual(self._auth(client, old_token).status_code, 202)
resp = client.put(
"/users/bob/password",
json={"password": "a-brand-new-password"},
)
self.assertEqual(resp.status_code, 200)
for source in ("bearer", "cookie"):
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
changed_at = app.user_sessions["bob"][1]
resp = self._auth(client, self._token(app, "bob", "garage", changed_at))
self.assertEqual(resp.status_code, 202)
def test_login_in_the_same_second_as_password_change_is_valid(self):
app = self._create_app()
changed_at = datetime.now().replace(microsecond=500000)
new_token = self._token(app, "bob", "garage", iat=int(changed_at.timestamp()))
with (
patch("frigate.api.auth.datetime") as mock_datetime,
AuthTestClient(app) as client,
):
mock_datetime.now.return_value = changed_at
resp = client.put(
"/users/bob/password",
json={"password": "a-brand-new-password"},
)
self.assertEqual(resp.status_code, 200)
for source in ("bearer", "cookie"):
self.assertEqual(self._auth(client, new_token, source).status_code, 202)
def test_own_password_change_cookie_stays_valid(self):
app = self._create_app()
with AuthTestClient(app) as client:
resp = client.put(
"/users/bob/password",
json={"password": "a-brand-new-password", "old_password": PASSWORD},
headers={"remote-user": "bob", "remote-role": "garage"},
)
self.assertEqual(resp.status_code, 200)
new_token = resp.cookies["frigate_token"]
resp = self._auth(client, new_token, "cookie")
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-role"], "garage")
def test_admin_password_reset_revokes_older_tokens(self):
config = FrigateConfig(
**{**self.minimal_config, "auth": {"reset_admin_password": True}}
)
FrigateApp.init_auth(SimpleNamespace(config=config))
app = self._create_app()
old_token = self._token(app, "admin", "admin", iat=int(time.time()) - 10)
with AuthTestClient(app) as client:
for source in ("bearer", "cookie"):
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
def test_auth_does_not_query_users(self):
app = self._create_app()
# expires inside the refresh window so the cookie refresh runs too
token = jwt.encode(
{"alg": "HS256"},
{"sub": "bob", "role": "garage", "exp": int(time.time()) + 60, "iat": 0},
app.jwt_token,
)
with (
patch.object(User, "select", side_effect=AssertionError("db query")),
AuthTestClient(app) as client,
):
client.cookies.set("frigate_token", token)
resp = client.get("/auth", headers={"x-server-port": "8971"})
self.assertEqual(resp.status_code, 202)
self.assertIn("frigate_token=", resp.headers["set-cookie"])
def _login_cookie(self, headers: dict | None = None) -> str:
with AuthTestClient(self._create_app()) as client:
resp = client.post(
"/login",
json={"user": "bob", "password": PASSWORD},
headers=headers or {},
)
self.assertEqual(resp.status_code, 200)
return resp.headers["set-cookie"].lower()
def test_login_cookie_is_secure_over_tls(self):
cookie = self._login_cookie({"x-forwarded-proto": "https"})
self.assertIn("; secure", cookie)
def test_login_cookie_is_not_secure_over_http(self):
cookie = self._login_cookie({"x-forwarded-proto": "http"})
self.assertNotIn("; secure", cookie)
def test_cookie_secure_config_forces_secure(self):
self.minimal_config["auth"]["cookie_secure"] = True
cookie = self._login_cookie()
self.assertIn("; secure", cookie)
def test_refreshed_cookie_is_secure_over_tls(self):
app = self._create_app()
token = jwt.encode(
{"alg": "HS256"},
{"sub": "bob", "exp": int(time.time()) + 60, "iat": int(time.time())},
app.jwt_token,
)
with AuthTestClient(app) as client:
client.cookies.set("frigate_token", token)
resp = client.get(
"/auth",
headers={"x-server-port": "8971", "x-forwarded-proto": "https"},
)
self.assertEqual(resp.status_code, 202)
self.assertIn("; secure", resp.headers["set-cookie"].lower())
@@ -14,6 +14,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.holder import ConfigHolder from frigate.config.holder import ConfigHolder
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -503,6 +504,358 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
finally: finally:
os.unlink(config_path) os.unlink(config_path)
camera_path = "rtsp://10.0.0.1:554/video?password=s3cret&user=admin"
def _add_credentials(self):
"""Give the config a credentialed camera path and go2rtc secrets."""
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"] = (
self.camera_path
)
self.minimal_config["go2rtc"] = {
"rtsp": {"username": "rtspuser", "password": "rtsppass"},
"api": {"username": "apiuser", "password": "apipass"},
"mqtt": {"username": "mquser", "password": "mqpass"},
"ngrok": {"command": "ngrok tcp 8555 --authtoken ngroksecret"},
"homekit": {"front_door": {"pin": "19550224"}},
"publish": {"front_door": "rtmp://a.rtmp.youtube.com/live2/streamkey"},
"webrtc": {
"candidates": ["10.0.0.5:8555"],
"ice_servers": [
{
"urls": ["turn:turn.example.com:3478"],
"username": "turnuser",
"credential": "turnpass",
}
],
},
}
def _read_config_file(self, config_path):
with open(config_path) as f:
raw = f.read()
return raw, ruamel.yaml.YAML().load(raw)
@patch("frigate.api.app.find_config_file")
def test_masked_camera_path_round_trips(self, mock_find_config):
self._add_credentials()
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
camera = client.get("/config").json()["cameras"]["front_door"]
inputs = camera["ffmpeg"]["inputs"]
self.assertNotIn("s3cret", inputs[0]["path"])
resp = client.put(
"/config/set",
json={
"config_data": {
"cameras": {
"front_door": {
"ffmpeg": {"inputs": inputs},
"detect": {"fps": 7},
}
}
},
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
_, saved = self._read_config_file(config_path)
self.assertEqual(
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"],
self.camera_path,
)
finally:
os.unlink(config_path)
def _save(self, client, config_data):
return client.put(
"/config/set",
json={"config_data": config_data, "requires_restart": 0},
)
def _set_inputs(self, paths):
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [
{"path": path, "roles": [role]} for path, role in paths
]
# config parsing rebuilds FRIGATE_ENV_VARS from its sources
@patch.dict("frigate.util.env._CONTAINER_ENV", {"FRIGATE_RTSP_PASSWORD": "s3cret"})
@patch.dict(
"frigate.util.env.FRIGATE_ENV_VARS", {"FRIGATE_RTSP_PASSWORD": "s3cret"}
)
@patch("frigate.api.app.find_config_file")
def test_masked_path_keeps_env_placeholder(self, mock_find_config):
path = "rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.0.1:554/live"
self._set_inputs([(path, "detect")])
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
camera = client.get("/config").json()["cameras"]["front_door"]
self.assertNotIn("s3cret", str(camera))
resp = self._save(
client,
{
"cameras": {
"front_door": {
"ffmpeg": {"inputs": camera["ffmpeg"]["inputs"]},
"detect": {"fps": 7},
}
}
},
)
self.assertEqual(resp.status_code, 200)
raw, saved = self._read_config_file(config_path)
self.assertNotIn("s3cret", raw)
self.assertEqual(
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_over_masked_path_round_trips(self, mock_find_config):
path = "rtsp://10.0.0.1:554/live?x=a@b"
self._set_inputs([(path, "detect")])
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
inputs = client.get("/config").json()["cameras"]["front_door"][
"ffmpeg"
]["inputs"]
self.assertNotEqual(inputs[0]["path"], path)
resp = self._save(
client,
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
)
self.assertEqual(resp.status_code, 200)
_, saved = self._read_config_file(config_path)
self.assertEqual(
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_unrestorable_masked_values_are_rejected(self, mock_find_config):
self._add_credentials()
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
for name, request in (
(
"index-keyed inputs",
{
"json": {
"config_data": {
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": {
"0": {"path": "rtsp://*:*@h/x"}
}
}
}
}
},
"requires_restart": 0,
}
},
),
(
"go2rtc streams",
{
"json": {
"config_data": {
"go2rtc": {"streams": {"cam": ["rtsp://*:*@h/x"]}}
},
"requires_restart": 0,
}
},
),
(
"query string",
{
"params": {
"cameras.front_door.ffmpeg.inputs.0.path": (
"rtsp://*:*@h/x"
),
"go2rtc.rtsp.password": REDACTED_CREDENTIAL_SENTINEL,
},
"json": {"requires_restart": 0},
},
),
):
with self.subTest(name):
resp = client.put("/config/set", **request)
self.assertEqual(resp.status_code, 400)
self.assertIn("credentials", resp.json()["message"])
raw, _ = self._read_config_file(config_path)
self.assertNotIn("*:*", raw)
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config):
self._add_credentials()
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
response = client.get("/config").json()
body = str(response)
for secret in (
"rtsppass",
"apipass",
"mqpass",
"ngroksecret",
"19550224",
"streamkey",
):
self.assertNotIn(secret, body)
go2rtc = response["go2rtc"]
self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"])
# browsers need the TURN account to relay WebRTC
self.assertEqual(
go2rtc["webrtc"]["ice_servers"][0]["credential"], "turnpass"
)
go2rtc.pop("ngrok")
go2rtc["webrtc"]["candidates"] = ["10.0.0.9:8555"]
self.assertEqual(
self._save(client, {"go2rtc": go2rtc}).status_code, 200
)
raw, saved = self._read_config_file(config_path)
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
self.assertEqual(saved["go2rtc"]["rtsp"]["password"], "rtsppass")
self.assertEqual(saved["go2rtc"]["mqtt"]["password"], "mqpass")
self.assertEqual(
saved["go2rtc"]["homekit"]["front_door"]["pin"], "19550224"
)
self.assertIn("streamkey", saved["go2rtc"]["publish"]["front_door"])
self.assertEqual(saved["go2rtc"]["webrtc"]["candidates"], ["10.0.0.9:8555"])
self.assertEqual(
saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "turnpass"
)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_masked_inputs_restore_by_value(self, mock_find_config):
main = "rtsp://u:MAIN@10.0.0.1:554/main"
sub = "rtsp://u:SUB@10.0.0.1:554/sub"
for name, edit, expected in (
("delete first", lambda inputs: [inputs[1]], [sub]),
("reorder", lambda inputs: [inputs[1], inputs[0]], [sub, main]),
):
with self.subTest(name):
self._set_inputs([(main, "record"), (sub, "detect")])
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
inputs = client.get("/config").json()["cameras"]["front_door"][
"ffmpeg"
]["inputs"]
resp = self._save(
client,
{
"cameras": {
"front_door": {"ffmpeg": {"inputs": edit(inputs)}}
}
},
)
self.assertEqual(resp.status_code, 200)
_, saved = self._read_config_file(config_path)
saved_inputs = saved["cameras"]["front_door"]["ffmpeg"]["inputs"]
self.assertEqual([i["path"] for i in saved_inputs], expected)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_ambiguous_or_unmatched_masked_input(self, mock_find_config):
self._set_inputs(
[
("rtsp://u:one@10.0.0.1:554/live", "record"),
("rtsp://u:two@10.0.0.1:554/live", "detect"),
]
)
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
inputs = client.get("/config").json()["cameras"]["front_door"][
"ffmpeg"
]["inputs"]
# both inputs mask to the same value and roles no longer tell
# them apart
remaining = dict(inputs[1], roles=["detect", "record"])
resp = self._save(
client,
{"cameras": {"front_door": {"ffmpeg": {"inputs": [remaining]}}}},
)
self.assertEqual(resp.status_code, 400)
inputs[0]["path"] = "rtsp://*:*@10.0.0.99:554/live"
resp = self._save(
client,
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
)
self.assertEqual(resp.status_code, 400)
# roles break the tie when the second input stays as it was
resp = self._save(
client,
{"cameras": {"front_door": {"ffmpeg": {"inputs": [inputs[1]]}}}},
)
self.assertEqual(resp.status_code, 200)
raw, saved = self._read_config_file(config_path)
self.assertNotIn("*:*", raw)
self.assertEqual(
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"],
"rtsp://u:two@10.0.0.1:554/live",
)
finally:
os.unlink(config_path)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -33,6 +33,7 @@ class TestGo2rtcProxyAuth(BaseTestHttp):
self.minimal_config = _CONFIG self.minimal_config = _CONFIG
self.app = super().create_app() self.app = super().create_app()
self.app.jwt_token = OctKey.import_key(b"0" * 32) self.app.jwt_token = OctKey.import_key(b"0" * 32)
self.app.user_sessions = {"admin": ("admin", None), "viewer": ("viewer", None)}
get_patch = patch( get_patch = patch(
"frigate.api.auth.requests.get", "frigate.api.auth.requests.get",
@@ -0,0 +1,121 @@
"""Tests for motion search job limits."""
from datetime import datetime
from unittest.mock import patch
from frigate.jobs import motion_search
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
URL = "/front_door/search/motion"
BODY = {
"start_time": 1000,
"end_time": 2000,
"polygon_points": [[0, 0], [1, 0], [1, 1]],
}
class TestHttpMotionSearch(BaseTestHttp):
def setUp(self):
super().setUp([Event, Recordings, ReviewSegment])
self.app = self.create_app()
motion_search._motion_search_jobs.clear()
self.addCleanup(motion_search._motion_search_jobs.clear)
runner_patch = patch("frigate.jobs.motion_search.MotionSearchRunner")
self.runner = runner_patch.start()
self.addCleanup(runner_patch.stop)
def start(self, client: AuthTestClient, user: str, body: dict = BODY):
return client.post(URL, json=body, headers={"remote-user": user})
def test_second_job_for_same_user_rejected(self):
with AuthTestClient(self.app) as client:
self.assertEqual(self.start(client, "alice").status_code, 200)
self.assertEqual(self.start(client, "alice").status_code, 409)
self.assertEqual(self.start(client, "bob").status_code, 200)
def test_user_can_start_again_after_job_finishes(self):
with AuthTestClient(self.app) as client:
job_id = self.start(client, "alice").json()["job_id"]
job = motion_search.get_motion_search_job(job_id)
job.end_time = datetime.now().timestamp()
self.assertEqual(self.start(client, "alice").status_code, 200)
self.assertIsNotNone(motion_search.get_motion_search_job(job_id))
def test_cancelled_job_frees_user_but_holds_global_slot_until_grace(self):
with AuthTestClient(self.app) as client:
job_id = self.start(client, "alice").json()["job_id"]
with patch("frigate.jobs.motion_search.InterProcessRequestor"):
self.assertTrue(motion_search.cancel_motion_search_job(job_id))
job = motion_search.get_motion_search_job(job_id)
self.assertIsNotNone(job.cancelled_at)
self.assertIsNone(job.end_time)
self.assertEqual(self.start(client, "alice").status_code, 200)
self.assertEqual(self.start(client, "bob").status_code, 200)
self.assertEqual(self.start(client, "carol").status_code, 429)
job.cancelled_at -= motion_search.CANCEL_GRACE_SECONDS + 1
self.assertEqual(self.start(client, "carol").status_code, 200)
def test_start_failure_releases_slot(self):
self.runner.return_value.start.side_effect = RuntimeError("boom")
with AuthTestClient(self.app) as client:
with self.assertRaises(RuntimeError):
self.start(client, "alice")
self.assertEqual(motion_search._motion_search_jobs, {})
self.runner.return_value.requestor.stop.assert_called_once()
self.runner.return_value.start.side_effect = None
self.assertEqual(self.start(client, "alice").status_code, 200)
def test_shared_identity_skips_per_user_limit(self):
with AuthTestClient(self.app) as client:
self.assertEqual(self.start(client, "anonymous").status_code, 200)
self.assertEqual(self.start(client, "anonymous").status_code, 200)
motion_search._motion_search_jobs.clear()
self.minimal_config["auth"] = {"enabled": False}
app = self.create_app()
with AuthTestClient(app) as client:
self.assertEqual(self.start(client, "viewer").status_code, 200)
self.assertEqual(self.start(client, "viewer").status_code, 200)
def test_proxy_user_header_keeps_per_user_limit_without_auth(self):
self.minimal_config["auth"] = {"enabled": False}
self.minimal_config["proxy"] = {"header_map": {"user": "x-forwarded-user"}}
app = self.create_app()
with AuthTestClient(app) as client:
self.assertEqual(self.start(client, "alice").status_code, 200)
self.assertEqual(self.start(client, "alice").status_code, 409)
def test_global_cap_enforced(self):
with AuthTestClient(self.app) as client:
for i in range(motion_search.MAX_ACTIVE_JOBS):
self.assertEqual(self.start(client, f"user{i}").status_code, 200)
self.assertEqual(self.start(client, "late").status_code, 429)
def test_finished_jobs_evicted_after_ttl(self):
with AuthTestClient(self.app) as client:
old_id = self.start(client, "alice").json()["job_id"]
recent_id = self.start(client, "bob").json()["job_id"]
running_id = self.start(client, "carol").json()["job_id"]
now = 10_000.0
motion_search.get_motion_search_job(old_id).end_time = (
now - motion_search.FINISHED_JOB_TTL - 1
)
motion_search.get_motion_search_job(recent_id).end_time = now - 1
motion_search._evict_finished_jobs(now)
self.assertIsNone(motion_search.get_motion_search_job(old_id))
self.assertIsNotNone(motion_search.get_motion_search_job(recent_id))
self.assertIsNotNone(motion_search.get_motion_search_job(running_id))
def test_username_and_cancel_time_not_broadcast(self):
job = motion_search.MotionSearchJob(username="alice", cancelled_at=1.0)
payload = job.to_dict()
self.assertNotIn("username", payload)
self.assertNotIn("cancelled_at", payload)
+93 -1
View File
@@ -1,8 +1,13 @@
"""Test camera user and password cleanup.""" """Test camera user and password cleanup."""
import time
import unittest import unittest
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters from frigate.util.builtin import (
clean_camera_user_pass,
escape_special_characters,
is_masked_camera_path,
)
class TestUserPassCleanup(unittest.TestCase): class TestUserPassCleanup(unittest.TestCase):
@@ -45,3 +50,90 @@ class TestUserPassMasking(unittest.TestCase):
escaped = clean_camera_user_pass(self.rtsp_log_message) escaped = clean_camera_user_pass(self.rtsp_log_message)
print(f"The escaped is {escaped}") print(f"The escaped is {escaped}")
assert escaped == "Did you mean file:rtsp://*:*@192.168.1.3:554" assert escaped == "Did you mean file:rtsp://*:*@192.168.1.3:554"
def test_masks_userinfo_and_credential_query_forms(self):
cases = {
"rtsp://first.last@mail.com:p%40w@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"rtsp://user+tag:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"rtsp://:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"tapo://cloudpassword@10.0.0.2": "tapo://*:*@10.0.0.2",
"rtsp://u:p@ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"rtsp://user:pa/ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"rtsp://u:p@ss#x@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"rtsp://admin:2024/pw@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
"rtsp://admin:12#abc@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
"rtsp://admin:7?x@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
"rtsps://u:1/2@h/x": "rtsps://*:*@h/x",
"rtsp://admin:pre&token=suf@10.0.0.2/x": "rtsp://*:*@",
"rtsp://admin:a?pwd=b@10.0.0.2/x": "rtsp://*:*@",
"rtsp://10.0.0.2:554/live?password=pre@suf": "rtsp://*:*@",
"http://10.0.0.2:88/cgi?cmd=snap&usr=admin&pwd=P@ss": "http://*:*@",
"ffmpeg:http://h:80/flv?user=u&password=p@ss#video=copy": (
"ffmpeg:http://*:*@"
),
"rtsp://u:p@10.0.0.2:554/live?token=t": "rtsp://*:*@10.0.0.2:554/live?token=*",
"http://h/x?api_key=a&apikey=b&access_token=c": (
"http://h/x?api_key=*&apikey=*&access_token=*"
),
"http://h/cgi?user=admin&password=x": "http://h/cgi?user=*&password=*",
"http://h/cgi?password=x&user=admin&channel=1": (
"http://h/cgi?password=*&user=*&channel=1"
),
"http://h/x?usr=a&pwd=b": "http://h/x?usr=*&pwd=*",
"http://h/x?username=a&password=b": "http://h/x?username=*&password=*",
"rtsp://host/live?user=admin&password=p@ss": (
"rtsp://host/live?user=*&password=*"
),
"ring:?refresh_token=abc.def": "ring:?refresh_token=*",
"nest:?client_id=1&client_secret=s&refresh_token=t&project_id=p": (
"nest:?client_id=1&client_secret=*&refresh_token=*&project_id=p"
),
"homekit://10.0.0.2:51827?client_id=c&client_private=k&device_id=d": (
"homekit://10.0.0.2:51827?client_id=c&client_private=*&device_id=d"
),
"exec:ffmpeg -i rtsp://u:p@h/s -c copy -f rtsp {output}": (
"exec:ffmpeg -i rtsp://*:*@h/s -c copy -f rtsp {output}"
),
"ffmpeg -i rtsp://u:p@h/1 -i http://h/snap?user=a&password=b -f null": (
"ffmpeg -i rtsp://*:*@h/1 -i http://h/snap?user=*&password=* -f null"
),
}
for line, expected in cases.items():
with self.subTest(line=line):
masked = clean_camera_user_pass(line)
self.assertEqual(masked, expected)
self.assertTrue(is_masked_camera_path(masked))
self.assertEqual(clean_camera_user_pass(masked), masked)
def test_safe_strings_unchanged(self):
for line in (
"rtsp://10.0.0.2:554/live",
"ffmpeg:front_door#video=copy",
"rtsp://127.0.0.1:8554/front_door?video=copy&audio=aac",
"homekit://10.0.0.2:51827?device_id=d",
"http://10.0.0.1/snap.jpg?email=me@x.com",
"http://h/x?auth=a&author=b&authorization=c&user_agent=d&autoken=e",
"http://h/x?private=1&passthrough=1",
"Camera front_door is not responding",
):
with self.subTest(line=line):
self.assertEqual(clean_camera_user_pass(line), line)
self.assertFalse(is_masked_camera_path(line))
def test_commas_outside_credentials_survive(self):
"""ffprobe splits multiple paths on commas left after masking."""
for line in (
"rtsp://a/x,rtsp://u:p@b/y",
"rtsp://a:554/x,rtsp://u:p@b/y",
):
with self.subTest(line=line):
self.assertIn(",", clean_camera_user_pass(line))
self.assertNotIn(",", clean_camera_user_pass("rtsp://u:p,q@b/y"))
def test_long_line_masks_in_linear_time(self):
"""Log lines run through the mask, so a long one must stay fast."""
start = time.perf_counter()
clean_camera_user_pass("rtsp://u:p" * 20000)
clean_camera_user_pass("rtsp://" + "a." * 40000)
self.assertLess(time.perf_counter() - start, 1)
+76
View File
@@ -0,0 +1,76 @@
"""Tests that IPC handler errors cannot stall inter-process communication."""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import zmq
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.inter_process import InterProcessCommunicator
class TestInterProcessHandlerErrors(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.address = f"ipc://{os.path.join(self.tmp.name, 'comms')}"
with patch("frigate.comms.inter_process.SOCKET_REP_REQ", self.address):
self.communicator = InterProcessCommunicator()
self.context = zmq.Context()
self.requestor = self.context.socket(zmq.REQ)
self.requestor.setsockopt(zmq.RCVTIMEO, 5000)
self.requestor.connect(self.address)
def tearDown(self):
self.requestor.close(linger=0)
self.context.destroy(linger=0)
self.communicator.stop()
self.tmp.cleanup()
def test_handler_exception_still_replies_and_keeps_reading(self):
def dispatcher(topic, value):
if topic == "boom":
raise ValueError("handler failed")
return value
self.communicator.subscribe(dispatcher)
self.communicator.start()
with self.assertLogs("frigate.comms.inter_process", level="ERROR"):
self.requestor.send_json(("boom", None))
self.assertEqual(self.requestor.recv_json(), [])
self.requestor.send_json(("echo", "ok"))
self.assertEqual(self.requestor.recv_json(), "ok")
self.assertTrue(self.communicator.reader_thread.is_alive())
class TestDispatcherPublishErrors(unittest.TestCase):
def test_failing_communicator_does_not_block_the_others(self):
failing = MagicMock()
failing.publish.side_effect = ValueError("publish failed")
healthy = MagicMock()
with (
patch("frigate.comms.dispatcher.CameraActivityManager"),
patch("frigate.comms.dispatcher.AudioActivityManager"),
):
dispatcher = Dispatcher(
MagicMock(), MagicMock(), MagicMock(), {}, [failing, healthy]
)
healthy.publish.reset_mock()
with self.assertLogs("frigate.comms.dispatcher", level="ERROR"):
dispatcher.publish("reviews", "{}")
dispatcher.publish_local("notices", "[]")
healthy.publish.assert_any_call("reviews", "{}", False)
healthy.publish.assert_any_call("notices", "[]", False)
if __name__ == "__main__":
unittest.main()
+29 -5
View File
@@ -1,5 +1,6 @@
"""Tests for the motion search hardware-accelerated decode helpers.""" """Tests for the motion search hardware-accelerated decode helpers."""
import subprocess as sp
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest import mock from unittest import mock
@@ -140,6 +141,11 @@ class TestVodDecodeCommand(unittest.TestCase):
class TestProbeVodKeyframePts(unittest.TestCase): class TestProbeVodKeyframePts(unittest.TestCase):
def _proc(self, stdout="", returncode=0):
proc = mock.Mock(returncode=returncode)
proc.communicate.return_value = (stdout, None)
return proc
def test_parses_keyframe_packets(self): def test_parses_keyframe_packets(self):
sample = ( sample = (
'{"packets":[' '{"packets":['
@@ -147,19 +153,37 @@ class TestProbeVodKeyframePts(unittest.TestCase):
'{"pts_time":"1.000000","flags":"___"},' '{"pts_time":"1.000000","flags":"___"},'
'{"pts_time":"2.000000","flags":"K__"}]}' '{"pts_time":"2.000000","flags":"K__"}]}'
) )
completed = mock.Mock(stdout=sample, returncode=0)
with mock.patch( with mock.patch(
"frigate.jobs.motion_search_decode.sp.run", return_value=completed "frigate.jobs.motion_search_decode.sp.Popen",
return_value=self._proc(sample),
): ):
pts = probe_vod_keyframe_pts("ffprobe", "http://x/index.m3u8") pts = probe_vod_keyframe_pts(
"ffprobe", "http://x/index.m3u8", lambda: False
)
self.assertEqual(pts, [0.0, 2.0]) self.assertEqual(pts, [0.0, 2.0])
def test_returns_empty_on_failure(self): def test_returns_empty_on_failure(self):
with mock.patch( with mock.patch(
"frigate.jobs.motion_search_decode.sp.run", "frigate.jobs.motion_search_decode.sp.Popen",
side_effect=OSError("boom"), side_effect=OSError("boom"),
): ):
self.assertEqual(probe_vod_keyframe_pts("ffprobe", "http://x"), []) self.assertEqual(
probe_vod_keyframe_pts("ffprobe", "http://x", lambda: False), []
)
def test_stop_request_kills_probe(self):
proc = self._proc()
proc.communicate.side_effect = [
sp.TimeoutExpired("ffprobe", 0.5),
("", None),
]
with mock.patch(
"frigate.jobs.motion_search_decode.sp.Popen", return_value=proc
):
self.assertEqual(
probe_vod_keyframe_pts("ffprobe", "http://x", lambda: True), []
)
proc.kill.assert_called_once()
class TestProbeVideoDimensions(unittest.TestCase): class TestProbeVideoDimensions(unittest.TestCase):
+67
View File
@@ -36,6 +36,73 @@ class TestListenConfig(unittest.TestCase):
self.assertEqual(listen.internal_port, 5001) self.assertEqual(listen.internal_port, 5001)
self.assertEqual(listen.external_port, 8971) self.assertEqual(listen.external_port, 8971)
def test_valid_listen_values_are_rebuilt(self):
cases = {
5000: 5000,
"5000": 5000,
"127.0.0.1:5000": "127.0.0.1:5000",
"0.0.0.0:5000": "0.0.0.0:5000",
"[::1]:5000": "[::1]:5000",
"[::]:5000": "[::]:5000",
"[2001:DB8:0:0::1]:5000": "[2001:db8::1]:5000",
" 5000 ": 5000,
"5000\n": 5000,
" 127.0.0.1:5000 ": "127.0.0.1:5000",
"localhost:5000": "localhost:5000",
"frigate-host.lan:5000": "frigate-host.lan:5000",
"*:5000": "*:5000",
}
for value, expected in cases.items():
with self.subTest(value=value):
listen = ListenConfig(internal=value)
self.assertEqual(listen.internal, expected)
self.assertEqual(listen.internal_port, 5000)
def test_invalid_listen_values_rejected(self):
cases = [
"5000; location /x/ { alias /; autoindex on; } #:5000",
"127.0.0.1:5000; include /etc/passwd",
"5000 ssl",
"5000;",
"::1:5000",
"[127.0.0.1]:5000",
"[fe80::1%eth0]:5000",
"local host:5000",
"localhost;:5000",
"local{host}:5000",
"host#:5000",
"'localhost':5000",
'"localhost":5000',
"-host:5000",
"host-:5000",
"host..lan:5000",
"**:5000",
"*.lan:5000",
"unix:/tmp/sock:5000",
"5000 5001",
":5000",
"127.0.0.1:",
"127.0.0.1:+5000",
0,
65536,
-1,
]
for value in cases:
with self.subTest(value=value):
with self.assertRaises(ValidationError):
ListenConfig(internal=value)
def test_bool_listen_value_rejected(self):
# get_nginx_settings.py sees the raw bool and falls back to the default,
# so accepting it as port 1 would split nginx and the auth port check
for value in (True, False):
with self.subTest(value=value):
with self.assertRaises(ValidationError):
ListenConfig(internal=value)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)
+1 -9
View File
@@ -3,7 +3,7 @@
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from frigate.config import AuthConfig, FrigateConfig from frigate.config import FrigateConfig
from frigate.output.ws_auth import ws_has_camera_access from frigate.output.ws_auth import ws_has_camera_access
@@ -55,11 +55,3 @@ class TestWsHasCameraAccess(unittest.TestCase):
self.assertFalse( self.assertFalse(
ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config) ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config)
) )
def test_replaced_auth_config_revokes_camera(self):
ws = self._make_ws("limited_user")
self.assertTrue(ws_has_camera_access(ws, "front_door", self.config))
self.config.auth = AuthConfig(roles={"limited_user": ["back_door"]})
self.assertFalse(ws_has_camera_access(ws, "front_door", self.config))
self.assertTrue(ws_has_camera_access(ws, "back_door", self.config))
+215 -1
View File
@@ -1,12 +1,13 @@
"""Tests for the device each model runner reports after loading.""" """Tests for the device each model runner reports after loading."""
import os import os
import sys
import tempfile import tempfile
import threading import threading
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from frigate.detectors import detection_runners from frigate.detectors import detection_runners, hardware
from frigate.detectors.detection_runners import ( from frigate.detectors.detection_runners import (
CudaGraphRunner, CudaGraphRunner,
ONNXModelRunner, ONNXModelRunner,
@@ -17,6 +18,7 @@ from frigate.detectors.detection_runners import (
record_loaded_device, record_loaded_device,
snapshot_loaded_devices, snapshot_loaded_devices,
) )
from frigate.util import model as model_util
class TestRunnerDeviceName(unittest.TestCase): class TestRunnerDeviceName(unittest.TestCase):
@@ -317,3 +319,215 @@ class TestLighterANE(unittest.TestCase):
get_optimized_runner("/models/arcface.onnx", "CPU", "arcface") get_optimized_runner("/models/arcface.onnx", "CPU", "arcface")
ane.assert_not_called() ane.assert_not_called()
class TestMIGraphX(unittest.TestCase):
"""AMD's MIGraphX plugin provider, selected through its devices in the ROCm image."""
def setUp(self):
loaded_devices.clear()
self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup)
for patcher in (
patch.object(hardware, "detect_amd_gpu", return_value=MagicMock()),
patch.object(detection_runners, "MODEL_CACHE_DIR", self.root.name),
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(detection_runners, "get_lighter_ane_devices", return_value=[]),
):
patcher.start()
self.addCleanup(patcher.stop)
self.plugin = MagicMock()
self.plugin.get_library_path.return_value = "/site/libmigraphx-ep.so"
def _device(self) -> MagicMock:
device = MagicMock()
device.ep_name = "MIGraphXExecutionProvider"
return device
def test_no_devices_outside_the_rocm_image(self):
with (
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": None}),
patch.object(
detection_runners.ort, "register_execution_provider_library"
) as register,
):
self.assertEqual(detection_runners.get_migraphx_devices(), [])
self.assertEqual(detection_runners.get_migraphx_devices(), [])
register.assert_not_called()
def test_no_devices_without_an_amd_gpu(self):
with (
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}),
patch.object(hardware, "detect_amd_gpu", return_value=None),
patch.object(detection_runners.ort, "get_ep_devices") as get_ep_devices,
patch.object(
detection_runners.ort, "register_execution_provider_library"
) as register,
):
self.assertEqual(detection_runners.get_migraphx_devices(), [])
get_ep_devices.assert_not_called()
register.assert_not_called()
def test_the_provider_is_registered_once(self):
device = self._device()
other = MagicMock()
other.ep_name = "CPUExecutionProvider"
with (
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}),
patch.object(
detection_runners.ort,
"get_ep_devices",
side_effect=[[other], [other, device], [other, device]],
),
patch.object(
detection_runners.ort, "register_execution_provider_library"
) as register,
):
self.assertEqual(detection_runners.get_migraphx_devices(), [device])
self.assertEqual(detection_runners.get_migraphx_devices(), [device])
register.assert_called_once_with(
"MIGraphXExecutionProvider", "/site/libmigraphx-ep.so"
)
def test_a_provider_that_will_not_load_is_skipped(self):
with (
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}),
patch.object(detection_runners.ort, "get_ep_devices", return_value=[]),
patch.object(
detection_runners.ort,
"register_execution_provider_library",
side_effect=RuntimeError("libamdhip64.so.7: cannot open shared object"),
),
self.assertLogs(detection_runners.logger, level="WARNING"),
):
self.assertEqual(detection_runners.get_migraphx_devices(), [])
def test_the_session_runs_on_the_first_gpu(self):
device = self._device()
session = MagicMock()
session.get_providers.return_value = [
"MIGraphXExecutionProvider",
"CPUExecutionProvider",
]
options = MagicMock()
with (
patch.object(
detection_runners,
"get_migraphx_devices",
return_value=[device, self._device()],
),
patch.object(
detection_runners, "get_ort_session_options", return_value=options
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
) as inference_session,
):
runner = get_optimized_runner("/models/yolo.onnx", "AUTO", "yolo-generic")
cache_dir = os.path.join(self.root.name, "migraphx/ort")
options.add_provider_for_devices.assert_called_once_with(
[device], {"cache_dir": cache_dir}
)
inference_session.assert_called_once_with(
"/models/yolo.onnx", sess_options=options
)
self.assertTrue(os.path.isdir(cache_dir))
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(
loaded_devices["/models/yolo.onnx"], ("yolo-generic", "MIGraphX")
)
def test_a_model_migraphx_cannot_load_uses_the_default_providers(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(
detection_runners,
"get_migraphx_devices",
return_value=[self._device()],
),
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners, "is_openvino_gpu_npu_available", return_value=False
),
patch.object(
detection_runners.ort,
"InferenceSession",
side_effect=[RuntimeError("compile failed"), session],
) as inference_session,
patch.object(
detection_runners, "get_ort_session_options", return_value=MagicMock()
),
self.assertLogs(detection_runners.logger, level="WARNING"),
):
runner = get_optimized_runner("/models/arcface.onnx", "AUTO", "arcface")
self.assertEqual(inference_session.call_count, 2)
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(loaded_devices["/models/arcface.onnx"], ("arcface", "CPU"))
def test_unsupported_models_and_cpu_models_skip_migraphx(self):
for model_path, device, model_type in [
("/models/paddleocr.onnx", "AUTO", "paddleocr"),
("/models/dfine.onnx", "AUTO", "dfine"),
("/models/yolo.onnx", "CPU", "yolo-generic"),
]:
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
self.subTest(model_type=model_type, device=device),
patch.object(
detection_runners,
"get_migraphx_devices",
return_value=[self._device()],
) as migraphx,
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners,
"is_openvino_gpu_npu_available",
return_value=False,
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
),
patch.object(
detection_runners, "get_ort_session_options", return_value=None
),
):
get_optimized_runner(model_path, device, model_type)
migraphx.assert_not_called()
self.assertEqual(loaded_devices[model_path], (model_type, "CPU"))
def test_migraphx_is_never_requested_by_name(self):
with patch.object(
model_util.ort,
"get_available_providers",
return_value=[
"AzureExecutionProvider",
"CPUExecutionProvider",
"MIGraphXExecutionProvider",
],
):
providers, options = model_util.get_ort_providers()
self.assertEqual(providers, ["CPUExecutionProvider"])
self.assertEqual(options, [{"enable_cpu_mem_arena": False}])
+76 -26
View File
@@ -3,8 +3,13 @@
import unittest import unittest
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
from frigate.api.notification import _validate_push_endpoint, _validate_subscription from frigate.api.notification import _validate_subscription
from frigate.comms.webpush import _build_web_pushers from frigate.comms.webpush import (
_build_web_pushers,
_NoRedirectSession,
_vapid_audience,
validate_push_endpoint,
)
VALID_ENDPOINTS = [ VALID_ENDPOINTS = [
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu", "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
@@ -26,11 +31,11 @@ class TestValidatePushEndpoint(unittest.TestCase):
def test_accepts_real_push_service_endpoints(self): def test_accepts_real_push_service_endpoints(self):
for endpoint in VALID_ENDPOINTS: for endpoint in VALID_ENDPOINTS:
with self.subTest(endpoint=endpoint): with self.subTest(endpoint=endpoint):
self.assertIsNone(_validate_push_endpoint(endpoint)) self.assertIsNone(validate_push_endpoint(endpoint))
def test_rejects_http(self): def test_rejects_http(self):
self.assertIsNotNone( self.assertIsNotNone(
_validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token") validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token")
) )
def test_rejects_non_http_schemes(self): def test_rejects_non_http_schemes(self):
@@ -40,7 +45,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
"//example.com/token", "//example.com/token",
): ):
with self.subTest(endpoint=endpoint): with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint)) self.assertIsNotNone(validate_push_endpoint(endpoint))
def test_rejects_localhost(self): def test_rejects_localhost(self):
for endpoint in ( for endpoint in (
@@ -50,7 +55,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
"https://[::1]/token", "https://[::1]/token",
): ):
with self.subTest(endpoint=endpoint): with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint)) self.assertIsNotNone(validate_push_endpoint(endpoint))
def test_rejects_private_addresses(self): def test_rejects_private_addresses(self):
for endpoint in ( for endpoint in (
@@ -61,7 +66,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
"https://0.0.0.0/token", "https://0.0.0.0/token",
): ):
with self.subTest(endpoint=endpoint): with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint)) self.assertIsNotNone(validate_push_endpoint(endpoint))
def test_rejects_internal_hostnames(self): def test_rejects_internal_hostnames(self):
for endpoint in ( for endpoint in (
@@ -71,16 +76,16 @@ class TestValidatePushEndpoint(unittest.TestCase):
"https://host.home.arpa/token", "https://host.home.arpa/token",
): ):
with self.subTest(endpoint=endpoint): with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint)) self.assertIsNotNone(validate_push_endpoint(endpoint))
def test_rejects_non_default_port(self): def test_rejects_non_default_port(self):
self.assertIsNotNone( self.assertIsNotNone(
_validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token") validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token")
) )
def test_rejects_embedded_credentials(self): def test_rejects_embedded_credentials(self):
self.assertIsNotNone( self.assertIsNotNone(
_validate_push_endpoint( validate_push_endpoint(
"https://user:pass@fcm.googleapis.com/fcm/send/token" "https://user:pass@fcm.googleapis.com/fcm/send/token"
) )
) )
@@ -88,26 +93,26 @@ class TestValidatePushEndpoint(unittest.TestCase):
def test_rejects_endpoint_without_path(self): def test_rejects_endpoint_without_path(self):
for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"): for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"):
with self.subTest(endpoint=endpoint): with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint)) self.assertIsNotNone(validate_push_endpoint(endpoint))
def test_rejects_endpoint_that_breaks_audience_parsing(self): def test_rejects_empty_hostname_labels(self):
# webpush.py locates the host by searching for a separator after index for endpoint in (
# 10, which raises ValueError when the url has no path at all "https://./x",
endpoint = "https://fcm.googleapis.com" "https://fcm..googleapis.com/token",
"https://.fcm.googleapis.com/token",
with self.assertRaises(ValueError): "https://fcm.googleapis.com./token",
endpoint.index("/", 10) ):
with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint)) self.assertIsNotNone(validate_push_endpoint(endpoint))
def test_rejects_missing_or_non_string_endpoint(self): def test_rejects_missing_or_non_string_endpoint(self):
for endpoint in (None, "", 5, {"url": "https://example.com/token"}): for endpoint in (None, "", 5, {"url": "https://example.com/token"}):
with self.subTest(endpoint=endpoint): with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint)) self.assertIsNotNone(validate_push_endpoint(endpoint))
def test_rejects_overlong_endpoint(self): def test_rejects_overlong_endpoint(self):
self.assertIsNotNone( self.assertIsNotNone(
_validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}") validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}")
) )
@@ -148,10 +153,6 @@ class TestValidateSubscription(unittest.TestCase):
self.assertIsNotNone(_validate_subscription(sub)) self.assertIsNotNone(_validate_subscription(sub))
if __name__ == "__main__":
unittest.main()
class TestBuildWebPushers(unittest.TestCase): class TestBuildWebPushers(unittest.TestCase):
def test_skips_subscriptions_with_malformed_keys(self): def test_skips_subscriptions_with_malformed_keys(self):
bad = _subscription(VALID_ENDPOINTS[0]) bad = _subscription(VALID_ENDPOINTS[0])
@@ -163,3 +164,52 @@ class TestBuildWebPushers(unittest.TestCase):
self.assertEqual(len(pushers), 1) self.assertEqual(len(pushers), 1)
self.assertEqual(pushers[0].receiver_key, b"\x04" + bytes(64)) self.assertEqual(pushers[0].receiver_key, b"\x04" + bytes(64))
def _valid_keys_subscription(self, endpoint: str) -> dict:
sub = _subscription(endpoint)
sub["keys"]["p256dh"] = urlsafe_b64encode(b"\x04" + bytes(64)).decode()
return sub
def test_skips_stored_rows_that_break_the_audience(self):
good = self._valid_keys_subscription(VALID_ENDPOINTS[0])
rows = [
self._valid_keys_subscription("https://./secret-token"),
self._valid_keys_subscription("https://[::1/secret-token"),
{"keys": good["keys"]},
"not-a-dict",
good,
]
with self.assertLogs("frigate.comms.webpush", level="WARNING") as logs:
pushers = _build_web_pushers("viewer", rows)
self.assertEqual(len(logs.output), 4)
self.assertIn("'.'", logs.output[0])
self.assertNotIn("secret-token", "".join(logs.output))
self.assertEqual(len(pushers), 1)
self.assertEqual(pushers[0].subscription_info["endpoint"], VALID_ENDPOINTS[0])
self.assertIsInstance(pushers[0].mod_or_session, _NoRedirectSession)
def test_keeps_stored_self_hosted_endpoints(self):
rows = [
self._valid_keys_subscription("https://ntfy.example.com:8443/up123"),
self._valid_keys_subscription("https://ntfy.example.com/"),
]
pushers = _build_web_pushers("viewer", rows)
self.assertEqual(len(pushers), 2)
class TestVapidAudience(unittest.TestCase):
def test_uses_scheme_and_host(self):
self.assertEqual(
_vapid_audience(VALID_ENDPOINTS[0]), "https://fcm.googleapis.com"
)
self.assertEqual(
_vapid_audience(VALID_ENDPOINTS[4]), "https://fcm.googleapis.com:443"
)
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
"""Tests that push sends never follow redirects."""
import unittest
import requests
from requests.adapters import BaseAdapter
from frigate.comms.webpush import _NoRedirectSession
ENDPOINT = "https://push.example.com/token"
class RedirectAdapter(BaseAdapter):
def __init__(self):
super().__init__()
self.calls = 0
def send(self, request, **kwargs):
self.calls += 1
response = requests.Response()
response.status_code = 307
response.headers["Location"] = "http://127.0.0.1:5000/api/restart"
response.url = request.url
response.request = request
return response
def close(self):
pass
class TestNoRedirectSession(unittest.TestCase):
def test_does_not_follow_redirects(self):
adapter = RedirectAdapter()
session = _NoRedirectSession()
session.mount("https://", adapter)
session.mount("http://", adapter)
response = session.post(ENDPOINT, data=b"payload")
self.assertEqual(response.status_code, 307)
self.assertEqual(adapter.calls, 1)
if __name__ == "__main__":
unittest.main()
-126
View File
@@ -1,126 +0,0 @@
"""Tests for the same-origin check on WebSocket handshakes."""
import unittest
from unittest.mock import MagicMock
from frigate.comms.ws import OriginCheckedWebSocketApplication, WebSocket
from frigate.util.origin import origin_allowed
def ws_origin_allowed(environ: dict[str, str]) -> bool:
return origin_allowed(
environ.get("HTTP_ORIGIN"),
environ.get("HTTP_HOST"),
environ.get("HTTP_X_FORWARDED_HOST"),
)
class TestOriginAllowed(unittest.TestCase):
def test_missing_origin_allowed(self):
self.assertTrue(ws_origin_allowed({"HTTP_HOST": "frigate.lan"}))
def test_direct_access_same_origin(self):
# nginx forwards Host without the port and X-Forwarded-Host with it
environ = {
"HTTP_ORIGIN": "http://192.168.1.10:5000",
"HTTP_HOST": "192.168.1.10",
"HTTP_X_FORWARDED_HOST": "192.168.1.10:5000",
}
self.assertTrue(ws_origin_allowed(environ))
def test_default_port_origin(self):
environ = {
"HTTP_ORIGIN": "https://frigate.example.com",
"HTTP_HOST": "frigate.example.com",
}
self.assertTrue(ws_origin_allowed(environ))
def test_host_comparison_is_case_insensitive(self):
environ = {
"HTTP_ORIGIN": "https://Frigate.Example.com",
"HTTP_HOST": "frigate.example.COM",
}
self.assertTrue(ws_origin_allowed(environ))
def test_ipv6_host(self):
environ = {"HTTP_ORIGIN": "http://[::1]:8971", "HTTP_HOST": "[::1]"}
self.assertTrue(ws_origin_allowed(environ))
def test_cross_origin_rejected(self):
environ = {
"HTTP_ORIGIN": "https://evil.example",
"HTTP_HOST": "frigate.lan",
"HTTP_X_FORWARDED_HOST": "frigate.lan:5000",
}
self.assertFalse(ws_origin_allowed(environ))
def test_null_origin_rejected(self):
self.assertFalse(
ws_origin_allowed({"HTTP_ORIGIN": "null", "HTTP_HOST": "frigate.lan"})
)
def test_proxy_rewriting_host_with_forwarded_host(self):
environ = {
"HTTP_ORIGIN": "https://frigate.example.com",
"HTTP_HOST": "10.0.0.5",
"HTTP_X_FORWARDED_HOST": "frigate.example.com",
}
self.assertTrue(ws_origin_allowed(environ))
def test_forwarded_host_list_uses_first_entry(self):
environ = {
"HTTP_ORIGIN": "https://frigate.example.com",
"HTTP_HOST": "10.0.0.5",
"HTTP_X_FORWARDED_HOST": "frigate.example.com, proxy.internal",
}
self.assertTrue(ws_origin_allowed(environ))
environ["HTTP_ORIGIN"] = "https://proxy.internal"
self.assertFalse(ws_origin_allowed(environ))
def test_home_assistant_ingress(self):
# HA core copies Host and sets X-Forwarded-Host to its own host
environ = {
"HTTP_ORIGIN": "https://ha.example.com:8123",
"HTTP_HOST": "ha.example.com",
"HTTP_X_FORWARDED_HOST": "ha.example.com:8123",
"HTTP_X_INGRESS_PATH": "/api/hassio_ingress/abc123",
}
self.assertTrue(ws_origin_allowed(environ))
def test_ingress_path_does_not_bypass_origin_check(self):
environ = {
"HTTP_ORIGIN": "https://evil.example",
"HTTP_HOST": "frigate.example.com",
"HTTP_X_INGRESS_PATH": "/frigate",
}
self.assertFalse(ws_origin_allowed(environ))
def test_home_assistant_integration_proxy(self):
# hass-web-proxy-lib drops Host, so Host is the integration's Frigate URL
environ = {
"HTTP_ORIGIN": "https://ha.example.com:8123",
"HTTP_HOST": "frigate.lan",
"HTTP_X_FORWARDED_HOST": "ha.example.com:8123",
}
self.assertTrue(ws_origin_allowed(environ))
class TestOriginCheckedWebSocketApplication(unittest.TestCase):
def test_cross_origin_handshake_gets_403(self):
app = OriginCheckedWebSocketApplication(handler_cls=WebSocket)
start_response = MagicMock()
environ = {
"REQUEST_METHOD": "GET",
"HTTP_ORIGIN": "https://evil.example",
"HTTP_HOST": "frigate.lan",
}
self.assertEqual(app(environ, start_response), [])
start_response.assert_called_once()
self.assertEqual(start_response.call_args.args[0], "403 Forbidden")
self.assertNotIn("ws4py.websocket", environ)
if __name__ == "__main__":
unittest.main()
+22 -4
View File
@@ -21,8 +21,11 @@ import numpy as np
from ruamel.yaml import YAML from ruamel.yaml import YAML
from frigate.const import ( from frigate.const import (
REGEX_HTTP_CAMERA_USER_PASS, REGEX_CREDENTIAL_QUERY_KEY,
REGEX_CREDENTIAL_QUERY_PARAM,
REGEX_MASKED_CREDENTIAL,
REGEX_RTSP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS,
REGEX_URL_USERINFO,
STREAM_TYPE_MAIN, STREAM_TYPE_MAIN,
STREAM_TYPE_SUB, STREAM_TYPE_SUB,
) )
@@ -117,9 +120,24 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
def clean_camera_user_pass(line: str) -> str: def clean_camera_user_pass(line: str) -> str:
"""Removes user and password from line.""" """Mask URL userinfo and credential query parameters in line."""
rtsp_cleaned = re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line)
return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", rtsp_cleaned) line = re.sub(REGEX_URL_USERINFO, _mask_userinfo, line)
return re.sub(REGEX_CREDENTIAL_QUERY_PARAM, r"\1*", line)
def _mask_userinfo(match: re.Match[str]) -> str:
# A credential key in the userinfo means a query password holds "@" or a
# password holds a query key. Neither split is safe, so mask the whole URL.
if re.search(REGEX_CREDENTIAL_QUERY_KEY, match.group(1)):
return "://*:*@"
return f"://*:*@{match.group(2)}"
def is_masked_camera_path(path: str) -> bool:
"""Return True if path holds credentials masked by clean_camera_user_pass."""
return re.search(REGEX_MASKED_CREDENTIAL, path) is not None
def escape_special_characters(path: str) -> str: def escape_special_characters(path: str) -> str:
+45
View File
@@ -1,8 +1,10 @@
"""configuration utils.""" """configuration utils."""
import asyncio import asyncio
import ipaddress
import logging import logging
import os import os
import re
import shutil import shutil
from functools import cache from functools import cache
from typing import Any from typing import Any
@@ -126,6 +128,49 @@ def redact_credential(obj: dict[str, Any], key: str) -> None:
obj.pop(key, None) obj.pop(key, None)
LISTEN_HOSTNAME = re.compile(
r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*"
)
def parse_listen_address(value: int | str) -> tuple[int | str, int]:
"""Validate a networking.listen value and rebuild it from its parsed parts.
Accepts a port, "host:port", "*:port", or "[IPv6]:port", where host is an
IPv4 address or an RFC 1123 hostname. nginx receives the rebuilt value
verbatim, so anything else raises ValueError.
Returns:
The normalized value (an int for a bare port) and the port
"""
if isinstance(value, bool) or not isinstance(value, int | str):
raise ValueError(f"invalid listen value: {value!r}")
host, sep, port_text = str(value).strip().rpartition(":")
if not re.fullmatch(r"[0-9]{1,5}", port_text) or not (1 <= int(port_text) <= 65535):
raise ValueError(f"invalid listen port: {value!r}")
port = int(port_text)
if not sep:
return port, port
if host.startswith("[") and host.endswith("]"):
address = ipaddress.IPv6Address(host[1:-1])
if address.scope_id:
raise ValueError(f"listen address must not have a scope ID: {value!r}")
return f"[{address}]:{port}", port
if host != "*" and not LISTEN_HOSTNAME.fullmatch(host):
raise ValueError(f"invalid listen address: {value!r}")
return f"{host}:{port}", port
def find_config_file() -> str: def find_config_file() -> str:
"""Return the path of the config file to use. """Return the path of the config file to use.
+3 -9
View File
@@ -385,15 +385,9 @@ def get_ort_providers(
} }
) )
elif provider == "MIGraphXExecutionProvider": elif provider == "MIGraphXExecutionProvider":
migraphx_cache_dir = os.path.join(MODEL_CACHE_DIR, "migraphx") # MIGraphX is a plugin provider, which ONNX Runtime can't create by
os.makedirs(migraphx_cache_dir, exist_ok=True) # name, so get_optimized_runner selects it through its devices
continue
providers.append(provider)
options.append(
{
"migraphx_model_cache_dir": migraphx_cache_dir,
}
)
elif provider == "CPUExecutionProvider": elif provider == "CPUExecutionProvider":
providers.append(provider) providers.append(provider)
options.append( options.append(
-31
View File
@@ -1,31 +0,0 @@
"""Same-origin check for browser WebSocket handshakes and go2rtc stream requests."""
from urllib.parse import urlsplit
def _hostname(value: str) -> str | None:
try:
return urlsplit(value).hostname
except ValueError:
return None
def origin_allowed(
origin: str | None, host: str | None, forwarded_host: str | None
) -> bool:
"""Return True unless a browser sent the request from a foreign origin.
Browsers always send Origin on a WebSocket handshake, so a missing Origin
means a non-browser client. A page cannot add headers to a handshake, so
X-Forwarded-Host comes from a proxy. Ports are ignored because nginx
forwards Host without one.
"""
if origin is None:
return True
origin_host = _hostname(origin)
if not origin_host:
return False
hosts = ((forwarded_host or "").split(",")[0].strip(), host or "")
return any(origin_host == _hostname(f"//{h}") for h in hosts if h)
+6 -1
View File
@@ -338,7 +338,12 @@ def process_frames(
continue continue
if datetime.now().astimezone(UTC) > next_region_update: if datetime.now().astimezone(UTC) > next_region_update:
region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name) updated_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name)
# A failed request replies empty, so keep the current grid
if updated_grid:
region_grid = updated_grid
next_region_update = get_tomorrow_at_time(2) next_region_update = get_tomorrow_at_time(2)
try: try:
File diff suppressed because one or more lines are too long
+44
View File
@@ -218,6 +218,50 @@ test.describe("Chat — streaming @medium", () => {
frigateApp.page.getByText(/searching for people/i), frigateApp.page.getByText(/searching for people/i),
).toBeVisible({ timeout: 5_000 }); ).toBeVisible({ timeout: 5_000 });
}); });
test("assistant markdown renders only same-origin images", async ({
frigateApp,
}) => {
const reply =
"![leaked alt](https://evil.example/x.png?d=secret) " +
"![local](/api/events/abc/thumbnail.webp) " +
"[offsite](https://evil.example/page)";
await installChatStreamOverride(frigateApp, [
{ type: "content", delta: reply },
{
type: "messages",
messages: [
{ role: "system", content: "sys" },
{ role: "user", content: "show me" },
{ role: "assistant", content: reply },
],
},
{ type: "done" },
]);
const offsiteRequests: string[] = [];
frigateApp.page.on("request", (req) => {
if (req.url().includes("evil.example")) offsiteRequests.push(req.url());
});
await frigateApp.goto("/chat");
const input = frigateApp.page.getByPlaceholder(/ask/i);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill("show me");
await input.press("Enter");
await expect(frigateApp.page.getByText("leaked alt")).toBeVisible({
timeout: 10_000,
});
await expect(
frigateApp.page.locator('img[src*="evil.example"]'),
).toHaveCount(0);
await expect(
frigateApp.page.locator('img[src="/api/events/abc/thumbnail.webp"]'),
).toHaveCount(1);
const link = frigateApp.page.getByRole("link", { name: "offsite" });
await expect(link).toHaveAttribute("target", "_blank");
await expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(offsiteRequests).toEqual([]);
});
}); });
test.describe("Chat — stop @medium", () => { test.describe("Chat — stop @medium", () => {
@@ -32,7 +32,11 @@ type CameraInput = {
input_args?: string; input_args?: string;
}; };
async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) { async function installRoutes(
page: Page,
frontDoorInputs: CameraInput[],
rawInputs: CameraInput[] = frontDoorInputs,
) {
const config = configFactory({ const config = configFactory({
go2rtc: { streams: GO2RTC_STREAMS }, go2rtc: { streams: GO2RTC_STREAMS },
cameras: { cameras: {
@@ -56,7 +60,7 @@ async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
await page.route("**/api/config/raw_paths", (route) => await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ route.fulfill({
json: { json: {
cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } }, cameras: { front_door: { ffmpeg: { inputs: rawInputs } } },
go2rtc: { streams: GO2RTC_STREAMS }, go2rtc: { streams: GO2RTC_STREAMS },
}, },
}), }),
@@ -200,4 +204,64 @@ test.describe("camera ffmpeg input source toggle @medium", () => {
payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0]; payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0];
expect(input?.input_args).not.toBe("preset-rtsp-restream"); expect(input?.input_args).not.toBe("preset-rtsp-restream");
}); });
test("a masked path reveals its own saved path after an earlier input is deleted", async ({
frigateApp,
}) => {
const main = "rtsp://admin:mainpw@10.0.0.1:554/main";
const sub = "rtsp://admin:subpw@10.0.0.1:554/sub";
await installRoutes(
frigateApp.page,
[
{ path: "rtsp://*:*@10.0.0.1:554/main", roles: ["record"] },
{ path: "rtsp://*:*@10.0.0.1:554/sub", roles: ["detect"] },
],
[
{ path: main, roles: ["record"] },
{ path: sub, roles: ["detect"] },
],
);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
const paths = frigateApp.page.getByRole("textbox", { name: "Input path" });
await expect(paths).toHaveCount(2);
await frigateApp.page
.getByRole("button", { name: "Delete" })
.first()
.click();
await expect(paths).toHaveCount(1);
await paths.focus();
await expect(paths).toHaveValue(sub);
});
test("inputs with the same masked path each reveal their own path by role", async ({
frigateApp,
}) => {
const record = "rtsp://admin:one@10.0.0.1:554/live";
const detect = "rtsp://viewer:two@10.0.0.1:554/live";
const masked = "rtsp://*:*@10.0.0.1:554/live";
await installRoutes(
frigateApp.page,
[
{ path: masked, roles: ["record"] },
{ path: masked, roles: ["detect"] },
],
[
{ path: record, roles: ["record"] },
{ path: detect, roles: ["detect"] },
],
);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
const paths = frigateApp.page.getByRole("textbox", { name: "Input path" });
await expect(paths).toHaveCount(2);
await paths.nth(0).focus();
await expect(paths.nth(0)).toHaveValue(record);
await paths.nth(1).focus();
await expect(paths.nth(1)).toHaveValue(detect);
});
}); });
+304 -425
View File
File diff suppressed because it is too large Load Diff
+22 -23
View File
@@ -23,28 +23,28 @@
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.2", "@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
"@radix-ui/react-alert-dialog": "^1.1.24", "@radix-ui/react-alert-dialog": "^1.2.1",
"@radix-ui/react-aspect-ratio": "^1.1.16", "@radix-ui/react-aspect-ratio": "^1.2.0",
"@radix-ui/react-checkbox": "^1.3.12", "@radix-ui/react-checkbox": "^1.4.1",
"@radix-ui/react-collapsible": "^1.1.21", "@radix-ui/react-collapsible": "^1.2.1",
"@radix-ui/react-context-menu": "^2.3.8", "@radix-ui/react-context-menu": "^2.4.1",
"@radix-ui/react-dialog": "^1.2.0", "@radix-ui/react-dialog": "^1.2.0",
"@radix-ui/react-dropdown-menu": "^2.1.25", "@radix-ui/react-dropdown-menu": "^2.2.1",
"@radix-ui/react-hover-card": "^1.1.24", "@radix-ui/react-hover-card": "^1.2.1",
"@radix-ui/react-label": "^2.1.16", "@radix-ui/react-label": "^2.1.17",
"@radix-ui/react-popover": "^1.2.0", "@radix-ui/react-popover": "^1.3.1",
"@radix-ui/react-progress": "^1.1.17", "@radix-ui/react-progress": "^1.2.1",
"@radix-ui/react-radio-group": "^1.4.8", "@radix-ui/react-radio-group": "^1.5.1",
"@radix-ui/react-scroll-area": "^1.3.0", "@radix-ui/react-scroll-area": "^1.4.0",
"@radix-ui/react-select": "^2.3.8", "@radix-ui/react-select": "^2.4.1",
"@radix-ui/react-separator": "^1.1.16", "@radix-ui/react-separator": "^1.2.0",
"@radix-ui/react-slider": "^1.5.0", "@radix-ui/react-slider": "^1.6.1",
"@radix-ui/react-slot": "1.4.0", "@radix-ui/react-slot": "1.5.0",
"@radix-ui/react-switch": "^1.3.8", "@radix-ui/react-switch": "^1.4.1",
"@radix-ui/react-tabs": "^1.1.22", "@radix-ui/react-tabs": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.19", "@radix-ui/react-toggle": "^1.2.1",
"@radix-ui/react-toggle-group": "^1.1.20", "@radix-ui/react-toggle-group": "^1.2.1",
"@radix-ui/react-tooltip": "^1.3.0", "@radix-ui/react-tooltip": "^1.4.1",
"@rjsf/core": "^6.10.1", "@rjsf/core": "^6.10.1",
"@rjsf/shadcn": "^6.10.1", "@rjsf/shadcn": "^6.10.1",
"@rjsf/utils": "^6.10.1", "@rjsf/utils": "^6.10.1",
@@ -127,7 +127,6 @@
}, },
"overrides": { "overrides": {
"@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-popper": "1.2.8", "@radix-ui/react-slot": "1.5.0"
"@radix-ui/react-slot": "1.4.0"
} }
} }
+1 -1
View File
@@ -40,7 +40,7 @@
}, },
"cookie_secure": { "cookie_secure": {
"label": "Secure cookie flag", "label": "Secure cookie flag",
"description": "Set the secure flag on the auth cookie; should be true when using TLS." "description": "Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS."
}, },
"session_length": { "session_length": {
"label": "Session length", "label": "Session length",
@@ -56,6 +56,8 @@
"noROI": "Please draw a region of interest", "noROI": "Please draw a region of interest",
"noTimeRange": "Please select a time range", "noTimeRange": "Please select a time range",
"invalidTimeRange": "End time must be after start time", "invalidTimeRange": "End time must be after start time",
"alreadyRunning": "You already have a motion search running. Wait for it to finish or cancel it.",
"tooManySearches": "Too many motion searches are running. Try again when one finishes.",
"searchFailed": "Search failed: {{message}}", "searchFailed": "Search failed: {{message}}",
"polygonTooSmall": "Polygon must have at least 3 points", "polygonTooSmall": "Polygon must have at least 3 points",
"unknown": "Unknown error" "unknown": "Unknown error"
+29
View File
@@ -18,6 +18,18 @@ import { cn } from "@/lib/utils";
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip"; import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
import { parseAttachedEvent } from "@/utils/chatUtil"; import { parseAttachedEvent } from "@/utils/chatUtil";
import type { ChatStats, ShowStatsMode } from "@/types/chat"; import type { ChatStats, ShowStatsMode } from "@/types/chat";
import { baseUrl } from "@/api/baseUrl";
// Assistant output can carry prompt-injected URLs, and the browser fetches
// images without a click, so only same-origin images render.
function isSameOrigin(url: string | undefined): boolean {
if (!url) return false;
try {
return new URL(url, baseUrl).origin === window.location.origin;
} catch {
return false;
}
}
type MessageBubbleProps = { type MessageBubbleProps = {
role: "user" | "assistant"; role: "user" | "assistant";
@@ -223,6 +235,23 @@ export function MessageBubble({
{...props} {...props}
/> />
), ),
img: ({ node: _n, src, alt, ...props }) =>
typeof src === "string" && isSameOrigin(src) ? (
<img src={src} alt={alt} {...props} />
) : (
<>{alt}</>
),
a: ({ node: _n, href, ...props }) =>
isSameOrigin(href) ? (
<a href={href} {...props} />
) : (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
}} }}
> >
{content} {content}
@@ -1,5 +1,7 @@
import type { WidgetProps } from "@rjsf/utils"; import type { WidgetProps } from "@rjsf/utils";
import useSWR from "swr"; import useSWR from "swr";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import { useMemo, useState, type FocusEvent } from "react"; import { useMemo, useState, type FocusEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu"; import { LuEye, LuEyeOff } from "react-icons/lu";
@@ -21,6 +23,7 @@ type RawPathsResponse = {
ffmpeg?: { ffmpeg?: {
inputs?: Array<{ inputs?: Array<{
path?: string; path?: string;
roles?: string[];
}>; }>;
}; };
} }
@@ -66,17 +69,35 @@ export function CameraPathWidget(props: WidgetProps) {
shouldFetchRawPaths ? "config/raw_paths" : null, shouldFetchRawPaths ? "config/raw_paths" : null,
); );
const rawValue = typeof value === "string" ? value : "";
const roles =
inputIndex === undefined
? undefined
: get(formContext?.formData, ["inputs", inputIndex, "roles"]);
// Match by masked value with roles breaking a tie, as the backend restores
// on save, so deleting or reordering inputs never reveals another input's path
const rawPath = useMemo(() => { const rawPath = useMemo(() => {
if (!cameraName || inputIndex === undefined) { if (!cameraName || !isMaskedPath(rawValue)) {
return undefined; return undefined;
} }
const path = let matches = (
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path; rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []
return typeof path === "string" ? path : undefined; ).filter(
}, [cameraName, inputIndex, rawPaths]); (input): input is { path: string; roles?: string[] } =>
typeof input.path === "string" &&
maskCredentials(input.path) === rawValue,
);
if (new Set(matches.map((input) => input.path)).size > 1) {
matches = matches.filter((input) => isEqual(input.roles, roles));
}
const paths = new Set(matches.map((input) => input.path));
return paths.size === 1 ? [...paths][0] : undefined;
}, [cameraName, rawPaths, rawValue, roles]);
const rawValue = typeof value === "string" ? value : "";
const resolvedValue = const resolvedValue =
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? ""); isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
const canReveal = const canReveal =
@@ -210,7 +210,7 @@ export function VideoPreview({
}, [playerRef]); }, [playerRef]);
const onProgressHover = useCallback( const onProgressHover = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => { (event: React.MouseEvent<HTMLElement>) => {
if (!sliderRef.current) { if (!sliderRef.current) {
return; return;
} }
@@ -401,7 +401,7 @@ export function InProgressPreview({
); );
const onProgressHover = useCallback( const onProgressHover = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => { (event: React.MouseEvent<HTMLElement>) => {
if (!sliderRef.current || !previewFrames) { if (!sliderRef.current || !previewFrames) {
return; return;
} }
+28 -31
View File
@@ -1,40 +1,37 @@
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i; // Mirrors clean_camera_user_pass and REGEX_MASKED_CREDENTIAL in the backend.
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i; const CREDENTIAL_QUERY_KEYS =
"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)";
const URL_USERINFO_PATTERN =
/:\/\/((?:[^\s/?#:[\]]*:(?:(?!:\/\/)\S)*|[^\s/?#]+))@((?:(?!:\/\/)\S)*)/g;
const CREDENTIAL_QUERY_KEY_PATTERN = new RegExp(
`[?&]${CREDENTIAL_QUERY_KEYS}=`,
"i",
);
const CREDENTIAL_QUERY_PATTERN = new RegExp(
`([?&]${CREDENTIAL_QUERY_KEYS}=)[^&\\s]*`,
"gi",
);
const MASKED_PATTERN = new RegExp(
`://\\*:\\*@|[?&]${CREDENTIAL_QUERY_KEYS}=\\*(?![^&\\s])`,
"i",
);
export const isMaskedPath = (value: string): boolean => export const isMaskedPath = (value: string): boolean =>
MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value); MASKED_PATTERN.test(value);
export const hasCredentials = (value: string): boolean => {
if (!value) {
return false;
}
if (isMaskedPath(value)) {
return true;
}
try {
const parsed = new URL(value);
if (parsed.username || parsed.password) {
return true;
}
return (
parsed.searchParams.has("user") && parsed.searchParams.has("password")
);
} catch {
return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
}
};
export const maskCredentials = (value: string): string => { export const maskCredentials = (value: string): string => {
if (!value) { if (!value) {
return value; return value;
} }
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@"); return value
.replace(URL_USERINFO_PATTERN, (_match, userinfo: string, rest: string) =>
return maskedAuth CREDENTIAL_QUERY_KEY_PATTERN.test(userinfo)
.replace(/([?&]user=)[^&]*/gi, "$1*") ? "://*:*@"
.replace(/([?&]password=)[^&]*/gi, "$1*"); : `://*:*@${rest}`,
)
.replace(CREDENTIAL_QUERY_PATTERN, "$1*");
}; };
export const hasCredentials = (value: string): boolean =>
!!value && (isMaskedPath(value) || maskCredentials(value) !== value);
@@ -892,6 +892,20 @@ export default function MotionSearchView({
setIsSearching(false); setIsSearching(false);
} }
} catch (error) { } catch (error) {
const status = axios.isAxiosError(error)
? error.response?.status
: undefined;
if (status === 409 || status === 429) {
toast.error(
t(
status === 409 ? "errors.alreadyRunning" : "errors.tooManySearches",
),
);
setIsSearching(false);
return;
}
let errorMessage = t("errors.unknown"); let errorMessage = t("errors.unknown");
if (axios.isAxiosError<{ message?: string; detail?: string }>(error)) { if (axios.isAxiosError<{ message?: string; detail?: string }>(error)) {
+1
View File
@@ -28,6 +28,7 @@ export default defineConfig({
}, },
"/live": { "/live": {
target: `ws://${proxyHost}`, target: `ws://${proxyHost}`,
changeOrigin: true,
ws: true, ws: true,
}, },
}, },