mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 16:45:19 +00:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: ",")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ public final class SecureLogger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
case .debug: return .debug
|
case .debug: return .debug
|
||||||
@@ -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,6 +286,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user