黑海洋 黑海洋 WIKI

域名 / IP 被墙了怎么办?前端多入口容灾的完整落地方案

开发 · 约 56 分钟 · 黑海洋 · 4,215

零、这篇和常见写法的差别在哪

网上讲「被墙了怎么办」的文章,绝大多数停在两个层面:

  • 有的是运营视角:域名池、换域名、备案、CDN 分散。这类内容对「运营治理」很到位,但默认了一个前提——前端能掌控的资源只有域名。
  • 有的是协议视角:ECH、IP 直连、TLS 指纹。这类内容技术密度高,但往往不谈资产流水线、重试预算、众包探测这些「跑起来之后才会痛」的事。

这篇想做的是把两层拼起来,并补上 2026 年才成立的两个新事实。所以全文按五个层来组织,每层只解决一件事:

五层纵深:每层只解决一件事

上面一层挂了,下面一层还在;下面一层也挂了,还有触达层。这个结构决定了后面所有代码的归属——写代码之前先想清楚这段逻辑属于哪一层,不属于任何一层的,就是过度设计。


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

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

四种「被墙」不是一种故障

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

由此得到两条硬规则,后面所有设计都要服从它们:

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

合规说明:面向中国大陆用户的加速域名需要完成 ICP 备案,域名池里每个域名都应当是合规、可备案的业务域名。本文讨论的是提升业务可用性与网络路径多样性。


二、2026 年两个新事实(这是本文和旧文最大的分歧点)

2.1 IP 地址证书已经 GA,「IP 直连兜底」这条路重新打开了

很多文章(包括很新的)会写:浏览器无法直连 IP 并指定域名 SNI,所以「IP 直连兜底」走不通。

前半句是对的,后半句在 2026 年 1 月 15 日之后不成立了。

Let's Encrypt 官方公告:IP 地址证书已 General Availability,支持 IPv4 和 IPv6,有效期 160 小时(约 6.7 天),在 ACME 客户端里选 shortlived profile 即可签发。

这条改变了一个基础事实:

  • 连接 IP 字面量时,ClientHello 里不携带 SNI(RFC 6066 明确规定 HostName 里不允许出现 IP 字面量)。所以「按域名识别后 RST」这套阻断,对着 IP 连接没有可匹配的字段。
  • DNS 环节整个消失。DNS 污染对你无效,因为压根没查 DNS。
  • 证书校验能过(IP SAN 匹配),所以 fetch 能正常发、能读 body、能走 CORS——不像自签名证书那样被浏览器直接拦掉。

注意区分两件事:

想做的事 能不能做 说明
连 IP,但伪装成某个域名的 SNI ✗ 浏览器 JS 做不到,也确实不需要
连 IP,不带 SNI,用 IP-SAN 证书 ✓ 这才是「IP 直连兜底」的正确形态

2.2 ECH 落地了,但必须绑 DoH

Cloudflare 文档写明:Free 套餐默认开启 ECH,外层 SNI 统一为 cloudflare-ech.com,中间人只看到「你在访问 Cloudflare 上的某个站」,看不到具体域名。Chrome 117+ / Firefox 118+ 支持,Android 17 已把它做进系统层并默认开启 ECH GREASE(连「这台机器开了 ECH」这个特征都被抹平)。

但有个前提必须记住,Cloudflare 文档自己写了:ECH 的密钥来自 DNS 的 HTTPS 资源记录(HTTPS RR)。所以:

ECH 必须配 DoH。 否则明文 DNS 被污染或被丢弃,你连 ECH 配置都拉不到,ECH 等于没开。

也要认边界:阻断方可以直接封 cloudflare-ech.com 这个外层 SNI,或者封 Cloudflare 的 IP 段。所以 ECH 是「提高成本」,不是「免疫」。


三、传输层:五条互相补位的出口

传输层:五条互相补位的出口

出口 做法 覆盖的死角 边界
A 域名 + ECH Cloudflare 开 ECH,客户端配 DoH 抗 SNI 阻断 外层 SNI 被整体封、CF IP 段被封时失效
B IP + IP-SAN 证书 LE 短有效期证书 + nginx default_server 抗 DNS 污染 + 抗 SNI IP 本身被封时失效
C 泛域名随机子域 *.example.com 通配证书,随机串轮换 抗「按精确域名封」 主域被整体封时失效
D 端口池 443 / 8443 / 2053 / 2083 / 2087 / 2096 抗端口阻断 单端口策略封禁时逐个试
E 共享 IP 承载 大 CDN 共享 IP、Serverless 默认域名 抗单 IP 封锁 封了会连坐大量正常业务,成本高

为什么必须并列而不是主备:ECH 挡不住「外层 SNI 被整体封」;IP 证书挡不住「IP 本身被封」。两者死角不重叠,所以是并列的两条腿,由竞速层决定这一轮走哪条。

3.1 IP-SAN 证书签发

