import Foundation import Network import os.log private let proxyLog = Logger(subsystem: "nl.vonas.mediacluster.ios", category: "ClearKeyProxy") /// Lokale HTTP-proxy: haalt DASH MPD/segmenten op, strip DRM-metadata, decrypt CENC met ClearKey. /// VLC speelt daarna plain DASH vanaf 127.0.0.1. final class ClearKeyDashProxy { static let shared = ClearKeyDashProxy() private let queue = DispatchQueue(label: "nl.vonas.vmc.clearkey-proxy") private var listener: NWListener? private var port: UInt16 = 0 private var remoteManifest: URL? private var keyMap: ClearKeyKeyMap? private var cryptoState = CencMp4Decryptor.SessionState() private var generation = 0 private let urlSession: URLSession = { let cfg = URLSessionConfiguration.ephemeral cfg.timeoutIntervalForRequest = 25 cfg.timeoutIntervalForResource = 60 cfg.httpMaximumConnectionsPerHost = 8 cfg.requestCachePolicy = .reloadIgnoringLocalCacheData return URLSession(configuration: cfg) }() private init() {} var isRunning: Bool { listener != nil && port > 0 } /// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug. /// Warmt de MPD-fetch op zodat VLC niet meteen een lege/fout-response krijgt. func start(manifestURL: URL, keys: [ClearKeyEntry]) async throws -> URL { let map = ClearKeyKeyMap(entries: keys) guard !map.isEmpty else { throw ProxyError.noKeys } let boundPort: UInt16 = try await withCheckedThrowingContinuation { cont in queue.async { self.stopLocked() self.remoteManifest = manifestURL self.keyMap = map self.cryptoState = CencMp4Decryptor.SessionState() self.generation += 1 final class ResumeOnce: @unchecked Sendable { private var done = false private let lock = NSLock() func run(_ body: () -> Void) { lock.lock() defer { lock.unlock() } guard !done else { return } done = true body() } } let once = ResumeOnce() do { let params = NWParameters.tcp params.allowLocalEndpointReuse = true let listener = try NWListener(using: params, on: .any) self.listener = listener listener.stateUpdateHandler = { [weak self] state in guard let self else { return } switch state { case .ready: let p = listener.port?.rawValue ?? 0 self.port = p proxyLog.info("listener ready on port \(p, privacy: .public)") once.run { if p > 0 { cont.resume(returning: p) } else { cont.resume(throwing: ProxyError.bindFailed) } } case .failed(let err): proxyLog.error("listener failed: \(String(describing: err), privacy: .public)") once.run { cont.resume(throwing: err) } default: break } } listener.newConnectionHandler = { [weak self] conn in self?.handle(connection: conn) } listener.start(queue: self.queue) self.queue.asyncAfter(deadline: .now() + 5) { once.run { cont.resume(throwing: ProxyError.bindFailed) } } } catch { once.run { cont.resume(throwing: error) } } } } guard boundPort > 0 else { throw ProxyError.bindFailed } let gen = generation let (body, _) = try await payload(for: "/manifest.mpd") guard !body.isEmpty, gen == generation else { throw ProxyError.emptyManifest } let preview = String(data: body.prefix(200), encoding: .utf8) ?? "" proxyLog.info("warmup MPD ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)") guard let local = URL(string: "http://127.0.0.1:\(boundPort)/manifest.mpd") else { throw ProxyError.bindFailed } return local } func stop() { queue.sync { stopLocked() } } private func stopLocked() { generation += 1 listener?.cancel() listener = nil port = 0 remoteManifest = nil keyMap = nil cryptoState = CencMp4Decryptor.SessionState() proxyLog.info("proxy stopped") } // MARK: - HTTP private func handle(connection: NWConnection) { connection.start(queue: queue) receiveRequest(on: connection, buffer: Data()) } private func receiveRequest(on connection: NWConnection, buffer: Data) { connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in guard let self else { connection.cancel() return } if error != nil { connection.cancel() return } var buf = buffer if let data { buf.append(data) } if let range = buf.range(of: Data("\r\n\r\n".utf8)) { let headerData = buf.subdata(in: 0.. 256 * 1024 { connection.cancel() return } self.receiveRequest(on: connection, buffer: buf) } } private func serve(connection: NWConnection, headerData: Data) { guard let header = String(data: headerData, encoding: .utf8), let firstLine = header.split(separator: "\r\n", omittingEmptySubsequences: false).first else { respond(connection, status: 400, contentType: "text/plain", body: Data("bad request".utf8)) return } let parts = firstLine.split(separator: " ") guard parts.count >= 2 else { respond(connection, status: 400, contentType: "text/plain", body: Data("bad request".utf8)) return } let method = String(parts[0]) let pathAndQuery = String(parts[1]) guard method == "GET" || method == "HEAD" else { respond(connection, status: 405, contentType: "text/plain", body: Data("method".utf8)) return } let rangeHeader = Self.parseHeader(header, name: "Range") Task { do { let (body, contentType) = try await self.payload(for: pathAndQuery) if let rangeHeader, let byteRange = Self.parseByteRange(rangeHeader, total: body.count) { let slice = body.subdata(in: byteRange.start..<(byteRange.end + 1)) if method == "HEAD" { self.respond( connection, status: 206, contentType: contentType, body: Data(), headOnly: true, contentLength: slice.count, extraHeaders: [ "Accept-Ranges: bytes", "Content-Range: bytes \(byteRange.start)-\(byteRange.end)/\(body.count)", ] ) } else { self.respond( connection, status: 206, contentType: contentType, body: slice, extraHeaders: [ "Accept-Ranges: bytes", "Content-Range: bytes \(byteRange.start)-\(byteRange.end)/\(body.count)", ] ) } } else if method == "HEAD" { self.respond( connection, status: 200, contentType: contentType, body: Data(), headOnly: true, contentLength: body.count, extraHeaders: ["Accept-Ranges: bytes"] ) } else { self.respond( connection, status: 200, contentType: contentType, body: body, extraHeaders: ["Accept-Ranges: bytes"] ) } } catch { let msg = error.localizedDescription proxyLog.error("serve \(pathAndQuery, privacy: .public) → \(msg, privacy: .public)") self.respond(connection, status: 502, contentType: "text/plain; charset=utf-8", body: Data(msg.utf8)) } } } private func payload(for pathAndQuery: String) async throws -> (Data, String) { guard let keyMap, let remoteManifest else { throw ProxyError.notStarted } let split = pathAndQuery.split(separator: "?", maxSplits: 1).map(String.init) let pathOnly = split.first ?? pathAndQuery let query = split.count > 1 ? split[1] : nil if pathOnly == "/" || pathOnly == "/manifest.mpd" { let raw = try await fetch(remoteManifest) guard let xml = String(data: raw, encoding: .utf8), !xml.isEmpty else { // Sommige CDN's sturen UTF-16 BOM; probeer lossy. let lossy = String(decoding: raw, as: UTF8.self) guard !lossy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw ProxyError.emptyManifest } let rewritten = rewriteMpd(lossy, mpdURL: remoteManifest) return (Data(rewritten.utf8), "application/dash+xml") } let rewritten = rewriteMpd(xml, mpdURL: remoteManifest) if !rewritten.localizedCaseInsensitiveContains(" (Data, String) { var data = try await fetch(url) let lower = url.absoluteString.lowercased() let isManifest = lower.contains(".mpd") if isManifest, let xml = String(data: data, encoding: .utf8) ?? Optional(String(decoding: data, as: UTF8.self)) { let rewritten = rewriteMpd(xml, mpdURL: url) return (Data(rewritten.utf8), "application/dash+xml") } queue.sync { _ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoState) } let ct: String if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init") || lower.contains(".cmfv") || lower.contains(".cmfa") { ct = "video/mp4" } else { ct = "application/octet-stream" } return (data, ct) } private func fetch(_ url: URL) async throws -> Data { var req = URLRequest(url: url) req.timeoutInterval = 25 req.cachePolicy = .reloadIgnoringLocalCacheData req.setValue("VMC-iOS-ClearKey/0.5.4", forHTTPHeaderField: "User-Agent") req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges") let (data, resp) = try await urlSession.data(for: req) if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { throw ProxyError.upstream(http.statusCode) } return data } private func rewriteMpd(_ xml: String, mpdURL: URL) -> String { var s = xml // Strip ContentProtection (ClearKey/Widevine/PlayReady/mp4protection). s = s.replacingOccurrences( of: #"]*/>"#, with: "", options: .regularExpression ) s = s.replacingOccurrences( of: #"]*>[\s\S]*?"#, with: "", options: .regularExpression ) // Live dynamic: Location mag niet naar remote DRM-MPD wijzen (VLC omzeilt proxy). s = s.replacingOccurrences( of: #"]*>[\s\S]*?"#, with: "http://127.0.0.1:\(port)/manifest.mpd", options: .regularExpression ) // Herschrijf elke BaseURL naar de juiste resolved CDN-base via /p//. if let baseRegex = try? NSRegularExpression(pattern: #"([\s\S]*?)"#, options: []) { let ns = s as NSString let matches = baseRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length)) for match in matches.reversed() { guard match.numberOfRanges >= 2 else { continue } let content = ns.substring(with: match.range(at: 1)) .trimmingCharacters(in: .whitespacesAndNewlines) if content.contains("127.0.0.1:\(port)") { continue } let resolved = resolveBaseURL(content, mpdURL: mpdURL) let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(asDirectory(resolved)))/" s = (s as NSString).replacingCharacters(in: match.range, with: "\(proxyBase)") } } if !s.localizedCaseInsensitiveContains("]*>"#, options: []) { let range = NSRange(s.startIndex..\(proxyBase)", at: r.upperBound) } } } // Absolute http(s) URL's in attributen → /a/ if let urlRegex = try? NSRegularExpression(pattern: #"https?://[^\s"'<>]+"#, options: []) { let ns = s as NSString let matches = urlRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length)) for match in matches.reversed() { let abs = ns.substring(with: match.range) if abs.contains("127.0.0.1:\(port)") { continue } let replacement = "http://127.0.0.1:\(port)/a/\(encodeURLString(abs))" s = (s as NSString).replacingCharacters(in: match.range, with: replacement) } } return s } /// Resolve BaseURL tekst t.o.v. MPD-URL; altijd als directory-URL. private func resolveBaseURL(_ raw: String, mpdURL: URL) -> URL { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return asDirectory(mpdURL.deletingLastPathComponent()) } if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://"), let abs = URL(string: trimmed) { return asDirectory(abs) } if let joined = URL(string: trimmed, relativeTo: mpdURL)?.absoluteURL { return asDirectory(joined) } return asDirectory(mpdURL.deletingLastPathComponent()) } private func asDirectory(_ url: URL) -> URL { var s = url.absoluteString if s.isEmpty { return url } if !s.hasSuffix("/") { // Als laatste path-component op een bestand lijkt (.mpd/.mp4), strip; anders slash toevoegen. let last = url.lastPathComponent.lowercased() if last.contains("."), !last.hasSuffix("/") { return asDirectory(url.deletingLastPathComponent()) } s += "/" } return URL(string: s) ?? url } private func join(base: URL, relative: String) -> URL { if relative.hasPrefix("http://") || relative.hasPrefix("https://") { return URL(string: relative) ?? base } return URL(string: relative, relativeTo: base)?.absoluteURL ?? base.appendingPathComponent(relative) } private func encodeURL(_ url: URL) -> String { encodeURLString(url.absoluteString) } private func encodeURLString(_ s: String) -> String { Data(s.utf8).base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } private func decodeURL(_ b64url: String) -> URL? { var b64 = b64url .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") let pad = (4 - b64.count % 4) % 4 if pad > 0 { b64 += String(repeating: "=", count: pad) } guard let data = Data(base64Encoded: b64), let s = String(data: data, encoding: .utf8) else { return nil } return URL(string: s) } private static func parseHeader(_ header: String, name: String) -> String? { let lines = header.split(separator: "\r\n") let prefix = name.lowercased() + ":" for line in lines.dropFirst() { let l = line.trimmingCharacters(in: .whitespaces) if l.lowercased().hasPrefix(prefix) { return String(l.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) } } return nil } private static func parseByteRange(_ value: String, total: Int) -> (start: Int, end: Int)? { // bytes=START-END | bytes=START- guard value.lowercased().hasPrefix("bytes=") else { return nil } let spec = value.dropFirst(6) let parts = spec.split(separator: "-", maxSplits: 1).map(String.init) guard let start = Int(parts.first ?? "") else { return nil } let end: Int if parts.count > 1, let e = Int(parts[1]) { end = min(e, total - 1) } else { end = total - 1 } guard total > 0, start >= 0, start <= end, end < total else { return nil } return (start, end) } private func respond( _ connection: NWConnection, status: Int, contentType: String, body: Data, headOnly: Bool = false, contentLength: Int? = nil, extraHeaders: [String] = [] ) { let reason: String switch status { case 200: reason = "OK" case 206: reason = "Partial Content" case 400: reason = "Bad Request" case 405: reason = "Method Not Allowed" case 502: reason = "Bad Gateway" default: reason = "Error" } let len = contentLength ?? body.count var header = "HTTP/1.1 \(status) \(reason)\r\n" header += "Content-Type: \(contentType)\r\n" header += "Content-Length: \(len)\r\n" header += "Connection: close\r\n" header += "Access-Control-Allow-Origin: *\r\n" header += "Cache-Control: no-store\r\n" for h in extraHeaders { header += h + "\r\n" } header += "\r\n" var payload = Data(header.utf8) if !headOnly { payload.append(body) } connection.send(content: payload, completion: .contentProcessed { _ in connection.cancel() }) } enum ProxyError: LocalizedError { case noKeys case bindFailed case notStarted case badPath case emptyManifest case upstream(Int) var errorDescription: String? { switch self { case .noKeys: return "Geen geldige ClearKey-sleutels" case .bindFailed: return "Kon lokale ClearKey-proxy niet starten" case .notStarted: return "ClearKey-proxy is niet actief" case .badPath: return "Ongeldig proxy-pad" case .emptyManifest: return "Leeg of ongeldig DASH-manifest ontvangen" case .upstream(let code): return "Upstream HTTP \(code)" } } } }