core, ui: chat relay test (#6736)

This commit is contained in:
spaced4ndy
2026-04-02 15:36:36 +00:00
committed by GitHub
parent 42fe94752c
commit a14a66db14
40 changed files with 1670 additions and 148 deletions
+41
View File
@@ -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?
+9
View File
@@ -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 }
@@ -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<UserChatRelay>) 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
}
}
@@ -259,6 +259,7 @@ struct OperatorView: View {
TestServersButton(
smpServers: $userServers[operatorIndex].smpServers,
xftpServers: $userServers[operatorIndex].xftpServers,
chatRelays: $userServers[operatorIndex].chatRelays,
testing: $testing
)
}
@@ -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..<chatRelays.count {
if chatRelays[i].enabled && !chatRelays[i].deleted {
chatRelays[i].tested = nil
}
}
for i in 0..<smpServers.count {
if smpServers[i].enabled {
smpServers[i].tested = nil
@@ -416,6 +428,19 @@ struct TestServersButton: View {
}
return fs
}
private func runRelaysTest() async -> [String: RelayTestFailure] {
var fs: [String: RelayTestFailure] = [:]
for i in 0..<chatRelays.count {
if chatRelays[i].enabled && !chatRelays[i].deleted {
if let f = await testRelayConnection(relay: $chatRelays[i]) {
let name = !chatRelays[i].name.isEmpty ? chatRelays[i].name : chatRelays[i].domains.first ?? chatRelays[i].address
fs[name] = f
}
}
}
return fs
}
}
struct YourServersView_Previews: PreviewProvider {
+1
View File
@@ -795,6 +795,7 @@ public enum ChatErrorType: Decodable, Hashable {
case connectionIncognitoChangeProhibited
case connectionUserChangeProhibited
case peerChatVRangeIncompatible
case relayTestError(message: String)
case internalError(message: String)
case exception(message: String)
}
+13 -4
View File
@@ -2562,10 +2562,14 @@ public enum RelayStatus: String, Decodable, Equatable, Hashable {
case rsActive = "active"
}
public struct RelayProfile: Codable, Equatable, Hashable {
public var name: String
}
public struct UserChatRelay: Identifiable, Codable, Equatable, Hashable {
public var chatRelayId: Int64?
public var address: String
public var name: String
public var relayProfile: RelayProfile
public var domains: [String]
public var preset: Bool
public var tested: Bool?
@@ -2573,10 +2577,15 @@ public struct UserChatRelay: Identifiable, Codable, Equatable, Hashable {
public var deleted: Bool
public var createdAt = Date()
public var name: String {
get { relayProfile.name }
set { relayProfile.name = newValue }
}
public init(chatRelayId: Int64? = nil, address: String, name: String, domains: [String], preset: Bool, tested: Bool? = nil, enabled: Bool, deleted: Bool, createdAt: Date = Date()) {
self.chatRelayId = chatRelayId
self.address = address
self.name = name
self.relayProfile = RelayProfile(name: name)
self.domains = domains
self.preset = preset
self.tested = tested
@@ -2586,7 +2595,7 @@ public struct UserChatRelay: Identifiable, Codable, Equatable, Hashable {
}
public static func == (l: UserChatRelay, r: UserChatRelay) -> 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
@@ -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<String>,
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
@@ -1216,6 +1216,14 @@ object ChatController {
throw Exception("testProtoServer bad response: ${r.responseType} ${r.details}")
}
suspend fun testChatRelay(rh: Long?, address: String): Pair<RelayProfile?, RelayTestFailure?> {
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<ServerOperator>): 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<ChatTag>): 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<UserOperatorServers>): CR()
@Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List<UserServersError>, val serverWarnings: List<UserServersWarning> = 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()
}
@@ -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)
@@ -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<UserChatRelay>,
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<UserChatRelay>) {
SectionView(stringResource(MR.strings.preset_relay_name).uppercase()) {
SectionItemView {
Text(relay.value.name)
}
}
SectionDividerSpaced()
private fun PresetRelay(relay: MutableState<UserChatRelay>, testing: MutableState<Boolean>) {
SectionView(stringResource(MR.strings.preset_relay_address).uppercase()) {
SelectionContainer {
Text(
@@ -178,13 +192,20 @@ private fun PresetRelay(relay: MutableState<UserChatRelay>) {
}
}
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<UserChatRelay>,
onDelete: (() -> Unit)?
onDelete: (() -> Unit)?,
testing: MutableState<Boolean>
) {
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<UserChatRelay>,
valid: Boolean = true
valid: Boolean = true,
testing: MutableState<Boolean>
) {
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<UserChatRelay>) {
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<UserChatRelay>): 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
}
@@ -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()
@@ -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<UserServer>,
xftpServers: List<UserServer>,
chatRelays: List<UserChatRelay> = emptyList(),
testing: MutableState<Boolean>,
onUpdate: (ServerProtocol, List<UserServer>) -> Unit
onUpdate: (ServerProtocol, List<UserServer>) -> Unit,
onUpdateRelays: ((List<UserChatRelay>) -> 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<Boolean>,
smpServers: List<UserServer>,
xftpServers: List<UserServer>,
chatRelays: List<UserChatRelay>,
m: ChatModel,
onUpdate: (ServerProtocol, List<UserServer>) -> Unit
onUpdate: (ServerProtocol, List<UserServer>) -> Unit,
onUpdateRelays: ((List<UserChatRelay>) -> 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<String>()
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<UserServer>, m: ChatModel, onUp
return fs
}
private fun resetRelayTestStatus(relays: List<UserChatRelay>): List<UserChatRelay> {
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<UserChatRelay>, onUpdated: (List<UserChatRelay>) -> Unit): Map<String, RelayTestFailure> {
val fs: MutableMap<String, RelayTestFailure> = mutableMapOf()
val updatedRelays = ArrayList<UserChatRelay>(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<List<UserOperatorServers>>,
operatorServersIndex: Int,
@@ -2840,8 +2840,15 @@
<string name="test_relay">Test relay</string>
<string name="use_for_new_channels">Use for new channels</string>
<string name="delete_relay">Delete relay</string>
<string name="not_implemented">Not implemented</string>
<string name="relay_testing_not_available">Relay testing is not yet available.</string>
<string name="test_relay_to_retrieve_name"><![CDATA[<b>Test relay</b> to retrieve its name.]]></string>
<string name="relay_test_failed_alert">Relay test failed!</string>
<string name="relay_test_step_get_link">Get link</string>
<string name="relay_test_step_decode_link">Decode link</string>
<string name="relay_test_step_connect">Connect</string>
<string name="relay_test_step_wait_response">Wait response</string>
<string name="relay_test_step_verify">Verify</string>
<string name="error_relay_test_failed_at_step">Test failed at step %s.</string>
<string name="error_relay_test_server_auth">Server requires authorization to connect to relay, check password.</string>
<string name="invalid_relay_name">Invalid relay name!</string>
<string name="check_relay_name">Check relay name and try again.</string>
<string name="invalid_relay_address">Invalid relay address!</string>
+14 -1
View File
@@ -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?
+2
View File
@@ -414,6 +414,7 @@ undocumentedCommands =
"APISwitchGroupMember",
"APISyncContactRatchet",
"APISyncGroupMemberRatchet",
"APITestChatRelay",
"APITestProtoServer",
"APIUnhideUser",
"APIUnmuteUser",
@@ -471,6 +472,7 @@ undocumentedCommands =
"StopRemoteHost",
"StoreRemoteFile",
"SwitchRemoteHost",
"TestChatRelay",
"TestProtoServer",
"TestStorageEncryption",
"VerifyRemoteCtrlSession"
+1
View File
@@ -132,6 +132,7 @@ undocumentedResponses =
"CRChatItemInfo",
"CRChatItems",
"CRChatItemTTL",
"CRChatRelayTestResult",
"CRChats",
"CRConnectionsDiff",
"CRChatTags",
+2
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
@@ -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
```
+813
View File
@@ -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: <link>` to the same (the `name` field is now accessed via `relayProfile`), but the CLI command syntax stays the same (`/relays name=bob_relay <link>`).
---
## 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.
+1 -1
View File
@@ -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";
+2
View File
@@ -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,
+29 -3
View File
@@ -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)
+57 -3
View File
@@ -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,
+51 -9
View File
@@ -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 ()
+5 -4
View File
@@ -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])
+22
View File
@@ -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)
+23 -1
View File
@@ -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
+2 -2
View File
@@ -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
@@ -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;
|]
+4 -4
View File
@@ -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
@@ -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;
|]
@@ -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
@@ -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
+15
View File
@@ -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)
+11 -1
View File
@@ -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]
+30
View File
@@ -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
+2 -1
View File
@@ -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"