import Foundation import SwiftUI struct ProxyHost: Identifiable, Codable, Hashable { var id: Int var domainNames: [String] var forwardHost: String var forwardPort: Int var forwardScheme: String var certificateId: Int var sslForced: Bool var http2Support: Bool var blockExploits: Bool var allowWebsocketUpgrade: Bool var enabled: Bool var accessListId: Int var advancedConfig: String var domain: String { domainNames.first ?? "Untitled host" } var destination: String { "\(forwardHost):\(forwardPort)" } var secured: Bool { certificateId > 0 } var symbol: String { if domain.contains("photos") { return "photo.on.rectangle.angled" } if domain.contains("home") { return "house" } if domain.contains("git") { return "chevron.left.forwardslash.chevron.right" } if domain.contains("media") { return "play.rectangle" } if domain.contains("status") { return "chart.xyaxis.line" } return "globe" } static let examples: [ProxyHost] = [ sample(1, "home.lab", "192.168.1.20", 8123), sample(2, "photos.lab", "192.168.1.24", 2283), sample(3, "git.lab", "192.168.1.30", 3000), sample(4, "media.lab", "192.168.1.24", 8096), sample(5, "status.lab", "192.168.1.20", 3001), sample(6, "notes.lab", "192.168.1.30", 8080, secure: false, enabled: false) ] static func sample(_ id: Int, _ domain: String, _ host: String, _ port: Int, secure: Bool = true, enabled: Bool = true) -> ProxyHost { ProxyHost(id: id, domainNames: [domain], forwardHost: host, forwardPort: port, forwardScheme: "http", certificateId: secure ? 1 : 0, sslForced: secure, http2Support: true, blockExploits: true, allowWebsocketUpgrade: true, enabled: enabled, accessListId: 0, advancedConfig: "") } } struct ProxyCertificate: Identifiable, Codable { var id: Int var niceName: String var domainNames: [String] var provider: String var expiresOn: String? } struct ActivityEntry: Identifiable { let id = UUID() let title: String let detail: String let date = Date() } @MainActor @Observable final class ProxyStore { var hosts = ProxyHost.examples var certificates = [ProxyCertificate(id: 1, niceName: "Homelab wildcard", domainNames: ["*.lab"], provider: "letsencrypt", expiresOn: nil)] var activity: [ActivityEntry] = [] var isDemo = true var isBusy = false var error: String? var baseURL = UserDefaults.standard.string(forKey: "serverURL") ?? "" var serverName = "Homelab" var lastSynced: Date? private var token = "" private var didRestoreCredentials = false init() { if let data = UserDefaults.standard.data(forKey: "demoHosts"), let saved = try? JSONDecoder().decode([ProxyHost].self, from: data) { hosts = saved } } private func persist() { guard isDemo, let data = try? JSONEncoder().encode(hosts) else { return } UserDefaults.standard.set(data, forKey: "demoHosts") } func restoreConnection() async { guard !didRestoreCredentials, !isBusy, isDemo else { return } didRestoreCredentials = true do { guard let saved = try CredentialStore.load(ProxyCredentials.self, account: CredentialStore.proxyAccount) else { return } try await connect(url: saved.url, email: saved.email, password: saved.password, saveCredentials: false) } catch { self.error = "Could not restore the proxy connection. " + error.localizedDescription } } func forgetCredentials() throws { try CredentialStore.delete(account: CredentialStore.proxyAccount) didRestoreCredentials = true } func connect(url: String, email: String, password: String, saveCredentials: Bool = true) async throws { guard !isBusy else { throw ServiceError.message("Wait for the current request to finish.") } isBusy = true defer { isBusy = false } didRestoreCredentials = true let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let parsed = URL(string: trimmed), ["http", "https"].contains(parsed.scheme ?? ""), parsed.host != nil else { throw ServiceError.message("Enter a valid server URL, including http:// or https://.") } let previous = baseURL let previousToken = token baseURL = trimmed.hasSuffix("/api") ? trimmed : trimmed + "/api" do { let data = try await request("tokens", method: "POST", body: ["identity": email, "secret": password], authenticated: false) struct Login: Decodable { let token: String } token = try JSONDecoder().decode(Login.self, from: data).token let loaded: [ProxyHost] = try await fetch("nginx/proxy-hosts") let certs: [ProxyCertificate] = try await fetch("nginx/certificates") if saveCredentials { try CredentialStore.save(ProxyCredentials(url: baseURL, email: email, password: password), account: CredentialStore.proxyAccount) } hosts = loaded certificates = certs isDemo = false error = nil serverName = parsed.host ?? "My server" lastSynced = Date() UserDefaults.standard.set(baseURL, forKey: "serverURL") activity = [ActivityEntry(title: "Connected to server", detail: serverName)] } catch { baseURL = previous token = previousToken throw error } } func refresh() async { guard !isBusy else { return } isBusy = true defer { isBusy = false } if isDemo { lastSynced = Date() return } do { let loaded: [ProxyHost] = try await fetch("nginx/proxy-hosts") let certs: [ProxyCertificate] = try await fetch("nginx/certificates") hosts = loaded certificates = certs lastSynced = Date() } catch { self.error = error.localizedDescription } } func save(_ host: ProxyHost, isNew: Bool) async throws { var saved = host if isDemo { if isNew { saved.id = (hosts.map(\.id).max() ?? 0) + 1 } } else { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase let encoded = try encoder.encode(host) var body = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] ?? [:] body.removeValue(forKey: "id") if isNew { body["locations"] = [] body["meta"] = [:] body["caching_enabled"] = false body["hsts_enabled"] = false body["hsts_subdomains"] = false } let data = try await request(isNew ? "nginx/proxy-hosts" : "nginx/proxy-hosts/\(host.id)", method: isNew ? "POST" : "PUT", body: body) saved = try decoder.decode(ProxyHost.self, from: data) } if let index = hosts.firstIndex(where: { $0.id == saved.id }) { hosts[index] = saved } else { hosts.append(saved) } activity.insert(ActivityEntry(title: isNew ? "Proxy host created" : "Proxy host updated", detail: saved.domain), at: 0) persist() } func toggle(_ host: ProxyHost) async { do { if !isDemo { _ = try await request("nginx/proxy-hosts/\(host.id)/\(host.enabled ? "disable" : "enable")", method: "POST") } if let index = hosts.firstIndex(where: { $0.id == host.id }) { hosts[index].enabled.toggle() } activity.insert(ActivityEntry(title: host.enabled ? "Proxy host disabled" : "Proxy host enabled", detail: host.domain), at: 0) persist() } catch { self.error = error.localizedDescription } } func delete(_ host: ProxyHost) async { do { if !isDemo { _ = try await request("nginx/proxy-hosts/\(host.id)", method: "DELETE") } hosts.removeAll { $0.id == host.id } activity.insert(ActivityEntry(title: "Proxy host deleted", detail: host.domain), at: 0) persist() } catch { self.error = error.localizedDescription } } private var decoder: JSONDecoder { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder } private func fetch(_ path: String) async throws -> T { let data = try await request(path) return try decoder.decode(T.self, from: data) } private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil, authenticated: Bool = true) async throws -> Data { guard let url = URL(string: baseURL + "/" + path) else { throw ServiceError.message("Invalid server URL.") } var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = 20 if authenticated { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body { request.httpBody = try JSONSerialization.data(withJSONObject: body) request.setValue("application/json", forHTTPHeaderField: "Content-Type") } let (data, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { throw ServiceError.message("The server returned an invalid response.") } guard (200..<300).contains(response.statusCode) else { throw ServiceError.message(response.statusCode == 401 ? "Your session expired or your credentials are incorrect. Please reconnect." : "The server returned HTTP \(response.statusCode). Check your connection and permissions.") } return data } } enum ServiceError: LocalizedError { case message(String) var errorDescription: String? { switch self { case .message(let message): return message } } }