434 lines
13 KiB
Swift
434 lines
13 KiB
Swift
import Foundation
|
|
#if canImport(Darwin)
|
|
import Darwin
|
|
#elseif canImport(Glibc)
|
|
import Glibc
|
|
#elseif canImport(Musl)
|
|
import Musl
|
|
#endif
|
|
|
|
private let requestReadTimeoutMilliseconds: Int32 = 5000
|
|
|
|
struct CLILocalHTTPRequest {
|
|
let method: String
|
|
let target: String
|
|
let host: String
|
|
let path: String
|
|
let queryItems: [String: String]
|
|
|
|
static func parse(_ data: Data) -> Result<CLILocalHTTPRequest, CLILocalHTTPRequestParseError> {
|
|
guard let raw = String(data: data, encoding: .utf8),
|
|
let firstLine = raw.components(separatedBy: "\r\n").first
|
|
else {
|
|
return .failure(.invalidRequest)
|
|
}
|
|
|
|
let parts = firstLine.split(separator: " ")
|
|
guard parts.count >= 3 else { return .failure(.invalidRequest) }
|
|
|
|
let method = String(parts[0]).uppercased()
|
|
let target = String(parts[1])
|
|
guard target.hasPrefix("/") else { return .failure(.invalidRequest) }
|
|
|
|
let headerResult = Self.parseHeaders(raw)
|
|
let host: String
|
|
switch headerResult {
|
|
case let .success(headers):
|
|
let hosts = headers.compactMap { name, value in
|
|
name.lowercased() == "host" ? value : nil
|
|
}
|
|
guard let candidate = hosts.first else { return .failure(.missingHost) }
|
|
guard hosts.count == 1 else { return .failure(.duplicateHost) }
|
|
guard Self.isAllowedLoopbackHost(candidate) else { return .failure(.disallowedHost) }
|
|
host = candidate
|
|
case let .failure(error):
|
|
return .failure(error)
|
|
}
|
|
|
|
let components = URLComponents(string: "http://localhost\(target)")
|
|
let path = components?.path ?? target
|
|
var queryItems: [String: String] = [:]
|
|
for item in components?.queryItems ?? [] {
|
|
if let value = item.value {
|
|
queryItems[item.name] = value
|
|
}
|
|
}
|
|
|
|
return .success(CLILocalHTTPRequest(
|
|
method: method,
|
|
target: target,
|
|
host: host,
|
|
path: path,
|
|
queryItems: queryItems))
|
|
}
|
|
|
|
private static func parseHeaders(_ raw: String) -> Result<[(String, String)], CLILocalHTTPRequestParseError> {
|
|
let lines = raw.components(separatedBy: "\r\n")
|
|
var headers: [(String, String)] = []
|
|
|
|
for line in lines.dropFirst() {
|
|
if line.isEmpty { break }
|
|
guard let separator = line.firstIndex(of: ":") else {
|
|
return .failure(.invalidRequest)
|
|
}
|
|
let name = String(line[..<separator]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let value = String(line[line.index(after: separator)...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !name.isEmpty else { return .failure(.invalidRequest) }
|
|
headers.append((name, value))
|
|
}
|
|
|
|
return .success(headers)
|
|
}
|
|
|
|
private static func isAllowedLoopbackHost(_ host: String) -> Bool {
|
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, !trimmed.contains(",") else { return false }
|
|
|
|
let hostWithoutPort: String
|
|
if trimmed.hasPrefix("[") {
|
|
guard let closingBracket = trimmed.firstIndex(of: "]") else { return false }
|
|
hostWithoutPort = String(trimmed[...closingBracket])
|
|
let remainder = trimmed[trimmed.index(after: closingBracket)...]
|
|
guard remainder.isEmpty || Self.isValidPortSuffix(String(remainder)) else { return false }
|
|
} else {
|
|
let segments = trimmed.split(separator: ":", omittingEmptySubsequences: false)
|
|
switch segments.count {
|
|
case 1:
|
|
hostWithoutPort = String(segments[0])
|
|
case 2:
|
|
guard Self.isValidPort(String(segments[1])) else { return false }
|
|
hostWithoutPort = String(segments[0])
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
switch hostWithoutPort.lowercased() {
|
|
case "127.0.0.1", "localhost", "localhost.", "[::1]":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func isValidPortSuffix(_ raw: String) -> Bool {
|
|
guard raw.hasPrefix(":") else { return false }
|
|
return self.isValidPort(String(raw.dropFirst()))
|
|
}
|
|
|
|
private static func isValidPort(_ raw: String) -> Bool {
|
|
guard let port = Int(raw), port > 0, port <= Int(UInt16.max) else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
enum CLILocalHTTPRequestParseError: Error, Equatable {
|
|
case invalidRequest
|
|
case missingHost
|
|
case duplicateHost
|
|
case disallowedHost
|
|
}
|
|
|
|
enum CLIHTTPStatus {
|
|
case ok
|
|
case badRequest
|
|
case forbidden
|
|
case notFound
|
|
case methodNotAllowed
|
|
case internalServerError
|
|
case gatewayTimeout
|
|
var code: Int {
|
|
switch self {
|
|
case .ok: 200
|
|
case .badRequest: 400
|
|
case .forbidden: 403
|
|
case .notFound: 404
|
|
case .methodNotAllowed: 405
|
|
case .internalServerError: 500
|
|
case .gatewayTimeout: 504
|
|
}
|
|
}
|
|
|
|
var reason: String {
|
|
switch self {
|
|
case .ok: "OK"
|
|
case .badRequest: "Bad Request"
|
|
case .forbidden: "Forbidden"
|
|
case .notFound: "Not Found"
|
|
case .methodNotAllowed: "Method Not Allowed"
|
|
case .internalServerError: "Internal Server Error"
|
|
case .gatewayTimeout: "Gateway Timeout"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CLILocalHTTPResponse {
|
|
let status: CLIHTTPStatus
|
|
let body: Data
|
|
let contentType: String
|
|
let usageCacheKeys: [String?]?
|
|
|
|
init(
|
|
status: CLIHTTPStatus,
|
|
body: Data,
|
|
contentType: String = "application/json; charset=utf-8",
|
|
usageCacheKeys: [String?]? = nil)
|
|
{
|
|
self.status = status
|
|
self.body = body
|
|
self.contentType = contentType
|
|
self.usageCacheKeys = usageCacheKeys
|
|
}
|
|
|
|
var serialized: Data {
|
|
var headers = "HTTP/1.1 \(self.status.code) \(self.status.reason)\r\n"
|
|
headers += "Content-Type: \(self.contentType)\r\n"
|
|
headers += "Content-Length: \(self.body.count)\r\n"
|
|
headers += "Connection: close\r\n"
|
|
headers += "\r\n"
|
|
|
|
var data = Data(headers.utf8)
|
|
data.append(self.body)
|
|
return data
|
|
}
|
|
}
|
|
|
|
final class CLILocalHTTPServer: @unchecked Sendable {
|
|
typealias Handler = @Sendable (CLILocalHTTPRequest) async -> CLILocalHTTPResponse
|
|
|
|
private let host: String
|
|
private let port: UInt16
|
|
private let handler: Handler
|
|
private let stateLock = NSLock()
|
|
private var listeningFD: Int32?
|
|
private var stopRequested = false
|
|
|
|
init(host: String, port: UInt16, handler: @escaping Handler) {
|
|
self.host = host
|
|
self.port = port
|
|
self.handler = handler
|
|
}
|
|
|
|
func stop() {
|
|
self.stateLock.lock()
|
|
self.stopRequested = true
|
|
self.stateLock.unlock()
|
|
}
|
|
|
|
func run(onListening: @Sendable () -> Void = {}) async throws {
|
|
ignoreSIGPIPE()
|
|
|
|
#if canImport(Darwin)
|
|
let streamType = SOCK_STREAM
|
|
#elseif canImport(Glibc)
|
|
let streamType = Int32(SOCK_STREAM.rawValue)
|
|
#elseif canImport(Musl)
|
|
let streamType = Int32(SOCK_STREAM)
|
|
#endif
|
|
|
|
let serverFD = socket(AF_INET, streamType, 0)
|
|
guard serverFD >= 0 else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
var ownsServerFD = true
|
|
defer {
|
|
if ownsServerFD {
|
|
closeSocket(serverFD)
|
|
}
|
|
}
|
|
|
|
var reuse: Int32 = 1
|
|
setsockopt(
|
|
serverFD,
|
|
SOL_SOCKET,
|
|
SO_REUSEADDR,
|
|
&reuse,
|
|
socklen_t(MemoryLayout<Int32>.size))
|
|
|
|
var address = sockaddr_in()
|
|
#if canImport(Darwin)
|
|
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
|
#endif
|
|
address.sin_family = sa_family_t(AF_INET)
|
|
address.sin_port = self.port.bigEndian
|
|
guard inet_pton(AF_INET, self.host, &address.sin_addr) == 1 else {
|
|
throw POSIXError(.EADDRNOTAVAIL)
|
|
}
|
|
|
|
let bound = withUnsafePointer(to: &address) { pointer in
|
|
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in
|
|
bind(serverFD, socketAddress, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
}
|
|
}
|
|
guard bound == 0 else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
|
|
guard listen(serverFD, 16) == 0 else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
guard self.installListeningFD(serverFD) else {
|
|
return
|
|
}
|
|
ownsServerFD = false
|
|
defer {
|
|
if self.releaseListeningFD(serverFD) {
|
|
closeSocket(serverFD)
|
|
}
|
|
}
|
|
onListening()
|
|
|
|
while !self.isStopRequested {
|
|
guard waitForReadable(serverFD, timeoutMilliseconds: 250) else {
|
|
continue
|
|
}
|
|
var clientAddress = sockaddr()
|
|
var clientLength = socklen_t(MemoryLayout<sockaddr>.size)
|
|
let clientFD = accept(serverFD, &clientAddress, &clientLength)
|
|
guard clientFD >= 0 else {
|
|
if self.isStopRequested { return }
|
|
if errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK || errno == ECONNABORTED {
|
|
continue
|
|
}
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
let handler = self.handler
|
|
Task {
|
|
defer { closeSocket(clientFD) }
|
|
await handleClient(clientFD, handler: handler)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var isStopRequested: Bool {
|
|
self.stateLock.lock()
|
|
let value = self.stopRequested
|
|
self.stateLock.unlock()
|
|
return value
|
|
}
|
|
|
|
private func installListeningFD(_ fd: Int32) -> Bool {
|
|
self.stateLock.lock()
|
|
defer { self.stateLock.unlock() }
|
|
guard !self.stopRequested else { return false }
|
|
self.listeningFD = fd
|
|
return true
|
|
}
|
|
|
|
private func releaseListeningFD(_ fd: Int32) -> Bool {
|
|
self.stateLock.lock()
|
|
defer { self.stateLock.unlock() }
|
|
guard self.listeningFD == fd else { return false }
|
|
self.listeningFD = nil
|
|
return true
|
|
}
|
|
}
|
|
|
|
private func handleClient(
|
|
_ clientFD: Int32,
|
|
handler: @Sendable (CLILocalHTTPRequest) async -> CLILocalHTTPResponse) async
|
|
{
|
|
let request: CLILocalHTTPRequest
|
|
switch readRequest(clientFD) {
|
|
case let .success(parsedRequest):
|
|
request = parsedRequest
|
|
case .failure(.disallowedHost):
|
|
sendResponse(
|
|
CLILocalHTTPResponse(
|
|
status: .forbidden,
|
|
body: Data(#"{"error":"forbidden host"}"#.utf8)),
|
|
to: clientFD)
|
|
return
|
|
case .failure:
|
|
sendResponse(
|
|
CLILocalHTTPResponse(
|
|
status: .badRequest,
|
|
body: Data(#"{"error":"invalid request"}"#.utf8)),
|
|
to: clientFD)
|
|
return
|
|
}
|
|
|
|
let response = await handler(request)
|
|
sendResponse(response, to: clientFD)
|
|
}
|
|
|
|
private func readRequest(_ fd: Int32) -> Result<CLILocalHTTPRequest, CLILocalHTTPRequestParseError> {
|
|
var data = Data()
|
|
var buffer = [UInt8](repeating: 0, count: 4096)
|
|
let bufferSize = buffer.count
|
|
var sawHeaderEnd = false
|
|
|
|
while data.count < 16384 {
|
|
guard waitForReadable(fd, timeoutMilliseconds: requestReadTimeoutMilliseconds) else {
|
|
return .failure(.invalidRequest)
|
|
}
|
|
let count = buffer.withUnsafeMutableBytes { rawBuffer in
|
|
recv(fd, rawBuffer.baseAddress, bufferSize, 0)
|
|
}
|
|
guard count > 0 else { break }
|
|
data.append(buffer, count: count)
|
|
if data.range(of: Data("\r\n\r\n".utf8)) != nil {
|
|
sawHeaderEnd = true
|
|
break
|
|
}
|
|
}
|
|
|
|
guard sawHeaderEnd else { return .failure(.invalidRequest) }
|
|
return CLILocalHTTPRequest.parse(data)
|
|
}
|
|
|
|
private func sendResponse(_ response: CLILocalHTTPResponse, to fd: Int32) {
|
|
let data = response.serialized
|
|
data.withUnsafeBytes { rawBuffer in
|
|
guard let base = rawBuffer.baseAddress else { return }
|
|
var sent = 0
|
|
while sent < data.count {
|
|
let count = send(fd, base.advanced(by: sent), data.count - sent, sendNoSignalFlags())
|
|
guard count > 0 else { break }
|
|
sent += count
|
|
}
|
|
}
|
|
}
|
|
|
|
private func waitForReadable(_ fd: Int32, timeoutMilliseconds: Int32) -> Bool {
|
|
var pollFD = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
|
while true {
|
|
let result = poll(&pollFD, 1, timeoutMilliseconds)
|
|
if result > 0 {
|
|
return (pollFD.revents & Int16(POLLIN)) != 0
|
|
}
|
|
if result == -1, errno == EINTR {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func sendNoSignalFlags() -> Int32 {
|
|
#if canImport(Darwin)
|
|
0
|
|
#else
|
|
Int32(MSG_NOSIGNAL)
|
|
#endif
|
|
}
|
|
|
|
private func ignoreSIGPIPE() {
|
|
#if canImport(Darwin)
|
|
_ = Darwin.signal(SIGPIPE, SIG_IGN)
|
|
#elseif canImport(Glibc)
|
|
_ = Glibc.signal(SIGPIPE, SIG_IGN)
|
|
#elseif canImport(Musl)
|
|
_ = Musl.signal(SIGPIPE, SIG_IGN)
|
|
#endif
|
|
}
|
|
|
|
private func closeSocket(_ fd: Int32) {
|
|
#if canImport(Darwin)
|
|
Darwin.close(fd)
|
|
#elseif canImport(Glibc)
|
|
Glibc.close(fd)
|
|
#elseif canImport(Musl)
|
|
Musl.close(fd)
|
|
#endif
|
|
}
|