# 首次
acme.sh --set-default-ca --server letsencrypt
acme.sh --issue --standalone -d 203.0.113.7 --profile shortlived

# 装到 nginx 并自动 reload
acme.sh --install-cert -d 203.0.113.7 \
  --key-file       /etc/le/ip.key \
  --fullchain-file /etc/le/ip.fullchain.pem \
  --reloadcmd      "nginx -s reload"

--profile shortlived 是硬要求:Let's Encrypt 规定 IP 证书必须是短有效期,不带这个参数请求会被拒。

3.2 nginx 侧要接受「没有 SNI 的连接」

关键在 default_server。少了它,无 SNI 的连接会被另一个 server 块抢走,然后证书不匹配。

map $http_origin $cors_origin {
  default "";
  "~^https://(app\.example\.com|shell\d+\.example\.net)$" $http_origin;
}

server {
  listen 443 ssl default_server;
  listen 8443 ssl default_server;
  server_name _;

  ssl_certificate     /etc/le/ip.fullchain.pem;
  ssl_certificate_key /etc/le/ip.key;
  ssl_protocols       TLSv1.2 TLSv1.3;
  ssl_early_data      on;

  add_header Access-Control-Allow-Origin  $cors_origin always;
  add_header Access-Control-Allow-Headers "authorization,content-type,x-idempotency-key" always;
  add_header Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS" always;
  add_header Access-Control-Max-Age       86400 always;
  add_header Vary Origin always;

  if ($request_method = OPTIONS) { return 204; }

  location = /__health {
    return 204;
    add_header Access-Control-Allow-Origin * always;
    add_header Cache-Control no-store always;
  }
  location / { proxy_pass http://127.0.0.1:3000; }
}

四、寻址层:签名地址包 + 多通道并行拉取

寻址层:并行拉、逐个验签、取最大版本号

4.1 为什么必须签名

地址包会被放在第三方公共存储(对象存储、jsDelivr、GitHub raw、IPFS 网关)上。这些地方的写入权限一旦出问题,攻击者就能往包里塞自己的服务器地址,全量用户被导向钓鱼站。所以包必须签名,公钥硬编码在应用里,并且内置两把(当前 + 下一把)以便轮换。

4.2 包结构

{
  "v": 7,
  "iat": "2026-11-01T00:00:00Z",
  "exp": "2026-11-08T00:00:00Z",
  "minShell": "1.4.0",
  "probe": "https://probe-a.example.net/__health",
  "exits": [
    {
      "id": "cf-ech",
      "kind": "domain",
      "host": "app.example.com",
      "ports": [443, 2053, 2083, 2087, 2096, 8443],
      "ech": true,
      "asn": "AS13335",
      "weight": 100,
      "note": "需 DoH,抗 SNI 阻断"
    },
    {
      "id": "ip-a",
      "kind": "ip",
      "host": "203.0.113.7",
      "ports": [443, 8443],
      "certType": "ip-san",
      "certNotAfter": "2026-11-06T12:00:00Z",
      "asn": "AS64500",
      "weight": 80,
      "note": "无 SNI,抗 DNS 污染"
    }
  ],
  "sig": "base64url(ECDSA-P256-SHA256 over canonical JSON without sig)"
}

payload / sig 分离还是整体签,两种都行,但签名端和验签端必须用完全一致的序列化。下文示例用「去掉 sig 字段后 JSON.stringify」。

4.3 服务端签名

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

// 一次性:generateKeyPairSync('ec', { namedCurve: 'P-256' })
// 私钥离线保管,公钥 JWK 写入前端包
const privateKey = /* 从离线介质读取 PEM */ null

const payload = JSON.stringify({
  v: 21,
  iat: Date.now(),
  exp: Date.now() + 7 * 24 * 3600 * 1000,
  probe: 'https://probe-a.example.net/__health',
  exits: [
    { id: 'cf-ech', kind: 'domain', host: 'app.example.com', ports: [443, 8443], ech: true, weight: 100, asn: 'AS13335' },
    { id: 'ip-a',   kind: 'ip',     host: '203.0.113.7',   ports: [443, 8443], certType: 'ip-san', weight: 80, asn: 'AS64500' },
  ],
})

// WebCrypto 验的是 IEEE P1363 格式(r||s),这里必须指定,否则验签必失败
const sig = sign('sha256', Buffer.from(payload), {
  key: privateKey,
  dsaEncoding: 'ieee-p1363',
}).toString('base64url')

writeFileSync('pool.json', JSON.stringify({ payload, sig }))

dsaEncoding: 'ieee-p1363' 这一行是最容易踩的坑:Node 默认输出 DER 编码的签名,而 WebCrypto 的 verify 期望 r||s 的裸格式。两者不匹配的表现是「服务端签得好好的,前端每次都验签失败」,且报错信息毫无提示。

4.4 客户端验签

// src/lib/bootstrap.ts
const PUB_JWK = { kty: 'EC', crv: 'P-256', x: '…', y: '…' }   // 硬编码,随发版固定

export type Exit = {
  id: string
  kind: 'domain' | 'ip'
  host: string
  ports: number[]
  ech?: boolean
  certType?: string
  certNotAfter?: string
  weight: number
  asn?: string
}
export type Bundle = {
  v: number; iat: number; exp: number; minShell?: string
  probe: string; exits: Exit[]; sig: string
}

function canonical(b: Bundle): Uint8Array {
  const { sig, ...rest } = b
  return new TextEncoder().encode(JSON.stringify(rest))
}

export async function verifyBundle(raw: unknown): Promise<Bundle | undefined> {
  const b = raw as Bundle
  if (!b?.exits?.length || !b.sig) return undefined

  const key = await crypto.subtle.importKey(
    'jwk', PUB_JWK, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'],
  )
  const sig = Uint8Array.from(
    atob(b.sig.replace(/-/g, '+').replace(/_/g, '/')),
    (c) => c.charCodeAt(0),
  )
  const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, sig, canonical(b))
  if (!ok) return undefined
  if (Date.now() > b.exp) return undefined          // 过期包直接丢,防止旧池复活
  return b
}

