diff --git a/docs/en/guide/url-formats.md b/docs/en/guide/url-formats.md index 7f499bac..13f92e9c 100644 --- a/docs/en/guide/url-formats.md +++ b/docs/en/guide/url-formats.md @@ -164,6 +164,21 @@ http://192.168.1.1:5140/http/iptv.example.com/channel1?r2h-ifname=eth0 See [Time Processing Guide](/en/guide/time-processing) for details. +### Upstream 30x Redirects + +When the upstream returns `301` / `302` / `303` / `307` / `308`, rtp2httpd rewrites the `Location` header by scheme so the player stays on this instance: + +| Upstream `Location` | Forwarded to the client | +| --- | --- | +| `http://iptv.example.com:8080/path` | `/http/iptv.example.com:8080/path` | +| `rtsp://iptv.example.com:554/path?auth=...` | `/rtsp/iptv.example.com:554/path?auth=...` | +| `rtp://239.0.0.1:1234` | `/rtp/239.0.0.1:1234` | +| `udp://239.0.0.1:1234` | `/udp/239.0.0.1:1234` | +| `/rtsp/iptv.example.com:554/path` (root-relative) | forwarded unchanged | +| `https://...` or other unsupported schemes | forwarded unchanged | + +The rewrite covers the same URL formats M3U transform recognizes: `http://`, `rtsp://`, `rtp://`, and `udp://`. This covers the case where `catchup-source` first points to an HTTP auth/token service, which then 302-redirects to the real playback URL. If `app-path-prefix` is configured, rewritten locations include that prefix. + ### Notes - Only supports HTTP upstream (HTTPS is not supported) diff --git a/docs/guide/url-formats.md b/docs/guide/url-formats.md index 6f8e1f6e..522218e4 100644 --- a/docs/guide/url-formats.md +++ b/docs/guide/url-formats.md @@ -164,6 +164,21 @@ http://192.168.1.1:5140/http/iptv.example.com/channel1?r2h-ifname=eth0 详见 [时间处理说明](./time-processing.md)。 +### 上游 30x 重定向 + +当上游返回 `301` / `302` / `303` / `307` / `308` 时,rtp2httpd 会按协议改写 `Location`,让播放器继续走本实例: + +| 上游 `Location` | 转发给客户端 | +| --- | --- | +| `http://iptv.example.com:8080/path` | `/http/iptv.example.com:8080/path` | +| `rtsp://iptv.example.com:554/path?auth=...` | `/rtsp/iptv.example.com:554/path?auth=...` | +| `rtp://239.0.0.1:1234` | `/rtp/239.0.0.1:1234` | +| `udp://239.0.0.1:1234` | `/udp/239.0.0.1:1234` | +| `/rtsp/iptv.example.com:554/path`(根路径相对地址) | 原样转发 | +| `https://...` 或其他未支持的协议 | 原样转发 | + +改写范围与 M3U 可识别的 URL 格式一致:`http://`、`rtsp://`、`rtp://`、`udp://`。这适用于 `catchup-source` 先指向一个 HTTP 鉴权/换链服务,再由该服务 302 到真实播放地址的场景。如果配置了 `app-path-prefix`,改写后的地址会带上此前缀。 + ### 注意事项 - 仅支持 HTTP 上游(不支持 HTTPS) diff --git a/e2e/test_http_proxy.py b/e2e/test_http_proxy.py index 18540144..ce28a8c0 100644 --- a/e2e/test_http_proxy.py +++ b/e2e/test_http_proxy.py @@ -9,10 +9,15 @@ import pytest from helpers import ( + LOOPBACK_IF, + MCAST_ADDR, MockHTTPUpstream, MockHTTPUpstreamSilent, + MockRTSPServer, + MulticastSender, R2HProcess, find_free_port, + find_free_udp_port, http_get, stream_get, ) @@ -341,11 +346,11 @@ def test_proxy_302_body(self, shared_r2h): # --------------------------------------------------------------------------- -def _get_location(hdrs): +def _get_location(hdrs: dict) -> str | None: """Extract Location header value (case-insensitive lookup).""" for k, v in hdrs.items(): if k.lower() == "location": - return v + return str(v) return None @@ -497,7 +502,7 @@ def test_redirect_location_preserves_query_string(self, shared_r2h): upstream.stop() def test_redirect_https_location_not_rewritten(self, shared_r2h): - """https:// Location should NOT be rewritten (only http:// is supported).""" + """https:// Location should NOT be rewritten (not an M3U-recognized scheme).""" upstream = MockHTTPUpstream( routes={ "/secure-redir": { @@ -551,6 +556,290 @@ def test_non_redirect_location_not_rewritten(self, shared_r2h): finally: upstream.stop() + def test_302_rtsp_location_rewritten(self, shared_r2h): + """302 Location with rtsp:// URL should be rewritten to /rtsp/... path.""" + upstream = MockHTTPUpstream( + routes={ + "/catchup": { + "status": 302, + "body": b"", + "headers": {"Location": "rtsp://10.0.0.1:1554/iptv/live?auth=token123"}, + }, + } + ) + upstream.start() + try: + status, hdrs, _ = http_get( + "127.0.0.1", + shared_r2h.port, + f"/http/127.0.0.1:{upstream.port}/catchup", + timeout=5.0, + ) + assert status == 302 + location = _get_location(hdrs) + assert location == "/rtsp/10.0.0.1:1554/iptv/live?auth=token123" + finally: + upstream.stop() + + def test_302_rtsp_location_rewritten_with_app_path_prefix(self, r2h_binary): + """rtsp:// redirect Location should include app-path-prefix when configured.""" + port = find_free_port() + config = f"""\ +[global] +verbosity = 4 +app-path-prefix = {APP_PREFIX} + +[bind] +* {port} +""" + r2h = R2HProcess(r2h_binary, port, config_content=config) + upstream = MockHTTPUpstream( + routes={ + "/catchup": { + "status": 302, + "body": b"", + "headers": {"Location": "rtsp://10.0.0.1:1554/iptv/live"}, + }, + } + ) + upstream.start() + try: + r2h.start() + status, hdrs, _ = http_get( + "127.0.0.1", + port, + f"{APP_PREFIX}/http/127.0.0.1:{upstream.port}/catchup", + timeout=5.0, + ) + assert status == 302 + location = _get_location(hdrs) + assert location == f"{APP_PREFIX}/rtsp/10.0.0.1:1554/iptv/live" + finally: + r2h.stop() + upstream.stop() + + @pytest.mark.parametrize( + "upstream_location, expected", + [ + ("rtp://239.0.0.1:1234", "/rtp/239.0.0.1:1234"), + ("rtp://239.0.0.1:1234?fcc=10.0.0.2:8027", "/rtp/239.0.0.1:1234?fcc=10.0.0.2:8027"), + ("udp://239.0.0.1:1234", "/udp/239.0.0.1:1234"), + ("udp://239.0.0.1:1234?fec=1235", "/udp/239.0.0.1:1234?fec=1235"), + ], + ) + def test_302_m3u_scheme_location_rewritten(self, shared_r2h, upstream_location, expected): + """M3U-recognized rtp:// and udp:// Locations rewrite onto /rtp/ and /udp/.""" + upstream = MockHTTPUpstream( + routes={ + "/catchup": { + "status": 302, + "body": b"", + "headers": {"Location": upstream_location}, + }, + } + ) + upstream.start() + try: + status, hdrs, _ = http_get( + "127.0.0.1", + shared_r2h.port, + f"/http/127.0.0.1:{upstream.port}/catchup", + timeout=5.0, + ) + assert status == 302 + assert _get_location(hdrs) == expected + finally: + upstream.stop() + + def test_302_relative_location_kept_unchanged(self, shared_r2h): + """Root-relative Location such as /rtsp/... must be forwarded as-is.""" + upstream = MockHTTPUpstream( + routes={ + "/catchup": { + "status": 302, + "body": b"", + "headers": {"Location": "/rtsp/10.0.0.1:1554/iptv/live?auth=token123"}, + }, + } + ) + upstream.start() + try: + status, hdrs, _ = http_get( + "127.0.0.1", + shared_r2h.port, + f"/http/127.0.0.1:{upstream.port}/catchup", + timeout=5.0, + ) + assert status == 302 + location = _get_location(hdrs) + assert location == "/rtsp/10.0.0.1:1554/iptv/live?auth=token123" + finally: + upstream.stop() + + @pytest.mark.rtsp + def test_302_rtsp_location_rewritten_and_playable(self, shared_r2h): + """HTTP 302 to rtsp:// should rewrite to /rtsp/... and the client can play it.""" + rtsp = MockRTSPServer(num_packets=50) + rtsp.start() + try: + rtsp_url = f"rtsp://127.0.0.1:{rtsp.port}/iptv/live?auth=token123" + expected = f"/rtsp/127.0.0.1:{rtsp.port}/iptv/live?auth=token123" + upstream = MockHTTPUpstream( + routes={ + "/catchup": { + "status": 302, + "body": b"", + "headers": {"Location": rtsp_url}, + }, + } + ) + upstream.start() + try: + status, hdrs, _ = http_get( + "127.0.0.1", + shared_r2h.port, + f"/http/127.0.0.1:{upstream.port}/catchup", + timeout=5.0, + ) + assert status == 302 + location = _get_location(hdrs) + assert location is not None, "Location header missing" + assert location == expected + + stream_status, _, body = stream_get( + "127.0.0.1", + shared_r2h.port, + location, + read_bytes=4096, + timeout=20.0, + ) + assert stream_status == 200 + assert len(body) > 0, "Expected RTSP stream after following rewritten Location" + finally: + upstream.stop() + finally: + rtsp.stop() + + @pytest.mark.rtsp + def test_302_relative_rtsp_location_playable(self, shared_r2h): + """HTTP 302 to /rtsp/... should be forwarded unchanged and remain playable.""" + rtsp = MockRTSPServer(num_packets=50) + rtsp.start() + try: + expected = f"/rtsp/127.0.0.1:{rtsp.port}/iptv/live?auth=token123" + upstream = MockHTTPUpstream( + routes={ + "/catchup": { + "status": 302, + "body": b"", + "headers": {"Location": expected}, + }, + } + ) + upstream.start() + try: + status, hdrs, _ = http_get( + "127.0.0.1", + shared_r2h.port, + f"/http/127.0.0.1:{upstream.port}/catchup", + timeout=5.0, + ) + assert status == 302 + location = _get_location(hdrs) + assert location is not None, "Location header missing" + assert location == expected + + stream_status, _, body = stream_get( + "127.0.0.1", + shared_r2h.port, + location, + read_bytes=4096, + timeout=20.0, + ) + assert stream_status == 200 + assert len(body) > 0, "Expected RTSP stream after following relative Location" + finally: + upstream.stop() + finally: + rtsp.stop() + + @pytest.mark.multicast + def test_302_rtp_location_rewritten_and_playable(self, r2h_binary): + """HTTP 302 to rtp:// should rewrite to /rtp/... and the client can play it.""" + port = find_free_port() + r2h = R2HProcess(r2h_binary, port, extra_args=["-v", "4", "-m", "100", "-r", LOOPBACK_IF]) + mcast_port = find_free_udp_port() + sender = MulticastSender(addr=MCAST_ADDR, port=mcast_port, pps=200) + rtp_url = f"rtp://{MCAST_ADDR}:{mcast_port}" + expected = f"/rtp/{MCAST_ADDR}:{mcast_port}" + upstream = MockHTTPUpstream( + routes={ + "/live": { + "status": 302, + "body": b"", + "headers": {"Location": rtp_url}, + }, + } + ) + sender.start() + upstream.start() + try: + r2h.start() + status, hdrs, _ = http_get( + "127.0.0.1", + port, + f"/http/127.0.0.1:{upstream.port}/live", + timeout=5.0, + ) + assert status == 302 + location = _get_location(hdrs) + assert location is not None, "Location header missing" + assert location == expected + + stream_status, _, body = stream_get( + "127.0.0.1", + port, + location, + read_bytes=4096, + timeout=10.0, + ) + assert stream_status == 200 + assert len(body) > 0, "Expected multicast stream after following rewritten Location" + finally: + r2h.stop() + upstream.stop() + sender.stop() + + def test_relative_location_does_not_log_http_scheme_error(self, r2h_binary): + """Relative Location must not be logged as 'URL must start with http://'.""" + port = find_free_port() + r2h = R2HProcess(r2h_binary, port, extra_args=["-v", "4", "-m", "5"]) + upstream = MockHTTPUpstream( + routes={ + "/catchup": { + "status": 302, + "body": b"", + "headers": {"Location": "/rtsp/example"}, + }, + } + ) + upstream.start() + try: + r2h.start() + status, hdrs, _ = http_get( + "127.0.0.1", + port, + f"/http/127.0.0.1:{upstream.port}/catchup", + timeout=5.0, + ) + assert status == 302 + assert _get_location(hdrs) == "/rtsp/example" + log = r2h.read_log() + assert "URL must start with http://" not in log + finally: + r2h.stop() + upstream.stop() + # --------------------------------------------------------------------------- # HTTP proxy with empty response body diff --git a/src/http_proxy.c b/src/http_proxy.c index 197cd8a8..94bb0b7a 100644 --- a/src/http_proxy.c +++ b/src/http_proxy.c @@ -1197,11 +1197,21 @@ static int http_proxy_parse_response_headers(http_proxy_session_t *session) { /* Rewrite Location header for redirects */ if (is_redirect && has_location) { - char app_base_path[HTTP_URL_BUFFER_SIZE]; - if (http_proxy_get_app_base_path(app_base_path, sizeof(app_base_path)) == 0 && - http_proxy_build_url(location_header, app_base_path, rewritten_location, sizeof(rewritten_location)) == 0) { - location_rewritten = 1; - logger(LOG_DEBUG, "HTTP Proxy: Rewritten Location: %s -> %s", location_header, rewritten_location); + /* + * Root-relative Locations (e.g. /rtsp/host:port/path) already target this + * rtp2httpd instance and must be forwarded unchanged. Absolute URLs in the + * same schemes M3U transform accepts (http/rtsp/rtp/udp) are rewritten + * onto the matching proxy prefix. + */ + if (location_header[0] == '/') { + logger(LOG_DEBUG, "HTTP Proxy: Keeping relative Location unchanged: %s", location_header); + } else { + char app_base_path[HTTP_URL_BUFFER_SIZE]; + if (http_proxy_get_app_base_path(app_base_path, sizeof(app_base_path)) == 0 && + http_proxy_build_url(location_header, app_base_path, rewritten_location, sizeof(rewritten_location)) == 0) { + location_rewritten = 1; + logger(LOG_DEBUG, "HTTP Proxy: Rewritten Location: %s -> %s", location_header, rewritten_location); + } } } @@ -1531,18 +1541,49 @@ int http_proxy_session_tick(http_proxy_session_t *session, int64_t now) { return 0; } +/* + * Match an absolute URL against the schemes M3U transform accepts + * (http, rtsp, rtp, udp). See is_url_recognizable() in m3u.c. + */ +static int http_proxy_match_m3u_scheme(const char *url, const char **host_start, const char **scheme_prefix) { + static const struct { + const char *scheme; + size_t len; + const char *prefix; + } table[] = { + {"http://", 7, "http/"}, + {"rtsp://", 7, "rtsp/"}, + {"rtp://", 6, "rtp/"}, + {"udp://", 6, "udp/"}, + }; + size_t i; + + if (!url || !host_start || !scheme_prefix) + return -1; + + for (i = 0; i < ARRAY_SIZE(table); i++) { + if (strncasecmp(url, table[i].scheme, table[i].len) == 0) { + *host_start = url + table[i].len; + *scheme_prefix = table[i].prefix; + return 0; + } + } + return -1; +} + int http_proxy_build_url(const char *http_url, const char *base_url_placeholder, char *output, size_t output_size) { const char *host_start; + const char *scheme_prefix; char *encoded_token = NULL; int result; int has_r2h_token = (config.r2h_token && config.r2h_token[0] != '\0'); - /* Skip http:// prefix */ - if (strncasecmp(http_url, "http://", 7) != 0) { - logger(LOG_ERROR, "http_proxy_build_url: URL must start with http://"); + if (!http_url || !base_url_placeholder || !output || output_size == 0) + return -1; + + /* Convert scheme://host/... -> {BASE_URL}scheme/host/... */ + if (http_proxy_match_m3u_scheme(http_url, &host_start, &scheme_prefix) != 0) return -1; - } - host_start = http_url + 7; /* Points to host:port/path */ /* URL encode r2h-token if configured */ if (has_r2h_token) { @@ -1553,21 +1594,21 @@ int http_proxy_build_url(const char *http_url, const char *base_url_placeholder, } } - /* Build proxy URL: {BASE_URL}http/host:port/path[?r2h-token=xxx] */ /* Check if original URL has query parameters */ const char *query_start = strchr(host_start, '?'); if (has_r2h_token && encoded_token) { if (query_start) { /* Original URL has query params, append r2h-token with & */ - result = snprintf(output, output_size, "%shttp/%s&r2h-token=%s", base_url_placeholder, host_start, encoded_token); + result = snprintf(output, output_size, "%s%s%s&r2h-token=%s", base_url_placeholder, scheme_prefix, host_start, + encoded_token); } else { /* No query params, add r2h-token with ? */ - result = snprintf(output, output_size, "%shttp/%s?r2h-token=%s", base_url_placeholder, host_start, encoded_token); + result = snprintf(output, output_size, "%s%s%s?r2h-token=%s", base_url_placeholder, scheme_prefix, host_start, + encoded_token); } } else { - /* No r2h-token, just transform the URL */ - result = snprintf(output, output_size, "%shttp/%s", base_url_placeholder, host_start); + result = snprintf(output, output_size, "%s%s%s", base_url_placeholder, scheme_prefix, host_start); } if (encoded_token) diff --git a/src/http_proxy.h b/src/http_proxy.h index 10e2b179..064f85f2 100644 --- a/src/http_proxy.h +++ b/src/http_proxy.h @@ -220,10 +220,14 @@ int http_proxy_session_tick(http_proxy_session_t *session, int64_t now); void http_proxy_resume_upstream(http_proxy_session_t *session); /** - * Build HTTP proxy URL for transformed M3U - * Converts http://host:port/path to {BASE_URL}http/host:port/path + * Build a proxy URL from an absolute upstream URL. + * Converts M3U-recognized schemes onto the matching proxy prefix: + * http://host:port/path -> {BASE_URL}http/host:port/path, + * rtsp://host:port/path -> {BASE_URL}rtsp/host:port/path, + * rtp://addr:port -> {BASE_URL}rtp/addr:port, + * udp://addr:port -> {BASE_URL}udp/addr:port. * - * @param http_url Original HTTP URL (must start with http://) + * @param http_url Original URL (must start with http://, rtsp://, rtp://, or udp://) * @param base_url_placeholder Placeholder string for base URL (e.g., * "{BASE_URL}") * @param output Buffer to store transformed URL