mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 08:05:21 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
d4f0c49787
commit
f9032cf2b9
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .nostrCarrier:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,15 @@ struct BLEReceivePipeline {
|
||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||
// remote handover toward a relayed announce rides this same
|
||||
// deterministic relay treatment instead of the broadcast clamp.
|
||||
// Ping/pong diagnostics ride it too: probes need the same
|
||||
// deterministic multi-hop relay as DMs (always relay, jitter,
|
||||
// no TTL cap) so RTT and hop counts reflect the real path.
|
||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||
|| packet.type == MessageType.ping.rawValue
|
||||
|| packet.type == MessageType.pong.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
|
||||
@@ -80,6 +80,24 @@ final class BLEService: NSObject {
|
||||
// Route health for originated source routes; guarded by collectionsQueue.
|
||||
private var sourceRouteFailures = BLESourceRouteFailureCache()
|
||||
|
||||
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
|
||||
// inbound ping budget — keyed by the ingress link (the directly connected
|
||||
// peer that delivered the packet), since the unsigned claimed sender is
|
||||
// spoofable — so a directed unencrypted probe cannot be turned into an
|
||||
// amplification primitive. Both are owned by collectionsQueue barriers
|
||||
// like the other mutable collections.
|
||||
private struct PendingMeshPing {
|
||||
let peerID: PeerID
|
||||
let sentAt: Date
|
||||
let completion: @MainActor (MeshPingResult?) -> Void
|
||||
let timeout: DispatchWorkItem
|
||||
}
|
||||
private var pendingMeshPings: [Data: PendingMeshPing] = [:]
|
||||
private var meshPingResponseLimiter = SyncResponseRateLimiter(
|
||||
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
||||
window: TransportConfig.meshPingInboundWindowSeconds
|
||||
)
|
||||
|
||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
||||
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
||||
@@ -2493,6 +2511,150 @@ extension BLEService {
|
||||
PeerID(routingData: data)
|
||||
}
|
||||
|
||||
// MARK: - Mesh Diagnostics (/ping, /trace, topology map)
|
||||
|
||||
/// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL).
|
||||
/// The completion fires exactly once on the main actor: with RTT/hops
|
||||
/// when the matching pong returns, or nil after the timeout window.
|
||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self,
|
||||
let recipientData = peerID.toShort().routingData,
|
||||
let payload = MeshPingPayload(
|
||||
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
|
||||
originTTL: self.messageTTL
|
||||
) else {
|
||||
Task { @MainActor in completion(nil) }
|
||||
return
|
||||
}
|
||||
let nonce = payload.nonce
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.ping.rawValue,
|
||||
senderID: self.myPeerIDData,
|
||||
recipientID: recipientData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload.encode(),
|
||||
signature: nil,
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
let timeout = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
let expired = self.collectionsQueue.sync(flags: .barrier) {
|
||||
self.pendingMeshPings.removeValue(forKey: nonce)
|
||||
}
|
||||
guard let expired else { return }
|
||||
Task { @MainActor in expired.completion(nil) }
|
||||
}
|
||||
self.collectionsQueue.sync(flags: .barrier) {
|
||||
self.pendingMeshPings[nonce] = PendingMeshPing(
|
||||
peerID: PeerID(hexData: recipientData),
|
||||
sentAt: Date(),
|
||||
completion: completion,
|
||||
timeout: timeout
|
||||
)
|
||||
}
|
||||
self.messageQueue.asyncAfter(
|
||||
deadline: .now() + TransportConfig.meshPingTimeoutSeconds,
|
||||
execute: timeout
|
||||
)
|
||||
self.broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a ping addressed to us with a pong echoing its nonce; pings
|
||||
/// addressed elsewhere are left to the generic directed-relay path.
|
||||
///
|
||||
/// `linkPeerID` is the directly connected peer that delivered the packet
|
||||
/// (the ingress link), NOT the packet's claimed sender: pings are
|
||||
/// unsigned, so `packet.senderID` is attacker-controlled, and keying the
|
||||
/// response budget on it would let one connected peer rotate forged
|
||||
/// sender IDs to emit unbounded pongs. The budget is per physical link;
|
||||
/// the pong still goes to the claimed sender (that's the protocol).
|
||||
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
|
||||
guard packet.recipientID == myPeerIDData else { return }
|
||||
guard let ping = MeshPingPayload.decode(packet.payload) else {
|
||||
SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
let allowed = collectionsQueue.sync(flags: .barrier) {
|
||||
meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date())
|
||||
}
|
||||
guard allowed else {
|
||||
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
|
||||
SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .security)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let pong = MeshPingPayload(nonce: ping.nonce, originTTL: messageTTL) else { return }
|
||||
let reply = BitchatPacket(
|
||||
type: MessageType.pong.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: packet.senderID,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: pong.encode(),
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
broadcastPacket(reply)
|
||||
}
|
||||
|
||||
/// Resolves a pong against its outstanding probe. The unguessable echoed
|
||||
/// nonce plus the sender check bind the reply to the probed peer; hops
|
||||
/// come from the pong's TTL decrements on the return path.
|
||||
private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
guard packet.recipientID == myPeerIDData else { return }
|
||||
guard let pong = MeshPingPayload.decode(packet.payload) else { return }
|
||||
let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in
|
||||
guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil }
|
||||
return pendingMeshPings.removeValue(forKey: pong.nonce)
|
||||
}
|
||||
guard let pending else { return }
|
||||
pending.timeout.cancel()
|
||||
let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded())
|
||||
let result = MeshPingResult(
|
||||
rttMs: max(0, rttMs),
|
||||
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
|
||||
)
|
||||
Task { @MainActor in pending.completion(result) }
|
||||
}
|
||||
|
||||
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
|
||||
/// bidirectionally-confirmed neighbor claims ([] = direct, nil = none).
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
||||
refreshLocalTopology()
|
||||
if let route = computeRoute(to: peerID) {
|
||||
return route.compactMap { PeerID(routingData: $0) }
|
||||
}
|
||||
// Confirmed claims can lag a brand-new link (the peer's next announce
|
||||
// hasn't arrived yet); a live direct connection is still a known path.
|
||||
return isPeerConnected(peerID) ? [] : nil
|
||||
}
|
||||
|
||||
/// Mesh graph for the topology map. Edges are advisory: announces cap
|
||||
/// neighbor lists at 10, so an edge claimed by either endpoint counts.
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? {
|
||||
refreshLocalTopology()
|
||||
let claims = meshTopology.adjacencySnapshot()
|
||||
var nodes = Set<PeerID>()
|
||||
var edges = Set<MeshTopologyEdge>()
|
||||
for (source, neighbors) in claims {
|
||||
guard let sourcePeer = PeerID(routingData: source) else { continue }
|
||||
nodes.insert(sourcePeer)
|
||||
for neighborData in neighbors {
|
||||
guard let neighborPeer = PeerID(routingData: neighborData),
|
||||
neighborPeer != sourcePeer else { continue }
|
||||
nodes.insert(neighborPeer)
|
||||
edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer))
|
||||
}
|
||||
}
|
||||
nodes.insert(myPeerID)
|
||||
return MeshTopologySnapshot(
|
||||
localPeerID: myPeerID,
|
||||
nodes: nodes.sorted(),
|
||||
edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) }
|
||||
)
|
||||
}
|
||||
|
||||
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
|
||||
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
|
||||
let plan = BLERouteForwardingPolicy.plan(
|
||||
@@ -3381,6 +3543,15 @@ extension BLEService {
|
||||
case .nostrCarrier:
|
||||
handleNostrCarrier(packet, from: peerID)
|
||||
|
||||
case .ping:
|
||||
// Rate limiting must key on the ingress link (`peerID`), not the
|
||||
// packet-claimed sender: pings are unsigned, so `senderID` is
|
||||
// attacker-controlled and rotating it would reset the budget.
|
||||
handleMeshPing(packet, fromLink: peerID)
|
||||
|
||||
case .pong:
|
||||
handleMeshPong(packet, from: senderID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user