中文 English

办宽带送的 IPTV 吃灰两年?一根“虚拟网线”让全家手机电视看上 112 个直播台(上海电信实测)

发布时间: 2026-10-17 · 阅读量 --
网络 network 路由器 router 软路由 soft-router ImmortalWrt OpenWrt pve Proxmox VE iptv 自动化 Automation

先说结论

你家光猫很可能早就把 IPTV 专网“透传”到了网线上,只是没人去接。我家就是这样:不改光猫、不要机顶盒账号、不碰主路由,只在 PVE 上给旁路由多插了一块带 VLAN 85 标签的虚拟网卡,再装一个叫 rtp2httpd 的小服务,全家的手机、电视、电脑就都能看上海电信 IPTV 的 112 个直播频道,换台首帧只要 0.04 秒。

本文会用“快递分拣”“电台广播”“打车追公交”这些生活里的例子把 VLAN、组播、FCC 讲明白,给出每一步的实测证据和真实截图,最后奉上 Windows 11 / Ubuntu 26.04 / macOS 27 三平台一键脚本(实测可一键配置、一键回滚),以及交给 AI Agent 自动完成的护栏模板。

封面:宽带送的电视别再吃灰了

图 1:本文自制封面。一根光纤进户,经过旁路由“转接”,把电信组播直播变成全家设备都能看的 HTTP 流。


一、问题背景:每个月都在交钱的电视,一直在吃灰

办上海电信宽带时,营业厅顺手给我开了数字电视(IPTV),还送了一台机顶盒。机顶盒用了没几天就进了抽屉:遥控器难用,开机慢,而且只能在客厅那台电视上看。

我家的网络是一套典型的“家庭机房”结构:

我的需求很朴素:既然 IPTV 已经开通,能不能不用机顶盒,让家里的手机、平板、电视盒子、电脑都能直接看直播?最好不要动现有上网链路,出了问题也能一键还原。

改造前后拓扑对比

图 2:改造前后的拓扑。灰色是原有上网链路,完全不动;绿色是这次新增的一块虚拟网卡和一个服务;紫色是家里设备最终的观看方式。


二、问题表现:想用起来,却不知道从哪下手

真正动手前,我遇到的困惑其实比技术问题更多:

  1. 网上的教程五花八门:有的说要在光猫里改 VLAN,有的说要抓机顶盒的 DHCP option 60/125 报文做认证,有的说要先拿到光猫超级密码,还有的说要买支持 IPTV 的交换机。
  2. VLAN 号说法不一:有人说上海电信 IPTV 是 85,有人说组播在 51,还有帖子提到 45。
  3. iKuai 自带 IGMP 代理,但我这台 iKuai 3.7 没有 udpxy;如果让组播直接灌进局域网,没开 IGMP Snooping 的交换机和 Wi-Fi 可能会被冲垮。
  4. 我连“光猫到底有没有把 IPTV 送到我这根网线上”都不知道。

所以这次我给自己定了一个原则:先拿证据,再动手;能只读就不改;每一步都要可回滚。


三、先把概念讲明白:三个生活里的比方

1. VLAN:一条马路,分好几条车道

VLAN 车道比喻

图 3:同一根光纤上跑着不同“车道”的流量。我家实测:无标签车道是上网,85 号车道是 IPTV 专网和直播组播,51、45 号车道空空如也。

想象一辆快递车(网线)一趟要给小区里好几栋楼送包裹。为了不送错,每个包裹上都写着楼号(VLAN 标签),车开到小区门口,门卫按楼号分拣,互不串门。

专业一点说:802.1Q 标准会在以太网帧里插入一个 4 字节的“标签”,标签里有一个 0~4095 的编号。光猫把上网流量和 IPTV 流量用不同的编号区分,再从同一个网口送出来。只要我们的设备“认得 85 号楼”,就能把 IPTV 包裹单独接住。

2. 组播:打电话和电台广播的区别

单播、组播和 rtp2httpd

图 4:单播像打电话,组播像电台广播,rtp2httpd 像家里的“收音转接员”。下方是真实的数据链路。

所以需要一个“转接员”:它待在路由器上,替大家收听电台,再用 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:排查证据链。每一步都有实测数据支撑,先证明能拿到,再证明能收到,最后证明收得稳。


五、问题根因:不是没开通,而是缺一个“接收器 + 转换器”

把证据串起来,根因就很清楚了:

  1. 光猫早已把 IPTV 业务以 VLAN 85 的形式透传到网线上,和上网流量共用一根线;
  2. 家里没有任何设备在 85 号车道上“接货”,所以 IPTV 一直处于开通但闲置的状态;
  3. 就算接住了,组播也不适合直接放进家里的交换机和 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 网卡支持热插拔,旁路由不需要重启,全家网络一秒都没断。

PVE 硬件页真实截图

图 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,后果会更难排查。所以这两个开关一个都不能少。

LuCI 接口页真实截图

图 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 快速换台走对路

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

rtp2httpd 配置页真实截图

图 11:LuCI 中的 rtp2httpd 配置页真实截图(已脱敏)。绑定地址只填局域网 IP,上游接口选 eth1。

频道列表里的地址写成 rtp://组播地址:5140?fcc=FCC服务器:7777。rtp2httpd 会自动把它改写成局域网可访问的 HTTP 地址,并对外提供:

第 6 步:验收

