✕
开启精彩搜索
热门搜索
emby EMBY 解析 免流 源码 IPV6 阿里 阿里云 青龙 音乐 网心 网心云 webdav 阿里云盘 unraid 洗白 Rclone 刮削 云梦

从零用 Cloudflare Workers + D1 搭建骆驼 IPTV 系统:完整踩坑记录

一篇踩了无数坑的实战复盘,给后来者少走弯路。
项目地址示例:https://iptv.777171.xyz
客户端:杨公子TV(com.qhtv.powerful,versionName 6.3.6)
技术栈:Cloudflare Workers + D1 + TypeScript + crypto-js + pako


一、为什么要做这件事

手里有一套 PHP 写的骆驼 IPTV 后台系统(login3.php、data.php、epgadmin.php、channeladmin.php 等),跑在传统 VPS 上。因为:

  • 服务器到期、成本高
  • 想享受 Cloudflare 的全球加速
  • 想用 Serverless 架构省心维护

于是决定用 Cloudflare Workers + D1 重写整套系统。

本文记录从登录到频道加载这一条主链路上踩过的所有坑,包括:

  1. PHP 逻辑如何翻译成 TypeScript
  2. AES-ECB 加密如何与 PHP openssl_encrypt 对齐
  3. gzip 压缩为什么不能用 pako.gzip
  4. Cloudflare WAF / Bot Fight Mode 怎么拦截了 APP 请求
  5. APP 用 appname 而不是数据库配置做密钥
  6. 版本更新提示怎么屏蔽

二、整体架构对比

模块 原 PHP 架构 Workers + D1 架构
运行环境 LNMP(Linux + Nginx + MySQL + PHP) V8 隔离沙箱(JS/TS)
数据库 MySQL Cloudflare D1(SQLite)
加密 openssl_encrypt(AES-128-ECB) crypto-js AES-ECB
压缩 gzcompress(zlib 流) pako.deflate
Session PHP $_SESSION JWT(HMAC-SHA256)
静态资源 Nginx 直接服务 Cloudflare Assets 绑定
定时任务 crontab Workers Cron Triggers

三、踩坑全过程

🕳 坑 1:clientLogin.ts 文件不存在导致 1101

现象:

  • APP 请求 login3.php,返回 500 error code: 1101
  • wrangler tail 里看不到任何 login3.php 的日志

原因:

index.ts 里 import { handleClientLogin } from './handlers/clientLogin';,但 src/handlers/ 下根本没有 clientLogin.ts。

esbuild 在构建时不报错,但运行时找不到函数,直接抛 1101。

修复:

新建 src/handlers/clientLogin.ts,导出 handleClientLogin。


🕳 坑 2:Cloudflare WAF / Bot Fight Mode 拦截 APP 请求

现象:

  • curl 打 login3.php → 200 OK
  • APP 打 login3.php → 403 Forbidden
  • wrangler tail 里看不到这条请求

原因:

APP 的 User-Agent 是 MSIE(伪装的),Cloudflare 的 Browser Integrity Check / Bot Fight Mode 把这个 UA 判定为自动化工具,直接在边缘层返回 403,请求根本没到 Worker。

curl 的 UA 是 curl/8.13.0,Cloudflare 对 curl 反而放行,所以对比测试会误导。

证据:

  • 响应头里有 cf-nel / Report-To,只有 Cloudflare 边缘层返回错误才会加
  • wrangler tail 里完全没有 login3.php 日志
  • 403 响应体是 error code: 1020(Cloudflare 的 Bot 拦截码)

修复:

  1. Cloudflare 控制台 → Security → Bots → Bot Fight Mode → OFF
  2. Security → Settings → Browser Integrity Check → OFF
  3. Security → Settings → Security Level → Essentially Off

改完后,wrangler tail 里立刻出现 POST .../login3.php - Ok。


🕳 坑 3:APP 用 form-urlencoded,不是 JSON

现象:

data.php 里 await request.json() 抛错:

SyntaxError: Unexpected token 'd', "data={"reg"... is not valid JSON

原因:

APP 发的 Content-Type 是 application/x-www-form-urlencoded,body 是 data={...} 而不是裸 JSON。

修复:

写一个兼容三种格式的请求解析:

let body: any;
const contentType = request.headers.get('Content-Type') || '';

