mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Gateway mode: opt-in mesh↔Nostr uplink for geohash channels (#1384)
* Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Gateway mode: opt-in mesh↔Nostr uplink for geohash channels An opt-in "internet gateway" toggle lets one connected phone bridge the local geohash channel for mesh-only peers: signed kind-20000 events ride a new nostrCarrier (0x28) packet — directed to the gateway for uplink, broadcast with TTL for downlink — with Schnorr verification at every hop, CourierStore-style quotas, and explicit loop-prevention rules. - BitFoundation: MessageType.nostrCarrier = 0x28 - NostrCarrierPacket: 2-byte-length TLV codec (direction, geohash, signed event JSON), 16 KiB cap, tolerant decoder - GatewayService: closure-injected policy layer — verify gates (sig, kind, #g tag, age, size), uplink quotas (10/min/depositor rate limit, offline queue of 20 total / 5 per depositor, drop-oldest, flush on reconnect), downlink budget (30/min, bounded drop-oldest backlog), bounded loop-prevention ID sets - BLEService: runtime capability bits (advertise .gateway only while the toggle is on, re-announce on change), signed directed uplink sends, carrier ingress with depositor signature verification - Mesh-only senders uplink automatically from sendGeohash when no relay is connected and a reachable peer advertises .gateway; once-per- channel "sent via mesh gateway" notice - UI: gateway toggle beside the Tor toggle, globe header indicator, VoiceOver labels, xcstrings entries Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Gateway: harden downlink freshness, uplink verify ordering, and drain Fixes the confirmed downlink/uplink defects from the PR #1384 review + Codex findings: - Downlink age + #g gate (Codex P2 / review #1): rebroadcastRelayEvent now drops events outside the same freshness window receivers enforce and whose #g tag mismatches the carrier geohash, BEFORE spending any budget — so a 1h/200-event channel-resubscribe backfill no longer burns the 30/min BLE budget on events every receiver drops. - Rate-limit + dedup before Schnorr (review #2): handleUplinkDeposit now runs cheap structural checks + carried-ID dedup + rate-token consume before isValidSignature(), so a replay flood is bounded by cheap work instead of unbounded main-actor verifies. - Quota-dropped deposits not rendered (review #3): enqueueUplink reports acceptance and injectInbound only fires for events actually published/queued, ending the local-timeline divergence. - Drain timer + mark-after-send (Codex P2 / review #4): a burst beyond budget now arms a timer to drain when the window frees; rebroadcast IDs are marked only after an event is actually sent, so overflow- dropped events stay retryable. - Symmetric publish path (review #5): the gateway publish closure now refuses when no geo relay is known, matching the local send path instead of publishing dead traffic to default relays. - Loop-rule doc (review #7): softened to reflect that rule 3 is a call-site convention with unit-tested backstops; added tests for the publishedEventIDs backstop, downlink freshness/mismatch, drain timer, and quota-drop non-injection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Gateway: stop self-echo of uplinked events onto the mesh Every event a gateway uplinks to the relays comes back through its own geohash subscription. `rebroadcastRelayEvent` deduped against `meshBroadcastEventIDs`, `rebroadcastEventIDs`, and `pendingDownlinks`, but not `publishedEventIDs` — so an event this gateway just published was downlink-rebroadcast onto the same mesh it originated from, doubling BLE airtime per uplinked message and able to starve the 30/min downlink budget on a busy channel (device-confirmed, filed on #1384). Fix: also skip the downlink rebroadcast when the event id is in `publishedEventIDs`. That set is already the bounded (drop-oldest, capacity maxTrackedEventIDs) loop-rule-2 uplink cache, populated only by `publish()`, so genuine inbound-from-internet events (never published here) still rebroadcast normally. Reconciles cleanly with the existing loop-prevention sets — no new state. Adds a GatewayServiceTests case asserting an uplinked event that echoes back via the subscription is not rebroadcast, while a genuine inbound event still is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
2360140760
commit
d4f0c49787
@@ -12,20 +12,26 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
@Published private(set) var bookmarkNames: [String: String]
|
@Published private(set) var bookmarkNames: [String: String]
|
||||||
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||||
@Published private(set) var userTorEnabled: Bool
|
@Published private(set) var userTorEnabled: Bool
|
||||||
|
@Published private(set) var gatewayEnabled: Bool
|
||||||
|
|
||||||
private let manager: LocationChannelManager
|
private let manager: LocationChannelManager
|
||||||
private let network: NetworkActivationService
|
private let network: NetworkActivationService
|
||||||
|
private let gateway: GatewayService
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
manager: LocationChannelManager? = nil,
|
manager: LocationChannelManager? = nil,
|
||||||
network: NetworkActivationService? = nil
|
network: NetworkActivationService? = nil,
|
||||||
|
gateway: GatewayService? = nil
|
||||||
) {
|
) {
|
||||||
let manager = manager ?? .shared
|
let manager = manager ?? .shared
|
||||||
let network = network ?? .shared
|
let network = network ?? .shared
|
||||||
|
let gateway = gateway ?? .shared
|
||||||
|
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.network = network
|
self.network = network
|
||||||
|
self.gateway = gateway
|
||||||
|
self.gatewayEnabled = gateway.isEnabled
|
||||||
self.permissionState = manager.permissionState
|
self.permissionState = manager.permissionState
|
||||||
self.availableChannels = manager.availableChannels
|
self.availableChannels = manager.availableChannels
|
||||||
self.selectedChannel = manager.selectedChannel
|
self.selectedChannel = manager.selectedChannel
|
||||||
@@ -96,6 +102,10 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
network.setUserTorEnabled(enabled)
|
network.setUserTorEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setGatewayEnabled(_ enabled: Bool) {
|
||||||
|
gateway.setEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
func refreshMeshChannelsIfNeeded() {
|
func refreshMeshChannelsIfNeeded() {
|
||||||
guard case .mesh = selectedChannel,
|
guard case .mesh = selectedChannel,
|
||||||
permissionState == .authorized,
|
permissionState == .authorized,
|
||||||
@@ -160,6 +170,10 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
network.$userTorEnabled
|
network.$userTorEnabled
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$userTorEnabled)
|
.assign(to: &$userTorEnabled)
|
||||||
|
|
||||||
|
gateway.$isEnabled
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$gatewayEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func level(forLength length: Int) -> GeohashChannelLevel {
|
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||||
|
|||||||
@@ -9348,6 +9348,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"content.accessibility.gateway_active" : {
|
||||||
|
"comment" : "Accessibility label for the internet gateway indicator",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Internet gateway active, sharing your connection with the mesh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"content.accessibility.jump_to_latest" : {
|
"content.accessibility.jump_to_latest" : {
|
||||||
"comment" : "Accessibility label for the jump to latest messages button",
|
"comment" : "Accessibility label for the jump to latest messages button",
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@@ -17884,6 +17896,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"content.header.gateway_active" : {
|
||||||
|
"comment" : "Tooltip for the internet gateway indicator",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Sharing your internet connection with nearby mesh peers"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"content.header.people" : {
|
"content.header.people" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -25819,6 +25843,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"location_channels.gateway.subtitle" : {
|
||||||
|
"comment" : "Explanation under the internet gateway toggle",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "share your internet with nearby mesh peers so their geohash messages reach the network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"location_channels.gateway.title" : {
|
||||||
|
"comment" : "Title for the internet gateway toggle in the location channels sheet",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "internet gateway"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"location_channels.loading_nearby" : {
|
"location_channels.loading_nearby" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -33665,6 +33713,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"system.gateway.sent_via_mesh" : {
|
||||||
|
"comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "sent via mesh gateway"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"system.geohash.blocked" : {
|
"system.geohash.blocked" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//
|
||||||
|
// NostrCarrierPacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed
|
||||||
|
/// Nostr event ferried over the mesh between a mesh-only peer and an
|
||||||
|
/// internet gateway peer.
|
||||||
|
///
|
||||||
|
/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer):
|
||||||
|
/// a mesh-only sender asks the gateway to publish its locally signed
|
||||||
|
/// geohash event to Nostr relays.
|
||||||
|
/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway
|
||||||
|
/// rebroadcasts inbound relay events so mesh-only peers see the channel.
|
||||||
|
///
|
||||||
|
/// The carried event is public geohash chat — already plaintext on Nostr —
|
||||||
|
/// so the carrier adds no encryption. It IS signed by the originator's
|
||||||
|
/// per-geohash identity, so neither the gateway nor any mesh relay can forge
|
||||||
|
/// or alter it undetected: gateways and receivers verify the Schnorr
|
||||||
|
/// signature before acting on it.
|
||||||
|
///
|
||||||
|
/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the
|
||||||
|
/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped
|
||||||
|
/// for forward compatibility.
|
||||||
|
struct NostrCarrierPacket: Equatable {
|
||||||
|
enum Direction: UInt8 {
|
||||||
|
case toGateway = 0x01
|
||||||
|
case fromGateway = 0x02
|
||||||
|
}
|
||||||
|
|
||||||
|
let direction: Direction
|
||||||
|
let geohash: String
|
||||||
|
/// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags,
|
||||||
|
/// content, sig).
|
||||||
|
let eventJSON: Data
|
||||||
|
|
||||||
|
/// BLE airtime cap for a carried event.
|
||||||
|
static let maxEventJSONBytes = 16 * 1024
|
||||||
|
static let maxGeohashLength = 12
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case direction = 0x01
|
||||||
|
case geohash = 0x02
|
||||||
|
case eventJSON = 0x03
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(direction: Direction, geohash: String, eventJSON: Data) {
|
||||||
|
let geohashBytes = Data(geohash.utf8)
|
||||||
|
guard !geohashBytes.isEmpty,
|
||||||
|
geohashBytes.count <= Self.maxGeohashLength,
|
||||||
|
!eventJSON.isEmpty,
|
||||||
|
eventJSON.count <= Self.maxEventJSONBytes else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.direction = direction
|
||||||
|
self.geohash = geohash
|
||||||
|
self.eventJSON = eventJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(direction: Direction, geohash: String, event: NostrEvent) {
|
||||||
|
guard let json = try? event.jsonString(), !json.isEmpty else { return nil }
|
||||||
|
self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes the carried event. Callers MUST still verify
|
||||||
|
/// `event.isValidSignature()` before publishing or displaying it.
|
||||||
|
func event() -> NostrEvent? {
|
||||||
|
guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try? NostrEvent(from: dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
var data = Data()
|
||||||
|
data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12)
|
||||||
|
|
||||||
|
func appendTLV(_ type: TLVType, _ value: Data) {
|
||||||
|
data.append(type.rawValue)
|
||||||
|
data.append(UInt8((value.count >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(value.count & 0xFF))
|
||||||
|
data.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendTLV(.direction, Data([direction.rawValue]))
|
||||||
|
appendTLV(.geohash, Data(geohash.utf8))
|
||||||
|
appendTLV(.eventJSON, eventJSON)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> NostrCarrierPacket? {
|
||||||
|
// Defensive slice re-base (Data slices keep parent indices).
|
||||||
|
let data = Data(data)
|
||||||
|
var offset = 0
|
||||||
|
var direction: Direction?
|
||||||
|
var geohash: String?
|
||||||
|
var eventJSON: Data?
|
||||||
|
|
||||||
|
while offset + 3 <= data.count {
|
||||||
|
let typeRaw = data[offset]
|
||||||
|
let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2])
|
||||||
|
offset += 3
|
||||||
|
guard offset + length <= data.count else { return nil }
|
||||||
|
let value = data.subdata(in: offset..<offset + length)
|
||||||
|
offset += length
|
||||||
|
|
||||||
|
switch TLVType(rawValue: typeRaw) {
|
||||||
|
case .direction:
|
||||||
|
guard value.count == 1, let parsed = Direction(rawValue: value[0]) else { return nil }
|
||||||
|
direction = parsed
|
||||||
|
case .geohash:
|
||||||
|
guard let parsed = String(data: value, encoding: .utf8) else { return nil }
|
||||||
|
geohash = parsed
|
||||||
|
case .eventJSON:
|
||||||
|
eventJSON = value
|
||||||
|
case nil:
|
||||||
|
// Unknown TLV; skip (tolerant decoder for forward compatibility).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard offset == data.count,
|
||||||
|
let direction,
|
||||||
|
let geohash,
|
||||||
|
let eventJSON else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NostrCarrierPacket(direction: direction, geohash: geohash, eventJSON: eventJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
|||||||
switch MessageType(rawValue: packetType) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
return true
|
||||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost:
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .nostrCarrier:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID.toShort()]?.capabilities ?? []
|
peers[peerID.toShort()]?.capabilities ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Peers whose last verified announce advertised the given capability.
|
||||||
|
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
||||||
|
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
||||||
|
}
|
||||||
|
|
||||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||||
let connected = peers.filter { $0.value.isConnected }
|
let connected = peers.filter { $0.value.isConnected }
|
||||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ struct BLEReceivePipeline {
|
|||||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||||
// remote handover toward a relayed announce rides this same
|
// remote handover toward a relayed announce rides this same
|
||||||
// deterministic relay treatment instead of the broadcast clamp.
|
// deterministic relay treatment instead of the broadcast clamp.
|
||||||
|
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||||
|
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||||
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
|
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||||
|
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
||||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ final class BLEService: NSObject {
|
|||||||
// Local-only store-and-forward counters; nil in unit tests.
|
// Local-only store-and-forward counters; nil in unit tests.
|
||||||
var sfMetrics: StoreAndForwardMetrics?
|
var sfMetrics: StoreAndForwardMetrics?
|
||||||
|
|
||||||
|
// Gateway mode: sink for received nostrCarrier packets (set by app
|
||||||
|
// wiring, called on the main actor after transport-level checks) and the
|
||||||
|
// runtime-toggled capability bits ORed into `PeerCapabilities.localSupported`
|
||||||
|
// for every announce. `directedToUs` distinguishes an uplink deposit
|
||||||
|
// addressed to this device from a downlink broadcast.
|
||||||
|
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)?
|
||||||
|
private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||||||
// packets between in-process service instances.
|
// packets between in-process service instances.
|
||||||
@@ -638,6 +646,32 @@ final class BLEService: NSObject {
|
|||||||
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enables or disables a runtime-advertised capability bit (e.g. the
|
||||||
|
/// internet-gateway toggle) and re-announces so peers learn promptly.
|
||||||
|
/// Build-time bits stay in `PeerCapabilities.localSupported`.
|
||||||
|
func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) {
|
||||||
|
let changed: Bool = collectionsQueue.sync(flags: .barrier) {
|
||||||
|
let before = runtimeCapabilities
|
||||||
|
if enabled {
|
||||||
|
runtimeCapabilities.insert(capability)
|
||||||
|
} else {
|
||||||
|
runtimeCapabilities.remove(capability)
|
||||||
|
}
|
||||||
|
return runtimeCapabilities != before
|
||||||
|
}
|
||||||
|
guard changed else { return }
|
||||||
|
sendAnnounce(forceSend: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reachable peers currently advertising the `.gateway` capability.
|
||||||
|
func reachableGatewayPeers() -> [PeerID] {
|
||||||
|
let now = Date()
|
||||||
|
return collectionsQueue.sync {
|
||||||
|
peerRegistry.peers(advertising: .gateway)
|
||||||
|
.filter { peerRegistry.isReachable($0, now: now) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getPeerNicknames() -> [PeerID: String] {
|
func getPeerNicknames() -> [PeerID: String] {
|
||||||
return collectionsQueue.sync {
|
return collectionsQueue.sync {
|
||||||
peerRegistry.displayNicknames(selfNickname: myNickname)
|
peerRegistry.displayNicknames(selfNickname: myNickname)
|
||||||
@@ -1272,16 +1306,16 @@ final class BLEService: NSObject {
|
|||||||
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
||||||
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
||||||
|
|
||||||
let connectedPeerIDs: [Data] = collectionsQueue.sync {
|
let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync {
|
||||||
peerRegistry.connectedRoutingData
|
(peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities))
|
||||||
}
|
}
|
||||||
|
|
||||||
let announcement = AnnouncementPacket(
|
let announcement = AnnouncementPacket(
|
||||||
nickname: myNickname,
|
nickname: myNickname,
|
||||||
noisePublicKey: noisePub,
|
noisePublicKey: noisePub,
|
||||||
signingPublicKey: signingPub,
|
signingPublicKey: signingPub,
|
||||||
directNeighbors: connectedPeerIDs,
|
directNeighbors: connectedPeerIDs,
|
||||||
capabilities: PeerCapabilities.localSupported
|
capabilities: advertisedCapabilities
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = announcement.encode() else {
|
guard let payload = announcement.encode() else {
|
||||||
@@ -2762,6 +2796,81 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Gateway carrier (nostrCarrier)
|
||||||
|
|
||||||
|
/// Sign and send an encoded `toGateway` carrier payload directed at a
|
||||||
|
/// gateway peer. The packet is signed so the gateway can key its uplink
|
||||||
|
/// quotas to an authenticated depositor; the carried Nostr event has its
|
||||||
|
/// own Schnorr signature for content authenticity. Returns false when
|
||||||
|
/// the gateway is not reachable or signing fails.
|
||||||
|
func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool {
|
||||||
|
guard isPeerReachable(gatewayPeer) else { return false }
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.nostrCarrier.rawValue,
|
||||||
|
senderID: myPeerIDData,
|
||||||
|
recipientID: Data(hexString: gatewayPeer.id),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: messageTTL
|
||||||
|
)
|
||||||
|
guard let signed = noiseService.signPacket(packet) else { return false }
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
// broadcastPacket applies a known route when one exists and
|
||||||
|
// otherwise floods the directed packet like a DM, so a gateway
|
||||||
|
// that is reachable but multi-hop still gets the deposit.
|
||||||
|
self?.broadcastPacket(signed)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast an encoded `fromGateway` carrier payload on the mesh with
|
||||||
|
/// the default TTL. Unsigned at the packet layer — receivers verify the
|
||||||
|
/// carried event's own Schnorr signature.
|
||||||
|
func broadcastNostrCarrier(_ payload: Data) {
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.nostrCarrier.rawValue,
|
||||||
|
senderID: myPeerIDData,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: messageTTL
|
||||||
|
)
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
self?.broadcastPacket(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport-level handling for a received nostrCarrier packet; policy
|
||||||
|
/// (verification of the carried event, quotas, loop prevention) lives in
|
||||||
|
/// `GatewayService` behind `onNostrCarrierPacket`.
|
||||||
|
private func handleNostrCarrier(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
let senderID = PeerID(hexData: packet.senderID)
|
||||||
|
let directedToUs: Bool
|
||||||
|
if let recipientID = packet.recipientID {
|
||||||
|
// Carriers addressed elsewhere ride the generic relay path untouched.
|
||||||
|
guard recipientID == myPeerIDData else { return }
|
||||||
|
// Uplink deposit: quotas are keyed by the depositor, so the
|
||||||
|
// packet signature must verify against the sender's announced
|
||||||
|
// signing key. Unlike courier deposits the depositor may be
|
||||||
|
// multi-hop away, so ingress-link identity is not required.
|
||||||
|
let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey }
|
||||||
|
guard let signingKey,
|
||||||
|
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||||||
|
SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
directedToUs = true
|
||||||
|
} else {
|
||||||
|
directedToUs = false
|
||||||
|
}
|
||||||
|
let payload = packet.payload
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
self?.onNostrCarrierPacket?(payload, senderID, directedToUs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Link capability snapshots (thread-safe via bleQueue)
|
// MARK: Link capability snapshots (thread-safe via bleQueue)
|
||||||
|
|
||||||
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
||||||
@@ -3269,6 +3378,8 @@ extension BLEService {
|
|||||||
case .boardPost:
|
case .boardPost:
|
||||||
// Invalid or deleted posts must not spread; skip the relay step.
|
// Invalid or deleted posts must not spread; skip the relay step.
|
||||||
guard handleBoardPost(packet, from: senderID) else { return }
|
guard handleBoardPost(packet, from: senderID) else { return }
|
||||||
|
case .nostrCarrier:
|
||||||
|
handleNostrCarrier(packet, from: peerID)
|
||||||
|
|
||||||
case .leave:
|
case .leave:
|
||||||
handleLeave(packet, from: senderID)
|
handleLeave(packet, from: senderID)
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
//
|
||||||
|
// GatewayService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Policy engine for gateway mode: an opt-in "share my internet with the
|
||||||
|
/// mesh" bridge. While the toggle is on, this device advertises the
|
||||||
|
/// `.gateway` capability bit, publishes signed geohash events deposited by
|
||||||
|
/// mesh-only peers to Nostr relays (uplink), and rebroadcasts inbound relay
|
||||||
|
/// events onto the mesh (downlink) so mesh-only peers can take part in the
|
||||||
|
/// local geohash channel. Mesh-only peers need no toggle: their uplink
|
||||||
|
/// engages automatically when relays are unreachable and a gateway peer
|
||||||
|
/// exists.
|
||||||
|
///
|
||||||
|
/// Threat model:
|
||||||
|
/// - Keys never leave the originating device. Mesh-only senders sign events
|
||||||
|
/// locally with their per-geohash ephemeral identity; the gateway carries
|
||||||
|
/// only the finished, signed event.
|
||||||
|
/// - The gateway cannot forge or alter events: every carried event is
|
||||||
|
/// Schnorr-verified here before it is published or rebroadcast, and again
|
||||||
|
/// independently by relays and receivers.
|
||||||
|
/// - Carried contents are public geohash chat, already plaintext on Nostr,
|
||||||
|
/// so the mesh carrier adds no confidentiality loss.
|
||||||
|
///
|
||||||
|
/// Loop-prevention rules:
|
||||||
|
/// 1. An event learned from a `fromGateway` mesh broadcast is never
|
||||||
|
/// re-published to relays, never re-uplinked, and never rebroadcast
|
||||||
|
/// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot
|
||||||
|
/// echo mesh-carried traffic back out. Mesh-level propagation of the
|
||||||
|
/// original broadcast packet is the TTL relay's job, not ours.
|
||||||
|
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
|
||||||
|
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
|
||||||
|
/// repeat deposits and relay echoes are absorbed. An event this gateway
|
||||||
|
/// itself uplinked (`publishedEventIDs`) is additionally never
|
||||||
|
/// downlink-rebroadcast: it originated on this mesh, so echoing it back
|
||||||
|
/// when our own relay subscription redelivers it would double BLE airtime
|
||||||
|
/// (the device-confirmed self-echo bug).
|
||||||
|
/// 3. Uplink is only attempted for locally composed events at the send site
|
||||||
|
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
|
||||||
|
/// carrier never re-enter the uplink path. This is a call-site convention;
|
||||||
|
/// the `meshBroadcastEventIDs`/`publishedEventIDs` backstops in
|
||||||
|
/// `uplinkViaMesh` enforce it defensively and are unit-tested.
|
||||||
|
/// Rules 1 and 2 are enforced here and unit-tested.
|
||||||
|
/// Rebroadcast storms at the mesh layer are additionally bounded by the BLE
|
||||||
|
/// `MessageDeduplicator` and packet TTL, and receivers dedup carried events
|
||||||
|
/// against their own relay subscriptions via the Nostr event-ID cache in
|
||||||
|
/// `NostrInboundPipeline`.
|
||||||
|
///
|
||||||
|
/// All dependencies are closure-injected (repo convention) so the policy
|
||||||
|
/// layer is unit-testable without relays or radios.
|
||||||
|
@MainActor
|
||||||
|
final class GatewayService: ObservableObject {
|
||||||
|
enum Limits {
|
||||||
|
/// Uplink deposits held while relays are unreachable (CourierStore-style
|
||||||
|
/// bounded mailbag: bounded total, bounded per depositor).
|
||||||
|
static let maxQueuedUplinks = 20
|
||||||
|
static let maxQueuedUplinksPerDepositor = 5
|
||||||
|
/// Uplink deposits accepted per depositor per minute.
|
||||||
|
static let uplinkEventsPerMinutePerDepositor = 10
|
||||||
|
/// Downlink mesh rebroadcasts per minute — BLE airtime is precious.
|
||||||
|
/// Beyond the budget events queue (bounded, drop-oldest) and drain on
|
||||||
|
/// a scheduled timer once the window frees (also re-driven by the next
|
||||||
|
/// inbound relay event); a quiet channel does not strand its backlog.
|
||||||
|
static let downlinkEventsPerMinute = 30
|
||||||
|
static let maxPendingDownlinks = 30
|
||||||
|
/// Accepted clock skew for a carried ephemeral event; anything older
|
||||||
|
/// is stale replay the relays would drop anyway.
|
||||||
|
static let maxEventAgeSeconds: TimeInterval = 15 * 60
|
||||||
|
/// Bounded loop-prevention ID caches (oldest evicted).
|
||||||
|
static let maxTrackedEventIDs = 512
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QueuedUplink {
|
||||||
|
let depositor: PeerID
|
||||||
|
let geohash: String
|
||||||
|
let event: NostrEvent
|
||||||
|
let queuedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = GatewayService()
|
||||||
|
|
||||||
|
/// The user toggle. While true this device advertises `.gateway` and
|
||||||
|
/// bridges mesh <-> Nostr for geohash channels.
|
||||||
|
@Published private(set) var isEnabled: Bool
|
||||||
|
|
||||||
|
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
|
||||||
|
|
||||||
|
/// Publishes a verified event to the geo relays for a geohash.
|
||||||
|
var publishToRelays: (@MainActor (NostrEvent, String) -> Void)?
|
||||||
|
/// Broadcasts an encoded `fromGateway` carrier payload on the mesh.
|
||||||
|
var broadcastToMesh: (@MainActor (Data) -> Void)?
|
||||||
|
/// Sends an encoded `toGateway` carrier payload directed to a gateway
|
||||||
|
/// peer. Returns false when the transport could not accept it.
|
||||||
|
var sendToGatewayPeer: (@MainActor (Data, PeerID) -> Bool)?
|
||||||
|
/// Reachable mesh peers currently advertising the `.gateway` capability.
|
||||||
|
var availableGatewayPeers: (@MainActor () -> [PeerID])?
|
||||||
|
/// Whether any Nostr relay connection is currently working.
|
||||||
|
var relaysConnected: (@MainActor () -> Bool)?
|
||||||
|
/// The geohash channel the local user is viewing, if any.
|
||||||
|
var currentGeohash: (@MainActor () -> String?)?
|
||||||
|
/// Injects a verified carried event into the same inbound pipeline as
|
||||||
|
/// relay-received events (blocking, rate limits, dedup, rendering).
|
||||||
|
var injectInbound: (@MainActor (NostrEvent) -> Void)?
|
||||||
|
/// Fired on toggle changes (advertise/withdraw the capability bit and
|
||||||
|
/// force a re-announce).
|
||||||
|
var onEnabledChanged: (@MainActor (Bool) -> Void)?
|
||||||
|
/// Schedules a downlink-drain closure to run after a delay. Injected so
|
||||||
|
/// the drain timer is deterministic in tests; nil arms a real `Task`.
|
||||||
|
var scheduleDrainTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
|
||||||
|
/// Loop rule 1: event IDs seen in `fromGateway` mesh broadcasts.
|
||||||
|
private var meshBroadcastEventIDs: BoundedIDSet
|
||||||
|
/// Loop rule 2 (uplink): event IDs this gateway already published.
|
||||||
|
private var publishedEventIDs: BoundedIDSet
|
||||||
|
/// Loop rule 2 (downlink): event IDs this gateway already rebroadcast.
|
||||||
|
private var rebroadcastEventIDs: BoundedIDSet
|
||||||
|
|
||||||
|
private(set) var queuedUplinks: [QueuedUplink] = []
|
||||||
|
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
|
||||||
|
private var downlinkSendTimes: [Date] = []
|
||||||
|
private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = []
|
||||||
|
/// True while a drain timer is armed, so a burst schedules at most one.
|
||||||
|
private var downlinkDrainScheduled = false
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let now: () -> Date
|
||||||
|
private static let enabledKey = "gateway.userEnabled"
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.now = now
|
||||||
|
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
|
||||||
|
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||||
|
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||||
|
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toggle
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
guard enabled != isEnabled else { return }
|
||||||
|
isEnabled = enabled
|
||||||
|
defaults.set(enabled, forKey: Self.enabledKey)
|
||||||
|
if !enabled {
|
||||||
|
queuedUplinks.removeAll()
|
||||||
|
pendingDownlinks.removeAll()
|
||||||
|
uplinkDepositTimes.removeAll()
|
||||||
|
}
|
||||||
|
SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session)
|
||||||
|
onEnabledChanged?(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mesh carrier ingress (both roles)
|
||||||
|
|
||||||
|
/// Entry point for received `nostrCarrier` packets. `directedToUs` is
|
||||||
|
/// true for packets addressed to this device (uplink deposits); false
|
||||||
|
/// for broadcasts (downlink rebroadcasts from a gateway).
|
||||||
|
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
|
||||||
|
guard let carrier = NostrCarrierPacket.decode(payload) else {
|
||||||
|
SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch carrier.direction {
|
||||||
|
case .toGateway:
|
||||||
|
// Uplink deposits are directed; a broadcast toGateway is malformed.
|
||||||
|
guard directedToUs else { return }
|
||||||
|
handleUplinkDeposit(carrier, from: peerID)
|
||||||
|
case .fromGateway:
|
||||||
|
// Downlink rides broadcast only; a directed fromGateway is malformed.
|
||||||
|
guard !directedToUs else { return }
|
||||||
|
handleDownlinkBroadcast(carrier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uplink (gateway role: mesh peer -> internet)
|
||||||
|
|
||||||
|
private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
// Cheap structural checks first (parse, size, geohash, kind, #g tag,
|
||||||
|
// age) — no crypto — so junk and stale replays are dropped before we
|
||||||
|
// ever pay for a MainActor Schnorr verify.
|
||||||
|
guard let event = structurallyValidEvent(from: carrier) else {
|
||||||
|
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
|
||||||
|
// fromGateway-learned event is mesh-carried and must never be
|
||||||
|
// re-published. Loop rule 2: repeat deposits of an already handled
|
||||||
|
// event are absorbed. A replay of one valid deposit is short-circuited
|
||||||
|
// here without a per-packet signature verify.
|
||||||
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
|
!publishedEventIDs.contains(event.id),
|
||||||
|
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Consume the per-depositor rate token BEFORE the expensive verify so
|
||||||
|
// a flood of distinct forged/junk deposits is bounded by cheap work,
|
||||||
|
// not by main-actor Schnorr verifications.
|
||||||
|
guard allowUplinkDeposit(from: depositor) else {
|
||||||
|
SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Only now pay for cryptographic verification; receivers verify again.
|
||||||
|
guard event.isValidSignature() else {
|
||||||
|
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let accepted: Bool
|
||||||
|
if relaysConnected?() ?? false {
|
||||||
|
publish(event, geohash: carrier.geohash)
|
||||||
|
accepted = true
|
||||||
|
} else {
|
||||||
|
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only render on our own timeline what we actually accepted for
|
||||||
|
// publish or queue: a quota-dropped deposit is never published and,
|
||||||
|
// being directed, no other peer will ever see it, so showing it would
|
||||||
|
// diverge our timeline permanently from what reached the channel.
|
||||||
|
if accepted, currentGeohash?() == carrier.geohash {
|
||||||
|
injectInbound?(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish everything queued while relays were unreachable. Called when
|
||||||
|
/// relay connectivity comes back.
|
||||||
|
func flushQueuedUplinks() {
|
||||||
|
guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return }
|
||||||
|
let queued = queuedUplinks
|
||||||
|
queuedUplinks.removeAll()
|
||||||
|
for item in queued where !publishedEventIDs.contains(item.event.id) {
|
||||||
|
publish(item.event, geohash: item.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publish(_ event: NostrEvent, geohash: String) {
|
||||||
|
publishedEventIDs.insert(event.id)
|
||||||
|
publishToRelays?(event, geohash)
|
||||||
|
SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when the item was actually stored for later publish.
|
||||||
|
@discardableResult
|
||||||
|
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
|
||||||
|
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
|
||||||
|
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
|
||||||
|
SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if queuedUplinks.count >= Limits.maxQueuedUplinks {
|
||||||
|
queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1)
|
||||||
|
}
|
||||||
|
queuedUplinks.append(item)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func allowUplinkDeposit(from depositor: PeerID) -> Bool {
|
||||||
|
let cutoff = now().addingTimeInterval(-60)
|
||||||
|
var times = uplinkDepositTimes[depositor, default: []]
|
||||||
|
times.removeAll { $0 < cutoff }
|
||||||
|
guard times.count < Limits.uplinkEventsPerMinutePerDepositor else {
|
||||||
|
uplinkDepositTimes[depositor] = times
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
times.append(now())
|
||||||
|
uplinkDepositTimes[depositor] = times
|
||||||
|
// Bound the tracker itself against a churn of spoofed depositors.
|
||||||
|
if uplinkDepositTimes.count > Limits.maxTrackedEventIDs {
|
||||||
|
uplinkDepositTimes = uplinkDepositTimes.filter { !$0.value.isEmpty && $0.value.contains { $0 >= cutoff } }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Downlink (gateway role: internet -> mesh)
|
||||||
|
|
||||||
|
/// Called for every event the gateway's own geohash-channel subscription
|
||||||
|
/// delivers. Wraps it in a `fromGateway` carrier and broadcasts it on
|
||||||
|
/// the mesh, within the airtime budget.
|
||||||
|
func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) {
|
||||||
|
guard isEnabled, broadcastToMesh != nil else { return }
|
||||||
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
|
// Freshness + geohash gate BEFORE spending any budget. A channel
|
||||||
|
// (re)subscribe backfills up to an hour of history (limit 200), but
|
||||||
|
// every receiver's `validatedEvent` drops anything older than the
|
||||||
|
// same window — so rebroadcasting backfill would burn the whole
|
||||||
|
// per-minute budget on events no mesh peer accepts. Also require the
|
||||||
|
// event's own `#g` tag to match the carrier geohash.
|
||||||
|
guard isFresh(event),
|
||||||
|
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Loop rule 1: never rebroadcast mesh-carried events back onto the
|
||||||
|
// mesh. Loop rule 2 (self-echo): never rebroadcast an event this
|
||||||
|
// gateway itself uplinked (`publishedEventIDs`) — it originated on this
|
||||||
|
// very mesh, so our own relay subscription echoing it back must not
|
||||||
|
// double the BLE airtime by pushing it out again. Loop rule 2
|
||||||
|
// (downlink): rebroadcast each genuine inbound relay event at most once
|
||||||
|
// — but mark only AFTER it is actually sent (in `drainPendingDownlinks`),
|
||||||
|
// so an event dropped by the queue overflow stays retryable on relay
|
||||||
|
// redelivery. Guard against a redelivery re-queueing an event that is
|
||||||
|
// still waiting to be sent.
|
||||||
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
|
!publishedEventIDs.contains(event.id),
|
||||||
|
!rebroadcastEventIDs.contains(event.id),
|
||||||
|
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Verify before spending BLE airtime; receivers verify again.
|
||||||
|
guard event.isValidSignature() else { return }
|
||||||
|
|
||||||
|
pendingDownlinks.append((event, geohash))
|
||||||
|
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
||||||
|
// Bandwidth guard: drop-oldest — fresher chat is worth more. The
|
||||||
|
// dropped event is not yet in `rebroadcastEventIDs`, so a later
|
||||||
|
// relay redelivery can still carry it.
|
||||||
|
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
|
||||||
|
}
|
||||||
|
drainPendingDownlinks()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drainPendingDownlinks() {
|
||||||
|
let cutoff = now().addingTimeInterval(-60)
|
||||||
|
downlinkSendTimes.removeAll { $0 < cutoff }
|
||||||
|
while !pendingDownlinks.isEmpty,
|
||||||
|
downlinkSendTimes.count < Limits.downlinkEventsPerMinute {
|
||||||
|
let (event, geohash) = pendingDownlinks.removeFirst()
|
||||||
|
// A queued event may have aged past the window while it waited;
|
||||||
|
// don't burn airtime on what receivers would now drop.
|
||||||
|
guard isFresh(event) else { continue }
|
||||||
|
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
|
||||||
|
let payload = carrier.encode() else { continue }
|
||||||
|
broadcastToMesh?(payload)
|
||||||
|
// Mark-after-send: only now is the relay event definitively
|
||||||
|
// rebroadcast (loop rule 2).
|
||||||
|
rebroadcastEventIDs.insert(event.id)
|
||||||
|
downlinkSendTimes.append(now())
|
||||||
|
}
|
||||||
|
// Budget exhausted with events still queued: arm a timer to drain when
|
||||||
|
// the window frees, instead of stranding them until the next inbound
|
||||||
|
// relay event (which may never come on a channel that went quiet).
|
||||||
|
scheduleDownlinkDrainIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arms a single timer to drain the backlog once the per-minute window
|
||||||
|
/// frees. No-op when nothing is pending or a drain is already scheduled.
|
||||||
|
private func scheduleDownlinkDrainIfNeeded() {
|
||||||
|
guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return }
|
||||||
|
// The window frees when the oldest recorded send ages out of 60s.
|
||||||
|
let oldest = downlinkSendTimes.min() ?? now()
|
||||||
|
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
|
||||||
|
downlinkDrainScheduled = true
|
||||||
|
let fire: @MainActor () -> Void = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.downlinkDrainScheduled = false
|
||||||
|
self.drainPendingDownlinks()
|
||||||
|
}
|
||||||
|
if let scheduleDrainTimer {
|
||||||
|
scheduleDrainTimer(delay, fire)
|
||||||
|
} else {
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
|
fire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Downlink (receiver role: carried event arrives over mesh)
|
||||||
|
|
||||||
|
private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) {
|
||||||
|
guard let event = validatedEvent(from: carrier) else { return }
|
||||||
|
// Mark only AFTER signature verification, so a forged copy carrying a
|
||||||
|
// real event's ID cannot poison the never-republish set, and use the
|
||||||
|
// marking as dedup: the same broadcast relayed along several mesh
|
||||||
|
// paths injects once (the pipeline's Nostr event-ID cache additionally
|
||||||
|
// dedups against our own relay subscription).
|
||||||
|
guard meshBroadcastEventIDs.insert(event.id) else { return }
|
||||||
|
// Only inject events for the channel we're viewing; the inbound
|
||||||
|
// pipeline files public messages under the current geohash.
|
||||||
|
guard currentGeohash?() == carrier.geohash else { return }
|
||||||
|
injectInbound?(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uplink (sender role: mesh-only peer with no relays)
|
||||||
|
|
||||||
|
/// Hands a locally signed event to a mesh gateway peer when we have no
|
||||||
|
/// working relay connection. Returns true when the event was sent.
|
||||||
|
///
|
||||||
|
/// v1 is deliberately fire-and-forget: no gateway ack. The event also
|
||||||
|
/// stays in `NostrRelayManager`'s own pending queue, so if our internet
|
||||||
|
/// comes back the relays dedup the duplicate publish by event ID.
|
||||||
|
///
|
||||||
|
/// Loop rule 3: call sites only pass freshly composed events (see
|
||||||
|
/// `GeohashSubscriptionManager.sendGeohash`); received carrier events
|
||||||
|
/// never reach this path, and the mesh-carried guard below backstops it.
|
||||||
|
func uplinkViaMesh(event: NostrEvent, geohash: String) -> Bool {
|
||||||
|
if relaysConnected?() ?? true { return false }
|
||||||
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
|
!publishedEventIDs.contains(event.id) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A single gateway is enough — relays fan out from there, and BLE
|
||||||
|
// airtime is precious.
|
||||||
|
guard let gateway = availableGatewayPeers?().first else { return false }
|
||||||
|
guard let carrier = NostrCarrierPacket(direction: .toGateway, geohash: geohash, event: event),
|
||||||
|
let payload = carrier.encode() 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)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
/// Structural and cryptographic checks every carried event must pass
|
||||||
|
/// before a gateway publishes it or a receiver displays it. Ordered
|
||||||
|
/// cheap-first; Schnorr verification runs last.
|
||||||
|
private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
|
||||||
|
guard let event = structurallyValidEvent(from: carrier),
|
||||||
|
event.isValidSignature() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cheap half of `validatedEvent`: parse + size + geohash + kind +
|
||||||
|
/// `#g` tag + freshness, with NO signature verification. Callers that can
|
||||||
|
/// dedup or rate-limit on the carried ID run this first so the expensive
|
||||||
|
/// Schnorr verify is reached only for events that survive the cheap gates.
|
||||||
|
private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
|
||||||
|
guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes,
|
||||||
|
Self.isValidGeohash(carrier.geohash),
|
||||||
|
let event = carrier.event(),
|
||||||
|
event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||||
|
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }),
|
||||||
|
isFresh(event) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `event.created_at` is within the accepted clock skew — the
|
||||||
|
/// SAME freshness window receivers enforce, so a gateway never spends
|
||||||
|
/// airtime on events every receiver would drop as stale.
|
||||||
|
private func isFresh(_ event: NostrEvent) -> Bool {
|
||||||
|
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isValidGeohash(_ geohash: String) -> Bool {
|
||||||
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count)
|
||||||
|
&& geohash.allSatisfy { allowed.contains($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
|
||||||
|
/// evicted when full.
|
||||||
|
private struct BoundedIDSet {
|
||||||
|
private var members: Set<String> = []
|
||||||
|
private var order: [String] = []
|
||||||
|
let capacity: Int
|
||||||
|
|
||||||
|
init(capacity: Int) {
|
||||||
|
self.capacity = capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(_ id: String) -> Bool {
|
||||||
|
members.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns false when the ID was already present.
|
||||||
|
@discardableResult
|
||||||
|
mutating func insert(_ id: String) -> Bool {
|
||||||
|
guard members.insert(id).inserted else { return false }
|
||||||
|
order.append(id)
|
||||||
|
if order.count > capacity {
|
||||||
|
members.remove(order.removeFirst())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,10 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
// Courier envelopes are directed deposits between trusted peers and
|
// Courier envelopes are directed deposits between trusted peers and
|
||||||
// must never spread via gossip sync.
|
// must never spread via gossip sync.
|
||||||
case .courierEnvelope: return nil
|
case .courierEnvelope: return nil
|
||||||
|
// Gateway carriers are ephemeral live traffic (uplinks are directed,
|
||||||
|
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
||||||
|
// would waste airtime and extend their lifetime.
|
||||||
|
case .nostrCarrier: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ final class ChatViewModelBootstrapper {
|
|||||||
configureNoiseCallbacks()
|
configureNoiseCallbacks()
|
||||||
bindTransferProgress()
|
bindTransferProgress()
|
||||||
configureGeoChannels()
|
configureGeoChannels()
|
||||||
|
configureGateway()
|
||||||
bindTeleportState()
|
bindTeleportState()
|
||||||
requestNotifications()
|
requestNotifications()
|
||||||
registerObservers()
|
registerObservers()
|
||||||
@@ -244,6 +245,72 @@ private extension ChatViewModelBootstrapper {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wires the gateway-mode policy layer (`GatewayService`) to the mesh
|
||||||
|
/// transport, the relay manager, and the inbound Nostr pipeline. All
|
||||||
|
/// dependencies are closures so the service stays unit-testable with
|
||||||
|
/// fakes.
|
||||||
|
func configureGateway() {
|
||||||
|
// Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
|
||||||
|
// has no carrier packets to bridge.
|
||||||
|
guard let bleService = viewModel.meshService as? BLEService else { return }
|
||||||
|
let gateway = GatewayService.shared
|
||||||
|
|
||||||
|
gateway.publishToRelays = { event, geohash in
|
||||||
|
let relays = GeoRelayDirectory.shared.closestRelays(
|
||||||
|
toGeohash: geohash,
|
||||||
|
count: TransportConfig.nostrGeoRelayCount
|
||||||
|
)
|
||||||
|
// Symmetric with the local send path (GeohashSubscriptionManager
|
||||||
|
// .sendGeohash): with no known geo relay, refuse rather than
|
||||||
|
// publish to default relays no geo subscriber reads — that would
|
||||||
|
// be silent dead traffic, not delivery.
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
|
}
|
||||||
|
gateway.broadcastToMesh = { [weak bleService] payload in
|
||||||
|
bleService?.broadcastNostrCarrier(payload)
|
||||||
|
}
|
||||||
|
gateway.sendToGatewayPeer = { [weak bleService] payload, peer in
|
||||||
|
bleService?.sendNostrCarrier(payload, to: peer) ?? false
|
||||||
|
}
|
||||||
|
gateway.availableGatewayPeers = { [weak bleService] in
|
||||||
|
bleService?.reachableGatewayPeers() ?? []
|
||||||
|
}
|
||||||
|
gateway.relaysConnected = { NostrRelayManager.shared.isConnected }
|
||||||
|
gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash }
|
||||||
|
// Carried events enter the same pipeline as relay-received events so
|
||||||
|
// blocking, rate limits, dedup, and rendering behave identically.
|
||||||
|
gateway.injectInbound = { [weak viewModel] event in
|
||||||
|
viewModel?.handleNostrEvent(event)
|
||||||
|
}
|
||||||
|
// The capability bit is advertised ONLY while the toggle is on; a
|
||||||
|
// change forces a re-announce so peers learn promptly.
|
||||||
|
gateway.onEnabledChanged = { [weak bleService] enabled in
|
||||||
|
bleService?.setLocalCapability(.gateway, enabled: enabled)
|
||||||
|
}
|
||||||
|
bleService.onNostrCarrierPacket = { payload, from, directedToUs in
|
||||||
|
GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uplinks deposited while relays were unreachable flush on reconnect.
|
||||||
|
NostrRelayManager.shared.$isConnected
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { connected in
|
||||||
|
if connected {
|
||||||
|
GatewayService.shared.flushQueuedUplinks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.cancellables)
|
||||||
|
|
||||||
|
// Apply the persisted toggle at launch.
|
||||||
|
if gateway.isEnabled {
|
||||||
|
bleService.setLocalCapability(.gateway, enabled: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func bindTeleportState() {
|
func bindTeleportState() {
|
||||||
viewModel.locationManager.$teleported
|
viewModel.locationManager.$teleported
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ final class GeohashSubscriptionManager {
|
|||||||
private weak var context: (any GeohashSubscriptionContext)?
|
private weak var context: (any GeohashSubscriptionContext)?
|
||||||
private let inbound: NostrInboundPipeline
|
private let inbound: NostrInboundPipeline
|
||||||
private let presence: GeoPresenceTracker
|
private let presence: GeoPresenceTracker
|
||||||
|
/// Geohashes already told "sent via mesh gateway" this session, so the
|
||||||
|
/// notice appears once per channel instead of once per message.
|
||||||
|
private var gatewayNoticeGeohashes = Set<String>()
|
||||||
|
|
||||||
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
|
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -145,6 +148,9 @@ final class GeohashSubscriptionManager {
|
|||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.subscribeNostrEvent(event)
|
self?.inbound.subscribeNostrEvent(event)
|
||||||
|
// Gateway downlink: rebroadcast relay events for the viewed
|
||||||
|
// channel onto the mesh (no-op unless gateway mode is on).
|
||||||
|
GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +241,9 @@ final class GeohashSubscriptionManager {
|
|||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.handleNostrEvent(event)
|
self?.inbound.handleNostrEvent(event)
|
||||||
|
// Gateway downlink: rebroadcast relay events for the viewed
|
||||||
|
// channel onto the mesh (no-op unless gateway mode is on).
|
||||||
|
GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +289,23 @@ final class GeohashSubscriptionManager {
|
|||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mesh gateway uplink: with no working relay connection, hand the
|
||||||
|
// locally signed event to a mesh peer advertising the gateway
|
||||||
|
// capability (keys never leave this device — only the finished,
|
||||||
|
// signed event travels). Uplink is only ever attempted here, for a
|
||||||
|
// freshly composed event, never for received carrier events (loop
|
||||||
|
// rule 3 in GatewayService).
|
||||||
|
if GatewayService.shared.uplinkViaMesh(event: event, geohash: channel.geohash),
|
||||||
|
gatewayNoticeGeohashes.insert(channel.geohash).inserted {
|
||||||
|
context.addPublicSystemMessage(
|
||||||
|
String(
|
||||||
|
localized: "system.gateway.sent_via_mesh",
|
||||||
|
defaultValue: "sent via mesh gateway",
|
||||||
|
comment: "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
|
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ struct ContentHeaderView: View {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
|
if locationChannelsModel.gatewayEnabled {
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.font(.bitchatSystem(size: 12))
|
||||||
|
.foregroundColor(palette.secondary.opacity(0.8))
|
||||||
|
.headerTapTarget()
|
||||||
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator")
|
||||||
|
)
|
||||||
|
.help(
|
||||||
|
String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if carriedMailCount > 0 {
|
if carriedMailCount > 0 {
|
||||||
Image(systemName: "figure.walk")
|
Image(systemName: "figure.walk")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ struct LocationChannelsSheet: View {
|
|||||||
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
||||||
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
||||||
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
||||||
|
static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title"
|
||||||
|
static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle"
|
||||||
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||||
|
|
||||||
@@ -244,6 +246,8 @@ struct LocationChannelsSheet: View {
|
|||||||
sectionDivider
|
sectionDivider
|
||||||
torToggleSection
|
torToggleSection
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
gatewayToggleSection
|
||||||
|
.padding(.top, 8)
|
||||||
Button(action: SystemSettings.location.open) {
|
Button(action: SystemSettings.location.open) {
|
||||||
Text(Strings.removeAccess)
|
Text(Strings.removeAccess)
|
||||||
.bitchatFont(size: 12)
|
.bitchatFont(size: 12)
|
||||||
@@ -508,6 +512,32 @@ extension LocationChannelsSheet {
|
|||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var gatewayToggleBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { locationChannelsModel.gatewayEnabled },
|
||||||
|
set: { locationChannelsModel.setGatewayEnabled($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gatewayToggleSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Toggle(isOn: gatewayToggleBinding) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(Strings.gatewayTitle)
|
||||||
|
.bitchatFont(size: 12, weight: .semibold)
|
||||||
|
.foregroundColor(palette.primary)
|
||||||
|
Text(Strings.gatewaySubtitle)
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(palette.secondary.opacity(0.12))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
private var standardGreen: Color { palette.primary }
|
private var standardGreen: Color { palette.primary }
|
||||||
private var standardBlue: Color { palette.accentBlue }
|
private var standardBlue: Color { palette.accentBlue }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// NostrCarrierPacketTests.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("Nostr carrier packet TLV")
|
||||||
|
struct NostrCarrierPacketTests {
|
||||||
|
private func makeEvent(geohash: String = "u4pruy", content: String = "hello mesh") throws -> NostrEvent {
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
return try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
|
content: content,
|
||||||
|
geohash: geohash,
|
||||||
|
senderIdentity: identity,
|
||||||
|
nickname: "tester"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("round-trips both directions with the signed event intact")
|
||||||
|
func roundTrip() throws {
|
||||||
|
let event = try makeEvent()
|
||||||
|
for direction in [NostrCarrierPacket.Direction.toGateway, .fromGateway] {
|
||||||
|
let packet = try #require(NostrCarrierPacket(direction: direction, geohash: "u4pruy", event: event))
|
||||||
|
let encoded = try #require(packet.encode())
|
||||||
|
let decoded = try #require(NostrCarrierPacket.decode(encoded))
|
||||||
|
|
||||||
|
#expect(decoded == packet)
|
||||||
|
#expect(decoded.direction == direction)
|
||||||
|
#expect(decoded.geohash == "u4pruy")
|
||||||
|
|
||||||
|
// The carried event survives byte-exact: same ID, and the
|
||||||
|
// signature still verifies after the mesh hop.
|
||||||
|
let carried = try #require(decoded.event())
|
||||||
|
#expect(carried.id == event.id)
|
||||||
|
#expect(carried.sig == event.sig)
|
||||||
|
#expect(carried.isValidSignature())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rejects an oversized event at construction and at decode")
|
||||||
|
func oversizedRejected() throws {
|
||||||
|
let oversized = Data(repeating: 0x7B, count: NostrCarrierPacket.maxEventJSONBytes + 1)
|
||||||
|
#expect(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", eventJSON: oversized) == nil)
|
||||||
|
|
||||||
|
// Hand-build the TLV bytes to bypass the initializer's cap.
|
||||||
|
var data = Data([0x01, 0x00, 0x01, NostrCarrierPacket.Direction.toGateway.rawValue])
|
||||||
|
let geohash = Data("u4pruy".utf8)
|
||||||
|
data.append(contentsOf: [0x02, 0x00, UInt8(geohash.count)])
|
||||||
|
data.append(geohash)
|
||||||
|
data.append(contentsOf: [0x03, UInt8((oversized.count >> 8) & 0xFF), UInt8(oversized.count & 0xFF)])
|
||||||
|
data.append(oversized)
|
||||||
|
#expect(NostrCarrierPacket.decode(data) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rejects an over-length or empty geohash")
|
||||||
|
func geohashBoundsEnforced() throws {
|
||||||
|
let event = try makeEvent()
|
||||||
|
#expect(NostrCarrierPacket(direction: .toGateway, geohash: "", event: event) == nil)
|
||||||
|
#expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 13), event: event) == nil)
|
||||||
|
#expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 12), event: event) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("skips unknown TLVs for forward compatibility")
|
||||||
|
func unknownTLVSkipped() throws {
|
||||||
|
let event = try makeEvent()
|
||||||
|
let packet = try #require(NostrCarrierPacket(direction: .fromGateway, geohash: "u4pruy", event: event))
|
||||||
|
var encoded = try #require(packet.encode())
|
||||||
|
// Append an unknown TLV (type 0x7F, 2-byte value).
|
||||||
|
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
|
||||||
|
let decoded = try #require(NostrCarrierPacket.decode(encoded))
|
||||||
|
#expect(decoded == packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rejects truncated and missing-field payloads")
|
||||||
|
func malformedRejected() throws {
|
||||||
|
let event = try makeEvent()
|
||||||
|
let packet = try #require(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", event: event))
|
||||||
|
let encoded = try #require(packet.encode())
|
||||||
|
|
||||||
|
// Truncation anywhere inside the last TLV fails cleanly.
|
||||||
|
#expect(NostrCarrierPacket.decode(encoded.dropLast(1)) == nil)
|
||||||
|
#expect(NostrCarrierPacket.decode(encoded.prefix(4)) == nil)
|
||||||
|
#expect(NostrCarrierPacket.decode(Data()) == nil)
|
||||||
|
|
||||||
|
// Direction TLV alone (missing geohash and event) fails.
|
||||||
|
#expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x01])) == nil)
|
||||||
|
// Unknown direction value fails.
|
||||||
|
#expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x77])) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
//
|
||||||
|
// GatewayServiceTests.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("Gateway mode policy")
|
||||||
|
@MainActor
|
||||||
|
struct GatewayServiceTests {
|
||||||
|
private static let geohash = "u4pruy"
|
||||||
|
|
||||||
|
/// Closure-injected harness around `GatewayService` recording every
|
||||||
|
/// side effect, with a controllable clock and relay connectivity.
|
||||||
|
@MainActor
|
||||||
|
private final class Fixture {
|
||||||
|
private final class ClockBox {
|
||||||
|
var now = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
var relaysConnected = true
|
||||||
|
var currentGeohash: String? = GatewayServiceTests.geohash
|
||||||
|
var gatewayPeers: [PeerID] = []
|
||||||
|
var sendToGatewaySucceeds = true
|
||||||
|
|
||||||
|
private(set) var published: [(event: NostrEvent, geohash: String)] = []
|
||||||
|
private(set) var broadcasts: [Data] = []
|
||||||
|
private(set) var injected: [NostrEvent] = []
|
||||||
|
private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = []
|
||||||
|
private(set) var enabledChanges: [Bool] = []
|
||||||
|
private(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = []
|
||||||
|
|
||||||
|
private let clock = ClockBox()
|
||||||
|
let defaults: UserDefaults
|
||||||
|
let service: GatewayService
|
||||||
|
|
||||||
|
init(enabled: Bool = true, suite: String = "GatewayServiceTests-\(UUID().uuidString)") {
|
||||||
|
defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
let clock = clock
|
||||||
|
service = GatewayService(defaults: defaults) { clock.now }
|
||||||
|
service.publishToRelays = { [weak self] event, geohash in
|
||||||
|
self?.published.append((event, geohash))
|
||||||
|
}
|
||||||
|
service.broadcastToMesh = { [weak self] payload in
|
||||||
|
self?.broadcasts.append(payload)
|
||||||
|
}
|
||||||
|
service.sendToGatewayPeer = { [weak self] payload, peer in
|
||||||
|
guard let self, self.sendToGatewaySucceeds else { return false }
|
||||||
|
self.uplinkSends.append((payload, peer))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
service.availableGatewayPeers = { [weak self] in self?.gatewayPeers ?? [] }
|
||||||
|
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
|
||||||
|
service.currentGeohash = { [weak self] in self?.currentGeohash }
|
||||||
|
service.injectInbound = { [weak self] event in self?.injected.append(event) }
|
||||||
|
service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) }
|
||||||
|
// Capture drain timers instead of arming a real Task so the drain
|
||||||
|
// is deterministic under the fake clock.
|
||||||
|
service.scheduleDrainTimer = { [weak self] delay, work in
|
||||||
|
self?.scheduledDrains.append((delay, work))
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
service.setEnabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func advance(_ seconds: TimeInterval) {
|
||||||
|
clock.now = clock.now.addingTimeInterval(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires every currently-scheduled drain timer (simulating the window
|
||||||
|
/// freeing), as the real Task would after its delay.
|
||||||
|
func fireScheduledDrains() {
|
||||||
|
let due = scheduledDrains
|
||||||
|
scheduledDrains.removeAll()
|
||||||
|
for item in due { item.work() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Event helpers
|
||||||
|
|
||||||
|
private func makeEvent(
|
||||||
|
geohash: String = GatewayServiceTests.geohash,
|
||||||
|
content: String = "hello \(UUID().uuidString.prefix(8))"
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
return try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
|
content: content,
|
||||||
|
geohash: geohash,
|
||||||
|
senderIdentity: identity,
|
||||||
|
nickname: "tester"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy of `event` with tampered content but the original ID and
|
||||||
|
/// signature — what a forging gateway or mesh peer would produce.
|
||||||
|
private func forge(_ event: NostrEvent) throws -> NostrEvent {
|
||||||
|
let dict: [String: Any] = [
|
||||||
|
"id": event.id,
|
||||||
|
"pubkey": event.pubkey,
|
||||||
|
"created_at": event.created_at,
|
||||||
|
"kind": event.kind,
|
||||||
|
"tags": event.tags,
|
||||||
|
"content": event.content + " (tampered)",
|
||||||
|
"sig": event.sig ?? ""
|
||||||
|
]
|
||||||
|
return try NostrEvent(from: dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func carrierPayload(
|
||||||
|
_ event: NostrEvent,
|
||||||
|
direction: NostrCarrierPacket.Direction = .toGateway,
|
||||||
|
geohash: String = GatewayServiceTests.geohash
|
||||||
|
) throws -> Data {
|
||||||
|
let packet = try #require(NostrCarrierPacket(direction: direction, geohash: geohash, event: event))
|
||||||
|
return try #require(packet.encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deposit(
|
||||||
|
_ event: NostrEvent,
|
||||||
|
into fixture: Fixture,
|
||||||
|
from depositor: PeerID = PeerID(str: "1122334455667788"),
|
||||||
|
geohash: String = GatewayServiceTests.geohash
|
||||||
|
) throws {
|
||||||
|
let payload = try carrierPayload(event, direction: .toGateway, geohash: geohash)
|
||||||
|
fixture.service.handleMeshCarrier(payload, from: depositor, directedToUs: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uplink verification gates
|
||||||
|
|
||||||
|
@Test("publishes a verified deposit to the geo relays")
|
||||||
|
func verifiedDepositPublished() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let event = try makeEvent()
|
||||||
|
try deposit(event, into: fixture)
|
||||||
|
|
||||||
|
#expect(fixture.published.count == 1)
|
||||||
|
#expect(fixture.published.first?.event.id == event.id)
|
||||||
|
#expect(fixture.published.first?.geohash == Self.geohash)
|
||||||
|
// Viewing the same geohash: the carried message shows on our own timeline.
|
||||||
|
#expect(fixture.injected.map(\.id) == [event.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rejects a forged signature")
|
||||||
|
func forgedSignatureRejected() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let forged = try forge(try makeEvent())
|
||||||
|
try deposit(forged, into: fixture)
|
||||||
|
|
||||||
|
#expect(fixture.published.isEmpty)
|
||||||
|
#expect(fixture.injected.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rejects wrong kind, geohash mismatch, and stale events")
|
||||||
|
func structuralGates() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
|
||||||
|
// Wrong kind (kind-1 text note instead of kind-20000 ephemeral).
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
let note = try NostrProtocol.createGeohashTextNote(
|
||||||
|
content: "note",
|
||||||
|
geohash: Self.geohash,
|
||||||
|
senderIdentity: identity
|
||||||
|
)
|
||||||
|
try deposit(note, into: fixture)
|
||||||
|
#expect(fixture.published.isEmpty)
|
||||||
|
|
||||||
|
// Carrier geohash disagreeing with the event's #g tag.
|
||||||
|
let mismatched = try makeEvent(geohash: "9q8yyk")
|
||||||
|
try deposit(mismatched, into: fixture, geohash: Self.geohash)
|
||||||
|
#expect(fixture.published.isEmpty)
|
||||||
|
|
||||||
|
// Stale event (beyond accepted clock skew).
|
||||||
|
let stale = try makeEvent()
|
||||||
|
fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60)
|
||||||
|
try deposit(stale, into: fixture)
|
||||||
|
#expect(fixture.published.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("does nothing while the toggle is off")
|
||||||
|
func disabledGatewayIgnoresDeposits() throws {
|
||||||
|
let fixture = Fixture(enabled: false)
|
||||||
|
try deposit(try makeEvent(), into: fixture)
|
||||||
|
#expect(fixture.published.isEmpty)
|
||||||
|
#expect(fixture.service.queuedUplinks.isEmpty)
|
||||||
|
|
||||||
|
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
|
||||||
|
#expect(fixture.broadcasts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uplink quotas and rate limit
|
||||||
|
|
||||||
|
@Test("rate-limits deposits per depositor per minute")
|
||||||
|
func uplinkRateLimit() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let depositor = PeerID(str: "aabbccddeeff0011")
|
||||||
|
|
||||||
|
for _ in 0..<GatewayService.Limits.uplinkEventsPerMinutePerDepositor {
|
||||||
|
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||||
|
}
|
||||||
|
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor)
|
||||||
|
|
||||||
|
// One over budget inside the window: dropped.
|
||||||
|
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||||
|
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor)
|
||||||
|
|
||||||
|
// Another depositor is unaffected.
|
||||||
|
try deposit(try makeEvent(), into: fixture, from: PeerID(str: "0011223344556677"))
|
||||||
|
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor + 1)
|
||||||
|
|
||||||
|
// The window slides.
|
||||||
|
fixture.advance(61)
|
||||||
|
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||||
|
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor + 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("bounds the offline uplink queue per depositor and in total, evicting oldest")
|
||||||
|
func uplinkQueueQuotas() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
fixture.relaysConnected = false
|
||||||
|
|
||||||
|
let depositorA = PeerID(str: "1111111111111111")
|
||||||
|
var firstFromA: NostrEvent?
|
||||||
|
for i in 0..<(GatewayService.Limits.maxQueuedUplinksPerDepositor + 1) {
|
||||||
|
let event = try makeEvent()
|
||||||
|
if i == 0 { firstFromA = event }
|
||||||
|
try deposit(event, into: fixture, from: depositorA)
|
||||||
|
}
|
||||||
|
// The sixth deposit from the same depositor is rejected.
|
||||||
|
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
|
||||||
|
|
||||||
|
// Fill the global queue with other depositors.
|
||||||
|
var filler = GatewayService.Limits.maxQueuedUplinksPerDepositor
|
||||||
|
var suffix = 2
|
||||||
|
while filler < GatewayService.Limits.maxQueuedUplinks {
|
||||||
|
let depositor = PeerID(str: String(repeating: "\(suffix)", count: 16))
|
||||||
|
for _ in 0..<GatewayService.Limits.maxQueuedUplinksPerDepositor {
|
||||||
|
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||||
|
}
|
||||||
|
filler += GatewayService.Limits.maxQueuedUplinksPerDepositor
|
||||||
|
suffix += 1
|
||||||
|
}
|
||||||
|
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinks)
|
||||||
|
|
||||||
|
// One more from a fresh depositor evicts the oldest queued deposit.
|
||||||
|
let newest = try makeEvent()
|
||||||
|
try deposit(newest, into: fixture, from: PeerID(str: "9999999999999999"))
|
||||||
|
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinks)
|
||||||
|
#expect(fixture.service.queuedUplinks.contains { $0.event.id == newest.id })
|
||||||
|
#expect(!fixture.service.queuedUplinks.contains { $0.event.id == firstFromA?.id })
|
||||||
|
|
||||||
|
// Relay connectivity returning flushes the queue.
|
||||||
|
fixture.relaysConnected = true
|
||||||
|
fixture.service.flushQueuedUplinks()
|
||||||
|
#expect(fixture.published.count == GatewayService.Limits.maxQueuedUplinks)
|
||||||
|
#expect(fixture.service.queuedUplinks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("absorbs repeat deposits of the same event")
|
||||||
|
func repeatDepositAbsorbed() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let event = try makeEvent()
|
||||||
|
try deposit(event, into: fixture)
|
||||||
|
try deposit(event, into: fixture, from: PeerID(str: "8877665544332211"))
|
||||||
|
#expect(fixture.published.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a quota-dropped deposit is not rendered on the local timeline")
|
||||||
|
func quotaDroppedDepositNotInjected() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
fixture.relaysConnected = false
|
||||||
|
let depositor = PeerID(str: "1111111111111111")
|
||||||
|
|
||||||
|
// Fill this depositor's offline queue to its per-depositor cap; each
|
||||||
|
// accepted deposit is rendered locally (we view that geohash).
|
||||||
|
for _ in 0..<GatewayService.Limits.maxQueuedUplinksPerDepositor {
|
||||||
|
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||||
|
}
|
||||||
|
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
|
||||||
|
#expect(fixture.injected.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
|
||||||
|
|
||||||
|
// One past the cap: dropped by the queue quota, so it is neither
|
||||||
|
// queued nor shown — no phantom local message.
|
||||||
|
try deposit(try makeEvent(), into: fixture, from: depositor)
|
||||||
|
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
|
||||||
|
#expect(fixture.injected.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loop prevention
|
||||||
|
|
||||||
|
@Test("never re-publishes or re-carries an event learned from a fromGateway broadcast")
|
||||||
|
func meshCarriedNeverRepublished() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let event = try makeEvent()
|
||||||
|
let broadcast = try carrierPayload(event, direction: .fromGateway)
|
||||||
|
|
||||||
|
// Another gateway rebroadcast this event onto the mesh; we saw it.
|
||||||
|
fixture.service.handleMeshCarrier(broadcast, from: PeerID(str: "8877665544332211"), directedToUs: false)
|
||||||
|
#expect(fixture.injected.map(\.id) == [event.id])
|
||||||
|
|
||||||
|
// Rule: a deposit of the same event must never be published…
|
||||||
|
try deposit(event, into: fixture)
|
||||||
|
#expect(fixture.published.isEmpty)
|
||||||
|
|
||||||
|
// …a relay echo of it must never be rebroadcast (we'd loop it back)…
|
||||||
|
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
||||||
|
#expect(fixture.broadcasts.isEmpty)
|
||||||
|
|
||||||
|
// …and it must never be re-uplinked from this device.
|
||||||
|
fixture.relaysConnected = false
|
||||||
|
fixture.gatewayPeers = [PeerID(str: "8877665544332211")]
|
||||||
|
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
|
||||||
|
#expect(fixture.uplinkSends.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rebroadcasts a relay event at most once, absorbing echoes")
|
||||||
|
func relayEventRebroadcastOnce() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let event = try makeEvent()
|
||||||
|
|
||||||
|
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
||||||
|
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
||||||
|
#expect(fixture.broadcasts.count == 1)
|
||||||
|
|
||||||
|
// The rebroadcast payload decodes as a fromGateway carrier for the
|
||||||
|
// same signed event.
|
||||||
|
let payload = try #require(fixture.broadcasts.first)
|
||||||
|
let decoded = try #require(NostrCarrierPacket.decode(payload))
|
||||||
|
#expect(decoded.direction == .fromGateway)
|
||||||
|
#expect(decoded.event()?.id == event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("never downlink-rebroadcasts an event it uplinked and saw echo back")
|
||||||
|
func uplinkedEventNotSelfEchoed() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let event = try makeEvent()
|
||||||
|
|
||||||
|
// A mesh-only peer deposits an event; the gateway uplinks (publishes)
|
||||||
|
// it to the relays (loop rule 2 records it in publishedEventIDs).
|
||||||
|
try deposit(event, into: fixture)
|
||||||
|
#expect(fixture.published.map(\.event.id) == [event.id])
|
||||||
|
|
||||||
|
// The gateway's own geohash subscription now delivers that same event
|
||||||
|
// right back (~0.15s later on device). It originated on this mesh, so
|
||||||
|
// rebroadcasting it would double BLE airtime — the device-confirmed
|
||||||
|
// self-echo. It must be suppressed.
|
||||||
|
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
||||||
|
#expect(fixture.broadcasts.isEmpty)
|
||||||
|
|
||||||
|
// Sanity: a genuine inbound-from-internet event (never uplinked here)
|
||||||
|
// still downlink-rebroadcasts normally.
|
||||||
|
let inbound = try makeEvent()
|
||||||
|
fixture.service.rebroadcastRelayEvent(inbound, geohash: Self.geohash)
|
||||||
|
#expect(fixture.broadcasts.count == 1)
|
||||||
|
let payload = try #require(fixture.broadcasts.first)
|
||||||
|
let decoded = try #require(NostrCarrierPacket.decode(payload))
|
||||||
|
#expect(decoded.event()?.id == inbound.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a forged broadcast cannot poison the loop-prevention set")
|
||||||
|
func forgedBroadcastDoesNotPoison() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let event = try makeEvent()
|
||||||
|
let forgedPayload = try carrierPayload(try forge(event), direction: .fromGateway)
|
||||||
|
|
||||||
|
fixture.service.handleMeshCarrier(forgedPayload, from: PeerID(str: "8877665544332211"), directedToUs: false)
|
||||||
|
#expect(fixture.injected.isEmpty)
|
||||||
|
|
||||||
|
// The genuine event still uplinks: the forged copy (sharing its ID)
|
||||||
|
// was rejected before the mesh-carried marking.
|
||||||
|
try deposit(event, into: fixture)
|
||||||
|
#expect(fixture.published.map(\.event.id) == [event.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Downlink bandwidth guard
|
||||||
|
|
||||||
|
@Test("caps mesh rebroadcasts per minute, queueing with drop-oldest")
|
||||||
|
func downlinkBudget() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let overBudget = GatewayService.Limits.downlinkEventsPerMinute + 5
|
||||||
|
|
||||||
|
var events: [NostrEvent] = []
|
||||||
|
for _ in 0..<overBudget {
|
||||||
|
let event = try makeEvent()
|
||||||
|
events.append(event)
|
||||||
|
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
||||||
|
}
|
||||||
|
#expect(fixture.broadcasts.count == GatewayService.Limits.downlinkEventsPerMinute)
|
||||||
|
|
||||||
|
// Once the window slides, the next relay event drains the backlog too.
|
||||||
|
fixture.advance(61)
|
||||||
|
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
|
||||||
|
#expect(fixture.broadcasts.count == overBudget + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("does not spend downlink budget on stale backfill or geohash mismatch")
|
||||||
|
func downlinkDropsStaleAndMismatched() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
|
||||||
|
// A fresh, matching event rebroadcasts.
|
||||||
|
let fresh = try makeEvent()
|
||||||
|
// An event whose #g tag disagrees with the carrier geohash.
|
||||||
|
let mismatched = try makeEvent(geohash: "9q8yyk")
|
||||||
|
// An event that will age past the receiver window before we offer it.
|
||||||
|
let willBeStale = try makeEvent()
|
||||||
|
|
||||||
|
fixture.service.rebroadcastRelayEvent(fresh, geohash: Self.geohash)
|
||||||
|
#expect(fixture.broadcasts.count == 1)
|
||||||
|
|
||||||
|
fixture.service.rebroadcastRelayEvent(mismatched, geohash: Self.geohash)
|
||||||
|
#expect(fixture.broadcasts.count == 1) // geohash mismatch: dropped pre-budget
|
||||||
|
|
||||||
|
fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60)
|
||||||
|
fixture.service.rebroadcastRelayEvent(willBeStale, geohash: Self.geohash)
|
||||||
|
#expect(fixture.broadcasts.count == 1) // stale backfill: dropped pre-budget
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("drains a downlink backlog on the scheduled timer when the channel goes quiet")
|
||||||
|
func downlinkDrainsOnTimer() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let overBudget = GatewayService.Limits.downlinkEventsPerMinute + 5
|
||||||
|
|
||||||
|
for _ in 0..<overBudget {
|
||||||
|
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
|
||||||
|
}
|
||||||
|
// Budget spent; the tail is queued and a drain timer was armed.
|
||||||
|
#expect(fixture.broadcasts.count == GatewayService.Limits.downlinkEventsPerMinute)
|
||||||
|
#expect(!fixture.scheduledDrains.isEmpty)
|
||||||
|
|
||||||
|
// The channel goes quiet — no new inbound event. The window frees and
|
||||||
|
// the timer fires on its own, draining the remaining backlog.
|
||||||
|
fixture.advance(61)
|
||||||
|
fixture.fireScheduledDrains()
|
||||||
|
#expect(fixture.broadcasts.count == overBudget)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Receiver downlink handling
|
||||||
|
|
||||||
|
@Test("injects a carried event once, even when broadcast twice")
|
||||||
|
func downlinkInjectedOnce() throws {
|
||||||
|
let fixture = Fixture(enabled: false) // receivers need no toggle
|
||||||
|
let event = try makeEvent()
|
||||||
|
let payload = try carrierPayload(event, direction: .fromGateway)
|
||||||
|
|
||||||
|
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "8877665544332211"), directedToUs: false)
|
||||||
|
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "7766554433221100"), directedToUs: false)
|
||||||
|
#expect(fixture.injected.map(\.id) == [event.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("only injects events for the geohash channel being viewed")
|
||||||
|
func downlinkChannelScoped() throws {
|
||||||
|
let fixture = Fixture(enabled: false)
|
||||||
|
fixture.currentGeohash = "9q8yyk"
|
||||||
|
let event = try makeEvent()
|
||||||
|
let payload = try carrierPayload(event, direction: .fromGateway)
|
||||||
|
|
||||||
|
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "8877665544332211"), directedToUs: false)
|
||||||
|
#expect(fixture.injected.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a directed fromGateway or broadcast toGateway carrier is malformed and dropped")
|
||||||
|
func directionMisuseDropped() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let event = try makeEvent()
|
||||||
|
|
||||||
|
let downlink = try carrierPayload(event, direction: .fromGateway)
|
||||||
|
fixture.service.handleMeshCarrier(downlink, from: PeerID(str: "8877665544332211"), directedToUs: true)
|
||||||
|
#expect(fixture.injected.isEmpty)
|
||||||
|
|
||||||
|
let uplink = try carrierPayload(event, direction: .toGateway)
|
||||||
|
fixture.service.handleMeshCarrier(uplink, from: PeerID(str: "8877665544332211"), directedToUs: false)
|
||||||
|
#expect(fixture.published.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mesh-only sender uplink
|
||||||
|
|
||||||
|
@Test("uplinks via a mesh gateway only when relays are down and a gateway exists")
|
||||||
|
func uplinkViaMeshConditions() throws {
|
||||||
|
let fixture = Fixture(enabled: false)
|
||||||
|
let gatewayPeer = PeerID(str: "8877665544332211")
|
||||||
|
let event = try makeEvent()
|
||||||
|
|
||||||
|
// Relays working: no mesh uplink.
|
||||||
|
fixture.relaysConnected = true
|
||||||
|
fixture.gatewayPeers = [gatewayPeer]
|
||||||
|
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
|
||||||
|
|
||||||
|
// Relays down, no gateway around: no mesh uplink.
|
||||||
|
fixture.relaysConnected = false
|
||||||
|
fixture.gatewayPeers = []
|
||||||
|
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
|
||||||
|
|
||||||
|
// Relays down and a gateway peer advertised: uplink goes out.
|
||||||
|
fixture.gatewayPeers = [gatewayPeer]
|
||||||
|
#expect(fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
|
||||||
|
#expect(fixture.uplinkSends.count == 1)
|
||||||
|
#expect(fixture.uplinkSends.first?.peer == gatewayPeer)
|
||||||
|
|
||||||
|
let sentPayload = try #require(fixture.uplinkSends.first?.payload)
|
||||||
|
let sent = try #require(NostrCarrierPacket.decode(sentPayload))
|
||||||
|
#expect(sent.direction == .toGateway)
|
||||||
|
#expect(sent.geohash == Self.geohash)
|
||||||
|
#expect(sent.event()?.id == event.id)
|
||||||
|
|
||||||
|
// A failed transport hand-off reports false.
|
||||||
|
fixture.sendToGatewaySucceeds = false
|
||||||
|
#expect(!fixture.service.uplinkViaMesh(event: try makeEvent(), geohash: Self.geohash))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("uplinkViaMesh backstop refuses an event this gateway already published")
|
||||||
|
func uplinkViaMeshBackstopsPublished() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
let event = try makeEvent()
|
||||||
|
|
||||||
|
// This gateway publishes the event to relays (loop rule 2 records it
|
||||||
|
// in publishedEventIDs).
|
||||||
|
try deposit(event, into: fixture)
|
||||||
|
#expect(fixture.published.map(\.event.id) == [event.id])
|
||||||
|
|
||||||
|
// Relays then drop and a gateway peer appears. The same event must not
|
||||||
|
// be re-uplinked from this device — the publishedEventIDs backstop in
|
||||||
|
// uplinkViaMesh catches it even though it was never a mesh broadcast.
|
||||||
|
fixture.relaysConnected = false
|
||||||
|
fixture.gatewayPeers = [PeerID(str: "8877665544332211")]
|
||||||
|
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
|
||||||
|
#expect(fixture.uplinkSends.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toggle
|
||||||
|
|
||||||
|
@Test("persists the toggle and reports changes")
|
||||||
|
func togglePersistsAndNotifies() throws {
|
||||||
|
let suite = "GatewayServiceTests-\(UUID().uuidString)"
|
||||||
|
let fixture = Fixture(enabled: false, suite: suite)
|
||||||
|
#expect(!fixture.service.isEnabled)
|
||||||
|
|
||||||
|
fixture.service.setEnabled(true)
|
||||||
|
#expect(fixture.enabledChanges == [true])
|
||||||
|
// Setting the same value again is a no-op.
|
||||||
|
fixture.service.setEnabled(true)
|
||||||
|
#expect(fixture.enabledChanges == [true])
|
||||||
|
|
||||||
|
// A fresh service over the same defaults restores the toggle.
|
||||||
|
let revived = GatewayService(defaults: UserDefaults(suiteName: suite)!)
|
||||||
|
#expect(revived.isEnabled)
|
||||||
|
|
||||||
|
fixture.service.setEnabled(false)
|
||||||
|
#expect(fixture.enabledChanges == [true, false])
|
||||||
|
fixture.defaults.removePersistentDomain(forName: suite)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("disabling the toggle drops queued work")
|
||||||
|
func disableClearsQueues() throws {
|
||||||
|
let fixture = Fixture()
|
||||||
|
fixture.relaysConnected = false
|
||||||
|
try deposit(try makeEvent(), into: fixture)
|
||||||
|
#expect(fixture.service.queuedUplinks.count == 1)
|
||||||
|
|
||||||
|
fixture.service.setEnabled(false)
|
||||||
|
#expect(fixture.service.queuedUplinks.isEmpty)
|
||||||
|
|
||||||
|
// Nothing to flush after re-enabling.
|
||||||
|
fixture.service.setEnabled(true)
|
||||||
|
fixture.relaysConnected = true
|
||||||
|
fixture.service.flushQueuedUplinks()
|
||||||
|
#expect(fixture.published.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,10 @@ public enum MessageType: UInt8 {
|
|||||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
||||||
|
|
||||||
|
// Gateway mode: signed Nostr event ferried between a mesh-only peer and
|
||||||
|
// an internet gateway peer.
|
||||||
|
case nostrCarrier = 0x28
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .announce: return "announce"
|
case .announce: return "announce"
|
||||||
@@ -38,6 +42,7 @@ public enum MessageType: UInt8 {
|
|||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
case .fileTransfer: return "fileTransfer"
|
case .fileTransfer: return "fileTransfer"
|
||||||
case .boardPost: return "boardPost"
|
case .boardPost: return "boardPost"
|
||||||
|
case .nostrCarrier: return "nostrCarrier"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user