Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -177,13 +178,15 @@ 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
),
.testTarget(
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import NIOCore
import NIOSSL
public import SystemPackage
public import X509

#if HTTP3
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)")
}
}
}

Expand Down Expand Up @@ -325,13 +347,15 @@ 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<HTTPVersion> {
didSet {
if self.supportedHTTPVersions.isEmpty {
preconditionFailure(NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified.description)
}

do {
try self.validateBindTargets()
try self.validateTransportConfiguration()
} catch {
preconditionFailure("\(error)")
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ enum NIOHTTPServerSwiftConfigurationError: Error, CustomStringConvertible {
case trustRootsSourceAndVerificationCallbackMismatch
case singularAndPluralBindTargetsProvided
case bindTargetsHostsAndPortsLengthMismatch
case hostPortAndSocketPathProvided

var description: String {
switch self {
Expand All @@ -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."
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import NIOCertificateReloading
import NIOHTTP2
import SwiftASN1
public import X509
import SystemPackage

@available(anyAppleOS 27.0, *)
extension NIOHTTPServerConfiguration {
Expand All @@ -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"`,
Expand Down Expand Up @@ -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: (
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`) | - |
Expand Down Expand Up @@ -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
Expand Down
36 changes: 9 additions & 27 deletions Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}
}
2 changes: 2 additions & 0 deletions Sources/NIOHTTPServer/NIOHTTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Child: Sendable>(
address: NIOCore.SocketAddress,
addressContinuation: AsyncThrowingStream<NIOCore.SocketAddress, any Error>.Continuation,
Expand Down
Loading