if (contentType.includes('application/json')) {
    body = await request.json();
} else if (contentType.includes('application/x-www-form-urlencoded')) {
    const form = await request.formData();
    body = {};
    form.forEach((v, k) => { body[k] = v; });
} else {
    const text = await request.text();
    try {
        body = JSON.parse(text);
    } catch {
        const params = new URLSearchParams(text);
        body = {};
        params.forEach((v, k) => { body[k] = v; });
    }
}

注意: login3.php 和 data.php 都要加这段。


🕳 坑 4:crypto-js MD5 与 PHP md5() 中文编码不一致

现象:

  • /debug/encrypt 里算出的 key 跟 PHP 算出的不一致

原因:

一开始用了 Web Crypto API 模拟 MD5(Web Crypto 不支持 MD5,用 SHA-256 代替),结果自然不对。

修复:

直接 import MD5 from 'crypto-js/md5':

import MD5 from 'crypto-js/md5';

export function md5(input: string): string {
    return MD5(input).toString();
}

crypto-js 的 MD5 对 UTF-8 中文处理和 PHP 一致。


点击展开▾

🕳 坑 5:AES-ECB 不能用 Web Crypto 模拟

现象:

  • key 和 shortkey 都对齐了
  • 但 encrypted 跟 PHP 不一致

原因:

Web Crypto API 不支持 AES-ECB,只支持 CBC / CTR / GCM / KW。

之前用「CBC + 全零 IV + 单块加密」模拟 ECB:

// ❌ 错误做法
const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-CBC', iv: zeroIV },
    keyObj,
    block
);

问题: crypto.subtle.encrypt 的 CBC 内部会做一次 PKCS7 填充,你又手动 pkcs7Pad 了一次 → 双重填充,结果跟 PHP 不同。

修复:

用 crypto-js 的 AES-ECB:

import AES from 'crypto-js/aes';
import ECB from 'crypto-js/mode-ecb';
import Pkcs7 from 'crypto-js/pad-pkcs7';
import Utf8 from 'crypto-js/enc-utf8';

export function aesEncrypt(plaintext: string, key: string): string {
    const encrypted = AES.encrypt(Utf8.parse(plaintext), Utf8.parse(key), {
        mode: ECB,
        padding: Pkcs7
    });
    return encrypted.toString();  // 返回 base64
}

验证方式:

用 PHP 和 Worker 分别加密同一个测试字符串,比对结果:

Worker encrypted = B27oSn/ir3ooA2bNrfidvl8hMieSdibqFPdUQXb3LVM=
PHP encrypted    = B27oSn/ir3ooA2bNrfidvl8hMieSdibqFPdUQXb3LVM=   ✅

🕳 坑 6:APP 用 appname 而不是数据库配置做密钥

现象:

  • /debug/encrypt 用硬编码 "群晖电视" 测试,PHP 和 Worker 结果完全一致
  • 真实登录仍报「登录时发生错误」

原因:

通过 HttpCanary 抓包发现 APP 传的 login 参数是:

{"region":"","mac":"00:db:61:5b:59:f9","androidid":"...","model":"ALN-AL00","nettype":"","appname":"杨公子TV"}

APP 用它自己传上来的 appname 算密钥!

但数据库里是 app_appname = "群晖电视" → 两边密钥不同 → APP 解密失败。

修复:

UPDATE iptv_config SET value = '杨公子TV' WHERE name = 'app_appname';

教训: 数据库里的 app_appname 必须跟 APP 内硬编码的名字一致,否则密钥对不上。


🕳 坑 7:gzcompress 不能用 pako.gzip

现象:

  • data.php 请求进去了,[client data] error 也没了
  • APP 还是「获取节目信息失败」

原因:

PHP 的 gzcompress() 产生的是 zlib 流(RFC 1950),不是 gzip 流(RFC 1952)。

PHP 函数 对应 pako 函数 格式
gzcompress pako.deflate zlib
gzencode pako.gzip gzip
gzdeflate pako.deflateRaw raw deflate

必须用 pako.deflate,不能用 pako.gzip。

修复:

import { deflate } from 'pako';

const compressed = deflate(jsonStr);
const base64Str = uint8ToBase64(compressed);

注意: pako 的 ESM 版本没有 default 导出,必须命名导入:

// ❌ 错误
import pako from 'pako';

// ✅ 正确
import { deflate } from 'pako';

🕳 坑 8:data.php 加密逻辑跟 login3.php 完全不同

原 PHP login3.php 的加密流程:

$key = md5($sig.$appname.$packagename."AD80F93B542B");
$key = md5($key.$appname.$packagename);
$shortkey = substr($key, 5, 16);     // 起点 5
$encrypted = openssl_encrypt($json, 'AES-128-ECB', $shortkey, 0, '');
echo $encrypted;

