跳轉到

peer-discovery(節點發現實作)

對應 implementation flow:程式流程/peer-discovery.md

本檔角色:P2P 節點發現的實作層 —— libp2p 節點組態、GossipSub topic / 簽章訊息、DHT provider / peer 查找、Peer Scoring、房間發現、NAT 穿透(Circuit Relay v2)、bootstrap 清單、topic rate limit。 bootstrap 故障域建議(LEDGER_PROVIDER_FAULT_DOMAINS_RECOMMENDED)與撥號逾時(BOOTSTRAP_DIAL_TIMEOUT_MS)的部署面見 部署資訊.md §5;空 bootstrap 清單是合法本地/既有 session 模式,不另設最小節點常數。簽章 / 驗簽見 key-manager.md · security.md;配對演算法見 matchmaking.md。對應 src/peer-discovery/

1. libp2p 節點組態

async function createNode(opts: {
  privateKey: Libp2pPrivateKey;
  bootstrapList: string[];
}) {
  return createLibp2p({
    privateKey: opts.privateKey,
    // 不掛裸 /p2p-circuit;明示 relay 的 reservation 見 §7。
    addresses: { listen: ["/webrtc"] },
    transports: [
      webRTC(),
      webRTCDirect(),
      webSockets(),
      circuitRelayTransport(),
    ],
    streamMuxers: [yamux()],
    connectionEncrypters: [noise()], // libp2p 3.x 選項名(複數)
    services: {
      identify: identify(),
      ping: ping(), // kad-dht 硬性要求的兄弟服務(peer 存活探測)
      dht: kadDHT({
        protocol: "/open4wd/kad/1.0.0",
        clientMode: false,
        querySelfInterval: 600_000,
      }),
      pubsub: gossipsub({
        // 來源=@libp2p/gossipsub;介面以目前 js-libp2p package 為準
        emitSelf: false,
        allowPublishToZeroTopicPeers: false, // gossipsub 11+ 選項名
        D: 6,
        heartbeatInterval: 1000,
        scoreThresholds: {
          gossipThreshold: -10,
          publishThreshold: -50,
          graylistThreshold: -80,
        },
        scoreParams: createPeerScoreParams({
          // 以官方建構器補齊未列欄位預設(直接給部分欄位會缺欄)
          topics: {
            "/open4wd/<chainId>/rooms/v1": createTopicScoreParams({
              topicWeight: 0.5,
              timeInMeshWeight: 1,
              timeInMeshQuantum: 1000,
              timeInMeshCap: 3600,
              firstMessageDeliveriesWeight: 1,
              firstMessageDeliveriesDecay: 0.5,
              firstMessageDeliveriesCap: 100,
            }),
          },
          appSpecificWeight: 1,
          appSpecificScore, // §5 AppPeerScore 綁定
          IPColocationFactorWeight: -5, // 同 IP 過度集中必須是負分(實際欄位名 IP 大寫)
          behaviourPenaltyWeight: -10,
          behaviourPenaltyDecay: 0.5,
        }),
      }),
    },
    peerDiscovery: [bootstrap({ list: opts.bootstrapList })],
  });
}

appSpecificWeight§5 的信譽 / 黑名單加權。

2. Topics 與訊息結構

enum Topic {
  Rooms = "/open4wd/<chainId>/rooms/v1",
}
// 房內文字聊天走 DataChannel;賽中溝通只走 participant 原簽章圖示訊息。
// UGC 目錄由 ledger DerivedState 推導,不建立 GossipSub topic。

所有 topic 訊息皆為 SignedPayload<T>(結構見 key-manager.md §SignedPayload{ payload, timestamp, nonce, signer, signature })。payload 內含 type 判別字:

type RoomAnnouncement = SignedPayload<{
  type: "room-announce";
  roomId: RoomId; // canonical lowercase UUID v4(見 matchmaking.md generateRoomId)
  hostPeerId: PeerId;
  signalingEndpoint: string | null;
  gossipSignaling: boolean;
  trackId: string;
  playerCount: number;
  maxPlayers: number;
  visibility: 'public' | 'private';
  quickMatchEnabled: boolean;
  participantPasswordRequired: boolean;
  state: 'waiting' | 'preloading' | 'racing' | 'settling' | 'closed';
  clientVersion: string;
  ratingAnchorX1000: number;
  startsAt: number;
  ttl: number; // 牆鐘毫秒;ephemeral 發現層,非帳本
}>;
type RoomCloseEvent = SignedPayload<{
  type: "room-close";
  roomId: RoomId;
  reason: "match-over" | "host-left" | "expired";
}>;
// signaling 使用 /open4wd/<chainId>/signaling/v1/<canonical-scope> 獨立動態 topic,
// payload = { type:'signal-v1', scope, target, message };topic 與 payload scope 必須相同

