Files
Termind/TFM/Infrastructure/Networking/LoopbackHTTPServer.swift
T
2026-09-12 23:57:33 +08:00

374 lines
14 KiB
Swift

import Foundation
import Network
import TermindDomain
import struct TermindDomain.Host
nonisolated struct HTTPRequest: Sendable {
var method: String
var target: String
var version: String
var headers: [String: String]
var body: Data
func header(_ name: String) -> String? { headers[name.lowercased()] }
}
nonisolated struct HTTPResponse: Sendable {
nonisolated enum Body: Sendable {
case data(Data)
case stream(AsyncStream<Data>)
}
var status: Int
var headers: [String: String]
var body: Body
init(status: Int, headers: [String: String] = [:], body: Data = Data()) {
self.status = status
self.headers = headers
self.body = .data(body)
}
init(status: Int, headers: [String: String] = [:], stream: AsyncStream<Data>) {
self.status = status
self.headers = headers
body = .stream(stream)
}
static func text(_ status: Int, _ value: String, headers: [String: String] = [:]) -> Self {
var all = headers
all["Content-Type"] = "text/plain; charset=utf-8"
return Self(status: status, headers: all, body: Data(value.utf8))
}
}
actor LoopbackHTTPServer {
typealias Handler = @Sendable (HTTPRequest) async -> HTTPResponse
typealias StateHandler = @Sendable (State) -> Void
nonisolated enum State: Sendable, Equatable {
case stopped
case starting
case ready(port: UInt16)
case failed(String)
}
static let maximumHeaderBytes = 64 * 1_024
static let maximumBodyBytes = 4 * 1_024 * 1_024
static let maximumConnections = 64
private var listeners: [NWListener] = []
private var connections: [ObjectIdentifier: NWConnection] = [:]
private var token = ""
private var handler: Handler?
private var stateHandler: StateHandler?
private var readyListeners = 0
func start(
port: UInt16, token: String,
stateHandler: @escaping StateHandler = { _ in },
handler: @escaping Handler
) throws {
stop()
guard let endpointPort = NWEndpoint.Port(rawValue: port), !token.isEmpty else {
throw LoopbackHTTPServerError.invalidConfiguration
}
self.token = token
self.handler = handler
self.stateHandler = stateHandler
stateHandler(.starting)
do {
listeners = try ["127.0.0.1", "::1"].map { address in
let tcp = NWProtocolTCP.Options()
tcp.enableKeepalive = true
tcp.keepaliveIdle = 60
tcp.keepaliveInterval = 10
tcp.keepaliveCount = 3
tcp.connectionTimeout = 15
let parameters = NWParameters(tls: nil, tcp: tcp)
parameters.acceptLocalOnly = true
parameters.allowLocalEndpointReuse = true
parameters.requiredLocalEndpoint = .hostPort(
host: NWEndpoint.Host(address), port: endpointPort
)
let listener = try NWListener(using: parameters)
listener.newConnectionLimit = Self.maximumConnections
listener.newConnectionHandler = { [weak self] connection in
Task { await self?.accept(connection) }
}
listener.stateUpdateHandler = { [weak self] newState in
Task { await self?.listenerChanged(newState, port: port) }
}
listener.start(queue: DispatchQueue(label: "termind.mcp.listener.\(address)"))
return listener
}
} catch {
stop()
throw error
}
}
func replaceToken(_ token: String) {
self.token = token
for connection in connections.values { connection.cancel() }
connections.removeAll()
}
func stop() {
for listener in listeners { listener.cancel() }
for connection in connections.values { connection.cancel() }
listeners.removeAll()
connections.removeAll()
handler = nil
readyListeners = 0
stateHandler?(.stopped)
stateHandler = nil
}
private func listenerChanged(_ newState: NWListener.State, port: UInt16) {
switch newState {
case .ready:
readyListeners += 1
if readyListeners == listeners.count {
stateHandler?(.ready(port: port))
}
case .failed(let error):
stateHandler?(.failed(error.localizedDescription))
for listener in listeners { listener.cancel() }
default:
break
}
}
private func accept(_ connection: NWConnection) async {
guard connections.count < Self.maximumConnections else {
connection.cancel()
return
}
let id = ObjectIdentifier(connection)
connections[id] = connection
connection.start(queue: DispatchQueue(label: "termind.mcp.connection"))
defer {
connections[id] = nil
connection.cancel()
}
var buffer = Data()
while !Task.isCancelled {
let parsed: (HTTPRequest, Data)
do {
parsed = try await Self.readRequest(connection, initial: buffer)
buffer = parsed.1
} catch LoopbackHTTPServerError.endOfStream {
return
} catch LoopbackHTTPServerError.headersTooLarge {
try? await Self.send(.text(431, "Request headers are too large."), on: connection)
return
} catch LoopbackHTTPServerError.bodyTooLarge {
try? await Self.send(.text(413, "Request body is too large."), on: connection)
return
} catch LoopbackHTTPServerError.chunkedRequest {
try? await Self.send(.text(411, "Chunked request bodies are not supported."), on: connection)
return
} catch {
try? await Self.send(.text(400, "Malformed HTTP request."), on: connection)
return
}
let request = parsed.0
let response: HTTPResponse
if !Self.authorized(request, token: token) {
response = .text(
401, "A valid bearer token is required.",
headers: ["WWW-Authenticate": "Bearer"]
)
} else if !Self.allowedOrigin(request.header("origin")) {
response = .text(403, "The request Origin is not loopback.")
} else if let handler {
response = await handler(request)
} else {
response = .text(503, "The MCP server is stopping.")
}
do { try await Self.send(response, on: connection) } catch { return }
if case .stream = response.body { return }
let connectionHeader = request.header("connection")?.lowercased()
let keepAlive =
request.version == "HTTP/1.1"
? connectionHeader != "close" : connectionHeader == "keep-alive"
if !keepAlive { return }
}
}
private static func authorized(_ request: HTTPRequest, token: String) -> Bool {
guard let value = request.header("authorization"), value.hasPrefix("Bearer ") else {
return false
}
return constantTimeEqual(String(value.dropFirst(7)), token)
}
static func constantTimeEqual(_ left: String, _ right: String) -> Bool {
let a = Array(left.utf8)
let b = Array(right.utf8)
let count = max(a.count, b.count)
var difference = a.count ^ b.count
for index in 0..<count {
difference |=
Int(index < a.count ? a[index] : 0)
^ Int(index < b.count ? b[index] : 0)
}
return difference == 0
}
static func allowedOrigin(_ raw: String?) -> Bool {
guard let raw else { return true }
guard let components = URLComponents(string: raw),
components.scheme == "http" || components.scheme == "https",
components.user == nil, components.password == nil,
components.path.isEmpty, components.query == nil, components.fragment == nil,
let rawHost = components.host?.lowercased()
else { return false }
let host =
rawHost.hasPrefix("[") && rawHost.hasSuffix("]")
? String(rawHost.dropFirst().dropLast()) : rawHost
if host == "localhost" || host == "::1" { return true }
let octets = host.split(separator: ".").compactMap { UInt8($0) }
return octets.count == 4 && octets[0] == 127
}
private static func readRequest(
_ connection: NWConnection, initial: Data
) async throws -> (HTTPRequest, Data) {
var buffer = initial
let separator = Data("\r\n\r\n".utf8)
while buffer.range(of: separator) == nil {
guard buffer.count <= maximumHeaderBytes else {
throw LoopbackHTTPServerError.headersTooLarge
}
buffer.append(try await receive(connection))
}
guard let range = buffer.range(of: separator), range.lowerBound <= maximumHeaderBytes else {
throw LoopbackHTTPServerError.headersTooLarge
}
let head = buffer[..<range.lowerBound]
guard let text = String(data: head, encoding: .utf8) else {
throw LoopbackHTTPServerError.malformed
}
let lines = text.components(separatedBy: "\r\n")
guard let first = lines.first else { throw LoopbackHTTPServerError.malformed }
let requestLine = first.split(separator: " ", omittingEmptySubsequences: true)
guard requestLine.count == 3, requestLine[2].hasPrefix("HTTP/") else {
throw LoopbackHTTPServerError.malformed
}
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":") else {
throw LoopbackHTTPServerError.malformed
}
let name = line[..<colon].trimmingCharacters(in: .whitespaces).lowercased()
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { throw LoopbackHTTPServerError.malformed }
headers[name] = headers[name].map { "\($0), \(value)" } ?? value
}
if headers["transfer-encoding"] != nil {
throw LoopbackHTTPServerError.chunkedRequest
}
let length: Int
if let raw = headers["content-length"] {
guard let parsed = Int(raw), parsed >= 0 else { throw LoopbackHTTPServerError.malformed }
length = parsed
} else {
length = 0
}
guard length <= maximumBodyBytes else { throw LoopbackHTTPServerError.bodyTooLarge }
var remainder = Data(buffer[range.upperBound...])
while remainder.count < length { remainder.append(try await receive(connection)) }
let body = Data(remainder.prefix(length))
remainder.removeFirst(length)
return (
HTTPRequest(
method: String(requestLine[0]), target: String(requestLine[1]),
version: String(requestLine[2]), headers: headers, body: body
), remainder
)
}
private static func receive(_ connection: NWConnection) async throws -> Data {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, any Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1_024) {
data, _, complete, error in
if let error {
continuation.resume(throwing: error)
} else if let data, !data.isEmpty {
continuation.resume(returning: data)
} else if complete {
continuation.resume(throwing: LoopbackHTTPServerError.endOfStream)
} else {
continuation.resume(throwing: LoopbackHTTPServerError.malformed)
}
}
}
}
private static func send(_ response: HTTPResponse, on connection: NWConnection) async throws {
var headers = response.headers
headers["Server"] = "Termind"
headers["Cache-Control"] = headers["Cache-Control"] ?? "no-store"
let prefix: Data
switch response.body {
case .data(let body):
headers["Content-Length"] = String(body.count)
headers["Connection"] = headers["Connection"] ?? "keep-alive"
prefix = responseHead(status: response.status, headers: headers)
try await send(prefix + body, on: connection)
case .stream(let stream):
headers["Connection"] = "close"
prefix = responseHead(status: response.status, headers: headers)
try await send(prefix, on: connection)
for await chunk in stream { try await send(chunk, on: connection) }
}
}
private static func responseHead(status: Int, headers: [String: String]) -> Data {
var value = "HTTP/1.1 \(status) \(reason(status))\r\n"
for (name, headerValue) in headers.sorted(by: { $0.key < $1.key }) {
value += "\(name): \(headerValue)\r\n"
}
value += "\r\n"
return Data(value.utf8)
}
private static func send(_ data: Data, on connection: NWConnection) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
connection.send(
content: data,
completion: .contentProcessed { error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
})
}
}
private static func reason(_ status: Int) -> String {
switch status {
case 200: "OK"
case 202: "Accepted"
case 400: "Bad Request"
case 401: "Unauthorized"
case 403: "Forbidden"
case 404: "Not Found"
case 405: "Method Not Allowed"
case 411: "Length Required"
case 413: "Content Too Large"
case 415: "Unsupported Media Type"
case 431: "Request Header Fields Too Large"
case 503: "Service Unavailable"
default: "Error"
}
}
}
nonisolated enum LoopbackHTTPServerError: Error, Sendable {
case invalidConfiguration, malformed, headersTooLarge, bodyTooLarge, chunkedRequest, endOfStream
}