diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 3d3f298..0928771 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -19,6 +19,7 @@ import NIOCertificateReloading import NIOHTTP2 import SwiftASN1 public import X509 +import System @available(anyAppleOS 26.0, *) extension NIOHTTPServerConfiguration { @@ -95,7 +96,8 @@ extension NIOHTTPServerConfiguration { let bindTargetScope = snapshot.scoped(to: "bindTarget") let singularHost = bindTargetScope.string(forKey: "host") let singularPort = bindTargetScope.int(forKey: "port") - let hasSingular = singularHost != nil || singularPort != nil + let singularSocketPath = bindTargetScope.string(forKey: "socketPath") + let hasSingular = singularHost != nil || singularPort != nil || singularSocketPath != nil if hasSingular && hasPlural { throw NIOHTTPServerSwiftConfigurationError.singularAndPluralBindTargetsProvided @@ -119,17 +121,31 @@ extension NIOHTTPServerConfiguration.BindTarget { /// Initialize a bind target configuration from a config reader. /// /// ## Configuration keys: - /// - `host` (string, required): The hostname or IP address the server will bind to (e.g., "localhost", "0.0.0.0"). - /// - `port` (int, required): The port number the server will listen on (e.g., 8080, 443). + /// - `host` (string): The hostname or IP address to bind to. Required unless `socketPath` is given. + /// - `port` (int): The port to listen on. Required unless `socketPath` is given. + /// - `socketPath` (string): A unix domain socket path to bind to. Mutually exclusive with `host`/`port`. /// /// - Parameter config: The configuration reader. public init(config: ConfigSnapshotReader) throws { - self.init( - backing: .hostAndPort( + let host = config.string(forKey: "host") + let port = config.int(forKey: "port") + let socketPath = config.string(forKey: "socketPath") + + let backing: Backing + if let socketPath { + guard host == nil, port == nil else { + throw NIOHTTPServerSwiftConfigurationError.hostPortAndSocketPathProvided + } + let filePath = FilePath(socketPath) + backing = .unixDomainSocket(path: filePath) + } else { + backing = .hostAndPort( host: try config.requiredString(forKey: "host"), port: try config.requiredInt(forKey: "port") ) - ) + } + + self.init(backing: backing) } } diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index 3e06c14..6d0077a 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -15,6 +15,7 @@ import NIOCore import NIOSSL public import X509 +public import System /// Configuration settings for ``NIOHTTPServer``. /// @@ -29,6 +30,7 @@ public struct NIOHTTPServerConfiguration: Sendable { public struct BindTarget: Sendable { enum Backing { case hostAndPort(host: String, port: Int) + case unixDomainSocket(path: FilePath) } let backing: Backing @@ -47,8 +49,22 @@ public struct NIOHTTPServerConfiguration: Sendable { public static func hostAndPort(host: String, port: Int) -> Self { Self(backing: .hostAndPort(host: host, port: port)) } + + /// Creates a bind target for a unix domain socket. + /// + /// - Parameter path: The file system path to bind the unix domain socket to (e.g., "/tmp/server.sock") + /// - Returns: A configured `BindTarget` instance + /// + /// ## Example + /// ```swift + /// let target = BindTarget.unixDomainSocket(path: "/tmp/server.sock") + /// ``` + public static func unixDomainSocket(path: FilePath) -> Self { + Self(backing: .unixDomainSocket(path: path)) + } } + /// Configuration for transport security settings. /// /// Provides options for running the server with or without TLS encryption. @@ -344,6 +360,17 @@ public struct NIOHTTPServerConfiguration: Sendable { throw NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified } + #if HTTP3 + // HTTP/3 runs over QUIC/UDP and cannot be served over a unix domain socket. + if supportedHTTPVersions.http3ConfigIfSupported != nil { + for bindTarget in bindTargets { + if case .unixDomainSocket = bindTarget.backing { + throw NIOHTTPServerConfigurationError.unixDomainSocketNotSupportedOverHTTP3 + } + } + } + #endif + self.bindTargets = bindTargets self.supportedHTTPVersions = supportedHTTPVersions self.transportSecurity = transportSecurity diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift index 19ba873..3b8916a 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift @@ -20,6 +20,7 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case onlyPEMFileCredentialsCurrentlySupportedOverHTTP3 // swift-nio-quic doesn't currently support mTLS. See https://github.com/apple/swift-nio-quic/issues/5. case mTLSNotCurrentlySupportedOverHTTP3 + case unixDomainSocketNotSupportedOverHTTP3 var description: String { switch self { @@ -37,6 +38,9 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case .mTLSNotCurrentlySupportedOverHTTP3: "Invalid configuration: mTLS is not currently supported over HTTP/3." + + case .unixDomainSocketNotSupportedOverHTTP3: + "Invalid configuration: unix domain socket bind targets are not supported over HTTP/3, which runs over QUIC/UDP." } } } diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift index 6508d70..55a0673 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift @@ -21,6 +21,7 @@ enum NIOHTTPServerSwiftConfigurationError: Error, CustomStringConvertible { case trustRootsSourceAndVerificationCallbackMismatch case singularAndPluralBindTargetsProvided case bindTargetsHostsAndPortsLengthMismatch + case hostPortAndSocketPathProvided var description: String { switch self { @@ -38,6 +39,9 @@ enum NIOHTTPServerSwiftConfigurationError: Error, CustomStringConvertible { case .bindTargetsHostsAndPortsLengthMismatch: "Invalid configuration: 'bindTargets.hosts' and 'bindTargets.ports' must have the same number of elements." + + case .hostPortAndSocketPathProvided: + "Invalid configuration: a bind target has both 'host'/'port' and 'socketPath' set. Use either a host and port, or a unix domain socket path, not both." } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 57a98b2..70afb6a 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -20,6 +20,7 @@ import NIOHTTPTypes import NIOHTTPTypesHTTP1 import NIOPosix import NIOSSL +import System @available(anyAppleOS 26.0, *) extension NIOHTTPServer { @@ -121,10 +122,9 @@ extension NIOHTTPServer { do { for bindTarget in bindTargets { + let serverQuiescingHelper = ServerQuiescingHelper(group: self.eventLoopGroup) switch bindTarget.backing { case .hostAndPort(let host, let port): - let serverQuiescingHelper = ServerQuiescingHelper(group: self.eventLoopGroup) - let serverChannel = try await bootstrap.serverChannelInitializer { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( @@ -148,6 +148,30 @@ extension NIOHTTPServer { ) } serverChannels.append((serverChannel, serverQuiescingHelper)) + case .unixDomainSocket(let path): + let serverChannel = try await bootstrap.serverChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + serverQuiescingHelper.makeServerChannelHandler(channel: channel) + ) + + if let maxConnections = self.configuration.maxConnections { + try channel.pipeline.syncOperations.addHandler( + ConnectionLimitHandler(maxConnections: maxConnections) + ) + } + } + }.bind(unixDomainSocketPath: path.string) { channel in + self.setupHTTP1_1Connection( + channel: channel, + asyncChannelConfiguration: .init( + backPressureStrategy: .init(self.configuration.backpressureStrategy), + isOutboundHalfClosureEnabled: true + ), + isSecure: false + ) + } + serverChannels.append((serverChannel, serverQuiescingHelper)) } } } catch { diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift index b531fc8..c4c2734 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift @@ -145,6 +145,10 @@ extension NIOHTTPServer { } serverChannels.append((quicChannel, multiplexer)) + + case .unixDomainSocket(_): + // Rejected during configuration validation; see NIOHTTPServerConfiguration.init. + preconditionFailure("Unix domain sockets are not supported for HTTP/3") } } } catch { diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index c57cc5b..f28aaa8 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -15,22 +15,26 @@ import NIOConcurrencyHelpers import NIOCore import NIOPosix +import System enum ListeningAddressError: CustomStringConvertible, Error { - case addressOrPortNotAvailable - case unsupportedAddressType + case addressNotAvailable + case portNotAvailable case serverClosed + case pathnameNotAvailable var description: String { switch self { - case .addressOrPortNotAvailable: - return "Unable to retrieve the bound address or port from the underlying socket" - case .unsupportedAddressType: - return "Unsupported address type: only IPv4 and IPv6 are supported" + case .addressNotAvailable: + return "Unable to retrieve the bound address from the underlying socket" + case .portNotAvailable: + return "Unable to retrieve the bound port from the underlying socket" case .serverClosed: return """ There is no listening address bound for this server: there may have been an error which caused the server to close, or it may have shut down. """ + case .pathnameNotAvailable: + return "Unable to retrieve the unix domain socket path from the underlying socket" } } } @@ -134,19 +138,37 @@ extension NIOHTTPServer { @available(anyAppleOS 26.0, *) extension NIOHTTPServer.SocketAddress { init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { - guard let address, let port = address.port else { - throw ListeningAddressError.addressOrPortNotAvailable + guard let address else { + throw .addressNotAvailable + } + + var port: Int { + get throws(ListeningAddressError) { + guard let port = address.port else { + throw .portNotAvailable + } + return port + } + } + + var pathname: String { + get throws(ListeningAddressError) { + guard let pathname = address.pathname else { + throw .pathnameNotAvailable + } + return pathname + } } switch address { case .v4(let ipv4Address): - self.init(base: .ipv4(.init(host: ipv4Address.host, port: port))) + try self.init(base: .ipv4(.init(host: ipv4Address.host, port: port))) case .v6(let ipv6Address): - self.init(base: .ipv6(.init(host: ipv6Address.host, port: port))) + try self.init(base: .ipv6(.init(host: ipv6Address.host, port: port))) - case .unixDomainSocket: - throw ListeningAddressError.unsupportedAddressType + case .unixDomainSocket(_): + try self.init(base: .unixDomainSocket(path: pathname)) } } } @@ -154,19 +176,38 @@ extension NIOHTTPServer.SocketAddress { @available(anyAppleOS 26.0, *) extension NIOHTTPServerConfiguration.BindTarget { init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { - guard let address, let port = address.port else { - throw ListeningAddressError.addressOrPortNotAvailable + guard let address else { + throw .addressNotAvailable + } + + var port: Int { + get throws(ListeningAddressError) { + guard let port = address.port else { + throw .portNotAvailable + } + return port + } + } + + var filePath: FilePath { + get throws(ListeningAddressError) { + guard let pathname = address.pathname else { + throw .pathnameNotAvailable + } + let filePath = FilePath(pathname) + return filePath + } } switch address { case .v4(let ipv4Address): - self.init(backing: .hostAndPort(host: ipv4Address.host, port: port)) + try self.init(backing: .hostAndPort(host: ipv4Address.host, port: port)) case .v6(let ipv6Address): - self.init(backing: .hostAndPort(host: ipv6Address.host, port: port)) + try self.init(backing: .hostAndPort(host: ipv6Address.host, port: port)) - case .unixDomainSocket: - throw ListeningAddressError.unsupportedAddressType + case .unixDomainSocket(_): + try self.init(backing: .unixDomainSocket(path: filePath)) } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 5a5bbc4..4395499 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -26,6 +26,7 @@ import NIOPosix import NIOSSL import NIOTLS import X509 +import System @available(anyAppleOS 26.0, *) extension NIOHTTPServer { @@ -223,10 +224,9 @@ extension NIOHTTPServer { var serverChannels = [(NIOAsyncChannel, Never>, ServerQuiescingHelper)]() do { for bindTarget in bindTargets { + let serverQuiescingHelper = ServerQuiescingHelper(group: self.eventLoopGroup) switch bindTarget.backing { case .hostAndPort(let host, let port): - let serverQuiescingHelper = ServerQuiescingHelper(group: self.eventLoopGroup) - let serverChannel = try await bootstrap.serverChannelInitializer { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( @@ -247,6 +247,27 @@ extension NIOHTTPServer { ) } serverChannels.append((serverChannel, serverQuiescingHelper)) + case .unixDomainSocket(let path): + let serverChannel = try await bootstrap.serverChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + serverQuiescingHelper.makeServerChannelHandler(channel: channel) + ) + + if let maxConnections = self.configuration.maxConnections { + try channel.pipeline.syncOperations.addHandler( + ConnectionLimitHandler(maxConnections: maxConnections) + ) + } + } + }.bind(unixDomainSocketPath: path.string) { channel in + self.setupSecureUpgradeConnectionChildChannel( + channel: channel, + supportedHTTPVersions: supportedHTTPVersions, + sslContext: sslContext + ) + } + serverChannels.append((serverChannel, serverQuiescingHelper)) } } } catch { diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 37c0bf2..b508dca 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -30,6 +30,7 @@ import ServiceLifecycle import SwiftASN1 import Synchronization import X509 +import System /// A generic HTTP server that can handle incoming HTTP requests. /// @@ -165,6 +166,10 @@ public struct NIOHTTPServer: HTTPServer { let serverChannels = try await self.makeServerChannels() + // Remove the socket files for any UDS bind targets so their paths are freed for the next run. + // Registered only after all binds succeeded, so every path is one we created. + defer { await self.removeUNIXDomainSocketFiles() } + return try await withTaskCancellationHandler { try await withGracefulShutdownHandler { try await self._serve(serverChannels: serverChannels, connectionHandler: connectionHandler) @@ -435,6 +440,23 @@ public struct NIOHTTPServer: HTTPServer { } } } + + /// Removes the socket files backing any unix-domain-socket bind targets. + private func removeUNIXDomainSocketFiles() async { + let fileIO = NonBlockingFileIO(threadPool: .singleton) + for bindTarget in self.configuration.bindTargets { + guard case .unixDomainSocket(let path) = bindTarget.backing else { continue } + do { + try await fileIO.unlink(path: path.string) + } catch { + self.logger.debug( + "Failed to remove unix domain socket file", + metadata: ["path": "\(path)", "error": "\(error)"] + ) + } + } + } + } @available(anyAppleOS 26.0, *) diff --git a/Sources/NIOHTTPServer/SocketAddress.swift b/Sources/NIOHTTPServer/SocketAddress.swift index 3e2384d..d75177a 100644 --- a/Sources/NIOHTTPServer/SocketAddress.swift +++ b/Sources/NIOHTTPServer/SocketAddress.swift @@ -55,6 +55,7 @@ extension NIOHTTPServer { enum Base: Hashable, Sendable { case ipv4(IPv4) case ipv6(IPv6) + case unixDomainSocket(path: String) } let base: Base @@ -88,23 +89,38 @@ extension NIOHTTPServer { } /// The ``SocketAddress``'s host. - public var host: String { + public var host: String? { switch self.base { case .ipv4(let ipv4): return ipv4.host case .ipv6(let ipv6): return ipv6.host + case .unixDomainSocket(_): + return nil } } /// The ``SocketAddress``'s port. - public var port: Int { + public var port: Int? { switch self.base { case .ipv4(let ipv4): return ipv4.port - case .ipv6(let ipv6): return ipv6.port + case .unixDomainSocket(_): + return nil + } + } + + /// The ``SocketAddress``'s unix domain socket path. + public var unixDomainSocketPath: String? { + switch self.base { + case .ipv4(_): + return nil + case .ipv6(_): + return nil + case .unixDomainSocket(let path): + return path } } } diff --git a/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift b/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift index 26039d9..927c193 100644 --- a/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift @@ -98,5 +98,19 @@ struct HTTP3ConfigurationTests { } } } + + @Test("Unix domain socket bind target is rejected for HTTP/3") + @available(anyAppleOS 26.0, *) + func unixDomainSocketRejectedForHTTP3() { + #expect(throws: NIOHTTPServerConfigurationError.unixDomainSocketNotSupportedOverHTTP3) { + _ = try NIOHTTPServerConfiguration( + bindTarget: .unixDomainSocket(path: "/tmp/http3.sock"), + supportedHTTPVersions: [.http3(config: .defaults)], + transportSecurity: .tls( + credentials: .pemFile(certificateChainPath: "/cert.pem", privateKeyPath: "/key.pem") + ) + ) + } + } } #endif // HTTP3 diff --git a/Tests/NIOHTTPServerTests/HTTPServerTests.swift b/Tests/NIOHTTPServerTests/HTTPServerTests.swift index 1939375..99d3136 100644 --- a/Tests/NIOHTTPServerTests/HTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPServerTests.swift @@ -13,9 +13,11 @@ //===----------------------------------------------------------------------===// import BasicContainers +import Foundation import Logging import NIOHTTPServer import Testing +import System @Suite struct HTTPServerTests { @@ -60,4 +62,72 @@ struct HTTPServerTests { group.cancelAll() } } + + @Test("Unix domain socket file is removed on shutdown") + @available(anyAppleOS 26.0, *) + func testUnixDomainSocketFileRemovedOnShutdown() async throws { + // Keep the path short so it stays under the platform's `sun_path` limit (104 on Darwin, 108 on Linux), + // even on CI where the system temporary directory can be deep. + let socketPath = "/tmp/nio-http-server-uds-\(UUID().uuidString).sock" + // Guard against a leftover file from a previously crashed run, and clean up if this test fails early. + try? FileManager.default.removeItem(atPath: socketPath) + defer { try? FileManager.default.removeItem(atPath: socketPath) } + let filePath = FilePath(socketPath) + + let server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: try .init( + bindTarget: .unixDomainSocket(path: filePath), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + ) + + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve { request, context, reader, responseSender in + let responseWriter = try await responseSender.send(HTTPResponse(status: .ok)) + var buffer = UniqueArray() + try await responseWriter.finish(buffer: &buffer, finalElement: nil) + } + } + + // Wait until the server is bound; the socket file must exist while it is listening. + _ = try await server.listeningAddresses + #expect(FileManager.default.fileExists(atPath: socketPath)) + + // Shutting the server down should release the socket by removing its file. + group.cancelAll() + } + + // The task group only returns once `serve` has fully unwound, including its cleanup `defer`. + #expect(!FileManager.default.fileExists(atPath: socketPath)) + } + + @Test("Bind fails when the unix domain socket path is already occupied") + @available(anyAppleOS 26.0, *) + func testUnixDomainSocketBindFailsWhenPathExists() async throws { + let socketPath = "/tmp/nio-http-server-uds-\(UUID().uuidString).sock" + // Simulate a leftover/occupied socket by pre-creating a file at the path. + #expect(FileManager.default.createFile(atPath: socketPath, contents: nil)) + defer { try? FileManager.default.removeItem(atPath: socketPath) } + let filePath = FilePath(socketPath) + + let server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: try .init( + bindTarget: .unixDomainSocket(path: filePath), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + ) + + // Binding to an occupied path must fail rather than silently reusing or removing the file. + await #expect(throws: Error.self) { + try await server.serve { _, _, _, _ in } + } + + // The pre-existing file must be left untouched: cleanup only runs for sockets we bound ourselves. + #expect(FileManager.default.fileExists(atPath: socketPath)) + } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 5812317..4760b7a 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -165,7 +165,10 @@ struct NIOHTTPServiceLifecycleTests { let (clientConnectionChannel, alpnResultFuture) = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup).connect( - to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) + to: try .init( + ipAddress: try #require(serverAddress.host), + port: try #require(serverAddress.port) + ) ) { socketChannel in socketChannel.configureTestClientSSLPipeline(tlsConfig: tlsConfig).flatMap { socketChannel.configureTestSecureUpgradeClientPipeline().map { connectionChannel in diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 033c441..139843b 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -41,6 +41,8 @@ struct NIOHTTPServerSwiftConfigurationTests { case .hostAndPort(let host, let port): #expect(host == "localhost") #expect(port == 8080) + case .unixDomainSocket(let path): + Issue.record("Expected first bind target to be host/port, got unix domain socket path: \(path)") } } @@ -71,6 +73,36 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect("Missing required config value for key: port." == "\(configError)") } + + @Test("Valid unix domain socket path") + @available(anyAppleOS 26.0, *) + func testValidUnixDomainSocketConfig() throws { + let provider = InMemoryProvider(values: ["socketPath": "/tmp/test.sock"]) + + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let bindTarget = try NIOHTTPServerConfiguration.BindTarget(config: snapshot) + + switch bindTarget.backing { + case .unixDomainSocket(let path): + #expect(path == "/tmp/test.sock") + case .hostAndPort(let host, let port): + Issue.record("Expected a unix domain socket bind target, got host \(host) and port \(port) instead.") + } + } + + @Test("Init fails when both socketPath and host/port are provided") + @available(anyAppleOS 26.0, *) + func testSocketPathAndHostPortThrows() throws { + let provider = InMemoryProvider(values: ["socketPath": "/tmp/test.sock", "host": "localhost", "port": 8080]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + #expect(throws: NIOHTTPServerSwiftConfigurationError.hostPortAndSocketPathProvided) { + try NIOHTTPServerConfiguration.BindTarget(config: snapshot) + } + } } @Suite("Multiple bind targets via bindTargets") diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index e7509aa..a1e13a4 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -627,7 +627,9 @@ struct NIOHTTPServerTests { serverHandler: HTTPServerClosureRequestHandler { _, _, _, _ in }, body: { (addresses: [NIOHTTPServer.SocketAddress]) in #expect(addresses.count == 2) - #expect(addresses[0].port != addresses[1].port) + let firstPort = try #require(addresses[0].port) + let secondPort = try #require(addresses[1].port) + #expect(firstPort != secondPort) } ) } diff --git a/Tests/NIOHTTPServerTests/SocketAddressTests.swift b/Tests/NIOHTTPServerTests/SocketAddressTests.swift new file mode 100644 index 0000000..74ab9b1 --- /dev/null +++ b/Tests/NIOHTTPServerTests/SocketAddressTests.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import Testing + +@testable import NIOHTTPServer + +@Suite +struct SocketAddressTests { + @available(anyAppleOS 26.0, *) + @Test("A nil NIO address throws addressNotAvailable") + func testNilAddressThrows() throws { + #expect(throws: ListeningAddressError.addressNotAvailable) { + _ = try NIOHTTPServer.SocketAddress(nil as NIOCore.SocketAddress?) + } + } + + @available(anyAppleOS 26.0, *) + @Test("An IPv4 NIO address maps to the ipv4 base") + func testIPv4Address() throws { + // `makeAddressResolvingHost` populates the address' `host` field the way a real bound + // `localAddress` does; `SocketAddress(ipAddress:port:)` would leave `host` empty. + let nioAddress = try NIOCore.SocketAddress.makeAddressResolvingHost("127.0.0.1", port: 8080) + let address = try NIOHTTPServer.SocketAddress(nioAddress) + + let ipv4 = try #require(address.ipv4) + #expect(ipv4.host == "127.0.0.1") + #expect(ipv4.port == 8080) + + // Convenience accessors should agree with the concrete IPv4 value. + #expect(address.host == "127.0.0.1") + #expect(address.port == 8080) + + // It must not masquerade as any other address kind. + #expect(address.ipv6 == nil) + #expect(address.unixDomainSocketPath == nil) + } + + @available(anyAppleOS 26.0, *) + @Test("An IPv6 NIO address maps to the ipv6 base") + func testIPv6Address() throws { + let nioAddress = try NIOCore.SocketAddress.makeAddressResolvingHost("::1", port: 9090) + let address = try NIOHTTPServer.SocketAddress(nioAddress) + + let ipv6 = try #require(address.ipv6) + #expect(ipv6.host == "::1") + #expect(ipv6.port == 9090) + + #expect(address.host == "::1") + #expect(address.port == 9090) + + #expect(address.ipv4 == nil) + #expect(address.unixDomainSocketPath == nil) + } + + @available(anyAppleOS 26.0, *) + @Test("A unix domain socket NIO address maps to the unixDomainSocket base") + func testUnixDomainSocketAddress() throws { + let path = "/tmp/nio-http-server-socket-address-test.sock" + let nioAddress = try NIOCore.SocketAddress(unixDomainSocketPath: path) + let address = try NIOHTTPServer.SocketAddress(nioAddress) + + #expect(address.unixDomainSocketPath == path) + + // A UDS address exposes neither host nor port. + #expect(address.host == nil) + #expect(address.port == nil) + #expect(address.ipv4 == nil) + #expect(address.ipv6 == nil) + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift index a4dadf7..5b87944 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift @@ -19,6 +19,8 @@ import NIOHTTPTypes import NIOHTTPTypesHTTP1 import NIOPosix +@testable import NIOHTTPServer + @available(anyAppleOS 26.0, *) extension Channel { /// Adds HTTP/1.1 client handlers to the pipeline. @@ -56,7 +58,18 @@ extension ClientBootstrap { func connectToTestHTTP1Server( at serverAddress: NIOHTTPServer.SocketAddress ) async throws -> NIOAsyncChannel { - try await self.connect(to: try .init(ipAddress: serverAddress.host, port: serverAddress.port)) { channel in + let target: NIOCore.SocketAddress + + switch serverAddress.base { + case .ipv4(let address): + target = try NIOCore.SocketAddress(ipAddress: address.host, port: address.port) + case .ipv6(let address): + target = try NIOCore.SocketAddress(ipAddress: address.host, port: address.port) + case .unixDomainSocket(path: let path): + target = try NIOCore.SocketAddress(unixDomainSocketPath: path) + } + + return try await self.connect(to: target) { channel in channel.configureTestHTTP1ClientPipeline() } } diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift index 63c34a2..9c3d4a0 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift @@ -60,9 +60,18 @@ extension ClientBootstrap { at serverAddress: NIOHTTPServer.SocketAddress, tlsConfig: TLSConfiguration ) async throws -> NegotiatedClientConnection { - let clientNegotiatedChannel = try await self.connect( - to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) - ) { channel in + let target: NIOCore.SocketAddress + + switch serverAddress.base { + case .ipv4(let address): + target = try NIOCore.SocketAddress(ipAddress: address.host, port: address.port) + case .ipv6(let address): + target = try NIOCore.SocketAddress(ipAddress: address.host, port: address.port) + case .unixDomainSocket(path: let path): + target = try NIOCore.SocketAddress(unixDomainSocketPath: path) + } + + let clientNegotiatedChannel = try await self.connect(to: target) { channel in channel.configureTestClientSSLPipeline(tlsConfig: tlsConfig).flatMap { channel.configureTestSecureUpgradeClientPipeline() }