mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-21 14:12:47 +03:00
* 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.
541 lines
17 KiB
Python
541 lines
17 KiB
Python
"""Utilities for builtin types manipulation."""
|
|
|
|
import ast
|
|
import copy
|
|
import logging
|
|
import math
|
|
import multiprocessing.queues
|
|
import queue
|
|
import re
|
|
import shlex
|
|
import struct
|
|
import time
|
|
import urllib.parse
|
|
from collections import deque
|
|
from collections.abc import Mapping
|
|
from multiprocessing.managers import ValueProxy
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import numpy as np
|
|
from ruamel.yaml import YAML
|
|
|
|
from frigate.const import (
|
|
REGEX_CREDENTIAL_QUERY_KEY,
|
|
REGEX_CREDENTIAL_QUERY_PARAM,
|
|
REGEX_MASKED_CREDENTIAL,
|
|
REGEX_RTSP_CAMERA_USER_PASS,
|
|
REGEX_URL_USERINFO,
|
|
STREAM_TYPE_MAIN,
|
|
STREAM_TYPE_SUB,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from frigate.config import CameraConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EventsPerSecond:
|
|
def __init__(self, max_events=1000, last_n_seconds=10) -> None:
|
|
self._start = None
|
|
self._max_events = max_events
|
|
self._last_n_seconds = last_n_seconds
|
|
self._timestamps: deque[float] = deque(maxlen=max_events)
|
|
|
|
def start(self) -> None:
|
|
self._start = time.monotonic()
|
|
|
|
def update(self) -> None:
|
|
now = time.monotonic()
|
|
if self._start is None:
|
|
self._start = now
|
|
self._timestamps.append(now)
|
|
self.expire_timestamps(now)
|
|
|
|
def eps(self) -> float:
|
|
now = time.monotonic()
|
|
if self._start is None:
|
|
self._start = now
|
|
# compute the (approximate) events in the last n seconds
|
|
self.expire_timestamps(now)
|
|
# rate over at least one second (or the whole window, if shorter),
|
|
# so a burst of events right after start() is not divided by a
|
|
# tiny window
|
|
seconds = max(
|
|
min(now - self._start, self._last_n_seconds),
|
|
min(1.0, self._last_n_seconds),
|
|
)
|
|
return len(self._timestamps) / seconds
|
|
|
|
# remove aged out timestamps
|
|
def expire_timestamps(self, now: float) -> None:
|
|
threshold = now - self._last_n_seconds
|
|
while self._timestamps and self._timestamps[0] < threshold:
|
|
self._timestamps.popleft()
|
|
|
|
|
|
class InferenceSpeed:
|
|
def __init__(self, metric: ValueProxy[float]) -> None:
|
|
self.__metric = metric
|
|
self.__initialized = False
|
|
|
|
def update(self, inference_time: float) -> None:
|
|
if not self.__initialized:
|
|
self.__metric.value = inference_time
|
|
self.__initialized = True
|
|
return
|
|
|
|
self.__metric.value = (self.__metric.value * 9 + inference_time) / 10
|
|
|
|
def current(self) -> float:
|
|
return self.__metric.value
|
|
|
|
|
|
def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dict:
|
|
"""
|
|
:param dct1: First dict to merge
|
|
:param dct2: Second dict to merge
|
|
:param override: if same key exists in both dictionaries, should override? otherwise ignore.
|
|
:param merge_lists: if True, lists will be merged.
|
|
:return: The merge dictionary
|
|
"""
|
|
merged = copy.deepcopy(dct1)
|
|
for k, v2 in dct2.items():
|
|
if k in merged:
|
|
v1 = merged[k]
|
|
if isinstance(v1, dict) and isinstance(v2, Mapping):
|
|
merged[k] = deep_merge(v1, v2, override)
|
|
elif isinstance(v1, list) and isinstance(v2, list):
|
|
if merge_lists:
|
|
merged[k] = v1 + v2
|
|
elif override:
|
|
merged[k] = copy.deepcopy(v2)
|
|
else:
|
|
if override:
|
|
merged[k] = copy.deepcopy(v2)
|
|
else:
|
|
merged[k] = copy.deepcopy(v2)
|
|
return merged
|
|
|
|
|
|
def clean_camera_user_pass(line: str) -> str:
|
|
"""Mask URL userinfo and credential query parameters in line."""
|
|
|
|
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:
|
|
"""Cleans reserved characters to encodings for ffmpeg."""
|
|
if len(path) > 1000:
|
|
raise ValueError("Input too long to check")
|
|
|
|
try:
|
|
found = re.search(REGEX_RTSP_CAMERA_USER_PASS, path).group(0)[3:-1]
|
|
pw = found[(found.index(":") + 1) :]
|
|
return path.replace(pw, urllib.parse.quote_plus(pw))
|
|
except AttributeError:
|
|
# path does not have user:pass
|
|
return path
|
|
|
|
|
|
def get_ffmpeg_arg_list(arg: Any) -> list:
|
|
"""Use arg if list or convert to list format."""
|
|
return arg if isinstance(arg, list) else shlex.split(arg)
|
|
|
|
|
|
# all built-in record presets use this segment_time
|
|
DEFAULT_RECORD_SEGMENT_TIME = 10
|
|
|
|
|
|
def get_record_segment_time(
|
|
config: "CameraConfig", stream_type: str = STREAM_TYPE_MAIN
|
|
) -> int:
|
|
"""Extract -segment_time from the camera's record output args for a stream."""
|
|
output_args = (
|
|
config.ffmpeg.output_args.effective_record_sub
|
|
if stream_type == STREAM_TYPE_SUB
|
|
else config.ffmpeg.output_args.record
|
|
)
|
|
record_args = get_ffmpeg_arg_list(output_args)
|
|
|
|
if record_args and record_args[0].startswith("preset"):
|
|
return DEFAULT_RECORD_SEGMENT_TIME
|
|
|
|
try:
|
|
idx = record_args.index("-segment_time")
|
|
return int(record_args[idx + 1])
|
|
except (ValueError, IndexError):
|
|
return DEFAULT_RECORD_SEGMENT_TIME
|
|
|
|
|
|
def load_labels(
|
|
path: str | None, encoding="utf-8", prefill=0, indexed: bool | None = None
|
|
):
|
|
"""Loads labels from file (with or without index numbers).
|
|
|
|
Only the indices the file defines are returned, so the result describes
|
|
exactly the classes a model can name. Callers must treat a missing index
|
|
as an unnamed class rather than assuming a contiguous range.
|
|
|
|
Args:
|
|
path: path to label file.
|
|
encoding: label file encoding.
|
|
prefill: pad indices below this with "unknown" before reading the file.
|
|
indexed: whether lines start with an index; auto-detected when None.
|
|
Returns:
|
|
Dictionary mapping indices to labels.
|
|
"""
|
|
if path is None:
|
|
return {}
|
|
|
|
with open(path, encoding=encoding) as f:
|
|
labels = {index: "unknown" for index in range(prefill)}
|
|
lines = f.readlines()
|
|
if not lines:
|
|
return {}
|
|
|
|
if indexed != False and lines[0].split(" ", maxsplit=1)[0].isdigit():
|
|
pairs = [line.split(" ", maxsplit=1) for line in lines]
|
|
labels.update({int(index): label.strip() for index, label in pairs})
|
|
else:
|
|
labels.update({index: line.strip() for index, line in enumerate(lines)})
|
|
|
|
return labels
|
|
|
|
|
|
def to_relative_box(
|
|
width: int, height: int, box: tuple[int, int, int, int]
|
|
) -> tuple[int | float, int | float, int | float, int | float]:
|
|
return (
|
|
box[0] / width, # x
|
|
box[1] / height, # y
|
|
(box[2] - box[0]) / width, # w
|
|
(box[3] - box[1]) / height, # h
|
|
)
|
|
|
|
|
|
def create_mask(frame_shape, mask):
|
|
mask_img = np.zeros(frame_shape, np.uint8)
|
|
mask_img[:] = 255
|
|
|
|
|
|
def process_config_query_string(query_string: dict[str, list]) -> dict[str, Any]:
|
|
updates = {}
|
|
for key_path_str, new_value_list in query_string.items():
|
|
# use the string key as-is for updates dictionary
|
|
if len(new_value_list) > 1:
|
|
updates[key_path_str] = new_value_list
|
|
else:
|
|
value = new_value_list[0]
|
|
try:
|
|
# no need to convert if we have a mask/zone string
|
|
value = ast.literal_eval(value) if "," not in value else value
|
|
except (ValueError, SyntaxError):
|
|
pass
|
|
updates[key_path_str] = value
|
|
return updates
|
|
|
|
|
|
def flatten_config_data(
|
|
config_data: dict[str, Any], parent_key: str = ""
|
|
) -> dict[str, Any]:
|
|
items = []
|
|
for key, value in config_data.items():
|
|
escaped_key = escape_config_key_segment(str(key))
|
|
new_key = f"{parent_key}.{escaped_key}" if parent_key else escaped_key
|
|
if isinstance(value, dict):
|
|
items.extend(flatten_config_data(value, new_key).items())
|
|
else:
|
|
items.append((new_key, value))
|
|
return dict(items)
|
|
|
|
|
|
def escape_config_key_segment(segment: str) -> str:
|
|
"""Escape dots and backslashes so they can be treated as literal key chars."""
|
|
return segment.replace("\\", "\\\\").replace(".", "\\.")
|
|
|
|
|
|
def split_config_key_path(key_path_str: str) -> list[str]:
|
|
"""Split a dotted config path, honoring \\. as a literal dot in a key."""
|
|
parts: list[str] = []
|
|
current: list[str] = []
|
|
escaped = False
|
|
|
|
for char in key_path_str:
|
|
if escaped:
|
|
current.append(char)
|
|
escaped = False
|
|
continue
|
|
|
|
if char == "\\":
|
|
escaped = True
|
|
continue
|
|
|
|
if char == ".":
|
|
parts.append("".join(current))
|
|
current = []
|
|
continue
|
|
|
|
current.append(char)
|
|
|
|
if escaped:
|
|
current.append("\\")
|
|
|
|
parts.append("".join(current))
|
|
return parts
|
|
|
|
|
|
def update_yaml_file_bulk(file_path: str, updates: dict[str, Any]):
|
|
yaml = YAML()
|
|
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
|
|
try:
|
|
with open(file_path) as f:
|
|
data = yaml.load(f)
|
|
except FileNotFoundError:
|
|
logger.error(
|
|
f"Unable to read from Frigate config file {file_path}. Make sure it exists and is readable."
|
|
)
|
|
return
|
|
|
|
# Apply all updates
|
|
for key_path_str, new_value in updates.items():
|
|
key_path = split_config_key_path(key_path_str)
|
|
for i in range(len(key_path)):
|
|
try:
|
|
index = int(key_path[i])
|
|
key_path[i] = (key_path[i - 1], index)
|
|
key_path.pop(i - 1)
|
|
except ValueError:
|
|
pass
|
|
data = update_yaml(data, key_path, new_value)
|
|
|
|
try:
|
|
with open(file_path, "w") as f:
|
|
yaml.dump(data, f)
|
|
except Exception as e:
|
|
logger.error(f"Unable to write to Frigate config file {file_path}: {e}")
|
|
|
|
|
|
def clear_orphaned_comments(collection, parent, parent_key) -> None:
|
|
"""Drop stale ruamel comment tokens after a deletion empties a collection.
|
|
|
|
When the last entry of a mapping or sequence is removed, any comments that
|
|
lived inside that collection's block are orphaned. ruamel then emits them
|
|
above a flow-style `{}`/`[]` dedented to column 0, which is unparseable and
|
|
corrupts the config. Clearing the emptied collection's own comment metadata
|
|
(and the parent's entry pointing at it) keeps the dump valid. Non-empty
|
|
collections are left untouched so comments on remaining siblings survive.
|
|
"""
|
|
if not hasattr(collection, "ca") or len(collection) != 0:
|
|
return
|
|
|
|
collection.ca.items.clear()
|
|
collection.ca.comment = None
|
|
if parent is not None and hasattr(parent, "ca"):
|
|
parent.ca.items.pop(parent_key, None)
|
|
|
|
|
|
def update_yaml(data, key_path, new_value):
|
|
temp = data
|
|
parent = None
|
|
parent_key = None
|
|
for key in key_path[:-1]:
|
|
if isinstance(key, tuple):
|
|
if key[0] not in temp:
|
|
temp[key[0]] = [{}] * max(1, key[1] + 1)
|
|
elif len(temp[key[0]]) <= key[1]:
|
|
temp[key[0]] += [{}] * (key[1] - len(temp[key[0]]) + 1)
|
|
parent, parent_key = temp[key[0]], key[1]
|
|
temp = temp[key[0]][key[1]]
|
|
else:
|
|
if key not in temp or temp[key] is None:
|
|
temp[key] = {}
|
|
parent, parent_key = temp, key
|
|
temp = temp[key]
|
|
|
|
last_key = key_path[-1]
|
|
if new_value == "":
|
|
if isinstance(last_key, tuple):
|
|
del temp[last_key[0]][last_key[1]]
|
|
clear_orphaned_comments(temp[last_key[0]], temp, last_key[0])
|
|
else:
|
|
del temp[last_key]
|
|
clear_orphaned_comments(temp, parent, parent_key)
|
|
else:
|
|
if isinstance(last_key, tuple):
|
|
if last_key[0] not in temp:
|
|
temp[last_key[0]] = [{}] * max(1, last_key[1] + 1)
|
|
elif len(temp[last_key[0]]) <= last_key[1]:
|
|
temp[last_key[0]] += [{}] * (last_key[1] - len(temp[last_key[0]]) + 1)
|
|
temp[last_key[0]][last_key[1]] = new_value
|
|
else:
|
|
if (
|
|
last_key in temp
|
|
and isinstance(temp[last_key], dict)
|
|
and isinstance(new_value, dict)
|
|
):
|
|
temp[last_key].update(new_value)
|
|
else:
|
|
temp[last_key] = new_value
|
|
|
|
return data
|
|
|
|
|
|
def find_by_key(dictionary, target_key):
|
|
if target_key in dictionary:
|
|
return dictionary[target_key]
|
|
else:
|
|
for value in dictionary.values():
|
|
if isinstance(value, dict):
|
|
result = find_by_key(value, target_key)
|
|
if result is not None:
|
|
return result
|
|
return None
|
|
|
|
|
|
def clear_and_unlink(file: Path, missing_ok: bool = True) -> None:
|
|
"""clear file then unlink to avoid space retained by file descriptors."""
|
|
if not missing_ok and not file.exists():
|
|
raise FileNotFoundError()
|
|
|
|
# empty contents of file before unlinking https://github.com/blakeblackshear/frigate/issues/4769
|
|
with open(file, "w"):
|
|
pass
|
|
|
|
file.unlink(missing_ok=missing_ok)
|
|
|
|
|
|
def empty_and_close_queue(q):
|
|
while True:
|
|
try:
|
|
q.get(block=True, timeout=0.5)
|
|
except (queue.Empty, EOFError):
|
|
break
|
|
except Exception as e:
|
|
logger.debug(f"Error while emptying queue: {e}")
|
|
break
|
|
|
|
# close the queue if it is a multiprocessing queue
|
|
# manager proxy queues do not have close or join_thread method
|
|
if isinstance(q, multiprocessing.queues.Queue):
|
|
try:
|
|
q.close()
|
|
q.join_thread()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def generate_color_palette(n):
|
|
# mimic matplotlib's color scheme
|
|
base_colors = [
|
|
(31, 119, 180), # blue
|
|
(255, 127, 14), # orange
|
|
(44, 160, 44), # green
|
|
(214, 39, 40), # red
|
|
(148, 103, 189), # purple
|
|
(140, 86, 75), # brown
|
|
(227, 119, 194), # pink
|
|
(127, 127, 127), # gray
|
|
(188, 189, 34), # olive
|
|
(23, 190, 207), # cyan
|
|
]
|
|
|
|
def interpolate(color1, color2, factor):
|
|
return tuple(int(c1 + (c2 - c1) * factor) for c1, c2 in zip(color1, color2))
|
|
|
|
if n <= len(base_colors):
|
|
return base_colors[:n]
|
|
|
|
colors = base_colors.copy()
|
|
step = 1 / (n - len(base_colors) + 1)
|
|
extra_colors_needed = n - len(base_colors)
|
|
|
|
# interpolate between the base colors to generate more if needed
|
|
for i in range(extra_colors_needed):
|
|
index = i % (len(base_colors) - 1)
|
|
factor = (i + 1) * step
|
|
color1 = base_colors[index]
|
|
color2 = base_colors[index + 1]
|
|
colors.append(interpolate(color1, color2, factor))
|
|
|
|
return colors
|
|
|
|
|
|
def serialize(vector: list[float] | np.ndarray | float, pack: bool = True) -> bytes:
|
|
"""Serializes a list of floats, numpy array, or single float into a compact "raw bytes" format"""
|
|
if isinstance(vector, np.ndarray):
|
|
# Convert numpy array to list of floats
|
|
vector = vector.flatten().tolist()
|
|
elif isinstance(vector, (float, np.float32, np.float64)):
|
|
# Handle single float values
|
|
vector = [vector]
|
|
elif not isinstance(vector, list):
|
|
raise TypeError(
|
|
f"Input must be a list of floats, a numpy array, or a single float. Got {type(vector)}"
|
|
)
|
|
|
|
try:
|
|
if pack:
|
|
return struct.pack("%sf" % len(vector), *vector)
|
|
else:
|
|
return vector
|
|
except struct.error as e:
|
|
raise ValueError(f"Failed to pack vector: {e}. Vector: {vector}") from e
|
|
|
|
|
|
def deserialize(bytes_data: bytes) -> list[float]:
|
|
"""Deserializes a compact "raw bytes" format into a list of floats"""
|
|
return list(struct.unpack("%sf" % (len(bytes_data) // 4), bytes_data))
|
|
|
|
|
|
def sanitize_float(value):
|
|
"""Replace NaN or inf with 0.0."""
|
|
if isinstance(value, (int, float)) and not math.isfinite(value):
|
|
return 0.0
|
|
return value
|
|
|
|
|
|
def has_non_finite_number(value: Any) -> bool:
|
|
"""Return True if any number in a parsed JSON value is NaN or infinite."""
|
|
if isinstance(value, float):
|
|
return not math.isfinite(value)
|
|
if isinstance(value, dict):
|
|
return any(has_non_finite_number(v) for v in value.values())
|
|
if isinstance(value, list):
|
|
return any(has_non_finite_number(v) for v in value)
|
|
|
|
return False
|
|
|
|
|
|
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
|
return 1 - cosine_distance(a, b)
|
|
|
|
|
|
def cosine_distance(a: np.ndarray, b: np.ndarray) -> float:
|
|
"""Returns cosine distance to match sqlite-vec's calculation."""
|
|
dot = np.dot(a, b)
|
|
a_mag = np.dot(a, a) # ||a||^2
|
|
b_mag = np.dot(b, b) # ||b||^2
|
|
|
|
if a_mag == 0 or b_mag == 0:
|
|
return 1.0
|
|
|
|
return 1.0 - (dot / (np.sqrt(a_mag) * np.sqrt(b_mag)))
|