用 Cloudflare Workers 自建 Telegram Bot API 反代
Telegram 的 Bot API 在国内直连基本不可用。用 Cloudflare Workers 做一层反向代理,是目前最省事的方案:不用买服务器、不用管证书、不用维护进程,改几行 JS 就能加限流、白名单和日志。
这篇文章给出一个可以直接使用的完整实现,包含安全策略、环境变量、部署步骤和客户端接入方式,文末附完整代码。
为什么用 Workers
替代方案不少:自建 VPS 反代、用公共反代、上付费中转。选 Workers 的理由:
- 免费额度够用 —— 个人 Bot 的流量远达不到免费层上限
- 全球边缘节点 —— Telegram API 在海外,Workers 天然就近出网
- 零运维 —— 不用管服务器、证书、重启
- 可编程 —— 想加什么策略改几行 JS 就行
代价是需要自己把边界情况想清楚。下面的实现已经把这些处理好了。
整体设计
一句话:只转发合法的 Bot API 路径,其余全部拒绝。
客户端 → https://你的域名/bot<token>/getMe
↓
Cloudflare Worker
↓ 路径校验 / Token 校验 / IP 白名单 / 限流 / 体积限制
https://api.telegram.org/bot<token>/getMe
↓
返回响应
安全策略
| 策略 | 作用 | 是否必开 |
|---|---|---|
| 路径锁定 | 只放行 /bot* 和 /file/bot*,其余返回 404 |
必开 |
| Token 白名单 | 只允许已注册的 Bot Token | 建议开 |
| IP 白名单 | 只允许指定来源 IP / 网段 | 可选 |
| 方法限制 | 只允许 GET / POST | 必开 |
| 速率限制 | 单 IP 每分钟请求上限 | 建议开 |
| 请求体上限 | 拒绝超过 50 MiB 的请求 | 建议开 |
| 响应头净化 | 只透传必要响应头 | 必开 |
几个设计取舍
强制上游返回未压缩内容。 请求时带上 accept-encoding: identity,让 Telegram 直接返回明文。多了一层代理后,压缩状态很容易在转手过程中变得不一致,干脆不要压缩,content-length 永远对得上。
保留下载文件名。 Telegram 下载文件时会带 content-disposition,这个头必须透传,否则客户端拿到的是无名文件。
流式读取请求体。 上传大文件时不把整个 body 读进内存,而是一边转发一边计数,超过 50 MiB 就中断。
限流按 IP 分桶。 用两级 Map 存储(IP → 时间桶),避免 IPv6 地址里的冒号干扰解析。
环境变量
在 Cloudflare Dashboard → Workers → Settings → Variables 里配置:
| 变量 | 说明 | 默认 |
|---|---|---|
ALLOWED_TOKENS |
逗号分隔的 Bot Token,不设则放行任意 | 不限制 |
IP_WHITELIST |
逗号分隔的 IP 或 CIDR | 不限制 |
RATE_LIMIT_RPM |
单 IP 每分钟请求数 | 120 |
LOG_LEVEL |
none / errors / all |
errors |
部署
路线 A:Dashboard 粘贴
- Cloudflare Dashboard → Workers & Pages → 创建 Worker
- 编辑代码 → 粘贴文末完整代码
- Deploy
- 在 Settings → Variables 里配置上面那几个环境变量
- 在 Settings → Triggers → Custom Domains 绑定你的域名
适合偶尔改一次的情况。
路线 B:wrangler CLI
npm i -D wrangler
npx wrangler deploy
wrangler.toml:
name = "tg-api-relay"
main = "index.js"
compatibility_date = "2025-01-01"
自定义域名在 Dashboard 里加一次即可,不用写进配置。
接入客户端
配好反代域名后,把客户端的 Telegram API 地址从 https://api.telegram.org 换成你的域名。
以 OpenClaw 为例:
{
"channels": {
"telegram": {
"apiRoot": "https://tg-api.example.com"
}
}
}
字段名是 apiRoot,不是 baseUrl 或 apiUrl。改完重启生效。
验证
部署完成后跑三条命令:
# 1. 健康检查
curl https://tg-api.example.com/health
# 期望:{"status":"ok","ts":...}
# 2. 用假 token 试探(401 来自 Telegram,说明转发正常)
curl https://tg-api.example.com/bot123456:FAKE/getMe
# 期望:{"ok":false,"error_code":401,"description":"Unauthorized"}
# 3. 非法路径应被拦截
curl https://tg-api.example.com/etc/passwd
# 期望:404
第 2 条最关键:返回 401 而不是 403,说明 Worker 确实把请求送到了 Telegram,而不是自己拦下了。
完整代码
展开查看完整 index.js(点击复制)
/**
* Cloudflare Worker — Telegram Bot API 反向代理
*
* 安全策略(由环境变量控制):
* - 路径锁定:仅转发 /bot* 和 /file/bot* 路径,拒绝任意代理
* - IP 白名单:可选,仅允许指定 IP/网段
* - Token 校验:可选,仅放行已注册的 Bot Token
* - 方法限制:仅允许 GET / POST
* - 速率限制:单 IP 每分钟请求上限
* - Header 净化:仅转发必要请求头,剥离泄露信息的响应头
* - Body 上限:拒绝 > 50 MiB 的请求(含无 Content-Length 的 chunked 请求)
*
* 环境变量(在 Cloudflare Dashboard → Workers → Settings → Variables 中配置):
* ALLOWED_TOKENS 可选,逗号分隔的 Bot Token,如 "123:abc,456:def"。不设则放行任意 Bot Token
* IP_WHITELIST 可选,逗号分隔的 IP 或 CIDR,如 "1.2.3.4,10.0.0.0/8"。不设则不限制来源
* RATE_LIMIT_RPM 可选,单 IP 每分钟最大请求数,默认 120
* LOG_LEVEL 可选,"none" | "errors"(默认) | "all"
*
* 2026-10-20 修复:
* 1) /file/bot<token>/... 路径不被 Token 白名单识别(会误杀文件下载)
* 2) 剥离 content-disposition 导致下载文件丢失原文件名
* 3) 剥离 content-encoding 导致压缩响应被当明文返回(乱码/解析失败)
* 4) LOG_LEVEL=all 时错误日志反而被跳过
* 5) 速率限制桶清理对 IPv6 失效(内存泄漏)
* 6) Body 上限可被无 Content-Length 的 chunked 请求绕过
*/
// ============================================================
// 内存速率限制器(单 Worker 实例内有效,重启清零)
// 对于个人 Bot 足够;如需跨实例持久化,换成 KV 即可
// ============================================================
const rateState = new Map();
let _cleanupCounter = 0;
// 同时匹配 /bot<token>/... 与 /file/bot<token>/...(修复 #1)
const BOT_TOKEN_RE = /^\/(?:file\/)?bot([^/]+)/;
const MAX_BODY_BYTES = 50 * 1024 * 1024;
function checkRate(clientIP, limitPerWindow, windowMs = 60000) {
const bucket = Math.floor(Date.now() / windowMs);
// 用两级 Map 存储,避免 IPv6 地址里的冒号破坏 key 解析(修复 #5)
let byBucket = rateState.get(clientIP);
if (!byBucket) {
byBucket = new Map();
rateState.set(clientIP, byBucket);
}
const current = byBucket.get(bucket) || 0;
if (current >= limitPerWindow) return false;
byBucket.set(bucket, current + 1);
// 顺手清掉该 IP 已过期的窗口
for (const b of byBucket.keys()) {
if (bucket - b > 2) byBucket.delete(b);
}
return true;
}
// 定期清理整条 IP 记录,防止内存泄漏
function cleanupRateState() {
const bucket = Math.floor(Date.now() / 60000);
for (const [ip, byBucket] of rateState) {
for (const b of byBucket.keys()) {
if (bucket - b > 2) byBucket.delete(b);
}
if (byBucket.size === 0) rateState.delete(ip);
}
}
// ============================================================
// IP / CIDR 匹配
// ============================================================
function ipToInt(ip) {
const parts = ip.split('.');
if (parts.length !== 4) return null;
return ((+parts[0] << 24) | (+parts[1] << 16) | (+parts[2] << 8) | (+parts[3])) >>> 0;
}
function isIPv4(ip) {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
if (!m) return false;
// 每段必须 0-255,否则 ipToInt 会算出错误结果
return m.slice(1).every((p) => Number(p) <= 255);
}
function matchCIDR(ipInt, cidr) {
const [range, bitsStr] = cidr.split('/');
const bits = parseInt(bitsStr, 10);
if (isNaN(bits) || bits < 0 || bits > 32) return false;
const rangeInt = ipToInt(range);
if (rangeInt === null) return false;
const mask = bits === 0 ? 0 : ~((1 << (32 - bits)) - 1) >>> 0;
return (ipInt & mask) === (rangeInt & mask);
}
function isIPAllowed(ip, whitelist) {
if (!isIPv4(ip)) return false;
const ipInt = ipToInt(ip);
for (const entry of whitelist) {
if (entry.includes('/')) {
if (matchCIDR(ipInt, entry)) return true;
} else {
if (ip === entry) return true;
}
}
return false;
}
// ============================================================
// 日志(修复 #4:all 模式下错误日志不再丢失)
// ============================================================
// 日志里的路径可能含 Bot Token,输出前必须脱敏
function redactPath(pathname) {
// 同时处理 /bot<token>/... 与 /file/bot<token>/...
return pathname.replace(/^((?:\/file)?\/bot)[^/\s]+/i, '$1<redacted>');
}
function log(config, level, message) {
if (config.logLevel === 'none') return;
if (config.logLevel === 'all') {
console.log(`[tg-relay:${level}] ${message}`);
return;
}
if (level === 'errors') {
console.log(`[tg-relay:${level}] ${message}`);
}
}
// ============================================================
// 流式 Body 计数(修复 #6:无 Content-Length 时也能限制)
// ============================================================
class PayloadTooLargeError extends Error {}
function limitBodyStream(stream, maxBytes) {
let total = 0;
const counter = new TransformStream({
transform(chunk, controller) {
total += chunk.byteLength;
if (total > maxBytes) {
controller.error(new PayloadTooLargeError('body too large'));
return;
}
controller.enqueue(chunk);
},
});
return stream.pipeThrough(counter);
}
// ============================================================
// 主入口
// ============================================================
export default {
async fetch(request, env) {
const config = {
allowedTokens: env.ALLOWED_TOKENS
? env.ALLOWED_TOKENS.split(',').map(t => t.trim()).filter(Boolean)
: null,
ipWhitelist: env.IP_WHITELIST
? env.IP_WHITELIST.split(',').map(i => i.trim()).filter(Boolean)
: null,
rateLimitRPM: parseInt(env.RATE_LIMIT_RPM, 10) || 120,
logLevel: env.LOG_LEVEL || 'errors',
};
try {
return await handleRequest(request, config);
} catch (err) {
if (err instanceof PayloadTooLargeError) {
return new Response('Payload Too Large', { status: 413 });
}
log(config, 'errors', `unhandled error: ${err.message}`);
return new Response('Internal Server Error', { status: 502 });
}
},
};
// ============================================================
// 请求处理
// ============================================================
async function handleRequest(request, config) {
const url = new URL(request.url);
const { pathname, search } = url;
const clientIP = request.headers.get('CF-Connecting-IP') || '0.0.0.0';
// ---- Health check ----
if (pathname === '/' || pathname === '/health') {
return new Response(JSON.stringify({ status: 'ok', ts: Date.now() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// 定期清理(每 100 次真实请求执行一次,排除健康检查)
if (++_cleanupCounter % 100 === 0) cleanupRateState();
// ---- 1. 路径验证 ----
if (!pathname.startsWith('/bot') && !pathname.startsWith('/file/bot')) {
log(config, 'errors', `blocked: ${clientIP} non-bot path ${pathname}`);
return new Response('Not Found', { status: 404 });
}
// ---- 2. HTTP 方法限制 ----
if (request.method !== 'GET' && request.method !== 'POST') {
log(config, 'errors', `blocked: ${clientIP} bad method ${request.method}`);
return new Response('Method Not Allowed', { status: 405 });
}
// ---- 3. Bot Token 校验(可选,同时覆盖 /file/bot 路径)----
if (config.allowedTokens && config.allowedTokens.length > 0) {
const m = pathname.match(BOT_TOKEN_RE);
if (!m || !config.allowedTokens.includes(m[1])) {
log(config, 'errors', `blocked: ${clientIP} unknown bot token`);
return new Response('Forbidden', { status: 403 });
}
}
// ---- 4. IP 白名单(可选) ----
if (config.ipWhitelist && config.ipWhitelist.length > 0 && !isIPAllowed(clientIP, config.ipWhitelist)) {
// 白名单只支持 IPv4;若来源是 IPv6,明确记录原因,避免误判为配置无效
if (!isIPv4(clientIP)) {
log(config, 'errors', `blocked: ${clientIP} is not IPv4 but IP_WHITELIST is IPv4-only`);
} else {
log(config, 'errors', `blocked: ${clientIP} not in whitelist`);
}
return new Response('Forbidden', { status: 403 });
}
// ---- 5. 速率限制 ----
if (!checkRate(clientIP, config.rateLimitRPM, 60000)) {
log(config, 'errors', `rate-limited: ${clientIP}`);
return new Response('Too Many Requests', {
status: 429,
headers: { 'Retry-After': '60' },
});
}
// ---- 6. Body 上限(声明值 + 流式兜底)----
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > MAX_BODY_BYTES) {
log(config, 'errors', `blocked: ${clientIP} body ${contentLength}B > limit`);
return new Response('Payload Too Large', { status: 413 });
}
// ---- 7. 构造目标 URL ----
const targetUrl = `https://api.telegram.org${pathname}${search}`;
// ---- 8. Header 净化:仅转发必要请求头 ----
const forwardHeaders = new Headers();
const ALLOWED_REQ_HEADERS = new Set([
'content-type',
'content-length',
'accept',
'user-agent',
]);
for (const [key, value] of request.headers) {
if (ALLOWED_REQ_HEADERS.has(key.toLowerCase())) {
forwardHeaders.set(key, value);
}
}
// 强制上游返回未压缩内容:避免运行时已解压而响应头仍声称压缩导致的不一致
forwardHeaders.set('accept-encoding', 'identity');
// ---- 9. 发起转发 ----
let body;
if (request.method === 'POST') {
// 有 body 就套一层计数流;无 Content-Length 时同样受限(修复 #6)
body = request.body ? limitBodyStream(request.body, MAX_BODY_BYTES) : undefined;
}
const init = {
method: request.method,
headers: forwardHeaders,
body,
redirect: 'follow',
};
let modifiedRequest;
try {
// 流式 body 在标准 fetch 里必须声明 duplex;Workers 忽略该字段时也无害
modifiedRequest = new Request(targetUrl, body ? { ...init, duplex: 'half' } : init);
} catch {
// 某些运行时(含部分 Workers 版本)不接受 duplex,退回不带该字段的构造
modifiedRequest = new Request(targetUrl, init);
}
let response;
try {
response = await fetch(modifiedRequest);
} catch (err) {
if (err instanceof PayloadTooLargeError || String(err?.message || '').includes('body too large') || String(err?.cause?.message || '').includes('body too large')) {
log(config, 'errors', `blocked: ${clientIP} body exceeded limit while streaming`);
return new Response('Payload Too Large', { status: 413 });
}
log(config, 'errors', `fetch-error: ${clientIP} → api.telegram.org: ${err.message}`);
return new Response('Bad Gateway', { status: 502 });
}
log(config, 'all', `${clientIP} ${request.method} ${redactPath(pathname)} → ${response.status}`);
// ---- 10. 返回响应:剥离非必要响应头 ----
// 保留 content-disposition(下载文件名)与 content-encoding(压缩一致性)
const responseHeaders = new Headers();
const ALLOWED_RES_HEADERS = new Set([
'content-type',
'content-length',
'content-disposition',
'date',
'retry-after',
]);
const upstreamEncoding = (response.headers.get('content-encoding') || '').trim().toLowerCase();
for (const [key, value] of response.headers) {
const k = key.toLowerCase();
if (!ALLOWED_RES_HEADERS.has(k)) continue;
// 有 content-encoding 时不要透传 content-length:
// 运行时可能已解压 body,长度会与压缩长度不一致,导致响应被截断
if (k === 'content-length' && upstreamEncoding && upstreamEncoding !== 'identity') continue;
responseHeaders.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}