验收时我没有只看“页面能打开”,而是用数据说话:

rtp2httpd 状态页真实截图

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

网页播放器真实截图

图 13:浏览器直接打开 /player 播放 CCTV9 纪录频道的真实截图。右侧是 112 个频道和分组,节目单信息也能正常显示。


七、番外:为什么网页播放器有点卡,VLC 却很流畅?

配置完成后,我用浏览器打开 /player,发现播放时不时会一顿一顿。第一反应是网络问题,但证据并不支持:同一时刻用 curl 拉流 30 秒,零丢包,码率稳定。

于是我把网页播放器的代码翻出来看了看,原因找到了:

网页播放器与本地播放器的解码管线对比

图 14:网页播放器的每一步都在网页 JavaScript 里用 CPU 计算,本地播放器则由显卡和系统解码器负责。

  1. 电信频道的音频是 MP2 格式,浏览器原生不支持,播放器只能用 WebAssembly 软件解码;
  2. 视频是 1080i 隔行扫描,播放器默认开着“自动反交错”和“画质增强”,这两项都要在网页里逐帧计算;
  3. 播放器默认把延迟控制在 1.5 秒左右,落后了就用 1.2 倍速追赶。缓冲区一紧张,画面就会时快时慢。

打个比方:网页播放器像用手机计算器算一整天的账,本地播放器像用专业收银机,都能算,但快慢差很多。

解决办法:


八、一键全自动脚本:Windows 11 / Ubuntu 26.04 / macOS 27

把上面的手工步骤固化成脚本后,整个过程就是一条命令。三个平台的脚本逻辑完全一致,全部操作都通过 SSH 完成,不依赖任何第三方服务。频道表内置在脚本里,只记录组播地址的最后一段。

一键脚本流水线

图 15:一键脚本的 8 步流水线。默认只读体检,加 --apply 才改配置,--rollback 一键还原。

脚本特性:

我在一台一次性的 ImmortalWrt 24.10.6 测试虚拟机上,把三个平台的脚本都从零跑过一遍:配置、重复配置、回滚,每次都 112/112 个频道可用,回滚后路由器和 PVE 都恢复原状。测试完这台虚拟机已经销毁。

一键脚本真实运行截图

图 16:Ubuntu 上运行一键脚本的真实终端输出(地址已由脚本自动打码)。先只读体检,再一键配置,最后本机验收 112 个频道。

下载

四个文件需要放在同一个目录:

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:人工执行

  1. 准备 SSH 免密登录:把本机公钥加入路由器的 /etc/dropbear/authorized_keys;如果要让脚本自动加 PVE 网卡,也要能免密登录 PVE 宿主机。
  2. 下载四个文件,放在同一个目录。
  3. 先只读体检,确认能连上、能找到网卡:
# 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
  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
  1. 看到“本机验收:播放列表 N 个频道”后,把 http://旁路由地址:5140/playlist.m3u 填进播放器即可。
  2. 想撤销时,把 --apply 换成 --rollback --yes(Windows 为 -Mode rollback -Yes)。

方法 2:交给 AI Agent 自动配置

如果你用 Claude Code、Codex 这类终端 Agent,可以把下面这段护栏指令连同四个脚本一起交给它。重点在于:只读优先、先备份、全程脱敏、按指标验收、失败就回滚。

给 Agent 的任务模板:

你是家庭网络运维助手,目标是让上海电信 IPTV 通过 rtp2httpd 在局域网内可看。请严格遵守:

  1. 脱敏:任何输出、日志、总结中不得出现完整的 IP 地址、MAC 地址、主机名、宽带账号和密码,IP 一律写成 192.168.x.x 这种形式。
  2. 只读优先:先运行 check 模式;在我确认之前,不得执行 apply 或 rollback。
  3. 备份先行:apply 前确认脚本已生成 router-backup.tgz,并告诉我备份路径。
  4. 不碰上网链路:不得修改主路由、光猫和局域网的默认路由与 DNS;如果 apply 后默认路由发生变化,立即执行 rollback。
  5. 验收指标:iptv 接口拿到专网地址;可用频道数大于 100;本机 5 秒拉流大于 1 MB;默认路由不变。任一不满足就停止并汇报。
  6. 交付:用表格汇报模式、网卡名、可用频道数、拉流字节数、备份路径,以及回滚命令。

我自己的这次改造,其实就是这样和 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 个频道”,而是一套可以复用的方法:

  1. 先拿证据,再下结论:抓包安静不代表没开通,主动发一个 DHCP 请求才知道 85 号车道上有人;
  2. 最小改动:上网链路一个字不改,只加一块虚拟网卡和一个服务,所有改动都能一键回滚;
  3. 安全隔离:iptv 单独一个防火墙区域,不抢默认路由,不用专网 DNS,服务只监听局域网;
  4. 用数据验收:零丢包、码率稳定、默认路由不变,比“页面能打开”可靠得多;
  5. 把经验写进脚本:并发扫描漏扫、inactive.route、管道提前关闭导致脚本静默退出,这些坑都已经在脚本里处理好。

如果你家也开着 IPTV 却一直没用,不妨先跑一遍只读体检,说不定那条“85 号车道”早就在你家网线上等着了。


参考资料

评论

使用 GitHub 登录后即可评论,中英文版本共用同一讨论。 在 GitHub 上参与讨论

本文阅读量 --