域名 / IP 被墙了怎么办?前端多入口容灾的完整落地方案
一句话结论:浏览器里不能指定 IP、不能控制 DNS,所以"被墙"场景下前端唯一能掌控的资源就是域名。方案核心是:域名池 + 签名下发 + 客户端竞速选线 + 老用 2026-11-1 06:15:0 Author: blog.upx8.com(查看原文) 阅读量:10 收藏

一句话结论:浏览器里不能指定 IP、不能控制 DNS,所以"被墙"场景下前端唯一能掌控的资源就是域名。方案核心是:域名池 + 签名下发 + 客户端竞速选线 + 老用户自动迁移 + 众包探测自动换域名。服务端负责让每个域名背后的 IP 彼此独立,不要"一个 IP 被封、所有域名陪葬"。


一、先搞清楚"被墙"到底是哪一种

"打不开"在用户眼里是一个现象,在网络层面至少有四种完全不同的形态。手段要对症,否则全是白做。

形态 机制 前端可观察到的现象 有效手段 无效手段
DNS 污染 / 劫持 解析返回错误 IP 或空 连接到错误地址、证书报错、NXDOMAIN 换域名;App/小程序内用 DoH 解析 同域名重试
IP 封锁(黑洞) 目标 IP 丢包 请求长时间挂起直到超时 换 IP(换 CDN/换入口);对冲请求 同 IP 下换域名
SNI / TCP RST 按域名识别后重置连接 握手阶段被重置,ERR_CONNECTION_RESET 换域名;子域轮换;不同域名不同证书 换 IP 不换域名
平台/浏览器拦截 微信、QQ、浏览器安全库把域名标红 出现拦截提示页,而非网络错误 换域名;域名提前"预热"养信誉 任何网络层手段

由此得到两条硬规则:

  1. 每个域名背后的 IP 必须相互独立。如果十个域名全指向同一个 IP,IP 一封全灭,域名池形同虚设。
  2. 每个域名的证书、注册商、权威 DNS 尽量分散。一张证书的 SAN 里放一堆域名,一个被标记就会被整批关联。

合规说明:面向中国大陆用户的加速域名需要完成 ICP 备案;域名池里的每个域名都应当是合规、可备案的业务域名。本文讨论的是提升业务可用性与网络路径多样性,不涉及规避监管,也不适用于内容本身违规的站点。


二、浏览器的硬限制(决定了方案的形状)

想做的事 浏览器能不能做 结论
直连某个 IP 并带上指定域名 SNI ❌ 无法"IP 直连兜底"
自己做 DNS 解析 ❌ 无法绕过 DNS 污染,只能换域名
通过 DoH 的 HTTP 接口查询 TXT 记录 ✅ 可以用作"域名池下发渠道"
并行探测多个域名并选最快 ✅ 竞速选线
在 Service Worker 里拦截导航并返回兜底页 ✅ 老用户自动迁移的基础
记住上次可用的域名 ✅ localStorage / IDB

所以:App / 小程序 / Electron 客户端可以额外做 DoH 解析、IP 直连、自带域名轮换;纯网页只能靠域名。下文以纯网页为主,并在对应处标注 App 可增强的点。


三、总体架构

            ┌──────────────── 触达层(用户不依赖任何单一域名也能找到你)────────────────┐
            │  独立导航页 · App 内置 · 公众号/社群/邮件/短信 · 搜索引擎品牌词 │
            └────────────────────────────────────────────────────────────────────────┘
用户 ──▶ ① 域名池(内置 + 多渠道签名下发)
         ② 竞速选线(并行探测,谁通走谁)
         ③ Service Worker 兜底页(入口被封时自动迁移)
         ④ 业务请求多域名 + 重试预算
         ⑤ 众包探测上报 ──▶ 服务端判定"某域名在某地区/运营商失效" ──▶ 自动换域名、下发新池

服务端基础设施配合:

域名A ──▶ CDN厂商1 ──▶ 回源网段1 ┐
域名B ──▶ CDN厂商2 ──▶ 回源网段2 ├──▶ 源站(只放行回源网段,校验回源密钥头)
域名C ──▶ 独立IP / 云厂商3 ─────┘

