用 Cloudflare Workers 自建 Telegram Bot API 反代:从能用到能用住

用 Cloudflare Workers 自建 Telegram Bot API 反代:从能用到能用住

用 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 粘贴

  1. Cloudflare Dashboard → Workers & Pages → 创建 Worker
  2. 编辑代码 → 粘贴文末完整代码
  3. Deploy
  4. 在 Settings → Variables 里配置上面那几个环境变量
  5. 在 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,
  });
}