mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 09:05:19 +00:00
Add mesh diagnostics: /ping, /trace, and topology map (#1377)
* Add mesh diagnostics: /ping, /trace, and topology map - New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce + origin TTL) with per-peer inbound rate limiting (5 per 10s) - /ping @name reports RTT and hop count, 10s timeout - /trace @name prints the estimated path from gossiped directNeighbors - Topology map sheet (circular Canvas layout) reachable from App Info - Ping/pong ride the deterministic directed-relay path like DMs - Tests: payload round-trip, hop-count math, command output, edge normalization, layout Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing Three fixes for PR #1377 review: 1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the panicClearAllData / clearCurrentPublicTimeline detached utility-priority tasks delete the real ~/Library/Application Support/files tree, which the test process shares. The wipe fires at a nondeterministic time and raced the sendImage test's JPEG in files/images/outgoing (write then re-read), so prepareImagePacket threw and the test timed out. Both wipes are now skipped under tests (existing TestEnvironment.isRunningTests pattern); this also stops test runs from deleting the developer's real media. 2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on packet.senderID let one connected peer rotate forged sender IDs to bypass the 5-per-10s budget. The limiter now keys on the ingress link (the directly connected peer that delivered the packet); the pong still goes to the claimed sender. Regression test proves rotating senders over one link exhaust one budget (fails 10 vs 5 pongs on the old code). 3. Codex P2: /ping output arrived up to 10s later and was routed from selectedPrivateChatPeer at callback time, misrouting the result after a chat switch. The origin conversation is now captured when the command is issued (CommandOutputDestination) and deferred output is routed there: a DM result lands in the origin chat's history even if deselected, and a mesh-timeline result pins to #mesh instead of the active channel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * App Info: move NETWORK section under HOW TO USE and uppercase NETWORK/SYMBOLS headers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Conform DiagnosticsMockContext to sendPublicMessage CommandContextProvider gained sendPublicMessage (Cashu /pay, #1376) after this branch forked, so the diagnostics test mock no longer conformed once main was merged in. Add the no-op stub. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
d4f0c49787
commit
f9032cf2b9
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .nostrCarrier:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,15 @@ struct BLEReceivePipeline {
|
||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||
// remote handover toward a relayed announce rides this same
|
||||
// deterministic relay treatment instead of the broadcast clamp.
|
||||
// Ping/pong diagnostics ride it too: probes need the same
|
||||
// deterministic multi-hop relay as DMs (always relay, jitter,
|
||||
// no TTL cap) so RTT and hop counts reflect the real path.
|
||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||
|| packet.type == MessageType.ping.rawValue
|
||||
|| packet.type == MessageType.pong.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
|
||||
@@ -80,6 +80,24 @@ final class BLEService: NSObject {
|
||||
// Route health for originated source routes; guarded by collectionsQueue.
|
||||
private var sourceRouteFailures = BLESourceRouteFailureCache()
|
||||
|
||||
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
|
||||
// inbound ping budget — keyed by the ingress link (the directly connected
|
||||
// peer that delivered the packet), since the unsigned claimed sender is
|
||||
// spoofable — so a directed unencrypted probe cannot be turned into an
|
||||
// amplification primitive. Both are owned by collectionsQueue barriers
|
||||
// like the other mutable collections.
|
||||
private struct PendingMeshPing {
|
||||
let peerID: PeerID
|
||||
let sentAt: Date
|
||||
let completion: @MainActor (MeshPingResult?) -> Void
|
||||
let timeout: DispatchWorkItem
|
||||
}
|
||||
private var pendingMeshPings: [Data: PendingMeshPing] = [:]
|
||||
private var meshPingResponseLimiter = SyncResponseRateLimiter(
|
||||
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
||||
window: TransportConfig.meshPingInboundWindowSeconds
|
||||
)
|
||||
|
||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
||||
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
||||
@@ -2493,6 +2511,150 @@ extension BLEService {
|
||||
PeerID(routingData: data)
|
||||
}
|
||||
|
||||
// MARK: - Mesh Diagnostics (/ping, /trace, topology map)
|
||||
|
||||
/// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL).
|
||||
/// The completion fires exactly once on the main actor: with RTT/hops
|
||||
/// when the matching pong returns, or nil after the timeout window.
|
||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self,
|
||||
let recipientData = peerID.toShort().routingData,
|
||||
let payload = MeshPingPayload(
|
||||
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
|
||||
originTTL: self.messageTTL
|
||||
) else {
|
||||
Task { @MainActor in completion(nil) }
|
||||
return
|
||||
}
|
||||
let nonce = payload.nonce
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.ping.rawValue,
|
||||
senderID: self.myPeerIDData,
|
||||
recipientID: recipientData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload.encode(),
|
||||
signature: nil,
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
let timeout = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
let expired = self.collectionsQueue.sync(flags: .barrier) {
|
||||
self.pendingMeshPings.removeValue(forKey: nonce)
|
||||
}
|
||||
guard let expired else { return }
|
||||
Task { @MainActor in expired.completion(nil) }
|
||||
}
|
||||
self.collectionsQueue.sync(flags: .barrier) {
|
||||
self.pendingMeshPings[nonce] = PendingMeshPing(
|
||||
peerID: PeerID(hexData: recipientData),
|
||||
sentAt: Date(),
|
||||
completion: completion,
|
||||
timeout: timeout
|
||||
)
|
||||
}
|
||||
self.messageQueue.asyncAfter(
|
||||
deadline: .now() + TransportConfig.meshPingTimeoutSeconds,
|
||||
execute: timeout
|
||||
)
|
||||
self.broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a ping addressed to us with a pong echoing its nonce; pings
|
||||
/// addressed elsewhere are left to the generic directed-relay path.
|
||||
///
|
||||
/// `linkPeerID` is the directly connected peer that delivered the packet
|
||||
/// (the ingress link), NOT the packet's claimed sender: pings are
|
||||
/// unsigned, so `packet.senderID` is attacker-controlled, and keying the
|
||||
/// response budget on it would let one connected peer rotate forged
|
||||
/// sender IDs to emit unbounded pongs. The budget is per physical link;
|
||||
/// the pong still goes to the claimed sender (that's the protocol).
|
||||
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
|
||||
guard packet.recipientID == myPeerIDData else { return }
|
||||
guard let ping = MeshPingPayload.decode(packet.payload) else {
|
||||
SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
let allowed = collectionsQueue.sync(flags: .barrier) {
|
||||
meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date())
|
||||
}
|
||||
guard allowed else {
|
||||
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
|
||||
SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .security)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let pong = MeshPingPayload(nonce: ping.nonce, originTTL: messageTTL) else { return }
|
||||
let reply = BitchatPacket(
|
||||
type: MessageType.pong.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: packet.senderID,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: pong.encode(),
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
broadcastPacket(reply)
|
||||
}
|
||||
|
||||
/// Resolves a pong against its outstanding probe. The unguessable echoed
|
||||
/// nonce plus the sender check bind the reply to the probed peer; hops
|
||||
/// come from the pong's TTL decrements on the return path.
|
||||
private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
guard packet.recipientID == myPeerIDData else { return }
|
||||
guard let pong = MeshPingPayload.decode(packet.payload) else { return }
|
||||
let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in
|
||||
guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil }
|
||||
return pendingMeshPings.removeValue(forKey: pong.nonce)
|
||||
}
|
||||
guard let pending else { return }
|
||||
pending.timeout.cancel()
|
||||
let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded())
|
||||
let result = MeshPingResult(
|
||||
rttMs: max(0, rttMs),
|
||||
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
|
||||
)
|
||||
Task { @MainActor in pending.completion(result) }
|
||||
}
|
||||
|
||||
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
|
||||
/// bidirectionally-confirmed neighbor claims ([] = direct, nil = none).
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
||||
refreshLocalTopology()
|
||||
if let route = computeRoute(to: peerID) {
|
||||
return route.compactMap { PeerID(routingData: $0) }
|
||||
}
|
||||
// Confirmed claims can lag a brand-new link (the peer's next announce
|
||||
// hasn't arrived yet); a live direct connection is still a known path.
|
||||
return isPeerConnected(peerID) ? [] : nil
|
||||
}
|
||||
|
||||
/// Mesh graph for the topology map. Edges are advisory: announces cap
|
||||
/// neighbor lists at 10, so an edge claimed by either endpoint counts.
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? {
|
||||
refreshLocalTopology()
|
||||
let claims = meshTopology.adjacencySnapshot()
|
||||
var nodes = Set<PeerID>()
|
||||
var edges = Set<MeshTopologyEdge>()
|
||||
for (source, neighbors) in claims {
|
||||
guard let sourcePeer = PeerID(routingData: source) else { continue }
|
||||
nodes.insert(sourcePeer)
|
||||
for neighborData in neighbors {
|
||||
guard let neighborPeer = PeerID(routingData: neighborData),
|
||||
neighborPeer != sourcePeer else { continue }
|
||||
nodes.insert(neighborPeer)
|
||||
edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer))
|
||||
}
|
||||
}
|
||||
nodes.insert(myPeerID)
|
||||
return MeshTopologySnapshot(
|
||||
localPeerID: myPeerID,
|
||||
nodes: nodes.sorted(),
|
||||
edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) }
|
||||
)
|
||||
}
|
||||
|
||||
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
|
||||
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
|
||||
let plan = BLERouteForwardingPolicy.plan(
|
||||
@@ -3381,6 +3543,15 @@ extension BLEService {
|
||||
case .nostrCarrier:
|
||||
handleNostrCarrier(packet, from: peerID)
|
||||
|
||||
case .ping:
|
||||
// Rate limiting must key on the ingress link (`peerID`), not the
|
||||
// packet-claimed sender: pings are unsigned, so `senderID` is
|
||||
// attacker-controlled and rotating it would reset the budget.
|
||||
handleMeshPing(packet, fromLink: peerID)
|
||||
|
||||
case .pong:
|
||||
handleMeshPong(packet, from: senderID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user