Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7
Add I2P support with unified privacy routing
This commit is contained in:
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.i2p
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isI2p
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
|
||||
|
||||
class I2pRelayEvaluation(
|
||||
val i2pSettings: I2pRelaySettings,
|
||||
val trustedRelayList: Set<NormalizedRelayUrl>,
|
||||
val dmRelayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
fun useI2p(relay: NormalizedRelayUrl): Boolean =
|
||||
if (i2pSettings.i2pType == I2pType.OFF) {
|
||||
false
|
||||
} else {
|
||||
if (relay.isLocalHost()) {
|
||||
false
|
||||
} else if (relay.isI2p()) {
|
||||
i2pSettings.i2pRelaysViaI2p
|
||||
} else if (relay in dmRelayList) {
|
||||
i2pSettings.dmRelaysViaI2p
|
||||
} else if (relay in trustedRelayList) {
|
||||
i2pSettings.trustedRelaysViaI2p
|
||||
} else {
|
||||
i2pSettings.newRelaysViaI2p
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.i2p
|
||||
|
||||
data class I2pRelaySettings(
|
||||
val i2pType: I2pType = I2pType.OFF,
|
||||
val i2pRelaysViaI2p: Boolean = true,
|
||||
val dmRelaysViaI2p: Boolean = false,
|
||||
val newRelaysViaI2p: Boolean = false,
|
||||
val trustedRelaysViaI2p: Boolean = false,
|
||||
)
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.i2p
|
||||
|
||||
sealed class I2pServiceStatus {
|
||||
data class Active(
|
||||
val port: Int,
|
||||
) : I2pServiceStatus()
|
||||
|
||||
data object Off : I2pServiceStatus()
|
||||
|
||||
data object Connecting : I2pServiceStatus()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : I2pServiceStatus()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.i2p
|
||||
|
||||
// Shape mirrors TorSettings. Both daemons can be enabled side-by-side (needed so
|
||||
// .onion and .i2p hidden services stay reachable independently), but only one
|
||||
// transport carries clearnet at a time — see PrivacySettings.preferredClearnetTransport.
|
||||
// The per-feature booleans here only take effect when I2P is the preferred clearnet
|
||||
// transport; otherwise the matching TorSettings flag wins.
|
||||
data class I2pSettings(
|
||||
val i2pType: I2pType = I2pType.OFF,
|
||||
val externalSocksPort: Int = 4447,
|
||||
val i2pRelaysViaI2p: Boolean = true,
|
||||
val dmRelaysViaI2p: Boolean = false,
|
||||
val newRelaysViaI2p: Boolean = false,
|
||||
val trustedRelaysViaI2p: Boolean = false,
|
||||
val urlPreviewsViaI2p: Boolean = false,
|
||||
val profilePicsViaI2p: Boolean = false,
|
||||
val imagesViaI2p: Boolean = false,
|
||||
val videosViaI2p: Boolean = false,
|
||||
val moneyOperationsViaI2p: Boolean = false,
|
||||
val nip05VerificationsViaI2p: Boolean = false,
|
||||
val mediaUploadsViaI2p: Boolean = false,
|
||||
)
|
||||
|
||||
// EXTERNAL-only: this app does not embed an I2P router. Bootstrap on Android is
|
||||
// minutes-long for a fresh netDb and the protocol provides no shortcut analogous
|
||||
// to Tor's hardcoded directory authorities; we'd ship a "permanently warming up"
|
||||
// experience. Users who want I2P run i2pd or Java I2P independently and point
|
||||
// Amethyst at its SOCKS port (default 4447). See I2pManager.
|
||||
enum class I2pType(
|
||||
val screenCode: Int,
|
||||
) {
|
||||
OFF(0),
|
||||
EXTERNAL(2),
|
||||
}
|
||||
|
||||
fun parseI2pType(code: Int?): I2pType =
|
||||
when (code) {
|
||||
I2pType.EXTERNAL.screenCode -> I2pType.EXTERNAL
|
||||
else -> I2pType.OFF
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.i2p
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
// Platform-agnostic interface for the I2P daemon lifecycle.
|
||||
// Android internal mode bundles net.i2p:router; external mode just opens a SOCKS
|
||||
// proxy to a user-run daemon. activePortOrNull surfaces whichever local SOCKS
|
||||
// port the HTTP manager should route I2P traffic through.
|
||||
interface II2pManager {
|
||||
val status: StateFlow<I2pServiceStatus>
|
||||
val activePortOrNull: StateFlow<Int?>
|
||||
|
||||
suspend fun dormant()
|
||||
|
||||
suspend fun active()
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.i2p
|
||||
|
||||
interface II2pSettingsPersistence {
|
||||
fun load(): I2pSettings
|
||||
|
||||
fun save(settings: I2pSettings)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacy
|
||||
|
||||
// What the network call is for. PrivacyRouter uses this to look up the matching
|
||||
// per-feature toggle on whichever transport is preferred for clearnet
|
||||
// (TorSettings.imagesViaTor vs I2pSettings.imagesViaI2p, etc.). Hidden-service
|
||||
// hostnames ignore the role and hard-pin to their matching network.
|
||||
enum class FeatureRole {
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
URL_PREVIEW,
|
||||
PROFILE_PIC,
|
||||
NIP05,
|
||||
MONEY,
|
||||
UPLOAD,
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacy
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.i2p.I2pRelayEvaluation
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isI2p
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
|
||||
|
||||
// Per-relay transport pick. Onion always wins for Tor, I2P always wins for I2P
|
||||
// (matching PrivacyRouter's hostname pin). For non-hidden hosts, prefer I2P if
|
||||
// both transports want the relay — arbitrary tiebreak documented here so the
|
||||
// behavior is testable.
|
||||
class PrivacyRelayEvaluation(
|
||||
private val tor: TorRelayEvaluation,
|
||||
private val i2p: I2pRelayEvaluation,
|
||||
) {
|
||||
fun transport(relay: NormalizedRelayUrl): PrivacyTransport {
|
||||
if (relay.isOnion()) return if (tor.useTor(relay)) PrivacyTransport.TOR else PrivacyTransport.DIRECT
|
||||
if (relay.isI2p()) return if (i2p.useI2p(relay)) PrivacyTransport.I2P else PrivacyTransport.DIRECT
|
||||
return when {
|
||||
i2p.useI2p(relay) -> PrivacyTransport.I2P
|
||||
tor.useTor(relay) -> PrivacyTransport.TOR
|
||||
else -> PrivacyTransport.DIRECT
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacy
|
||||
|
||||
// Outcome of PrivacyRouter.route. Either an actionable transport, or Blocked
|
||||
// when a hidden-service hostname cannot be reached (matching daemon is OFF)
|
||||
// and we fail closed rather than leak the request over the clearnet.
|
||||
sealed interface PrivacyRoute {
|
||||
data object Direct : PrivacyRoute
|
||||
|
||||
data object Tor : PrivacyRoute
|
||||
|
||||
data object I2p : PrivacyRoute
|
||||
|
||||
// Hidden-service request whose matching daemon is disabled. Callers must
|
||||
// surface this as a visible failure — don't fall back to DIRECT.
|
||||
data class Blocked(
|
||||
val reason: BlockReason,
|
||||
) : PrivacyRoute
|
||||
}
|
||||
|
||||
enum class BlockReason {
|
||||
ONION_REQUIRES_TOR,
|
||||
I2P_REQUIRES_I2P,
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacy
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.i2p.I2pSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.HiddenServiceKind
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
// Decides how a request is routed.
|
||||
//
|
||||
// Hidden-service hostnames hard-pin to their matching network — .onion needs
|
||||
// Tor, .i2p needs I2P — and fail closed (Blocked) when that daemon is OFF
|
||||
// rather than leak the request over the clearnet.
|
||||
//
|
||||
// For clearnet hostnames there is exactly one active privacy transport at a
|
||||
// time (PrivacySettings.preferredClearnetTransport). Per-feature toggles on
|
||||
// that transport's own settings (TorSettings.*ViaTor or I2pSettings.*ViaI2p)
|
||||
// decide whether this particular request is routed through it; otherwise the
|
||||
// request goes Direct.
|
||||
object PrivacyRouter {
|
||||
fun route(
|
||||
url: String,
|
||||
role: FeatureRole,
|
||||
settings: PrivacySettings,
|
||||
): PrivacyRoute =
|
||||
when (RelayUrlNormalizer.classifyHidden(url)) {
|
||||
HiddenServiceKind.LOCALHOST -> PrivacyRoute.Direct
|
||||
HiddenServiceKind.ONION ->
|
||||
if (settings.torAvailable) PrivacyRoute.Tor else PrivacyRoute.Blocked(BlockReason.ONION_REQUIRES_TOR)
|
||||
HiddenServiceKind.I2P ->
|
||||
if (settings.i2pAvailable) PrivacyRoute.I2p else PrivacyRoute.Blocked(BlockReason.I2P_REQUIRES_I2P)
|
||||
HiddenServiceKind.CLEARNET -> clearnet(role, settings)
|
||||
}
|
||||
|
||||
private fun clearnet(
|
||||
role: FeatureRole,
|
||||
settings: PrivacySettings,
|
||||
): PrivacyRoute =
|
||||
when (settings.preferredClearnetTransport) {
|
||||
PrivacyTransport.TOR ->
|
||||
if (settings.torAvailable && torEnables(role, settings.tor)) PrivacyRoute.Tor else PrivacyRoute.Direct
|
||||
PrivacyTransport.I2P ->
|
||||
if (settings.i2pAvailable && i2pEnables(role, settings.i2p)) PrivacyRoute.I2p else PrivacyRoute.Direct
|
||||
PrivacyTransport.DIRECT -> PrivacyRoute.Direct
|
||||
}
|
||||
|
||||
private fun torEnables(
|
||||
role: FeatureRole,
|
||||
tor: TorSettings,
|
||||
): Boolean =
|
||||
when (role) {
|
||||
FeatureRole.IMAGE -> tor.imagesViaTor
|
||||
FeatureRole.VIDEO -> tor.videosViaTor
|
||||
FeatureRole.URL_PREVIEW -> tor.urlPreviewsViaTor
|
||||
FeatureRole.PROFILE_PIC -> tor.profilePicsViaTor
|
||||
FeatureRole.NIP05 -> tor.nip05VerificationsViaTor
|
||||
FeatureRole.MONEY -> tor.moneyOperationsViaTor
|
||||
FeatureRole.UPLOAD -> tor.mediaUploadsViaTor
|
||||
}
|
||||
|
||||
private fun i2pEnables(
|
||||
role: FeatureRole,
|
||||
i2p: I2pSettings,
|
||||
): Boolean =
|
||||
when (role) {
|
||||
FeatureRole.IMAGE -> i2p.imagesViaI2p
|
||||
FeatureRole.VIDEO -> i2p.videosViaI2p
|
||||
FeatureRole.URL_PREVIEW -> i2p.urlPreviewsViaI2p
|
||||
FeatureRole.PROFILE_PIC -> i2p.profilePicsViaI2p
|
||||
FeatureRole.NIP05 -> i2p.nip05VerificationsViaI2p
|
||||
FeatureRole.MONEY -> i2p.moneyOperationsViaI2p
|
||||
FeatureRole.UPLOAD -> i2p.mediaUploadsViaI2p
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacy
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.i2p.I2pSettings
|
||||
import com.vitorpamplona.amethyst.commons.i2p.I2pType
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
|
||||
// Aggregate of all privacy configuration. Both Tor and I2P daemons can run
|
||||
// side-by-side so hidden services on each network stay reachable, but only one
|
||||
// transport carries clearnet at a time: preferredClearnetTransport picks it.
|
||||
// The per-feature booleans on each TorSettings / I2pSettings only take effect
|
||||
// for the preferred transport.
|
||||
data class PrivacySettings(
|
||||
val tor: TorSettings = TorSettings(),
|
||||
val i2p: I2pSettings = I2pSettings(),
|
||||
val preferredClearnetTransport: PrivacyTransport = PrivacyTransport.DIRECT,
|
||||
) {
|
||||
val torAvailable: Boolean get() = tor.torType != TorType.OFF
|
||||
val i2pAvailable: Boolean get() = i2p.i2pType != I2pType.OFF
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacy
|
||||
|
||||
enum class PrivacyTransport {
|
||||
DIRECT,
|
||||
TOR,
|
||||
I2P,
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacy
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.i2p.I2pSettings
|
||||
import com.vitorpamplona.amethyst.commons.i2p.I2pType
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class PrivacyRouterTest {
|
||||
private val clearnet = "wss://relay.damus.io/"
|
||||
private val onion = "wss://abc123.onion/"
|
||||
private val i2p = "wss://abc.i2p/"
|
||||
private val localhost = "ws://127.0.0.1:8080/"
|
||||
|
||||
private fun settings(
|
||||
torOn: Boolean = false,
|
||||
i2pOn: Boolean = false,
|
||||
preferred: PrivacyTransport = PrivacyTransport.DIRECT,
|
||||
tor: TorSettings = TorSettings(torType = if (torOn) TorType.INTERNAL else TorType.OFF),
|
||||
i2p: I2pSettings = I2pSettings(i2pType = if (i2pOn) I2pType.EXTERNAL else I2pType.OFF),
|
||||
) = PrivacySettings(
|
||||
tor = tor,
|
||||
i2p = i2p,
|
||||
preferredClearnetTransport = preferred,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun localhost_alwaysDirect() {
|
||||
assertEquals(
|
||||
PrivacyRoute.Direct,
|
||||
PrivacyRouter.route(localhost, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.TOR)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onion_pinsToTor_whenAvailable() {
|
||||
assertEquals(PrivacyRoute.Tor, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onion_blocksWhenTorOff() {
|
||||
assertEquals(
|
||||
PrivacyRoute.Blocked(BlockReason.ONION_REQUIRES_TOR),
|
||||
PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = false, i2pOn = true, preferred = PrivacyTransport.I2P)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun i2p_pinsToI2p_whenAvailable() {
|
||||
assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(i2pOn = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun i2p_blocksWhenI2pOff() {
|
||||
assertEquals(
|
||||
PrivacyRoute.Blocked(BlockReason.I2P_REQUIRES_I2P),
|
||||
PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(torOn = true, i2pOn = false, preferred = PrivacyTransport.TOR)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun b32_i2p_address_pinsToI2p() {
|
||||
val b32 = "wss://abcdefghijklmnopqrstuvwxyz234567.b32.i2p/"
|
||||
assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(b32, FeatureRole.IMAGE, settings(i2pOn = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearnet_directWhenNoPreferred() {
|
||||
assertEquals(
|
||||
PrivacyRoute.Direct,
|
||||
PrivacyRouter.route(
|
||||
clearnet,
|
||||
FeatureRole.IMAGE,
|
||||
settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.DIRECT, tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearnet_torRoutes_whenPreferredAndFeatureOn() {
|
||||
val s =
|
||||
settings(
|
||||
torOn = true,
|
||||
preferred = PrivacyTransport.TOR,
|
||||
tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true),
|
||||
)
|
||||
assertEquals(PrivacyRoute.Tor, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearnet_torDirect_whenPreferredButFeatureOff() {
|
||||
val s =
|
||||
settings(
|
||||
torOn = true,
|
||||
preferred = PrivacyTransport.TOR,
|
||||
tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = false),
|
||||
)
|
||||
assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearnet_i2pRoutes_whenPreferredAndFeatureOn() {
|
||||
val s =
|
||||
settings(
|
||||
i2pOn = true,
|
||||
preferred = PrivacyTransport.I2P,
|
||||
i2p = I2pSettings(i2pType = I2pType.EXTERNAL, videosViaI2p = true),
|
||||
)
|
||||
assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearnet_i2pDirect_whenPreferredButFeatureOff() {
|
||||
val s =
|
||||
settings(
|
||||
i2pOn = true,
|
||||
preferred = PrivacyTransport.I2P,
|
||||
i2p = I2pSettings(i2pType = I2pType.EXTERNAL, videosViaI2p = false),
|
||||
)
|
||||
assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearnet_preferredButDaemonOff_isDirect() {
|
||||
// User picked TOR but the daemon is off — go Direct instead of erroring.
|
||||
val s =
|
||||
settings(
|
||||
torOn = false,
|
||||
preferred = PrivacyTransport.TOR,
|
||||
tor = TorSettings(torType = TorType.OFF, imagesViaTor = true),
|
||||
)
|
||||
assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearnet_i2pTogglesIgnored_whenTorIsPreferred() {
|
||||
// I2P is the only daemon running but TOR is the preferred clearnet transport,
|
||||
// so I2P toggles must not take effect — fall through to Direct.
|
||||
val s =
|
||||
settings(
|
||||
torOn = false,
|
||||
i2pOn = true,
|
||||
preferred = PrivacyTransport.TOR,
|
||||
i2p = I2pSettings(i2pType = I2pType.EXTERNAL, imagesViaI2p = true),
|
||||
)
|
||||
assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothDaemonsRunning_clearnetUsesOnlyPreferred() {
|
||||
// Both daemons up, both have images toggled on, preferred is I2P → I2P wins.
|
||||
val s =
|
||||
settings(
|
||||
torOn = true,
|
||||
i2pOn = true,
|
||||
preferred = PrivacyTransport.I2P,
|
||||
tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true),
|
||||
i2p = I2pSettings(i2pType = I2pType.EXTERNAL, imagesViaI2p = true),
|
||||
)
|
||||
assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenServiceIgnoresClearnetPreference() {
|
||||
// .onion still pins to Tor even when I2P is the preferred clearnet transport.
|
||||
val s = settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.I2P)
|
||||
assertEquals(PrivacyRoute.Tor, PrivacyRouter.route(onion, FeatureRole.IMAGE, s))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user