From b8a21a877666761705ccc98ffe79049c6c893e7f Mon Sep 17 00:00:00 2001 From: Oleksandr Zhurba Date: Mon, 13 Jul 2026 22:36:47 +0200 Subject: [PATCH 1/7] Add unix domain socket support for bind targets Adds a `.unixDomainSocket(path:)` bind target so the server can listen on a UNIX domain socket in addition to host and port. - Add `BindTarget.unixDomainSocket(path:)` and a matching `SocketAddress` case - Bind via `ServerBootstrap.bind(unixDomainSocketPath:)` for both plaintext and secure-upgrade channels, and report the bound path from `listeningAddresses` - Remove the socket file on shutdown; fail the bind if the path is already occupied so a stale socket is never silently reused - Support a `socketPath` key in swift-configuration, mutually exclusive with `host`/`port` Resolves swift-server/swift-http-server#69 --- .../NIOHTTPServer+SwiftConfiguration.swift | 26 +++++-- .../NIOHTTPServerConfiguration.swift | 15 +++++ .../NIOHTTPServerConfigurationError.swift | 4 ++ ...NIOHTTPServerSwiftConfigurationError.swift | 4 ++ .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 27 +++++++- .../NIOHTTPServer+ListeningAddress.swift | 31 ++++++--- .../NIOHTTPServer+SecureUpgrade.swift | 24 ++++++- Sources/NIOHTTPServer/NIOHTTPServer.swift | 20 ++++++ Sources/NIOHTTPServer/SocketAddress.swift | 22 +++++- .../NIOHTTPServerTests/HTTPServerTests.swift | 67 +++++++++++++++++++ .../NIOHTTPServer+ServiceLifecycleTests.swift | 5 +- ...NIOHTTPServerSwiftConfigurationTests.swift | 32 +++++++++ .../NIOHTTPServerTests.swift | 4 +- Tests/NIOHTTPServerTests/TestError.swift | 1 + .../Utilities/NIOClient/NIOClient+HTTP1.swift | 11 ++- .../NIOClient/NIOClient+SecureUpgrade.swift | 13 +++- 16 files changed, 278 insertions(+), 28 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 807ef45..69f1263 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -93,7 +93,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 @@ -117,17 +118,30 @@ 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 + } + backing = .unixDomainSocket(path: socketPath) + } 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 2f462e3..c97aed5 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -29,6 +29,7 @@ public struct NIOHTTPServerConfiguration: Sendable { public struct BindTarget: Sendable { enum Backing { case hostAndPort(host: String, port: Int) + case unixDomainSocket(path: String) } let backing: Backing @@ -47,8 +48,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: String) -> Self { + Self(backing: .unixDomainSocket(path: path)) + } } + /// Configuration for transport security settings. /// /// Provides options for running the server with or without TLS encryption. diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift index 7f56f9c..4709d58 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift @@ -17,6 +17,7 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case noSupportedHTTPVersionsSpecified case incompatibleTransportSecurity case noBindTargetsSpecified + case hostPortAndSocketPathProvided var description: String { switch self { @@ -28,6 +29,9 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case .noBindTargetsSpecified: "Invalid configuration: at least one bind target must be specified." + + 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/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 494f140..7289554 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -119,10 +119,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( @@ -146,6 +145,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) { 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+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index b96de84..c7b5b11 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -20,6 +20,7 @@ enum ListeningAddressError: CustomStringConvertible, Error { case addressOrPortNotAvailable case unsupportedAddressType case serverClosed + case pathnameNotAvailable var description: String { switch self { @@ -31,6 +32,8 @@ enum ListeningAddressError: CustomStringConvertible, Error { 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,17 +137,27 @@ 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 .addressOrPortNotAvailable } - switch address { - case .v4(let ipv4Address): - self.init(base: .ipv4(.init(host: ipv4Address.host, port: port))) - case .v6(let ipv6Address): - self.init(base: .ipv6(.init(host: ipv6Address.host, port: port))) - case .unixDomainSocket: - throw ListeningAddressError.unsupportedAddressType + let base: Base = switch (address, address.port, address.pathname) { + case (.v4(let ipv4Address), .some(let port), _): + .ipv4(.init(host: ipv4Address.host, port: port)) + + case (.v6(let ipv6Address), .some(let port), _): + .ipv6(.init(host: ipv6Address.host, port: port)) + + case (.unixDomainSocket, _, .some(let path)): + .unixDomainSocket(path: path) + + case (.v4, .none, _), (.v6, .none, _): + throw .addressOrPortNotAvailable + + case (.unixDomainSocket, _, .none): + throw .pathnameNotAvailable } + + self.init(base: base) } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index f5cf9e5..87d9da3 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -225,10 +225,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( @@ -249,6 +248,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) { 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 5de87a3..6bca66a 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -165,6 +165,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) @@ -372,6 +376,22 @@ 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) + } 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/HTTPServerTests.swift b/Tests/NIOHTTPServerTests/HTTPServerTests.swift index 1939375..372e7db 100644 --- a/Tests/NIOHTTPServerTests/HTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPServerTests.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import BasicContainers +import Foundation import Logging import NIOHTTPServer import Testing @@ -60,4 +61,70 @@ 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 server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: try .init( + bindTarget: .unixDomainSocket(path: socketPath), + 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 server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: try .init( + bindTarget: .unixDomainSocket(path: socketPath), + 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 904f3b1..d015c67 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/TestError.swift b/Tests/NIOHTTPServerTests/TestError.swift index fe19b49..e0a25d1 100644 --- a/Tests/NIOHTTPServerTests/TestError.swift +++ b/Tests/NIOHTTPServerTests/TestError.swift @@ -17,4 +17,5 @@ enum TestError: Error { case errorWhileReading case errorWhileWriting case intentional + case unsupportedAddress } diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift index a4dadf7..2ced0d4 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift @@ -56,7 +56,16 @@ 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 + if let path = serverAddress.unixDomainSocketPath { + target = try NIOCore.SocketAddress(unixDomainSocketPath: path) + } else if let host = serverAddress.host, let port = serverAddress.port { + target = try NIOCore.SocketAddress(ipAddress: host, port: port) + } else { + throw TestError.unsupportedAddress + } + + 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..afe3d64 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift @@ -60,9 +60,16 @@ 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 + if let path = serverAddress.unixDomainSocketPath { + target = try NIOCore.SocketAddress(unixDomainSocketPath: path) + } else if let host = serverAddress.host, let port = serverAddress.port { + target = try NIOCore.SocketAddress(ipAddress: host, port: port) + } else { + throw TestError.unsupportedAddress + } + + let clientNegotiatedChannel = try await self.connect(to: target) { channel in channel.configureTestClientSSLPipeline(tlsConfig: tlsConfig).flatMap { channel.configureTestSecureUpgradeClientPipeline() } From 07b668d83ec5a3a868c5dba2c6d9ebb1cdcc68dc Mon Sep 17 00:00:00 2001 From: Oleksandr Zhurba Date: Sat, 18 Jul 2026 14:51:04 +0200 Subject: [PATCH 2/7] Use FilePath instead of String for UDS bind target path Motivation: The UDS bind path was typed as a plain String. A System.FilePath gives it a strongly-typed, path-aware API at the configuration boundary. Modifications: - BindTarget.unixDomainSocket(path:) and its backing now take a FilePath - Convert to String only at the NIO edge (bind(unixDomainSocketPath:) and fileIO.unlink), where NIO still requires a String - public import System where FilePath appears in public API; plain import elsewhere - Update UDS tests to pass a FilePath Result: The UDS bind path is strongly typed; string literals still work via ExpressibleByStringLiteral, so call sites are unchanged. --- .../Configuration/NIOHTTPServer+SwiftConfiguration.swift | 4 +++- .../Configuration/NIOHTTPServerConfiguration.swift | 5 +++-- Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 3 ++- Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift | 3 ++- Sources/NIOHTTPServer/NIOHTTPServer.swift | 3 ++- Tests/NIOHTTPServerTests/HTTPServerTests.swift | 7 +++++-- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 69f1263..7cc292b 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 { @@ -133,7 +134,8 @@ extension NIOHTTPServerConfiguration.BindTarget { guard host == nil, port == nil else { throw NIOHTTPServerSwiftConfigurationError.hostPortAndSocketPathProvided } - backing = .unixDomainSocket(path: socketPath) + let filePath = FilePath(socketPath) + backing = .unixDomainSocket(path: filePath) } else { backing = .hostAndPort( host: try config.requiredString(forKey: "host"), diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index c97aed5..31068a8 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,7 +30,7 @@ public struct NIOHTTPServerConfiguration: Sendable { public struct BindTarget: Sendable { enum Backing { case hostAndPort(host: String, port: Int) - case unixDomainSocket(path: String) + case unixDomainSocket(path: FilePath) } let backing: Backing @@ -58,7 +59,7 @@ public struct NIOHTTPServerConfiguration: Sendable { /// ```swift /// let target = BindTarget.unixDomainSocket(path: "/tmp/server.sock") /// ``` - public static func unixDomainSocket(path: String) -> Self { + public static func unixDomainSocket(path: FilePath) -> Self { Self(backing: .unixDomainSocket(path: path)) } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 7289554..08f71b9 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 { @@ -158,7 +159,7 @@ extension NIOHTTPServer { ) } } - }.bind(unixDomainSocketPath: path) { channel in + }.bind(unixDomainSocketPath: path.string) { channel in self.setupHTTP1_1Connection( channel: channel, asyncChannelConfiguration: .init( diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 87d9da3..ff2062d 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 { @@ -261,7 +262,7 @@ extension NIOHTTPServer { ) } } - }.bind(unixDomainSocketPath: path) { channel in + }.bind(unixDomainSocketPath: path.string) { channel in self.setupSecureUpgradeConnectionChildChannel( channel: channel, supportedHTTPVersions: supportedHTTPVersions, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 6bca66a..c93163b 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. /// @@ -382,7 +383,7 @@ public struct NIOHTTPServer: HTTPServer { for bindTarget in self.configuration.bindTargets { guard case .unixDomainSocket(let path) = bindTarget.backing else { continue } do { - try await fileIO.unlink(path: path) + try await fileIO.unlink(path: path.string) } catch { self.logger.debug( "Failed to remove unix domain socket file", diff --git a/Tests/NIOHTTPServerTests/HTTPServerTests.swift b/Tests/NIOHTTPServerTests/HTTPServerTests.swift index 372e7db..99d3136 100644 --- a/Tests/NIOHTTPServerTests/HTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPServerTests.swift @@ -17,6 +17,7 @@ import Foundation import Logging import NIOHTTPServer import Testing +import System @Suite struct HTTPServerTests { @@ -71,11 +72,12 @@ struct HTTPServerTests { // 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: socketPath), + bindTarget: .unixDomainSocket(path: filePath), supportedHTTPVersions: [.http1_1], transportSecurity: .plaintext ) @@ -109,11 +111,12 @@ struct HTTPServerTests { // 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: socketPath), + bindTarget: .unixDomainSocket(path: filePath), supportedHTTPVersions: [.http1_1], transportSecurity: .plaintext ) From b65b66340d8d2f44f7ca05d97784b3f7f9f67a15 Mon Sep 17 00:00:00 2001 From: Oleksandr Zhurba Date: Sat, 18 Jul 2026 14:52:13 +0200 Subject: [PATCH 3/7] Refine listening SocketAddress construction and add tests Motivation: SocketAddress.init(_:) folded address, port and pathname lookups into a single tuple switch with coarse error cases, making failure reasons ambiguous and leaving the mapping untested. Modifications: - Replace addressOrPortNotAvailable/unsupportedAddressType with the more precise addressNotAvailable and portNotAvailable cases - Rebuild init(_:) around per-field throwing accessors and an exhaustive switch over the address kind - Switch the NIOClient test helper over SocketAddress.base instead of the optional host/port/path accessors - Add SocketAddressTests covering the IPv4, IPv6, UDS and nil mappings Result: Listening-address construction has precise error reasons and unit-test coverage; behaviour is unchanged. --- .../NIOHTTPServer+ListeningAddress.swift | 51 ++++++++---- .../SocketAddressTests.swift | 83 +++++++++++++++++++ .../Utilities/NIOClient/NIOClient+HTTP1.swift | 14 ++-- 3 files changed, 125 insertions(+), 23 deletions(-) create mode 100644 Tests/NIOHTTPServerTests/SocketAddressTests.swift diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index c7b5b11..2c44ec4 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -15,19 +15,20 @@ 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. @@ -138,24 +139,38 @@ extension NIOHTTPServer { extension NIOHTTPServer.SocketAddress { init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { guard let address else { - throw .addressOrPortNotAvailable + throw .addressNotAvailable } - let base: Base = switch (address, address.port, address.pathname) { - case (.v4(let ipv4Address), .some(let port), _): - .ipv4(.init(host: ipv4Address.host, port: port)) + 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 + } + } - case (.v6(let ipv6Address), .some(let port), _): - .ipv6(.init(host: ipv6Address.host, port: port)) + let base: NIOHTTPServer.SocketAddress.Base - case (.unixDomainSocket, _, .some(let path)): - .unixDomainSocket(path: path) + switch address { + case .v4(let ipv4Address): + base = try .ipv4(.init(host: ipv4Address.host, port: port)) - case (.v4, .none, _), (.v6, .none, _): - throw .addressOrPortNotAvailable + case .v6(let ipv6Address): + base = try .ipv6(.init(host: ipv6Address.host, port: port)) - case (.unixDomainSocket, _, .none): - throw .pathnameNotAvailable + case .unixDomainSocket(_): + base = try .unixDomainSocket(path: pathname) } self.init(base: base) 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 2ced0d4..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. @@ -57,12 +59,14 @@ extension ClientBootstrap { at serverAddress: NIOHTTPServer.SocketAddress ) async throws -> NIOAsyncChannel { let target: NIOCore.SocketAddress - if let path = serverAddress.unixDomainSocketPath { + + 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) - } else if let host = serverAddress.host, let port = serverAddress.port { - target = try NIOCore.SocketAddress(ipAddress: host, port: port) - } else { - throw TestError.unsupportedAddress } return try await self.connect(to: target) { channel in From 999a3df0e72b0f81beb5846accdf80255cee0d2e Mon Sep 17 00:00:00 2001 From: Oleksandr Zhurba Date: Sat, 18 Jul 2026 15:47:29 +0200 Subject: [PATCH 4/7] Mirror exhaustive SocketAddress switch in secure-upgrade test client Switch connectToTestSecureUpgradeHTTPServer over SocketAddress.base like the HTTP/1.1 helper, and drop the now-unused TestError.unsupportedAddress. --- Tests/NIOHTTPServerTests/TestError.swift | 1 - .../NIOClient/NIOClient+SecureUpgrade.swift | 12 +++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Tests/NIOHTTPServerTests/TestError.swift b/Tests/NIOHTTPServerTests/TestError.swift index e0a25d1..fe19b49 100644 --- a/Tests/NIOHTTPServerTests/TestError.swift +++ b/Tests/NIOHTTPServerTests/TestError.swift @@ -17,5 +17,4 @@ enum TestError: Error { case errorWhileReading case errorWhileWriting case intentional - case unsupportedAddress } diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift index afe3d64..9c3d4a0 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift @@ -61,12 +61,14 @@ extension ClientBootstrap { tlsConfig: TLSConfiguration ) async throws -> NegotiatedClientConnection { let target: NIOCore.SocketAddress - if let path = serverAddress.unixDomainSocketPath { + + 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) - } else if let host = serverAddress.host, let port = serverAddress.port { - target = try NIOCore.SocketAddress(ipAddress: host, port: port) - } else { - throw TestError.unsupportedAddress } let clientNegotiatedChannel = try await self.connect(to: target) { channel in From 7807c52948f76a013cfddc98a06302baeb2bc839 Mon Sep 17 00:00:00 2001 From: Oleksandr Zhurba Date: Thu, 24 Sep 2026 18:30:28 +0200 Subject: [PATCH 5/7] Add an end-to-end unix domain socket test and drop our unlink NIO unlinks the socket path when the listening socket closes, so our own unlink always ran too late: it logged a spurious failure and could delete a path another process had since bound Added a request-response test over a UDS, parameterised over plaintext HTTP/1.1, HTTP/1.1 and HTTP/2 --- Sources/NIOHTTPServer/NIOHTTPServer.swift | 24 +------ .../NIOHTTPServerTests/HTTPServerTests.swift | 7 +- .../NIOHTTPServerTests.swift | 65 +++++++++++++++++++ .../Utilities/Helpers.swift | 25 ++++--- 4 files changed, 89 insertions(+), 32 deletions(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index d2d7aaa..935df31 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -607,24 +607,6 @@ extension ServerBootstrap { @available(anyAppleOS 26.0, *) extension NIOHTTPServer { - /// Removes the socket file backing `address`, if `address` is a unix domain socket address. - /// - /// Failing to remove the file is not fatal to the server: it only leaves the path occupied, so the error is - /// logged and swallowed. - func removeSocketFile(boundTo address: NIOCore.SocketAddress) async { - guard let path = address.pathname else { return } - - do { - try await NonBlockingFileIO(threadPool: .singleton).unlink(path: path) - } catch { - self.logger.debug( - "Failed to remove unix domain socket file", - error: error, - metadata: ["path": "\(path)"] - ) - } - } - /// Awaits the next address from `iterator`. func nextBoundAddress( from iterator: inout sending AsyncThrowingStream.AsyncIterator @@ -640,6 +622,8 @@ extension NIOHTTPServer { /// /// - Note: The bind address is yielded to the provided `addressContinuation` immediately after the TCP socket has /// been bound. + /// - Note: For a unix domain socket address, closing the socket also removes the socket file: `ServerSocket` + /// cleans the path up on close, so the path is free for the next bind without this server unlinking it. func withTCPChannel( address: NIOCore.SocketAddress, addressContinuation: AsyncThrowingStream.Continuation, @@ -659,10 +643,6 @@ extension NIOHTTPServer { throw error } - // Binding a unix domain socket creates the socket file: remove it once this listener is done, so the path is - // free for the next run. Registered only after the bind succeeded, so the file is one this server created. - defer { await self.removeSocketFile(boundTo: address) } - try await withTaskCancellationHandler { try await withGracefulShutdownHandler { if Task.isCancelled || Task.isShuttingDownGracefully { diff --git a/Tests/NIOHTTPServerTests/HTTPServerTests.swift b/Tests/NIOHTTPServerTests/HTTPServerTests.swift index 0621fb4..f6cefd3 100644 --- a/Tests/NIOHTTPServerTests/HTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPServerTests.swift @@ -99,7 +99,9 @@ struct HTTPServerTests { group.cancelAll() } - // The task group only returns once `serve` has fully unwound, including its cleanup `defer`. + // The task group only returns once `serve` has fully unwound, so every listening socket has been closed. + // Closing a bound unix domain socket is what removes the socket file: NIO's `ServerSocket` unlinks the path + // on close, so the server does not have to unlink it itself. #expect(!FileManager.default.fileExists(atPath: socketPath)) } @@ -126,7 +128,8 @@ struct HTTPServerTests { try await server.serve { _, _, _, _ in } } - // The pre-existing file must be left untouched: cleanup only runs for sockets we bound ourselves. + // The pre-existing file must be left untouched: the bind never succeeded, so there is no socket of ours to + // close, and nothing removes a path this server did not bind. #expect(FileManager.default.fileExists(atPath: socketPath)) } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index a39399d..b6bb874 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import BasicContainers +import Foundation import Logging import NIOCore import NIOEmbedded @@ -25,6 +26,7 @@ import NIOPosix import NIOSSL import SwiftASN1 import Synchronization +import System import Testing import X509 @@ -118,6 +120,69 @@ struct NIOHTTPServerTests { } } + /// HTTP/3 is absent from the arguments on purpose: it runs over QUIC/UDP, so a unix domain socket bind target is + /// rejected when the configuration is created (see `HTTP3ConfigurationTests`). + @available(anyAppleOS 26.0, *) + @Test( + "Request-response over a unix domain socket", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1, .http2] + ) + func testRequestResponseOverUnixDomainSocket(httpVersion: NIOHTTPServer.HTTPVersion) 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" + defer { try? FileManager.default.removeItem(atPath: socketPath) } + + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger, + bindTargetsOverride: [.unixDomainSocket(path: FilePath(socketPath))] + ) + + try await confirmation { responseReceived in + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, + server: server, + serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseWriter in + #expect(request == .makeRequest(method: .post, for: httpVersion)) + + // The connection is carried by a socket with no host or port, so the addresses report a path + // instead: the local one is the socket we bound, the peer's is unnamed. + let localAddress = try #require(requestContext.connectionContext.localAddress) + #expect(localAddress.unixDomainSocketPath == socketPath) + #expect(localAddress.host == nil) + #expect(localAddress.port == nil) + + try await TestHelpers.echoResponse( + readUpTo: ByteBuffer.testData.readableBytes, + reader: reader, + sender: responseWriter + ) + } + ) { serverAddress, inbound, outbound in + #expect(serverAddress.unixDomainSocketPath == socketPath) + + try await outbound.write(.testHead(method: .post, for: httpVersion)) + try await outbound.write(.testBody) + try await outbound.write(.testEnd) + + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.makeResponse(status: .ok, for: httpVersion)], + expectedBody: [.testData], + expectedTrailers: .testTrailer, + expectStreamEnd: httpVersion != .plaintextHTTP1_1 && httpVersion != .http1_1 + ) + + responseReceived() + } + } + + // The listener has gone away with the server task, so the socket file must not be left behind. + #expect(!FileManager.default.fileExists(atPath: socketPath)) + } + @available(anyAppleOS 26.0, *) @Test( "mTLS request-response with custom verification callback returning peer certificates", diff --git a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift index 333f24f..03be534 100644 --- a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift +++ b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift @@ -400,13 +400,16 @@ struct TestHelpers { extension TestHelpers { static func makeTLSServerConfiguration( supportedHTTPVersions: Set = [.http1_1, .http2], - concurrentListeners: Int = 1 + concurrentListeners: Int = 1, + bindTargetsOverride: [NIOHTTPServerConfiguration.BindTarget]? = nil ) throws -> (NIOHTTPServerConfiguration, String) { let (leafPath, caPath, privateKeyPath) = try TestCA.makeSelfSignedChainWithSAN().writeToDisk() - let bindTargets = (0.. Void)? = nil ) throws -> (NIOHTTPServer, TestClientConnection.Configuration) { - let bindTargets = (0.. Date: Fri, 2 Oct 2026 19:35:23 +0200 Subject: [PATCH 6/7] Use SystemPackage for FilePath and validate UDS bind targets in setters `import System` is Darwin-only; depend on swift-system and import SystemPackage so Linux builds. Also enforce the UDS/HTTP/3 rule when `bindTargets` or `supportedHTTPVersions` is set after creation, not only in init. --- Package.swift | 3 ++ ...IOHTTPServerConfiguration+Validation.swift | 15 ++++++++ .../NIOHTTPServerConfiguration.swift | 26 +++++++------- .../NIOHTTPServer+SwiftConfiguration.swift | 2 +- .../NIOHTTPServer+ListeningAddress.swift | 2 +- Sources/NIOHTTPServer/SocketAddress.swift | 2 +- .../HTTP3ConfigurationTests.swift | 34 +++++++++++++++++++ .../NIOHTTPServerTests/HTTPServerTests.swift | 2 +- .../NIOHTTPServerTests.swift | 2 +- 9 files changed, 70 insertions(+), 18 deletions(-) diff --git a/Package.swift b/Package.swift index 3b7b9de..7b399b1 100644 --- a/Package.swift +++ b/Package.swift @@ -109,6 +109,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.44.0"), .package(url: "https://github.com/apple/swift-configuration.git", from: "1.2.0", traits: []), .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.11.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.4.0"), ], targets: [ .target( @@ -177,6 +178,7 @@ let package = Package( ), .product(name: "NIOHTTP3", package: "swift-nio-http3", condition: .when(traits: ["HTTP3"])), .product(name: "HTTPAPIs", package: "swift-http-api-proposal"), + .product(name: "SystemPackage", package: "swift-system"), ], swiftSettings: extraSettings ), @@ -184,6 +186,7 @@ let package = Package( name: "NIOHTTPServerTests", dependencies: [ .product(name: "Logging", package: "swift-log"), + .product(name: "SystemPackage", package: "swift-system"), .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), "NIOHTTPServer", diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift index a799aec..7138be0 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift @@ -30,6 +30,21 @@ extension NIOHTTPServerConfiguration { self.sslContext = try self.makeSSLContext() } + /// Validates that every bind target can be served over the supported HTTP versions. + /// + /// HTTP/3 runs over QUIC/UDP, so it cannot be served over a unix domain socket. + func validateBindTargets() throws { + #if HTTP3 + guard self.supportedHTTPVersions.http3ConfigIfSupported != nil else { return } + + for bindTarget in self.bindTargets { + if case .unixDomainSocket = bindTarget.backing { + throw NIOHTTPServerConfigurationError.unixDomainSocketNotSupportedOverHTTP3 + } + } + #endif + } + /// Creates the `NIOSSLContext` used by the secure upgrade channel(s), or `nil` if the configuration does not /// specify a secure upgrade channel. private func makeSSLContext() throws -> NIOSSLContext? { diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index c8268fa..d6d282c 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -14,7 +14,7 @@ import NIOCore import NIOSSL -public import System +public import SystemPackage public import X509 #if HTTP3 @@ -305,12 +305,19 @@ public struct NIOHTTPServerConfiguration: Sendable { /// Network binding configuration specifying all addresses where the server should listen. /// - /// - Precondition: Must not be empty. + /// - Precondition: Must not be empty, and must not contain a unix domain socket target when + /// ``supportedHTTPVersions`` contains `.http3`. public var bindTargets: [BindTarget] { didSet { if self.bindTargets.isEmpty { preconditionFailure(NIOHTTPServerConfigurationError.noBindTargetsSpecified.description) } + + do { + try self.validateBindTargets() + } catch { + preconditionFailure("\(error)") + } } } @@ -340,6 +347,7 @@ public struct NIOHTTPServerConfiguration: Sendable { /// - When `supportedHTTPVersions` contains `.http2` and `.http3`, TLS credentials must be provided as PEM files /// on disk. Other credential sources are not supported. /// - `transportSecurity` can only be set to `.plaintext` when `supportedHTTPVersions == [.http1_1]`. + /// - `.http3` cannot be added while ``bindTargets`` contains a unix domain socket target. public var supportedHTTPVersions: Set { didSet { if self.supportedHTTPVersions.isEmpty { @@ -347,6 +355,7 @@ public struct NIOHTTPServerConfiguration: Sendable { } do { + try self.validateBindTargets() try self.validateTransportConfiguration() } catch { preconditionFailure("\(error)") @@ -420,17 +429,6 @@ 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 @@ -439,6 +437,8 @@ public struct NIOHTTPServerConfiguration: Sendable { self.connectionTimeouts = .defaults self.gracefulShutdown = .defaults + try self.validateBindTargets() + // Validate the compatibility of `supportedHTTPVersions` and `transportSecurity`. try self.validateTransportConfiguration() } diff --git a/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift index 4c70f76..0afd6f9 100644 --- a/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift @@ -19,7 +19,7 @@ import NIOCertificateReloading import NIOHTTP2 import SwiftASN1 public import X509 -import System +import SystemPackage @available(anyAppleOS 27.0, *) extension NIOHTTPServerConfiguration { diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index f30b2d6..81b54e8 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -15,7 +15,7 @@ import NIOConcurrencyHelpers import NIOCore import NIOPosix -import System +import SystemPackage enum ListeningAddressError: CustomStringConvertible, Error { case addressNotAvailable diff --git a/Sources/NIOHTTPServer/SocketAddress.swift b/Sources/NIOHTTPServer/SocketAddress.swift index 09c2d91..f2712f4 100644 --- a/Sources/NIOHTTPServer/SocketAddress.swift +++ b/Sources/NIOHTTPServer/SocketAddress.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import NIOCore -import System +import SystemPackage @available(anyAppleOS 27.0, *) extension NIOHTTPServer { diff --git a/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift b/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift index 3f0f1e5..54c303f 100644 --- a/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift @@ -91,5 +91,39 @@ struct HTTP3ConfigurationTests { ) } } + + // The setters below must enforce the same rule as `init`. Each test first builds the starting configuration + // outside the exit test, so the exit test can only pass because of the setter, not because the starting + // configuration was invalid. + + @Test("Adding HTTP/3 to a configuration with a unix domain socket bind target is a precondition failure") + @available(anyAppleOS 27.0, *) + func addingHTTP3ToUnixDomainSocketConfigurationFails() async throws { + _ = try TestHelpers.makeTLSServerConfiguration( + supportedHTTPVersions: [.http1_1, .http2], + bindTargetsOverride: [.unixDomainSocket(path: "/tmp/http3-setter.sock")] + ) + + await #expect(processExitsWith: .failure) { + var (configuration, _) = try TestHelpers.makeTLSServerConfiguration( + supportedHTTPVersions: [.http1_1, .http2], + bindTargetsOverride: [.unixDomainSocket(path: "/tmp/http3-setter.sock")] + ) + configuration.supportedHTTPVersions.insert(.http3(config: .defaults)) + } + } + + @Test("Adding a unix domain socket bind target to an HTTP/3 configuration is a precondition failure") + @available(anyAppleOS 27.0, *) + func addingUnixDomainSocketToHTTP3ConfigurationFails() async throws { + _ = try TestHelpers.makeTLSServerConfiguration(supportedHTTPVersions: [.http3(config: .defaults)]) + + await #expect(processExitsWith: .failure) { + var (configuration, _) = try TestHelpers.makeTLSServerConfiguration( + supportedHTTPVersions: [.http3(config: .defaults)] + ) + configuration.bindTargets = [.unixDomainSocket(path: "/tmp/http3-setter.sock")] + } + } } #endif // HTTP3 diff --git a/Tests/NIOHTTPServerTests/HTTPServerTests.swift b/Tests/NIOHTTPServerTests/HTTPServerTests.swift index 3d4945f..3c2b030 100644 --- a/Tests/NIOHTTPServerTests/HTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPServerTests.swift @@ -16,7 +16,7 @@ import BasicContainers import Foundation import Logging import NIOHTTPServer -import System +import SystemPackage import Testing @Suite diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index a7da817..d9625d8 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -26,7 +26,7 @@ import NIOPosix import NIOSSL import SwiftASN1 import Synchronization -import System +import SystemPackage import Testing import X509 From d01e530a7774240a0e9643386881451d0fff2bf2 Mon Sep 17 00:00:00 2001 From: Oleksandr Zhurba Date: Fri, 2 Oct 2026 20:15:30 +0200 Subject: [PATCH 7/7] Tighten UDS bind target tests, config parsing and docs --- .../NIOHTTPServerConfiguration.swift | 2 +- .../NIOHTTPServer+SwiftConfiguration.swift | 22 +++-- .../SwiftConfigurationIntegration.md | 19 +++- .../NIOHTTPServer/NIOHTTPServer+HTTP3.swift | 4 +- .../NIOHTTPServer+ListeningAddress.swift | 89 ++++--------------- Sources/NIOHTTPServer/NIOHTTPServer.swift | 6 +- .../NIOHTTPServerTests/HTTPServerTests.swift | 24 +++-- ...NIOHTTPServerSwiftConfigurationTests.swift | 33 +++++++ .../SocketAddressTests.swift | 4 +- 9 files changed, 103 insertions(+), 100 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index d6d282c..5fa2cb0 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -29,7 +29,7 @@ import NIOQUIC public struct NIOHTTPServerConfiguration: Sendable { /// Specifies where the server should bind and listen for incoming connections. /// - /// Currently supports binding to a specific host and port combination. + /// Supports binding to a host and port combination, or to a unix domain socket path. /// Additional binding targets may be added in the future. public struct BindTarget: Sendable { enum Backing { diff --git a/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift index 0afd6f9..083aed9 100644 --- a/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift @@ -30,12 +30,12 @@ extension NIOHTTPServerConfiguration { /// ``NIOHTTPServerConfiguration`` is comprised of four types. Provide configuration for each type under the /// specified key: /// - /// - **`"bindTarget"`**: A single address and port to bind to (see ``BindTarget/init(config:)``). Use this when - /// binding to exactly one address. + /// - **`"bindTarget"`**: A single host and port, or a single unix domain socket path, to bind to (see + /// ``BindTarget/init(config:)``). Use this when binding to exactly one address. /// /// - **`"bindTargets"`**: Multiple addresses to bind to, provided as parallel string and int arrays under - /// `bindTargets.hosts` and `bindTargets.ports`. Exactly one of `"bindTarget"` or `"bindTargets"` must be - /// provided. + /// `bindTargets.hosts` and `bindTargets.ports`. Unix domain sockets are not supported here; use `"bindTarget"` + /// for a socket path. Exactly one of `"bindTarget"` or `"bindTargets"` must be provided. /// /// - **`"http"`**: Supported HTTP versions and per-version settings: /// - `"versions"` (required string array): the HTTP versions to support (permitted values: `"http1_1"`, @@ -71,6 +71,8 @@ extension NIOHTTPServerConfiguration { /// `"bindTarget"` and `"bindTargets"` are provided. /// - Throws `NIOHTTPServerSwiftConfigurationError/bindTargetsHostsAndPortsLengthMismatch` if /// `bindTargets.hosts` and `bindTargets.ports` have different lengths. + /// - Throws `NIOHTTPServerSwiftConfigurationError/hostPortAndSocketPathProvided` if `bindTarget.socketPath` + /// is provided together with `bindTarget.host` or `bindTarget.port`. public init( config: ConfigReader, customCertificateVerificationCallback: ( @@ -136,14 +138,20 @@ extension NIOHTTPServerConfiguration.BindTarget { /// - `socketPath` (string): A unix domain socket path to bind to. Mutually exclusive with `host`/`port`. /// /// - Parameter config: The configuration reader. + /// - Throws: `NIOHTTPServerSwiftConfigurationError/hostPortAndSocketPathProvided` if `socketPath` is provided + /// together with `host` or `port`, even when that `host` or `port` is not a valid value. public init(config: ConfigSnapshotReader) throws { - 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 { + // `int(forKey:)` reads a port that is not a valid integer (e.g. `"http"`) as absent, so also read it as a + // string: a malformed host or port still conflicts with `socketPath` instead of being silently ignored. + let hasHostOrPort = + config.string(forKey: "host") != nil + || config.int(forKey: "port") != nil + || config.string(forKey: "port") != nil + guard !hasHostOrPort else { throw NIOHTTPServerSwiftConfigurationError.hostPortAndSocketPathProvided } let filePath = FilePath(socketPath) diff --git a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md index 384dcc1..4e15145 100644 --- a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md +++ b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md @@ -33,7 +33,8 @@ let serverConfiguration = try NIOHTTPServerConfiguration(config: config) its respective key prefix. > Important: Exactly one of `bindTarget` (singular, for a single address) or `bindTargets` (plural, for multiple -> addresses) must be provided. Providing both results in an error. +> addresses) must be provided. Providing both results in an error. A unix domain socket can only be configured +> through `bindTarget.socketPath`, and is not supported over HTTP/3. > Important: HTTP/2 and HTTP/3 cannot be served over plaintext. If `"http2"` or `"http3"` is included in > `http.versions`, the transport security must be set to `"tls"` or `"mTLS"`. Additionally, HTTP/3 requires PEM file @@ -42,8 +43,9 @@ its respective key prefix. | Prefix | Configuration Key | Type | Required/Optional | Default | |-------------------------------------|-------------------------------------------|-------------------|-------------------------------------------------------------------------------------------------------------------------------|----------------| -| `bindTarget` | `host` | `string` | Required when binding to a single address (mutually exclusive with `bindTargets`) | - | -| | `port` | `int` | Required when binding to a single address (mutually exclusive with `bindTargets`) | - | +| `bindTarget` | `host` | `string` | Required when binding to a single host and port (mutually exclusive with `bindTargets` and `socketPath`) | - | +| | `port` | `int` | Required when binding to a single host and port (mutually exclusive with `bindTargets` and `socketPath`) | - | +| | `socketPath` | `string` | Required when binding to a unix domain socket (mutually exclusive with `host`/`port` and `bindTargets`) | - | | `bindTargets` | `hosts` | `string array` | Required when binding to multiple addresses (mutually exclusive with `bindTarget`); must match length of `ports` | - | | | `ports` | `int array` | Required when binding to multiple addresses (mutually exclusive with `bindTarget`); must match length of `hosts` | - | | `http` | `versions` | `string array` | Required (permitted values: `"http1_1"`, `"http2"`, `"http3"`) | - | @@ -180,6 +182,17 @@ of the same length: } ``` +To bind to a unix domain socket, give `bindTarget` a `socketPath` instead of a `host` and `port`: + +```json +{ + "bindTarget": { + "socketPath": "/run/server.sock" + }, + // ...rest of the configuration +} +``` + ### Custom certificate verification When using mTLS, you can provide a custom certificate verification callback instead of relying on trust roots. To do diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift index 4c69947..f7a0e37 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift @@ -292,8 +292,8 @@ extension NIOHTTPServer { } guard let localAddress = quicChannel.localAddress else { - addressContinuation.finish(throwing: ListeningAddressError.addressNotAvailable) - throw ListeningAddressError.addressNotAvailable + addressContinuation.finish(throwing: ListeningAddressError.addressOrPortNotAvailable) + throw ListeningAddressError.addressOrPortNotAvailable } // A socket does not join its real reuseport group until it is bound, so this comes after the bind. diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index 81b54e8..7d94777 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -15,26 +15,22 @@ import NIOConcurrencyHelpers import NIOCore import NIOPosix -import SystemPackage enum ListeningAddressError: CustomStringConvertible, Error { - case addressNotAvailable - case portNotAvailable - case serverClosed + case addressOrPortNotAvailable case pathnameNotAvailable + case serverClosed var description: String { switch self { - 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 .addressOrPortNotAvailable: + return "Unable to retrieve the bound address or port from the underlying socket" + case .pathnameNotAvailable: + return "Unable to retrieve the unix domain socket path 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" } } } @@ -138,76 +134,21 @@ extension NIOHTTPServer { @available(anyAppleOS 27.0, *) extension NIOHTTPServer.SocketAddress { init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { - 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): - try self.init(base: .ipv4(.init(host: ipv4Address.host, port: port))) + guard let port = address?.port else { throw .addressOrPortNotAvailable } + self.init(base: .ipv4(.init(host: ipv4Address.host, port: port))) case .v6(let ipv6Address): - try self.init(base: .ipv6(.init(host: ipv6Address.host, port: port))) - - case .unixDomainSocket(_): - try self.init(base: .unixDomainSocket(path: pathname)) - } - } -} - -@available(anyAppleOS 27.0, *) -extension NIOHTTPServerConfiguration.BindTarget { - init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { - 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 - } - } + guard let port = address?.port else { throw .addressOrPortNotAvailable } + self.init(base: .ipv6(.init(host: ipv6Address.host, port: port))) - switch address { - case .v4(let ipv4Address): - try self.init(backing: .hostAndPort(host: ipv4Address.host, port: port)) - - case .v6(let ipv6Address): - try self.init(backing: .hostAndPort(host: ipv6Address.host, port: port)) + case .unixDomainSocket: + guard let pathname = address?.pathname else { throw .pathnameNotAvailable } + self.init(base: .unixDomainSocket(path: pathname)) - case .unixDomainSocket(_): - try self.init(backing: .unixDomainSocket(path: filePath)) + case nil: + throw .addressOrPortNotAvailable } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 9e14b00..1a9ae86 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -608,7 +608,7 @@ extension NIOHTTPServer { from iterator: inout sending AsyncThrowingStream.AsyncIterator ) async throws -> NIOCore.SocketAddress { guard let address = try await iterator.next() else { - throw ListeningAddressError.addressNotAvailable + throw ListeningAddressError.addressOrPortNotAvailable } return address } @@ -648,8 +648,8 @@ extension NIOHTTPServer { } guard let localAddress = serverChannel.channel.localAddress else { - addressContinuation.finish(throwing: ListeningAddressError.addressNotAvailable) - throw ListeningAddressError.addressNotAvailable + addressContinuation.finish(throwing: ListeningAddressError.addressOrPortNotAvailable) + throw ListeningAddressError.addressOrPortNotAvailable } addressContinuation.yield(localAddress) diff --git a/Tests/NIOHTTPServerTests/HTTPServerTests.swift b/Tests/NIOHTTPServerTests/HTTPServerTests.swift index 3c2b030..d19ba0a 100644 --- a/Tests/NIOHTTPServerTests/HTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPServerTests.swift @@ -15,7 +15,9 @@ import BasicContainers import Foundation import Logging +import NIOCore import NIOHTTPServer +import NIOPosix import SystemPackage import Testing @@ -105,31 +107,37 @@ struct HTTPServerTests { #expect(!FileManager.default.fileExists(atPath: socketPath)) } - @Test("Bind fails when the unix domain socket path is already occupied") + @Test("Bind fails when the unix domain socket path is already occupied", .timeLimit(.minutes(1))) @available(anyAppleOS 27.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) + + // Occupy the path with a live unix domain socket listener. A socket, unlike a regular file, is what a cleanup + // of existing socket files would remove, so this also guards against the bind ever taking the path over. + let occupyingChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .bind(unixDomainSocketPath: socketPath) + .get() let server = NIOHTTPServer( logger: Logger(label: "Test"), configuration: try .init( - bindTarget: .unixDomainSocket(path: filePath), + bindTarget: .unixDomainSocket(path: FilePath(socketPath)), 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) { + // Binding to an occupied path must fail with EADDRINUSE rather than reusing or removing the socket. + let error = await #expect(throws: IOError.self) { try await server.serve { _, _, _, _ in } } + #expect(error?.errnoCode == EADDRINUSE) - // The pre-existing file must be left untouched: the bind never succeeded, so there is no socket of ours to + // The occupying socket must be left untouched: the bind never succeeded, so there is no socket of ours to // close, and nothing removes a path this server did not bind. #expect(FileManager.default.fileExists(atPath: socketPath)) + + try await occupyingChannel.close() } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index f3e7827..2d1af49 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -103,6 +103,19 @@ struct NIOHTTPServerSwiftConfigurationTests { try NIOHTTPServerConfiguration.BindTarget(config: snapshot) } } + + @Test("Init fails when socketPath is provided with a port that is not an integer") + @available(anyAppleOS 27.0, *) + func testSocketPathAndMalformedPortThrows() throws { + // A malformed port must still conflict with `socketPath`, rather than being read as absent. + let provider = InMemoryProvider(values: ["socketPath": "/tmp/test.sock", "port": "http"]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + #expect(throws: NIOHTTPServerSwiftConfigurationError.hostPortAndSocketPathProvided) { + try NIOHTTPServerConfiguration.BindTarget(config: snapshot) + } + } } @Suite("Multiple bind targets via bindTargets") @@ -182,6 +195,26 @@ struct NIOHTTPServerSwiftConfigurationTests { } } + @Test("Providing a singular socket path and plural bind targets throws an error") + @available(anyAppleOS 27.0, *) + func testSingularSocketPathAndPluralThrows() throws { + // A socket path alone, without a host or port, must still count as a singular bind target. + let provider = InMemoryProvider( + values: [ + "bindTarget.socketPath": "/tmp/test.sock", + "bindTargets.hosts": .init(.stringArray(["127.0.0.1"]), isSecret: false), + "bindTargets.ports": .init(.intArray([8443]), isSecret: false), + "http.versions": .init(.stringArray(["http1_1"]), isSecret: false), + "transportSecurity.mode": "plaintext", + ] + ) + let config = ConfigReader(provider: provider) + + #expect(throws: NIOHTTPServerSwiftConfigurationError.singularAndPluralBindTargetsProvided) { + _ = try NIOHTTPServerConfiguration(config: config) + } + } + @Test("Mismatched hosts and ports lengths throws an error") @available(anyAppleOS 27.0, *) func testMismatchedHostsAndPortsLengthsThrows() throws { diff --git a/Tests/NIOHTTPServerTests/SocketAddressTests.swift b/Tests/NIOHTTPServerTests/SocketAddressTests.swift index 0b722aa..2788f7e 100644 --- a/Tests/NIOHTTPServerTests/SocketAddressTests.swift +++ b/Tests/NIOHTTPServerTests/SocketAddressTests.swift @@ -20,9 +20,9 @@ import Testing @Suite struct SocketAddressTests { @available(anyAppleOS 27.0, *) - @Test("A nil NIO address throws addressNotAvailable") + @Test("A nil NIO address throws addressOrPortNotAvailable") func testNilAddressThrows() throws { - #expect(throws: ListeningAddressError.addressNotAvailable) { + #expect(throws: ListeningAddressError.addressOrPortNotAvailable) { _ = try NIOHTTPServer.SocketAddress(nil as NIOCore.SocketAddress?) } }