四、域名资产管理:把"换域名"做成流水线

被墙是常态而不是意外,所以域名要像耗材一样管理:

采购 → 备案 → 配置(独立IP/CDN/证书) → 预热(小流量) → 上线 → 持续监测 → 摘除 → 替换

要点:

  1. 储备量:线上常驻 3~5 个入口域名,库里再备 5 个以上"已备案、已解析、已预热"的备用域名。
  2. 预热:备用域名平时承接 1%~5% 的真实流量。新域名信誉为零,突然启用更容易被平台拦截。
  3. 分散:不同注册商、不同后缀(.com / .net / .cn 等,注意备案要求)、不同权威 DNS、不同 CDN、不同证书。
  4. 泛域名子域轮换(对付"按域名封"的补充手段):使用 *.example.com 通配证书,客户端访问 随机串.example.com。单个子域被封,换一个子域即可,主域不受影响。注意:如果封锁策略是按主域整体封,这一招无效,所以它是补充,不是替代。
  5. 监测:云拨测(多地区 × 多运营商)每分钟探测每个域名,加上客户端众包上报(见第八节)。任一域名在某地区可达率持续低于阈值,自动告警并触发摘除。
  6. 别在公开场合同时暴露所有域名。域名池只通过签名渠道下发给客户端,不要把全部备用域名写在公开页面上。

五、域名池:多渠道签名下发

5.1 设计原则

  1. 包内内置一组域名,是最后底线,不依赖任何远端。
  2. 多个互相独立的下发渠道,客户端并行拉取,取"最快且验签通过"的:
    • 渠道 A:云厂商 A 的对象存储 + CDN 上的静态 JSON;
    • 渠道 B:云厂商 B 的对象存储;
    • 渠道 C:业务域名上的副本;
    • 渠道 D:DoH 查询 TXT 记录(通过 fetch 读取,渠道本身是 DoH 服务商的域名,与你的业务域名完全无关)。
  3. 必须签名,并带 version(防回滚)和 expiresAt(防过期)。否则劫持任一渠道就能把用户导向恶意域名。
  4. 私钥离线保管,客户端内置两把公钥,便于轮换。

5.2 服务端:生成并签名

// scripts/sign-pool.mjs
import { sign } from 'node:crypto';
import { writeFileSync } from 'node:fs';

// 一次性:生成 P-256 密钥对,私钥离线保存,公钥(JWK)写入前端包
// const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });

const privateKey = /* 从离线介质读取 PEM */ null;

const payload = JSON.stringify({
  v: 21,                                     // 版本号,每次发布递增
  iat: Date.now(),
  exp: Date.now() + 7 * 24 * 3600 * 1000,    // 7 天过期,强制定期更新
  entry: ['https://www.example.com', 'https://www.example.net', 'https://www.example.org'],
  api:   ['https://api.example.com', 'https://api.example.net', 'https://api.example.org'],
  cdn:   ['https://cdn-a.example.com', 'https://cdn-b.example.net']
});

// WebCrypto 验证的是 IEEE P1363 格式(r||s),签名时必须指定
const sig = sign('sha256', Buffer.from(payload), {
  key: privateKey, dsaEncoding: 'ieee-p1363'
}).toString('base64');

writeFileSync('pool.json', JSON.stringify({ payload, sig }));
// 同时发布到 ≥ 2 家不同云的对象存储;TXT 渠道见下

5.3 TXT 渠道(可选,但很有价值)

把 base64(payload) + '.' + sig 切成多段,放在若干条 TXT 记录里(单条字符串 ≤ 255 字节,所以要分条,如 p1.pool.example.com ~ p4.pool.example.com)。客户端通过公共 DoH 的 JSON 接口读取:

// 通过 DoH JSON 接口查询 TXT(接口形态以所选 DoH 服务商文档为准)
async function readTxt(name, dohBase /* 如 'https://dns.alidns.com/resolve' */) {
  const r = await fetch(`${dohBase}?name=${encodeURIComponent(name)}&type=TXT`, {
    headers: { accept: 'application/dns-json' }, cache: 'no-store'
  });
  const j = await r.json();
  return (j.Answer || []).map((a) => a.data.replace(/^"|"$/g, '')).join('');
}