用 P-256 而不是 Ed25519 的原因:P-256 在 WebCrypto 里全平台无短板。地址包这条链路上任何一次验签失败都是致命的,不值得为省几行代码去赌浏览器版本。

4.5 多通道并行 + 取最大版本号

// src/lib/channels.ts
import { verifyBundle, type Bundle } from './bootstrap'
import { dbp } from './db'

const CHANNELS: Array<() => Promise<unknown>> = [
  // ① 本地缓存:离线时唯一可用,且它证明过自己能用,优先级最高
  async () => (await (await dbp).get('meta', 'bundle'))?.value,

  // ② 应用内置种子:打包进 shell,永不失效
  async () => (await import('../seed.json')).default,

  // ③ 公共只读存储:多放几处,任一存活即可
  async () => (await fetch('https://cdn.jsdelivr.net/gh/USER/REPO@main/pool.json', { cache: 'no-store' })).json(),
  async () => (await fetch('https://raw.githubusercontent.com/USER/REPO/main/pool.json', { cache: 'no-store' })).json(),
  async () => (await fetch('https://BUCKET.r2.dev/pool.json', { cache: 'no-store' })).json(),

  // ④ DoH 查 TXT:走加密 DNS,绕开污染
  async () => {
    const r = await fetch(
      'https://cloudflare-dns.com/dns-query?name=_boot.app.example.com&type=TXT',
      { headers: { accept: 'application/dns-json' }, cache: 'no-store' },
    )
    const j = await r.json()
    const txt = (j?.Answer ?? []).map((a: any) => a.data.replace(/^"|"$/g, '')).join('')
    return txt ? JSON.parse(txt) : undefined
  },
]

export async function resolveBundle(): Promise<Bundle | undefined> {
  const results = await Promise.allSettled(CHANNELS.map((f) => f()))
  const bundles = (await Promise.all(
    results.map((r) => (r.status === 'fulfilled' ? verifyBundle(r.value) : undefined)),
  )).filter(Boolean) as Bundle[]

  if (!bundles.length) return undefined

  // 取版本号最高的,而不是「第一个成功的」
  const best = bundles.sort((a, b) => b.v - a.v)[0]
  await (await dbp).put('meta', { key: 'bundle', value: best })
  return best
}

两个细节是刻意的:

  • 并行是为了「部分通道被墙时仍能拉到」。
  • 取最大 v 而不是取第一个成功的,是为了防止一个存活但陈旧的通道把出口池拖回旧状态。这个 bug 非常阴险,表现为「换了新出口,但一部分用户永远停在旧地址」。用 Promise.any 也能收敛(靠版本回退校验),但会多一个刷新周期的窗口,不如直接全等。

TXT 通道的容量限制:单条 TXT 字符串 ≤ 255 字节,所以大包要切成 p1.pool.example.com ~ p4.pool.example.com 多条,客户端拼起来再验签。建议 TXT 只用来下发精简版列表 + 签名,作为第三、第四条通道,而不是唯一通道。


五、判定层:先分清「哪种墙」,再决定换哪条腿

判定层:先分清「哪种墙」,再决定换哪条腿

浏览器拿不到区分「墙」和「服务器宕机」的直接信号,但时序特征 + 多源对照能推出高置信度结论。