原 PHP data.php 的加密流程:

$str = json_encode($contents, JSON_UNESCAPED_UNICODE);
$str = preg_replace('#null,#', '', $str);
$str = base64_encode(gzcompress($str));      // ★ gzip + base64
$key = md5($key.$randkey);                    // ★ 加 randkey
$key = substr($key, 7, 16);                   // ★ 起点 7
$encrypted = openssl_encrypt($str, 'AES-128-ECB', $key, 0, '');

// ★ 字符替换
$encrypted = str_replace("f", "&", $encrypted);
$encrypted = str_replace("b", "f", $encrypted);
$encrypted = str_replace("&", "b", $encrypted);
$encrypted = str_replace("t", "#", $encrypted);
$encrypted = str_replace("y", "t", $encrypted);
$encrypted = str_replace("#", "y", $encrypted);

// ★ substr + strrev
$coded = substr($encrypted, 44, 128);
$coded = strrev($coded);
$str = $coded . $encrypted;
echo $str;

对应 TypeScript:

let jsonStr = JSON.stringify(categories);
jsonStr = jsonStr.replace(/null,/g, '');

const compressed = deflate(jsonStr);
const base64Str = uint8ToBase64(compressed);

let key = md5(`${sig}${appAppname}${packageName}AD80F93B542B`);
key = md5(`${key}${appAppname}${packageName}`);
key = md5(`${key}${randkey}`);                 // ★ 加 randkey
const shortKey = key.substring(7, 23);          // ★ 起点 7

let encrypted = aesEncrypt(base64Str, shortKey);