说明:TXT 渠道的可达性取决于 DoH 服务商本身,且受限于单条记录长度;建议只用来下发精简版域名列表 + 签名,作为第三、第四条渠道,而不是唯一渠道。

5.4 客户端:并行拉取 + 验签 + 防回滚

// pool/pool.js
const BUILTIN = {
  v: 0,
  entry: ['https://www.example.com', 'https://www.example.net', 'https://www.example.org'],
  api:   ['https://api.example.com', 'https://api.example.net', 'https://api.example.org'],
  cdn:   ['https://cdn-a.example.com', 'https://cdn-b.example.net']
};

// 内置两把公钥:当前 + 下一把(轮换用)
const PUBKEYS = [ /* JWK_CURRENT */, /* JWK_NEXT */ ];

// 互相独立的渠道:不同云、不同域名
const CHANNELS = [
  'https://cfg-a.example-static.com/pool.json',
  'https://cfg-b.example-oss.net/pool.json',
  'https://api.example.com/pool.json'
];

const LS = 'dr:pool';
const readCached = () => { try { return JSON.parse(localStorage.getItem(LS)); } catch { return null; } };

async function verify(env) {
  const data = new TextEncoder().encode(env.payload);
  const sig = Uint8Array.from(atob(env.sig), (c) => c.charCodeAt(0));
  for (const jwk of PUBKEYS) {
    const key = await crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
    if (await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, sig, data)) return true;
  }
  return false;
}

async function pull(url, cached) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 3000);
  try {
    const res = await fetch(url, { signal: ctrl.signal, cache: 'no-store' });
    if (!res.ok) throw new Error('status-' + res.status);
    const env = await res.json();
    if (!(await verify(env))) throw new Error('bad-sig');
    const pool = JSON.parse(env.payload);
    if (pool.exp < Date.now()) throw new Error('expired');
    if (cached && pool.v < cached.v) throw new Error('rollback');
    return pool;
  } finally { clearTimeout(timer); }
}

let current = readCached() || BUILTIN;
let last = 0;

export async function refreshPool(force = false) {
  if (!force && Date.now() - last < 30000) return current;     // 节流,避免故障期自我 DDoS
  last = Date.now();
  try {
    const pool = await Promise.any(CHANNELS.map((u) => pull(u, readCached())));
    current = pool;
    try { localStorage.setItem(LS, JSON.stringify(pool)); } catch {}
  } catch { /* 全部失败:继续用缓存/内置,不抛错 */ }
  return current;
}

export const getPool = () => current;
refreshPool();
setInterval(() => refreshPool(), 10 * 60 * 1000);

六、竞速选线:别等超时,并行探测

被墙里最常见的是黑洞型:请求不报错,只是一直挂着。传统"超时 3s → 换下一个"体验极差。正确做法是探测并行化。

6.1 探测端点

每个域名提供一个极轻量的健康端点:

GET /__health  →  204,带 Access-Control-Allow-Origin: *,Cache-Control: no-store

6.2 竞速选线

// net/race.js
import { getPool } from '../pool/pool.js';

function probe(base, ms = 2500) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), ms);
  const t0 = performance.now();
  return fetch(base + '/__health?_=' + Date.now(), { cache: 'no-store', signal: ctrl.signal })
    .then((r) => { if (!r.ok) throw new Error('status-' + r.status); return { base, rtt: performance.now() - t0 }; })
    .finally(() => clearTimeout(timer));
}

// 并行探测一组域名,返回最先成功的;上次成功的域名优先开跑,其余错开 300ms 起跑,兼顾速度与请求量
export function raceBest(domains, { stagger = 300 } = {}) {
  let last = null; try { last = localStorage.getItem('dr:best'); } catch {}
  const ordered = last && domains.includes(last) ? [last, ...domains.filter((d) => d !== last)] : domains;

  return Promise.any(ordered.map((d, i) =>
    new Promise((r) => setTimeout(r, i === 0 ? 0 : stagger)).then(() => probe(d))
  )).then((hit) => {
    try { localStorage.setItem('dr:best', hit.base); } catch {}
    return hit.base;
  });
}

// 入口:选出可用 API 域名
export const pickApiBase = () => raceBest(getPool().api);