观测到的事实 推断 下一步
失败耗时 < 1.5s,TypeError: Failed to fetch sni-blocked 优先 kind: 'ip' 的出口(无 SNI)
失败耗时 > 8s 直到主动 abort ip-blocked 换 ASN / 换端口池 / 走共享 IP
解析成功,但连到的 IP 与池里记录不一致 dns-poisoned 先切 DoH,再重试域名出口
第三方探针通,自有入口全灭 origin-down 判定为自己的出口问题,全力切换
第三方探针也全灭 offline 与墙无关,走纯离线模式 + 队列
域名出口全灭、IP 出口存活 partial 降权域名出口,权重转移给 IP 出口
// src/lib/classify.ts
export type Diagnosis =
  | 'healthy' | 'origin-down' | 'dns-poisoned'
  | 'sni-blocked' | 'ip-blocked' | 'offline'

export async function classify(probeUrl: string): Promise<Diagnosis> {
  if (!navigator.onLine) return 'offline'

  // 多源对照:三个互不相关的第三方探针,谁通用谁
  const thirdParty = await Promise.any([
    fetch('https://www.gstatic.com/generate_204', { mode: 'no-cors', cache: 'no-store' }),
    fetch('https://cp.cloudflare.com/', { mode: 'no-cors', cache: 'no-store' }),
    fetch('https://detectportal.firefox.com/success.txt', { mode: 'no-cors', cache: 'no-store' }),
  ]).then(() => true).catch(() => false)

  if (!thirdParty) return 'offline'

  const t0 = performance.now()
  try {
    await fetch(probeUrl, { cache: 'no-store', signal: AbortSignal.timeout(10_000) })
    return 'healthy'
  } catch {
    const dt = performance.now() - t0
    if (dt < 1500) return 'sni-blocked'   // 快速重置
    return 'ip-blocked'                   // 慢速超时
  }
}

最容易被误判的一处:把 navigator.onLine 当真理。连着 WiFi、但出站全被丢包时它仍返回 true。必须靠真实探测来判定。

探针选择要贴合你的用户群。面向中国大陆用户时,gstatic / cloudflare / firefox 这几个本身就常年不可达,会让探针永远返回「离线」,把「站点被封」误判成「用户断网」。此时应换成你所在用户群体一定可达的探针(例如 https://www.baidu.com/favicon.ico)。


六、取数层:竞速、对冲、熔断,三个必须分清的分支

取数层:竞速、对冲、熔断

被墙里最常见的是黑洞型:请求不报错,只是一直挂着。传统「超时 3s → 换下一个」体验极差。正确做法是对冲请求:800ms 没响应就并行打下一个,谁先回谁赢。

6.1 三种「失败」走三条不同的路

失败类型 含义 动作
超时 / RST / DNS 失败 / 502 / 504 入口不通 换出口重试(有意义)
429 / 503(带 Retry-After) 后端明确在忙 不换出口,守着退避,展示降级 UI
200 但 content-type 是 HTML 多半是拦截页/挑战页 当失败处理,不要当 JSON 解析,绝不写缓存

第三条是容灾代码最容易自己制造事故的地方:被墙时最常见的「成功响应」就是运营商或平台的拦截页,HTTP 200、内容是 HTML。如果你用 CacheableResponsePlugin({ statuses: [200] }),会把拦截页当成首页缓存下来,然后用户被永久锁在那个页面上。

6.2 重试预算

额外请求 ≤ 总请求的 10%(且每分钟至少 3 次)。 故障期流量本来就紧张,竞速会把请求放大 3~4 倍,必须有预算桶把波浪宽度压住。

// 预算桶:额外请求不超过总请求的 10%
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 + 60_000 } }
const spend = () => { tick(); if (B.spent < Math.max(3, B.reqs * 0.1)) { B.spent++; return true } return false }

6.3 多出口取数(含修正后的 abort 语义)

// src/lib/fetch.ts
import type { Exit } from './bootstrap'

const breakers = new Map<string, { fails: number; openUntil: number }>()
const keyOf = (e: Exit) => `${e.kind}:${e.host}`

function isOpen(e: Exit) {
  const b = breakers.get(keyOf(e))
  return !!b && b.openUntil > Date.now()
}
function markFail(e: Exit) {
  const k = keyOf(e)
  const b = breakers.get(k) ?? { fails: 0, openUntil: 0 }
  b.fails++
  if (b.fails >= 2) { b.openUntil = Date.now() + 60_000; b.fails = 0 }
  breakers.set(k, b)
}
function markOk(e: Exit) { breakers.delete(keyOf(e)) }

type Hit = { res: Response; exit: Exit }

