refactor(apple): move the mgmt API off URLSession so ATS can stay on

The previous commit bought the library back on VPN/remote hosts by declaring
NSAllowsArbitraryLoads, which works but is blunt: it drops ATS for ALL of the
app's URLSession traffic, and the only other traffic is third-party cover-art
CDN fetches -- the one surface we never wanted to open. It cost the TLS-version
floor, forward secrecy, and the cleartext-HTTP block on URLs the host supplies
at runtime (custom entries and scanner plugins carry arbitrary ones).

So take the host out of the URL loading system instead. MgmtTransport speaks
HTTPS over Network.framework, which ATS does not govern, and states the trust
rule we actually mean in a verify block: the leaf must hash to the fingerprint
pinned during PIN pairing. That is the same rule punktfunk-core has always
applied on the QUIC stream plane -- which is exactly why streaming kept working
over Tailscale while the library did not.

With that, the ATS dict is gone and ATS is fully enforced again. Cover-art CDN
fetches keep ordinary URLSession with full system trust evaluation and no client
certificate. LibraryTLSDelegate is deleted; nothing pins through URLSession now.

Also here:
- HTTPResponse: just enough HTTP/1.1 to read one GET -- status, headers,
  Content-Length and chunked framing (hyper streams the art proxy chunked). A
  body shorter than Content-Length throws instead of returning partial JSON,
  which would otherwise read as "this host has no games".
- LibraryError.pinMismatch, so a re-keyed host says "pair again" rather than
  sending someone to debug their network.
- 403 joins 401 as "unauthorized": both are the host declining the certificate.
- baseURL brackets IPv6 literals; the old string interpolation did not.
- 11 tests covering the framings hyper emits and the failure modes that would
  otherwise be silent.

