Observability: DEBUG debug-level default, greppable decision tags, log export + UDP LAN sink

Debugging the integration build on real iOS 26.5 devices over the air.

- BitLogger DEBUG default minimumLevel now .debug (was .info) so a
  tap-launched sideload surfaces .debug decision logs without the
  BITCHAT_LOG_LEVEL env var; env still wins; release unchanged (.info,
  emits nothing). Extracted defaultMinimumLevel + a test asserting it.
- Greppable bracket decision tags at .info (visible even at .info) via
  SecureLogger, ID-prefixes only, sanitized:
  [ROUTE] source-route vs flood origination (+reason) + fragment
  targeted-resync; [GW] uplink accept/reject, downlink rebroadcast/drop,
  loop skips, drain-timer; [WIFI] offer/accept/decline, AWDL connect,
  transfer start/complete+bytes, fallback+reason; [PREKEY] seal choice,
  consume, unknown-prekey open fail, bundle ingest, re-gossip;
  [SYNC] one concise summary per cycle + per-request served counts (no
  per-packet spam); [PING]/[TRACE] RTT + computed path.
- New OSLog categories: mesh, gateway, transport.
- Refactored BLESourceRouteOriginationPolicy.route -> decide returning a
  Decision enum (flood(reason)/route(hops)) so the flood reason is
  greppable and unit-tested.
- DEBUG-only in-memory ring buffer (LogExportBuffer, ~2000 lines/512KB,
  thread-safe, off main thread) + App Info "Export Logs" share sheet
  (iOS UIActivityViewController / macOS NSSavePanel), VoiceOver labels.
- DEBUG-only UDP LAN log sink (LogNetworkSink): opt-in, off by default,
  fire-and-forget, never blocks/throws; each line prefixed with the
  device nickname for demux; configured via App Info host:port fields.
  Same sanitized formatted line feeds buffer, sink, and export.