async function hit(
  e: Exit, port: number, path: string, init: RequestInit, timeoutMs: number, ac: AbortController,
): Promise<Hit> {
  const timer = setTimeout(() => ac.abort(), timeoutMs)
  try {
    const res = await fetch(`https://${e.host}:${port}${path}`, {
      ...init,
      signal: ac.signal,
      credentials: 'omit',     // 跨 origin 靠 Bearer,不靠 Cookie
      mode: 'cors',
      cache: 'no-store',
      headers: { ...init.headers, 'x-exit': e.id },
    })
    if (res.status === 429 || res.status === 503) {
      const err: any = new Error('OVERLOADED')
      err.retryAfter = Number(res.headers.get('retry-after')) || 5
      err.status = res.status
      throw err
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    // 拦截页冒充成功:200 但 content-type 是 HTML
    const ct = res.headers.get('content-type') ?? ''
    if (!ct.includes('json')) throw new Error('NOT_JSON')
    return { res, exit: e }
  } finally {
    clearTimeout(timer)
  }
}

/**
 * 竞速取数。关键点:
 *  - Promise.any(不是 race):race 会被最快失败抢占
 *  - 胜出的那条不能被 abort:否则 body 流会断
 *  - 429/503 不是「入口不通」,不参与换出口,直接向调用方抛出
 */
export async function raceFetch(
  path: string,
  init: RequestInit,
  exits: Exit[],
  opts: { timeoutMs?: number; wave?: number } = {},
): Promise<Response> {
  const { timeoutMs = 4000, wave = 4 } = opts

  const candidates = exits
    .filter((e) => !isOpen(e))
    .flatMap((e) => e.ports.map((p) => ({ e, p })))
    .sort((a, b) => b.e.weight - a.e.weight)
    .slice(0, wave)

  if (!candidates.length) throw new Error('NO_EXIT')

  const acs = candidates.map(() => new AbortController())
  let winner = -1

  const tasks = candidates.map(({ e, p }, i) =>
    hit(e, p, path, init, timeoutMs, acs[i]).then(
      (h) => { if (winner < 0) winner = i; markOk(e); return h },
      (err) => { markFail(e); throw err },
    ),
  )

  try {
    const h = await Promise.any(tasks)
    return h.res
  } catch (err: any) {
    // 任一出口明确「后端在忙」,就不要报成「全部失败」
    const overloaded = (err?.errors ?? []).find((x: any) => x?.message === 'OVERLOADED')
    if (overloaded) throw overloaded
    throw new Error('ALL_EXITS_FAILED')
  } finally {
    // 关键:跳过胜出者
    acs.forEach((a, i) => { if (i !== winner) a.abort() })
  }
}

这里的三个修正点值得单独说明,因为它们都是真实项目里翻过车的:

  1. Promise.race 换 Promise.any:race 会被第一个失败抢占,导致「最快的出口刚 RST 就把整个请求判死」。
  2. 胜出者不被 abort:fetch 拿到响应头就 resolve,body 还没读完。此时 abort 会让 body 流报错,调用方拿到的是 AbortError 而不是数据。这个 bug 只在「竞速成功」的路径上出现,而竞速成功恰恰是容灾生效的时刻。
  3. 429/503 不参与换出口:如果车道 0 在 800ms 返回 429,同时对冲的车道 1 马上要成功,直接 reject 会把通的路作废。所以把它当「候选结论」,等其他车道跑完或成功。

七、离线层:彻底连不上时,为什么还能打开

离线层:彻底连不上时,为什么还能打开

这一层是整个方案里唯一能在「彻底被墙」状态下生效的机制。SW 拦截导航并返回缓存壳——这个过程完全没有网络参与,也不做 DNS 解析。

前提是三条同时成立,缺一条这条链路就断:

  1. 用户在被封之前访问过,SW 已安装完成;
  2. app shell 已预缓存完毕;
  3. 存储没有被浏览器回收(要靠 storage.persist() 提前申请)。

7.1 三块存储各管一件事

存储 装什么 为什么不能混用
Cache API HTTP 响应原文(app shell、静态资源、可缓存的 GET) 响应是「原文」,后端改字段结构后很难安全复用
IndexedDB 已归一化的业务快照 + outbox 写队列 能按业务键精确失效、能做 schema 迁移
LocalStorage 只放 < 4KB 的开关位(主题、上次路由) 同步 API 阻塞主线程、5MB 上限、无事务

OPFS 单独说一句:SW 里没有 createSyncAccessHandle()(只有 dedicated worker 有),只能用异步 API,而异步 API 慢,所以 SW 里别指望用 OPFS 做高频小写入。它适合大二进制、WASM 模块、离线媒体。

配额是共享的:Cache API / IDB / OPFS 共用同一个 origin quota,必须统一回收。用量 > 80% 时只清运行时缓存,预缓存与 outbox 一律保留。

7.2 Service Worker

// src/sw.ts
/// <reference lib="webworker" />
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
import { registerRoute, NavigationRoute, setCatchHandler } from 'workbox-routing'
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'

declare const self: ServiceWorkerGlobalScope & {
  __WB_MANIFEST: Array<{ url: string; revision: string | null }>
}

const PREFIX = 'c-'                          // 运行时缓存统一前缀,便于回收
cleanupOutdatedCaches()
precacheAndRoute(self.__WB_MANIFEST)

const isShell = async (res: Response) => {
  if (!res.ok) return false
  if (!(res.headers.get('content-type') ?? '').includes('text/html')) return false
  return (await res.clone().text()).includes('name="app-shell"')   // 只认真正的应用壳
}

// 导航:SW 启动与网络请求并行(Navigation Preload),失败回退迁移页
const navStrategy = new NetworkFirst({
  cacheName: `${PREFIX}nav`,
  networkTimeoutSeconds: 3,
  plugins: [new CacheableResponsePlugin({ statuses: [200] })],
})

registerRoute(new NavigationRoute(async (params) => {
  const event = params.event as FetchEvent
  const preload = await event.preloadResponse?.catch(() => undefined)
  if (preload) return preload
  try {
    const res = await navStrategy.handle(params)
    // 200 但不是应用壳 → 多半是拦截页,别缓存它
    if (!(await isShell(res.clone()))) {
      const cached = await caches.match('/offline.html')
      if (cached) return cached
    }
    return res
  } catch {
    return (await caches.match('/offline.html')) ?? Response.error()
  }
}))

setCatchHandler(async ({ request }) => {
  if (request.destination === 'document') {
    return (await caches.match('/offline.html')) ?? Response.error()
  }
  return Response.error()
})

// 静态资源:长缓存
registerRoute(
  ({ request, url }) =>
    url.origin === self.location.origin &&
    ['script', 'style', 'font', 'image'].includes(request.destination),
  new CacheFirst({
    cacheName: `${PREFIX}static`,
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 300, maxAgeSeconds: 60 * 60 * 24 * 30 }),
    ],
  }),
)

