feat(tor): add kmp-tor desktop daemon + preferences
Phase 2: Desktop Tor runtime implementation. DesktopTorManager: - Implements ITorManager using kmp-tor (Apache 2.0) - Reactive: auto-starts/stops Tor based on TorType flow - Supports INTERNAL (embedded kmp-tor) and EXTERNAL (user SOCKS port) - NEWNYM/Dormant/Active signal support via TorCmd - OS-specific data dirs (~/Library/Application Support, ~/.local/share, %APPDATA%) - Directory permissions set to 700 - stopSync() for shutdown hook integration DesktopTorPreferences: - Implements ITorSettingsPersistence using java.util.prefs.Preferences - Underscore-separated keys matching project convention - Default TorType.OFF (user must opt-in) - No explicit flush() (matches existing DesktopPreferences pattern) Gradle: - kmp-tor runtime 2.6.0 + resource-exec-tor 409.5.0 in version catalog - java.management module added to nativeDistributions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+216
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* 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.desktop.tor
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.ITorManager
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import io.matthewnelson.kmp.tor.resource.exec.tor.ResourceLoaderTorExec
|
||||
import io.matthewnelson.kmp.tor.runtime.Action.Companion.startDaemonAsync
|
||||
import io.matthewnelson.kmp.tor.runtime.Action.Companion.stopDaemonAsync
|
||||
import io.matthewnelson.kmp.tor.runtime.Action.Companion.stopDaemonSync
|
||||
import io.matthewnelson.kmp.tor.runtime.RuntimeEvent
|
||||
import io.matthewnelson.kmp.tor.runtime.TorRuntime
|
||||
import io.matthewnelson.kmp.tor.runtime.TorState
|
||||
import io.matthewnelson.kmp.tor.runtime.core.OnEvent
|
||||
import io.matthewnelson.kmp.tor.runtime.core.OnFailure
|
||||
import io.matthewnelson.kmp.tor.runtime.core.OnSuccess
|
||||
import io.matthewnelson.kmp.tor.runtime.core.TorEvent
|
||||
import io.matthewnelson.kmp.tor.runtime.core.config.TorOption
|
||||
import io.matthewnelson.kmp.tor.runtime.core.ctrl.TorCmd
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Desktop Tor daemon manager using kmp-tor.
|
||||
*
|
||||
* Reactive: automatically starts/stops Tor daemon based on [torTypeFlow] changes.
|
||||
* Supports INTERNAL (embedded kmp-tor) and EXTERNAL (user-provided SOCKS port) modes.
|
||||
*/
|
||||
class DesktopTorManager(
|
||||
private val torTypeFlow: StateFlow<TorType>,
|
||||
private val externalPortFlow: StateFlow<Int>,
|
||||
private val scope: CoroutineScope,
|
||||
) : ITorManager {
|
||||
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
|
||||
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
|
||||
|
||||
override val activePortOrNull: StateFlow<Int?> =
|
||||
_status
|
||||
.map { (it as? TorServiceStatus.Active)?.port }
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val runtime: TorRuntime by lazy {
|
||||
TorRuntime.Builder(desktopEnvironment()) {
|
||||
observerStatic(
|
||||
RuntimeEvent.LISTENERS,
|
||||
OnEvent.Executor.Immediate,
|
||||
OnEvent { listeners ->
|
||||
val port =
|
||||
listeners.socks
|
||||
.firstOrNull()
|
||||
?.port
|
||||
?.value
|
||||
if (port != null) {
|
||||
_status.value = TorServiceStatus.Active(port)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
observerStatic(
|
||||
RuntimeEvent.STATE,
|
||||
OnEvent.Executor.Immediate,
|
||||
OnEvent { state ->
|
||||
val daemon = state.daemon
|
||||
when {
|
||||
daemon is TorState.Daemon.Off -> {
|
||||
_status.value = TorServiceStatus.Off
|
||||
}
|
||||
|
||||
daemon is TorState.Daemon.Starting -> {
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
}
|
||||
|
||||
daemon is TorState.Daemon.Stopping -> {
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Suppress verbose logging to prevent relay hostname leaks
|
||||
required(TorEvent.ERR)
|
||||
required(TorEvent.WARN)
|
||||
|
||||
config { _ ->
|
||||
TorOption.__SocksPort.configure { auto() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Reactive: auto-derive Tor lifecycle from settings changes
|
||||
scope.launch {
|
||||
torTypeFlow.collect { mode ->
|
||||
when (mode) {
|
||||
TorType.INTERNAL -> {
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
try {
|
||||
runtime.startDaemonAsync()
|
||||
// LISTENERS event will set Active(port)
|
||||
} catch (e: Exception) {
|
||||
Log.e("DesktopTorManager", "Failed to start Tor", e)
|
||||
_status.value = TorServiceStatus.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
TorType.EXTERNAL -> {
|
||||
_status.value = TorServiceStatus.Active(externalPortFlow.value)
|
||||
}
|
||||
|
||||
TorType.OFF -> {
|
||||
try {
|
||||
runtime.stopDaemonAsync()
|
||||
} catch (_: Exception) {
|
||||
// May not be running
|
||||
}
|
||||
_status.value = TorServiceStatus.Off
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun dormant() {
|
||||
if (_status.value is TorServiceStatus.Active) {
|
||||
runtime.enqueue(TorCmd.Signal.Dormant, OnFailure.noOp(), OnSuccess.noOp())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun active() {
|
||||
if (_status.value is TorServiceStatus.Active) {
|
||||
runtime.enqueue(TorCmd.Signal.Active, OnFailure.noOp(), OnSuccess.noOp())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun newIdentity() {
|
||||
if (_status.value is TorServiceStatus.Active) {
|
||||
runtime.enqueue(TorCmd.Signal.NewNym, OnFailure.noOp(), OnSuccess.noOp())
|
||||
}
|
||||
}
|
||||
|
||||
/** Call from shutdown hook to stop Tor synchronously. */
|
||||
fun stopSync() {
|
||||
try {
|
||||
runtime.stopDaemonSync()
|
||||
} catch (_: Exception) {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun desktopEnvironment(): TorRuntime.Environment {
|
||||
val appDir = torDataDirectory()
|
||||
appDir.mkdirs()
|
||||
// Restrict permissions to owner only (700)
|
||||
appDir.setReadable(false, false)
|
||||
appDir.setReadable(true, true)
|
||||
appDir.setWritable(false, false)
|
||||
appDir.setWritable(true, true)
|
||||
appDir.setExecutable(false, false)
|
||||
appDir.setExecutable(true, true)
|
||||
|
||||
return TorRuntime.Environment.Builder(
|
||||
workDirectory = appDir.resolve("work"),
|
||||
cacheDirectory = appDir.resolve("cache"),
|
||||
loader = ResourceLoaderTorExec::getOrCreate,
|
||||
) {}
|
||||
}
|
||||
|
||||
/** OS-specific data directory for Tor. */
|
||||
internal fun torDataDirectory(): File {
|
||||
val osName = System.getProperty("os.name", "").lowercase()
|
||||
val home = System.getProperty("user.home") ?: "."
|
||||
return when {
|
||||
osName.contains("mac") -> {
|
||||
File(home, "Library/Application Support/Amethyst/tor")
|
||||
}
|
||||
|
||||
osName.contains("win") -> {
|
||||
File(System.getenv("APPDATA") ?: "$home/AppData/Roaming", "Amethyst/tor")
|
||||
}
|
||||
|
||||
else -> {
|
||||
val xdgData = System.getenv("XDG_DATA_HOME") ?: "$home/.local/share"
|
||||
File(xdgData, "Amethyst/tor")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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.desktop.tor
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.ITorSettingsPersistence
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* Desktop persistence for Tor settings using java.util.prefs.Preferences.
|
||||
*
|
||||
* Per-device settings (not synced across accounts).
|
||||
* Default TorType is OFF — user must opt-in.
|
||||
* No explicit flush() — matches existing DesktopPreferences pattern.
|
||||
*/
|
||||
object DesktopTorPreferences : ITorSettingsPersistence {
|
||||
private val prefs = Preferences.userNodeForPackage(DesktopTorPreferences::class.java)
|
||||
|
||||
override fun load(): TorSettings =
|
||||
TorSettings(
|
||||
torType = TorType.entries.firstOrNull { it.name == prefs.get("tor_type", TorType.OFF.name) } ?: TorType.OFF,
|
||||
externalSocksPort = prefs.getInt("tor_external_port", 9050),
|
||||
onionRelaysViaTor = prefs.getBoolean("tor_onion_relays", true),
|
||||
dmRelaysViaTor = prefs.getBoolean("tor_dm_relays", false),
|
||||
newRelaysViaTor = prefs.getBoolean("tor_new_relays", false),
|
||||
trustedRelaysViaTor = prefs.getBoolean("tor_trusted_relays", false),
|
||||
urlPreviewsViaTor = prefs.getBoolean("tor_url_previews", false),
|
||||
profilePicsViaTor = prefs.getBoolean("tor_profile_pics", false),
|
||||
imagesViaTor = prefs.getBoolean("tor_images", false),
|
||||
videosViaTor = prefs.getBoolean("tor_videos", false),
|
||||
moneyOperationsViaTor = prefs.getBoolean("tor_money", false),
|
||||
nip05VerificationsViaTor = prefs.getBoolean("tor_nip05", false),
|
||||
mediaUploadsViaTor = prefs.getBoolean("tor_media_uploads", false),
|
||||
)
|
||||
|
||||
override fun save(settings: TorSettings) {
|
||||
prefs.put("tor_type", settings.torType.name)
|
||||
prefs.putInt("tor_external_port", settings.externalSocksPort)
|
||||
prefs.putBoolean("tor_onion_relays", settings.onionRelaysViaTor)
|
||||
prefs.putBoolean("tor_dm_relays", settings.dmRelaysViaTor)
|
||||
prefs.putBoolean("tor_new_relays", settings.newRelaysViaTor)
|
||||
prefs.putBoolean("tor_trusted_relays", settings.trustedRelaysViaTor)
|
||||
prefs.putBoolean("tor_url_previews", settings.urlPreviewsViaTor)
|
||||
prefs.putBoolean("tor_profile_pics", settings.profilePicsViaTor)
|
||||
prefs.putBoolean("tor_images", settings.imagesViaTor)
|
||||
prefs.putBoolean("tor_videos", settings.videosViaTor)
|
||||
prefs.putBoolean("tor_money", settings.moneyOperationsViaTor)
|
||||
prefs.putBoolean("tor_nip05", settings.nip05VerificationsViaTor)
|
||||
prefs.putBoolean("tor_media_uploads", settings.mediaUploadsViaTor)
|
||||
// No explicit flush() — JVM auto-flushes on shutdown (matches DesktopPreferences pattern)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user