mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 00:25:19 +00:00
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>
This commit is contained in:
@@ -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",
|
||||||
@@ -17872,6 +17884,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" : {
|
||||||
@@ -25713,6 +25737,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" : {
|
||||||
@@ -33535,6 +33583,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:
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .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,
|
||||||
|
|||||||
@@ -60,6 +60,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.
|
||||||
@@ -625,6 +633,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)
|
||||||
@@ -1259,16 +1293,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 {
|
||||||
@@ -2716,6 +2750,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 {
|
||||||
@@ -3210,6 +3319,9 @@ extension BLEService {
|
|||||||
case .courierEnvelope:
|
case .courierEnvelope:
|
||||||
handleCourierEnvelope(packet, from: peerID)
|
handleCourierEnvelope(packet, from: peerID)
|
||||||
|
|
||||||
|
case .nostrCarrier:
|
||||||
|
handleNostrCarrier(packet, from: peerID)
|
||||||
|
|
||||||
case .leave:
|
case .leave:
|
||||||
handleLeave(packet, from: senderID)
|
handleLeave(packet, from: senderID)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
//
|
||||||
|
// 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 (each enforced here and unit-tested):
|
||||||
|
/// 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.
|
||||||
|
/// 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.
|
||||||
|
/// 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 as
|
||||||
|
/// the window frees; a busy channel triggers frequent drains, a quiet
|
||||||
|
/// one never queues.
|
||||||
|
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)?
|
||||||
|
|
||||||
|
// 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)] = []
|
||||||
|
|
||||||
|
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 }
|
||||||
|
guard let event = validatedEvent(from: carrier) else {
|
||||||
|
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Loop rule 1: events learned from a fromGateway broadcast are
|
||||||
|
// mesh-carried; a gateway must never re-publish them.
|
||||||
|
guard !meshBroadcastEventIDs.contains(event.id) else { return }
|
||||||
|
// Loop rule 2: repeat deposits of an already handled event are absorbed.
|
||||||
|
guard !publishedEventIDs.contains(event.id),
|
||||||
|
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard allowUplinkDeposit(from: depositor) else {
|
||||||
|
SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if relaysConnected?() ?? false {
|
||||||
|
publish(event, geohash: carrier.geohash)
|
||||||
|
} else {
|
||||||
|
enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the carried message on our own timeline when we're viewing
|
||||||
|
// that geohash; the relay echo (if any) dedups in the pipeline.
|
||||||
|
if 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueueUplink(_ item: QueuedUplink) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if queuedUplinks.count >= Limits.maxQueuedUplinks {
|
||||||
|
queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1)
|
||||||
|
}
|
||||||
|
queuedUplinks.append(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
// Loop rule 1: never rebroadcast mesh-carried events back onto the
|
||||||
|
// mesh. Loop rule 2: rebroadcast each relay event at most once.
|
||||||
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
|
!rebroadcastEventIDs.contains(event.id) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Verify before spending BLE airtime; receivers verify again.
|
||||||
|
guard event.isValidSignature() else { return }
|
||||||
|
rebroadcastEventIDs.insert(event.id)
|
||||||
|
|
||||||
|
pendingDownlinks.append((event, geohash))
|
||||||
|
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
||||||
|
// Bandwidth guard: drop-oldest — fresher chat is worth more.
|
||||||
|
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()
|
||||||
|
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
|
||||||
|
let payload = carrier.encode() else { continue }
|
||||||
|
broadcastToMesh?(payload)
|
||||||
|
downlinkSendTimes.append(now())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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 }),
|
||||||
|
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds,
|
||||||
|
event.isValidSignature() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,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,68 @@ 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
|
||||||
|
)
|
||||||
|
if relays.isEmpty {
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
} else {
|
||||||
|
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(
|
||||||
|
|||||||
@@ -91,6 +91,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,453 @@
|
|||||||
|
//
|
||||||
|
// 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 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) }
|
||||||
|
if enabled {
|
||||||
|
service.setEnabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func advance(_ seconds: TimeInterval) {
|
||||||
|
clock.now = clock.now.addingTimeInterval(seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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("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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,11 @@ public enum MessageType: UInt8 {
|
|||||||
// Fragmentation (simplified)
|
// Fragmentation (simplified)
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
|
|
||||||
|
// Gateway mode: signed Nostr event ferried between a mesh-only peer and
|
||||||
|
// an internet gateway peer (0x23-0x27 reserved by in-flight features).
|
||||||
|
case nostrCarrier = 0x28
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .announce: return "announce"
|
case .announce: return "announce"
|
||||||
@@ -36,6 +40,7 @@ public enum MessageType: UInt8 {
|
|||||||
case .noiseEncrypted: return "noiseEncrypted"
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
case .fileTransfer: return "fileTransfer"
|
case .fileTransfer: return "fileTransfer"
|
||||||
|
case .nostrCarrier: return "nostrCarrier"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user