All new code is #if DEBUG; release emits nothing and ships no export/sink UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 23:04:33 +02:00
co-authored by Claude Fable 5
parent e0e90af9fd
commit 02aa1d67db
16 changed files with 530 additions and 63 deletions
+8
View File
@@ -8,6 +8,9 @@
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
#if DEBUG
import BitLogger
#endif
@main @main
struct BitchatApp: App { struct BitchatApp: App {
@@ -43,6 +46,11 @@ struct BitchatApp: App {
.onAppear { .onAppear {
appDelegate.runtime = runtime appDelegate.runtime = runtime
runtime.start() runtime.start()
#if DEBUG
// Arm the opt-in UDP log sink from persisted config (no-op
// until a collector host is set in the App Info sheet).
LogNetworkSink.shared.reloadConfiguration()
#endif
} }
.onOpenURL { url in .onOpenURL { url in
runtime.handleOpenURL(url) runtime.handleOpenURL(url)
+31 -9
View File
@@ -2703,7 +2703,7 @@ extension BLEService {
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket { private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
let now = Date() let now = Date()
let route = BLESourceRouteOriginationPolicy.route( let decision = BLESourceRouteOriginationPolicy.decide(
for: packet, for: packet,
to: recipient, to: recipient,
localPeerIDData: myPeerIDData, localPeerIDData: myPeerIDData,
@@ -2715,7 +2715,19 @@ extension BLEService {
}, },
computeRoute: { self.computeRoute(to: $0) } computeRoute: { self.computeRoute(to: $0) }
) )
guard let route else { return packet } let route: [Data]
switch decision {
case .flood(let reason):
// Only log the interesting directed cases; a plain broadcast keeps
// flood by design and would spam the origination path.
if reason != .broadcast {
SecureLogger.info("[ROUTE] flood to \(recipient.id.prefix(8))… (\(reason.rawValue))", category: .mesh)
}
return packet
case .route(let hops):
SecureLogger.info("[ROUTE] source-route to \(recipient.id.prefix(8))… via \(hops.count) hop(s)", category: .mesh)
route = hops
}
// Create new packet with route applied and version upgraded to 2 // Create new packet with route applied and version upgraded to 2
let routedPacket = BitchatPacket( let routedPacket = BitchatPacket(
type: packet.type, type: packet.type,
@@ -2805,7 +2817,7 @@ extension BLEService {
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) { private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
guard packet.recipientID == myPeerIDData else { return } guard packet.recipientID == myPeerIDData else { return }
guard let ping = MeshPingPayload.decode(packet.payload) else { guard let ping = MeshPingPayload.decode(packet.payload) else {
SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))", category: .session) SecureLogger.debug("[PING] malformed ping via \(linkPeerID.id.prefix(8))", category: .mesh)
return return
} }
let allowed = collectionsQueue.sync(flags: .barrier) { let allowed = collectionsQueue.sync(flags: .barrier) {
@@ -2813,7 +2825,7 @@ extension BLEService {
} }
guard allowed else { guard allowed else {
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") { if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))", category: .security) SecureLogger.warning("[PING] rate-limiting pings via link \(linkPeerID.id.prefix(8))", category: .mesh)
} }
return return
} }
@@ -2847,6 +2859,7 @@ extension BLEService {
rttMs: max(0, rttMs), rttMs: max(0, rttMs),
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl) hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
) )
SecureLogger.info("[PING] pong from \(peerID.id.prefix(8))… rtt=\(result.rttMs)ms hops=\(result.hops)", category: .mesh)
Task { @MainActor in pending.completion(result) } Task { @MainActor in pending.completion(result) }
} }
@@ -2855,11 +2868,15 @@ extension BLEService {
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
refreshLocalTopology() refreshLocalTopology()
if let route = computeRoute(to: peerID) { if let route = computeRoute(to: peerID) {
return route.compactMap { PeerID(routingData: $0) } let path = route.compactMap { PeerID(routingData: $0) }
SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… via \(path.count) hop(s)", category: .mesh)
return path
} }
// Confirmed claims can lag a brand-new link (the peer's next announce // 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. // hasn't arrived yet); a live direct connection is still a known path.
return isPeerConnected(peerID) ? [] : nil let direct = isPeerConnected(peerID)
SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))\(direct ? "direct (0 hops)" : "no known path")", category: .mesh)
return direct ? [] : nil
} }
/// Mesh graph for the topology map. Edges are advisory: announces cap /// Mesh graph for the topology map. Edges are advisory: announces cap
@@ -3005,9 +3022,11 @@ extension BLEService {
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) { if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey) sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
prekeyID = prekey.id prekeyID = prekey.id
SecureLogger.info("[PREKEY] seal prekey (id=\(prekey.id)) id=\(messageID.prefix(8))", category: .session)
} else { } else {
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
prekeyID = nil prekeyID = nil
SecureLogger.info("[PREKEY] seal static (no bundle) id=\(messageID.prefix(8))", category: .session)
} }
let envelope = CourierEnvelope( let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag( recipientTag: CourierEnvelope.recipientTag(
@@ -3101,6 +3120,7 @@ extension BLEService {
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey) (typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
if opened.consumedPrekey { if opened.consumedPrekey {
let replenished = noiseService.replenishPrekeysIfNeeded() let replenished = noiseService.replenishPrekeysIfNeeded()
SecureLogger.info("[PREKEY] consume id=\(prekeyID), re-gossip bundle (replenished=\(replenished))", category: .session)
sendPrekeyBundle(force: replenished) sendPrekeyBundle(force: replenished)
} }
} else { } else {
@@ -3332,7 +3352,7 @@ extension BLEService {
return nil return nil
} }
guard let signingKey else { guard let signingKey else {
SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8)))", category: .security) SecureLogger.debug("[PREKEY] bundle defer (no bound signing key) owner \(owner.id.prefix(8))", category: .session)
return return
} }
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
@@ -3343,11 +3363,13 @@ extension BLEService {
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) { private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey), guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8)))", category: .security) SecureLogger.info("[PREKEY] bundle reject (bad signature) owner \(owner.id.prefix(8))", category: .session)
return return
} }
if prekeyBundleStore.ingest(bundle) { if prekeyBundleStore.ingest(bundle) {
SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security) SecureLogger.info("[PREKEY] bundle accept owner \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .session)
} else {
SecureLogger.debug("[PREKEY] bundle reject (not newer/invalid) owner \(owner.id.prefix(8))", category: .session)
} }
gossipSyncManager?.onPublicPacketSeen(packet) gossipSyncManager?.onPublicPacketSeen(packet)
} }
@@ -4,8 +4,27 @@ import Foundation
/// Decides whether an outbound directed packet should carry a v2 source /// Decides whether an outbound directed packet should carry a v2 source
/// route. Pure gating logic so BLEService's hot send path stays a thin wire. /// route. Pure gating logic so BLEService's hot send path stays a thin wire.
enum BLESourceRouteOriginationPolicy { enum BLESourceRouteOriginationPolicy {
/// Returns the intermediate-hop route to attach, or nil to keep the /// Why a packet kept flood/direct-write behavior instead of routing.
/// current flood/direct-write behavior unchanged. /// The `rawValue` doubles as the greppable `[ROUTE]` log reason.
enum FloodReason: String {
case relayedNotOriginator = "not originator"
case broadcast = "broadcast recipient"
case noTTLHeadroom = "link-local ttl"
case recipientDirect = "recipient direct"
case routeSuppressed = "route suppressed→flood"
case noPath = "no v2 path"
}
/// The routing decision for an originated packet.
enum Decision: Equatable {
/// Keep flood/direct-write behavior unchanged; carries the reason.
case flood(FloodReason)
/// Originate a v2 source route over these intermediate hops.
case route([Data])
}
/// Returns whether to originate a v2 source route (with its hops) or keep
/// flood/direct-write, with the reason for the latter.
/// ///
/// Routes are only originated when every gate passes: /// Routes are only originated when every gate passes:
/// - we authored the packet (relays must not rewrite and re-sign someone /// - we authored the packet (relays must not rewrite and re-sign someone
@@ -19,22 +38,22 @@ enum BLESourceRouteOriginationPolicy {
/// - routing to the recipient is not suppressed by a recent unconfirmed /// - routing to the recipient is not suppressed by a recent unconfirmed
/// routed send, and /// routed send, and
/// - the topology yields a complete path. /// - the topology yields a complete path.
static func route( static func decide(
for packet: BitchatPacket, for packet: BitchatPacket,
to recipient: PeerID, to recipient: PeerID,
localPeerIDData: Data, localPeerIDData: Data,
isRecipientConnected: (PeerID) -> Bool, isRecipientConnected: (PeerID) -> Bool,
shouldAttemptRoute: (PeerID) -> Bool, shouldAttemptRoute: (PeerID) -> Bool,
computeRoute: (PeerID) -> [Data]? computeRoute: (PeerID) -> [Data]?
) -> [Data]? { ) -> Decision {
guard packet.senderID == localPeerIDData else { return nil } guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) }
guard let recipientData = packet.recipientID, guard let recipientData = packet.recipientID,
recipientData.count == 8, recipientData.count == 8,
!recipientData.allSatisfy({ $0 == 0xFF }) else { return nil } !recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) }
guard packet.ttl > 1 else { return nil } guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) }
guard !isRecipientConnected(recipient) else { return nil } guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) }
guard shouldAttemptRoute(recipient) else { return nil } guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) }
guard let route = computeRoute(recipient), !route.isEmpty else { return nil } guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) }
return route return .route(route)
} }
} }
+22 -9
View File
@@ -152,7 +152,7 @@ final class GatewayService: ObservableObject {
pendingDownlinks.removeAll() pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll() uplinkDepositTimes.removeAll()
} }
SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session) SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway)
onEnabledChanged?(enabled) onEnabledChanged?(enabled)
} }
@@ -163,7 +163,7 @@ final class GatewayService: ObservableObject {
/// for broadcasts (downlink rebroadcasts from a gateway). /// for broadcasts (downlink rebroadcasts from a gateway).
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) { func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
guard let carrier = NostrCarrierPacket.decode(payload) else { guard let carrier = NostrCarrierPacket.decode(payload) else {
SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))", category: .session) SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))", category: .gateway)
return return
} }
switch carrier.direction { switch carrier.direction {
@@ -186,7 +186,7 @@ final class GatewayService: ObservableObject {
// age) no crypto so junk and stale replays are dropped before we // age) no crypto so junk and stale replays are dropped before we
// ever pay for a MainActor Schnorr verify. // ever pay for a MainActor Schnorr verify.
guard let event = structurallyValidEvent(from: carrier) else { guard let event = structurallyValidEvent(from: carrier) else {
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8)) (failed validation)", category: .security) SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))", category: .gateway)
return return
} }
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a // Dedup by the carried event ID BEFORE verification. Loop rule 1: a
@@ -197,18 +197,19 @@ final class GatewayService: ObservableObject {
guard !meshBroadcastEventIDs.contains(event.id), guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id), !publishedEventIDs.contains(event.id),
!queuedUplinks.contains(where: { $0.event.id == event.id }) else { !queuedUplinks.contains(where: { $0.event.id == event.id }) else {
SecureLogger.debug("[GW] uplink skip (loop/dup) \(event.id.prefix(8))", category: .gateway)
return return
} }
// Consume the per-depositor rate token BEFORE the expensive verify so // Consume the per-depositor rate token BEFORE the expensive verify so
// a flood of distinct forged/junk deposits is bounded by cheap work, // a flood of distinct forged/junk deposits is bounded by cheap work,
// not by main-actor Schnorr verifications. // not by main-actor Schnorr verifications.
guard allowUplinkDeposit(from: depositor) else { guard allowUplinkDeposit(from: depositor) else {
SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))", category: .session) SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))", category: .gateway)
return return
} }
// Only now pay for cryptographic verification; receivers verify again. // Only now pay for cryptographic verification; receivers verify again.
guard event.isValidSignature() else { guard event.isValidSignature() else {
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8)) (bad signature)", category: .security) SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))", category: .gateway)
return return
} }
@@ -218,6 +219,9 @@ final class GatewayService: ObservableObject {
accepted = true accepted = true
} else { } else {
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now())) accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
if accepted {
SecureLogger.info("[GW] uplink accept→queue \(event.id.prefix(8))… (relays down)", category: .gateway)
}
} }
// Only render on our own timeline what we actually accepted for // Only render on our own timeline what we actually accepted for
@@ -243,7 +247,7 @@ final class GatewayService: ObservableObject {
private func publish(_ event: NostrEvent, geohash: String) { private func publish(_ event: NostrEvent, geohash: String) {
publishedEventIDs.insert(event.id) publishedEventIDs.insert(event.id)
publishToRelays?(event, geohash) publishToRelays?(event, geohash)
SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session) SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway)
} }
/// Returns true when the item was actually stored for later publish. /// Returns true when the item was actually stored for later publish.
@@ -251,7 +255,7 @@ final class GatewayService: ObservableObject {
private func enqueueUplink(_ item: QueuedUplink) -> Bool { private func enqueueUplink(_ item: QueuedUplink) -> Bool {
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))", category: .session) SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))", category: .gateway)
return false return false
} }
if queuedUplinks.count >= Limits.maxQueuedUplinks { if queuedUplinks.count >= Limits.maxQueuedUplinks {
@@ -294,6 +298,7 @@ final class GatewayService: ObservableObject {
// event's own `#g` tag to match the carrier geohash. // event's own `#g` tag to match the carrier geohash.
guard isFresh(event), guard isFresh(event),
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else { event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
SecureLogger.debug("[GW] downlink drop (stale/mismatch) \(event.id.prefix(8))", category: .gateway)
return return
} }
// Loop rule 1: never rebroadcast mesh-carried events back onto the // Loop rule 1: never rebroadcast mesh-carried events back onto the
@@ -305,10 +310,14 @@ final class GatewayService: ObservableObject {
guard !meshBroadcastEventIDs.contains(event.id), guard !meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id), !rebroadcastEventIDs.contains(event.id),
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else { !pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
SecureLogger.debug("[GW] downlink skip (loop/dup) \(event.id.prefix(8))", category: .gateway)
return return
} }
// Verify before spending BLE airtime; receivers verify again. // Verify before spending BLE airtime; receivers verify again.
guard event.isValidSignature() else { return } guard event.isValidSignature() else {
SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))", category: .gateway)
return
}
pendingDownlinks.append((event, geohash)) pendingDownlinks.append((event, geohash))
if pendingDownlinks.count > Limits.maxPendingDownlinks { if pendingDownlinks.count > Limits.maxPendingDownlinks {
@@ -316,6 +325,7 @@ final class GatewayService: ObservableObject {
// dropped event is not yet in `rebroadcastEventIDs`, so a later // dropped event is not yet in `rebroadcastEventIDs`, so a later
// relay redelivery can still carry it. // relay redelivery can still carry it.
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
SecureLogger.info("[GW] downlink drop-oldest (budget), \(pendingDownlinks.count) pending", category: .gateway)
} }
drainPendingDownlinks() drainPendingDownlinks()
} }
@@ -332,6 +342,7 @@ final class GatewayService: ObservableObject {
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event), guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
let payload = carrier.encode() else { continue } let payload = carrier.encode() else { continue }
broadcastToMesh?(payload) broadcastToMesh?(payload)
SecureLogger.info("[GW] downlink rebroadcast \(event.id.prefix(8))… to mesh for #\(geohash)", category: .gateway)
// Mark-after-send: only now is the relay event definitively // Mark-after-send: only now is the relay event definitively
// rebroadcast (loop rule 2). // rebroadcast (loop rule 2).
rebroadcastEventIDs.insert(event.id) rebroadcastEventIDs.insert(event.id)
@@ -351,9 +362,11 @@ final class GatewayService: ObservableObject {
let oldest = downlinkSendTimes.min() ?? now() let oldest = downlinkSendTimes.min() ?? now()
let delay = max(0.05, 60 - now().timeIntervalSince(oldest)) let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
downlinkDrainScheduled = true downlinkDrainScheduled = true
SecureLogger.info("[GW] drain-timer armed (\(String(format: "%.1f", delay))s, \(pendingDownlinks.count) pending)", category: .gateway)
let fire: @MainActor () -> Void = { [weak self] in let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return } guard let self else { return }
self.downlinkDrainScheduled = false self.downlinkDrainScheduled = false
SecureLogger.info("[GW] drain-timer fired", category: .gateway)
self.drainPendingDownlinks() self.drainPendingDownlinks()
} }
if let scheduleDrainTimer { if let scheduleDrainTimer {
@@ -408,7 +421,7 @@ final class GatewayService: ObservableObject {
return false return false
} }
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false } guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))", category: .session) SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))", category: .gateway)
return true return true
} }
@@ -463,6 +463,7 @@ final class NoiseEncryptionService {
/// so the caller can re-gossip the shrunken bundle only when it changed. /// so the caller can re-gossip the shrunken bundle only when it changed.
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) { func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) {
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else { guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
SecureLogger.info("[PREKEY] open failed (unknown prekey id=\(prekeyID))", category: .session)
throw NoiseEncryptionError.unknownPrekey throw NoiseEncryptionError.unknownPrekey
} }
let handshake = NoiseHandshakeState( let handshake = NoiseHandshakeState(
@@ -137,7 +137,7 @@ final class WifiBulkSenderSession {
do { do {
listener = try NWListener(using: parameters) listener = try NWListener(using: parameters)
} catch { } catch {
SecureLogger.error("WifiBulk: listener creation failed: \(error)", category: .session) SecureLogger.error("[WIFI] listener creation failed: \(error)", category: .transport)
return false return false
} }
listener.service = service listener.service = service
@@ -214,7 +214,7 @@ final class WifiBulkSenderSession {
guard let self, let connection, !self.finished, self.authenticated == nil else { return false } guard let self, let connection, !self.finished, self.authenticated == nil else { return false }
guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else { guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else {
// Bonjour-level gatecrasher: no channel key, no service. // Bonjour-level gatecrasher: no channel key, no service.
SecureLogger.warning("WifiBulk: disconnecting client with invalid auth frame", category: .security) SecureLogger.warning("[WIFI] AWDL connect rejected (invalid auth frame)", category: .security)
self.dropCandidate(connection) self.dropCandidate(connection)
return false return false
} }
@@ -238,6 +238,7 @@ final class WifiBulkSenderSession {
candidate.cancel() candidate.cancel()
} }
candidates.removeAll() candidates.removeAll()
SecureLogger.info("[WIFI] AWDL connected (sender), streaming \(totalChunks) chunk(s)", category: .transport)
streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer) streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer)
} }
@@ -372,8 +373,10 @@ final class WifiBulkReceiverSession {
guard let self else { return } guard let self else { return }
switch state { switch state {
case .ready: case .ready:
SecureLogger.info("[WIFI] AWDL connect established (receiver)", category: .transport)
self.sendAuthFrameAndReceive() self.sendAuthFrameAndReceive()
case .failed(let error): case .failed(let error):
SecureLogger.info("[WIFI] AWDL connect failed: \(error)", category: .transport)
self.fail("connect failed: \(error)") self.fail("connect failed: \(error)")
case .waiting(let error): case .waiting(let error):
// .waiting can resolve on its own, but a per-transfer channel // .waiting can resolve on its own, but a per-transfer channel
@@ -172,6 +172,7 @@ final class WifiBulkTransferService {
serviceName: serviceName serviceName: serviceName
) )
guard let offerData = offer.encode() else { guard let offerData = offer.encode() else {
SecureLogger.info("[WIFI] fallback→BLE to \(peerID.id.prefix(8))… (offer encode failed)", category: .transport)
fallbackToBLE() fallbackToBLE()
return return
} }
@@ -229,7 +230,7 @@ final class WifiBulkTransferService {
return return
} }
SecureLogger.debug("WifiBulk: offered \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))", category: .session) SecureLogger.info("[WIFI] offer sent \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))", category: .transport)
environment.progressStart(transferId, session.totalChunks) environment.progressStart(transferId, session.totalChunks)
let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in
@@ -271,6 +272,7 @@ final class WifiBulkTransferService {
transfer.accepted = true transfer.accepted = true
transfer.offerTimeout?.cancel() transfer.offerTimeout?.cancel()
transfer.offerTimeout = nil transfer.offerTimeout = nil
SecureLogger.info("[WIFI] offer accepted by \(peerID.id.prefix(8))…, activating channel", category: .transport)
transfer.session?.activate(key: key) transfer.session?.activate(key: key)
} }
@@ -284,13 +286,13 @@ final class WifiBulkTransferService {
switch outcome { switch outcome {
case .completed: case .completed:
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… completed (\(reason))", category: .session) SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… complete (\(reason))", category: .transport)
case .fallback: case .fallback:
SecureLogger.info("WifiBulk: transfer \(transfer.transferId.prefix(8)) falling back to BLE (\(reason))", category: .session) SecureLogger.info("[WIFI] fallback→BLE \(transfer.transferId.prefix(8))… (\(reason))", category: .transport)
environment.progressReset(transfer.transferId) environment.progressReset(transfer.transferId)
transfer.fallback() transfer.fallback()
case .cancelled: case .cancelled:
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… cancelled", category: .session) SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… cancelled", category: .transport)
environment.progressCancel(transfer.transferId) environment.progressCancel(transfer.transferId)
} }
} }
@@ -338,13 +340,13 @@ final class WifiBulkTransferService {
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key) let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
incoming[offer.transferID] = transfer incoming[offer.transferID] = transfer
SecureLogger.debug("WifiBulk: accepted offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .session) SecureLogger.info("[WIFI] offer accept \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .transport)
startBrowsing(for: transfer) startBrowsing(for: transfer)
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
guard let self, let transfer else { return } guard let self, let transfer else { return }
SecureLogger.info("WifiBulk: incoming transfer window expired", category: .session) SecureLogger.info("[WIFI] incoming window expired", category: .transport)
self.tearDownIncoming(transfer) self.tearDownIncoming(transfer)
} }
transfer.windowTimeout = windowTimeout transfer.windowTimeout = windowTimeout
@@ -352,7 +354,7 @@ final class WifiBulkTransferService {
} }
private func decline(offer: WifiBulkOffer, peerID: PeerID) { private func decline(offer: WifiBulkOffer, peerID: PeerID) {
SecureLogger.debug("WifiBulk: declining offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .session) SecureLogger.info("[WIFI] offer decline \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .transport)
guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return } guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return }
_ = environment.sendNoisePayload( _ = environment.sendNoisePayload(
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData), BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
@@ -380,7 +382,7 @@ final class WifiBulkTransferService {
browser.stateUpdateHandler = { [weak self, weak transfer] state in browser.stateUpdateHandler = { [weak self, weak transfer] state in
guard let self, let transfer else { return } guard let self, let transfer else { return }
if case .failed(let error) = state { if case .failed(let error) = state {
SecureLogger.warning("WifiBulk: browser failed: \(error)", category: .session) SecureLogger.warning("[WIFI] browser failed: \(error)", category: .transport)
self.tearDownIncoming(transfer) self.tearDownIncoming(transfer)
} }
} }
@@ -416,13 +418,13 @@ final class WifiBulkTransferService {
} }
session.onCompleted = { [weak self, weak transfer] payload in session.onCompleted = { [weak self, weak transfer] payload in
guard let self, let transfer else { return } guard let self, let transfer else { return }
SecureLogger.debug("WifiBulk: received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))", category: .session) SecureLogger.info("[WIFI] transfer complete, received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))", category: .transport)
self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes) self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes)
self.tearDownIncoming(transfer) self.tearDownIncoming(transfer)
} }
session.onFailed = { [weak self, weak transfer] reason in session.onFailed = { [weak self, weak transfer] reason in
guard let self, let transfer else { return } guard let self, let transfer else { return }
SecureLogger.info("WifiBulk: incoming transfer failed (\(reason)); sender falls back to BLE", category: .session) SecureLogger.info("[WIFI] incoming failed (\(reason)); sender falls back to BLE", category: .transport)
self.tearDownIncoming(transfer) self.tearDownIncoming(transfer)
} }
transfer.session = session transfer.session = session
+23 -3
View File
@@ -313,7 +313,6 @@ final class GossipSyncManager {
private func sendPeriodicSync(for types: SyncTypeFlags) { private func sendPeriodicSync(for types: SyncTypeFlags) {
// Unicast sync to connected peers to allow RSR attribution // Unicast sync to connected peers to allow RSR attribution
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty { if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
for peerID in connectedPeers { for peerID in connectedPeers {
sendRequestSync(to: peerID, types: types) sendRequestSync(to: peerID, types: types)
} }
@@ -350,7 +349,7 @@ final class GossipSyncManager {
private func _requestMissingFragments(_ fragmentIDs: [Data]) { private func _requestMissingFragments(_ fragmentIDs: [Data]) {
guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return } guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return }
guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return } guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return }
SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync) SecureLogger.info("[ROUTE] targeted-resync \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .mesh)
for peerID in connectedPeers { for peerID in connectedPeers {
sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter) sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
} }
@@ -391,7 +390,7 @@ final class GossipSyncManager {
// A response can replay the whole store, so bound how often one peer // A response can replay the whole store, so bound how often one peer
// can trigger a diff pass regardless of how fast it asks. // can trigger a diff pass regardless of how fast it asks.
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else { guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))", category: .sync) SecureLogger.warning("[SYNC] rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))", category: .sync)
return return
} }
let requestedTypes = (request.types ?? .publicMessages) let requestedTypes = (request.types ?? .publicMessages)
@@ -399,6 +398,9 @@ final class GossipSyncManager {
// older packets are outside the filter but not missing, and without // older packets are outside the filter but not missing, and without
// the cursor they would be re-sent every round. // the cursor they would be re-sent every round.
let since = request.sinceTimestamp let since = request.sinceTimestamp
// One concise per-request summary (RSR is already rate-limited per
// peer), never per-packet spam.
var servedCount = 0
// Decode GCS into sorted set and prepare membership checker // Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool { func mightContain(_ id: Data) -> Bool {
@@ -418,6 +420,7 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
@@ -432,6 +435,7 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
@@ -456,6 +460,7 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
@@ -470,6 +475,7 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
@@ -488,6 +494,7 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
@@ -502,6 +509,7 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
@@ -518,9 +526,14 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
if servedCount > 0 {
SecureLogger.info("[SYNC] served \(servedCount) packet(s) [\(requestedTypes.debugShortLabel)] to \(peerID.id.prefix(8))", category: .sync)
}
} }
// Build REQUEST_SYNC payload using current candidates and GCS params // Build REQUEST_SYNC payload using current candidates and GCS params
@@ -664,6 +677,7 @@ final class GossipSyncManager {
// One request per due schedule rather than a union filter: each type // One request per due schedule rather than a union filter: each type
// group gets the full GCS capacity and its own since-cursor, so heavy // group gets the full GCS capacity and its own since-cursor, so heavy
// fragment traffic can't crowd messages out of the filter. // fragment traffic can't crowd messages out of the filter.
var dueLabels: [String] = []
for index in syncSchedules.indices { for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue } guard syncSchedules[index].interval > 0 else { continue }
// No board source wired up means nothing to offer or store; // No board source wired up means nothing to offer or store;
@@ -672,8 +686,14 @@ final class GossipSyncManager {
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now syncSchedules[index].lastSent = now
sendPeriodicSync(for: syncSchedules[index].types) sendPeriodicSync(for: syncSchedules[index].types)
dueLabels.append(syncSchedules[index].types.debugShortLabel)
} }
} }
// One concise summary per maintenance cycle, not per packet.
if !dueLabels.isEmpty {
let peerCount = delegate?.getConnectedPeers().count ?? 0
SecureLogger.info("[SYNC] cycle: request [\(dueLabels.joined(separator: " "))] to \(peerCount) peer(s)", category: .sync)
}
} }
private func cleanupStaleAnnouncementsIfNeeded(now: Date) { private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
+15
View File
@@ -150,4 +150,19 @@ struct SyncTypeFlags: OptionSet {
} }
return SyncTypeFlags(rawValue: raw) return SyncTypeFlags(rawValue: raw)
} }
/// Compact human label for the `[SYNC]` observability logs, e.g. "msg,frag,pre".
/// (Referenced only from DEBUG log lines, but kept unconditional so the
/// log-call arguments compile in release too, where the log itself no-ops.)
var debugShortLabel: String {
var parts: [String] = []
if contains(.announce) { parts.append("ann") }
if contains(.message) { parts.append("msg") }
if contains(.fragment) { parts.append("frag") }
if contains(.fileTransfer) { parts.append("file") }
if contains(.prekeyBundle) { parts.append("pre") }
if contains(.groupMessage) { parts.append("grp") }
if contains(.boardPost) { parts.append("board") }
return parts.isEmpty ? "none" : parts.joined(separator: ",")
}
} }
+127
View File
@@ -1,4 +1,12 @@
import SwiftUI import SwiftUI
#if DEBUG
import BitLogger
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
#endif
struct AppInfoView: View { struct AppInfoView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@@ -10,6 +18,14 @@ struct AppInfoView: View {
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
@State private var showTopology = false @State private var showTopology = false
#if DEBUG
// Opt-in LAN log streaming + in-app export. DEBUG-only; empty host = off.
@AppStorage(LogNetworkSink.hostDefaultsKey) private var logSinkHost = ""
@AppStorage(LogNetworkSink.portDefaultsKey) private var logSinkPort = LogNetworkSink.defaultPort
@State private var exportedLogURL: URL?
@State private var isPresentingLogShare = false
#endif
private var selectedTheme: AppTheme { private var selectedTheme: AppTheme {
AppTheme(rawValue: appThemeRawValue) ?? .matrix AppTheme(rawValue: appThemeRawValue) ?? .matrix
} }
@@ -293,11 +309,122 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Privacy.panic) FeatureRow(info: Strings.Privacy.panic)
} }
#if DEBUG
debugSection
#endif
} }
.padding() .padding()
} }
#if DEBUG
// Tester-only observability panel: live LAN log streaming + a logs export.
// Never compiled into release (privacy-first: release ships no such UI).
@ViewBuilder
private var debugSection: some View {
VStack(alignment: .leading, spacing: 12) {
SectionHeader("debug & logs")
Text("Stream sanitized logs over the LAN to a collector (nc -lu \(logSinkPort)). Empty host = off.")
.bitchatFont(size: 12)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
TextField("collector host (e.g. 192.168.1.5)", text: $logSinkHost)
.textFieldStyle(.roundedBorder)
.bitchatFont(size: 13)
#if os(iOS)
.keyboardType(.numbersAndPunctuation)
.autocapitalization(.none)
.disableAutocorrection(true)
#endif
.accessibilityLabel(Text("log collector host"))
TextField("port", value: $logSinkPort, format: .number.grouping(.never))
.textFieldStyle(.roundedBorder)
.bitchatFont(size: 13)
.frame(width: 70)
#if os(iOS)
.keyboardType(.numberPad)
#endif
.accessibilityLabel(Text("log collector port"))
}
.onChange(of: logSinkHost) { _ in LogNetworkSink.shared.reloadConfiguration() }
.onChange(of: logSinkPort) { _ in LogNetworkSink.shared.reloadConfiguration() }
Button(action: exportLogs) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "square.and.arrow.up")
.font(.bitchatSystem(size: 20))
.foregroundColor(textColor)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text("Export Logs")
.bitchatFont(size: 14, weight: .semibold)
.foregroundColor(textColor)
Text("Share the recent in-memory log buffer as a .txt file.")
.bitchatFont(size: 12)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Export logs"))
.accessibilityHint(Text("Shares the recent log buffer as a text file"))
}
#if os(iOS)
.sheet(isPresented: $isPresentingLogShare) {
if let exportedLogURL {
LogShareSheet(activityItems: [exportedLogURL])
}
}
#endif
}
/// Write the buffered log to a temp `.txt` and present the platform share.
private func exportLogs() {
let text = LogExportBuffer.shared.snapshot()
let stamp = ISO8601DateFormatter().string(from: Date())
.replacingOccurrences(of: ":", with: "-")
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("bitchat-logs-\(stamp).txt")
do {
try text.data(using: .utf8)?.write(to: url)
} catch {
return
}
#if os(iOS)
exportedLogURL = url
isPresentingLogShare = true
#elseif os(macOS)
let panel = NSSavePanel()
panel.nameFieldStringValue = url.lastPathComponent
panel.allowedContentTypes = [.plainText]
if panel.runModal() == .OK, let dest = panel.url {
try? text.data(using: .utf8)?.write(to: dest)
}
#endif
}
#endif
} }
#if DEBUG && os(iOS)
/// Minimal UIActivityViewController bridge for the Export Logs share sheet.
private struct LogShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}
#endif
struct AppInfoFeatureInfo { struct AppInfoFeatureInfo {
let icon: String let icon: String
let title: LocalizedStringKey let title: LocalizedStringKey
@@ -32,13 +32,13 @@ struct BLESourceRouteOriginationPolicyTests {
) )
} }
private func route( private func decide(
packet: BitchatPacket, packet: BitchatPacket,
isRecipientConnected: Bool = false, isRecipientConnected: Bool = false,
shouldAttemptRoute: Bool = true, shouldAttemptRoute: Bool = true,
computedRoute: [Data]? = nil computedRoute: [Data]? = nil
) -> [Data]? { ) -> BLESourceRouteOriginationPolicy.Decision {
BLESourceRouteOriginationPolicy.route( BLESourceRouteOriginationPolicy.decide(
for: packet, for: packet,
to: recipient, to: recipient,
localPeerIDData: localPeerIDData, localPeerIDData: localPeerIDData,
@@ -49,38 +49,38 @@ struct BLESourceRouteOriginationPolicyTests {
} }
@Test func routesWhenAllGatesPass() { @Test func routesWhenAllGatesPass() {
#expect(route(packet: makePacket()) == [hop]) #expect(decide(packet: makePacket()) == .route([hop]))
} }
@Test func relayedPacketNeverGetsRoute() { @Test func relayedPacketNeverGetsRoute() {
let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011")) let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011"))
#expect(route(packet: relayed) == nil) #expect(decide(packet: relayed) == .flood(.relayedNotOriginator))
} }
@Test func broadcastRecipientNeverGetsRoute() { @Test func broadcastRecipientNeverGetsRoute() {
let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8)) let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8))
#expect(route(packet: broadcast) == nil) #expect(decide(packet: broadcast) == .flood(.broadcast))
let noRecipient = makePacket(recipientID: nil) let noRecipient = makePacket(recipientID: nil)
#expect(route(packet: noRecipient) == nil) #expect(decide(packet: noRecipient) == .flood(.broadcast))
} }
@Test func linkLocalTTLNeverGetsRoute() { @Test func linkLocalTTLNeverGetsRoute() {
// TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops. // TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops.
#expect(route(packet: makePacket(ttl: 0)) == nil) #expect(decide(packet: makePacket(ttl: 0)) == .flood(.noTTLHeadroom))
#expect(route(packet: makePacket(ttl: 1)) == nil) #expect(decide(packet: makePacket(ttl: 1)) == .flood(.noTTLHeadroom))
} }
@Test func directlyConnectedRecipientNeverGetsRoute() { @Test func directlyConnectedRecipientNeverGetsRoute() {
#expect(route(packet: makePacket(), isRecipientConnected: true) == nil) #expect(decide(packet: makePacket(), isRecipientConnected: true) == .flood(.recipientDirect))
} }
@Test func suppressedRecipientFallsBackToFlood() { @Test func suppressedRecipientFallsBackToFlood() {
#expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil) #expect(decide(packet: makePacket(), shouldAttemptRoute: false) == .flood(.routeSuppressed))
} }
@Test func missingOrEmptyRouteFallsBackToFlood() { @Test func missingOrEmptyRouteFallsBackToFlood() {
var sawComputeRoute = false var sawComputeRoute = false
let result = BLESourceRouteOriginationPolicy.route( let result = BLESourceRouteOriginationPolicy.decide(
for: makePacket(), for: makePacket(),
to: recipient, to: recipient,
localPeerIDData: localPeerIDData, localPeerIDData: localPeerIDData,
@@ -91,8 +91,8 @@ struct BLESourceRouteOriginationPolicyTests {
return nil return nil
} }
) )
#expect(result == nil) #expect(result == .flood(.noPath))
#expect(sawComputeRoute) #expect(sawComputeRoute)
#expect(route(packet: makePacket(), computedRoute: []) == nil) #expect(decide(packet: makePacket(), computedRoute: []) == .flood(.noPath))
} }
} }
@@ -0,0 +1,64 @@
//
// LogExportBuffer.swift
// BitLogger
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if DEBUG
import Foundation
/// In-memory ring buffer of the most recent sanitized log lines, so a tester
/// can export logs untethered when live streaming isn't available.
///
/// DEBUG-only: never compiled into release builds, matching the rest of the
/// logging stack. Bounded by both a line count and a byte budget (oldest
/// evicted first). Appends run on a private serial queue so a hot logging path
/// never blocks on buffer maintenance and the main thread is never touched.
///
/// It stores exactly the SecureLogger-sanitized text (fingerprints truncated,
/// base64 redacted, peer IDs shortened), so the export carries no secrets.
public final class LogExportBuffer {
public static let shared = LogExportBuffer()
private let queue = DispatchQueue(label: "chat.bitchat.securelogger.export", qos: .utility)
private var lines: [String] = []
private var byteCount = 0
private let maxLines: Int
private let maxBytes: Int
init(maxLines: Int = 2000, maxBytes: Int = 512 * 1024) {
self.maxLines = maxLines
self.maxBytes = maxBytes
}
/// Append one already-formatted, already-sanitized log line. Non-blocking
/// (async on the private queue).
func append(_ line: String) {
queue.async {
self.lines.append(line)
self.byteCount += line.utf8.count + 1 // + newline
while self.lines.count > self.maxLines || self.byteCount > self.maxBytes {
guard !self.lines.isEmpty else { break }
let removed = self.lines.removeFirst()
self.byteCount -= (removed.utf8.count + 1)
}
}
}
/// A newline-joined snapshot of the buffered lines, oldest first. Safe to
/// call from the main thread (brief synchronous read).
public func snapshot() -> String {
queue.sync { lines.joined(separator: "\n") }
}
public func clear() {
queue.async {
self.lines.removeAll(keepingCapacity: true)
self.byteCount = 0
}
}
}
#endif
@@ -0,0 +1,104 @@
//
// LogNetworkSink.swift
// BitLogger
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if DEBUG
import Foundation
#if canImport(Network)
import Network
#endif
/// Best-effort, fire-and-forget UDP forwarder for sanitized log lines, so a
/// sideloaded device on iOS 17+ (where `idevicesyslog` Wi-Fi streaming no
/// longer works) can stream its logs to a LAN collector with no cable.
///
/// Design constraints:
/// - DEBUG-only: never compiled into release. A privacy-first release build
/// carries zero network-log code.
/// - Opt-in and off by default: nothing is ever sent until a collector host
/// is configured. An empty host means no egress.
/// - Never blocks the caller and never throws into the app: every send runs
/// on a private queue and failures are silently dropped. A dead or absent
/// collector cannot affect app behavior (UDP is connectionless datagrams
/// to nowhere are simply lost).
/// - Forwards the exact same sanitized text the ring buffer captures, so no
/// secrets leave the device. Each line is prefixed with the device's label
/// (`[nickname] `) so one Mac-side `nc -lu <port>` can demux all devices.
public final class LogNetworkSink {
public static let shared = LogNetworkSink()
/// UserDefaults keys shared with the in-app config UI (App Info sheet).
public static let hostDefaultsKey = "debug.logSink.host"
public static let portDefaultsKey = "debug.logSink.port"
public static let defaultPort = 9999
/// Where the device label is read from (the app's chosen nickname).
public static let nicknameDefaultsKey = "bitchat.nickname"
private let queue = DispatchQueue(label: "chat.bitchat.securelogger.netsink", qos: .utility)
private let defaults: UserDefaults
private var label = ""
private var enabled = false
#if canImport(Network)
private var connection: NWConnection?
#endif
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
/// Re-read collector host/port and the device label from UserDefaults and
/// (re)build the UDP connection. Call on launch and whenever the config UI
/// changes. An empty host tears the sink down (no egress).
public func reloadConfiguration() {
let host = (defaults.string(forKey: Self.hostDefaultsKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let storedPort = defaults.integer(forKey: Self.portDefaultsKey)
let port = storedPort > 0 ? storedPort : Self.defaultPort
let label = (defaults.string(forKey: Self.nicknameDefaultsKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
configure(host: host, port: port, label: label)
}
/// Point the sink at `host:port` with a device `label`. Empty host or an
/// invalid port disables egress.
public func configure(host: String, port: Int, label: String) {
queue.async {
self.label = label
#if canImport(Network)
self.connection?.cancel()
self.connection = nil
guard !host.isEmpty,
(1...65535).contains(port),
let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
self.enabled = false
return
}
let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .udp)
connection.start(queue: self.queue)
self.connection = connection
self.enabled = true
#else
self.enabled = false
#endif
}
}
/// Forward one already-formatted, already-sanitized line. Non-blocking;
/// drops silently when disabled or on any send failure.
func send(_ line: String) {
queue.async {
guard self.enabled else { return }
#if canImport(Network)
guard let connection = self.connection else { return }
let prefixed = self.label.isEmpty ? line : "[\(self.label)] \(line)"
guard let data = (prefixed + "\n").data(using: .utf8) else { return }
connection.send(content: data, completion: .idempotent)
#endif
}
}
}
#endif
@@ -20,4 +20,11 @@ public extension OSLog {
static let security = OSLog(subsystem: subsystem, category: "security") static let security = OSLog(subsystem: subsystem, category: "security")
static let handshake = OSLog(subsystem: subsystem, category: "handshake") static let handshake = OSLog(subsystem: subsystem, category: "handshake")
static let sync = OSLog(subsystem: subsystem, category: "sync") static let sync = OSLog(subsystem: subsystem, category: "sync")
// Added for the observability pass: coarse filtering buckets for the mesh
// routing layer, the internet gateway bridge, and the Wi-Fi bulk transport.
// The bracket tags in the messages ([ROUTE]/[GW]/[WIFI]/) remain the
// primary filter; these just let `log stream --category` narrow further.
static let mesh = OSLog(subsystem: subsystem, category: "mesh")
static let gateway = OSLog(subsystem: subsystem, category: "gateway")
static let transport = OSLog(subsystem: subsystem, category: "transport")
} }
@@ -86,6 +86,17 @@ public final class SecureLogger {
case .fault: return 4 case .fault: return 4
} }
} }
/// Short uppercase label for the in-memory export buffer line prefix.
var label: String {
switch self {
case .debug: return "DEBUG"
case .info: return "INFO"
case .warning: return "WARN"
case .error: return "ERROR"
case .fault: return "FAULT"
}
}
var osLogType: OSLogType { var osLogType: OSLogType {
switch self { switch self {
@@ -100,18 +111,34 @@ public final class SecureLogger {
// MARK: - Global Threshold // MARK: - Global Threshold
/// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL. /// Minimum level that will be logged. Override via env BITCHAT_LOG_LEVEL
/// (env always wins). The default is `.debug` in DEBUG builds and `.info`
/// otherwise: a sideloaded, tap-launched build can't receive the env var,
/// so DEBUG must surface the `.debug` decision logs out of the box. Release
/// emits nothing regardless (all log paths are `#if DEBUG`).
/// Internal-settable so tests can verify level filtering; app code should not mutate it. /// Internal-settable so tests can verify level filtering; app code should not mutate it.
internal static var minimumLevel: LogLevel = { internal static var minimumLevel: LogLevel = defaultMinimumLevel
/// The level used when nothing mutates `minimumLevel`. Honors the
/// BITCHAT_LOG_LEVEL env override; otherwise `.debug` in DEBUG builds (so a
/// tap-launched sideload surfaces decision logs) and `.info` elsewhere.
/// Exposed for tests.
internal static var defaultMinimumLevel: LogLevel {
let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased() let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased()
switch env { switch env {
case "debug": return .debug case "debug": return .debug
case "info": return .info
case "warning": return .warning case "warning": return .warning
case "error": return .error case "error": return .error
case "fault": return .fault case "fault": return .fault
default: return .info default:
#if DEBUG
return .debug
#else
return .info
#endif
} }
}() }
private static func shouldLog(_ level: LogLevel) -> Bool { private static func shouldLog(_ level: LogLevel) -> Bool {
return level.order >= minimumLevel.order return level.order >= minimumLevel.order
@@ -168,6 +195,7 @@ public extension SecureLogger {
#if DEBUG #if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc) os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
record(level: .error, message: "\(location) Error in \(sanitized): \(errorDesc)")
#else #else
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc) os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
#endif #endif
@@ -258,9 +286,10 @@ private extension SecureLogger {
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = "\(location) \(message())".sanitized() let sanitized = "\(location) \(message())".sanitized()
os_log("%{public}@", log: category, type: level.osLogType, sanitized) os_log("%{public}@", log: category, type: level.osLogType, sanitized)
record(level: level, message: sanitized)
#endif #endif
} }
/// Log a security event /// Log a security event
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info, static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
file: String, line: Int, function: String) { file: String, line: Int, function: String) {
@@ -269,6 +298,18 @@ private extension SecureLogger {
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let message = "\(location) \(event.message)" let message = "\(location) \(event.message)"
os_log("%{public}@", log: .security, type: level.osLogType, message) os_log("%{public}@", log: .security, type: level.osLogType, message)
record(level: level, message: message)
#endif
}
/// Fan a sanitized line out to the in-memory export buffer and the
/// (opt-in, off by default) UDP network sink. DEBUG-only; both consumers
/// are non-blocking. Same formatted text everywhere.
static func record(level: LogLevel, message: String) {
#if DEBUG
let line = "[\(level.label)] \(message)"
LogExportBuffer.shared.append(line)
LogNetworkSink.shared.send(line)
#endif #endif
} }
@@ -51,4 +51,25 @@ final class LogLevelFilteringTests: XCTestCase {
XCTAssertEqual(counter.count, 1, "Enabled levels must still log") XCTAssertEqual(counter.count, 1, "Enabled levels must still log")
} }
/// A sideloaded, tap-launched build can't receive BITCHAT_LOG_LEVEL, so the
/// DEBUG default must be `.debug` (not `.info`) or the `.debug` decision
/// logs stay invisible on device. Release still defaults to `.info` and
/// emits nothing regardless (all log paths are `#if DEBUG`).
func testDebugBuildDefaultsToDebugLevelUnlessOverridden() throws {
// Only meaningful when the env override isn't set (CI sometimes sets it).
try XCTSkipUnless(
ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"] == nil,
"BITCHAT_LOG_LEVEL is set; default-level assertion doesn't apply"
)
#if DEBUG
XCTAssertEqual(
SecureLogger.defaultMinimumLevel,
.debug,
"DEBUG builds must default to .debug so tap-launched sideloads surface decision logs"
)
#else
XCTAssertEqual(SecureLogger.defaultMinimumLevel, .info)
#endif
}
} }