数据库丢失危机与内网穿透节点失联,改进 Cloudflare Worker 反向代理报错页面

当我将博客从传统的云服务器迁回家里的自托管环境时,便知这场关于「控制欲」的实验注定不会平静。果不其然,频繁的配置变更与环境重构,让这台机器接连上演了几出令人心跳骤停的「惊魂记」。

数据库的至暗时刻

最先亮起红灯的是数据库。昨天晚上我正于宝塔面板中调试新的软件,指尖一滑,竟触发了不可逆的库删除操作。万幸的是,在迁移的前夕,出于一种近乎本能的谨慎,我已在面板内完成了一份全量数据库备份。否则,那些积攒经年的文章、访客留下的评论,以及无数次微调过的配置文件,恐将瞬间化作虚无。

经此一劫,我意识到单纯的数据库备份在灾难面前何其脆弱。在此郑重建议诸位:务必采用 Halo 博客系统自带的备份机制,执行定期的全量打包。若仅依赖数据库快照,即便数据侥幸复原,那些上传的附件、定制的主题文件与安装的插件,仍可能散落遗失,最终陷入「文章归来,面目全非」的尴尬废墟。所幸我插件不多,尚能凭借记忆在后台逐一召回。

穿透节点的连环崩坏

数据库的警报声尚未完全消散,内网穿透的节点又宣告阵亡。长期倚仗的日本节点(jp.5.frp.one)毫无征兆地失联,导致架设其上的 Cloudflare Worker 反向代理瞬间沦为摆设。访客试图推开博客大门时,映入眼帘的却是几串冰冷、毫无美感的 JSON 报错代码。更为致命的是,彼时我的主域名与备用域名竟共用了同一条隧道——这无异于将所有鸡蛋置于一篮,结果是「屋漏偏逢连夜雨」,双域同崩,博客彻底陷入瘫痪。