Known trade-off: no connection reuse yet, so each poster costs its own
handshake where the pooled URLSession shared one. Fine on a LAN, worth revisiting
for large libraries over a high-latency link.
This commit is contained in:
2026-08-08 01:09:04 +02:00
parent bae8742e48
commit 244cafe005
9 changed files with 549 additions and 145 deletions
+8 -19
View File
@@ -19,25 +19,14 @@
<array>
<string>_punktfunk._udp</string>
</array>
<!-- App Transport Security. We talk to the user's OWN host over HTTPS with a SELF-SIGNED
certificate at a user-supplied address, and verify it by SHA-256 fingerprint pinning
established during PIN pairing (LibraryTLSDelegate) — for a box on someone's LAN there is
no CA that could vouch for it, so pinning is the stronger check, not a weaker one.
Default ATS exempts only "local" destinations (.local, unqualified names, RFC1918 and
link-local literals). Every other address gets the full policy, which a self-signed cert
cannot satisfy — so the management API (game library) worked at 192.168.x but died at the
TLS layer on Tailscale's 100.64/10 CGNAT range, a WireGuard peer, or a public IP. The QUIC
stream plane never showed it: that is raw UDP and never enters the URL loading system.
⚠ This must stay the ONLY key in this dict. On iOS 10+/macOS 10.12+ the system IGNORES
NSAllowsArbitraryLoads whenever NSAllowsLocalNetworking, NSAllowsArbitraryLoadsInWebContent
or NSAllowsArbitraryLoadsForMedia is present alongside it — adding one silently restores
the bug. Other origins (cover-art CDNs) keep full system trust evaluation regardless:
LibraryTLSDelegate hands every non-host challenge to .performDefaultHandling. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- NOTE: there is deliberately NO NSAppTransportSecurity dict here. ATS stays fully ON.
The host is self-signed at a user-supplied address, which default ATS can never accept
(it exempts only .local, unqualified names, and RFC1918/link-local literals — notably NOT
Tailscale's 100.64/10 CGNAT range), so the management API talks over MgmtTransport
(Network.framework), which is outside the URL loading system and pins the host by
SHA-256 fingerprint instead. That leaves cover-art CDN fetches as the app's only
URLSession traffic, and they keep the full ATS policy — which is the whole reason not to
reach for NSAllowsArbitraryLoads here. See MgmtTransport.swift. -->
<!-- Background keep-alive (opt-in, iOS/iPadOS): the ONLY sanctioned way to keep the long-lived
QUIC socket + pump-thread set alive while backgrounded is the audio background mode, backed
by the session's real, audible remote audio (AVAudioEngine keeps rendering). Video decode is
@@ -21,7 +21,7 @@ import GameController
struct LibraryCoverflowView: View {
@Environment(\.gamepadInk) private var ink
let games: [GameEntry]
let imageSession: URLSession?
let artLoader: LibraryArtLoader?
var onLaunch: ((String) -> Void)?
/// Button B (back) dismisses the library screen. No touch equivalent needed here (the toolbar
/// Close button already covers that); this is what makes gamepad-only exit possible.
@@ -124,7 +124,7 @@ struct LibraryCoverflowView: View {
_ game: GameEntry, width: CGFloat, height: CGFloat, entrance: CardEntrance
) -> some View {
PosterImage(
candidates: game.art.posterCandidates, title: game.title, session: imageSession,
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
onLoaded: { artSettled += 1 })
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
@@ -24,10 +24,9 @@ struct LibraryView: View {
@State private var games: [GameEntry] = []
@State private var loading = false
@State private var errorText: String?
/// Authenticated session for cover-art fetches (the same paired identity + host pinning as the
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
@State private var imageSession: URLSession?
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
@State private var artLoader: LibraryArtLoader?
#if os(iOS) || os(macOS) || os(tvOS)
// Gamepad-driven browsing see ContentView's identical gate. With no controller (or the
// setting off) every platform keeps the plain-grid presentation of this same view.
@@ -62,8 +61,11 @@ struct LibraryView: View {
}
.task { await load() }
.onDisappear {
imageSession?.finishTasksAndInvalidate()
imageSession = nil
// Hand the loader off before clearing it, so its pooled connections are closed
// rather than left open on a screen the user has left.
let leaving = artLoader
artLoader = nil
Task { await leaving?.close() }
}
#if os(iOS) || os(macOS)
// B closes the library even before the coverflow exists (loading / error / empty):
@@ -89,7 +91,7 @@ struct LibraryView: View {
} else {
if gamepadUIActive {
LibraryCoverflowView(
games: games, imageSession: imageSession, onLaunch: onLaunch,
games: games, artLoader: artLoader, onLaunch: onLaunch,
onDismiss: { (onClose ?? { dismiss() })() },
controllerActive: controllerActive)
} else {
@@ -124,10 +126,10 @@ struct LibraryView: View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(entries) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) }
.buttonStyle(.plain)
} else {
GameCard(game: game, imageSession: imageSession)
GameCard(game: game, artLoader: artLoader)
}
}
}
@@ -206,8 +208,7 @@ struct LibraryView: View {
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256
).launchersFirst
imageSession?.finishTasksAndInvalidate()
imageSession = try LibraryImageLoader.session(
artLoader = try LibraryArtLoader(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
@@ -249,11 +250,11 @@ private struct LibraryBackCatcher: View {
/// (portrait header hero) and finally a text placeholder.
private struct GameCard: View {
let game: GameEntry
let imageSession: URLSession?
let artLoader: LibraryArtLoader?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
@@ -60,8 +60,8 @@ private extension Image {
}
}
/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own
/// art proxy, not just public CDNs see `LibraryImageLoader`), advancing past any that fail to
/// Sequentially tries cover-art URLs over `loader` (so a paired client can reach the host's own
/// art proxy, not just public CDNs see `LibraryArtLoader`), advancing past any that fail to
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
@@ -70,7 +70,7 @@ private extension Image {
struct PosterImage: View {
let candidates: [URL]
let title: String
let session: URLSession?
let loader: LibraryArtLoader?
/// Fires once this poster has settled art loaded, or every candidate exhausted and the
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
@@ -108,7 +108,7 @@ struct PosterImage: View {
onLoaded?()
return
}
guard let session, let data = try? await session.data(from: candidates[index]).0,
guard let loader, let data = try? await loader.data(for: candidates[index]),
let loaded = PlatformImage(data: data)
else {
index += 1 // advance to the next candidate (or past the end placeholder)
@@ -18,9 +18,6 @@
import CryptoKit
import Foundation
import Security
import os
private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-tls")
enum ClientTLS {
enum TLSError: LocalizedError {
@@ -134,62 +131,8 @@ enum ClientTLS {
}
}
/// URLSession delegate that pins the host's self-signed cert (by the fingerprint the client
/// already trusts) and presents the client identity for the mTLS client-cert challenge but ONLY
/// for challenges from `host`:`port` (the punktfunk host itself). A session built with this
/// delegate is safe to reuse for OTHER origins too (e.g. a GOG/Heroic/Xbox cover-art CDN): a
/// non-matching origin falls through to `.performDefaultHandling`, i.e. normal system trust
/// evaluation and no client cert exactly what `URLSession.shared` would have done. Without the
/// host scoping, pinning would reject every external origin's cert (its fingerprint never matches
/// the host's) and the client identity would leak to servers that didn't ask for it.
final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
private let identity: SecIdentity
private let pinnedHostFingerprint: Data? // SHA-256 of the host cert DER; nil = accept any (TOFU)
private let host: String
private let port: Int
init(identity: SecIdentity, pinnedHostFingerprint: Data?, host: String, port: UInt16) {
self.identity = identity
self.pinnedHostFingerprint = pinnedHostFingerprint
self.host = host
self.port = Int(port)
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let space = challenge.protectionSpace
guard space.host == host, space.port == port else {
completionHandler(.performDefaultHandling, nil)
return
}
switch space.authenticationMethod {
case NSURLAuthenticationMethodServerTrust:
// Pin the host cert by fingerprint the host is self-signed (the client trusts it the
// same way the QUIC session does). No pin yet (TOFU) accept the presented leaf.
guard let trust = space.serverTrust,
let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first
else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let der = SecCertificateCopyData(leaf) as Data
let fp = Data(SHA256.hash(data: der))
if let pinned = pinnedHostFingerprint, pinned != fp {
tlsLog.warning("library: host cert fingerprint mismatch — refusing")
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: trust))
case NSURLAuthenticationMethodClientCertificate:
completionHandler(.useCredential,
URLCredential(identity: identity, certificates: nil, persistence: .forSession))
default:
completionHandler(.performDefaultHandling, nil)
}
}
}
// The URLSession pinning delegate that used to live here is gone: the management API now speaks
// over `MgmtTransport` (Network.framework), which states the same trust rule in a
// `sec_protocol_options_set_verify_block` and unlike the URL loading system is not subject to
// App Transport Security. That is what lets ATS stay ON for the cover-art CDN fetches, which are
// the only URLSession traffic left in the app. See MgmtTransport.swift for the full rationale.
@@ -0,0 +1,129 @@
// Minimal HTTP/1.1 response parsing for `MgmtTransport`.
//
// We speak HTTP ourselves because the management API has to be reached OUTSIDE the URL loading
// system: App Transport Security applies to URLSession and cannot be relaxed for the arbitrary,
// user-supplied addresses a punktfunk host lives at (see MgmtTransport for the full story). What
// we need is one unauthenticated-by-header GET per request, so this covers exactly that and
// nothing more no redirects, no keep-alive, no request bodies, no content negotiation.
//
// Deliberately free of any Network.framework / PunktfunkCore dependency: it is pure bytes-in,
// value-out, so it can be unit-tested (and typechecked) on its own.
import Foundation
/// A parsed HTTP/1.1 response. `headers` keys are lowercased, so lookups are case-insensitive the
/// way the grammar requires.
struct HTTPResponse: Sendable {
let status: Int
let headers: [String: String]
let body: Data
func header(_ name: String) -> String? { headers[name.lowercased()] }
}
enum HTTPParseError: Error, Sendable {
/// The header block never terminated, or the body is shorter than `Content-Length` promised
/// i.e. the peer hung up mid-response. Never surface a truncated body as success: a clipped
/// JSON array would read as "this host has no games" rather than as the failure it is.
case truncated
case malformedStatusLine
case malformedHeader
case malformedChunk
}
enum HTTPResponseParser {
/// Parse a complete response everything read from the socket until EOF.
static func parse(_ raw: Data) throws -> HTTPResponse {
let bytes = [UInt8](raw)
guard let headEnd = findHeaderEnd(bytes) else { throw HTTPParseError.truncated }
// Header field-values are ISO-8859-1 by the grammar; decoding that way never fails, which
// keeps a stray non-UTF-8 byte in some header from failing the whole response.
let head = String(decoding: bytes[0..<headEnd], as: UTF8.self)
var lines = head.components(separatedBy: "\r\n")
guard !lines.isEmpty else { throw HTTPParseError.malformedStatusLine }
// "HTTP/1.1 200 OK" the reason phrase is optional and ignored.
let statusLine = lines.removeFirst().split(separator: " ", maxSplits: 2,
omittingEmptySubsequences: false)
guard statusLine.count >= 2, statusLine[0].hasPrefix("HTTP/"),
let status = Int(statusLine[1])
else { throw HTTPParseError.malformedStatusLine }
var headers: [String: String] = [:]
for line in lines where !line.isEmpty {
// A leading space/tab marks an obsolete folded continuation line. Nothing we talk to
// emits them, and silently mis-parsing one as a field is worse than refusing it.
guard !line.hasPrefix(" "), !line.hasPrefix("\t"),
let colon = line.firstIndex(of: ":")
else { throw HTTPParseError.malformedHeader }
let name = String(line[line.startIndex..<colon]).lowercased()
let value = String(line[line.index(after: colon)...])
.trimmingCharacters(in: .whitespaces)
// Repeated fields join with ", " per RFC 9110; none of ours repeat, but dropping one
// silently would be a lie.
headers[name] = headers[name].map { "\($0), \(value)" } ?? value
}
let rest = Data(bytes[(headEnd + 4)...])
let body: Data
if headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
body = try decodeChunked(rest)
} else if let lengthField = headers["content-length"] {
guard let length = Int(lengthField.trimmingCharacters(in: .whitespaces)), length >= 0
else { throw HTTPParseError.malformedHeader }
guard rest.count >= length else { throw HTTPParseError.truncated }
body = rest.prefix(length)
} else {
// No framing header: the body runs to EOF, which is exactly what we read.
body = rest
}
return HTTPResponse(status: status, headers: headers, body: body)
}
/// Index of the CRLFCRLF that ends the header block.
private static func findHeaderEnd(_ b: [UInt8]) -> Int? {
guard b.count >= 4 else { return nil }
for i in 0...(b.count - 4) where b[i] == 0x0D && b[i + 1] == 0x0A
&& b[i + 2] == 0x0D && b[i + 3] == 0x0A {
return i
}
return nil
}
/// `Transfer-Encoding: chunked` decoding. hyper streams the art proxy this way, so this is a
/// live path, not defensive dead code.
static func decodeChunked(_ data: Data) throws -> Data {
let b = [UInt8](data)
var i = 0
var out = Data()
while true {
guard let lineEnd = findCRLF(b, from: i) else { throw HTTPParseError.malformedChunk }
// "1a" or "1a;ext=value" the size is hex, any chunk extension is ignored.
let sizeField = String(decoding: b[i..<lineEnd], as: UTF8.self)
.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false)[0]
.trimmingCharacters(in: .whitespaces)
guard let size = Int(sizeField, radix: 16), size >= 0 else {
throw HTTPParseError.malformedChunk
}
i = lineEnd + 2
if size == 0 { return out } // terminal chunk; trailers (if any) are ignored
guard i + size <= b.count else { throw HTTPParseError.malformedChunk }
out.append(contentsOf: b[i..<(i + size)])
i += size
guard i + 1 < b.count, b[i] == 0x0D, b[i + 1] == 0x0A else {
throw HTTPParseError.malformedChunk
}
i += 2
}
}
private static func findCRLF(_ b: [UInt8], from: Int) -> Int? {
guard from >= 0, b.count >= 2 else { return nil }
var i = from
while i + 1 < b.count {
if b[i] == 0x0D && b[i + 1] == 0x0A { return i }
i += 1
}
return nil
}
}
@@ -83,6 +83,10 @@ public extension Array where Element == GameEntry {
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
public enum LibraryError: LocalizedError {
case unauthorized
/// The host's certificate didn't hash to the fingerprint pinned at pairing an impostor, or
/// a host reinstalled/re-keyed since. Distinct from `unreachable` because the remedy is
/// completely different: re-pair, don't go hunting the network.
case pinMismatch
case http(Int)
case unreachable(String)
@@ -91,6 +95,9 @@ public enum LibraryError: LocalizedError {
case .unauthorized:
return "The host didn't recognize this device. Pair with the host first — it "
+ "authorizes paired clients by their certificate (no token needed)."
case .pinMismatch:
return "The host's certificate doesn't match the one this device paired with. "
+ "If the host was reinstalled, forget it here and pair again."
case .http(let code):
return "The management API returned HTTP \(code)."
case .unreachable(let why):
@@ -122,47 +129,66 @@ public enum LibraryClient {
keyPEM: String,
hostFingerprint: Data?
) async throws -> [GameEntry] {
guard let url = URL(string: "https://\(address):\(port)/api/v1/library") else {
throw LibraryError.unreachable("invalid host address")
}
let identity: SecIdentity
do {
identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
} catch {
throw LibraryError.unreachable(
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
let delegate = LibraryTLSDelegate(
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
let req = URLRequest(url: url, timeoutInterval: 10)
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: req)
} catch {
throw LibraryError.unreachable(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw LibraryError.unreachable("not an HTTP response")
}
switch http.statusCode {
guard let base = URL(string: "\(baseURL(address: address, port: port))/api/v1/library")
else { throw LibraryError.unreachable("invalid host address") }
let identity = try clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
let response = try await send(
path: "/api/v1/library", address: address, port: port,
identity: identity, hostFingerprint: hostFingerprint)
switch response.status {
case 200:
var games = try JSONDecoder().decode([GameEntry].self, from: data)
// Steam art now comes back as host-relative proxy paths (`/api/v1/library/art/...`,
// see the host's `library::steam_art`) so they work the same regardless of which
var games = try JSONDecoder().decode([GameEntry].self, from: response.body)
// Steam art comes back as host-relative proxy paths (`/api/v1/library/art/...`, see
// the host's `library::steam_art`) so they work the same regardless of which
// interface/port the client reached the host on. Resolve them against THIS host now,
// so every other consumer just sees ordinary absolute URLs.
let base = url
for i in games.indices {
games[i].art = games[i].art.resolved(against: base)
}
return games
case 401:
// 403 joins 401 here: both are the host declining this certificate, and the remedy the
// user needs is the same one.
case 401, 403:
throw LibraryError.unauthorized
default:
throw LibraryError.http(http.statusCode)
throw LibraryError.http(response.status)
}
}
/// `https://addr:port`, IPv6 literals bracketed the mirror of the Rust client's `base_url`.
static func baseURL(address: String, port: UInt16) -> String {
let bare = address.hasPrefix("[") && address.hasSuffix("]")
? String(address.dropFirst().dropLast()) : address
return bare.contains(":") ? "https://[\(bare)]:\(port)" : "https://\(bare):\(port)"
}
/// Build the paired identity, restating any keychain failure in the UI's vocabulary.
static func clientIdentity(certPEM: String, keyPEM: String) throws -> SecIdentity {
do {
return try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
} catch {
throw LibraryError.unreachable(
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
}
/// One GET against the host, with transport failures mapped onto `LibraryError`.
static func send(
path: String, address: String, port: UInt16,
identity: SecIdentity, hostFingerprint: Data?
) async throws -> HTTPResponse {
do {
return try await MgmtTransport.get(
host: address, port: port, path: path,
identity: identity, pinnedHostFingerprint: hostFingerprint)
} catch MgmtTransportError.pinMismatch {
throw LibraryError.pinMismatch
} catch MgmtTransportError.timedOut {
throw LibraryError.unreachable("timed out")
} catch let error as MgmtTransportError {
throw LibraryError.unreachable(String(describing: error))
} catch {
throw LibraryError.unreachable(error.localizedDescription)
}
}
}
@@ -186,23 +212,57 @@ extension Artwork {
}
}
/// Builds the authenticated `URLSession` the library UI uses to fetch cover-art images the same
/// paired identity + host pinning as [`LibraryClient.fetch`], reused across a whole grid's worth of
/// poster loads (this session is NOT one-shot: callers own its lifetime and should invalidate it
/// when the view goes away). Safe to use for every candidate URL a `GameEntry`'s `Artwork` carries:
/// `LibraryTLSDelegate` only pins/presents-cert for the host itself, deferring to normal system
/// trust + no client cert for any other origin (an external CDN URL).
public enum LibraryImageLoader {
public static func session(
/// Loads cover art for the library UI, routing each URL to the transport that suits its origin.
///
/// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy
/// (`/api/v1/library/art/...`, resolved to absolute URLs against this host) and public store CDN
/// URLs carried verbatim on custom/GOG/Heroic entries. Host URLs go over [`MgmtTransport`] with
/// the paired identity and the pinned fingerprint outside the URL loading system, so App
/// Transport Security can stay ON app-wide. Every other origin keeps ordinary `URLSession` with
/// full system trust evaluation and no client certificate, which is exactly what it should get.
///
/// Built once per library screen and reused across a whole grid's worth of posters.
public final class LibraryArtLoader: @unchecked Sendable {
private let address: String
private let port: UInt16
private let identity: SecIdentity
private let hostFingerprint: Data?
/// Third-party origins only. No delegate: these are ordinary public HTTPS URLs and get the
/// system's normal certificate validation.
private let cdn = URLSession(configuration: .default)
public init(
address: String,
port: UInt16 = punktfunkDefaultMgmtPort,
certPEM: String,
keyPEM: String,
hostFingerprint: Data?
) throws -> URLSession {
let identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
let delegate = LibraryTLSDelegate(
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
return URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
) throws {
self.address = address
self.port = port
self.identity = try LibraryClient.clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
self.hostFingerprint = hostFingerprint
}
public func data(for url: URL) async throws -> Data {
guard isHostOrigin(url) else { return try await cdn.data(from: url).0 }
var path = url.path.isEmpty ? "/" : url.path
if let query = url.query { path += "?\(query)" }
let response = try await LibraryClient.send(
path: path, address: address, port: port,
identity: identity, hostFingerprint: hostFingerprint)
guard response.status == 200 else { throw LibraryError.http(response.status) }
return response.body
}
/// Does this URL point at the host's own art proxy? Compared on host + port rather than a
/// string prefix, so a differently-spelled but equivalent URL still takes the pinned path.
private func isHostOrigin(_ url: URL) -> Bool {
guard let host = url.host else { return false }
let bare = address.hasPrefix("[") && address.hasSuffix("]")
? String(address.dropFirst().dropLast()) : address
let scheme = url.scheme?.lowercased()
return host.caseInsensitiveCompare(bare) == .orderedSame
&& (url.port ?? (scheme == "http" ? 80 : 443)) == Int(port)
}
}
@@ -0,0 +1,184 @@
// HTTPS transport for the host's management REST API, built on Network.framework rather than
// URLSession.
//
// WHY NOT URLSession. App Transport Security governs the URL loading system, and its default
// policy exempts only "local" destinations `.local` names, unqualified names, and RFC1918 /
// link-local IP literals. Everything else must present a certificate that passes system trust
// evaluation. A punktfunk host is self-signed by construction (there is no CA that could vouch
// for a box on someone's LAN), so the library worked at 192.168.x and died at the TLS layer on
// every other address: a Tailscale peer (100.64/10 is CGNAT, NOT RFC1918), a WireGuard peer, or a
// public IP. No ATS key can express "any address the user typed" the exception keys are
// domain-scoped so the only ways out were disabling ATS app-wide (which also drops the TLS
// floor and the cleartext block on third-party cover-art fetches, the one surface we did NOT want
// to open) or leaving the URL loading system for this one origin. This is that second option.
//
// Network.framework is not subject to ATS, and `sec_protocol_options_set_verify_block` lets us
// state the trust rule we actually mean: the leaf certificate must hash to the fingerprint the
// user pinned during PIN pairing. That is a stronger check than CA trust here, not a weaker one,
// and it is the same rule the QUIC stream plane has always applied via punktfunk-core which is
// precisely why streaming kept working over Tailscale while the library did not.
import CryptoKit
import Foundation
import Network
import Security
enum MgmtTransportError: Error, Sendable {
/// The host's certificate did not hash to the pinned fingerprint an impostor, or a host
/// that was reinstalled/re-keyed since pairing.
case pinMismatch
case connection(String)
case timedOut
case tooLarge
}
enum MgmtTransport {
/// Largest response we will buffer. The host's art proxy serves Steam hero images that run to
/// a few MB; anything past this is not a poster and not a library payload.
static let maxResponseBytes = 16 * 1024 * 1024
/// `GET https://host:port/path`, authenticated by mTLS (`identity`) and pinned by
/// `pinnedHostFingerprint` (nil = trust-on-first-use, matching the QUIC connect's semantics).
static func get(
host: String,
port: UInt16,
path: String,
identity: SecIdentity,
pinnedHostFingerprint: Data?,
timeout: TimeInterval = 15
) async throws -> HTTPResponse {
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
throw MgmtTransportError.connection("invalid port \(port)")
}
// One serial queue drives the connection, the verify block, and the timeout, so every
// touch of `Transfer` below is already serialized no locking, and no chance of two
// callbacks racing to resume the continuation.
let queue = DispatchQueue(label: "io.unom.punktfunk.mgmt-transport")
let state = Transfer()
let options = tlsOptions(identity: identity, pin: pinnedHostFingerprint,
state: state, queue: queue)
let connection = NWConnection(
to: .hostPort(host: NWEndpoint.Host(unbracketed(host)), port: nwPort),
using: NWParameters(tls: options, tcp: NWProtocolTCP.Options()))
let request = requestBytes(host: host, port: port, path: path)
return try await withCheckedThrowingContinuation { continuation in
func finish(_ result: Result<HTTPResponse, Error>) {
guard !state.finished else { return }
state.finished = true
connection.cancel()
continuation.resume(with: result)
}
// A rejected pin surfaces as a generic handshake failure; `state.pinRejected` is how
// we recover what actually happened so the UI can say "re-pair" instead of "offline".
func fail(_ error: NWError) {
finish(.failure(state.pinRejected
? MgmtTransportError.pinMismatch
: MgmtTransportError.connection(String(describing: error))))
}
func receiveLoop() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) {
chunk, _, isComplete, error in
if let chunk, !chunk.isEmpty { state.buffer.append(chunk) }
if state.buffer.count > maxResponseBytes {
finish(.failure(MgmtTransportError.tooLarge))
return
}
if let error { fail(error); return }
guard isComplete else { receiveLoop(); return }
// `Connection: close` means EOF ends the response parse what we have.
finish(Result { try HTTPResponseParser.parse(state.buffer) })
}
}
connection.stateUpdateHandler = { newState in
switch newState {
case .ready:
connection.send(content: request, completion: .contentProcessed { error in
if let error { fail(error); return }
receiveLoop()
})
case .failed(let error):
fail(error)
case .cancelled:
// Only reachable via our own `finish`, which has already resumed; the guard
// in `finish` makes this a no-op rather than a double-resume crash.
finish(.failure(MgmtTransportError.connection("cancelled")))
default:
break
}
}
queue.asyncAfter(deadline: .now() + timeout) {
finish(.failure(MgmtTransportError.timedOut))
}
connection.start(queue: queue)
}
}
/// Mutable per-request state. Confined to the transport's serial queue see `get`.
private final class Transfer: @unchecked Sendable {
var finished = false
var pinRejected = false
var buffer = Data()
}
private static func tlsOptions(
identity: SecIdentity, pin: Data?, state: Transfer, queue: DispatchQueue
) -> NWProtocolTLS.Options {
let options = NWProtocolTLS.Options()
let sec = options.securityProtocolOptions
sec_protocol_options_set_min_tls_protocol_version(sec, .TLSv12)
// Our half of the mTLS handshake: the same paired identity the host authorizes the
// read-only library routes by (mgmt/auth.rs `cert_may_access`).
if let secIdentity = sec_identity_create(identity) {
sec_protocol_options_set_local_identity(sec, secIdentity)
}
// Replaces system trust evaluation wholesale, which is the point: the host is self-signed
// and carries no SAN, so there is nothing for the system policy to succeed at. Pinning the
// leaf's SHA-256 is the real check.
sec_protocol_options_set_verify_block(sec, { _, trust, complete in
let secTrust = sec_trust_copy_ref(trust).takeRetainedValue()
guard let chain = SecTrustCopyCertificateChain(secTrust) as? [SecCertificate],
let leaf = chain.first
else {
state.pinRejected = true
complete(false)
return
}
guard let pin else {
complete(true) // trust-on-first-use: no pin recorded for this host yet
return
}
let fingerprint = Data(SHA256.hash(data: SecCertificateCopyData(leaf) as Data))
let matches = fingerprint == pin
if !matches { state.pinRejected = true }
complete(matches)
}, queue)
return options
}
private static func requestBytes(host: String, port: UInt16, path: String) -> Data {
// An IPv6 literal is bracketed in the Host header (RFC 9110 §7.2); a name or IPv4 is not.
let bare = unbracketed(host)
let authority = bare.contains(":") ? "[\(bare)]:\(port)" : "\(bare):\(port)"
let request = """
GET \(path) HTTP/1.1\r
Host: \(authority)\r
User-Agent: punktfunk-apple\r
Accept: */*\r
Connection: close\r
\r
"""
return Data(request.utf8)
}
/// Saved hosts store bare addresses, but a user who pasted a bracketed IPv6 literal shouldn't
/// get an unresolvable endpoint out of it.
private static func unbracketed(_ host: String) -> String {
guard host.hasPrefix("["), host.hasSuffix("]"), host.count > 2 else { return host }
return String(host.dropFirst().dropLast())
}
}
@@ -78,4 +78,102 @@ final class LibraryClientTests: XCTestCase {
XCTAssertEqual(resolved.hero, "https://cdn.example.com/hero.jpg") // unchanged
XCTAssertNil(resolved.logo)
}
// MARK: - HTTP response parsing (MgmtTransport)
// The management API is reached over Network.framework rather than URLSession (ATS cannot be
// relaxed for the arbitrary addresses a host lives at see MgmtTransport), so we parse HTTP
// ourselves. These cover the framings hyper actually emits, plus the failure modes where
// getting it wrong would be silent.
private func raw(_ text: String) -> Data { Data(text.utf8) }
func testParsesContentLengthFramedJSON() throws {
let body = #"[{"id":"steam:570"}]"#
let response = try HTTPResponseParser.parse(raw(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
+ "Content-Length: \(body.utf8.count)\r\n\r\n\(body)"))
XCTAssertEqual(response.status, 200)
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), body)
// Field names are case-insensitive per RFC 9110.
XCTAssertEqual(response.header("CONTENT-TYPE"), "application/json")
}
func testParsesChunkedBody() throws {
// How hyper streams the art proxy.
let response = try HTTPResponseParser.parse(raw(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+ "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"))
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "hello world")
}
func testChunkExtensionsAndTrailersAreIgnored() throws {
let response = try HTTPResponseParser.parse(raw(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+ "3;foo=bar\r\nabc\r\n0\r\nX-Trailer: 1\r\n\r\n"))
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "abc")
}
func testUnauthorizedStatusSurvives() throws {
// What an unpaired certificate gets from the host the status is the whole signal.
let response = try HTTPResponseParser.parse(
raw("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"))
XCTAssertEqual(response.status, 401)
XCTAssertTrue(response.body.isEmpty)
}
func testBodyRunsToEOFWithoutFramingHeaders() throws {
let response = try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\n\r\nraw-to-eof"))
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "raw-to-eof")
}
func testTruncatedBodyThrowsRatherThanReturningPartialJSON() {
// The one that matters: a body cut short must NOT come back as success. A clipped JSON
// array would decode to fewer games "this host has no games" instead of an error.
XCTAssertThrowsError(
try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\nContent-Length: 99\r\n\r\nshort")))
}
func testOverLongBodyIsClippedToContentLength() throws {
let response = try HTTPResponseParser.parse(
raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabcdef"))
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "abc")
}
func testMalformedResponsesThrow() {
// Header block never terminated (peer hung up), a non-HTTP greeting, and a chunked stream
// cut mid-chunk.
XCTAssertThrowsError(
try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n")))
XCTAssertThrowsError(try HTTPResponseParser.parse(raw("NOT-HTTP\r\n\r\n")))
XCTAssertThrowsError(try HTTPResponseParser.parse(raw(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n9\r\nabc")))
}
func testMultiWordReasonPhraseParses() throws {
let response = try HTTPResponseParser.parse(
raw("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"))
XCTAssertEqual(response.status, 404)
}
func testBinaryBodySurvivesByteForByte() throws {
// Posters are PNG/JPEG: the body must never be round-tripped through a String.
let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF, 0x0D, 0x0A]
var message = raw("HTTP/1.1 200 OK\r\nContent-Length: \(png.count)\r\n\r\n")
message.append(contentsOf: png)
let response = try HTTPResponseParser.parse(message)
XCTAssertEqual([UInt8](response.body), png)
}
func testBaseURLBracketsIPv6Only() {
XCTAssertEqual(LibraryClient.baseURL(address: "192.168.1.70", port: 47990),
"https://192.168.1.70:47990")
XCTAssertEqual(LibraryClient.baseURL(address: "100.101.102.103", port: 47990),
"https://100.101.102.103:47990")
XCTAssertEqual(LibraryClient.baseURL(address: "fd7a:115c::1", port: 47990),
"https://[fd7a:115c::1]:47990")
// An address the user pasted already bracketed must not end up double-bracketed.
XCTAssertEqual(LibraryClient.baseURL(address: "[fd7a:115c::1]", port: 47990),
"https://[fd7a:115c::1]:47990")
}
}