办宽带送的 IPTV 吃灰两年?一根“虚拟网线”让全家手机电视看上 112 个直播台(上海电信实测)
先说结论
你家光猫很可能早就把 IPTV 专网“透传”到了网线上,只是没人去接。我家就是这样:不改光猫、不要机顶盒账号、不碰主路由,只在 PVE 上给旁路由多插了一块带 VLAN 85 标签的虚拟网卡,再装一个叫 rtp2httpd 的小服务,全家的手机、电视、电脑就都能看上海电信 IPTV 的 112 个直播频道,换台首帧只要 0.04 秒。
本文会用“快递分拣”“电台广播”“打车追公交”这些生活里的例子把 VLAN、组播、FCC 讲明白,给出每一步的实测证据和真实截图,最后奉上 Windows 11 / Ubuntu 26.04 / macOS 27 三平台一键脚本(实测可一键配置、一键回滚),以及交给 AI Agent 自动完成的护栏模板。

图 1:本文自制封面。一根光纤进户,经过旁路由“转接”,把电信组播直播变成全家设备都能看的 HTTP 流。
一、问题背景:每个月都在交钱的电视,一直在吃灰
办上海电信宽带时,营业厅顺手给我开了数字电视(IPTV),还送了一台机顶盒。机顶盒用了没几天就进了抽屉:遥控器难用,开机慢,而且只能在客厅那台电视上看。
我家的网络是一套典型的“家庭机房”结构:
- 光猫已经改成桥接,只负责光电转换;
- 一台小主机装了 PVE(Proxmox VE 虚拟化平台),上面跑着几台虚拟机;
- 其中一台是 iKuai 主路由,负责 PPPoE 拨号上网;
- 另一台是 ImmortalWrt 旁路由,负责 DNS、分流这些“精细活”,也是家里设备的默认网关。
我的需求很朴素:既然 IPTV 已经开通,能不能不用机顶盒,让家里的手机、平板、电视盒子、电脑都能直接看直播?最好不要动现有上网链路,出了问题也能一键还原。

图 2:改造前后的拓扑。灰色是原有上网链路,完全不动;绿色是这次新增的一块虚拟网卡和一个服务;紫色是家里设备最终的观看方式。
二、问题表现:想用起来,却不知道从哪下手
真正动手前,我遇到的困惑其实比技术问题更多:
- 网上的教程五花八门:有的说要在光猫里改 VLAN,有的说要抓机顶盒的 DHCP option 60/125 报文做认证,有的说要先拿到光猫超级密码,还有的说要买支持 IPTV 的交换机。
- VLAN 号说法不一:有人说上海电信 IPTV 是 85,有人说组播在 51,还有帖子提到 45。
- iKuai 自带 IGMP 代理,但我这台 iKuai 3.7 没有 udpxy;如果让组播直接灌进局域网,没开 IGMP Snooping 的交换机和 Wi-Fi 可能会被冲垮。
- 我连“光猫到底有没有把 IPTV 送到我这根网线上”都不知道。
所以这次我给自己定了一个原则:先拿证据,再动手;能只读就不改;每一步都要可回滚。
三、先把概念讲明白:三个生活里的比方
1. VLAN:一条马路,分好几条车道

图 3:同一根光纤上跑着不同“车道”的流量。我家实测:无标签车道是上网,85 号车道是 IPTV 专网和直播组播,51、45 号车道空空如也。
想象一辆快递车(网线)一趟要给小区里好几栋楼送包裹。为了不送错,每个包裹上都写着楼号(VLAN 标签),车开到小区门口,门卫按楼号分拣,互不串门。
专业一点说:802.1Q 标准会在以太网帧里插入一个 4 字节的“标签”,标签里有一个 0~4095 的编号。光猫把上网流量和 IPTV 流量用不同的编号区分,再从同一个网口送出来。只要我们的设备“认得 85 号楼”,就能把 IPTV 包裹单独接住。
2. 组播:打电话和电台广播的区别

图 4:单播像打电话,组播像电台广播,rtp2httpd 像家里的“收音转接员”。下方是真实的数据链路。
- 单播就像打电话:一人一条线,100 个人看就要 100 份流量。我们平时刷网页、看视频网站都是这种方式。
- 组播就像电台广播:电台只播一份,谁想听就把收音机拧到那个频率。这个“拧频率”的动作,在网络里叫 IGMP 加入(IGMP Join)。电信 IPTV 的直播就是这样发出来的,每个频道对应一个组播地址,比如 233.18.204.x 加端口 5140。
- 麻烦在于,家里很多交换机和 Wi-Fi 不懂“电台”,会把组播当成广播到处乱喊,轻则其他设备变卡,重则整个 Wi-Fi 瘫痪。
所以需要一个“转接员”:它待在路由器上,替大家收听电台,再用 HTTP 一对一地把节目递给每台想看的设备。rtp2httpd 就是干这个的。它是 udpxy 的现代替代品,除了组播转 HTTP,还支持 FCC 快速换台、网页播放器和状态页。
3. DHCP:前台发房卡
设备连上一个新网络时,会先向网络里的 DHCP 服务器“领房卡”,也就是 IP 地址、网关、DNS。IPTV 专网有自己的“前台”:你在 85 号车道上喊一声“我要房卡”,如果有人回应,就说明光猫确实把 IPTV 专网送下来了。这一步就是整个排查的突破口。
四、问题分析:用证据一步步排除
1. 摸清拓扑:电信线到底接在哪
我先登录 PVE 宿主机查看网桥配置,确认电信光猫接在一块独立物理网口上。它通过一个网桥(以下称 vmbr1)交给 iKuai 做 PPPoE 拨号。另外有一块物理网口是空着的,备用。
2. 抓包:85 号车道上静悄悄
我在这块物理网口上抓了 30 秒包,过滤掉 PPPoE 之后,只剩下交换机的 STP 心跳,没有任何带 VLAN 标签的帧。
先别急着下结论“没开 IPTV”。这就像一个没人住的楼层,楼道当然安静:IPTV 专网平时没有终端在用,自然没有流量。要验证,就得主动去敲门。
3. 主动敲门:在不同 VLAN 上发 DHCP 请求
我在 PVE 宿主机上临时建了几个 VLAN 子接口,用 BusyBox 的 udhcpc 发 DHCP 请求。为了不影响宿主机,回调脚本只记录结果,不真的配置地址:
for v in 85 51 45; do
ip link add link <电信物理网口> name iptvp.$v type vlan id $v
ip link set iptvp.$v up
busybox udhcpc -i iptvp.$v -n -q -f -t 4 -T 3 -s /bin/true
ip link del iptvp.$v
done
结果(地址已脱敏):
== VLAN 85
udhcpc: lease of 30.111.x.x obtained from 30.111.x.x, lease time 1200
== VLAN 51
udhcpc: no lease, failing
== VLAN 45
udhcpc: no lease, failing
85 号车道上的“前台”秒回了一张 30 开头的专网房卡,而且没有要求 option 60 之类的认证信息。这说明光猫早就把 IPTV 专网透传下来了,我家不需要机顶盒账号,也不需要改光猫。
4. 收听电台:组播到底收不收得到
拿到专网地址后,我用一个 20 行的 Python 小脚本,在 VLAN 85 接口上加入了两个组播组:
233.18.204.x:5140 pkts=5116 bytes=6896368 ~9.2Mbps first=9021
233.18.204.x:5140 pkts=5061 bytes=6822228 ~9.1Mbps first=9021
每路约 9 Mbps,数据包开头的 0x9021 是标准的 RTP 头,里面装的是 MPEG-TS 视频流。组播也在 85 号车道上,51 号车道根本用不上。
5. 全量扫描:113 个频道里 112 个有流
我拿社区整理的上海电信频道表(GitHub 上的 ihipop/Shanghai-IPTV 项目)逐个测试。第一轮 12 路并发、每路 3 秒,有 37 个频道没出流;把这 37 个单独重测,全部有流。原因是很多频道同时加入组播时,上游处理 IGMP 加入有延迟,3 秒窗口不够。最终 113 个频道里 112 个可用,只有一个小众频道没有信号。
这个“第一轮漏扫、第二轮补扫”的经验,后来直接写进了一键脚本。

图 5:排查证据链。每一步都有实测数据支撑,先证明能拿到,再证明能收到,最后证明收得稳。
五、问题根因:不是没开通,而是缺一个“接收器 + 转换器”
把证据串起来,根因就很清楚了:
- 光猫早已把 IPTV 业务以 VLAN 85 的形式透传到网线上,和上网流量共用一根线;
- 家里没有任何设备在 85 号车道上“接货”,所以 IPTV 一直处于开通但闲置的状态;
- 就算接住了,组播也不适合直接放进家里的交换机和 Wi-Fi,需要一个转换器把它变成普通的 HTTP 单播流。
解决方案也就呼之欲出:在 85 号车道上放一个接收器(旁路由的新网卡),再配一个转换器(rtp2httpd)。
六、解决方案:手把手配置(全程不碰上网链路)
下面是我在自己网络里实际执行的步骤。地址都用 x 代替,你需要换成自己的值。
第 1 步:PVE 给旁路由热插拔一块 VLAN 85 网卡
在 PVE 的网页界面中,打开旁路由虚拟机,点“硬件”,再点“添加”,选“网络设备”:桥接选电信那块网口对应的网桥,VLAN 标签填 85,防火墙不勾选。也可以在 PVE 宿主机上用一条命令完成:
qm set <旁路由VMID> --net1 virtio,bridge=vmbr1,tag=85,firewall=0
virtio 网卡支持热插拔,旁路由不需要重启,全家网络一秒都没断。

