diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index d90df814..a920c128 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -62,6 +62,28 @@ final class AppChromeModel: ObservableObject { isAppInfoPresented = true } + /// Builds the mesh topology map model from the transport's gossiped + /// graph plus the live nickname table. Unknown nodes (heard about via a + /// neighbor claim but never announced to us) fall back to a short ID. + func meshTopologyDisplayModel() -> MeshTopologyDisplayModel { + let mesh = chatViewModel.meshService + guard let snapshot = mesh.currentMeshTopology() else { return .empty } + let nicknames = mesh.getPeerNicknames() + + let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in + let isSelf = peerID == snapshot.localPeerID + let label: String + if isSelf { + label = chatViewModel.nickname + } else { + label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…" + } + return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf) + } + let edges = snapshot.edges.map { ($0.a.id, $0.b.id) } + return MeshTopologyDisplayModel(nodes: nodes, edges: edges) + } + func triggerScreenshotPrivacyWarning() { showScreenshotPrivacyWarning = true } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index bed148dd..49e5487e 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -5142,7 +5142,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "symbols" + "value" : "SYMBOLS" } } } @@ -5171,6 +5171,54 @@ } } }, + "app_info.network.title" : { + "comment" : "Section header for network diagnostics in the app info sheet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NETWORK" + } + } + } + }, + "app_info.network.topology.description" : { + "comment" : "Row description for the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "map of peers and links learned from mesh announces" + } + } + } + }, + "app_info.network.topology.hint" : { + "comment" : "Accessibility hint for the mesh topology row", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the mesh topology map" + } + } + } + }, + "app_info.network.topology.title" : { + "comment" : "Row title opening the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + } + } + }, "app_info.privacy.ephemeral.description" : { "extractionState" : "manual", "localizations" : { @@ -15449,6 +15497,18 @@ } } }, + "content.commands.ping" : { + "comment" : "Description of the /ping command in the suggestions panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "measure round-trip time to a mesh peer" + } + } + } + }, "content.commands.slap" : { "extractionState" : "manual", "localizations" : { @@ -15628,6 +15688,18 @@ } } }, + "content.commands.trace" : { + "comment" : "Description of the /trace command in the suggestions panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimate the mesh path to a peer" + } + } + } + }, "content.commands.unblock" : { "extractionState" : "manual", "localizations" : { @@ -35384,6 +35456,66 @@ } } }, + "topology.caption" : { + "comment" : "Caption under the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted" + } + } + } + }, + "topology.empty" : { + "comment" : "Empty state of the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no mesh links yet — the map fills in as peer announces arrive" + } + } + } + }, + "topology.refresh" : { + "comment" : "Accessibility label of the topology refresh button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "refresh topology" + } + } + } + }, + "topology.summary" : { + "comment" : "Topology map summary: number of peers and links", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$ld peers · %2$ld links" + } + } + } + }, + "topology.title" : { + "comment" : "Title of the mesh topology map sheet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + } + } + }, "verification.my_qr.accessibility_label" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index 0ad3d554..bb0a0a65 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -25,6 +25,8 @@ enum CommandInfo: String, Identifiable { case who case favorite = "fav" case unfavorite = "unfav" + case ping + case trace var id: String { rawValue } @@ -32,7 +34,7 @@ enum CommandInfo: String, Identifiable { var placeholder: String? { switch self { - case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: + case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace: return "<" + String(localized: "content.input.nickname_placeholder") + ">" case .pay: return "<" + String(localized: "content.input.token_placeholder") + ">" @@ -54,6 +56,8 @@ enum CommandInfo: String, Identifiable { case .who: String(localized: "content.commands.who") case .favorite: String(localized: "content.commands.favorite") case .unfavorite: String(localized: "content.commands.unfavorite") + case .ping: String(localized: "content.commands.ping") + case .trace: String(localized: "content.commands.trace") } } @@ -66,11 +70,11 @@ enum CommandInfo: String, Identifiable { if !isGeoPublic { commands.append(.pay) } - // The processor rejects favorites in geohash contexts, so only - // suggest them where they actually work: mesh. + // The processor rejects favorites and mesh diagnostics in geohash + // contexts, so only suggest them where they actually work: mesh. if isGeoPublic || isGeoDM { return commands } - return commands + [.favorite, .unfavorite] + return commands + [.favorite, .unfavorite, .ping, .trace] } } diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index a6a8c149..10837d9b 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -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 } } diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 691bb3e2..92e3bec8 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -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, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 3c1538d3..c7a8570a 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -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.. 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() + var edges = Set() + 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) diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 220e66ef..9555609a 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -22,6 +22,17 @@ struct CommandGeoParticipant { let displayName: String } +/// The conversation a command was typed into, captured when the command is +/// issued so deferred output (e.g. an async /ping result, which can arrive +/// many seconds later) lands there even if the user switches chats first. +enum CommandOutputDestination: Equatable { + /// The #mesh public timeline. Commands that defer output (/ping) are + /// mesh-only, so a non-DM origin is always the mesh timeline. + case meshTimeline + /// The private chat that was open when the command was typed. + case privateChat(PeerID) +} + /// Protocol defining what CommandProcessor needs from its context. /// This breaks the circular dependency between CommandProcessor and ChatViewModel. @MainActor @@ -51,6 +62,13 @@ protocol CommandContextProvider: AnyObject { // MARK: - System Messages func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) func addPublicSystemMessage(_ content: String) + /// The conversation the user is typing into right now. Commands that + /// finish asynchronously capture this BEFORE starting async work, so a + /// chat switch cannot misroute their deferred output. + func currentCommandDestination() -> CommandOutputDestination + /// Routes deferred command output (e.g. an async /ping result) into the + /// conversation captured when the command was issued. + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) // MARK: - Favorites /// Toggles the favorite via the unified peer flow, which persists by the @@ -108,6 +126,12 @@ final class CommandProcessor { case "/unfav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: false) + case "/ping": + if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") } + return handlePing(args) + case "/trace": + if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") } + return handleTrace(args) case "/pay": return handlePay(args) case "/help": @@ -129,6 +153,8 @@ final class CommandProcessor { /slap @name — slap with a large trout /block @name · /unblock @name /fav @name · /unfav @name — favorites (mesh only) + /ping @name — measure round-trip time (mesh only) + /trace @name — estimated mesh path (mesh only) /pay — send a cashu ecash token in this chat /help — this list """ @@ -336,6 +362,76 @@ final class CommandProcessor { return .error(message: "cannot unblock \(nickname): not found") } + // MARK: - Mesh Diagnostics + + private enum MeshPeerResolution { + case resolved(peerID: PeerID, nickname: String) + case failed(CommandResult) + } + + /// Resolves a mesh peer for /ping and /trace. Geohash identities are + /// rejected — diagnostics measure the BLE mesh, not Nostr. + private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution { + let targetName = args.trimmed + guard !targetName.isEmpty else { + return .failed(.error(message: "usage: /\(command) ")) + } + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName + guard let peerID = contextProvider?.getPeerIDForNickname(nickname), + !peerID.isGeoDM, !peerID.isGeoChat else { + return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh")) + } + return .resolved(peerID: peerID, nickname: nickname) + } + + private func handlePing(_ args: String) -> CommandResult { + let target: (peerID: PeerID, nickname: String) + switch resolveMeshPeer(args, command: "ping") { + case .resolved(let peerID, let nickname): target = (peerID, nickname) + case .failed(let result): return result + } + + let nickname = target.nickname + let currentProvider = contextProvider + // Capture the origin conversation now: the pong can arrive up to + // meshPingTimeoutSeconds later, and reading the selected chat at + // callback time would misroute the result after a chat switch. + let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline + meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in + let provider = currentProvider + guard let result else { + provider?.addCommandOutput("no reply from \(nickname)", to: destination) + return + } + let hopText: String = result.hops.map { hops in + hops == 1 ? " · direct (1 hop)" : " · \(hops) hops" + } ?? "" + provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination) + } + return .success(message: "pinging \(nickname)…") + } + + private func handleTrace(_ args: String) -> CommandResult { + let target: (peerID: PeerID, nickname: String) + switch resolveMeshPeer(args, command: "trace") { + case .resolved(let peerID, let nickname): target = (peerID, nickname) + case .failed(let result): return result + } + + guard let mesh = meshService, + let intermediates = mesh.computeMeshPath(to: target.peerID) else { + return .success(message: "no known path to \(target.nickname)") + } + // Graph-derived from gossiped neighbor claims, not route-recorded — + // present it as an estimate. + let hopNames = intermediates.map { hop in + mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…" + } + let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ") + let hops = intermediates.count + 1 + return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))") + } + /// `/pay ` — validates the token decodes, then sends it as /// the message body in the current chat. Cashu tokens are bearer /// instruments (whoever redeems first gets the funds), so posting one to diff --git a/bitchat/Services/MeshTopologyTracker.swift b/bitchat/Services/MeshTopologyTracker.swift index eb4e0c3c..5596b5b1 100644 --- a/bitchat/Services/MeshTopologyTracker.swift +++ b/bitchat/Services/MeshTopologyTracker.swift @@ -49,6 +49,13 @@ final class MeshTopologyTracker { } } + /// Raw directed neighbor claims, for diagnostics (topology map, /trace). + /// Callers treat the claims as advisory: announces cap `directNeighbors` + /// at 10, so an edge may be claimed by only one of its endpoints. + func adjacencySnapshot() -> [Data: Set] { + queue.sync { claims } + } + func removePeer(_ data: Data?) { guard let peer = sanitize(data) else { return } queue.sync(flags: .barrier) { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 00a17a87..45aed91e 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -31,6 +31,40 @@ struct TransportPeerSnapshot: Equatable, Hashable { } } +/// Outcome of a `/ping` probe over the mesh. +struct MeshPingResult: Equatable { + /// Round-trip time in milliseconds. + let rttMs: Int + /// Total hops to the peer (1 = directly connected), derived from the + /// pong's TTL decrements; nil when the reply carried inconsistent TTLs. + let hops: Int? +} + +/// Undirected mesh link between two peers, normalized so `(a, b)` and +/// `(b, a)` collapse to one edge. +struct MeshTopologyEdge: Hashable { + let a: PeerID + let b: PeerID + + init(_ first: PeerID, _ second: PeerID) { + if first < second { + a = first + b = second + } else { + a = second + b = first + } + } +} + +/// Point-in-time view of the mesh graph learned from gossiped announces +/// (each announce carries up to 10 `directNeighbors`). +struct MeshTopologySnapshot: Equatable { + let localPeerID: PeerID + let nodes: [PeerID] + let edges: [MeshTopologyEdge] +} + enum TransportEvent: @unchecked Sendable { case messageReceived(BitchatMessage) case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) @@ -128,6 +162,17 @@ protocol Transport: AnyObject { // payload (post or tombstone) so it spreads over relay and gossip sync. func sendBoardPayload(_ payload: Data) + // Mesh diagnostics (optional for transports). Defaults are inert so + // queue-backed transports (e.g. NostrTransport) stay untouched. + /// Sends a directed ping probe; the completion fires exactly once on the + /// main actor with the measured result, or nil on timeout/unsupported. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) + /// Estimated intermediate hops toward `peerID` from gossiped topology + /// ([] = direct link, nil = no known path). + func computeMeshPath(to peerID: PeerID) -> [PeerID]? + /// Current mesh graph for the topology map; nil when unsupported. + func currentMeshTopology() -> MeshTopologySnapshot? + // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) @@ -174,6 +219,14 @@ extension Transport { func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } func sendBoardPayload(_ payload: Data) {} + + // Mesh diagnostics are mesh-transport-only; other transports report + // "no reply"/"no path" rather than pretending to measure anything. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + Task { @MainActor in completion(nil) } + } + func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil } + func currentMeshTopology() -> MeshTopologySnapshot? { nil } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func cancelTransfer(_ transferId: String) {} diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 649a8be9..31659710 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -16,6 +16,11 @@ enum TransportConfig { static let bleFragmentRelayTtlCap: UInt8 = 7 static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs + // Mesh diagnostics (/ping) + static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window + static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)... + static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification) + // UI / Storage Caps static let privateChatCap: Int = 1337 static let meshTimelineCap: Int = 1337 diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index eb082c31..3f455497 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -40,6 +40,9 @@ struct SyncTypeFlags: OptionSet { // Courier envelopes are directed deposits between trusted peers and // must never spread via gossip sync. case .courierEnvelope: return nil + // Ping/pong are ephemeral directed probes; replaying them via gossip + // sync would only produce stale, unanswerable echoes. + case .ping, .pong: return nil // Gateway carriers are ephemeral live traffic (uplinks are directed, // downlinks are rate-budgeted rebroadcasts); replaying them via sync // would waste airtime and extend their lifetime. diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a6606549..62b49da5 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1547,6 +1547,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } } + /// Origin conversation for deferred command output, captured when the + /// command is issued (before any async work starts). + @MainActor + func currentCommandDestination() -> CommandOutputDestination { + if let peerID = selectedPrivateChatPeer { + return .privateChat(peerID) + } + // Deferring commands (/ping) are rejected in geohash channels, so a + // non-DM origin is always the #mesh timeline. + return .meshTimeline + } + + /// Routes deferred command output (async /ping results) into the + /// conversation captured at issue time, immune to chat switches in the + /// meantime. A DM result lands in the origin chat's history even if that + /// chat is no longer selected (or was cleared — it then reappears as the + /// first message when the chat is reopened). + @MainActor + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { + switch destination { + case .privateChat(let peerID): + addLocalPrivateSystemMessage(content, to: peerID) + case .meshTimeline: + publicConversationCoordinator.addMeshOnlySystemMessage(content) + } + } + // MARK: - Message Reception @MainActor diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index 152c09df..3f74a354 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -5,6 +5,11 @@ struct AppInfoView: View { @ThemedPalette private var palette @AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue + /// Supplies the mesh topology map data. Nil (previews, missing wiring) + /// hides the topology row entirely. + var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? + @State private var showTopology = false + private var selectedTheme: AppTheme { AppTheme(rawValue: appThemeRawValue) ?? .matrix } @@ -75,6 +80,15 @@ struct AppInfoView: View { ] } + enum Network { + static let title: LocalizedStringKey = "app_info.network.title" + static let topology = AppInfoFeatureInfo( + icon: "point.3.connected.trianglepath.dotted", + title: "app_info.network.topology.title", + description: "app_info.network.topology.description" + ) + } + enum Privacy { static let title: LocalizedStringKey = "app_info.privacy.title" static let noTracking = AppInfoFeatureInfo( @@ -129,6 +143,11 @@ struct AppInfoView: View { .themedSheetBackground() } .frame(width: 600, height: 700) + .sheet(isPresented: $showTopology) { + if let topologyProvider { + MeshTopologyView(provider: topologyProvider) + } + } #else NavigationView { ScrollView { @@ -143,6 +162,11 @@ struct AppInfoView: View { } } } + .sheet(isPresented: $showTopology) { + if let topologyProvider { + MeshTopologyView(provider: topologyProvider) + } + } #endif } @@ -199,6 +223,27 @@ struct AppInfoView: View { .foregroundColor(textColor) } + // Network diagnostics + if topologyProvider != nil { + VStack(alignment: .leading, spacing: 16) { + SectionHeader(Strings.Network.title) + + Button { + showTopology = true + } label: { + HStack(spacing: 0) { + FeatureRow(info: Strings.Network.topology) + Image(systemName: "chevron.right") + .font(.bitchatSystem(size: 12)) + .foregroundColor(secondaryTextColor) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint(Text("app_info.network.topology.hint")) + } + } + // Features VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.Features.title) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 86bb31ad..bd434ad1 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -149,7 +149,7 @@ struct ContentView: View { #endif } .sheet(isPresented: $appChromeModel.isAppInfoPresented) { - AppInfoView() + AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() }) } .sheet(isPresented: Binding( get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil }, diff --git a/bitchat/Views/MeshTopologyView.swift b/bitchat/Views/MeshTopologyView.swift new file mode 100644 index 00000000..a8bc4e3e --- /dev/null +++ b/bitchat/Views/MeshTopologyView.swift @@ -0,0 +1,217 @@ +// +// MeshTopologyView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Display model for the mesh topology map: nodes are known mesh peers, +/// edges are gossiped `directNeighbors` claims. Built on the main actor from +/// a `MeshTopologySnapshot` plus the current nickname table. +struct MeshTopologyDisplayModel { + struct Node: Identifiable, Equatable { + let id: String + let label: String + let isSelf: Bool + } + + let nodes: [Node] + /// Pairs of `Node.id`; every id is present in `nodes`. + let edges: [(String, String)] + + static let empty = MeshTopologyDisplayModel(nodes: [], edges: []) +} + +/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in +/// the center), drawn with Canvas so it stays cheap at any peer count. +struct MeshTopologyView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.appTheme) private var appTheme + @ThemedPalette private var palette + + /// Fetches a fresh model; called on appear and on manual refresh. + let provider: @MainActor () -> MeshTopologyDisplayModel + @State private var model: MeshTopologyDisplayModel = .empty + + var body: some View { + #if os(macOS) + VStack(spacing: 0) { + HStack { + Text("topology.title") + .bitchatFont(size: 16, weight: .bold) + .foregroundColor(palette.primary) + Spacer() + refreshButton + Button("app_info.done") { + dismiss() + } + .buttonStyle(.plain) + .foregroundColor(palette.primary) + } + .padding() + .themedSurface(opacity: 0.95) + + content + } + .frame(width: 500, height: 520) + .themedSheetBackground() + #else + NavigationView { + content + .themedSheetBackground() + .navigationTitle(Text("topology.title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + refreshButton + } + ToolbarItem(placement: .navigationBarTrailing) { + SheetCloseButton { dismiss() } + .foregroundColor(palette.primary) + } + } + } + #endif + } + + private var refreshButton: some View { + Button { + model = provider() + } label: { + Image(systemName: "arrow.clockwise") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("topology.refresh")) + } + + @ViewBuilder + private var content: some View { + VStack(spacing: 12) { + if model.nodes.count <= 1 { + Spacer() + Text("topology.empty") + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Spacer() + } else { + graphCanvas + .padding(.horizontal, 8) + } + + VStack(spacing: 4) { + Text(summaryText) + .bitchatFont(size: 13, weight: .semibold) + .foregroundColor(palette.primary) + Text("topology.caption") + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + .padding(.bottom, 16) + } + .onAppear { model = provider() } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(summaryText)) + } + + private var summaryText: String { + String( + format: String( + localized: "topology.summary", + comment: "Topology map summary: number of peers and links" + ), + locale: .current, + model.nodes.count, + model.edges.count + ) + } + + private var graphCanvas: some View { + Canvas { context, size in + let positions = Self.layout(nodes: model.nodes, in: size) + let fontDesign = appTheme.bodyFontDesign + + // Edges first so nodes draw on top. + for (fromID, toID) in model.edges { + guard let from = positions[fromID], let to = positions[toID] else { continue } + var path = Path() + path.move(to: from) + path.addLine(to: to) + context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1) + } + + for node in model.nodes { + guard let center = positions[node.id] else { continue } + let radius: CGFloat = node.isSelf ? 7 : 5 + let dot = Path(ellipseIn: CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + )) + context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary)) + if node.isSelf { + let ring = Path(ellipseIn: CGRect( + x: center.x - radius - 3, + y: center.y - radius - 3, + width: (radius + 3) * 2, + height: (radius + 3) * 2 + )) + context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1) + } + context.draw( + Text(node.label) + .font(.system(size: 10, design: fontDesign)) + .foregroundColor(node.isSelf ? palette.accent : palette.secondary), + at: CGPoint(x: center.x, y: center.y + radius + 4), + anchor: .top + ) + } + } + .accessibilityHidden(true) // The combined summary label narrates the graph. + } + + /// Circular layout: self in the center, everyone else evenly spaced on a + /// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle. + static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + // Leave room for the label row under each ring node. + let radius = max(20, min(size.width, size.height) / 2 - 36) + var positions: [String: CGPoint] = [:] + + let ringNodes = nodes.filter { !$0.isSelf } + for node in nodes where node.isSelf { + positions[node.id] = center + } + for (index, node) in ringNodes.enumerated() { + let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2 + positions[node.id] = CGPoint( + x: center.x + radius * cos(angle), + y: center.y + radius * sin(angle) + ) + } + return positions + } +} + +#Preview("Topology") { + MeshTopologyView(provider: { + MeshTopologyDisplayModel( + nodes: [ + .init(id: "self", label: "me", isSelf: true), + .init(id: "a", label: "alice", isSelf: false), + .init(id: "b", label: "bob", isSelf: false), + .init(id: "c", label: "carol", isSelf: false) + ], + edges: [("self", "a"), ("a", "b"), ("self", "c")] + ) + }) +} diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index fe7624ec..6a9cdfc1 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -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 { diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 1937174e..718df1c8 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -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) } diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index e4cf08de..5f1243e8 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -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 diff --git a/bitchatTests/Services/MeshDiagnosticsTests.swift b/bitchatTests/Services/MeshDiagnosticsTests.swift new file mode 100644 index 00000000..ced8a92e --- /dev/null +++ b/bitchatTests/Services/MeshDiagnosticsTests.swift @@ -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 ") + 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 = [] + 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) + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift new file mode 100644 index 00000000..54265b8c --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift @@ -0,0 +1,57 @@ +// +// MeshPingPayload.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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 + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index d1e2ea0d..763abaa7 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -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" } } diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift new file mode 100644 index 00000000..8032bd9c --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift @@ -0,0 +1,70 @@ +// +// MeshPingPayloadTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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) + } +}