一、问题:令牌为什么会让直播"突然全挂"
上一篇产出的频道地址长这样:
rtsp://27.148.240.185/PLTV/.../xxx.smil?accountinfo=%7E%7EV2.0%7E...%7E...:20261005120930,10500002078800,27.152.x.x,...&...
accountinfo 是电信华为中间件签发的时效性令牌,解码后能看到三个关键字段:签发时间戳、绑定出口 IP、频道资产 ID。这意味着地址失效有三种方式:
失效的表现很统一:所有频道同时 403,而不是个别频道。上一篇文章里我只有一条路——重新抓一次机顶盒开机流量。显然不能接受,家庭服务必须自愈。
二、逆向结论:为什么"免盒子续期"是可能的
对认证链做过完整逆向(细节够单独写一篇,这里只给结论):
- 认证链四步:
AuthenticationURL?Action=Login→authLoginHWCTC→ValidAuthenticationHWCTC(提交 128 位 hex 的 Authenticator)→getchannellistHWCTC(凭 UserToken + JSESSIONID 拉全量频道表)。 - 令牌是开机时批量下发的:盒子认证成功后,
getchannellistHWCTC.jsp一次性返回全部 235 个频道的播放地址(gzip + chunked,约 700KB)。做流量分析时记得把抓包起点放在盒子断电重启之前,否则会错过这条关键响应。 - 服务端不校验凭证新鲜度:把抓包获得的
Authenticator+tempKey原样重放,三步认证照样通过,拿到全新令牌。也就是说——抓到一次凭证,等于拿到一张长期饭票。这是本文方案的成立前提,已实测多轮续期稳定通过。
有了第 3 条,续期就退化成一个纯 HTTP 流程:任何设备(包括路由器自己)发三个 POST,10 秒拿到全频道新令牌,机顶盒从此可以进抽屉了。
三、方案设计
关键能力:rtp2httpd 原生支持配置热重载
更新频道配置不需要重启服务。rtp2httpd 3.x(stackia/rtp2httpd,面向国内 IPTV 环境的现代化重写版)收到 SIGHUP 信号时会重新解析配置文件、刷新频道表,正在播放的流完全不受影响。部署前可以在自己的二进制里确认这个行为:
strings /usr/bin/rtp2httpd | grep -i reload
# Only reload M3U/EPG, skipping configuration reload (config file path not set)
# Configuration reloaded successfully from %s
于是整个链路里最"重"的环节被消掉了:容器覆写 /etc/rtp2httpd.conf → 向宿主机进程发 SIGHUP → 频道表热替换(实测重载前后进程 PID 不变,播放无感知)。SIGHUP 的信号语义见 signal(7) man page。
续期触发条件(三层兜底)
每 5 分钟一轮检查:
├─ ① 出口 IP ≠ 上次续期时的 IP → 续期(IP 漂移,令牌已死)
├─ ② 距上次续期 > 24h → 续期(定时兜底,防服务端会话老化)
└─ ③ 试播 CCTV-1 连续 2 次失败 → 续期(自愈:不管什么原因,重认证准没错)
其中 ①② 是预防,③ 是治疗——即使前两条都没料到的原因导致失效,试播失败也会触发自愈,这就是"无感"的最后一道保险。
四、部署步骤
4.1 目录结构
/root/iptv/
├── docker-compose.yml
├── renewer/
│ ├── Dockerfile
│ └── renewer.py # 续期核心逻辑(附录有完整版)
└── data/ # 产物: state.json + 三份 m3u
4.2 准备工作
# 备份现有配置(容器会接管这个文件)
cp /etc/rtp2httpd.conf /etc/rtp2httpd.conf.bak-$(date +%F)
mkdir -p /root/iptv/renewer /root/iptv/data
4.3 拉取基础镜像(国内加速)
国内网络环境建议通过镜像加速站获取官方 python:3.12-alpine 镜像(Docker Hub 官方镜像页):
docker pull docker.1ms.run/library/python:3.12-alpine
docker tag docker.1ms.run/library/python:3.12-alpine python:3.12-alpine
4.4 Dockerfile
基础镜像内置 apk 与 pip 均切换到 清华大学 TUNA 镜像源(Alpine 源配置说明 / PyPI 源配置说明),构建阶段全部走国内网络:
FROM python:3.12-alpine
# 国内源: alpine apk + pypi
RUN sed -i 's#https://dl-cdn.alpinelinux.org#https://mirrors.tuna.tsinghua.edu.cn#g' /etc/apk/repositories \
&& apk add --no-cache tzdata \
&& pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple requests
WORKDIR /app
COPY renewer.py .
CMD ["python", "-u", "renewer.py"]
4.5 docker-compose.yml
services:
renewer:
build: ./renewer
container_name: iptv-renewer
restart: unless-stopped
network_mode: host # 关键: 与机顶盒同出口 IP, 认证才会被接受
pid: host # 关键: 让容器看到宿主机进程, 才能发 SIGHUP
privileged: true # 配合 pid:host, 允许向宿主进程发信号
environment:
TZ: Asia/Shanghai
CHECK_INTERVAL: "300" # 每 5 分钟检查一轮
RENEW_MAX_AGE_H: "24" # 定时兜底周期
STREAM_CHECK_URL: "http://127.0.0.1:5140/%E5%A4%AE%E8%A7%86/CCTV-1"
HTTP_M3U_BASE: "http://10.0.0.1:5140"
volumes:
- ./data:/data
# 单文件挂载, 容器内原地覆写(同 inode), 不破坏挂载
- /etc/rtp2httpd.conf:/conf/rtp2httpd.conf
三个关键配置都在这里了(完整字段语义见 Docker Compose 文档 与 docker run 运行时参考):
network_mode: host不能省(host 网络驱动文档)。令牌绑定 CGNAT 出口 IP,容器必须和机顶盒走同一条出口,认证才会成功;pid: host+privileged是为了发 SIGHUP。容器扫描/proc找到宿主机 rtp2httpd 的 master 进程(最早启动的那个),向它发信号;- conf 是单文件 bind mount,容器内必须原地覆写(
open(path,'w')截断写,保持同一 inode),如果用"写临时文件再 rename"的方式会把挂载关系写断。
4.6 启动与验证
cd /root/iptv && docker compose up -d --build
# 看首轮续期日志
docker logs -f iptv-renewer
我的首轮输出:
[12:35:36] iptv-renewer 启动: 检查间隔 300s, 最长续期周期 24.0h
[12:35:36] 需要续期: 出口IP漂移: None -> 27.152.x.x
[12:35:36] ===== 开始续期 (出口IP=27.152.x.x) =====
[12:35:36] 认证成功: UserToken=MyumOM****DscD
[12:35:36] 频道列表: HTTP 200, 698892B
[12:35:36] 解析频道: 235
[12:35:36] 已写入 /conf/rtp2httpd.conf (116 RTSP 频道)
[12:35:36] 已向 rtp2httpd (pid=13154) 发送 SIGHUP 热重载
[12:35:38] 试播验证: 成功 (65536B)
[12:35:38] HLS 频道: 103
[12:35:38] ===== 续期完成, 试播正常 =====
5 分钟后的稳态轮:
[12:40:39] 试播验证: 成功 (65536B)
[12:40:39] 令牌有效 (IP=27.152.x.x, 已 0.1h), 跳过
再从任意一台局域网设备实测:
curl -o /dev/null -w "%{http_code} %{size_download}B\n" \
"http://10.0.0.1:5140/%E5%A4%AE%E8%A7%86/CCTV-1" --max-time 8
# 200 262144B ✅ 全家设备的播放地址, 一个字都没改过
五、续期器内部逻辑(renewer.py 核心)
完整脚本在附录,这里只讲三个值得说的实现细节。
1. SIGHUP 只发给 master。 rtp2httpd 是多进程(master + workers),源码里有 "block SIGHUP before forking worker" 的逻辑,说明 worker 收到 SIGHUP 会死。用 /proc/*/stat 里的进程启动时间挑出最早的那个(master)再发:
def find_master_pid():
best, best_start = None, None
for d in os.listdir('/proc'):
if not d.isdigit():
continue
try:
with open(f'/proc/{d}/comm') as f:
if f.read().strip() != 'rtp2httpd':
continue
with open(f'/proc/{d}/stat') as f:
start = int(f.read().split(') ', 1)[1].split()[19])
if best_start is None or start < best_start:
best, best_start = int(d), start
except (OSError, IndexError, ValueError):
continue
return best # 取启动最早的 = master
2. RTSP 和 HLS 是两组不相交的频道。 频道表里 235 个频道中,ChannelSDP 为 RTSP 的有 125 个、ChannelURL 为 HLS 的有 110 个,两者不是同一频道的两种格式,而是两批不同的频道。所以产物是两份独立 m3u(116 个 RTSP 进 rtp2httpd 配置,103 个 HLS 单独一份),而不是"一个频道双字段"。
3. 探活就是最诚实的健康检查。 与其猜测令牌还活不活着,不如每 5 分钟拉 64KB 真流——rtp2httpd 会拿着当前配置里的令牌去做一次真实的 RTSP SETUP,服务端说行就是行。这个探活同时把 rtp2httpd 自身的健康也测了。
六、FAQ
Q: 更新配置真的不断流吗? A: 实测热重载前后 master PID 不变,正在播放的流不受影响。SIGHUP 只刷新频道表。
Q: 容器要特权模式,安全吗?
A: privileged + pid:host 确实权限很大,但这个容器里只跑我们自己写的 100 来行 Python,且它本来就要给宿主机进程发信号。在意的话可以改用 SSH 回环方案(容器内 ssh 到宿主机执行 restart),代价是多维护一套密钥。
Q: IP 探测服务怎么选?
A: 优先使用国内可直达的 echo 服务,脚本内置了多个源自动降级:members.3322.org/dyndns/getip、myip.ipip.net、ip.3322.net。解析时用正则提取文本中的首个 IPv4 即可(myip.ipip.net 返回的是带中文的句子)。
Q: 构建或拉取镜像很慢怎么办?
A: 按文中配置即可:基础镜像走 docker.1ms.run 加速站拉取,Dockerfile 内 apk/pip 已切换到 TUNA 国内源,构建全程不需要访问境外源。
Q: 凭证会失效吗?
A: Authenticator 是盒子开机时固件算出来的,重放目前长期有效。如果哪天运营商升级校验逻辑(校验新鲜度/设备指纹),日志会持续报"续期失败"——到时候重新抓一次开机包、更新脚本里的凭证常量即可。这是整套方案里唯一可能需要人工干预的场景。
Q: OpenWrt 上不用 Docker 行不行?
A: 行,opkg install python3-light 然后把 renewer.py 改成纯标准库(requests 换 urllib)跑 crontab 也可以。用 Docker 主要是图依赖隔离和管理方便(iStore 面板能直接看日志/重启)。
Q: 令牌过期瞬间,正在看的节目会断吗? A: 令牌失效影响的是"新发起的播放",已经在播的流通常能继续到自然结束。最坏情况是切换频道失败几十秒——正好是自愈续期一轮的时间。
七、总结
整个系统现在的形态:
机顶盒(已收进抽屉)
┌─ 每5min: IP探测 + 试播探活
电信 EPG ◄──────┤─ 触发时: 三步重放认证 → 拉全量频道表
└─ 更新 conf → SIGHUP 热重载(不断流) → 试播验证
│
OpenWrt rtp2httpd :5140 │ ← 令牌只活在这一段
▼
全屋设备 http://路由器:5140/分组/频道名 ← 永远不变
从"每隔几天手动抓包续命",到"路由器上一个 20MB 的容器无声值守",这大概是这套自建 IPTV 链路的最终形态了。剩下唯一可能动手的场景:运营商哪天修掉这个重放漏洞——但那是另一个故事了。
参考资料
附录:完整脚本
附录 A:renewer.py(凭证已脱敏)
# -*- coding: utf-8 -*-
"""IPTV 令牌自动续期容器 (免盒子, 24小时无感)
运行于 OpenWrt 路由器 Docker, host 网络 (与机顶盒同 CGNAT 出口)。
触发条件(满足其一):
1. 出口公网 IP 与上次续期时不一致 (令牌绑定出口 IP, 漂移即失效)
2. 距上次续期超过 RENEW_MAX_AGE_H 小时 (定时兜底)
3. 连续两次试播失败 (自愈)
动作: 三步重放认证 -> 拉取全量频道 -> 原地覆写 /etc/rtp2httpd.conf
-> SIGHUP 热重载 rtp2httpd (不断流) -> 试播验证 -> 产出 m3u
"""
import gzip
import json
import os
import re
import signal
import sys
import time
import zlib
from urllib.parse import quote
import requests
# ============ 设备身份凭证 (来自抓包, 长期有效) ============
HOST = 'http://27.148.x.x:33200' # EPG 服务器
USERID = '<你的UserID>' # 抓包中 UserID= 参数
AUTHENTICATOR = '<128位hex,抓包获得>' # ValidAuthenticationHWCTC 提交的凭证
TEMPKEY = '<32位hex,抓包获得>' # userToken/tempKey
STB = dict(STBType='EC6108V9U_pub_fjmdx', STBVersion='19.2.0-LFJD04.B015',
conntype='2', STBID='<盒子的STBID>',
templateName='StbEpg2023Group', areaId='1111',
userGroupId='<你的userGroupId>', mac='<盒子MAC>',
UserField='2', SoftwareVersion='19.2.0-LFJD04.B015',
IsSmartStb='0')
UA = {'User-Agent': 'Mozilla/5.0 (X11; U; Linux i686; en-US) AppleWebKit/534.0 (KHTML, like Gecko)'}
# ============ 运行配置 ============
CONF_PATH = '/conf/rtp2httpd.conf' # 宿主机 /etc/rtp2httpd.conf 单文件挂载
DATA_DIR = '/data'
CHECK_INTERVAL = int(os.environ.get('CHECK_INTERVAL', '300'))
RENEW_MAX_AGE_H = float(os.environ.get('RENEW_MAX_AGE_H', '24'))
STREAM_CHECK_URL = os.environ.get('STREAM_CHECK_URL', 'http://127.0.0.1:5140/%E5%A4%AE%E8%A7%86/CCTV-1')
HTTP_M3U_BASE = os.environ.get('HTTP_M3U_BASE', 'http://10.0.0.1:5140')
IP_ECHO_URLS = ['http://members.3322.org/dyndns/getip', 'https://myip.ipip.net',
'http://ip.3322.net', 'https://api.ipify.org', 'https://icanhazip.com']
STATE_FILE = os.path.join(DATA_DIR, 'state.json')
def log(msg):
print(time.strftime('[%Y-%m-%d %H:%M:%S] ') + msg, flush=True)
# ---------------- 出口 IP 探测 ----------------
def get_egress_ip():
"""从文本中提取首个 IPv4 (myip.ipip.net 等返回带中文的句子)"""
for u in IP_ECHO_URLS:
try:
r = requests.get(u, timeout=8)
m = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', r.text)
if m:
return m.group(1)
except Exception as e:
log(f'IP探测失败 {u}: {e}')
return None
# ---------------- 认证链 (三步重放) ----------------
def authenticate():
s = requests.Session()
s.headers.update(UA)
s.get(HOST + '/EPG/jsp/AuthenticationURL',
params={'UserID': USERID, 'Action': 'Login'}, timeout=15)
s.post(HOST + '/EPG/jsp/authLoginHWCTC.jsp', data=f'UserID={USERID}&VIP=',
headers={'Referer': HOST + f'/EPG/jsp/AuthenticationURL?UserID={USERID}&Action=Login',
'Content-Type': 'application/x-www-form-urlencoded'}, timeout=15)
body = (f'UserID={USERID}&Lang=1&SupportHD=1&NetUserID=&Authenticator={AUTHENTICATOR}'
f'&STBType={STB["STBType"]}&STBVersion={STB["STBVersion"]}&conntype={STB["conntype"]}'
f'&STBID={STB["STBID"]}&templateName={STB["templateName"]}&areaId={STB["areaId"]}'
f'&userToken={TEMPKEY}&userGroupId={STB["userGroupId"]}&productPackageId='
f'&mac={STB["mac"].replace(":", "%3A")}&UserField={STB["UserField"]}'
f'&SoftwareVersion={STB["SoftwareVersion"]}&IsSmartStb={STB["IsSmartStb"]}'
f'&desktopId=&stbmaker=&VIP=')
r = s.post(HOST + '/EPG/jsp/ValidAuthenticationHWCTC.jsp', data=body,
headers={'Referer': HOST + '/EPG/jsp/authLoginHWCTC.jsp',
'Content-Type': 'application/x-www-form-urlencoded'}, timeout=15)
ut = re.search(r'"UserToken" value="(.+?)"', r.text)
jsid = s.cookies.get('JSESSIONID')
if not ut or not jsid:
raise RuntimeError('认证失败, 响应片段: ' + r.text[:300])
log(f'认证成功: UserToken={ut.group(1)}')
return ut.group(1), jsid
def fetch_channels(user_token, jsid):
body = (f'conntype={STB["conntype"]}&UserToken={user_token}&tempKey={TEMPKEY}'
f'&stbid=990060&SupportHD=1&UserID={USERID}&Lang=1')
r = requests.post(HOST + '/EPG/jsp/getchannellistHWCTC.jsp', data=body,
headers={**UA, 'Referer': HOST + '/EPG/index.jsp',
'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': f'JSESSIONID={jsid}'}, timeout=60)
content = r.content
stripped = content.lstrip(b'\r\n \t')
if stripped[:2] == b'\x1f\x8b':
content = gzip.decompress(stripped)
elif 'deflate' in (r.headers.get('Content-Encoding') or '').lower():
try:
content = zlib.decompress(stripped)
except zlib.error:
content = zlib.decompress(stripped, -zlib.MAX_WBITS)
log(f'频道列表: HTTP {r.status_code}, {len(content)}B')
return content.decode('utf-8', 'replace')
def parse_channels(text):
pat = re.compile(
r'ChannelName="([^"]+)".*?UserChannelID="(\d+)".*?ChannelURL="([^"]+)".*?ChannelSDP="([^"]+)"', re.S)
chans, seen = [], set()
for m in pat.finditer(text):
no = m.group(2)
if no in seen:
continue
seen.add(no)
chans.append({'name': m.group(1), 'no': no, 'url': m.group(3), 'sdp': m.group(4)})
log(f'解析频道: {len(chans)}')
return chans
# ---------------- 频道清洗/分组 ----------------
def clean(n):
n = re.sub(r'[((][^()()]*[))]', '', n)
n = re.sub(r'-?RTSP.*$', '', n, flags=re.I)
n = re.sub(r'\(0222\)|\(现网使用\)|\(测试\)|-2311|-2212', '', n)
n = n.replace('RSTP', '').strip()
return re.sub(r'[-\s]+$', '', n).strip()
def group(name):
if '未知' in name:
return '其他'
if '4K' in name or '超高清' in name:
return '4K专区'
if name.startswith('CCTV') or 'CGTN' in name or '中国教育' in name:
return '央视'
if any(k in name for k in ('福建', '东南卫视', '海峡', '厦门')):
return '本地福建'
if '卡通' in name or '少儿' in name or '中学生' in name:
return '少儿'
if '卫视' in name:
return '卫视'
return '其他'
ORDER = ['央视', '卫视', '本地福建', '4K专区', '少儿', '其他']
def build_channel_list(chans):
"""RTSP 与 HLS 是两组不相交的频道, 分开产出。
返回 (rtsp_items, hls_items): 各为 [(group, name, url)]"""
rtsp_items, hls_items = [], []
for c in sorted(chans, key=lambda x: int(x['no']) if x['no'].isdigit() else 99999):
name = clean(c['name'])
if not name or '直播室' in name or '马赛克' in name:
continue
g = group(name)
if c['sdp'].startswith('rtsp'):
rtsp_items.append((g, name, c['sdp']))
elif c['url'].startswith('rtsp'):
rtsp_items.append((g, name, c['url']))
if c['url'].startswith('http'):
hls_items.append((g, name, c['url']))
return rtsp_items, hls_items
def gen_conf(items):
groups = {}
for g, n, u in items:
groups.setdefault(g, []).append((n, u))
cfg = ['#RTP2HTTPD Config file - auto renewed by iptv-renewer',
time.strftime('# last renewal: %Y-%m-%d %H:%M:%S'), '',
'[global]', 'verbosity = 1', 'maxclients = 20', 'workers = 2',
'cors-allow-origin = *', '', '[bind]', '* 5140', '', '[services]', '#EXTM3U']
for g in ORDER:
lst = groups.get(g)
if not lst:
continue
cfg.append(f'# ---- {g} ----')
for n, u in lst:
cfg.append(f'#EXTINF:-1 group-title="{g}",{n}')
cfg.append(u)
return '\n'.join(cfg) + '\n'
def gen_http_m3u(items):
"""客户端播放列表: 地址永远不变 -> 全屋无感"""
out = ['#EXTM3U', '']
for g, n, _ in items:
out.append(f'# ---- {g} ----')
out.append(f'#EXTINF:-1 group-title="{g}",{n}')
out.append(f'{HTTP_M3U_BASE}/{quote(g, safe="")}/{quote(n, safe="")}')
out.append('')
return '\n'.join(out) + '\n'
def gen_rtsp_m3u(items):
out = ['#EXTM3U']
for g, n, u in items:
out.append(f'#EXTINF:-1 tvg-name="{n}" group-title="{g}",{n}')
out.append(u)
return '\n'.join(out) + '\n'
def gen_hls_m3u(items):
out = ['#EXTM3U']
for g, n, u in items:
out.append(f'#EXTINF:-1 tvg-name="{n}" group-title="{g}",{n}')
out.append(u)
return '\n'.join(out) + '\n'
# ---------------- rtp2httpd 热重载 ----------------
def find_master_pid():
"""pid:host 模式下容器可直接看到宿主机进程, 取最早启动的 master"""
best, best_start = None, None
for d in os.listdir('/proc'):
if not d.isdigit():
continue
try:
with open(f'/proc/{d}/comm') as f:
if f.read().strip() != 'rtp2httpd':
continue
with open(f'/proc/{d}/stat') as f:
start = int(f.read().split(') ', 1)[1].split()[19])
if best_start is None or start < best_start:
best, best_start = int(d), start
except (OSError, IndexError, ValueError):
continue
return best
def hot_reload():
pid = find_master_pid()
if pid is None:
raise RuntimeError('未找到宿主机 rtp2httpd 进程')
os.kill(pid, signal.SIGHUP)
log(f'已向 rtp2httpd (pid={pid}) 发送 SIGHUP 热重载')
time.sleep(2)
if find_master_pid() is None:
raise RuntimeError('SIGHUP 后 rtp2httpd 消失了!')
return pid
def verify_stream():
"""试播验证: 拉一段流即认为令牌有效"""
try:
with requests.get(STREAM_CHECK_URL, stream=True, timeout=15) as r:
n = len(r.raw.read(64 * 1024))
log(f'试播验证: {"成功" if n > 0 else "无数据"} ({n}B)')
return n > 0
except Exception as e:
log(f'试播验证失败: {e}')
return False
# ---------------- 状态与主循环 ----------------
def load_state():
try:
return json.load(open(STATE_FILE, encoding='utf-8'))
except Exception:
return None
def save_state(ip, n_ch):
json.dump({'ts': time.time(), 'ts_str': time.strftime('%Y-%m-%d %H:%M:%S'),
'egress_ip': ip, 'channels': n_ch},
open(STATE_FILE, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
def write_atomic(path, content):
tmp = path + '.tmp'
with open(tmp, 'w', encoding='utf-8', newline='\n') as f:
f.write(content)
os.replace(tmp, path)
def do_renew(current_ip):
log(f'===== 开始续期 (出口IP={current_ip}) =====')
user_token, jsid = authenticate()
text = fetch_channels(user_token, jsid)
chans = parse_channels(text)
rtsp_items, hls_items = build_channel_list(chans)
if len(rtsp_items) < 50:
raise RuntimeError(f'频道数异常 ({len(rtsp_items)}), 拒绝覆写配置')
conf = gen_conf(rtsp_items)
# 原地覆写挂载文件 (保持同一 inode, bind mount 有效)
with open(CONF_PATH, 'w', encoding='utf-8', newline='\n') as f:
f.write(conf)
log(f'已写入 {CONF_PATH} ({len(rtsp_items)} RTSP 频道)')
hot_reload()
ok = verify_stream()
# 产出 m3u 副本
write_atomic(os.path.join(DATA_DIR, 'channels.m3u'), gen_rtsp_m3u(rtsp_items))
write_atomic(os.path.join(DATA_DIR, 'channels-hls.m3u'), gen_hls_m3u(hls_items))
write_atomic(os.path.join(DATA_DIR, 'channels-http.m3u'), gen_http_m3u(rtsp_items))
save_state(current_ip, len(rtsp_items))
log(f'HLS 频道: {len(hls_items)}')
log(f'===== 续期完成, 试播{"正常" if ok else "异常(请人工检查)"} =====')
return ok
def need_renew(state, current_ip):
if state is None:
return '首次运行'
if current_ip and state.get('egress_ip') != current_ip:
return f'出口IP漂移: {state.get("egress_ip")} -> {current_ip}'
if time.time() - state.get('ts', 0) > RENEW_MAX_AGE_H * 3600:
return f'令牌超过 {RENEW_MAX_AGE_H}h 定时刷新'
return None
def main():
log(f'iptv-renewer 启动: 检查间隔 {CHECK_INTERVAL}s, 最长续期周期 {RENEW_MAX_AGE_H}h')
probe_fails = 0
while True:
try:
state = load_state()
ip = get_egress_ip()
reason = need_renew(state, ip)
if not reason and state is not None:
# 令牌名义上仍有效 -> 试播探活, 失败即自愈续期
if not verify_stream():
probe_fails += 1
if probe_fails >= 2:
reason = '连续试播失败, 疑似令牌失效 (自动自愈)'
probe_fails = 0
else:
log('试播失败一次, 下轮复检')
time.sleep(15)
else:
probe_fails = 0
if reason:
log(f'需要续期: {reason}')
try:
do_renew(ip)
except Exception as e:
log(f'!! 续期失败 (保留旧配置继续服务): {e}')
elif state is not None:
log(f'令牌有效 (IP={state.get("egress_ip")}, '
f'已 {(time.time()-state["ts"])/3600:.1f}h), 跳过')
except Exception as e:
log(f'!! 循环异常: {e}')
time.sleep(CHECK_INTERVAL)
if __name__ == '__main__':
sys.exit(main())
附录 B:如何获得脱敏处的凭证
用上一篇的方法抓一次盒子开机流量(从盒子断电重启前开始抓),然后:
- 过滤
host 27.148.x.x && port 33200的 HTTP POST,找到ValidAuthenticationHWCTC请求体里的Authenticator=(128 位 hex)与UserID=; - 同一请求上下文里的
userToken/响应 JS 中的tempKey; STBID、mac、userGroupId在authLoginHWCTC/ EPG 首页请求的参数里都能找到。
抓到一次即可长期使用——这就是本文成立的全部前提。
系列目录:① 从 OpenWrt 抓包到 rtp2httpd 转流 → ② 本篇:令牌免盒子自动续期。认证链逆向的完整过程(含抓包定位方法与凭证提取细节)如果有人感兴趣,可以再写③。
评论