241 lines
10 KiB
Swift
241 lines
10 KiB
Swift
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<T: Decodable>(_ 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 }
|
|
}
|
|
}
|