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

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

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

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

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

Three fixes for PR #1377 review:

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

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

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

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

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

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

* Conform DiagnosticsMockContext to sendPublicMessage

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

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 15:08:22 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent d4f0c49787
commit f9032cf2b9
22 changed files with 1320 additions and 7 deletions
+22
View File
@@ -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
}
+133 -1
View File
@@ -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" : {
+8 -4
View File
@@ -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]
}
}
@@ -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,
+171
View File
@@ -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)
+96
View File
@@ -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 <token> 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) <nickname>"))
}
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 <cashu-token>` 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
@@ -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<Data>] {
queue.sync { claims }
}
func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) {
+53
View File
@@ -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) {}
+5
View File
@@ -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
+3
View File
@@ -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.
+27
View File
@@ -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
+45
View File
@@ -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)
+1 -1
View File
@@ -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 },
+217
View File
@@ -0,0 +1,217 @@
//
// MeshTopologyView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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")]
)
})
}
+63
View File
@@ -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 {
+14
View File
@@ -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)
}
+21
View File
@@ -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)
}
}
@@ -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)
}
}