Add mesh diagnostics: /ping, /trace, and topology map (#1377)

* Add mesh diagnostics: /ping, /trace, and topology map

- New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce
  + origin TTL) with per-peer inbound rate limiting (5 per 10s)
- /ping @name reports RTT and hop count, 10s timeout
- /trace @name prints the estimated path from gossiped directNeighbors
- Topology map sheet (circular Canvas layout) reachable from App Info
- Ping/pong ride the deterministic directed-relay path like DMs
- Tests: payload round-trip, hop-count math, command output, edge
  normalization, layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing

Three fixes for PR #1377 review:

1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the
   panicClearAllData / clearCurrentPublicTimeline detached utility-priority
   tasks delete the real ~/Library/Application Support/files tree, which the
   test process shares. The wipe fires at a nondeterministic time and raced
   the sendImage test's JPEG in files/images/outgoing (write then re-read),
   so prepareImagePacket threw and the test timed out. Both wipes are now
   skipped under tests (existing TestEnvironment.isRunningTests pattern);
   this also stops test runs from deleting the developer's real media.

2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on
   packet.senderID let one connected peer rotate forged sender IDs to bypass
   the 5-per-10s budget. The limiter now keys on the ingress link (the
   directly connected peer that delivered the packet); the pong still goes
   to the claimed sender. Regression test proves rotating senders over one
   link exhaust one budget (fails 10 vs 5 pongs on the old code).

3. Codex P2: /ping output arrived up to 10s later and was routed from
   selectedPrivateChatPeer at callback time, misrouting the result after a
   chat switch. The origin conversation is now captured when the command is
   issued (CommandOutputDestination) and deferred output is routed there:
   a DM result lands in the origin chat's history even if deselected, and a
   mesh-timeline result pins to #mesh instead of the active channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* App Info: move NETWORK section under HOW TO USE and uppercase NETWORK/SYMBOLS headers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Conform DiagnosticsMockContext to sendPublicMessage

CommandContextProvider gained sendPublicMessage (Cashu /pay, #1376) after
this branch forked, so the diagnostics test mock no longer conformed once
main was merged in. Add the no-op stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 15:08:22 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent d4f0c49787
commit f9032cf2b9
22 changed files with 1320 additions and 7 deletions
@@ -0,0 +1,57 @@
//
// MeshPingPayload.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
/// Wire payload shared by the `ping` (0x26) and `pong` (0x27) message types.
///
/// Layout (9 bytes):
/// - 8 bytes: random nonce (a pong echoes the nonce of the ping it answers)
/// - 1 byte: origin TTL the TTL the packet was launched with, so the
/// receiver can compute the hop count as `originTTL - receivedTTL`.
///
/// Both directions are unencrypted and unsigned: the payload carries no
/// private data, and the unguessable nonce already binds a pong to a probe
/// the local device actually sent.
public struct MeshPingPayload: Equatable {
public static let nonceLength = 8
private static let encodedLength = nonceLength + 1
public let nonce: Data
public let originTTL: UInt8
public init?(nonce: Data, originTTL: UInt8) {
guard nonce.count == Self.nonceLength else { return nil }
self.nonce = nonce
self.originTTL = originTTL
}
public func encode() -> Data {
var data = Data(capacity: Self.encodedLength)
data.append(nonce)
data.append(originTTL)
return data
}
/// Accepts payloads with trailing bytes so future revisions can extend
/// the format without breaking older clients.
public static func decode(_ data: Data) -> MeshPingPayload? {
guard data.count >= encodedLength else { return nil }
let nonce = Data(data.prefix(nonceLength))
let originTTL = data[data.index(data.startIndex, offsetBy: nonceLength)]
return MeshPingPayload(nonce: nonce, originTTL: originTTL)
}
/// Number of links a packet crossed, derived from TTL decrements plus the
/// final delivery link (a directly connected peer is 1 hop away).
/// Returns nil when the TTLs are inconsistent (received above origin).
public static func hopCount(originTTL: UInt8, receivedTTL: UInt8) -> Int? {
guard originTTL >= receivedTTL else { return nil }
return Int(originTTL - receivedTTL) + 1
}
}
@@ -26,6 +26,10 @@ public enum MessageType: UInt8 {
case fileTransfer = 0x22 // Binary file/audio/image payloads
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
// Mesh diagnostics
case ping = 0x26 // Directed echo request (nonce + origin TTL)
case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL)
// Gateway mode: signed Nostr event ferried between a mesh-only peer and
// an internet gateway peer.
case nostrCarrier = 0x28
@@ -42,6 +46,8 @@ public enum MessageType: UInt8 {
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
case .boardPost: return "boardPost"
case .ping: return "ping"
case .pong: return "pong"
case .nostrCarrier: return "nostrCarrier"
}
}