Initial Commit

This commit is contained in:
2026-09-27 15:42:22 +02:00
commit c34956249d
36 changed files with 1660 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
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 }
}
}