Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/di/HttpModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import to.bitkit.utils.UrlValidator
import javax.inject.Singleton
import javax.net.ssl.SSLSocketFactory
import io.ktor.client.plugins.logging.Logger as KtorLogger

@Module
Expand All @@ -47,6 +48,10 @@ object HttpModule {
}
}

@Provides
@Singleton
fun provideSslSocketFactory(): SSLSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory

@Provides
@Singleton
fun provideUrlValidator(httpClient: HttpClient) = UrlValidator { url ->
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,11 @@ class LightningRepo @Inject constructor(
Logger.warn("Failed ldk-node config change, recovering in background…", context = TAG)
scope.launch { restartWithPreviousConfig() }
}.onSuccess {
settingsStore.update { it.copy(electrumServer = newServerUrl) }
runSuspendCatching { settingsStore.update { it.copy(electrumServer = newServerUrl) } }
.onFailure {
Logger.error("Failed to persist electrum server '$newServerUrl'", it, context = TAG)
return@withContext Result.failure(it)
}

Logger.info("Successfully changed electrum server", context = TAG)
}
Expand Down
45 changes: 42 additions & 3 deletions app/src/main/java/to/bitkit/services/ElectrumProbeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ import java.io.InputStream
import java.io.Writer
import java.net.InetSocketAddress
import java.net.Socket
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.inject.Inject
import javax.inject.Singleton
import javax.net.ssl.SSLPeerUnverifiedException
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import kotlin.time.Duration.Companion.seconds
Expand All @@ -38,6 +41,7 @@ import kotlin.time.Duration.Companion.seconds
@Singleton
class ElectrumProbeService @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val sslSocketFactory: SSLSocketFactory,
) {
companion object {
private const val TAG = "ElectrumProbeService"
Expand All @@ -59,6 +63,9 @@ class ElectrumProbeService @Inject constructor(

/** JSON-RPC id of the `server.features` request. */
private const val FEATURES_REQUEST_ID = 1

/** JSSE endpoint identification that checks the certificate's name, not only its chain. */
private const val HOSTNAME_VERIFICATION = "HTTPS"
}

// Deliberately not the injected Json: that one sets prettyPrint, and electrum is line-delimited,
Expand Down Expand Up @@ -110,14 +117,17 @@ class ElectrumProbeService @Inject constructor(
// A TLS handshake against a plain-TCP server hangs without a read timeout, which is the
// misconfiguration that wedges the node's release when it is left to node.start().
return runCatching {
val factory = SSLSocketFactory.getDefault() as SSLSocketFactory
val ssl = factory.createSocket(plain, server.host, server.getPort(), true) as SSLSocket
val ssl = sslSocketFactory.createSocket(plain, server.host, server.getPort(), true) as SSLSocket
ssl.soTimeout = CONNECT_TIMEOUT.inWholeMilliseconds.toInt()
// A raw SSLSocket validates the chain but not the name the certificate was issued for, so
// a CA-valid certificate for another host probes clean and is only rejected afterwards by
// the node's own electrum client — after the restart this probe exists to avoid.
ssl.sslParameters = ssl.sslParameters.apply { endpointIdentificationAlgorithm = HOSTNAME_VERIFICATION }
ssl.startHandshake()
ssl
}.getOrElse {
plain.runCatching { close() }
throw ElectrumProbeError.ProtocolMismatch(server, it)
throw it.toTlsProbeError(server)
}
}

Expand Down Expand Up @@ -216,13 +226,42 @@ private data class RpcResponse(

private fun JsonElement?.isNullOrJsonNull() = this == null || this is JsonNull

/** Depth the handshake failure's cause chain is walked to, enough for JSSE's wrapping and cycle-proof. */
private const val MAX_CAUSE_DEPTH = 8

// A failed TLS handshake is only evidence of the wrong protocol or port when the peer did not speak
// TLS at all. A server that presented a certificate Bitkit does not trust — self-signed Fulcrum or
// electrs on its SSL port — has the protocol right, so it must not be told to check it.
internal fun Throwable.toTlsProbeError(server: ElectrumServer): ElectrumProbeError =
if (isCertificateFailure()) {
ElectrumProbeError.UntrustedCertificate(server, this)
} else {
ElectrumProbeError.ProtocolMismatch(server, this)
}

private fun Throwable.isCertificateFailure(): Boolean {
var cause: Throwable? = this
var depth = 0
while (cause != null && depth < MAX_CAUSE_DEPTH) {
when (cause) {
is CertificateException, is CertPathValidatorException, is SSLPeerUnverifiedException -> return true
else -> cause = cause.cause
}
depth++
}
return false
}

sealed class ElectrumProbeError(message: String, cause: Throwable? = null) : AppError(message, cause) {
class Unreachable(server: ElectrumServer, cause: Throwable) :
ElectrumProbeError("Could not reach electrum server '$server'", cause)

class ProtocolMismatch(server: ElectrumServer, cause: Throwable) :
ElectrumProbeError("Failed TLS handshake with electrum server '$server', check the protocol", cause)

class UntrustedCertificate(server: ElectrumServer, cause: Throwable) :
ElectrumProbeError("Rejected untrusted certificate of electrum server '$server'", cause)

class NotElectrum(server: ElectrumServer, cause: Throwable? = null) :
ElectrumProbeError("Received no electrum response from '$server'", cause)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
Expand All @@ -29,7 +27,6 @@ import androidx.navigation.NavController
import to.bitkit.R
import to.bitkit.models.ElectrumProtocol
import to.bitkit.models.ElectrumServerPeer
import to.bitkit.models.Toast
import to.bitkit.ui.appViewModel
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.Caption13Up
Expand All @@ -53,31 +50,6 @@ fun ElectrumConfigScreen(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val app = appViewModel ?: return
val context = LocalContext.current

// Monitor connection results
LaunchedEffect(uiState.connectionResult) {
uiState.connectionResult?.let { result ->
if (result.isSuccess) {
app.toast(
type = Toast.ToastType.SUCCESS,
title = context.getString(R.string.settings__es__server_updated_title),
description = context.getString(R.string.settings__es__server_updated_message)
.replace("{host}", uiState.host)
.replace("{port}", uiState.port),
testTag = "ElectrumUpdatedToast",
)
} else {
app.toast(
type = Toast.ToastType.WARNING,
title = context.getString(R.string.settings__es__server_error),
description = context.getString(R.string.settings__es__server_error_description),
testTag = "ElectrumErrorToast",
)
}
viewModel.clearConnectionResult()
}
}

Content(
uiState = uiState,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package to.bitkit.ui.settings.advanced

import android.content.Context
import androidx.annotation.StringRes
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand All @@ -26,6 +27,7 @@ import to.bitkit.models.MAX_VALID_PORT
import to.bitkit.models.Toast
import to.bitkit.models.getDefaultPort
import to.bitkit.repositories.LightningRepo
import to.bitkit.services.ElectrumProbeError
import to.bitkit.ui.shared.toast.ToastEventBus
import javax.inject.Inject

Expand Down Expand Up @@ -150,33 +152,33 @@ class ElectrumConfigViewModel @Inject constructor(
_uiState.update { it.copy(isLoading = true) }

viewModelScope.launch(bgDispatcher) {
runCatching {
val electrumServer = ElectrumServer.fromUserInput(
host = currentState.host,
port = port,
protocol = protocol,
)
val serverUrl = electrumServer.toString()

lightningRepo.restartWithElectrumServer(serverUrl)
.onSuccess {
_uiState.update {
it.copy(
isLoading = false,
connectionResult = Result.success(Unit),
hasEdited = false,
)
}
}
.onFailure { error -> throw error }
}.onFailure { e ->
_uiState.update {
it.copy(
isLoading = false,
connectionResult = Result.failure(e),
val serverUrl = ElectrumServer.fromUserInput(
host = currentState.host,
port = port,
protocol = protocol,
).toString()

lightningRepo.restartWithElectrumServer(serverUrl)
Comment thread
jvsena42 marked this conversation as resolved.
.onSuccess {
_uiState.update { it.copy(isLoading = false, hasEdited = false) }
ToastEventBus.send(
type = Toast.ToastType.SUCCESS,
title = context.getString(R.string.settings__es__server_updated_title),
description = context.getString(R.string.settings__es__server_updated_message)
.replace("{host}", currentState.host)
.replace("{port}", currentState.port),
testTag = "ElectrumUpdatedToast",
)
}
.onFailure { error ->
_uiState.update { it.copy(isLoading = false) }
ToastEventBus.send(
type = Toast.ToastType.WARNING,
title = context.getString(R.string.settings__es__server_error),
description = context.getString(error.toServerErrorDescriptionRes()),
testTag = "ElectrumErrorToast",
)
}
}
}
}

Expand Down Expand Up @@ -248,10 +250,6 @@ class ElectrumConfigViewModel @Inject constructor(
return uiPeer != state.connectedPeer
}

fun clearConnectionResult() {
_uiState.update { it.copy(connectionResult = null) }
}

fun onClickConnect() {
viewModelScope.launch(bgDispatcher) {
val validationError = validateInput()
Expand Down Expand Up @@ -357,6 +355,13 @@ data class ElectrumConfigUiState(
val port: String = "",
val protocol: ElectrumProtocol? = null,
val isLoading: Boolean = false,
val connectionResult: Result<Unit>? = null,
val hasEdited: Boolean = false,
)

@StringRes
private fun Throwable.toServerErrorDescriptionRes(): Int = when (this) {
is ElectrumProbeError.NetworkMismatch -> R.string.settings__es__server_error_network
is ElectrumProbeError.ProtocolMismatch -> R.string.settings__es__server_error_protocol
Comment thread
jvsena42 marked this conversation as resolved.
is ElectrumProbeError.UntrustedCertificate -> R.string.settings__es__server_error_certificate
else -> R.string.settings__es__server_error_description
}
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,10 @@
<string name="settings__es__port">Port</string>
<string name="settings__es__protocol">Protocol</string>
<string name="settings__es__server_error">Electrum Connection Failed</string>
<string name="settings__es__server_error_certificate">This server\'s certificate is not trusted. Use a server with a trusted certificate, or connect to its TCP port.</string>
<string name="settings__es__server_error_description">Bitkit could not establish a connection to Electrum.</string>
<string name="settings__es__server_error_network">This server is on a different Bitcoin network. Choose a server for the network Bitkit is using.</string>
<string name="settings__es__server_error_protocol">Secure connection failed. Check that the protocol (TCP or TLS) matches the server port.</string>
<string name="settings__es__server_updated_message">Successfully connected to {host}:{port}</string>
<string name="settings__es__server_updated_title">Electrum Server Updated</string>
<string name="settings__fee__custom__description">Depends on fee</string>
Expand Down
16 changes: 16 additions & 0 deletions app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,22 @@ class LightningRepoTest : BaseUnitTest() {
assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState)
}

// Regression: the settings write after a successful restart must surface as a failed Result.
// Thrown out of the returned Result it escapes the caller's coroutine, which leaves the
// Electrum config screen spinning with no toast.
@Test
fun `restartWithElectrumServer returns failure when persisting the server fails`() = test {
startNodeForTesting()
val customServerUrl = "ssl://test.example.com:50002"
whenever(lightningService.node).thenReturn(null)
whenever(lightningService.stop()).thenReturn(Unit)
whenever(settingsStore.update(any())).thenThrow(RuntimeException("write failed"))

val result = sut.restartWithElectrumServer(customServerUrl)

assertTrue(result.isFailure)
}

@Test
fun `restartWithElectrumServer should handle stop failure`() = test {
startNodeForTesting()
Expand Down
Loading
Loading