signalingEndpoint 是房主目前可用的 WSS signaling 端點候選;沒有可公告端點時為 nullgossipSignaling 是 scoped Gossip signaling 的能力開關,不是端點位址,也不代表 WSS 已找到目標。

發現層僅是 GossipSub 傳輸載體;Quick Match 候選判定與動態窗口見 matchmaking.md。房間發現訊息的 startsAt / ttl 都是 ephemeral 牆鐘時間,不入帳本、不參與 derive(故可用 Date.now(),不受 consensusNow 規範約束,見 ledger.md)。

接收與發送只允許 rooms,以及由合法 canonical scope 派生的 signaling topic。 每個已解碼 payload 都先過通用成本界限(深度 8、 entries 512、單一字串 UTF-8 32,768 bytes),再過 topic-specific schema; signaling topic suffix 與 payload scope 不一致一律拒絕。RoomId、PeerId、signaling endpoint、 資源 ID、player count、版本資訊 bounded record(已知欄位另驗型別 / 長度)以及 SDP/ICE 欄位均有型別與長度界限。版本相容性的必填欄位與值域仍由 matchmaking/ versioning 權威層判定;未知 topic 或結構 schema 不符一律 fail-closed。

3. DHT 操作

class TrackProviderHelper {
  // 宣告本節點 pin 了 trackId 的內容
  async announceProvide(trackId: string): Promise<void> {
    await libp2p.contentRouting.provide(CID.parse(trackId));
  }
  // 找誰有 trackId(10s timeout)
  async findProviders(
    trackId: string,
    opts: { limit: number },
  ): Promise<PeerInfo[]> {
    const providers: PeerInfo[] = [];
    for await (const p of libp2p.contentRouting.findProviders(
      CID.parse(trackId),
      { signal: AbortSignal.timeout(10_000) },
    )) {
      providers.push(p);
      if (providers.length >= opts.limit) break;
    }
    return providers;
  }
}

async function findPeer(peerId: PeerId): Promise<Multiaddr[]> {
  const info = await libp2p.peerRouting.findPeer(peerId, {
    signal: AbortSignal.timeout(10_000),
  });
  return info.multiaddrs;
}

4. GossipSub 訂閱 / 發送

class TopicSubscriber {
  constructor(
    private libp2p: Libp2p,
    private keyManager: KeyManager,
    private knownPeers: Set<PeerId>,
  ) {}

  async subscribe<T>(
    topic: Topic,
    handler: (msg: SignedPayload<T>) => void,
  ): Promise<void> {
    assertAllowedTopic(topic); // 固定 topic 只允許 rooms
    this.libp2p.services.pubsub.subscribe(topic);
    this.libp2p.services.pubsub.addEventListener("message", async (e) => {
      if (e.detail.topic !== topic) return;
      if (e.detail.data.byteLength > 65_536) return; // dag-cbor decode 前
      try {
        const signed: SignedPayload<T> = dagCbor.decode(e.detail.data); // wire=dag-cbor(SignedPayload 含 Uint8Array 欄位、JSON 無法無損往返;沿用專案 canonical 序列化)
        const source = normalizeSource(e.detail.from);
        if (source === null || source !== signed.signer) return; // transport source 綁 envelope
        if (!isValidTopicPayload(topic, signed.payload, signed.signer)) return;
        const verified = verifyP2PMessageAuthenticity(signed, this.knownPeers);
        if (!verified.ok) return; // signer / timestamp / nonce shape / signature
        if (nonceRegistry.has(source, signed.nonce)) return;
        if (!rateLimiter.shouldAccept(source, topic)) return; // 同 source 跨 topic 聚合
        if (!nonceRegistry.commit(source, signed.nonce, signed.timestamp))
          return;
        handler(signed);
      } catch {
        /* drop malformed */
      }
    });
  }

  async publish<T>(topic: Topic, payload: T): Promise<void> {
    assertAllowedTopic(topic);
    if (!isValidTopicPayload(topic, payload))
      throw new RangeError("invalid gossip payload");
    const signed = await signPayload(payload); // key-manager.md §9(free function,內部用 keyManager.sign)
    const encoded = dagCbor.encode(signed);
    if (encoded.byteLength > 65_536)
      throw new RangeError("gossip message exceeds byte limit");
    await this.libp2p.services.pubsub.publish(topic, encoded);
  }
}

