This commit is contained in:
Yangxt
2026-09-21 16:46:55 +08:00
parent 01842c59c6
commit ad2b0a7d3e
58 changed files with 6906 additions and 3 deletions
+44
View File
@@ -0,0 +1,44 @@
package mysqlserver
import (
"crypto/sha1"
"crypto/subtle"
)
func nativePasswordToken(password string, scramble []byte) []byte {
if password == "" {
return []byte{}
}
if len(scramble) > 20 {
scramble = scramble[:20]
}
hash1 := sha1.Sum([]byte(password))
hash2 := sha1.Sum(hash1[:])
h := sha1.New()
h.Write(scramble)
h.Write(hash2[:])
hash3 := h.Sum(nil)
out := make([]byte, 20)
for i := 0; i < 20; i++ {
out[i] = hash1[i] ^ hash3[i]
}
return out
}
func verifyNativePassword(password string, scramble, auth []byte) bool {
if password == "" {
return len(auth) == 0
}
want := nativePasswordToken(password, scramble)
if len(auth) != len(want) {
return false
}
return subtle.ConstantTimeCompare(auth, want) == 1
}
func usernamesEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
+105
View File
@@ -0,0 +1,105 @@
package mysqlserver
import (
"fmt"
"io"
"net"
"time"
)
type packetConn struct {
c net.Conn
seq uint8
max int
readTO time.Duration
writeTO time.Duration
}
func newPacketConn(c net.Conn, timeout time.Duration) *packetConn {
return &packetConn{c: c, max: MaxPacketSize, readTO: timeout, writeTO: timeout}
}
func (p *packetConn) resetSeq() { p.seq = 0 }
func (p *packetConn) readPacket() ([]byte, error) {
var payload []byte
for {
if p.readTO > 0 {
_ = p.c.SetReadDeadline(time.Now().Add(p.readTO))
}
var hdr [4]byte
if _, err := io.ReadFull(p.c, hdr[:]); err != nil {
return nil, err
}
n := int(readU24(hdr[:3]))
seq := hdr[3]
if seq != p.seq {
return nil, fmt.Errorf("sequence mismatch: got %d want %d", seq, p.seq)
}
p.seq++
if n == 0 {
return payload, nil
}
if n > p.max {
return nil, fmt.Errorf("packet too large: %d", n)
}
chunk := make([]byte, n)
if _, err := io.ReadFull(p.c, chunk); err != nil {
return nil, err
}
payload = append(payload, chunk...)
if n < 0xffffff {
return payload, nil
}
}
}
func (p *packetConn) writePacket(payload []byte) error {
off := 0
for {
n := len(payload) - off
if n > 0xffffff {
n = 0xffffff
}
var hdr [4]byte
hdr[0] = byte(n)
hdr[1] = byte(n >> 8)
hdr[2] = byte(n >> 16)
hdr[3] = p.seq
p.seq++
if p.writeTO > 0 {
_ = p.c.SetWriteDeadline(time.Now().Add(p.writeTO))
}
if _, err := p.c.Write(hdr[:]); err != nil {
return err
}
if n > 0 {
if _, err := p.c.Write(payload[off : off+n]); err != nil {
return err
}
}
off += n
if n < 0xffffff {
return nil
}
}
}
func (p *packetConn) writeOK(affected, lastInsertID uint64, status, warnings uint16) error {
return p.writePacket(encodeOK(affected, lastInsertID, status, warnings))
}
func (p *packetConn) writeERR(errno uint16, msg string) error {
return p.writePacket(encodeERR(errno, msg))
}
func (p *packetConn) remote() string {
if p.c.RemoteAddr() == nil {
return ""
}
return p.c.RemoteAddr().String()
}
func (p *packetConn) close() error {
return p.c.Close()
}
+3
View File
@@ -0,0 +1,3 @@
// Package mysqlserver implements a loopback MySQL protocol server.
// Yingdao connects here; COM_QUERY is forwarded through the Navicat HTTP tunnel.
package mysqlserver
+57
View File
@@ -0,0 +1,57 @@
package mysqlserver
import (
"database/sql"
"fmt"
"net"
"testing"
_ "github.com/go-sql-driver/mysql"
)
func TestGoSQLDriverCompat(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "123456")
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatal(err)
}
dsn := fmt.Sprintf("root:123456@tcp(%s:%s)/app?timeout=5s&readTimeout=5s&writeTimeout=5s&charset=utf8mb4&parseTime=false", host, port)
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
var v string
if err := db.QueryRow("SELECT 1").Scan(&v); err != nil {
t.Fatal(err)
}
if v != "1" {
t.Fatalf("got %q", v)
}
var n sql.NullString
if err := db.QueryRow("SELECT NULL AS n").Scan(&n); err != nil {
t.Fatal(err)
}
if n.Valid {
t.Fatal("expected NULL")
}
var big string
if err := db.QueryRow("SELECT bigint_val").Scan(&big); err != nil {
t.Fatal(err)
}
if big != "18446744073709551615" {
t.Fatalf("bigint %q", big)
}
var zh string
if err := db.QueryRow("SELECT zh_val").Scan(&zh); err != nil {
t.Fatal(err)
}
if zh != "你好世界" {
t.Fatalf("zh %q", zh)
}
}
+138
View File
@@ -0,0 +1,138 @@
package mysqlserver
import "crypto/rand"
func buildHandshake(connID uint32, scramble []byte) []byte {
if len(scramble) < 20 {
s := make([]byte, 20)
copy(s, scramble)
scramble = s
}
b := []byte{ProtocolVersion}
b = appendNUL(b, ServerVersion)
b = appendU32(b, connID)
b = append(b, scramble[:8]...)
b = append(b, 0x00)
b = appendU16(b, uint16(serverCapabilities))
b = append(b, charsetUTF8MB4)
b = appendU16(b, serverStatusAutocommit)
b = appendU16(b, uint16(serverCapabilities>>16))
b = append(b, 21) // auth plugin data len: 20 bytes + NUL
b = append(b, make([]byte, 10)...)
b = append(b, scramble[8:20]...)
b = append(b, 0x00)
b = appendNUL(b, AuthPluginNative)
return b
}
func randomScramble() ([]byte, error) {
s := make([]byte, 20)
if _, err := rand.Read(s); err != nil {
return nil, err
}
for i, c := range s {
if c == 0 {
s[i] = 1
}
}
return s, nil
}
type handshakeResponse struct {
Capabilities uint32
MaxPacket uint32
Charset byte
Username string
Auth []byte
Database string
Plugin string
SSL bool
}
func parseHandshakeResponse(payload []byte) (handshakeResponse, error) {
var hr handshakeResponse
if len(payload) < 32 {
return hr, errPacket("handshake response too short")
}
hr.Capabilities = uint32(payload[0]) | uint32(payload[1])<<8 | uint32(payload[2])<<16 | uint32(payload[3])<<24
hr.MaxPacket = uint32(payload[4]) | uint32(payload[5])<<8 | uint32(payload[6])<<16 | uint32(payload[7])<<24
hr.Charset = payload[8]
hr.SSL = hr.Capabilities&clientSSL != 0
if hr.SSL && len(payload) <= 32 {
return hr, nil
}
if hr.Capabilities&clientProtocol41 == 0 {
return hr, errPacket("CLIENT_PROTOCOL_41 required")
}
off := 32
user, off, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Username = user
switch {
case hr.Capabilities&clientPluginAuthLenencClientData != 0:
n, nOff, err := readLenencInt(payload, off)
if err != nil {
return hr, err
}
off = nOff
end := off + int(n)
if end > len(payload) {
return hr, errPacket("auth-response truncated")
}
hr.Auth = append([]byte(nil), payload[off:end]...)
off = end
case hr.Capabilities&clientSecureConnection != 0:
if off >= len(payload) {
return hr, errPacket("auth-response truncated")
}
n := int(payload[off])
off++
end := off + n
if end > len(payload) {
return hr, errPacket("auth-response truncated")
}
hr.Auth = append([]byte(nil), payload[off:end]...)
off = end
default:
s, nOff, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Auth = []byte(s)
off = nOff
}
if hr.Capabilities&clientConnectWithDB != 0 && off < len(payload) {
db, nOff, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Database = db
off = nOff
}
if hr.Capabilities&clientPluginAuth != 0 && off < len(payload) {
plugin, _, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Plugin = plugin
}
return hr, nil
}
func encodeAuthSwitch(plugin string, scramble []byte) []byte {
b := []byte{markerEOF} // 0xFE AuthSwitchRequest
b = appendNUL(b, plugin)
b = append(b, scramble...)
b = append(b, 0x00)
return b
}
type protoError struct{ s string }
func (e protoError) Error() string { return e.s }
func errPacket(s string) error { return protoError{s} }
+263
View File
@@ -0,0 +1,263 @@
package mysqlserver
import (
"encoding/binary"
"fmt"
"io"
)
const (
ProtocolVersion = 10
ServerVersion = "8.0.0-mysql-tunnel"
AuthPluginNative = "mysql_native_password"
AuthPluginSHA2 = "caching_sha2_password"
MaxPacketSize = 16 << 20
)
const (
ComQuit byte = 0x01
ComInitDB byte = 0x02
ComQuery byte = 0x03
ComFieldList byte = 0x04
ComPing byte = 0x0e
ComStmtPrepare byte = 0x16
ComStmtExecute byte = 0x17
ComStmtClose byte = 0x19
ComResetConnection byte = 0x1f
)
const (
clientLongPassword uint32 = 1
clientFoundRows uint32 = 1 << 1
clientLongFlag uint32 = 1 << 2
clientConnectWithDB uint32 = 1 << 3
clientProtocol41 uint32 = 1 << 9
clientSSL uint32 = 1 << 11
clientTransactions uint32 = 1 << 13
clientSecureConnection uint32 = 1 << 15
clientMultiStatements uint32 = 1 << 16
clientMultiResults uint32 = 1 << 17
clientPSMultiResults uint32 = 1 << 18
clientPluginAuth uint32 = 1 << 19
clientConnectAttrs uint32 = 1 << 20
clientPluginAuthLenencClientData uint32 = 1 << 21
clientSessionTrack uint32 = 1 << 23
clientDeprecateEOF uint32 = 1 << 24
)
const (
serverStatusAutocommit uint16 = 0x0002
)
const (
charsetUTF8MB4 uint8 = 45
charsetBinary uint8 = 63
)
const (
typeDecimal byte = 0
typeTiny byte = 1
typeShort byte = 2
typeLong byte = 3
typeFloat byte = 4
typeDouble byte = 5
typeNull byte = 6
typeTimestamp byte = 7
typeLongLong byte = 8
typeInt24 byte = 9
typeDate byte = 10
typeTime byte = 11
typeDatetime byte = 12
typeYear byte = 13
typeJSON byte = 245
typeNewDecimal byte = 246
typeTinyBlob byte = 249
typeMediumBlob byte = 250
typeLongBlob byte = 251
typeBlob byte = 252
typeVarString byte = 253
typeString byte = 254
)
const (
flagNotNull uint16 = 1
flagPriKey uint16 = 2
flagUniqueKey uint16 = 4
flagMultipleKey uint16 = 8
flagBlob uint16 = 16
flagUnsigned uint16 = 32
flagZerofill uint16 = 64
flagBinary uint16 = 128
flagEnum uint16 = 256
flagAutoIncrement uint16 = 512
flagTimestamp uint16 = 1024
flagSet uint16 = 2048
)
const (
errAccessDenied uint16 = 1045
errUnknownCommand uint16 = 1047
errNoDB uint16 = 1046
errParseError uint16 = 1064
errUnknown uint16 = 1105
errNotSupported uint16 = 1235
)
const (
markerOK byte = 0x00
markerEOF byte = 0xfe
markerERR byte = 0xff
markerNull byte = 0xfb
)
var serverCapabilities = clientLongPassword | clientFoundRows | clientLongFlag |
clientConnectWithDB | clientProtocol41 | clientTransactions | clientSecureConnection |
clientMultiResults | clientPluginAuth | clientPluginAuthLenencClientData |
clientConnectAttrs | clientDeprecateEOF | clientSessionTrack
func appendU24(dst []byte, n uint32) []byte {
return append(dst, byte(n), byte(n>>8), byte(n>>16))
}
func appendU32(dst []byte, n uint32) []byte {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, n)
return append(dst, b...)
}
func appendU16(dst []byte, n uint16) []byte {
return append(dst, byte(n), byte(n>>8))
}
func appendNUL(dst []byte, s string) []byte {
dst = append(dst, s...)
return append(dst, 0)
}
func appendLenencInt(dst []byte, n uint64) []byte {
switch {
case n < 251:
return append(dst, byte(n))
case n < 1<<16:
return append(dst, 0xfc, byte(n), byte(n>>8))
case n < 1<<24:
return append(dst, 0xfd, byte(n), byte(n>>8), byte(n>>16))
default:
b := make([]byte, 8)
binary.LittleEndian.PutUint64(b, n)
return append(append(dst, 0xfe), b...)
}
}
func appendLenencString(dst []byte, s []byte) []byte {
dst = appendLenencInt(dst, uint64(len(s)))
return append(dst, s...)
}
func readU24(b []byte) uint32 {
return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16
}
func readLenencInt(b []byte, off int) (uint64, int, error) {
if off >= len(b) {
return 0, off, io.ErrUnexpectedEOF
}
switch b[off] {
case 0xfb:
return 0, off + 1, fmt.Errorf("NULL lenenc")
case 0xfc:
if off+3 > len(b) {
return 0, off, io.ErrUnexpectedEOF
}
return uint64(b[off+1]) | uint64(b[off+2])<<8, off + 3, nil
case 0xfd:
if off+4 > len(b) {
return 0, off, io.ErrUnexpectedEOF
}
return uint64(b[off+1]) | uint64(b[off+2])<<8 | uint64(b[off+3])<<16, off + 4, nil
case 0xfe:
if off+9 > len(b) {
return 0, off, io.ErrUnexpectedEOF
}
return binary.LittleEndian.Uint64(b[off+1 : off+9]), off + 9, nil
default:
return uint64(b[off]), off + 1, nil
}
}
func readLenencString(b []byte, off int) ([]byte, int, error) {
n, off, err := readLenencInt(b, off)
if err != nil {
return nil, off, err
}
end := off + int(n)
if end > len(b) {
return nil, off, io.ErrUnexpectedEOF
}
return b[off:end], end, nil
}
func readNUL(b []byte, off int) (string, int, error) {
for i := off; i < len(b); i++ {
if b[i] == 0 {
return string(b[off:i]), i + 1, nil
}
}
return string(b[off:]), len(b), nil
}
func sqlState(errno uint16) string {
switch errno {
case errAccessDenied:
return "28000"
case errParseError:
return "42000"
case errNoDB:
return "3D000"
case errUnknownCommand, errNotSupported:
return "HY000"
default:
return "HY000"
}
}
func encodeOK(affected, lastInsertID uint64, status, warnings uint16) []byte {
b := []byte{markerOK}
b = appendLenencInt(b, affected)
b = appendLenencInt(b, lastInsertID)
b = appendU16(b, status)
b = appendU16(b, warnings)
return b
}
func encodeERR(errno uint16, msg string) []byte {
b := []byte{markerERR}
b = appendU16(b, errno)
b = append(b, '#')
state := sqlState(errno)
if len(state) != 5 {
state = "HY000"
}
b = append(b, state...)
b = append(b, msg...)
return b
}
func encodeEOF(warnings, status uint16) []byte {
b := []byte{markerEOF}
b = appendU16(b, warnings)
b = appendU16(b, status)
return b
}
func isNumericType(t byte) bool {
switch t {
case typeDecimal, typeTiny, typeShort, typeLong, typeFloat, typeDouble,
typeNull, typeLongLong, typeInt24, typeYear, typeNewDecimal, typeBit:
return true
}
return false
}
const typeBit byte = 16
+122
View File
@@ -0,0 +1,122 @@
package mysqlserver
import (
"bytes"
"testing"
"mysql-tunnel/internal/navicat"
)
func TestLenencInt(t *testing.T) {
cases := []uint64{0, 1, 250, 251, 0xffff, 0x10000, 1 << 32}
for _, n := range cases {
enc := appendLenencInt(nil, n)
got, _, err := readLenencInt(enc, 0)
if err != nil || got != n {
t.Fatalf("n=%d got=%d err=%v", n, got, err)
}
}
}
func TestOKERRPackets(t *testing.T) {
ok := encodeOK(3, 9, serverStatusAutocommit, 0)
if ok[0] != markerOK {
t.Fatal("ok header")
}
n, off, err := readLenencInt(ok, 1)
if err != nil || n != 3 {
t.Fatal(n, err)
}
id, _, err := readLenencInt(ok, off)
if err != nil || id != 9 {
t.Fatal(id, err)
}
er := encodeERR(1064, "syntax")
if er[0] != markerERR || er[1] != 0x28 || er[2] != 0x04 {
t.Fatalf("%x", er)
}
if !bytes.Contains(er, []byte("syntax")) {
t.Fatal(string(er))
}
}
func TestNativePasswordRoundtrip(t *testing.T) {
scramble := bytes.Repeat([]byte{3}, 20)
tok := nativePasswordToken("123456", scramble)
if !verifyNativePassword("123456", scramble, tok) {
t.Fatal("should match")
}
if verifyNativePassword("wrong", scramble, tok) {
t.Fatal("should fail")
}
if !verifyNativePassword("", scramble, nil) {
t.Fatal("empty")
}
}
func TestHandshakeBuildParseResponse(t *testing.T) {
scramble := bytes.Repeat([]byte{7}, 20)
hs := buildHandshake(42, scramble)
if hs[0] != ProtocolVersion {
t.Fatal("proto")
}
if !bytes.Contains(hs, []byte(ServerVersion)) {
t.Fatal("version")
}
if !bytes.Contains(hs, []byte(AuthPluginNative)) {
t.Fatal("plugin")
}
token := nativePasswordToken("p", scramble)
resp := buildClientAuth("root", token, "app", clientProtocol41|clientSecureConnection|clientPluginAuth|clientConnectWithDB|clientPluginAuthLenencClientData)
hr, err := parseHandshakeResponse(resp)
if err != nil {
t.Fatal(err)
}
if hr.Username != "root" || hr.Database != "app" || !bytes.Equal(hr.Auth, token) {
t.Fatalf("%+v", hr)
}
}
func TestTextRowNULLAndBigint(t *testing.T) {
row := encodeTextRow([]navicat.Cell{
{Null: true},
{Bytes: []byte("18446744073709551615")},
{Bytes: []byte("1.50")},
{Bytes: []byte("你好")},
})
if row[0] != markerNull {
t.Fatal("null marker")
}
off := 1
b, off, err := readLenencString(row, off)
if err != nil || string(b) != "18446744073709551615" {
t.Fatal(string(b), err)
}
b, off, err = readLenencString(row, off)
if string(b) != "1.50" {
t.Fatal(string(b))
}
b, _, err = readLenencString(row, off)
if string(b) != "你好" {
t.Fatal(string(b))
}
}
func buildClientAuth(user string, token []byte, db string, caps uint32) []byte {
b := appendU32(nil, caps)
b = appendU32(b, MaxPacketSize)
b = append(b, charsetUTF8MB4)
b = append(b, make([]byte, 23)...)
b = appendNUL(b, user)
b = appendLenencInt(b, uint64(len(token)))
b = append(b, token...)
if caps&clientConnectWithDB != 0 {
b = appendNUL(b, db)
}
if caps&clientPluginAuth != 0 {
b = appendNUL(b, AuthPluginNative)
}
return b
}
+95
View File
@@ -0,0 +1,95 @@
package mysqlserver
import (
"mysql-tunnel/internal/navicat"
)
func encodeColumnCount(n int) []byte {
return appendLenencInt(nil, uint64(n))
}
func encodeColumn(f navicat.Field) []byte {
typ := byte(f.Type)
charset := uint16(charsetUTF8MB4)
if isNumericType(typ) || f.Flags&uint32(flagBinary) != 0 && isBlobType(typ) {
charset = uint16(charsetBinary)
}
if isNumericType(typ) {
charset = uint16(charsetBinary)
}
length := f.Length
if length == 0 {
length = uint32(len(f.Name))
if length == 0 {
length = 64
}
}
flags := uint16(f.Flags)
decimals := byte(0)
if typ == typeFloat || typ == typeDouble || typ == typeNewDecimal || typ == typeDecimal {
decimals = 31
}
b := appendLenencString(nil, []byte("def"))
b = appendLenencString(b, nil) // schema
b = appendLenencString(b, []byte(f.Table))
b = appendLenencString(b, []byte(f.Table))
b = appendLenencString(b, []byte(f.Name))
b = appendLenencString(b, []byte(f.Name))
b = appendLenencInt(b, 0x0c)
b = appendU16(b, charset)
b = appendU32(b, length)
b = append(b, typ)
b = appendU16(b, flags)
b = append(b, decimals, 0x00, 0x00)
return b
}
func encodeTextRow(row []navicat.Cell) []byte {
var b []byte
for _, c := range row {
if c.Null {
b = append(b, markerNull)
continue
}
b = appendLenencString(b, c.Bytes)
}
return b
}
func isBlobType(t byte) bool {
switch t {
case typeTinyBlob, typeMediumBlob, typeLongBlob, typeBlob:
return true
}
return false
}
func (p *packetConn) writeResultSet(res navicat.Result, caps uint32, status uint16) error {
if err := p.writePacket(encodeColumnCount(len(res.Fields))); err != nil {
return err
}
for _, f := range res.Fields {
if err := p.writePacket(encodeColumn(f)); err != nil {
return err
}
}
if caps&clientDeprecateEOF != 0 {
if err := p.writeOK(0, 0, status, 0); err != nil {
return err
}
} else {
if err := p.writePacket(encodeEOF(0, status)); err != nil {
return err
}
}
for _, row := range res.Rows {
if err := p.writePacket(encodeTextRow(row)); err != nil {
return err
}
}
if caps&clientDeprecateEOF != 0 {
return p.writeOK(uint64(res.AffectedRows), uint64(res.InsertID), status, 0)
}
return p.writePacket(encodeEOF(0, status))
}
+194
View File
@@ -0,0 +1,194 @@
package mysqlserver
import (
"context"
"fmt"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
"mysql-tunnel/internal/logger"
)
type Options struct {
Host string
Port int
Username string
Password string
DefaultDB string
MaxConnections int
Timeout time.Duration
LogSQL bool
}
type Server struct {
opt Options
backend Backend
log *logger.Logger
ln net.Listener
connID atomic.Uint32
sem chan struct{}
wg sync.WaitGroup
}
func New(opt Options, backend Backend, log *logger.Logger) *Server {
if opt.Host == "" {
opt.Host = "127.0.0.1"
}
if opt.Port == 0 {
opt.Port = 13306
}
if opt.MaxConnections < 1 {
opt.MaxConnections = 5
}
if opt.Timeout <= 0 {
opt.Timeout = 30 * time.Second
}
return &Server{
opt: opt,
backend: backend,
log: log,
sem: make(chan struct{}, opt.MaxConnections),
}
}
func (s *Server) Addr() string {
if s.ln != nil {
return s.ln.Addr().String()
}
return net.JoinHostPort(s.opt.Host, strconv.Itoa(s.opt.Port))
}
func (s *Server) ListenAndServe(ctx context.Context) error {
ln, err := net.Listen("tcp", net.JoinHostPort(s.opt.Host, strconv.Itoa(s.opt.Port)))
if err != nil {
return err
}
return s.Serve(ctx, ln)
}
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
s.ln = ln
if s.log != nil {
s.log.Info("server started", ln.Addr().String())
}
go func() {
<-ctx.Done()
_ = ln.Close()
}()
for {
c, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
s.wg.Wait()
return nil
default:
return err
}
}
select {
case s.sem <- struct{}{}:
default:
_ = c.Close()
if s.log != nil {
s.log.Warn("max connections reached, rejected", c.RemoteAddr().String())
}
continue
}
s.wg.Add(1)
go func(c net.Conn) {
defer s.wg.Done()
defer func() { <-s.sem }()
s.handle(ctx, c)
}(c)
}
}
func (s *Server) handle(ctx context.Context, c net.Conn) {
pc := newPacketConn(c, s.opt.Timeout)
remote := pc.remote()
if s.log != nil {
s.log.Info("client connected", remote)
}
defer func() {
_ = pc.close()
if s.log != nil {
s.log.Info("client disconnected", remote)
}
}()
scramble, err := randomScramble()
if err != nil {
return
}
id := s.connID.Add(1)
if err := pc.writePacket(buildHandshake(id, scramble)); err != nil {
return
}
payload, err := pc.readPacket()
if err != nil {
return
}
hr, err := parseHandshakeResponse(payload)
if err != nil {
_ = pc.writeERR(errUnknown, "bad handshake")
return
}
if hr.SSL {
_ = pc.writeERR(errNotSupported, "SSL is not supported on the local tunnel")
return
}
auth := hr.Auth
plugin := hr.Plugin
if plugin == "" {
plugin = AuthPluginNative
}
if plugin != AuthPluginNative {
if err := pc.writePacket(encodeAuthSwitch(AuthPluginNative, scramble)); err != nil {
return
}
auth, err = pc.readPacket()
if err != nil {
return
}
if len(auth) > 0 && auth[len(auth)-1] == 0 && len(auth) != 20 {
auth = auth[:len(auth)-1]
}
}
db := hr.Database
if db == "" {
db = s.opt.DefaultDB
}
if len(auth) == 21 && auth[20] == 0 {
auth = auth[:20]
}
if !usernamesEqual(hr.Username, s.opt.Username) || !verifyNativePassword(s.opt.Password, scramble, auth) {
_ = pc.writeERR(errAccessDenied, fmt.Sprintf("Access denied for user '%s'@'localhost'", hr.Username))
return
}
if err := pc.writeOK(0, 0, serverStatusAutocommit, 0); err != nil {
return
}
sess := &session{
pc: pc,
backend: s.backend,
log: s.log,
logSQL: s.opt.LogSQL,
timeout: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, s.opt.Timeout)
},
user: hr.Username,
db: db,
defaultDB: s.opt.DefaultDB,
caps: hr.Capabilities,
status: serverStatusAutocommit,
scramble: scramble,
}
sess.serve(ctx)
}
+423
View File
@@ -0,0 +1,423 @@
package mysqlserver
import (
"context"
"fmt"
"net"
"net/http/httptest"
"strings"
"testing"
"time"
"mysql-tunnel/internal/navicat"
"mysql-tunnel/internal/tunnel"
)
type stubBackend struct{}
func (stubBackend) Query(_ context.Context, _ string, sqls ...string) (*navicat.QueryResponse, error) {
var results []navicat.Result
for _, sql := range sqls {
s := strings.TrimSpace(sql)
u := strings.ToUpper(s)
switch {
case strings.HasPrefix(u, "SET NAMES"), strings.HasPrefix(u, "USE "):
results = append(results, navicat.Result{})
case u == "SELECT 1" || strings.HasPrefix(u, "SELECT 1 "):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "1", Type: 8, Length: 20}},
Rows: [][]navicat.Cell{{{Bytes: []byte("1")}}},
})
case strings.Contains(u, "SELECT NULL"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "n", Type: 6}},
Rows: [][]navicat.Cell{{{Null: true}}},
})
case strings.Contains(strings.ToLower(s), "bigint_val"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "bigint_val", Type: 8, Flags: 32, Length: 20}},
Rows: [][]navicat.Cell{{{Bytes: []byte("18446744073709551615")}}},
})
case strings.Contains(strings.ToLower(s), "dec_val"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "dec_val", Type: 246, Length: 32}},
Rows: [][]navicat.Cell{{{Bytes: []byte("1234567890.123456789")}}},
})
case strings.Contains(s, "你好") || strings.Contains(strings.ToLower(s), "zh_val"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "zh_val", Type: 253, Length: 12}},
Rows: [][]navicat.Cell{{{Bytes: []byte("你好世界")}}},
})
case strings.HasPrefix(u, "UPDATE"), strings.HasPrefix(u, "INSERT"):
results = append(results, navicat.Result{AffectedRows: 2, InsertID: 7})
case strings.Contains(u, "SYNTAX"):
results = append(results, navicat.Result{Errno: 1064, Message: "You have an error in your SQL syntax"})
default:
results = append(results, navicat.Result{Errno: 1064, Message: "stub: unhandled SQL"})
}
}
return &navicat.QueryResponse{Results: results}, nil
}
func startServer(t *testing.T, backend Backend, user, pass string) (addr string, cancel context.CancelFunc) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
s := New(Options{
Host: "127.0.0.1", Port: 1, Username: user, Password: pass,
DefaultDB: "app", MaxConnections: 5, Timeout: 5 * time.Second,
}, backend, nil)
errCh := make(chan error, 1)
go func() { errCh <- s.Serve(ctx, ln) }()
t.Cleanup(func() {
cancel()
_ = ln.Close()
})
return ln.Addr().String(), cancel
}
type testClient struct {
pc *packetConn
caps uint32
}
func dialTest(t *testing.T, addr, user, pass, db string) *testClient {
t.Helper()
c, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
t.Fatal(err)
}
pc := newPacketConn(c, 5*time.Second)
hs, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
scramble, err := scrambleFromHandshake(hs)
if err != nil {
t.Fatal(err)
}
caps := clientProtocol41 | clientSecureConnection | clientPluginAuth |
clientConnectWithDB | clientPluginAuthLenencClientData | clientDeprecateEOF | clientMultiResults
token := nativePasswordToken(pass, scramble)
if err := pc.writePacket(buildClientAuth(user, token, db, caps)); err != nil {
t.Fatal(err)
}
ok, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if len(ok) == 0 || ok[0] != markerOK {
t.Fatalf("auth failed: %x %q", ok, ok)
}
return &testClient{pc: pc, caps: caps}
}
func scrambleFromHandshake(payload []byte) ([]byte, error) {
if len(payload) < 20 || payload[0] != ProtocolVersion {
return nil, fmt.Errorf("bad handshake")
}
off := 1
_, off, err := readNUL(payload, off)
if err != nil {
return nil, err
}
off += 4 // conn id
part1 := payload[off : off+8]
off += 8 + 1 + 2 + 1 + 2 + 2 + 1 + 10
if off+13 > len(payload) {
return nil, fmt.Errorf("short scramble")
}
part2 := payload[off : off+12]
s := append(append([]byte{}, part1...), part2...)
return s, nil
}
func (c *testClient) query(t *testing.T, sql string) (cols []string, rows [][]*string, affected, lastID uint64, errno int, errMsg string) {
t.Helper()
c.pc.resetSeq()
payload := append([]byte{ComQuery}, []byte(sql)...)
if err := c.pc.writePacket(payload); err != nil {
t.Fatal(err)
}
pkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
if len(pkt) == 0 {
t.Fatal("empty")
}
if pkt[0] == markerERR {
errno = int(pkt[1]) | int(pkt[2])<<8
if len(pkt) > 9 {
errMsg = string(pkt[9:])
}
return
}
if pkt[0] == markerOK {
n, off, _ := readLenencInt(pkt, 1)
id, _, _ := readLenencInt(pkt, off)
return nil, nil, n, id, 0, ""
}
nCols, _, err := readLenencInt(pkt, 0)
if err != nil {
t.Fatal(err)
}
for i := uint64(0); i < nCols; i++ {
colPkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
name := columnName(colPkt)
cols = append(cols, name)
}
// metadata end OK/EOF
if _, err := c.pc.readPacket(); err != nil {
t.Fatal(err)
}
for {
rowPkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
if len(rowPkt) == 0 {
break
}
if rowPkt[0] == markerERR {
errno = int(rowPkt[1]) | int(rowPkt[2])<<8
return
}
if rowPkt[0] == markerEOF && len(rowPkt) < 9 {
break
}
if rowPkt[0] == markerOK && len(rowPkt) >= 7 {
break
}
off := 0
row := make([]*string, nCols)
for i := uint64(0); i < nCols; i++ {
if off >= len(rowPkt) {
t.Fatal("short row")
}
if rowPkt[off] == markerNull {
row[i] = nil
off++
continue
}
b, nOff, err := readLenencString(rowPkt, off)
if err != nil {
t.Fatal(err)
}
s := string(b)
row[i] = &s
off = nOff
}
rows = append(rows, row)
}
return
}
func (c *testClient) ping(t *testing.T) {
t.Helper()
c.pc.resetSeq()
if err := c.pc.writePacket([]byte{ComPing}); err != nil {
t.Fatal(err)
}
pkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
if pkt[0] != markerOK {
t.Fatalf("ping %x", pkt)
}
}
func (c *testClient) quit() {
c.pc.resetSeq()
_ = c.pc.writePacket([]byte{ComQuit})
_ = c.pc.close()
}
func columnName(pkt []byte) string {
off := 0
skip := func() {
_, n, err := readLenencString(pkt, off)
if err != nil {
return
}
off = n
}
skip() // catalog
skip() // schema
skip() // table
skip() // org table
b, _, err := readLenencString(pkt, off)
if err != nil {
return ""
}
return string(b)
}
func TestServerAuthPingQueryQuit(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "123456")
cl := dialTest(t, addr, "root", "123456", "app")
cl.ping(t)
_, rows, _, _, errno, msg := cl.query(t, "SELECT 1")
if errno != 0 {
t.Fatalf("errno %d %s", errno, msg)
}
if len(rows) != 1 || rows[0][0] == nil || *rows[0][0] != "1" {
t.Fatalf("rows=%v", rows)
}
cl.quit()
}
func TestServerBadPassword(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "123456")
c, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
pc := newPacketConn(c, 5*time.Second)
hs, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
scramble, err := scrambleFromHandshake(hs)
if err != nil {
t.Fatal(err)
}
caps := clientProtocol41 | clientSecureConnection | clientPluginAuth | clientPluginAuthLenencClientData
tok := nativePasswordToken("wrong", scramble)
if err := pc.writePacket(buildClientAuth("root", tok, "", caps)); err != nil {
t.Fatal(err)
}
pkt, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if pkt[0] != markerERR {
t.Fatalf("want ERR got %x", pkt)
}
errno := int(pkt[1]) | int(pkt[2])<<8
if errno != int(errAccessDenied) {
t.Fatalf("errno %d", errno)
}
}
func TestServerResultTypes(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "p")
cl := dialTest(t, addr, "root", "p", "app")
defer cl.quit()
_, rows, _, _, errno, _ := cl.query(t, "SELECT NULL AS n")
if errno != 0 || rows[0][0] != nil {
t.Fatalf("null errno=%d row=%v", errno, rows)
}
_, rows, _, _, errno, _ = cl.query(t, "SELECT bigint_val")
if errno != 0 || *rows[0][0] != "18446744073709551615" {
t.Fatalf("bigint %v errno=%d", rows, errno)
}
_, rows, _, _, errno, _ = cl.query(t, "SELECT dec_val")
if errno != 0 || *rows[0][0] != "1234567890.123456789" {
t.Fatalf("dec %v", rows)
}
_, rows, _, _, errno, _ = cl.query(t, "SELECT zh_val")
if errno != 0 || *rows[0][0] != "你好世界" {
t.Fatalf("zh %v", rows)
}
_, _, affected, lastID, errno, msg := cl.query(t, "UPDATE t SET x=1")
if errno != 0 {
t.Fatal(msg)
}
if affected != 2 || lastID != 7 {
t.Fatalf("affected=%d id=%d", affected, lastID)
}
_, _, _, _, errno, msg = cl.query(t, "SELECT SYNTAX")
if errno != 1064 || !strings.Contains(msg, "syntax") {
t.Fatalf("errno=%d msg=%s", errno, msg)
}
}
func TestServerAuthSwitch(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "p")
c, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
pc := newPacketConn(c, 5*time.Second)
hs, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
scramble, err := scrambleFromHandshake(hs)
if err != nil {
t.Fatal(err)
}
caps := clientProtocol41 | clientSecureConnection | clientPluginAuth | clientPluginAuthLenencClientData
b := appendU32(nil, caps)
b = appendU32(b, MaxPacketSize)
b = append(b, charsetUTF8MB4)
b = append(b, make([]byte, 23)...)
b = appendNUL(b, "root")
b = appendLenencInt(b, 0) // empty, will switch
b = appendNUL(b, AuthPluginSHA2)
if err := pc.writePacket(b); err != nil {
t.Fatal(err)
}
sw, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if sw[0] != markerEOF {
t.Fatalf("want auth switch %x", sw)
}
tok := nativePasswordToken("p", scramble)
if err := pc.writePacket(tok); err != nil {
t.Fatal(err)
}
ok, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if ok[0] != markerOK {
t.Fatalf("auth after switch %x", ok)
}
}
func TestPhase4NavicatStack(t *testing.T) {
mock := httptest.NewServer(navicat.NewMockHandler(navicat.WithToken("t")))
t.Cleanup(mock.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
n := navicat.NewClient(httpClient, navicat.ClientOptions{
URL: mock.URL + "?token=t",
Target: navicat.Target{
Host: "mysql", Port: 3306, User: "remote", Password: "rp",
Database: "app", Charset: "utf8mb4",
},
})
addr, _ := startServer(t, n, "root", "local")
cl := dialTest(t, addr, "root", "local", "app")
defer cl.quit()
_, rows, _, _, errno, msg := cl.query(t, "SELECT 1")
if errno != 0 {
t.Fatal(msg)
}
if *rows[0][0] != "1" {
t.Fatalf("%v", rows)
}
}
+168
View File
@@ -0,0 +1,168 @@
package mysqlserver
import (
"context"
"fmt"
"regexp"
"strings"
"mysql-tunnel/internal/logger"
"mysql-tunnel/internal/navicat"
)
var useDBRe = regexp.MustCompile(`(?is)^\s*USE\s+(?:` + "`" + `([^` + "`" + `]+)` + "`" + `|([A-Za-z0-9_]+))\s*;?\s*$`)
type Backend interface {
Query(ctx context.Context, database string, sqls ...string) (*navicat.QueryResponse, error)
}
type session struct {
pc *packetConn
backend Backend
log *logger.Logger
logSQL bool
timeout ctxTimeout
user string
db string
defaultDB string
caps uint32
status uint16
scramble []byte
}
type ctxTimeout func() (context.Context, context.CancelFunc)
func (s *session) serve(ctx context.Context) {
defer s.pc.close()
for {
select {
case <-ctx.Done():
return
default:
}
s.pc.resetSeq()
payload, err := s.pc.readPacket()
if err != nil {
return
}
if len(payload) == 0 {
_ = s.pc.writeERR(errUnknownCommand, "empty command")
continue
}
if err := s.dispatch(ctx, payload[0], payload[1:]); err != nil {
return
}
}
}
func (s *session) dispatch(parent context.Context, cmd byte, body []byte) error {
switch cmd {
case ComQuit:
return fmt.Errorf("quit")
case ComPing:
return s.pc.writeOK(0, 0, s.status, 0)
case ComInitDB:
db := strings.TrimSpace(string(body))
return s.changeDB(parent, db)
case ComQuery:
return s.query(parent, string(body))
case ComResetConnection:
s.db = s.defaultDB
return s.pc.writeOK(0, 0, s.status, 0)
case ComFieldList, ComStmtPrepare, ComStmtExecute, ComStmtClose:
return s.pc.writeERR(errNotSupported, "command not implemented yet")
default:
return s.pc.writeERR(errUnknownCommand, "unknown command")
}
}
func (s *session) changeDB(parent context.Context, db string) error {
if db == "" {
return s.pc.writeERR(errNoDB, "no database selected")
}
s.db = db
return s.pc.writeOK(0, 0, s.status, 0)
}
func (s *session) query(parent context.Context, sql string) error {
sql = strings.TrimRight(sql, "\x00")
s.logQuery(sql)
pendingDB := ""
if m := useDBRe.FindStringSubmatch(sql); m != nil {
pendingDB = m[1]
if pendingDB == "" {
pendingDB = m[2]
}
}
ctx, cancel := s.timeout()
defer cancel()
if ctx == nil {
ctx = parent
}
resp, err := s.backend.Query(ctx, s.db, sql)
if err != nil {
return s.writeBackendErr(err)
}
for _, r := range resp.Results {
if r.IsError() {
return s.pc.writeERR(uint16(r.Errno), r.Message)
}
}
if pendingDB != "" {
s.db = pendingDB
}
res, ok := navicat.LastResultSet(resp)
if !ok {
return s.pc.writeOK(0, 0, s.status, 0)
}
if res.IsError() {
return s.pc.writeERR(uint16(res.Errno), res.Message)
}
if res.IsResultSet() {
if s.log != nil {
s.log.Info("query completed", fmt.Sprintf("rows=%d", len(res.Rows)))
}
return s.pc.writeResultSet(res, s.caps, s.status)
}
if s.log != nil {
s.log.Info("query completed", fmt.Sprintf("affected=%d", res.AffectedRows))
}
return s.pc.writeOK(uint64(res.AffectedRows), uint64(res.InsertID), s.status, 0)
}
func (s *session) writeBackendErr(err error) error {
msg := logger.RedactError(err)
if ne, ok := err.(*navicat.Error); ok {
errno := uint16(ne.Errno)
if errno == 0 {
errno = errUnknown
}
if ne.Kind == "http" || ne.Kind == "tunnel" {
errno = errUnknown
}
return s.pc.writeERR(errno, msg)
}
return s.pc.writeERR(errUnknown, msg)
}
func (s *session) logQuery(sql string) {
if s.log == nil {
return
}
if !s.logSQL {
s.log.Info("query")
return
}
s.log.Info("query", trimSQL(sql))
}
func trimSQL(sql string) string {
s := logger.RedactText(sql)
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > 240 {
return s[:240] + "..."
}
return s
}