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
28 changes: 28 additions & 0 deletions docs/en/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,34 @@ See [Time Processing Guide](/en/guide/time-processing) for details.
- Can be configured to use a specific network interface via `upstream-interface-http`, or overridden per request using the `r2h-ifname` parameter
- If the proxied target URL is an m3u file, all `http://` URLs in it will be automatically rewritten to go through the rtp2httpd proxy (to ensure HLS streams are correctly proxied)

## IPv6 Support

Host addresses in all URLs support IPv6. When writing IPv6 literals in URLs, wrap them in square brackets `[]`:

```url
# HTTP reverse proxy (IPv6 upstream)
http://192.168.1.1:5140/http/[2001:db8::1]:8080/live/stream.m3u8

# RTSP to HTTP (IPv6 upstream)
http://192.168.1.1:5140/rtsp/[2001:db8::1]:554/channel1

# RTSP URL with credentials
rtsp://user:pass@[2001:db8::1]:554/live

# IPv6 multicast (requires IPv6 multicast support from the system and network interface)
http://192.168.1.1:5140/rtp/[ff3e::1]:1234
```

### Behavior

- **Automatic dual-stack resolution for hostnames**: when the upstream address is a hostname, IPv6 / IPv4 addresses are tried in system resolver order, automatically falling back to the next address on connection failure
- **Host validation**: when `hostname` is configured, Host headers in `[IPv6]:port` format pass validation correctly
- **Server listening**: both the `[bind]` config section and the `--listen` flag accept IPv6 addresses (e.g. `::1 5140` or `-l [::1]:5140`)

### Limitations

- **`mcast-rejoin-interval` does not support IPv6**

## M3U Playlist Access

```url
Expand Down
4 changes: 4 additions & 0 deletions docs/en/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ external-m3u-update-interval = 7200
# - Misconfigured network devices that drop multicast memberships
# Recommended value: 30-120 seconds (less than typical switch timeout of 260 seconds)
# Note: Disabled by default (0), only enable when experiencing multicast stream interruptions
# Note: Does not support IPv6
mcast-rejoin-interval = 0

# FCC media stream listening port range (optional, format: start-end, default: random ports)
Expand Down Expand Up @@ -245,6 +246,9 @@ ffmpeg-args = -hwaccel none
# Listen on specific IP, port 8081
192.168.1.1 8081

# Listen on an IPv6 address (brackets optional)
2001:db8::1 5140

# Multiple listen addresses are supported

# The [services] section can contain M3U playlists starting with #EXTM3U
Expand Down
28 changes: 28 additions & 0 deletions docs/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,34 @@ http://192.168.1.1:5140/http/iptv.example.com/channel1?r2h-ifname=eth0
- 可通过 `upstream-interface-http` 配置指定上游网络接口,也可以通过 `r2h-ifname` 参数在每次请求中指定
- 如果被代理的目标 URL 是 m3u 类型,其中所有 `http://` URL 会被自动改写为经过 rtp2httpd 代理后的地址(为了保证 HLS 流能被正确代理)

## IPv6 支持

所有 URL 中的主机地址均支持 IPv6。在 URL 中书写 IPv6 字面量时,需要使用方括号 `[]` 包裹:

```url
# HTTP 反向代理(IPv6 上游)
http://192.168.1.1:5140/http/[2001:db8::1]:8080/live/stream.m3u8

# RTSP 转 HTTP(IPv6 上游)
http://192.168.1.1:5140/rtsp/[2001:db8::1]:554/channel1

# RTSP URL 带认证信息
rtsp://user:pass@[2001:db8::1]:554/live

# IPv6 组播(需要系统和网络接口支持 IPv6 组播)
http://192.168.1.1:5140/rtp/[ff3e::1]:1234
```

### 行为说明

- **域名自动双栈解析**:上游地址为域名时,按系统解析顺序依次尝试 IPv6 / IPv4 地址,连接失败时自动回退到下一个地址
- **Host 校验**:配置了 `hostname` 时,请求头中的 `[IPv6]:端口` 格式 Host 也能正确通过校验
- **服务监听**:`[bind]` 配置节和 `--listen` 参数均支持 IPv6 地址(如 `::1 5140` 或 `-l [::1]:5140`)

### 限制

- **`mcast-rejoin-interval` 不支持 IPv6**

## M3U 播放列表访问

