diff --git a/docs/en/guide/url-formats.md b/docs/en/guide/url-formats.md index cf5866b..8195e64 100644 --- a/docs/en/guide/url-formats.md +++ b/docs/en/guide/url-formats.md @@ -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 diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index e65d7a7..4eb47d7 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -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) @@ -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 diff --git a/docs/guide/url-formats.md b/docs/guide/url-formats.md index b73d64d..c000146 100644 --- a/docs/guide/url-formats.md +++ b/docs/guide/url-formats.md @@ -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 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 7343b91..41ade37 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -183,6 +183,7 @@ external-m3u-update-interval = 7200 # - 配置不当的网络设备会丢弃组播成员关系 # 推荐值: 30-120 秒(小于典型交换机超时 260 秒) # 注意:默认禁用(0),仅在遇到组播流中断时才需要启用 +# 注意:不支持 IPv6 mcast-rejoin-interval = 0 # FCC 监听媒体流端口范围(可选,格式: 起始-结束,默认随机端口) @@ -245,6 +246,9 @@ ffmpeg-args = -hwaccel none # 监听特定 IP 的 8081 端口 192.168.1.1 8081 +# 监听 IPv6 地址(可省略方括号) +2001:db8::1 5140 + # 支持多个监听地址 # [services] 内可以直接编写以 #EXTM3U 开头的 m3u 节目清单 diff --git a/e2e/helpers/__init__.py b/e2e/helpers/__init__.py index f42764a..35927ca 100644 --- a/e2e/helpers/__init__.py +++ b/e2e/helpers/__init__.py @@ -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 @@ -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", diff --git a/e2e/helpers/http.py b/e2e/helpers/http.py index 25c0284..57c7d56 100644 --- a/e2e/helpers/http.py +++ b/e2e/helpers/http.py @@ -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("") diff --git a/e2e/helpers/mock_http.py b/e2e/helpers/mock_http.py index 8e84c1a..2f128c4 100644 --- a/e2e/helpers/mock_http.py +++ b/e2e/helpers/mock_http.py @@ -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 @@ -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, diff --git a/e2e/helpers/mock_rtsp.py b/e2e/helpers/mock_rtsp.py index 8f7dc80..a6cd88e 100644 --- a/e2e/helpers/mock_rtsp.py +++ b/e2e/helpers/mock_rtsp.py @@ -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: @@ -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 @@ -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) @@ -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: diff --git a/e2e/helpers/ports.py b/e2e/helpers/ports.py index 21d4335..820716b 100644 --- a/e2e/helpers/ports.py +++ b/e2e/helpers/ports.py @@ -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: diff --git a/e2e/test_ipv6.py b/e2e/test_ipv6.py new file mode 100644 index 0000000..d2a6a31 --- /dev/null +++ b/e2e/test_ipv6.py @@ -0,0 +1,289 @@ +""" +E2E tests for IPv6 support. + +Covers: +- HTTP proxy with IPv6-only upstream (`/http/[::1]:port/...`), including the + bracketed Host header, M3U rewrite, and redirect Location rewrite. +- RTSP proxy over TCP interleaved with IPv6 upstream (`/rtsp/[::1]:port/...`). +- HTTP server listening on the IPv6 loopback, hostname validation with + `[::1]:port` Host headers, and playlist base URL generation. +- FCC graceful fallback when the FCC server address is IPv6. +""" + +import time + +import pytest + +from helpers import ( + MockHTTPUpstream, + MockRTSPServer, + R2HProcess, + find_free_port, + http_get, + ipv6_loopback_available, + stream_get, + wait_for_port, +) + +pytestmark = pytest.mark.skipif( + not ipv6_loopback_available(), + reason="IPv6 loopback (::1) not available on this host", +) + +_TIMEOUT = 5.0 +_STREAM_TIMEOUT = 20.0 + + +# --------------------------------------------------------------------------- +# Module-scoped shared rtp2httpd instance (dual-stack listen) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def shared_r2h(r2h_binary): + """A single rtp2httpd instance shared by IPv6 proxy tests.""" + port = find_free_port() + r2h = R2HProcess(r2h_binary, port, extra_args=["-v", "4", "-m", "100"]) + r2h.start() + yield r2h + r2h.stop() + + +# --------------------------------------------------------------------------- +# HTTP proxy with IPv6 upstream +# --------------------------------------------------------------------------- + + +class TestHTTPProxyIPv6Upstream: + """`/http/[::1]:port/...` should proxy to an IPv6-only upstream.""" + + def test_proxy_200_via_ipv6(self, shared_r2h): + upstream = MockHTTPUpstream( + host="::1", + routes={"/ok": {"status": 200, "body": b"v6-world", "headers": {"Content-Type": "text/plain"}}}, + ) + upstream.start() + try: + status, _, body = http_get( + "127.0.0.1", + shared_r2h.port, + f"/http/[::1]:{upstream.port}/ok", + timeout=_TIMEOUT, + ) + assert status == 200 + assert body == b"v6-world" + finally: + upstream.stop() + + def test_host_header_is_bracketed(self, shared_r2h): + """The upstream must receive `Host: [::1]:port` (bracketed IPv6).""" + upstream = MockHTTPUpstream( + host="::1", + routes={"/ok": {"status": 200, "body": b"x"}}, + ) + upstream.start() + try: + status, _, _ = http_get( + "127.0.0.1", + shared_r2h.port, + f"/http/[::1]:{upstream.port}/ok", + timeout=_TIMEOUT, + ) + assert status == 200 + assert len(upstream.requests_log) == 1 + host_hdr = upstream.requests_log[0]["headers"].get("Host") + assert host_hdr == f"[::1]:{upstream.port}" + finally: + upstream.stop() + + def test_m3u_relative_rewrite_ipv6_authority(self, shared_r2h): + """Relative M3U URLs from an IPv6 upstream should be rewritten with a + bracketed `[::1]:port` authority in the proxy URL.""" + m3u = "#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXTINF:10,\nseg0.ts\n#EXTINF:10,\n/abs/seg1.ts\n" + upstream = MockHTTPUpstream( + host="::1", + routes={ + "/live/playlist.m3u8": { + "status": 200, + "body": m3u, + "headers": {"Content-Type": "application/vnd.apple.mpegurl"}, + }, + }, + ) + upstream.start() + try: + status, _, body = http_get( + "127.0.0.1", + shared_r2h.port, + f"/http/[::1]:{upstream.port}/live/playlist.m3u8", + timeout=_TIMEOUT, + ) + text = body.decode("utf-8", errors="replace") + assert status == 200 + assert f"/http/[::1]:{upstream.port}/live/seg0.ts" in text + assert f"/http/[::1]:{upstream.port}/abs/seg1.ts" in text + finally: + upstream.stop() + + def test_redirect_location_ipv6_rewritten(self, shared_r2h): + """A redirect to an IPv6 upstream URL should be rewritten to /http/[::1]:...""" + upstream = MockHTTPUpstream( + host="::1", + routes={ + "/old": { + "status": 302, + "body": b"", + "headers": {"Location": "http://[::1]:8080/new/page"}, + }, + }, + ) + upstream.start() + try: + status, hdrs, _ = http_get( + "127.0.0.1", + shared_r2h.port, + f"/http/[::1]:{upstream.port}/old", + timeout=_TIMEOUT, + ) + assert status == 302 + location = next((v for k, v in hdrs.items() if k.lower() == "location"), None) + assert location == "/http/[::1]:8080/new/page" + finally: + upstream.stop() + + +# --------------------------------------------------------------------------- +# RTSP proxy (TCP interleaved) with IPv6 upstream +# --------------------------------------------------------------------------- + + +@pytest.mark.rtsp +class TestRTSPIPv6Upstream: + """`/rtsp/[::1]:port/...` should play over TCP interleaved transport.""" + + def test_rtsp_tcp_stream_via_ipv6(self, shared_r2h): + rtsp = MockRTSPServer(num_packets=500, host="::1") + rtsp.start() + try: + status, _, body = stream_get( + "127.0.0.1", + shared_r2h.port, + f"/rtsp/[::1]:{rtsp.port}/stream", + read_bytes=4096, + timeout=_STREAM_TIMEOUT, + ) + assert status == 200 + assert len(body) >= 188 + assert body[0] == 0x47, "Expected TS sync byte 0x47, got 0x%02x" % body[0] + finally: + rtsp.stop() + + def test_rtsp_handshake_uses_bracketed_url(self, shared_r2h): + """RTSP requests must carry rtsp://[::1]:port/... URIs (brackets kept).""" + rtsp = MockRTSPServer(num_packets=500, host="::1") + rtsp.start() + try: + stream_get( + "127.0.0.1", + shared_r2h.port, + f"/rtsp/[::1]:{rtsp.port}/stream", + read_bytes=2048, + timeout=_STREAM_TIMEOUT, + ) + methods = rtsp.requests_received + assert "OPTIONS" in methods + assert "DESCRIBE" in methods + assert "SETUP" in methods + assert "PLAY" in methods + describe = next(r for r in rtsp.requests_detailed if r["method"] == "DESCRIBE") + assert describe["uri"].startswith(f"rtsp://[::1]:{rtsp.port}/") + finally: + rtsp.stop() + + +# --------------------------------------------------------------------------- +# HTTP server on IPv6 loopback +# --------------------------------------------------------------------------- + + +class TestHTTPServerIPv6Listen: + """rtp2httpd bound to ::1 should serve requests and validate IPv6 Hosts.""" + + @pytest.fixture(scope="class") + def v6_r2h(self, r2h_binary): + port = find_free_port("::1") + config = f"""\ +[global] +verbosity = 4 +hostname = ::1 + +[bind] +::1 {port} + +[services] +#EXTM3U +#EXTINF:-1,Channel One +rtp://239.0.0.1:1234 +""" + r2h = R2HProcess(r2h_binary, port, config_content=config) + r2h.start(wait=False) + assert wait_for_port(port, host="::1", timeout=6.0), "rtp2httpd did not listen on [::1]" + yield r2h + r2h.stop() + + def test_request_with_bracketed_host_accepted(self, v6_r2h): + """A request with `Host: [::1]:port` must pass hostname validation.""" + status, _, body = http_get("::1", v6_r2h.port, "/playlist.m3u", timeout=_TIMEOUT) + assert status == 200 + assert b"#EXTM3U" in body + + def test_request_with_wrong_host_rejected(self, v6_r2h): + status, _, _ = http_get( + "::1", v6_r2h.port, "/playlist.m3u", timeout=_TIMEOUT, headers={"Host": "evil.example.com"} + ) + assert status == 400 + + def test_playlist_base_url_bracketed(self, v6_r2h): + """Playlist URLs must use a valid bracketed IPv6 authority.""" + status, _, body = http_get("::1", v6_r2h.port, "/playlist.m3u", timeout=_TIMEOUT) + text = body.decode("utf-8", errors="replace") + assert status == 200 + assert "http://[::1]" in text + # A bare (unbracketed) IPv6 authority like http://::1 is invalid + assert "http://::1" not in text + + +# --------------------------------------------------------------------------- +# FCC with IPv6 address: graceful fallback +# --------------------------------------------------------------------------- + + +class TestFCCIPv6Fallback: + """`fcc=[::1]:port` must not crash; FCC is disabled with a warning and the + stream falls back to plain multicast.""" + + def test_fcc_ipv6_disabled_with_warning(self, r2h_binary): + port = find_free_port() + r2h = R2HProcess( + r2h_binary, + port, + extra_args=["-v", "4", "-m", "100"], + capture_log=True, + ) + try: + r2h.start() + # No multicast data flows; just verify the server survives the + # request and logs the IPv4-only fallback. + stream_get( + "127.0.0.1", + port, + "/rtp/239.0.0.1:5140?fcc=[::1]:9999", + read_bytes=128, + timeout=2.0, + ) + time.sleep(0.2) + assert r2h.process is not None and r2h.process.poll() is None, "rtp2httpd crashed" + log = r2h.read_log() + assert "FCC is IPv4-only" in log + 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 9da6e31..726bc9a 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 @@ -190,6 +190,20 @@ return view.extend({ // If hostname doesn't have protocol, prepend http:// for URL parsing var hasProtocol = /^https?:\/\//i.test(targetHostname); + + // Bracket bare IPv6 literals (multiple colons, no brackets) so URL + // parsing and final URL construction are valid; also handle hosts + // written with an explicit scheme (e.g. "http://::1") + var schemeMatch = targetHostname.match(/^https?:\/\//i); + var scheme = schemeMatch ? schemeMatch[0] : ""; + var authority = targetHostname.slice(scheme.length); + if ( + authority.indexOf("[") === -1 && + authority.indexOf("/") === -1 && + authority.indexOf(":") !== authority.lastIndexOf(":") + ) { + targetHostname = scheme + "[" + authority + "]"; + } var urlToParse = hasProtocol ? targetHostname : "http://" + targetHostname; diff --git a/src/configuration.c b/src/configuration.c index 82dd7fe..311c818 100644 --- a/src/configuration.c +++ b/src/configuration.c @@ -214,6 +214,13 @@ void parse_bind_sec(char *line) { if (strcmp("*", node) == 0) { free(node); node = NULL; + } else if (node && node[0] == '[') { + /* Strip brackets from "[IPv6]" notation for getaddrinfo */ + char *closing = strchr(node, ']'); + if (closing) { + *closing = '\0'; + memmove(node, node + 1, strlen(node + 1) + 1); + } } logger(LOG_DEBUG, "node: %s, port: %s", node, service); diff --git a/src/http.c b/src/http.c index b292137..6f0c77c 100644 --- a/src/http.c +++ b/src/http.c @@ -1,6 +1,7 @@ #include "http.h" #include "configuration.h" #include "connection.h" +#include "utils.h" #include #include #include @@ -860,27 +861,22 @@ int http_parse_url_components(const char *url, char *protocol, char *host, char int http_match_host_header(const char *request_host_header, const char *expected_host) { char request_hostname[256]; + char expected_hostname[256]; if (!request_host_header || !expected_host) return -1; - /* Extract hostname from Host header (ignore port part) */ - const char *request_colon = strchr(request_host_header, ':'); - if (request_colon) { - /* Host header has port, extract hostname part only */ - size_t host_len = (size_t)(request_colon - request_host_header); - if (host_len >= sizeof(request_hostname)) - host_len = sizeof(request_hostname) - 1; - strncpy(request_hostname, request_host_header, host_len); - request_hostname[host_len] = '\0'; - } else { - /* Host header has no port */ - strncpy(request_hostname, request_host_header, sizeof(request_hostname) - 1); - request_hostname[sizeof(request_hostname) - 1] = '\0'; - } + /* Extract hostname from Host header (ignore port part). Handles + * "[IPv6]:port", bracketed/bare IPv6 literals, hostnames, and IPv4. */ + if (parse_host_port(request_host_header, request_hostname, sizeof(request_hostname), NULL) != 0) + return 0; + + /* Normalize expected host: strip brackets if configured as "[IPv6]" */ + if (parse_host_port(expected_host, expected_hostname, sizeof(expected_hostname), NULL) != 0) + return 0; /* Compare only the hostname parts (case-insensitive) */ - return (strcasecmp(request_hostname, expected_host) == 0) ? 1 : 0; + return (strcasecmp(request_hostname, expected_hostname) == 0) ? 1 : 0; } /** diff --git a/src/http_proxy.c b/src/http_proxy.c index 81136e1..e12a3ba 100644 --- a/src/http_proxy.c +++ b/src/http_proxy.c @@ -127,7 +127,6 @@ void http_proxy_resume_upstream(http_proxy_session_t *session) { int http_proxy_parse_url(http_proxy_session_t *session, const char *url) { const char *p; - char *colon; char *slash; size_t host_len; @@ -156,57 +155,20 @@ int http_proxy_parse_url(http_proxy_session_t *session, const char *url) { host_len = strlen(p); } - /* Check for port in host:port format */ - /* Need to handle IPv6 addresses like [::1]:8080 */ - if (p[0] == '[') { - /* IPv6 address */ - char *bracket = strchr(p, ']'); - if (bracket && bracket < p + host_len) { - colon = strchr(bracket, ':'); - if (colon && colon < p + host_len) { - /* Has port after IPv6 address */ - size_t addr_len = bracket - p + 1; - if (addr_len >= HTTP_PROXY_HOST_SIZE) { - logger(LOG_ERROR, "HTTP Proxy: Host too long"); - return -1; - } - memcpy(session->target_host, p, addr_len); - session->target_host[addr_len] = '\0'; - session->target_port = atoi(colon + 1); - } else { - /* No port, just IPv6 address */ - if (host_len >= HTTP_PROXY_HOST_SIZE) { - logger(LOG_ERROR, "HTTP Proxy: Host too long"); - return -1; - } - memcpy(session->target_host, p, host_len); - session->target_host[host_len] = '\0'; - } - } else { - logger(LOG_ERROR, "HTTP Proxy: Invalid IPv6 address format"); + /* Parse host[:port], handling "[IPv6]:port", bracketed/bare IPv6 literals, + * hostnames, and IPv4. target_host stores the bare host (no brackets). */ + { + char hostport[HTTP_PROXY_HOST_SIZE + 16]; + if (host_len >= sizeof(hostport)) { + logger(LOG_ERROR, "HTTP Proxy: Host too long"); return -1; } - } else { - /* IPv4 or hostname */ - colon = memchr(p, ':', host_len); - if (colon) { - /* Has port */ - size_t hostname_len = colon - p; - if (hostname_len >= HTTP_PROXY_HOST_SIZE) { - logger(LOG_ERROR, "HTTP Proxy: Host too long"); - return -1; - } - memcpy(session->target_host, p, hostname_len); - session->target_host[hostname_len] = '\0'; - session->target_port = atoi(colon + 1); - } else { - /* No port */ - if (host_len >= HTTP_PROXY_HOST_SIZE) { - logger(LOG_ERROR, "HTTP Proxy: Host too long"); - return -1; - } - memcpy(session->target_host, p, host_len); - session->target_host[host_len] = '\0'; + memcpy(hostport, p, host_len); + hostport[host_len] = '\0'; + + if (parse_host_port(hostport, session->target_host, sizeof(session->target_host), &session->target_port) != 0) { + logger(LOG_ERROR, "HTTP Proxy: Invalid host format: %s", hostport); + return -1; } } @@ -313,65 +275,90 @@ void http_proxy_set_request_headers(http_proxy_session_t *session, const char *h } } -int http_proxy_connect(http_proxy_session_t *session) { - struct sockaddr_in server_addr; - struct hostent *he; - int connect_result; - - if (!session || session->socket >= 0) { - logger(LOG_ERROR, "HTTP Proxy: Invalid session or already connected"); - return -1; +/* Free the getaddrinfo candidate list (after success or final failure) */ +static void http_proxy_free_connect_results(http_proxy_session_t *session) { + if (session->connect_results) { + freeaddrinfo(session->connect_results); + session->connect_results = NULL; + session->connect_next = NULL; } +} - /* Resolve hostname */ - he = gethostbyname(session->target_host); - if (!he) { - logger(LOG_ERROR, "HTTP Proxy: Cannot resolve hostname %s: %s", session->target_host, hstrerror(h_errno)); - return -1; - } +/* Called when the current socket connected successfully: keep it, drop the + * remaining candidates, and queue the HTTP request. */ +static int http_proxy_on_connected(http_proxy_session_t *session) { + http_proxy_free_connect_results(session); - /* Validate address list */ - if (!he->h_addr_list[0]) { - logger(LOG_ERROR, "HTTP Proxy: No addresses for hostname %s", session->target_host); - return -1; - } + http_proxy_set_state(session, HTTP_PROXY_STATE_CONNECTED); - /* Create TCP socket */ - session->socket = socket(AF_INET, SOCK_STREAM, 0); - if (session->socket < 0) { - logger(LOG_ERROR, "HTTP Proxy: Failed to create socket: %s", strerror(errno)); + /* Build and queue HTTP request */ + if (http_proxy_build_request(session) < 0) { + logger(LOG_ERROR, "HTTP Proxy: Failed to build request"); + http_proxy_set_state(session, HTTP_PROXY_STATE_ERROR); return -1; } - platform_set_nosigpipe(session->socket); - /* Set socket to non-blocking mode */ - if (connection_set_nonblocking(session->socket) < 0) { - logger(LOG_ERROR, "HTTP Proxy: Failed to set socket non-blocking: %s", strerror(errno)); - close(session->socket); - session->socket = -1; - return -1; - } + http_proxy_set_state(session, HTTP_PROXY_STATE_SENDING_REQUEST); + return 0; +} + +/** + * Try connecting to the next candidate from the getaddrinfo result list + * (sequential dual-stack fallback, IPv6/IPv4 in resolver order). + * On EINPROGRESS the session enters CONNECTING and the poller drives + * completion; on hard failure the next candidate is tried immediately. + * @return 0 if a connection succeeded or is in progress, -1 when all + * candidates are exhausted + */ +static int http_proxy_try_next_candidate(http_proxy_session_t *session) { + while (session->connect_next) { + struct addrinfo *rp = session->connect_next; + session->connect_next = rp->ai_next; + + int sock = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + if (sock < 0) { + session->connect_last_errno = errno; + continue; + } + platform_set_nosigpipe(sock); + + if (connection_set_nonblocking(sock) < 0) { + session->connect_last_errno = errno; + close(sock); + continue; + } + + /* Bind to upstream interface if configured */ + bind_to_upstream_interface(sock, session->upstream_ifname); - /* Bind to upstream interface if configured */ - bind_to_upstream_interface(session->socket, session->upstream_ifname); + int connect_result = connect(sock, rp->ai_addr, rp->ai_addrlen); - /* Connect to server (non-blocking) */ - memset(&server_addr, 0, sizeof(server_addr)); - server_addr.sin_family = AF_INET; - server_addr.sin_port = htons(session->target_port); - memcpy(&server_addr.sin_addr.s_addr, he->h_addr_list[0], he->h_length); + if (connect_result == 0) { + /* Immediate connection success (rare, but possible for localhost) */ + logger(LOG_DEBUG, "HTTP Proxy: Connected immediately to %s:%d (%s)", session->target_host, session->target_port, + rp->ai_family == AF_INET6 ? "IPv6" : "IPv4"); + + session->socket = sock; + if (session->epoll_fd >= 0) { + if (poller_add(session->epoll_fd, session->socket, + POLLER_IN | POLLER_OUT | POLLER_HUP | POLLER_ERR | POLLER_RDHUP) < 0) { + logger(LOG_ERROR, "HTTP Proxy: Failed to add socket to poller: %s", strerror(errno)); + close(session->socket); + session->socket = -1; + return -1; + } + fdmap_set(session->socket, session->conn); + } - connect_result = connect(session->socket, (struct sockaddr *)&server_addr, sizeof(server_addr)); + return http_proxy_on_connected(session); + } - /* Handle non-blocking connect result */ - if (connect_result < 0) { if (errno == EINPROGRESS || errno == EWOULDBLOCK) { /* Connection in progress - normal for non-blocking sockets */ - logger(LOG_DEBUG, "HTTP Proxy: Connection to %s:%d in progress (async)", session->target_host, - session->target_port); + logger(LOG_DEBUG, "HTTP Proxy: Connection to %s:%d in progress (async, %s)", session->target_host, + session->target_port, rp->ai_family == AF_INET6 ? "IPv6" : "IPv4"); - /* Register socket with poller for POLLER_OUT to detect connection - * completion */ + session->socket = sock; if (session->epoll_fd >= 0) { if (poller_add(session->epoll_fd, session->socket, POLLER_OUT | POLLER_IN | POLLER_ERR | POLLER_HUP | POLLER_RDHUP) < 0) { @@ -381,47 +368,73 @@ int http_proxy_connect(http_proxy_session_t *session) { return -1; } fdmap_set(session->socket, session->conn); - logger(LOG_DEBUG, "HTTP Proxy: Socket registered with poller for connection"); } http_proxy_set_state(session, HTTP_PROXY_STATE_CONNECTING); - return 0; /* Success - connection in progress */ - } else { - /* Real connection error */ - logger(LOG_ERROR, "HTTP Proxy: Failed to connect to %s:%d: %s", session->target_host, session->target_port, - strerror(errno)); - close(session->socket); - session->socket = -1; - return -1; + /* Restart the per-candidate timeout even if we were already in + * CONNECTING (set_state is a no-op for same-state transitions) */ + session->last_state_change_ms = get_time_ms(); + return 0; } + + /* Hard failure - try next candidate */ + session->connect_last_errno = errno; + logger(LOG_DEBUG, "HTTP Proxy: connect() to %s:%d failed (%s): %s", session->target_host, session->target_port, + rp->ai_family == AF_INET6 ? "IPv6" : "IPv4", strerror(errno)); + close(sock); } - /* Immediate connection success (rare, but possible for localhost) */ - logger(LOG_DEBUG, "HTTP Proxy: Connected immediately to %s:%d", session->target_host, session->target_port); + logger(LOG_ERROR, "HTTP Proxy: Failed to connect to %s:%d: %s", session->target_host, session->target_port, + session->connect_last_errno ? strerror(session->connect_last_errno) : "no usable address"); + http_proxy_free_connect_results(session); + return -1; +} - /* Register socket with poller */ - if (session->epoll_fd >= 0) { - if (poller_add(session->epoll_fd, session->socket, - POLLER_IN | POLLER_OUT | POLLER_HUP | POLLER_ERR | POLLER_RDHUP) < 0) { - logger(LOG_ERROR, "HTTP Proxy: Failed to add socket to poller: %s", strerror(errno)); - close(session->socket); - session->socket = -1; - return -1; - } - fdmap_set(session->socket, session->conn); +/** + * Handle failure of the in-progress connect attempt: close the current + * socket and fall back to the next address candidate if one remains. + * @return 0 if another attempt was started, -1 if all candidates failed + */ +static int http_proxy_handle_connect_failure(http_proxy_session_t *session, int error) { + session->connect_last_errno = error; + logger(LOG_DEBUG, "HTTP Proxy: Async connect to %s:%d failed: %s", session->target_host, session->target_port, + strerror(error)); + + if (session->socket >= 0) { + worker_cleanup_socket_from_epoll(session->epoll_fd, session->socket); + session->socket = -1; } - http_proxy_set_state(session, HTTP_PROXY_STATE_CONNECTED); + return http_proxy_try_next_candidate(session); +} - /* Build and queue HTTP request */ - if (http_proxy_build_request(session) < 0) { - logger(LOG_ERROR, "HTTP Proxy: Failed to build request"); - http_proxy_set_state(session, HTTP_PROXY_STATE_ERROR); +int http_proxy_connect(http_proxy_session_t *session) { + struct addrinfo hints; + char port_str[16]; + int gai_result; + + if (!session || session->socket >= 0) { + logger(LOG_ERROR, "HTTP Proxy: Invalid session or already connected"); return -1; } - http_proxy_set_state(session, HTTP_PROXY_STATE_SENDING_REQUEST); - return 0; + /* Resolve hostname (dual-stack: IPv6 and IPv4 candidates) */ + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + snprintf(port_str, sizeof(port_str), "%d", session->target_port); + + gai_result = getaddrinfo(session->target_host, port_str, &hints, &session->connect_results); + if (gai_result != 0) { + logger(LOG_ERROR, "HTTP Proxy: Cannot resolve hostname %s: %s", session->target_host, gai_strerror(gai_result)); + session->connect_results = NULL; + return -1; + } + + session->connect_next = session->connect_results; + session->connect_last_errno = 0; + + return http_proxy_try_next_candidate(session); } static int http_proxy_build_request(http_proxy_session_t *session) { @@ -431,11 +444,10 @@ static int http_proxy_build_request(http_proxy_session_t *session) { size_t remaining; const char *override_user_agent = http_proxy_get_override_user_agent(); - /* Build Host header with port if non-standard */ - if (session->target_port == 80) { - snprintf(host_header, sizeof(host_header), "%s", session->target_host); - } else { - snprintf(host_header, sizeof(host_header), "%s:%d", session->target_host, session->target_port); + /* Build Host header with port if non-standard (IPv6 hosts bracketed) */ + if (format_host_port_for_url(session->target_host, session->target_port, 80, host_header, sizeof(host_header)) < 0) { + logger(LOG_ERROR, "HTTP Proxy: Host header too long"); + return -1; } /* Use stored method or default to GET */ @@ -1223,27 +1235,9 @@ int http_proxy_handle_socket_event(http_proxy_session_t *session, uint32_t event return -1; } - /* Check for hard socket errors first */ - if (events & POLLER_ERR) { - int sock_error = 0; - socklen_t error_len = sizeof(sock_error); - if (getsockopt(session->socket, SOL_SOCKET, SO_ERROR, &sock_error, &error_len) == 0 && sock_error != 0) { - logger(LOG_ERROR, "HTTP Proxy: Socket error: %s", strerror(sock_error)); - } else { - logger(LOG_ERROR, "HTTP Proxy: Socket error event received"); - } - /* During STREAMING: drain pending client output before disconnecting */ - if (session->state == HTTP_PROXY_STATE_STREAMING) { - http_proxy_set_state(session, HTTP_PROXY_STATE_COMPLETE); - } else { - http_proxy_set_state(session, HTTP_PROXY_STATE_ERROR); - return -1; - } - } - - /* Handle connection completion FIRST - before checking HUP events - * When TCP connect completes, we may get POLLER_OUT | POLLER_HUP together - * in some edge cases, so we must check connection state first */ + /* Handle in-progress connect FIRST - before generic error/HUP handling. + * A failed candidate (POLLER_ERR/HUP or SO_ERROR) falls back to the next + * address from the getaddrinfo list instead of erroring out. */ if (session->state == HTTP_PROXY_STATE_CONNECTING) { int sock_error = 0; socklen_t error_len = sizeof(sock_error); @@ -1254,26 +1248,49 @@ int http_proxy_handle_socket_event(http_proxy_session_t *session, uint32_t event return -1; } + if (sock_error == 0 && (events & (POLLER_ERR | POLLER_HUP)) && !(events & POLLER_OUT)) { + /* Error/hangup event without a definite SO_ERROR - treat as failure */ + sock_error = ECONNREFUSED; + } + if (sock_error != 0) { - logger(LOG_ERROR, "HTTP Proxy: Connection to %s:%d failed: %s", session->target_host, session->target_port, - strerror(sock_error)); - http_proxy_set_state(session, HTTP_PROXY_STATE_ERROR); - return -1; + if (http_proxy_handle_connect_failure(session, sock_error) < 0) { + http_proxy_set_state(session, HTTP_PROXY_STATE_ERROR); + return -1; + } + return 0; /* Next candidate attempt in progress */ } /* Connection succeeded */ logger(LOG_INFO, "HTTP Proxy: Connected to %s:%d", session->target_host, session->target_port); - http_proxy_set_state(session, HTTP_PROXY_STATE_CONNECTED); - - /* Build and queue HTTP request */ - if (http_proxy_build_request(session) < 0) { - logger(LOG_ERROR, "HTTP Proxy: Failed to build request"); - http_proxy_set_state(session, HTTP_PROXY_STATE_ERROR); + if (http_proxy_on_connected(session) < 0) { return -1; } + } - http_proxy_set_state(session, HTTP_PROXY_STATE_SENDING_REQUEST); + /* Check for hard socket errors */ + if (events & POLLER_ERR) { + int sock_error = 0; + socklen_t error_len = sizeof(sock_error); + if (getsockopt(session->socket, SOL_SOCKET, SO_ERROR, &sock_error, &error_len) == 0 && sock_error != 0) { + logger(LOG_ERROR, "HTTP Proxy: Socket error: %s", strerror(sock_error)); + /* During STREAMING: drain pending client output before disconnecting */ + if (session->state == HTTP_PROXY_STATE_STREAMING) { + http_proxy_set_state(session, HTTP_PROXY_STATE_COMPLETE); + } else { + http_proxy_set_state(session, HTTP_PROXY_STATE_ERROR); + return -1; + } + } else if (session->state != HTTP_PROXY_STATE_SENDING_REQUEST) { + logger(LOG_ERROR, "HTTP Proxy: Socket error event received"); + if (session->state == HTTP_PROXY_STATE_STREAMING) { + http_proxy_set_state(session, HTTP_PROXY_STATE_COMPLETE); + } else { + http_proxy_set_state(session, HTTP_PROXY_STATE_ERROR); + return -1; + } + } } /* Handle writable socket - send pending request */ @@ -1379,6 +1396,9 @@ int http_proxy_session_cleanup(http_proxy_session_t *session) { session->socket = -1; } + /* Free pending connect candidates if any */ + http_proxy_free_connect_results(session); + /* Free rewrite body buffer if allocated */ if (session->rewrite_body_buffer) { free(session->rewrite_body_buffer); @@ -1414,6 +1434,14 @@ int http_proxy_session_tick(http_proxy_session_t *session, int64_t now) { } int64_t elapsed = now - session->last_state_change_ms; if (elapsed >= HTTP_PROXY_TIMEOUT_SEC * 1000) { + /* Connect timeout: fall back to the next address candidate if any */ + if (session->state == HTTP_PROXY_STATE_CONNECTING && session->connect_next) { + logger(LOG_INFO, "HTTP Proxy: Connect to %s:%d timed out, trying next address", session->target_host, + session->target_port); + if (http_proxy_handle_connect_failure(session, ETIMEDOUT) == 0) { + return 0; + } + } logger(LOG_ERROR, "HTTP Proxy: State %d timed out after %lld ms", session->state, (long long)elapsed); http_proxy_set_state(session, HTTP_PROXY_STATE_ERROR); return -1; diff --git a/src/http_proxy.h b/src/http_proxy.h index 44681cb..4b922c1 100644 --- a/src/http_proxy.h +++ b/src/http_proxy.h @@ -4,8 +4,9 @@ #include #include -/* Forward declaration */ +/* Forward declarations */ struct connection_s; +struct addrinfo; /* ========== HTTP PROXY BUFFER SIZE CONFIGURATION ========== */ @@ -55,11 +56,19 @@ typedef struct { int64_t last_state_change_ms; /* Timestamp of last state change */ int status_index; /* Index in status_shared->clients array */ - /* Target server info */ + /* Target server info (target_host stores the bare host without IPv6 + * brackets; brackets are re-added by URL/Host header formatters) */ char target_host[HTTP_PROXY_HOST_SIZE]; int target_port; char target_path[HTTP_PROXY_PATH_SIZE]; + /* Dual-stack async connect state (sequential candidate fallback). + * connect_results owns the getaddrinfo list; connect_next points to the + * next untried candidate. */ + struct addrinfo *connect_results; + struct addrinfo *connect_next; + int connect_last_errno; + /* Request method from client (GET, POST, PUT, DELETE, etc.) */ char method[16]; diff --git a/src/http_proxy_rewrite.c b/src/http_proxy_rewrite.c index 3697509..84e6bb4 100644 --- a/src/http_proxy_rewrite.c +++ b/src/http_proxy_rewrite.c @@ -47,14 +47,17 @@ int rewrite_resolve_relative_url(const char *relative_url, const char *base_host return -1; int result; + char authority[HTTP_PROXY_HOST_SIZE + 16]; + + /* Build host[:port] authority with IPv6 brackets, omitting default port */ + if (format_host_port_for_url(base_host, base_port, 80, authority, sizeof(authority)) < 0) { + logger(LOG_ERROR, "rewrite_resolve_relative_url: authority too long"); + return -1; + } if (relative_url[0] == '/') { /* Absolute path - use host:port directly */ - if (base_port == 80) { - result = snprintf(output, output_size, "http://%s%s", base_host, relative_url); - } else { - result = snprintf(output, output_size, "http://%s:%d%s", base_host, base_port, relative_url); - } + result = snprintf(output, output_size, "http://%s%s", authority, relative_url); } else { /* Relative path - need to extract directory from base_path */ char dir_path[HTTP_PROXY_PATH_SIZE]; @@ -77,11 +80,7 @@ int rewrite_resolve_relative_url(const char *relative_url, const char *base_host strcpy(dir_path, "/"); } - if (base_port == 80) { - result = snprintf(output, output_size, "http://%s%s%s", base_host, dir_path, relative_url); - } else { - result = snprintf(output, output_size, "http://%s:%d%s%s", base_host, base_port, dir_path, relative_url); - } + result = snprintf(output, output_size, "http://%s%s%s", authority, dir_path, relative_url); } if (result < 0 || (size_t)result >= output_size) { diff --git a/src/m3u.c b/src/m3u.c index ed64eb7..ff4044a 100644 --- a/src/m3u.c +++ b/src/m3u.c @@ -165,6 +165,8 @@ char *get_server_address(void) { char *non_upstream_private_ip = NULL; char *non_upstream_public_ip = NULL; char *upstream_ip = NULL; + char *non_upstream_ipv6 = NULL; + char *upstream_ipv6 = NULL; char addr_str[INET6_ADDRSTRLEN]; char server_port[16]; char full_url[2048]; @@ -207,13 +209,19 @@ char *get_server_address(void) { } } + /* Re-bracket bare IPv6 literals for URL embedding */ + char host_formatted[300]; + if (format_host_for_url(host, host_formatted, sizeof(host_formatted)) != 0) { + snprintf(host_formatted, sizeof(host_formatted), "%s", host); + } + /* Build base URL: protocol://host:port or protocol://host (if port is 80 * and protocol is http) */ if (port[0] == '\0' || (strcmp(protocol, "http") == 0 && strcmp(port, "80") == 0) || (strcmp(protocol, "https") == 0 && strcmp(port, "443") == 0)) { - snprintf(full_url, sizeof(full_url), "%s://%s", protocol, host); + snprintf(full_url, sizeof(full_url), "%s://%s", protocol, host_formatted); } else { - snprintf(full_url, sizeof(full_url), "%s://%s:%s", protocol, host, port); + snprintf(full_url, sizeof(full_url), "%s://%s:%s", protocol, host_formatted, port); } /* Add path if present, ensuring it ends with slash */ @@ -254,7 +262,22 @@ char *get_server_address(void) { if (ifa->ifa_flags & IFF_LOOPBACK) continue; - /* Only process IPv4 for now */ + /* Check if this is an upstream interface */ + int is_upstream = 0; + if (config.upstream_interface[0] != '\0' && strcmp(ifa->ifa_name, config.upstream_interface) == 0) { + is_upstream = 1; + } + if (config.upstream_interface_fcc[0] != '\0' && strcmp(ifa->ifa_name, config.upstream_interface_fcc) == 0) { + is_upstream = 1; + } + if (config.upstream_interface_rtsp[0] != '\0' && strcmp(ifa->ifa_name, config.upstream_interface_rtsp) == 0) { + is_upstream = 1; + } + if (config.upstream_interface_multicast[0] != '\0' && + strcmp(ifa->ifa_name, config.upstream_interface_multicast) == 0) { + is_upstream = 1; + } + if (ifa->ifa_addr->sa_family == AF_INET) { struct sockaddr_in addr; memcpy(&addr, ifa->ifa_addr, sizeof(addr)); @@ -262,22 +285,6 @@ char *get_server_address(void) { if (inet_ntop(AF_INET, &addr.sin_addr, addr_str, sizeof(addr_str)) == NULL) continue; - /* Check if this is an upstream interface */ - int is_upstream = 0; - if (config.upstream_interface[0] != '\0' && strcmp(ifa->ifa_name, config.upstream_interface) == 0) { - is_upstream = 1; - } - if (config.upstream_interface_fcc[0] != '\0' && strcmp(ifa->ifa_name, config.upstream_interface_fcc) == 0) { - is_upstream = 1; - } - if (config.upstream_interface_rtsp[0] != '\0' && strcmp(ifa->ifa_name, config.upstream_interface_rtsp) == 0) { - is_upstream = 1; - } - if (config.upstream_interface_multicast[0] != '\0' && - strcmp(ifa->ifa_name, config.upstream_interface_multicast) == 0) { - is_upstream = 1; - } - if (is_upstream) { /* Store as upstream IP (lowest priority) */ if (!upstream_ip) @@ -294,34 +301,68 @@ char *get_server_address(void) { non_upstream_public_ip = strdup(addr_str); } } + } else if (ifa->ifa_addr->sa_family == AF_INET6) { + /* IPv6 fallback (used when no IPv4 address is available) */ + struct sockaddr_in6 addr6; + memcpy(&addr6, ifa->ifa_addr, sizeof(addr6)); + + /* Skip link-local addresses (fe80::/10) - not usable without zone ID */ + if (IN6_IS_ADDR_LINKLOCAL(&addr6.sin6_addr)) + continue; + + if (inet_ntop(AF_INET6, &addr6.sin6_addr, addr_str, sizeof(addr_str)) == NULL) + continue; + + if (is_upstream) { + if (!upstream_ipv6) + upstream_ipv6 = strdup(addr_str); + } else { + if (!non_upstream_ipv6) + non_upstream_ipv6 = strdup(addr_str); + } } } freeifaddrs(ifaddr); - /* Priority: non-upstream private > non-upstream public > upstream > localhost - */ + /* Priority: non-upstream private v4 > non-upstream public v4 > upstream v4 + * > non-upstream IPv6 > upstream IPv6 > localhost */ if (non_upstream_private_ip) { host_ip = non_upstream_private_ip; - if (non_upstream_public_ip) - free(non_upstream_public_ip); - if (upstream_ip) - free(upstream_ip); + non_upstream_private_ip = NULL; } else if (non_upstream_public_ip) { host_ip = non_upstream_public_ip; - if (upstream_ip) - free(upstream_ip); + non_upstream_public_ip = NULL; } else if (upstream_ip) { host_ip = upstream_ip; + upstream_ip = NULL; + } else if (non_upstream_ipv6) { + host_ip = non_upstream_ipv6; + non_upstream_ipv6 = NULL; + } else if (upstream_ipv6) { + host_ip = upstream_ipv6; + upstream_ipv6 = NULL; } else { host_ip = strdup("localhost"); } - /* Build complete URL with protocol, host, and port */ - if (strcmp(server_port, "80") == 0) { - snprintf(full_url, sizeof(full_url), "http://%s/", host_ip); - } else { - snprintf(full_url, sizeof(full_url), "http://%s:%s/", host_ip, server_port); + free(non_upstream_private_ip); + free(non_upstream_public_ip); + free(upstream_ip); + free(non_upstream_ipv6); + free(upstream_ipv6); + + /* Build complete URL with protocol, host, and port (bracket IPv6 hosts) */ + { + char host_formatted[INET6_ADDRSTRLEN + 16]; + if (format_host_for_url(host_ip, host_formatted, sizeof(host_formatted)) != 0) { + snprintf(host_formatted, sizeof(host_formatted), "%s", host_ip); + } + if (strcmp(server_port, "80") == 0) { + snprintf(full_url, sizeof(full_url), "http://%s/", host_formatted); + } else { + snprintf(full_url, sizeof(full_url), "http://%s:%s/", host_formatted, server_port); + } } free(host_ip); diff --git a/src/multicast.c b/src/multicast.c index ffe291f..304d2b6 100644 --- a/src/multicast.c +++ b/src/multicast.c @@ -568,16 +568,26 @@ int mcast_session_tick(mcast_session_t *session, service_t *service, int64_t now return 0; } - /* Periodic multicast rejoin (if enabled) */ + /* Periodic multicast rejoin (if enabled). + * Raw-socket rejoin is IGMP (IPv4) only; for IPv6 groups an MLD equivalent + * is not implemented yet, so warn once and skip. */ if (config.mcast_rejoin_interval > 0) { - int64_t elapsed_ms = now - session->last_rejoin_time; - if (elapsed_ms >= config.mcast_rejoin_interval * 1000) { - logger(LOG_DEBUG, "Multicast: Periodic rejoin (interval: %d seconds)", config.mcast_rejoin_interval); - - if (rejoin_mcast_group(service) == 0) { - session->last_rejoin_time = now; - } else { - logger(LOG_ERROR, "Multicast: Failed to rejoin group, will retry next interval"); + if (service->addr->ai_family != AF_INET) { + if (!session->rejoin_unsupported_warned) { + logger(LOG_WARN, "Multicast: mcast-rejoin-interval is not supported for IPv6 groups (no MLD " + "raw-socket rejoin), skipping periodic rejoin"); + session->rejoin_unsupported_warned = 1; + } + } else { + int64_t elapsed_ms = now - session->last_rejoin_time; + if (elapsed_ms >= config.mcast_rejoin_interval * 1000) { + logger(LOG_DEBUG, "Multicast: Periodic rejoin (interval: %d seconds)", config.mcast_rejoin_interval); + + if (rejoin_mcast_group(service) == 0) { + session->last_rejoin_time = now; + } else { + logger(LOG_ERROR, "Multicast: Failed to rejoin group, will retry next interval"); + } } } } diff --git a/src/multicast.h b/src/multicast.h index 2ac998a..6bfc3ae 100644 --- a/src/multicast.h +++ b/src/multicast.h @@ -13,10 +13,11 @@ struct buffer_ref_s; * Multicast session context - encapsulates all multicast-related state */ typedef struct mcast_session_s { - int initialized; /* Flag: session has been initialized */ - int sock; /* Multicast socket (-1 if not joined) */ - int64_t last_data_time; /* Timestamp of last received data (ms) */ - int64_t last_rejoin_time; /* Timestamp of last periodic rejoin (ms) */ + int initialized; /* Flag: session has been initialized */ + int sock; /* Multicast socket (-1 if not joined) */ + int64_t last_data_time; /* Timestamp of last received data (ms) */ + int64_t last_rejoin_time; /* Timestamp of last periodic rejoin (ms) */ + int rejoin_unsupported_warned; /* Warn-once flag for IPv6 rejoin no-op */ } mcast_session_t; /** diff --git a/src/rtsp.c b/src/rtsp.c index 604fed0..df7d906 100644 --- a/src/rtsp.c +++ b/src/rtsp.c @@ -272,6 +272,7 @@ void rtsp_session_init(rtsp_session_t *session) { session->rtcp_socket = -1; session->cseq = 1; session->server_port = 554; /* Default RTSP port */ + session->upstream_family = AF_INET; session->redirect_count = 0; session->r2h_start[0] = '\0'; session->playseek_range_start[0] = '\0'; @@ -685,17 +686,18 @@ int rtsp_parse_server_url(rtsp_session_t *session, const char *rtsp_url, const c strcpy(session->server_path, "/"); } - /* Build server_url without credentials (for RTSP requests and Digest auth) */ - int url_len; - if (session->server_port == 554) { - /* Omit default port */ - url_len = snprintf(session->server_url, sizeof(session->server_url), "rtsp://%s%s", session->server_host, - session->server_path); - } else { - url_len = snprintf(session->server_url, sizeof(session->server_url), "rtsp://%s:%d%s", session->server_host, - session->server_port, session->server_path); + /* Build server_url without credentials (for RTSP requests and Digest auth). + * IPv6 hosts are bracketed; default port 554 is omitted. */ + char url_authority[RTSP_SERVER_HOST_SIZE + 16]; + if (format_host_port_for_url(session->server_host, session->server_port, 554, url_authority, sizeof(url_authority)) < + 0) { + logger(LOG_ERROR, "RTSP: Server authority too long"); + return -1; } + int url_len = + snprintf(session->server_url, sizeof(session->server_url), "rtsp://%s%s", url_authority, session->server_path); + if (url_len >= (int)sizeof(session->server_url)) { logger(LOG_ERROR, "RTSP: Server URL too long, truncated"); return -1; @@ -707,158 +709,163 @@ int rtsp_parse_server_url(rtsp_session_t *session, const char *rtsp_url, const c return 0; } -int rtsp_connect(rtsp_session_t *session) { - struct sockaddr_in server_addr; - struct hostent *he; - int connect_result; - - /* Start STUN discovery early - before TCP connect - * This allows STUN to run in parallel with TCP connection establishment - * Only do this on initial connect (not on redirect or reconnect for TEARDOWN) - * Check: UDP socket not yet created and STUN not already in progress/completed */ - if (config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0' && session->rtp_socket < 0 && - !session->stun.in_progress && !session->stun.completed) { - if (rtsp_setup_udp_sockets(session) == 0) { - if (stun_send_request(&session->stun, session->rtp_socket) == 0) { - logger(LOG_DEBUG, "RTSP: Started STUN discovery before TCP connect"); - } - } +/* Free the getaddrinfo candidate list (after success or final failure) */ +static void rtsp_free_connect_results(rtsp_session_t *session) { + if (session->connect_results) { + freeaddrinfo(session->connect_results); + session->connect_results = NULL; + session->connect_next = NULL; } +} - /* Resolve hostname */ - he = gethostbyname(session->server_host); - if (!he) { - logger(LOG_ERROR, "RTSP: Cannot resolve hostname %s: %s", session->server_host, hstrerror(h_errno)); - return -1; - } +/** + * Try connecting to the next candidate from the getaddrinfo result list + * (sequential dual-stack fallback, IPv6/IPv4 in resolver order). + * Both immediate success and EINPROGRESS leave the session in + * CONNECTING/RECONNECTING; the poller event drives completion via + * getsockopt(SO_ERROR). A hard connect() failure moves on to the next + * candidate immediately. + * @return 0 if a connection is in progress, -1 when all candidates failed + */ +static int rtsp_try_next_candidate(rtsp_session_t *session) { + while (session->connect_next) { + struct addrinfo *rp = session->connect_next; + session->connect_next = rp->ai_next; - /* Validate address list */ - if (!he->h_addr_list[0]) { - logger(LOG_ERROR, "RTSP: No addresses for hostname %s", session->server_host); - return -1; - } + int sock = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + if (sock < 0) { + session->connect_last_errno = errno; + continue; + } + platform_set_nosigpipe(sock); - /* Create TCP socket */ - session->socket = socket(AF_INET, SOCK_STREAM, 0); - if (session->socket < 0) { - logger(LOG_ERROR, "RTSP: Failed to create socket: %s", strerror(errno)); - return -1; - } - platform_set_nosigpipe(session->socket); + if (connection_set_nonblocking(sock) < 0) { + session->connect_last_errno = errno; + close(sock); + continue; + } - /* Set socket to non-blocking mode for poller */ - if (connection_set_nonblocking(session->socket) < 0) { - logger(LOG_ERROR, "RTSP: Failed to set socket non-blocking: %s", strerror(errno)); - close(session->socket); - session->socket = -1; - return -1; - } + bind_to_upstream_interface(sock, session->upstream_ifname); - bind_to_upstream_interface(session->socket, session->upstream_ifname); + int connect_result = connect(sock, rp->ai_addr, rp->ai_addrlen); - /* Connect to server (non-blocking) */ - memset(&server_addr, 0, sizeof(server_addr)); - server_addr.sin_family = AF_INET; - server_addr.sin_port = htons(session->server_port); - memcpy(&server_addr.sin_addr.s_addr, he->h_addr_list[0], he->h_length); + if (connect_result == 0 || errno == EINPROGRESS || errno == EWOULDBLOCK) { + logger(LOG_DEBUG, "RTSP: Connection to %s:%d in progress (%s)", session->server_host, session->server_port, + rp->ai_family == AF_INET6 ? "IPv6" : "IPv4"); - connect_result = connect(session->socket, (struct sockaddr *)&server_addr, sizeof(server_addr)); + /* If early STUN/UDP sockets were created for a different address family + * (dual-stack fallback switched family), drop them and reset STUN state; + * they will be recreated with the correct family before SETUP */ + if (session->rtp_socket >= 0 && session->upstream_family != rp->ai_family) { + rtsp_close_udp_sockets(session, "upstream address family changed"); + memset(&session->stun, 0, sizeof(session->stun)); + } - /* Handle non-blocking connect result */ - if (connect_result < 0) { - if (errno == EINPROGRESS || errno == EWOULDBLOCK) { - /* Connection in progress - this is normal for non-blocking sockets */ - logger(LOG_DEBUG, "RTSP: Connection to %s:%d in progress (async)", session->server_host, session->server_port); + session->socket = sock; + session->upstream_family = rp->ai_family; - /* Register socket with poller for writable to detect connection completion - */ + /* Register socket with poller; completion is detected via POLLER_OUT + * (also for the rare immediate-success case, where SO_ERROR is 0) */ if (session->epoll_fd >= 0) { - if (poller_add(session->epoll_fd, session->socket, POLLER_OUT | POLLER_IN | POLLER_ERR | POLLER_HUP) < 0) { + if (poller_add(session->epoll_fd, session->socket, + POLLER_OUT | POLLER_IN | POLLER_ERR | POLLER_HUP | POLLER_RDHUP) < 0) { logger(LOG_ERROR, "RTSP: Failed to add socket to poller: %s", strerror(errno)); close(session->socket); session->socket = -1; return -1; } fdmap_set(session->socket, session->conn); - logger(LOG_DEBUG, "RTSP: Socket registered with poller for connection completion"); } - /* Set state to CONNECTING - connection will complete asynchronously */ - rtsp_session_set_state(session, RTSP_STATE_CONNECTING); - return 0; /* Success - connection in progress */ - } else { - /* Real connection error */ - logger(LOG_ERROR, "RTSP: Failed to connect to %s:%d: %s", session->server_host, session->server_port, - strerror(errno)); - close(session->socket); - session->socket = -1; - return -1; + /* Keep RECONNECTING (teardown reconnect path) as-is; otherwise enter + * CONNECTING. Refresh the timestamp manually for the per-candidate + * timeout because set_state is a no-op for same-state transitions. */ + if (session->state != RTSP_STATE_RECONNECTING) { + rtsp_session_set_state(session, RTSP_STATE_CONNECTING); + } + session->last_state_change_ms = get_time_ms(); + return 0; } + + /* Hard failure - try next candidate */ + session->connect_last_errno = errno; + logger(LOG_DEBUG, "RTSP: connect() to %s:%d failed (%s): %s", session->server_host, session->server_port, + rp->ai_family == AF_INET6 ? "IPv6" : "IPv4", strerror(errno)); + close(sock); } - /* Immediate connection success (rare for non-blocking, but possible for - * localhost) */ - logger(LOG_DEBUG, "RTSP: Connected immediately to %s:%d", session->server_host, session->server_port); + logger(LOG_ERROR, "RTSP: Failed to connect to %s:%d: %s", session->server_host, session->server_port, + session->connect_last_errno ? strerror(session->connect_last_errno) : "no usable address"); + rtsp_free_connect_results(session); + return -1; +} - /* Register socket with poller for read events */ - if (session->epoll_fd >= 0) { - if (poller_add(session->epoll_fd, session->socket, POLLER_IN | POLLER_HUP | POLLER_ERR | POLLER_RDHUP) < 0) { - logger(LOG_ERROR, "RTSP: Failed to add socket to poller: %s", strerror(errno)); - close(session->socket); - session->socket = -1; - return -1; - } - fdmap_set(session->socket, session->conn); - logger(LOG_DEBUG, "RTSP: Socket registered with poller"); +/** + * Handle failure of the in-progress connect attempt: close the current + * socket and fall back to the next address candidate if one remains. + * @return 0 if another attempt was started, -1 if all candidates failed + */ +static int rtsp_handle_connect_failure(rtsp_session_t *session, int error) { + session->connect_last_errno = error; + logger(LOG_DEBUG, "RTSP: Async connect to %s:%d failed: %s", session->server_host, session->server_port, + strerror(error)); + + if (session->socket >= 0) { + worker_cleanup_socket_from_epoll(session->epoll_fd, session->socket); + session->socket = -1; } - rtsp_session_set_state(session, RTSP_STATE_CONNECTED); - return 0; + return rtsp_try_next_candidate(session); } -int rtsp_handle_socket_event(rtsp_session_t *session, uint32_t events) { - int result; +int rtsp_connect(rtsp_session_t *session) { + struct addrinfo hints; + char port_str[16]; + int gai_result; - /* Check for connection errors or hangup */ - if (events & (POLLER_HUP | POLLER_ERR | POLLER_RDHUP)) { - if (events & POLLER_ERR) { - int sock_error = 0; - socklen_t error_len = sizeof(sock_error); - if (getsockopt(session->socket, SOL_SOCKET, SO_ERROR, &sock_error, &error_len) == 0 && sock_error != 0) { - logger(LOG_ERROR, "RTSP: Socket error: %s", strerror(sock_error)); - } else { - logger(LOG_ERROR, "RTSP: Socket error event received"); - } - } else if (events & (POLLER_HUP | POLLER_RDHUP)) { - logger(LOG_INFO, "RTSP: Server closed connection"); - } + /* Resolve hostname first (dual-stack: IPv6 and IPv4 candidates), so the + * upstream address family is known before creating UDP/STUN sockets */ + rtsp_free_connect_results(session); + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + snprintf(port_str, sizeof(port_str), "%d", session->server_port); + + gai_result = getaddrinfo(session->server_host, port_str, &hints, &session->connect_results); + if (gai_result != 0) { + logger(LOG_ERROR, "RTSP: Cannot resolve hostname %s: %s", session->server_host, gai_strerror(gai_result)); + session->connect_results = NULL; + return -1; + } - /* If we're in TEARDOWN states, server closing connection is acceptable - * (some servers don't send TEARDOWN response before closing) */ - if (session->state == RTSP_STATE_SENDING_TEARDOWN || session->state == RTSP_STATE_AWAITING_TEARDOWN) { - logger(LOG_DEBUG, "RTSP: Server closed connection during TEARDOWN (acceptable)"); - rtsp_force_cleanup(session); - return -1; - } + session->connect_next = session->connect_results; + session->connect_last_errno = 0; + if (session->connect_results) { + session->upstream_family = session->connect_results->ai_family; + } - /* During PLAYING: upstream is done — drain pending client output - * before disconnecting regardless of error/hangup distinction. */ - if (session->state == RTSP_STATE_PLAYING) { - logger(LOG_INFO, "RTSP: Upstream closed during PLAYING, draining client"); - rtsp_force_cleanup(session); - if (session->conn && session->conn->state != CONN_CLOSING) { - session->conn->state = CONN_CLOSING; - connection_epoll_update_events(session->conn->epfd, session->conn->fd, - POLLER_IN | POLLER_OUT | POLLER_RDHUP | POLLER_HUP | POLLER_ERR); + /* Start STUN discovery early - before TCP connect + * This allows STUN to run in parallel with TCP connection establishment + * Only do this on initial connect (not on redirect or reconnect for TEARDOWN) + * Check: UDP socket not yet created and STUN not already in progress/completed */ + if (config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0' && session->rtp_socket < 0 && + !session->stun.in_progress && !session->stun.completed) { + if (rtsp_setup_udp_sockets(session) == 0) { + if (stun_send_request(&session->stun, session->rtp_socket) == 0) { + logger(LOG_DEBUG, "RTSP: Started STUN discovery before TCP connect"); } - return 0; } - - rtsp_session_set_state(session, RTSP_STATE_ERROR); - return -1; /* Connection closed or error */ } - /* Handle connection completion (both initial and reconnect for TEARDOWN) */ + return rtsp_try_next_candidate(session); +} + +int rtsp_handle_socket_event(rtsp_session_t *session, uint32_t events) { + int result; + + /* Handle in-progress connect FIRST (both initial and reconnect for + * TEARDOWN). A failed candidate falls back to the next address from the + * getaddrinfo list instead of erroring out. */ if (session->state == RTSP_STATE_CONNECTING || session->state == RTSP_STATE_RECONNECTING) { int sock_error = 0; socklen_t error_len = sizeof(sock_error); @@ -870,17 +877,22 @@ int rtsp_handle_socket_event(rtsp_session_t *session, uint32_t events) { return -1; } + if (sock_error == 0 && (events & (POLLER_ERR | POLLER_HUP | POLLER_RDHUP)) && !(events & POLLER_OUT)) { + /* Error/hangup event without a definite SO_ERROR - treat as failure */ + sock_error = ECONNREFUSED; + } + if (sock_error != 0) { - /* Connection failed */ - logger(LOG_ERROR, "RTSP: Connection to %s:%d failed: %s", session->server_host, session->server_port, - strerror(sock_error)); + if (rtsp_handle_connect_failure(session, sock_error) == 0) { + return 0; /* Next candidate attempt in progress */ + } rtsp_session_set_state(session, RTSP_STATE_ERROR); return -1; } - /* Connection succeeded */ + /* Connection succeeded - drop remaining candidates */ + rtsp_free_connect_results(session); logger(LOG_INFO, "RTSP: Connected to %s:%d", session->server_host, session->server_port); - logger(LOG_DEBUG, "RTSP: Connection to %s:%d completed successfully", session->server_host, session->server_port); /* Update poller to monitor both read and write */ if (session->epoll_fd >= 0) { @@ -913,6 +925,45 @@ int rtsp_handle_socket_event(rtsp_session_t *session, uint32_t events) { /* Now pending_request is ready, will be sent when POLLER_OUT fires */ } + /* Check for connection errors or hangup */ + else if (events & (POLLER_HUP | POLLER_ERR | POLLER_RDHUP)) { + if (events & POLLER_ERR) { + int sock_error = 0; + socklen_t error_len = sizeof(sock_error); + if (getsockopt(session->socket, SOL_SOCKET, SO_ERROR, &sock_error, &error_len) == 0 && sock_error != 0) { + logger(LOG_ERROR, "RTSP: Socket error: %s", strerror(sock_error)); + } else { + logger(LOG_ERROR, "RTSP: Socket error event received"); + } + } else if (events & (POLLER_HUP | POLLER_RDHUP)) { + logger(LOG_INFO, "RTSP: Server closed connection"); + } + + /* If we're in TEARDOWN states, server closing connection is acceptable + * (some servers don't send TEARDOWN response before closing) */ + if (session->state == RTSP_STATE_SENDING_TEARDOWN || session->state == RTSP_STATE_AWAITING_TEARDOWN) { + logger(LOG_DEBUG, "RTSP: Server closed connection during TEARDOWN (acceptable)"); + rtsp_force_cleanup(session); + return -1; + } + + /* During PLAYING: upstream is done — drain pending client output + * before disconnecting regardless of error/hangup distinction. */ + if (session->state == RTSP_STATE_PLAYING) { + logger(LOG_INFO, "RTSP: Upstream closed during PLAYING, draining client"); + rtsp_force_cleanup(session); + if (session->conn && session->conn->state != CONN_CLOSING) { + session->conn->state = CONN_CLOSING; + connection_epoll_update_events(session->conn->epfd, session->conn->fd, + POLLER_IN | POLLER_OUT | POLLER_RDHUP | POLLER_HUP | POLLER_ERR); + } + return 0; + } + + rtsp_session_set_state(session, RTSP_STATE_ERROR); + return -1; /* Connection closed or error */ + } + /* Handle writable socket - try to send pending data */ if (events & POLLER_OUT) { if (session->pending_request_len > 0 && session->pending_request_sent < session->pending_request_len) { @@ -1619,6 +1670,15 @@ int rtsp_session_tick(rtsp_session_t *session, int64_t now) { break; } if (timeout_sec > 0 && elapsed >= timeout_sec * 1000) { + /* Connect timeout: fall back to the next address candidate if any */ + if ((session->state == RTSP_STATE_CONNECTING || session->state == RTSP_STATE_RECONNECTING) && + session->connect_next) { + logger(LOG_INFO, "RTSP: Connect to %s:%d timed out, trying next address", session->server_host, + session->server_port); + if (rtsp_handle_connect_failure(session, ETIMEDOUT) == 0) { + return 0; + } + } logger(LOG_ERROR, "RTSP: State %d timed out after %lld ms", session->state, (long long)elapsed); rtsp_session_set_state(session, RTSP_STATE_ERROR); return -1; @@ -1932,6 +1992,9 @@ static void rtsp_force_cleanup(rtsp_session_t *session) { rtsp_close_udp_sockets(session, "cleanup"); + /* Free pending connect candidates if any */ + rtsp_free_connect_results(session); + /* Reset response buffer position */ session->response_buffer_pos = 0; @@ -2328,7 +2391,10 @@ static int rtsp_setup_udp_sockets(rtsp_session_t *session) { const int port_range = 10000; const int port_min = 10000; const int port_start_offset = (int)(get_time_ms() % port_range); - struct sockaddr_in local_addr; + struct sockaddr_storage local_addr; + socklen_t local_addr_len; + /* Create UDP sockets matching the upstream address family */ + int family = (session->upstream_family == AF_INET6) ? AF_INET6 : AF_INET; int port_base; int port_max; int pair_count; @@ -2364,15 +2430,24 @@ static int rtsp_setup_udp_sockets(rtsp_session_t *session) { start_pair_index = ((port_start_offset & ~1) / 2) % pair_count; memset(&local_addr, 0, sizeof(local_addr)); - local_addr.sin_family = AF_INET; - local_addr.sin_addr.s_addr = INADDR_ANY; + if (family == AF_INET6) { + struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *)&local_addr; + sin6->sin6_family = AF_INET6; + sin6->sin6_addr = in6addr_any; + local_addr_len = sizeof(struct sockaddr_in6); + } else { + struct sockaddr_in *sin = (struct sockaddr_in *)&local_addr; + sin->sin_family = AF_INET; + sin->sin_addr.s_addr = INADDR_ANY; + local_addr_len = sizeof(struct sockaddr_in); + } for (int attempt = 0; attempt < pair_count; attempt++) { int pair_index = (start_pair_index + attempt) % pair_count; int candidate_rtp_port = port_base + pair_index * 2; int bind_errno = 0; - rtp_socket = socket(AF_INET, SOCK_DGRAM, 0); + rtp_socket = socket(family, SOCK_DGRAM, 0); if (rtp_socket < 0) { logger(LOG_ERROR, "RTSP: Failed to create RTP socket: %s", strerror(errno)); return -1; @@ -2392,8 +2467,8 @@ static int rtsp_setup_udp_sockets(rtsp_session_t *session) { bind_to_upstream_interface(rtp_socket, session->upstream_ifname); - local_addr.sin_port = htons(candidate_rtp_port); - if (bind(rtp_socket, (struct sockaddr *)&local_addr, sizeof(local_addr)) < 0) { + sockaddr_set_port((struct sockaddr *)&local_addr, (uint16_t)candidate_rtp_port); + if (bind(rtp_socket, (struct sockaddr *)&local_addr, local_addr_len) < 0) { bind_errno = errno; close(rtp_socket); rtp_socket = -1; @@ -2404,7 +2479,7 @@ static int rtsp_setup_udp_sockets(rtsp_session_t *session) { return -1; } - rtcp_socket = socket(AF_INET, SOCK_DGRAM, 0); + rtcp_socket = socket(family, SOCK_DGRAM, 0); if (rtcp_socket < 0) { logger(LOG_ERROR, "RTSP: Failed to create RTCP socket: %s", strerror(errno)); close(rtp_socket); @@ -2426,8 +2501,8 @@ static int rtsp_setup_udp_sockets(rtsp_session_t *session) { bind_to_upstream_interface(rtcp_socket, session->upstream_ifname); - local_addr.sin_port = htons(candidate_rtp_port + 1); - if (bind(rtcp_socket, (struct sockaddr *)&local_addr, sizeof(local_addr)) < 0) { + sockaddr_set_port((struct sockaddr *)&local_addr, (uint16_t)(candidate_rtp_port + 1)); + if (bind(rtcp_socket, (struct sockaddr *)&local_addr, local_addr_len) < 0) { bind_errno = errno; close(rtp_socket); close(rtcp_socket); @@ -2700,7 +2775,7 @@ static void rtsp_send_udp_nat_probe(rtsp_session_t *session) { rtcp_packet[3] = 0x01; /* length in words - 1 (low byte) = 1 */ memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; + hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_DGRAM; hints.ai_protocol = IPPROTO_UDP; @@ -2710,24 +2785,28 @@ static void rtsp_send_udp_nat_probe(rtsp_session_t *session) { return; } + /* Pick the first address matching the UDP socket address family */ + for (rp = result; rp != NULL; rp = rp->ai_next) { + if (rp->ai_family == session->upstream_family) { + break; + } + } + if (!rp) { + rp = result; /* Fallback: try the first resolved address */ + } + /* Send 3 NAT probe packets for both RTP and RTCP */ for (int attempt = 0; attempt < 3; attempt++) { - for (rp = result; rp != NULL; rp = rp->ai_next) { - /* Send RTP probe */ - if (session->server_rtp_port > 0 && session->rtp_socket >= 0) { - sendto(session->rtp_socket, rtp_packet, sizeof(rtp_packet), 0, rp->ai_addr, rp->ai_addrlen); - } - - /* Send RTCP probe - update port in sockaddr */ - if (session->server_rtcp_port > 0 && session->rtcp_socket >= 0) { - if (rp->ai_family == AF_INET) { - struct sockaddr_in *addr = (struct sockaddr_in *)(uintptr_t)rp->ai_addr; - addr->sin_port = htons(session->server_rtcp_port); - } - sendto(session->rtcp_socket, rtcp_packet, sizeof(rtcp_packet), 0, rp->ai_addr, rp->ai_addrlen); - } + /* Send RTP probe */ + if (session->server_rtp_port > 0 && session->rtp_socket >= 0) { + sockaddr_set_port(rp->ai_addr, (uint16_t)session->server_rtp_port); + sendto(session->rtp_socket, rtp_packet, sizeof(rtp_packet), 0, rp->ai_addr, rp->ai_addrlen); + } - break; /* Only use first resolved address */ + /* Send RTCP probe - update port in sockaddr */ + if (session->server_rtcp_port > 0 && session->rtcp_socket >= 0) { + sockaddr_set_port(rp->ai_addr, (uint16_t)session->server_rtcp_port); + sendto(session->rtcp_socket, rtcp_packet, sizeof(rtcp_packet), 0, rp->ai_addr, rp->ai_addrlen); } } diff --git a/src/rtsp.h b/src/rtsp.h index d31f3cd..96f456b 100644 --- a/src/rtsp.h +++ b/src/rtsp.h @@ -6,6 +6,9 @@ #include "stun.h" +/* Forward declaration */ +struct addrinfo; + #define RTSP_DISABLE_TCP_TRANSPORT 0 /* To debug UDP transport, set to 1 */ /* Timeout constants for RTSP state machine */ @@ -120,10 +123,20 @@ typedef struct { char server_url[RTSP_SERVER_URL_SIZE]; /* Full RTSP URL */ char setup_url[RTSP_SERVER_URL_SIZE]; /* Resolved SETUP URL (from Content-Base + a=control) */ - char server_host[RTSP_SERVER_HOST_SIZE]; /* RTSP server hostname */ + char server_host[RTSP_SERVER_HOST_SIZE]; /* RTSP server hostname (bare, no + IPv6 brackets) */ int server_port; /* RTSP server port */ char server_path[RTSP_SERVER_PATH_SIZE]; /* RTSP path with query string */ - int redirect_count; /* Number of redirects followed */ + + /* Dual-stack async connect state (sequential candidate fallback). + * connect_results owns the getaddrinfo list; connect_next points to the + * next untried candidate. */ + struct addrinfo *connect_results; + struct addrinfo *connect_next; + int connect_last_errno; + int upstream_family; /* Address family of the connected/connecting upstream + (AF_INET / AF_INET6) */ + int redirect_count; /* Number of redirects followed */ char r2h_start[RTSP_TIME_STRING_SIZE]; char playseek_range_start[RTSP_TIME_STRING_SIZE]; int use_playseek_range; diff --git a/src/service.c b/src/service.c index 775365a..4623d58 100644 --- a/src/service.c +++ b/src/service.c @@ -1744,6 +1744,14 @@ service_t *service_create_from_rtp_url(const char *http_url) { result->fcc_addr = NULL; result->fcc_type = components.fcc_type; result->fec_port = components.fec_port; + if (components.has_fcc && (fcc_res->ai_family != AF_INET || res->ai_family != AF_INET)) { + /* FCC protocol carries 4-byte IPv4 addresses in its packet body; there is + * no known IPv6 protocol variant. Disable FCC and fall back to plain + * multicast. */ + logger(LOG_WARN, "FCC is IPv4-only (protocol limitation), ignoring fcc=%s:%s and falling back to plain multicast", + components.fcc_addr, components.fcc_port); + components.has_fcc = 0; + } if (components.has_fcc) { fcc_res_addr = malloc(sizeof(struct sockaddr_storage)); fcc_res_ai = malloc(sizeof(struct addrinfo)); diff --git a/src/stun.c b/src/stun.c index 3760e66..e26581c 100644 --- a/src/stun.c +++ b/src/stun.c @@ -43,49 +43,27 @@ static void stun_gen_transaction_id(unsigned char tid[STUN_TRANSACTION_ID_SIZE]) } /** - * Parse host:port string - * @param server_str Input string like "stun.miwifi.com:3478" or - * "stun.miwifi.com" - * @param host Output host buffer + * Parse host[:port] string, supporting "[IPv6]:port", bracketed and bare + * IPv6 literals, hostnames, and IPv4. + * @param server_str Input string like "stun.miwifi.com:3478" or "[2001:db8::1]:3478" + * @param host Output host buffer (brackets stripped) * @param host_size Size of host buffer * @param port Output port (default STUN_DEFAULT_PORT if not specified) * @return 0 on success, -1 on error */ static int stun_parse_server(const char *server_str, char *host, size_t host_size, int *port) { - const char *colon; - size_t host_len; - - if (!server_str || !host || host_size == 0) { + *port = STUN_DEFAULT_PORT; + if (parse_host_port(server_str, host, host_size, port) < 0) { return -1; } - - /* Find last colon (for IPv6 compatibility, though we only support IPv4) */ - colon = strrchr(server_str, ':'); - - if (colon && colon != server_str) { - /* Has port */ - host_len = colon - server_str; - if (host_len >= host_size) { - host_len = host_size - 1; - } - memcpy(host, server_str, host_len); - host[host_len] = '\0'; - *port = atoi(colon + 1); - if (*port <= 0 || *port > 65535) { - *port = STUN_DEFAULT_PORT; - } - } else { - /* No port, use default */ - strncpy(host, server_str, host_size - 1); - host[host_size - 1] = '\0'; - *port = STUN_DEFAULT_PORT; - } - return 0; } int stun_send_request(stun_state_t *state, int socket_fd) { - struct addrinfo hints, *res = NULL; + struct addrinfo hints, *res = NULL, *rp = NULL; + struct sockaddr_storage local_addr; + socklen_t local_addr_len = sizeof(local_addr); + int socket_family = AF_UNSPEC; char host[256]; char port_str[16]; int port; @@ -107,9 +85,15 @@ int stun_send_request(stun_state_t *state, int socket_fd) { return -1; } - /* Resolve STUN server */ + /* Determine the socket's address family so we only send to a compatible + * STUN server address */ + if (getsockname(socket_fd, (struct sockaddr *)&local_addr, &local_addr_len) == 0) { + socket_family = local_addr.ss_family; + } + + /* Resolve STUN server (dual-stack) */ memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; + hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_DGRAM; hints.ai_protocol = IPPROTO_UDP; @@ -119,6 +103,18 @@ int stun_send_request(stun_state_t *state, int socket_fd) { return -1; } + /* Pick the first address compatible with the socket's family */ + for (rp = res; rp != NULL; rp = rp->ai_next) { + if (socket_family == AF_UNSPEC || rp->ai_family == socket_family) { + break; + } + } + if (!rp) { + logger(LOG_WARN, "STUN: No %s address for server %s", socket_family == AF_INET6 ? "IPv6" : "IPv4", host); + freeaddrinfo(res); + return -1; + } + /* Generate transaction ID */ stun_gen_transaction_id(state->transaction_id); @@ -143,7 +139,7 @@ int stun_send_request(stun_state_t *state, int socket_fd) { memcpy(request + 8, state->transaction_id, STUN_TRANSACTION_ID_SIZE); /* Send request */ - sent = sendto(socket_fd, request, sizeof(request), 0, res->ai_addr, res->ai_addrlen); + sent = sendto(socket_fd, request, sizeof(request), 0, rp->ai_addr, rp->ai_addrlen); freeaddrinfo(res); if (sent != sizeof(request)) { @@ -207,13 +203,12 @@ int stun_parse_response(stun_state_t *state, const uint8_t *data, size_t len) { /* XOR-MAPPED-ADDRESS (preferred) */ if (attr_type == STUN_ATTR_XOR_MAPPED_ADDR && attr_len >= 8) { uint8_t family = data[val_off + 1]; + uint16_t xport = ((uint16_t)data[val_off + 2] << 8) | data[val_off + 3]; + uint16_t port = xport ^ (STUN_MAGIC_COOKIE >> 16); + if (family == STUN_ADDR_FAMILY_IPV4) { - uint16_t xport = ((uint16_t)data[val_off + 2] << 8) | data[val_off + 3]; uint32_t xaddr = ((uint32_t)data[val_off + 4] << 24) | ((uint32_t)data[val_off + 5] << 16) | ((uint32_t)data[val_off + 6] << 8) | data[val_off + 7]; - - /* XOR decode */ - uint16_t port = xport ^ (STUN_MAGIC_COOKIE >> 16); uint32_t addr = xaddr ^ STUN_MAGIC_COOKIE; state->mapped_rtp_port = port; @@ -230,12 +225,37 @@ int stun_parse_response(stun_state_t *state, const uint8_t *data, size_t len) { logger(LOG_INFO, "STUN: Discovered mapped address %s:%d", ip_str, port); return 0; } + + if (family == STUN_ADDR_FAMILY_IPV6 && attr_len >= 20) { + /* IPv6: address is XORed with magic cookie || transaction ID */ + struct in6_addr in6; + uint8_t xor_key[16]; + xor_key[0] = (STUN_MAGIC_COOKIE >> 24) & 0xFF; + xor_key[1] = (STUN_MAGIC_COOKIE >> 16) & 0xFF; + xor_key[2] = (STUN_MAGIC_COOKIE >> 8) & 0xFF; + xor_key[3] = STUN_MAGIC_COOKIE & 0xFF; + memcpy(xor_key + 4, state->transaction_id, STUN_TRANSACTION_ID_SIZE); + for (int i = 0; i < 16; i++) { + in6.s6_addr[i] = data[val_off + 4 + i] ^ xor_key[i]; + } + + state->mapped_rtp_port = port; + state->mapped_rtcp_port = port + 1; + state->in_progress = 0; + state->completed = 1; + + char ip_str[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &in6, ip_str, sizeof(ip_str)); + + logger(LOG_INFO, "STUN: Discovered mapped address [%s]:%d", ip_str, port); + return 0; + } } /* MAPPED-ADDRESS (fallback for older servers) */ if (attr_type == STUN_ATTR_MAPPED_ADDR && attr_len >= 8) { uint8_t family = data[val_off + 1]; - if (family == STUN_ADDR_FAMILY_IPV4) { + if (family == STUN_ADDR_FAMILY_IPV4 || (family == STUN_ADDR_FAMILY_IPV6 && attr_len >= 20)) { uint16_t port = ((uint16_t)data[val_off + 2] << 8) | data[val_off + 3]; state->mapped_rtp_port = port; diff --git a/src/utils.c b/src/utils.c index 3adbaa1..7106858 100644 --- a/src/utils.c +++ b/src/utils.c @@ -268,6 +268,113 @@ uint32_t get_local_ip_for_fcc(const char *override, const char *override_fcc) { return local_ip; } +int host_needs_brackets(const char *host) { + if (!host || host[0] == '\0' || host[0] == '[') + return 0; + return strchr(host, ':') != NULL; +} + +int format_host_for_url(const char *host, char *out, size_t out_size) { + int written; + + if (!host || !out || out_size == 0) + return -1; + + if (host_needs_brackets(host)) { + written = snprintf(out, out_size, "[%s]", host); + } else { + written = snprintf(out, out_size, "%s", host); + } + + return (written < 0 || (size_t)written >= out_size) ? -1 : 0; +} + +int format_host_port_for_url(const char *host, int port, int default_port, char *out, size_t out_size) { + char bracketed[512]; + int written; + + if (!host || !out || out_size == 0) + return -1; + + if (format_host_for_url(host, bracketed, sizeof(bracketed)) < 0) + return -1; + + if (default_port > 0 && port == default_port) { + written = snprintf(out, out_size, "%s", bracketed); + } else { + written = snprintf(out, out_size, "%s:%d", bracketed, port); + } + + return (written < 0 || (size_t)written >= out_size) ? -1 : 0; +} + +int parse_host_port(const char *input, char *host, size_t host_size, int *port) { + size_t host_len; + const char *port_str = NULL; + + if (!input || !host || host_size == 0) + return -1; + + if (input[0] == '[') { + /* Bracketed IPv6 literal: [addr][:port] */ + const char *closing = strchr(input, ']'); + if (!closing) + return -1; + host_len = (size_t)(closing - input - 1); + if (host_len == 0 || host_len >= host_size) + return -1; + memcpy(host, input + 1, host_len); + host[host_len] = '\0'; + if (closing[1] == ':') { + port_str = closing + 2; + } else if (closing[1] != '\0') { + return -1; + } + } else { + const char *first_colon = strchr(input, ':'); + if (first_colon && strchr(first_colon + 1, ':')) { + /* Multiple colons: bare IPv6 literal without port */ + host_len = strlen(input); + if (host_len >= host_size) + return -1; + memcpy(host, input, host_len + 1); + } else if (first_colon) { + /* hostname:port or IPv4:port */ + host_len = (size_t)(first_colon - input); + if (host_len == 0 || host_len >= host_size) + return -1; + memcpy(host, input, host_len); + host[host_len] = '\0'; + port_str = first_colon + 1; + } else { + host_len = strlen(input); + if (host_len == 0 || host_len >= host_size) + return -1; + memcpy(host, input, host_len + 1); + } + } + + if (port_str && *port_str && port) { + char *end = NULL; + long parsed = strtol(port_str, &end, 10); + if (end == port_str || *end != '\0' || parsed <= 0 || parsed > 65535) + return -1; + *port = (int)parsed; + } + + return 0; +} + +void sockaddr_set_port(struct sockaddr *sa, uint16_t port) { + if (!sa) + return; + if (sa->sa_family == AF_INET) { + ((struct sockaddr_in *)(uintptr_t)sa)->sin_port = htons(port); + } else if (sa->sa_family == AF_INET6) { + ((struct sockaddr_in6 *)(uintptr_t)sa)->sin6_port = htons(port); + } +} + char *build_proxy_base_url(const char *host_header, const char *x_forwarded_host, const char *x_forwarded_proto) { const char *host = NULL; const char *proto = "http"; @@ -302,6 +409,17 @@ char *build_proxy_base_url(const char *host_header, const char *x_forwarded_host } if (host) { + /* Normalize host: a bare IPv6 literal (no brackets) must be bracketed + * before being embedded in a URL. "host:port" with a single colon is + * left untouched. */ + char normalized_host[512]; + const char *first_colon = strchr(host, ':'); + if (host[0] != '[' && first_colon && strchr(first_colon + 1, ':')) { + if (format_host_for_url(host, normalized_host, sizeof(normalized_host)) == 0) { + host = normalized_host; + } + } + /* Build base URL from host and proto */ size_t url_len = strlen(proto) + 3 + strlen(host) + 2; /* proto://host/ */ base_url = malloc(url_len); diff --git a/src/utils.h b/src/utils.h index cab5565..be314df 100644 --- a/src/utils.h +++ b/src/utils.h @@ -2,7 +2,9 @@ #define __UTILS_H__ #include "configuration.h" +#include #include +#include /** * Logger function. Show the message if current verbosity is above @@ -128,6 +130,63 @@ char *build_proxy_base_url(const char *host_header, const char *x_forwarded_host */ uint32_t get_local_ip_for_fcc(const char *override, const char *override_fcc); +/** + * Check if a host string is a bare IPv6 literal that needs brackets when + * embedded into a URL authority or Host header (contains ':' and is not + * already bracketed). + * + * @param host Host string (hostname, IPv4, or IPv6 literal) + * @return 1 if brackets are needed, 0 otherwise + */ +int host_needs_brackets(const char *host); + +/** + * Format a host for use inside a URL authority or Host header. + * Bare IPv6 literals are wrapped in brackets; everything else is copied + * verbatim. + * + * @param host Input host (without brackets) + * @param out Output buffer + * @param out_size Output buffer size + * @return 0 on success, -1 if the output buffer is too small + */ +int format_host_for_url(const char *host, char *out, size_t out_size); + +/** + * Format a host[:port] authority for URLs / Host headers. + * Bare IPv6 literals are bracketed. The port is omitted when it equals + * default_port (pass 0 / negative default_port to always include the port). + * + * @param host Input host (without brackets) + * @param port Port number + * @param default_port Port to omit from output (e.g. 80 for HTTP), or 0 + * @param out Output buffer + * @param out_size Output buffer size + * @return 0 on success, -1 if the output buffer is too small + */ +int format_host_port_for_url(const char *host, int port, int default_port, char *out, size_t out_size); + +/** + * Parse a "host[:port]" string supporting "[IPv6]:port", bracketed and bare + * IPv6 literals, hostnames, and IPv4. A bare string with more than one ':' + * is treated as an IPv6 literal without port. + * + * @param input Input string + * @param host Output host buffer (brackets stripped) + * @param host_size Output host buffer size + * @param port Output port (untouched when no port is present) + * @return 0 on success, -1 on parse error / overflow + */ +int parse_host_port(const char *input, char *host, size_t host_size, int *port); + +/** + * Set the port on a sockaddr (AF_INET or AF_INET6). + * + * @param sa Socket address + * @param port Port number (host byte order) + */ +void sockaddr_set_port(struct sockaddr *sa, uint16_t port); + /* Array size calculation macro */ #define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))