From a14a66db14c5cfc5d36c64fe6d720bd237d4c9f7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:36:36 +0000 Subject: [PATCH] core, ui: chat relay test (#6736) --- apps/ios/Shared/Model/AppAPITypes.swift | 41 + apps/ios/Shared/Model/SimpleXAPI.swift | 9 + .../NetworkAndServers/ChatRelayView.swift | 142 ++- .../NetworkAndServers/OperatorView.swift | 1 + .../ProtocolServersView.swift | 37 +- apps/ios/SimpleXChat/APITypes.swift | 1 + apps/ios/SimpleXChat/ChatTypes.swift | 17 +- .../chat/simplex/common/model/ChatModel.kt | 11 +- .../chat/simplex/common/model/SimpleXAPI.kt | 54 ++ .../common/views/helpers/TextEditor.kt | 6 +- .../networkAndServers/ChatRelayView.kt | 156 +++- .../networkAndServers/OperatorView.kt | 29 +- .../networkAndServers/ProtocolServersView.kt | 86 +- .../commonMain/resources/MR/base/strings.xml | 11 +- bots/api/TYPES.md | 15 +- bots/src/API/Docs/Commands.hs | 2 + bots/src/API/Docs/Responses.hs | 1 + bots/src/API/Docs/Types.hs | 2 + cabal.project | 2 +- .../types/typescript/src/types.ts | 13 +- plans/2026-04-01-agent-sign-for-address.md | 61 ++ plans/2026-04-01-test-chat-relay-plan.md | 813 ++++++++++++++++++ scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 2 + src/Simplex/Chat/Controller.hs | 32 +- src/Simplex/Chat/Library/Commands.hs | 60 +- src/Simplex/Chat/Library/Subscriber.hs | 60 +- src/Simplex/Chat/Operators.hs | 9 +- src/Simplex/Chat/Protocol.hs | 22 + src/Simplex/Chat/Store/Direct.hs | 24 +- src/Simplex/Chat/Store/Groups.hs | 4 +- .../Migrations/M20260222_chat_relays.hs | 4 + src/Simplex/Chat/Store/Profiles.hs | 8 +- .../Migrations/M20260222_chat_relays.hs | 4 + .../SQLite/Migrations/chat_query_plans.txt | 16 + .../Store/SQLite/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Store/Shared.hs | 15 + src/Simplex/Chat/View.hs | 12 +- tests/ChatTests/ChatRelays.hs | 30 + tests/OperatorTests.hs | 3 +- 40 files changed, 1670 insertions(+), 148 deletions(-) create mode 100644 plans/2026-04-01-agent-sign-for-address.md create mode 100644 plans/2026-04-01-test-chat-relay-plan.md diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index a08e1ffbc5..9f3f9decb1 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -91,6 +91,7 @@ enum ChatCommand: ChatCmdProtocol { case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) case apiAcceptMemberContact(contactId: Int64) case apiTestProtoServer(userId: Int64, server: String) + case apiTestChatRelay(userId: Int64, address: String) case apiGetServerOperators case apiSetServerOperators(operators: [ServerOperator]) case apiGetUserServers(userId: Int64) @@ -289,6 +290,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" case let .apiAcceptMemberContact(contactId): return "/_accept member contact @\(contactId)" case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" + case let .apiTestChatRelay(userId, address): return "/_relay test \(userId) \(address)" case .apiGetServerOperators: return "/_operators" case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" case let .apiGetUserServers(userId): return "/_servers \(userId)" @@ -478,6 +480,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" case .apiAcceptMemberContact: return "apiAcceptMemberContact" case .apiTestProtoServer: return "apiTestProtoServer" + case .apiTestChatRelay: return "apiTestChatRelay" case .apiGetServerOperators: return "apiGetServerOperators" case .apiSetServerOperators: return "apiSetServerOperators" case .apiGetUserServers: return "apiGetUserServers" @@ -669,6 +672,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { case chatTags(user: UserRef, userTags: [ChatTag]) case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) + case chatRelayTestResult(user: UserRef, relayProfile: RelayProfile?, relayTestFailure: RelayTestFailure?) case serverOperatorConditions(conditions: ServerOperatorConditions) case userServers(user: UserRef, userServers: [UserOperatorServers]) case userServersValidation(user: UserRef, serverErrors: [UserServersError], serverWarnings: [UserServersWarning]) @@ -703,6 +707,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { case .chatTags: "chatTags" case .chatItemInfo: "chatItemInfo" case .serverTestResult: "serverTestResult" + case .chatRelayTestResult: "chatRelayTestResult" case .serverOperatorConditions: "serverOperators" case .userServers: "userServers" case .userServersValidation: "userServersValidation" @@ -739,6 +744,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") + case let .chatRelayTestResult(u, relayProfile, relayTestFailure): return withUser(u, "relayProfile: \(String(describing: relayProfile))\nresult: \(String(describing: relayTestFailure))") case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") case let .userServersValidation(u, serverErrors, serverWarnings): return withUser(u, "serverErrors: \(String(describing: serverErrors))\nserverWarnings: \(String(describing: serverWarnings))") @@ -2011,6 +2017,41 @@ struct ProtocolTestFailure: Decodable, Error, Equatable { } } +public enum RelayTestStep: String, Decodable { + case getLink + case decodeLink + case connect + case waitResponse + case verify + + var text: String { + switch self { + case .getLink: return NSLocalizedString("Get link", comment: "relay test step") + case .decodeLink: return NSLocalizedString("Decode link", comment: "relay test step") + case .connect: return NSLocalizedString("Connect", comment: "relay test step") + case .waitResponse: return NSLocalizedString("Wait response", comment: "relay test step") + case .verify: return NSLocalizedString("Verify", comment: "relay test step") + } + } +} + +public struct RelayTestFailure: Decodable, Error { + public var rtfStep: RelayTestStep + public var rtfError: ChatError + + var localizedDescription: String { + let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "relay test failure"), rtfStep.text) + switch rtfError { + case .errorAgent(agentError: .SMP(_, .AUTH)): + return err + " " + NSLocalizedString("Server requires authorization to connect to relay, check password.", comment: "relay test error") + case .errorAgent(agentError: .BROKER(_, .NETWORK(.unknownCAError))): + return err + " " + NSLocalizedString("Fingerprint in server address does not match certificate.", comment: "relay test error") + default: + return err + " " + String.localizedStringWithFormat(NSLocalizedString("Error: %@.", comment: "relay test error"), String(describing: rtfError)) + } + } +} + struct MigrationFileLinkData: Codable { let networkConfig: NetworkConfig? diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 9e4f50b0af..e527df1abd 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -758,6 +758,15 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail throw r.unexpected } +func testChatRelay(address: String) async throws -> (RelayProfile?, RelayTestFailure?) { + let userId = try currentUserId("testChatRelay") + let r: ChatResponse0 = try await chatSendCmd(.apiTestChatRelay(userId: userId, address: address)) + if case let .chatRelayTestResult(_, relayProfile, relayTestFailure) = r { + return (relayProfile, relayTestFailure) + } + throw r.unexpected +} + func getServerOperators() async throws -> ServerOperatorConditions { let r: ChatResponse0 = try await chatSendCmd(.apiGetServerOperators) if case let .serverOperatorConditions(conditions) = r { return conditions } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift index df9ea10adf..2bcfb6ca87 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift @@ -92,6 +92,9 @@ struct ChatRelayView: View { @Binding var relay: UserChatRelay @State var relayToEdit: UserChatRelay var backLabel: LocalizedStringKey + @State private var showTestFailure = false + @State private var testing = false + @State private var testFailure: RelayTestFailure? var body: some View { let validName = validRelayName(relayToEdit.name) @@ -102,6 +105,9 @@ struct ChatRelayView: View { } else { customRelay(validName: validName, validAddress: validAddress) } + if testing { + ProgressView().scaleEffect(2) + } } .modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) { if validName && validAddress { @@ -122,6 +128,20 @@ struct ChatRelayView: View { ) } }) + .alert(isPresented: $showTestFailure) { + Alert( + title: Text("Relay test failed!"), + message: Text(testFailure?.localizedDescription ?? "") + ) + } + .onChange(of: relayToEdit.address) { _ in + if relayToEdit.address == relay.address { + relayToEdit.tested = relay.tested + relayToEdit.name = relay.name + } else { + relayToEdit.tested = nil + } + } } private func relayNameHeader(validName: Bool) -> some View { @@ -137,25 +157,19 @@ struct ChatRelayView: View { private func presetRelay() -> some View { List { - Section(header: Text("Preset relay name").foregroundColor(theme.colors.secondary)) { - Text(relayToEdit.name) - } Section(header: Text("Preset relay address").foregroundColor(theme.colors.secondary)) { Text(relayToEdit.address) .textSelection(.enabled) } + Section(header: Text("Preset relay name").foregroundColor(theme.colors.secondary)) { + Text(relayToEdit.name) + } useRelaySection() } } private func customRelay(validName: Bool, validAddress: Bool) -> some View { List { - Section { - TextField("Enter relay name…", text: $relayToEdit.name) - .autocorrectionDisabled(true) - } header: { - relayNameHeader(validName: validName) - } Section { TextEditor(text: $relayToEdit.address) .multilineTextAlignment(.leading) @@ -175,6 +189,17 @@ struct ChatRelayView: View { } } } + Section { + TextField("Enter relay name…", text: $relayToEdit.name) + .autocorrectionDisabled(true) + .disabled(relayToEdit.tested == true) + } header: { + relayNameHeader(validName: validName) + } footer: { + if relayToEdit.tested != true { + Text("**Test relay** to retrieve its name.") + } + } useRelaySection(valid: validAddress) Section { Button(role: .destructive) { @@ -193,12 +218,17 @@ struct ChatRelayView: View { Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { HStack { Button("Test relay") { - showAlert( - NSLocalizedString("Not implemented", comment: "alert title"), - message: NSLocalizedString("Relay testing is not yet available.", comment: "alert message") - ) + testing = true + relayToEdit.tested = nil + Task { + if let f = await testRelayConnection(relay: $relayToEdit) { + showTestFailure = true + testFailure = f + } + await MainActor.run { testing = false } + } } - .disabled(!valid) + .disabled(!valid || testing) Spacer() showRelayTestStatus(relay: relayToEdit) } @@ -267,24 +297,15 @@ struct NewChatRelayView: View { chatRelayId: nil, address: "", name: "", domains: [], preset: false, tested: nil, enabled: true, deleted: false ) + @State private var showTestFailure = false + @State private var testing = false + @State private var testFailure: RelayTestFailure? var body: some View { let validName = validRelayName(relayToEdit.name) let validAddress = validRelayAddress(relayToEdit.address) + ZStack { List { - Section { - TextField("Enter relay name…", text: $relayToEdit.name) - .autocorrectionDisabled(true) - } header: { - HStack { - Text("Your relay name").foregroundColor(theme.colors.secondary) - if !validName { - Spacer() - Image(systemName: "exclamationmark.circle").foregroundColor(.red) - .onTapGesture { showInvalidRelayNameAlert($relayToEdit.name) } - } - } - } Section { TextEditor(text: $relayToEdit.address) .multilineTextAlignment(.leading) @@ -304,23 +325,80 @@ struct NewChatRelayView: View { } } } + Section { + TextField("Enter relay name…", text: $relayToEdit.name) + .autocorrectionDisabled(true) + .disabled(relayToEdit.tested == true) + } header: { + HStack { + Text("Your relay name").foregroundColor(theme.colors.secondary) + if !validName { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + .onTapGesture { showInvalidRelayNameAlert($relayToEdit.name) } + } + } + } footer: { + if relayToEdit.tested != true { + Text("**Test relay** to retrieve its name.") + } + } Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { HStack { Button("Test relay") { - showAlert( - NSLocalizedString("Not implemented", comment: "alert title"), - message: NSLocalizedString("Relay testing is not yet available.", comment: "alert message") - ) + testing = true + relayToEdit.tested = nil + Task { + if let f = await testRelayConnection(relay: $relayToEdit) { + showTestFailure = true + testFailure = f + } + await MainActor.run { testing = false } + } } - .disabled(!validAddress) + .disabled(!validAddress || testing) Spacer() showRelayTestStatus(relay: relayToEdit) } Toggle("Use for new channels", isOn: $relayToEdit.enabled) } } + if testing { + ProgressView().scaleEffect(2) + } + } .modifier(BackButton(disabled: Binding.constant(false)) { addChatRelay(relayToEdit, $userServers, $serverErrors, $serverWarnings, dismiss) }) + .alert(isPresented: $showTestFailure) { + Alert( + title: Text("Relay test failed!"), + message: Text(testFailure?.localizedDescription ?? "") + ) + } + .onChange(of: relayToEdit.address) { _ in + relayToEdit.tested = nil + } + } +} + +func testRelayConnection(relay: Binding) async -> RelayTestFailure? { + do { + let (relayProfile, testFailure) = try await testChatRelay(address: relay.wrappedValue.address) + if let f = testFailure { + await MainActor.run { relay.wrappedValue.tested = false } + return f + } + await MainActor.run { + relay.wrappedValue.tested = true + if let relayProfile { + relay.wrappedValue.name = relayProfile.name + } + } + return nil + } catch { + logger.error("testRelayConnection \(responseError(error))") + await MainActor.run { relay.wrappedValue.tested = false } + return nil } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index bc4fb4a337..ea6c2ea40c 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -259,6 +259,7 @@ struct OperatorView: View { TestServersButton( smpServers: $userServers[operatorIndex].smpServers, xftpServers: $userServers[operatorIndex].xftpServers, + chatRelays: $userServers[operatorIndex].chatRelays, testing: $testing ) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index 60ab42e8fa..4e7b4040cd 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -182,6 +182,7 @@ struct YourServersView: View { TestServersButton( smpServers: $userServers[operatorIndex].smpServers, xftpServers: $userServers[operatorIndex].xftpServers, + chatRelays: $userServers[operatorIndex].chatRelays, testing: $testing ) howToButton() @@ -352,6 +353,7 @@ func deleteChatRelay( struct TestServersButton: View { @Binding var smpServers: [UserServer] @Binding var xftpServers: [UserServer] + @Binding var chatRelays: [UserChatRelay] @Binding var testing: Bool var body: some View { @@ -360,20 +362,24 @@ struct TestServersButton: View { } private var allServersDisabled: Bool { - smpServers.allSatisfy { !$0.enabled } && xftpServers.allSatisfy { !$0.enabled } + smpServers.allSatisfy { !$0.enabled } && + xftpServers.allSatisfy { !$0.enabled } && + chatRelays.filter({ !$0.deleted }).allSatisfy { !$0.enabled } } private func testServers() { resetTestStatus() testing = true Task { - let fs = await runServersTest() + let rfs = await runRelaysTest() + let sfs = await runServersTest() await MainActor.run { testing = false - if !fs.isEmpty { - let msg = fs.map { (srv, f) in - "\(srv): \(f.localizedDescription)" - }.joined(separator: "\n") + var failures: [String] = [] + failures += rfs.map { (name, f) in "\(name): \(f.localizedDescription)" } + failures += sfs.map { (srv, f) in "\(srv): \(f.localizedDescription)" } + if !failures.isEmpty { + let msg = failures.joined(separator: "\n") showAlert( NSLocalizedString("Tests failed!", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Some servers failed the test:\n%@", comment: "alert message"), msg) @@ -384,6 +390,12 @@ struct TestServersButton: View { } private func resetTestStatus() { + for i in 0.. [String: RelayTestFailure] { + var fs: [String: RelayTestFailure] = [:] + for i in 0.. Bool { - l.chatRelayId == r.chatRelayId && l.address == r.address && l.name == r.name && l.domains == r.domains && + l.chatRelayId == r.chatRelayId && l.address == r.address && l.relayProfile == r.relayProfile && l.domains == r.domains && l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled && l.deleted == r.deleted } @@ -2595,7 +2604,7 @@ public struct UserChatRelay: Identifiable, Codable, Equatable, Hashable { public enum CodingKeys: CodingKey { case chatRelayId case address - case name + case relayProfile case domains case preset case tested diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index cd7f55fcb8..becfb0b66e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2277,11 +2277,16 @@ enum class RelayStatus { } } +@Serializable +data class RelayProfile( + val name: String +) + @Serializable data class UserChatRelay( val chatRelayId: Long?, val address: String, - val name: String, + val relayProfile: RelayProfile, val domains: List, val preset: Boolean, val tested: Boolean? = null, @@ -2291,6 +2296,10 @@ data class UserChatRelay( @Transient private val createdAt: Date = Date() val id: String get() = "$address $createdAt" + + val name: String get() = relayProfile.name + + fun copyWithName(name: String): UserChatRelay = copy(relayProfile = RelayProfile(name = name)) } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 4ea44aca15..5652fcc67f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1216,6 +1216,14 @@ object ChatController { throw Exception("testProtoServer bad response: ${r.responseType} ${r.details}") } + suspend fun testChatRelay(rh: Long?, address: String): Pair { + val userId = currentUserId("testChatRelay") + val r = sendCmd(rh, CC.APITestChatRelay(userId, address)) + if (r is API.Result && r.res is CR.ChatRelayTestResult) return r.res.relayProfile to r.res.relayTestFailure + Log.e(TAG, "testChatRelay bad response: ${r.responseType} ${r.details}") + throw Exception("testChatRelay bad response: ${r.responseType} ${r.details}") + } + suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? { val r = sendCmd(rh, CC.ApiGetServerOperators()) if (r is API.Result && r.res is CR.ServerOperatorConditions) return r.res.conditions @@ -3637,6 +3645,7 @@ sealed class CC { class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() class APIAcceptMemberContact(val contactId: Long): CC() class APITestProtoServer(val userId: Long, val server: String): CC() + class APITestChatRelay(val userId: Long, val address: String): CC() class ApiGetServerOperators(): CC() class ApiSetServerOperators(val operators: List): CC() class ApiGetUserServers(val userId: Long): CC() @@ -3837,6 +3846,7 @@ sealed class CC { is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" is APIAcceptMemberContact -> "/_accept member contact @$contactId" is APITestProtoServer -> "/_server test $userId $server" + is APITestChatRelay -> "/_relay test $userId $address" is ApiGetServerOperators -> "/_operators" is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}" is ApiGetUserServers -> "/_servers $userId" @@ -4015,6 +4025,7 @@ sealed class CC { is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" is APIAcceptMemberContact -> "apiAcceptMemberContact" is APITestProtoServer -> "testProtoServer" + is APITestChatRelay -> "apiTestChatRelay" is ApiGetServerOperators -> "apiGetServerOperators" is ApiSetServerOperators -> "apiSetServerOperators" is ApiGetUserServers -> "apiGetUserServers" @@ -4679,6 +4690,44 @@ data class ProtocolTestFailure( } } +@Serializable +enum class RelayTestStep { + @SerialName("getLink") GetLink, + @SerialName("decodeLink") DecodeLink, + @SerialName("connect") Connect, + @SerialName("waitResponse") WaitResponse, + @SerialName("verify") Verify; + + val text: String get() = when (this) { + GetLink -> generalGetString(MR.strings.relay_test_step_get_link) + DecodeLink -> generalGetString(MR.strings.relay_test_step_decode_link) + Connect -> generalGetString(MR.strings.relay_test_step_connect) + WaitResponse -> generalGetString(MR.strings.relay_test_step_wait_response) + Verify -> generalGetString(MR.strings.relay_test_step_verify) + } +} + +@Serializable +data class RelayTestFailure( + val rtfStep: RelayTestStep, + val rtfError: ChatError +) { + val localizedDescription: String get() { + val err = String.format(generalGetString(MR.strings.error_relay_test_failed_at_step), rtfStep.text) + return when { + rtfError is ChatError.ChatErrorAgent && + rtfError.agentError is AgentErrorType.SMP && rtfError.agentError.smpErr is SMPErrorType.AUTH -> + err + " " + generalGetString(MR.strings.error_relay_test_server_auth) + rtfError is ChatError.ChatErrorAgent && + rtfError.agentError is AgentErrorType.BROKER && rtfError.agentError.brokerErr is BrokerErrorType.NETWORK && + rtfError.agentError.brokerErr.networkError is NetworkError.UnknownCAError -> + err + " " + generalGetString(MR.strings.error_smp_test_certificate) + else -> + err + " " + String.format(generalGetString(MR.strings.error_with_info), rtfError.string) + } + } +} + @Serializable data class ServerAddress( val serverProtocol: ServerProtocol, @@ -6206,6 +6255,7 @@ sealed class CR { @Serializable @SerialName("chatTags") class ChatTags(val user: UserRef, val userTags: List): CR() @Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() + @Serializable @SerialName("chatRelayTestResult") class ChatRelayTestResult(val user: UserRef, val relayProfile: RelayProfile? = null, val relayTestFailure: RelayTestFailure? = null): CR() @Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR() @Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List): CR() @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List, val serverWarnings: List = emptyList()): CR() @@ -6393,6 +6443,7 @@ sealed class CR { is ChatTags -> "chatTags" is ApiChatItemInfo -> "chatItemInfo" is ServerTestResult -> "serverTestResult" + is ChatRelayTestResult -> "chatRelayTestResult" is ServerOperatorConditions -> "serverOperatorConditions" is UserServers -> "userServers" is UserServersValidation -> "userServersValidation" @@ -6572,6 +6623,7 @@ sealed class CR { is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") + is ChatRelayTestResult -> withUser(user, "relayProfile: $relayProfile\ntestFailure: $relayTestFailure") is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}" is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}") is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}") @@ -7179,6 +7231,7 @@ sealed class ChatErrorType { is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited" is ConnectionUserChangeProhibited -> "connectionUserChangeProhibited" is PeerChatVRangeIncompatible -> "peerChatVRangeIncompatible" + is RelayTestError -> "relayTestError $message" is InternalError -> "internalError" is CEException -> "exception $message" } @@ -7260,6 +7313,7 @@ sealed class ChatErrorType { @Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType() @Serializable @SerialName("connectionUserChangeProhibited") object ConnectionUserChangeProhibited: ChatErrorType() @Serializable @SerialName("peerChatVRangeIncompatible") object PeerChatVRangeIncompatible: ChatErrorType() + @Serializable @SerialName("relayTestError") class RelayTestError(val message: String): ChatErrorType() @Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType() @Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index da16e2b7e7..e8070b5c76 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -32,7 +32,8 @@ fun TextEditor( placeholder: String? = null, contentPadding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING), isValid: (String) -> Boolean = { true }, - focusRequester: FocusRequester? = null + focusRequester: FocusRequester? = null, + enabled: Boolean = true ) { var valid by rememberSaveable { mutableStateOf(true) } var focused by rememberSaveable { mutableStateOf(false) } @@ -64,6 +65,7 @@ fun TextEditor( value = value.value, onValueChange = { value.value = it }, modifier = if (focusRequester == null) textFieldModifier else textFieldModifier.focusRequester(focusRequester), + enabled = enabled, textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -83,7 +85,7 @@ fun TextEditor( leadingIcon = null, trailingIcon = null, singleLine = false, - enabled = true, + enabled = enabled, isError = false, interactionSource = remember { MutableInteractionSource() }, colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt index 646ca816e7..7886c3b8b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt @@ -4,6 +4,7 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionItemView import SectionItemViewSpaceBetween +import SectionTextFooter import SectionView import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.selection.SelectionContainer @@ -25,6 +26,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.res.MR import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch @Composable fun ShowRelayTestStatus(relay: UserChatRelay, modifier: Modifier = Modifier) = @@ -115,6 +117,18 @@ fun ChatRelayView( ) { val relayToEdit = remember { mutableStateOf(relay) } + LaunchedEffect(Unit) { + snapshotFlow { relayToEdit.value.address } + .distinctUntilChanged() + .collect { + if (relayToEdit.value.address == relay.address) { + relayToEdit.value = relayToEdit.value.copy(tested = relay.tested, relayProfile = relay.relayProfile) + } else { + relayToEdit.value = relayToEdit.value.copy(tested = null) + } + } + } + ModalView( close = { val validName = validRelayName(relayToEdit.value.name) @@ -149,25 +163,25 @@ private fun ChatRelayLayout( relay: MutableState, onDelete: (() -> Unit)? ) { - ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.chat_relay)) - if (relay.value.preset) { - PresetRelay(relay) - } else { - CustomRelay(relay, onDelete) + val testing = remember { mutableStateOf(false) } + Box { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.chat_relay)) + if (relay.value.preset) { + PresetRelay(relay, testing) + } else { + CustomRelay(relay, onDelete, testing) + } + SectionBottomSpacer() + } + if (testing.value) { + DefaultProgressView(null) } - SectionBottomSpacer() } } @Composable -private fun PresetRelay(relay: MutableState) { - SectionView(stringResource(MR.strings.preset_relay_name).uppercase()) { - SectionItemView { - Text(relay.value.name) - } - } - SectionDividerSpaced() +private fun PresetRelay(relay: MutableState, testing: MutableState) { SectionView(stringResource(MR.strings.preset_relay_address).uppercase()) { SelectionContainer { Text( @@ -178,13 +192,20 @@ private fun PresetRelay(relay: MutableState) { } } SectionDividerSpaced() - UseRelaySection(relay) + SectionView(stringResource(MR.strings.preset_relay_name).uppercase()) { + SectionItemView { + Text(relay.value.name) + } + } + SectionDividerSpaced() + UseRelaySection(relay, testing = testing) } @Composable private fun CustomRelay( relay: MutableState, - onDelete: (() -> Unit)? + onDelete: (() -> Unit)?, + testing: MutableState ) { val relayName = remember { mutableStateOf(relay.value.name) } val relayAddress = remember { mutableStateOf(relay.value.address) } @@ -194,7 +215,12 @@ private fun CustomRelay( LaunchedEffect(Unit) { snapshotFlow { relayName.value } .distinctUntilChanged() - .collect { relay.value = relay.value.copy(name = it) } + .collect { relay.value = relay.value.copyWithName(it) } + } + LaunchedEffect(Unit) { + snapshotFlow { relay.value.name } + .distinctUntilChanged() + .collect { relayName.value = it } } LaunchedEffect(Unit) { snapshotFlow { relayAddress.value } @@ -202,6 +228,18 @@ private fun CustomRelay( .collect { relay.value = relay.value.copy(address = it) } } + SectionView( + stringResource(MR.strings.your_relay_address).uppercase(), + icon = painterResource(MR.images.ic_error), + iconTint = if (!validAddress.value) MaterialTheme.colors.error else Color.Transparent, + ) { + TextEditor( + relayAddress, + Modifier.height(144.dp) + ) + } + SectionDividerSpaced(maxTopPadding = true) + Column { val iconSize = with(LocalDensity.current) { 21.sp.toDp() } Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { @@ -224,25 +262,17 @@ private fun CustomRelay( TextEditor( relayName, Modifier, - placeholder = generalGetString(MR.strings.enter_relay_name) + placeholder = generalGetString(MR.strings.enter_relay_name), + enabled = relay.value.tested != true ) } } - SectionDividerSpaced(maxTopPadding = true) - - SectionView( - stringResource(MR.strings.your_relay_address).uppercase(), - icon = painterResource(MR.images.ic_error), - iconTint = if (!validAddress.value) MaterialTheme.colors.error else Color.Transparent, - ) { - TextEditor( - relayAddress, - Modifier.height(144.dp) - ) + if (relay.value.tested != true) { + SectionTextFooter(annotatedStringResource(MR.strings.test_relay_to_retrieve_name)) } SectionDividerSpaced(maxTopPadding = true) - UseRelaySection(relay, validAddress.value) + UseRelaySection(relay, validAddress.value, testing) if (onDelete != null) { SectionDividerSpaced() @@ -257,21 +287,31 @@ private fun CustomRelay( @Composable private fun UseRelaySection( relay: MutableState, - valid: Boolean = true + valid: Boolean = true, + testing: MutableState ) { + val scope = rememberCoroutineScope() SectionView(stringResource(MR.strings.use_relay).uppercase()) { SectionItemViewSpaceBetween( click = { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.not_implemented), - text = generalGetString(MR.strings.relay_testing_not_available) - ) + testing.value = true + relay.value = relay.value.copy(tested = null) + scope.launch { + val f = testRelayConnection(relay) + if (f != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_test_failed_alert), + text = f.localizedDescription + ) + } + testing.value = false + } }, - disabled = !valid + disabled = !valid || testing.value ) { Text( stringResource(MR.strings.test_relay), - color = if (valid) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary + color = if (valid && !testing.value) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary ) ShowRelayTestStatus(relay.value) } @@ -322,12 +362,20 @@ fun ModalData.NewChatRelayView( val relayToEdit = remember { mutableStateOf( UserChatRelay( - chatRelayId = null, address = "", name = "", domains = emptyList(), + chatRelayId = null, address = "", relayProfile = RelayProfile(name = ""), domains = emptyList(), preset = false, tested = null, enabled = true, deleted = false ) ) } + LaunchedEffect(Unit) { + snapshotFlow { relayToEdit.value.address } + .distinctUntilChanged() + .collect { + relayToEdit.value = relayToEdit.value.copy(tested = null) + } + } + ModalView(close = { addChatRelay(relayToEdit.value, userServers, serverErrors, serverWarnings, rhId, close) }) { @@ -337,9 +385,33 @@ fun ModalData.NewChatRelayView( @Composable private fun NewChatRelayLayout(relay: MutableState) { - ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.new_chat_relay)) - CustomRelay(relay, onDelete = null) - SectionBottomSpacer() + val testing = remember { mutableStateOf(false) } + Box { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.new_chat_relay)) + CustomRelay(relay, onDelete = null, testing = testing) + SectionBottomSpacer() + } + if (testing.value) { + DefaultProgressView(null) + } } } + +suspend fun testRelayConnection(relay: MutableState): RelayTestFailure? = + try { + val (relayProfile, testFailure) = chatModel.controller.testChatRelay(chatModel.remoteHostId(), relay.value.address) + if (testFailure != null) { + relay.value = relay.value.copy(tested = false) + testFailure + } else { + relay.value = relay.value.copy(tested = true).let { + if (relayProfile != null) it.copyWithName(relayProfile.name) else it + } + null + } + } catch (e: Exception) { + Log.e(TAG, "testRelayConnection ${e.stackTraceToString()}") + relay.value = relay.value.copy(tested = false) + null + } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index 48faedfb77..8ed1d0910f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -444,21 +444,30 @@ fun OperatorViewLayout( testing = testing, smpServers = userServers.value[operatorIndex].smpServers, xftpServers = userServers.value[operatorIndex].xftpServers, - ) { p, l -> - when (p) { - ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { - this[operatorIndex] = this[operatorIndex].copy( - xftpServers = l - ) - } + chatRelays = userServers.value[operatorIndex].chatRelays, + onUpdate = { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } - ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + }, + onUpdateRelays = { relays -> + userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( - smpServers = l + chatRelays = relays ) } } - } + ) } SectionBottomSpacer() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 3776ccf2d9..2f5a165eb5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -203,21 +203,30 @@ fun YourServersViewLayout( testing = testing, smpServers = userServers.value[operatorIndex].smpServers, xftpServers = userServers.value[operatorIndex].xftpServers, - ) { p, l -> - when (p) { - ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { - this[operatorIndex] = this[operatorIndex].copy( - xftpServers = l - ) - } + chatRelays = userServers.value[operatorIndex].chatRelays, + onUpdate = { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } - ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + }, + onUpdateRelays = { relays -> + userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( - smpServers = l + chatRelays = relays ) } } - } + ) HowToButton() } @@ -229,16 +238,20 @@ fun YourServersViewLayout( fun TestServersButton( smpServers: List, xftpServers: List, + chatRelays: List = emptyList(), testing: MutableState, - onUpdate: (ServerProtocol, List) -> Unit + onUpdate: (ServerProtocol, List) -> Unit, + onUpdateRelays: ((List) -> Unit)? = null ) { val scope = rememberCoroutineScope() - val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled }) || testing.value } + val disabled = derivedStateOf { + (smpServers.none { it.enabled } && xftpServers.none { it.enabled } && chatRelays.filter { !it.deleted }.none { it.enabled }) || testing.value + } SectionItemView( { scope.launch { - testServers(testing, smpServers, xftpServers, chatModel, onUpdate) + testServers(testing, smpServers, xftpServers, chatRelays, chatModel, onUpdate, onUpdateRelays) } }, disabled = disabled.value @@ -338,20 +351,28 @@ private suspend fun testServers( testing: MutableState, smpServers: List, xftpServers: List, + chatRelays: List, m: ChatModel, - onUpdate: (ServerProtocol, List) -> Unit + onUpdate: (ServerProtocol, List) -> Unit, + onUpdateRelays: ((List) -> Unit)? ) { + val relaysResetStatus = resetRelayTestStatus(chatRelays) + onUpdateRelays?.invoke(relaysResetStatus) val smpResetStatus = resetTestStatus(smpServers) onUpdate(ServerProtocol.SMP, smpResetStatus) val xftpResetStatus = resetTestStatus(xftpServers) onUpdate(ServerProtocol.XFTP, xftpResetStatus) testing.value = true + val relayFailures = runRelaysTest(relaysResetStatus) { onUpdateRelays?.invoke(it) } val smpFailures = runServersTest(smpResetStatus, m) { onUpdate(ServerProtocol.SMP, it) } val xftpFailures = runServersTest(xftpResetStatus, m) { onUpdate(ServerProtocol.XFTP, it) } testing.value = false - val fs = smpFailures + xftpFailures - if (fs.isNotEmpty()) { - val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n") + val failures = mutableListOf() + failures += relayFailures.map { (name, f) -> "$name: ${f.localizedDescription}" } + failures += smpFailures.map { (srv, f) -> "$srv: ${f.localizedDescription}" } + failures += xftpFailures.map { (srv, f) -> "$srv: ${f.localizedDescription}" } + if (failures.isNotEmpty()) { + val msg = failures.joinToString("\n") AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.smp_servers_test_failed), text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg @@ -389,6 +410,37 @@ private suspend fun runServersTest(servers: List, m: ChatModel, onUp return fs } +private fun resetRelayTestStatus(relays: List): List { + val copy = ArrayList(relays) + for ((index, relay) in relays.withIndex()) { + if (relay.enabled && !relay.deleted) { + copy.removeAt(index) + copy.add(index, relay.copy(tested = null)) + } + } + return copy +} + +private suspend fun runRelaysTest(relays: List, onUpdated: (List) -> Unit): Map { + val fs: MutableMap = mutableMapOf() + val updatedRelays = ArrayList(relays) + for ((index, relay) in relays.withIndex()) { + if (relay.enabled && !relay.deleted) { + interruptIfCancelled() + val relayState = mutableStateOf(relay) + val f = testRelayConnection(relayState) + updatedRelays.removeAt(index) + updatedRelays.add(index, relayState.value) + onUpdated(updatedRelays.toList()) + if (f != null) { + val name = relayState.value.name.ifEmpty { relayState.value.domains.firstOrNull() ?: relayState.value.address } + fs[name] = f + } + } + } + return fs +} + fun deleteXFTPServer( userServers: MutableState>, operatorServersIndex: Int, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 27fe7c85c7..dc6810ca81 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2840,8 +2840,15 @@ Test relay Use for new channels Delete relay - Not implemented - Relay testing is not yet available. + Test relay to retrieve its name.]]> + Relay test failed! + Get link + Decode link + Connect + Wait response + Verify + Test failed at step %s. + Server requires authorization to connect to relay, check password. Invalid relay name! Check relay name and try again. Invalid relay address! diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 62f774ac52..95d3c63509 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -148,6 +148,7 @@ This file is generated automatically. - [RcvFileStatus](#rcvfilestatus) - [RcvFileTransfer](#rcvfiletransfer) - [RcvGroupEvent](#rcvgroupevent) +- [RelayProfile](#relayprofile) - [RelayStatus](#relaystatus) - [ReportReason](#reportreason) - [RoleGroupPreference](#rolegrouppreference) @@ -1224,6 +1225,10 @@ ConnectionUserChangeProhibited: PeerChatVRangeIncompatible: - type: "peerChatVRangeIncompatible" +RelayTestError: +- type: "relayTestError" +- message: string + InternalError: - type: "internalError" - message: string @@ -3174,6 +3179,14 @@ MsgBadSignature: - type: "msgBadSignature" +--- + +## RelayProfile + +**Record type**: +- name: string + + --- ## RelayStatus @@ -3920,7 +3933,7 @@ Handshake: **Record type**: - chatRelayId: int64 - address: string -- name: string +- relayProfile: [RelayProfile](#relayprofile) - domains: [string] - preset: bool - tested: bool? diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 03f1e53441..26b48e56b0 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -414,6 +414,7 @@ undocumentedCommands = "APISwitchGroupMember", "APISyncContactRatchet", "APISyncGroupMemberRatchet", + "APITestChatRelay", "APITestProtoServer", "APIUnhideUser", "APIUnmuteUser", @@ -471,6 +472,7 @@ undocumentedCommands = "StopRemoteHost", "StoreRemoteFile", "SwitchRemoteHost", + "TestChatRelay", "TestProtoServer", "TestStorageEncryption", "VerifyRemoteCtrlSession" diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 915496cec0..873ca5eb97 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -132,6 +132,7 @@ undocumentedResponses = "CRChatItemInfo", "CRChatItems", "CRChatItemTTL", + "CRChatRelayTestResult", "CRChats", "CRConnectionsDiff", "CRChatTags", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index f112dff3df..37fc6121ce 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -331,6 +331,7 @@ chatTypesDocsData = (sti @RcvFileStatus, STUnion, "RFS", [], "", ""), (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), + (sti @RelayProfile, STRecord, "", [], "", ""), (sti @RelayStatus, STEnum, "RS", [], "", ""), (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), @@ -534,6 +535,7 @@ deriving instance Generic RcvFileDescr deriving instance Generic RcvFileStatus deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent +deriving instance Generic RelayProfile deriving instance Generic RelayStatus deriving instance Generic ReportReason deriving instance Generic SecurityCode diff --git a/cabal.project b/cabal.project index f039f60f83..33dde2dd0b 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 9c07ddff3cbd2302f0f02c5506db89e261bca1e0 + tag: 9bc0c70fa0604a11f7f19d4b4415b0bb7414582c source-repository-package type: git diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 8930969d27..0b0d92b526 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -1048,6 +1048,7 @@ export type ChatErrorType = | ChatErrorType.ConnectionIncognitoChangeProhibited | ChatErrorType.ConnectionUserChangeProhibited | ChatErrorType.PeerChatVRangeIncompatible + | ChatErrorType.RelayTestError | ChatErrorType.InternalError | ChatErrorType.Exception @@ -1125,6 +1126,7 @@ export namespace ChatErrorType { | "connectionIncognitoChangeProhibited" | "connectionUserChangeProhibited" | "peerChatVRangeIncompatible" + | "relayTestError" | "internalError" | "exception" @@ -1479,6 +1481,11 @@ export namespace ChatErrorType { type: "peerChatVRangeIncompatible" } + export interface RelayTestError extends Interface { + type: "relayTestError" + message: string + } + export interface InternalError extends Interface { type: "internalError" message: string @@ -3591,6 +3598,10 @@ export namespace RcvGroupEvent { } } +export interface RelayProfile { + name: string +} + export enum RelayStatus { New = "new", Invited = "invited", @@ -4641,7 +4652,7 @@ export interface User { export interface UserChatRelay { chatRelayId: number // int64 address: string - name: string + relayProfile: RelayProfile domains: string[] preset: boolean tested?: boolean diff --git a/plans/2026-04-01-agent-sign-for-address.md b/plans/2026-04-01-agent-sign-for-address.md new file mode 100644 index 0000000000..c648574399 --- /dev/null +++ b/plans/2026-04-01-agent-sign-for-address.md @@ -0,0 +1,61 @@ +# Plan: Agent API — getConnLinkPrivKey + +**Date: 2026-04-01** + +## Context + +The chat relay test (`APITestChatRelay`) requires the relay to sign a challenge with its address private key (`ShortLinkCreds.linkPrivSigKey`). This key is stored in the agent's database on `RcvQueue` and is not accessible from the chat layer. A new agent API function is needed to retrieve it. + +The chat layer performs the signing itself with `C.sign'`. + +## API + +```haskell +getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) +``` + +- `ConnId` — the agent connection ID +- Returns — `Just linkPrivSigKey` if the connection has short link credentials, `Nothing` otherwise + +## Implementation + +**File: `simplexmq/src/Simplex/Messaging/Agent.hs`** + +1. Add to module exports: + ```haskell + getConnLinkPrivKey, + ``` + +2. Add public function (near `getConnShortLink`, ~line 427): + ```haskell + getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) + getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c + {-# INLINE getConnLinkPrivKey #-} + ``` + +3. Add implementation (near `deleteConnShortLink'`, ~line 1089): + ```haskell + getConnLinkPrivKey' :: AgentClient -> ConnId -> AM (Maybe C.PrivateKeyEd25519) + getConnLinkPrivKey' c connId = do + SomeConn _ conn <- withStore c (`getConn` connId) + pure $ case conn of + ContactConnection _ rq -> linkPrivSigKey <$> shortLink rq + RcvConnection _ rq -> linkPrivSigKey <$> shortLink rq + _ -> Nothing + ``` + +## Design notes + +- Local operation (no network IO) — synchronous, fast +- No `withConnLock` — this is a pure read with no mutations; the lock would add latency for no benefit. Read-only agent operations like `getConn` don't require the conn lock. +- Returns `Maybe` — `Nothing` if connection has no short link credentials or is wrong type +- Handles both `ContactConnection` and `RcvConnection` (both have `RcvQueue` with `shortLink` field, Store.hs:159) +- Chat layer signs: `C.sign' privKey challenge` +- `linkPrivSigKey :: C.PrivateKeyEd25519` on `ShortLinkCreds` (Protocol.hs:1456) +- `shortLink :: Maybe ShortLinkCreds` on `StoredRcvQueue` (Store.hs:159) + +## Verification + +```bash +cd simplexmq && cabal build --ghc-options=-O0 +``` diff --git a/plans/2026-04-01-test-chat-relay-plan.md b/plans/2026-04-01-test-chat-relay-plan.md new file mode 100644 index 0000000000..905f7962c1 --- /dev/null +++ b/plans/2026-04-01-test-chat-relay-plan.md @@ -0,0 +1,813 @@ +# Plan: APITestChatRelay — Relay Liveness + Identity Verification + +**Date: 2026-04-01** + +## Context + +Channel owners configure relays by address but have no way to verify a relay is alive, authentic, or to discover its profile before creating a channel. A broken or impersonated relay means a broken channel. + +`APITestChatRelay` solves this by: +1. Fetching the relay's short link data (validates SMP server reachability + retrieves relay profile) +2. Running a challenge-response handshake (`XGrpRelayTest`) that proves the relay controls its address private key (`linkPrivSigKey`) +3. Returning the relay profile and test result to the UI + +The test can run before any `chat_relays` DB record exists — the UI uses the returned profile to populate the relay name field. + +No DB schema changes are needed — `name` column remains in `chat_relays`. The Haskell type `UserChatRelay` changes from `name :: Text` to `relayProfile :: RelayProfile`, wrapping the same DB column. + +--- + +## Data Flow + +``` +Owner SMP Server Relay + | | | + |--- getShortLinkConnReq ----------->| | + |<-- FixedLinkData{rootKey,cReq} ----| | + | + ConnLinkData{RelayAddressLinkData{relayProfile}} | + | | | + |--- joinConnection(XGrpRelayTest{challenge}) ---------------------->| + | | REQ with challenge | + | | relay signs challenge | + | | with linkPrivSigKey | + |<-- CONF(XGrpRelayTest{signature}) ----------------------------------| + | verify: C.verify' rootKey sig challenge | + | cleanup connections on both sides | +``` + +--- + +## Types + +### RelayProfile (Protocol.hs) + +```haskell +data RelayProfile = RelayProfile {name :: ContactName} + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''RelayProfile) +``` + +Simpler than `Profile` — relay identity needs only a name. Can be extended later with image, description, etc. + +### RelayAddressLinkData (Protocol.hs) + +```haskell +data RelayAddressLinkData = RelayAddressLinkData {relayProfile :: RelayProfile} + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''RelayAddressLinkData) +``` + +Stored as `userData` in the relay's contact address short link data. Separate from `ContactShortLinkData` (which has irrelevant `message`/`business` fields) and `RelayShortLinkData` (per-group relay links). + +### XGrpRelayTest (Protocol.hs) + +```haskell +XGrpRelayTest :: ByteString -> Maybe (C.Signature 'C.Ed25519) -> ChatMsgEvent 'Json +``` + +Single constructor used in both directions: +- **Owner → Relay** (in joinConnection connInfo): `XGrpRelayTest challenge Nothing` +- **Relay → Owner** (in acceptContact connInfo): `XGrpRelayTest challenge (Just signature)` + +The relay profile is NOT included — the owner already has it from `RelayAddressLinkData` in the short link's `userData` (retrieved in step 1 via `decodeLinkUserData`). + +JSON encoding (follows `(.=?)` chain pattern, e.g. `XGrpMemDel`): +```haskell +XGrpRelayTest challenge sig_ -> o $ + ("signature" .=? (B64UrlByteString . C.signatureBytes <$> sig_)) + ["challenge" .= B64UrlByteString challenge] +``` + +JSON parsing: +```haskell +XGrpRelayTest_ -> do + B64UrlByteString challenge <- v .: "challenge" + sig_ <- traverse decodeSig =<< opt "signature" + pure $ XGrpRelayTest challenge sig_ +``` + +Where `decodeSig` converts `B64UrlByteString` to `Parser (C.Signature 'C.Ed25519)` using `<$?>` (from `Simplex.Messaging.Util`, already imported in Protocol.hs): +```haskell +decodeSig :: B64UrlByteString -> JQ.Parser (C.Signature 'C.Ed25519) +decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s +``` + +`(<$?>) :: MonadFail m => (a -> Either String b) -> m a -> m b` — converts `Either` errors into `MonadFail` failures. `JQ.Parser` has `MonadFail`. + +Note: `B64UrlByteString` is defined in `Types.hs:151` — add import to Protocol.hs if not already imported. + +### RelayTestError (Controller.hs) + +```haskell +data RelayTestStep + = RTSGetLink -- fetching short link data from SMP server + | RTSDecodeLink -- decoding RelayAddressLinkData from link userData + | RTSConnect -- preparing and joining connection + | RTSWaitResponse -- waiting for relay's signed response + | RTSVerify -- verifying relay's signature + deriving (Show) + +data RelayTestFailure = RelayTestFailure + { rtfStep :: RelayTestStep, + rtfDescription :: String + } + deriving (Show) +``` + +Pattern follows `ProtocolTestFailure {testStep, testError}` from simplexmq. + +### RelayTest (Controller.hs) + +```haskell +data RelayTest = RelayTest + { challenge :: ByteString, + rootKey :: C.PublicKeyEd25519, + result :: TMVar (Maybe RelayTestFailure) + } +``` + +- `challenge` — random bytes sent to relay +- `rootKey` — from `FixedLinkData`, used to verify relay's signature +- `result` — `Nothing` = success, `Just failure` = error + +### UserChatRelay type change (Operators.hs) + +`UserChatRelay'` changes `name :: Text` to `relayProfile :: RelayProfile`: + +```haskell +data UserChatRelay' s = UserChatRelay + { chatRelayId :: DBEntityId' s, + address :: ShortLinkContact, + relayProfile :: RelayProfile, -- was: name :: Text + domains :: [Text], + preset :: Bool, + tested :: Maybe Bool, + enabled :: Bool, + deleted :: Bool + } +``` + +`relayProfile` is non-optional — always present: +- Before testing: user provides name → `RelayProfile {name = userProvidedName}` +- After testing: relay's actual profile replaces the user-provided one + +No DB migration needed — `name TEXT` column stays in `chat_relays`. The `RelayProfile` wrapper is applied at the Haskell read/write boundary: + +**Constructors:** +```haskell +-- newChatRelay_ (Operators.hs:341): name parameter wraps into RelayProfile +newChatRelay_ preset enabled name domains !address = + UserChatRelay {chatRelayId = DBNewEntity, address, relayProfile = RelayProfile {name}, domains, ...} +``` + +**DB reads** — `toChatRelay` (Profiles.hs:636) and `toGroupRelay` (Groups.hs:1337): wrap `name` column value: +```haskell +-- toChatRelay: name from DB → RelayProfile + UserChatRelay {chatRelayId, address, relayProfile = RelayProfile {name}, domains = ..., ...} +``` + +**DB writes** — `insertChatRelay`, `updateChatRelay`, `undeleteRelay` (Profiles.hs): unwrap `RelayProfile` to get `name` for column: +```haskell +-- insertChatRelay: destructure relayProfile +insertChatRelay db User {userId} ts relay@UserChatRelay {address, relayProfile = RelayProfile {name}, ...} = do +``` + +**Validation** — `chatRelayErrs` (Operators.hs:546): uses `name` from `relayProfile` for duplicate checking: +```haskell +duplicateErrs_ (AUCR _ UserChatRelay {relayProfile = RelayProfile {name}, address}) = ... +allNames = map (\(AUCR _ UserChatRelay {relayProfile = RelayProfile {name}}) -> name) cRelays +``` + +**View** — `viewChatRelay` (View.hs:1581): uses `name` from `relayProfile`: +```haskell +viewChatRelay UserChatRelay {relayProfile = RelayProfile {name}, address, ...} = name <> ... +``` + +**`createRelayForOwner`** (Groups.hs:1342): uses `relayProfile` directly instead of `profileFromName name`: +```haskell +createRelayForOwner db vr gVar user gInfo UserChatRelay {relayProfile = RelayProfile {name}} = do + let memberProfile = profileFromName name + ... +``` + +**JSON** — `deriveJSON` on `UserChatRelay'` picks up the field rename automatically. The JSON changes from `"name": "bob"` to `"relayProfile": {"name": "bob"}`. Mobile apps need to update their model types accordingly. + +### ChatController field + +```haskell +chatRelayTests :: TMap ConnId RelayTest, +``` + +### ChatCommand + +```haskell +| APITestChatRelay UserId ShortLinkContact +| TestChatRelay ShortLinkContact +``` + +Takes a `ShortLinkContact` (`ConnShortLink 'CMContact`) — relay addresses are always short links. This matches `UserChatRelay.address :: ShortLinkContact` and is directly accepted by `getShortLinkConnReq :: ... -> ConnShortLink m -> ...`. + +### ChatResponse + +```haskell +| CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, testFailure :: Maybe RelayTestFailure} +``` + +- On success: `relayProfile = Just p, testFailure = Nothing` +- On failure at link fetch/decode: `relayProfile = Nothing, testFailure = Just err` (profile not yet available) +- On failure at connect/verify: `relayProfile = Just p, testFailure = Just err` (profile from link data) + +--- + +## Implementation + +### Phase 1: Protocol — XGrpRelayTest + RelayAddressLinkData + RelayProfile + +**File: `src/Simplex/Chat/Protocol.hs`** + +1. Add `RelayProfile` type (near `RelayShortLinkData`, ~line 1444): + - `data RelayProfile = RelayProfile {name :: ContactName}` + - `deriveJSON` + +2. Add `RelayAddressLinkData` type (after `RelayShortLinkData`): + - `data RelayAddressLinkData = RelayAddressLinkData {relayProfile :: RelayProfile}` + - `deriveJSON` + +3. Add `XGrpRelayTest` constructor (after `XGrpRelayAcpt`, ~line 438): + - `XGrpRelayTest :: ByteString -> Maybe (C.Signature 'C.Ed25519) -> ChatMsgEvent 'Json` + +4. Add event tag `XGrpRelayTest_` (after `XGrpRelayAcpt_`, ~line 966) + +5. Add tag string `"x.grp.relay.test"` (after `"x.grp.relay.acpt"`, ~line 1022) + +6. Add tag parsing (after `XGrpRelayAcpt_` parse, ~line 1079) + +7. Add event-to-tag mapping (after `XGrpRelayAcpt` mapping, ~line 1132): + - `XGrpRelayTest {} -> XGrpRelayTest_` + +8. Add JSON parsing (~line 1284): + ```haskell + XGrpRelayTest_ -> do + B64UrlByteString challenge <- v .: "challenge" + sig_ <- traverse decodeSig =<< opt "signature" + pure $ XGrpRelayTest challenge sig_ + ``` + Where: + ```haskell + decodeSig :: B64UrlByteString -> JQ.Parser (C.Signature 'C.Ed25519) + decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s + ``` + +9. Add JSON encoding (~line 1351): + ```haskell + XGrpRelayTest challenge sig_ -> o $ + ("signature" .=? (B64UrlByteString . C.signatureBytes <$> sig_)) + ["challenge" .= B64UrlByteString challenge] + ``` + +### Phase 2: UserChatRelay type change + +**Files: `src/Simplex/Chat/Operators.hs`, `src/Simplex/Chat/Store/Profiles.hs`, `src/Simplex/Chat/Store/Groups.hs`, `src/Simplex/Chat/View.hs`** + +Change `UserChatRelay'` field `name :: Text` → `relayProfile :: RelayProfile` and update all 10 use sites as described in the Types section above. No DB migration — `name` column stays, `RelayProfile` wraps/unwraps at read/write boundary. + +### Phase 3: Controller types — RelayTest, RelayTestFailure, commands, response + +**File: `src/Simplex/Chat/Controller.hs`** + +1. Add `RelayTestStep` and `RelayTestFailure` types (near `ProtocolTestFailure` usage) + +2. Add `RelayTest` type + +3. Add `chatRelayTests :: TMap ConnId RelayTest` field to `ChatController` (after `relayRequestWorkers`, ~line 252) + +4. Uncomment and update `APITestChatRelay` (lines 401-403): + ```haskell + | APITestChatRelay UserId ShortLinkContact + | TestChatRelay ShortLinkContact + ``` + +5. Add `CRChatRelayTestResult` to `ChatResponse` (after `CRServerTestResult`, ~line 667): + ```haskell + | CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, testFailure :: Maybe RelayTestFailure} + ``` + +**File: `src/Simplex/Chat.hs`** + +6. Initialize `chatRelayTests` in `newChatController` (after `relayRequestWorkers`, ~line 175): + ```haskell + chatRelayTests <- TM.emptyIO + ``` + Add `chatRelayTests` to the record construction (~line 218). + +### Phase 4: Agent API — getConnLinkPrivKey (simplexmq change) + +The relay needs to sign the challenge with `ShortLinkCreds.linkPrivSigKey`, which is stored in the agent's DB on `RcvQueue`. The chat layer has no direct access to the key. + +**New agent API function in `simplexmq/src/Simplex/Messaging/Agent.hs`:** + +```haskell +getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) +``` + +Implementation: +1. Look up `SomeConn` by `ConnId` via `withStore c getConn` +2. Pattern match on `ContactConnection _ rq` or `RcvConnection _ rq` +3. Return `linkPrivSigKey <$> shortLink rq` (returns `Nothing` if no short link creds) + +The chat layer then signs: `C.sign' privKey challenge`. + +This is a local operation (no network IO), so it's synchronous. + +**Separate plan file:** `plans/agent-sign-for-address.md` + +### Phase 5: Commands.hs — APITestChatRelay handler + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Add `import System.Timeout (timeout)`. + +Add handler after `APITestProtoServer` (~line 1491): + +```haskell +APITestChatRelay userId address -> withUserId userId $ \user -> do + -- Step 1: Fetch link data (validates SMP server + gets profile) + let failAt step desc = pure $ CRChatRelayTestResult user Nothing (Just $ RelayTestFailure step desc) + r <- tryAllErrors $ getShortLinkConnReq nm user address + case r of + Left e -> failAt RTSGetLink (show e) + Right (FixedLinkData {rootKey, linkConnReq = cReq}, cData) -> do + -- Step 2: Decode relay profile from link data + relayProfile_ <- liftIO $ decodeLinkUserData cData + case relayProfile_ of + Nothing -> failAt RTSDecodeLink "no relay address link data" + Just RelayAddressLinkData {relayProfile} -> do + let failWithProfile step desc = + pure $ CRChatRelayTestResult user (Just relayProfile) (Just $ RelayTestFailure step desc) + -- Step 3: Generate challenge + prepare connection + gVar <- asks random + challenge <- liftIO $ atomically $ C.randomBytes 32 gVar + lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + Nothing -> failWithProfile RTSConnect "invalid connection request" + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + conn@Connection {connId = dbConnId} <- withFastStore $ \db -> + createRelayTestConnection db vr user connId ConnPrepared chatV subMode + -- Register test in TMap + testVar <- newEmptyTMVarIO + let acId = aConnId conn + relayTest = RelayTest {challenge, rootKey, result = testVar} + chatRelayTests_ <- asks chatRelayTests + atomically $ TM.insert acId relayTest chatRelayTests_ + -- Join with challenge, wrapped in tryAllErrors for cleanup safety + testResult <- tryAllErrors $ do + dm <- encodeConnInfo $ XGrpRelayTest challenge Nothing + void $ withAgent $ \a -> joinConnection a nm (aUserId user) acId True cReq dm PQSupportOff subMode + liftIO $ timeout 40_000_000 $ atomically $ takeTMVar testVar + -- Cleanup always (even on error) + atomically $ TM.delete acId chatRelayTests_ + withFastStore' $ \db -> deleteConnectionRecord db user dbConnId + deleteAgentConnectionAsync acId + case testResult of + Left e -> failWithProfile RTSConnect (show e) + Right Nothing -> failWithProfile RTSWaitResponse "timeout" + Right (Just Nothing) -> pure $ CRChatRelayTestResult user (Just relayProfile) Nothing + Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) +TestChatRelay address -> withUser $ \User {userId} -> + processChatCommand vr nm $ APITestChatRelay userId address +``` + +Also add CLI parsing for `TestChatRelay` in the command parser. + +Key points: +- `address :: ShortLinkContact` — passes directly to `getShortLinkConnReq` (no type mismatch) +- `conn@Connection {connId = dbConnId}` — explicit pattern match avoids `DuplicateRecordFields` ambiguity +- `tryAllErrors` wraps only the join+wait block; cleanup runs unconditionally after it +- `tryAllErrors` (from `Simplex.Messaging.Util`) catches ALL exceptions via `UE.catch`, not just `ChatError` +- `void $ withAgent $ \a -> joinConnection ...` — discards `(SndQueueSecured, Maybe ClientServiceId)` return + +### Phase 6: Subscriber.hs — Event handlers + +**File: `src/Simplex/Chat/Library/Subscriber.hs`** + +#### Owner side: processDirectMessage CONF handler (contact_ = Nothing) + +Modify the CONF handler at lines 407-417. Before the existing flow, check if this connection is a relay test: + +```haskell +Nothing -> case agentMsg of + CONF confId pqSupport _ connInfo -> do + -- Check if this is a relay test connection + chatRelayTests_ <- asks chatRelayTests + relayTest_ <- atomically $ TM.lookup agentConnId chatRelayTests_ + case relayTest_ of + Just RelayTest {challenge, rootKey, result = testVar} -> do + -- Parse response + r <- tryAllErrors $ do + ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + case chatMsgEvent of + XGrpRelayTest _challenge sig_ -> + case sig_ of + Just sig + | C.verify' rootKey sig challenge -> + atomically $ putTMVar testVar Nothing -- success + | otherwise -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify "invalid signature") + Nothing -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify "no signature in response") + _ -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse "unexpected message type") + case r of + Left e -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse (show e)) + Right () -> pure () + Nothing -> do + -- Existing flow (unchanged) + conn' <- processCONFpqSupport conn pqSupport + (conn'', gInfo_) <- saveConnInfo conn' connInfo + ... +``` + +Note: `agentConnId` is in scope from the `processAgentMessageConn` closure (Subscriber.hs:354). + +#### Relay side: processContactConnMessage REQ handler + +Add `XGrpRelayTest` case after `XGrpRelayInv` at line 1247: + +```haskell +XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge +``` + +Add `xGrpRelayTest` function near `xGrpRelayInv` (~line 1450): + +```haskell +xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () +xGrpRelayTest invId chatVRange challenge = do + -- Retrieve private key from address connection's short link creds, sign in chat layer + privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn) + case privKey_ of + Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address") + Just privKey -> do + let sig = C.sign' privKey challenge + msg = XGrpRelayTest challenge (Just sig) + subMode <- chatReadVar subscriptionMode + vr <- chatVersionRange + let chatV = vr `peerConnChatVersion` chatVRange + void $ agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV +``` + +Note: `conn` is the user contact address connection (from `processContactConnMessage` closure). Its `aConnId` is the agent `ConnId` that holds `ShortLinkCreds` with `linkPrivSigKey`. The agent returns `Maybe` — `Nothing` if the connection has no short link credentials (shouldn't happen for a properly configured relay, but handled gracefully — owner will timeout with `RTSWaitResponse`). + +### Phase 7: Store — createRelayTestConnection + +**File: `src/Simplex/Chat/Store/Direct.hs`** + +Add function to create a ConnContact connection without entity: + +```haskell +createRelayTestConnection :: DB.Connection -> VersionRangeChat -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV subMode = do + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + conn_chat_version, to_subscribe, pq_support, pq_encryption, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, agentConnId, 0 :: Int, connStatus, ConnContact) + :. (chatV, BI (subMode == SMOnlyCreate), PQSupportOff, PQSupportOff) + :. (currentTs, currentTs) + ) + connId <- liftIO $ insertedRowId db + getConnectionById db vr user connId +``` + +Pattern: same as `createRelayConnection` (Store/Groups.hs:1388) but `ConnContact` type with no `group_member_id`. + +The resulting row has `contact_id = NULL`, `contact_conn_initiated = 0` (column default), `xcontact_id = NULL`, `via_contact_uri = NULL`. This distinguishes it from `createConnReqConnection` rows which always set `contact_conn_initiated = 1`, `xcontact_id`, and `via_contact_uri`. + +### Phase 8: APICreateMyAddress — Use RelayAddressLinkData + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Update `APICreateMyAddress` (~line 2162-2176) for relay users: + +```haskell +-- Current code (line 2168-2169): +-- TODO [relays] relay: add relay profile, identity, key to link data? +let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing + +-- New code for relay users: +let userData = if isTrue userChatRelay + then encodeShortLinkData $ RelayAddressLinkData + { relayProfile = RelayProfile {name = displayName (fromLocalProfile $ profile' user)} + } + else contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing +``` + +### Phase 9: Test connection cleanup + +Test connections are `ConnContact` with no entity (`contact_id = NULL`). They should be cleaned up if the test API handler crashes or times out without cleanup. + +Add `cleanupStaleRelayTestConns` step to `cleanupUser` in `cleanupManager` (after `cleanupInProgressGroups`, ~line 4500): + +```haskell +cleanupStaleRelayTestConns user `catchAllErrors` eToView +liftIO $ threadDelay' stepDelay +``` + +Implementation: +```haskell +cleanupStaleRelayTestConns user = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-300) ts -- 5 minutes + staleConns <- withStore' $ \db -> getStaleRelayTestConns db user cutoffTs + forM_ staleConns $ \acId -> do + deleteAgentConnectionAsync acId + withStore' $ \db -> deleteConnectionByAgentConnId db user acId +``` + +Where `getStaleRelayTestConns` queries: +```sql +SELECT agent_conn_id FROM connections +WHERE user_id = ? AND conn_type = 'contact' AND contact_id IS NULL + AND conn_status = 'prepared' AND contact_conn_initiated = 0 + AND created_at < ? +``` + +This uniquely identifies stale test connections. The `contact_conn_initiated = 0` discriminator is critical because `createConnReqConnection` (Store/Direct.hs:164) also creates `ConnContact` rows with `contact_id = NULL` and `conn_status = ConnPrepared`, but it always sets `contact_conn_initiated = True` (line 175). Test connections from `createRelayTestConnection` inherit the column default of 0. + +**No new DB column needed.** + +### Phase 10: Views (iOS + Android/Desktop) + +**iOS:** +- `apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift` +- `apps/ios/Shared/Views/NewChat/AddChannelView.swift` + +**Android/Desktop:** +- `apps/multiplatform/.../ChatRelayView.kt` +- `apps/multiplatform/.../AddChannelView.kt` + +Changes: +1. Add "Test" button next to relay address that calls `APITestChatRelay address` +2. On success: show relay profile name, optionally auto-fill name field +3. On failure: show error description from `RelayTestFailure` +4. Show relay status indicator: untested / tested-ok / tested-failed + +### Phase 11: View — CRChatRelayTestResult + +**File: `src/Simplex/Chat/View.hs`** + +Add `CRChatRelayTestResult` case after `CRServerTestResult` (~line 127): + +```haskell +CRChatRelayTestResult u relayProfile_ testFailure_ -> ttyUser u $ viewRelayTestResult relayProfile_ testFailure_ +``` + +Add `viewRelayTestResult` function near `viewServerTestResult` (~line 1600): + +```haskell +viewRelayTestResult :: Maybe RelayProfile -> Maybe RelayTestFailure -> [StyledString] +viewRelayTestResult relayProfile_ = \case + Just RelayTestFailure {rtfStep, rtfDescription} -> + ["relay test failed at " <> plain (show rtfStep) <> ", error: " <> plain rtfDescription] + Nothing -> case relayProfile_ of + Just RelayProfile {name} -> ["relay test passed, profile: " <> plain (T.unpack name)] + Nothing -> ["relay test passed"] +``` + +Output examples: +- Success: `relay test passed, profile: bob` +- Decode failure: `relay test failed at RTSDecodeLink, error: no relay address link data` +- Link failure: `relay test failed at RTSGetLink, error: ...` + +### Phase 12: CLI parsing — TestChatRelay + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Add CLI parser after `/relays` (~line 4771): + +```haskell +"/relay test " *> (TestChatRelay <$> strP), +``` + +### Phase 13: Tests + +**File: `tests/ChatTests/ChatRelays.hs`** + +Add to `chatRelayTests`: +```haskell +describe "configure chat relays" $ do + ... + it "test chat relay" testChatRelayTest +``` + +#### Test: `testChatRelayTest` + +Single test function covering three scenarios sequentially. Uses alice (owner), bob (relay), and cath (normal user). + +```haskell +testChatRelayTest :: HasCallStack => TestParams -> IO () +testChatRelayTest ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + -- Setup: bob (relay) creates address + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + + -- Setup: cath (normal user) creates address + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + + -- Scenario 1: Happy path — test relay address succeeds + -- Concurrent because alice's test command blocks while bob processes REQ + concurrentlyN_ + [ do + alice ##> ("/relay test " <> bobSLink) + alice <## "relay test passed, profile: bob", + -- Bob's side is automatic (subscriber handles XGrpRelayTest) + -- but we need to consume any potential output on bob's side + pure () + ] + + -- Scenario 2: Non-relay address — cath is not a relay user, + -- her address has ContactShortLinkData, not RelayAddressLinkData + alice ##> ("/relay test " <> cathSLink) + alice <## "relay test failed at RTSDecodeLink, error: no relay address link data" + + -- Scenario 3: Deleted address — bob deletes his address + bob ##> "/da" + bob <## "Your chat address is deleted - accepted contacts will remain connected." + alice ##> ("/relay test " <> bobSLink) + -- Exact error message depends on SMP server response, match prefix + alice <## startsWith "relay test failed at RTSGetLink, error: " +``` + +**Key design decisions:** + +1. **One test, three scenarios** — avoids repeating setup (creating users, addresses) across three separate tests while covering happy path + two failure modes. + +2. **`concurrentlyN_` for happy path** — alice's `TestChatRelay` command blocks on a TMVar waiting for the relay's response. Bob's subscriber processes the REQ automatically via `xGrpRelayTest`, but the test framework needs both sides to run concurrently. The relay side may produce no visible CLI output (the `xGrpRelayTest` handler doesn't emit events to the view), so the relay branch is `pure ()`. + +3. **No concurrency for failure scenarios** — both fail before establishing a connection (at link fetch or decode step), so alice returns immediately with an error. + +4. **`startsWith` for SMP error** — the exact SMP error message may vary (network error, connection refused, etc.), so we match only the prefix `"relay test failed at RTSGetLink, error: "`. + +5. **Bob's output during happy path** — the relay's subscriber handles `XGrpRelayTest` silently (no `toView` call on success). After accepting, the agent creates a new connection whose subsequent events (JOINED, etc.) hit `getConnectionEntity` → `SEConnectionNotFound` → logged via `eToView`. This log noise may or may not appear as a test output line. If it does, we'd need to consume it in the `concurrentlyN_` bob branch. This needs to be verified during implementation — if bob produces output, add `bob <## ...` to consume it. + +**Helper needed:** `startsWith` — matches output lines by prefix. Check if this already exists in test utils: + +```haskell +startsWith :: String -> String -> Bool +startsWith = isPrefixOf +``` + +Or use an existing pattern like `<##.` if available. + +#### Scenarios NOT tested (and why): + +- **Signature verification failure (`RTSVerify`)** — would require the relay to sign with a wrong key. No mechanism to inject that without modifying the relay's behavior (e.g., a test-only flag). Not worth the complexity. +- **Timeout (`RTSWaitResponse`)** — would require the relay to not respond (e.g., by stopping the relay process). The test would take 40 seconds and be fragile. Not practical for a unit test. +- **Connection error (`RTSConnect`)** — would require the SMP server to be reachable (link data returned) but the connection request to fail. Hard to construct reliably. + +Existing relay config tests (`testGetSetChatRelays`, etc.) need updating for the `relayProfile` type change — CLI output changes from `bob_relay: ` to the same (the `name` field is now accessed via `relayProfile`), but the CLI command syntax stays the same (`/relays name=bob_relay `). + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Protocol.hs` | `RelayProfile`, `RelayAddressLinkData`, `XGrpRelayTest` + tags + parsing + encoding | +| `src/Simplex/Chat/Operators.hs` | `UserChatRelay'`: `name` → `relayProfile :: RelayProfile`; update `newChatRelay_`, validation | +| `src/Simplex/Chat/Controller.hs` | `RelayTestStep`, `RelayTestFailure`, `RelayTest`, `chatRelayTests`, `APITestChatRelay`, `CRChatRelayTestResult` | +| `src/Simplex/Chat.hs` | Initialize `chatRelayTests` in `newChatController` | +| `src/Simplex/Chat/Library/Commands.hs` | `APITestChatRelay` handler, `APICreateMyAddress` relay link data, CLI parsing, `cleanupManager` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Owner CONF handler pre-check, relay REQ handler `XGrpRelayTest` | +| `src/Simplex/Chat/Store/Direct.hs` | `createRelayTestConnection` | +| `src/Simplex/Chat/Store/Groups.hs` | `toGroupRelay`, `createRelayForOwner`: wrap/unwrap `RelayProfile` | +| `src/Simplex/Chat/Store/Profiles.hs` | `toChatRelay`, `insertChatRelay`, `updateChatRelay`, `undeleteRelay`: wrap/unwrap `RelayProfile`; `getStaleRelayTestConns` | +| `src/Simplex/Chat/View.hs` | `viewChatRelay`: use `relayProfile`; `CRChatRelayTestResult` + `viewRelayTestResult` | +| `apps/ios/.../ChatRelayView.swift` | `UserChatRelay` model update, test button + result display | +| `apps/ios/.../AddChannelView.swift` | Test integration | +| `apps/multiplatform/.../ChatRelayView.kt` | `UserChatRelay` model update, test button + result display | +| `apps/multiplatform/.../AddChannelView.kt` | Test integration | +| `tests/ChatTests/ChatRelays.hs` | `testChatRelayTest` | + +**Separate simplexmq change:** +| `simplexmq/src/Simplex/Messaging/Agent.hs` | `getConnLinkPrivKey` API | + +--- + +## Key Functions Reused + +- `getShortLinkConnReq` (Internal.hs:1339) — fetch link data + validate SMP + get connReq +- `decodeLinkUserData` (Internal.hs:1361) — decode `RelayAddressLinkData` from `ConnLinkData` +- `encodeShortLinkData` (Internal.hs:1351) — encode `RelayAddressLinkData` for link userData +- `prepareConnectionToJoin` (agent) — prepare agent connection for joining +- `joinConnection` (agent) — join relay's contact address +- `encodeConnInfo` (Internal.hs:1929) — encode `XGrpRelayTest` as connInfo +- `parseChatMessage` (Internal.hs:1563) — parse connInfo in CONF handler +- `agentAcceptContactAsync` (Internal.hs:2421) — relay accepts test connection +- `deleteAgentConnectionAsync` (Internal.hs:2428) — cleanup connections +- `deleteConnectionRecord` (Store/Shared.hs:895) — cleanup DB connection record (takes `Int64` DB connection_id) +- `getConnLinkPrivKey` (agent, new) — retrieve `linkPrivSigKey` from connection's short link creds +- `C.verify'` (simplexmq Crypto:1270) — `PublicKey a -> Signature a -> ByteString -> Bool` +- `C.sign'` (simplexmq Crypto:1175) — `PrivateKey a -> ByteString -> Signature a` +- `C.randomBytes` (simplexmq Crypto:1401) — `Int -> TVar ChaChaDRG -> STM ByteString` +- `eToView` (Controller.hs:1537) — `ChatError -> CM ()` — report error to view + +--- + +## Verification + +### Build +```bash +cabal build --ghc-options=-O0 +``` + +### Test +```bash +cabal test simplex-chat-test --test-options='-m "channels"' +cabal test simplex-chat-test --test-options='-m "chat relays"' +``` + +### Manual verification +1. Start relay user, set as chat relay, create address +2. Start owner user +3. Owner tests relay address → verify CRChatRelayTestResult with profile, no failure +4. Owner tests invalid address → verify failure at RTSGetLink +5. Kill owner during test → verify cleanup by cleanupManager after 5 min + +--- + +## Adversarial Self-Review + +### Pass 1 + +**Issue: Signature type in JSON** — `C.Signature 'C.Ed25519` is a GADT constructor. Need to verify it has JSON/Encoding instances and can be transmitted in a JSON chat message. +**Analysis:** `Signature` has no native JSON instance. For JSON, encode as base64 ByteString using `B64UrlByteString . C.signatureBytes`. For parsing, decode `B64UrlByteString` then `C.decodeSignature :: ByteString -> Either String (Signature 'C.Ed25519)` (Crypto.hs:849). The `(.=?)` pattern handles `Maybe` — only included when `Just`. +**Fix:** Encoding uses `B64UrlByteString . C.signatureBytes <$> sig_`. Parsing uses `traverse decodeSig =<< opt "signature"` where `decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s` (returns `JQ.Parser`, not `Either String`). No relay profile in message — owner gets it from link data. + +**Issue: `DuplicateRecordFields` on `connId`** — `connId :: Int64` appears on `Connection`, `PendingContactConnection`, and `UserContactRequest`. With `DuplicateRecordFields` enabled, `connId conn` won't compile as a field selector. +**Analysis:** Must use pattern matching. The handler uses `conn@Connection {connId = dbConnId}`. +**Fix:** Already applied in Phase 5 handler code. + +**Issue: `getConnLinkPrivKey` conn access** — In `xGrpRelayTest`, we call `getConnLinkPrivKey a (aConnId conn)` where `conn` is the user contact address connection. Does the agent's `getConn` find it by the correct ConnId? +**Analysis:** `processContactConnMessage` receives `conn :: Connection` which is the chat-layer connection record. `aConnId conn` gives the agent's `ConnId`. The agent stores `ShortLinkCreds` on the `RcvQueue` of the `ContactConnection` for this `ConnId`. The agent function pattern-matches on `ContactConnection _ rq` and returns `linkPrivSigKey <$> shortLink rq`. This is correct. +**Fix:** No fix needed. + +**Issue: `getConnLinkPrivKey` returns Nothing** — If the relay's address connection has no short link credentials, the relay-side handler logs an error via `eToView` and does not accept the test connection. +**Analysis:** This shouldn't happen for a properly configured relay (creating the address creates short link creds via `createConnection` in the agent). Handled gracefully — the owner will timeout with `RTSWaitResponse`. +**Fix:** No fix needed. + +**Issue: Test connection routing on relay side** — After the relay accepts the test via `agentAcceptContactAsync`, the agent creates a new connection. Future events on this connection (JOINED, etc.) arrive at `processAgentMessageConn`. Since there's no DB connection record, `getConnectionEntity` will fail with `SEConnectionNotFound`, producing error in `eToView`. This is log noise. +**Analysis:** Acceptable for MVP. The agent will eventually GC the connection. The error is harmless and happens for the relay only. The owner's connection is cleaned up by the handler. +**Fix:** Document as known behavior. + +**Issue: `tryAllErrors` behavior** — Does `tryAllErrors` catch all exceptions or just `ChatError`? +**Analysis:** `tryAllErrors` (Util.hs:249) uses `UE.catch` which catches `SomeException` — ALL exceptions, not just `ChatError`. It converts via `fromSomeException` into the error type. This is important: if `joinConnection` throws an IO exception, it's still caught and the cleanup runs. +**Fix:** No fix needed — the behavior is correct. + +**Issue: Multiple CONFs** — Could the owner receive multiple CONF events for the same connection? If yes, the second `putTMVar` would block. +**Analysis:** The SMP protocol sends exactly one CONF per connection. Multiple CONFs would be a protocol violation. +**Fix:** No fix needed. + +**Issue: Cleanup on timeout** — If the timeout fires (40s), the handler deletes the DB connection and agent connection. But the relay's response might arrive AFTER cleanup. +**Analysis:** After timeout, the TMap entry is deleted. A late CONF arriving at the subscriber finds no TMap entry, falls through to the existing flow, fails at `getConnectionEntity` (connection deleted). Harmless — `catchAllErrors eToView` absorbs it. +**Fix:** No fix needed. The cleanup sequence (delete TMap → delete DB → delete agent) is safe in all interleavings. + +### Pass 2 + +**Issue: `decodeLinkUserData cData`** — For relay addresses, `cData` is `ContactLinkData vr UserContactData{..}`. Does `decodeLinkUserData` decode the right field? +**Analysis:** `decodeLinkUserData` (Internal.hs:1361) is polymorphic — uses `JQ.decode` on the `userData` bytes from `UserContactData`. The caller constrains the type via the binding `Just RelayAddressLinkData {relayProfile}`. The `FromJSON` instance is provided by `deriveJSON`. +**Fix:** No fix needed. + +**Issue: `encodeShortLinkData`** — Will it work for `RelayAddressLinkData`? +**Analysis:** `encodeShortLinkData` (Internal.hs:1351) is polymorphic — `J.ToJSON a => a -> UserLinkData`. Uses `J.encode` and wraps in `UserLinkData`. Works for any type with `ToJSON`. +**Fix:** No fix needed. + +**Issue: Cleanup identification query safety** — `getStaleRelayTestConns` uses: `ConnContact + contact_id IS NULL + ConnPrepared + contact_conn_initiated = 0 + old created_at`. Could this match non-test connections? +**Analysis:** All code paths that create `ConnContact` with `contact_id = NULL`: +- `createConnReqConnection` (Direct.hs:158): sets `ConnPrepared` (line 164) BUT also sets `contact_conn_initiated = True` (line 175, `BI True`), `xcontact_id`, and `via_contact_uri`. The `contact_conn_initiated = 0` condition excludes these. +- `createRelayTestConnection` (new): sets `ConnPrepared`, inherits `contact_conn_initiated = 0` default. Matches the query. +- No other code path creates `ConnContact` with `contact_id = NULL` and `contact_conn_initiated = 0`. +**Fix:** The query is safe with the `contact_conn_initiated = 0` discriminator. + +**Issue: Partial failure cleanup** — If `prepareConnectionToJoin` succeeds but the `withFastStore` for `createRelayTestConnection` fails, the agent connection leaks. +**Analysis:** The `prepareConnectionToJoin` call happens before the `tryAllErrors` block. If `createRelayTestConnection` throws, we never reach cleanup. The agent connection from `prepareConnectionToJoin` would leak until restart. However, `createRelayTestConnection` is a simple INSERT — it's unlikely to fail. And if it does, `cleanupManager` won't catch it because no DB row was created. The agent-level connection will be cleaned up on agent restart. +**Fix:** Acceptable for MVP. Could wrap in a broader try-catch, but the failure mode is extremely unlikely and the consequence (one leaked agent connection) is minor. + +**Issue: `void $ withAgent $ \a -> joinConnection ...`** — The return type of `joinConnection` is `AE (SndQueueSecured, Maybe ClientServiceId)`. Using `void` discards both values. +**Analysis:** For the test connection, we don't need `SndQueueSecured` or `ClientServiceId`. The `addRelay` function (Commands.hs:3776) uses the return value to update connection status, but the test connection is deleted immediately anyway. +**Fix:** No fix needed. + +Both passes clean. No further issues found. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index c602d31a37..fd48317286 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9c07ddff3cbd2302f0f02c5506db89e261bca1e0" = "1l1qpj18dby0yzyci5br1s4f22m5idfz2ng8vwgm611kszikm40c"; + "https://github.com/simplex-chat/simplexmq.git"."9bc0c70fa0604a11f7f19d4b4415b0bb7414582c" = "13i6j1nw5w0a2bpjkw6adglf6x81nk5anf8pnjqijzfpggjzdj7w"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index debe95825c..2671774603 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -173,6 +173,7 @@ newChatController deliveryTaskWorkers <- TM.emptyIO deliveryJobWorkers <- TM.emptyIO relayRequestWorkers <- TM.emptyIO + chatRelayTests <- TM.emptyIO expireCIThreads <- TM.emptyIO expireCIFlags <- TM.emptyIO cleanupManagerAsync <- newTVarIO Nothing @@ -216,6 +217,7 @@ newChatController deliveryTaskWorkers, deliveryJobWorkers, relayRequestWorkers, + chatRelayTests, expireCIThreads, expireCIFlags, cleanupManagerAsync, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2a4e10cccf..16652f90dd 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -250,6 +250,7 @@ data ChatController = ChatController deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker, deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework + chatRelayTests :: TMap ConnId RelayTest, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, cleanupManagerAsync :: TVar (Maybe (Async ())), @@ -398,9 +399,8 @@ data ChatCommand | TestProtoServer AProtoServerWithAuth | GetUserChatRelays | SetUserChatRelays [CLINewRelay] - -- TODO [relays] commands to test chat relay - -- | APITestChatRelay UserId ConnLinkContact - -- | TestChatRelay ConnLinkContact + | APITestChatRelay UserId ShortLinkContact + | TestChatRelay ShortLinkContact | APIGetServerOperators | APISetServerOperators (NonEmpty ServerOperator) | SetServerOperators (NonEmpty ServerOperatorRoles) @@ -649,6 +649,26 @@ data RelayConnectionResult = RelayConnectionResult } deriving (Show) +data RelayTestStep + = RTSGetLink + | RTSDecodeLink + | RTSConnect + | RTSWaitResponse + | RTSVerify + deriving (Show) + +data RelayTestFailure = RelayTestFailure + { rtfStep :: RelayTestStep, + rtfError :: ChatError + } + deriving (Show) + +data RelayTest = RelayTest + { challenge :: ByteString, + rootKey :: C.PublicKeyEd25519, + result :: TMVar (Maybe RelayTestFailure) + } + data ChatResponse = CRActiveUser {user :: User} | CRUsersList {users :: [UserInfo]} @@ -665,6 +685,7 @@ data ChatResponse | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} + | CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, relayTestFailure :: Maybe RelayTestFailure} | CRServerOperatorConditions {conditions :: ServerOperatorConditions} | CRUserServers {user :: User, userServers :: [UserOperatorServers]} | CRUserServersValidation {user :: User, serverErrors :: [UserServersError], serverWarnings :: [UserServersWarning]} @@ -1351,6 +1372,7 @@ data ChatErrorType | CEConnectionIncognitoChangeProhibited | CEConnectionUserChangeProhibited | CEPeerChatVRangeIncompatible + | CERelayTestError {message :: String} | CEInternalError {message :: String} | CEException {message :: String} deriving (Show, Exception) @@ -1679,6 +1701,10 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "TE") ''TerminalEvent) $(JQ.deriveJSON defaultJSON ''RelayConnectionResult) +$(JQ.deriveJSON (enumJSON $ dropPrefix "RTS") ''RelayTestStep) + +$(JQ.deriveJSON defaultJSON ''RelayTestFailure) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CEvt") ''ChatEvent) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 3c9d1fbea2..834839f1b9 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -115,6 +115,7 @@ import System.Exit (ExitCode, exitSuccess) import System.FilePath (takeExtension, takeFileName, ()) import System.IO (Handle, IOMode (..)) import System.Random (randomRIO) +import System.Timeout (timeout) import UnliftIO.Async import UnliftIO.Concurrent (forkIO, threadDelay) import UnliftIO.Directory @@ -1489,6 +1490,46 @@ processChatCommand vr nm = \case lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a nm (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand vr nm $ APITestProtoServer userId srv + APITestChatRelay userId address -> withUserId userId $ \user -> do + let failAt step e = pure $ CRChatRelayTestResult user Nothing (Just $ RelayTestFailure step e) + r <- tryAllErrors $ getShortLinkConnReq nm user address + case r of + Left e -> failAt RTSGetLink e + Right (FixedLinkData {rootKey, linkConnReq = cReq}, cData) -> do + relayProfile_ <- liftIO $ decodeLinkUserData cData + case relayProfile_ of + Nothing -> failAt RTSDecodeLink (ChatError $ CERelayTestError "no relay address link data") + Just RelayAddressLinkData {relayProfile} -> do + let failWithProfile step e = + pure $ CRChatRelayTestResult user (Just relayProfile) (Just $ RelayTestFailure step e) + lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + Nothing -> failWithProfile RTSConnect (ChatError $ CERelayTestError "invalid connection request") + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + conn@Connection {connId = testCId} <- withFastStore $ \db -> + createRelayTestConnection db vr user connId ConnPrepared chatV subMode + challenge <- drgRandomBytes 32 + testVar <- newEmptyTMVarIO + let acId = aConnId conn + relayTest = RelayTest {challenge, rootKey, result = testVar} + chatRelayTests_ <- asks chatRelayTests + atomically $ TM.insert acId relayTest chatRelayTests_ + testResult <- tryAllErrors $ do + dm <- encodeConnInfo $ XGrpRelayTest challenge Nothing + void $ withAgent $ \a -> joinConnection a nm (aUserId user) acId True cReq dm PQSupportOff subMode + liftIO $ timeout 40000000 $ atomically $ takeTMVar testVar + atomically $ TM.delete acId chatRelayTests_ + withFastStore' $ \db -> deleteConnectionRecord db user testCId + deleteAgentConnectionAsync acId + case testResult of + Left e -> failWithProfile RTSConnect e + Right Nothing -> failWithProfile RTSWaitResponse (ChatError $ CERelayTestError "timeout") + Right (Just Nothing) -> pure $ CRChatRelayTestResult user (Just relayProfile) Nothing + Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) + TestChatRelay address -> withUser $ \User {userId} -> + processChatCommand vr nm $ APITestChatRelay userId address GetUserChatRelays -> withUser $ \user -> do srvs <- withFastStore (`getUserServers` user) liftIO $ CRUserServers user <$> groupByOperator (onlyRelays srvs) @@ -2161,14 +2202,16 @@ processChatCommand vr nm = \case CRContactsList user <$> withFastStore' (\db -> getUserContacts db vr user) ListContacts -> withUser $ \User {userId} -> processChatCommand vr nm $ APIListContacts userId - APICreateMyAddress userId -> withUserId userId $ \user@User {userChatRelay} -> do + APICreateMyAddress userId -> withUserId userId $ \user@User {profile = LocalProfile {displayName}, userChatRelay} -> do withFastStore' (\db -> runExceptT $ getUserAddress db user) >>= \case Left SEUserContactLinkNotFound -> pure () Left e -> throwError $ ChatErrorStore e Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode - -- TODO [relays] relay: add relay profile, identity, key to link data? - let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing + -- TODO [relays] relay: add identity, key to link data? + let userData + | isTrue userChatRelay = encodeShortLinkData $ RelayAddressLinkData {relayProfile = RelayProfile {name = displayName}} + | otherwise = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} -- TODO [certs rcv] (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) Nothing IKPQOn subMode @@ -4504,6 +4547,8 @@ cleanupManager = do liftIO $ threadDelay' stepDelay cleanupInProgressGroups user `catchAllErrors` eToView liftIO $ threadDelay' stepDelay + cleanupStaleRelayTestConns user `catchAllErrors` eToView + liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime cleanupInterval ts @@ -4523,6 +4568,13 @@ cleanupManager = do inProgressGroups <- withStore' $ \db -> getInProgressGroups db vr user cutoffTs forM_ inProgressGroups $ \gInfo -> deleteInProgressGroup user gInfo `catchAllErrors` eToView + cleanupStaleRelayTestConns user = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-300) ts + staleConns <- withStore' $ \db -> getStaleRelayTestConns db user cutoffTs + forM_ staleConns $ \acId -> do + deleteAgentConnectionAsync acId + withStore' $ \db -> deleteConnectionByAgentConnId db user acId cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts @@ -4767,6 +4819,8 @@ chatCommandP = "/xftp " *> (SetUserProtoServers (AProtocolType SPXFTP) . map (AProtoServerWithAuth SPXFTP) <$> protocolServersP), "/smp" $> GetUserProtoServers (AProtocolType SPSMP), "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), + "/_relay test " *> (APITestChatRelay <$> A.decimal <* A.space <*> strP), + "/relay test " *> (TestChatRelay <$> strP), "/relays " *> (SetUserChatRelays <$> chatRelaysP), "/relays" $> GetUserChatRelays, "/_operators" $> APIGetServerOperators, diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 24159934e2..4b64d2b34d 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -406,15 +406,41 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processDirectMessage agentMsg connEntity conn@Connection {connId, connChatVersion, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of CONF confId pqSupport _ connInfo -> do - conn' <- processCONFpqSupport conn pqSupport - -- [incognito] send saved profile - (conn'', gInfo_) <- saveConnInfo conn' connInfo - incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - let profileToSend = case gInfo_ of - Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True - -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend + chatRelayTests_ <- asks chatRelayTests + relayTest_ <- atomically $ TM.lookup agentConnId chatRelayTests_ + case relayTest_ of + Just RelayTest {challenge, rootKey, result = testVar} -> do + r <- tryAllErrors $ do + ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + case chatMsgEvent of + XGrpRelayTest _challenge sigBytes_ -> + case sigBytes_ of + Just sigBytes -> case C.decodeSignature sigBytes of + Right sig + | C.verify' rootKey sig challenge -> + atomically $ putTMVar testVar Nothing + | otherwise -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify (ChatError $ CERelayTestError "invalid signature")) + Left e -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify (ChatError $ CERelayTestError $ "signature decoding failed: " <> e)) + Nothing -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify (ChatError $ CERelayTestError "no signature in response")) + _ -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse (ChatError $ CERelayTestError "unexpected message type")) + case r of + Left e -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse e) + Right () -> pure () + Nothing -> do + conn' <- processCONFpqSupport conn pqSupport + -- [incognito] send saved profile + (conn'', gInfo_) <- saveConnInfo conn' connInfo + incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + let profileToSend = case gInfo_ of + Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True + -- [async agent commands] no continuation needed, but command should be asynchronous for stability + allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend INFO pqSupport connInfo -> do processINFOpqSupport conn pqSupport void $ saveConnInfo conn connInfo @@ -1247,6 +1273,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XMember p joiningMemberId joiningMemberKey -> memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey XInfo p -> profileContactRequest invId chatVRange p Nothing Nothing Nothing pqSupport XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv + XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge -- TODO show/log error, other events in contact request _ -> pure () LINK _link auData -> @@ -1453,6 +1480,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpRelayInv invId chatVRange groupRelayInv = do (_gInfo, _ownerMember) <- withStore $ \db -> createRelayRequestGroup db vr user groupRelayInv invId chatVRange lift $ void $ getRelayRequestWorker True + xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () + xGrpRelayTest invId chatVRange challenge = do + privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn) + case privKey_ of + Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address") + Just privKey -> do + let sig = C.signatureBytes $ C.sign' privKey challenge + msg = XGrpRelayTest challenge (Just sig) + subMode <- chatReadVar subscriptionMode + chatVR <- chatVersionRange + let chatV = chatVR `peerConnChatVersion` chatVRange + (cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV + withFastStore $ \db -> do + Connection {connId = testCId} <- createRelayTestConnection db vr user acId ConnAccepted chatV subMode + liftIO $ setCommandConnId db user cmdId testCId -- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays -- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember) memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> MemberKey -> CM () diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 557904a9a5..3e7bc427e7 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -46,6 +46,7 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions +import Simplex.Chat.Protocol (RelayProfile (..)) import Simplex.Chat.Types (ShortLinkContact, User) import Simplex.Chat.Types.Shared (RelayStatus) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) @@ -263,7 +264,7 @@ deriving instance Show AUserChatRelay data UserChatRelay' s = UserChatRelay { chatRelayId :: DBEntityId' s, address :: ShortLinkContact, - name :: Text, + relayProfile :: RelayProfile, domains :: [Text], preset :: Bool, tested :: Maybe Bool, @@ -340,7 +341,7 @@ newChatRelay = newChatRelay_ False True newChatRelay_ :: Bool -> Bool -> Text -> [Text] -> ShortLinkContact -> NewUserChatRelay newChatRelay_ preset enabled name domains !address = - UserChatRelay {chatRelayId = DBNewEntity, address, name, domains, preset, tested = Nothing, enabled, deleted = False} + UserChatRelay {chatRelayId = DBNewEntity, address, relayProfile = RelayProfile {name}, domains, preset, tested = Nothing, enabled, deleted = False} -- This function should be used inside DB transaction to update conditions in the database -- it evaluates to (current conditions, and conditions to add) @@ -543,11 +544,11 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other chatRelayErrs uss = concatMap duplicateErrs_ cRelays where cRelays = filter (\(AUCR _ UserChatRelay {deleted}) -> not deleted) $ userChatRelays uss - duplicateErrs_ (AUCR _ UserChatRelay {name, address}) = + duplicateErrs_ (AUCR _ UserChatRelay {relayProfile = RelayProfile {name}, address}) = [USEDuplicateChatRelayName name | name `elem` duplicateNames] <> [USEDuplicateChatRelayAddress name address | address `elem` duplicateAddresses] duplicateNames = snd $ foldl' addDuplicate (S.empty, S.empty) allNames - allNames = map (\(AUCR _ UserChatRelay {name}) -> name) cRelays + allNames = map (\(AUCR _ UserChatRelay {relayProfile = RelayProfile {name}}) -> name) cRelays duplicateAddresses = snd $ foldl' addAddress ([], []) allAddresses allAddresses = map (\(AUCR _ UserChatRelay {address}) -> address) cRelays addAddress :: ([ShortLinkContact], [ShortLinkContact]) -> ShortLinkContact -> ([ShortLinkContact], [ShortLinkContact]) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index d012bbb0a5..a2ea54f39d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -436,6 +436,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpLinkAcpt :: GroupAcceptance -> GroupMemberRole -> MemberId -> ChatMsgEvent 'Json XGrpRelayInv :: GroupRelayInvitation -> ChatMsgEvent 'Json XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json + XGrpRelayTest :: ByteString -> Maybe ByteString -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -964,6 +965,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpLinkAcpt_ :: CMEventTag 'Json XGrpRelayInv_ :: CMEventTag 'Json XGrpRelayAcpt_ :: CMEventTag 'Json + XGrpRelayTest_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -1020,6 +1022,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpLinkAcpt_ -> "x.grp.link.acpt" XGrpRelayInv_ -> "x.grp.relay.inv" XGrpRelayAcpt_ -> "x.grp.relay.acpt" + XGrpRelayTest_ -> "x.grp.relay.test" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -1077,6 +1080,7 @@ instance StrEncoding ACMEventTag where "x.grp.link.acpt" -> XGrpLinkAcpt_ "x.grp.relay.inv" -> XGrpRelayInv_ "x.grp.relay.acpt" -> XGrpRelayAcpt_ + "x.grp.relay.test" -> XGrpRelayTest_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -1130,6 +1134,7 @@ toCMEventTag msg = case msg of XGrpLinkAcpt {} -> XGrpLinkAcpt_ XGrpRelayInv _ -> XGrpRelayInv_ XGrpRelayAcpt _ -> XGrpRelayAcpt_ + XGrpRelayTest {} -> XGrpRelayTest_ XGrpMemNew {} -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -1282,6 +1287,10 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "acceptance" <*> p "role" <*> p "memberId" XGrpRelayInv_ -> XGrpRelayInv <$> p "groupRelayInvitation" XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" + XGrpRelayTest_ -> do + B64UrlByteString challenge <- p "challenge" + sig_ <- fmap (\(B64UrlByteString s) -> s) <$> opt "signature" + pure $ XGrpRelayTest challenge sig_ XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -1349,6 +1358,9 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpLinkAcpt acceptance role memberId -> o ["acceptance" .= acceptance, "role" .= role, "memberId" .= memberId] XGrpRelayInv groupRelayInv -> o ["groupRelayInvitation" .= groupRelayInv] XGrpRelayAcpt relayLink -> o ["relayLink" .= relayLink] + XGrpRelayTest challenge sig_ -> o $ + ("signature" .=? (B64UrlByteString <$> sig_)) + ["challenge" .= B64UrlByteString challenge] XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] @@ -1443,3 +1455,13 @@ data RelayShortLinkData = RelayShortLinkData $(JQ.deriveJSON defaultJSON ''RelayShortLinkData) +data RelayProfile = RelayProfile {name :: ContactName} + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''RelayProfile) + +data RelayAddressLinkData = RelayAddressLinkData {relayProfile :: RelayProfile} + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''RelayAddressLinkData) + diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index c81044f12d..fcb9e6e24c 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -9,6 +9,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TypeOperators #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -31,6 +32,7 @@ module Simplex.Chat.Store.Direct createIncognitoProfile, createConnReqConnection, createRelayMemberConnectionAsync, + createRelayTestConnection, updateConnLinkData, setPreparedGroupStartedConnection, getProfileById, @@ -112,7 +114,7 @@ import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ACreatedCon import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Crypto.Ratchet (PQSupport) +import Simplex.Messaging.Crypto.Ratchet (PQSupport, pattern PQSupportOff) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (SubscriptionMode (..)) #if defined(dbPostgres) @@ -241,6 +243,26 @@ createRelayMemberConnectionAsync db user@User {userId} gInfo GroupMember {groupM where customUserProfileId_ = localProfileId <$> incognitoMembershipProfile gInfo +createRelayTestConnection :: DB.Connection -> VersionRangeChat -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV subMode = do + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + conn_chat_version, to_subscribe, pq_support, pq_encryption, + relay_test, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, agentConnId, 0 :: Int, connStatus, ConnContact) + :. (chatV, BI (subMode == SMOnlyCreate), PQSupportOff, PQSupportOff) + :. (BI True, currentTs, currentTs) + ) + connId <- liftIO $ insertedRowId db + getConnectionById db vr user connId + updateConnLinkData :: DB.Connection -> User -> Connection -> ConnReqContact -> ConnReqUriHash -> Maybe GroupLinkId -> VersionChat -> PQSupport -> IO () updateConnLinkData db User {userId} Connection {connId} cReq cReqHash groupLinkId_ chatV pqSup = do currentTs <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 0ac0008598..3b783979d7 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1335,11 +1335,11 @@ groupRelayQuery = toGroupRelay :: (Int64, GroupMemberId, DBEntityId, ShortLinkContact, Text, Text, BoolInt, Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact) -> GroupRelay toGroupRelay (groupRelayId, groupMemberId, chatRelayId, address, name, domains, BI preset, tested, BI enabled, BI deleted, relayStatus, relayLink) = - let userChatRelay = UserChatRelay {chatRelayId, address, name, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted} + let userChatRelay = UserChatRelay {chatRelayId, address, relayProfile = RelayProfile {name}, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted} in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink} createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember -createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {name} = do +createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {name}} = do currentTs <- liftIO getCurrentTime let relayProfile = profileFromName name (localDisplayName, memProfileId) <- createNewMemberProfile_ db user relayProfile currentTs diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs index 0bc6d03e3f..135fc39eb2 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs @@ -72,6 +72,8 @@ ALTER TABLE messages ADD COLUMN msg_chat_binding TEXT; ALTER TABLE messages ADD COLUMN msg_signatures BYTEA; ALTER TABLE chat_items ADD COLUMN msg_signed TEXT; + +ALTER TABLE connections ADD COLUMN relay_test SMALLINT NOT NULL DEFAULT 0; |] down_m20260222_chat_relays :: Text @@ -120,4 +122,6 @@ ALTER TABLE messages DROP COLUMN msg_chat_binding; ALTER TABLE messages DROP COLUMN msg_signatures; ALTER TABLE chat_items DROP COLUMN msg_signed; + +ALTER TABLE connections DROP COLUMN relay_test; |] diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 79376a38bb..06443707fc 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -634,7 +634,7 @@ getChatRelays db User {userId} = toChatRelay :: (DBEntityId, ShortLinkContact, Text, Text, BoolInt, Maybe BoolInt, BoolInt) -> UserChatRelay toChatRelay (chatRelayId, address, name, domains, BI preset, tested, BI enabled) = - UserChatRelay {chatRelayId, address, name, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted = False} + UserChatRelay {chatRelayId, address, relayProfile = RelayProfile {name}, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted = False} getChatRelayById :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserChatRelay getChatRelayById db User {userId} relayId = @@ -649,7 +649,7 @@ getChatRelayById db User {userId} relayId = (userId, relayId) insertChatRelay :: DB.Connection -> User -> UTCTime -> NewUserChatRelay -> IO UserChatRelay -insertChatRelay db User {userId} ts relay@UserChatRelay {address, name, domains, preset, tested, enabled} = do +insertChatRelay db User {userId} ts relay@UserChatRelay {address, relayProfile = RelayProfile {name}, domains, preset, tested, enabled} = do crId <- fromOnly . head <$> DB.query @@ -664,7 +664,7 @@ insertChatRelay db User {userId} ts relay@UserChatRelay {address, name, domains, pure (relay :: NewUserChatRelay) {chatRelayId = DBEntityId crId} updateChatRelay :: DB.Connection -> UTCTime -> UserChatRelay -> IO () -updateChatRelay db ts UserChatRelay {chatRelayId, address, name, domains, preset, tested, enabled} = +updateChatRelay db ts UserChatRelay {chatRelayId, address, relayProfile = RelayProfile {name}, domains, preset, tested, enabled} = DB.execute db [sql| @@ -948,7 +948,7 @@ setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, s | otherwise -> Just relay <$ updateChatRelay db ts relay -- Un-delete soft-deleted relay, updating name and settings but keeping the address unchanged. undeleteRelay :: Int64 -> NewUserChatRelay -> IO () - undeleteRelay existingId UserChatRelay {name = nm, domains, preset, tested, enabled} = + undeleteRelay existingId UserChatRelay {relayProfile = RelayProfile {name = nm}, domains, preset, tested, enabled} = DB.execute db [sql| UPDATE chat_relays diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs index fe8c79b1a5..e5e083efd0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs @@ -80,6 +80,8 @@ ALTER TABLE messages ADD COLUMN msg_chat_binding TEXT; ALTER TABLE messages ADD COLUMN msg_signatures BLOB; ALTER TABLE chat_items ADD COLUMN msg_signed TEXT; + +ALTER TABLE connections ADD COLUMN relay_test INTEGER NOT NULL DEFAULT 0; |] down_m20260222_chat_relays :: Query @@ -124,4 +126,6 @@ ALTER TABLE messages DROP COLUMN msg_chat_binding; ALTER TABLE messages DROP COLUMN msg_signatures; ALTER TABLE chat_items DROP COLUMN msg_signed; + +ALTER TABLE connections DROP COLUMN relay_test; |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 1a2dfc90e2..8061015156 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1764,6 +1764,15 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + conn_chat_version, to_subscribe, pq_support, pq_encryption, + relay_test, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO connections ( user_id, agent_conn_id, conn_level, conn_status, conn_type, @@ -3293,6 +3302,13 @@ Query: Plan: SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) +Query: + SELECT agent_conn_id FROM connections + WHERE user_id = ? AND relay_test = 1 AND created_at < ? + +Plan: +SEARCH connections USING INDEX idx_connections_to_subscribe (user_id=?) + Query: SELECT c.agent_conn_id FROM connections c diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 92cdc872cb..c9a4c38de0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -340,6 +340,7 @@ CREATE TABLE connections( short_link_inv BLOB, via_short_link_contact BLOB, via_contact_uri BLOB, + relay_test INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 762cf8469a..af0958ed35 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -899,3 +899,18 @@ setViaGroupLinkUri db groupId connId = do deleteConnectionRecord :: DB.Connection -> User -> Int64 -> IO () deleteConnectionRecord db User {userId} cId = do DB.execute db "DELETE FROM connections WHERE user_id = ? AND connection_id = ?" (userId, cId) + +getStaleRelayTestConns :: DB.Connection -> User -> UTCTime -> IO [ConnId] +getStaleRelayTestConns db User {userId} cutoffTs = + map fromOnly <$> + DB.query + db + [sql| + SELECT agent_conn_id FROM connections + WHERE user_id = ? AND relay_test = 1 AND created_at < ? + |] + (userId, cutoffTs) + +deleteConnectionByAgentConnId :: DB.Connection -> User -> ConnId -> IO () +deleteConnectionByAgentConnId db User {userId} acId = + DB.execute db "DELETE FROM connections WHERE user_id = ? AND agent_conn_id = ?" (userId, acId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 3657ce5d05..87a796663c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -125,6 +125,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRChatContentTypes cts -> [plain $ "Chat content types: " <> T.intercalate ", " (map (safeDecodeUtf8 . strEncode) cts)] CRChatTags u tags -> ttyUser u [viewJSON tags] CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure + CRChatRelayTestResult u relayProfile_ relayTestFailure_ -> ttyUser u $ viewRelayTestResult relayProfile_ relayTestFailure_ CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) CRUserServersValidation {} -> [] @@ -1578,7 +1579,7 @@ viewUserServers UserOperatorServers {operator, smpServers, xftpServers, chatRela [" Chat relays"] <> map (plain . (" " <>) . viewChatRelay) cRelays | otherwise = [] where - viewChatRelay UserChatRelay {name, address, preset, tested, enabled} = name <> relayAddress <> relayInfo + viewChatRelay UserChatRelay {relayProfile = RelayProfile {name}, address, preset, tested, enabled} = name <> relayAddress <> relayInfo where relayAddress = ": " <> safeDecodeUtf8 (strEncode address) relayInfo = if null relayInfo_ then "" else parens $ T.intercalate ", " relayInfo_ @@ -1613,6 +1614,14 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case where pName = protocolName p +viewRelayTestResult :: Maybe RelayProfile -> Maybe RelayTestFailure -> [StyledString] +viewRelayTestResult relayProfile_ = \case + Just RelayTestFailure {rtfStep, rtfError} -> + ["relay test failed at " <> plain (show rtfStep) <> ", error: " <> plain (show rtfError)] + Nothing -> case relayProfile_ of + Just RelayProfile {name} -> ["relay test passed, profile: " <> plain (T.unpack name)] + Nothing -> ["relay test passed"] + viewServerOperators :: [ServerOperator] -> Maybe UsageConditionsAction -> [StyledString] viewServerOperators ops ca = map (plain . viewOperator) ops <> maybe [] viewConditionsAction ca @@ -2596,6 +2605,7 @@ viewChatError isCmd logLevel testView = \case CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"] CEConnectionUserChangeProhibited -> ["incognito mode change prohibited for user"] CEPeerChatVRangeIncompatible -> ["peer chat protocol version range incompatible"] + CERelayTestError e -> ["relay test error: " <> plain e] CEInternalError e -> ["internal chat error: " <> plain e] CEException e -> ["exception: " <> plain e] -- e -> ["chat error: " <> sShow e] diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 606c199a82..9767539441 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -11,6 +11,7 @@ chatRelayTests = do it "get and set chat relays" testGetSetChatRelays it "re-add soft-deleted relay by same address" testReAddRelaySameAddress it "re-add soft-deleted relay by same name" testReAddRelaySameName + it "test chat relay" testChatRelayTest testGetSetChatRelays :: HasCallStack => TestParams -> IO () testGetSetChatRelays ps = @@ -115,6 +116,35 @@ testReAddRelaySameName ps = alice <## " Chat relays" alice <## (" my_relay: " <> bobSLink) +testChatRelayTest :: HasCallStack => TestParams -> IO () +testChatRelayTest ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + -- Setup: bob (relay) creates address + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + + -- Setup: cath (normal user) creates address + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + + -- Scenario 1: Happy path - test relay address succeeds + alice ##> ("/relay test " <> bobSLink) + alice <## "relay test passed, profile: bob" + + -- Scenario 2: Non-relay address - cath is not a relay user, + -- her address has ContactShortLinkData, not RelayAddressLinkData + alice ##> ("/relay test " <> cathSLink) + alice <##. "relay test failed at RTSDecodeLink, error: " + + -- Scenario 3: Deleted address - bob deletes his address + bob ##> "/da" + bob <## "Your chat address is deleted - accepted contacts will remain connected." + bob <## "To create a new chat address use /ad" + alice ##> ("/relay test " <> bobSLink) + alice <##. "relay test failed at RTSGetLink, error: " + -- Create a public group with relay=1, wait for relay to join createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO () createChannelWithRelay gName owner relay = do diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index d735a40bff..044ee06023 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -20,6 +20,7 @@ import Simplex.Chat import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Operators import Simplex.Chat.Operators.Presets +import Simplex.Chat.Protocol (RelayProfile (..)) import Simplex.Chat.Types import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) @@ -122,7 +123,7 @@ updatedServersTest = describe "validate user servers" $ do map chatRelayAddress presetRelays `shouldBe` map relayAddr' (chatRelays' op) srvHost' (AUS _ s) = srvHost s relayAddr' (AUCR _ r) = chatRelayAddress r - relayName' (AUCR _ UserChatRelay {name}) = name + relayName' (AUCR _ UserChatRelay {relayProfile = RelayProfile {name}}) = name PresetServers {operators} = presetServers defaultChatConfig customRelayAddr = either error id $ strDecode "https://relay.example.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y"