mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-06-06 17:42:31 +00:00
core, ui: chat relay test (#6736)
This commit is contained in:
@@ -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?
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+54
@@ -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()
|
||||
}
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
+114
-42
@@ -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
|
||||
}
|
||||
|
||||
+19
-10
@@ -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()
|
||||
|
||||
+69
-17
@@ -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
@@ -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?
|
||||
|
||||
@@ -414,6 +414,7 @@ undocumentedCommands =
|
||||
"APISwitchGroupMember",
|
||||
"APISyncContactRatchet",
|
||||
"APISyncGroupMemberRatchet",
|
||||
"APITestChatRelay",
|
||||
"APITestProtoServer",
|
||||
"APIUnhideUser",
|
||||
"APIUnmuteUser",
|
||||
@@ -471,6 +472,7 @@ undocumentedCommands =
|
||||
"StopRemoteHost",
|
||||
"StoreRemoteFile",
|
||||
"SwitchRemoteHost",
|
||||
"TestChatRelay",
|
||||
"TestProtoServer",
|
||||
"TestStorageEncryption",
|
||||
"VerifyRemoteCtrlSession"
|
||||
|
||||
@@ -132,6 +132,7 @@ undocumentedResponses =
|
||||
"CRChatItemInfo",
|
||||
"CRChatItems",
|
||||
"CRChatItemTTL",
|
||||
"CRChatRelayTestResult",
|
||||
"CRChats",
|
||||
"CRConnectionsDiff",
|
||||
"CRChatTags",
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user