Compare commits

..
Author SHA1 Message Date
Josh Hawkins e981ea326f use sub_filter to pass vod playlist args to segment URLs
Home Assistant signs vod playlist URLs with authSig and needs it on every init, segment, and index URL in the playlist. sub_filter appends the request args to the three URL endings vod writes, giving byte-identical playlists to the old secure_token output.
2026-11-07 15:38:23 -06:00
Josh Hawkins 3d157d77eb remove nginx-secure-token-module from the nginx build
Kaltura deleted the nginx-secure-token-module repo, so the tarball download in build_nginx.sh returned a 404 and any build without a cached nginx layer failed. This drops the module and its secure_token directives from the vod location.
2026-11-07 15:37:55 -06:00
10 changed files with 40 additions and 185 deletions
-7
View File
@@ -4,7 +4,6 @@ set -euxo pipefail
NGINX_VERSION="1.27.4"
VOD_MODULE_VERSION="v1.9.1"
SECURE_TOKEN_MODULE_VERSION="1.5"
SET_MISC_MODULE_VERSION="v0.33"
NGX_DEVEL_KIT_VERSION="v0.3.3"
@@ -52,11 +51,6 @@ patch -d /tmp/nginx-vod-module/ -p1 << 'EOF'
EOF
mkdir /tmp/nginx-secure-token-module
wget https://github.com/kaltura/nginx-secure-token-module/archive/refs/tags/${SECURE_TOKEN_MODULE_VERSION}.tar.gz
tar -zxf ${SECURE_TOKEN_MODULE_VERSION}.tar.gz -C /tmp/nginx-secure-token-module --strip-components=1
rm ${SECURE_TOKEN_MODULE_VERSION}.tar.gz
mkdir /tmp/ngx_devel_kit
wget https://github.com/vision5/ngx_devel_kit/archive/refs/tags/${NGX_DEVEL_KIT_VERSION}.tar.gz
tar -zxf ${NGX_DEVEL_KIT_VERSION}.tar.gz -C /tmp/ngx_devel_kit --strip-components=1
@@ -80,7 +74,6 @@ cd /tmp/nginx
--add-module=../ngx_devel_kit \
--add-module=../nginx-set-misc-module \
--add-module=../nginx-vod-module \
--add-module=../nginx-secure-token-module \
--with-cc-opt="-O3 -Wno-error=implicit-fallthrough"
make CC="ccache gcc" -j$(nproc) && make install
@@ -14,17 +14,11 @@ fi
# Tell S6-Overlay not to restart this service
s6-svc -O .
# 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.
# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages
function set_libva_version() {
local ffmpeg_path
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+")
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
export LIBAVFORMAT_VERSION_MAJOR
}
@@ -57,19 +57,10 @@ 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
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
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
export LIBAVFORMAT_VERSION_MAJOR
}
@@ -25,5 +25,4 @@ except FileNotFoundError:
config: dict[str, Any] = {}
path = config.get("ffmpeg", {}).get("path", "default")
service = sys.argv[1] if len(sys.argv) > 1 else "frigate"
print(resolve_ffmpeg_path(path, "ffmpeg", service))
print(resolve_ffmpeg_path(path, "ffmpeg"))
@@ -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", "go2rtc")
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg")
for key in apply_go2rtc_ffmpeg_config(go2rtc_config, ffmpeg_path):
print(
@@ -143,8 +143,14 @@ http {
# version 6 (RFC 8216 section 7); the module default is 4
vod_hls_version 6;
secure_token $args;
secure_token_types application/vnd.apple.mpegurl;
# Pass the playlist's query args (e.g. Home Assistant's authSig) on
# to every URL in it. These match the only URL endings vod writes
# with fmp4 and no subtitles.
sub_filter_types application/vnd.apple.mpegurl;
sub_filter_once off;
sub_filter '.m3u8' '.m3u8$is_args$args';
sub_filter '.m4s' '.m4s$is_args$args';
sub_filter '.mp4"' '.mp4$is_args$args"';
include security_headers.conf;
add_header Cache-Control "no-store";
+2 -2
View File
@@ -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. 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.
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.
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.
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.
### Custom go2rtc version
+6 -125
View File
@@ -1,15 +1,13 @@
"""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"
@@ -23,20 +21,12 @@ 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",
service: str = "frigate",
):
def _resolve(self, path: str, *, euid: int, env: dict, binary: str = "ffmpeg"):
with (
patch("os.geteuid", return_value=euid),
patch.dict("os.environ", env, clear=True),
):
return resolve_ffmpeg_path(path, binary, service)
return resolve_ffmpeg_path(path, binary)
def test_custom_path_used_when_service_is_unprivileged(self) -> None:
self.assertEqual(
@@ -75,59 +65,6 @@ 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
@@ -178,65 +115,15 @@ class TestConfigFfmpegRootGuard(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):
class TestFrigateServiceIsGranularRoot(unittest.TestCase):
"""Root via FRIGATE_ROOT_SERVICES only, never via the escape hatch."""
def _check(self, *, euid: int, env: dict, service: str = "frigate") -> bool:
def _check(self, *, euid: int, env: dict) -> bool:
with (
patch("os.geteuid", return_value=euid),
patch.dict("os.environ", env, clear=True),
):
return service_is_granular_root(service)
return frigate_service_is_granular_root()
def test_false_without_any_root_signal(self) -> None:
self.assertFalse(self._check(euid=0, env={}))
@@ -259,12 +146,6 @@ class TestServiceIsGranularRoot(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"}))
+15 -24
View File
@@ -46,14 +46,14 @@ DROPPED_DETECTOR_OPTIONS = {
}
# 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
# 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
# plant one and be root after the next restart.
RUNTIME_USER_WRITABLE_DIRS = (CONFIG_DIR, BASE_DIR, CACHE_DIR, "/dev/shm", "/tmp")
def service_is_granular_root(service: str) -> bool:
"""Report whether FRIGATE_ROOT_SERVICES runs the given service as root.
def frigate_service_is_granular_root() -> bool:
"""Report whether FRIGATE_ROOT_SERVICES runs frigate 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 service_is_granular_root(service: str) -> bool:
return False
entries = os.environ.get("FRIGATE_ROOT_SERVICES", "").split(",")
return any("".join(entry.split()) == service for entry in entries)
return any("".join(entry.split()) == "frigate" for entry in entries)
def is_runtime_user_writable(path: str) -> bool:
@@ -79,12 +79,11 @@ def is_runtime_user_writable(path: str) -> bool:
@cache
def _warn_ignored_ffmpeg_path(path: str, service: str) -> None:
def _warn_ignored_ffmpeg_path(path: str) -> None:
"""Warn once per path; resolution runs per camera and per binary."""
logger.warning(
"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",
"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",
path,
service,
)
@@ -98,19 +97,16 @@ 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", service: str = "frigate"
) -> str:
def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> 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. 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.
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.
"""
if (
path == "default"
@@ -120,16 +116,11 @@ def resolve_ffmpeg_path(
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:
# 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
if not (frigate_service_is_granular_root() and is_runtime_user_writable(path)):
return f"{path}/bin/{binary}"
_warn_ignored_ffmpeg_path(path, service)
_warn_ignored_ffmpeg_path(path)
version = DEFAULT_FFMPEG_VERSION
return f"/usr/lib/ffmpeg/{version}/bin/{binary}"
+2 -2
View File
@@ -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 service_is_granular_root("frigate") and is_runtime_user_writable(str(base)):
if frigate_service_is_granular_root() and is_runtime_user_writable(str(base)):
return (
f"{base} is writable by the unprivileged user while "
"FRIGATE_ROOT_SERVICES runs frigate as root"