mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// MeshPingPayloadTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import BitFoundation
|
||||
|
||||
struct MeshPingPayloadTests {
|
||||
|
||||
@Test func encodeDecodeRoundTrip() throws {
|
||||
let nonce = Data([0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xFF])
|
||||
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
|
||||
|
||||
let encoded = payload.encode()
|
||||
#expect(encoded.count == 9)
|
||||
#expect(encoded.prefix(8) == nonce)
|
||||
#expect(encoded.last == 7)
|
||||
|
||||
let decoded = try #require(MeshPingPayload.decode(encoded))
|
||||
#expect(decoded == payload)
|
||||
}
|
||||
|
||||
@Test func decodeToleratesTrailingBytes() throws {
|
||||
let nonce = Data(repeating: 0x42, count: 8)
|
||||
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 3))
|
||||
var extended = payload.encode()
|
||||
extended.append(contentsOf: [0xDE, 0xAD])
|
||||
|
||||
let decoded = try #require(MeshPingPayload.decode(extended))
|
||||
#expect(decoded == payload)
|
||||
}
|
||||
|
||||
@Test func decodeRespectsSliceIndices() throws {
|
||||
// Data slices keep their parent's indices; decoding must not assume
|
||||
// startIndex == 0.
|
||||
let nonce = Data(repeating: 0x11, count: 8)
|
||||
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 5))
|
||||
let framed = Data([0x00, 0x00]) + payload.encode()
|
||||
let slice = framed.dropFirst(2)
|
||||
|
||||
let decoded = try #require(MeshPingPayload.decode(slice))
|
||||
#expect(decoded == payload)
|
||||
}
|
||||
|
||||
@Test func rejectsTruncatedPayload() {
|
||||
#expect(MeshPingPayload.decode(Data(repeating: 0x01, count: 8)) == nil)
|
||||
#expect(MeshPingPayload.decode(Data()) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsWrongNonceLength() {
|
||||
#expect(MeshPingPayload(nonce: Data(repeating: 0, count: 7), originTTL: 7) == nil)
|
||||
#expect(MeshPingPayload(nonce: Data(repeating: 0, count: 9), originTTL: 7) == nil)
|
||||
}
|
||||
|
||||
@Test func hopCountMath() {
|
||||
// Direct link: no TTL decrement, one hop.
|
||||
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 7) == 1)
|
||||
// One relay in between: two hops.
|
||||
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 6) == 2)
|
||||
// Full TTL consumed.
|
||||
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 1) == 7)
|
||||
// Inconsistent TTLs (received above origin) are rejected.
|
||||
#expect(MeshPingPayload.hopCount(originTTL: 3, receivedTTL: 7) == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user