图 6:PVE 虚拟机硬件页真实截图(已脱敏)。net0 是原来的局域网口,net1 是新增的 IPTV 网卡:桥接到电信网口,tag=85。
用快递的比方说,这一步相当于在小区门口给旁路由开了一个专收 85 号楼包裹的窗口。
第 2 步:ImmortalWrt 新建 iptv 接口
旁路由里会多出一块 eth1。新建一个名为 iptv 的接口,用 DHCP 客户端模式,并关掉两样东西:
uci set network.iptv=interface
uci set network.iptv.device=eth1
uci set network.iptv.proto=dhcp
uci set network.iptv.defaultroute=0 # 不抢默认路由,上网依旧走主路由
uci set network.iptv.peerdns=0 # 不用专网下发的 DNS
uci set network.iptv.delegate=0
uci commit network
ifup iptv
这里有两个关键开关,我用小区门禁打个比方:iptv 接口只是给 IPTV 开的一扇“小侧门”。defaultroute=0 表示不准把侧门当成正门(不能接管全家上网),peerdns=0 表示不准听侧门门卫指路(专网下发的 DNS 是 10.x 段的内网地址,用它解析会出问题)。
我家旁路由恰好还有一条去往 10 网段的内网路由,如果误用专网 DNS,后果会更难排查。所以这两个开关一个都不能少。

图 7:ImmortalWrt 接口页真实截图(已脱敏)。新出现的 iptv 接口通过 eth1 拿到了 30.110 开头的专网地址。
第 3 步:给 iptv 单独划一个防火墙区域
IPTV 专网里还有大量其他用户的设备,所以 iptv 必须是一个单独的防火墙区域:入站默认拒绝,只放行四类流量。
z=$(uci add firewall zone)
uci set firewall.$z.name=iptv
uci add_list firewall.$z.network=iptv
uci set firewall.$z.input=REJECT
uci set firewall.$z.output=ACCEPT
uci set firewall.$z.forward=REJECT
# 然后添加 4 条入站放行规则(脚本里有完整写法):
# 1. DHCP 续租(UDP 68) 2. IGMP 3. 组播视频(UDP 5140) 4. 来自 FCC 服务器的 UDP
uci commit firewall
/etc/init.d/firewall reload

图 8:防火墙区域真实截图。iptv 区域的入站和转发都是“拒绝”,也不和局域网互相转发。

图 9:4 条 IPTV 放行规则真实截图(已脱敏)。我手工配置时,组播规则按“D 类组播地址段”放行;一键脚本里改成按端口 5140 放行,效果相同,写法更直观。
第 4 步:让 FCC 快速换台走对路

图 10:FCC 就像先打车追上公交,再换乘上车。实测开启后,换台首帧约 0.04 秒。
数字电视的画面要从“关键帧”(I 帧)开始才能解码。不开 FCC 时,换台后要干等下一个关键帧,就像到车站刚好错过一班公交,只能等下一班,屏幕会黑 1~2 秒。
FCC(Fast Channel Change)的做法是:换台瞬间,由 FCC 服务器用单播先补发一段从最近关键帧开始的画面,等组播接上了再无缝切换过去。社区整理的上海电信 FCC 服务器在 124.75.25.x 网段,端口 7777。
FCC 回包是单播,所以要做两件事:防火墙放行它(第 3 步的第 4 条规则),并让去往它的流量走 IPTV 网关。网关地址是 DHCP 动态下发的,我写了一个热插拔脚本,接口每次拿到地址时自动加路由:
# /etc/hotplug.d/iface/90-iptv-route
[ "$INTERFACE" = iptv ] || exit 0
case "$ACTION" in
ifup|ifupdate)
gw=$(ifstatus iptv | jsonfilter -e '@.inactive.route[@.mask=0].nexthop' -e '@.route[@.mask=0].nexthop' | head -n1)
[ -n "$gw" ] && ip route replace <FCC服务器>/32 via "$gw" dev "$DEVICE"
;;
esac
这里有一个小坑:设置了 defaultroute=0 之后,DHCP 下发的网关不会出现在 ifstatus 的 route 字段里,而是被放进了 inactive.route。我第一版脚本就因为只读 route 字段,没取到网关。
第 5 步:安装并配置 rtp2httpd
ImmortalWrt 24.10 的软件源里有现成的包:
opkg update
opkg install rtp2httpd luci-app-rtp2httpd luci-i18n-rtp2httpd-zh-cn
配置要点:只监听局域网地址(不暴露给专网),上游接口指向 eth1,再把频道列表交给它:
uci add_list rtp2httpd.@instance[0].listen=192.168.x.1:5140
uci set rtp2httpd.@instance[0].enabled=1
uci set rtp2httpd.@instance[0].upstream_interface=eth1
uci set rtp2httpd.@instance[0].mcast_rejoin_interval=60
uci set rtp2httpd.@instance[0].external_m3u=file:///etc/rtp2httpd/iptv.m3u
uci commit rtp2httpd
/etc/init.d/rtp2httpd enable && /etc/init.d/rtp2httpd restart

图 11:LuCI 中的 rtp2httpd 配置页真实截图(已脱敏)。绑定地址只填局域网 IP,上游接口选 eth1。
频道列表里的地址写成 rtp://组播地址:5140?fcc=FCC服务器:7777。rtp2httpd 会自动把它改写成局域网可访问的 HTTP 地址,并对外提供:
- 播放列表:http://旁路由地址:5140/playlist.m3u
- 网页播放器:http://旁路由地址:5140/player
- 状态页:http://旁路由地址:5140/status
第 6 步:验收
验收时我没有只看“页面能打开”,而是用数据说话:
- 在 Mac 上连续拉流 30 秒:19.1 万个 TS 包,连续性计数器错误为 0,码率稳定在 9.6 Mbps;
- 用 ffprobe 检查:H.264 High 1920×1080 隔行扫描(1080i)、25 帧,音频为 MP2 48 kHz;
- 4K 频道约 37 Mbps,也稳定;
- 旁路由 CPU 基本空闲,UDP 接收缓冲区溢出次数为 0;
- 默认路由依旧指向主路由,DNS 没变,全家上网不受影响。

图 12:rtp2httpd 状态页真实截图(已脱敏)。同时有一路高清和一路 4K 在播,每个连接的带宽、队列和丢包一目了然。

图 13:浏览器直接打开 /player 播放 CCTV9 纪录频道的真实截图。右侧是 112 个频道和分组,节目单信息也能正常显示。
七、番外:为什么网页播放器有点卡,VLC 却很流畅?
配置完成后,我用浏览器打开 /player,发现播放时不时会一顿一顿。第一反应是网络问题,但证据并不支持:同一时刻用 curl 拉流 30 秒,零丢包,码率稳定。
于是我把网页播放器的代码翻出来看了看,原因找到了:

图 14:网页播放器的每一步都在网页 JavaScript 里用 CPU 计算,本地播放器则由显卡和系统解码器负责。
- 电信频道的音频是 MP2 格式,浏览器原生不支持,播放器只能用 WebAssembly 软件解码;
- 视频是 1080i 隔行扫描,播放器默认开着“自动反交错”和“画质增强”,这两项都要在网页里逐帧计算;
- 播放器默认把延迟控制在 1.5 秒左右,落后了就用 1.2 倍速追赶。缓冲区一紧张,画面就会时快时慢。
打个比方:网页播放器像用手机计算器算一整天的账,本地播放器像用专业收银机,都能算,但快慢差很多。
解决办法:
- 在网页播放器设置里关掉“画质增强”和“自动反交错”,卡顿会明显缓解;
- 日常观看建议用本地播放器,把 http://旁路由地址:5140/playlist.m3u 填进去即可:Mac 上用 IINA 或 VLC,电视和盒子上用 TiviMate、APTV、Kodi;
- 网页播放器适合临时预览、快速换台看一眼。
八、一键全自动脚本:Windows 11 / Ubuntu 26.04 / macOS 27
把上面的手工步骤固化成脚本后,整个过程就是一条命令。三个平台的脚本逻辑完全一致,全部操作都通过 SSH 完成,不依赖任何第三方服务。频道表内置在脚本里,只记录组播地址的最后一段。

