一、问题:令牌为什么会让直播"突然全挂"

上一篇产出的频道地址长这样:

text
rtsp://27.148.240.185/PLTV/.../xxx.smil?accountinfo=%7E%7EV2.0%7E...%7E...:20261005120930,10500002078800,27.152.x.x,...&...

accountinfo 是电信华为中间件签发的时效性令牌,解码后能看到三个关键字段:签发时间戳、绑定出口 IP、频道资产 ID。这意味着地址失效有三种方式:

失效的表现很统一:所有频道同时 403,而不是个别频道。上一篇文章里我只有一条路——重新抓一次机顶盒开机流量。显然不能接受,家庭服务必须自愈。

二、逆向结论:为什么"免盒子续期"是可能的

对认证链做过完整逆向(细节够单独写一篇,这里只给结论):

  1. 认证链四步:AuthenticationURL?Action=Login → authLoginHWCTC → ValidAuthenticationHWCTC(提交 128 位 hex 的 Authenticator)→ getchannellistHWCTC(凭 UserToken + JSESSIONID 拉全量频道表)。
  2. 令牌是开机时批量下发的:盒子认证成功后,getchannellistHWCTC.jsp 一次性返回全部 235 个频道的播放地址(gzip + chunked,约 700KB)。做流量分析时记得把抓包起点放在盒子断电重启之前,否则会错过这条关键响应。
  3. 服务端不校验凭证新鲜度:把抓包获得的 Authenticator + tempKey 原样重放,三步认证照样通过,拿到全新令牌。也就是说——抓到一次凭证,等于拿到一张长期饭票。这是本文方案的成立前提,已实测多轮续期稳定通过。

有了第 3 条,续期就退化成一个纯 HTTP 流程:任何设备(包括路由器自己)发三个 POST,10 秒拿到全频道新令牌,机顶盒从此可以进抽屉了。

三、方案设计

关键能力:rtp2httpd 原生支持配置热重载

更新频道配置不需要重启服务。rtp2httpd 3.x(stackia/rtp2httpd,面向国内 IPTV 环境的现代化重写版)收到 SIGHUP 信号时会重新解析配置文件、刷新频道表,正在播放的流完全不受影响。部署前可以在自己的二进制里确认这个行为:

bash
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。

续期触发条件(三层兜底)

text
每 5 分钟一轮检查:
├─ ① 出口 IP ≠ 上次续期时的 IP   → 续期(IP 漂移,令牌已死)
├─ ② 距上次续期 > 24h            → 续期(定时兜底,防服务端会话老化)
└─ ③ 试播 CCTV-1 连续 2 次失败   → 续期(自愈:不管什么原因,重认证准没错)

其中 ①② 是预防,③ 是治疗——即使前两条都没料到的原因导致失效,试播失败也会触发自愈,这就是"无感"的最后一道保险。

四、部署步骤

4.1 目录结构

text
/root/iptv/
├── docker-compose.yml
├── renewer/
│   ├── Dockerfile
│   └── renewer.py        # 续期核心逻辑(附录有完整版)
└── data/                 # 产物: state.json + 三份 m3u

4.2 准备工作

bash
# 备份现有配置(容器会接管这个文件)
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 官方镜像页):

bash
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 源配置说明),构建阶段全部走国内网络:

dockerfile
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

yaml
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 启动与验证

bash
cd /root/iptv && docker compose up -d --build

# 看首轮续期日志
docker logs -f iptv-renewer

我的首轮输出:

text
[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 分钟后的稳态轮:

text
[12:40:39] 试播验证: 成功 (65536B)
[12:40:39] 令牌有效 (IP=27.152.x.x, 已 0.1h), 跳过

再从任意一台局域网设备实测:

bash
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)再发:

python
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: 令牌失效影响的是"新发起的播放",已经在播的流通常能继续到自然结束。最坏情况是切换频道失败几十秒——正好是自愈续期一轮的时间。

七、总结

整个系统现在的形态:

text
机顶盒(已收进抽屉)
                ┌─ 每5min: IP探测 + 试播探活
电信 EPG ◄──────┤─ 触发时: 三步重放认证 → 拉全量频道表
                └─ 更新 conf → SIGHUP 热重载(不断流) → 试播验证
                        │
OpenWrt rtp2httpd :5140 │  ← 令牌只活在这一段
                        ▼
   全屋设备 http://路由器:5140/分组/频道名  ← 永远不变

从"每隔几天手动抓包续命",到"路由器上一个 20MB 的容器无声值守",这大概是这套自建 IPTV 链路的最终形态了。剩下唯一可能动手的场景:运营商哪天修掉这个重放漏洞——但那是另一个故事了。

参考资料

附录:完整脚本

附录 A:renewer.py(凭证已脱敏)

python
# -*- 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:如何获得脱敏处的凭证

用上一篇的方法抓一次盒子开机流量(从盒子断电重启前开始抓),然后:

  1. 过滤 host 27.148.x.x && port 33200 的 HTTP POST,找到 ValidAuthenticationHWCTC 请求体里的 Authenticator=(128 位 hex)与 UserID=;
  2. 同一请求上下文里的 userToken/响应 JS 中的 tempKey;
  3. STBID、mac、userGroupId 在 authLoginHWCTC / EPG 首页请求的参数里都能找到。

抓到一次即可长期使用——这就是本文成立的全部前提。

系列目录:① 从 OpenWrt 抓包到 rtp2httpd 转流 → ② 本篇:令牌免盒子自动续期。认证链逆向的完整过程(含抓包定位方法与凭证提取细节)如果有人感兴趣,可以再写③。