Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/en/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions docs/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
295 changes: 292 additions & 3 deletions e2e/test_http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading