399 lines
16 KiB
Swift
399 lines
16 KiB
Swift
import SwiftUI
|
||
|
||
/// "Server information" screen shown from Settings, mirroring Monal's
|
||
/// `ServerDetails`: a grouped list of headline + caption entries whose row
|
||
/// background is tinted green/orange/red according to the detected status.
|
||
struct ServerInfoView: View {
|
||
@ObservedObject var model: AppModel
|
||
@State private var isLoading = false
|
||
|
||
var body: some View {
|
||
#if os(iOS)
|
||
Form {
|
||
infocontent()
|
||
}
|
||
.navigationTitle(model.account?.domain ?? "Сервер")
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button {
|
||
Task { await load() }
|
||
} label: {
|
||
Label("Обновить", systemImage: "arrow.clockwise")
|
||
}
|
||
.disabled(isLoading)
|
||
}
|
||
}
|
||
.task { await load() }
|
||
.listStyle(.grouped)
|
||
#else
|
||
List {
|
||
infocontent()
|
||
}
|
||
.navigationTitle(model.account?.domain ?? "Сервер")
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button {
|
||
Task { await load() }
|
||
} label: {
|
||
Label("Обновить", systemImage: "arrow.clockwise")
|
||
}
|
||
.disabled(isLoading)
|
||
}
|
||
}
|
||
.task { await load() }
|
||
.listStyle(.inset)
|
||
.frame(minWidth: 480, minHeight: 420)
|
||
#endif
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func infocontent() -> some View {
|
||
if let info = model.serverInformation {
|
||
Section("Это статистика вашего соединения.") {
|
||
statisticsEntries(info)
|
||
}
|
||
Section("Это программное обеспечение, работающее на вашем сервере.") {
|
||
softwareEntry(info)
|
||
}
|
||
Section {
|
||
connectionRows(info)
|
||
} header: {
|
||
Text("Параметры подключения к вашему серверу.")
|
||
} footer: {
|
||
Text(
|
||
"Luma автоматически выбирает самый безопасный вариант: при TLS договаривается о максимальной версии (1.3 > 1.2), а при входе — о самом стойком SASL-механизме, который поддерживает сервер."
|
||
)
|
||
}
|
||
Section("Современные возможности XMPP, обнаруженные на вашем сервере после входа.") {
|
||
ForEach(info.capabilities) { capability in
|
||
entry(
|
||
title: capability.title,
|
||
detail: capability.detail,
|
||
status: capability.status
|
||
)
|
||
}
|
||
}
|
||
Section("MUC-серверы, обнаруженные на вашем сервере.") {
|
||
mucEntries(info)
|
||
}
|
||
Section("STUN и TURN сервисы, объявленные вашим сервером.") {
|
||
stunTurnEntries(info)
|
||
}
|
||
Section("Методы аутентификации SASL, которые поддерживает ваш сервер.") {
|
||
saslEntries(info)
|
||
}
|
||
Section("Методы аутентификации SASL2 (RFC 9050), которые поддерживает ваш сервер.") {
|
||
sasl2Entries(info)
|
||
}
|
||
Section {
|
||
channelBindingEntries(info)
|
||
} header: {
|
||
Text("Привязка канала (channel binding), которую поддерживает ваш сервер.")
|
||
} footer: {
|
||
Text(
|
||
"Channel binding связывает SASL-обмен с TLS-каналом: MITM-ретрансляция не сможет подменить соединение. Зелёным отмечен тип, использованный в текущем соединении, оранжевым — типы, которые Luma не поддерживает."
|
||
)
|
||
}
|
||
} else if isLoading {
|
||
Section {
|
||
HStack(spacing: 12) {
|
||
ProgressView()
|
||
Text("Загрузка информации о сервере…")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
} else {
|
||
Section {
|
||
ContentUnavailableView(
|
||
"Нет данных о сервере",
|
||
systemImage: "server.rack",
|
||
description: Text(
|
||
model.connectionStatus == .connected
|
||
? "Сервер не ответил на запрос. Попробуйте обновить."
|
||
: "Подключитесь к серверу, чтобы увидеть его возможности."
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Sections
|
||
|
||
private func softwareEntry(_ info: ServerInformation) -> some View {
|
||
let name = info.software.name ?? "Неизвестный сервер"
|
||
let version = info.software.version ?? "неизвестная версия"
|
||
let platform = info.software.os.map { ", работает на \($0)" } ?? ""
|
||
return VStack(alignment: .leading, spacing: 4) {
|
||
Text(name)
|
||
.font(.headline)
|
||
Text("версия \(version)\(platform)")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Link(
|
||
"Рекомендации для администраторов серверов",
|
||
destination: URL(
|
||
string: "https://github.com/monal-im/Monal/wiki/Considerations-for-XMPP-server-admins"
|
||
)!
|
||
)
|
||
.font(.caption)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.listRowBackground(Color.gray.opacity(0.16))
|
||
}
|
||
|
||
private func connectionRows(_ info: ServerInformation) -> some View {
|
||
Group {
|
||
if let account = model.account {
|
||
if let host = account.manualHost {
|
||
let port = account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222)
|
||
LabeledContent("Хост", value: "\(host):\(port)")
|
||
LabeledContent(
|
||
"Шифрование",
|
||
value: account.usesDirectTLS ? "Direct TLS" : "STARTTLS"
|
||
)
|
||
} else {
|
||
LabeledContent("Хост", value: "DNS SRV")
|
||
}
|
||
LabeledContent("Ресурс", value: account.effectiveResource)
|
||
}
|
||
if let tlsVersion = info.tlsVersion {
|
||
LabeledContent(
|
||
"Версия TLS",
|
||
value: info.tlsCipher.map { "\(tlsVersion) · \($0)" } ?? tlsVersion
|
||
)
|
||
} else {
|
||
LabeledContent("Версия TLS", value: "Неизвестно")
|
||
}
|
||
if let saslMechanism = info.saslMechanism {
|
||
LabeledContent("SASL-механизм", value: saslMechanism)
|
||
} else {
|
||
LabeledContent("SASL-механизм", value: "Неизвестно")
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func mucEntries(_ info: ServerInformation) -> some View {
|
||
if info.conferenceServers.isEmpty {
|
||
entry(
|
||
title: "Нет",
|
||
detail: "Этот сервер не предоставляет MUC-серверы.",
|
||
status: .error
|
||
)
|
||
} else {
|
||
ForEach(info.conferenceServers) { server in
|
||
entry(
|
||
title: "Сервер: \(server.jid)",
|
||
detail: "\(server.name ?? "<неизвестное имя>") (тип «\(server.type)», категория «\(server.category)»)",
|
||
status: server.type == "text" ? .success : .normal
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func stunTurnEntries(_ info: ServerInformation) -> some View {
|
||
if info.externalServices.isEmpty {
|
||
entry(
|
||
title: "Нет",
|
||
detail: "Этот сервер не предоставляет STUN / TURN сервисы.",
|
||
status: .error
|
||
)
|
||
} else {
|
||
ForEach(info.externalServices) { service in
|
||
let endpoint = "\(service.host):\(service.port.map(String.init) ?? "-")"
|
||
entry(
|
||
title: service.type.uppercased(),
|
||
detail: endpoint,
|
||
status: ["stun", "turn", "stuns", "turns"].contains(service.type.lowercased())
|
||
? .success
|
||
: .error
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func statisticsEntries(_ info: ServerInformation) -> some View {
|
||
let stats = info.connectionStats
|
||
entry(
|
||
title: "Последний вход",
|
||
detail: formatDate(stats.lastLogin),
|
||
status: .normal
|
||
)
|
||
entry(
|
||
title: "Сессия Stream Management установлена",
|
||
detail: formatDate(stats.smacksSessionEstablished),
|
||
status: .normal
|
||
)
|
||
entry(
|
||
title: "Станз отправлено / не подтверждено / получено в этой сессии",
|
||
detail: "\(stats.sent) / \(stats.unacknowledged) / \(stats.received)",
|
||
status: .normal
|
||
)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func saslEntries(_ info: ServerInformation) -> some View {
|
||
if info.saslMethods.isEmpty {
|
||
entry(
|
||
title: "Нет",
|
||
detail: "Сервер не объявил методы SASL-аутентификации.",
|
||
status: .error
|
||
)
|
||
} else {
|
||
ForEach(info.saslMethods, id: \.self) { method in
|
||
entry(
|
||
title: "Метод: \(method)",
|
||
detail: method == info.usedSASLMechanism
|
||
? "Используется в текущем соединении. " + saslDescription(method)
|
||
: saslDescription(method),
|
||
status: method == info.usedSASLMechanism
|
||
? .success
|
||
: (SASLMechanismPreference.weakMechanisms.contains(method)
|
||
? .warning
|
||
: .normal)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func sasl2Entries(_ info: ServerInformation) -> some View {
|
||
if info.sasl2Methods.isEmpty {
|
||
entry(
|
||
title: "Нет",
|
||
detail: "Сервер не объявил методы аутентификации SASL2 (RFC 9050).",
|
||
status: .normal
|
||
)
|
||
} else {
|
||
ForEach(info.sasl2Methods, id: \.self) { method in
|
||
entry(
|
||
title: "Метод: \(method)",
|
||
detail: method == info.usedSASLMechanism
|
||
? "Используется в текущем соединении. " + sasl2Description(method)
|
||
: sasl2Description(method),
|
||
status: method == info.usedSASLMechanism
|
||
? .success
|
||
: (SASLMechanismPreference.weakMechanisms.contains(method)
|
||
? .warning
|
||
: .normal)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func sasl2Description(_ method: String) -> String {
|
||
switch method {
|
||
case "PLAIN":
|
||
return "Отправляет пароль открытым текстом (шифруется только TLS), не очень безопасно."
|
||
case "OAUTHBEARER":
|
||
return "OAuth 2.0 bearer-токен: пароль не передаётся, но сам токен — bearer-секрет, не очень безопасно."
|
||
case "EXTERNAL":
|
||
return "Использует TLS-клиентские сертификаты для аутентификации."
|
||
case let method where method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS"):
|
||
return "Salted Challenge Response Authentication Mechanism с channel-binding."
|
||
case let method where method.hasPrefix("SCRAM-"):
|
||
return "Salted Challenge Response Authentication Mechanism на указанном хэше."
|
||
default:
|
||
return "Неизвестный метод аутентификации."
|
||
}
|
||
}
|
||
|
||
/// Channel-binding types Luma can actually produce binding data for.
|
||
private static let supportedChannelBindingTypes: Set<String> = [
|
||
"tls-exporter", "tls-server-end-point",
|
||
]
|
||
|
||
@ViewBuilder
|
||
private func channelBindingEntries(_ info: ServerInformation) -> some View {
|
||
if info.channelBindingTypes.isEmpty {
|
||
entry(
|
||
title: "Нет",
|
||
detail: "Сервер не рекламирует channel-binding типы (XEP-0440): SASL-обмен не детектирует MITM-атаку на TLS-слой.",
|
||
status: .warning
|
||
)
|
||
} else {
|
||
ForEach(info.channelBindingTypes, id: \.self) { type in
|
||
let used = info.usedChannelBindingType == type
|
||
let supported = Self.supportedChannelBindingTypes.contains(type)
|
||
entry(
|
||
title: "Тип: \(type)",
|
||
detail: channelBindingDescription(type, used: used, supported: supported),
|
||
status: used ? .success : (supported ? .normal : .warning)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func channelBindingDescription(
|
||
_ type: String,
|
||
used: Bool,
|
||
supported: Bool
|
||
) -> String {
|
||
if used {
|
||
return "Используется в текущем соединении."
|
||
}
|
||
if supported {
|
||
return "Luma поддерживает этот тип привязки."
|
||
}
|
||
return "Luma не поддерживает этот тип привязки."
|
||
}
|
||
|
||
private func saslDescription(_ method: String) -> String {
|
||
switch method {
|
||
case "PLAIN":
|
||
return "Отправляет пароль открытым текстом (шифруется только TLS), не очень безопасно."
|
||
case "EXTERNAL":
|
||
return "Использует TLS-клиентские сертификаты для аутентификации."
|
||
case let method where method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS"):
|
||
return "Salted Challenge Response Authentication Mechanism с channel-binding."
|
||
case let method where method.hasPrefix("SCRAM-"):
|
||
return "Salted Challenge Response Authentication Mechanism на указанном хэше."
|
||
default:
|
||
return "Неизвестный метод аутентификации."
|
||
}
|
||
}
|
||
|
||
private func formatDate(_ date: Date?) -> String {
|
||
guard let date else { return "<неизвестно>" }
|
||
return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short)
|
||
}
|
||
|
||
// MARK: - Entry
|
||
|
||
private func entry(
|
||
title: String,
|
||
detail: String,
|
||
status: ServerInformation.CapabilityStatus
|
||
) -> some View {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(title)
|
||
.font(.headline)
|
||
Text(detail)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
// .frame(maxWidth: .infinity, alignment: .leading)
|
||
.listRowBackground(status.color)
|
||
}
|
||
|
||
private func load() async {
|
||
guard model.connectionStatus == .connected else { return }
|
||
isLoading = true
|
||
await model.refreshServerInformation()
|
||
isLoading = false
|
||
}
|
||
}
|
||
|
||
private extension ServerInformation.CapabilityStatus {
|
||
var color: Color {
|
||
switch self {
|
||
case .success: return Color.green.opacity(0.16)
|
||
case .normal: return Color.gray.opacity(0.16)
|
||
case .warning: return Color.orange.opacity(0.16)
|
||
case .error: return Color.red.opacity(0.16)
|
||
}
|
||
}
|
||
}
|