// API GET:SWR,但排除实时通道与个人凭证
registerRoute(
  ({ request, url }) =>
    request.method === 'GET' &&
    url.pathname.startsWith('/api/') &&
    !url.pathname.startsWith('/api/live/') &&
    !url.pathname.startsWith('/api/me'),
  new StaleWhileRevalidate({
    cacheName: `${PREFIX}api`,
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 }),
    ],
  }),
)

要点:

  • 缓存名不带 build id。带了的项目每次发版白屏一次:新 SW 激活时旧缓存全清,而新预缓存还没落盘。运行时缓存用固定名,预缓存交给 Workbox 按 revision 管理。
  • sw.js 与 index.html 必须 Cache-Control: no-cache,否则更新通道会被缓存卡死。
  • CacheableResponsePlugin 的 statuses 别加 0。0 是给 opaque 响应(跨域 no-cors)用的,只用 CORS 请求时加上它会把失败的跨域请求缓存成空壳。
  • opaque 响应读不到 body,res.json() 直接抛。跨域接口必须 CORS。

7.3 存储持久化与配额回收

// src/lib/storage.ts
export async function guardStorage() {
  // 越早申请越好:Chrome 对「高参与度站点 / 已安装 PWA」自动给,Firefox 会弹窗
  if (navigator.storage?.persist) await navigator.storage.persist()

  const est = await navigator.storage.estimate()
  const pct = est.quota ? ((est.usage ?? 0) / est.quota) * 100 : 0
  if (pct > 80) {
    // 只清运行时缓存,预缓存与 outbox 一律保留
    for (const n of await caches.keys()) if (n.startsWith('c-')) await caches.delete(n)
  }
}

7.4 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));
})()));

顺序是先删缓存再 unregister,反了会让页面在「无 SW 且无缓存」的状态下多撑一次请求。

7.5 写队列(outbox)

被墙期间写入不能丢、不能重。三件套:幂等键 + 指数退避 + navigator.locks。

// src/lib/outbox.ts
import { dbp, type OutboxItem } from './db'

const MAX_ATTEMPTS = 8

export async function enqueue(item: Omit<OutboxItem, 'id' | 'createdAt' | 'attempts' | 'nextAt' | 'status'>) {
  const db = await dbp
  const full: OutboxItem = {
    ...item,
    id: crypto.randomUUID(),        // 同时作为 Idempotency-Key
    createdAt: Date.now(),
    attempts: 0,
    nextAt: 0,
    status: 'pending',
  }
  await db.put('outbox', full)
  return full
}

async function sendOne(it: OutboxItem): Promise<'done' | 'conflict' | 'retry' | 'dead'> {
  const res = await fetch(it.url, {
    method: it.method,
    headers: {
      'content-type': 'application/json',
      'x-idempotency-key': it.id,   // 服务端必须按它去重
      ...(it.baseVersion ? { 'if-match': it.baseVersion } : {}),
    },
    body: it.body === undefined ? undefined : JSON.stringify(it.body),
  })

  if (res.ok) return 'done'
  if (res.status === 409) return 'conflict'          // 版本冲突,交给上层合并
  if (res.status >= 500 || res.status === 429) return 'retry'
  if (res.status >= 400) return 'dead'               // 4xx 重试没意义
  return 'retry'
}

