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
+76
View File
@@ -0,0 +1,76 @@
package navicat
import (
"encoding/binary"
"fmt"
)
func putU32(n uint32) []byte {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, n)
return b
}
func putU16(n uint16) []byte {
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, n)
return b
}
// AppendBlock encodes GetBlock() from ntunnel_mysql.php.
// NULL is not represented here; use AppendNull for row cells.
func AppendBlock(dst []byte, val []byte) []byte {
if val == nil {
val = []byte{}
}
n := len(val)
if n < 254 {
dst = append(dst, byte(n))
return append(dst, val...)
}
dst = append(dst, markLong)
dst = append(dst, putU32(uint32(n))...)
return append(dst, val...)
}
func AppendNull(dst []byte) []byte {
return append(dst, markNull)
}
func AppendHeader(dst []byte, version uint16, errno uint32) []byte {
dst = append(dst, putU32(Magic)...)
dst = append(dst, putU16(version)...)
dst = append(dst, putU32(errno)...)
dst = append(dst, 0, 0, 0, 0, 0, 0)
return dst
}
func AppendResultSetHeader(dst []byte, errno, affected, insertID, numFields, numRows uint32) []byte {
dst = append(dst, putU32(errno)...)
dst = append(dst, putU32(affected)...)
dst = append(dst, putU32(insertID)...)
dst = append(dst, putU32(numFields)...)
dst = append(dst, putU32(numRows)...)
dst = append(dst, make([]byte, 12)...)
return dst
}
func AppendField(dst []byte, name, table string, typ, flags, length uint32) []byte {
dst = AppendBlock(dst, []byte(name))
dst = AppendBlock(dst, []byte(table))
dst = append(dst, putU32(typ)...)
dst = append(dst, putU32(flags)...)
dst = append(dst, putU32(length)...)
return dst
}
func knownVersion(v uint16) bool {
return v == Version202 || v == Version206
}
func fmtErrno(errno uint32, msg string) string {
if msg == "" {
return fmt.Sprintf("tunnel errno %d", errno)
}
return fmt.Sprintf("tunnel errno %d: %s", errno, msg)
}
+177
View File
@@ -0,0 +1,177 @@
package navicat
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"mysql-tunnel/internal/logger"
"mysql-tunnel/internal/tunnel"
)
var charsetIdent = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
// Target is the remote MySQL as seen by ntunnel_mysql.php.
type Target struct {
Host string
Port int
User string
Password string
Database string
Charset string
}
// Client talks Navicat HTTP Tunnel over the generic HTTP client.
type Client struct {
http *tunnel.HTTPClient
url string
target Target
encodeBase64 bool
retries int
}
type ClientOptions struct {
URL string
Target Target
EncodeBase64 bool
// Retries is extra attempts on transport failure only (not SQL errors). 0 means a single try.
Retries int
}
func NewClient(httpClient *tunnel.HTTPClient, opt ClientOptions) *Client {
retries := opt.Retries
if retries < 0 {
retries = 0
}
return &Client{
http: httpClient,
url: opt.URL,
target: opt.Target,
encodeBase64: opt.EncodeBase64,
retries: retries,
}
}
func (c *Client) Connect(ctx context.Context) (*ConnectResponse, error) {
body, status, err := c.post(ctx, Request{
Action: ActionConnect,
Host: c.target.Host,
Port: c.target.Port,
Login: c.target.User,
Password: c.target.Password,
Database: c.target.Database,
EncodeBase64: c.encodeBase64,
})
if err != nil {
return nil, err
}
resp, err := ParseConnect(body)
if err != nil {
return nil, err
}
if !resp.Header.OK() {
return resp, headerError(resp.Header, status)
}
return resp, nil
}
func (c *Client) Query(ctx context.Context, database string, sqls ...string) (*QueryResponse, error) {
db := database
if db == "" {
db = c.target.Database
}
queries := make([]string, 0, len(sqls)+1)
if s := charsetSQL(c.target.Charset); s != "" {
queries = append(queries, s)
}
queries = append(queries, nonEmpty(sqls)...)
if len(queries) == 0 {
return nil, fmt.Errorf("no SQL to execute")
}
body, status, err := c.post(ctx, Request{
Action: ActionQuery,
Host: c.target.Host,
Port: c.target.Port,
Login: c.target.User,
Password: c.target.Password,
Database: db,
Queries: queries,
EncodeBase64: c.encodeBase64,
})
if err != nil {
return nil, err
}
resp, err := ParseQuery(body)
if err != nil {
return nil, err
}
if !resp.Header.OK() {
return resp, headerError(resp.Header, status)
}
return resp, nil
}
func (c *Client) post(ctx context.Context, req Request) (body []byte, status int, err error) {
encoded := req.Encode()
attempts := 1 + c.retries
var last error
for i := 0; i < attempts; i++ {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
httpResp, err := c.http.Do(ctx, http.MethodPost, c.url, "application/x-www-form-urlencoded", strings.NewReader(encoded))
if err != nil {
last = err
continue
}
status = httpResp.StatusCode
body = httpResp.Body
if status == http.StatusForbidden {
return body, status, &Error{Kind: "http", HTTPStatus: status, Errno: ErrnoInvalidToken, Message: "invalid or missing token"}
}
if status < 200 || status >= 300 {
return body, status, &Error{Kind: "http", HTTPStatus: status, Message: "unexpected status"}
}
if looksHTML(body) {
return body, status, &Error{Kind: "http", HTTPStatus: status, Message: "tunnel returned HTML, not Navicat binary"}
}
return body, status, nil
}
return nil, 0, last
}
func charsetSQL(cs string) string {
cs = strings.TrimSpace(cs)
if cs == "" {
return ""
}
if strings.EqualFold(cs, "utf8") || strings.EqualFold(cs, "utf8mb3") {
return ""
}
if !charsetIdent.MatchString(cs) {
return ""
}
return "SET NAMES " + cs
}
// LastResultSet returns the last result that has columns (skipping SET NAMES OK packets).
func LastResultSet(resp *QueryResponse) (Result, bool) {
if resp == nil {
return Result{}, false
}
for i := len(resp.Results) - 1; i >= 0; i-- {
if resp.Results[i].IsResultSet() {
return resp.Results[i], true
}
}
if len(resp.Results) == 0 {
return Result{}, false
}
return resp.Results[len(resp.Results)-1], true
}
func RedactedURL(raw string) string {
return logger.RedactURL(raw)
}
+196
View File
@@ -0,0 +1,196 @@
package navicat
import (
"context"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"mysql-tunnel/internal/tunnel"
)
func testClient(t *testing.T, opts ...MockOption) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(NewMockHandler(opts...))
t.Cleanup(srv.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
c := NewClient(httpClient, ClientOptions{
URL: srv.URL + "?token=secret",
Target: Target{
Host: "mysql",
Port: 3306,
User: "root",
Password: "remote-pass",
Database: "app",
Charset: "utf8mb4",
},
})
return c, srv
}
func TestClientConnectAndSelect1(t *testing.T) {
var cap CapturedRequest
c, _ := testClient(t, WithToken("secret"), WithCapture(&cap))
ctx := context.Background()
conn, err := c.Connect(ctx)
if err != nil {
t.Fatal(err)
}
if conn.Conn.ServerInfo != "8.0.36-mock" {
t.Fatalf("%+v", conn.Conn)
}
if cap.Action != ActionConnect || cap.Host != "mysql" || cap.Login != "root" {
t.Fatalf("connect capture %+v", cap)
}
if cap.Port != "3306" {
t.Fatalf("port %s", cap.Port)
}
if !cap.HasPassword {
t.Fatal("password field missing")
}
q, err := c.Query(ctx, "", "SELECT 1")
if err != nil {
t.Fatal(err)
}
res, ok := LastResultSet(q)
if !ok || res.Rows[0][0].Text() != "1" {
t.Fatalf("select 1: %+v", q)
}
if cap.Action != ActionQuery {
t.Fatalf("query capture %+v", cap)
}
joined := strings.Join(cap.Queries, " | ")
if !strings.Contains(joined, "SET NAMES utf8mb4") {
t.Fatalf("expected SET NAMES in %q", joined)
}
if !strings.Contains(joined, "SELECT 1") {
t.Fatalf("expected SELECT 1 in %q", joined)
}
}
func TestClientTypesOverHTTP(t *testing.T) {
c, _ := testClient(t, WithToken("secret"))
ctx := context.Background()
q, err := c.Query(ctx, "app", "SELECT NULL AS n")
if err != nil {
t.Fatal(err)
}
res, _ := LastResultSet(q)
if !res.Rows[0][0].Null {
t.Fatal("NULL")
}
q, err = c.Query(ctx, "", "SELECT bigint_val")
if err != nil {
t.Fatal(err)
}
res, _ = LastResultSet(q)
got := res.Rows[0][0].Text()
if got != "18446744073709551615" {
t.Fatalf("bigint %q", got)
}
if _, err := strconv.ParseInt(got, 10, 64); err == nil {
t.Fatal("test value should overflow int64")
}
q, err = c.Query(ctx, "", "SELECT dec_val")
if err != nil {
t.Fatal(err)
}
res, _ = LastResultSet(q)
if res.Fields[0].Type != 246 || res.Rows[0][0].Text() != "1234567890.123456789" {
t.Fatalf("decimal %+v", res)
}
q, err = c.Query(ctx, "", "SELECT zh_val")
if err != nil {
t.Fatal(err)
}
res, _ = LastResultSet(q)
if res.Rows[0][0].Text() != "你好世界" {
t.Fatalf("zh %q", res.Rows[0][0].Text())
}
}
func TestClientSQLError(t *testing.T) {
c, _ := testClient(t, WithToken("secret"))
q, err := c.Query(context.Background(), "", "SELECT SYNTAX")
if err != nil {
t.Fatal(err)
}
res, ok := LastResultSet(q)
if !ok || !res.IsError() || res.Errno != 1064 {
t.Fatalf("%+v", q)
}
}
func TestClientDML(t *testing.T) {
c, _ := testClient(t, WithToken("secret"))
q, err := c.Query(context.Background(), "", "UPDATE product SET price=1")
if err != nil {
t.Fatal(err)
}
res := q.Results[len(q.Results)-1]
if res.IsResultSet() || res.AffectedRows != 1 || res.InsertID != 42 {
t.Fatalf("%+v", res)
}
}
func TestClientBadTokenPOST(t *testing.T) {
srv := httptest.NewServer(NewMockHandler(WithToken("right")))
t.Cleanup(srv.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
c := NewClient(httpClient, ClientOptions{
URL: srv.URL + "?token=wrong",
Target: Target{Host: "mysql", Port: 3306, User: "root"},
})
_, err := c.Connect(context.Background())
if err == nil {
t.Fatal("expected token error")
}
ne, ok := err.(*Error)
if !ok || ne.Errno != ErrnoInvalidToken {
t.Fatalf("%v", err)
}
}
func TestClientConnectDenied(t *testing.T) {
c, _ := testClient(t, WithToken("secret"), WithConnectError(1045, "Access denied for user 'root'@'localhost'"))
_, err := c.Connect(context.Background())
if err == nil {
t.Fatal("expected deny")
}
if !strings.Contains(err.Error(), "1045") {
t.Fatal(err)
}
if strings.Contains(err.Error(), "remote-pass") {
t.Fatal("password leaked")
}
}
func TestEncodeBase64Query(t *testing.T) {
var cap CapturedRequest
srv := httptest.NewServer(NewMockHandler(WithToken("secret"), WithCapture(&cap)))
t.Cleanup(srv.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
c := NewClient(httpClient, ClientOptions{
URL: srv.URL + "?token=secret",
Target: Target{Host: "mysql", Port: 3306, User: "root", Charset: "utf8"},
EncodeBase64: true,
})
_, err := c.Query(context.Background(), "", "SELECT 1")
if err != nil {
t.Fatal(err)
}
if cap.EncodeBase64 != "1" {
t.Fatalf("%+v", cap)
}
if strings.Contains(strings.Join(cap.Queries, ""), "SELECT 1") {
t.Fatalf("wire should be base64, got %v", cap.Queries)
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package navicat implements the Navicat HTTP Tunnel protocol used by
// ntunnel_mysql.php (script version 206 in this repository).
//
// Every request is stateless: the PHP script opens a new MySQL connection,
// runs actn=C or actn=Q, and disconnects. See docs/PROTOCOL.md.
package navicat
const (
Magic uint32 = 1111
Version202 uint16 = 202
Version206 uint16 = 206
HeaderSize = 16
ActionConnect = "C"
ActionQuery = "Q"
ErrnoUnsupportedPHP uint32 = 201
ErrnoInvalidParams uint32 = 202
ErrnoMySQLMissing uint32 = 203
ErrnoInvalidToken uint32 = 204
sepMore byte = 0x01
sepLast byte = 0x00
markLong byte = 0xFE
markNull byte = 0xFF
)
+45
View File
@@ -0,0 +1,45 @@
package navicat
import "fmt"
// Error is a tunnel-layer or remote MySQL error. It never includes passwords or tokens.
type Error struct {
Kind string // http, tunnel, mysql
HTTPStatus int
Errno uint32
Message string
}
func (e *Error) Error() string {
if e == nil {
return ""
}
msg := e.Message
if msg == "" {
msg = "unknown error"
}
switch e.Kind {
case "http":
if e.HTTPStatus != 0 {
return fmt.Sprintf("tunnel http %d: %s", e.HTTPStatus, msg)
}
return "tunnel http: " + msg
case "mysql":
return fmt.Sprintf("mysql errno %d: %s", e.Errno, msg)
default:
return fmtErrno(e.Errno, msg)
}
}
func headerError(h Header, httpStatus int) *Error {
kind := "tunnel"
switch h.Errno {
case ErrnoUnsupportedPHP, ErrnoInvalidParams, ErrnoMySQLMissing, ErrnoInvalidToken:
kind = "tunnel"
default:
if h.Errno > 0 {
kind = "mysql"
}
}
return &Error{Kind: kind, HTTPStatus: httpStatus, Errno: h.Errno, Message: h.Message}
}
+228
View File
@@ -0,0 +1,228 @@
package navicat
import (
"encoding/base64"
"net/http"
"strings"
)
// MockOption configures the in-process ntunnel_mysql.php stand-in.
type MockOption func(*mockState)
type mockState struct {
token string
connectFail *Error
queryFn func(sql string) Result
capture *CapturedRequest
}
// CapturedRequest is the last POST seen by the mock.
type CapturedRequest struct {
Action string
Host string
Port string
Login string
Database string
Queries []string
EncodeBase64 string
HasPassword bool
}
func WithToken(token string) MockOption {
return func(m *mockState) { m.token = token }
}
func WithConnectError(errno uint32, msg string) MockOption {
return func(m *mockState) {
m.connectFail = &Error{Kind: "mysql", Errno: errno, Message: msg}
}
}
func WithQueryHandler(fn func(sql string) Result) MockOption {
return func(m *mockState) { m.queryFn = fn }
}
func WithCapture(dst *CapturedRequest) MockOption {
return func(m *mockState) { m.capture = dst }
}
// NewMockHandler implements a subset of ntunnel_mysql.php for tests.
func NewMockHandler(opts ...MockOption) http.Handler {
st := &mockState{queryFn: defaultQuery}
for _, o := range opts {
o(st)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if st.token != "" && r.URL.Query().Get("token") != st.token {
if r.PostForm.Get("actn") != "" {
writeBytes(w, encodeHeaderError(ErrnoInvalidToken, "invalid access token"))
return
}
http.Error(w, "403 Forbidden: invalid or missing token", http.StatusForbidden)
return
}
if r.Method != http.MethodPost {
writeBytes(w, encodeHeaderError(ErrnoInvalidParams, "invalid parameters"))
return
}
actn := r.PostForm.Get("actn")
host := r.PostForm.Get("host")
login := r.PostForm.Get("login")
if actn == "" || host == "" || !r.PostForm.Has("port") || login == "" {
writeBytes(w, encodeHeaderError(ErrnoInvalidParams, "invalid parameters"))
return
}
if st.capture != nil {
*st.capture = CapturedRequest{
Action: actn,
Host: host,
Port: r.PostForm.Get("port"),
Login: login,
Database: r.PostForm.Get("db"),
Queries: append([]string(nil), r.PostForm["q[]"]...),
EncodeBase64: r.PostForm.Get("encodeBase64"),
HasPassword: r.PostForm.Has("password"),
}
}
if st.connectFail != nil {
writeBytes(w, encodeHeaderError(st.connectFail.Errno, st.connectFail.Message))
return
}
switch actn {
case ActionConnect:
writeBytes(w, encodeConnectOK("127.0.0.1 via TCP/IP", "10", "8.0.36-mock"))
case ActionQuery:
queries := append([]string(nil), r.PostForm["q[]"]...)
if r.PostForm.Get("encodeBase64") == "1" {
decoded := make([]string, 0, len(queries))
for _, q := range queries {
b, err := base64.StdEncoding.DecodeString(q)
if err != nil {
writeBytes(w, encodeQueryResults([]Result{{Errno: 1064, Message: "bad base64 query"}}))
return
}
decoded = append(decoded, string(b))
}
queries = decoded
}
var results []Result
for _, q := range queries {
if q == "" {
continue
}
results = append(results, st.queryFn(q))
}
writeBytes(w, encodeQueryResults(results))
default:
writeBytes(w, AppendHeader(nil, Version206, 0))
}
})
}
func defaultQuery(sql string) Result {
s := strings.TrimSpace(sql)
u := strings.ToUpper(s)
switch {
case strings.HasPrefix(u, "SET NAMES"):
return Result{}
case u == "SELECT 1" || strings.HasPrefix(u, "SELECT 1 "):
return selectLiteral("1", "1", 8, 20)
case strings.Contains(u, "SELECT NULL"):
return Result{
NumFields: 1,
NumRows: 1,
Fields: []Field{{Name: "n", Type: 6, Length: 0}},
Rows: [][]Cell{{{Null: true}}},
}
case strings.Contains(strings.ToLower(s), "bigint_val"):
r := selectLiteral("bigint_val", "18446744073709551615", 8, 20)
r.Fields[0].Flags = 32 // UNSIGNED
return r
case strings.Contains(strings.ToLower(s), "dec_val"):
return selectLiteral("dec_val", "1234567890.123456789", 246, 32)
case strings.Contains(s, "你好") || strings.Contains(strings.ToLower(s), "zh_val"):
return selectLiteral("zh_val", "你好世界", 253, 12)
case strings.HasPrefix(u, "INSERT") || strings.HasPrefix(u, "UPDATE") || strings.HasPrefix(u, "DELETE") || strings.HasPrefix(u, "REPLACE"):
return Result{AffectedRows: 1, InsertID: 42, Info: "Rows matched: 1 Changed: 1 Warnings: 0"}
case strings.Contains(u, "SYNTAX"):
return Result{Errno: 1064, Message: "You have an error in your SQL syntax"}
default:
return Result{Errno: 1064, Message: "mock: unhandled SQL"}
}
}
func selectLiteral(name, value string, typ, length uint32) Result {
return Result{
NumFields: 1,
NumRows: 1,
Fields: []Field{{Name: name, Type: typ, Length: length}},
Rows: [][]Cell{{{Bytes: []byte(value)}}},
}
}
func encodeHeaderError(errno uint32, msg string) []byte {
b := AppendHeader(nil, Version206, errno)
return AppendBlock(b, []byte(msg))
}
func encodeConnectOK(host, proto, server string) []byte {
b := AppendHeader(nil, Version206, 0)
b = AppendBlock(b, []byte(host))
b = AppendBlock(b, []byte(proto))
b = AppendBlock(b, []byte(server))
return b
}
func encodeQueryResults(results []Result) []byte {
b := AppendHeader(nil, Version206, 0)
if len(results) == 0 {
return b
}
for i, res := range results {
b = appendQueryResult(b, res)
if i < len(results)-1 {
b = append(b, sepMore)
} else {
b = append(b, sepLast)
}
}
return b
}
func appendQueryResult(b []byte, res Result) []byte {
nfields := res.NumFields
nrows := res.NumRows
if nfields == 0 {
nfields = uint32(len(res.Fields))
}
if nrows == 0 && len(res.Rows) > 0 {
nrows = uint32(len(res.Rows))
}
b = AppendResultSetHeader(b, res.Errno, res.AffectedRows, res.InsertID, nfields, nrows)
if res.Errno > 0 {
return AppendBlock(b, []byte(res.Message))
}
if nfields == 0 {
return AppendBlock(b, []byte(res.Info))
}
for _, f := range res.Fields {
b = AppendField(b, f.Name, f.Table, f.Type, f.Flags, f.Length)
}
for _, row := range res.Rows {
for _, cell := range row {
if cell.Null {
b = AppendNull(b)
} else {
b = AppendBlock(b, cell.Bytes)
}
}
}
return b
}
func writeBytes(w http.ResponseWriter, body []byte) {
w.Header().Set("Content-Type", "text/plain; charset=x-user-defined")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
+116
View File
@@ -0,0 +1,116 @@
package navicat
import (
"encoding/binary"
"fmt"
"io"
)
type reader struct {
buf []byte
off int
}
func newReader(b []byte) *reader {
return &reader{buf: b}
}
func (r *reader) remaining() int {
return len(r.buf) - r.off
}
func (r *reader) peek() (byte, error) {
if r.off >= len(r.buf) {
return 0, io.EOF
}
return r.buf[r.off], nil
}
func (r *reader) readByte() (byte, error) {
if r.off >= len(r.buf) {
return 0, io.ErrUnexpectedEOF
}
b := r.buf[r.off]
r.off++
return b, nil
}
func (r *reader) readN(n int) ([]byte, error) {
if n < 0 {
return nil, fmt.Errorf("negative length")
}
if r.off+n > len(r.buf) {
return nil, io.ErrUnexpectedEOF
}
s := r.buf[r.off : r.off+n]
r.off += n
return s, nil
}
func (r *reader) u16() (uint16, error) {
b, err := r.readN(2)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint16(b), nil
}
func (r *reader) u32() (uint32, error) {
b, err := r.readN(4)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint32(b), nil
}
// block reads GetBlock. nullable allows 0xFF as SQL NULL (EchoData only).
func (r *reader) block(nullable bool) (val []byte, isNull bool, err error) {
if r.off >= len(r.buf) {
return nil, false, io.ErrUnexpectedEOF
}
first := r.buf[r.off]
if nullable && first == markNull {
r.off++
return nil, true, nil
}
r.off++
var n int
if first < 254 {
n = int(first)
} else if first == markLong {
ln, err := r.u32()
if err != nil {
return nil, false, err
}
if ln > 64<<20 {
return nil, false, fmt.Errorf("block length %d exceeds 64MiB limit", ln)
}
n = int(ln)
} else {
return nil, false, fmt.Errorf("invalid block marker 0x%02x", first)
}
b, err := r.readN(n)
if err != nil {
return nil, false, err
}
return b, false, nil
}
func (r *reader) blockBytes() ([]byte, error) {
b, isNull, err := r.block(false)
if err != nil {
return nil, err
}
if isNull {
return nil, fmt.Errorf("unexpected NULL block")
}
return b, nil
}
func (r *reader) blockString() (string, error) {
b, err := r.blockBytes()
if err != nil {
return "", err
}
return string(b), nil
}
+205
View File
@@ -0,0 +1,205 @@
package navicat
import (
"bytes"
"strings"
"testing"
)
func TestBlockRoundTripShortAndLong(t *testing.T) {
cases := [][]byte{
{},
[]byte("a"),
bytes.Repeat([]byte("x"), 253),
bytes.Repeat([]byte("y"), 254),
bytes.Repeat([]byte("z"), 1024),
[]byte("你好世界"),
}
for _, in := range cases {
enc := AppendBlock(nil, in)
r := newReader(enc)
got, isNull, err := r.block(false)
if err != nil {
t.Fatalf("len=%d: %v", len(in), err)
}
if isNull {
t.Fatal("null")
}
if !bytes.Equal(got, in) {
t.Fatalf("mismatch len=%d", len(in))
}
}
}
func TestNullCell(t *testing.T) {
enc := AppendNull(nil)
r := newReader(enc)
_, isNull, err := r.block(true)
if err != nil || !isNull {
t.Fatalf("err=%v null=%v", err, isNull)
}
}
func TestHeader202And206(t *testing.T) {
for _, ver := range []uint16{Version202, Version206} {
body := AppendHeader(nil, ver, 0)
h, _, err := ParseHeader(body)
if err != nil {
t.Fatal(err)
}
if h.Version != ver || !h.OK() {
t.Fatalf("%+v", h)
}
}
}
func TestHeaderError204(t *testing.T) {
body := encodeHeaderError(ErrnoInvalidToken, "invalid access token")
h, _, err := ParseHeader(body)
if err != nil {
t.Fatal(err)
}
if h.Errno != ErrnoInvalidToken || h.Message != "invalid access token" {
t.Fatalf("%+v", h)
}
}
func TestConnectParse(t *testing.T) {
body := encodeConnectOK("127.0.0.1 via TCP/IP", "10", "8.0.36")
resp, err := ParseConnect(body)
if err != nil {
t.Fatal(err)
}
if resp.Conn.ProtoInfo != "10" || resp.Conn.ServerInfo != "8.0.36" {
t.Fatalf("%+v", resp.Conn)
}
}
func TestQuerySelect1(t *testing.T) {
body := encodeQueryResults([]Result{selectLiteral("1", "1", 8, 20)})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
if len(resp.Results) != 1 {
t.Fatalf("results=%d", len(resp.Results))
}
r := resp.Results[0]
if !r.IsResultSet() || r.NumRows != 1 {
t.Fatalf("%+v", r)
}
if r.Rows[0][0].Null || r.Rows[0][0].Text() != "1" {
t.Fatalf("cell=%+v", r.Rows[0][0])
}
}
func TestNullDecimalBigintUTF8(t *testing.T) {
body := encodeQueryResults([]Result{
{
NumFields: 4,
NumRows: 1,
Fields: []Field{
{Name: "n", Type: 6},
{Name: "b", Type: 8, Length: 20},
{Name: "d", Type: 246, Length: 32},
{Name: "z", Type: 253, Length: 12},
},
Rows: [][]Cell{{
{Null: true},
{Bytes: []byte("18446744073709551615")},
{Bytes: []byte("1234567890.123456789")},
{Bytes: []byte("你好世界")},
}},
},
})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
row := resp.Results[0].Rows[0]
if !row[0].Null {
t.Fatal("expected NULL")
}
if row[1].Text() != "18446744073709551615" {
t.Fatalf("bigint parsed badly: %q", row[1].Text())
}
if row[2].Text() != "1234567890.123456789" {
t.Fatalf("decimal parsed badly: %q", row[2].Text())
}
if row[3].Text() != "你好世界" {
t.Fatalf("utf8: %q", row[3].Text())
}
}
func TestSQLError(t *testing.T) {
body := encodeQueryResults([]Result{{Errno: 1064, Message: "You have an error in your SQL syntax"}})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
if !resp.Results[0].IsError() || !strings.Contains(resp.Results[0].Message, "syntax") {
t.Fatalf("%+v", resp.Results[0])
}
}
func TestDMLOk(t *testing.T) {
body := encodeQueryResults([]Result{{AffectedRows: 3, InsertID: 9, Info: "Rows matched: 3"}})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
r := resp.Results[0]
if r.IsResultSet() || r.AffectedRows != 3 || r.InsertID != 9 {
t.Fatalf("%+v", r)
}
}
func TestHTMLRejected(t *testing.T) {
_, err := ParseConnect([]byte("<html>nope</html>"))
if err == nil {
t.Fatal("expected error")
}
}
func TestRequestOmitsEmptyQueriesAndEncodesArray(t *testing.T) {
enc := Request{
Action: ActionQuery,
Host: "mysql",
Port: 3306,
Login: "root",
Queries: []string{"SELECT 1", "", "SELECT 2"},
}.Encode()
if strings.Contains(enc, "password=secret") {
t.Fatal("unexpected")
}
if !strings.Contains(enc, "q%5B%5D=SELECT+1") && !strings.Contains(enc, "q%5B%5D=SELECT%201") {
t.Fatalf("q[] missing: %s", enc)
}
if strings.Count(enc, "q%5B%5D=") != 2 {
t.Fatalf("empty query not skipped: %s", enc)
}
}
func TestRequestBase64(t *testing.T) {
enc := Request{
Action: ActionQuery,
Host: "mysql",
Port: 3306,
Login: "root",
Queries: []string{"SELECT 1"},
EncodeBase64: true,
}.Encode()
if !strings.Contains(enc, "encodeBase64=1") {
t.Fatal(enc)
}
if strings.Contains(enc, "SELECT+1") {
t.Fatalf("sql should be base64: %s", enc)
}
}
func TestErrorDoesNotEmbedPassword(t *testing.T) {
e := &Error{Kind: "mysql", Errno: 1045, Message: "Access denied for user 'root'@'localhost'"}
if strings.Contains(e.Error(), "password") && strings.Contains(e.Error(), "=") {
t.Fatal(e.Error())
}
}
+63
View File
@@ -0,0 +1,63 @@
package navicat
import (
"encoding/base64"
"net/url"
"strconv"
"strings"
)
// Request is one Navicat HTTP Tunnel POST body.
type Request struct {
Action string
Host string
Port int
Login string
Password string
Database string
Queries []string
EncodeBase64 bool
}
// Form builds application/x-www-form-urlencoded fields matching $_POST in ntunnel_mysql.php.
func (r Request) Form() url.Values {
v := url.Values{}
v.Set("actn", r.Action)
v.Set("host", r.Host)
if r.Port > 0 {
v.Set("port", strconv.Itoa(r.Port))
} else {
v.Set("port", "")
}
v.Set("login", r.Login)
v.Set("password", r.Password)
v.Set("db", r.Database)
queries := nonEmpty(r.Queries)
if r.EncodeBase64 {
v.Set("encodeBase64", "1")
for _, q := range queries {
v.Add("q[]", base64.StdEncoding.EncodeToString([]byte(q)))
}
} else {
for _, q := range queries {
v.Add("q[]", q)
}
}
return v
}
func (r Request) Encode() string {
return r.Form().Encode()
}
func nonEmpty(in []string) []string {
out := make([]string, 0, len(in))
for _, s := range in {
if strings.TrimSpace(s) == "" {
continue
}
out = append(out, s)
}
return out
}
+266
View File
@@ -0,0 +1,266 @@
package navicat
import (
"fmt"
"strings"
)
// Header is EchoHeader from ntunnel_mysql.php (16 bytes).
type Header struct {
Magic uint32
Version uint16
Errno uint32
Message string
UncertainVersion bool
}
func (h Header) OK() bool { return h.Errno == 0 }
// ConnInfo is actn=C payload after a successful header.
type ConnInfo struct {
HostInfo string
ProtoInfo string
ServerInfo string
}
// Field is one EchoFieldsHeader column. Type/flags/length are mysqli numeric values.
type Field struct {
Name string
Table string
Type uint32
Flags uint32
Length uint32
}
// Cell is one row field. Bytes are kept as the PHP string; never parsed as int64/float64.
type Cell struct {
Null bool
Bytes []byte
}
func (c Cell) Text() string {
if c.Null {
return ""
}
return string(c.Bytes)
}
// Result is one query in an actn=Q response.
type Result struct {
Errno uint32
Message string
AffectedRows uint32
InsertID uint32 // protocol is 32-bit; do not invent high bits
NumFields uint32
NumRows uint32
Info string
Fields []Field
Rows [][]Cell
}
func (r Result) IsError() bool { return r.Errno > 0 }
func (r Result) IsResultSet() bool {
return r.Errno == 0 && r.NumFields > 0
}
// ConnectResponse is a parsed actn=C reply.
type ConnectResponse struct {
Header Header
Conn ConnInfo
}
// QueryResponse is a parsed actn=Q reply.
type QueryResponse struct {
Header Header
Results []Result
}
func ParseHeader(body []byte) (Header, *reader, error) {
var h Header
if len(body) < HeaderSize {
if looksHTML(body) {
return h, nil, &Error{Kind: "http", Message: "tunnel returned HTML, not Navicat binary"}
}
return h, nil, fmt.Errorf("response too short (%d bytes)", len(body))
}
r := newReader(body)
magic, err := r.u32()
if err != nil {
return h, nil, err
}
if magic != Magic {
if looksHTML(body) {
return h, nil, &Error{Kind: "http", Message: "tunnel returned HTML, not Navicat binary"}
}
return h, nil, fmt.Errorf("invalid Navicat magic %d (want %d)", magic, Magic)
}
ver, err := r.u16()
if err != nil {
return h, nil, err
}
errno, err := r.u32()
if err != nil {
return h, nil, err
}
if _, err := r.readN(6); err != nil {
return h, nil, err
}
h.Magic = magic
h.Version = ver
h.Errno = errno
h.UncertainVersion = !knownVersion(ver)
if errno != 0 {
msg, err := r.blockString()
if err != nil {
return h, nil, fmt.Errorf("read error message: %w", err)
}
h.Message = msg
}
return h, r, nil
}
func ParseConnect(body []byte) (*ConnectResponse, error) {
h, r, err := ParseHeader(body)
if err != nil {
return nil, err
}
out := &ConnectResponse{Header: h}
if !h.OK() {
return out, nil
}
host, err := r.blockString()
if err != nil {
return nil, fmt.Errorf("conn host_info: %w", err)
}
proto, err := r.blockString()
if err != nil {
return nil, fmt.Errorf("conn proto_info: %w", err)
}
server, err := r.blockString()
if err != nil {
return nil, fmt.Errorf("conn server_info: %w", err)
}
out.Conn = ConnInfo{HostInfo: host, ProtoInfo: proto, ServerInfo: server}
return out, nil
}
func ParseQuery(body []byte) (*QueryResponse, error) {
h, r, err := ParseHeader(body)
if err != nil {
return nil, err
}
out := &QueryResponse{Header: h}
if !h.OK() {
return out, nil
}
for {
if r.remaining() == 0 {
break
}
res, err := parseOneResult(r)
if err != nil {
return nil, err
}
out.Results = append(out.Results, res)
if r.remaining() == 0 {
break
}
sep, err := r.readByte()
if err != nil {
return nil, err
}
if sep == sepLast {
break
}
if sep != sepMore {
return nil, fmt.Errorf("invalid query separator 0x%02x", sep)
}
}
return out, nil
}
func parseOneResult(r *reader) (Result, error) {
var res Result
var err error
if res.Errno, err = r.u32(); err != nil {
return res, err
}
if res.AffectedRows, err = r.u32(); err != nil {
return res, err
}
if res.InsertID, err = r.u32(); err != nil {
return res, err
}
if res.NumFields, err = r.u32(); err != nil {
return res, err
}
if res.NumRows, err = r.u32(); err != nil {
return res, err
}
if _, err = r.readN(12); err != nil {
return res, err
}
if res.Errno > 0 {
res.Message, err = r.blockString()
return res, err
}
if res.NumFields == 0 {
res.Info, err = r.blockString()
return res, err
}
res.Fields = make([]Field, 0, res.NumFields)
for i := uint32(0); i < res.NumFields; i++ {
f, err := parseField(r)
if err != nil {
return res, fmt.Errorf("field %d: %w", i, err)
}
res.Fields = append(res.Fields, f)
}
res.Rows = make([][]Cell, 0, res.NumRows)
for i := uint32(0); i < res.NumRows; i++ {
row := make([]Cell, res.NumFields)
for j := uint32(0); j < res.NumFields; j++ {
val, isNull, err := r.block(true)
if err != nil {
return res, fmt.Errorf("row %d col %d: %w", i, j, err)
}
row[j] = Cell{Null: isNull, Bytes: val}
}
res.Rows = append(res.Rows, row)
}
return res, nil
}
func parseField(r *reader) (Field, error) {
var f Field
var err error
if f.Name, err = r.blockString(); err != nil {
return f, err
}
if f.Table, err = r.blockString(); err != nil {
return f, err
}
if f.Type, err = r.u32(); err != nil {
return f, err
}
if f.Flags, err = r.u32(); err != nil {
return f, err
}
if f.Length, err = r.u32(); err != nil {
return f, err
}
return f, nil
}
func looksHTML(body []byte) bool {
s := strings.TrimSpace(string(body))
if len(s) < 5 {
return false
}
n := 64
if len(s) < n {
n = len(s)
}
ls := strings.ToLower(s[:n])
return strings.HasPrefix(ls, "<!doctype") || strings.HasPrefix(ls, "<html")
}