From 38568e1b1d287380dbff5cfd18a7c24b49cae0ae Mon Sep 17 00:00:00 2001 From: qingzeqi Date: Sat, 30 May 2026 23:40:42 +0800 Subject: [PATCH] Allow insecure TLS for iOS servers --- apps/iphone/Yanami/Models/AppModels.swift | 24 +++++++++ .../iphone/Yanami/Services/KomariClient.swift | 53 ++++++++++++++++--- apps/iphone/Yanami/Views/NodeDetailView.swift | 12 ++++- apps/iphone/Yanami/Views/ServerFormView.swift | 6 +++ 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/apps/iphone/Yanami/Models/AppModels.swift b/apps/iphone/Yanami/Models/AppModels.swift index b0593e8..9e4200c 100644 --- a/apps/iphone/Yanami/Models/AppModels.swift +++ b/apps/iphone/Yanami/Models/AppModels.swift @@ -55,8 +55,32 @@ struct ServerProfile: Codable, Equatable, Identifiable { var requires2FA = false var authType = AuthType.password var customHeaders: [CustomHeader] = [] + var allowInsecureTLS = false var createdAt = Date() + enum CodingKeys: String, CodingKey { + case id, name, baseURL, username, password, apiKey, sessionToken, requires2FA + case authType, customHeaders, allowInsecureTLS, createdAt + } + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "My Komari" + baseURL = try container.decodeIfPresent(String.self, forKey: .baseURL) ?? "https://" + username = try container.decodeIfPresent(String.self, forKey: .username) ?? "" + password = try container.decodeIfPresent(String.self, forKey: .password) ?? "" + apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey) ?? "" + sessionToken = try container.decodeIfPresent(String.self, forKey: .sessionToken) ?? "" + requires2FA = try container.decodeIfPresent(Bool.self, forKey: .requires2FA) ?? false + authType = try container.decodeIfPresent(AuthType.self, forKey: .authType) ?? .password + customHeaders = try container.decodeIfPresent([CustomHeader].self, forKey: .customHeaders) ?? [] + allowInsecureTLS = try container.decodeIfPresent(Bool.self, forKey: .allowInsecureTLS) ?? false + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() + } + var normalizedBaseURL: String { baseURL.trimmingCharacters(in: .whitespacesAndNewlines).trimmedTrailingSlash() } diff --git a/apps/iphone/Yanami/Services/KomariClient.swift b/apps/iphone/Yanami/Services/KomariClient.swift index 375069f..2d25fb2 100644 --- a/apps/iphone/Yanami/Services/KomariClient.swift +++ b/apps/iphone/Yanami/Services/KomariClient.swift @@ -24,7 +24,7 @@ struct KomariClient { } let request = try makeRequest(path: "/api/login", method: "POST", jsonBody: body) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw KomariClientError.invalidResponse } @@ -106,7 +106,7 @@ struct KomariClient { let uuid = try normalizedUUID(uuid) let encodedUuid = uuid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uuid let request = try makeRequest(path: "/api/recent/\(encodedUuid)", method: "GET", token: token) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw KomariClientError.invalidResponse } @@ -125,7 +125,7 @@ struct KomariClient { func getClients(token: String) async throws -> [ManagedClient] { let request = try makeRequest(path: "/api/admin/client/list", method: "GET", token: token) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await data(for: request) try validateAdminResponse(data: data, response: response, path: "/api/admin/client/list") return try decodeAdminPayload([ManagedClient].self, from: data) } @@ -133,14 +133,14 @@ struct KomariClient { func deleteClient(token: String, uuid: String) async throws { let uuid = try normalizedUUID(uuid) let request = try makeRequest(path: "/api/admin/client/\(uuid)/remove", method: "POST", token: token) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await data(for: request) try validateAdminResponse(data: data, response: response, path: "/api/admin/client/\(uuid)/remove") } func getClientToken(token: String, uuid: String) async throws -> String { let uuid = try normalizedUUID(uuid) let request = try makeRequest(path: "/api/admin/client/\(uuid)/token", method: "GET", token: token) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await data(for: request) try validateAdminResponse(data: data, response: response, path: "/api/admin/client/\(uuid)/token") let payload = try decodeAdminPayload([String: String].self, from: data) return payload["token"] ?? "" @@ -150,14 +150,14 @@ struct KomariClient { func getPingTasks(token: String) async throws -> [AdminPingTask] { let request = try makeRequest(path: "/api/admin/ping/", method: "GET", token: token) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await data(for: request) try validateAdminResponse(data: data, response: response, path: "/api/admin/ping/") return try decodeAdminPayload([AdminPingTask].self, from: data) } func deletePingTask(token: String, ids: [Int]) async throws { let request = try makeRequest(path: "/api/admin/ping/delete", method: "POST", jsonBody: ["id": ids], token: token) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await data(for: request) try validateAdminResponse(data: data, response: response, path: "/api/admin/ping/delete") } @@ -297,7 +297,7 @@ struct KomariClient { body["params"] = params } let request = try makeRequest(path: "/api/rpc2", method: "POST", jsonBody: body, token: token) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw KomariClientError.invalidResponse } @@ -354,6 +354,16 @@ struct KomariClient { return request } + private func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard profile.allowInsecureTLS else { + return try await URLSession.shared.data(for: request) + } + let delegate = KomariInsecureTLSDelegate(allowedHost: request.url?.host) + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + defer { session.finishTasksAndInvalidate() } + return try await session.data(for: request) + } + private func normalizedUUID(_ uuid: String) throws -> String { let cleaned = uuid.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleaned.isEmpty else { @@ -386,6 +396,33 @@ struct KomariClient { }() } +final class KomariInsecureTLSDelegate: NSObject, URLSessionDelegate { + private let allowedHost: String? + + init(allowedHost: String?) { + self.allowedHost = allowedHost?.lowercased() + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust, + isAllowedHost(challenge.protectionSpace.host) else { + completionHandler(.performDefaultHandling, nil) + return + } + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } + + private func isAllowedHost(_ host: String) -> Bool { + guard let allowedHost else { return false } + return host.lowercased() == allowedHost + } +} + enum KomariClientError: LocalizedError { case invalidConfiguration(String) case invalidResponse diff --git a/apps/iphone/Yanami/Views/NodeDetailView.swift b/apps/iphone/Yanami/Views/NodeDetailView.swift index cb88efb..df13a61 100644 --- a/apps/iphone/Yanami/Views/NodeDetailView.swift +++ b/apps/iphone/Yanami/Views/NodeDetailView.swift @@ -635,6 +635,7 @@ final class SshTerminalViewModel: ObservableObject { private let server: ServerProfile private let token: String private var urlSession: URLSession? + private var urlSessionDelegate: URLSessionDelegate? private var webSocketTask: URLSessionWebSocketTask? private var heartbeatTimer: Timer? private var didSendInitialDirectory = false @@ -677,7 +678,15 @@ final class SshTerminalViewModel: ObservableObject { } request.setValue(buildOrigin(), forHTTPHeaderField: "Origin") - let session = URLSession(configuration: .default) + let session: URLSession + if server.allowInsecureTLS { + let delegate = KomariInsecureTLSDelegate(allowedHost: url.host) + urlSessionDelegate = delegate + session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + } else { + urlSessionDelegate = nil + session = URLSession(configuration: .default) + } urlSession = session let task = session.webSocketTask(with: request) webSocketTask = task @@ -856,6 +865,7 @@ final class SshTerminalViewModel: ObservableObject { webSocketTask = nil urlSession?.invalidateAndCancel() urlSession = nil + urlSessionDelegate = nil didSendInitialDirectory = false } } diff --git a/apps/iphone/Yanami/Views/ServerFormView.swift b/apps/iphone/Yanami/Views/ServerFormView.swift index a08e2c0..ddc00a1 100644 --- a/apps/iphone/Yanami/Views/ServerFormView.swift +++ b/apps/iphone/Yanami/Views/ServerFormView.swift @@ -22,6 +22,12 @@ struct ServerFormView: View { .textInputAutocapitalization(.never) .keyboardType(.URL) .autocorrectionDisabled() + Toggle("Allow insecure TLS", isOn: $draft.allowInsecureTLS) + if draft.allowInsecureTLS { + Text("Use only for self-signed or private certificates you trust.") + .font(.caption) + .foregroundStyle(.secondary) + } } Section("Authentication") {