// ★ 字符替换
encrypted = encrypted.replace(/f/g, '&');
encrypted = encrypted.replace(/b/g, 'f');
encrypted = encrypted.replace(/&/g, 'b');
encrypted = encrypted.replace(/t/g, '#');
encrypted = encrypted.replace(/y/g, 't');
encrypted = encrypted.replace(/#/g, 'y');

// ★ substr(44, 128) + strrev + 拼接
const coded = encrypted.substring(44, 44 + 128).split('').reverse().join('');
const finalStr = coded + encrypted;

顺序不能乱,任何一处差异都会导致 APP 解不出来。


🕳 坑 9:版本更新提示怎么屏蔽

现象:

APP 启动后弹「版本更新」对话框。

原因:

getver.php 返回的 appver = "7.7.7.8",比 APP 本地版本 6.3.6 高。

修复:

UPDATE iptv_config SET value = '6.3.6' WHERE name = 'appver';
UPDATE iptv_config SET value = '' WHERE name = 'appurl';

要点: appver 改成和 APP 本地版本完全一致的值(6.3.6),appurl 清空(即使 APP 想更新也下不了)。


四、验证加密是否对齐的万能方法

任何加密相关的问题,都用这个方法定位:

第 1 步:写一个 PHP 测试脚本

用 https://onecompiler.com/php 或 https://3v4l.org/:

<?php
$shortkey = '6ee44eb901a69c76';
$test = '{"test":"hello","num":123}';
$enc = openssl_encrypt($test, 'AES-128-ECB', $shortkey, 0, '');
echo "PHP encrypted = " . $enc . "\n";

第 2 步:在 Worker 里加一个 /debug/encrypt 路由

if (path === '/debug/encrypt') {
    const { md5, aesEncrypt } = await import('./crypto');
    const sig = '12315';
    const appname = '群晖电视';
    const packagename = 'com.qhtv.powerful';
    let key = md5(`${sig}${appname}${packagename}AD80F93B542B`);
    key = md5(`${key}${appname}${packagename}`);
    const shortkey = key.substring(5, 21);
    const test = '{"test":"hello","num":123}';
    const enc = aesEncrypt(test, shortkey);
    return new Response(JSON.stringify({ key, shortkey, encrypted: enc }, null, 2));
}

第 3 步:对比三项

对比项 一致 不一致
key MD5 + 拼接顺序一致 中文编码或拼接顺序问题
shortkey substr 起点/长度一致 起点错了
encrypted AES 模式/填充/密钥一致 AES 实现不对

这个对比是定位加密问题最快的方法。


五、最终的目录结构

iptv-worker/
├── src/
│   ├── index.ts              # Worker 入口 + 路由
│   ├── crypto.ts             # md5 + AES-ECB(crypto-js)
│   ├── db.ts                 # D1 封装
│   └── handlers/
│       ├── clientLogin.ts    # login3.php 重写
│       ├── clientData.ts     # data.php 重写
│       ├── clientMisc.ts     # getver/bg/getIpInfo
│       ├── adminAuth.ts      # 后台登录
│       ├── adminDashboard.ts # 后台首页
│       ├── adminUsers.ts     # 用户管理
│       ├── adminAuthor.ts    # 授权管理
│       ├── adminChannel.ts   # 频道管理
│       ├── adminEpg.ts       # EPG 管理
│       ├── adminMeal.ts      # 套餐管理
│       ├── adminMovie.ts     # 点播管理
│       ├── adminOrder.ts     # 订单管理
│       ├── adminSys.ts       # 系统设置
│       └── adminException.ts # 异常管理
├── public/                   # 静态资源(CSS/JS/图片)
├── schema.sql                # D1 建表 + 初始化数据
├── wrangler.toml
└── package.json

六、wrangler.toml 关键配置

name = "iptv-worker"
main = "src/index.ts"
compatibility_date = "2024-09-23"

[[d1_databases]]
binding = "DB"
database_name = "iptv_db"
database_id = "你的数据库ID"

[vars]
PANEL_MD5_KEY = "tvkey_"

[assets]
directory = "./public"
binding = "ASSETS"

七、几个必须记住的坑

坑 现象 解法
Cloudflare WAF 拦 APP curl 能过,APP 403 关 Bot Fight Mode + Browser Integrity Check
APP 用 MSIE UA 被 Cloudflare 判定为 Bot 同上
form-urlencoded 不是 JSON Unexpected token 'd' 兼容三种 Content-Type
Web Crypto 不支持 AES-ECB 加密结果不一致 用 crypto-js
gzcompress ≠ pako.gzip APP 解不出来 用 pako.deflate
APP 用 appname 做密钥 解密失败 数据库 app_appname 改成 APP 内硬编码值
data.php 加密流程跟 login3.php 不同 APP 解密失败 加 randkey、substr(7,16)、字符替换、substr+strrev
pako 无 default 导出 Build failed import { deflate } from 'pako'
版本更新提示 APP 弹更新 appver 改成 APP 版本号,appurl 清空

八、调试工具清单

工具 用途
npx wrangler tail --format pretty 实时看 Worker 日志
HttpCanary(安卓) 抓 APP 请求
curl(Windows cmd) 手动模拟请求
https://onecompiler.com/php 在线跑 PHP 加密脚本对比
Cloudflare D1 Console 直接执行 SQL
/debug/encrypt 路由 对比加密结果

九、总结

整个项目从零到跑通,最大的时间黑洞是加密对齐:

  • MD5 中文编码(crypto-js vs Web Crypto)
  • AES-ECB 模式(Web Crypto 不支持,必须用 crypto-js)
  • gzcompress 的格式(zlib 而不是 gzip)
  • 字符替换顺序(f→&→b→f 顺序不能乱)
  • substr 起点(login3 用 5,data 用 7)

第二大坑是 Cloudflare 的边缘拦截——因为 APP 用的是 MSIE UA,被 Bot Fight Mode 拦了。这个坑最难查,因为:

  • curl 能通,误导你以为 Worker 没问题
  • wrangler tail 里看不到请求,误导你以为没到 Worker
  • 403 响应里有 cf-nel,是 Cloudflare 边缘层返回的标志

遇到类似问题,先看 wrangler tail 里有没有这条请求的日志:

  • 有 → Worker 收到了,问题在 Worker 代码
  • 没有 → Cloudflare 边缘拦的,去关 WAF / Bot 防护

希望这篇踩坑记录能帮到后续做类似项目的人。


附录:常用 SQL 速查

-- 查看关键配置
SELECT name, value FROM iptv_config 
WHERE name IN ('app_sign','app_appname','app_packagename','randkey','appver','appurl');

-- 改 app_appname(对齐 APP 内硬编码)
UPDATE iptv_config SET value = '杨公子TV' WHERE name = 'app_appname';

-- 屏蔽版本更新
UPDATE iptv_config SET value = '6.3.6' WHERE name = 'appver';
UPDATE iptv_config SET value = '' WHERE name = 'appurl';

-- 查用户
SELECT name, mac, deviceid, status, exp, meal FROM iptv_users LIMIT 10;

-- 查频道分类
SELECT name, type, enable FROM iptv_category ORDER BY id;

-- 查套餐
SELECT id, name, amount, days, content, status FROM iptv_meals;

完。 后续遇到新问题,直接翻这篇文档查。

💬 评论区 0