Files
GHOSTWIRE/ios/GHOSTWIRE/API.swift
T
Daniel Redetzke 066d669f3a Add native iOS app with App Store preparation
iOS app (ios/, SwiftUI, iOS 17+) in the web UI's styling with the Kamon
logo. It covers everything the web interface does except password, API
tokens and backups: dashboard, peers with search and filter, peer detail
with traffic charts, add/edit peers, one-time config with QR code and
share sheet, server settings with apply bar, app settings, data
retention and log viewer.

- Pairing by QR code or pasted pairing code; token kept in the keychain;
  self-signed certificates are pinned by SHA-256 fingerprint.
- Colour providers and logo drawing are nonisolated: SwiftUI's background
  renderer calls them, and main-actor closures crashed there when the
  camera scanner was open.
- App Store: version 1.0, export compliance, privacy manifest, app icon,
  release.sh (archive and upload), listing text, review notes and 6.9"
  screenshots in ios/AppStore.

Server:
- Full-access API tokens may use settings, logs and restart; password,
  tokens, backup/restore and the admin username stay admin-only.
- Web pairing dialog gains "Copy pairing code".
- The development simulator reports health checks in Linux wording.
2026-10-03 18:34:41 +03:00

101 lines
4.2 KiB
Swift

import CryptoKit
import Foundation
enum APIError: LocalizedError {
case server(String)
case unauthorized
case badPairing(String)
var errorDescription: String? {
switch self {
case .server(let m): m
case .unauthorized: "This iPhone is no longer paired. Pair it again from Settings → Pair iOS app in the web interface."
case .badPairing(let m): m
}
}
}
/// Accepts the server only if its certificate matches the fingerprint from
/// the pairing code. Without a fingerprint (Let's Encrypt), normal system
/// trust applies.
nonisolated final class PinningDelegate: NSObject, URLSessionDelegate, Sendable {
let fingerprint: String
init(fingerprint: String) {
self.fingerprint = fingerprint.replacingOccurrences(of: ":", with: "").uppercased()
}
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async
-> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust,
!fingerprint.isEmpty else {
return (.performDefaultHandling, nil)
}
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], let leaf = chain.first else {
return (.cancelAuthenticationChallenge, nil)
}
let digest = SHA256.hash(data: SecCertificateCopyData(leaf) as Data)
let hex = digest.map { String(format: "%02X", $0) }.joined()
return hex == fingerprint ? (.useCredential, URLCredential(trust: trust)) : (.cancelAuthenticationChallenge, nil)
}
}
/// Client for GHOSTWIRE's /api/v1, authenticated with the paired API token.
final class API {
let base: String
private let token: String
private let session: URLSession
init(pairing p: Pairing) {
var url = p.url.trimmingCharacters(in: .whitespacesAndNewlines)
while url.hasSuffix("/") { url.removeLast() }
base = url
token = p.token
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 15
session = URLSession(configuration: cfg, delegate: PinningDelegate(fingerprint: p.fingerprint), delegateQueue: nil)
}
static let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .custom { dec in
let s = try dec.singleValueContainer().decode(String.self)
guard let date = parseGoDate(s) else {
throw DecodingError.dataCorrupted(.init(codingPath: dec.codingPath, debugDescription: "bad date \(s)"))
}
return date
}
return d
}()
/// Sends a request and returns the raw body. Body values of nil are sent
/// as JSON null ("use the server default").
func data(_ method: String, _ path: String, body: [String: Any?]? = nil) async throws -> Data {
guard let url = URL(string: base + "/api/v1" + path) else { throw APIError.badPairing("The server address is not valid.") }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: body.mapValues { $0 ?? NSNull() })
}
let (data, resp) = try await session.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 { throw APIError.unauthorized }
guard (200..<300).contains(code) else {
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
throw APIError.server(obj?["error"] as? String ?? "The server answered with HTTP \(code).")
}
return data
}
func get<T: Decodable>(_ path: String) async throws -> T {
try Self.decoder.decode(T.self, from: try await data("GET", path))
}
func send<T: Decodable>(_ method: String, _ path: String, _ body: [String: Any?]? = nil) async throws -> T {
try Self.decoder.decode(T.self, from: try await data(method, path, body: body))
}
}