5. Peer Scoring(信譽 / 黑名單加權)

appSpecificScore 接進 gossipsub scoreParams,把 derive 出的信譽 / 黑名單狀態餵給 mesh 評分:

class AppPeerScore implements PeerScoreOverride {
  constructor(
    private reputation: ReputationStore,
    private moderation: ModerationStore,
  ) {}

  appSpecificScore(peerId: PeerId): number {
    if (this.moderation.isBlacklisted(peerId)) return -1000; // moderation.md:黑名單
    const score = getEffectiveScore(peerId, this.reputation, Date.now()); // 讀點契約(reputation.md §4:唯一讀分入口、新手下限作用於分數本身);0–1000、init 500
    if (score >= 800) return 50; // 高信譽加分
    if (score < 300) return -50; // 低信譽扣分
    return 0;
  }
}

門檻 >= 800 / < 300(信譽 0–1000、init 500、reputationToStars = floor(score/200),≈ 4★ / <1.5★)。ReputationStore / ModerationStore 為 DerivedState 的唯讀視圖(來源見 reputation.md / ledger.md)。

6. 房間發現 API

interface RoomDiscovery {
  announceRoom(a: Omit<RoomAnnouncement["payload"], "type">): Promise<void>;
  closeRoom(
    roomId: RoomId,
    reason: RoomCloseEvent["payload"]["reason"],
  ): Promise<void>;
  subscribeRooms(handler: (rooms: RoomAnnouncement[]) => void): Subscription;
  listActiveRooms(): RoomAnnouncement[]; // 去重後即時清單
}

RoomDiscoveryImplMap<RoomId, RoomAnnouncement> 維護房間表:room-announce 寫入、 room-close 刪除;每 30s 掃 ttl 過期清理,房主與 joiner 以絕對 nextRefreshAt 每 30s 重新公告。lease TTL 為 120s:涵蓋背景分頁進入一分鐘級 timer throttling 後的一次漏拍與網路延遲,但仍遠低於 300s 接收上限,避免 crash 後長期殘留。 頁面回到 visible 時不等下一個 timer,立即補一張最新 snapshot;延遲 callback 只補一次、不追趕 burst。明示 close 仍立即發布 room-closeroom-presence-left 並解除 visibility 訂閱。

房主每次重新公告都從當前 Room snapshot 投影 player count、state 與 participant displayRating 中位數;不得重播建房時的靜態值。visibilityquickMatchEnabled 為明確政策,participantPasswordRequired 只代表 admission 保護狀態。Quick Match 只能把廣告當預篩,真正容量與政策由 host admission 原子重驗(D-20260802-08)。

7. NAT 穿透:Circuit Relay v2

libp2p 先使用 direct/STUN;當 connection:close 顯示目前連線全失,或冷啟動經過 BOOTSTRAP_DIAL_TIMEOUT_MS = 15_000 後仍為零連線時,client 才逐一使用 玩家當次 snapshot 明示選取的 relay 候選建立 <relay multiaddr>/p2p-circuit reservation。 adapter 的 reserveRelay() Promise 完成才代表 v2 reservation 成立;第一次由 transportManager.listen(...) 建立 circuit listener,後續以同一 Listener.listen(...) 重用;單純 dial 成功不能算 reservation 成功。多個 recovery 共用單一航班;每個 libp2p generation 也只建立一個 circuit listener,第一個 relay 失敗後以同一 listener 嘗試 下一個,避免失敗 listener 累積。冷啟動只排一個 15 秒觀察 timer,不會在啟動時預撥 所有 relay:

const recoverRelay = makeRelayRecoveryOnDirectLoss({
  node: makeExplicitRelayReservationNode(node),
  relayAddrs: selectedNetworkServices.relay,
  isCurrent: () => generation === activeGeneration,
  onUnavailable: reportTransportUnavailable,
});
let inFlightRelayRecovery: Promise<void> | null = null;
const runRelayRecovery = () => {
  inFlightRelayRecovery = recoverRelay();
};
node.addEventListener("connection:close", runRelayRecovery);
let coldRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
if (selectedNetworkServices.relay.length > 0) {
  coldRecoveryTimer = setTimeout(runRelayRecovery, BOOTSTRAP_DIAL_TIMEOUT_MS);
}
// generation teardown:先 clear timer、停止 transport 以中止 listen,再等待 in-flight recovery 收斂。
if (coldRecoveryTimer !== null) clearTimeout(coldRecoveryTimer);
await shutdownTransport();
await inFlightRelayRecovery?.catch(() => undefined);

