← 中文资源

简体中文版本

服务器名称指示(SNI)

SNI 让 TLS 客户端在握手时发送目标主机名,使一个 IP 地址可在请求解密前为多个 HTTPS 站点提供各自证书,是共享托管与大规模 HTTPS 的基础。

阅读英文原文

概念概述

服务器名称指示(SNI)(Server Name Indication (SNI))指的是:SNI 让 TLS 客户端在握手时发送目标主机名,使一个 IP 地址可在请求解密前为多个 HTTPS 站点提供各自证书,是共享托管与大规模 HTTPS 的基础。

从更严格的定义看,Server Name Indication is an extension to the TLS handshake in which the client states the hostname it intends to reach, allowing the server to select the matching certificate before any HTTP data is exchanged. 该概念在工程实践中的价值,在于它能帮助团队用统一的语言描述一个具体的质量属性或设计约束,从而在评审与协作中减少歧义。

为什么需要它

It is the reason shared hosting and large content platforms can terminate thousands of HTTPS domains on a small pool of IP addresses. 它与 Application-Layer Protocol Negotiation (ALPN)、PROXY Protocol for Client Address Preservation、Server-Timing Metrics Header 等概念经常一起出现,用于解决相近的工程问题。理解它的适用场景,有助于在多种备选方案之间做出有依据的取舍,而不是凭直觉选择技术方案。

常见误解

  • SNI encrypts or hides the hostname from observers
  • a single wildcard certificate can replace SNI for unrelated domains

需要避免的反模式

  • relying on one IP per certificate and exhausting address space
  • assuming SNI is always present when talking to legacy clients

实践要点

  1. Without SNI the server must choose a certificate before it knows which site the client wants, which historically forced one dedicated IP address per HTTPS domain.
  2. Because the hostname travels in cleartext in the ClientHello, network observers can see which site is requested even though the content stays encrypted, which motivated Encrypted Client Hello as a successor.
  3. Servers must still define a default certificate for clients that omit SNI, and automation that provisions certificates per domain must keep the name set in sync with the SNI-selected virtual hosts.

参考来源

https://datatracker.ietf.org/doc/html/rfc6066

代码示例

export interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
  retryAfterMs: number;
}

export interface RateLimiterOptions {
  limit: number;
  windowMs: number;
  now?: () => number;
  maxKeys?: number;
}

interface WindowState {
  count: number;
  resetAt: number;
}

export class FixedWindowRateLimiter {
  private readonly limit: number;
  private readonly windowMs: number;
  private readonly now: () => number;
  private readonly maxKeys: number;
  private readonly windows = new Map<string, WindowState>();

  constructor(options: RateLimiterOptions) {
    if (!Number.isInteger(options.limit) || options.limit < 1) {
      throw new RangeError("limit must be a positive integer");
    }
    if (!Number.isFinite(options.windowMs) || options.windowMs <= 0) {
      throw new RangeError("windowMs must be a positive finite number");
    }
    const maxKeys = options.maxKeys ?? 10_000;
    if (!Number.isInteger(maxKeys) || maxKeys < 1) {
      throw new RangeError("maxKeys must be a positive integer");
    }
    this.limit = options.limit;
    this.windowMs = options.windowMs;
    this.now = options.now ?? Date.now;
    this.maxKeys = maxKeys;
  }

  consume(key: string, cost: number = 1): RateLimitResult {
    if (typeof key !== "string" || key.length === 0) {
      throw new TypeError("key must be a non-empty string");
    }
    if (!Number.isInteger(cost) || cost < 1) {
      throw new RangeError("cost must be a positive integer");
    }
    const now = this.now();
    if (!Number.isFinite(now)) throw new RangeError("now must return a finite timestamp");

    let state = this.windows.get(key);
    if (!state || now >= state.resetAt) {
      if (!state && this.windows.size >= this.maxKeys) this.evict(now);
      state = { count: 0, resetAt: now + this.windowMs };
      this.windows.set(key, state);
    }

    const allowed = state.count + cost <= this.limit;
    if (allowed) state.count += cost;
    return {
      allowed,
      remaining: Math.max(0, this.limit - state.count),
      resetAt: state.resetAt,
      retryAfterMs: allowed ? 0 : Math.max(0, state.resetAt - now),
    };
  }

  reset(key?: string): void {
    if (key === undefined) this.windows.clear();
    else this.windows.delete(key);
  }

  get size(): number {
    return this.windows.size;
  }

  private evict(now: number): void {
    for (const [key, state] of this.windows) {
      if (now >= state.resetAt) this.windows.delete(key);
    }
    if (this.windows.size < this.maxKeys) return;
    const oldest = this.windows.keys().next();
    if (!oldest.done) this.windows.delete(oldest.value);
  }
}
export interface Endpoints {
  "GET /users/:id": { params: { id: string }; response: { id: string; name: string } };
  "POST /users": { body: { name: string }; response: { id: string } };
}

export function createClient(baseUrl: string) {
  async function call<
    K extends keyof Endpoints,
    E extends Endpoints[K],
  >(key: K, arg: E extends { body: infer B } ? B : E extends { params: infer P } ? P : never): Promise<E extends { response: infer R } ? R : never> {
    const [method, path] = (key as string).split(" ");
    const url = baseUrl + path.replace(/:id/, (arg as { id?: string }).id ?? "");
    const response = await fetch(url, {
      method,
      body: method === "GET" ? undefined : JSON.stringify(arg),
    });
    return (await response.json()) as never;
  }
  return { call };
}