From b8a21a877666761705ccc98ffe79049c6c893e7f Mon Sep 17 00:00:00 2001 From: Oleksandr Zhurba Date: Mon, 13 Jul 2026 22:36:47 +0200 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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