export async function flushOutbox() {
  if (!navigator.onLine) return
  const db = await dbp

  const run = async () => {
    const all = await db.getAllFromIndex('outbox', 'by-status', 'pending')
    const now = Date.now()
    for (const it of all.filter((x) => x.nextAt <= now)) {
      await db.put('outbox', { ...it, status: 'inflight' })
      let outcome: Awaited<ReturnType<typeof sendOne>>
      try { outcome = await sendOne(it) } catch { outcome = 'retry' }

      if (outcome === 'done') { await db.delete('outbox', it.id); continue }
      if (outcome === 'dead' || outcome === 'conflict') {
        await db.put('outbox', { ...it, status: 'dead' }); continue
      }
      const attempts = it.attempts + 1
      if (attempts >= MAX_ATTEMPTS) { await db.put('outbox', { ...it, status: 'dead', attempts }); continue }
      // 指数退避 + 抖动,避免恢复瞬间全量重放打爆后端
      const backoff = Math.min(2 ** attempts * 1000, 5 * 60 * 1000)
      await db.put('outbox', {
        ...it, status: 'pending', attempts,
        nextAt: Date.now() + backoff + Math.random() * 1000,
      })
    }
  }

  // 多标签页同时在线时只允许一个标签 flush,防重复提交
  if (navigator.locks?.request) {
    await navigator.locks.request('outbox-flush', { ifAvailable: true }, async (lock) => {
      if (!lock) return
      await run()
    })
  } else {
    await run()
  }
}

addEventListener('online', () => void flushOutbox())
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') void flushOutbox()
})
setInterval(() => void flushOutbox(), 30_000)

navigator.locks 这一步最容易被漏掉:没有它,用户开三个标签页、网络一恢复,同一条订单会被提交三次。幂等键能救数据,但救不了后端 QPS。

不要依赖 Background Sync:Chrome/Edge/Opera/Samsung 有,Safari 全系(含 iOS 26/27)和 Firefox 全系没有。只能当加速器,主力必须是上面这套「IDB outbox + 定时器 + 可见性事件」。


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

前面所有手段都假设「用户手里有一个能用的入口域名」。首次访问的用户没有 SW、没有缓存、没有池,只能靠这一层。

渠道 做法
独立导航页 极简静态页,托管在与主站完全不同的服务商 + 注册商,永远指向当前可用入口
App / 客户端内 内置地址包 + 内置导航页入口,启动即竞速
公众号 / 社群 / 邮件 / 短信 事先沟通「如打不开请访问导航页 xxx」
搜索引擎品牌词 维护品牌词搜索结果指向最新入口
单文件 portable.html 零外部请求、全内联的兜底页,用户可保存、转发、U 盘拷
用户收藏的老域名 靠 SW 兜底页自动迁移

8.1 迁移时登录态怎么办

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

做法 体验 说明
新域名重新登录 一般 最简单,适合低频场景
一次性迁移码 好 旧域名生成 60 秒有效、单次使用的 ticket,跳转新域名后换新 token
统一 Bearer + 静默刷新 好 配合 refresh token 的服务端校验

迁移码必须放 fragment(#t=...)而不是 query:

// 旧域名(还能打开时)
location.replace(`https://new.example.net/#t=${ticket}`)

// 新域名,读取后立刻清掉
const t = new URLSearchParams(location.hash.slice(1)).get('t')
history.replaceState(null, '', location.pathname)

?t=xxx 会进 Nginx access log、浏览器历史、以及跨域时的 Referer 头;# 后面的内容这三处都拿不到。


九、服务端与网络侧:让每个域名「死得独立」

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

  1. IP 隔离:每个入口域名绑定不同 CDN 厂商或不同 IP 段。能做到「一个 IP 段被封只死一个域名」,才是真多活。
  2. 回源多线路:CDN 回源到源站的 IP 不暴露;源站安全组只放行回源网段,并校验回源密钥头。
  3. 隐藏源站:清理历史 DNS 记录、证书透明度日志、邮件头里泄露的源站 IP;泄露过就换 IP。
  4. 证书分散:不同域名独立证书,不要塞同一张 SAN。
  5. DNS 分散:两家权威 DNS,TTL 60~300s,启用健康检查自动摘除。
  6. 健康端点:/__health 返回 204,极轻,不走业务逻辑,不要让它回源数据库。
  7. 域名预热:备用域名平时承接 1%~5% 的真实流量。新域名信誉为零,突然启用更容易被「平台拦截」而不是「网络封锁」,而平台拦截换 IP、换端口全都救不回来。
  8. 域名流水线:采购 → 备案 → 配置 → 预热 → 上线 → 监测 → 摘除 → 替换。被墙是常态不是意外,域名要像耗材一样管理。
# 源站:只信任回源网段,并校验回源密钥头
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; }
  location = /__health { return 204; add_header Access-Control-Allow-Origin * always; add_header Cache-Control no-store always; }
}

