Migrated the fake plaintext server in the Persistence tests target to use PostgreSQL instead.
This commit is contained in:
@@ -1,162 +0,0 @@
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// A fake MySQL server speaking just enough of the wire protocol to complete a plaintext handshake.
|
||||
///
|
||||
/// Its greeting advertises the `mysql_native_password` plugin but **not** the `CLIENT_SSL` capability, and
|
||||
/// it answers the client's handshake response with a bare OK packet — so a client asking for TLS can only
|
||||
/// end up connected in plaintext. This is what the `prefer` fallback test connects to, proving the driver
|
||||
/// downgrades to plaintext rather than refusing the connection.
|
||||
final class PlaintextMySQLServer {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The port the server listens on, assigned by the system at bind time.
|
||||
let port: Int
|
||||
|
||||
/// The listening channel the server accepts connections through.
|
||||
private let channel: Channel
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
private init(
|
||||
channel: Channel,
|
||||
port: Int
|
||||
) {
|
||||
self.channel = channel
|
||||
self.port = port
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Starts a server on the loopback interface, on a system-assigned port.
|
||||
/// - Returns: the running server, ready to be connected to at ``port``.
|
||||
static func start() async throws -> PlaintextMySQLServer {
|
||||
let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)
|
||||
.childChannelInitializer { channel in
|
||||
channel.eventLoop.makeCompletedFuture {
|
||||
try channel.pipeline.syncOperations.addHandler(Handler())
|
||||
}
|
||||
}
|
||||
.bind(host: "127.0.0.1", port: 0)
|
||||
.get()
|
||||
|
||||
guard let port = channel.localAddress?.port else {
|
||||
throw ChannelError.unknownLocalAddress
|
||||
}
|
||||
|
||||
return .init(
|
||||
channel: channel,
|
||||
port: port
|
||||
)
|
||||
}
|
||||
|
||||
/// Stops the server, closing its listening channel.
|
||||
func stop() async throws {
|
||||
try await channel.close().get()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Handlers
|
||||
|
||||
private extension PlaintextMySQLServer {
|
||||
|
||||
/// Greets a freshly accepted connection, accepts whatever authentication response arrives,
|
||||
/// and closes on anything after that (e.g. a `COM_QUIT`).
|
||||
final class Handler: ChannelInboundHandler {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
typealias InboundIn = ByteBuffer
|
||||
typealias OutboundOut = ByteBuffer
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Whether the client's handshake response has already been answered with an OK packet.
|
||||
private var didAuthenticate = false
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func channelActive(context: ChannelHandlerContext) {
|
||||
context.writeAndFlush(
|
||||
wrapOutboundOut(Self.greeting(allocator: context.channel.allocator)),
|
||||
promise: nil
|
||||
)
|
||||
}
|
||||
|
||||
func channelRead(
|
||||
context: ChannelHandlerContext,
|
||||
data: NIOAny
|
||||
) {
|
||||
guard didAuthenticate else {
|
||||
didAuthenticate = true
|
||||
|
||||
context.writeAndFlush(
|
||||
wrapOutboundOut(Self.ok(allocator: context.channel.allocator)),
|
||||
promise: nil
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
context.close(promise: nil)
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
/// The `HandshakeV10` greeting, framed and ready to send as the connection's first packet.
|
||||
///
|
||||
/// The advertised capabilities are `CLIENT_LONG_PASSWORD`, `CLIENT_PROTOCOL_41`,
|
||||
/// `CLIENT_SECURE_CONNECTION`, and `CLIENT_PLUGIN_AUTH` — deliberately **not** `CLIENT_SSL`,
|
||||
/// so the client cannot upgrade the connection to TLS.
|
||||
private static func greeting(allocator: ByteBufferAllocator) -> ByteBuffer {
|
||||
var payload = allocator.buffer(capacity: 80)
|
||||
|
||||
payload.writeInteger(10, endianness: .little, as: UInt8.self) // protocol version
|
||||
payload.writeNullTerminatedString("8.0.0") // server version
|
||||
payload.writeInteger(1, endianness: .little, as: UInt32.self) // connection id
|
||||
payload.writeBytes([1, 2, 3, 4, 5, 6, 7, 8]) // auth plugin data, part 1
|
||||
payload.writeInteger(0, endianness: .little, as: UInt8.self) // filler
|
||||
payload.writeInteger(0x8201, endianness: .little, as: UInt16.self) // capabilities, lower: LONG_PASSWORD | PROTOCOL_41 | SECURE_CONNECTION
|
||||
payload.writeInteger(0x21, endianness: .little, as: UInt8.self) // character set (utf8)
|
||||
payload.writeInteger(0x0002, endianness: .little, as: UInt16.self) // status flags (autocommit)
|
||||
payload.writeInteger(0x0008, endianness: .little, as: UInt16.self) // capabilities, upper: PLUGIN_AUTH
|
||||
payload.writeInteger(21, endianness: .little, as: UInt8.self) // auth plugin data length
|
||||
payload.writeBytes([UInt8](repeating: 0, count: 10)) // reserved
|
||||
payload.writeBytes([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 0]) // auth plugin data, part 2
|
||||
payload.writeNullTerminatedString("mysql_native_password") // auth plugin name
|
||||
|
||||
return framed(payload, sequence: 0, allocator: allocator)
|
||||
}
|
||||
|
||||
/// A bare OK packet, framed as the reply to the client's handshake response.
|
||||
private static func ok(allocator: ByteBufferAllocator) -> ByteBuffer {
|
||||
var payload = allocator.buffer(capacity: 8)
|
||||
|
||||
payload.writeBytes([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]) // OK, no rows, autocommit, no warnings
|
||||
|
||||
return framed(payload, sequence: 2, allocator: allocator)
|
||||
}
|
||||
|
||||
/// Wraps a payload in the MySQL packet frame: a 3-byte little-endian length and a sequence byte.
|
||||
private static func framed(
|
||||
_ payload: ByteBuffer,
|
||||
sequence: UInt8,
|
||||
allocator: ByteBufferAllocator
|
||||
) -> ByteBuffer {
|
||||
var packet = allocator.buffer(capacity: payload.readableBytes + 4)
|
||||
var payload = payload
|
||||
|
||||
packet.writeInteger(UInt8(payload.readableBytes & 0xff))
|
||||
packet.writeInteger(UInt8((payload.readableBytes >> 8) & 0xff))
|
||||
packet.writeInteger(UInt8((payload.readableBytes >> 16) & 0xff))
|
||||
packet.writeInteger(sequence)
|
||||
packet.writeBuffer(&payload)
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// A fake PostgreSQL server speaking just enough of the wire protocol to complete a plaintext startup.
|
||||
///
|
||||
/// It answers the client's `SSLRequest` with `'N'` (no SSL) and the subsequent startup message with
|
||||
/// `AuthenticationOk`, `BackendKeyData`, and `ReadyForQuery` — so a client asking for TLS can only end up
|
||||
/// connected in plaintext. This is what the `prefer` fallback test connects to, proving the driver
|
||||
/// downgrades to plaintext rather than refusing the connection, and what the `require` test connects to,
|
||||
/// proving the driver refuses the connection instead of downgrading.
|
||||
final class PlaintextPostgresServer {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The port the server listens on, assigned by the system at bind time.
|
||||
let port: Int
|
||||
|
||||
/// The listening channel the server accepts connections through.
|
||||
private let channel: Channel
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
private init(
|
||||
channel: Channel,
|
||||
port: Int
|
||||
) {
|
||||
self.channel = channel
|
||||
self.port = port
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Starts a server on the loopback interface, on a system-assigned port.
|
||||
/// - Returns: the running server, ready to be connected to at ``port``.
|
||||
static func start() async throws -> PlaintextPostgresServer {
|
||||
let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)
|
||||
.childChannelInitializer { channel in
|
||||
channel.eventLoop.makeCompletedFuture {
|
||||
try channel.pipeline.syncOperations.addHandler(Handler())
|
||||
}
|
||||
}
|
||||
.bind(host: "127.0.0.1", port: 0)
|
||||
.get()
|
||||
|
||||
guard let port = channel.localAddress?.port else {
|
||||
throw ChannelError.unknownLocalAddress
|
||||
}
|
||||
|
||||
return .init(
|
||||
channel: channel,
|
||||
port: port
|
||||
)
|
||||
}
|
||||
|
||||
/// Stops the server, closing its listening channel.
|
||||
func stop() async throws {
|
||||
try await channel.close().get()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Handlers
|
||||
|
||||
private extension PlaintextPostgresServer {
|
||||
|
||||
/// Refuses the client's `SSLRequest`, accepts whatever startup message arrives, and closes on anything
|
||||
/// after that (e.g. a `Terminate`).
|
||||
///
|
||||
/// Unlike MySQL, the PostgreSQL client speaks first, so nothing is written on `channelActive`.
|
||||
final class Handler: ChannelInboundHandler {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
typealias InboundIn = ByteBuffer
|
||||
typealias OutboundOut = ByteBuffer
|
||||
|
||||
// MARK: Enumerations
|
||||
|
||||
/// The startup phases the connection moves through.
|
||||
private enum State {
|
||||
case awaitingSSLRequest
|
||||
case awaitingStartup
|
||||
case established
|
||||
}
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The magic code identifying an `SSLRequest` message.
|
||||
private static let sslRequestCode: Int32 = 80877103
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The startup phase the connection is currently in.
|
||||
private var state: State = .awaitingSSLRequest
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func channelRead(
|
||||
context: ChannelHandlerContext,
|
||||
data: NIOAny
|
||||
) {
|
||||
var buffer = unwrapInboundIn(data)
|
||||
|
||||
switch state {
|
||||
case .awaitingSSLRequest:
|
||||
// Peek past the Int32 length at the Int32 code: an `SSLRequest` is refused with a bare
|
||||
// 'N', while a direct startup message (a client connecting with TLS disabled) is
|
||||
// answered straight away.
|
||||
guard buffer.getInteger(at: buffer.readerIndex + 4, as: Int32.self) == Self.sslRequestCode else {
|
||||
state = .established
|
||||
|
||||
context.writeAndFlush(
|
||||
wrapOutboundOut(Self.startupResponse(allocator: context.channel.allocator)),
|
||||
promise: nil
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
state = .awaitingStartup
|
||||
|
||||
var refusal = context.channel.allocator.buffer(capacity: 1)
|
||||
|
||||
refusal.writeInteger(UInt8(ascii: "N"))
|
||||
|
||||
context.writeAndFlush(
|
||||
wrapOutboundOut(refusal),
|
||||
promise: nil
|
||||
)
|
||||
case .awaitingStartup:
|
||||
state = .established
|
||||
|
||||
context.writeAndFlush(
|
||||
wrapOutboundOut(Self.startupResponse(allocator: context.channel.allocator)),
|
||||
promise: nil
|
||||
)
|
||||
case .established:
|
||||
context.close(promise: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
/// The reply completing a plaintext startup: `AuthenticationOk`, `BackendKeyData`, and
|
||||
/// `ReadyForQuery` in a single flush.
|
||||
///
|
||||
/// `BackendKeyData` is not optional filler — the client requires it before `ReadyForQuery` by
|
||||
/// default and fails the connection when it is missing.
|
||||
private static func startupResponse(allocator: ByteBufferAllocator) -> ByteBuffer {
|
||||
var buffer = allocator.buffer(capacity: 32)
|
||||
|
||||
buffer.writeInteger(UInt8(ascii: "R")) // AuthenticationOk
|
||||
buffer.writeInteger(Int32(8))
|
||||
buffer.writeInteger(Int32(0))
|
||||
|
||||
buffer.writeInteger(UInt8(ascii: "K")) // BackendKeyData
|
||||
buffer.writeInteger(Int32(12))
|
||||
buffer.writeInteger(Int32(1)) // process id
|
||||
buffer.writeInteger(Int32(0)) // secret key
|
||||
|
||||
buffer.writeInteger(UInt8(ascii: "Z")) // ReadyForQuery
|
||||
buffer.writeInteger(Int32(5))
|
||||
buffer.writeInteger(UInt8(ascii: "I")) // idle
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user