community registry listing 只是 unauthenticated discovery 輸入,永遠不能自行授予 relay 能力;relay 必須來自玩家手動設定、已接受 session invite,或玩家明示選取的 registry entry。 設定頁可逐類設定 bootstrap 與 relay,listing 本身不等於選取。節點的通用 listen 清單刻意不含/p2p-circuit,避免 libp2p RelayDiscovery 自動向任意 HOP-capable peer reservation。公版未部署設定保持空陣列。

行動網路嚴格 NAT 的 TURN relay fallback 見 部署資訊.md §4.3 · signaling-service.md §TURN

8. Bootstrap 候選

const bootstrapNodes = resolveBootstrapNodes({
  configured: selectedNetworkServices.bootstrap,
});

selectedNetworkServices.bootstrap 是玩家手動、已接受 session invite 與玩家明示選取的 community registry 候選所形成的有界 snapshot。resolveBootstrapNodes 只做 multiaddr 驗證、去重與當次 generation 排序;不抓主 repo 文字檔、不讀內建預設、不做失敗 fallback,也不把 runtime 順序 寫回 settings/registry。空輸入保持空陣列,代表不主動撥號 bootstrap,但本機與既有 session 仍可運作。

LEDGER_PROVIDER_FAULT_DOMAINS_RECOMMENDED = 3 只是在玩家選取多個獨立 provider 故障域時的可用性提示,不是硬下限或 主專案營運承諾;BOOTSTRAP_DIAL_TIMEOUT_MS = 15_000。registry 僅列公開且通過技術檢核的 服務,沒有優先級、信任或持續可用保證。

9. Topic Rate Limit

每 authenticated source 跨所有 topic 合計每 60s 上限 30 則(TOPIC_RATE_*network.md §10),逾額丟棄(防 gossip flooding)。counter map 硬上限 4,096;窗口過期時清理,滿載時淘汰最久未活動來源,不允許無界 signer churn:

class TopicRateLimiter {
  private counters = new Map<
    PeerId,
    { count: number; windowStart: number; lastSeen: number }
  >();
  private readonly WINDOW_MS = TOPIC_RATE_WINDOW_MS; // 60_000,network.md §10
  private readonly MAX_PER_WINDOW = TOPIC_RATE_MAX_PER_WINDOW; // 30,network.md §10
  shouldAccept(peerId: PeerId, topic: Topic): boolean {
    void topic; // 配額刻意跨 topic 聚合
    const now = Date.now();
    const c = this.counters.get(peerId);
    if (!c || now - c.windowStart > this.WINDOW_MS) {
      pruneExpired();
      if (!c && this.counters.size >= 4096) evictLeastRecentlyActive();
      this.counters.set(peerId, { count: 1, windowStart: now, lastSeen: now });
      return true;
    }
    c.count++;
    c.lastSeen = now;
    return c.count <= this.MAX_PER_WINDOW;
  }
}

限流只接受「authenticated GossipSub source = envelope signer」且驗簽成功的來源,並固定放在 nonce commit 前;同一來源跨 topic 共用 30/60s 配額,不能靠換 topic 繞過。Replay state 採每來源 64 nonce、最多 4096 來源,容量滿時 LRU 淘汰最久未活動來源,避免 Sybil 共享容量鎖死正常節點。raw message 65,536 bytes 上限仍在 dag-cbor decode 前執行。另有不隨 source LRU 淘汰的 global fixed-window raw bytes/message 與 signature verification token 上限;因此攻擊者輪換大量 PeerId 仍無法重置整體 CPU/ 記憶體預算。

10. 觀察接點(給其他模組)

interface DiscoveryEvents {
  on(event: "peer-connect", handler: (peerId: PeerId) => void): void;
  on(event: "peer-disconnect", handler: (peerId: PeerId) => void): void;
  on(event: "room-discovered", handler: (room: RoomAnnouncement) => void): void;
  on(event: "room-closed", handler: (code: string) => void): void;
}

11. 跨模組對接

模組 對接
key-manager.md signPayload / SignedPayload 結構
security.md verifyP2PMessage(簽章 + timestamp + nonce 防重放)
matchmaking.md Quick Match 既有房候選 / 動態窗口
reputation.md · moderation.md Peer Scoring 的信譽 / 黑名單來源(DerivedState)
signaling-service.md WebRTC 握手中介;GossipSub 為其 P2P fallback
ledger.md DerivedState 唯讀視圖;發現層訊息為 ephemeral 不入帳本
部署資訊.md §5 bootstrap 常數 / 部署面