Skip to content

Commit fb4d774

Browse files
committed
refactor: switch server API to command-based architecture
- Add handleCommand hook for server-side command dispatch in handlePacket - Thread serverOptions (handleCommand, encoding) through createServer → Server → ConnectionConfig → Connection - Add Query.fromPacket() for server-side COM_QUERY deserialization - Make Handshake packet support configurable getSalt and authPluginName - Simplify HandshakeResponse to accept pre-calculated authToken - Use _serverEncoding getter for server write helpers (writeColumns, writeOk, etc.) - Bump sequenceId by 1 on server-side command start - Emit auth errors to connection instead of throwing
1 parent cb5adcc commit fb4d774

11 files changed

Lines changed: 83 additions & 97 deletions

File tree

index.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,19 @@ exports.Pool = Pool;
2727

2828
exports.PoolCluster = PoolCluster;
2929

30-
exports.createServer = function (handler) {
30+
exports.createServer = function (opts = {}) {
31+
let handler;
32+
if (typeof opts === 'function') {
33+
handler = opts;
34+
opts = {};
35+
} else {
36+
handler = opts.onConnection;
37+
}
3138
const Server = require('./lib/server.js');
32-
const s = new Server();
39+
const s = new Server({
40+
handleCommand: opts.handleCommand,
41+
encoding: opts.encoding || 'cesu8',
42+
});
3343
if (handler) {
3444
s.on('connection', handler);
3545
}

lib/base/connection.js

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -515,14 +515,20 @@ class BaseConnection extends EventEmitter {
515515
);
516516
}
517517
}
518+
if (
519+
!this._command &&
520+
this.config.isServer &&
521+
this.config.serverOptions?.handleCommand
522+
) {
523+
const commandCode = packet.peekByte();
524+
this._command = this.config.serverOptions.handleCommand(commandCode);
525+
}
518526
if (!this._command) {
519527
const marker = packet.peekByte();
520-
// If it's an Err Packet, we should use it.
521528
if (marker === 0xff) {
522529
const error = Packets.Error.fromPacket(packet);
523530
this.protocolError(error.message, error.code);
524531
} else {
525-
// Otherwise, it means it's some other unexpected packet.
526532
this.protocolError(
527533
'Unexpected packet while no commands in the queue',
528534
'PROTOCOL_UNEXPECTED_PACKET'
@@ -1016,27 +1022,31 @@ class BaseConnection extends EventEmitter {
10161022
// ===================================
10171023
// outgoing server connection methods
10181024
// ===================================
1025+
1026+
get _serverEncoding() {
1027+
return (
1028+
this.config.serverOptions?.encoding ||
1029+
(this.serverConfig && this.serverConfig.encoding) ||
1030+
'cesu8'
1031+
);
1032+
}
1033+
10191034
writeColumns(columns) {
10201035
this.writePacket(Packets.ResultSetHeader.toPacket(columns.length));
10211036
columns.forEach((column) => {
10221037
this.writePacket(
1023-
Packets.ColumnDefinition.toPacket(column, this.serverConfig.encoding)
1038+
Packets.ColumnDefinition.toPacket(column, this._serverEncoding)
10241039
);
10251040
});
10261041
this.writeEof();
10271042
}
10281043

1029-
// row is array of columns, not hash
10301044
writeTextRow(column) {
1031-
this.writePacket(
1032-
Packets.TextRow.toPacket(column, this.serverConfig.encoding)
1033-
);
1045+
this.writePacket(Packets.TextRow.toPacket(column, this._serverEncoding));
10341046
}
10351047

10361048
writeBinaryRow(column) {
1037-
this.writePacket(
1038-
Packets.BinaryRow.toPacket(column, this.serverConfig.encoding)
1039-
);
1049+
this.writePacket(Packets.BinaryRow.toPacket(column, this._serverEncoding));
10401050
}
10411051

10421052
writeTextResult(rows, columns, binary = false) {
@@ -1061,13 +1071,11 @@ class BaseConnection extends EventEmitter {
10611071
if (!args) {
10621072
args = { affectedRows: 0 };
10631073
}
1064-
this.writePacket(Packets.OK.toPacket(args, this.serverConfig.encoding));
1074+
this.writePacket(Packets.OK.toPacket(args, this._serverEncoding));
10651075
}
10661076

10671077
writeError(args) {
1068-
// if we want to send error before initial hello was sent, use default encoding
1069-
const encoding = this.serverConfig ? this.serverConfig.encoding : 'cesu8';
1070-
this.writePacket(Packets.Error.toPacket(args, encoding));
1078+
this.writePacket(Packets.Error.toPacket(args, this._serverEncoding));
10711079
}
10721080

10731081
serverHandshake(args) {

lib/commands/auth_switch.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,11 @@ function authSwitchRequest(packet, connection, command) {
9696

9797
const authPlugin = getAuthPlugin(pluginName, connection);
9898
if (!authPlugin) {
99-
throw new Error(
100-
`Server requests authentication using unknown plugin ${pluginName}. See ${'TODO: add plugins doco here'} on how to configure or author authentication plugins.`
99+
const err = new Error(
100+
`Server requests authentication using unknown plugin ${pluginName}.`
101101
);
102+
connection.emit('error', err);
103+
return;
102104
}
103105
connection._authPlugin = authPlugin({ connection, command });
104106
Promise.resolve(connection._authPlugin(pluginData))

lib/commands/client_handshake.js

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const Command = require('./command.js');
1414
const Packets = require('../packets/index.js');
1515
const ClientConstants = require('../constants/client.js');
1616
const CharsetToEncoding = require('../constants/charset_encodings.js');
17+
18+
// TODO: refactor to use plugins
19+
// need to coordinate with ChangeUser command,
20+
// currently it uses sync calculateNativePasswordAuthToken method from here
1721
const auth41 = require('../auth_41.js');
1822
const { getAuthPlugin } = require('./auth_switch.js');
1923
const {
@@ -60,27 +64,16 @@ class ClientHandshake extends Command {
6064
}
6165
this.user = connection.config.user;
6266
this.password = connection.config.password;
63-
// "password1" is an alias to the original "password" value
64-
// to make it easier to integrate multi-factor authentication
6567
this.password1 = connection.config.password;
66-
// "password2" and "password3" are the 2nd and 3rd factor authentication
67-
// passwords, which can be undefined depending on the authentication
68-
// plugin being used
6968
this.password2 = connection.config.password2;
7069
this.password3 = connection.config.password3;
7170
this.passwordSha1 = connection.config.passwordSha1;
72-
this.database = connection.config.database;
7371
this.authPluginName = this.handshake.authPluginName;
7472

75-
// Optimization: Try to use the server's preferred authentication method
76-
// to avoid an unnecessary auth switch roundtrip
7773
const serverAuthMethod = this.handshake.authPluginName;
7874
const isSecureConnection =
7975
connection.config.ssl || connection.config.socketPath;
8076

81-
// Combine auth plugin data for easier handling
82-
// Note: authPluginData2 can include a trailing NUL byte when PLUGIN_AUTH is set
83-
// We must ensure exactly 20 bytes for the scramble
8477
const authPluginData =
8578
this.handshake.authPluginData1 && this.handshake.authPluginData2
8679
? Buffer.concat([
@@ -89,8 +82,6 @@ class ClientHandshake extends Command {
8982
]).slice(0, 20)
9083
: Buffer.alloc(20);
9184

92-
// Check if user has custom auth plugin or legacy handler for the server-advertised method
93-
// If so, we must not bypass the auth switch flow with our built-in implementation
9485
const hasCustomAuthPlugin =
9586
connection.config.authPlugins &&
9687
Object.prototype.hasOwnProperty.call(
@@ -100,8 +91,6 @@ class ClientHandshake extends Command {
10091
const hasLegacyAuthSwitchHandler =
10192
typeof connection.config.authSwitchHandler === 'function';
10293

103-
// Determine which auth method to use
104-
// Try to use server's preferred method if we can, otherwise fallback to native
10594
const canUseDirectAuth =
10695
!hasCustomAuthPlugin &&
10796
!hasLegacyAuthSwitchHandler &&
@@ -113,7 +102,6 @@ class ClientHandshake extends Command {
113102
? serverAuthMethod
114103
: 'mysql_native_password';
115104

116-
// Calculate the auth token for the chosen method
117105
const authToken = this.calculateAuthToken(
118106
clientAuthMethod,
119107
this.password,
@@ -144,9 +132,6 @@ class ClientHandshake extends Command {
144132
});
145133
connection.writePacket(handshakeResponse.toPacket());
146134

147-
// If we used a non-native auth method in the initial handshake response,
148-
// we need to prepare for potential AuthMoreData packets by creating
149-
// the appropriate auth plugin instance
150135
if (clientAuthMethod !== 'mysql_native_password') {
151136
this.initializeAuthPlugin(clientAuthMethod, authPluginData, connection);
152137
}

lib/commands/command.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class Command extends EventEmitter {
2424
if (!this.next) {
2525
this.next = this.start;
2626
connection._resetSequenceId();
27+
if (connection.config.isServer) {
28+
connection._bumpSequenceId(1);
29+
}
2730
}
2831
if (packet && packet.isError()) {
2932
const err = packet.asError(connection.clientEncoding);

lib/commands/server_handshake.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class ServerHandshake extends Command {
2828
connection.emit('error', new Error('Error generating random bytes'));
2929
return;
3030
}
31-
connection.writePacket(serverHelloPacket.toPacket(0));
31+
connection.writePacket(serverHelloPacket.toPacket(10));
3232
});
3333
return ServerHandshake.prototype.readClientReply;
3434
}
@@ -55,6 +55,7 @@ class ServerHandshake extends Command {
5555
// if (err)
5656
if (!mysqlError) {
5757
connection.writeOk();
58+
connection.sequenceId = 0;
5859
} else {
5960
// TODO create constants / errorToCode
6061
// 1045 = ER_ACCESS_DENIED_ERROR

lib/connection_config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const validOptions = {
7070
waitForConnections: 1,
7171
jsonStrings: 1,
7272
gracefulEnd: 1,
73+
serverOptions: 1,
7374
};
7475

7576
class ConnectionConfig {
@@ -94,6 +95,7 @@ class ConnectionConfig {
9495
}
9596
}
9697
this.isServer = options.isServer;
98+
this.serverOptions = options.serverOptions;
9799
this.stream = options.stream;
98100
this.host = options.host || 'localhost';
99101
this.port =

lib/packets/handshake.js

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
'use strict';
22

3+
const crypto = require('crypto');
34
const Packet = require('../packets/packet');
45
const ClientConstants = require('../constants/client.js');
56

67
// https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake
78

9+
const getSalt = () =>
10+
new Promise((accept, reject) => {
11+
crypto.randomBytes(20, (err, data) => {
12+
if (err) {
13+
reject(err);
14+
return;
15+
}
16+
accept(data);
17+
});
18+
});
19+
820
class Handshake {
921
constructor(args) {
1022
this.protocolVersion = args.protocolVersion;
@@ -15,25 +27,22 @@ class Handshake {
1527
this.authPluginData2 = args.authPluginData2;
1628
this.characterSet = args.characterSet;
1729
this.statusFlags = args.statusFlags;
18-
this.authPluginName = args.authPluginName;
30+
this.authPluginName = args.authPluginName || 'mysql_native_password';
31+
this.getSalt = args.getSalt || getSalt;
1932
}
2033

2134
setScrambleData(cb) {
22-
require('crypto').randomBytes(20, (err, data) => {
23-
if (err) {
24-
cb(err);
25-
return;
26-
}
27-
this.authPluginData1 = data.slice(0, 8);
28-
this.authPluginData2 = data.slice(8, 20);
35+
this.getSalt().then((salt) => {
36+
this.authPluginData1 = salt.slice(0, 8);
37+
this.authPluginData2 = salt.slice(8, 20);
2938
cb();
3039
});
3140
}
3241

33-
toPacket(sequenceId) {
42+
toPacket() {
3443
const length = 68 + Buffer.byteLength(this.serverVersion, 'utf8');
3544
const buffer = Buffer.alloc(length + 4, 0); // zero fill, 10 bytes filler later needs to contain zeros
36-
const packet = new Packet(sequenceId, buffer, 0, length + 4);
45+
const packet = new Packet(0, buffer, 0, length + 4);
3746
packet.offset = 4;
3847
packet.writeInt8(this.protocolVersion);
3948
packet.writeString(this.serverVersion, 'cesu8');
@@ -51,7 +60,7 @@ class Handshake {
5160
packet.skip(10);
5261
packet.writeBuffer(this.authPluginData2);
5362
packet.writeInt8(0);
54-
packet.writeString('mysql_native_password', 'latin1');
63+
packet.writeString(this.authPluginName, 'latin1');
5564
packet.writeInt8(0);
5665
return packet;
5766
}

lib/packets/handshake_response.js

Lines changed: 2 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,59 +4,14 @@ const ClientConstants = require('../constants/client.js');
44
const CharsetToEncoding = require('../constants/charset_encodings.js');
55
const Packet = require('../packets/packet.js');
66

7-
const auth41 = require('../auth_41.js');
8-
97
class HandshakeResponse {
108
constructor(handshake) {
119
this.user = handshake.user || '';
1210
this.database = handshake.database || '';
13-
this.password = handshake.password || '';
14-
this.passwordSha1 = handshake.passwordSha1;
15-
this.authPluginData1 = handshake.authPluginData1;
16-
this.authPluginData2 = handshake.authPluginData2;
1711
this.compress = handshake.compress;
1812
this.clientFlags = handshake.flags;
19-
20-
// Accept pre-calculated authToken and authPluginName from caller
21-
// This allows the caller to optimize by using the server's preferred auth method
22-
if (
23-
handshake.authToken !== undefined &&
24-
handshake.authPluginName !== undefined
25-
) {
26-
// Validate types to fail fast with clear errors
27-
if (!Buffer.isBuffer(handshake.authToken)) {
28-
throw new TypeError(
29-
'HandshakeResponse authToken must be a Buffer when provided'
30-
);
31-
}
32-
if (typeof handshake.authPluginName !== 'string') {
33-
throw new TypeError(
34-
'HandshakeResponse authPluginName must be a string when provided'
35-
);
36-
}
37-
this.authToken = handshake.authToken;
38-
this.authPluginName = handshake.authPluginName;
39-
} else {
40-
// Fallback to legacy behavior: calculate mysql_native_password token
41-
// TODO: pre-4.1 auth support
42-
let authToken;
43-
if (this.passwordSha1) {
44-
authToken = auth41.calculateTokenFromPasswordSha(
45-
this.passwordSha1,
46-
this.authPluginData1,
47-
this.authPluginData2
48-
);
49-
} else {
50-
authToken = auth41.calculateToken(
51-
this.password,
52-
this.authPluginData1,
53-
this.authPluginData2
54-
);
55-
}
56-
this.authToken = authToken;
57-
this.authPluginName = 'mysql_native_password';
58-
}
59-
13+
this.authToken = handshake.authToken;
14+
this.authPluginName = handshake.authPluginName;
6015
this.charsetNumber = handshake.charsetNumber;
6116
this.encoding = CharsetToEncoding[handshake.charsetNumber];
6217
this.connectAttributes = handshake.connectAttributes;

lib/packets/query.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ class Query {
9797
const p = this.serializeToBuffer(Packet.MockBuffer());
9898
return this.serializeToBuffer(Buffer.allocUnsafe(p.offset));
9999
}
100+
101+
static fromPacket(packet, encoding) {
102+
const _commandCode = packet.readInt8();
103+
if (_commandCode !== CommandCode.QUERY) {
104+
throw new Error('Incorrect command code for Query packet');
105+
}
106+
const query = packet.readString(undefined, encoding);
107+
return new Query(query);
108+
}
100109
}
101110

102111
module.exports = Query;

0 commit comments

Comments
 (0)