Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably use the new FilePath type once it becomes available

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably use the new FilePath type once it becomes available

In the UDS bind support the socket path has two different types depending on direction: on input we take a FilePath (BindTarget.unixDomainSocket(path:)), but on output we expose it as String (SocketAddress.unixDomainSocketPath, mirroring NIOCore.SocketAddress.pathname). If we commit to FilePath, I'd lean toward using it on output too for consistency — but keeping String also makes sense since that's just what NIO reports back. Which way should we go?

Self(backing: .unixDomainSocket(path: path))
}
}


/// Configuration for transport security settings.
///
/// Provides options for running the server with or without TLS encryption.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible {
case noSupportedHTTPVersionsSpecified
case incompatibleTransportSecurity
case noBindTargetsSpecified
case hostPortAndSocketPathProvided

var description: String {
switch self {
Expand All @@ -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."
}
}
}
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
27 changes: 25 additions & 2 deletions Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,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(
Expand All @@ -111,6 +110,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 {
Expand Down
31 changes: 22 additions & 9 deletions Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ enum ListeningAddressError: CustomStringConvertible, Error {
case addressOrPortNotAvailable
case unsupportedAddressType
case serverClosed
case pathnameNotAvailable

var description: String {
switch self {
Expand All @@ -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"
}
}
}
Expand Down Expand Up @@ -134,17 +137,27 @@ extension NIOHTTPServer {
@available(anyAppleOS 26.0, *)
extension NIOHTTPServer.SocketAddress {
fileprivate 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)
}
}
24 changes: 22 additions & 2 deletions Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,9 @@ extension NIOHTTPServer {
var serverChannels = [(NIOAsyncChannel<EventLoopFuture<NegotiatedChannel>, 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(
Expand All @@ -225,6 +224,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 {
Expand Down
20 changes: 20 additions & 0 deletions Sources/NIOHTTPServer/NIOHTTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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, handler: handler)
Expand Down Expand Up @@ -330,6 +334,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, *)
Expand Down
22 changes: 19 additions & 3 deletions Sources/NIOHTTPServer/SocketAddress.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ extension NIOHTTPServer {
enum Base: Hashable, Sendable {
case ipv4(IPv4)
case ipv6(IPv6)
case unixDomainSocket(path: String)
}

let base: Base
Expand Down Expand Up @@ -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
}
}
}
Expand Down
Loading