6.3 区分"用户断网"与"站点被封"

两者用户提示完全不同,必须区分:

// 同时探测:你的域名 + 一个几乎必通的公共探针(图片,no-cors 即可)
async function diagnose(bases) {
  const mine = await Promise.any(bases.map((b) => fetch(b + '/__health', { cache: 'no-store' }))).then(() => true, () => false);
  if (mine) return 'ok';
  const world = await new Promise((resolve) => {
    const img = new Image(); const t = setTimeout(() => resolve(false), 3000);
    img.onload = () => { clearTimeout(t); resolve(true); };
    img.onerror = () => { clearTimeout(t); resolve(false); };
    img.src = 'https://www.baidu.com/favicon.ico?_=' + Date.now();   // 换成你所在用户群体一定可达的探针
  });
  return world ? 'site-unreachable' : 'client-offline';   // 前者:提示"正在切换线路";后者:提示"请检查网络"
}

七、老用户自动迁移:入口域名被封,用户怎么自己"搬家"

用户收藏的、历史记录里的,是已经被封的那个域名。他们能打开这个域名的唯一机会,是:浏览器里已经装了 Service Worker,且请求没有被网络层彻底阻断(例如被封之前就缓存的 offline.html)。

现实提醒:如果当前网络直接丢弃了所有到该域名的请求,浏览器也无法执行 SW 的网络请求,但 SW 依然可以在本地拦截导航并返回缓存里的兜底页——这正是这一层的价值:不依赖网络,也能把用户带到新入口。前提是用户之前访问过,SW 已安装。

7.1 Service Worker(精简版)

/* sw.js —— 目标:入口被封时,仍能返回应用壳或迁移页 */
const VERSION = '__BUILD_ID__';
const PRE = `pre-${VERSION}`;
const ASSETS = 'assets-v1';
const SHELL = '/index.html';
const MANIFEST = self.__PRECACHE_MANIFEST__ || [SHELL, '/offline.html'];   // 必须由构建生成
const HASHED = /\/assets\/.+\.[0-9a-f]{8,}\.(js|css|woff2?|png|jpe?g|svg|webp)$/i;

self.addEventListener('install', (e) => {
  e.waitUntil((async () => {
    const c = await caches.open(PRE);
    await Promise.allSettled(MANIFEST.map((u) => c.add(new Request(u, { cache: 'reload' }))));   // 单条失败不拖垮整体
  })());
});

self.addEventListener('activate', (e) => {
  e.waitUntil((async () => {
    const ks = await caches.keys();
    await Promise.all(ks.filter((k) => k.startsWith('pre-') && k !== PRE).map((k) => caches.delete(k)));
    await self.clients.claim();
  })());
});

self.addEventListener('message', (e) => {
  if (e.data && e.data.type === 'SKIP_WAITING') self.skipWaiting();
});

async function isShell(res) {
  if (!res.ok || !(res.headers.get('content-type') || '').includes('text/html')) return false;
  return (await res.clone().text()).includes('name="app-shell"');    // 防止把劫持页/拦截页缓存成首页
}

async function nav(req) {
  const c = await caches.open(PRE);
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), 2500);
  try {
    const res = await fetch(req, { signal: ctrl.signal });
    if (res.status >= 500) throw new Error('bad');
    if (await isShell(res)) c.put(SHELL, res.clone());              // 只缓存真正的应用壳
    else if (res.ok) throw new Error('not-shell');                  // 200 但不是应用壳:多半是拦截页/劫持页
    return res;
  } catch {
    // 失败或不是应用壳:直接给迁移页(offline.html 会自己探测并跳转)
    return (await c.match('/offline.html')) || (await c.match(SHELL)) ||
      new Response('offline', { status: 503, headers: { 'content-type': 'text/plain' } });
  } finally { clearTimeout(t); }
}

