数据库丢失危机与内网穿透节点失联,改进 Cloudflare Worker 反向代理报错页面
当我将博客从传统的云服务器迁回家里的自托管环境时,便知这场关于「控制欲」的实验注定不会平静。果不其然,频繁的配置变更与环境重构,让这台机器接连上演了几出令人心跳骤停的「惊魂记」。
数据库的至暗时刻
最先亮起红灯的是数据库。昨天晚上我正于宝塔面板中调试新的软件,指尖一滑,竟触发了不可逆的库删除操作。万幸的是,在迁移的前夕,出于一种近乎本能的谨慎,我已在面板内完成了一份全量数据库备份。否则,那些积攒经年的文章、访客留下的评论,以及无数次微调过的配置文件,恐将瞬间化作虚无。
经此一劫,我意识到单纯的数据库备份在灾难面前何其脆弱。在此郑重建议诸位:务必采用 Halo 博客系统自带的备份机制,执行定期的全量打包。若仅依赖数据库快照,即便数据侥幸复原,那些上传的附件、定制的主题文件与安装的插件,仍可能散落遗失,最终陷入「文章归来,面目全非」的尴尬废墟。所幸我插件不多,尚能凭借记忆在后台逐一召回。
穿透节点的连环崩坏
数据库的警报声尚未完全消散,内网穿透的节点又宣告阵亡。长期倚仗的日本节点(jp.5.frp.one)毫无征兆地失联,导致架设其上的 Cloudflare Worker 反向代理瞬间沦为摆设。访客试图推开博客大门时,映入眼帘的却是几串冰冷、毫无美感的 JSON 报错代码。更为致命的是,彼时我的主域名与备用域名竟共用了同一条隧道——这无异于将所有鸡蛋置于一篮,结果是「屋漏偏逢连夜雨」,双域同崩,博客彻底陷入瘫痪。
经过一番紧急抢救,目前服务已恢复如常。为了防止「单点失效」的悲剧重演,我特意为备用域名 (现在我把所有域名迁移到 Cloudflare Tunnel 了,虽然速度偏慢,但远比 ChmlFrp 要稳定),将其作为可靠的「后手」。从此,即便主节点再度失联,访客仍能借由备用通道窥见这片数字花园的一角。exyone.us.kg绑定了立陶宛节点(ltw.frp.one)
重构有温度的错误美学
借着此次排障契机,我也顺手重构了 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 重定向做了相对路径处理。
- 可维护性:所有关键配置走环境变量,方便后续切换节点,避免了旧版本“硬编码”的问题。
评论