经过一番紧急抢救,目前服务已恢复如常。为了防止「单点失效」的悲剧重演,我特意为备用域名 exyone.us.kg绑定了立陶宛节点(ltw.frp.one (现在我把所有域名迁移到 Cloudflare Tunnel 了,虽然速度偏慢,但远比 ChmlFrp 要稳定),将其作为可靠的「后手」。从此,即便主节点再度失联,访客仍能借由备用通道窥见这片数字花园的一角。

重构有温度的错误美学

借着此次排障契机,我也顺手重构了 Cloudflare Worker 的反向代理逻辑,重点在于重塑错误页面的「观感」。过往,一旦后端节点离线,页面只会粗暴地抛出 JSON 或 Cloudflare 默认的 5xx 灰页,访客茫然,我也觉得缺乏诚意。

如今,我改用纯 HTML5 雕琢了一枚带有「冬日风雪」意境的自定义错误页:背景是深邃的蓝紫渐变,屏幕上有轻盈的雪絮飘落,核心信息则栖身于毛玻璃质感的卡片之中,底部衬以雪山剪影作为装饰。最实用的设计在于,页面会引导访客在 5 秒后自动跳转至备用域名。这不仅是功能的冗余,更是一种带有诗意的引导——当技术不可避免地遭遇故障时,至少让访客感受到的不是机器的冷漠,而是一份数字世界的体面与挽留。

// ==================== 配置区域(建议使用环境变量) ====================
// 在 Cloudflare Worker 的设置中,将以下变量配置为环境变量,避免硬编码
// 例如:TARGET_URL = "http://jp.5.frp.one:15200"
const DEFAULT_TARGET_URL = 'http://jp.5.frp.one:15200';

// 新增:备用节点域名(错误时跳转)
const REDIRECT_DOMAIN = 'https://exyone.us.kg';

// 需要修改的请求头列表(移除可能引起问题的客户端头)
const HOP_BY_HOP_HEADERS = [
  'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
  'te', 'trailers', 'transfer-encoding', 'upgrade' // upgrade 需要特殊处理(WebSocket)
];

// 需要隐藏的响应头(避免泄露后端信息)
const SENSITIVE_RESPONSE_HEADERS = [
  'server', 'x-powered-by', 'x-aspnet-version'
];

// ========== 新增:错误页面生成函数(含自动跳转) ==========
function buildErrorPage(statusCode, errorMessage, details) {
  const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>节点风雪受阻 - 跳转备用节点</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
  
    body {
      font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      color: #e2e8f0;
      min-height: 100vh; 
      display: flex; 
      align-items: center; 
      justify-content: center; 
      padding: 16px;
      /* 深邃的冬日蓝渐变背景 */
      background: linear-gradient(to bottom, #0c1929 0%, #1e3a8a 50%, #1e40af 100%);
      overflow: hidden;
      position: relative;
    }

    /* ==================== 落雪动画系统 ==================== */
    .snowflakes { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; }
    .snowflake {
      position: absolute;
      top: -20px;
      color: #fff;
      opacity: 0;
      animation: fall linear infinite;
      will-change: transform, opacity;
    }
    /* 定义不同的飘落轨迹和速度 */
    .s1 { left: 10%; animation-duration: 10s; animation-delay: 0s; opacity: 0.8; }
    .s2 { left: 20%; animation-duration: 15s; animation-delay: 2s; opacity: 0.6; }
    .s3 { left: 30%; animation-duration: 12s; animation-delay: 5s; opacity: 0.9; }
    .s4 { left: 45%; animation-duration: 18s; animation-delay: 1s; opacity: 0.5; }
    .s5 { left: 55%; animation-duration: 11s; animation-delay: 7s; opacity: 0.7; }
    .s6 { left: 70%; animation-duration: 14s; animation-delay: 3s; opacity: 0.8; }
    .s7 { left: 85%; animation-duration: 16s; animation-delay: 6s; opacity: 0.4; }
    .s8 { left: 90%; animation-duration: 9s;  animation-delay: 8s; opacity: 0.9; }
    .s9 { left: 5%;  animation-duration: 20s; animation-delay: 4s; opacity: 0.6; }
    .s10{ left: 60%; animation-duration: 13s; animation-delay: 9s; opacity: 0.7; }
  
    @keyframes fall {
      0%   { transform: translateY(-20px) rotate(0deg); opacity: 0; }
      10%  { opacity: 1; }
      90%  { opacity: 1; }
      100% { transform: translateY(100vh) rotate(360deg); opacity: 0; }
    }

    /* ==================== 底部 SVG 装饰(雪山剪影) ==================== */
    .mountain-decoration {
      position: fixed; bottom: 0; left: 0; width: 100%; 
      z-index: 0; line-height: 0;
      opacity: 0.4;
    }

    /* ==================== 毛玻璃卡片 ==================== */
    .card {
      max-width: 680px; 
      width: 100%; 
      border-radius: 20px;
      padding: 32px;
      position: relative;
      z-index: 10;
      /* 冰蓝色调的毛玻璃 */
      background: rgba(15, 23, 42, 0.65);
      backdrop-filter: blur(20px) saturate(150%);
      -webkit-backdrop-filter: blur(20px) saturate(150%);
      border: 1px solid rgba(147, 197, 253, 0.15);
      box-shadow: 
        0 8px 32px 0 rgba(0, 0, 0, 0.5),
        inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
      animation: cardIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
    }

    @keyframes cardIn {
      from { opacity: 0; transform: translateY(30px) scale(0.98); }
      to { opacity: 1; transform: translateY(0) scale(1); }
    }

    /* ==================== 强调文字 ==================== */
    strong {
      font-weight: 700;
      /* 冰蓝渐变文字 */
      background: linear-gradient(120deg, #93c5fd 0%, #60a5fa 50%, #3b82f6 100%);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
      background-clip: text;
    }

    .badge {
      display: inline-flex; align-items: center; gap: 6px;
      font-size: 12px; font-weight: 600;
      padding: 5px 12px; border-radius: 999px;
      background: rgba(56, 189, 248, 0.15);
      color: #7dd3fc;
      border: 1px solid rgba(56, 189, 248, 0.3);
      margin-bottom: 16px;
    }

    h1 { 
      font-size: 20px; 
      font-weight: 500; 
      margin-bottom: 16px; 
      color: #f1f5f9;
      line-height: 1.5;
    }
  
    p { 
      font-size: 14px; 
      color: #94a3b8; 
      margin-top: 10px; 
      line-height: 1.6;
    }

    .code {
      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 
      font-size: 13px;
      padding: 3px 8px; 
      border-radius: 6px; 
      background: rgba(30, 58, 138, 0.4); 
      color: #bfdbfe;
      border: 1px solid rgba(59, 130, 246, 0.2);
    }

    .divider { 
      border: none; 
      border-top: 1px solid rgba(148, 163, 184, 0.1); 
      margin: 20px 0; 
    }

    a.button {
      display: inline-flex; 
      align-items: center; 
      gap: 8px;
      font-size: 15px; 
      font-weight: 600; 
      text-decoration: none;
      padding: 12px 24px; 
      border-radius: 14px; 
      color: #ffffff;
      background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
      border: 1px solid rgba(96, 165, 250, 0.4);
      box-shadow: 0 10px 25px -5px rgba(37, 99, 235, 0.5);
      transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
      margin-top: 8px;
    }
  
    a.button:hover { 
      transform: translateY(-3px); 
      box-shadow: 0 15px 30px -5px rgba(37, 99, 235, 0.6);
      background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
    }

    .countdown { 
      font-size: 13px; 
      color: #64748b; 
      margin-top: 16px; 
    }
  
    .countdown span {
      color: #93c5fd;
      font-weight: 600;
    }
  </style>
</head>
<body>

  <!-- 纯 SVG 雪花群 -->
  <div class="snowflakes" aria-hidden="true">
    <!-- 简约线条雪花 SVG,尺寸由外层 CSS 控制 -->
    <span class="snowflake s1"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/><line x1="19.07" y1="4.93" x2="4.93" y2="19.07"/></svg></span>
    <span class="snowflake s2"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="4"/></svg></span>
    <span class="snowflake s3"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/><line x1="19.07" y1="4.93" x2="4.93" y2="19.07"/></svg></span>
    <span class="snowflake s4"><svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5"/></svg></span>
    <span class="snowflake s5"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/></svg></span>
    <span class="snowflake s6"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/><line x1="19.07" y1="4.93" x2="4.93" y2="19.07"/></svg></span>
    <span class="snowflake s7"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg></span>
    <span class="snowflake s8"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/><line x1="19.07" y1="4.93" x2="4.93" y2="19.07"/></svg></span>
    <span class="snowflake s9"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="4"/></svg></span>
    <span class="snowflake s10"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/></svg></span>
  </div>

  <!-- 底部雪地/雪山 SVG 装饰 -->
  <div class="mountain-decoration" aria-hidden="true">
    <svg viewBox="0 0 1440 320" preserveAspectRatio="none" style="width: 100%; height: auto;">
      <path fill="#1e3a8a" fill-opacity="0.6" d="M0,224L48,213.3C96,203,192,181,288,181.3C384,181,480,203,576,224C672,245,768,267,864,261.3C960,256,1056,224,1152,197.3C1248,171,1344,149,1392,138.7L1440,128L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"></path>
      <path fill="#0f172a" fill-opacity="0.8" d="M0,288L60,272C120,256,240,224,360,218.7C480,213,600,235,720,245.3C840,256,960,256,1080,245.3C1200,235,1320,213,1380,202.7L1440,192L1440,320L1380,320C1320,320,1200,320,1080,320C960,320,840,320,720,320C600,320,480,320,360,320C240,320,120,320,60,320L0,320Z"></path>
    </svg>
  </div>

  <!-- 主要内容卡片 -->
  <div class="card">
    <span class="badge">
      <!-- 小雪花图标 -->
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/><line x1="19.07" y1="4.93" x2="4.93" y2="19.07"/></svg>
      线路风雪受阻
    </span>
  
    <h1>当前 <strong>日本节点</strong> 连接受阻,请启用备用通道</h1>

    <p>捕获异常:<span class="code">HTTP ${statusCode}</span></p>
    <p>受阻原因:<span class="code">${escapeHtml(errorMessage)}</span></p>
    <p>诊断日志:<span class="code">${escapeHtml(details)}</span></p>

    <hr class="divider">

    <p><strong>为您推荐通往立陶宛的平稳路线:</strong></p>
    <a href="${REDIRECT_DOMAIN}" class="button" id="manualLink">
      破冰启程,前往 ${REDIRECT_DOMAIN}
    </a>
    <p class="countdown">
      正在为您规划新路线,<span id="seconds">5</span> 秒后自动发车…
    </p>
  </div>

  <script>
    let countdown = 5;
    const secondsSpan = document.getElementById('seconds');
    const timer = setInterval(() => {
      countdown--;
      if (secondsSpan) secondsSpan.innerText = countdown;
      if (countdown <= 0) {
        clearInterval(timer);
        window.location.href = '${REDIRECT_DOMAIN}';
      }
    }, 1000);
    document.getElementById('manualLink')?.addEventListener('click', () => clearInterval(timer));
  </script>
</body>
</html>`;

  return new Response(html, {
    status: statusCode,
    headers: {
      'Content-Type': 'text/html; charset=utf-8',
      'Cache-Control': 'no-cache',
      'Referrer-Policy': 'no-referrer',
    },
  });
}

function escapeHtml(str) {
  if (!str) return '';
  return str.replace(/[&<>"']/g, (m) => {
    if (m === '&') return '&';
    if (m === '<') return '<';
    if (m === '>') return '>';
    if (m === '"') return '"';
    if (m === "'") return ''';
    return m;
  });
}

// ========== 错误页部分结束 ==========

export default {
  async fetch(request, env, ctx) {
    const TARGET_URL = env.TARGET_URL || DEFAULT_TARGET_URL;

    try {
      const url = new URL(request.url);
      const targetUrl = TARGET_URL + url.pathname + url.search;

      // 创建一个新的 Request 对象,复制原始请求的方法、body 和大部分头部
      // 但需要清理一些不必要的头部
      const proxyRequestInit = {
        method: request.method,
        headers: sanitizeRequestHeaders(request.headers),
        body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined,
      };

      // 使用 AbortController 实现超时控制(例如 15 秒)
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 15000);
      proxyRequestInit.signal = controller.signal;

      // 构建代理请求
      const proxyRequest = new Request(targetUrl, proxyRequestInit);

      // 设置重要的代理头部,帮助后端识别真实客户端
      proxyRequest.headers.set('X-Forwarded-For', request.headers.get('cf-connecting-ip') || '');
      proxyRequest.headers.set('X-Forwarded-Proto', url.protocol.replace(':', ''));
      proxyRequest.headers.set('X-Forwarded-Host', url.host);
      proxyRequest.headers.set('X-Real-IP', request.headers.get('cf-connecting-ip') || '');

      // 注意:Workers 的 fetch() 会以请求 URL 的 host 作为 Host,无法也不建议手动覆盖
      // 若需要后端识别特定主机名,可通过 X-Forwarded-Host 或自定头部传递

      // 发送请求到目标服务器
      let response = await fetch(proxyRequest);
      clearTimeout(timeoutId);

      // 响应属性不可变,需通过构造新 Response 来修改
      response = new Response(response.body, response);
      sanitizeResponseHeaders(response.headers);

      // 处理重定向时的 Location 头重写(如果想让客户端直接访问 Worker 的域名,而不是目标域名)
      if (response.status >= 300 && response.status < 400) {
        const location = response.headers.get('location');
        if (location) {
          try {
            const locationUrl = new URL(location, TARGET_URL); // 解析绝对或相对路径
            // 将 location 重写为当前 Worker 的域名 + 原路径
            const newLocation = url.protocol + '//' + url.host + locationUrl.pathname + locationUrl.search;
            response.headers.set('location', newLocation);
          } catch (e) {
            // 如果 location 不是合法 URL,则保持原样
          }
        }
      }

      return response;

    } catch (error) {
      // 详细的错误分类处理(将原来的 JSON 响应替换为 HTML 错误页面)
      let status = 500;
      let message = 'Internal Server Error';

      if (error.name === 'AbortError') {
        status = 504; // Gateway Timeout
        message = 'Upstream timeout';
      } else if (error.message && error.message.includes('DNS')) {
        status = 502; // Bad Gateway
        message = 'DNS lookup failed';
      } else if (error.message && /fetch|connection|network/i.test(error.message)) {
        status = 502;
        message = 'Upstream connection failed';
      }

      // 返回极简 HTML 错误页面(包含自动跳转)
      return buildErrorPage(status, message, error.message);
    }
  }
};

/**
 * 清理请求头,移除 hop-by-hop 头以及一些可能导致问题的自定义头
 */
function sanitizeRequestHeaders(headers) {
  const sanitized = new Headers(headers);
  HOP_BY_HOP_HEADERS.forEach(header => sanitized.delete(header));
  // 也可以根据需要移除其他头,比如 Referer、Origin 等(如果不想透传)
  return sanitized;
}

/**
 * 清理响应头,移除敏感信息和 hop-by-hop 头
 */
function sanitizeResponseHeaders(headers) {
  HOP_BY_HOP_HEADERS.forEach(header => headers.delete(header));
  SENSITIVE_RESPONSE_HEADERS.forEach(header => headers.delete(header));
}

代码更新说明

  • 安全性:清理了敏感头,增加了超时保护(15秒),并对 Location 重定向做了相对路径处理。
  • 可维护性:所有关键配置走环境变量,方便后续切换节点,避免了旧版本“硬编码”的问题。

评论