RelayHub.kt

6.1 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.sync.Mutex4import kotlinx.coroutines.sync.withLock5import javax.inject.Inject6import javax.inject.Singleton78/** Whether this device will relay for others. Settings-backed; see [RelaySettingsStore]. */9interface RelayPolicy {10    fun isRelayEnabled(): Boolean11}1213/**14 * One authenticated peer connection, as seen by the relay.15 *16 * A link is in exactly one mode at a time, which is what keeps the single17 * Noise session per connection safe to write to: while SYNCING the connection's18 * own coroutine is the only writer, and while RELAYING the partner's read loop19 * is. The mutex covers the overlap at the edges (RELAY_OPEN/RELAY_CLOSE).20 */21class RelayLink internal constructor(22    val peerKeyHex: String,23    private val sendPlaintext: suspend (ByteArray) -> Unit,24) {25    internal enum class Mode { IDLE, SYNCING, RELAYING }2627    private val writeLock = Mutex()28    internal var mode: Mode = Mode.IDLE29    internal var partner: RelayLink? = null3031    internal suspend fun send(message: RelayMessage) = sendRaw(message.encodeToBytes())3233    /** Sends already-encoded plaintext — the forwarding path never re-encodes. */34    internal suspend fun sendRaw(frame: ByteArray) = writeLock.withLock { sendPlaintext(frame) }35}3637/**38 * Phone-side relay dispatch (`PEACH_SYNC_RELAY_DESIGN.md` §2 and §4).39 *40 * Two laptops can never connect to each other — neither can listen — so a phone41 * paired with both forwards between them. Frames pass through verbatim: the42 * relay decrypts only the per-hop link encryption it is a party to, sees a43 * RELAY_FRAME wrapper, and re-encrypts the same bytes onto the other link. The44 * inner Noise KK session belongs to the two laptops and terminates there.45 *46 * The hub holds every live authenticated connection keyed by the peer's static47 * public key, which is the addressing layer the design calls for — nothing else48 * on the wire identifies a destination.49 *50 * Multiple laptop pairs can be relayed at once (design §4); what is *not*51 * allowed is one link serving two channels, since a RELAY_FRAME carries no52 * channel id and would be ambiguous.53 */54@Singleton55class RelayHub @Inject constructor(56    private val policy: RelayPolicy,57) {58    private val lock = Mutex()59    private val links = mutableMapOf<String, RelayLink>()6061    /** Registers an authenticated connection. Call [unregister] when it ends. */62    suspend fun register(peerKeyHex: String, sendPlaintext: suspend (ByteArray) -> Unit): RelayLink {63        val link = RelayLink(peerKeyHex.lowercase(), sendPlaintext)64        lock.withLock { links[link.peerKeyHex] = link }65        return link66    }6768    suspend fun unregister(link: RelayLink) {69        val partner = lock.withLock {70            if (links[link.peerKeyHex] === link) links.remove(link.peerKeyHex)71            detachLocked(link)72        }73        // A relayed peer whose partner vanished would otherwise sit waiting on a74        // channel that can never deliver another frame.75        partner?.let { runCatching { it.send(RelayMessage.Close) } }76    }7778    /** Marks a link busy with its own vault's sync, so it is not offered as a relay target. */79    suspend fun beginSync(link: RelayLink): Boolean = lock.withLock {80        if (link.mode != RelayLink.Mode.IDLE) return@withLock false81        link.mode = RelayLink.Mode.SYNCING82        true83    }8485    suspend fun endSync(link: RelayLink) = lock.withLock {86        if (link.mode == RelayLink.Mode.SYNCING) link.mode = RelayLink.Mode.IDLE87    }8889    /**90     * Answers a RELAY_REQUEST. On success both links enter RELAYING and every91     * later RELAY_FRAME on either is forwarded to the other.92     */93    suspend fun onRequest(from: RelayLink, targetKeyHex: String) {94        if (!policy.isRelayEnabled()) {95            from.send(RelayMessage.Disabled)96            return97        }98        val target = lock.withLock {99            val candidate = links[targetKeyHex.lowercase()]100            if (candidate == null ||101                candidate === from ||102                candidate.mode != RelayLink.Mode.IDLE ||103                from.mode != RelayLink.Mode.IDLE104            ) {105                null106            } else {107                from.partner = candidate108                candidate.partner = from109                from.mode = RelayLink.Mode.RELAYING110                candidate.mode = RelayLink.Mode.RELAYING111                candidate112            }113        }114        if (target == null) {115            from.send(RelayMessage.Unavailable)116            return117        }118        // The target learns first: an ACCEPT that beat the OPEN would invite the119        // requester to send frames into a peer that has not been told to expect120        // them.121        try {122            target.send(RelayMessage.Open(from.peerKeyHex))123        } catch (_: Exception) {124            lock.withLock { detachLocked(from) }125            from.send(RelayMessage.Unavailable)126            return127        }128        from.send(RelayMessage.Accept)129    }130131    /**132     * Forwards one frame to the link's partner, byte for byte. Returns false if133     * there is no channel, which the caller answers with RELAY_CLOSE.134     */135    suspend fun forward(from: RelayLink, frame: ByteArray): Boolean {136        val partner = lock.withLock { from.partner } ?: return false137        return try {138            partner.sendRaw(frame)139            true140        } catch (_: Exception) {141            closeChannel(from)142            false143        }144    }145146    /** Tears the channel down and tells the other end, if there is one. */147    suspend fun closeChannel(link: RelayLink) {148        val partner = lock.withLock { detachLocked(link) }149        partner?.let { runCatching { it.send(RelayMessage.Close) } }150    }151152    /** Must be called with [lock] held. Returns the detached partner, if any. */153    private fun detachLocked(link: RelayLink): RelayLink? {154        val partner = link.partner155        link.partner = null156        if (link.mode == RelayLink.Mode.RELAYING) link.mode = RelayLink.Mode.IDLE157        if (partner != null) {158            partner.partner = null159            if (partner.mode == RelayLink.Mode.RELAYING) partner.mode = RelayLink.Mode.IDLE160        }161        return partner162    }163}