十、落地顺序与验收

落地顺序与验收

10.1 实施顺序

优先级 事项 为什么在这个位置
P0 故障判定 classify() + RUM 上报 没有诊断,后面所有切换都是瞎切
P0 域名资产流水线(独立 IP / 证书 / DNS 分散) 没有这个,域名池是空中楼阁
P0 签名池 + 多通道下发 + 内置种子 纯离线状态下「寻址」要能成立
P1 SW 预缓存 app shell + storage.persist() 单位成本最低、收益最大的一条
P1 IP-SAN 证书出口 + nginx default_server 让「IP 直连」这条路真的能走
P1 竞速 + 对冲 + 熔断 + 重试预算 对付黑洞型故障的核心
P1 outbox + 幂等键 + navigator.locks 被墙期间的写入不能丢、不能重
P2 众包探测 → 聚合 → 自动摘除 → 下发 把发现和处置时间压到分钟级
P2 独立导航页 + 单文件 portable.html 首次访问用户的最后一道保险

10.2 验收清单

# 注入方式 期望结果
1 断网后点桌面图标 app shell 从 SW 缓存打开,无任何网络错误页
2 hosts 把主域名指向 10.0.0.1(DNS 污染) 判定 dns-poisoned,自动切 IP 出口,接口恢复
3 iptables 对该 IP:443 REJECT(SNI/RST) 判定 sni-blocked,≤1.5s 切到 IP 证书出口
4 iptables 对该 IP 全端口 DROP(黑洞) 判定 ip-blocked,切到另一个 ASN 的出口
5 关闭主出口 443,只留 8443 端口池生效,走 8443,不报错
6 篡改 pool.json 的 exits[0].host 验签失败,包被丢弃,沿用本地缓存包
7 三标签页离线各写 1 次,恢复网络 服务端收到 3 条,不是 9 条
8 mock 返回 200 + 拦截 HTML 不被缓存成首页,API 不当 JSON
9 断开到被封域名的全部网络 SW 返回迁移页,≤5s 跳到可用入口,无跳转循环
10 IP 证书剩 2 小时 面板告警,该出口权重降为 0
11 清空浏览器数据后手工打开 portable.html 显示最新地址列表 + 二维码,可复制
12 发布一个故意写错的 SW kill-switch 能清理并恢复,页面回到可用状态

验收标准(可量化):

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

iptables 这类注入建议在 docker 双容器里做,不碰你真机网络。


十一、常见的坑

  1. 多个域名指向同一个 IP:IP 一封全灭,域名池形同虚设。
  2. 所有域名放进同一张证书:一个被标记,整批被关联。
  3. 只靠 Service Worker:首次访问的用户零保护;必须配合触达层与池下发。
  4. 配置/池与业务同域下发:主域被封时更新通道一起死。
  5. 池不签名:劫持任一通道就能把用户导向恶意域名。
  6. 同域名重试:对 IP 黑洞和域名被封都没用,只会增加等待。
  7. 过载时还换域名重试:429/503 换出口 = 把压力放大到所有入口。
  8. 缓存了拦截页/劫持页当首页:写缓存前必须校验应用壳标记。
  9. 备用域名没预热:临时启用的新域名信誉为零,更容易被平台拦截。
  10. 迁移时把 token 放进 URL:会进日志、历史、Referer;用单次迁移码,放 fragment 并立即清除。
  11. 上报没有防刷:被人刷假数据,好域名被自动摘除。
  12. navigator.onLine 当真理:连着 WiFi 但出站被封时仍为 true。
  13. Promise.race 当竞速:会被最快失败抢占;用 Promise.any。
  14. 胜出者被 abort:body 流会断,且只在成功路径上出问题。
  15. Node 签名用默认 DER 编码:WebCrypto 验签必失败;要 dsaEncoding: 'ieee-p1363'。
  16. 依赖 Background Sync:Safari / Firefox 全系没有,主力必须是 IDB outbox。
  17. 探针选在大陆不可达的域名:gstatic / cloudflare / firefox 常年不通,会把「站点被封」误判成「用户断网」。
  18. 容灾代码自己没有容灾:兜底逻辑抛异常比没有兜底更糟,每个分支都要能安全返回。

十二、结语

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

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

再加两句 2026 年的新事实:

  • IP-SAN 证书 GA 之后,「IP 直连兜底」重新可用。连 IP、不带 SNI、配 IP 证书,同时干掉 DNS 污染和 SNI 阻断,代价是 6.7 天一轮的证书续期。
  • ECH 是性价比最高的一类手段,但必须绑 DoH,且挡不住外层 SNI 被整体封。

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


附录:参考资料

相关文档

评论与补充0

有问题或想补充的,写在下面 · 选中正文文字再点「报告错误」会自动引用

还没有评论。有疑问、补充,或者发现错字,都可以写在下面。

就事论事,别发广告 · 正常评论