图 15:一键脚本的 8 步流水线。默认只读体检,加 --apply 才改配置,--rollback 一键还原。
脚本特性:
- 三种模式:check 只读体检(默认)、apply 一键配置、rollback 一键回滚;
- 先备份:apply 前自动执行 sysupgrade -b,把路由器配置备份下载到本机;如果指定了 PVE,还会备份虚拟机配置文件;
- 可选的 PVE 自动化:指定 PVE 地址和 VMID 后,自动热插拔 tag=85 网卡,并识别出路由器里新增的网卡名;
- 幂等:重复执行不会产生重复的防火墙规则;
- 两轮频道扫描:先 12 路并发快扫,再对没出流的频道 4 路并发慢扫,避开 IGMP 加入延迟造成的漏扫;
- 真实验收:在路由器上和本机各真实拉一段流,确认有数据才算成功;
- 全程脱敏:屏幕输出里的 IP 地址都会打码。
我在一台一次性的 ImmortalWrt 24.10.6 测试虚拟机上,把三个平台的脚本都从零跑过一遍:配置、重复配置、回滚,每次都 112/112 个频道可用,回滚后路由器和 PVE 都恢复原状。测试完这台虚拟机已经销毁。

图 16:Ubuntu 上运行一键脚本的真实终端输出(地址已由脚本自动打码)。先只读体检,再一键配置,最后本机验收 112 个频道。
下载
四个文件需要放在同一个目录:
- 打包下载:iptv-toolkit.zip
- 路由器端逻辑(三个平台共用):iptv_router_payload.sh
- Windows 11:iptv_toolkit_windows11.ps1
- Ubuntu 26.04:iptv_toolkit_ubuntu2604.sh
- macOS 27:iptv_toolkit_macos27.zsh
- 校验值:SHA256SUMS.txt
1. 路由器端逻辑(iptv_router_payload.sh)
这是真正在路由器上执行的部分,使用 POSIX sh 编写,兼容 BusyBox ash,同时支持 opkg(ImmortalWrt 24.10)和 apk(OpenWrt 25 起)。
#!/bin/sh
# iptv-router-payload: runs ON the OpenWrt/ImmortalWrt router (POSIX sh / BusyBox ash).
# Inputs (env): MODE=check|apply|rollback IPTV_DEV=ethX|auto LISTEN_PORT=5140
# MCAST_PREFIX=233.18.204 MCAST_PORT=5140 FCC=host:port|"" SCAN_ALL=0|1
set -u
MODE="${MODE:-check}"
IPTV_DEV="${IPTV_DEV:-auto}"
LISTEN_PORT="${LISTEN_PORT:-5140}"
MCAST_PREFIX="${MCAST_PREFIX:-233.18.204}"
MCAST_PORT="${MCAST_PORT:-5140}"
FCC="${FCC:-}"
SCAN_ALL="${SCAN_ALL:-0}"
M3U=/etc/rtp2httpd/iptv.m3u
HOOK=/etc/hotplug.d/iface/90-iptv-route
say() { printf '[router] %s\n' "$*"; }
ok() { printf '[router] OK %s\n' "$*"; }
warn() { printf '[router] WARN %s\n' "$*"; }
die() { printf '[router] FAIL %s\n' "$*"; exit 1; }
mask() { sed -E 's/([0-9]+\.[0-9]+)\.[0-9]+\.[0-9]+/\1.x.x/g'; }
# Channel table: last octet | group | name (all on ${MCAST_PREFIX}.N:${MCAST_PORT})
channels() {
cat <<'EOF'
57|上海文广|上海新闻综合
51|上海文广|东方卫视
45|上海文广|生活时尚
48|上海文广|都市剧场
53|上海文广|上海都市
55|上海文广|东方影视
58|上海文广|五星体育
59|上海文广|上海第一财经
65|上海文广|东方财经
63|上海文广|上海教育
52|CCTV|CCTV1
68|CCTV|CCTV2
69|CCTV|CCTV3
70|CCTV|CCTV4
71|CCTV|CCTV5
67|CCTV|CCTV5+
72|CCTV|CCTV6
73|CCTV|CCTV7
74|CCTV|CCTV8
75|CCTV|CCTV9
76|CCTV|CCTV10
77|CCTV|CCTV11
78|CCTV|CCTV12
79|CCTV|CCTV13
80|CCTV|CCTV14
81|CCTV|CCTV15
82|CCTV|CCTV16
83|CCTV|CCTV17
98|CCTV|CETV1
154|CCTV|CETV2
111|CCTV|CETV4
187|CCTV|CETV早期教育
28|CCTV|CGTN
93|卫视频道|安徽卫视
87|卫视频道|北京卫视
221|卫视频道|兵团卫视
94|卫视频道|东南卫视
110|卫视频道|甘肃卫视
88|卫视频道|广东卫视
107|卫视频道|广西卫视
101|卫视频道|贵州卫视
102|卫视频道|海南卫视
103|卫视频道|河北卫视
105|卫视频道|河南卫视
90|卫视频道|黑龙江卫视
92|卫视频道|湖北卫视
86|卫视频道|湖南卫视
108|卫视频道|吉林卫视
85|卫视频道|江苏卫视
95|卫视频道|江西卫视
96|卫视频道|辽宁卫视
217|卫视频道|内蒙古卫视
230|卫视频道|宁夏卫视
112|卫视频道|青海卫视
172|卫视频道|三沙卫视
91|卫视频道|山东卫视
211|卫视频道|山西卫视
223|卫视频道|陕西卫视
89|卫视频道|深圳卫视
99|卫视频道|四川卫视
97|卫视频道|天津卫视
222|卫视频道|西藏卫视
220|卫视频道|新疆卫视
155|卫视频道|延边卫视
106|卫视频道|云南卫视
84|卫视频道|浙江卫视
100|卫视频道|重庆卫视
50|数字频道|欢笑剧场
104|数字频道|金鹰纪实
47|数字频道|乐游
157|数字频道|家庭理财
231|数字频道|财富天下
49|数字频道|法治天地
42|数字频道|快乐垂钓
44|数字频道|游戏风云
116|数字频道|BesTV电影介绍
201|数字频道|IPTV测试
174|数字频道|CHC动作电影
173|数字频道|CHC家庭影院
175|数字频道|CHC影迷电影
178|数字频道|兵器科技
184|数字频道|第一剧场
183|数字频道|风云剧场
185|数字频道|风云音乐
176|数字频道|风云足球
181|数字频道|高尔夫网球
182|数字频道|怀旧剧场
180|数字频道|女性时尚
179|数字频道|世界地理
186|数字频道|文化精品
177|数字频道|央视台球
66|少儿频道|金色学堂
46|少儿频道|动漫秀场
54|少儿频道|哈哈炫动
151|少儿频道|嘉佳卡通
113|少儿频道|金鹰卡通
109|少儿频道|卡酷少儿
224|4K频道|东方卫视「4K」
115|4K频道|欢笑剧场「4K」
169|4K频道|BesTV电影「4K」
170|4K频道|BesTV记录「4K」
114|4K频道|CCTV16「4K」
188|4K频道|CCTV4K「4K」
209|4K频道|北京卫视「4K」
218|4K频道|广东卫视「4K」
227|4K频道|湖南卫视「4K」
226|4K频道|江苏卫视「4K」
228|4K频道|山东卫视「4K」
219|4K频道|深圳卫视「4K」
225|4K频道|四川卫视「4K」
229|4K频道|浙江卫视「4K」
171|4K频道|BesTV动画「4K」
EOF
}
[ -f /etc/openwrt_release ] || die "not an OpenWrt system"
. /etc/openwrt_release
say "firmware: ${DISTRIB_ID:-OpenWrt} ${DISTRIB_RELEASE:-?} mode: $MODE"
lan_ip() { uci -q get network.lan.ipaddr | awk '{print $1}' | cut -d/ -f1; }
LAN_IP="$(lan_ip)"
[ -n "$LAN_IP" ] || die "cannot read network.lan.ipaddr"
pkg_install() {
if command -v apk >/dev/null 2>&1; then apk update >/dev/null && apk add "$@"
else opkg update >/dev/null && opkg install "$@"; fi
}
pkg_remove() {
if command -v apk >/dev/null 2>&1; then apk del "$@"; else opkg remove "$@"; fi
}
pick_dev() {
[ "$IPTV_DEV" != auto ] && { echo "$IPTV_DEV"; return; }
cur="$(uci -q get network.iptv.device)"
[ -n "$cur" ] && { echo "$cur"; return; }
used="$(uci show network 2>/dev/null)"
for d in /sys/class/net/eth*; do
[ -e "$d" ] || continue
n="${d##*/}"
case "$n" in *.*) continue ;; esac
echo "$used" | grep -q "[' ]$n['. ]" || echo "$n"
done | head -n1
}
# fetch N seconds of a stream through rtp2httpd, print byte count
probe() {
f="/tmp/iptv-probe.$$.$1"
wget -q -O "$f" "http://$LAN_IP:$LISTEN_PORT/rtp/$MCAST_PREFIX.$1:$MCAST_PORT" 2>/dev/null &
p=$!; sleep "${2:-3}"; kill "$p" 2>/dev/null; wait "$p" 2>/dev/null
if [ -f "$f" ]; then wc -c <"$f"; else echo 0; fi; rm -f "$f"
}
do_check() {
dev="$(pick_dev)"
if [ -n "$dev" ]; then ok "IPTV NIC candidate: $dev"; else warn "no unused ethX found (add a VLAN-tagged NIC first)"; fi
if uci -q get network.iptv >/dev/null; then
st="$(ifstatus iptv 2>/dev/null | jsonfilter -e '@.up')"
addr="$(ifstatus iptv 2>/dev/null | jsonfilter -e '@["ipv4-address"][0].address' | mask)"
ok "interface iptv exists (up=$st ip=${addr:-none})"
else
say "interface iptv not configured yet"
fi
if [ -x /usr/bin/rtp2httpd ]; then ok "rtp2httpd installed"; else say "rtp2httpd not installed"; fi
say "default route: $(ip route | grep '^default' | mask)"
}
do_apply() {
dev="$(pick_dev)"
[ -n "$dev" ] || die "no IPTV NIC; pass IPTV_DEV or add a NIC with VLAN tag first"
[ -e "/sys/class/net/$dev" ] || die "device $dev not found"
say "using $dev as IPTV uplink"
uci set network.iptv=interface
uci set network.iptv.device="$dev"
uci set network.iptv.proto=dhcp
uci set network.iptv.defaultroute=0
uci set network.iptv.peerdns=0
uci set network.iptv.delegate=0
uci commit network
# firewall: rebuild our zone and rules idempotently
for s in $(uci -X show firewall | sed -nE "s/^firewall\.([^.]+)\.name='(iptv|IPTV-Allow-.*)'$/\1/p"); do
uci delete "firewall.$s"
done
z=$(uci add firewall zone)
uci set "firewall.$z.name=iptv"
uci add_list "firewall.$z.network=iptv"
uci set "firewall.$z.input=REJECT"
uci set "firewall.$z.output=ACCEPT"
uci set "firewall.$z.forward=REJECT"
rule() {
r=$(uci add firewall rule)
uci set "firewall.$r.name=$1"; uci set "firewall.$r.src=iptv"
uci set "firewall.$r.family=ipv4"; uci set "firewall.$r.target=ACCEPT"
shift
while [ $# -gt 1 ]; do uci set "firewall.$r.$1=$2"; shift 2; done
}
rule IPTV-Allow-DHCP-Renew proto udp dest_port 68
rule IPTV-Allow-IGMP proto igmp
rule IPTV-Allow-Multicast proto udp dest_port "$MCAST_PORT"
[ -n "$FCC" ] && rule IPTV-Allow-FCC proto udp src_ip "${FCC%:*}"
uci commit firewall
if [ -n "$FCC" ]; then
cat >"$HOOK" <<EOF
#!/bin/sh
# Route the IPTV FCC server via the IPTV VLAN gateway (DHCP-provided)
[ "\$INTERFACE" = iptv ] || exit 0
case "\$ACTION" in
ifup|ifupdate)
gw=\$(ifstatus iptv | jsonfilter -e '@.inactive.route[@.mask=0].nexthop' -e '@.route[@.mask=0].nexthop' | head -n1)
[ -n "\$gw" ] && ip route replace ${FCC%:*}/32 via "\$gw" dev "\$DEVICE" && logger -t iptv "FCC route via \$gw"
;;
esac
EOF
chmod +x "$HOOK"
fi
[ -x /usr/bin/rtp2httpd ] || { say "installing rtp2httpd ..."; pkg_install rtp2httpd luci-app-rtp2httpd >/dev/null || die "package install failed"; }
pkg_install luci-i18n-rtp2httpd-zh-cn >/dev/null 2>&1 || true
/etc/init.d/firewall reload >/dev/null 2>&1
ifup iptv
i=0
while [ $i -lt 20 ]; do
addr="$(ifstatus iptv | jsonfilter -e '@["ipv4-address"][0].address')"
[ -n "$addr" ] && break
i=$((i + 1)); sleep 1
done
[ -n "$addr" ] || die "iptv got no DHCP lease on $dev (check the VLAN tag)"
ok "iptv lease: $(echo "$addr" | mask)"
[ -n "$FCC" ] && ACTION=ifup INTERFACE=iptv DEVICE="$dev" sh "$HOOK"
sec=$(uci -X show rtp2httpd | sed -nE "s/^rtp2httpd\.([^=.]+)=instance$/\1/p" | head -n1)
[ -n "$sec" ] || sec=$(uci add rtp2httpd instance)
uci -q delete "rtp2httpd.$sec.listen"
uci add_list "rtp2httpd.$sec.listen=$LAN_IP:$LISTEN_PORT"
uci set "rtp2httpd.$sec.enabled=1"
uci set "rtp2httpd.$sec.upstream_interface=$dev"
uci set "rtp2httpd.$sec.maxclients=32"
uci set "rtp2httpd.$sec.workers=2"
uci set "rtp2httpd.$sec.mcast_rejoin_interval=60"
uci set "rtp2httpd.$sec.verbose=1"
uci commit rtp2httpd
/etc/init.d/rtp2httpd enable
/etc/init.d/rtp2httpd restart >/dev/null 2>&1
sleep 2
say "scanning channels (12 at a time, 3 s each) ..."
mkdir -p /etc/rtp2httpd
tmp=/tmp/iptv-scan.$$; : >"$tmp"
if [ "$SCAN_ALL" = 1 ]; then
i=1; while [ $i -le 254 ]; do echo "$i|自动发现|频道 $i"; i=$((i + 1)); done
else
channels
fi >"$tmp.list"
# pass 1: 12 parallel x 3 s; pass 2: retry misses 4 parallel x 6 s (IGMP joins can be slow under load)
scan() { # $1 = list file, $2 = parallel, $3 = seconds
n=0
while IFS='|' read -r oct grp name; do
( b=$(probe "$oct" "$3"); [ "$b" -gt 200000 ] && echo "$oct|$grp|$name" >>"$tmp" ) &
n=$((n + 1)); [ $((n % $2)) -eq 0 ] && wait
done <"$1"
wait
}
scan "$tmp.list" 12 3
while IFS='|' read -r oct rest; do grep -q "^$oct|" "$tmp" || echo "$oct|$rest"; done <"$tmp.list" >"$tmp.miss"
[ -s "$tmp.miss" ] && { say "retrying $(wc -l <"$tmp.miss") silent channels ..."; scan "$tmp.miss" 4 6; }
rm -f "$tmp.miss"
total=$(wc -l <"$tmp.list"); live=$(wc -l <"$tmp")
[ "$live" -gt 0 ] || die "no channel produced data; check firewall/IGMP"
ok "live channels: $live / $total"
q=""; [ -n "$FCC" ] && q="?fcc=$FCC"
{
echo '#EXTM3U'
while IFS='|' read -r oct grp name; do
grep -q "^$oct|" "$tmp" || continue
printf '#EXTINF:-1 tvg-name="%s" group-title="%s",%s\n' "$name" "$grp" "$name"
printf 'rtp://%s.%s:%s%s\n' "$MCAST_PREFIX" "$oct" "$MCAST_PORT" "$q"
done <"$tmp.list"
} >"$M3U"
rm -f "$tmp" "$tmp.list"
uci set "rtp2httpd.$sec.external_m3u=file://$M3U"
uci set "rtp2httpd.$sec.external_m3u_update_interval=0"
uci commit rtp2httpd
/etc/init.d/rtp2httpd restart >/dev/null 2>&1
sleep 2
for f in "$HOOK" /etc/rtp2httpd/; do
[ -e "$f" ] && ! grep -qx "$f" /etc/sysupgrade.conf && echo "$f" >>/etc/sysupgrade.conf
done
first=$(sed -n 's#^rtp://[0-9.]*\.\([0-9]*\):.*#\1#p' "$M3U" | head -n1)
b=$(probe "$first" 5)
[ "$b" -gt 1000000 ] || die "verification stream too small ($b bytes)"
ok "verify: $((b / 1024)) KiB in 5 s via rtp2httpd"
ok "playlist: http://<router>:$LISTEN_PORT/playlist.m3u"
say "default route still: $(ip route | grep '^default' | mask)"
}
do_rollback() {
/etc/init.d/rtp2httpd stop >/dev/null 2>&1
/etc/init.d/rtp2httpd disable >/dev/null 2>&1
pkg_remove luci-i18n-rtp2httpd-zh-cn luci-app-rtp2httpd rtp2httpd >/dev/null 2>&1
rm -rf /etc/rtp2httpd "$HOOK" /etc/config/rtp2httpd
sed -i '\#^/etc/rtp2httpd/$#d;\#^/etc/hotplug.d/iface/90-iptv-route$#d' /etc/sysupgrade.conf
ifdown iptv 2>/dev/null
uci -q delete network.iptv; uci commit network
for s in $(uci -X show firewall | sed -nE "s/^firewall\.([^.]+)\.name='(iptv|IPTV-Allow-.*)'$/\1/p"); do
uci delete "firewall.$s"
done
uci commit firewall
/etc/init.d/firewall reload >/dev/null 2>&1
/etc/init.d/network reload >/dev/null 2>&1
ok "router side rolled back"
}
case "$MODE" in
check) do_check ;;
apply) do_apply ;;
rollback) do_rollback ;;
*) die "unknown MODE=$MODE" ;;
esac
2. Windows 11(iptv_toolkit_windows11.ps1)
只使用系统自带的 PowerShell 5.1、OpenSSH 客户端和 curl.exe。为了避开 PowerShell 5.1 把文本加上 CRLF、把二进制写成 UTF-16 的老问题,脚本直接用 .NET Process 按字节读写 ssh 的输入输出。如果你是复制粘贴保存的,请用记事本另存为“带有 BOM 的 UTF-8”。
<#
==============================================================================
iptv_toolkit_windows11.ps1
上海电信 IPTV -> OpenWrt/ImmortalWrt + rtp2httpd 一键配置(Windows 11 原生)
需与 iptv_router_payload.sh 放在同一目录。只依赖系统自带的 PowerShell / OpenSSH 客户端 / curl.exe,不访问任何第三方服务。
体检(只读): powershell -ExecutionPolicy Bypass -File .\iptv_toolkit_windows11.ps1 -Router 192.168.x.1
一键配置 : ... -Router 192.168.x.1 -Mode apply [-Pve 192.168.x.10 -VmId 101 -Bridge vmbr1]
一键回滚 : ... -Router 192.168.x.1 -Mode rollback -Yes [-Pve 192.168.x.10 -VmId 101]
==============================================================================
#>
param(
[Parameter(Mandatory = $true)][string]$Router,
[ValidateSet('check', 'apply', 'rollback')][string]$Mode = 'check',
[string]$Pve = '',
[string]$VmId = '',
[string]$Bridge = 'vmbr1',
[int]$Vlan = 85,
[string]$User = 'root',
[string]$IptvDev = 'auto',
[int]$Port = 5140,
[switch]$NoFcc,
[switch]$ScanAll,
[switch]$Yes,
[string]$OutDir = (Join-Path $HOME ("iptv-toolkit-" + (Get-Date -Format 'yyyyMMdd-HHmmss')))
)
$ErrorActionPreference = 'Stop'
$FccNet = '124.75.25'; $FccId = '211'; $FccPort = '7777' # 上海电信 FCC 服务器(社区公开)
$Fcc = if ($NoFcc) { '' } else { "$FccNet.$($FccId):$FccPort" }
if ($Pve -and -not $VmId) { throw '-Pve 需要同时指定 -VmId' }
function Mask([string]$s) { $s -replace '(\d+\.\d+)\.\d+\.\d+', '$1.x.x' }
function Info([string]$m) { Write-Host "[local] $m" -ForegroundColor Cyan }
function Ok([string]$m) { Write-Host "[local] OK $m" -ForegroundColor Green }
function Die([string]$m) { Write-Host "[local] FAIL $m" -ForegroundColor Red; exit 1 }
$SshOpts = '-o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=15'
# Run ssh with exact bytes on stdin/stdout (no CRLF or UTF-16 conversion).
function Invoke-Ssh {
param([string]$Target, [string]$Command, [byte[]]$InputBytes = $null, [string]$OutFile = '', [switch]$Capture)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'ssh'
$psi.Arguments = "$SshOpts $Target `"$($Command -replace '"', '\"')`""
$psi.UseShellExecute = $false
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = [bool]($OutFile -or $Capture)
$p = [System.Diagnostics.Process]::Start($psi)
if ($InputBytes) { $p.StandardInput.BaseStream.Write($InputBytes, 0, $InputBytes.Length) }
$p.StandardInput.Close()
$text = $null
if ($OutFile) {
$fs = [System.IO.File]::Create($OutFile); $p.StandardOutput.BaseStream.CopyTo($fs); $fs.Close()
} elseif ($Capture) {
$ms = New-Object System.IO.MemoryStream; $p.StandardOutput.BaseStream.CopyTo($ms)
$text = [System.Text.Encoding]::UTF8.GetString($ms.ToArray())
}
$p.WaitForExit()
if ($p.ExitCode -ne 0) { throw "ssh $Target exited with $($p.ExitCode)" }
return $text
}
function RouterSsh([string]$c, [switch]$Capture) { Invoke-Ssh -Target "$User@$Router" -Command $c -Capture:$Capture }
function PveSsh([string]$c, [switch]$Capture) { Invoke-Ssh -Target "root@$Pve" -Command $c -Capture:$Capture }
# 路由器端逻辑(POSIX sh)放在同目录的 iptv_router_payload.sh,按原始字节读取并去掉 CR
$PayloadFile = Join-Path $PSScriptRoot 'iptv_router_payload.sh'
if (-not (Test-Path $PayloadFile)) { Die "找不到 $PayloadFile(请和本脚本放在同一目录)" }
$PayloadBytes = [byte[]]([System.IO.File]::ReadAllBytes($PayloadFile) | Where-Object { $_ -ne 13 })
function Run-Payload([string]$m, [string]$dev) {
Invoke-Ssh -Target "$User@$Router" -Command "MODE=$m IPTV_DEV=$dev LISTEN_PORT=$Port FCC='$Fcc' SCAN_ALL=$([int][bool]$ScanAll) sh -s" -InputBytes $PayloadBytes | Out-Null
}
function Router-Devs { (RouterSsh 'ls /sys/class/net' -Capture) -split "\s+" | Where-Object { $_ -match '^eth\d+$' } | Sort-Object }
function Pve-IptvNet {
$cfg = PveSsh "qm config $VmId" -Capture
foreach ($line in ($cfg -split "`n")) {
if ($line -match "^(net\d+): .*bridge=$Bridge(,.*)?,tag=$Vlan(,.*)?$") { return $Matches[1] }
}
return ''
}
# 1. preflight
Info "目标路由器: $(Mask $Router) 模式: $Mode"
try { RouterSsh 'true' | Out-Null } catch { Write-Host "[local] $($_.Exception.Message)" -ForegroundColor DarkGray; Die "SSH 免密登录路由器失败,请先把公钥加入路由器(type `$env:USERPROFILE\.ssh\id_ed25519.pub | ssh $User@<路由器> 'cat >> /etc/dropbear/authorized_keys')" }
Ok 'SSH 连通'
if ($Pve) {
try { PveSsh "qm config $VmId >/dev/null" | Out-Null } catch { Die "PVE 上找不到 VM $VmId 或 SSH 失败" }
Ok "PVE 连通,VM $VmId 存在"
}
switch ($Mode) {
'check' {
Run-Payload 'check' 'auto'
if ($Pve) { $n = Pve-IptvNet; if ($n) { Ok "PVE 已有 IPTV 网卡: $n (tag=$Vlan)" } else { Info "PVE 尚未添加 tag=$Vlan 网卡" } }
}
'apply' {
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$bk = Join-Path $OutDir 'router-backup.tgz'
Invoke-Ssh -Target "$User@$Router" -Command 'sysupgrade -b /tmp/iptv-pre.tgz >/dev/null 2>&1 && cat /tmp/iptv-pre.tgz && rm -f /tmp/iptv-pre.tgz' -OutFile $bk | Out-Null
if ((Get-Item $bk).Length -lt 1024) { Die '配置备份失败' }
Ok "路由器配置已备份: $bk"
$dev = $IptvDev
if ($Pve) {
PveSsh "cp /etc/pve/qemu-server/$VmId.conf /root/$VmId.conf.bak-iptv-$(Get-Date -Format yyyyMMddHHmmss)" | Out-Null
$n = Pve-IptvNet
if ($n) { Ok "PVE 已存在 IPTV 网卡 $n,跳过" }
else {
$before = Router-Devs
$used = ((PveSsh "qm config $VmId" -Capture) -split "`n") | Where-Object { $_ -match '^net(\d+):' } | ForEach-Object { [int]($_ -replace '^net(\d+):.*', '$1') }
$free = 0; while ($used -contains $free) { $free++ }
PveSsh "qm set $VmId --net$free virtio,bridge=$Bridge,tag=$Vlan,firewall=0 >/dev/null 2>&1" | Out-Null
Ok "PVE 已热插拔 net$free (bridge=$Bridge, tag=$Vlan)"
$new = $null
for ($i = 0; $i -lt 20 -and -not $new; $i++) {
$new = Router-Devs | Where-Object { $before -notcontains $_ } | Select-Object -First 1
if (-not $new) { Start-Sleep -Seconds 1 }
}
if (-not $new) { Die '路由器没有识别到新网卡(虚拟机可能不支持热插拔,重启 VM 后再试)' }
$dev = $new; Ok "路由器识别到新网卡: $dev"
}
}
Run-Payload 'apply' $dev
$m3u = & curl.exe -s -m 10 "http://$($Router):$Port/playlist.m3u"
$urls = @($m3u | Where-Object { $_ -like 'http*' })
if ($urls.Count -eq 0) { Die '本机取不到播放列表' }
$bytes = [int](& curl.exe -s -m 5 -o NUL -w '%{size_download}' $urls[0])
Ok "本机验收: 播放列表 $($urls.Count) 个频道,5 秒拉流 $([int]($bytes / 1024)) KiB"
Info "播放器填写: http://$(Mask $Router):$Port/playlist.m3u (把 x.x 换成你的路由器地址)"
}
'rollback' {
if (-not $Yes) { if ((Read-Host '确认回滚 IPTV 配置?[y/N]') -notmatch '^[yY]$') { exit 0 } }
Run-Payload 'rollback' 'auto'
if ($Pve) {
$n = Pve-IptvNet
if ($n) { PveSsh "qm set $VmId --delete $n >/dev/null 2>&1" | Out-Null; Ok "PVE 已移除 $n" } else { Info 'PVE 上没有需要移除的 IPTV 网卡' }
}
}
}
Ok '完成'
3. Ubuntu 26.04(iptv_toolkit_ubuntu2604.sh)
只依赖 bash、ssh、curl,遵循 set -euo pipefail 安全模式。
#!/usr/bin/env bash
# ==============================================================================
# iptv_toolkit_ubuntu2604.sh
# 上海电信 IPTV -> OpenWrt/ImmortalWrt + rtp2httpd 一键配置(Ubuntu 26.04 原生)
# 需与 iptv_router_payload.sh 放在同一目录。只依赖 bash / ssh / curl,全部操作通过 SSH 完成,不访问任何第三方服务。
#
# 体检(只读): bash iptv_toolkit_ubuntu2604.sh --router 192.168.x.1
# 一键配置 : bash iptv_toolkit_ubuntu2604.sh --router 192.168.x.1 --apply \
# [--pve 192.168.x.10 --vmid 101 --bridge vmbr1]
# 一键回滚 : bash iptv_toolkit_ubuntu2604.sh --router 192.168.x.1 --rollback --yes \
# [--pve 192.168.x.10 --vmid 101 --bridge vmbr1]
# ==============================================================================
set -euo pipefail
MODE=check ROUTER="" PVE="" VMID="" BRIDGE=vmbr1 VLAN=85 USER_NAME=root
IPTV_DEV=auto PORT=5140 SCAN_ALL=0 YES=0 NO_FCC=0
FCC_NET="124.75.25" FCC_ID="211" FCC_PORT="7777" # 上海电信 FCC 服务器(社区公开)
OUT_DIR="${HOME}/iptv-toolkit-$(date +%Y%m%d-%H%M%S)"
usage() { sed -n '3,14p' "$0"; exit "${1:-0}"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--router) ROUTER="$2"; shift 2 ;;
--pve) PVE="$2"; shift 2 ;;
--vmid) VMID="$2"; shift 2 ;;
--bridge) BRIDGE="$2"; shift 2 ;;
--vlan) VLAN="$2"; shift 2 ;;
--user) USER_NAME="$2"; shift 2 ;;
--iptv-dev) IPTV_DEV="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--no-fcc) NO_FCC=1; shift ;;
--scan-all) SCAN_ALL=1; shift ;;
--out) OUT_DIR="$2"; shift 2 ;;
--check) MODE=check; shift ;;
--apply) MODE=apply; shift ;;
--rollback) MODE=rollback; shift ;;
--yes) YES=1; shift ;;
-h|--help) usage 0 ;;
*) echo "unknown option: $1" >&2; usage 1 ;;
esac
done
[[ -n "$ROUTER" ]] || usage 1
[[ -z "$PVE" || -n "$VMID" ]] || { echo "--pve 需要同时指定 --vmid" >&2; exit 1; }
FCC="${FCC_NET}.${FCC_ID}:${FCC_PORT}"; [[ "$NO_FCC" == 1 ]] && FCC=""
c_ok=$'\e[32m' c_warn=$'\e[33m' c_err=$'\e[31m' c_info=$'\e[36m' c_off=$'\e[0m'
info() { printf '%s[local]%s %s\n' "$c_info" "$c_off" "$*"; }
ok() { printf '%s[local] OK%s %s\n' "$c_ok" "$c_off" "$*"; }
warn() { printf '%s[local] WARN%s %s\n' "$c_warn" "$c_off" "$*"; }
die() { printf '%s[local] FAIL%s %s\n' "$c_err" "$c_off" "$*" >&2; exit 1; }
mask() { sed -E 's/([0-9]+\.[0-9]+)\.[0-9]+\.[0-9]+/\1.x.x/g'; }
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=15)
r_ssh() { ssh "${SSH_OPTS[@]}" "${USER_NAME}@${ROUTER}" "$@"; }
p_ssh() { ssh "${SSH_OPTS[@]}" "root@${PVE}" "$@"; }
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PAYLOAD_FILE="${PAYLOAD_FILE:-$SCRIPT_DIR/iptv_router_payload.sh}"
[[ -f "$PAYLOAD_FILE" ]] || die "找不到 $PAYLOAD_FILE(请和本脚本放在同一目录)"
payload() { tr -d '\r' <"$PAYLOAD_FILE"; }
run_payload() { # $1 = mode, $2 = iptv dev
payload | r_ssh "MODE=$1 IPTV_DEV=$2 LISTEN_PORT=$PORT FCC='$FCC' SCAN_ALL=$SCAN_ALL sh -s"
}
router_devs() { r_ssh 'ls /sys/class/net' | grep -E '^eth[0-9]+$' | sort; }
pve_iptv_net() { # prints netN that sits on $BRIDGE with tag=$VLAN, if any
p_ssh "qm config $VMID" | sed -nE "s/^(net[0-9]+): .*bridge=${BRIDGE}(,.*)?,tag=${VLAN}(,.*)?$/\1/p" | head -n1
}
# 1. preflight
info "目标路由器: $(echo "$ROUTER" | mask) 模式: $MODE"
r_ssh true 2>/dev/null || die "SSH 免密登录路由器失败,请先执行: ssh-copy-id ${USER_NAME}@<路由器>"
ok "SSH 连通"
if [[ -n "$PVE" ]]; then
p_ssh "qm config $VMID >/dev/null" 2>/dev/null || die "PVE 上找不到 VM $VMID 或 SSH 失败"
ok "PVE 连通,VM $VMID 存在"
fi
case "$MODE" in
check)
run_payload check auto
[[ -n "$PVE" ]] && { n=$(pve_iptv_net || true); [[ -n "$n" ]] && ok "PVE 已有 IPTV 网卡: $n (tag=$VLAN)" || info "PVE 尚未添加 tag=$VLAN 网卡"; }
;;
apply)
mkdir -p "$OUT_DIR"
# 2. backup
r_ssh 'sysupgrade -b /tmp/iptv-pre.tgz >/dev/null 2>&1 && cat /tmp/iptv-pre.tgz && rm -f /tmp/iptv-pre.tgz' >"$OUT_DIR/router-backup.tgz"
[[ -s "$OUT_DIR/router-backup.tgz" ]] || die "配置备份失败"
ok "路由器配置已备份: $OUT_DIR/router-backup.tgz"
# 3. optional: add VLAN NIC on PVE
dev="$IPTV_DEV"
if [[ -n "$PVE" ]]; then
p_ssh "cp /etc/pve/qemu-server/$VMID.conf /root/$VMID.conf.bak-iptv-$(date +%Y%m%d%H%M%S)"
n=$(pve_iptv_net || true)
if [[ -n "$n" ]]; then
ok "PVE 已存在 IPTV 网卡 $n,跳过"
else
before=$(router_devs)
free=$(p_ssh "qm config $VMID" | awk -F: '/^net[0-9]+:/{print $1}' | sed 's/net//' | sort -n | awk 'BEGIN{n=0}{if($1==n)n++}END{print n}')
p_ssh "qm set $VMID --net$free virtio,bridge=$BRIDGE,tag=$VLAN,firewall=0" >/dev/null
ok "PVE 已热插拔 net$free (bridge=$BRIDGE, tag=$VLAN)"
for _ in $(seq 1 20); do
new=$(comm -13 <(echo "$before") <(router_devs) | head -n1)
[[ -n "$new" ]] && break; sleep 1
done
[[ -n "${new:-}" ]] || die "路由器没有识别到新网卡(虚拟机可能不支持热插拔,重启 VM 后再试)"
dev="$new"; ok "路由器识别到新网卡: $dev"
fi
fi
# 4-7. configure, install, scan, verify on the router
run_payload apply "$dev" | tee "$OUT_DIR/apply.log"
# local acceptance
m3u=$(curl -s -m 10 "http://${ROUTER}:${PORT}/playlist.m3u" || true)
n_ch=$(printf '%s\n' "$m3u" | grep -c '^http' || true)
[[ "$n_ch" -gt 0 ]] || die "本机取不到播放列表"
url=$(printf '%s\n' "$m3u" | awk '/^http/{print; exit}')
bytes=$(curl -s -m 5 -o /dev/null -w '%{size_download}' "$url" || true)
ok "本机验收: 播放列表 $n_ch 个频道,5 秒拉流 $((bytes / 1024)) KiB"
info "播放器填写: http://$(echo "$ROUTER" | mask):${PORT}/playlist.m3u (把 x.x 换成你的路由器地址)"
;;
rollback)
if [[ "$YES" != 1 ]]; then
read -r -p "确认回滚 IPTV 配置?[y/N] " a; [[ "$a" == y || "$a" == Y ]] || exit 0
fi
run_payload rollback auto
if [[ -n "$PVE" ]]; then
n=$(pve_iptv_net || true)
if [[ -n "$n" ]]; then p_ssh "qm set $VMID --delete $n" >/dev/null; ok "PVE 已移除 $n"; else info "PVE 上没有需要移除的 IPTV 网卡"; fi
fi
;;
esac
ok "完成"
4. macOS 27(iptv_toolkit_macos27.zsh)
纯 zsh 实现,不需要 Homebrew。和 Ubuntu 版的区别只在 zsh 语法细节上,例如 read 提示符的写法、脚本目录的取法。
#!/usr/bin/env zsh
# ==============================================================================
# iptv_toolkit_macos27.zsh
# 上海电信 IPTV -> OpenWrt/ImmortalWrt + rtp2httpd 一键配置(macOS 27 原生)
# 需与 iptv_router_payload.sh 放在同一目录。只依赖系统自带 zsh / ssh / curl,全部操作通过 SSH 完成,不访问任何第三方服务。
#
# 体检(只读): zsh iptv_toolkit_macos27.zsh --router 192.168.x.1
# 一键配置 : zsh iptv_toolkit_macos27.zsh --router 192.168.x.1 --apply \
# [--pve 192.168.x.10 --vmid 101 --bridge vmbr1]
# 一键回滚 : zsh iptv_toolkit_macos27.zsh --router 192.168.x.1 --rollback --yes \
# [--pve 192.168.x.10 --vmid 101 --bridge vmbr1]
# ==============================================================================
setopt errexit nounset pipefail
MODE=check ROUTER="" PVE="" VMID="" BRIDGE=vmbr1 VLAN=85 USER_NAME=root
IPTV_DEV=auto PORT=5140 SCAN_ALL=0 YES=0 NO_FCC=0
FCC_NET="124.75.25" FCC_ID="211" FCC_PORT="7777" # 上海电信 FCC 服务器(社区公开)
OUT_DIR="${HOME}/iptv-toolkit-$(date +%Y%m%d-%H%M%S)"
usage() { sed -n '3,14p' "$0"; exit "${1:-0}"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--router) ROUTER="$2"; shift 2 ;;
--pve) PVE="$2"; shift 2 ;;
--vmid) VMID="$2"; shift 2 ;;
--bridge) BRIDGE="$2"; shift 2 ;;
--vlan) VLAN="$2"; shift 2 ;;
--user) USER_NAME="$2"; shift 2 ;;
--iptv-dev) IPTV_DEV="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--no-fcc) NO_FCC=1; shift ;;
--scan-all) SCAN_ALL=1; shift ;;
--out) OUT_DIR="$2"; shift 2 ;;
--check) MODE=check; shift ;;
--apply) MODE=apply; shift ;;
--rollback) MODE=rollback; shift ;;
--yes) YES=1; shift ;;
-h|--help) usage 0 ;;
*) echo "unknown option: $1" >&2; usage 1 ;;
esac
done
[[ -n "$ROUTER" ]] || usage 1
[[ -z "$PVE" || -n "$VMID" ]] || { echo "--pve 需要同时指定 --vmid" >&2; exit 1; }
FCC="${FCC_NET}.${FCC_ID}:${FCC_PORT}"; [[ "$NO_FCC" == 1 ]] && FCC=""
c_ok=$'\e[32m' c_warn=$'\e[33m' c_err=$'\e[31m' c_info=$'\e[36m' c_off=$'\e[0m'
info() { printf '%s[local]%s %s\n' "$c_info" "$c_off" "$*"; }
ok() { printf '%s[local] OK%s %s\n' "$c_ok" "$c_off" "$*"; }
warn() { printf '%s[local] WARN%s %s\n' "$c_warn" "$c_off" "$*"; }
die() { printf '%s[local] FAIL%s %s\n' "$c_err" "$c_off" "$*" >&2; exit 1; }
mask() { sed -E 's/([0-9]+\.[0-9]+)\.[0-9]+\.[0-9]+/\1.x.x/g'; }
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=15)
r_ssh() { ssh "${SSH_OPTS[@]}" "${USER_NAME}@${ROUTER}" "$@"; }
p_ssh() { ssh "${SSH_OPTS[@]}" "root@${PVE}" "$@"; }
SCRIPT_DIR="${0:A:h}"
PAYLOAD_FILE="${PAYLOAD_FILE:-$SCRIPT_DIR/iptv_router_payload.sh}"
[[ -f "$PAYLOAD_FILE" ]] || die "找不到 $PAYLOAD_FILE(请和本脚本放在同一目录)"
payload() { tr -d '\r' <"$PAYLOAD_FILE"; }
run_payload() { # $1 = mode, $2 = iptv dev
payload | r_ssh "MODE=$1 IPTV_DEV=$2 LISTEN_PORT=$PORT FCC='$FCC' SCAN_ALL=$SCAN_ALL sh -s"
}
router_devs() { r_ssh 'ls /sys/class/net' | grep -E '^eth[0-9]+$' | sort; }
pve_iptv_net() { # prints netN that sits on $BRIDGE with tag=$VLAN, if any
p_ssh "qm config $VMID" | sed -nE "s/^(net[0-9]+): .*bridge=${BRIDGE}(,.*)?,tag=${VLAN}(,.*)?$/\1/p" | head -n1
}
# 1. preflight
info "目标路由器: $(echo "$ROUTER" | mask) 模式: $MODE"
r_ssh true 2>/dev/null || die "SSH 免密登录路由器失败,请先执行: ssh-copy-id ${USER_NAME}@<路由器>"
ok "SSH 连通"
if [[ -n "$PVE" ]]; then
p_ssh "qm config $VMID >/dev/null" 2>/dev/null || die "PVE 上找不到 VM $VMID 或 SSH 失败"
ok "PVE 连通,VM $VMID 存在"
fi
case "$MODE" in
check)
run_payload check auto
[[ -n "$PVE" ]] && { n=$(pve_iptv_net || true); [[ -n "$n" ]] && ok "PVE 已有 IPTV 网卡: $n (tag=$VLAN)" || info "PVE 尚未添加 tag=$VLAN 网卡"; }
;;
apply)
mkdir -p "$OUT_DIR"
# 2. backup
r_ssh 'sysupgrade -b /tmp/iptv-pre.tgz >/dev/null 2>&1 && cat /tmp/iptv-pre.tgz && rm -f /tmp/iptv-pre.tgz' >"$OUT_DIR/router-backup.tgz"
[[ -s "$OUT_DIR/router-backup.tgz" ]] || die "配置备份失败"
ok "路由器配置已备份: $OUT_DIR/router-backup.tgz"
# 3. optional: add VLAN NIC on PVE
dev="$IPTV_DEV"
if [[ -n "$PVE" ]]; then
p_ssh "cp /etc/pve/qemu-server/$VMID.conf /root/$VMID.conf.bak-iptv-$(date +%Y%m%d%H%M%S)"
n=$(pve_iptv_net || true)
if [[ -n "$n" ]]; then
ok "PVE 已存在 IPTV 网卡 $n,跳过"
else
before=$(router_devs)
free=$(p_ssh "qm config $VMID" | awk -F: '/^net[0-9]+:/{print $1}' | sed 's/net//' | sort -n | awk 'BEGIN{n=0}{if($1==n)n++}END{print n}')
p_ssh "qm set $VMID --net$free virtio,bridge=$BRIDGE,tag=$VLAN,firewall=0" >/dev/null
ok "PVE 已热插拔 net$free (bridge=$BRIDGE, tag=$VLAN)"
for i in $(seq 1 20); do
new=$(comm -13 <(echo "$before") <(router_devs) | head -n1)
[[ -n "$new" ]] && break; sleep 1
done
[[ -n "${new:-}" ]] || die "路由器没有识别到新网卡(虚拟机可能不支持热插拔,重启 VM 后再试)"
dev="$new"; ok "路由器识别到新网卡: $dev"
fi
fi
# 4-7. configure, install, scan, verify on the router
run_payload apply "$dev" | tee "$OUT_DIR/apply.log"
# local acceptance
m3u=$(curl -s -m 10 "http://${ROUTER}:${PORT}/playlist.m3u" || true)
n_ch=$(printf '%s\n' "$m3u" | grep -c '^http' || true)
[[ "$n_ch" -gt 0 ]] || die "本机取不到播放列表"
url=$(printf '%s\n' "$m3u" | awk '/^http/{print; exit}')
bytes=$(curl -s -m 5 -o /dev/null -w '%{size_download}' "$url" || true)
ok "本机验收: 播放列表 $n_ch 个频道,5 秒拉流 $((bytes / 1024)) KiB"
info "播放器填写: http://$(echo "$ROUTER" | mask):${PORT}/playlist.m3u (把 x.x 换成你的路由器地址)"
;;
rollback)
if [[ "$YES" != 1 ]]; then
read -r "a?确认回滚 IPTV 配置?[y/N] "; [[ "$a" == y || "$a" == Y ]] || exit 0
fi
run_payload rollback auto
if [[ -n "$PVE" ]]; then
n=$(pve_iptv_net || true)
if [[ -n "$n" ]]; then p_ssh "qm set $VMID --delete $n" >/dev/null; ok "PVE 已移除 $n"; else info "PVE 上没有需要移除的 IPTV 网卡"; fi
fi
;;
esac
ok "完成"
九、人工执行与 Agent 自动配置:两种用法
方法 1:人工执行
- 准备 SSH 免密登录:把本机公钥加入路由器的 /etc/dropbear/authorized_keys;如果要让脚本自动加 PVE 网卡,也要能免密登录 PVE 宿主机。
- 下载四个文件,放在同一个目录。
- 先只读体检,确认能连上、能找到网卡:
# Windows 11(PowerShell)
powershell -ExecutionPolicy Bypass -File .\iptv_toolkit_windows11.ps1 -Router 192.168.x.1
# Ubuntu 26.04
bash iptv_toolkit_ubuntu2604.sh --router 192.168.x.1
# macOS 27
zsh iptv_toolkit_macos27.zsh --router 192.168.x.1
- 一键配置(带上 PVE 参数,网卡也会自动加好):
# Windows 11
powershell -ExecutionPolicy Bypass -File .\iptv_toolkit_windows11.ps1 -Router 192.168.x.1 -Mode apply -Pve 192.168.x.10 -VmId 101 -Bridge vmbr1
# Ubuntu 26.04
bash iptv_toolkit_ubuntu2604.sh --router 192.168.x.1 --apply --pve 192.168.x.10 --vmid 101 --bridge vmbr1
# macOS 27
zsh iptv_toolkit_macos27.zsh --router 192.168.x.1 --apply --pve 192.168.x.10 --vmid 101 --bridge vmbr1
- 看到“本机验收:播放列表 N 个频道”后,把 http://旁路由地址:5140/playlist.m3u 填进播放器即可。
- 想撤销时,把
--apply换成--rollback --yes(Windows 为-Mode rollback -Yes)。
方法 2:交给 AI Agent 自动配置
如果你用 Claude Code、Codex 这类终端 Agent,可以把下面这段护栏指令连同四个脚本一起交给它。重点在于:只读优先、先备份、全程脱敏、按指标验收、失败就回滚。
给 Agent 的任务模板:
你是家庭网络运维助手,目标是让上海电信 IPTV 通过 rtp2httpd 在局域网内可看。请严格遵守:
- 脱敏:任何输出、日志、总结中不得出现完整的 IP 地址、MAC 地址、主机名、宽带账号和密码,IP 一律写成 192.168.x.x 这种形式。
- 只读优先:先运行 check 模式;在我确认之前,不得执行 apply 或 rollback。
- 备份先行:apply 前确认脚本已生成 router-backup.tgz,并告诉我备份路径。
- 不碰上网链路:不得修改主路由、光猫和局域网的默认路由与 DNS;如果 apply 后默认路由发生变化,立即执行 rollback。
- 验收指标:iptv 接口拿到专网地址;可用频道数大于 100;本机 5 秒拉流大于 1 MB;默认路由不变。任一不满足就停止并汇报。
- 交付:用表格汇报模式、网卡名、可用频道数、拉流字节数、备份路径,以及回滚命令。
我自己的这次改造,其实就是这样和 Agent 协作完成的:Agent 负责探测、抓包、扫描和写脚本;凡是改配置的步骤,都先给出方案和回滚手段,等我确认后再执行。
十、常见问题 Q&A
Q1:我没有 PVE,用的是实体软路由或者普通路由器,能照做吗?
能。核心只有两件事:让路由器在 VLAN 85 上拿到专网地址,再装 rtp2httpd。实体软路由可以直接在 WAN 口上建 VLAN 子接口(例如 eth1.85),把脚本参数换成 --iptv-dev eth1.85;不需要 PVE 那一步。
Q2:我的光猫只从标着 ITV 的口单独送 IPTV,而且不带 VLAN 标签,怎么办?
那就更简单了:从 ITV 口拉一根网线到路由器的空闲网口,iptv 接口直接使用这个网口,不需要打标签。先用 check 模式确认能识别到这块网卡。
Q3:一定不需要机顶盒账号和改光猫吗?
我家实测不需要:VLAN 85 上的 DHCP 不带任何认证就能拿到地址。但不同小区、不同光猫型号可能不一样。如果你发 DHCP 请求一直没有回应,可能需要按社区教程模拟机顶盒的 option 60/125 报文,或者请装维师傅把光猫的 IPTV 业务调整为透传。
Q4:Wi-Fi 下看会不会把网络拖垮?
不会。rtp2httpd 输出的是普通的 HTTP 单播流,一路高清约 8~10 Mbps,4K 约 30~37 Mbps,和看网络视频没有区别。组播只存在于旁路由的 eth1 上,不会进入局域网。
Q5:能回看和时移吗?
本文只覆盖直播。回看和时移走的是 RTSP 单播,需要机顶盒鉴权拿到的地址。GitHub 上的 nmdcli/shanghai-ct-iptv 项目可以在提供机顶盒账号、SN、MAC 的前提下抓取回看地址,rtp2httpd 也支持 RTSP 转发,感兴趣可以继续折腾。
Q6:频道有增减怎么办?
重新执行一次 --apply 即可,脚本是幂等的,会重新扫描并生成播放列表。想自己发现新频道,可以加 --scan-all,脚本会扫描整个 233.18.204.x 网段,新频道会以“频道 N”命名。
Q7:能在外面用手机看家里的 IPTV 吗?
不建议把 5140 端口直接暴露到公网:一是安全风险,二是 IPTV 节目的版权授权通常仅限家庭使用。确实需要的话,请通过 WireGuard 之类的 VPN 回家观看,仅供自己家庭使用。
Q8:iKuai 自带 IGMP 代理和 IPTV 功能,为什么不直接用?
我的 iKuai 3.7.20 只有 IGMP 代理,没有 udpxy。如果把组播直接代理进局域网,家里交换机和 Wi-Fi 很可能被组播冲击。另外,iKuai 是主路由,改它的风险比改旁路由大得多。把 IPTV 放在旁路由上,最坏的情况也只是删掉一块虚拟网卡。
Q9:重刷旁路由固件后,配置还在吗?
用 sysupgrade 升级时会保留:脚本已经把钩子脚本和 /etc/rtp2httpd/ 加进了 /etc/sysupgrade.conf。如果是彻底重装,重新跑一遍 --apply 就行,大约两分钟。
十一、总结
回头看,这次改造最大的收获不是“又多了 112 个频道”,而是一套可以复用的方法:
- 先拿证据,再下结论:抓包安静不代表没开通,主动发一个 DHCP 请求才知道 85 号车道上有人;
- 最小改动:上网链路一个字不改,只加一块虚拟网卡和一个服务,所有改动都能一键回滚;
- 安全隔离:iptv 单独一个防火墙区域,不抢默认路由,不用专网 DNS,服务只监听局域网;
- 用数据验收:零丢包、码率稳定、默认路由不变,比“页面能打开”可靠得多;
- 把经验写进脚本:并发扫描漏扫、inactive.route、管道提前关闭导致脚本静默退出,这些坑都已经在脚本里处理好。
如果你家也开着 IPTV 却一直没用,不妨先跑一遍只读体检,说不定那条“85 号车道”早就在你家网线上等着了。
参考资料
- ihipop/Shanghai-IPTV(上海电信组播频道表,频道数据来源):https://github.com/ihipop/Shanghai-IPTV
- stackia/rtp2httpd(组播 RTP 转 HTTP 服务):https://github.com/stackia/rtp2httpd
- nmdcli/shanghai-ct-iptv(上海电信 IPTV 频道、节目单、回看抓取):https://github.com/nmdcli/shanghai-ct-iptv
- 上海电信 2.5G SDN 光猫桥接并正常观看 IPTV 的配置思路(哔哩哔哩):https://www.bilibili.com/opus/1083392168879456277
- 使用 OpenWrt 观看上海电信 IPTV 的保姆级教程(什么值得买):https://post.smzdm.com/p/61886/
- OpenWrt 官方文档:VLAN:https://openwrt.org/docs/guide-user/network/vlan/start
- IEEE 802.1Q(VLAN 标准)概述:https://en.wikipedia.org/wiki/IEEE_802.1Q
- RFC 3376:IGMPv3:https://www.rfc-editor.org/rfc/rfc3376