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 9a3f60f..5fa2cb0 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -14,6 +14,7 @@ import NIOCore import NIOSSL +public import SystemPackage public import X509 #if HTTP3 @@ -28,11 +29,12 @@ 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 { case hostAndPort(host: String, port: Int) + case unixDomainSocket(path: FilePath) } let backing: Backing @@ -51,6 +53,19 @@ public struct NIOHTTPServerConfiguration: Sendable { public static func hostAndPort(host: String, port: Int) -> Self { Self(backing: .hostAndPort(host: host, port: port)) } + + /// Creates a bind target for a unix domain socket. + /// + /// - Parameter path: The file system path to bind the unix domain socket to (e.g., "/tmp/server.sock") + /// - Returns: A configured `BindTarget` instance + /// + /// ## Example + /// ```swift + /// let target = BindTarget.unixDomainSocket(path: "/tmp/server.sock") + /// ``` + public static func unixDomainSocket(path: FilePath) -> Self { + Self(backing: .unixDomainSocket(path: path)) + } } /// Configuration for transport security settings. @@ -290,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)") + } } } @@ -325,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 { @@ -332,6 +355,7 @@ public struct NIOHTTPServerConfiguration: Sendable { } do { + try self.validateBindTargets() try self.validateTransportConfiguration() } catch { preconditionFailure("\(error)") @@ -413,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/NIOHTTPServerConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift index 7c48d77..46cc31f 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift @@ -22,6 +22,7 @@ enum NIOHTTPServerConfigurationError: Error, Equatable, CustomStringConvertible case pemRawPublicKeysNotCurrentlySupported // swift-nio-quic doesn't currently support mTLS. See https://github.com/apple/swift-nio-quic/issues/5. case mTLSNotCurrentlySupportedOverHTTP3 + case unixDomainSocketNotSupportedOverHTTP3 case datagramSocketGroupCountOutOfRange(requested: Int, available: Int) var description: String { @@ -47,6 +48,9 @@ enum NIOHTTPServerConfigurationError: Error, Equatable, CustomStringConvertible case .mTLSNotCurrentlySupportedOverHTTP3: "Invalid configuration: mTLS is not currently supported over HTTP/3." + case .unixDomainSocketNotSupportedOverHTTP3: + "Invalid configuration: unix domain socket bind targets are not supported over HTTP/3, which runs over QUIC/UDP." + case .datagramSocketGroupCountOutOfRange(let requested, let available): "Invalid configuration: the datagram socket group asked for \(requested) sockets, but \(available) event loops are available." } 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/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift index 83b6936..083aed9 100644 --- a/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift @@ -19,6 +19,7 @@ import NIOCertificateReloading import NIOHTTP2 import SwiftASN1 public import X509 +import SystemPackage @available(anyAppleOS 27.0, *) extension NIOHTTPServerConfiguration { @@ -29,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"`, @@ -70,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: ( @@ -105,7 +108,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 @@ -129,17 +133,37 @@ 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. + /// - 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 { - self.init( - backing: .hostAndPort( + let socketPath = config.string(forKey: "socketPath") + + let backing: Backing + if let socketPath { + // `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) + backing = .unixDomainSocket(path: filePath) + } else { + backing = .hostAndPort( host: try config.requiredString(forKey: "host"), port: try config.requiredInt(forKey: "port") ) - ) + } + + self.init(backing: backing) } } diff --git a/Sources/NIOHTTPServer/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+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index d4b63b4..7d94777 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -18,15 +18,15 @@ import NIOPosix enum ListeningAddressError: CustomStringConvertible, Error { case addressOrPortNotAvailable - case unsupportedAddressType + case pathnameNotAvailable case serverClosed 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 .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. @@ -134,39 +134,21 @@ extension NIOHTTPServer { @available(anyAppleOS 27.0, *) extension NIOHTTPServer.SocketAddress { init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { - guard let address, let port = address.port else { - throw ListeningAddressError.addressOrPortNotAvailable - } - switch address { case .v4(let ipv4Address): + guard let port = address?.port else { throw .addressOrPortNotAvailable } self.init(base: .ipv4(.init(host: ipv4Address.host, port: port))) case .v6(let ipv6Address): + guard let port = address?.port else { throw .addressOrPortNotAvailable } self.init(base: .ipv6(.init(host: ipv6Address.host, port: port))) case .unixDomainSocket: - throw ListeningAddressError.unsupportedAddressType - } - } -} + guard let pathname = address?.pathname else { throw .pathnameNotAvailable } + self.init(base: .unixDomainSocket(path: pathname)) -@available(anyAppleOS 27.0, *) -extension NIOHTTPServerConfiguration.BindTarget { - init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { - guard let address, let port = address.port else { - throw ListeningAddressError.addressOrPortNotAvailable - } - - switch address { - case .v4(let ipv4Address): - self.init(backing: .hostAndPort(host: ipv4Address.host, port: port)) - - case .v6(let ipv6Address): - self.init(backing: .hostAndPort(host: ipv6Address.host, port: port)) - - case .unixDomainSocket: - throw ListeningAddressError.unsupportedAddressType + case nil: + throw .addressOrPortNotAvailable } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 40eb530..1a9ae86 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -618,6 +618,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, diff --git a/Sources/NIOHTTPServer/SocketAddress.swift b/Sources/NIOHTTPServer/SocketAddress.swift index abcb85b..f2712f4 100644 --- a/Sources/NIOHTTPServer/SocketAddress.swift +++ b/Sources/NIOHTTPServer/SocketAddress.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import NIOCore +import SystemPackage @available(anyAppleOS 27.0, *) extension NIOHTTPServer { @@ -57,6 +58,7 @@ extension NIOHTTPServer { enum Base: Hashable, Sendable { case ipv4(IPv4) case ipv6(IPv6) + case unixDomainSocket(path: String) } let base: Base @@ -90,23 +92,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 } } } @@ -118,6 +135,9 @@ extension NIOCore.SocketAddress { switch bindTarget.backing { case .hostAndPort(let host, let port): self = try .makeAddressResolvingHost(host, port: port) + + case .unixDomainSocket(let path): + self = try .init(unixDomainSocketPath: path.string) } } } diff --git a/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift b/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift index cd87aa4..54c303f 100644 --- a/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift @@ -77,5 +77,53 @@ struct HTTP3ConfigurationTests { } } } + + @Test("Unix domain socket bind target is rejected for HTTP/3") + @available(anyAppleOS 27.0, *) + func unixDomainSocketRejectedForHTTP3() { + #expect(throws: NIOHTTPServerConfigurationError.unixDomainSocketNotSupportedOverHTTP3) { + _ = try NIOHTTPServerConfiguration( + bindTarget: .unixDomainSocket(path: "/tmp/http3.sock"), + supportedHTTPVersions: [.http3(config: .defaults)], + transportSecurity: .tls( + credentials: .x509(.pemFile(certificateChainPath: "/cert.pem", privateKeyPath: "/key.pem")) + ) + ) + } + } + + // 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 15b1142..d19ba0a 100644 --- a/Tests/NIOHTTPServerTests/HTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPServerTests.swift @@ -13,8 +13,12 @@ //===----------------------------------------------------------------------===// import BasicContainers +import Foundation import Logging +import NIOCore import NIOHTTPServer +import NIOPosix +import SystemPackage import Testing @Suite @@ -59,4 +63,81 @@ struct HTTPServerTests { group.cancelAll() } } + + @Test("Unix domain socket file is removed on shutdown") + @available(anyAppleOS 27.0, *) + func testUnixDomainSocketFileRemovedOnShutdown() async throws { + // Keep the path short so it stays under the platform's `sun_path` limit (104 on Darwin, 108 on Linux), + // even on CI where the system temporary directory can be deep. + let socketPath = "/tmp/nio-http-server-uds-\(UUID().uuidString).sock" + // Guard against a leftover file from a previously crashed run, and clean up if this test fails early. + try? FileManager.default.removeItem(atPath: socketPath) + defer { try? FileManager.default.removeItem(atPath: socketPath) } + let filePath = FilePath(socketPath) + + let server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: try .init( + bindTarget: .unixDomainSocket(path: filePath), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + ) + + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve { request, context, reader, responseSender in + let responseWriter = try await responseSender.send(HTTPResponse(status: .ok)) + var buffer = UniqueArray() + try await responseWriter.finish(buffer: &buffer, finalElement: nil) + } + } + + // Wait until the server is bound; the socket file must exist while it is listening. + _ = try await server.listeningAddresses + #expect(FileManager.default.fileExists(atPath: socketPath)) + + // Shutting the server down should release the socket by removing its file. + group.cancelAll() + } + + // The task group only returns once `serve` has fully unwound, 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)) + } + + @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" + defer { try? FileManager.default.removeItem(atPath: 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(socketPath)), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + ) + + // 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 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 5b7948a..2d1af49 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,49 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect("Missing required config value for key: port." == "\(configError)") } + + @Test("Valid unix domain socket path") + @available(anyAppleOS 27.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 27.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) + } + } + + @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") @@ -150,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/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index 2828159..d9625d8 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 SystemPackage 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 27.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 27.0, *) @Test( "mTLS request-response with custom verification callback returning peer certificates", @@ -589,7 +654,9 @@ struct NIOHTTPServerTests { serverHandler: HTTPServerClosureRequestHandler { _, _, _, _ in }, body: { addresses in #expect(addresses.count == 2) - #expect(addresses[0].port != addresses[1].port) + let firstPort = try #require(addresses[0].port) + let secondPort = try #require(addresses[1].port) + #expect(firstPort != secondPort) } ) } diff --git a/Tests/NIOHTTPServerTests/SocketAddressTests.swift b/Tests/NIOHTTPServerTests/SocketAddressTests.swift new file mode 100644 index 0000000..2788f7e --- /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 27.0, *) + @Test("A nil NIO address throws addressOrPortNotAvailable") + func testNilAddressThrows() throws { + #expect(throws: ListeningAddressError.addressOrPortNotAvailable) { + _ = try NIOHTTPServer.SocketAddress(nil as NIOCore.SocketAddress?) + } + } + + @available(anyAppleOS 27.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 27.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 27.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/Helpers.swift b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift index 3a91472..f5f1907 100644 --- a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift +++ b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift @@ -414,13 +414,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.. Void)? = nil ) async throws -> TestClientConnection { - .init( + let target: NIOCore.SocketAddress + + switch serverAddress.base { + case .ipv4(let address): + target = try NIOCore.SocketAddress(ipAddress: address.host, port: address.port) + case .ipv6(let address): + target = try NIOCore.SocketAddress(ipAddress: address.host, port: address.port) + case .unixDomainSocket(let path): + target = try NIOCore.SocketAddress(unixDomainSocketPath: path) + } + + return .init( connectionProtocol: .http1( - connectionChannel: try await self.connect( - to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) - ) { channel in + connectionChannel: try await self.connect(to: target) { channel in channel.configureTestHTTP1ClientPipeline( additionalConnectionChannelInitializer: additionalConnectionChannelInitializer ) diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift index 8d034b6..fc1448f 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift @@ -70,9 +70,18 @@ extension ClientBootstrap { tlsConfig: TLSConfiguration, additionalConnectionChannelInitializer: (@Sendable (any Channel) throws -> Void)? = nil ) async throws -> TestClientConnection { - let (connectionChannel, alpnResultFuture) = try await self.connect( - to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) - ) { channel in + let target: NIOCore.SocketAddress + + switch serverAddress.base { + case .ipv4(let address): + target = try NIOCore.SocketAddress(ipAddress: address.host, port: address.port) + case .ipv6(let address): + target = try NIOCore.SocketAddress(ipAddress: address.host, port: address.port) + case .unixDomainSocket(let path): + target = try NIOCore.SocketAddress(unixDomainSocketPath: path) + } + + let (connectionChannel, alpnResultFuture) = try await self.connect(to: target) { channel in channel.configureTestClientSSLPipeline(tlsConfig: tlsConfig).flatMap { channel.configureTestSecureUpgradeClientPipeline( additionalConnectionChannelInitializer: additionalConnectionChannelInitializer diff --git a/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift b/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift index 75acde1..f574311 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift @@ -244,6 +244,21 @@ extension TestClientConnection { #if HTTP3 case (.http3, .some(let trustRootsPEMPath)): + // HTTP/3 runs over QUIC/UDP and does not support Unix domain sockets, so it always + // connects to a host and port. + let host: String + let port: Int + switch serverAddress.base { + case .ipv4(let address): + host = address.host + port = address.port + case .ipv6(let address): + host = address.host + port = address.port + case .unixDomainSocket: + throw TestError.invalidClientConfiguration + } + let (quicChannel, connectionCreator) = try await DatagramBootstrap(group: .singletonMultiThreadedEventLoopGroup).setupTestHTTP3Client( logger: configuration.logger, @@ -263,7 +278,7 @@ extension TestClientConnection { let h3Connection = try await multiplexer.concurrencyView.createConnection( serverName: "127.0.0.1", - remoteAddress: .init(ipAddress: serverAddress.host, port: serverAddress.port), + remoteAddress: .init(ipAddress: host, port: port), inboundPushStreamInitializer: { _ in fatalError("Push streams not supported") } )