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