From 3068cf942afbbd61944bc319c69ce32cd7a03930 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Thu, 25 Jun 2026 06:15:39 +0800 Subject: [PATCH 1/8] feat(access-log): add configurable access logging --- CMakeLists.txt | 1 + docs/en/reference/configuration.md | 46 ++ docs/reference/configuration.md | 46 ++ e2e/test_access_log.py | 212 ++++++++ .../luci-static/resources/view/rtp2httpd.js | 37 ++ .../po/templates/rtp2httpd.pot | 27 + .../po/zh_Hans/rtp2httpd.po | 31 ++ .../rtp2httpd/files/rtp2httpd.conf | 2 + .../rtp2httpd/files/rtp2httpd.init | 2 + rtp2httpd.conf | 6 + src/access_log.c | 467 ++++++++++++++++++ src/access_log.h | 11 + src/configuration.c | 54 ++ src/configuration.h | 4 + src/connection.c | 3 + src/supervisor.c | 2 + src/worker.c | 2 + 17 files changed, 953 insertions(+) create mode 100644 e2e/test_access_log.py create mode 100644 src/access_log.c create mode 100644 src/access_log.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 232bf1fc..89ff2ff9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,6 +34,7 @@ set(COMMON_SOURCES src/rtp2httpd.c src/supervisor.c src/configuration.c + src/access_log.c src/http.c src/http_fetch.c src/service.c diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index 13179a7b..798f7a5a 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -71,6 +71,8 @@ Unix socket listen paths must be absolute and must not contain whitespace. At st - `-v, --verbose` - Logging verbosity (0=FATAL, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG) - `-q, --quiet` - Show only fatal errors +- `--access-log ` - Write access logs to the specified file (default: disabled) +- `--log-format ` - Access log format using nginx-style `$variable` placeholders ### Security Control @@ -120,6 +122,14 @@ Configuration file path: `/etc/rtp2httpd.conf`. Lines starting with `#` or `;` a # Logging verbosity: 0=FATAL 1=ERROR 2=WARN 3=INFO 4=DEBUG verbosity = 3 +# Access log file path (default: disabled) +# Each media request writes one access log line at connection start +access_log = /var/log/rtp2httpd/access.log + +# Access log format (nginx-style $variables) +# Default: $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" +log_format = $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" + # Maximum concurrent clients maxclients = 20 @@ -273,6 +283,41 @@ rtp://239.253.64.120:5140 rtp://239.253.64.121:5140 ``` +### Access Log Placeholders + +When `access_log` is empty or unset, access logging is disabled. + +`log_format` supports these placeholders: + +- Time: `$time_iso8601`, `$time_local`, `$msec` +- Client and worker: `$client_addr`, `$remote_addr`, `$remote_port`, `$worker_pid` +- Request: `$request`, `$request_method`, `$service_url`, `$host`, `$http_user_agent`, `$http_x_forwarded_for` +- Stream: `$service_type`, `$upstream_url` +- Literal: `$$` outputs `$` + +`$client_addr` matches the client address shown on the status page. When the request's `X-Forwarded-For` is accepted, for example after enabling `xff`, it uses the first address from `X-Forwarded-For`; in that case there is usually no port, so `$remote_port` outputs `-`. + +Example logrotate config: + +```text +/var/log/rtp2httpd/access.log { + daily + rotate 7 + missingok + notifempty + compress + create 0644 root root + sharedscripts + postrotate + for pid in $(pidof rtp2httpd); do + ppid="$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null)" + [ "$(cat "/proc/$ppid/comm" 2>/dev/null)" = "rtp2httpd" ] && continue + kill -HUP "$pid" 2>/dev/null || true + done + endscript +} +``` + ## Runtime Configuration Management rtp2httpd supports configuration hot reload: after editing the configuration file, trigger a reload via signal or the status page to apply changes without restarting the entire process. rtp2httpd uses a supervisor + worker multi-process architecture. Signals must be sent to the **supervisor process** (the main `rtp2httpd` process, not worker child processes). @@ -300,6 +345,7 @@ kill -USR1 12345 - If `[bind]` listen addresses change, the supervisor sends `SIGTERM` to all workers and respawns them to apply the new listen addresses - If the `workers` count changes, the supervisor automatically adds or removes worker processes - For other configuration changes, the supervisor forwards `SIGHUP` to each worker, which applies them at runtime +- Workers reopen the access log file during reload, which helps with logrotate - If the config file fails to parse, the old configuration is kept and existing connections are not interrupted > [!NOTE] diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index e67e6a4c..eb55b757 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -71,6 +71,8 @@ Unix socket 监听路径必须是绝对路径,且路径中不能包含空白 - `-v, --verbose` - 日志详细程度 (0=FATAL, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG) - `-q, --quiet` - 仅显示致命错误 +- `--access-log <路径>` - 将访问日志写入指定文件 (默认: 禁用) +- `--log-format <格式>` - 访问日志格式,使用类似 nginx 的 `$变量` 占位符 ### 安全控制 @@ -120,6 +122,14 @@ Unix socket 监听路径必须是绝对路径,且路径中不能包含空白 # 日志详细程度: 0=FATAL 1=ERROR 2=WARN 3=INFO 4=DEBUG verbosity = 3 +# 访问日志文件路径(默认: 禁用) +# 每个媒体请求会在连接开始时记录一行访问日志 +access_log = /var/log/rtp2httpd/access.log + +# 访问日志格式(nginx 风格 $变量) +# 默认: $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" +log_format = $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" + # 最大并发客户端数 maxclients = 20 @@ -272,6 +282,41 @@ rtp://239.253.64.120:5140 rtp://239.253.64.121:5140 ``` +### 访问日志占位符 + +`access_log` 留空或不配置时不会记录访问日志。 + +`log_format` 支持以下占位符: + +- 时间:`$time_iso8601`、`$time_local`、`$msec` +- 客户端与工作进程:`$client_addr`、`$remote_addr`、`$remote_port`、`$worker_pid` +- 请求信息:`$request`、`$request_method`、`$service_url`、`$host`、`$http_user_agent`、`$http_x_forwarded_for` +- 流信息:`$service_type`、`$upstream_url` +- 字面量:`$$` 输出 `$` + +`$client_addr` 与状态页面显示的客户端地址一致。当请求中的 `X-Forwarded-For` 被接受时(例如启用 `xff` 后),它会使用 `X-Forwarded-For` 中的第一个地址;这种情况下通常没有端口,因此 `$remote_port` 输出 `-`。 + +logrotate 配置示例: + +```text +/var/log/rtp2httpd/access.log { + daily + rotate 7 + missingok + notifempty + compress + create 0644 root root + sharedscripts + postrotate + for pid in $(pidof rtp2httpd); do + ppid="$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null)" + [ "$(cat "/proc/$ppid/comm" 2>/dev/null)" = "rtp2httpd" ] && continue + kill -HUP "$pid" 2>/dev/null || true + done + endscript +} +``` + ## 运行时配置管理 rtp2httpd 支持配置热重载:修改配置文件后,通过发送信号或状态页面触发重载,即可应用变更,无需重启整个进程。rtp2httpd 采用 supervisor + worker 多进程架构,信号应发送给 **supervisor 进程**(即主 `rtp2httpd` 进程,而非 worker 子进程)。 @@ -299,6 +344,7 @@ kill -USR1 12345 - 若 `[bind]` 监听地址发生变化,supervisor 会向所有工作进程发送 `SIGTERM` 并重新拉起,以应用新的监听地址 - 若 `workers` 数量发生变化,supervisor 会自动增减工作进程 - 其他配置变更会转发 `SIGHUP` 给各工作进程,由工作进程在运行时应用 +- 工作进程会在重载时重新打开访问日志文件,便于配合 logrotate - 若配置文件解析失败,保留旧配置,不会中断现有连接 > [!NOTE] diff --git a/e2e/test_access_log.py b/e2e/test_access_log.py new file mode 100644 index 00000000..ab610007 --- /dev/null +++ b/e2e/test_access_log.py @@ -0,0 +1,212 @@ +""" +E2E tests for access log configuration and formatting. + +These tests use HTTP proxy requests because they exercise real media-client +status registration without depending on multicast availability. +""" + +import re + +import pytest + +from helpers import MockHTTPUpstream, R2HProcess, find_free_port, http_get + +pytestmark = pytest.mark.http_proxy + + +def _config(port: int, global_lines: list[str] | None = None) -> str: + lines = ["[global]", "verbosity = 4"] + if global_lines: + lines.extend(global_lines) + lines.extend(["", "[bind]", f"* {port}"]) + return "\n".join(lines) + "\n" + + +def _request_proxy(port: int, upstream: MockHTTPUpstream, path: str = "/hello", headers: dict | None = None): + return http_get( + "127.0.0.1", + port, + f"/http/127.0.0.1:{upstream.port}{path}", + timeout=5.0, + headers=headers, + ) + + +def _start_upstream() -> MockHTTPUpstream: + upstream = MockHTTPUpstream( + routes={ + "/hello": {"status": 200, "body": b"world", "headers": {"Content-Type": "text/plain"}}, + } + ) + upstream.start() + return upstream + + +def test_access_log_disabled_by_default(r2h_binary, tmp_path): + port = find_free_port() + log_path = tmp_path / "access.log" + r2h = R2HProcess(r2h_binary, port, config_content=_config(port)) + upstream = _start_upstream() + try: + r2h.start() + status, _, body = _request_proxy(port, upstream) + assert status == 200 + assert body == b"world" + assert not log_path.exists() + finally: + r2h.stop() + upstream.stop() + + +def test_config_access_log_writes_default_line(r2h_binary, tmp_path): + port = find_free_port() + log_path = tmp_path / "access.log" + r2h = R2HProcess( + r2h_binary, + port, + config_content=_config(port, [f"access_log = {log_path}"]), + ) + upstream = _start_upstream() + try: + r2h.start() + status, _, body = _request_proxy(port, upstream) + assert status == 200 + assert body == b"world" + + lines = log_path.read_text().splitlines() + assert len(lines) == 1 + assert re.search( + rf'^127\.0\.0\.1:\d+ \[[^\]]+\] "/http/127\.0\.0\.1:{upstream.port}/hello" http "http://127\.0\.0\.1:{upstream.port}/hello"$', + lines[0], + ) + finally: + r2h.stop() + upstream.stop() + + +def test_cli_access_log_overrides_config(r2h_binary, tmp_path): + port = find_free_port() + config_log_path = tmp_path / "config-access.log" + cli_log_path = tmp_path / "cli-access.log" + r2h = R2HProcess( + r2h_binary, + port, + config_content=_config( + port, + [ + f"access_log = {config_log_path}", + "log_format = config $service_type", + ], + ), + extra_args=[ + "--access-log", + str(cli_log_path), + "--log-format", + "cli $service_type $request", + ], + ) + upstream = _start_upstream() + try: + r2h.start() + status, _, body = _request_proxy(port, upstream) + assert status == 200 + assert body == b"world" + + assert not config_log_path.exists() + assert cli_log_path.read_text().strip() == f"cli http GET /http/127.0.0.1:{upstream.port}/hello" + finally: + r2h.stop() + upstream.stop() + + +def test_custom_format_filters_token_and_expands_placeholders(r2h_binary, tmp_path): + port = find_free_port() + log_path = tmp_path / "access.log" + log_format = ( + "$$ $request_method $service_url $remote_addr $remote_port $host $http_user_agent " + "$service_type $upstream_url $client_id $worker_id $state $state_code" + ) + r2h = R2HProcess( + r2h_binary, + port, + config_content=_config( + port, + [ + f"access_log = {log_path}", + f"log_format = {log_format}", + "r2h-token = secret-token", + ], + ), + ) + upstream = _start_upstream() + try: + r2h.start() + status, _, body = _request_proxy( + port, + upstream, + "/hello?r2h-token=secret-token&foo=bar", + headers={"Host": "example.test", "User-Agent": 'Agent"Test'}, + ) + assert status == 200 + assert body == b"world" + + line = log_path.read_text().strip() + assert line.startswith("$ GET ") + assert "secret-token" not in line + assert f"/http/127.0.0.1:{upstream.port}/hello?foo=bar" in line + assert "127.0.0.1" in line + assert "example.test" in line + assert 'Agent\\"Test' in line + assert f"http://127.0.0.1:{upstream.port}/hello?foo=bar" in line + assert line.endswith("$client_id $worker_id $state $state_code") + finally: + r2h.stop() + upstream.stop() + + +def test_client_addr_uses_x_forwarded_for_when_enabled(r2h_binary, tmp_path): + port = find_free_port() + log_path = tmp_path / "access.log" + r2h = R2HProcess( + r2h_binary, + port, + config_content=_config( + port, + [ + f"access_log = {log_path}", + "log_format = $client_addr|$remote_addr|$remote_port|$http_x_forwarded_for", + "xff = 1", + ], + ), + ) + upstream = _start_upstream() + try: + r2h.start() + status, _, body = _request_proxy( + port, + upstream, + headers={"X-Forwarded-For": "203.0.113.7, 10.0.0.1"}, + ) + assert status == 200 + assert body == b"world" + assert log_path.read_text().strip() == "203.0.113.7|203.0.113.7|-|203.0.113.7" + finally: + r2h.stop() + upstream.stop() + + +def test_non_media_requests_are_not_logged(r2h_binary, tmp_path): + port = find_free_port() + log_path = tmp_path / "access.log" + r2h = R2HProcess( + r2h_binary, + port, + config_content=_config(port, [f"access_log = {log_path}"]), + ) + try: + r2h.start() + status, _, _ = http_get("127.0.0.1", port, "/status") + assert status == 200 + assert not log_path.exists() + finally: + r2h.stop() diff --git a/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js b/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js index fbbc9563..92fb4837 100644 --- a/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js +++ b/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js @@ -798,6 +798,43 @@ return view.extend({ o.default = "0"; o.depends("use_config_file", "0"); + o = s.taboption( + "advanced", + form.Value, + "access_log", + _("Access Log Path"), + _( + "Write one access log line for each media request. Leave empty to disable access logging. Use an absolute path such as /tmp/rtp2httpd-access.log." + ) + ); + o.placeholder = "/tmp/rtp2httpd-access.log"; + o.depends("use_config_file", "0"); + o.validate = function (section_id, value) { + var path = String(value || "").trim(); + + if (!path) { + return true; + } + + if (path.charAt(0) !== "/" || /\s/.test(path)) { + return _("Use an absolute file path without whitespace, for example /tmp/rtp2httpd-access.log."); + } + + return true; + }; + + o = s.taboption( + "advanced", + form.Value, + "log_format", + _("Access Log Format"), + _( + "Nginx-style access log format. Empty uses the default format. Supported variables include $client_addr, $time_iso8601, $service_url, $service_type and $upstream_url." + ) + ); + o.placeholder = '$client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url"'; + o.depends("use_config_file", "0"); + o = s.taboption( "advanced", form.Value, diff --git a/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot b/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot index e0ba14ee..999f736f 100644 --- a/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot +++ b/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot @@ -15,6 +15,14 @@ msgstr "" msgid "App Path Prefix" msgstr "" +#: htdocs/luci-static/resources/view/rtp2httpd.js:746 +msgid "Access Log Format" +msgstr "" + +#: htdocs/luci-static/resources/view/rtp2httpd.js:723 +msgid "Access Log Path" +msgstr "" + #: htdocs/luci-static/resources/view/rtp2httpd.js:307 msgid "Auto restart after crash" msgstr "" @@ -35,6 +43,13 @@ msgstr "" msgid "Config File Content" msgstr "" +#: htdocs/luci-static/resources/view/rtp2httpd.js:748 +msgid "" +"Nginx-style access log format. Empty uses the default format. Supported " +"variables include $client_addr, $time_iso8601, $service_url, $service_type " +"and $upstream_url." +msgstr "" + #: htdocs/luci-static/resources/view/rtp2httpd.js:424 msgid "Configure separate interfaces for multicast, FCC and RTSP" msgstr "" @@ -334,6 +349,12 @@ msgid "" "socket path, for example 5140, 192.168.1.1:8081, or /var/run/rtp2httpd.sock." msgstr "" +#: htdocs/luci-static/resources/view/rtp2httpd.js:737 +msgid "" +"Use an absolute file path without whitespace, for example /tmp/rtp2httpd-" +"access.log." +msgstr "" + #: htdocs/luci-static/resources/view/rtp2httpd.js:753 msgid "" "User-Agent header used for upstream RTSP requests. Leave empty to use the " @@ -365,6 +386,12 @@ msgid "" "reverse proxy." msgstr "" +#: htdocs/luci-static/resources/view/rtp2httpd.js:725 +msgid "" +"Write one access log line for each media request. Leave empty to disable " +"access logging. Use an absolute path such as /tmp/rtp2httpd-access.log." +msgstr "" + #: htdocs/luci-static/resources/view/rtp2httpd.js:510 msgid "Workers" msgstr "" diff --git a/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po b/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po index ad9d2cdf..51931b6a 100644 --- a/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po +++ b/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po @@ -24,6 +24,14 @@ msgstr "高级接口设置" msgid "App Path Prefix" msgstr "应用路径前缀" +#: htdocs/luci-static/resources/view/rtp2httpd.js:746 +msgid "Access Log Format" +msgstr "访问日志格式" + +#: htdocs/luci-static/resources/view/rtp2httpd.js:723 +msgid "Access Log Path" +msgstr "访问日志路径" + #: htdocs/luci-static/resources/view/rtp2httpd.js:307 msgid "Auto restart after crash" msgstr "程序崩溃后自动重启" @@ -44,6 +52,15 @@ msgstr "CORS 允许的源" msgid "Config File Content" msgstr "配置文件内容" +#: htdocs/luci-static/resources/view/rtp2httpd.js:748 +msgid "" +"Nginx-style access log format. Empty uses the default format. Supported " +"variables include $client_addr, $time_iso8601, $service_url, $service_type " +"and $upstream_url." +msgstr "" +"Nginx 风格的访问日志格式。留空则使用默认格式。支持的变量包括 $client_addr、" +"$time_iso8601、$service_url、$service_type 和 $upstream_url。" + #: htdocs/luci-static/resources/view/rtp2httpd.js:424 msgid "Configure separate interfaces for multicast, FCC and RTSP" msgstr "分别配置组播、FCC 和 RTSP 的接口" @@ -371,6 +388,12 @@ msgstr "" "请使用端口、address:port、hostname:port、[IPv6]:port 或 Unix socket 绝对路径," "例如 5140、192.168.1.1:8081 或 /var/run/rtp2httpd.sock。" +#: htdocs/luci-static/resources/view/rtp2httpd.js:737 +msgid "" +"Use an absolute file path without whitespace, for example /tmp/rtp2httpd-" +"access.log." +msgstr "请使用不含空白字符的绝对文件路径,例如 /tmp/rtp2httpd-access.log。" + #: htdocs/luci-static/resources/view/rtp2httpd.js:753 msgid "" "User-Agent header used for upstream RTSP requests. Leave empty to use the " @@ -410,6 +433,14 @@ msgstr "" "并接受 X-Forwarded-Host / X-Forwarded-Proto 头作为 playlist.m3u 中的地址前" "缀。建议仅在使用反向代理时启用。" +#: htdocs/luci-static/resources/view/rtp2httpd.js:725 +msgid "" +"Write one access log line for each media request. Leave empty to disable " +"access logging. Use an absolute path such as /tmp/rtp2httpd-access.log." +msgstr "" +"为每个媒体请求写入一行访问日志。留空则禁用访问日志。请使用绝对路径,例如 " +"/tmp/rtp2httpd-access.log。" + #: htdocs/luci-static/resources/view/rtp2httpd.js:510 msgid "Workers" msgstr "工作进程数" diff --git a/openwrt-support/rtp2httpd/files/rtp2httpd.conf b/openwrt-support/rtp2httpd/files/rtp2httpd.conf index a2058a70..724c1fba 100644 --- a/openwrt-support/rtp2httpd/files/rtp2httpd.conf +++ b/openwrt-support/rtp2httpd/files/rtp2httpd.conf @@ -37,6 +37,8 @@ config rtp2httpd # option status_page_path '/status' # option player_page_path '/player' # option r2h_token 'your-secret-token-here' + # option access_log '/tmp/rtp2httpd-access.log' + # option log_format '$client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url"' # option external_m3u 'https://example.com/playlist.m3u' # option external_m3u_update_interval '7200' # option mcast_rejoin_interval '0' diff --git a/openwrt-support/rtp2httpd/files/rtp2httpd.init b/openwrt-support/rtp2httpd/files/rtp2httpd.init index e0993558..c63b4374 100644 --- a/openwrt-support/rtp2httpd/files/rtp2httpd.init +++ b/openwrt-support/rtp2httpd/files/rtp2httpd.init @@ -79,6 +79,8 @@ start_instance() { append_arg "$cfg" status_page_path "--status-page-path" append_arg "$cfg" player_page_path "--player-page-path" append_arg "$cfg" r2h_token "--r2h-token" + append_arg "$cfg" access_log "--access-log" + append_arg "$cfg" log_format "--log-format" append_arg "$cfg" external_m3u "--external-m3u" append_arg "$cfg" external_m3u_update_interval "--external-m3u-update-interval" append_arg "$cfg" mcast_rejoin_interval "--mcast-rejoin-interval" diff --git a/rtp2httpd.conf b/rtp2httpd.conf index 42064655..a08b503b 100644 --- a/rtp2httpd.conf +++ b/rtp2httpd.conf @@ -8,6 +8,12 @@ #VERBOSITY: 0-quiet 1-error 2-warn 3-info 4-debug (default 1) verbosity = 1 +# Access log file path (default none, disabled) +;access_log = /tmp/rtp2httpd-access.log + +# Access log format (nginx-style $variables) +;log_format = $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" + #maximum paralell clients (default 5) ;maxclients = 5 diff --git a/src/access_log.c b/src/access_log.c new file mode 100644 index 00000000..32270fa5 --- /dev/null +++ b/src/access_log.c @@ -0,0 +1,467 @@ +#include "access_log.h" +#include "configuration.h" +#include "connection.h" +#include "rtp2httpd.h" +#include "service.h" +#include "status.h" +#include "utils.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef O_CLOEXEC +#define O_CLOEXEC 0 +#endif + +#define ACCESS_LOG_INITIAL_CAPACITY 512 +#define ACCESS_LOG_MAX_LINE 8192 + +typedef struct { + char *data; + size_t len; + size_t cap; + int truncated; +} access_log_buffer_t; + +static int access_log_fd = -1; +static char *access_log_open_path = NULL; +static char *access_log_last_failed_path = NULL; + +static void access_log_close_fd(void) { + if (access_log_fd >= 0) { + close(access_log_fd); + access_log_fd = -1; + } + if (access_log_open_path) { + free(access_log_open_path); + access_log_open_path = NULL; + } +} + +void access_log_cleanup(void) { + access_log_close_fd(); + if (access_log_last_failed_path) { + free(access_log_last_failed_path); + access_log_last_failed_path = NULL; + } +} + +void access_log_reopen(void) { access_log_cleanup(); } + +static int access_log_ensure_fd(const char *path) { + if (!path || path[0] == '\0') + return -1; + + if (access_log_fd >= 0 && access_log_open_path && strcmp(access_log_open_path, path) == 0) + return access_log_fd; + + access_log_close_fd(); + + int fd = open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); + if (fd < 0) { + if (!access_log_last_failed_path || strcmp(access_log_last_failed_path, path) != 0) { + logger(LOG_ERROR, "Failed to open access log %s: %s", path, strerror(errno)); + free(access_log_last_failed_path); + access_log_last_failed_path = strdup(path); + } + return -1; + } + + access_log_fd = fd; + access_log_open_path = strdup(path); + if (!access_log_open_path) { + logger(LOG_ERROR, "Failed to store access log path"); + access_log_close_fd(); + return -1; + } + + if (access_log_last_failed_path) { + free(access_log_last_failed_path); + access_log_last_failed_path = NULL; + } + + return access_log_fd; +} + +static int access_log_buffer_init(access_log_buffer_t *buf) { + buf->data = malloc(ACCESS_LOG_INITIAL_CAPACITY); + if (!buf->data) + return -1; + buf->len = 0; + buf->cap = ACCESS_LOG_INITIAL_CAPACITY; + buf->truncated = 0; + buf->data[0] = '\0'; + return 0; +} + +static void access_log_buffer_free(access_log_buffer_t *buf) { + free(buf->data); + buf->data = NULL; + buf->len = 0; + buf->cap = 0; +} + +static int access_log_buffer_reserve(access_log_buffer_t *buf, size_t extra) { + if (buf->truncated) + return 0; + + if (extra > ACCESS_LOG_MAX_LINE - buf->len - 1) { + extra = ACCESS_LOG_MAX_LINE - buf->len - 1; + buf->truncated = 1; + } + + size_t need = buf->len + extra + 1; + if (need <= buf->cap) + return 0; + + size_t next_cap = buf->cap; + while (next_cap < need && next_cap < ACCESS_LOG_MAX_LINE) + next_cap *= 2; + if (next_cap > ACCESS_LOG_MAX_LINE) + next_cap = ACCESS_LOG_MAX_LINE; + + char *next = realloc(buf->data, next_cap); + if (!next) + return -1; + buf->data = next; + buf->cap = next_cap; + return 0; +} + +static int access_log_append_mem(access_log_buffer_t *buf, const char *value, size_t len) { + if (!value || len == 0 || buf->truncated) + return 0; + + size_t writable = len; + if (writable > ACCESS_LOG_MAX_LINE - buf->len - 1) { + writable = ACCESS_LOG_MAX_LINE - buf->len - 1; + buf->truncated = 1; + } + + if (access_log_buffer_reserve(buf, writable) < 0) + return -1; + + memcpy(buf->data + buf->len, value, writable); + buf->len += writable; + buf->data[buf->len] = '\0'; + return 0; +} + +static int access_log_append_string(access_log_buffer_t *buf, const char *value) { + if (!value) + return 0; + return access_log_append_mem(buf, value, strlen(value)); +} + +static int access_log_append_char(access_log_buffer_t *buf, char value) { + return access_log_append_mem(buf, &value, 1); +} + +static int access_log_append_escaped(access_log_buffer_t *buf, const char *value) { + static const char hex[] = "0123456789ABCDEF"; + + if (!value || value[0] == '\0') + return access_log_append_char(buf, '-'); + + for (const unsigned char *p = (const unsigned char *)value; *p; p++) { + char tmp[4]; + + switch (*p) { + case '\\': + if (access_log_append_string(buf, "\\\\") < 0) + return -1; + break; + case '"': + if (access_log_append_string(buf, "\\\"") < 0) + return -1; + break; + case '\n': + if (access_log_append_string(buf, "\\n") < 0) + return -1; + break; + case '\r': + if (access_log_append_string(buf, "\\r") < 0) + return -1; + break; + case '\t': + if (access_log_append_string(buf, "\\t") < 0) + return -1; + break; + default: + if (*p < 0x20 || *p == 0x7f) { + tmp[0] = '\\'; + tmp[1] = 'x'; + tmp[2] = hex[*p >> 4]; + tmp[3] = hex[*p & 0x0f]; + if (access_log_append_mem(buf, tmp, sizeof(tmp)) < 0) + return -1; + } else { + if (access_log_append_char(buf, (char)*p) < 0) + return -1; + } + break; + } + } + + return 0; +} + +static const char *access_log_service_type_name(service_t *service) { + if (!service) + return "-"; + + switch (service->service_type) { + case SERVICE_MRTP: + return "rtp"; + case SERVICE_RTSP: + return "rtsp"; + case SERVICE_HTTP: + return "http"; + default: + return "-"; + } +} + +static const char *access_log_upstream_url(service_t *service) { + if (!service) + return NULL; + + switch (service->service_type) { + case SERVICE_MRTP: + return service->rtp_url ? service->rtp_url : service->url; + case SERVICE_RTSP: + return service->rtsp_url ? service->rtsp_url : service->url; + case SERVICE_HTTP: + return service->http_url ? service->http_url : service->url; + default: + return service->url; + } +} + +static long access_log_timezone_offset_seconds(const struct tm *local_tm) { +#if defined(__linux__) || defined(__APPLE__) || defined(__FreeBSD__) + return local_tm->tm_gmtoff; +#else + (void)local_tm; + return 0; +#endif +} + +static void access_log_format_times(int64_t now_ms, char *time_iso8601, size_t time_iso8601_size, char *time_local, + size_t time_local_size, char *msec, size_t msec_size) { + time_t now_sec = (time_t)(now_ms / 1000); + int millis = (int)(now_ms % 1000); + struct tm local_tm; + long offset; + char sign; + long abs_offset; + static const char month_names[] = "JanFebMarAprMayJunJulAugSepOctNovDec"; + + if (millis < 0) + millis = 0; + + if (!localtime_r(&now_sec, &local_tm)) { + snprintf(time_iso8601, time_iso8601_size, "-"); + snprintf(time_local, time_local_size, "-"); + snprintf(msec, msec_size, "%lld.%03d", (long long)now_sec, millis); + return; + } + + offset = access_log_timezone_offset_seconds(&local_tm); + sign = offset >= 0 ? '+' : '-'; + abs_offset = offset >= 0 ? offset : -offset; + + snprintf(time_iso8601, time_iso8601_size, "%04d-%02d-%02dT%02d:%02d:%02d%c%02ld:%02ld", local_tm.tm_year + 1900, + local_tm.tm_mon + 1, local_tm.tm_mday, local_tm.tm_hour, local_tm.tm_min, local_tm.tm_sec, sign, + abs_offset / 3600, (abs_offset % 3600) / 60); + snprintf(time_local, time_local_size, "%02d/%.3s/%04d:%02d:%02d:%02d %c%02ld%02ld", local_tm.tm_mday, + &month_names[local_tm.tm_mon * 3], local_tm.tm_year + 1900, local_tm.tm_hour, local_tm.tm_min, + local_tm.tm_sec, sign, abs_offset / 3600, (abs_offset % 3600) / 60); + snprintf(msec, msec_size, "%lld.%03d", (long long)now_sec, millis); +} + +static void access_log_parse_remote_addr(const char *client_addr, char *remote_addr, size_t remote_addr_size, + char *remote_port, size_t remote_port_size) { + remote_addr[0] = '\0'; + remote_port[0] = '\0'; + + if (!client_addr || client_addr[0] == '\0') { + return; + } + + if (client_addr[0] == '[') { + const char *end = strchr(client_addr, ']'); + if (end) { + size_t addr_len = (size_t)(end - client_addr - 1); + if (addr_len >= remote_addr_size) + addr_len = remote_addr_size - 1; + memcpy(remote_addr, client_addr + 1, addr_len); + remote_addr[addr_len] = '\0'; + if (end[1] == ':' && end[2] != '\0') { + strncpy(remote_port, end + 2, remote_port_size - 1); + remote_port[remote_port_size - 1] = '\0'; + } + return; + } + } + + const char *last_colon = strrchr(client_addr, ':'); + if (last_colon && strchr(client_addr, ':') == last_colon) { + size_t addr_len = (size_t)(last_colon - client_addr); + if (addr_len >= remote_addr_size) + addr_len = remote_addr_size - 1; + memcpy(remote_addr, client_addr, addr_len); + remote_addr[addr_len] = '\0'; + strncpy(remote_port, last_colon + 1, remote_port_size - 1); + remote_port[remote_port_size - 1] = '\0'; + } else { + strncpy(remote_addr, client_addr, remote_addr_size - 1); + remote_addr[remote_addr_size - 1] = '\0'; + } +} + +static int access_log_append_placeholder(access_log_buffer_t *buf, const char *name, size_t name_len, connection_t *c, + service_t *service, const client_stats_t *client, const char *time_iso8601, + const char *time_local, const char *msec, const char *remote_addr, + const char *remote_port, const char *request) { + char numeric[64]; + +#define MATCH(name_literal) (name_len == strlen(name_literal) && strncmp(name, name_literal, name_len) == 0) + + if (MATCH("time_iso8601")) + return access_log_append_escaped(buf, time_iso8601); + if (MATCH("time_local")) + return access_log_append_escaped(buf, time_local); + if (MATCH("msec")) + return access_log_append_escaped(buf, msec); + if (MATCH("client_addr")) + return access_log_append_escaped(buf, client->client_addr); + if (MATCH("remote_addr")) + return access_log_append_escaped(buf, remote_addr); + if (MATCH("remote_port")) + return access_log_append_escaped(buf, remote_port); + if (MATCH("worker_pid")) { + snprintf(numeric, sizeof(numeric), "%d", (int)client->worker_pid); + return access_log_append_escaped(buf, numeric); + } + if (MATCH("request")) + return access_log_append_escaped(buf, request); + if (MATCH("request_method")) + return access_log_append_escaped(buf, c->http_req.method); + if (MATCH("service_url")) + return access_log_append_escaped(buf, client->service_url); + if (MATCH("host")) + return access_log_append_escaped(buf, c->http_req.hostname); + if (MATCH("http_user_agent")) + return access_log_append_escaped(buf, c->http_req.user_agent); + if (MATCH("http_x_forwarded_for")) + return access_log_append_escaped(buf, c->http_req.x_forwarded_for); + if (MATCH("service_type")) + return access_log_append_escaped(buf, access_log_service_type_name(service)); + if (MATCH("upstream_url")) + return access_log_append_escaped(buf, access_log_upstream_url(service)); + + if (access_log_append_char(buf, '$') < 0) + return -1; + return access_log_append_mem(buf, name, name_len); + +#undef MATCH +} + +static int access_log_render(access_log_buffer_t *buf, connection_t *c, service_t *service, + const client_stats_t *client, const char *format) { + char time_iso8601[64]; + char time_local[64]; + char msec[32]; + char remote_addr[128]; + char remote_port[32]; + char request[HTTP_URL_BUFFER_SIZE + 32]; + int64_t now_ms = get_realtime_ms(); + + access_log_format_times(now_ms, time_iso8601, sizeof(time_iso8601), time_local, sizeof(time_local), msec, + sizeof(msec)); + access_log_parse_remote_addr(client->client_addr, remote_addr, sizeof(remote_addr), remote_port, sizeof(remote_port)); + snprintf(request, sizeof(request), "%s %s", c->http_req.method[0] ? c->http_req.method : "-", client->service_url); + + for (const char *p = format; *p; p++) { + if (*p != '$') { + if (access_log_append_char(buf, *p) < 0) + return -1; + continue; + } + + if (p[1] == '$') { + if (access_log_append_char(buf, '$') < 0) + return -1; + p++; + continue; + } + + if (!(isalpha((unsigned char)p[1]) || p[1] == '_')) { + if (access_log_append_char(buf, '$') < 0) + return -1; + continue; + } + + const char *name = p + 1; + const char *end = name; + while (isalnum((unsigned char)*end) || *end == '_') + end++; + + if (access_log_append_placeholder(buf, name, (size_t)(end - name), c, service, client, time_iso8601, time_local, + msec, remote_addr, remote_port, request) < 0) { + return -1; + } + p = end - 1; + } + + if (access_log_append_char(buf, '\n') < 0) + return -1; + return 0; +} + +void access_log_write_connection(connection_t *c, service_t *service, int status_index) { + if (!c || !service || !config.access_log || config.access_log[0] == '\0' || !status_shared) + return; + + if (status_index < 0 || status_index >= STATUS_MAX_CLIENTS) + return; + + client_stats_t *client = &status_shared->clients[status_index]; + if (!client->active || client->service_url[0] == '\0') + return; + + int fd = access_log_ensure_fd(config.access_log); + if (fd < 0) + return; + + const char *format = + (config.log_format && config.log_format[0] != '\0') ? config.log_format : DEFAULT_ACCESS_LOG_FORMAT; + + access_log_buffer_t buf; + if (access_log_buffer_init(&buf) < 0) { + logger(LOG_ERROR, "Failed to allocate access log buffer"); + return; + } + + if (access_log_render(&buf, c, service, client, format) < 0) { + logger(LOG_ERROR, "Failed to render access log line"); + access_log_buffer_free(&buf); + return; + } + + ssize_t written = write(fd, buf.data, buf.len); + if (written < 0 || (size_t)written != buf.len) { + logger(LOG_ERROR, "Failed to write access log %s: %s", config.access_log, + written < 0 ? strerror(errno) : "short write"); + access_log_close_fd(); + } + + access_log_buffer_free(&buf); +} diff --git a/src/access_log.h b/src/access_log.h new file mode 100644 index 00000000..ef3cb80d --- /dev/null +++ b/src/access_log.h @@ -0,0 +1,11 @@ +#ifndef __ACCESS_LOG_H__ +#define __ACCESS_LOG_H__ + +typedef struct connection_s connection_t; +typedef struct service_s service_t; + +void access_log_write_connection(connection_t *c, service_t *service, int status_index); +void access_log_reopen(void); +void access_log_cleanup(void); + +#endif /* __ACCESS_LOG_H__ */ diff --git a/src/configuration.c b/src/configuration.c index 8dc72098..592f36f2 100644 --- a/src/configuration.c +++ b/src/configuration.c @@ -53,6 +53,8 @@ int cmd_rtsp_stun_server_set = 0; int cmd_http_proxy_user_agent_set = 0; int cmd_rtsp_user_agent_set = 0; int cmd_cors_allow_origin_set = 0; +int cmd_access_log_set = 0; +int cmd_log_format_set = 0; enum section_e { SEC_NONE = 0, SEC_BIND, SEC_SERVICES, SEC_GLOBAL }; @@ -134,6 +136,10 @@ static void free_config_strings(config_t *target, bool force_free) { safe_free_string(&target->rtsp_user_agent); if (!cmd_cors_allow_origin_set || force_free) safe_free_string(&target->cors_allow_origin); + if (!cmd_access_log_set || force_free) + safe_free_string(&target->access_log); + if (!cmd_log_format_set || force_free) + safe_free_string(&target->log_format); } static int snapshot_string(char **dst, char *src, int keep_shallow) { @@ -769,6 +775,24 @@ void parse_global_sec(char *line) { return; } + if (strcasecmp("access-log", param) == 0 || strcasecmp("access_log", param) == 0) { + if (set_if_not_cmd_override(cmd_access_log_set, "access-log")) { + safe_free_string(&config.access_log); + if (value[0] != '\0') + config.access_log = strdup(value); + } + return; + } + + if (strcasecmp("log-format", param) == 0 || strcasecmp("log_format", param) == 0) { + if (set_if_not_cmd_override(cmd_log_format_set, "log-format")) { + safe_free_string(&config.log_format); + if (value[0] != '\0') + config.log_format = strdup(value); + } + return; + } + logger(LOG_ERROR, "Unknown config parameter: %s", param); } @@ -1042,6 +1066,8 @@ int config_snapshot(config_t *snapshot) { snapshot->http_proxy_user_agent = NULL; snapshot->rtsp_user_agent = NULL; snapshot->cors_allow_origin = NULL; + snapshot->access_log = NULL; + snapshot->log_format = NULL; #define SNAPSHOT_STRING(field, cmd_flag) \ do { \ @@ -1064,6 +1090,8 @@ int config_snapshot(config_t *snapshot) { SNAPSHOT_STRING(http_proxy_user_agent, cmd_http_proxy_user_agent_set); SNAPSHOT_STRING(rtsp_user_agent, cmd_rtsp_user_agent_set); SNAPSHOT_STRING(cors_allow_origin, cmd_cors_allow_origin_set); + SNAPSHOT_STRING(access_log, cmd_access_log_set); + SNAPSHOT_STRING(log_format, cmd_log_format_set); #undef SNAPSHOT_STRING @@ -1135,6 +1163,8 @@ void config_init(void) { set_player_page_path_value("/player"); if (!cmd_app_path_prefix_set) set_app_path_prefix_value(""); + if (!cmd_log_format_set) + config.log_format = strdup(DEFAULT_ACCESS_LOG_FORMAT); /* Reset interface settings (only if not set by command line) */ if (!cmd_upstream_interface_set) @@ -1287,6 +1317,8 @@ void usage(FILE *f, char *progname) { "(default: disabled)\n" "\t-O --cors-allow-origin Set Access-Control-Allow-Origin header " "(default: disabled)\n" + "\t --access-log Write access logs to this file (default: disabled)\n" + "\t --log-format Access log format (nginx-style $variables)\n" "\t default " CONFIGFILE "\n", prog); } @@ -1333,6 +1365,9 @@ void parse_bind_cmd(char *arg) { } void parse_cmd_line(int argc, char *argv[]) { +#define OPT_ACCESS_LOG 1000 +#define OPT_LOG_FORMAT 1001 + const struct option longopts[] = {{"verbose", required_argument, 0, 'v'}, {"quiet", no_argument, 0, 'q'}, {"help", no_argument, 0, 'h'}, @@ -1367,6 +1402,8 @@ void parse_cmd_line(int argc, char *argv[]) { {"rtsp-stun-server", required_argument, 0, 'N'}, {"rtsp-user-agent", required_argument, 0, 'u'}, {"cors-allow-origin", required_argument, 0, 'O'}, + {"access-log", required_argument, 0, OPT_ACCESS_LOG}, + {"log-format", required_argument, 0, OPT_LOG_FORMAT}, {0, 0, 0, 0}}; const char short_opts[] = "v:qhUm:w:b:B:c:l:P:H:XT:i:f:t:r:y:R:F:A:s:p:M:I:SCZg:N:u:O:"; @@ -1578,6 +1615,20 @@ void parse_cmd_line(int argc, char *argv[]) { config.cors_allow_origin = strdup(optarg); cmd_cors_allow_origin_set = 1; break; + case OPT_ACCESS_LOG: + safe_free_string(&config.access_log); + if (optarg[0] != '\0') { + config.access_log = strdup(optarg); + } + cmd_access_log_set = 1; + break; + case OPT_LOG_FORMAT: + safe_free_string(&config.log_format); + if (optarg[0] != '\0') { + config.log_format = strdup(optarg); + } + cmd_log_format_set = 1; + break; default: logger(LOG_FATAL, "Unknown option! %d ", opt); usage(stderr, argv[0]); @@ -1606,4 +1657,7 @@ void parse_cmd_line(int argc, char *argv[]) { } logger(LOG_DEBUG, "Verbosity: %d, Maxclients: %d, Workers: %d", config.verbosity, config.maxclients, config.workers); + +#undef OPT_ACCESS_LOG +#undef OPT_LOG_FORMAT } diff --git a/src/configuration.h b/src/configuration.h index 69f48644..7dc5eaf3 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -12,6 +12,8 @@ #define CONFIGFILE SYSCONFDIR "/rtp2httpd.conf" +#define DEFAULT_ACCESS_LOG_FORMAT "$client_addr [$time_iso8601] \"$service_url\" $service_type \"$upstream_url\"" + typedef enum loglevel { LOG_FATAL = 0, /* Always shown */ LOG_ERROR, /* Critical failures that prevent functionality */ @@ -40,6 +42,8 @@ typedef struct bindaddr_s { typedef struct { /* Logging settings */ loglevel_t verbosity; /* Log verbosity level (LOG_FATAL to LOG_DEBUG) */ + char *access_log; /* Access log file path (NULL=disabled) */ + char *log_format; /* Access log format string */ /* Network and service settings */ int udpxy; /* Enable UDPxy URL format support (0=no, 1=yes) */ diff --git a/src/connection.c b/src/connection.c index 61c433ba..c52ee537 100644 --- a/src/connection.c +++ b/src/connection.c @@ -1,4 +1,5 @@ #include "connection.h" +#include "access_log.h" #include "embedded_web.h" #include "epg.h" #include "http.h" @@ -1135,6 +1136,8 @@ int connection_route_and_start(connection_t *c) { c->status_index = status_register_client(client_addr_str, display_url); if (c->status_index < 0) { logger(LOG_ERROR, "Failed to register streaming client in status tracking"); + } else { + access_log_write_connection(c, service, c->status_index); } } else { c->status_index = -1; diff --git a/src/supervisor.c b/src/supervisor.c index 72b819b1..8afa17f1 100644 --- a/src/supervisor.c +++ b/src/supervisor.c @@ -1,4 +1,5 @@ #include "supervisor.h" +#include "access_log.h" #include "configuration.h" #include "epg.h" #include "m3u.h" @@ -661,6 +662,7 @@ int run_worker(void) { /* Run worker event loop */ int result = worker_run_event_loop(s, maxs, notif_fd); + access_log_cleanup(); zerocopy_cleanup(); status_cleanup(); config_cleanup(true); diff --git a/src/worker.c b/src/worker.c index 748eb2ee..1f9824aa 100644 --- a/src/worker.c +++ b/src/worker.c @@ -1,4 +1,5 @@ #include "worker.h" +#include "access_log.h" #include "configuration.h" #include "connection.h" #include "epg.h" @@ -283,6 +284,7 @@ int worker_run_event_loop(int *listen_sockets, int num_sockets, int notif_fd) { if (config_reload(NULL) != 0) { logger(LOG_ERROR, "Configuration reload failed, keeping old config"); } + access_log_reopen(); } /* 1) Handle all ready events */ From 398bacbcb75941f6758e5adaacadba5495ea6b6f Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Thu, 25 Jun 2026 06:24:08 +0800 Subject: [PATCH 2/8] fix(access-log): address review feedback --- e2e/test_access_log.py | 31 ++++++++++++++++++++++++ src/access_log.c | 53 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/e2e/test_access_log.py b/e2e/test_access_log.py index ab610007..f405c97d 100644 --- a/e2e/test_access_log.py +++ b/e2e/test_access_log.py @@ -195,6 +195,37 @@ def test_client_addr_uses_x_forwarded_for_when_enabled(r2h_binary, tmp_path): upstream.stop() +def test_user_agent_token_is_redacted(r2h_binary, tmp_path): + port = find_free_port() + log_path = tmp_path / "access.log" + r2h = R2HProcess( + r2h_binary, + port, + config_content=_config( + port, + [ + f"access_log = {log_path}", + "log_format = $http_user_agent", + "r2h-token = secret-token", + ], + ), + ) + upstream = _start_upstream() + try: + r2h.start() + status, _, body = _request_proxy( + port, + upstream, + headers={"User-Agent": "Player/1.0 R2HTOKEN/secret-token TZ/UTC+8"}, + ) + assert status == 200 + assert body == b"world" + assert log_path.read_text().strip() == "Player/1.0 TZ/UTC+8" + finally: + r2h.stop() + upstream.stop() + + def test_non_media_requests_are_not_logged(r2h_binary, tmp_path): port = find_free_port() log_path = tmp_path / "access.log" diff --git a/src/access_log.c b/src/access_log.c index 32270fa5..b4bdd6a7 100644 --- a/src/access_log.c +++ b/src/access_log.c @@ -54,6 +54,19 @@ void access_log_cleanup(void) { void access_log_reopen(void) { access_log_cleanup(); } +static int access_log_set_cloexec(int fd) { +#if O_CLOEXEC == 0 && defined(FD_CLOEXEC) + int flags = fcntl(fd, F_GETFD); + if (flags < 0) + return -1; + if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) < 0) + return -1; +#else + (void)fd; +#endif + return 0; +} + static int access_log_ensure_fd(const char *path) { if (!path || path[0] == '\0') return -1; @@ -73,6 +86,12 @@ static int access_log_ensure_fd(const char *path) { return -1; } + if (access_log_set_cloexec(fd) < 0) { + logger(LOG_ERROR, "Failed to set access log close-on-exec flag for %s: %s", path, strerror(errno)); + close(fd); + return -1; + } + access_log_fd = fd; access_log_open_path = strdup(path); if (!access_log_open_path) { @@ -331,6 +350,7 @@ static int access_log_append_placeholder(access_log_buffer_t *buf, const char *n const char *time_local, const char *msec, const char *remote_addr, const char *remote_port, const char *request) { char numeric[64]; + char filtered_user_agent[sizeof(c->http_req.user_agent)]; #define MATCH(name_literal) (name_len == strlen(name_literal) && strncmp(name, name_literal, name_len) == 0) @@ -358,8 +378,11 @@ static int access_log_append_placeholder(access_log_buffer_t *buf, const char *n return access_log_append_escaped(buf, client->service_url); if (MATCH("host")) return access_log_append_escaped(buf, c->http_req.hostname); - if (MATCH("http_user_agent")) - return access_log_append_escaped(buf, c->http_req.user_agent); + if (MATCH("http_user_agent")) { + if (http_filter_user_agent_token(c->http_req.user_agent, filtered_user_agent, sizeof(filtered_user_agent)) < 0) + filtered_user_agent[0] = '\0'; + return access_log_append_escaped(buf, filtered_user_agent); + } if (MATCH("http_x_forwarded_for")) return access_log_append_escaped(buf, c->http_req.x_forwarded_for); if (MATCH("service_type")) @@ -426,6 +449,26 @@ static int access_log_render(access_log_buffer_t *buf, connection_t *c, service_ return 0; } +static int access_log_write_full(int fd, const char *data, size_t len) { + size_t offset = 0; + + while (offset < len) { + ssize_t written = write(fd, data + offset, len - offset); + if (written < 0) { + if (errno == EINTR) + continue; + return -1; + } + if (written == 0) { + errno = EIO; + return -1; + } + offset += (size_t)written; + } + + return 0; +} + void access_log_write_connection(connection_t *c, service_t *service, int status_index) { if (!c || !service || !config.access_log || config.access_log[0] == '\0' || !status_shared) return; @@ -456,10 +499,8 @@ void access_log_write_connection(connection_t *c, service_t *service, int status return; } - ssize_t written = write(fd, buf.data, buf.len); - if (written < 0 || (size_t)written != buf.len) { - logger(LOG_ERROR, "Failed to write access log %s: %s", config.access_log, - written < 0 ? strerror(errno) : "short write"); + if (access_log_write_full(fd, buf.data, buf.len) < 0) { + logger(LOG_ERROR, "Failed to write access log %s: %s", config.access_log, strerror(errno)); access_log_close_fd(); } From 1c2d8b64583fe2a918e4ced0993e74b1df6ac50b Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Thu, 25 Jun 2026 14:08:33 +0800 Subject: [PATCH 3/8] fix(luci): relax access log path input --- .../luci-static/resources/view/rtp2httpd.js | 17 +---------------- .../po/templates/rtp2httpd.pot | 9 +-------- .../luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po | 13 ++----------- 3 files changed, 4 insertions(+), 35 deletions(-) diff --git a/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js b/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js index 92fb4837..1962e515 100644 --- a/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js +++ b/openwrt-support/luci-app-rtp2httpd/htdocs/luci-static/resources/view/rtp2httpd.js @@ -803,25 +803,10 @@ return view.extend({ form.Value, "access_log", _("Access Log Path"), - _( - "Write one access log line for each media request. Leave empty to disable access logging. Use an absolute path such as /tmp/rtp2httpd-access.log." - ) + _("Write one access log line for each media request. Leave empty to disable access logging.") ); o.placeholder = "/tmp/rtp2httpd-access.log"; o.depends("use_config_file", "0"); - o.validate = function (section_id, value) { - var path = String(value || "").trim(); - - if (!path) { - return true; - } - - if (path.charAt(0) !== "/" || /\s/.test(path)) { - return _("Use an absolute file path without whitespace, for example /tmp/rtp2httpd-access.log."); - } - - return true; - }; o = s.taboption( "advanced", diff --git a/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot b/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot index 999f736f..c1396e83 100644 --- a/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot +++ b/openwrt-support/luci-app-rtp2httpd/po/templates/rtp2httpd.pot @@ -349,12 +349,6 @@ msgid "" "socket path, for example 5140, 192.168.1.1:8081, or /var/run/rtp2httpd.sock." msgstr "" -#: htdocs/luci-static/resources/view/rtp2httpd.js:737 -msgid "" -"Use an absolute file path without whitespace, for example /tmp/rtp2httpd-" -"access.log." -msgstr "" - #: htdocs/luci-static/resources/view/rtp2httpd.js:753 msgid "" "User-Agent header used for upstream RTSP requests. Leave empty to use the " @@ -388,8 +382,7 @@ msgstr "" #: htdocs/luci-static/resources/view/rtp2httpd.js:725 msgid "" -"Write one access log line for each media request. Leave empty to disable " -"access logging. Use an absolute path such as /tmp/rtp2httpd-access.log." +"Write one access log line for each media request. Leave empty to disable access logging." msgstr "" #: htdocs/luci-static/resources/view/rtp2httpd.js:510 diff --git a/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po b/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po index 51931b6a..cd852cc3 100644 --- a/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po +++ b/openwrt-support/luci-app-rtp2httpd/po/zh_Hans/rtp2httpd.po @@ -388,12 +388,6 @@ msgstr "" "请使用端口、address:port、hostname:port、[IPv6]:port 或 Unix socket 绝对路径," "例如 5140、192.168.1.1:8081 或 /var/run/rtp2httpd.sock。" -#: htdocs/luci-static/resources/view/rtp2httpd.js:737 -msgid "" -"Use an absolute file path without whitespace, for example /tmp/rtp2httpd-" -"access.log." -msgstr "请使用不含空白字符的绝对文件路径,例如 /tmp/rtp2httpd-access.log。" - #: htdocs/luci-static/resources/view/rtp2httpd.js:753 msgid "" "User-Agent header used for upstream RTSP requests. Leave empty to use the " @@ -435,11 +429,8 @@ msgstr "" #: htdocs/luci-static/resources/view/rtp2httpd.js:725 msgid "" -"Write one access log line for each media request. Leave empty to disable " -"access logging. Use an absolute path such as /tmp/rtp2httpd-access.log." -msgstr "" -"为每个媒体请求写入一行访问日志。留空则禁用访问日志。请使用绝对路径,例如 " -"/tmp/rtp2httpd-access.log。" +"Write one access log line for each media request. Leave empty to disable access logging." +msgstr "为每个媒体请求写入一行访问日志。留空则禁用访问日志。" #: htdocs/luci-static/resources/view/rtp2httpd.js:510 msgid "Workers" From c875538fa27aed80031965a0e0a56abd9a74706e Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Thu, 25 Jun 2026 14:12:01 +0800 Subject: [PATCH 4/8] fix(access-log): preserve single-write log lines --- src/access_log.c | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/access_log.c b/src/access_log.c index b4bdd6a7..856198be 100644 --- a/src/access_log.c +++ b/src/access_log.c @@ -449,23 +449,19 @@ static int access_log_render(access_log_buffer_t *buf, connection_t *c, service_ return 0; } -static int access_log_write_full(int fd, const char *data, size_t len) { - size_t offset = 0; - - while (offset < len) { - ssize_t written = write(fd, data + offset, len - offset); - if (written < 0) { - if (errno == EINTR) - continue; - return -1; - } - if (written == 0) { - errno = EIO; - return -1; - } - offset += (size_t)written; - } +static int access_log_write_line(int fd, const char *data, size_t len) { + ssize_t written; + do { + written = write(fd, data, len); + } while (written < 0 && errno == EINTR); + + if (written < 0) + return -1; + if ((size_t)written != len) { + errno = EIO; + return -1; + } return 0; } @@ -499,7 +495,7 @@ void access_log_write_connection(connection_t *c, service_t *service, int status return; } - if (access_log_write_full(fd, buf.data, buf.len) < 0) { + if (access_log_write_line(fd, buf.data, buf.len) < 0) { logger(LOG_ERROR, "Failed to write access log %s: %s", config.access_log, strerror(errno)); access_log_close_fd(); } From cfd491ac79dccd01a5d40ba27ec9b07d0bb81a98 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Thu, 25 Jun 2026 14:15:25 +0800 Subject: [PATCH 5/8] fix(config): avoid long option id collision --- src/configuration.c | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/configuration.c b/src/configuration.c index 592f36f2..b1cd6a12 100644 --- a/src/configuration.c +++ b/src/configuration.c @@ -58,7 +58,7 @@ int cmd_log_format_set = 0; enum section_e { SEC_NONE = 0, SEC_BIND, SEC_SERVICES, SEC_GLOBAL }; -enum long_option_e { OPT_APP_PATH_PREFIX = 1000 }; +enum long_option_e { OPT_APP_PATH_PREFIX = 1000, OPT_ACCESS_LOG, OPT_LOG_FORMAT }; /* M3U parsing state variables */ static char *inline_m3u_buffer = NULL; @@ -1365,9 +1365,6 @@ void parse_bind_cmd(char *arg) { } void parse_cmd_line(int argc, char *argv[]) { -#define OPT_ACCESS_LOG 1000 -#define OPT_LOG_FORMAT 1001 - const struct option longopts[] = {{"verbose", required_argument, 0, 'v'}, {"quiet", no_argument, 0, 'q'}, {"help", no_argument, 0, 'h'}, @@ -1657,7 +1654,4 @@ void parse_cmd_line(int argc, char *argv[]) { } logger(LOG_DEBUG, "Verbosity: %d, Maxclients: %d, Workers: %d", config.verbosity, config.maxclients, config.workers); - -#undef OPT_ACCESS_LOG -#undef OPT_LOG_FORMAT } From 25371d5512372e5913c1498239ff670583d3eb5d Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Thu, 25 Jun 2026 14:29:33 +0800 Subject: [PATCH 6/8] docs(access-log): move details to guide --- docs/.vitepress/config.ts | 2 + docs/en/guide/access-log.md | 104 +++++++++++++++++++++++++++++ docs/en/reference/configuration.md | 44 ++---------- docs/guide/access-log.md | 104 +++++++++++++++++++++++++++++ docs/reference/configuration.md | 44 ++---------- 5 files changed, 220 insertions(+), 78 deletions(-) create mode 100644 docs/en/guide/access-log.md create mode 100644 docs/guide/access-log.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8fb7bc5d..2819bfe3 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -44,6 +44,7 @@ export default defineConfig({ { text: "FCC 快速换台配置", link: "/guide/fcc-setup" }, { text: "公网访问建议", link: "/guide/public-access" }, { text: "时间处理说明", link: "/guide/time-processing" }, + { text: "访问日志", link: "/guide/access-log" }, { text: "视频快照配置", link: "/guide/video-snapshot" }, ], }, @@ -126,6 +127,7 @@ export default defineConfig({ { text: "Fast Channel Change", link: "/en/guide/fcc-setup" }, { text: "Public Access", link: "/en/guide/public-access" }, { text: "Time Processing", link: "/en/guide/time-processing" }, + { text: "Access Logging", link: "/en/guide/access-log" }, { text: "Video Snapshot", link: "/en/guide/video-snapshot" }, ], }, diff --git a/docs/en/guide/access-log.md b/docs/en/guide/access-log.md new file mode 100644 index 00000000..104054e3 --- /dev/null +++ b/docs/en/guide/access-log.md @@ -0,0 +1,104 @@ +# Access Logging + +rtp2httpd can write access logs for media requests. This is useful for auditing which channels clients access, identifying client sources behind a reverse proxy, and integrating with external logging systems. + +Access logging only records media requests that appear on the status page. Each media request writes one log line at connection start, so the log does not include values that are only known after the connection closes, such as actual bytes sent or playback duration. + +## Enabling Access Logging + +Access logging is disabled by default. Logs are written only after `access_log` is configured. + +### Configuration File + +```ini +[global] +access_log = /var/log/rtp2httpd/access.log +log_format = $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" +``` + +### Command Line + +```bash +rtp2httpd --access-log /var/log/rtp2httpd/access.log \ + --log-format '$client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url"' +``` + +The OpenWrt LuCI page also supports configuring the access log path and format. + +## Default Format + +When `log_format` is unset or empty, this default format is used: + +```text +$client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" +``` + +Example output: + +```text +192.0.2.10:53124 [2026-06-25T20:10:12+08:00] "/rtp/239.0.0.1:1234" rtp "rtp://239.0.0.1:1234" +``` + +These fields prioritize the client address and the Service URL requested by the client, making it easy to answer "who accessed which channel". + +## Placeholders + +`log_format` uses nginx-style `$variable` placeholders. The following placeholders are supported: + +| Placeholder | Description | +| --- | --- | +| `$time_iso8601` | Local time in ISO 8601 format, for example `2026-06-25T20:10:12+08:00` | +| `$time_local` | Local time in nginx-style format, for example `25/Jun/2026:20:10:12 +0800` | +| `$msec` | Unix timestamp with millisecond precision, for example `1782389412.123` | +| `$client_addr` | Client address shown on the status page | +| `$remote_addr` | Client IP or address parsed from `$client_addr` | +| `$remote_port` | Client port parsed from `$client_addr`; outputs `-` when no port is available | +| `$worker_pid` | PID of the worker process handling the request | +| `$request` | Request method and Service URL, for example `GET /rtp/239.0.0.1:1234` | +| `$request_method` | HTTP request method, for example `GET` | +| `$service_url` | Service URL requested by the client | +| `$host` | HTTP `Host` header | +| `$http_user_agent` | HTTP `User-Agent` header | +| `$http_x_forwarded_for` | HTTP `X-Forwarded-For` header | +| `$service_type` | Service type: `rtp`, `rtsp`, or `http` | +| `$upstream_url` | Actual upstream URL | +| `$$` | Outputs a literal `$` | + +Unknown placeholders are output as-is. Empty values are output as `-`. Quotes, backslashes, newlines, and control characters in log values are escaped. Each log line is limited to 8192 bytes. + +## Client Address and X-Forwarded-For + +`$client_addr` matches the client address shown on the status page. + +After `xff` is enabled, if the request's `X-Forwarded-For` header is accepted, `$client_addr` uses the first address from `X-Forwarded-For`. In that case there is usually no client port, so `$remote_port` outputs `-`. + +## Token Hiding + +If the request URL contains the `r2h-token` query parameter, `$service_url` in the access log does not include that parameter. `$http_user_agent` also hides `R2HTOKEN/...` fragments to avoid leaking tokens through access logs. + +## Using logrotate + +rtp2httpd workers keep the access log file open. After rotating logs, send `SIGHUP` to the supervisor process so workers reopen the log file. + +Example logrotate config: + +```text +/var/log/rtp2httpd/access.log { + daily + rotate 7 + missingok + notifempty + compress + create 0644 root root + sharedscripts + postrotate + for pid in $(pidof rtp2httpd); do + ppid="$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null)" + [ "$(cat "/proc/$ppid/comm" 2>/dev/null)" = "rtp2httpd" ] && continue + kill -HUP "$pid" 2>/dev/null || true + done + endscript +} +``` + +This skips worker child processes whose parent process is also `rtp2httpd`, and sends `SIGHUP` only to the supervisor. diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index 798f7a5a..0a88acd8 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -72,7 +72,7 @@ Unix socket listen paths must be absolute and must not contain whitespace. At st - `-v, --verbose` - Logging verbosity (0=FATAL, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG) - `-q, --quiet` - Show only fatal errors - `--access-log ` - Write access logs to the specified file (default: disabled) -- `--log-format ` - Access log format using nginx-style `$variable` placeholders +- `--log-format ` - Access log format using nginx-style `$variable` placeholders. See [Access Logging](/en/guide/access-log) ### Security Control @@ -122,11 +122,10 @@ Configuration file path: `/etc/rtp2httpd.conf`. Lines starting with `#` or `;` a # Logging verbosity: 0=FATAL 1=ERROR 2=WARN 3=INFO 4=DEBUG verbosity = 3 -# Access log file path (default: disabled) -# Each media request writes one access log line at connection start +# Access log file path (empty or unset means disabled) access_log = /var/log/rtp2httpd/access.log -# Access log format (nginx-style $variables) +# Access log format (optional, nginx-style $variables) # Default: $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" log_format = $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" @@ -283,40 +282,7 @@ rtp://239.253.64.120:5140 rtp://239.253.64.121:5140 ``` -### Access Log Placeholders - -When `access_log` is empty or unset, access logging is disabled. - -`log_format` supports these placeholders: - -- Time: `$time_iso8601`, `$time_local`, `$msec` -- Client and worker: `$client_addr`, `$remote_addr`, `$remote_port`, `$worker_pid` -- Request: `$request`, `$request_method`, `$service_url`, `$host`, `$http_user_agent`, `$http_x_forwarded_for` -- Stream: `$service_type`, `$upstream_url` -- Literal: `$$` outputs `$` - -`$client_addr` matches the client address shown on the status page. When the request's `X-Forwarded-For` is accepted, for example after enabling `xff`, it uses the first address from `X-Forwarded-For`; in that case there is usually no port, so `$remote_port` outputs `-`. - -Example logrotate config: - -```text -/var/log/rtp2httpd/access.log { - daily - rotate 7 - missingok - notifempty - compress - create 0644 root root - sharedscripts - postrotate - for pid in $(pidof rtp2httpd); do - ppid="$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null)" - [ "$(cat "/proc/$ppid/comm" 2>/dev/null)" = "rtp2httpd" ] && continue - kill -HUP "$pid" 2>/dev/null || true - done - endscript -} -``` +For how to enable access logging, configure placeholders, and use logrotate, see [Access Logging](/en/guide/access-log). ## Runtime Configuration Management @@ -345,7 +311,7 @@ kill -USR1 12345 - If `[bind]` listen addresses change, the supervisor sends `SIGTERM` to all workers and respawns them to apply the new listen addresses - If the `workers` count changes, the supervisor automatically adds or removes worker processes - For other configuration changes, the supervisor forwards `SIGHUP` to each worker, which applies them at runtime -- Workers reopen the access log file during reload, which helps with logrotate +- Workers reopen the [access log](/en/guide/access-log) file during reload, which helps with logrotate - If the config file fails to parse, the old configuration is kept and existing connections are not interrupted > [!NOTE] diff --git a/docs/guide/access-log.md b/docs/guide/access-log.md new file mode 100644 index 00000000..6680cdd8 --- /dev/null +++ b/docs/guide/access-log.md @@ -0,0 +1,104 @@ +# 访问日志 + +rtp2httpd 可以为媒体请求写入访问日志,便于审计客户端访问了哪些频道、排查反向代理后的客户端来源,以及对接外部日志系统。 + +访问日志只记录会出现在状态页面上的媒体请求。每个媒体请求会在连接开始时记录一行日志,因此不会包含需要等连接结束后才能确定的信息,例如实际发送字节数或播放时长。 + +## 启用访问日志 + +访问日志默认关闭。只有配置 `access_log` 后才会写入日志。 + +### 配置文件 + +```ini +[global] +access_log = /var/log/rtp2httpd/access.log +log_format = $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" +``` + +### 命令行 + +```bash +rtp2httpd --access-log /var/log/rtp2httpd/access.log \ + --log-format '$client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url"' +``` + +OpenWrt LuCI 页面中也可以配置访问日志路径和格式。 + +## 默认格式 + +`log_format` 未配置或为空时,使用以下默认格式: + +```text +$client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" +``` + +示例输出: + +```text +192.0.2.10:53124 [2026-06-25T20:10:12+08:00] "/rtp/239.0.0.1:1234" rtp "rtp://239.0.0.1:1234" +``` + +这几个字段优先展示客户端地址和客户端访问的 Service URL,适合直接判断“谁访问了哪个频道”。 + +## 占位符 + +`log_format` 使用类似 nginx 的 `$变量` 形式。支持的占位符如下: + +| 占位符 | 说明 | +| --- | --- | +| `$time_iso8601` | 本地时间,ISO 8601 格式,例如 `2026-06-25T20:10:12+08:00` | +| `$time_local` | 本地时间,nginx 风格格式,例如 `25/Jun/2026:20:10:12 +0800` | +| `$msec` | Unix 时间戳,精确到毫秒,例如 `1782389412.123` | +| `$client_addr` | 状态页面显示的客户端地址 | +| `$remote_addr` | 从 `$client_addr` 拆出的客户端 IP 或地址 | +| `$remote_port` | 从 `$client_addr` 拆出的客户端端口;没有端口时输出 `-` | +| `$worker_pid` | 处理该请求的 worker 进程 PID | +| `$request` | 请求方法和 Service URL,例如 `GET /rtp/239.0.0.1:1234` | +| `$request_method` | HTTP 请求方法,例如 `GET` | +| `$service_url` | 客户端访问的 Service URL | +| `$host` | HTTP `Host` 头 | +| `$http_user_agent` | HTTP `User-Agent` 头 | +| `$http_x_forwarded_for` | HTTP `X-Forwarded-For` 头 | +| `$service_type` | 服务类型:`rtp`、`rtsp` 或 `http` | +| `$upstream_url` | 实际上游 URL | +| `$$` | 输出字面量 `$` | + +未知占位符会按原样输出。空值会输出为 `-`。日志值中的引号、反斜杠、换行和控制字符会被转义,单行日志最长 8192 字节。 + +## 客户端地址与 X-Forwarded-For + +`$client_addr` 与状态页面显示的客户端地址一致。 + +启用 `xff` 后,如果请求中的 `X-Forwarded-For` 被接受,`$client_addr` 会使用 `X-Forwarded-For` 中的第一个地址。这种情况下通常没有客户端端口,因此 `$remote_port` 会输出 `-`。 + +## 令牌隐藏 + +如果请求 URL 中包含 `r2h-token` 查询参数,访问日志中的 `$service_url` 不会包含该参数。`$http_user_agent` 也会隐藏 `R2HTOKEN/...` 片段,避免令牌通过访问日志泄露。 + +## 配合 logrotate + +rtp2httpd worker 会保持访问日志文件打开。轮转日志后,需要向 supervisor 进程发送 `SIGHUP`,让 worker 重新打开日志文件。 + +logrotate 配置示例: + +```text +/var/log/rtp2httpd/access.log { + daily + rotate 7 + missingok + notifempty + compress + create 0644 root root + sharedscripts + postrotate + for pid in $(pidof rtp2httpd); do + ppid="$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null)" + [ "$(cat "/proc/$ppid/comm" 2>/dev/null)" = "rtp2httpd" ] && continue + kill -HUP "$pid" 2>/dev/null || true + done + endscript +} +``` + +这里会跳过父进程同样是 `rtp2httpd` 的 worker 子进程,只向 supervisor 发送 `SIGHUP`。 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index eb55b757..9347dbae 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -72,7 +72,7 @@ Unix socket 监听路径必须是绝对路径,且路径中不能包含空白 - `-v, --verbose` - 日志详细程度 (0=FATAL, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG) - `-q, --quiet` - 仅显示致命错误 - `--access-log <路径>` - 将访问日志写入指定文件 (默认: 禁用) -- `--log-format <格式>` - 访问日志格式,使用类似 nginx 的 `$变量` 占位符 +- `--log-format <格式>` - 访问日志格式,使用类似 nginx 的 `$变量` 占位符,详见 [访问日志](../guide/access-log.md) ### 安全控制 @@ -122,11 +122,10 @@ Unix socket 监听路径必须是绝对路径,且路径中不能包含空白 # 日志详细程度: 0=FATAL 1=ERROR 2=WARN 3=INFO 4=DEBUG verbosity = 3 -# 访问日志文件路径(默认: 禁用) -# 每个媒体请求会在连接开始时记录一行访问日志 +# 访问日志文件路径(留空或不配置时禁用) access_log = /var/log/rtp2httpd/access.log -# 访问日志格式(nginx 风格 $变量) +# 访问日志格式(可选,nginx 风格 $变量) # 默认: $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" log_format = $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" @@ -282,40 +281,7 @@ rtp://239.253.64.120:5140 rtp://239.253.64.121:5140 ``` -### 访问日志占位符 - -`access_log` 留空或不配置时不会记录访问日志。 - -`log_format` 支持以下占位符: - -- 时间:`$time_iso8601`、`$time_local`、`$msec` -- 客户端与工作进程:`$client_addr`、`$remote_addr`、`$remote_port`、`$worker_pid` -- 请求信息:`$request`、`$request_method`、`$service_url`、`$host`、`$http_user_agent`、`$http_x_forwarded_for` -- 流信息:`$service_type`、`$upstream_url` -- 字面量:`$$` 输出 `$` - -`$client_addr` 与状态页面显示的客户端地址一致。当请求中的 `X-Forwarded-For` 被接受时(例如启用 `xff` 后),它会使用 `X-Forwarded-For` 中的第一个地址;这种情况下通常没有端口,因此 `$remote_port` 输出 `-`。 - -logrotate 配置示例: - -```text -/var/log/rtp2httpd/access.log { - daily - rotate 7 - missingok - notifempty - compress - create 0644 root root - sharedscripts - postrotate - for pid in $(pidof rtp2httpd); do - ppid="$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null)" - [ "$(cat "/proc/$ppid/comm" 2>/dev/null)" = "rtp2httpd" ] && continue - kill -HUP "$pid" 2>/dev/null || true - done - endscript -} -``` +访问日志的启用方式、格式占位符和 logrotate 配置见 [访问日志](../guide/access-log.md)。 ## 运行时配置管理 @@ -344,7 +310,7 @@ kill -USR1 12345 - 若 `[bind]` 监听地址发生变化,supervisor 会向所有工作进程发送 `SIGTERM` 并重新拉起,以应用新的监听地址 - 若 `workers` 数量发生变化,supervisor 会自动增减工作进程 - 其他配置变更会转发 `SIGHUP` 给各工作进程,由工作进程在运行时应用 -- 工作进程会在重载时重新打开访问日志文件,便于配合 logrotate +- 工作进程会在重载时重新打开 [访问日志](../guide/access-log.md) 文件,便于配合 logrotate - 若配置文件解析失败,保留旧配置,不会中断现有连接 > [!NOTE] From 3ddbc589060dec5bc1d19957061703a4390fc4ff Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Thu, 25 Jun 2026 14:33:55 +0800 Subject: [PATCH 7/8] docs(access-log): address guide comments --- docs/en/guide/access-log.md | 6 +----- docs/guide/access-log.md | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/en/guide/access-log.md b/docs/en/guide/access-log.md index 104054e3..0cf545b3 100644 --- a/docs/en/guide/access-log.md +++ b/docs/en/guide/access-log.md @@ -2,8 +2,6 @@ rtp2httpd can write access logs for media requests. This is useful for auditing which channels clients access, identifying client sources behind a reverse proxy, and integrating with external logging systems. -Access logging only records media requests that appear on the status page. Each media request writes one log line at connection start, so the log does not include values that are only known after the connection closes, such as actual bytes sent or playback duration. - ## Enabling Access Logging Access logging is disabled by default. Logs are written only after `access_log` is configured. @@ -36,11 +34,9 @@ $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" Example output: ```text -192.0.2.10:53124 [2026-06-25T20:10:12+08:00] "/rtp/239.0.0.1:1234" rtp "rtp://239.0.0.1:1234" +192.0.2.10:53124 [2026-06-25T20:10:12+08:00] "/CCTV-1" rtp "rtp://239.0.0.1:1234" ``` -These fields prioritize the client address and the Service URL requested by the client, making it easy to answer "who accessed which channel". - ## Placeholders `log_format` uses nginx-style `$variable` placeholders. The following placeholders are supported: diff --git a/docs/guide/access-log.md b/docs/guide/access-log.md index 6680cdd8..02a51eb8 100644 --- a/docs/guide/access-log.md +++ b/docs/guide/access-log.md @@ -2,8 +2,6 @@ rtp2httpd 可以为媒体请求写入访问日志,便于审计客户端访问了哪些频道、排查反向代理后的客户端来源,以及对接外部日志系统。 -访问日志只记录会出现在状态页面上的媒体请求。每个媒体请求会在连接开始时记录一行日志,因此不会包含需要等连接结束后才能确定的信息,例如实际发送字节数或播放时长。 - ## 启用访问日志 访问日志默认关闭。只有配置 `access_log` 后才会写入日志。 @@ -36,11 +34,9 @@ $client_addr [$time_iso8601] "$service_url" $service_type "$upstream_url" 示例输出: ```text -192.0.2.10:53124 [2026-06-25T20:10:12+08:00] "/rtp/239.0.0.1:1234" rtp "rtp://239.0.0.1:1234" +192.0.2.10:53124 [2026-06-25T20:10:12+08:00] "/CCTV-1" rtp "rtp://239.0.0.1:1234" ``` -这几个字段优先展示客户端地址和客户端访问的 Service URL,适合直接判断“谁访问了哪个频道”。 - ## 占位符 `log_format` 使用类似 nginx 的 `$变量` 形式。支持的占位符如下: From cfc7031df2e0c0c4c8a8f9b095e269748feb0720 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Thu, 25 Jun 2026 14:38:03 +0800 Subject: [PATCH 8/8] test(unix-socket): wait for worker restart state --- e2e/test_unix_socket.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/e2e/test_unix_socket.py b/e2e/test_unix_socket.py index fca8786f..89181b96 100644 --- a/e2e/test_unix_socket.py +++ b/e2e/test_unix_socket.py @@ -61,7 +61,7 @@ def _wait_unix_http_status(socket_path: str, path: str, expected_status: int = 2 assert last_status == expected_status -def _wait_unix_http_body_contains(socket_path: str, path: str, needle: bytes, timeout: float = 5.0) -> None: +def _wait_unix_http_body_contains(socket_path: str, path: str, needle: bytes, timeout: float = 5.0) -> bytes: deadline = time.time() + timeout last_body = b"" while time.time() < deadline: @@ -70,11 +70,23 @@ def _wait_unix_http_body_contains(socket_path: str, path: str, needle: bytes, ti if status == 200: last_body = body if needle in body: - return + return body except OSError: pass time.sleep(0.1) assert needle in last_body + return last_body + + +def _wait_log_contains(r2h: R2HProcess, needle: str, timeout: float = 5.0) -> None: + deadline = time.time() + timeout + last_log = "" + while time.time() < deadline: + last_log = r2h.read_log() + if needle in last_log: + return + time.sleep(0.1) + assert needle in last_log class TestUnixSocketListen: @@ -262,14 +274,13 @@ def test_reload_keeps_old_unix_listener_when_new_path_fails(self, r2h_binary): assert "keeping existing workers and listeners" in log os.kill(r2h.process.pid, signal.SIGUSR1) + _wait_log_contains(r2h, "Restarting worker 0") _wait_unix_http_status(old_sock_path, "/oldstatus") - status, _, body = unix_http_get(old_sock_path, "/playlist.m3u") - assert status == 200 + body = _wait_unix_http_body_contains(old_sock_path, "/playlist.m3u", b"Old Channel") playlist = body.decode() assert "Old Channel" in playlist assert "New Channel" not in playlist - _wait_unix_http_body_contains(old_sock_path, "/epg.xml", b"Old Programme") - status, _, body = unix_http_get(old_sock_path, "/epg.xml") + body = _wait_unix_http_body_contains(old_sock_path, "/epg.xml", b"Old Programme") assert b"Old Programme" in body assert b"New Programme" not in body status, _, _ = unix_http_get(old_sock_path, "/newstatus")