541 lines
22 KiB
Swift
541 lines
22 KiB
Swift
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..<range.lowerBound)
|
|
self.serve(connection: connection, headerData: headerData)
|
|
return
|
|
}
|
|
if isComplete {
|
|
connection.cancel()
|
|
return
|
|
}
|
|
if buf.count > 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("<MPD") {
|
|
throw ProxyError.emptyManifest
|
|
}
|
|
return (Data(rewritten.utf8), "application/dash+xml")
|
|
}
|
|
|
|
if pathOnly.hasPrefix("/a/") {
|
|
let b64 = String(pathOnly.dropFirst(3))
|
|
guard var url = decodeURL(b64) else { throw ProxyError.badPath }
|
|
if let query, url.query == nil {
|
|
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
comps?.percentEncodedQuery = query
|
|
if let u = comps?.url { url = u }
|
|
}
|
|
return try await fetchAndDecrypt(url, keys: keyMap)
|
|
}
|
|
|
|
if pathOnly.hasPrefix("/p/") {
|
|
let rest = String(pathOnly.dropFirst(3))
|
|
guard let slash = rest.firstIndex(of: "/") else { throw ProxyError.badPath }
|
|
let b64 = String(rest[..<slash])
|
|
var rel = String(rest[rest.index(after: slash)...])
|
|
if let query {
|
|
rel += (rel.contains("?") ? "&" : "?") + query
|
|
}
|
|
guard let base = decodeURL(b64) else { throw ProxyError.badPath }
|
|
let joined = join(base: asDirectory(base), relative: rel.removingPercentEncoding ?? rel)
|
|
return try await fetchAndDecrypt(joined, keys: keyMap)
|
|
}
|
|
|
|
throw ProxyError.badPath
|
|
}
|
|
|
|
private func fetchAndDecrypt(_ url: URL, keys: ClearKeyKeyMap) async throws -> (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: #"<ContentProtection\b[^>]*/>"#,
|
|
with: "",
|
|
options: .regularExpression
|
|
)
|
|
s = s.replacingOccurrences(
|
|
of: #"<ContentProtection\b[^>]*>[\s\S]*?</ContentProtection>"#,
|
|
with: "",
|
|
options: .regularExpression
|
|
)
|
|
|
|
// Live dynamic: Location mag niet naar remote DRM-MPD wijzen (VLC omzeilt proxy).
|
|
s = s.replacingOccurrences(
|
|
of: #"<Location\b[^>]*>[\s\S]*?</Location>"#,
|
|
with: "<Location>http://127.0.0.1:\(port)/manifest.mpd</Location>",
|
|
options: .regularExpression
|
|
)
|
|
|
|
// Herschrijf elke BaseURL naar de juiste resolved CDN-base via /p/<b64>/.
|
|
if let baseRegex = try? NSRegularExpression(pattern: #"<BaseURL>([\s\S]*?)</BaseURL>"#, 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: "<BaseURL>\(proxyBase)</BaseURL>")
|
|
}
|
|
}
|
|
|
|
if !s.localizedCaseInsensitiveContains("<BaseURL") {
|
|
let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(asDirectory(mpdURL.deletingLastPathComponent())))/"
|
|
if let regex = try? NSRegularExpression(pattern: #"<MPD\b[^>]*>"#, options: []) {
|
|
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
|
if let match = regex.firstMatch(in: s, options: [], range: range),
|
|
let r = Range(match.range, in: s) {
|
|
s.insert(contentsOf: "\n <BaseURL>\(proxyBase)</BaseURL>", at: r.upperBound)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Absolute http(s) URL's in attributen → /a/<b64>
|
|
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)"
|
|
}
|
|
}
|
|
}
|
|
}
|