self.addEventListener('fetch', (e) => {
  const req = e.request;
  if (req.method !== 'GET') return;
  const url = new URL(req.url);
  if (req.mode === 'navigate' && url.origin === self.location.origin) { e.respondWith(nav(req)); return; }
  if (HASHED.test(url.pathname)) {
    e.respondWith((async () => {
      const cache = await caches.open(ASSETS);
      const key = new Request(url.pathname);                        // 缓存键不含域名:换 CDN/换域名也命中
      const hit = await cache.match(key);
      if (hit) return hit;
      const res = await fetch(req);
      if (res.ok && res.type !== 'opaque') cache.put(key, res.clone()).catch(() => {});
      return res;
    })());
  }
});

要点:

  • SW 不拦截 API:接口缓存容易串用户数据,数据降级放在页面层。
  • 缓存键只用 pathname:同一份静态资源,换了域名依然命中。
  • sw.js 与 index.html 必须 Cache-Control: no-cache,否则更新通道会被缓存卡死。
  • 准备 kill-switch:SW 出 bug 会把用户锁死,要有一份"清缓存 + 注销自己"的自毁版随时可替换发布。
/* kill-sw.js —— 紧急时替换 /sw.js */
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil((async () => {
  await Promise.all((await caches.keys()).map((k) => caches.delete(k)));
  await self.registration.unregister();
  (await self.clients.matchAll({ type: 'window' })).forEach((c) => c.navigate(c.url));
})()));

7.2 offline.html:自带探测与跳转

<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>正在切换线路…</title>
<div id="m" style="font:16px/1.6 system-ui;padding:24px">正在为你切换到可用线路…</div>
<script>
(async () => {
  const BUILTIN = ['https://www.example.com','https://www.example.net','https://www.example.org'];
  let pooled = [];
  try { pooled = JSON.parse(localStorage.getItem('dr:pool') || '{}').entry || []; } catch (e) {}
  const list = [...new Set([...pooled, ...BUILTIN])].filter((d) => d !== location.origin);

  const probe = (d) => {
    const c = new AbortController(); setTimeout(() => c.abort(), 3000);
    return fetch(d + '/__health?_=' + Date.now(), { cache: 'no-store', signal: c.signal })
      .then((r) => { if (!r.ok) throw 0; return d; });
  };

  const hop = Number(new URLSearchParams(location.search).get('_hop') || 0);   // 防循环
  try {
    if (hop >= 2) throw 0;
    const best = await Promise.any(list.map(probe));
    location.replace(best + '/?_hop=' + (hop + 1));
  } catch (e) {
    document.getElementById('m').innerHTML = '暂时无法连接,请稍后重试,或尝试:' +
      list.map((d) => '<br><a href="' + d + '">' + d + '</a>').join('');
  }
})();
</script>

7.3 迁移后的登录态

localStorage 与 Cookie 都按域名隔离,新域名不会带着旧域名的登录态,并且绝不能把 token 放进 URL。可选做法:

做法 体验 说明
新域名重新登录 一般 最简单,适合低频场景
一次性迁移码 好 旧域名(能打开时)生成 60 秒有效、单次使用的 ticket,跳转新域名后用 ticket 换新 token;ticket 放 URL fragment(#t=...),读取后立即清除
统一用 Bearer token 并由新域名静默刷新 好 配合 refresh token 的服务端校验

八、众包探测:让用户帮你发现"哪个域名死了"

被墙往往是区域性、运营商性的,机房里的拨测看不全。让客户端把"某域名在我这里不通"上报回来,就是免费的全国拨测网络。

8.1 上报要点

  • 上报通道必须与被探测域名不同,例如走一个专用的、独立的统计域名,或经 navigator.sendBeacon。
  • 上报内容:失败的域名、失败阶段(超时/重置/状态码)、rtt、地区/运营商(服务端根据 IP 反查,客户端不要采集多余隐私)。
  • 本地缓冲、设上限、失败直接丢,别让观测链路反噬业务。
// telemetry.js
const buf = [];
export function reportFail(base, stage) {
  if (buf.length < 50) buf.push({ base, stage, ts: Date.now() });
}
setInterval(() => {
  if (!buf.length) return;
  const batch = buf.splice(0, buf.length);
  try { navigator.sendBeacon('https://stat.example-stat.com/dr', JSON.stringify(batch)); } catch {}
}, 30000);

8.2 服务端自动处置(闭环)

客户端上报 + 云拨测
        │
        ▼
按 (域名 × 省份 × 运营商) 聚合失败率
        │  失败率 > 阈值且持续 N 分钟,且样本量足够(防噪声/防恶意刷)
        ▼
标记该域名在该区域"疑似被封"
        │
        ├──▶ 下发新池:针对该区域把坏域名降权/移除,把预热过的备用域名补进来(签名版本 +1)
        ├──▶ 告警:通知运维替换域名
        └──▶ 进入"摘除→替换"流水线

注意防御恶意上报:对同一设备/IP 限频,要求样本量门槛,且下发"摘除"动作前建议结合云拨测交叉验证,避免被人刷数据把你的好域名摘掉。


九、业务请求:多域名 + 重试预算(简化版)

域名池里的 API 域名本质上是同一后端的多个入口,所以策略要分清:

失败类型 含义 动作
超时 / 连接重置 / DNS 失败 / 502 / 504 入口不通 换域名重试(有意义)
429 / 503(带 Retry-After) 后端明确在忙 不换域名,按 Retry-After 退避,展示降级 UI
200 但返回 HTML 多半是拦截页/挑战页 当失败处理,不要当 JSON 解析,也不要缓存
// net/api.js
import { getPool, refreshPool } from '../pool/pool.js';
import { reportFail } from '../telemetry.js';

// 全局重试预算:额外请求不得超过总请求的 10%(至少 3 次/分钟),防止故障期自我放大
const B = { reqs: 0, spent: 0, reset: 0 };
const tick = () => { const n = Date.now(); if (n > B.reset) { B.reqs = 0; B.spent = 0; B.reset = n + 60000; } };
const spend = () => { tick(); if (B.spent < Math.max(3, B.reqs * 0.1)) { B.spent++; return true; } return false; };

function ordered() {
  const l = [...getPool().api];
  try { const k = l.indexOf(localStorage.getItem('dr:api')); if (k > 0) l.unshift(l.splice(k, 1)[0]); } catch {}
  return l;
}

export function request(path, { method = 'GET', body, token, hedgeMs = 800, timeout = 3000 } = {}) {
  tick(); B.reqs++;
  const bases = ordered();
  const idem = method === 'GET' ? null : crypto.randomUUID();     // 写操作:所有尝试复用同一幂等键(服务端必须按它去重)
  const headers = {
    ...(token && { authorization: `Bearer ${token}` }),             // 多域名下不依赖 Cookie
    ...(body && { 'content-type': 'application/json' }),
    ...(idem && { 'x-idempotency-key': idem })
  };

  return new Promise((resolve, reject) => {
    const ctrls = []; const started = new Set(); let done = false, failed = 0;
    const end = (fn, v) => { if (done) return; done = true; ctrls.forEach((c) => c.abort()); fn(v); };

    const next = (i) => {
      failed++;
      if (failed >= bases.length) { refreshPool(); end(reject, new Error('all-failed')); }   // 全挂:立刻拉新池
      else start(i + 1);
    };

    const start = (i) => {
      if (done || started.has(i) || i >= bases.length) return;
      started.add(i);
      const base = bases[i];
      const c = new AbortController(); ctrls.push(c);
      const t = setTimeout(() => c.abort(), timeout);

      // 对冲:GET 且预算允许时,800ms 没响应就并行打下一个域名(对付"黑洞"最有效)
      if (method === 'GET') setTimeout(() => { if (!done && spend()) start(i + 1); }, hedgeMs);

      fetch(base + path, { method, headers, signal: c.signal, cache: 'no-store', body: body ? JSON.stringify(body) : undefined })
        .then((res) => {
          if (res.status === 429 || res.status === 503)
            return end(reject, Object.assign(new Error('overloaded'), { retryAfter: Number(res.headers.get('retry-after')) || 5 }));
          if (res.status >= 500) throw new Error('status-' + res.status);
          if (res.ok && !(res.headers.get('content-type') || '').includes('json')) throw new Error('not-json');
          try { localStorage.setItem('dr:api', base); } catch {}
          end(resolve, { res, base, idempotencyKey: idem });
        })
        .catch((err) => { if (!done) { reportFail(base, String(err.message || err)); next(i); } })
        .finally(() => clearTimeout(t));
    };

    start(0);
  });
}

服务端配合清单(缺一不可):

  • 幂等键去重:按 x-idempotency-key 去重(如 Redis SET NX,保留 24h)。没有它,就不要对写操作重试。
  • CORS:所有 API 域名放行所有入口域名,并允许 authorization, content-type, x-idempotency-key。
  • 同一份 DTO 契约:多个入口背后是同一后端,但要避免经由不同网关/版本造成响应漂移。

十、服务端 / 网络侧:让每个域名"死得独立"

这部分不写前端代码,但决定了域名池能不能真正生效。

  1. IP 隔离:每个入口域名绑定不同的 CDN 厂商或不同的 IP 段。能做到"一个 IP 段被封,只死一个域名",才是真多活。
  2. 回源多线路:CDN 回源到源站的 IP 不暴露;源站安全组只放行回源网段,并校验回源密钥头。
  3. 隐藏源站:清理历史 DNS 记录、证书透明度日志、邮件头里泄露的源站 IP;泄露过就换 IP。
  4. 证书分散:不同域名独立证书,不要塞同一张 SAN。
  5. DNS 分散:两家权威 DNS,TTL 60~300s,启用健康检查自动摘除。
  6. 健康端点:/__health 返回 204,极轻,不走业务逻辑,不要让它回源数据库。
  7. 导航页独立:托管在与主站完全不同的服务商、不同注册商的域名上,只做一件事——展示并跳转到当前可用入口。
# 源站:只信任回源网段,并校验回源密钥头
set_real_ip_from 203.0.113.0/24;        # 替换为厂商提供的回源网段
real_ip_header   X-Forwarded-For;

map $http_x_origin_auth $origin_ok { default 0; "replace-with-long-random-string" 1; }

server {
  listen 443 ssl;
  if ($origin_ok = 0) { return 444; }   # 非 CDN 回源直接断开
  location = /__health { return 204; add_header Access-Control-Allow-Origin * always; add_header Cache-Control no-store always; }
}

十一、触达层:让用户不依赖任何单一域名也能找到你

前面所有手段都假设"用户能拿到入口域名"。真正的最后一道保险是让用户有多条路径获知新入口:

渠道 做法
独立导航页 一个极简静态页,托管在完全独立的服务商 + 域名,永远指向当前可用入口
App / 客户端内 内置域名池 + 内置导航页入口,启动即竞速
官方公众号 / 社群 / 邮件 / 短信 事先沟通"如打不开请访问导航页 xxx"
搜索引擎品牌词 维护品牌词搜索结果指向最新入口
用户收藏的老域名 靠 SW 兜底页自动迁移(第七节)

十二、演练与验收

注入方式 操作 期望
IP 黑洞 防火墙对主 API 域名 IP 做 DROP(不是 REJECT) 对冲请求 ≤ 1.5s 切到备域名,用户无感
DNS 污染 hosts 把主域名指向 0.0.0.0 或错误 IP 自动换域名,reportFail 有记录
SNI/RST 模拟 代理对主域名直接断连 同上
入口域名被封 老用户访问被封域名(断开到该域名的网络) SW 返回迁移页,≤ 5s 跳转到可用入口
平台拦截页 mock 返回 200 + 拦截 HTML 不被缓存成首页;API 不当作 JSON
池渠道被劫持 返回签名错误 / 旧版本 / 已过期 全部被拒,继续用缓存或内置池
全部渠道失败 断开所有池渠道 继续使用缓存或内置池,业务不报错
全部域名同时失败 断开所有域名 展示快照/迁移页,且区分"断网"与"站点被封"
恶意上报 脚本刷大量虚假失败 服务端不会据此摘掉好域名
SW 出 bug 发布错误 SW kill-switch 能清理并恢复

验收标准:

  1. 主域名 IP 黑洞时,用户额外延迟 ≤ 1.5s。
  2. 入口域名被封,老用户 ≤ 5s 自动迁移,且无跳转循环。
  3. 任意一个域名、一个 CDN、一个池渠道失效,业务不报错。
  4. 故障期间客户端额外请求量 ≤ 正常请求量的 1.1 倍。
  5. 从"域名被判定被封"到"新池下发到客户端"的平均时长有明确 SLA(例如 ≤ 10 分钟)。

十三、上线检查清单

域名与网络

  • 常驻入口域名 ≥ 3,备用已备案/已解析/已预热的 ≥ 5
  • 每个域名绑定独立 IP 段或独立 CDN;证书、注册商、权威 DNS 分散
  • 源站隐藏,仅放行回源网段并校验回源密钥头
  • 每个域名有 /__health(204、CORS 放行、no-store)
  • 多地区 × 多运营商云拨测接入告警

池与下发

  • 包内内置域名池;签名池发布到 ≥ 2 家独立渠道(可选加 TXT 渠道)
  • 私钥离线,客户端内置两把公钥;v 防回滚、exp 防过期
  • 池刷新有节流(≥ 30s),全部失败时自动触发刷新

客户端

  • 竞速选线 + 上次成功域名优先
  • 区分"用户断网"与"站点不可达"并给出不同提示
  • sw.js、index.html 为 no-cache;预缓存清单由构建生成;导航缓存带 app-shell 校验
  • SW 不拦截 API;kill-switch SW 已演练
  • offline.html < 30KB、全内联、含探测跳转与防循环
  • 重试预算、对冲请求仅对 GET;429/503 不换域名
  • 登录态迁移方案已确定(迁移码走 fragment,不放 query)

闭环与触达

  • 客户端上报走独立域名,缓冲有上限
  • 服务端聚合 → 自动降权/摘除 → 下发新池 → 告警,全链路已联调
  • 独立导航页上线,已分发到 App/公众号/社群/短信模板

流程

  • 每季度一次故障注入演练,域名替换流水线每月至少空跑一次

十四、常见的坑

  1. 多个域名指向同一个 IP:IP 一封全灭,域名池形同虚设。
  2. 所有域名放进同一张证书:一个被标记,整批被关联。
  3. 只靠 Service Worker:首次访问的用户零保护;必须配合触达层与池下发。
  4. 配置/池与业务同域下发:主域被封时更新通道一起死。
  5. 池不签名:劫持任一渠道就能把用户导向恶意域名。
  6. 同域名重试:对 IP 黑洞和域名被封都没用,只会增加等待。
  7. 过载时还换域名重试:把压力放大到所有入口。
  8. 缓存了拦截页/劫持页当首页:写缓存前必须校验应用壳标记。
  9. 备用域名没预热:临时启用的新域名信誉为零,更容易被平台拦截。
  10. 迁移时把 token 放进 URL:会进入日志、历史、Referer;用单次迁移码,放 fragment 并立即清除。
  11. 上报没有防刷:被人刷假数据,好域名被自动摘除。
  12. navigator.onLine 当真理:连着 WiFi 但出站被封时仍为 true,要靠探测判断。
  13. 容灾代码自己没有容灾:兜底逻辑抛异常比没有兜底更糟,每个分支都要能安全返回。

十五、落地优先级

优先级 事项 说明
P0 域名资产流水线 + IP/CDN/证书/DNS 分散 没有这个,后面都是空中楼阁
P0 域名池 + 签名下发(≥ 2 渠道)+ 独立导航页 让首次访问用户也有救
P1 竞速选线 + 多域名 API + 重试预算 对付黑洞型故障
P1 Service Worker 迁移页 + kill-switch 让老用户自动搬家
P2 众包探测 + 自动摘除/下发闭环 缩短发现和处置时间
P3 App 端 DoH / IP 直连等增强 仅在有原生客户端时做

结语

被墙场景的本质是:你不能保证某个域名或 IP 永远可用,只能保证"换得快、换得到、换得安全"。

  • 换得到:域名池、独立导航页、多渠道触达,保证用户总能拿到一个新入口;
  • 换得快:竞速选线 + 对冲请求 + SW 迁移页,保证用户感知不到或只感知几秒;
  • 换得安全:签名 + 防回滚 + 防刷 + 预算控制,保证"换"这个动作本身不成为攻击面。

最后再强调一句:每个域名背后的 IP 必须独立,域名池才有意义;容灾要能指回具体故障,并且定期演练,没演练过的容灾等于没有。


文章来源: https://blog.upx8.com/IP
如有侵权请联系:admin#unsafe.sh