mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05: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
@@ -216,6 +216,69 @@ struct BLEServiceCoreTests {
|
||||
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
|
||||
))
|
||||
}
|
||||
|
||||
/// Pings are unsigned, so their claimed sender is attacker-controlled.
|
||||
/// The pong budget must be keyed on the ingress link (the directly
|
||||
/// connected peer that delivered the packet): rotating forged sender IDs
|
||||
/// over one link exhausts one budget instead of resetting it, so a single
|
||||
/// malicious link cannot turn /ping into an amplification primitive.
|
||||
@Test
|
||||
func meshPingResponseBudget_isPerIngressLinkNotClaimedSender() async throws {
|
||||
let ble = makeService()
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
|
||||
let link = PeerID(str: "1122334455667788")
|
||||
let budget = TransportConfig.meshPingInboundMaxPerLink
|
||||
let myRecipientData = try #require(Data(hexString: ble.myPeerID.id))
|
||||
|
||||
for i in 0..<(budget * 2) {
|
||||
// A fresh forged sender for every ping, all arriving on one link.
|
||||
let forgedSender = PeerID(str: String(format: "%016x", 0xA0_0000 + i))
|
||||
var nonce = Data(repeating: 0, count: MeshPingPayload.nonceLength)
|
||||
nonce[0] = UInt8(i)
|
||||
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.ping.rawValue,
|
||||
senderID: Data(hexString: forgedSender.id) ?? Data(),
|
||||
recipientID: myRecipientData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload.encode(),
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(packet, fromPeerID: link, preseedPeer: false)
|
||||
}
|
||||
|
||||
let reachedBudget = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .pong) >= budget },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(reachedBudget)
|
||||
// Give any over-budget pong a chance to surface, then confirm the
|
||||
// rotated sender IDs never bought a sixth response.
|
||||
let exceededBudget = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .pong) > budget },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!exceededBudget)
|
||||
#expect(outbound.count(ofType: .pong) == budget)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe capture of packets leaving the service under test.
|
||||
private final class OutboundPacketTap {
|
||||
private let lock = NSLock()
|
||||
private var packets: [BitchatPacket] = []
|
||||
|
||||
func record(_ packet: BitchatPacket) {
|
||||
lock.lock(); packets.append(packet); lock.unlock()
|
||||
}
|
||||
|
||||
func count(ofType type: MessageType) -> Int {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.filter { $0.type == type.rawValue }.count
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
|
||||
@@ -602,6 +602,8 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
private(set) var sentPublicRawMessages: [String] = []
|
||||
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
|
||||
private(set) var publicSystemMessages: [String] = []
|
||||
private(set) var commandOutputs: [String] = []
|
||||
private(set) var commandOutputDestinations: [CommandOutputDestination] = []
|
||||
private(set) var toggledFavorites: [PeerID] = []
|
||||
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||
|
||||
@@ -657,6 +659,18 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
publicSystemMessages.append(content)
|
||||
}
|
||||
|
||||
func currentCommandDestination() -> CommandOutputDestination {
|
||||
if let peerID = selectedPrivateChatPeer {
|
||||
return .privateChat(peerID)
|
||||
}
|
||||
return .meshTimeline
|
||||
}
|
||||
|
||||
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
|
||||
commandOutputs.append(content)
|
||||
commandOutputDestinations.append(destination)
|
||||
}
|
||||
|
||||
func toggleFavorite(peerID: PeerID) {
|
||||
toggledFavorites.append(peerID)
|
||||
}
|
||||
|
||||
@@ -196,6 +196,27 @@ final class MockTransport: Transport {
|
||||
return courierSendResult
|
||||
}
|
||||
|
||||
// MARK: - Mesh Diagnostics
|
||||
|
||||
private(set) var sentMeshPings: [PeerID] = []
|
||||
var meshPingResult: MeshPingResult?
|
||||
var meshPaths: [PeerID: [PeerID]] = [:]
|
||||
var meshTopologySnapshot: MeshTopologySnapshot?
|
||||
|
||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
||||
sentMeshPings.append(peerID)
|
||||
let result = meshPingResult
|
||||
Task { @MainActor in completion(result) }
|
||||
}
|
||||
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
||||
meshPaths[peerID]
|
||||
}
|
||||
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? {
|
||||
meshTopologySnapshot
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// Clears all recorded method calls for fresh assertions
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//
|
||||
// MeshDiagnosticsTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for /ping and /trace command handling and the topology snapshot
|
||||
// types backing the mesh topology map.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@Suite(.serialized)
|
||||
struct MeshDiagnosticsTests {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeProcessor(
|
||||
context: DiagnosticsMockContext,
|
||||
transport: MockTransport
|
||||
) -> CommandProcessor {
|
||||
CommandProcessor(
|
||||
contextProvider: context,
|
||||
meshService: transport,
|
||||
identityManager: MockIdentityManager(MockKeychain())
|
||||
)
|
||||
}
|
||||
|
||||
/// Waits for the async ping completion (MainActor hop) to land.
|
||||
@MainActor
|
||||
private func waitForCommandOutput(_ context: DiagnosticsMockContext) async {
|
||||
for _ in 0..<100 {
|
||||
if !context.commandOutputs.isEmpty { return }
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - /ping
|
||||
|
||||
@MainActor
|
||||
@Test func pingWithoutArgumentShowsUsage() {
|
||||
let context = DiagnosticsMockContext()
|
||||
let processor = makeProcessor(context: context, transport: MockTransport())
|
||||
let result = processor.process("/ping")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
#expect(message == "usage: /ping <nickname>")
|
||||
default:
|
||||
Issue.record("Expected usage error")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingUnknownPeerFails() {
|
||||
let context = DiagnosticsMockContext()
|
||||
let processor = makeProcessor(context: context, transport: MockTransport())
|
||||
let result = processor.process("/ping @ghost")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
#expect(message == "cannot ping ghost: not found on mesh")
|
||||
default:
|
||||
Issue.record("Expected error result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingGeoDMPeerIsRejected() {
|
||||
let context = DiagnosticsMockContext()
|
||||
context.nicknameToPeerID["alice"] = PeerID(nostr_: "aabbccddeeff00112233445566778899")
|
||||
let processor = makeProcessor(context: context, transport: MockTransport())
|
||||
let result = processor.process("/ping @alice")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
#expect(message == "cannot ping alice: not found on mesh")
|
||||
default:
|
||||
Issue.record("Expected error for geo peer")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingSuccessReportsRttAndHops() async {
|
||||
let context = DiagnosticsMockContext()
|
||||
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||
context.nicknameToPeerID["alice"] = peerID
|
||||
let transport = MockTransport()
|
||||
transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2)
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
let result = processor.process("/ping @alice")
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "pinging alice…")
|
||||
default:
|
||||
Issue.record("Expected immediate 'pinging' feedback")
|
||||
}
|
||||
#expect(transport.sentMeshPings == [peerID])
|
||||
|
||||
await waitForCommandOutput(context)
|
||||
#expect(context.commandOutputs == ["pong from alice: 42 ms · 2 hops"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingDirectPeerReportsSingleHop() async {
|
||||
let context = DiagnosticsMockContext()
|
||||
context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
|
||||
let transport = MockTransport()
|
||||
transport.meshPingResult = MeshPingResult(rttMs: 8, hops: 1)
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
_ = processor.process("/ping alice")
|
||||
await waitForCommandOutput(context)
|
||||
#expect(context.commandOutputs == ["pong from alice: 8 ms · direct (1 hop)"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingTimeoutReportsNoReply() async {
|
||||
let context = DiagnosticsMockContext()
|
||||
context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
|
||||
let transport = MockTransport()
|
||||
transport.meshPingResult = nil
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
_ = processor.process("/ping @alice")
|
||||
await waitForCommandOutput(context)
|
||||
#expect(context.commandOutputs == ["no reply from alice"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingOutputRoutesToConversationWhereCommandWasIssued() async {
|
||||
let context = DiagnosticsMockContext()
|
||||
let alice = PeerID(str: "abcd1234abcd1234")
|
||||
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
|
||||
context.nicknameToPeerID["alice"] = alice
|
||||
let transport = MockTransport()
|
||||
transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2)
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
// Issue the ping from bob's DM, then switch chats before the async
|
||||
// result lands. The output must follow the origin conversation, not
|
||||
// whatever is selected at callback time.
|
||||
context.selectedPrivateChatPeer = bob
|
||||
_ = processor.process("/ping @alice")
|
||||
context.selectedPrivateChatPeer = nil
|
||||
|
||||
await waitForCommandOutput(context)
|
||||
#expect(context.commandOutputDestinations == [.privateChat(bob)])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingIssuedFromPublicTimelineRoutesToMeshTimeline() async {
|
||||
let context = DiagnosticsMockContext()
|
||||
let alice = PeerID(str: "abcd1234abcd1234")
|
||||
context.nicknameToPeerID["alice"] = alice
|
||||
let transport = MockTransport()
|
||||
transport.meshPingResult = MeshPingResult(rttMs: 7, hops: 1)
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
_ = processor.process("/ping @alice")
|
||||
// Opening a DM afterwards must not swallow the public-timeline result.
|
||||
context.selectedPrivateChatPeer = alice
|
||||
|
||||
await waitForCommandOutput(context)
|
||||
#expect(context.commandOutputDestinations == [.meshTimeline])
|
||||
}
|
||||
|
||||
// MARK: - /trace
|
||||
|
||||
@MainActor
|
||||
@Test func traceDirectPeerShowsOneHop() {
|
||||
let context = DiagnosticsMockContext()
|
||||
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
|
||||
context.nicknameToPeerID["bob"] = bob
|
||||
let transport = MockTransport()
|
||||
transport.meshPaths[bob] = []
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
let result = processor.process("/trace @bob")
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "estimated path: you → bob (1 hop)")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func traceMultiHopUsesNicknamesWithShortIDFallback() {
|
||||
let context = DiagnosticsMockContext()
|
||||
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
|
||||
let alice = PeerID(str: "a11cea11cea11cea")
|
||||
let unknown = PeerID(str: "dead00beef001234")
|
||||
context.nicknameToPeerID["bob"] = bob
|
||||
let transport = MockTransport()
|
||||
transport.peerNicknames = [alice: "alice"]
|
||||
transport.meshPaths[bob] = [alice, unknown]
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
let result = processor.process("/trace bob")
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "estimated path: you → alice → dead00be… → bob (3 hops)")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func traceWithoutPathReportsNoKnownPath() {
|
||||
let context = DiagnosticsMockContext()
|
||||
context.nicknameToPeerID["bob"] = PeerID(str: "b0b0b0b0b0b0b0b0")
|
||||
let processor = makeProcessor(context: context, transport: MockTransport())
|
||||
|
||||
let result = processor.process("/trace @bob")
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "no known path to bob")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Topology snapshot types
|
||||
|
||||
@Test func topologyEdgeNormalizesEndpointOrder() {
|
||||
let a = PeerID(str: "aaaa000000000000")
|
||||
let b = PeerID(str: "bbbb000000000000")
|
||||
#expect(MeshTopologyEdge(a, b) == MeshTopologyEdge(b, a))
|
||||
#expect(Set([MeshTopologyEdge(a, b), MeshTopologyEdge(b, a)]).count == 1)
|
||||
}
|
||||
|
||||
@Test func topologyLayoutPlacesSelfInCenter() {
|
||||
let nodes: [MeshTopologyDisplayModel.Node] = [
|
||||
.init(id: "self", label: "me", isSelf: true),
|
||||
.init(id: "a", label: "alice", isSelf: false),
|
||||
.init(id: "b", label: "bob", isSelf: false)
|
||||
]
|
||||
let size = CGSize(width: 200, height: 200)
|
||||
let positions = MeshTopologyView.layout(nodes: nodes, in: size)
|
||||
|
||||
#expect(positions["self"] == CGPoint(x: 100, y: 100))
|
||||
#expect(positions.count == 3)
|
||||
// Ring nodes sit on the same radius around the center.
|
||||
let radiusA = hypot((positions["a"]?.x ?? 0) - 100, (positions["a"]?.y ?? 0) - 100)
|
||||
let radiusB = hypot((positions["b"]?.x ?? 0) - 100, (positions["b"]?.y ?? 0) - 100)
|
||||
#expect(abs(radiusA - radiusB) < 0.001)
|
||||
#expect(radiusA > 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal CommandContextProvider for diagnostics tests; records deferred
|
||||
/// command output so async /ping results can be asserted.
|
||||
@MainActor
|
||||
private final class DiagnosticsMockContext: CommandContextProvider {
|
||||
var nickname: String = "tester"
|
||||
var activeChannel: ChannelID = .mesh
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var blockedUsers: Set<String> = []
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychain())
|
||||
|
||||
var nicknameToPeerID: [String: PeerID] = [:]
|
||||
private(set) var commandOutputs: [String] = []
|
||||
private(set) var commandOutputDestinations: [CommandOutputDestination] = []
|
||||
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
||||
nicknameToPeerID[nickname]
|
||||
}
|
||||
|
||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] }
|
||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil }
|
||||
func startPrivateChat(with peerID: PeerID) {}
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {}
|
||||
func clearCurrentPublicTimeline() {}
|
||||
func clearPrivateChat(_ peerID: PeerID) {}
|
||||
func sendPublicRaw(_ content: String) {}
|
||||
func sendPublicMessage(_ content: String) {}
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
|
||||
func addPublicSystemMessage(_ content: String) {}
|
||||
func toggleFavorite(peerID: PeerID) {}
|
||||
|
||||
func currentCommandDestination() -> CommandOutputDestination {
|
||||
if let peerID = selectedPrivateChatPeer {
|
||||
return .privateChat(peerID)
|
||||
}
|
||||
return .meshTimeline
|
||||
}
|
||||
|
||||
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
|
||||
commandOutputs.append(content)
|
||||
commandOutputDestinations.append(destination)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user