```url
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ external-m3u-update-interval = 7200
# - 配置不当的网络设备会丢弃组播成员关系
# 推荐值: 30-120 秒(小于典型交换机超时 260 秒)
# 注意:默认禁用(0),仅在遇到组播流中断时才需要启用
# 注意:不支持 IPv6
mcast-rejoin-interval = 0

# FCC 监听媒体流端口范围(可选,格式: 起始-结束,默认随机端口)
Expand Down Expand Up @@ -245,6 +246,9 @@ ffmpeg-args = -hwaccel none
# 监听特定 IP 的 8081 端口
192.168.1.1 8081

# 监听 IPv6 地址(可省略方括号)
2001:db8::1 5140

# 支持多个监听地址

# [services] 内可以直接编写以 #EXTM3U 开头的 m3u 节目清单
Expand Down
2 changes: 2 additions & 0 deletions e2e/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
find_free_port,
find_free_udp_port,
find_free_udp_port_pair,
ipv6_loopback_available,
wait_for_port,
)
from .r2h_process import R2HProcess, make_m3u_rtsp_config
Expand Down Expand Up @@ -66,6 +67,7 @@
"find_free_udp_port_pair",
"http_get",
"http_request",
"ipv6_loopback_available",
"make_m3u_rtsp_config",
"make_rtp_packet",
"stream_get",
Expand Down
3 changes: 2 additions & 1 deletion e2e/helpers/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ def stream_get(
except OSError, socket.timeout:
return 0, {}, b""
try:
req_lines = ["GET %s HTTP/1.0" % path, "Host: %s" % host]
host_hdr = "[%s]" % host if ":" in host and not host.startswith("[") else host
req_lines = ["GET %s HTTP/1.0" % path, "Host: %s" % host_hdr]
for k, v in (headers or {}).items():
req_lines.append("%s: %s" % (k, v))
req_lines.append("")
Expand Down
17 changes: 13 additions & 4 deletions e2e/helpers/mock_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,19 @@ def log_message(self, format, *args) -> None: # noqa: ARG002, A002
pass # silence


class _HTTPServerV6(HTTPServer):
address_family = socket.AF_INET6


class MockHTTPUpstream:
"""Start a throwaway HTTP server with pre-configured routes."""
"""Start a throwaway HTTP server with pre-configured routes.

def __init__(self, port: int = 0, routes: dict | None = None):
self.port = port or find_free_port()
Pass ``host="::1"`` to serve on the IPv6 loopback instead of 127.0.0.1.
"""

def __init__(self, port: int = 0, routes: dict | None = None, host: str = "127.0.0.1"):
self.host = host
self.port = port or find_free_port(host)
self.routes = routes or {}
self.requests_log: list[dict] = []
self._server: HTTPServer | None = None
Expand All @@ -79,7 +87,8 @@ def start(self) -> None:
(_UpstreamHandler,),
{"routes": self.routes, "requests_log": self.requests_log},
)
self._server = HTTPServer(("127.0.0.1", self.port), handler)
server_cls = _HTTPServerV6 if ":" in self.host else HTTPServer
self._server = server_cls((self.host, self.port), handler)
self._thread = threading.Thread(
target=self._server.serve_forever,
daemon=True,
Expand Down
19 changes: 14 additions & 5 deletions e2e/helpers/mock_rtsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ class _RTSPServerBase:
"""

def __init__(
self, port: int = 0, sdp_control: str = "*", content_base: str | None = "auto", custom_sdp: str | None = None
self,
port: int = 0,
sdp_control: str = "*",
content_base: str | None = "auto",
custom_sdp: str | None = None,
host: str = "127.0.0.1",
):
"""
Args:
Expand All @@ -35,8 +40,10 @@ def __init__(
for relative controls); ``None`` omits the header entirely;
any other string is sent verbatim.
custom_sdp: If set, replaces the auto-generated SDP body.
host: Address to listen on (use "::1" for IPv6 loopback).
"""
self.port = port or find_free_port()
self.host = host
self.port = port or find_free_port(host)
self._sdp_control = sdp_control
self._content_base = content_base
self._custom_sdp = custom_sdp
Expand All @@ -49,9 +56,10 @@ def __init__(
# -- lifecycle -----------------------------------------------------------

def start(self) -> None:
self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
family = socket.AF_INET6 if ":" in self.host else socket.AF_INET
self._server_sock = socket.socket(family, socket.SOCK_STREAM)
self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._server_sock.bind(("127.0.0.1", self.port))
self._server_sock.bind((self.host, self.port))
self._server_sock.listen(5)
self._server_sock.settimeout(1.0)
self._thread = threading.Thread(target=self._accept, daemon=True)
Expand Down Expand Up @@ -198,8 +206,9 @@ def __init__(
sdp_control: str = "*",
content_base: str | None = "auto",
custom_sdp: str | None = None,
host: str = "127.0.0.1",
):
super().__init__(port, sdp_control=sdp_control, content_base=content_base, custom_sdp=custom_sdp)
super().__init__(port, sdp_control=sdp_control, content_base=content_base, custom_sdp=custom_sdp, host=host)
self._num_packets = num_packets

def _setup_response(self, cseq: str, transport_hdr: str) -> str:
Expand Down
19 changes: 15 additions & 4 deletions e2e/helpers/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,24 @@
import time


def find_free_port() -> int:
"""Find a free TCP port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
def find_free_port(host: str = "127.0.0.1") -> int:
"""Find a free TCP port on *host* (use "::1" for IPv6 loopback)."""
family = socket.AF_INET6 if ":" in host else socket.AF_INET
with socket.socket(family, socket.SOCK_STREAM) as s:
s.bind((host, 0))
return s.getsockname()[1]


def ipv6_loopback_available() -> bool:
"""Return True when binding a TCP socket to ::1 works on this host."""
try:
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s:
s.bind(("::1", 0))
return True
except OSError:
return False


def find_free_udp_port() -> int:
"""Find a free UDP port."""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
Expand Down
Loading
Loading