Skip to content

Commit cb49c66

Browse files
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 #69
1 parent 08fcdfc commit cb49c66

16 files changed

Lines changed: 278 additions & 28 deletions

Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ extension NIOHTTPServerConfiguration {
9393
let bindTargetScope = snapshot.scoped(to: "bindTarget")
9494
let singularHost = bindTargetScope.string(forKey: "host")
9595
let singularPort = bindTargetScope.int(forKey: "port")
96-
let hasSingular = singularHost != nil || singularPort != nil
96+
let singularSocketPath = bindTargetScope.string(forKey: "socketPath")
97+
let hasSingular = singularHost != nil || singularPort != nil || singularSocketPath != nil
9798

9899
if hasSingular && hasPlural {
99100
throw NIOHTTPServerSwiftConfigurationError.singularAndPluralBindTargetsProvided
@@ -117,17 +118,30 @@ extension NIOHTTPServerConfiguration.BindTarget {
117118
/// Initialize a bind target configuration from a config reader.
118119
///
119120
/// ## Configuration keys:
120-
/// - `host` (string, required): The hostname or IP address the server will bind to (e.g., "localhost", "0.0.0.0").
121-
/// - `port` (int, required): The port number the server will listen on (e.g., 8080, 443).
121+
/// - `host` (string): The hostname or IP address to bind to. Required unless `socketPath` is given.
122+
/// - `port` (int): The port to listen on. Required unless `socketPath` is given.
123+
/// - `socketPath` (string): A unix domain socket path to bind to. Mutually exclusive with `host`/`port`.
122124
///
123125
/// - Parameter config: The configuration reader.
124126
public init(config: ConfigSnapshotReader) throws {
125-
self.init(
126-
backing: .hostAndPort(
127+
let host = config.string(forKey: "host")
128+
let port = config.int(forKey: "port")
129+
let socketPath = config.string(forKey: "socketPath")
130+
131+
let backing: Backing
132+
if let socketPath {
133+
guard host == nil, port == nil else {
134+
throw NIOHTTPServerSwiftConfigurationError.hostPortAndSocketPathProvided
135+
}
136+
backing = .unixDomainSocket(path: socketPath)
137+
} else {
138+
backing = .hostAndPort(
127139
host: try config.requiredString(forKey: "host"),
128140
port: try config.requiredInt(forKey: "port")
129141
)
130-
)
142+
}
143+
144+
self.init(backing: backing)
131145
}
132146
}
133147

Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public struct NIOHTTPServerConfiguration: Sendable {
2929
public struct BindTarget: Sendable {
3030
enum Backing {
3131
case hostAndPort(host: String, port: Int)
32+
case unixDomainSocket(path: String)
3233
}
3334

3435
let backing: Backing
@@ -47,8 +48,22 @@ public struct NIOHTTPServerConfiguration: Sendable {
4748
public static func hostAndPort(host: String, port: Int) -> Self {
4849
Self(backing: .hostAndPort(host: host, port: port))
4950
}
51+
52+
/// Creates a bind target for a unix domain socket.
53+
///
54+
/// - Parameter path: The file system path to bind the unix domain socket to (e.g., "/tmp/server.sock")
55+
/// - Returns: A configured `BindTarget` instance
56+
///
57+
/// ## Example
58+
/// ```swift
59+
/// let target = BindTarget.unixDomainSocket(path: "/tmp/server.sock")
60+
/// ```
61+
public static func unixDomainSocket(path: String) -> Self {
62+
Self(backing: .unixDomainSocket(path: path))
63+
}
5064
}
5165

66+
5267
/// Configuration for transport security settings.
5368
///
5469
/// Provides options for running the server with or without TLS encryption.

Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible {
1717
case noSupportedHTTPVersionsSpecified
1818
case incompatibleTransportSecurity
1919
case noBindTargetsSpecified
20+
case hostPortAndSocketPathProvided
2021

2122
var description: String {
2223
switch self {
@@ -28,6 +29,9 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible {
2829

2930
case .noBindTargetsSpecified:
3031
"Invalid configuration: at least one bind target must be specified."
32+
33+
case .hostPortAndSocketPathProvided:
34+
"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."
3135
}
3236
}
3337
}

Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ enum NIOHTTPServerSwiftConfigurationError: Error, CustomStringConvertible {
2121
case trustRootsSourceAndVerificationCallbackMismatch
2222
case singularAndPluralBindTargetsProvided
2323
case bindTargetsHostsAndPortsLengthMismatch
24+
case hostPortAndSocketPathProvided
2425

2526
var description: String {
2627
switch self {
@@ -38,6 +39,9 @@ enum NIOHTTPServerSwiftConfigurationError: Error, CustomStringConvertible {
3839

3940
case .bindTargetsHostsAndPortsLengthMismatch:
4041
"Invalid configuration: 'bindTargets.hosts' and 'bindTargets.ports' must have the same number of elements."
42+
43+
case .hostPortAndSocketPathProvided:
44+
"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."
4145
}
4246
}
4347
}

Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,9 @@ extension NIOHTTPServer {
8484

8585
do {
8686
for bindTarget in bindTargets {
87+
let serverQuiescingHelper = ServerQuiescingHelper(group: self.eventLoopGroup)
8788
switch bindTarget.backing {
8889
case .hostAndPort(let host, let port):
89-
let serverQuiescingHelper = ServerQuiescingHelper(group: self.eventLoopGroup)
90-
9190
let serverChannel = try await bootstrap.serverChannelInitializer { channel in
9291
channel.eventLoop.makeCompletedFuture {
9392
try channel.pipeline.syncOperations.addHandler(
@@ -111,6 +110,30 @@ extension NIOHTTPServer {
111110
)
112111
}
113112
serverChannels.append((serverChannel, serverQuiescingHelper))
113+
case .unixDomainSocket(let path):
114+
let serverChannel = try await bootstrap.serverChannelInitializer { channel in
115+
channel.eventLoop.makeCompletedFuture {
116+
try channel.pipeline.syncOperations.addHandler(
117+
serverQuiescingHelper.makeServerChannelHandler(channel: channel)
118+
)
119+
120+
if let maxConnections = self.configuration.maxConnections {
121+
try channel.pipeline.syncOperations.addHandler(
122+
ConnectionLimitHandler(maxConnections: maxConnections)
123+
)
124+
}
125+
}
126+
}.bind(unixDomainSocketPath: path) { channel in
127+
self.setupHTTP1_1Connection(
128+
channel: channel,
129+
asyncChannelConfiguration: .init(
130+
backPressureStrategy: .init(self.configuration.backpressureStrategy),
131+
isOutboundHalfClosureEnabled: true
132+
),
133+
isSecure: false
134+
)
135+
}
136+
serverChannels.append((serverChannel, serverQuiescingHelper))
114137
}
115138
}
116139
} catch {

Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ enum ListeningAddressError: CustomStringConvertible, Error {
2020
case addressOrPortNotAvailable
2121
case unsupportedAddressType
2222
case serverClosed
23+
case pathnameNotAvailable
2324

2425
var description: String {
2526
switch self {
@@ -31,6 +32,8 @@ enum ListeningAddressError: CustomStringConvertible, Error {
3132
return """
3233
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.
3334
"""
35+
case .pathnameNotAvailable:
36+
return "Unable to retrieve the unix domain socket path from the underlying socket"
3437
}
3538
}
3639
}
@@ -134,17 +137,27 @@ extension NIOHTTPServer {
134137
@available(anyAppleOS 26.0, *)
135138
extension NIOHTTPServer.SocketAddress {
136139
fileprivate init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) {
137-
guard let address, let port = address.port else {
138-
throw ListeningAddressError.addressOrPortNotAvailable
140+
guard let address else {
141+
throw .addressOrPortNotAvailable
139142
}
140143

141-
switch address {
142-
case .v4(let ipv4Address):
143-
self.init(base: .ipv4(.init(host: ipv4Address.host, port: port)))
144-
case .v6(let ipv6Address):
145-
self.init(base: .ipv6(.init(host: ipv6Address.host, port: port)))
146-
case .unixDomainSocket:
147-
throw ListeningAddressError.unsupportedAddressType
144+
let base: Base = switch (address, address.port, address.pathname) {
145+
case (.v4(let ipv4Address), .some(let port), _):
146+
.ipv4(.init(host: ipv4Address.host, port: port))
147+
148+
case (.v6(let ipv6Address), .some(let port), _):
149+
.ipv6(.init(host: ipv6Address.host, port: port))
150+
151+
case (.unixDomainSocket, _, .some(let path)):
152+
.unixDomainSocket(path: path)
153+
154+
case (.v4, .none, _), (.v6, .none, _):
155+
throw .addressOrPortNotAvailable
156+
157+
case (.unixDomainSocket, _, .none):
158+
throw .pathnameNotAvailable
148159
}
160+
161+
self.init(base: base)
149162
}
150163
}

Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,9 @@ extension NIOHTTPServer {
201201
var serverChannels = [(NIOAsyncChannel<EventLoopFuture<NegotiatedChannel>, Never>, ServerQuiescingHelper)]()
202202
do {
203203
for bindTarget in bindTargets {
204+
let serverQuiescingHelper = ServerQuiescingHelper(group: self.eventLoopGroup)
204205
switch bindTarget.backing {
205206
case .hostAndPort(let host, let port):
206-
let serverQuiescingHelper = ServerQuiescingHelper(group: self.eventLoopGroup)
207-
208207
let serverChannel = try await bootstrap.serverChannelInitializer { channel in
209208
channel.eventLoop.makeCompletedFuture {
210209
try channel.pipeline.syncOperations.addHandler(
@@ -225,6 +224,27 @@ extension NIOHTTPServer {
225224
)
226225
}
227226
serverChannels.append((serverChannel, serverQuiescingHelper))
227+
case .unixDomainSocket(let path):
228+
let serverChannel = try await bootstrap.serverChannelInitializer { channel in
229+
channel.eventLoop.makeCompletedFuture {
230+
try channel.pipeline.syncOperations.addHandler(
231+
serverQuiescingHelper.makeServerChannelHandler(channel: channel)
232+
)
233+
234+
if let maxConnections = self.configuration.maxConnections {
235+
try channel.pipeline.syncOperations.addHandler(
236+
ConnectionLimitHandler(maxConnections: maxConnections)
237+
)
238+
}
239+
}
240+
}.bind(unixDomainSocketPath: path) { channel in
241+
self.setupSecureUpgradeConnectionChildChannel(
242+
channel: channel,
243+
supportedHTTPVersions: supportedHTTPVersions,
244+
sslContext: sslContext
245+
)
246+
}
247+
serverChannels.append((serverChannel, serverQuiescingHelper))
228248
}
229249
}
230250
} catch {

Sources/NIOHTTPServer/NIOHTTPServer.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ public struct NIOHTTPServer: HTTPServer {
145145

146146
let serverChannels = try await self.makeServerChannels()
147147

148+
// Remove the socket files for any UDS bind targets so their paths are freed for the next run.
149+
// Registered only after all binds succeeded, so every path is one we created.
150+
defer { await self.removeUNIXDomainSocketFiles() }
151+
148152
return try await withTaskCancellationHandler {
149153
try await withGracefulShutdownHandler {
150154
try await self._serve(serverChannels: serverChannels, handler: handler)
@@ -330,6 +334,22 @@ public struct NIOHTTPServer: HTTPServer {
330334
}
331335
}
332336

337+
/// Removes the socket files backing any unix-domain-socket bind targets.
338+
private func removeUNIXDomainSocketFiles() async {
339+
let fileIO = NonBlockingFileIO(threadPool: .singleton)
340+
for bindTarget in self.configuration.bindTargets {
341+
guard case .unixDomainSocket(let path) = bindTarget.backing else { continue }
342+
do {
343+
try await fileIO.unlink(path: path)
344+
} catch {
345+
self.logger.debug(
346+
"Failed to remove unix domain socket file",
347+
metadata: ["path": "\(path)", "error": "\(error)"]
348+
)
349+
}
350+
}
351+
}
352+
333353
}
334354

335355
@available(anyAppleOS 26.0, *)

Sources/NIOHTTPServer/SocketAddress.swift

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ extension NIOHTTPServer {
5555
enum Base: Hashable, Sendable {
5656
case ipv4(IPv4)
5757
case ipv6(IPv6)
58+
case unixDomainSocket(path: String)
5859
}
5960

6061
let base: Base
@@ -88,23 +89,38 @@ extension NIOHTTPServer {
8889
}
8990

9091
/// The ``SocketAddress``'s host.
91-
public var host: String {
92+
public var host: String? {
9293
switch self.base {
9394
case .ipv4(let ipv4):
9495
return ipv4.host
9596
case .ipv6(let ipv6):
9697
return ipv6.host
98+
case .unixDomainSocket(_):
99+
return nil
97100
}
98101
}
99102

100103
/// The ``SocketAddress``'s port.
101-
public var port: Int {
104+
public var port: Int? {
102105
switch self.base {
103106
case .ipv4(let ipv4):
104107
return ipv4.port
105-
106108
case .ipv6(let ipv6):
107109
return ipv6.port
110+
case .unixDomainSocket(_):
111+
return nil
112+
}
113+
}
114+
115+
/// The ``SocketAddress``'s unix domain socket path.
116+
public var unixDomainSocketPath: String? {
117+
switch self.base {
118+
case .ipv4(_):
119+
return nil
120+
case .ipv6(_):
121+
return nil
122+
case .unixDomainSocket(let path):
123+
return path
108124
}
109125
}
110126
}

0 commit comments

Comments
 (0)