mirror of
https://github.com/wgtunnel/desktop.git
synced 2026-06-02 00:29:09 +02:00
feat: add kill switch and tunnel restore on boot
This commit is contained in:
+2
-2
@@ -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<Int> {
|
||||
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
|
||||
|
||||
+2
-2
@@ -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<Int> {
|
||||
private val backendService: BackendCommandService by inject(BackendCommandService::class.java)
|
||||
private val backendService: BackendService by inject(BackendService::class.java)
|
||||
|
||||
override fun call(): Int = runBlocking { fetchSnapshot() }
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ class TunnelDeleteCommand : Callable<Int> {
|
||||
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
|
||||
|
||||
+2
-2
@@ -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<Int> {
|
||||
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(
|
||||
|
||||
+2
-2
@@ -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<Int> {
|
||||
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(
|
||||
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+3
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+8
-1
@@ -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,
|
||||
)
|
||||
|
||||
+4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-15
@@ -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<Unit> = safeDaemonCall {
|
||||
client.post(Routes.BACKEND_MODE) { setBody(mode) }
|
||||
client.put(Routes.BACKEND_MODE) { setBody(mode) }
|
||||
}
|
||||
|
||||
override suspend fun setKillSwitch(enabled: Boolean): Result<Unit> {
|
||||
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<Unit> {
|
||||
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<BackendStatus> = runCatching {
|
||||
val response = client.get(Routes.BACKEND_STATUS)
|
||||
response.body<BackendStatus>()
|
||||
+29
-2
@@ -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<Unit> {
|
||||
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<Unit> {
|
||||
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<Boolean> =
|
||||
callbackFlow {
|
||||
while (isActive) {
|
||||
+3
-3
@@ -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()
|
||||
|
||||
+12
-10
@@ -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<DaemonHealthService> { UdsDaemonHealthService(get()) }
|
||||
single<TunnelCommandService> { UdsTunnelCommandService(get(), tunnelRepository = get()) }
|
||||
single<BackendCommandService> { UdsBackendCommandService(get(), get(), get()) }
|
||||
single<DaemonService> { UdsDaemonService(get(), get(), get()) }
|
||||
single<TunnelService> { UdsTunnelService(get(), tunnelRepository = get()) }
|
||||
single<BackendService> { UdsBackendService(get(), get(), get()) }
|
||||
|
||||
single<TunnelImportService> { DefaultTunnelImportService(get()) }
|
||||
}
|
||||
|
||||
+2
@@ -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()
|
||||
}
|
||||
|
||||
+1
@@ -9,4 +9,5 @@ data class GeneralSettings(
|
||||
val theme: Theme = Theme.DARK,
|
||||
val locale: String? = null,
|
||||
val alreadyDonated: Boolean = false,
|
||||
val restoreTunnelOnBoot: Boolean = false,
|
||||
)
|
||||
|
||||
+2
@@ -16,4 +16,6 @@ interface GeneralSettingRepository {
|
||||
suspend fun updateLocale(locale: String)
|
||||
|
||||
suspend fun updateAlreadyDonated(donated: Boolean)
|
||||
|
||||
suspend fun updateRestoreTunnelOnBoot(enabled: Boolean)
|
||||
}
|
||||
|
||||
+3
-1
@@ -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<Unit>
|
||||
|
||||
suspend fun setKillSwitch(enabled: Boolean): Result<Unit>
|
||||
|
||||
suspend fun setKillSwitchLanBypass(enabled: Boolean): Result<Unit>
|
||||
|
||||
suspend fun getStatus(): Result<BackendStatus>
|
||||
|
||||
fun statusFlow(): Flow<BackendStatus>
|
||||
+5
-1
@@ -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<Unit>
|
||||
|
||||
suspend fun setRestoreTunnel(enabled: Boolean): Result<Unit>
|
||||
|
||||
val alive: Flow<Boolean>
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.client.service
|
||||
|
||||
interface TunnelCommandService {
|
||||
interface TunnelService {
|
||||
suspend fun startTunnel(id: Long): Result<Unit>
|
||||
|
||||
suspend fun stopTunnel(id: Long): Result<Unit>
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+20
-11
@@ -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) },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+14
-13
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+70
-33
@@ -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 = {
|
||||
|
||||
+1
@@ -5,4 +5,5 @@ data class SettingsUiState(
|
||||
val lockdownEnabled: Boolean = false,
|
||||
val lockdownRestoreOnBootEnabled: Boolean = false,
|
||||
val lockdownBypassEnabled: Boolean = false,
|
||||
val tunnelRestoreOnBootEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
+1
@@ -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."
|
||||
}
|
||||
|
||||
+6
-8
@@ -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<AppUiState, AppSideEffect>, 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) }
|
||||
}
|
||||
}
|
||||
|
||||
+26
-6
@@ -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<SettingsUiState, AppSideEffect>, 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-34
@@ -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<TunnelUiState, AppSideEffect>, 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 {
|
||||
|
||||
+17
-25
@@ -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<TunnelsUiState, AppSideEffect>, 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))
|
||||
}
|
||||
|
||||
+12
-3
@@ -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
|
||||
}
|
||||
|
||||
+53
-1
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-6
@@ -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<String>
|
||||
suspend fun updateKillSwitchRestore(enabled: Boolean)
|
||||
|
||||
suspend fun setStartConfigs(configs: Set<String>)
|
||||
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
|
||||
}
|
||||
|
||||
-109
@@ -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<DaemonCacheData> {
|
||||
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<String> {
|
||||
return getStore().get()?.startConfigs ?: emptySet()
|
||||
}
|
||||
|
||||
override suspend fun setStartConfigs(configs: Set<String>) {
|
||||
val store = getStore()
|
||||
store.update { current ->
|
||||
current?.copy(startConfigs = configs) ?: DaemonCacheData(startConfigs = configs)
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -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)
|
||||
}
|
||||
-9
@@ -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<String> = emptySet(),
|
||||
)
|
||||
-5
@@ -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)
|
||||
+2
-2
@@ -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<Backend> { AmneziaBackend() }
|
||||
single<DaemonCacheRepository> { KStoreDaemonCacheRepository() }
|
||||
single<DaemonCacheRepository> { SettingsDaemonCacheRepository() }
|
||||
single { TunnelDaemon(get(), get(), get(), IPC.getDaemonSocketPath()) }
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+22
-12
@@ -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<BackendMode>()
|
||||
|
||||
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<KillSwitchRequest>()
|
||||
|
||||
Logger.i {
|
||||
"Setting kill switch to enabled: ${request.enable} and bypassLan: ${request.bypassLan}"
|
||||
}
|
||||
put(Routes.BACKEND_KILL_SWITCH_BYPASS) {
|
||||
val request = call.receive<FlagRequest>()
|
||||
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<FlagRequest>()
|
||||
|
||||
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)
|
||||
|
||||
+22
-1
@@ -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<FlagRequest>()
|
||||
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<FlagRequest>()
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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") }
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+69
-31
@@ -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<Backend.Status> = _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<Unit> = runCatching {
|
||||
override suspend fun start(tunnel: Tunnel, config: String): Result<Unit> = 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<Unit> = runCatching {
|
||||
override suspend fun stop(id: Long): Result<Unit> = 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<String?> {
|
||||
override suspend fun getActiveConfig(id: Long): Result<String?> {
|
||||
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<Unit> {
|
||||
override suspend fun setKillSwitch(enabled: Boolean): Result<Unit> {
|
||||
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<Unit> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Unit>
|
||||
suspend fun start(tunnel: Tunnel, config: String): Result<Unit>
|
||||
|
||||
fun stop(id: Long): Result<Unit>
|
||||
suspend fun stop(id: Long): Result<Unit>
|
||||
|
||||
fun setMode(mode: Mode)
|
||||
suspend fun setMode(mode: Mode)
|
||||
|
||||
fun setKillSwitch(enabled: Boolean): Result<Unit>
|
||||
suspend fun setKillSwitch(enabled: Boolean): Result<Unit>
|
||||
|
||||
suspend fun setKillSwitchLanBypass(enabled: Boolean): Result<Unit>
|
||||
|
||||
fun shutdown()
|
||||
|
||||
fun getActiveConfig(id: Long): Result<String?>
|
||||
suspend fun getActiveConfig(id: Long): Result<String?>
|
||||
|
||||
val status: Flow<Status>
|
||||
|
||||
@@ -26,6 +28,7 @@ interface Backend {
|
||||
|
||||
data class Status(
|
||||
val killSwitchEnabled: Boolean,
|
||||
val killSwitchLanBypassEnabled: Boolean,
|
||||
val mode: Mode,
|
||||
val activeTunnels: Map<TunnelKey, Tunnel.State>,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 <bypass_ips> 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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user