From 0a288b8ad135ff863e85ad449e5b998010480841 Mon Sep 17 00:00:00 2001 From: zaneschepke Date: Sun, 15 Feb 2026 14:44:03 -0500 Subject: [PATCH] feat: add kill switch and tunnel restore on boot --- .../commands/killswitch/KillSwitchCommand.kt | 4 +- .../cli/commands/status/StatusCommand.kt | 4 +- .../commands/tunnel/TunnelDeleteCommand.kt | 2 +- .../cli/commands/tunnel/TunnelDownCommand.kt | 4 +- .../cli/commands/tunnel/TunnelUpCommand.kt | 4 +- .../cli/strategy/CliExecutionStrategy.kt | 6 +- .../1.json | 13 +- .../client/data/dao/GeneralSettingsDao.kt | 3 + .../client/data/entity/GeneralSettings.kt | 2 + .../client/data/mapper/SettingsMapper.kt | 9 +- .../data/repository/RoomSettingsRepository.kt | 4 + ...CommandService.kt => UdsBackendService.kt} | 35 ++- ...onHealthService.kt => UdsDaemonService.kt} | 31 +- ...lCommandService.kt => UdsTunnelService.kt} | 6 +- .../client/di/serviceModule.kt | 22 +- .../client/domain/error/ClientException.kt | 2 + .../client/domain/model/GeneralSettings.kt | 1 + .../repository/GeneralSettingRepository.kt | 2 + ...endCommandService.kt => BackendService.kt} | 4 +- ...aemonHealthService.kt => DaemonService.kt} | 6 +- ...nnelCommandService.kt => TunnelService.kt} | 2 +- .../files/aboutlibraries.json | 75 +++++ .../ui/common/button/SwitchWithDivider.kt | 2 +- .../ui/screens/settings/SettingsScreen.kt | 31 +- .../ui/screens/support/SupportScreen.kt | 4 +- .../ui/screens/support/donate/DonateScreen.kt | 8 +- .../donate/components/DonationHeroSection.kt | 2 +- .../ui/screens/tunnels/TunnelsScreen.kt | 27 +- .../screens/tunnels/components/Extensions.kt | 11 + .../screens/tunnels/components/TunnelList.kt | 103 +++++-- .../desktop/ui/state/SettingsUiState.kt | 1 + .../desktop/util/UiExtensions.kt | 1 + .../desktop/viewmodel/AppViewModel.kt | 14 +- .../desktop/viewmodel/SettingsViewModel.kt | 32 +- .../desktop/viewmodel/TunnelViewModel.kt | 67 ++-- .../desktop/viewmodel/TunnelsViewModel.kt | 42 +-- conveyor.conf | 15 +- .../core/helper/PermissionsHelper.kt | 54 +++- .../wireguardautotunnel/core/ipc/Routes.kt | 5 + .../{KillSwitchRequest.kt => FlagRequest.kt} | 2 +- daemon/build.gradle.kts | 5 +- .../daemon/TunnelDaemon.kt | 27 +- .../daemon/data/DaemonCacheRepository.kt | 30 +- .../data/KStoreDaemonCacheRepository.kt | 109 ------- .../data/SettingsDaemonCacheRepository.kt | 107 +++++++ .../daemon/data/model/DaemonCacheData.kt | 9 - .../daemon/data/model/KillSwitchSettings.kt | 5 - .../daemon/di/daemonModule.kt | 4 +- .../daemon/plugin/UDSPlugins.kt | 4 +- .../daemon/routes/backendRoutes.kt | 34 +- .../daemon/routes/daemonRoutes.kt | 23 +- .../daemon/routes/tunnelRoutes.kt | 8 +- gradle/libs.versions.toml | 6 +- .../tunnel/AmneziaBackend.kt | 100 ++++-- .../wireguardautotunnel/tunnel/Backend.kt | 13 +- .../tunnel/native/AwgTunnel.kt | 4 + .../tools/libwg-go/killswitch/killswitch.go | 69 +++++ tunnel/tools/libwg-go/vpn/dns/dns_linux.go | 2 +- .../tools/libwg-go/vpn/firewall/firewall.go | 5 + .../vpn/firewall/osfirewall/firewall_linux.go | 36 ++- .../vpn/firewall/osfirewall/firewall_macos.go | 290 ------------------ .../firewall/osfirewall/firewall_windows.go | 22 +- 62 files changed, 885 insertions(+), 689 deletions(-) rename client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/{UdsBackendCommandService.kt => UdsBackendService.kt} (85%) rename client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/{UdsDaemonHealthService.kt => UdsDaemonService.kt} (58%) rename client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/{UdsTunnelCommandService.kt => UdsTunnelService.kt} (96%) rename client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/{BackendCommandService.kt => BackendService.kt} (82%) rename client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/{DaemonHealthService.kt => DaemonService.kt} (50%) rename client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/{TunnelCommandService.kt => TunnelService.kt} (83%) rename core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/request/{KillSwitchRequest.kt => FlagRequest.kt} (53%) delete mode 100644 daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/KStoreDaemonCacheRepository.kt create mode 100644 daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/SettingsDaemonCacheRepository.kt delete mode 100644 daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/model/DaemonCacheData.kt delete mode 100644 daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/model/KillSwitchSettings.kt delete mode 100644 tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_macos.go diff --git a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/killswitch/KillSwitchCommand.kt b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/killswitch/KillSwitchCommand.kt index 451a06a..2380295 100644 --- a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/killswitch/KillSwitchCommand.kt +++ b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/killswitch/KillSwitchCommand.kt @@ -2,7 +2,7 @@ package com.zaneschepke.wireguardautotunnel.cli.commands.killswitch import com.zaneschepke.wireguardautotunnel.cli.util.CliUtils import com.zaneschepke.wireguardautotunnel.cli.util.CliUtils.renderAnsi -import com.zaneschepke.wireguardautotunnel.client.service.BackendCommandService +import com.zaneschepke.wireguardautotunnel.client.service.BackendService import java.util.concurrent.Callable import kotlinx.coroutines.runBlocking import org.koin.java.KoinJavaComponent.inject @@ -14,7 +14,7 @@ import picocli.CommandLine.* mixinStandardHelpOptions = true, ) class KillSwitchCommand : Callable { - private val backendService: BackendCommandService by inject(BackendCommandService::class.java) + private val backendService: BackendService by inject(BackendService::class.java) @Parameters(index = "0", description = ["The desired state: 'on' or 'off' (or true/false)."]) lateinit var state: String diff --git a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/status/StatusCommand.kt b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/status/StatusCommand.kt index e482113..92ceba8 100644 --- a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/status/StatusCommand.kt +++ b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/status/StatusCommand.kt @@ -2,7 +2,7 @@ package com.zaneschepke.wireguardautotunnel.cli.commands.status import com.zaneschepke.wireguardautotunnel.cli.util.CliUtils import com.zaneschepke.wireguardautotunnel.cli.util.CliUtils.renderAnsi -import com.zaneschepke.wireguardautotunnel.client.service.BackendCommandService +import com.zaneschepke.wireguardautotunnel.client.service.BackendService import com.zaneschepke.wireguardautotunnel.core.ipc.dto.BackendMode import com.zaneschepke.wireguardautotunnel.core.ipc.dto.BackendStatus import java.time.format.DateTimeFormatter @@ -17,7 +17,7 @@ import picocli.CommandLine.* mixinStandardHelpOptions = true, ) class StatusCommand : Callable { - private val backendService: BackendCommandService by inject(BackendCommandService::class.java) + private val backendService: BackendService by inject(BackendService::class.java) override fun call(): Int = runBlocking { fetchSnapshot() } diff --git a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelDeleteCommand.kt b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelDeleteCommand.kt index 25fb85b..1cf29e9 100644 --- a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelDeleteCommand.kt +++ b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelDeleteCommand.kt @@ -39,7 +39,7 @@ class TunnelDeleteCommand : Callable { return@runBlocking 0 } - CliUtils.withSpinner("Deleting '$tunnelName'...") { tunnelRepository.delete(tunnel) } + CliUtils.withSpinner("Deleting '$tunnelName'...") { tunnelRepository.delete(tunnel.id) } CliUtils.printSuccess("Tunnel '$tunnelName' deleted successfully.") 0 diff --git a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelDownCommand.kt b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelDownCommand.kt index c79a5b3..91a105e 100644 --- a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelDownCommand.kt +++ b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelDownCommand.kt @@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.cli.commands.tunnel import com.zaneschepke.wireguardautotunnel.cli.util.CliUtils import com.zaneschepke.wireguardautotunnel.client.domain.error.ClientException import com.zaneschepke.wireguardautotunnel.client.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.client.service.TunnelCommandService +import com.zaneschepke.wireguardautotunnel.client.service.TunnelService import java.util.concurrent.Callable import kotlinx.coroutines.runBlocking import org.koin.java.KoinJavaComponent.inject @@ -12,7 +12,7 @@ import picocli.CommandLine.Parameters @Command(name = "down", description = ["Bring a tunnel down."]) class TunnelDownCommand : Callable { - private val tunnelService: TunnelCommandService by inject(TunnelCommandService::class.java) + private val tunnelService: TunnelService by inject(TunnelService::class.java) private val tunnelRepository: TunnelRepository by inject(TunnelRepository::class.java) @Parameters( diff --git a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelUpCommand.kt b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelUpCommand.kt index 2842844..e43c291 100644 --- a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelUpCommand.kt +++ b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/commands/tunnel/TunnelUpCommand.kt @@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.cli.commands.tunnel import com.zaneschepke.wireguardautotunnel.cli.util.CliUtils import com.zaneschepke.wireguardautotunnel.client.domain.error.ClientException import com.zaneschepke.wireguardautotunnel.client.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.client.service.TunnelCommandService +import com.zaneschepke.wireguardautotunnel.client.service.TunnelService import java.util.concurrent.Callable import kotlinx.coroutines.runBlocking import org.koin.java.KoinJavaComponent.inject @@ -12,7 +12,7 @@ import picocli.CommandLine.Parameters @Command(name = "up", description = ["Bring a tunnel up."]) class TunnelUpCommand : Callable { - private val tunnelService: TunnelCommandService by inject(TunnelCommandService::class.java) + private val tunnelService: TunnelService by inject(TunnelService::class.java) private val tunnelRepository: TunnelRepository by inject(TunnelRepository::class.java) @Parameters( diff --git a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/strategy/CliExecutionStrategy.kt b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/strategy/CliExecutionStrategy.kt index 0a08f1f..a758696 100644 --- a/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/strategy/CliExecutionStrategy.kt +++ b/cli/src/main/java/com/zaneschepke/wireguardautotunnel/cli/strategy/CliExecutionStrategy.kt @@ -1,14 +1,14 @@ package com.zaneschepke.wireguardautotunnel.cli.strategy import com.zaneschepke.wireguardautotunnel.cli.util.CliUtils -import com.zaneschepke.wireguardautotunnel.client.service.DaemonHealthService +import com.zaneschepke.wireguardautotunnel.client.service.DaemonService import kotlinx.coroutines.runBlocking import org.koin.java.KoinJavaComponent.inject import picocli.CommandLine.* class CliExecutionStrategy(private val defaultStrategy: IExecutionStrategy) : IExecutionStrategy { - private val daemonHealthService: DaemonHealthService by inject(DaemonHealthService::class.java) + private val daemonService: DaemonService by inject(DaemonService::class.java) override fun execute(parseResult: ParseResult): Int = runBlocking { // skip help and version @@ -18,7 +18,7 @@ class CliExecutionStrategy(private val defaultStrategy: IExecutionStrategy) : IE val isAlive = try { - daemonHealthService.alive() + daemonService.alive() } catch (e: Exception) { false } diff --git a/client/schemas/com.zaneschepke.wireguardautotunnel.client.data.AppDatabase/1.json b/client/schemas/com.zaneschepke.wireguardautotunnel.client.data.AppDatabase/1.json index fcddab9..1a94297 100644 --- a/client/schemas/com.zaneschepke.wireguardautotunnel.client.data.AppDatabase/1.json +++ b/client/schemas/com.zaneschepke.wireguardautotunnel.client.data.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "57c05ed7dd6615f7812c499f8486ae43", + "identityHash": "b662918cb8aa7ed02d3d3050c1fba46e", "entities": [ { "tableName": "tunnel_config", @@ -106,7 +106,7 @@ }, { "tableName": "general_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `theme` TEXT NOT NULL DEFAULT 'DARK', `locale` TEXT, `already_donated` INTEGER NOT NULL DEFAULT 0)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `theme` TEXT NOT NULL DEFAULT 'DARK', `locale` TEXT, `already_donated` INTEGER NOT NULL DEFAULT 0, `restore_tunnel_on_boot` INTEGER NOT NULL DEFAULT 0)", "fields": [ { "fieldPath": "id", @@ -132,6 +132,13 @@ "affinity": "INTEGER", "notNull": true, "defaultValue": "0" + }, + { + "fieldPath": "restoreTunnelOnBoot", + "columnName": "restore_tunnel_on_boot", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" } ], "primaryKey": { @@ -144,7 +151,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '57c05ed7dd6615f7812c499f8486ae43')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b662918cb8aa7ed02d3d3050c1fba46e')" ] } } \ No newline at end of file diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/dao/GeneralSettingsDao.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/dao/GeneralSettingsDao.kt index 8f9f309..d43ed46 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/dao/GeneralSettingsDao.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/dao/GeneralSettingsDao.kt @@ -20,6 +20,9 @@ interface GeneralSettingsDao { @Query("UPDATE general_settings SET locale = :locale WHERE id = 1") suspend fun updateLocale(locale: String) + @Query("UPDATE general_settings SET restore_tunnel_on_boot = :enabled WHERE id = 1") + suspend fun updateRestoreTunnelOnBoot(enabled: Boolean) + @Query("UPDATE general_settings SET already_donated = :donated WHERE id = 1") suspend fun updateAlreadyDonated(donated: Boolean) } diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/entity/GeneralSettings.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/entity/GeneralSettings.kt index ae16ac2..26bbc68 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/entity/GeneralSettings.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/entity/GeneralSettings.kt @@ -10,4 +10,6 @@ data class GeneralSettings( @ColumnInfo(name = "theme", defaultValue = "DARK") val theme: String = "DARK", @ColumnInfo(name = "locale") val locale: String? = null, @ColumnInfo(name = "already_donated", defaultValue = "0") val alreadyDonated: Boolean = false, + @ColumnInfo(name = "restore_tunnel_on_boot", defaultValue = "0") + val restoreTunnelOnBoot: Boolean = false, ) diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/mapper/SettingsMapper.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/mapper/SettingsMapper.kt index 507bcdb..55ec836 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/mapper/SettingsMapper.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/mapper/SettingsMapper.kt @@ -10,7 +10,14 @@ fun Entity.toDomain(): Domain = theme = Theme.valueOf(theme.uppercase()), locale = locale, alreadyDonated = alreadyDonated, + restoreTunnelOnBoot = restoreTunnelOnBoot, ) fun Domain.toEntity(): Entity = - Entity(id = id, theme = theme.name, locale = locale, alreadyDonated = alreadyDonated) + Entity( + id = id, + theme = theme.name, + locale = locale, + alreadyDonated = alreadyDonated, + restoreTunnelOnBoot = restoreTunnelOnBoot, + ) diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/repository/RoomSettingsRepository.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/repository/RoomSettingsRepository.kt index 1213783..2eac8e6 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/repository/RoomSettingsRepository.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/repository/RoomSettingsRepository.kt @@ -33,4 +33,8 @@ class RoomSettingsRepository(private val settingsDao: GeneralSettingsDao) : override suspend fun updateAlreadyDonated(donated: Boolean) { settingsDao.updateAlreadyDonated(donated) } + + override suspend fun updateRestoreTunnelOnBoot(enabled: Boolean) { + settingsDao.updateRestoreTunnelOnBoot(enabled) + } } diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsBackendCommandService.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsBackendService.kt similarity index 85% rename from client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsBackendCommandService.kt rename to client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsBackendService.kt index c1fdb82..ab95017 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsBackendCommandService.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsBackendService.kt @@ -1,14 +1,14 @@ package com.zaneschepke.wireguardautotunnel.client.data.service import co.touchlab.kermit.Logger -import com.zaneschepke.wireguardautotunnel.client.data.service.UdsDaemonHealthService.Companion.DAEMON_WS_RECONNECT_DELAY_MILLIS +import com.zaneschepke.wireguardautotunnel.client.data.service.UdsDaemonService.Companion.DAEMON_WS_RECONNECT_DELAY_MILLIS import com.zaneschepke.wireguardautotunnel.client.domain.repository.LockdownSettingsRepository -import com.zaneschepke.wireguardautotunnel.client.service.BackendCommandService +import com.zaneschepke.wireguardautotunnel.client.service.BackendService import com.zaneschepke.wireguardautotunnel.core.ipc.Routes import com.zaneschepke.wireguardautotunnel.core.ipc.dto.BackendMode import com.zaneschepke.wireguardautotunnel.core.ipc.dto.BackendStatus import com.zaneschepke.wireguardautotunnel.core.ipc.dto.TunnelState -import com.zaneschepke.wireguardautotunnel.core.ipc.dto.request.KillSwitchRequest +import com.zaneschepke.wireguardautotunnel.core.ipc.dto.request.FlagRequest import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig import io.ktor.client.* import io.ktor.client.call.* @@ -16,40 +16,45 @@ import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.utils.io.* -import io.ktor.websocket.Frame -import io.ktor.websocket.readText +import io.ktor.websocket.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.* import kotlinx.coroutines.isActive import kotlinx.serialization.json.Json -class UdsBackendCommandService( +class UdsBackendService( private val client: HttpClient, private val json: Json, private val lockdownSettingsRepository: LockdownSettingsRepository, -) : BackendCommandService { +) : BackendService { override suspend fun setMode(mode: BackendMode): Result = safeDaemonCall { - client.post(Routes.BACKEND_MODE) { setBody(mode) } + client.put(Routes.BACKEND_MODE) { setBody(mode) } } override suspend fun setKillSwitch(enabled: Boolean): Result { lockdownSettingsRepository.updateEnabled(enabled) return safeDaemonCall { - val request = KillSwitchRequest(enabled) - client.post(Routes.BACKEND_KILL_SWITCH) { setBody(request) } + val request = FlagRequest(enabled) + client.put(Routes.BACKEND_KILL_SWITCH) { setBody(request) } Unit } .onFailure { lockdownSettingsRepository.updateEnabled(!enabled) } } + override suspend fun setKillSwitchLanBypass(enabled: Boolean): Result { + lockdownSettingsRepository.updateBypassLan(enabled) + return safeDaemonCall { + val request = FlagRequest(enabled) + client.put(Routes.BACKEND_KILL_SWITCH_BYPASS) { setBody(request) } + Unit + } + .onFailure { lockdownSettingsRepository.updateBypassLan(!enabled) } + } + override suspend fun getStatus(): Result = runCatching { val response = client.get(Routes.BACKEND_STATUS) response.body() diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsDaemonHealthService.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsDaemonService.kt similarity index 58% rename from client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsDaemonHealthService.kt rename to client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsDaemonService.kt index b34b51f..853ec06 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsDaemonHealthService.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsDaemonService.kt @@ -1,8 +1,11 @@ package com.zaneschepke.wireguardautotunnel.client.data.service import co.touchlab.kermit.Logger -import com.zaneschepke.wireguardautotunnel.client.service.DaemonHealthService +import com.zaneschepke.wireguardautotunnel.client.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.client.domain.repository.LockdownSettingsRepository +import com.zaneschepke.wireguardautotunnel.client.service.DaemonService import com.zaneschepke.wireguardautotunnel.core.ipc.Routes +import com.zaneschepke.wireguardautotunnel.core.ipc.dto.request.FlagRequest import io.ktor.client.* import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* @@ -16,7 +19,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.isActive -class UdsDaemonHealthService(private val client: HttpClient) : DaemonHealthService { +class UdsDaemonService( + private val client: HttpClient, + private val lockdownSettingsRepository: LockdownSettingsRepository, + private val generalSettingsRepository: GeneralSettingRepository, +) : DaemonService { override suspend fun alive(): Boolean { return try { @@ -27,6 +34,26 @@ class UdsDaemonHealthService(private val client: HttpClient) : DaemonHealthServi } } + override suspend fun setRestoreKillSwitch(enabled: Boolean): Result { + lockdownSettingsRepository.updateRestoreOnBoot(enabled) + return safeDaemonCall { + val request = FlagRequest(enabled) + client.put(Routes.DAEMON_RESTORE_KILL_SWITCH) { setBody(request) } + Unit + } + .onFailure { lockdownSettingsRepository.updateRestoreOnBoot(!enabled) } + } + + override suspend fun setRestoreTunnel(enabled: Boolean): Result { + generalSettingsRepository.updateRestoreTunnelOnBoot(enabled) + return safeDaemonCall { + val request = FlagRequest(enabled) + client.put(Routes.DAEMON_RESTORE_TUNNEL) { setBody(request) } + Unit + } + .onFailure { generalSettingsRepository.updateRestoreTunnelOnBoot(!enabled) } + } + override val alive: Flow = callbackFlow { while (isActive) { diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsTunnelCommandService.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsTunnelService.kt similarity index 96% rename from client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsTunnelCommandService.kt rename to client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsTunnelService.kt index 7ddc643..91fe824 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsTunnelCommandService.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsTunnelService.kt @@ -2,7 +2,7 @@ package com.zaneschepke.wireguardautotunnel.client.data.service import com.zaneschepke.wireguardautotunnel.client.domain.error.ClientException import com.zaneschepke.wireguardautotunnel.client.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.client.service.TunnelCommandService +import com.zaneschepke.wireguardautotunnel.client.service.TunnelService import com.zaneschepke.wireguardautotunnel.core.ipc.Routes import com.zaneschepke.wireguardautotunnel.core.ipc.dto.request.StartTunnelRequest import io.ktor.client.* @@ -11,10 +11,10 @@ import io.ktor.http.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -class UdsTunnelCommandService( +class UdsTunnelService( private val client: HttpClient, private val tunnelRepository: TunnelRepository, -) : TunnelCommandService { +) : TunnelService { val mutex = Mutex() diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/di/serviceModule.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/di/serviceModule.kt index 48dde18..ea7f26c 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/di/serviceModule.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/di/serviceModule.kt @@ -2,14 +2,14 @@ package com.zaneschepke.wireguardautotunnel.client.di import co.touchlab.kermit.Logger import com.zaneschepke.wireguardautotunnel.client.data.service.DefaultTunnelImportService -import com.zaneschepke.wireguardautotunnel.client.data.service.UdsBackendCommandService -import com.zaneschepke.wireguardautotunnel.client.data.service.UdsDaemonHealthService -import com.zaneschepke.wireguardautotunnel.client.data.service.UdsTunnelCommandService +import com.zaneschepke.wireguardautotunnel.client.data.service.UdsBackendService +import com.zaneschepke.wireguardautotunnel.client.data.service.UdsDaemonService +import com.zaneschepke.wireguardautotunnel.client.data.service.UdsTunnelService import com.zaneschepke.wireguardautotunnel.client.domain.error.ClientException -import com.zaneschepke.wireguardautotunnel.client.service.BackendCommandService -import com.zaneschepke.wireguardautotunnel.client.service.DaemonHealthService -import com.zaneschepke.wireguardautotunnel.client.service.TunnelCommandService +import com.zaneschepke.wireguardautotunnel.client.service.BackendService +import com.zaneschepke.wireguardautotunnel.client.service.DaemonService import com.zaneschepke.wireguardautotunnel.client.service.TunnelImportService +import com.zaneschepke.wireguardautotunnel.client.service.TunnelService import com.zaneschepke.wireguardautotunnel.core.crypto.HmacProtector import com.zaneschepke.wireguardautotunnel.core.ipc.Headers import com.zaneschepke.wireguardautotunnel.core.ipc.IPC @@ -68,6 +68,8 @@ val serviceModule = module { HttpStatusCode.InternalServerError -> ClientException.BadRequestException(bodyText) HttpStatusCode.Conflict -> ClientException.ConflictException(bodyText) + HttpStatusCode.Unauthorized -> + ClientException.UnauthorizedException(bodyText) else -> ClientException.UnknownError(bodyText) } } @@ -81,7 +83,7 @@ val serviceModule = module { install("HmacSigner") { requestPipeline.intercept(HttpRequestPipeline.Render) { payload -> val path = context.url.encodedPath - if (path.startsWith(Routes.DAEMON_BASE)) return@intercept + if (path == Routes.DAEMON_BASE) return@intercept val secret = IPC.getIPCSecret() val user = System.getProperty("user.name") @@ -106,9 +108,9 @@ val serviceModule = module { } } } - single { UdsDaemonHealthService(get()) } - single { UdsTunnelCommandService(get(), tunnelRepository = get()) } - single { UdsBackendCommandService(get(), get(), get()) } + single { UdsDaemonService(get(), get(), get()) } + single { UdsTunnelService(get(), tunnelRepository = get()) } + single { UdsBackendService(get(), get(), get()) } single { DefaultTunnelImportService(get()) } } diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/error/ClientException.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/error/ClientException.kt index 8969343..cdf1ea5 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/error/ClientException.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/error/ClientException.kt @@ -9,5 +9,7 @@ sealed class ClientException : Exception() { class UnknownError(override val message: String) : ClientException() + class UnauthorizedException(override val message: String) : ClientException() + class DaemonCommsException : ClientException() } diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/model/GeneralSettings.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/model/GeneralSettings.kt index 853d3a6..ff8ed51 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/model/GeneralSettings.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/model/GeneralSettings.kt @@ -9,4 +9,5 @@ data class GeneralSettings( val theme: Theme = Theme.DARK, val locale: String? = null, val alreadyDonated: Boolean = false, + val restoreTunnelOnBoot: Boolean = false, ) diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/repository/GeneralSettingRepository.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/repository/GeneralSettingRepository.kt index f26e3a1..13330c0 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/repository/GeneralSettingRepository.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/domain/repository/GeneralSettingRepository.kt @@ -16,4 +16,6 @@ interface GeneralSettingRepository { suspend fun updateLocale(locale: String) suspend fun updateAlreadyDonated(donated: Boolean) + + suspend fun updateRestoreTunnelOnBoot(enabled: Boolean) } diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/BackendCommandService.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/BackendService.kt similarity index 82% rename from client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/BackendCommandService.kt rename to client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/BackendService.kt index b64e117..220bdde 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/BackendCommandService.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/BackendService.kt @@ -4,11 +4,13 @@ import com.zaneschepke.wireguardautotunnel.core.ipc.dto.BackendMode import com.zaneschepke.wireguardautotunnel.core.ipc.dto.BackendStatus import kotlinx.coroutines.flow.Flow -interface BackendCommandService { +interface BackendService { suspend fun setMode(mode: BackendMode): Result suspend fun setKillSwitch(enabled: Boolean): Result + suspend fun setKillSwitchLanBypass(enabled: Boolean): Result + suspend fun getStatus(): Result fun statusFlow(): Flow diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/DaemonHealthService.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/DaemonService.kt similarity index 50% rename from client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/DaemonHealthService.kt rename to client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/DaemonService.kt index e1ea7ea..ea9c7af 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/DaemonHealthService.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/DaemonService.kt @@ -2,8 +2,12 @@ package com.zaneschepke.wireguardautotunnel.client.service import kotlinx.coroutines.flow.Flow -interface DaemonHealthService { +interface DaemonService { suspend fun alive(): Boolean + suspend fun setRestoreKillSwitch(enabled: Boolean): Result + + suspend fun setRestoreTunnel(enabled: Boolean): Result + val alive: Flow } diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/TunnelCommandService.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/TunnelService.kt similarity index 83% rename from client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/TunnelCommandService.kt rename to client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/TunnelService.kt index fa25507..9fdeb71 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/TunnelCommandService.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/service/TunnelService.kt @@ -1,6 +1,6 @@ package com.zaneschepke.wireguardautotunnel.client.service -interface TunnelCommandService { +interface TunnelService { suspend fun startTunnel(id: Long): Result suspend fun stopTunnel(id: Long): Result diff --git a/composeApp/src/jvmMain/composeResources/files/aboutlibraries.json b/composeApp/src/jvmMain/composeResources/files/aboutlibraries.json index 16d6188..1c1c59c 100644 --- a/composeApp/src/jvmMain/composeResources/files/aboutlibraries.json +++ b/composeApp/src/jvmMain/composeResources/files/aboutlibraries.json @@ -891,6 +891,32 @@ "Apache-2.0" ] }, + { + "uniqueId": "com.ibm.icu:icu4j", + "funding": [ + + ], + "developers": [ + { + "name": "Markus Scherer" + }, + { + "name": "Richard Gillam" + } + ], + "artifactVersion": "77.1", + "description": "International Components for Unicode for Java (ICU4J) is a mature, widely used Java library\n providing Unicode and Globalization support", + "scm": { + "connection": "scm:git:git://github.com/unicode-org/icu.git", + "url": "https://github.com/unicode-org/icu", + "developerConnection": "scm:git:git@github.com:unicode-org/icu.git" + }, + "name": "ICU4J", + "website": "https://icu.unicode.org/", + "licenses": [ + "a953261c1ab0a39c092979a5ef28d565" + ] + }, { "uniqueId": "com.materialkolor:material-kolor", "funding": [ @@ -1296,6 +1322,29 @@ "MIT" ] }, + { + "uniqueId": "io.github.skeptick.libres:libres", + "funding": [ + + ], + "developers": [ + { + "name": "Danil Yudov" + } + ], + "artifactVersion": "1.2.4", + "description": "Resources generation in Kotlin Multiplatform.", + "scm": { + "connection": "scm:git:ssh://git@github.com/skeptick/libres.git", + "url": "https://github.com/skeptick/libres", + "developerConnection": "scm:git:ssh://git@github.com/skeptick/libres.git" + }, + "name": "Libres Core", + "website": "https://github.com/skeptick/libres", + "licenses": [ + "Apache-2.0" + ] + }, { "uniqueId": "io.github.skolson:kmp-io", "funding": [ @@ -1915,6 +1964,27 @@ "Apache-2.0" ] }, + { + "uniqueId": "nl.jacobras:Human-Readable", + "funding": [ + + ], + "developers": [ + { + "name": "Jacob Ras" + } + ], + "artifactVersion": "1.12.3", + "description": "A small set of data formatting utilities for Kotlin Multiplatform (KMP)", + "scm": { + "url": "https://github.com/jacobras/human-readable" + }, + "name": "Human Readable", + "website": "https://github.com/jacobras/human-readable", + "licenses": [ + "MIT" + ] + }, { "uniqueId": "org.apache.commons:commons-lang3", "funding": [ @@ -4602,6 +4672,11 @@ "spdxId": "MIT", "name": "MIT License" }, + "a953261c1ab0a39c092979a5ef28d565": { + "hash": "a953261c1ab0a39c092979a5ef28d565", + "url": "https://raw.githubusercontent.com/unicode-org/icu/maint/maint-77/LICENSE", + "name": "Unicode-3.0" + }, "dd74d358d0b6f8b5099019a55929b63f": { "hash": "dd74d358d0b6f8b5099019a55929b63f", "url": "https://www.gnu.org/licenses/lgpl-2.1.html", diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/common/button/SwitchWithDivider.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/common/button/SwitchWithDivider.kt index bba7d33..e685218 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/common/button/SwitchWithDivider.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/common/button/SwitchWithDivider.kt @@ -27,7 +27,7 @@ fun SwitchWithDivider( color = MaterialTheme.colorScheme.outline, ) Box(modifier = Modifier.pointerInput(Unit) { detectTapGestures {} }) { - com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch( + ThemedSwitch( checked = checked, onClick = onClick, enabled = enabled, diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/settings/SettingsScreen.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/settings/SettingsScreen.kt index e0c8c62..4f86d45 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/settings/SettingsScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/settings/SettingsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.dokar.sonner.Toast import com.zaneschepke.wireguardautotunnel.composeapp.generated.resources.Res import com.zaneschepke.wireguardautotunnel.composeapp.generated.resources.appearance import com.zaneschepke.wireguardautotunnel.composeapp.generated.resources.general @@ -24,23 +25,35 @@ import com.zaneschepke.wireguardautotunnel.composeapp.generated.resources.lockdo import com.zaneschepke.wireguardautotunnel.composeapp.generated.resources.settings import com.zaneschepke.wireguardautotunnel.composeapp.generated.resources.tunnel import com.zaneschepke.wireguardautotunnel.desktop.ui.common.LocalNavController +import com.zaneschepke.wireguardautotunnel.desktop.ui.common.LocalToaster import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.ThemedSwitch +import com.zaneschepke.wireguardautotunnel.desktop.ui.common.label.GroupLabel import com.zaneschepke.wireguardautotunnel.desktop.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.settings.appearance.LockdownIntent +import com.zaneschepke.wireguardautotunnel.desktop.ui.sideeffects.AppSideEffect import com.zaneschepke.wireguardautotunnel.desktop.viewmodel.SettingsViewModel -import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch -import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(viewModel: SettingsViewModel = koinViewModel()) { val navController = LocalNavController.current + val toaster = LocalToaster.current val uiState by viewModel.collectAsState() + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is AppSideEffect.Toast -> { + toaster.show(Toast(sideEffect.message, sideEffect.type)) + } + } + } + if (!uiState.isLoaded) return Scaffold(topBar = { TopAppBar(title = { Text(stringResource(Res.string.settings)) }) }) { @@ -94,14 +107,14 @@ fun SettingsScreen(viewModel: SettingsViewModel = koinViewModel()) { title = "Allow local network access", onClick = { viewModel.onLockdownAction( - LockdownIntent.TogglePersist(!uiState.lockdownRestoreOnBootEnabled) + LockdownIntent.ToggleBypassLan(!uiState.lockdownRestoreOnBootEnabled) ) }, trailing = { ThemedSwitch( checked = uiState.lockdownBypassEnabled, onClick = { - viewModel.onLockdownAction(LockdownIntent.TogglePersist(it)) + viewModel.onLockdownAction(LockdownIntent.ToggleBypassLan(it)) }, ) }, @@ -116,16 +129,12 @@ fun SettingsScreen(viewModel: SettingsViewModel = koinViewModel()) { leading = { Icon(Icons.Default.RestartAlt, contentDescription = null) }, title = "Restore tunnel on system startup", onClick = { - viewModel.onLockdownAction( - LockdownIntent.TogglePersist(!uiState.lockdownRestoreOnBootEnabled) - ) + viewModel.onRestoreTunnelOnBoot(!uiState.tunnelRestoreOnBootEnabled) }, trailing = { ThemedSwitch( - checked = uiState.lockdownRestoreOnBootEnabled, - onClick = { - viewModel.onLockdownAction(LockdownIntent.TogglePersist(it)) - }, + checked = uiState.tunnelRestoreOnBootEnabled, + onClick = { viewModel.onRestoreTunnelOnBoot(it) }, ) }, ) diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/SupportScreen.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/SupportScreen.kt index f85df3f..d70b4c2 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/SupportScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/SupportScreen.kt @@ -63,11 +63,11 @@ import com.zaneschepke.wireguardautotunnel.composeapp.generated.resources.websit import com.zaneschepke.wireguardautotunnel.desktop.ui.common.LocalNavController import com.zaneschepke.wireguardautotunnel.desktop.ui.common.LocalToaster import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.desktop.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.desktop.ui.common.text.DescriptionText import com.zaneschepke.wireguardautotunnel.desktop.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.desktop.util.DesktopUtils import com.zaneschepke.wireguardautotunnel.desktop.util.toClipEntry -import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/donate/DonateScreen.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/donate/DonateScreen.kt index e5528e1..de383af 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/donate/DonateScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/donate/DonateScreen.kt @@ -40,12 +40,12 @@ import com.zaneschepke.wireguardautotunnel.composeapp.generated.resources.libera import com.zaneschepke.wireguardautotunnel.composeapp.generated.resources.options import com.zaneschepke.wireguardautotunnel.desktop.ui.common.LocalNavController import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.ThemedSwitch +import com.zaneschepke.wireguardautotunnel.desktop.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.desktop.ui.common.text.DescriptionText import com.zaneschepke.wireguardautotunnel.desktop.ui.navigation.Route +import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.support.donate.components.DonationHeroSection import com.zaneschepke.wireguardautotunnel.desktop.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch -import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText -import com.zaneschepke.wireguardautotunnel.ui.screens.support.donate.components.DonationHeroSection import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource import org.orbitmvi.orbit.compose.collectAsState diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/donate/components/DonationHeroSection.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/donate/components/DonationHeroSection.kt index 9b30bb6..5c800f7 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/donate/components/DonationHeroSection.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/support/donate/components/DonationHeroSection.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.support.donate.components +package com.zaneschepke.wireguardautotunnel.desktop.ui.screens.support.donate.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/TunnelsScreen.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/TunnelsScreen.kt index f5a2118..e2ec5fc 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/TunnelsScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/TunnelsScreen.kt @@ -168,18 +168,19 @@ fun TunnelsScreen(viewModel: TunnelsViewModel = koinViewModel()) { detectTapGestures(onPress = { viewModel.onClearSelectionMode() }) } } - ) - TunnelList( - uiState = uiState, - startTunnel = viewModel::onStartTunnel, - stopTunnel = viewModel::onStopTunnel, - viewModel::onItemsReordered, - viewModel::onPersistReorder, - viewModel::onSelectTunnel, - viewModel::onDeselectTunnel, - viewModel::onClearSelectionMode, - { intent -> pendingDeleteIntent = intent }, - viewModel::onExportIntent, - ) + ) { + TunnelList( + uiState = uiState, + startTunnel = viewModel::onStartTunnel, + stopTunnel = viewModel::onStopTunnel, + viewModel::onItemsReordered, + viewModel::onPersistReorder, + viewModel::onSelectTunnel, + viewModel::onDeselectTunnel, + viewModel::onClearSelectionMode, + { intent -> pendingDeleteIntent = intent }, + viewModel::onExportIntent, + ) + } } } diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/Extensions.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/Extensions.kt index d29a2bf..8b4d622 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/Extensions.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/Extensions.kt @@ -16,3 +16,14 @@ fun TunnelState.asColor(): Color { TunnelState.RESOLVING_DNS -> Straw } } + +fun TunnelState.asTooltipMessage(): String? { + return when (this) { + TunnelState.DOWN, + TunnelState.STARTING, + TunnelState.UNKNOWN -> null + TunnelState.HEALTHY -> "Healthy" + TunnelState.HANDSHAKE_FAILURE -> "Handshake failure" + TunnelState.RESOLVING_DNS -> "Resolving DNS" + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/TunnelList.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/TunnelList.kt index 0e01f5b..ca262ed 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/TunnelList.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/TunnelList.kt @@ -8,15 +8,21 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow @@ -30,15 +36,20 @@ import com.zaneschepke.wireguardautotunnel.client.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.core.ipc.dto.TunnelState import com.zaneschepke.wireguardautotunnel.desktop.ui.common.LocalNavController import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.SwitchWithDivider +import com.zaneschepke.wireguardautotunnel.desktop.ui.common.tooltip.CustomTooltip import com.zaneschepke.wireguardautotunnel.desktop.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.DeleteIntent import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.ExportIntent import com.zaneschepke.wireguardautotunnel.desktop.ui.state.TunnelsUiState -import com.zaneschepke.wireguardautotunnel.ui.common.button.SwitchWithDivider import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState -@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalComposeUiApi::class, + ExperimentalMaterial3Api::class, +) @Composable fun TunnelList( uiState: TunnelsUiState, @@ -61,14 +72,14 @@ fun TunnelList( onReorder(from.index, to.index) } - val tunnelIndicatorColors by + val tunnelIndicators by remember(uiState.tunnelStates, uiState.tunnels) { derivedStateOf { uiState.tunnels.associate { tunnel -> val state = uiState.tunnelStates.firstOrNull { it.id == tunnel.id }?.state ?: TunnelState.UNKNOWN - tunnel.id to state.asColor() + tunnel.id to (state.asColor() to state.asTooltipMessage()) } } } @@ -82,17 +93,32 @@ fun TunnelList( LazyColumn( state = lazyListState, modifier = - modifier.background(MaterialTheme.colorScheme.background).onKeyEvent { - if (it.key == Key.Escape && uiState.isSelectionMode) { - onExitSelectionMode() - true - } else false - }, + modifier + .background(MaterialTheme.colorScheme.background) + .onKeyEvent { + if (it.key == Key.Escape && uiState.isSelectionMode) { + onExitSelectionMode() + true + } else false + } + .fillMaxSize(), ) { + if (uiState.tunnels.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxSize().padding(top = 80.dp), + contentAlignment = Alignment.Center, + ) { + Text("No tunnels added yet! Click the + symbol to add a tunnel.") + } + } + return@LazyColumn + } + items(uiState.tunnels, key = { it.id }) { tunnel -> + val isSelected = uiState.selectedTunnels.contains(tunnel) ReorderableItem(reorderableState, key = tunnel.id) { isDragging -> val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) - val isSelected = uiState.selectedTunnels.contains(tunnel) ContextMenuArea( items = { @@ -144,22 +170,25 @@ fun TunnelList( .pointerInput(tunnel.id, uiState.isSelectionMode, isSelected) { awaitEachGesture { val down = awaitFirstDown() + down.consume() + val up = waitForUpOrCancellation() + if (up != null) { + up.consume() + if ((up.position - down.position).getDistance() < 5f) { + val modifiers = currentEvent.keyboardModifiers + val isMultiSelectModifier = + modifiers.isCtrlPressed || + modifiers.isMetaPressed - if ( - up != null && - (up.position - down.position).getDistance() < 5f - ) { - - val modifiers = currentEvent.keyboardModifiers - val isMultiSelectModifier = - modifiers.isCtrlPressed || modifiers.isMetaPressed - - if (isMultiSelectModifier || uiState.isSelectionMode) { - if (isSelected) onDeselected(tunnel) - else onSelected(tunnel) - } else { - navController.push(Route.Tunnel(tunnel.id)) + if ( + isMultiSelectModifier || uiState.isSelectionMode + ) { + if (isSelected) onDeselected(tunnel) + else onSelected(tunnel) + } else { + navController.push(Route.Tunnel(tunnel.id)) + } } } } @@ -167,14 +196,22 @@ fun TunnelList( .pointerHoverIcon(PointerIcon.Hand) .then(if (isDragging) Modifier.zIndex(1f) else Modifier), leading = { - Icon( - Icons.Rounded.Circle, - contentDescription = null, - tint = - tunnelIndicatorColors[tunnel.id] - ?: TunnelState.UNKNOWN.asColor(), - modifier = Modifier.size(14.dp), - ) + val tooltip = tunnelIndicators[tunnel.id]?.second + val indicatorColor = tunnelIndicators[tunnel.id]?.first + @Composable + fun icon() { + Icon( + Icons.Rounded.Circle, + contentDescription = null, + tint = indicatorColor ?: TunnelState.UNKNOWN.asColor(), + modifier = Modifier.size(14.dp), + ) + } + if (tooltip != null) { + CustomTooltip(text = tooltip) { icon() } + } else { + icon() + } }, selected = isSelected, trailing = { diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/state/SettingsUiState.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/state/SettingsUiState.kt index 8ac218d..532d4f3 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/state/SettingsUiState.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/state/SettingsUiState.kt @@ -5,4 +5,5 @@ data class SettingsUiState( val lockdownEnabled: Boolean = false, val lockdownRestoreOnBootEnabled: Boolean = false, val lockdownBypassEnabled: Boolean = false, + val tunnelRestoreOnBootEnabled: Boolean = false, ) diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/util/UiExtensions.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/util/UiExtensions.kt index 219fa1f..ef04561 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/util/UiExtensions.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/util/UiExtensions.kt @@ -18,6 +18,7 @@ fun ClientException?.asUserMessage(): String { is ClientException.DaemonCommsException -> "Daemon communication error, please check the daemon status." is ClientException.InternalServerError -> "An internal error occurred, please try again." + is ClientException.UnauthorizedException -> "Unauthorized, please try again." is ClientException.UnknownError, null -> "An unknown error occurred, please try again." } diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/AppViewModel.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/AppViewModel.kt index fb3d7b0..e6ed92e 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/AppViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/AppViewModel.kt @@ -3,8 +3,8 @@ package com.zaneschepke.wireguardautotunnel.desktop.viewmodel import androidx.lifecycle.ViewModel import com.zaneschepke.wireguardautotunnel.client.data.model.Theme import com.zaneschepke.wireguardautotunnel.client.domain.repository.GeneralSettingRepository -import com.zaneschepke.wireguardautotunnel.client.service.BackendCommandService -import com.zaneschepke.wireguardautotunnel.client.service.DaemonHealthService +import com.zaneschepke.wireguardautotunnel.client.service.BackendService +import com.zaneschepke.wireguardautotunnel.client.service.DaemonService import com.zaneschepke.wireguardautotunnel.desktop.ui.sideeffects.AppSideEffect import com.zaneschepke.wireguardautotunnel.desktop.ui.state.AppUiState import io.github.sudarshanmhasrup.localina.api.LocaleUpdater @@ -13,8 +13,8 @@ import org.orbitmvi.orbit.viewmodel.container class AppViewModel( private val settingsRepository: GeneralSettingRepository, - private val daemonHealthService: DaemonHealthService, - private val backendCommandService: BackendCommandService, + private val daemonService: DaemonService, + private val backendService: BackendService, ) : ContainerHost, ViewModel() { override val container = @@ -37,11 +37,9 @@ class AppViewModel( } } } + intent { daemonService.alive.collect { reduce { state.copy(daemonConnected = it) } } } intent { - daemonHealthService.alive.collect { reduce { state.copy(daemonConnected = it) } } - } - intent { - backendCommandService.statusFlow().collect { + backendService.statusFlow().collect { reduce { state.copy(lockdownActive = it.killSwitchEnabled) } } } diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/SettingsViewModel.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/SettingsViewModel.kt index 7da4038..889953e 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/SettingsViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/SettingsViewModel.kt @@ -1,12 +1,16 @@ package com.zaneschepke.wireguardautotunnel.desktop.viewmodel import androidx.lifecycle.ViewModel +import com.dokar.sonner.ToastType +import com.zaneschepke.wireguardautotunnel.client.domain.error.ClientException import com.zaneschepke.wireguardautotunnel.client.domain.repository.GeneralSettingRepository import com.zaneschepke.wireguardautotunnel.client.domain.repository.LockdownSettingsRepository -import com.zaneschepke.wireguardautotunnel.client.service.BackendCommandService +import com.zaneschepke.wireguardautotunnel.client.service.BackendService +import com.zaneschepke.wireguardautotunnel.client.service.DaemonService import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.settings.appearance.LockdownIntent import com.zaneschepke.wireguardautotunnel.desktop.ui.sideeffects.AppSideEffect import com.zaneschepke.wireguardautotunnel.desktop.ui.state.SettingsUiState +import com.zaneschepke.wireguardautotunnel.desktop.util.asUserMessage import kotlinx.coroutines.flow.combine import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -14,7 +18,8 @@ import org.orbitmvi.orbit.viewmodel.container class SettingsViewModel( private val settingsRepository: GeneralSettingRepository, private val lockdownRepository: LockdownSettingsRepository, - private val backendCommandService: BackendCommandService, + private val backendService: BackendService, + private val daemonService: DaemonService, ) : ContainerHost, ViewModel() { override val container = @@ -33,19 +38,34 @@ class SettingsViewModel( lockdownEnabled = lockdown.enabled, lockdownRestoreOnBootEnabled = lockdown.restoreOnBoot, lockdownBypassEnabled = lockdown.bypassLan, + tunnelRestoreOnBootEnabled = settings.restoreTunnelOnBoot, ) } } } } + fun onRestoreTunnelOnBoot(enabled: Boolean) = intent { + daemonService.setRestoreTunnel(enabled).onFailure { + val message = (it as? ClientException).asUserMessage() + postSideEffect(AppSideEffect.Toast(message, ToastType.Error)) + } + } + fun onLockdownAction(intent: LockdownIntent) = intent { when (intent) { - is LockdownIntent.ToggleBypassLan -> {} - is LockdownIntent.ToggleMaster -> { - backendCommandService.setKillSwitch(intent.enabled) + is LockdownIntent.ToggleBypassLan -> { + backendService.setKillSwitchLanBypass(intent.enabled) } - is LockdownIntent.TogglePersist -> {} + is LockdownIntent.ToggleMaster -> { + backendService.setKillSwitch(intent.enabled) + } + is LockdownIntent.TogglePersist -> { + daemonService.setRestoreKillSwitch(intent.enabled) + } + }.onFailure { + val message = (it as? ClientException).asUserMessage() + postSideEffect(AppSideEffect.Toast(message, ToastType.Error)) } } } diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelViewModel.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelViewModel.kt index df48930..86a9138 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelViewModel.kt @@ -3,17 +3,16 @@ package com.zaneschepke.wireguardautotunnel.desktop.viewmodel import androidx.lifecycle.ViewModel import com.dokar.sonner.ToastType import com.zaneschepke.wireguardautotunnel.client.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.client.service.BackendCommandService +import com.zaneschepke.wireguardautotunnel.client.service.BackendService import com.zaneschepke.wireguardautotunnel.desktop.ui.sideeffects.AppSideEffect import com.zaneschepke.wireguardautotunnel.desktop.ui.state.TunnelUiState import com.zaneschepke.wireguardautotunnel.parser.Config -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container class TunnelViewModel( - private val backendCommandService: BackendCommandService, + private val backendService: BackendService, private val tunnelRepository: TunnelRepository, val tunnelId: Long, ) : ContainerHost, ViewModel() { @@ -23,44 +22,44 @@ class TunnelViewModel( TunnelUiState(), buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, ) { - combine( - tunnelRepository.flow.map { it.firstOrNull { tun -> tun.id == tunnelId } }, - backendCommandService.statusFlow().map { status -> - status.activeTunnels.firstOrNull { tunnel -> tunnel.id == tunnelId } - }, - ) { tunnel, activeTunnel -> - tunnel to activeTunnel - } - .collect { (tunnel, status) -> - reduce { - state.copy( - isLoaded = true, - originalConfig = tunnel ?: state.originalConfig, - editedConfig = tunnel ?: state.editedConfig, - tunnelState = status?.state, - activeConfig = status?.activeConfig ?: state.activeConfig, - ) + intent { + tunnelRepository.flow + .map { it.firstOrNull { tun -> tun.id == tunnelId } } + .collect { tunnel -> + reduce { + state.copy( + isLoaded = true, + originalConfig = tunnel ?: state.originalConfig, + editedConfig = tunnel ?: state.editedConfig, + ) + } } - } + } + intent { + backendService + .statusFlow() + .map { status -> + status.activeTunnels.firstOrNull { tunnel -> tunnel.id == tunnelId } + } + .collect { + reduce { + state.copy( + tunnelState = it?.state ?: state.tunnelState, + activeConfig = it?.activeConfig ?: state.activeConfig, + ) + } + } + } } fun onConfigUpdate(newText: String) = intent { - reduce { - state.copy( - editedConfig = state.editedConfig.copy(quickConfig = newText), - isDirty = state.originalConfig != state.editedConfig, - ) - } + val newEdited = state.editedConfig.copy(quickConfig = newText) + reduce { state.copy(editedConfig = newEdited, isDirty = state.originalConfig != newEdited) } } fun onNameUpdated(name: String) = intent { - reduce { - val updatedConfig = state.editedConfig.copy(name = name) - state.copy( - editedConfig = updatedConfig, - isDirty = state.originalConfig != state.editedConfig, - ) - } + val newEdited = state.editedConfig.copy(name = name) + reduce { state.copy(editedConfig = newEdited, isDirty = state.originalConfig != newEdited) } } fun saveChanges() = intent { diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelsViewModel.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelsViewModel.kt index 1bbf3ce..56e63a7 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelsViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelsViewModel.kt @@ -5,9 +5,9 @@ import com.dokar.sonner.ToastType import com.zaneschepke.wireguardautotunnel.client.domain.error.ClientException import com.zaneschepke.wireguardautotunnel.client.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.client.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.client.service.BackendCommandService -import com.zaneschepke.wireguardautotunnel.client.service.TunnelCommandService +import com.zaneschepke.wireguardautotunnel.client.service.BackendService import com.zaneschepke.wireguardautotunnel.client.service.TunnelImportService +import com.zaneschepke.wireguardautotunnel.client.service.TunnelService import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.DeleteIntent import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.ExportIntent import com.zaneschepke.wireguardautotunnel.desktop.ui.sideeffects.AppSideEffect @@ -18,7 +18,7 @@ import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.dialogs.openFileSaver import io.github.vinceglb.filekit.name import io.github.vinceglb.filekit.write -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import org.orbitmvi.orbit.ContainerHost @@ -26,8 +26,8 @@ import org.orbitmvi.orbit.viewmodel.container class TunnelsViewModel( private val tunnelRepository: TunnelRepository, - private val backendCommandService: BackendCommandService, - private val tunnelCommandService: TunnelCommandService, + private val backendService: BackendService, + private val tunnelService: TunnelService, private val tunnelImportService: TunnelImportService, ) : ContainerHost, ViewModel() { @@ -37,24 +37,16 @@ class TunnelsViewModel( buildSettings = { repeatOnSubscribedStopTimeout = 5_000L }, ) { intent { - combine( - tunnelRepository.flow, - backendCommandService - .statusFlow() - .map { it.activeTunnels } - .distinctUntilChanged(), - ) { tunnels, tunnelStates -> - Pair(tunnels.sortedBy { it.position }, tunnelStates) - } - .collect { (tunnels, tunnelStates) -> - reduce { - state.copy( - tunnels = tunnels, - tunnelStates = tunnelStates, - isLoaded = true, - ) - } - } + tunnelRepository.flow.collect { tunnels -> + reduce { state.copy(tunnels = tunnels, isLoaded = true) } + } + } + intent { + backendService + .statusFlow() + .map { it.activeTunnels } + .distinctUntilChanged() + .collect { reduce { state.copy(tunnelStates = it) } } } } @@ -73,14 +65,14 @@ class TunnelsViewModel( } fun onStartTunnel(id: Long) = intent { - tunnelCommandService.startTunnel(id).onFailure { + tunnelService.startTunnel(id).onFailure { val message = (it as? ClientException).asUserMessage() postSideEffect(AppSideEffect.Toast(message, ToastType.Error)) } } fun onStopTunnel(id: Long) = intent { - tunnelCommandService.stopTunnel(id).onFailure { + tunnelService.stopTunnel(id).onFailure { val message = (it as? ClientException).asUserMessage() postSideEffect(AppSideEffect.Toast(message, ToastType.Error)) } diff --git a/conveyor.conf b/conveyor.conf index a681ea8..fca4c69 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -17,6 +17,13 @@ app { options += "-XX:+UseG1GC" options += "-XX:+UseStringDeduplication" + + + jlink-flags = [ + "--compress=zip-9", + "--strip-debug" + ] + # for high-res displays system-properties { "sun.java2d.uiScale" = "1.0" @@ -45,7 +52,7 @@ app { inputs += "daemon/build/install/daemon/lib/*.jar" inputs += "cli/build/install/cli/lib/*.jar" - // Target platforms + # Target platforms machines = [ linux.amd64.glibc, windows.amd64, @@ -77,11 +84,13 @@ app { file-name = "wgtunnel-daemon.service" + # start early to avoid leaks Unit { Description = "WG Tunnel Daemon" Documentation = "https://wgtunnel.com" - Before = "network-online.target" - After = "NetworkManager.service systemd-resolved.service" + Before= network.target network-pre.target + Wants= network.target + After= local-fs.target StartLimitBurst = 5 StartLimitIntervalSec = 20 } diff --git a/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/helper/PermissionsHelper.kt b/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/helper/PermissionsHelper.kt index 4ff3674..19f10c8 100644 --- a/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/helper/PermissionsHelper.kt +++ b/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/helper/PermissionsHelper.kt @@ -54,7 +54,7 @@ object PermissionsHelper { path, PosixFilePermissions.fromString(OWNER_FULL_CONTROL_SYMBOLIC), ) - Logger.i { "Successfully set directory permissions to " } + Logger.i { "Successfully set daemon data directory permission" } } catch (e: Exception) { Logger.e { "POSIX native permissions failed: ${e.message} → falling back to chmod" } try { @@ -77,6 +77,58 @@ object PermissionsHelper { } } + fun secureDaemonDataDirectory(path: Path) { + val pathString = path.toString() + try { + if (SystemUtils.IS_OS_WINDOWS) { + val process = + ProcessBuilder( + ICACLS, + pathString, + WIN_INHERIT_REPLACE, + WIN_GRANT_REPLACE, + "$SID_SYSTEM$WIN_FULL_CONTROL_INHERIT", + WIN_GRANT_REPLACE, + "$SID_ADMINISTRATORS$WIN_FULL_CONTROL_INHERIT", + ) + .start() + + val exitCode = process.waitFor() + if (exitCode == 0) { + Logger.i { "Successfully secured Windows directory: $pathString" } + logWindowsACLs(pathString) + } else { + val error = process.errorStream.bufferedReader().use { it.readText() } + Logger.e { "Failed to secure Windows directory: $error" } + } + } else { + try { + Files.setPosixFilePermissions( + path, + PosixFilePermissions.fromString(OWNER_ONLY_PRIVATE_DIR), + ) + Logger.i { "Successfully set POSIX permissions for directory: $pathString" } + } catch (e: Exception) { + Logger.e { + "POSIX native permissions failed: ${e.message} → falling back to chmod" + } + val exitCode = ProcessBuilder("chmod", "700", pathString).start().waitFor() + if (exitCode == 0) { + Logger.i { + "Successfully set directory permissions using chmod: $pathString" + } + } else { + Logger.e { "chmod failed with exit code $exitCode for: $pathString" } + } + } + val finalPerms = Files.getPosixFilePermissions(path) + Logger.i { "Final directory permissions: $finalPerms for $pathString" } + } + } catch (e: Exception) { + Logger.e(e) { "Error securing directory: $pathString" } + } + } + fun setupDirectoryPermissionsWindows(runtimeDirPath: String) { try { val process = diff --git a/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/Routes.kt b/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/Routes.kt index e65ff0f..3cd9608 100644 --- a/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/Routes.kt +++ b/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/Routes.kt @@ -5,12 +5,17 @@ object Routes { const val DAEMON_STATUS = "$DAEMON_BASE/status" const val DAEMON_STATUS_WS = "$DAEMON_BASE/status/ws" + const val DAEMON_RESTORE_TUNNEL = "$DAEMON_BASE/restore/tunnel" + const val DAEMON_RESTORE_KILL_SWITCH = "$DAEMON_BASE/restore/kill-switch" + const val BACKEND_BASE = "/backend" const val BACKEND_STATUS = "$BACKEND_BASE/status" const val BACKEND_ACTIVE_CONFIG = "$BACKEND_BASE/config/{id}/active" const val BACKEND_STATUS_WS = "$BACKEND_BASE/status/ws" const val BACKEND_KILL_SWITCH = "$BACKEND_BASE/kill-switch" + + const val BACKEND_KILL_SWITCH_BYPASS = "$BACKEND_BASE/kill-switch/bypass-lan" const val BACKEND_MODE = "$BACKEND_BASE/mode" object Tunnels { diff --git a/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/request/KillSwitchRequest.kt b/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/request/FlagRequest.kt similarity index 53% rename from core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/request/KillSwitchRequest.kt rename to core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/request/FlagRequest.kt index 4181c9f..fca05d5 100644 --- a/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/request/KillSwitchRequest.kt +++ b/core/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/request/FlagRequest.kt @@ -2,4 +2,4 @@ package com.zaneschepke.wireguardautotunnel.core.ipc.dto.request import kotlinx.serialization.Serializable -@Serializable data class KillSwitchRequest(val enable: Boolean, val bypassLan: Boolean = false) +@Serializable data class FlagRequest(val value: Boolean) diff --git a/daemon/build.gradle.kts b/daemon/build.gradle.kts index ac6a82f..964c016 100644 --- a/daemon/build.gradle.kts +++ b/daemon/build.gradle.kts @@ -22,9 +22,8 @@ dependencies { testImplementation(kotlin("test")) - // secure caching - implementation(libs.kstore) - implementation(libs.kstore.file) + // caching + implementation(libs.multiplatform.settings) implementation(libs.kotlinx.serialization) diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/TunnelDaemon.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/TunnelDaemon.kt index bd4a0dd..4218e38 100644 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/TunnelDaemon.kt +++ b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/TunnelDaemon.kt @@ -7,6 +7,7 @@ import com.zaneschepke.wireguardautotunnel.daemon.plugin.hmacShieldPlugin import com.zaneschepke.wireguardautotunnel.daemon.routes.backendRoutes import com.zaneschepke.wireguardautotunnel.daemon.routes.daemonRoutes import com.zaneschepke.wireguardautotunnel.daemon.routes.tunnelRoutes +import com.zaneschepke.wireguardautotunnel.daemon.tunnel.RunningTunnel import com.zaneschepke.wireguardautotunnel.tunnel.Backend import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.* @@ -94,8 +95,8 @@ class TunnelDaemon( } install(hmacShieldPlugin) routing { - daemonRoutes() - tunnelRoutes(backend) + daemonRoutes(cacheRepository) + tunnelRoutes(backend, cacheRepository) backendRoutes(backend) } monitor.subscribe(ApplicationStarted) { @@ -114,11 +115,23 @@ class TunnelDaemon( } scope.launch { - // TODO handle startup with cached settings - val settings = cacheRepository.getKillSwitchSettings() - val startConfigs = cacheRepository.getStartConfigs() - Logger.d { "Got kill switch settings $settings" } - Logger.d { "Got start configs of size ${startConfigs.size}" } + val restoreTun = cacheRepository.getRestoreTunnelOnBoot() + val restoreKillSwitch = cacheRepository.getKillSwitchRestore() + if (restoreKillSwitch) { + Logger.i { "Attempting to restore kill switch" } + backend + .setKillSwitch(true) + .onFailure { Logger.e(it) { "Failed to restore kill switch" } } + .onSuccess { Logger.i { "Kill switch successfully restored" } } + } + if (restoreTun) { + Logger.i { "Attempting to restore previous tunnel" } + val config = cacheRepository.getLastActiveTunnelConfig() ?: return@launch + val name = cacheRepository.getLastActiveTunnelName() ?: return@launch + val id = cacheRepository.getLastActiveTunnelId() ?: return@launch + val tunnel = RunningTunnel(id, name) + backend.start(tunnel, config) + } } } diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/DaemonCacheRepository.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/DaemonCacheRepository.kt index cfa9dc7..4fe722e 100644 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/DaemonCacheRepository.kt +++ b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/DaemonCacheRepository.kt @@ -1,13 +1,31 @@ package com.zaneschepke.wireguardautotunnel.daemon.data -import com.zaneschepke.wireguardautotunnel.daemon.data.model.KillSwitchSettings - interface DaemonCacheRepository { - suspend fun getKillSwitchSettings(): KillSwitchSettings + suspend fun updateKillSwitchEnabled(enabled: Boolean) - suspend fun setKillSwitchSettings(settings: KillSwitchSettings) + suspend fun updateKillSwitchBypassLan(enabled: Boolean) - suspend fun getStartConfigs(): Set + suspend fun updateKillSwitchRestore(enabled: Boolean) - suspend fun setStartConfigs(configs: Set) + suspend fun getKillSwitchEnabled(): Boolean + + suspend fun getKillSwitchBypassLan(): Boolean + + suspend fun getKillSwitchRestore(): Boolean + + suspend fun updateLastActiveTunnelConfig(quick: String) + + suspend fun getLastActiveTunnelConfig(): String? + + suspend fun updateLastActiveTunnelId(tunnelId: Long) + + suspend fun getLastActiveTunnelId(): Long? + + suspend fun updateLastActiveTunnelName(tunnelName: String) + + suspend fun getLastActiveTunnelName(): String? + + suspend fun setRestoreTunnelOnBoot(enabled: Boolean) + + suspend fun getRestoreTunnelOnBoot(): Boolean } diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/KStoreDaemonCacheRepository.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/KStoreDaemonCacheRepository.kt deleted file mode 100644 index 384d35d..0000000 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/KStoreDaemonCacheRepository.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.daemon.data - -import co.touchlab.kermit.Logger -import com.zaneschepke.wireguardautotunnel.daemon.data.model.DaemonCacheData -import com.zaneschepke.wireguardautotunnel.daemon.data.model.KillSwitchSettings -import io.github.xxfast.kstore.KStore -import io.github.xxfast.kstore.file.storeOf -import java.nio.file.Files -import java.nio.file.Paths -import java.nio.file.attribute.PosixFilePermissions -import kotlinx.io.files.Path -import kotlinx.serialization.json.Json -import org.apache.commons.lang3.SystemUtils - -class KStoreDaemonCacheRepository( - private val baseCacheDir: java.nio.file.Path = getCacheBaseDir() -) : DaemonCacheRepository { - - companion object { - const val CACHE_FILE_NAME = "cache.json" - - private fun getCacheBaseDir(): java.nio.file.Path { - return when { - SystemUtils.IS_OS_MAC_OSX -> Paths.get("/Library/Application Support/wgtunnel") - SystemUtils.IS_OS_WINDOWS -> Paths.get(System.getenv("PROGRAMDATA") + "\\wgtunnel") - else -> Paths.get("/var/lib/wgtunnel") - } - } - } - - init { - if (Files.notExists(baseCacheDir)) { - Files.createDirectories(baseCacheDir) - setSecurePermissions(baseCacheDir) - } - } - - private fun getStore(): KStore { - val storePathNio = baseCacheDir.resolve(CACHE_FILE_NAME) - val storeKPath = Path(storePathNio.toString()) - - if (!Files.exists(storePathNio)) { - Files.createFile(storePathNio) - } - - if (Files.size(storePathNio) == 0L) { - val defaultData = DaemonCacheData() - val defaultJson = Json.encodeToString(defaultData) - Files.writeString(storePathNio, defaultJson) - } - - setSecurePermissions(storePathNio) - - return storeOf(file = storeKPath, default = DaemonCacheData()) - } - - private fun setSecurePermissions(path: java.nio.file.Path) { - val os = System.getProperty("os.name").lowercase() - try { - if (!os.contains("win")) { - val isDirectory = Files.isDirectory(path) - val permsString = - if (isDirectory) "rwx------" else "rw-------" // 700 for dirs, 600 for files - val perms = PosixFilePermissions.fromString(permsString) - Files.setPosixFilePermissions(path, perms) - } else { - val process = - ProcessBuilder( - "icacls", - path.toString(), - "/inheritance:r", // remove inherited permissions - "/grant:r", - "SYSTEM:(F)", // full control to system - "/grant:r", - "Administrators:(F)", // full control to admin - ) - .start() - val exitCode = process.waitFor() - if (exitCode != 0) { - Logger.e { "icacls failed with code $exitCode" } - } - } - } catch (e: Exception) { - Logger.e(e) { "Failed to set permissions" } - } - } - - override suspend fun getKillSwitchSettings(): KillSwitchSettings { - return getStore().get()?.killSwitch ?: KillSwitchSettings(false, false) - } - - override suspend fun setKillSwitchSettings(settings: KillSwitchSettings) { - val store = getStore() - store.update { current -> - current?.copy(killSwitch = settings) ?: DaemonCacheData(killSwitch = settings) - } - } - - override suspend fun getStartConfigs(): Set { - return getStore().get()?.startConfigs ?: emptySet() - } - - override suspend fun setStartConfigs(configs: Set) { - val store = getStore() - store.update { current -> - current?.copy(startConfigs = configs) ?: DaemonCacheData(startConfigs = configs) - } - } -} diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/SettingsDaemonCacheRepository.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/SettingsDaemonCacheRepository.kt new file mode 100644 index 0000000..90c1ab4 --- /dev/null +++ b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/SettingsDaemonCacheRepository.kt @@ -0,0 +1,107 @@ +package com.zaneschepke.wireguardautotunnel.daemon.data + +import co.touchlab.kermit.Logger +import com.russhwolf.settings.PropertiesSettings +import com.russhwolf.settings.Settings +import com.russhwolf.settings.set +import com.zaneschepke.wireguardautotunnel.core.helper.PermissionsHelper +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.Properties +import org.apache.commons.lang3.SystemUtils + +class SettingsDaemonCacheRepository(private val baseCacheDir: Path = getCacheBaseDir()) : + DaemonCacheRepository { + + private val settings: Settings by lazy { + val storePathNio = baseCacheDir.resolve(CACHE_FILE_NAME) + + // create cache dir + if (Files.notExists(baseCacheDir)) { + Files.createDirectories(baseCacheDir) + } + + // secure the cache dir for admin/root only + PermissionsHelper.secureDaemonDataDirectory(baseCacheDir) + + // load data + val props = Properties() + if (Files.exists(storePathNio) && Files.size(storePathNio) > 0) { + Files.newInputStream(storePathNio).use { props.load(it) } + } + + // save + PropertiesSettings(props) { + try { + FileOutputStream(storePathNio.toFile()).use { output -> + props.store(output, "WireGuard AutoTunnel Daemon Cache") + } + } catch (e: Exception) { + Logger.e(e) { "Failed to save settings to disk" } + } + } + } + + companion object { + const val CACHE_FILE_NAME = "cache.properties" + + private const val KEY_KS_ENABLED = "killswitch_enabled" + private const val KEY_KS_BYPASS_LAN = "killswitch_bypass_lan" + private const val KEY_KS_RESTORE = "killswitch_restore" + private const val KEY_LAST_TUNNEL = "last_active_tunnel" + private const val KEY_LAST_TUNNEL_ID = "last_active_tunnel_id" + private const val KEY_LAST_TUNNEL_NAME = "last_active_tunnel_name" + private const val KEY_RESTORE_ON_BOOT = "restore_on_boot" + + private fun getCacheBaseDir(): Path { + return when { + SystemUtils.IS_OS_MAC_OSX -> Paths.get("/Library/Application Support/wgtunnel") + SystemUtils.IS_OS_WINDOWS -> Paths.get(System.getenv("PROGRAMDATA") + "\\wgtunnel") + else -> Paths.get("/var/lib/wgtunnel") + } + } + } + + override suspend fun updateKillSwitchEnabled(enabled: Boolean) = + settings.set(KEY_KS_ENABLED, enabled) + + override suspend fun getKillSwitchEnabled(): Boolean = + settings.getBoolean(KEY_KS_ENABLED, false) + + override suspend fun updateKillSwitchBypassLan(enabled: Boolean) = + settings.set(KEY_KS_BYPASS_LAN, enabled) + + override suspend fun getKillSwitchBypassLan(): Boolean = + settings.getBoolean(KEY_KS_BYPASS_LAN, false) + + override suspend fun updateKillSwitchRestore(enabled: Boolean) = + settings.set(KEY_KS_RESTORE, enabled) + + override suspend fun getKillSwitchRestore(): Boolean = + settings.getBoolean(KEY_KS_RESTORE, false) + + override suspend fun updateLastActiveTunnelConfig(quick: String) = + settings.set(KEY_LAST_TUNNEL, quick) + + override suspend fun getLastActiveTunnelConfig(): String? = + settings.getStringOrNull(KEY_LAST_TUNNEL) + + override suspend fun updateLastActiveTunnelId(tunnelId: Long) = + settings.set(KEY_LAST_TUNNEL_ID, tunnelId) + + override suspend fun getLastActiveTunnelId(): Long? = settings.getLongOrNull(KEY_LAST_TUNNEL_ID) + + override suspend fun updateLastActiveTunnelName(tunnelName: String) = + settings.set(KEY_LAST_TUNNEL_NAME, tunnelName) + + override suspend fun getLastActiveTunnelName(): String? = + settings.getStringOrNull(KEY_LAST_TUNNEL_NAME) + + override suspend fun setRestoreTunnelOnBoot(enabled: Boolean) = + settings.set(KEY_RESTORE_ON_BOOT, enabled) + + override suspend fun getRestoreTunnelOnBoot(): Boolean = + settings.getBoolean(KEY_RESTORE_ON_BOOT, false) +} diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/model/DaemonCacheData.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/model/DaemonCacheData.kt deleted file mode 100644 index cb0bf3c..0000000 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/model/DaemonCacheData.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.daemon.data.model - -import kotlinx.serialization.Serializable - -@Serializable -data class DaemonCacheData( - val killSwitch: KillSwitchSettings = KillSwitchSettings(false, false), - val startConfigs: Set = emptySet(), -) diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/model/KillSwitchSettings.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/model/KillSwitchSettings.kt deleted file mode 100644 index 83e4b82..0000000 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/data/model/KillSwitchSettings.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.daemon.data.model - -import kotlinx.serialization.Serializable - -@Serializable data class KillSwitchSettings(val enabled: Boolean, val bypassLan: Boolean) diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/di/daemonModule.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/di/daemonModule.kt index cea9d91..30c8078 100644 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/di/daemonModule.kt +++ b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/di/daemonModule.kt @@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.daemon.di import com.zaneschepke.wireguardautotunnel.core.ipc.IPC import com.zaneschepke.wireguardautotunnel.daemon.TunnelDaemon import com.zaneschepke.wireguardautotunnel.daemon.data.DaemonCacheRepository -import com.zaneschepke.wireguardautotunnel.daemon.data.KStoreDaemonCacheRepository +import com.zaneschepke.wireguardautotunnel.daemon.data.SettingsDaemonCacheRepository import com.zaneschepke.wireguardautotunnel.tunnel.AmneziaBackend import com.zaneschepke.wireguardautotunnel.tunnel.Backend import kotlinx.serialization.json.Json @@ -17,6 +17,6 @@ val daemonModule = module { } } single { AmneziaBackend() } - single { KStoreDaemonCacheRepository() } + single { SettingsDaemonCacheRepository() } single { TunnelDaemon(get(), get(), get(), IPC.getDaemonSocketPath()) } } diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/plugin/UDSPlugins.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/plugin/UDSPlugins.kt index b29d5ed..5322a64 100644 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/plugin/UDSPlugins.kt +++ b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/plugin/UDSPlugins.kt @@ -14,8 +14,8 @@ val hmacShieldPlugin = createApplicationPlugin("HmacShield") { onCall { call -> - // ignore daemon routes - if (call.request.path().contains(Routes.DAEMON_BASE)) { + // ignore daemon health calls + if (call.request.path() == Routes.DAEMON_BASE) { return@onCall } diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/backendRoutes.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/backendRoutes.kt index 8a088d3..079a375 100644 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/backendRoutes.kt +++ b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/backendRoutes.kt @@ -5,7 +5,7 @@ import com.zaneschepke.wireguardautotunnel.core.ipc.Routes import com.zaneschepke.wireguardautotunnel.core.ipc.dto.BackendMode import com.zaneschepke.wireguardautotunnel.core.ipc.dto.BackendStatus import com.zaneschepke.wireguardautotunnel.core.ipc.dto.TunnelStatus -import com.zaneschepke.wireguardautotunnel.core.ipc.dto.request.KillSwitchRequest +import com.zaneschepke.wireguardautotunnel.core.ipc.dto.request.FlagRequest import com.zaneschepke.wireguardautotunnel.daemon.dto.toDto import com.zaneschepke.wireguardautotunnel.daemon.dto.toInternal import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig @@ -17,19 +17,17 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.websocket.* -import io.ktor.websocket.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map @OptIn(ExperimentalCoroutinesApi::class) fun Route.backendRoutes(backend: Backend) { - post(Routes.BACKEND_MODE) { + put(Routes.BACKEND_MODE) { val mode = call.receive() Logger.i { "Setting backend mode to $mode" } @@ -37,16 +35,28 @@ fun Route.backendRoutes(backend: Backend) { call.respond(HttpStatusCode.OK, "Backend mode set to $mode") } - post(Routes.BACKEND_KILL_SWITCH) { - val request = call.receive() - - Logger.i { - "Setting kill switch to enabled: ${request.enable} and bypassLan: ${request.bypassLan}" - } + put(Routes.BACKEND_KILL_SWITCH_BYPASS) { + val request = call.receive() + Logger.i { "Setting backend bypass lan to $request" } backend - .setKillSwitch(request.enable) + .setKillSwitchLanBypass(request.value) .onSuccess { - call.respond(HttpStatusCode.OK, "Kill switch set to ${request.enable} successfully") + call.respond( + HttpStatusCode.OK, + "Bypass LAN for kill switch set to ${request.value} successfully", + ) + } + .onFailure { call.respond(HttpStatusCode.BadRequest, it.message ?: "Unknown error") } + } + + put(Routes.BACKEND_KILL_SWITCH) { + val request = call.receive() + + Logger.i { "Setting kill switch to enabled: ${request.value}" } + backend + .setKillSwitch(request.value) + .onSuccess { + call.respond(HttpStatusCode.OK, "Kill switch set to ${request.value} successfully") } .onFailure { if (it is BackendException.StateConflict) diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/daemonRoutes.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/daemonRoutes.kt index 23b67ea..17ad815 100644 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/daemonRoutes.kt +++ b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/daemonRoutes.kt @@ -1,16 +1,37 @@ package com.zaneschepke.wireguardautotunnel.daemon.routes +import co.touchlab.kermit.Logger import com.zaneschepke.wireguardautotunnel.core.ipc.Routes +import com.zaneschepke.wireguardautotunnel.core.ipc.dto.request.FlagRequest +import com.zaneschepke.wireguardautotunnel.daemon.data.DaemonCacheRepository import io.ktor.http.* +import io.ktor.server.request.receive +import io.ktor.server.response.respond import io.ktor.server.routing.* import io.ktor.server.websocket.* import kotlinx.coroutines.awaitCancellation -fun Route.daemonRoutes() { +fun Route.daemonRoutes(daemonCacheRepository: DaemonCacheRepository) { get(Routes.DAEMON_STATUS) { call.response.status(HttpStatusCode.OK) } webSocket(Routes.DAEMON_STATUS_WS) { try { awaitCancellation() } finally {} } + + put(Routes.DAEMON_RESTORE_TUNNEL) { + val request = call.receive() + Logger.d { "Updating restore tunnel to ${request.value}" } + daemonCacheRepository.setRestoreTunnelOnBoot(request.value) + Logger.d { "Successfully updated restore tunnel to ${request.value}" } + call.respond(HttpStatusCode.OK, "Tunnel restore updated to ${request.value}") + } + + put(Routes.DAEMON_RESTORE_KILL_SWITCH) { + val request = call.receive() + Logger.d { "Updating restore kill switch to ${request.value}" } + daemonCacheRepository.updateKillSwitchRestore(request.value) + Logger.d { "Successfully updated restore kill switch to ${request.value}" } + call.respond(HttpStatusCode.OK, "Kill switch restore updated to ${request.value}") + } } diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/tunnelRoutes.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/tunnelRoutes.kt index 84674ea..c2eb94d 100644 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/tunnelRoutes.kt +++ b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/routes/tunnelRoutes.kt @@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.daemon.routes import co.touchlab.kermit.Logger import com.zaneschepke.wireguardautotunnel.core.ipc.Routes import com.zaneschepke.wireguardautotunnel.core.ipc.dto.request.StartTunnelRequest +import com.zaneschepke.wireguardautotunnel.daemon.data.DaemonCacheRepository import com.zaneschepke.wireguardautotunnel.daemon.tunnel.RunningTunnel import com.zaneschepke.wireguardautotunnel.tunnel.Backend import com.zaneschepke.wireguardautotunnel.tunnel.util.BackendException @@ -11,7 +12,7 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -fun Route.tunnelRoutes(backend: Backend) { +fun Route.tunnelRoutes(backend: Backend, daemonCacheRepository: DaemonCacheRepository) { post(Routes.Tunnels.START_TEMPLATE) { val id = @@ -23,6 +24,11 @@ fun Route.tunnelRoutes(backend: Backend) { val tunnel = RunningTunnel(id, request.name) + Logger.d { "Updating daemon cache" } + daemonCacheRepository.updateLastActiveTunnelConfig(request.quickConfig) + daemonCacheRepository.updateLastActiveTunnelId(id) + daemonCacheRepository.updateLastActiveTunnelName(request.name) + backend .start(tunnel, request.quickConfig) .onSuccess { call.respond(HttpStatusCode.OK, "Tunnel ${request.name} started") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4167c89..3c0b45c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ orbitCompose = "11.0.0" sonner = "0.3.9" materialKolor = "4.1.1" nativeTray = "1.1.0" +mps = "1.3.0" # Files kmpIo = "0.3.0" @@ -46,7 +47,6 @@ picocli = "4.7.7" androidx-room = "2.8.4" androidx-sqlite = "2.6.2" -kstore = "1.0.0" lang3 = "3.20.0" @@ -128,8 +128,6 @@ jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jnaPla androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidx-sqlite" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } -kstore = { module = "io.github.xxfast:kstore", version.ref = "kstore" } -kstore-file = { module = "io.github.xxfast:kstore-file", version.ref = "kstore" } # Util apache-commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "lang3" } @@ -173,6 +171,8 @@ filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "file filekit-dialogs = { module = "io.github.vinceglb:filekit-dialogs", version.ref = "filekit" } filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" } +multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "mps" } + [plugins] composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" } diff --git a/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/AmneziaBackend.kt b/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/AmneziaBackend.kt index 8aa3752..9bb8264 100644 --- a/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/AmneziaBackend.kt +++ b/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/AmneziaBackend.kt @@ -11,13 +11,26 @@ import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class AmneziaBackend : Backend { private val tun = AwgTunnel.INSTANCE + private val tunnelMutex = Mutex() + private val killSwitchMutex = Mutex() + private var currentMode: Backend.Mode = Backend.Mode.Userspace - private val _status = MutableStateFlow(Backend.Status(false, currentMode, emptyMap())) + private val _status = + MutableStateFlow( + Backend.Status( + killSwitchEnabled = false, + killSwitchLanBypassEnabled = false, + mode = currentMode, + activeTunnels = emptyMap(), + ) + ) override val status: Flow = _status.asStateFlow() @@ -27,17 +40,27 @@ class AmneziaBackend : Backend { private val backendScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) init { - initKillSwitchStatus() + backendScope.launch { initKillSwitchStatus() } } - private fun initKillSwitchStatus() { - val status = tun.getKillSwitchStatus() - val enabled = status == 1 - _status.update { it.copy(killSwitchEnabled = enabled) } - } + private suspend fun initKillSwitchStatus() = + killSwitchMutex.withLock { + val killSwitchStatus = tun.getKillSwitchStatus() + val killSwitchEnabled = killSwitchStatus == 1 + val bypassEnabled = + if (killSwitchEnabled) { + val bypassStatus = tun.getKillSwitchLanBypassStatus() + bypassStatus == 1 + } else false + _status.update { + it.copy( + killSwitchEnabled = killSwitchEnabled, + killSwitchLanBypassEnabled = bypassEnabled, + ) + } + } - @Synchronized - override fun start(tunnel: Tunnel, config: String): Result = runCatching { + override suspend fun start(tunnel: Tunnel, config: String): Result = runCatching { if (_status.value.activeTunnels.any { it.key.id == tunnel.id }) { throw BackendException.StateConflict("Tunnel ${tunnel.id} is already in use") } @@ -58,11 +81,12 @@ class AmneziaBackend : Backend { trySend(statusCode) } } - val handle = - when (currentMode) { - Backend.Mode.Proxy -> tun.awgProxyTurnOn(config, statusCallback) - Backend.Mode.Userspace -> tun.awgTurnOn(config, statusCallback) + tunnelMutex.withLock { + when (currentMode) { + Backend.Mode.Proxy -> tun.awgProxyTurnOn(config, statusCallback) + Backend.Mode.Userspace -> tun.awgTurnOn(config, statusCallback) + } } if (handle < 0) { @@ -115,8 +139,7 @@ class AmneziaBackend : Backend { } } - @Synchronized - override fun stop(id: Long): Result = runCatching { + override suspend fun stop(id: Long): Result = runCatching { val tunnel = tunnelHandles.keys.find { it.id == id } ?: return Result.failure( @@ -129,9 +152,11 @@ class AmneziaBackend : Backend { BackendException.StateConflict("Tunnel with $id is not active.") ) - when (currentMode) { - Backend.Mode.Proxy -> tun.awgProxyTurnOff(handle) - Backend.Mode.Userspace -> tun.awgTurnOff(handle) + tunnelMutex.withLock { + when (currentMode) { + Backend.Mode.Proxy -> tun.awgProxyTurnOff(handle) + Backend.Mode.Userspace -> tun.awgTurnOff(handle) + } } tunnelJobs.remove(tunnel)?.cancel() @@ -141,7 +166,7 @@ class AmneziaBackend : Backend { _status.update { it.copy(activeTunnels = it.activeTunnels - key) } } - override fun setMode(mode: Backend.Mode) { + override suspend fun setMode(mode: Backend.Mode) { if (mode == currentMode) return shutdown() currentMode = mode @@ -160,7 +185,7 @@ class AmneziaBackend : Backend { _status.update { it.copy(activeTunnels = emptyMap()) } } - override fun getActiveConfig(id: Long): Result { + override suspend fun getActiveConfig(id: Long): Result { val handle = tunnelHandles.keys.find { it.id == id }?.let { tunnelHandles[it] } ?: return Result.failure( @@ -179,31 +204,44 @@ class AmneziaBackend : Backend { } } - override fun setKillSwitch(enabled: Boolean): Result { + override suspend fun setKillSwitch(enabled: Boolean): Result { if (_status.value.killSwitchEnabled == enabled) return Result.failure( BackendException.StateConflict("Kill switch enable: $enabled is already set.") ) - val setValue = if (enabled) 1 else 0 - val status = tun.setKillSwitch(setValue) - if (status == -1) - return Result.failure( - BackendException.InternalError( - "Kill switch failed to start with error code: $status" - ) - ) - val killSwitchEnabled = status == 1 + val killSwitchEnabled = + killSwitchMutex.withLock { + val setValue = if (enabled) 1 else 0 + val status = tun.setKillSwitch(setValue) + if (status == -1) + return Result.failure( + BackendException.InternalError( + "Kill switch failed to start with error code: $status" + ) + ) + status == 1 + } _status.update { it.copy(killSwitchEnabled = killSwitchEnabled) } return Result.success(Unit) } + override suspend fun setKillSwitchLanBypass(enabled: Boolean): Result { + if (!_status.value.killSwitchEnabled) + return Result.failure(BackendException.StateConflict("Kill switch is not active.")) + killSwitchMutex.withLock { + val setValue = if (enabled) 1 else 0 + tun.setKillSwitchLanBypass(setValue) + } + return Result.success(Unit) + } + private fun mapStatusCodeToState(statusCode: Int): Tunnel.State { return when (statusCode) { 0 -> Tunnel.State.Up.Healthy 1 -> Tunnel.State.Up.HandshakeFailure 2 -> Tunnel.State.Up.ResolvingDns 3 -> Tunnel.State.Up.Unknown - else -> Tunnel.State.Down // unknown or negative error code consider down + else -> Tunnel.State.Down } } } diff --git a/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/Backend.kt b/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/Backend.kt index 57ea0a2..0affff3 100644 --- a/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/Backend.kt +++ b/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/Backend.kt @@ -4,17 +4,19 @@ import com.zaneschepke.wireguardautotunnel.tunnel.model.TunnelKey import kotlinx.coroutines.flow.Flow interface Backend { - fun start(tunnel: Tunnel, config: String): Result + suspend fun start(tunnel: Tunnel, config: String): Result - fun stop(id: Long): Result + suspend fun stop(id: Long): Result - fun setMode(mode: Mode) + suspend fun setMode(mode: Mode) - fun setKillSwitch(enabled: Boolean): Result + suspend fun setKillSwitch(enabled: Boolean): Result + + suspend fun setKillSwitchLanBypass(enabled: Boolean): Result fun shutdown() - fun getActiveConfig(id: Long): Result + suspend fun getActiveConfig(id: Long): Result val status: Flow @@ -26,6 +28,7 @@ interface Backend { data class Status( val killSwitchEnabled: Boolean, + val killSwitchLanBypassEnabled: Boolean, val mode: Mode, val activeTunnels: Map, ) diff --git a/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/native/AwgTunnel.kt b/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/native/AwgTunnel.kt index d81171d..49c24fa 100644 --- a/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/native/AwgTunnel.kt +++ b/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/native/AwgTunnel.kt @@ -26,6 +26,10 @@ interface AwgTunnel : Library { fun setKillSwitch(value: Int): Int // 1 for enable, 0 for disable, return 1 or -1 for error + fun setKillSwitchLanBypass(value: Int): Int + + fun getKillSwitchLanBypassStatus(): Int + fun getKillSwitchStatus(): Int // 1 for enabled, 0 for disabled companion object { diff --git a/tunnel/tools/libwg-go/killswitch/killswitch.go b/tunnel/tools/libwg-go/killswitch/killswitch.go index 8000dfa..53ce051 100644 --- a/tunnel/tools/libwg-go/killswitch/killswitch.go +++ b/tunnel/tools/libwg-go/killswitch/killswitch.go @@ -4,6 +4,8 @@ package killswitch import "C" import ( + "net/netip" + "github.com/wgtunnel/desktop/tunnel/shared" "github.com/wgtunnel/desktop/tunnel/vpn/firewall/osfirewall/firewallmgr" ) @@ -50,3 +52,70 @@ func getKillSwitchStatus() C.int { } return C.int(0) } + +//export setKillSwitchLanBypass +func setKillSwitchLanBypass(enabled C.int) C.int { + fw, err := firewallmgr.Get() + if err != nil { + logger.Errorf("Failed to get firewall: %v", err) + return C.int(-1) + } + + if !fw.IsEnabled() { + logger.Errorf("Firewall is not active") + return C.int(-1) + } + + if enabled == 1 { + localPrefixes := []netip.Prefix{ + // IPv4 Private Ranges (RFC 1918) + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.168.0.0/16"), + + // IPv4 Link-Local (APIPA) + netip.MustParsePrefix("169.254.0.0/16"), + + // IPv4 Loopback + netip.MustParsePrefix("127.0.0.0/8"), + + // IPv4 Multicast (for local discovery, e.g., mDNS) + netip.MustParsePrefix("224.0.0.0/4"), + + // IPv6 Unique Local Addresses (ULA, RFC 4193) + netip.MustParsePrefix("fc00::/7"), + + // IPv6 Link-Local (RFC 4291) + netip.MustParsePrefix("fe80::/10"), + + // IPv6 Loopback + netip.MustParsePrefix("::1/128"), + + // IPv6 Multicast (for local discovery) + netip.MustParsePrefix("ff00::/8"), + } + err := fw.AllowLocalNetworks(localPrefixes) + if err != nil { + logger.Errorf("Failed to enable kill switch: %v", err) + return C.int(-1) + } + logger.Verbosef("Kill switch enabled") + } else { + fw.RemoveLocalNetworks() + } + return enabled +} + +//export getKillSwitchLanBypassStatus +func getKillSwitchLanBypassStatus() C.int { + fw, err := firewallmgr.Get() + if err != nil { + logger.Errorf("Failed to get firewall: %v", err) + return C.int(0) + } + + if fw.IsAllowLocalNetworksEnabled() { + return C.int(1) + } + return C.int(0) +} diff --git a/tunnel/tools/libwg-go/vpn/dns/dns_linux.go b/tunnel/tools/libwg-go/vpn/dns/dns_linux.go index 06ab24f..32a6a0a 100644 --- a/tunnel/tools/libwg-go/vpn/dns/dns_linux.go +++ b/tunnel/tools/libwg-go/vpn/dns/dns_linux.go @@ -51,7 +51,7 @@ func newConn() (*Conn, error) { } return &Conn{ conn: conn, - obj: conn.Object(dbusDest, dbus.ObjectPath(dbusPath)), + obj: conn.Object(dbusDest, dbusPath), }, nil } diff --git a/tunnel/tools/libwg-go/vpn/firewall/firewall.go b/tunnel/tools/libwg-go/vpn/firewall/firewall.go index 5a4493d..c97307f 100644 --- a/tunnel/tools/libwg-go/vpn/firewall/firewall.go +++ b/tunnel/tools/libwg-go/vpn/firewall/firewall.go @@ -24,4 +24,9 @@ type Firewall interface { // AllowLocalNetworks adds bypass rules for the specified local network prefixes. Requires kill switch enabled and // operates independently of tunnel/router bypasses. AllowLocalNetworks([]netip.Prefix) error + + // RemoveLocalNetworks removes any rules set by AllowLocalNetworks + RemoveLocalNetworks() error + + IsAllowLocalNetworksEnabled() bool } diff --git a/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_linux.go b/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_linux.go index 57c5745..4ff2e01 100644 --- a/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_linux.go +++ b/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_linux.go @@ -247,10 +247,7 @@ func (f *LinuxFirewall) AllowLocalNetworks(prefixes []netip.Prefix) error { } // remove any old rules - for _, rule := range f.localAddrRules { - f.conn.DelRule(rule) - } - f.localAddrRules = nil + f.RemoveLocalNetworks() // add bypass rules for each prefix for _, table := range f.getTables() { @@ -258,6 +255,18 @@ func (f *LinuxFirewall) AllowLocalNetworks(prefixes []netip.Prefix) error { if err != nil { return fmt.Errorf("get output chain: %w", err) } + + // temp remove drop rules + dropTemplate := createDropRule(table.Filter, outputChain) + existingDrop, err := findRule(f.conn, dropTemplate) + if err != nil { + return fmt.Errorf("find drop rule: %w", err) + } + if existingDrop != nil { + f.conn.DelRule(existingDrop) + } + + // add the local bypass rules for _, prefix := range prefixes { if prefix.Addr().Is6() && !f.v6Available { continue @@ -272,14 +281,33 @@ func (f *LinuxFirewall) AllowLocalNetworks(prefixes []netip.Prefix) error { f.localAddrRules = append(f.localAddrRules, rule) } } + + // add drop rule back + dropRule := createDropRule(table.Filter, outputChain) + f.conn.AddRule(dropRule) } + if err := f.conn.Flush(); err != nil { return fmt.Errorf("flush after bypassing local addrs: %w", err) } + f.logger.Verbosef("Bypassed local addrs: %v", prefixes) return nil } +func (f *LinuxFirewall) RemoveLocalNetworks() error { + for _, rule := range f.localAddrRules { + f.conn.DelRule(rule) + } + f.localAddrRules = nil + + return nil +} + +func (f *LinuxFirewall) IsAllowLocalNetworksEnabled() bool { + return f.localAddrRules != nil +} + func (f *LinuxFirewall) IsEnabled() bool { return f.killSwitchEnabled.Load() } diff --git a/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_macos.go b/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_macos.go deleted file mode 100644 index b067bfe..0000000 --- a/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_macos.go +++ /dev/null @@ -1,290 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright © 2026 WG Tunnel. -// Adapted from Tailscale - -//go:build darwin - -package osfirewall - -import ( - "errors" - "fmt" - "net/netip" - "os" - "os/exec" - "path/filepath" - "strings" - "unsafe" - - "github.com/amnezia-vpn/amneziawg-go/device" - "github.com/wgtunnel/desktop/tunnel/vpn/firewall" - "golang.org/x/net/bpf" - "golang.org/x/sys/unix" -) - -const ( - anchorName = "wgtunnel" - pfConfPath = "/etc/pf.conf" // System PF config; we'll append our anchor -) - -// macFirewall implements the firewall.Firewall interface for macOS using PF (Packet Filter). -type macFirewall struct { - tunnelPort uint16 // WireGuard listen port for inbound punch - killSwitchEnabled bool // Track if kill switch is active (not atomic, as PF is stateful) - v6Available bool // Whether the host supports IPv6 - logger *device.Logger -} - -func New(logger *device.Logger) (firewall.Firewall, error) { - v6err := CheckIPv6(logger) - supportsV6 := v6err == nil - logger.Verbosef("PF mode, v6 support: %v", supportsV6) - - return &macFirewall{ - v6Available: supportsV6, - logger: logger, - }, nil -} - -func (f *macFirewall) HasV6Available() bool { - return f.v6Available -} - -func (f *macFirewall) Active() bool { - return f.killSwitchEnabled -} - -// Enable initializes the firewall (e.g., ensures PF is enabled and our anchor is referenced). -func (f *macFirewall) Up() error { - // Ensure PF is enabled (macOS default is off; enable if needed) - if err := execSudoCommand("pfctl", "-e"); err != nil && !strings.Contains(err.Error(), "already enabled") { - return fmt.Errorf("enable PF: %w", err) - } - - // Add our anchor to /etc/pf.conf if not present (append if needed) - conf, err := os.ReadFile(pfConfPath) - if err != nil { - return fmt.Errorf("read pf.conf: %w", err) - } - if !strings.Contains(string(conf), fmt.Sprintf(`anchor "%s"`, anchorName)) { - if err := os.AppendFile(pfConfPath, []byte(fmt.Sprintf(`\nanchor "%s"\nload anchor "%s" from "/etc/pf.anchors/%s"\n`, anchorName, anchorName, anchorName)), 0644); err != nil { - return fmt.Errorf("append to pf.conf: %w", err) - } - if err := execSudoCommand("pfctl", "-f", pfConfPath); err != nil { - return fmt.Errorf("reload pf.conf: %w", err) - } - } - - f.logger.Verbosef("PF initialized") - return nil -} - -// SetTunnelPort sets the UDP port for the WireGuard tunnel and adds punch rules. -func (f *macFirewall) SetTunnelPort(port uint16) error { - rule := fmt.Sprintf("pass in quick proto udp to any port %d keep state", port) - if err := f.addRuleToAnchor(rule); err != nil { - return fmt.Errorf("add port punch rule: %w", err) - } - f.tunnelPort = port - f.logger.Verbosef("Added tunnel port punch for UDP port %d", port) - return nil -} - -// ToggleKillSwitch enables/disables the kill switch. -func (f *macFirewall) ToggleKillSwitch(enable bool) error { - if enable == f.killSwitchEnabled { - return nil - } - - if enable { - if err := f.addKillSwitchRules(); err != nil { - return fmt.Errorf("add kill switch rules: %w", err) - } - } else { - if err := f.delKillSwitchRules(); err != nil { - return fmt.Errorf("del kill switch rules: %w", err) - } - } - - f.killSwitchEnabled = enable - f.logger.Verbosef("Kill switch toggled: %v", enable) - return nil -} - -// addKillSwitchRules adds PF rules for kill switch (block non-tunnel outbound, with exemptions). -func (f *macFirewall) addKillSwitchRules() error { - rules := []string{ - "block out all", // Default block outbound - "pass out quick on utun0 all", // Allow on tunnel (adjust 'utun0' dynamically if needed) - "pass out quick to all", // Placeholder for SetBypassRoutes - // Add loopback allowance - "pass out quick on lo0 all", - "pass in quick on lo0 all", - // Established/related (PF handles keep state) - "pass out all keep state", - "pass in all keep state", - } - - if err := f.writeRulesToAnchor(rules); err != nil { - return err - } - f.logger.Verbosef("Kill switch rules added") - return nil -} - -// delKillSwitchRules removes kill switch rules by clearing the anchor. -func (f *macFirewall) delKillSwitchRules() error { - if err := f.clearAnchor(); err != nil { - return err - } - f.logger.Verbosef("Kill switch rules removed") - return nil -} - -// SetBypassRoutes adds exemptions for bootstrap routes. -func (f *macFirewall) SetBypassRoutes(bypassRoutes []netip.Prefix) error { - var rules []string - for _, route := range bypassRoutes { - rules = append(rules, fmt.Sprintf("pass out quick to %s all", route.String())) - } - if err := f.addRulesToAnchor(rules); err != nil { - return fmt.Errorf("add bypass routes: %w", err) - } - f.logger.Verbosef("Added bypass routes: %v", bypassRoutes) - return nil -} - -// TemporaryBypassSocket uses BPF to attach a filter to the socket for bypass. -func (f *macFirewall) TemporaryBypassSocket(fd int) (func() error, error) { - // Compile a simple BPF program to allow specific traffic (e.g., UDP to VPN ports) - // Example: Allow UDP dport == your tunnel port (adjust as needed) - // This is a basic UDP check; extend for port/IP as needed - instructions := []bpf.Instruction{ - bpf.LoadAbsolute{Off: 9, Size: 1}, // Load IP protocol (offset 9 in IP header) - bpf.JumpIf{Cond: bpf.JumpEqual, Val: 17, SkipFalse: 2}, // Check if UDP (17), jump to reject if not - // Add port check here if needed, e.g.: - // bpf.LoadAbsolute{Off: 22, Size: 2}, // Load UDP dport (network byte order, offset 20 src +2 dst in UDP) - // bpf.JumpIf{Cond: bpf.JumpEqual, Val: uint32(f.tunnelPort), SkipFalse: 1}, - bpf.RetConstant{Val: 65535}, // Accept (return max packet length) - bpf.RetConstant{Val: 0}, // Reject - } - - prog, err := bpf.Assemble(instructions) // Compile to machine code - if err != nil { - return nil, fmt.Errorf("assemble BPF: %w", err) - } - - // Prepare SockFprog struct - sockFprog := unix.SockFprog{ - Len: uint16(len(prog)), - Filter: (*unix.Sockfilter)(unsafe.Pointer(&prog[0])), - } - - // Attach to socket using exported SetsockoptSockFprog - if err := unix.SetsockoptSockFprog(fd, unix.SOL_SOCKET, unix.SO_ATTACH_FILTER, &sockFprog); err != nil { - return nil, fmt.Errorf("attach BPF to fd %d: %w", fd, err) - } - f.logger.Verbosef("BPF bypass attached to fd %d", fd) - - return func() error { - // Detach BPF - if err := unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_DETACH_FILTER, 0); err != nil { - f.logger.Errorf("Failed to detach BPF on fd %d: %v", fd, err) - return err - } - f.logger.Verbosef("BPF detached from fd %d", fd) - return nil - }, nil -} - -// Helper: writeRulesToAnchor writes rules to anchor file and reloads. -func (f *macFirewall) writeRulesToAnchor(rules []string) error { - anchorPath := filepath.Join("/etc/pf.anchors", anchorName) - content := strings.Join(rules, "\n") - if err := os.WriteFile(anchorPath, []byte(content), 0644); err != nil { - return fmt.Errorf("write anchor file: %w", err) - } - return f.reloadAnchor() -} - -// addRuleToAnchor appends a single rule and reloads. -func (f *macFirewall) addRuleToAnchor(rule string) error { - anchorPath := filepath.Join("/etc/pf.anchors", anchorName) - file, err := os.OpenFile(anchorPath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - return fmt.Errorf("open anchor file: %w", err) - } - defer file.Close() - if _, err := file.WriteString(rule + "\n"); err != nil { - return fmt.Errorf("append rule: %w", err) - } - return f.reloadAnchor() -} - -// addRulesToAnchor appends multiple rules and reloads. -func (f *macFirewall) addRulesToAnchor(rules []string) error { - anchorPath := filepath.Join("/etc/pf.anchors", anchorName) - file, err := os.OpenFile(anchorPath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - return fmt.Errorf("open anchor file: %w", err) - } - defer file.Close() - for _, rule := range rules { - if _, err := file.WriteString(rule + "\n"); err != nil { - return fmt.Errorf("append rule: %w", err) - } - } - return f.reloadAnchor() -} - -// clearAnchor clears the anchor file and reloads. -func (f *macFirewall) clearAnchor() error { - anchorPath := filepath.Join("/etc/pf.anchors", anchorName) - if err := os.WriteFile(anchorPath, []byte{}, 0644); err != nil { - return fmt.Errorf("clear anchor file: %w", err) - } - return f.reloadAnchor() -} - -// reloadAnchor reloads the PF anchor. -func (f *macFirewall) reloadAnchor() error { - if err := execSudoCommand("pfctl", "-a", anchorName, "-F", "all"); err != nil { - return fmt.Errorf("flush anchor: %w", err) - } - if err := execSudoCommand("pfctl", "-a", anchorName, "-f", filepath.Join("/etc/pf.anchors", anchorName)); err != nil { - return fmt.Errorf("load anchor: %w", err) - } - return nil -} - -// execSudoCommand runs a command with sudo (assumes sudo is available; handle prompts in app if needed). -func execSudoCommand(name string, args ...string) error { - cmd := exec.Command("sudo", append([]string{name}, args...)...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%s: %w\nOutput: %s", name, err, output) - } - return nil -} - -func CheckIPv6(logger *device.Logger) error { - // Similar to Linux: Check sysctl or interfaces for IPv6 - interfaces, err := net.Interfaces() - if err != nil { - return err - } - for _, iface := range interfaces { - addrs, err := iface.Addrs() - if err != nil { - continue - } - for _, addr := range addrs { - ip, _, err := net.ParseCIDR(addr.String()) - if err == nil && ip.To16() != nil && ip.To4() == nil { - logger.Verbosef("IPv6 detected on interface %s", iface.Name) - return nil - } - } - } - return errors.New("no IPv6 interfaces found") -} diff --git a/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_windows.go b/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_windows.go index 5b7ff13..e685568 100644 --- a/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_windows.go +++ b/tunnel/tools/libwg-go/vpn/firewall/osfirewall/firewall_windows.go @@ -134,12 +134,7 @@ func New(logger *device.Logger) (firewall.Firewall, error) { func (f *WindowsFirewall) AllowLocalNetworks(addrs []netip.Prefix) error { // cleanup old local addr rules - if err := f.removeRules(f.localAddrRules); err != nil { - f.logger.Errorf("Failed to remove old local addr rules: %v", err) - } - f.mu.Lock() - f.localAddrRules = nil - f.mu.Unlock() + f.RemoveLocalNetworks() // add new rules addedByPrefix, err := f.addPermissiveRulesForPrefixes(addrs, "bypass for local addr ") @@ -156,6 +151,21 @@ func (f *WindowsFirewall) AllowLocalNetworks(addrs []netip.Prefix) error { return nil } +func (f *WindowsFirewall) RemoveLocalNetworks() error { + if err := f.removeRules(f.localAddrRules); err != nil { + f.logger.Errorf("Failed to remove old local addr rules: %v", err) + } + f.mu.Lock() + f.localAddrRules = nil + f.mu.Unlock() + + return nil +} + +func (f *WindowsFirewall) IsAllowLocalNetworksEnabled() bool { + return f.localAddrRules != nil +} + func (f *WindowsFirewall) UpdatePermittedRoutes(newRoutes []netip.Prefix) error { f.mu.Lock() // routes to remove