前言:这是个什么场景
运营商 IPTV 的传统玩法是「组播 + udpxy」:光猫 IPTV 口走独立 VLAN,频道是 udp://@239.x.x.x:port 的组播流,用 udpxy 转成 HTTP 就能全屋播放。
但我摸排自家网络时发现,福建电信的这套 IPTV 根本不走组播——机顶盒直接从电信 CDN(rtsp://27.148.x.x:554/PLTV/...)拉单播流,每次播放还要带一个时效性令牌(accountinfo 参数)。这意味着:
- udpxy 完全用不上(它只处理 UDP 组播);
- 网上的现成频道表基本没有参考价值,必须自己抓;
- 抓到的地址要能播,必须搞清楚令牌从哪来、绑什么。
目标:不碰盒子系统(免刷机、免 ADB),只通过路由器旁路抓包,拿到「频道名 ↔ 播放地址」的完整映射,生成 m3u,并在路由器上完成协议转换,让手机、平板、电视浏览器都能直接看。
成果:100 个频道、91 个自动命名、实测 1080p H.264 与 4K HEVC 均可播放,全家设备通过 http://路由器IP:5140/playlist.m3u 直接导入。
环境准备
两个前提先确认:
- 盒子必须在 OpenWrt 的 LAN 内。如果它插在光猫的 IPTV 专用口(独立 VLAN),路由器什么都抓不到,需要先做单线复用——这是另一篇的话题了。
- 从 DHCP 租约里找到盒子的 IP:
# 在路由器上执行
cat /tmp/dhcp.leases
# 10.0.0.34 dc:2d:ea:1b:4a:91 android-dc2dea1b4a91c4d5 ...
# 主机名是 android-xxx 的基本就是盒子,记下这个 IP(下文以 10.0.0.34 为例)
第一步:摸清流量形态
先别急着抓,花一分钟搞清楚盒子在和谁通信,后面能少走很多弯路。
看实时连接表:
ssh root@10.0.0.1 "cat /proc/net/nf_conntrack | grep '10.0.0.34' | grep -vE '192.168|127.0' | head -15"
我的输出里出现了三类关键对端(IP 已脱敏):
顺手验证一下是不是真的没有组播(排除 udpxy 路线的可能性):
timeout 15 tcpdump -i br-lan -nn -c 30 'igmp' 2>&1 | head -20
如果 15 秒内一条 IGMP 加入报文都没有,就可以死心:这是单播 IPTV。
第二步:抓包(只抓信令,不抓视频)
这是整个流程里最需要注意的一步:视频流是每秒几 MB 的 UDP 洪流,绝不能整个抓。第一轮抓包我用了精确端口过滤:
# 后台运行,只录 EPG 信令 + 盒子发出的 RTSP 请求(不含服务器回的视频数据)
tcpdump -i br-lan -s 0 -w /tmp/box.pcap \
'host 10.0.0.34 and ((port 33200 or port 80) or (src host 10.0.0.34 and dst port 554))' &
然后配合两轮人工操作:
- 机顶盒断电重启——开机认证时盒子会从 EPG 拉完整频道表(最值钱的一波数据);
- 用遥控器换 5~10 个台——每次换台,盒子都会向 EPG 请求播放授权,然后向 RTSP 服务器发
SETUP,URL 里就带着令牌。
停止抓包后先在路由器上快速验货:
kill $(pidof tcpdump)
strings /tmp/box.pcap | grep -aoE 'rtsp://[^ ]*' | sort | uniq -c | head
看到形如下面的输出就成功了:
4 rtsp://27.148.244.x:554/PLTV/88888888/224/3221227274/83306404.smil?accountinfo=%7e%7eV2.0%7e...
2 rtsp://27.148.244.x:554/PLTV/88888888/224/3221227245/72237946.smil?accountinfo=%7e%7eV2.0%7e...
⚠️ 注意:
accountinfo就是播放令牌,绑定宽带公网 IP 并带时效。拿到手别外传——理论上同省同运营商的人拿到也无法使用(IP 不对),但这是凭证类信息,谨慎对待。
把 pcap 拉回电脑做正式解析(信令抓包很小,15 分钟换台也就 15MB):
scp root@10.0.0.1:/tmp/box.pcap .
第三步:解析——三路证据合并出「频道名 ↔ 地址」
抓包里能挖到三类数据,各管一段:
- EPG 响应里的频道表 JSON:换台/开机时服务器推送,形如
{"code":"Umai:CHAN/xxx@BESTV.STA.SMG","name":"东南卫视(高清)","ID":"39821399","mixNo":"31"}——有名字没地址; - RTSP SETUP 请求:有完整带令牌的地址,但不知道是哪个台;
- 盒子自己的换台日志:
GET /log/epgplay?a=playstart&_m=<频道代码>,把 1 和 2 桥接起来。
另外还有个意外收获:盒子的遥测上报(UDP 5501,opt=120 报文)里直接带着 频道名|rtsp://完整URL 的明文对,可以作为命名兜底。
配对逻辑:按时间线排序所有事件,每个 SETUP 找 ±1.5 秒内最近的 playstart,多数投票(因为一次播放会有 DESCRIBE/SETUP/PLAY 多条请求)。核心解析脚本(基于 dpkt):
# parse_iptv.py —— 核心逻辑节选
import dpkt, re, urllib.parse
from collections import defaultdict, Counter
import bisect
BOX = bytes([10, 0, 0, 34])
events, latest = [], {} # 事件时间线 / 每个资产的最新带令牌 URL
channels = {} # 频道表: code -> {name, mixNo, ...}
flows = defaultdict(bytearray)
with open("box3.pcap", "rb") as f:
for ts, buf in dpkt.pcap.Reader(f):
try:
ip = dpkt.ethernet.Ethernet(buf).data
tcp = ip.data
if not (isinstance(tcp, dpkt.tcp.TCP) and len(tcp.data)):
continue
if ip.src == BOX:
t = tcp.data.decode("latin-1")
# ① RTSP 播放请求(带令牌)
for m in re.finditer(r"(?:DESCRIBE|SETUP|PLAY)\s+(rtsp://\S+?)(?:\sRTSP|\r|\n)", t):
u = m.group(1)
path = urllib.parse.urlsplit(u).path
events.append((ts, "SETUP", path))
if path not in latest or ts > latest[path][0]:
latest[path] = (ts, u) # 保留最新令牌
# ② 换台日志(含频道代码)
for m in re.finditer(r"GET\s+(\S+)", t):
if "/log/epgplay" in m.group(1):
ev = urllib.parse.parse_qs(urllib.parse.urlsplit(m.group(1)).query)
if ev.get("a", [""])[0] == "playstart" and ev.get("_m"):
events.append((ts, "PLAYSTART", ev["_m"][0]))
else:
# ③ EPG 响应里的频道表
flows[(ip.src, tcp.sport)].extend(tcp.data)
except Exception:
pass
chan_re = re.compile(r'"code":"([^"]+)","name":"([^"]+)".*?"mixNo":"(\d+)"')
for data in flows.values():
for m in chan_re.finditer(data.decode("latin-1", "replace")):
channels.setdefault(m.group(1), {"name": m.group(2), "mixNo": int(m.group(3))})
# 时间线配对:SETUP <-> 最近的 PLAYSTART
events.sort(key=lambda e: e[0])
ps = [(ts, m) for ts, typ, m in events if typ == "PLAYSTART"]
ps_ts = [p[0] for p in ps]
pairs = defaultdict(Counter)
for ts, typ, detail in events:
if typ != "SETUP":
continue
i = bisect.bisect_left(ps_ts, ts - 1.5)
best = None
while i < len(ps) and ps[i][0] <= ts + 1.5:
d = abs(ps[i][0] - ts)
if best is None or d < best[0]:
best = (d, ps[i][1])
i += 1
if best:
pairs[detail][best[1]] += 1
# 输出 m3u
with open("channels.m3u", "w", encoding="utf-8") as out:
out.write("#EXTM3U\n")
for path, (ts, url) in sorted(latest.items(), key=lambda kv: kv[1][0]):
code, _ = pairs.get(path, [(None, 0)])[0] if path in pairs else (None, 0)
ch = channels.get(code) or {}
name = f"{ch['name']} (#{ch['mixNo']})" if ch else "未知频道"
out.write(f'#EXTINF:-1 tvg-name="{name}",{name}\n{url}\n')
跑完的输出(节选,地址已脱敏):
频道表: 89 条
事件: 604 条, 带令牌资产: 100
东南卫视(高清) (#2) /PLTV/88888888/224/3221227012/xxxxx.smil
CCTV-1(高清) (#11) /PLTV/88888888/224/3221227241/xxxxx.smil
浙江卫视4K超高清 (#513) /PLTV/88888888/224/3221227443/xxxxx.smil
...
第四步:实测播放,搞清令牌机制
用 ffprobe 验证抓到的地址能不能播:
ffprobe -v error -rtsp_transport tcp -timeout 15000000 \
-show_entries stream=codec_name,width,height -of csv "$URL"
# program,stream,h264,1920,1080
# stream,mp2
1080p H.264 出流成功。再做两个关键实验:
# ① 去掉 accountinfo 参数的裸地址 —— 403,令牌是必需的
# ② 70 分钟前签发的旧令牌 —— 依然能播
结论:令牌必须带,绑定宽带公网 IP(全家共享出口,所以人人能播),有效期至少 1.5 小时(实测 70 分钟仍有效,上限未测)。
第五步:rtp2httpd 转流——RTSP 变 HTTP 直链
播放器直接播 RTSP 没问题(VLC/PotPlayer 都行),但浏览器和一些电视 APP 不支持。在路由器上装 rtp2httpd(从 GitHub 下载对应架构的二进制放到 /usr/bin/rtp2httpd),它支持 RTSP 单播输入、转成 HTTP 直链输出——这正是 udpxy 做不到的。
配置文件 /etc/rtp2httpd.conf,频道直接以 M3U 形式内嵌在 [services] 段:
[global]
verbosity = 1
maxclients = 20 # 全家多设备并发
workers = 2
cors-allow-origin = *
[bind]
* 5140
[services]
#EXTM3U
#EXTINF:-1 group-title="央视",CCTV-1(高清) (#11)
rtsp://27.148.x.x:554/PLTV/88888888/224/3221227241/xxxxx.smil?accountinfo=%7e%7eV2.0%7e%7e%7e(脱敏)
#EXTINF:-1 group-title="4K专区",浙江卫视4K超高清 (#513)
rtsp://27.148.x.x:554/PLTV/88888888/224/3221227443/xxxxx.smil?accountinfo=%7e%7eV2.0%7e%7e%7e(脱敏)
...
100 个频道手动写不现实——我用脚本从
channels.m3u自动生成这份配置,顺便按「央视/卫视/本地/4K/少儿」打好group-title分组。
启用配置文件模式并启动服务:
uci set rtp2httpd.@rtp2httpd[0].use_config_file='1'
uci set rtp2httpd.@rtp2httpd[0].disabled='0'
uci commit rtp2httpd
/etc/init.d/rtp2httpd restart
验证: 从电脑直接拉流测试:
curl -s http://10.0.0.1:5140/playlist.m3u | head -8
# #EXTM3U
# #EXTINF:-1 group-title="央视",CCTV-1(高清) (#11)
# http://10.0.0.1:5140/%E5%A4%AE%E8%A7%86/CCTV-1%28%E9%AB%98%E6%B8%85%29%20%28%2311%29
ffprobe -v error -timeout 15000000 -show_entries stream=codec_name -of csv \
"http://10.0.0.1:5140/%E5%A4%AE%E8%A7%86/CCTV-1%28%E9%AB%98%E6%B8%85%29%20%28%2311%29"
# program,stream,h264,1920,1080 ← 通了
注意生成列表里的 HTTP 直链不含令牌——令牌由 rtp2httpd 在服务端持有,所以这份列表地址永不失效,即使上游令牌过期也只需要更新路由器上的配置。
全家使用方式:
常见问题排查
Q1:抓包文件膨胀得飞快 / 路由器卡死
视频数据混进来了。检查过滤表达式:not (dst host 盒子IP and tcp port 554) 这一半绝对不能少。稳妥起见抓之前先看 ls -la 确认增长速度,每秒几 MB 就是过滤没生效。
Q2:解析出的「未知频道」是哪来的 换台日志和频道表没对上号的资产(常见于付费频道、快速跳过的台)。占少数,不影响其他频道;介意的话在那些台多停几秒重抓。
Q3:某天所有频道都黑屏了
大概率令牌过期。机顶盒通电换一个台(10 秒钟),重跑抓包解析流程,更新 /etc/rtp2httpd.conf 后 /etc/init.d/rtp2httpd restart。播放器端的 m3u 地址不用改。
Q4:为什么不用 udpxy
udpxy 只支持 UDP 组播输入。如果你的 IPTV 走的是传统组播(能抓到 IGMP 加入报文、地址形如 udp://@239.x.x.x),那 udpxy 反而是标配;但我这套是公网 RTSP 单播,只有 rtp2httpd 这类支持 RTSP 输入的工具能转。
Q5:部分频道(VIP/付费)播不出来 令牌签发只代表播放授权流程走通了,订阅状态在服务端校验。未订购的频道换台时会提示未授权,抓到地址也播不了,属正常现象。
Q6:这些地址能发给别人吗 不能。令牌绑定你家宽带公网 IP,拿出去就是 403;而且这是运营商凭证类信息,脱敏处理前别贴论坛。
注意事项与后续
- 盒子全程零改动:不刷机、不装 APP、不动光猫,IPTV 照常使用,运营商侧无感知(抓包是纯旁路行为)。
- 抓包仅限自家网络内做,用于研究自家订购的服务的播放形态;地址和令牌注意脱敏。
- 可以尝试的进阶:我尝试定位过 EPG 上「签发令牌」的具体接口(想做成全自动续期脚本),目前确认换台埋点(PLAYRECORD)和节目单接口都不是它,后续值得做 TCP 会话级重组继续深挖。另外如果家里的固件支持华为 STB 工具开启 ADB,也可以直接从盒子数据库拿频道表,作为抓包路线的替代。
- rtp2httpd 还有 FCC(快速换台)支持、快照(
snapshot=1)等特性,配合 ffmpeg 可以做更多玩法。
整套链路跑通后,家里的电视、手机、平板都通过同一个地址看电视,体验和运营商原装盒子没有区别——而这一切只需要一台 OpenWrt 路由器和两次 10 分钟的换台。
评论