mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-07 22:23:00 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06c23ab51b | ||
|
|
e04035dfa2 |
@@ -14,11 +14,17 @@ fi
|
||||
# Tell S6-Overlay not to restart this service
|
||||
s6-svc -O .
|
||||
|
||||
# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages
|
||||
# The helper runs as root (-I skips user site-packages). The probe runs as the
|
||||
# service user when the service drops privileges; a root probe relies on
|
||||
# resolve_ffmpeg_path refusing runtime-user-writable paths.
|
||||
function set_libva_version() {
|
||||
local ffmpeg_path
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
local probe_as=()
|
||||
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||
probe_as=(s6-setuidgid frigate)
|
||||
fi
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py frigate)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
export LIBAVFORMAT_VERSION_MAJOR
|
||||
}
|
||||
|
||||
|
||||
@@ -57,10 +57,19 @@ function get_ip_and_port_from_supervisor() {
|
||||
export FRIGATE_GO2RTC_WEBRTC_CANDIDATE_INTERNAL="${ip_address}:${webrtc_port}"
|
||||
}
|
||||
|
||||
# The probe runs as the service user when the service drops privileges; a root
|
||||
# probe relies on resolve_ffmpeg_path refusing runtime-user-writable paths
|
||||
function set_libva_version() {
|
||||
local ffmpeg_path
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
local probe_as=()
|
||||
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||
probe_as=(s6-setuidgid go2rtc)
|
||||
fi
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py go2rtc)
|
||||
if ! LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+"); then
|
||||
echo "[WARN] Could not read the libavformat version from ${ffmpeg_path} as the go2rtc user; using the bundled build's version"
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "/usr/lib/ffmpeg/${DEFAULT_FFMPEG_VERSION}/bin/ffmpeg" -version | grep -Po "libavformat\W+\K\d+")
|
||||
fi
|
||||
export LIBAVFORMAT_VERSION_MAJOR
|
||||
}
|
||||
|
||||
|
||||
@@ -25,4 +25,5 @@ except FileNotFoundError:
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
path = config.get("ffmpeg", {}).get("path", "default")
|
||||
print(resolve_ffmpeg_path(path, "ffmpeg"))
|
||||
service = sys.argv[1] if len(sys.argv) > 1 else "frigate"
|
||||
print(resolve_ffmpeg_path(path, "ffmpeg", service))
|
||||
|
||||
@@ -103,7 +103,7 @@ if not is_valid_ffmpeg_path(path):
|
||||
print(
|
||||
"[ERROR] ffmpeg path contains spaces, quotes or shell characters, using the default ffmpeg instead."
|
||||
)
|
||||
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg")
|
||||
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg", "go2rtc")
|
||||
|
||||
for key in apply_go2rtc_ffmpeg_config(go2rtc_config, ffmpeg_path):
|
||||
print(
|
||||
|
||||
@@ -398,9 +398,9 @@ To do this:
|
||||
2. Update the `ffmpeg.path` in your Frigate config to `/config/custom-ffmpeg`.
|
||||
3. Restart Frigate and the custom version will be used if the steps above were done correctly.
|
||||
|
||||
Both binaries have to be executable by Frigate's unprivileged runtime user, so `chmod 755` them after extracting. The startup ownership sweep runs only once, so anything you add to `/config` later keeps whatever ownership and mode you gave it.
|
||||
Both binaries have to be executable by Frigate's unprivileged runtime user, so `chmod 755` them after extracting. go2rtc runs as its own user, so the build must be world-executable for go2rtc to use it (`ffmpeg:` sources, the birdseye restream and transcoded live streams). The startup ownership sweep runs only once, so anything you add to `/config` later keeps whatever ownership and mode you gave it.
|
||||
|
||||
There is one exception, and it only affects [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `frigate`. That mode runs Frigate as root while still handing `/config` to the unprivileged runtime user, so anything running as that user could swap the binary and gain root. A build inside any of Frigate's writable volumes (`/config`, `/media/frigate`, the cache and shm dirs) is ignored there and the bundled one is used, with a warning in the log. Keep the build somewhere root-owned (any absolute `ffmpeg.path` works, so a read-only bind mount such as `/opt/custom-ffmpeg` is enough) if you need both. The default mode and `FRIGATE_RUN_AS_ROOT=true` are unaffected and behave exactly as they always have.
|
||||
There is one exception, and it only affects [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `frigate` or `go2rtc`. That mode runs the listed service as root while still handing `/config` to the unprivileged runtime user, so anything running as that user could swap the binary and gain root. A build inside any of Frigate's writable volumes (`/config`, `/media/frigate`, the cache and shm dirs) is ignored by the root service, which uses the bundled one and logs a warning. A service that is not listed keeps using the custom build. Keep the build somewhere root-owned (any absolute `ffmpeg.path` works, so a read-only bind mount such as `/opt/custom-ffmpeg` is enough) if you need both. The default mode and `FRIGATE_RUN_AS_ROOT=true` are unaffected and behave exactly as they always have.
|
||||
|
||||
### Custom go2rtc version
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Tests for custom ffmpeg path resolution and the root-mode guard."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.const import DEFAULT_FFMPEG_VERSION
|
||||
from frigate.util.config import (
|
||||
_warn_ignored_ffmpeg_path,
|
||||
frigate_service_is_granular_root,
|
||||
resolve_ffmpeg_path,
|
||||
service_is_granular_root,
|
||||
)
|
||||
|
||||
BUNDLED = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
|
||||
@@ -21,12 +23,20 @@ class TestConfigFfmpegRootGuard(unittest.TestCase):
|
||||
# the warning is memoized so it fires once per path, not per camera
|
||||
_warn_ignored_ffmpeg_path.cache_clear()
|
||||
|
||||
def _resolve(self, path: str, *, euid: int, env: dict, binary: str = "ffmpeg"):
|
||||
def _resolve(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
euid: int,
|
||||
env: dict,
|
||||
binary: str = "ffmpeg",
|
||||
service: str = "frigate",
|
||||
):
|
||||
with (
|
||||
patch("os.geteuid", return_value=euid),
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
):
|
||||
return resolve_ffmpeg_path(path, binary)
|
||||
return resolve_ffmpeg_path(path, binary, service)
|
||||
|
||||
def test_custom_path_used_when_service_is_unprivileged(self) -> None:
|
||||
self.assertEqual(
|
||||
@@ -65,6 +75,59 @@ class TestConfigFfmpegRootGuard(unittest.TestCase):
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_custom_path_ignored_for_go2rtc_when_go2rtc_is_a_root_service(
|
||||
self,
|
||||
) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
CUSTOM,
|
||||
euid=0,
|
||||
env={"FRIGATE_ROOT_SERVICES": "go2rtc"},
|
||||
service="go2rtc",
|
||||
),
|
||||
BUNDLED,
|
||||
)
|
||||
|
||||
def test_go2rtc_keeps_custom_path_when_only_frigate_is_root(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
CUSTOM,
|
||||
euid=0,
|
||||
env={"FRIGATE_ROOT_SERVICES": "frigate"},
|
||||
service="go2rtc",
|
||||
),
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_go2rtc_custom_path_used_in_default_drop_mode(self) -> None:
|
||||
# the run scripts resolve as root but probe as the service user
|
||||
self.assertEqual(
|
||||
self._resolve(CUSTOM, euid=0, env={}, service="go2rtc"),
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_escape_hatch_keeps_go2rtc_custom_path(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
CUSTOM,
|
||||
euid=0,
|
||||
env={"FRIGATE_RUN_AS_ROOT": "true", "FRIGATE_ROOT_SERVICES": "go2rtc"},
|
||||
service="go2rtc",
|
||||
),
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_go2rtc_root_owned_path_is_left_alone(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
"/opt/custom-ffmpeg",
|
||||
euid=0,
|
||||
env={"FRIGATE_ROOT_SERVICES": "go2rtc"},
|
||||
service="go2rtc",
|
||||
),
|
||||
"/opt/custom-ffmpeg/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_root_services_has_no_effect_under_docker_user(self) -> None:
|
||||
# docker's own user: means the service never had root to keep, which is
|
||||
# also the case for get_ffmpeg_path.py in a --user container
|
||||
@@ -115,15 +178,65 @@ class TestConfigFfmpegRootGuard(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestFrigateServiceIsGranularRoot(unittest.TestCase):
|
||||
class TestConfigFfmpegRootGuardSymlinks(unittest.TestCase):
|
||||
"""A root service gets the resolved path, so a link cannot be retargeted."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
_warn_ignored_ffmpeg_path.cache_clear()
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
root = os.path.realpath(tmp.name)
|
||||
self.writable = os.path.join(root, "config")
|
||||
self.root_owned = os.path.join(root, "opt", "ffmpeg")
|
||||
os.makedirs(self.writable)
|
||||
os.makedirs(self.root_owned)
|
||||
self.link = os.path.join(self.writable, "link")
|
||||
dirs = patch("frigate.util.config.RUNTIME_USER_WRITABLE_DIRS", (self.writable,))
|
||||
dirs.start()
|
||||
self.addCleanup(dirs.stop)
|
||||
|
||||
def _resolve(self, env: dict, service: str = "frigate") -> str:
|
||||
with (
|
||||
patch("os.geteuid", return_value=0),
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
):
|
||||
return resolve_ffmpeg_path(self.link, "ffmpeg", service)
|
||||
|
||||
def test_link_to_a_root_owned_tree_resolves_to_its_target(self) -> None:
|
||||
os.symlink(self.root_owned, self.link)
|
||||
for service in ("frigate", "go2rtc"):
|
||||
self.assertEqual(
|
||||
self._resolve({"FRIGATE_ROOT_SERVICES": service}, service),
|
||||
f"{self.root_owned}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_link_to_a_writable_tree_is_refused(self) -> None:
|
||||
evil = os.path.join(self.writable, "evil")
|
||||
os.makedirs(evil)
|
||||
os.symlink(evil, self.link)
|
||||
self.assertEqual(self._resolve({"FRIGATE_ROOT_SERVICES": "frigate"}), BUNDLED)
|
||||
|
||||
def test_bin_linked_into_a_writable_tree_is_refused(self) -> None:
|
||||
evil = os.path.join(self.writable, "evil")
|
||||
os.makedirs(evil)
|
||||
os.symlink(evil, os.path.join(self.root_owned, "bin"))
|
||||
os.symlink(self.root_owned, self.link)
|
||||
self.assertEqual(self._resolve({"FRIGATE_ROOT_SERVICES": "frigate"}), BUNDLED)
|
||||
|
||||
def test_unprivileged_service_keeps_the_path_as_written(self) -> None:
|
||||
os.symlink(self.root_owned, self.link)
|
||||
self.assertEqual(self._resolve({}), f"{self.link}/bin/ffmpeg")
|
||||
|
||||
|
||||
class TestServiceIsGranularRoot(unittest.TestCase):
|
||||
"""Root via FRIGATE_ROOT_SERVICES only, never via the escape hatch."""
|
||||
|
||||
def _check(self, *, euid: int, env: dict) -> bool:
|
||||
def _check(self, *, euid: int, env: dict, service: str = "frigate") -> bool:
|
||||
with (
|
||||
patch("os.geteuid", return_value=euid),
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
):
|
||||
return frigate_service_is_granular_root()
|
||||
return service_is_granular_root(service)
|
||||
|
||||
def test_false_without_any_root_signal(self) -> None:
|
||||
self.assertFalse(self._check(euid=0, env={}))
|
||||
@@ -146,6 +259,12 @@ class TestFrigateServiceIsGranularRoot(unittest.TestCase):
|
||||
self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc,nginx"})
|
||||
)
|
||||
|
||||
def test_matches_the_named_service(self) -> None:
|
||||
env = {"FRIGATE_ROOT_SERVICES": "go2rtc"}
|
||||
self.assertTrue(self._check(euid=0, env=env, service="go2rtc"))
|
||||
self.assertFalse(self._check(euid=0, env=env, service="frigate"))
|
||||
self.assertFalse(self._check(euid=1000, env=env, service="go2rtc"))
|
||||
|
||||
def test_substring_of_a_service_name_does_not_match(self) -> None:
|
||||
self.assertFalse(self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "frigatee"}))
|
||||
|
||||
|
||||
+24
-15
@@ -46,14 +46,14 @@ DROPPED_DETECTOR_OPTIONS = {
|
||||
}
|
||||
|
||||
|
||||
# Trees the unprivileged runtime user can write. A root frigate service must
|
||||
# not execute a binary from any of them; a compromised uid-1000 process could
|
||||
# Trees the unprivileged runtime user can write. A root service must not
|
||||
# execute a binary from any of them; a compromised uid-1000 process could
|
||||
# plant one and be root after the next restart.
|
||||
RUNTIME_USER_WRITABLE_DIRS = (CONFIG_DIR, BASE_DIR, CACHE_DIR, "/dev/shm", "/tmp")
|
||||
|
||||
|
||||
def frigate_service_is_granular_root() -> bool:
|
||||
"""Report whether FRIGATE_ROOT_SERVICES runs frigate as root.
|
||||
def service_is_granular_root(service: str) -> bool:
|
||||
"""Report whether FRIGATE_ROOT_SERVICES runs the given service as root.
|
||||
|
||||
The escape hatch is excluded: it never sweeps /config and leaves no
|
||||
unprivileged service running, so custom binaries stay as safe as they
|
||||
@@ -66,7 +66,7 @@ def frigate_service_is_granular_root() -> bool:
|
||||
return False
|
||||
|
||||
entries = os.environ.get("FRIGATE_ROOT_SERVICES", "").split(",")
|
||||
return any("".join(entry.split()) == "frigate" for entry in entries)
|
||||
return any("".join(entry.split()) == service for entry in entries)
|
||||
|
||||
|
||||
def is_runtime_user_writable(path: str) -> bool:
|
||||
@@ -79,11 +79,12 @@ def is_runtime_user_writable(path: str) -> bool:
|
||||
|
||||
|
||||
@cache
|
||||
def _warn_ignored_ffmpeg_path(path: str) -> None:
|
||||
def _warn_ignored_ffmpeg_path(path: str, service: str) -> None:
|
||||
"""Warn once per path; resolution runs per camera and per binary."""
|
||||
logger.warning(
|
||||
"Ignoring ffmpeg.path %s because FRIGATE_ROOT_SERVICES runs frigate as root and that location is writable by the unprivileged user; using the bundled build",
|
||||
"Ignoring ffmpeg.path %s because FRIGATE_ROOT_SERVICES runs %s as root and that location is writable by the unprivileged user; using the bundled build",
|
||||
path,
|
||||
service,
|
||||
)
|
||||
|
||||
|
||||
@@ -97,16 +98,19 @@ def is_valid_ffmpeg_path(path: str) -> bool:
|
||||
return not path.startswith("/") or _CUSTOM_FFMPEG_PATH.fullmatch(path) is not None
|
||||
|
||||
|
||||
def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
|
||||
def resolve_ffmpeg_path(
|
||||
path: str, binary: str = "ffmpeg", service: str = "frigate"
|
||||
) -> str:
|
||||
"""Resolve an ffmpeg version alias or custom path to a binary path.
|
||||
|
||||
A bare version alias that is no longer bundled (for example one that was
|
||||
dropped when the default version changed) falls back to the default
|
||||
bundled version so existing configs keep working across an upgrade or a
|
||||
revert. Custom install paths (anything absolute) are used as-is, unless
|
||||
they fail `is_valid_ffmpeg_path` (which falls back to the default), or
|
||||
they are in a runtime-user-writable tree while FRIGATE_ROOT_SERVICES
|
||||
makes frigate root; see RUNTIME_USER_WRITABLE_DIRS.
|
||||
revert. Custom install paths (anything absolute) are used as-is. While
|
||||
FRIGATE_ROOT_SERVICES makes the service that execs it root, a custom path
|
||||
is resolved to its real location and refused inside a runtime-user-writable
|
||||
tree; see RUNTIME_USER_WRITABLE_DIRS. Paths that fail
|
||||
`is_valid_ffmpeg_path` also fall back to the default.
|
||||
"""
|
||||
if (
|
||||
path == "default"
|
||||
@@ -116,11 +120,16 @@ def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
|
||||
version = DEFAULT_FFMPEG_VERSION
|
||||
elif path in INCLUDED_FFMPEG_VERSIONS:
|
||||
version = path
|
||||
elif not service_is_granular_root(service):
|
||||
return f"{path}/bin/{binary}"
|
||||
else:
|
||||
if not (frigate_service_is_granular_root() and is_runtime_user_writable(path)):
|
||||
return f"{path}/bin/{binary}"
|
||||
# return the resolved form so a link in the path cannot be retargeted
|
||||
# after the check
|
||||
real = os.path.realpath(f"{path}/bin/{binary}")
|
||||
if is_valid_ffmpeg_path(real) and not is_runtime_user_writable(real):
|
||||
return real
|
||||
|
||||
_warn_ignored_ffmpeg_path(path)
|
||||
_warn_ignored_ffmpeg_path(path, service)
|
||||
version = DEFAULT_FFMPEG_VERSION
|
||||
|
||||
return f"/usr/lib/ffmpeg/{version}/bin/{binary}"
|
||||
|
||||
@@ -32,8 +32,8 @@ from pathlib import Path
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.util.config import (
|
||||
frigate_service_is_granular_root,
|
||||
is_runtime_user_writable,
|
||||
service_is_granular_root,
|
||||
)
|
||||
from frigate.util.file import FileLock
|
||||
|
||||
@@ -163,7 +163,7 @@ def _usable_reason() -> str | None:
|
||||
|
||||
base = user_base()
|
||||
|
||||
if frigate_service_is_granular_root() and is_runtime_user_writable(str(base)):
|
||||
if service_is_granular_root("frigate") and is_runtime_user_writable(str(base)):
|
||||
return (
|
||||
f"{base} is writable by the unprivileged user while "
|
||||
"FRIGATE_ROOT_SERVICES runs frigate as root"
|
||||
|
||||
Reference in New Issue
Block a user