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 } } }