app-init
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
||||
# Secrets & local runtime (any path named config.yaml)
|
||||
config.yaml
|
||||
/dist/
|
||||
/logs/
|
||||
*.log
|
||||
|
||||
# Binaries & test artifacts
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
*.dll
|
||||
|
||||
# Python (tests/)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Editor / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
*.swp
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Env files (defensive; project uses YAML)
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 mysql-tunnel contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,10 @@
|
||||
.PHONY: test build tidy
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
build:
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o dist/mysql-tunnel.exe ./cmd/tunnel
|
||||
@@ -22,7 +22,7 @@ Windows 本机 MySQL 协议代理,给 **影刀 RPA**(以及其它只认 MySQ
|
||||
|
||||
这 **不是** TCP-over-HTTP 端口转发,也 **不是** 业务 REST API。每一条 SQL 都走一次独立的 HTTP 请求。
|
||||
|
||||
协议细节见 [docs/PROTOCOL.md](docs/PROTOCOL.md)。
|
||||
协议细节见 [docs/PROTOCOL.md](docs/PROTOCOL.md)。开发 / 换图标 / 打包见 [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -282,7 +282,7 @@ CLI `--host` / `--port` 会覆盖这两项。
|
||||
| `max_connections` | `5` | 本机并发连接上限。须 `>= 1`。 |
|
||||
| `log_level` | `info` | `debug` / `info` / `warn` / `error`。 |
|
||||
| `log_sql` | `false` | `true` 时记录 SQL 文本(仍会脱敏 token/密码)。排障后再关掉。 |
|
||||
| `log_file` | `logs/tunnel.log` | 同时写 stdout 和该文件。空路径则只打控制台。 |
|
||||
| `log_file` | `logs/tunnel.log` | 日志目录与前缀。实际文件为 `logs/tunnel-YYYY-MM-DD.log`,按日切割,**仅保留 7 天**。空路径则只打控制台。 |
|
||||
|
||||
Windows 启动时会提醒:`config.yaml` 含密钥,请用 NTFS 权限限制为本机账户可读。
|
||||
|
||||
@@ -369,7 +369,7 @@ internal/winsvc Windows 服务安装与运行
|
||||
ntunnel_mysql.php 部署到远端的隧道脚本
|
||||
config.example.yaml 配置模板
|
||||
docs/PROTOCOL.md 协议对照 PHP 源码的说明
|
||||
tests/README.md 单测范围说明
|
||||
docs/DEVELOPMENT.md 开发说明(构建、换图标、服务、测试)
|
||||
build.bat Windows 一键测试 + 编译
|
||||
```
|
||||
|
||||
@@ -450,6 +450,8 @@ Mock 覆盖:`SELECT 1`、NULL、BIGINT(文本)、DECIMAL(文本)、utf
|
||||
|
||||
## 开发阶段(已完成)
|
||||
|
||||
开发约定、构建与 **更换图标** 见 [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)。
|
||||
|
||||
1. 协议调研 — 见 `docs/PROTOCOL.md`
|
||||
2. Navicat 客户端 `actn=C` / `actn=Q` + 解析
|
||||
3. 本机 MySQL 服务(握手、认证、`COM_QUERY`、PING、QUIT)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Prefer a typical Windows install; do not rely on where.exe (it may be missing from PATH).
|
||||
if exist "%ProgramFiles%\Go\bin\go.exe" set "PATH=%ProgramFiles%\Go\bin;%PATH%"
|
||||
|
||||
go version >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Go is not on PATH. Install Go 1.24+ and re-run build.bat.
|
||||
echo If Go is already installed, add its bin directory to PATH and open a new terminal.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist dist mkdir dist
|
||||
|
||||
REM rsrc_windows_amd64.syso embeds Common Controls 6 and the app icon (required by the GUI).
|
||||
REM After changing cmd\tunnel\app.ico or app.manifest, regenerate (see docs\DEVELOPMENT.md):
|
||||
REM go run github.com/akavel/rsrc@latest -manifest cmd\tunnel\app.manifest -ico cmd\tunnel\app.ico -arch amd64 -o cmd\tunnel\rsrc_windows_amd64.syso
|
||||
REM Optional PNG-to-ICO: go run .\tools\mkico
|
||||
REM Keep internal\gui\logo.png in sync with app.ico for tray / in-window icon.
|
||||
|
||||
set CGO_ENABLED=0
|
||||
go test ./...
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
go build -trimpath -ldflags="-s -w" -o dist\mysql-tunnel.exe .\cmd\tunnel
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo Built dist\mysql-tunnel.exe
|
||||
dir dist\mysql-tunnel.exe
|
||||
endlocal
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity version="0.5.0.0" processorArchitecture="*" name="mysql-tunnel" type="win32"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">True</dpiAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mysql-tunnel/internal/app"
|
||||
"mysql-tunnel/internal/config"
|
||||
"mysql-tunnel/internal/gui"
|
||||
"mysql-tunnel/internal/logger"
|
||||
"mysql-tunnel/internal/winsvc"
|
||||
)
|
||||
|
||||
const version = "0.5.0"
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
fs := flag.NewFlagSet("mysql-tunnel", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
configPath := fs.String("config", "config.yaml", "path to config.yaml")
|
||||
listenHost := fs.String("host", "", "override listen.host")
|
||||
listenPort := fs.Int("port", 0, "override listen.port")
|
||||
doTest := fs.Bool("test", false, "POST actn=C and SELECT 1 through the HTTP tunnel")
|
||||
showVersion := fs.Bool("version", false, "print version and exit")
|
||||
doGUI := fs.Bool("gui", false, "open the configuration window (Windows)")
|
||||
doConsole := fs.Bool("console", false, "listen in the foreground without a window")
|
||||
doInstall := fs.Bool("install", false, "install Windows service (Administrator)")
|
||||
doUninstall := fs.Bool("uninstall", false, "uninstall Windows service (Administrator)")
|
||||
doStart := fs.Bool("start", false, "start Windows service")
|
||||
doStop := fs.Bool("stop", false, "stop Windows service")
|
||||
doRestart := fs.Bool("restart", false, "restart Windows service")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("mysql-tunnel", version)
|
||||
return 0
|
||||
}
|
||||
if *doInstall {
|
||||
return cmdInstall(*configPath)
|
||||
}
|
||||
if *doUninstall {
|
||||
return report(winsvc.Uninstall())
|
||||
}
|
||||
if *doStart {
|
||||
return report(winsvc.Start())
|
||||
}
|
||||
if *doStop {
|
||||
return report(winsvc.Stop())
|
||||
}
|
||||
if *doRestart {
|
||||
_ = winsvc.Stop()
|
||||
return report(winsvc.Start())
|
||||
}
|
||||
|
||||
if winsvc.IsService() {
|
||||
return runHeadless(*configPath, *listenHost, *listenPort, false)
|
||||
}
|
||||
|
||||
if wantGUI(fs, *doGUI, *doConsole, *doTest) {
|
||||
return gui.Run(*configPath)
|
||||
}
|
||||
|
||||
return runHeadless(*configPath, *listenHost, *listenPort, *doTest)
|
||||
}
|
||||
|
||||
func wantGUI(fs *flag.FlagSet, gui, console, doTest bool) bool {
|
||||
if console || doTest {
|
||||
return false
|
||||
}
|
||||
if gui {
|
||||
return true
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
// Double-click / no flags → GUI. `--config` without `--gui` stays CLI.
|
||||
if flagWasSet(fs, "config") || flagWasSet(fs, "host") || flagWasSet(fs, "port") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func flagWasSet(fs *flag.FlagSet, name string) bool {
|
||||
found := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func runHeadless(configPath, listenHost string, listenPort int, doTest bool) int {
|
||||
resolved, err := config.ResolveConfigPath(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config error: %s\n", err)
|
||||
return 1
|
||||
}
|
||||
cfg, err := config.Load(resolved, config.Overrides{
|
||||
ListenHost: listenHost,
|
||||
ListenPort: listenPort,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config error: %s\n", logger.RedactError(err))
|
||||
return 1
|
||||
}
|
||||
cfg.ResolvePaths()
|
||||
|
||||
log, err := logger.New(cfg.LogFile, cfg.LogLevel, cfg.LogSQL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "logger error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer log.Close()
|
||||
|
||||
for _, w := range cfg.Warnings {
|
||||
log.Warn(w)
|
||||
}
|
||||
|
||||
if doTest {
|
||||
fmt.Println("mysql-tunnel --test")
|
||||
r := app.Test(context.Background(), cfg, log)
|
||||
fmt.Println(strings.ReplaceAll(r.Text(), "\r\n", "\n"))
|
||||
if !r.OK() {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
runSrv := func(ctx context.Context) error {
|
||||
return app.Serve(ctx, cfg, log)
|
||||
}
|
||||
|
||||
if winsvc.IsService() {
|
||||
if err := winsvc.Run(runSrv); err != nil {
|
||||
log.Error("service stopped", logger.RedactError(err))
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
fmt.Printf("mysql-tunnel listening on %s:%d\n", cfg.ListenHost, cfg.ListenPort)
|
||||
fmt.Println("Yingdao: Host=127.0.0.1 Port=" + strconv.Itoa(cfg.ListenPort) + " User=" + cfg.LocalUsername)
|
||||
fmt.Println("Tunnel:", config.RedactedTunnelURL(cfg.TunnelURL))
|
||||
fmt.Println("Config:", cfg.Path)
|
||||
fmt.Println("Log:", cfg.LogFile)
|
||||
if err := runSrv(ctx); err != nil {
|
||||
log.Error("server stopped", logger.RedactError(err))
|
||||
return 1
|
||||
}
|
||||
log.Info("server stopped")
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdInstall(configFlag string) int {
|
||||
exe, err := winsvc.ExePath()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install: cannot locate exe: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
src, err := config.ResolveConfigPath(configFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install: %s\n", err)
|
||||
fmt.Fprintln(os.Stderr, "put a filled config.yaml next to mysql-tunnel.exe or pass --config")
|
||||
return 1
|
||||
}
|
||||
if _, err := config.Load(src, config.Overrides{}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install: invalid config: %s\n", logger.RedactError(err))
|
||||
return 1
|
||||
}
|
||||
dest := filepath.Join(filepath.Dir(exe), "config.yaml")
|
||||
srcAbs, _ := filepath.Abs(src)
|
||||
destAbs, _ := filepath.Abs(dest)
|
||||
if filepath.Clean(srcAbs) != filepath.Clean(destAbs) {
|
||||
if err := winsvc.CopyFile(srcAbs, destAbs); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install: copy config: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("copied config.yaml ->", destAbs)
|
||||
}
|
||||
if err := winsvc.Install(exe, destAbs); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := winsvc.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "service installed but start failed: %v\n", err)
|
||||
fmt.Println("try: mysql-tunnel.exe --start")
|
||||
return 1
|
||||
}
|
||||
fmt.Println("Windows service installed and started:", winsvc.Name)
|
||||
fmt.Println(" exe: ", exe)
|
||||
fmt.Println(" config:", destAbs)
|
||||
fmt.Println(" stop: mysql-tunnel.exe --stop")
|
||||
fmt.Println(" uninstall: mysql-tunnel.exe --uninstall")
|
||||
return 0
|
||||
}
|
||||
|
||||
func report(err error) int {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("ok")
|
||||
return 0
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
# Copy to config.yaml and fill in real values.
|
||||
# Do not commit config.yaml. Do not put tokens or passwords in logs.
|
||||
|
||||
listen:
|
||||
host: 127.0.0.1
|
||||
port: 13306
|
||||
|
||||
local_auth:
|
||||
username: root
|
||||
password: "123456"
|
||||
|
||||
tunnel:
|
||||
url: "https://example.com/ntunnel_mysql.php?token=YOUR_TOKEN"
|
||||
encode_base64: false
|
||||
tls_insecure_skip_verify: false
|
||||
|
||||
remote:
|
||||
host: "mysql"
|
||||
port: 3306
|
||||
username: "root"
|
||||
password: "YOUR_PASSWORD"
|
||||
database: "your_database"
|
||||
charset: "utf8mb4"
|
||||
|
||||
options:
|
||||
request_timeout: 30
|
||||
max_connections: 5
|
||||
log_level: info
|
||||
log_sql: false
|
||||
log_file: logs/tunnel.log # writes logs/tunnel-YYYY-MM-DD.log; keep 7 days
|
||||
@@ -0,0 +1,303 @@
|
||||
# Navicat HTTP Tunnel Protocol
|
||||
|
||||
This document is derived from the project’s actual tunnel script:
|
||||
|
||||
`ntunnel_mysql.php`
|
||||
|
||||
That file is Navicat 16’s `ntunnel_mysql.php` (script version **206**), plus a GET `token` gate. It is **not** a custom JSON/business API.
|
||||
|
||||
Wire format below is taken from the PHP source. Items that the PHP does not define are marked **TODO**.
|
||||
|
||||
## 1. Compatibility with a deployed `ntunnel_mysql.php`
|
||||
|
||||
**Compatible at the protocol layer**, with high confidence:
|
||||
|
||||
| Observation | Meaning |
|
||||
|---|---|
|
||||
| Same binary header (`magic=1111`, version field, errno) | Standard Navicat tunnel response |
|
||||
| Same POST fields (`actn`, `host`, `port`, `login`, `password`, `db`, `q[]`) | Standard Navicat tunnel request |
|
||||
| Comment in PHP: `Navicat URL: https://ntunnel.example.com/index.php?token=YOUR_SECRET` | The `?token=` URL is an intentional extension |
|
||||
| `$allowTestMenu = false` | Browser GET does not show the HTML tester unless token + missing POST is handled as binary error |
|
||||
|
||||
The public hostname/path (with or without `/ntunnel_mysql.php`) is a deployment detail (reverse proxy / rewrite). The client must POST to the configured URL **including** the `token` query string.
|
||||
|
||||
`--test` POSTs `actn=C` then `actn=Q` with `SELECT 1` (implemented in Phase 2).
|
||||
|
||||
## 2. HTTP layer
|
||||
|
||||
| Item | Value | Source |
|
||||
|---|---|---|
|
||||
| Method | `POST` | tester JS `xmlhttp.open("POST", ...)` and `$_POST[...]` |
|
||||
| Request `Content-Type` | `application/x-www-form-urlencoded` | tester JS |
|
||||
| Response `Content-Type` | `text/plain; charset=x-user-defined` | PHP `header(...)` |
|
||||
| Body | **binary**, not JSON | `EchoHeader` / `GetBlock` |
|
||||
| URL query | `token` is **required** | `isValidAccessToken()` reads `$_GET['token']` only |
|
||||
| Token location | Query string only. Not POST, not `Authorization` | PHP |
|
||||
| TLS | Use HTTPS when the URL is `https://`. Do not disable certificate verification unless explicitly configured. | product requirement |
|
||||
|
||||
GET without POST:
|
||||
|
||||
- Missing/wrong token → HTTP `403` text `403 Forbidden: invalid or missing token`
|
||||
- Valid token, `$allowTestMenu = false` → binary header errno **202** `invalid parameters`
|
||||
|
||||
Tunnel POST with missing/wrong token → HTTP 200 (typical PHP) with binary errno **204** `invalid access token`.
|
||||
|
||||
## 3. POST parameters
|
||||
|
||||
Required by PHP (`isset`):
|
||||
|
||||
- `actn`
|
||||
- `host`
|
||||
- `port`
|
||||
- `login`
|
||||
|
||||
Used if present:
|
||||
|
||||
| Field | Role | Notes |
|
||||
|---|---|---|
|
||||
| `actn` | Action | Only `"C"` and `"Q"` are handled |
|
||||
| `host` | MySQL host **as seen from the PHP server** | Often `127.0.0.1` / `localhost` / docker name `mysql` |
|
||||
| `port` | MySQL port | Empty is allowed; mysqli then uses default port |
|
||||
| `login` | Remote MySQL user | |
|
||||
| `password` | Remote MySQL password | May be empty. PHP does not `isset`-check it |
|
||||
| `db` | Default database | Empty string skips `mysqli_select_db` |
|
||||
| `q[]` | SQL list | Used only when `actn=Q`. Empty items are skipped |
|
||||
| `encodeBase64` | `"1"` to Base64-decode each `q[]` | Optional. Intended for WAF bypass |
|
||||
|
||||
**Not a POST field in this script:**
|
||||
|
||||
- `charset` — PHP hardcodes `mysqli_set_charset($conn, 'UTF8')` after connect
|
||||
- session id / cookie — none
|
||||
- close / disconnect action — none
|
||||
|
||||
**TODO:** If Yingdao needs `utf8mb4`, send `SET NAMES utf8mb4` as the first `q[]` (or a preceding query in the same HTTP request). Do not invent a `charset` POST field unless a later capture shows Navicat sending one (this PHP ignores unknown fields).
|
||||
|
||||
## 4. `actn` values
|
||||
|
||||
Observed in this file:
|
||||
|
||||
| `actn` | Meaning |
|
||||
|---|---|
|
||||
| `C` | Connect + return server info. No SQL. |
|
||||
| `Q` | Connect + execute each `q[]` |
|
||||
|
||||
Any other `actn` (including missing after the `isset` check has passed): PHP still connects, writes `EchoHeader`, then writes **no** conn-info and **no** result set.
|
||||
|
||||
There is **no** `actn=D` / close. The HTTP request is the entire session.
|
||||
|
||||
**TODO:** Confirm whether the live Navicat client ever sends other `actn` values. This PHP would ignore them after a successful connect.
|
||||
|
||||
## 5. Session / transaction model
|
||||
|
||||
**Stateless.** Every POST:
|
||||
|
||||
1. Validates token
|
||||
2. `mysqli_connect(...)`
|
||||
3. `mysqli_set_charset(..., 'UTF8')`
|
||||
4. optional `mysqli_select_db`
|
||||
5. `C` or `Q`
|
||||
6. Script ends; PHP drops the MySQL connection
|
||||
|
||||
Consequences (not assumptions — this is what the PHP does):
|
||||
|
||||
- `START TRANSACTION` / `COMMIT` **cannot** span two HTTP requests
|
||||
- `SET` session variables do not persist across COM_QUERY unless we put them in the same POST `q[]` list
|
||||
- `last_insert_id` is only meaningful inside that same HTTP response
|
||||
- Local Yingdao connection state (`USE db`) must be stored **in mysql-tunnel** and sent as `db=` on every later request
|
||||
|
||||
Phase 1/2 client will therefore send `host/port/login/password/db` on **every** request.
|
||||
|
||||
## 6. Binary encoding primitives
|
||||
|
||||
All integers are **big-endian**.
|
||||
|
||||
```
|
||||
GetLongBinary(n) = pack("N", n) // 4 bytes
|
||||
GetShortBinary(n) = pack("n", n) // 2 bytes
|
||||
GetDummy(k) = k zero bytes
|
||||
```
|
||||
|
||||
`GetBlock(val)` (length-prefixed bytes, `strlen` = byte length):
|
||||
|
||||
| First byte | Meaning |
|
||||
|---|---|
|
||||
| `0x00`–`0xFD` | `len` then `len` bytes of data (`len < 254`) |
|
||||
| `0xFE` | next 4 bytes = `len` (uint32 BE), then `len` bytes |
|
||||
| `0xFF` | **NULL** (only used for row field values in `EchoData`) |
|
||||
|
||||
Empty string is `0x00` (length 0), not NULL.
|
||||
|
||||
**Limitation:** `pack("N", $insertid)` and `pack("N", $affectedrows)` are 32-bit. Values that do not fit are truncated/wrapped by PHP.
|
||||
|
||||
**TODO:** If a table uses `BIGINT AUTO_INCREMENT` beyond 2^32-1, the tunnel cannot return the full id. mysql-tunnel should surface the 32-bit value it actually received, not invent a 64-bit value.
|
||||
|
||||
## 7. Response: common header (`EchoHeader`)
|
||||
|
||||
Always 16 bytes, then an optional error block:
|
||||
|
||||
```
|
||||
offset 0 uint32 BE magic = 1111 (0x00000457)
|
||||
offset 4 uint16 BE version = 206 (0x00CE) // this script; older copies used 202
|
||||
offset 6 uint32 BE errno
|
||||
offset 10 6 bytes zeros
|
||||
offset 16 [GetBlock] error message if errno != 0
|
||||
```
|
||||
|
||||
The built-in tester reads errno at offset 6 and the message at offset 16. mysql-tunnel must do the same.
|
||||
|
||||
Tunnel-level errno values defined by this PHP (not MySQL):
|
||||
|
||||
| errno | Message |
|
||||
|---|---|
|
||||
| 201 | unsupported php version |
|
||||
| 202 | invalid parameters |
|
||||
| 203 | MySQL not supported on the server |
|
||||
| 204 | invalid access token |
|
||||
| other | `mysqli_connect_errno` / `mysqli_errno` (real MySQL errors: 1045, 1049, 1146, 1064, ...) |
|
||||
|
||||
Parser should accept version **206** (this file) and **202** (older public copies). Reject unknown magic. **TODO:** whether to hard-fail on unexpected version numbers, or warn and continue if magic matches.
|
||||
|
||||
## 8. `actn=C` response
|
||||
|
||||
If header errno == 0:
|
||||
|
||||
```
|
||||
GetBlock(host_info) // e.g. "127.0.0.1 via TCP/IP"
|
||||
GetBlock(proto_info) // mysqli_get_proto_info is an int; PHP stringifies it, e.g. "10"
|
||||
GetBlock(server_info) // e.g. "8.0.36"
|
||||
```
|
||||
|
||||
If header errno != 0: only the error `GetBlock` after the 16-byte header. No conn info.
|
||||
|
||||
## 9. `actn=Q` response
|
||||
|
||||
After a successful connect header (errno 0):
|
||||
|
||||
For each **non-empty** `q[i]`:
|
||||
|
||||
```
|
||||
EchoResultSetHeader:
|
||||
uint32 BE errno
|
||||
uint32 BE affected_rows
|
||||
uint32 BE insert_id
|
||||
uint32 BE num_fields
|
||||
uint32 BE num_rows
|
||||
12 zero bytes
|
||||
|
||||
if errno > 0:
|
||||
GetBlock(mysql_error)
|
||||
else if num_fields > 0:
|
||||
EchoFieldsHeader (num_fields times)
|
||||
EchoData (num_rows times)
|
||||
else:
|
||||
GetBlock(mysqli_info) or GetBlock("") // OK-style extra info string
|
||||
|
||||
separator:
|
||||
0x01 if this is not the last q[] index
|
||||
0x00 if this is the last q[] index
|
||||
```
|
||||
|
||||
Empty `q[]` entries are `continue`’d **before** a result is written. If the last array slot is empty, the previous result may end with `0x01` and the stream never gets a final `0x00`.
|
||||
|
||||
**Implementation rule:** send only non-empty queries; prefer a single `q[]` per local `COM_QUERY`.
|
||||
|
||||
### Field header (`EchoFieldsHeader`, mysqli path)
|
||||
|
||||
Per column:
|
||||
|
||||
```
|
||||
GetBlock(name)
|
||||
GetBlock(table)
|
||||
uint32 BE type // mysqli_fetch_field_direct()->type (MYSQLI_TYPE_*)
|
||||
uint32 BE flags // mysqli flags bitfield
|
||||
uint32 BE length // display/octet length
|
||||
```
|
||||
|
||||
`MYSQLI_TYPE_*` values are the same numbers as MySQL protocol field types (e.g. `3=LONG`, `8=LONGLONG`, `246=NEWDECIMAL`, `253=VAR_STRING`, `245=JSON`). mysql-tunnel should copy these into Column Definition packets.
|
||||
|
||||
Legacy `mysql_field_type()` string mapping exists only on the old `mysql_*` path. Prefer treating incoming type as a numeric MySQL type.
|
||||
|
||||
### Row data (`EchoData`)
|
||||
|
||||
Per row, per field:
|
||||
|
||||
- SQL `NULL` → single byte `0xFF`
|
||||
- otherwise → `GetBlock(byte string)`
|
||||
|
||||
`mysqli_fetch_row` returns strings (and NULL). **BIGINT and DECIMAL stay text.** Do not parse them as Go `int64` / `float64`.
|
||||
|
||||
## 10. Mapping to local MySQL protocol (later phases)
|
||||
|
||||
| Tunnel result | Local packet |
|
||||
|---|---|
|
||||
| `num_fields > 0` | Result Set: column count, column defs, rows, EOF/OK |
|
||||
| `num_fields == 0` and errno 0 | OK packet: `affected_rows`, `last_insert_id` |
|
||||
| query errno > 0 | ERR packet: SQLSTATE unknown unless we map common errno; message from `GetBlock` |
|
||||
| connect errno > 0 | ERR packet to Yingdao (or fail `--test`) |
|
||||
| HTTP 403 / non-binary body | Local ERR with a tunnel-layer message; never include token/password |
|
||||
|
||||
**TODO:** SQLSTATE is not in the tunnel response. Use `HY000` unless we add a local errno→SQLSTATE table.
|
||||
|
||||
**TODO:** `mysqli_info()` string (`Records: n Duplicates: m ...`) is not required by the MySQL OK packet. Can be ignored or logged.
|
||||
|
||||
## 11. SHOW / DESC / DML
|
||||
|
||||
This PHP runs **any** SQL string through `mysqli_real_query`. `SHOW DATABASES`, `SHOW TABLES`, `DESC t`, `SHOW COLUMNS`, `INSERT/UPDATE/DELETE/REPLACE` are all just `actn=Q`. mysql-tunnel should forward them unchanged.
|
||||
|
||||
`mysqli_real_query` + `mysqli_store_result` loads the full result into PHP memory. Large `SELECT`s become large HTTP bodies.
|
||||
|
||||
**TODO:** max result size / timeout behavior of the live gateway. Client-side `request_timeout` is our safety valve.
|
||||
|
||||
## 12. Sequence
|
||||
|
||||
```
|
||||
Yingdao mysql-tunnel ntunnel_mysql.php MySQL
|
||||
| | | |
|
||||
|-- TCP connect ------------->| | |
|
||||
|<-- Handshake ---------------| | |
|
||||
|-- Auth -------------------->| | |
|
||||
|<-- OK (local auth) ---------| | |
|
||||
| | | |
|
||||
|-- COM_QUERY SELECT 1 ------>| | |
|
||||
| |-- POST actn=Q + token ------>| |
|
||||
| | |-- connect ------------>|
|
||||
| | |-- SET charset UTF8 --->|
|
||||
| | |-- select_db ---------->|
|
||||
| | |-- real_query --------->|
|
||||
| | |<-- rows ---------------|
|
||||
| |<-- binary result set --------| |
|
||||
|<-- MySQL result set --------| | |
|
||||
| | | (PHP connection dropped)
|
||||
```
|
||||
|
||||
Connect check (`--test` / first use):
|
||||
|
||||
```
|
||||
mysql-tunnel --test
|
||||
POST actn=C (same URL + token + host/port/login/password/db)
|
||||
-> header errno 0 + ConnInfo => Tunnel + Remote MySQL OK
|
||||
POST actn=Q q[]=SELECT 1
|
||||
-> 1 field, 1 row => Query OK
|
||||
```
|
||||
|
||||
## 13. Open TODOs (do not guess)
|
||||
|
||||
1. Does a reverse proxy / WAF wrap or modify the PHP output (gzip, extra HTTP headers, HTML error pages)?
|
||||
2. Does Navicat send `encodeBase64=1` by default? Default in mysql-tunnel: **off**, configurable.
|
||||
3. Does Navicat send a `charset` POST field? This PHP ignores it.
|
||||
4. Should `utf8mb4` be applied via `SET NAMES` in the same `q[]` batch? (Client already does this when charset is not utf8/utf8mb3.)
|
||||
5. Multi-statement in one `COM_QUERY` (`a; b;`) vs one `q[]` vs multiple `q[]`.
|
||||
6. `COM_INIT_DB` / `USE`: local session `db` vs extra SQL. Current behavior: update session `db` and send `db=` on later requests.
|
||||
7. Prepared statements: tunnel has no binary protocol. Later: emulate prepare locally or send equivalent SQL.
|
||||
8. `insert_id` / `affected_rows` 32-bit wrap.
|
||||
9. Trailing `0x01` when the last `q[]` is empty.
|
||||
10. Whether HTTP keep-alive to the PHP front-end is allowed (PHP itself is still one MySQL connect per request).
|
||||
|
||||
## 14. Implementation rule for mysql-tunnel
|
||||
|
||||
Speak **this** protocol only:
|
||||
|
||||
- Keep `token` on the URL query string
|
||||
- POST `application/x-www-form-urlencoded`
|
||||
- Parse binary `EchoHeader` / `GetBlock`
|
||||
- Never log token, password, or the raw query string of the tunnel URL
|
||||
@@ -0,0 +1,16 @@
|
||||
module mysql-tunnel
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e
|
||||
golang.org/x/sys v0.36.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,26 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
net session >nul 2>&1
|
||||
if %errorLevel% neq 0 (
|
||||
echo Requesting Administrator...
|
||||
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
||||
exit /b
|
||||
)
|
||||
|
||||
if not exist "dist\mysql-tunnel.exe" (
|
||||
echo dist\mysql-tunnel.exe not found. Run build.bat first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "config.yaml" (
|
||||
echo config.yaml not found in %cd%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
dist\mysql-tunnel.exe --install --config "%cd%\config.yaml"
|
||||
pause
|
||||
endlocal
|
||||
@@ -0,0 +1,139 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"mysql-tunnel/internal/config"
|
||||
"mysql-tunnel/internal/logger"
|
||||
"mysql-tunnel/internal/mysqlserver"
|
||||
"mysql-tunnel/internal/navicat"
|
||||
"mysql-tunnel/internal/tunnel"
|
||||
)
|
||||
|
||||
type TestReport struct {
|
||||
TunnelOK bool
|
||||
RemoteOK bool
|
||||
QueryOK bool
|
||||
Lines []string
|
||||
}
|
||||
|
||||
func (r TestReport) OK() bool { return r.TunnelOK && r.RemoteOK && r.QueryOK }
|
||||
|
||||
func (r TestReport) Text() string {
|
||||
s := ""
|
||||
for i, line := range r.Lines {
|
||||
if i > 0 {
|
||||
s += "\r\n"
|
||||
}
|
||||
s += line
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func NewTunnelClient(cfg *config.Config) *navicat.Client {
|
||||
httpClient := tunnel.NewHTTPClient(tunnel.Options{
|
||||
Timeout: cfg.RequestTimeout,
|
||||
TLSInsecureSkipVerify: cfg.TLSInsecureSkipVerify,
|
||||
})
|
||||
return navicat.NewClient(httpClient, navicat.ClientOptions{
|
||||
URL: cfg.TunnelURL,
|
||||
EncodeBase64: cfg.EncodeBase64,
|
||||
Retries: 1,
|
||||
Target: navicat.Target{
|
||||
Host: cfg.RemoteHost,
|
||||
Port: cfg.RemotePort,
|
||||
User: cfg.RemoteUsername,
|
||||
Password: cfg.RemotePassword,
|
||||
Database: cfg.RemoteDatabase,
|
||||
Charset: cfg.RemoteCharset,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func Serve(ctx context.Context, cfg *config.Config, log *logger.Logger) error {
|
||||
navi := NewTunnelClient(cfg)
|
||||
srv := mysqlserver.New(mysqlserver.Options{
|
||||
Host: cfg.ListenHost,
|
||||
Port: cfg.ListenPort,
|
||||
Username: cfg.LocalUsername,
|
||||
Password: cfg.LocalPassword,
|
||||
DefaultDB: cfg.RemoteDatabase,
|
||||
MaxConnections: cfg.MaxConnections,
|
||||
Timeout: cfg.RequestTimeout,
|
||||
LogSQL: cfg.LogSQL,
|
||||
}, navi, log)
|
||||
if log != nil {
|
||||
log.Info("server starting", fmt.Sprintf("%s:%d", cfg.ListenHost, cfg.ListenPort))
|
||||
}
|
||||
return srv.ListenAndServe(ctx)
|
||||
}
|
||||
|
||||
func Test(ctx context.Context, cfg *config.Config, log *logger.Logger) TestReport {
|
||||
var r TestReport
|
||||
navi := NewTunnelClient(cfg)
|
||||
r.Lines = append(r.Lines, "Tunnel URL: "+config.RedactedTunnelURL(cfg.TunnelURL))
|
||||
|
||||
conn, err := navi.Connect(ctx)
|
||||
switch {
|
||||
case err == nil:
|
||||
r.TunnelOK = true
|
||||
r.RemoteOK = true
|
||||
r.Lines = append(r.Lines, "Tunnel: OK", "Remote MySQL: OK")
|
||||
if conn != nil && conn.Header.UncertainVersion {
|
||||
r.Lines = append(r.Lines, fmt.Sprintf(" (script version %d is not 202/206; continuing)", conn.Header.Version))
|
||||
}
|
||||
if log != nil && conn != nil {
|
||||
log.Info("tunnel connect ok", conn.Conn.ServerInfo)
|
||||
}
|
||||
case isTunnelLayer(err):
|
||||
r.Lines = append(r.Lines, "Tunnel: FAIL", " "+logger.RedactError(err), "Remote MySQL: FAIL", "Query SELECT 1: FAIL")
|
||||
if log != nil {
|
||||
log.Error("tunnel connect failed", logger.RedactError(err))
|
||||
}
|
||||
return r
|
||||
default:
|
||||
r.TunnelOK = true
|
||||
r.Lines = append(r.Lines, "Tunnel: OK", "Remote MySQL: FAIL", " "+logger.RedactError(err), "Query SELECT 1: FAIL")
|
||||
if log != nil {
|
||||
log.Error("remote mysql connect failed", logger.RedactError(err))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
q, err := navi.Query(ctx, cfg.RemoteDatabase, "SELECT 1")
|
||||
if err != nil {
|
||||
r.Lines = append(r.Lines, "Query SELECT 1: FAIL", " "+logger.RedactError(err))
|
||||
return r
|
||||
}
|
||||
res, ok := navicat.LastResultSet(q)
|
||||
if !ok || res.IsError() || len(res.Rows) != 1 || len(res.Rows[0]) != 1 || res.Rows[0][0].Text() != "1" {
|
||||
msg := "unexpected result"
|
||||
if res.IsError() {
|
||||
msg = fmt.Sprintf("mysql errno %d: %s", res.Errno, res.Message)
|
||||
}
|
||||
r.Lines = append(r.Lines, "Query SELECT 1: FAIL", " "+msg)
|
||||
return r
|
||||
}
|
||||
r.QueryOK = true
|
||||
r.Lines = append(r.Lines, "Query SELECT 1: OK")
|
||||
if log != nil {
|
||||
log.Info("query completed rows=1")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func isTunnelLayer(err error) bool {
|
||||
ne, ok := err.(*navicat.Error)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if ne.Kind == "http" {
|
||||
return true
|
||||
}
|
||||
switch ne.Errno {
|
||||
case navicat.ErrnoUnsupportedPHP, navicat.ErrnoInvalidParams, navicat.ErrnoMySQLMissing, navicat.ErrnoInvalidToken:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultListenHost = "127.0.0.1"
|
||||
DefaultListenPort = 13306
|
||||
DefaultTimeoutSec = 30
|
||||
DefaultMaxConnections = 5
|
||||
DefaultLogLevel = "info"
|
||||
DefaultLogFile = "logs/tunnel.log"
|
||||
DefaultCharset = "utf8mb4"
|
||||
)
|
||||
|
||||
// File holds the on-disk YAML shape. Nested pointers distinguish "unset" from zero values.
|
||||
type File struct {
|
||||
Listen ListenFile `yaml:"listen"`
|
||||
LocalAuth LocalAuthFile `yaml:"local_auth"`
|
||||
Tunnel TunnelFile `yaml:"tunnel"`
|
||||
Remote RemoteFile `yaml:"remote"`
|
||||
Options OptionsFile `yaml:"options"`
|
||||
}
|
||||
|
||||
type ListenFile struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type LocalAuthFile struct {
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
type TunnelFile struct {
|
||||
URL string `yaml:"url"`
|
||||
EncodeBase64 bool `yaml:"encode_base64"`
|
||||
TLSInsecureSkipVerify bool `yaml:"tls_insecure_skip_verify"`
|
||||
}
|
||||
|
||||
type RemoteFile struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
Database string `yaml:"database"`
|
||||
Charset string `yaml:"charset"`
|
||||
}
|
||||
|
||||
type OptionsFile struct {
|
||||
RequestTimeout int `yaml:"request_timeout"`
|
||||
MaxConnections int `yaml:"max_connections"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
LogSQL bool `yaml:"log_sql"`
|
||||
LogFile string `yaml:"log_file"`
|
||||
}
|
||||
|
||||
// Config is the validated runtime configuration.
|
||||
type Config struct {
|
||||
Path string
|
||||
|
||||
ListenHost string
|
||||
ListenPort int
|
||||
|
||||
LocalUsername string
|
||||
LocalPassword string
|
||||
|
||||
TunnelURL string
|
||||
EncodeBase64 bool
|
||||
TLSInsecureSkipVerify bool
|
||||
TLSInsecureSkipVerifyWarned bool
|
||||
|
||||
RemoteHost string
|
||||
RemotePort int
|
||||
RemoteUsername string
|
||||
RemotePassword string
|
||||
RemoteDatabase string
|
||||
RemoteCharset string
|
||||
|
||||
RequestTimeout time.Duration
|
||||
MaxConnections int
|
||||
LogLevel string
|
||||
LogSQL bool
|
||||
LogFile string
|
||||
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
type Overrides struct {
|
||||
ListenHost string
|
||||
ListenPort int
|
||||
}
|
||||
|
||||
func Defaults() Config {
|
||||
return Config{
|
||||
ListenHost: DefaultListenHost,
|
||||
ListenPort: DefaultListenPort,
|
||||
RequestTimeout: time.Duration(DefaultTimeoutSec) * time.Second,
|
||||
MaxConnections: DefaultMaxConnections,
|
||||
LogLevel: DefaultLogLevel,
|
||||
LogFile: DefaultLogFile,
|
||||
RemotePort: 3306,
|
||||
RemoteCharset: DefaultCharset,
|
||||
}
|
||||
}
|
||||
|
||||
func Parse(path string, ov Overrides) (*Config, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
|
||||
var file File
|
||||
if err := unmarshalYAML(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := Defaults()
|
||||
cfg.Path = path
|
||||
applyFile(&cfg, file)
|
||||
applyOverrides(&cfg, ov)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func Load(path string, ov Overrides) (*Config, error) {
|
||||
cfg, err := Parse(path, ov)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.warnFilePermissions()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyFile(cfg *Config, file File) {
|
||||
if file.Listen.Host != "" {
|
||||
cfg.ListenHost = file.Listen.Host
|
||||
}
|
||||
if file.Listen.Port != 0 {
|
||||
cfg.ListenPort = file.Listen.Port
|
||||
}
|
||||
cfg.LocalUsername = file.LocalAuth.Username
|
||||
cfg.LocalPassword = file.LocalAuth.Password
|
||||
cfg.TunnelURL = strings.TrimSpace(file.Tunnel.URL)
|
||||
cfg.EncodeBase64 = file.Tunnel.EncodeBase64
|
||||
cfg.TLSInsecureSkipVerify = file.Tunnel.TLSInsecureSkipVerify
|
||||
cfg.RemoteHost = file.Remote.Host
|
||||
if file.Remote.Port != 0 {
|
||||
cfg.RemotePort = file.Remote.Port
|
||||
}
|
||||
cfg.RemoteUsername = file.Remote.Username
|
||||
cfg.RemotePassword = file.Remote.Password
|
||||
cfg.RemoteDatabase = file.Remote.Database
|
||||
if file.Remote.Charset != "" {
|
||||
cfg.RemoteCharset = file.Remote.Charset
|
||||
}
|
||||
if file.Options.RequestTimeout != 0 {
|
||||
cfg.RequestTimeout = time.Duration(file.Options.RequestTimeout) * time.Second
|
||||
}
|
||||
if file.Options.MaxConnections != 0 {
|
||||
cfg.MaxConnections = file.Options.MaxConnections
|
||||
}
|
||||
if file.Options.LogLevel != "" {
|
||||
cfg.LogLevel = strings.ToLower(strings.TrimSpace(file.Options.LogLevel))
|
||||
}
|
||||
cfg.LogSQL = file.Options.LogSQL
|
||||
if file.Options.LogFile != "" {
|
||||
cfg.LogFile = file.Options.LogFile
|
||||
}
|
||||
}
|
||||
|
||||
func applyOverrides(cfg *Config, ov Overrides) {
|
||||
if ov.ListenHost != "" {
|
||||
cfg.ListenHost = ov.ListenHost
|
||||
}
|
||||
if ov.ListenPort != 0 {
|
||||
cfg.ListenPort = ov.ListenPort
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
var errs []string
|
||||
|
||||
if c.ListenHost == "" {
|
||||
errs = append(errs, "listen.host is required")
|
||||
} else if ip := net.ParseIP(c.ListenHost); ip != nil && ip.IsUnspecified() {
|
||||
errs = append(errs, "listen.host must not be 0.0.0.0/::; bind loopback only unless you fully understand the risk")
|
||||
} else if !isLoopbackHost(c.ListenHost) {
|
||||
c.Warnings = append(c.Warnings, fmt.Sprintf("listen.host %q is not loopback; the local MySQL port will be reachable beyond this machine", c.ListenHost))
|
||||
}
|
||||
if c.ListenPort < 1 || c.ListenPort > 65535 {
|
||||
errs = append(errs, "listen.port must be 1-65535")
|
||||
}
|
||||
if strings.TrimSpace(c.LocalUsername) == "" {
|
||||
errs = append(errs, "local_auth.username is required")
|
||||
}
|
||||
if c.TunnelURL == "" {
|
||||
errs = append(errs, "tunnel.url is required")
|
||||
} else {
|
||||
u, err := url.Parse(c.TunnelURL)
|
||||
if err != nil {
|
||||
errs = append(errs, "tunnel.url is not a valid URL")
|
||||
} else {
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
errs = append(errs, "tunnel.url scheme must be http or https")
|
||||
}
|
||||
if u.Host == "" {
|
||||
errs = append(errs, "tunnel.url must include a host")
|
||||
}
|
||||
if u.Query().Get("token") == "" {
|
||||
c.Warnings = append(c.Warnings, "tunnel.url has no token query parameter; ntunnel_mysql.php will reject requests with errno 204")
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(c.RemoteHost) == "" {
|
||||
errs = append(errs, "remote.host is required")
|
||||
}
|
||||
if c.RemotePort < 1 || c.RemotePort > 65535 {
|
||||
errs = append(errs, "remote.port must be 1-65535")
|
||||
}
|
||||
if strings.TrimSpace(c.RemoteUsername) == "" {
|
||||
errs = append(errs, "remote.username is required")
|
||||
}
|
||||
if c.RequestTimeout <= 0 {
|
||||
errs = append(errs, "options.request_timeout must be > 0")
|
||||
}
|
||||
if c.MaxConnections < 1 {
|
||||
errs = append(errs, "options.max_connections must be >= 1")
|
||||
}
|
||||
switch c.LogLevel {
|
||||
case "debug", "info", "warn", "error":
|
||||
default:
|
||||
errs = append(errs, "options.log_level must be debug, info, warn, or error")
|
||||
}
|
||||
|
||||
if c.TLSInsecureSkipVerify {
|
||||
c.TLSInsecureSkipVerifyWarned = true
|
||||
c.Warnings = append(c.Warnings, "SECURITY: tls_insecure_skip_verify is true; TLS certificate verification is disabled")
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New(strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
func (c *Config) warnFilePermissions() {
|
||||
info, err := os.Stat(c.Path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if info.Mode().Perm()&0o004 != 0 {
|
||||
c.Warnings = append(c.Warnings, fmt.Sprintf("%s is world-readable; it contains a tunnel token and database password", c.Path))
|
||||
}
|
||||
} else {
|
||||
c.Warnings = append(c.Warnings, fmt.Sprintf("%s contains secrets (token, passwords); restrict NTFS permissions to your account", c.Path))
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
h := strings.TrimSpace(host)
|
||||
if strings.EqualFold(h, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(h)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// RedactedTunnelURL strips userinfo and token/password query values for logs.
|
||||
func RedactedTunnelURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "(invalid-url)"
|
||||
}
|
||||
u.User = nil
|
||||
q := u.Query()
|
||||
for _, key := range []string{"token", "password", "pass", "access_token", "accessToken"} {
|
||||
if q.Has(key) {
|
||||
q.Set(key, "***")
|
||||
}
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTempConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadValidConfig(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
listen:
|
||||
host: 127.0.0.1
|
||||
port: 13306
|
||||
local_auth:
|
||||
username: root
|
||||
password: "local-secret"
|
||||
tunnel:
|
||||
url: "https://example.invalid/ntunnel?token=abc"
|
||||
remote:
|
||||
host: mysql
|
||||
port: 3306
|
||||
username: remote-root
|
||||
password: "remote-secret"
|
||||
database: app
|
||||
charset: utf8mb4
|
||||
options:
|
||||
request_timeout: 15
|
||||
max_connections: 3
|
||||
log_level: debug
|
||||
log_sql: false
|
||||
`)
|
||||
cfg, err := Load(path, Overrides{})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.ListenHost != "127.0.0.1" || cfg.ListenPort != 13306 {
|
||||
t.Fatalf("listen = %s:%d", cfg.ListenHost, cfg.ListenPort)
|
||||
}
|
||||
if cfg.RemoteHost != "mysql" || cfg.RemoteUsername != "remote-root" {
|
||||
t.Fatalf("remote mismatch")
|
||||
}
|
||||
if cfg.RequestTimeout.Seconds() != 15 {
|
||||
t.Fatalf("timeout = %s", cfg.RequestTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnspecifiedListen(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
listen:
|
||||
host: 0.0.0.0
|
||||
port: 13306
|
||||
local_auth:
|
||||
username: root
|
||||
password: x
|
||||
tunnel:
|
||||
url: "https://example.invalid/?token=t"
|
||||
remote:
|
||||
host: mysql
|
||||
username: root
|
||||
password: x
|
||||
`)
|
||||
_, err := Load(path, Overrides{})
|
||||
if err == nil || !strings.Contains(err.Error(), "0.0.0.0") {
|
||||
t.Fatalf("expected unspecified listen error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingTunnelURL(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
local_auth:
|
||||
username: root
|
||||
password: x
|
||||
remote:
|
||||
host: mysql
|
||||
username: root
|
||||
password: x
|
||||
`)
|
||||
_, err := Load(path, Overrides{})
|
||||
if err == nil || !strings.Contains(err.Error(), "tunnel.url") {
|
||||
t.Fatalf("expected tunnel.url error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIOverrides(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
listen:
|
||||
host: 127.0.0.1
|
||||
port: 13306
|
||||
local_auth:
|
||||
username: root
|
||||
password: x
|
||||
tunnel:
|
||||
url: "https://example.invalid/?token=t"
|
||||
remote:
|
||||
host: mysql
|
||||
username: root
|
||||
password: x
|
||||
`)
|
||||
cfg, err := Load(path, Overrides{ListenHost: "127.0.0.1", ListenPort: 23306})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ListenPort != 23306 {
|
||||
t.Fatalf("port override failed: %d", cfg.ListenPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactedTunnelURL(t *testing.T) {
|
||||
got := RedactedTunnelURL("https://bth-ntunnel.example/?token=super-secret&x=1")
|
||||
if strings.Contains(got, "super-secret") {
|
||||
t.Fatalf("token leaked: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "token=%2A%2A%2A") && !strings.Contains(got, "token=***") {
|
||||
t.Fatalf("token not redacted: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnWhenTokenMissing(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
local_auth:
|
||||
username: root
|
||||
password: x
|
||||
tunnel:
|
||||
url: "https://example.invalid/ntunnel"
|
||||
remote:
|
||||
host: mysql
|
||||
username: root
|
||||
password: x
|
||||
`)
|
||||
cfg, err := Load(path, Overrides{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, w := range cfg.Warnings {
|
||||
if strings.Contains(w, "token") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected token warning, got %#v", cfg.Warnings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ResolveConfigPath finds config.yaml: --config, then next to the exe, then cwd.
|
||||
func ResolveConfigPath(flagPath string) (string, error) {
|
||||
var cands []string
|
||||
if flagPath != "" {
|
||||
cands = append(cands, flagPath)
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
cands = append(cands, filepath.Join(dir, "config.yaml"))
|
||||
cands = append(cands, filepath.Join(dir, "..", "config.yaml"))
|
||||
}
|
||||
cands = append(cands, "config.yaml")
|
||||
seen := map[string]bool{}
|
||||
for _, p := range cands {
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
abs = filepath.Clean(abs)
|
||||
if seen[abs] {
|
||||
continue
|
||||
}
|
||||
seen[abs] = true
|
||||
st, err := os.Stat(abs)
|
||||
if err == nil && !st.IsDir() {
|
||||
return abs, nil
|
||||
}
|
||||
}
|
||||
if flagPath == "" {
|
||||
flagPath = "config.yaml"
|
||||
}
|
||||
abs, err := filepath.Abs(flagPath)
|
||||
if err != nil {
|
||||
return flagPath, fmt.Errorf("config not found: %s", flagPath)
|
||||
}
|
||||
return abs, fmt.Errorf("config not found: %s", abs)
|
||||
}
|
||||
|
||||
// ResolvePaths makes LogFile absolute relative to the config file directory.
|
||||
func (c *Config) ResolvePaths() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if p, err := filepath.Abs(c.Path); err == nil {
|
||||
c.Path = p
|
||||
}
|
||||
if c.LogFile == "" {
|
||||
c.LogFile = DefaultLogFile
|
||||
}
|
||||
if !filepath.IsAbs(c.LogFile) {
|
||||
c.LogFile = filepath.Join(filepath.Dir(c.Path), c.LogFile)
|
||||
}
|
||||
c.LogFile = filepath.Clean(c.LogFile)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolvePathsMakesLogAbsolute(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
local_auth:
|
||||
username: root
|
||||
password: x
|
||||
tunnel:
|
||||
url: "https://example.invalid/?token=t"
|
||||
remote:
|
||||
host: mysql
|
||||
username: root
|
||||
password: x
|
||||
options:
|
||||
log_file: logs/tunnel.log
|
||||
`)
|
||||
cfg, err := Load(path, Overrides{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.ResolvePaths()
|
||||
want := filepath.Join(filepath.Dir(path), "logs", "tunnel.log")
|
||||
if cfg.LogFile != want {
|
||||
t.Fatalf("LogFile=%s want %s", cfg.LogFile, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigPathPrefersExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveConfigPath(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != p && got != filepath.Clean(p) {
|
||||
// Abs may change separators on Windows
|
||||
if filepath.Clean(got) != filepath.Clean(p) {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func (c *Config) ToFile() File {
|
||||
if c == nil {
|
||||
return File{}
|
||||
}
|
||||
return File{
|
||||
Listen: ListenFile{Host: c.ListenHost, Port: c.ListenPort},
|
||||
LocalAuth: LocalAuthFile{Username: c.LocalUsername, Password: c.LocalPassword},
|
||||
Tunnel: TunnelFile{
|
||||
URL: c.TunnelURL,
|
||||
EncodeBase64: c.EncodeBase64,
|
||||
TLSInsecureSkipVerify: c.TLSInsecureSkipVerify,
|
||||
},
|
||||
Remote: RemoteFile{
|
||||
Host: c.RemoteHost,
|
||||
Port: c.RemotePort,
|
||||
Username: c.RemoteUsername,
|
||||
Password: c.RemotePassword,
|
||||
Database: c.RemoteDatabase,
|
||||
Charset: c.RemoteCharset,
|
||||
},
|
||||
Options: OptionsFile{
|
||||
RequestTimeout: int(c.RequestTimeout.Seconds()),
|
||||
MaxConnections: c.MaxConnections,
|
||||
LogLevel: c.LogLevel,
|
||||
LogSQL: c.LogSQL,
|
||||
LogFile: relativeLogFile(c.Path, c.LogFile),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func relativeLogFile(cfgPath, logFile string) string {
|
||||
if logFile == "" {
|
||||
return DefaultLogFile
|
||||
}
|
||||
if !filepath.IsAbs(logFile) {
|
||||
return filepath.ToSlash(filepath.Clean(logFile))
|
||||
}
|
||||
dir := filepath.Dir(cfgPath)
|
||||
rel, err := filepath.Rel(dir, logFile)
|
||||
if err != nil {
|
||||
return logFile
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func Save(path string, c *Config) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("config path is empty")
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
raw, err := yaml.Marshal(c.ToFile())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := append([]byte("# Written by mysql-tunnel. Contains secrets; do not commit.\n"), raw...)
|
||||
return os.WriteFile(path, out, 0o600)
|
||||
}
|
||||
|
||||
func DefaultSavePath() string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
return filepath.Join(filepath.Dir(exe), "config.yaml")
|
||||
}
|
||||
return "config.yaml"
|
||||
}
|
||||
|
||||
// LoadOrEmpty returns a parsed config, or defaults if the file is missing or invalid.
|
||||
func LoadOrEmpty(path string) *Config {
|
||||
if path == "" {
|
||||
path = DefaultSavePath()
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
c := Defaults()
|
||||
c.Path = path
|
||||
return &c
|
||||
}
|
||||
cfg, err := Parse(path, Overrides{})
|
||||
if err != nil {
|
||||
c := Defaults()
|
||||
c.Path = path
|
||||
c.Warnings = append(c.Warnings, err.Error())
|
||||
return &c
|
||||
}
|
||||
cfg.ResolvePaths()
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
in := Defaults()
|
||||
in.Path = path
|
||||
in.LocalUsername = "root"
|
||||
in.LocalPassword = "local-secret"
|
||||
in.TunnelURL = "https://example.invalid/ntunnel?token=abc"
|
||||
in.RemoteHost = "mysql"
|
||||
in.RemoteUsername = "remote-root"
|
||||
in.RemotePassword = "remote-secret"
|
||||
in.RemoteDatabase = "app"
|
||||
in.LogFile = filepath.Join(dir, "logs", "tunnel.log")
|
||||
in.EncodeBase64 = true
|
||||
in.LogSQL = true
|
||||
if err := Save(path, &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "token=abc") {
|
||||
t.Fatalf("saved yaml missing url: %s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "logs/tunnel.log") && !strings.Contains(string(raw), `log_file: logs/tunnel.log`) {
|
||||
if !strings.Contains(string(raw), "log_file:") {
|
||||
t.Fatalf("log_file missing: %s", raw)
|
||||
}
|
||||
}
|
||||
cfg, err := Load(path, Overrides{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.LocalPassword != "local-secret" || cfg.RemotePassword != "remote-secret" {
|
||||
t.Fatalf("passwords not round-tripped")
|
||||
}
|
||||
if cfg.TunnelURL != in.TunnelURL {
|
||||
t.Fatalf("url=%s", cfg.TunnelURL)
|
||||
}
|
||||
if !cfg.EncodeBase64 || !cfg.LogSQL {
|
||||
t.Fatalf("bools not round-tripped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrEmptyMissing(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nope.yaml")
|
||||
cfg := LoadOrEmpty(path)
|
||||
if cfg.ListenPort != DefaultListenPort {
|
||||
t.Fatalf("port=%d", cfg.ListenPort)
|
||||
}
|
||||
if cfg.Path != path {
|
||||
t.Fatalf("path=%s", cfg.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeLogFileKeepsRelative(t *testing.T) {
|
||||
got := relativeLogFile(filepath.Join(t.TempDir(), "c.yaml"), "logs/tunnel.log")
|
||||
if got != "logs/tunnel.log" {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
import "gopkg.in/yaml.v3"
|
||||
|
||||
func unmarshalYAML(raw []byte, v any) error {
|
||||
return yaml.Unmarshal(raw, v)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func Run(string) int {
|
||||
fmt.Fprintln(os.Stderr, "GUI is only available on Windows; use --console to listen")
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
//go:build windows
|
||||
|
||||
package gui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/lxn/walk"
|
||||
. "github.com/lxn/walk/declarative"
|
||||
"github.com/lxn/win"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"mysql-tunnel/internal/app"
|
||||
"mysql-tunnel/internal/config"
|
||||
"mysql-tunnel/internal/logger"
|
||||
)
|
||||
|
||||
//go:embed logo.png
|
||||
var logoPNG []byte
|
||||
|
||||
func init() {
|
||||
runtime.LockOSThread()
|
||||
}
|
||||
|
||||
func Run(configFlag string) int {
|
||||
hideConsoleIfStandalone()
|
||||
path := strings.TrimSpace(configFlag)
|
||||
if path == "" {
|
||||
path = "config.yaml"
|
||||
}
|
||||
if resolved, err := config.ResolveConfigPath(path); err == nil {
|
||||
path = resolved
|
||||
} else if path == "config.yaml" {
|
||||
path = config.DefaultSavePath()
|
||||
} else if abs, err := filepath.Abs(path); err == nil {
|
||||
path = abs
|
||||
}
|
||||
if err := runWindow(config.LoadOrEmpty(path)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ui struct {
|
||||
mw *walk.MainWindow
|
||||
ni *walk.NotifyIcon
|
||||
|
||||
listenHost *walk.LineEdit
|
||||
listenPort *walk.NumberEdit
|
||||
localUser *walk.LineEdit
|
||||
localPass *walk.LineEdit
|
||||
tunnelURL *walk.LineEdit
|
||||
encodeB64 *walk.CheckBox
|
||||
tlsSkip *walk.CheckBox
|
||||
remoteHost *walk.LineEdit
|
||||
remotePort *walk.NumberEdit
|
||||
remoteUser *walk.LineEdit
|
||||
remotePass *walk.LineEdit
|
||||
remoteDB *walk.LineEdit
|
||||
charset *walk.LineEdit
|
||||
timeout *walk.NumberEdit
|
||||
maxConn *walk.NumberEdit
|
||||
logLevel *walk.ComboBox
|
||||
logSQL *walk.CheckBox
|
||||
logFile *walk.LineEdit
|
||||
status *walk.TextEdit
|
||||
saveBtn *walk.PushButton
|
||||
testBtn *walk.PushButton
|
||||
startBtn *walk.PushButton
|
||||
stopBtn *walk.PushButton
|
||||
pathLabel *walk.Label
|
||||
hint *walk.StatusBarItem
|
||||
logoView *walk.ImageView
|
||||
appIcon *walk.Icon
|
||||
appBmp *walk.Bitmap
|
||||
|
||||
cfgPath string
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
busy bool
|
||||
cancel context.CancelFunc
|
||||
log *logger.Logger
|
||||
exiting bool
|
||||
hidOnce bool
|
||||
}
|
||||
|
||||
func runWindow(cfg *config.Config) error {
|
||||
u := &ui{cfgPath: cfg.Path}
|
||||
if err := u.build(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
defer u.dispose()
|
||||
u.mw.Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) dispose() {
|
||||
u.stopServer()
|
||||
if u.ni != nil {
|
||||
_ = u.ni.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) build(cfg *config.Config) error {
|
||||
logFile := relativeLogFile(cfg)
|
||||
levels := []string{"debug", "info", "warn", "error"}
|
||||
levelIdx := 1
|
||||
for i, s := range levels {
|
||||
if s == cfg.LogLevel {
|
||||
levelIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := (MainWindow{
|
||||
AssignTo: &u.mw,
|
||||
Title: "MySQL Tunnel",
|
||||
MinSize: Size{Width: 780, Height: 560},
|
||||
Size: Size{Width: 840, Height: 620},
|
||||
Layout: VBox{Margins: Margins{Left: 12, Top: 10, Right: 12, Bottom: 8}, Spacing: 8},
|
||||
StatusBarItems: []StatusBarItem{
|
||||
{AssignTo: &u.hint, Text: "就绪", Width: 800},
|
||||
},
|
||||
Children: []Widget{
|
||||
Composite{
|
||||
Layout: HBox{Spacing: 10, MarginsZero: true},
|
||||
Children: []Widget{
|
||||
ImageView{
|
||||
AssignTo: &u.logoView,
|
||||
MinSize: Size{Width: 40, Height: 40},
|
||||
MaxSize: Size{Width: 40, Height: 40},
|
||||
Mode: ImageViewModeZoom,
|
||||
},
|
||||
Composite{
|
||||
Layout: VBox{MarginsZero: true, Spacing: 2},
|
||||
Children: []Widget{
|
||||
Label{Text: "MySQL Tunnel", Font: Font{Family: "Segoe UI", PointSize: 13, Bold: true}},
|
||||
Label{AssignTo: &u.pathLabel, Text: cfg.Path},
|
||||
},
|
||||
},
|
||||
HSpacer{},
|
||||
},
|
||||
},
|
||||
Composite{
|
||||
Layout: HBox{Spacing: 10, MarginsZero: true},
|
||||
Children: []Widget{
|
||||
Composite{
|
||||
StretchFactor: 1,
|
||||
MinSize: Size{Width: 380},
|
||||
Layout: VBox{MarginsZero: true, Spacing: 8},
|
||||
Children: []Widget{
|
||||
GroupBox{
|
||||
Title: "本机监听(影刀连这里)",
|
||||
Layout: Grid{Columns: 2, Spacing: 6},
|
||||
Children: []Widget{
|
||||
fieldLabel("地址"),
|
||||
LineEdit{AssignTo: &u.listenHost, Text: cfg.ListenHost},
|
||||
fieldLabel("端口"),
|
||||
NumberEdit{AssignTo: &u.listenPort, Value: float64(cfg.ListenPort), MinValue: 1, MaxValue: 65535, Decimals: 0, MinSize: Size{Width: 90}},
|
||||
fieldLabel("本机用户"),
|
||||
LineEdit{AssignTo: &u.localUser, Text: cfg.LocalUsername},
|
||||
fieldLabel("本机密码"),
|
||||
LineEdit{AssignTo: &u.localPass, Text: cfg.LocalPassword, PasswordMode: true},
|
||||
},
|
||||
},
|
||||
GroupBox{
|
||||
Title: "HTTP 隧道",
|
||||
StretchFactor: 1,
|
||||
Layout: Grid{Columns: 2, Spacing: 6},
|
||||
Children: []Widget{
|
||||
fieldLabel("URL"),
|
||||
LineEdit{AssignTo: &u.tunnelURL, Text: cfg.TunnelURL},
|
||||
CheckBox{AssignTo: &u.encodeB64, Text: "SQL Base64(WAF)", Checked: cfg.EncodeBase64, ColumnSpan: 2},
|
||||
CheckBox{AssignTo: &u.tlsSkip, Text: "跳过 TLS 证书校验(仅排障)", Checked: cfg.TLSInsecureSkipVerify, ColumnSpan: 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Composite{
|
||||
StretchFactor: 1,
|
||||
MinSize: Size{Width: 380},
|
||||
Layout: VBox{MarginsZero: true, Spacing: 8},
|
||||
Children: []Widget{
|
||||
GroupBox{
|
||||
Title: "远程 MySQL(PHP 主机访问)",
|
||||
Layout: Grid{Columns: 2, Spacing: 6},
|
||||
Children: []Widget{
|
||||
fieldLabel("主机"),
|
||||
LineEdit{AssignTo: &u.remoteHost, Text: cfg.RemoteHost},
|
||||
fieldLabel("端口"),
|
||||
NumberEdit{AssignTo: &u.remotePort, Value: float64(nonzero(cfg.RemotePort, 3306)), MinValue: 1, MaxValue: 65535, Decimals: 0, MinSize: Size{Width: 90}},
|
||||
fieldLabel("用户名"),
|
||||
LineEdit{AssignTo: &u.remoteUser, Text: cfg.RemoteUsername},
|
||||
fieldLabel("密码"),
|
||||
LineEdit{AssignTo: &u.remotePass, Text: cfg.RemotePassword, PasswordMode: true},
|
||||
fieldLabel("默认库"),
|
||||
LineEdit{AssignTo: &u.remoteDB, Text: cfg.RemoteDatabase},
|
||||
fieldLabel("字符集"),
|
||||
LineEdit{AssignTo: &u.charset, Text: orDefault(cfg.RemoteCharset, "utf8mb4")},
|
||||
},
|
||||
},
|
||||
GroupBox{
|
||||
Title: "选项",
|
||||
StretchFactor: 1,
|
||||
Layout: Grid{Columns: 2, Spacing: 6},
|
||||
Children: []Widget{
|
||||
fieldLabel("超时(秒)"),
|
||||
NumberEdit{AssignTo: &u.timeout, Value: float64(timeoutSec(cfg)), MinValue: 1, MaxValue: 600, Decimals: 0, MinSize: Size{Width: 90}},
|
||||
fieldLabel("最大连接"),
|
||||
NumberEdit{AssignTo: &u.maxConn, Value: float64(nonzero(cfg.MaxConnections, 5)), MinValue: 1, MaxValue: 256, Decimals: 0, MinSize: Size{Width: 90}},
|
||||
fieldLabel("日志级别"),
|
||||
ComboBox{AssignTo: &u.logLevel, Model: levels, CurrentIndex: levelIdx, Editable: false},
|
||||
fieldLabel("日志文件"),
|
||||
LineEdit{AssignTo: &u.logFile, Text: logFile},
|
||||
CheckBox{AssignTo: &u.logSQL, Text: "记录 SQL(仍会脱敏)", Checked: cfg.LogSQL, ColumnSpan: 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Composite{
|
||||
Layout: HBox{Spacing: 8},
|
||||
Children: []Widget{
|
||||
PushButton{AssignTo: &u.saveBtn, Text: "保存", MinSize: Size{Width: 88}},
|
||||
PushButton{AssignTo: &u.testBtn, Text: "测试连接", MinSize: Size{Width: 88}},
|
||||
PushButton{AssignTo: &u.startBtn, Text: "启动", MinSize: Size{Width: 88}},
|
||||
PushButton{AssignTo: &u.stopBtn, Text: "停止", Enabled: false, MinSize: Size{Width: 88}},
|
||||
HSpacer{},
|
||||
},
|
||||
},
|
||||
GroupBox{
|
||||
Title: "运行日志",
|
||||
StretchFactor: 1,
|
||||
MinSize: Size{Height: 140},
|
||||
Layout: VBox{MarginsZero: true},
|
||||
Children: []Widget{
|
||||
TextEdit{AssignTo: &u.status, ReadOnly: true, VScroll: true, MaxLength: 64 * 1024},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).Create(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if u.saveBtn != nil {
|
||||
u.saveBtn.Clicked().Attach(u.onSave)
|
||||
}
|
||||
if u.testBtn != nil {
|
||||
u.testBtn.Clicked().Attach(u.onTest)
|
||||
}
|
||||
if u.startBtn != nil {
|
||||
u.startBtn.Clicked().Attach(u.onStart)
|
||||
}
|
||||
if u.stopBtn != nil {
|
||||
u.stopBtn.Clicked().Attach(u.onStop)
|
||||
u.stopBtn.SetEnabled(false)
|
||||
}
|
||||
u.appendStatus("填写后点「保存」,再「测试连接」。通过后点「启动」,影刀连 本机地址:端口。")
|
||||
u.appendStatus("最小化或关闭窗口会藏到系统托盘(监听可继续)。托盘左键打开,右键退出。")
|
||||
for _, w := range cfg.Warnings {
|
||||
u.appendStatus("注意: " + w)
|
||||
}
|
||||
|
||||
if err := u.applyBranding(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ni, err := walk.NewNotifyIcon(u.mw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.ni = ni
|
||||
if u.appIcon != nil {
|
||||
_ = ni.SetIcon(u.appIcon)
|
||||
_ = u.mw.SetIcon(u.appIcon)
|
||||
}
|
||||
_ = ni.SetToolTip("MySQL Tunnel")
|
||||
_ = ni.SetVisible(true)
|
||||
ni.MouseDown().Attach(func(x, y int, button walk.MouseButton) {
|
||||
if button == walk.LeftButton {
|
||||
u.showWindow()
|
||||
}
|
||||
})
|
||||
|
||||
showAct := walk.NewAction()
|
||||
_ = showAct.SetText("打开窗口")
|
||||
showAct.Triggered().Attach(u.showWindow)
|
||||
exitAct := walk.NewAction()
|
||||
_ = exitAct.SetText("退出")
|
||||
exitAct.Triggered().Attach(u.onExit)
|
||||
if menu := ni.ContextMenu(); menu != nil {
|
||||
_ = menu.Actions().Add(showAct)
|
||||
_ = menu.Actions().Add(exitAct)
|
||||
}
|
||||
|
||||
// Minimize → tray (not taskbar). Close also → tray unless exiting.
|
||||
u.mw.SizeChanged().Attach(func() {
|
||||
hwnd := win.HWND(u.mw.Handle())
|
||||
if win.IsIconic(hwnd) {
|
||||
u.hideToTray()
|
||||
}
|
||||
})
|
||||
u.mw.Closing().Attach(func(canceled *bool, reason walk.CloseReason) {
|
||||
u.mu.Lock()
|
||||
exiting := u.exiting
|
||||
u.mu.Unlock()
|
||||
if exiting {
|
||||
return
|
||||
}
|
||||
*canceled = true
|
||||
u.hideToTray()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) hideToTray() {
|
||||
if u.mw == nil {
|
||||
return
|
||||
}
|
||||
u.mw.Hide()
|
||||
hwnd := win.HWND(u.mw.Handle())
|
||||
win.ShowWindow(hwnd, win.SW_HIDE)
|
||||
if u.ni != nil && !u.hidOnce {
|
||||
u.hidOnce = true
|
||||
_ = u.ni.ShowInfo("MySQL Tunnel", "已放到系统托盘。左键打开窗口,右键退出。")
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) showWindow() {
|
||||
if u.mw == nil {
|
||||
return
|
||||
}
|
||||
u.mw.Show()
|
||||
hwnd := win.HWND(u.mw.Handle())
|
||||
win.ShowWindow(hwnd, win.SW_RESTORE)
|
||||
win.SetForegroundWindow(hwnd)
|
||||
}
|
||||
|
||||
func (u *ui) onExit() {
|
||||
u.mu.Lock()
|
||||
u.exiting = true
|
||||
u.mu.Unlock()
|
||||
u.stopServer()
|
||||
if u.ni != nil {
|
||||
_ = u.ni.SetVisible(false)
|
||||
}
|
||||
u.mw.Close()
|
||||
}
|
||||
|
||||
func (u *ui) collect() (*config.Config, error) {
|
||||
c := config.Defaults()
|
||||
c.Path = u.cfgPath
|
||||
c.ListenHost = strings.TrimSpace(lineText(u.listenHost))
|
||||
c.ListenPort = numValue(u.listenPort, config.DefaultListenPort)
|
||||
c.LocalUsername = strings.TrimSpace(lineText(u.localUser))
|
||||
c.LocalPassword = lineText(u.localPass)
|
||||
c.TunnelURL = strings.TrimSpace(lineText(u.tunnelURL))
|
||||
c.EncodeBase64 = boxChecked(u.encodeB64)
|
||||
c.TLSInsecureSkipVerify = boxChecked(u.tlsSkip)
|
||||
c.RemoteHost = strings.TrimSpace(lineText(u.remoteHost))
|
||||
c.RemotePort = numValue(u.remotePort, 3306)
|
||||
c.RemoteUsername = strings.TrimSpace(lineText(u.remoteUser))
|
||||
c.RemotePassword = lineText(u.remotePass)
|
||||
c.RemoteDatabase = strings.TrimSpace(lineText(u.remoteDB))
|
||||
c.RemoteCharset = strings.TrimSpace(lineText(u.charset))
|
||||
c.RequestTimeout = time.Duration(numValue(u.timeout, config.DefaultTimeoutSec)) * time.Second
|
||||
c.MaxConnections = numValue(u.maxConn, config.DefaultMaxConnections)
|
||||
if u.logLevel != nil {
|
||||
c.LogLevel = strings.ToLower(strings.TrimSpace(u.logLevel.Text()))
|
||||
}
|
||||
if c.LogLevel == "" {
|
||||
c.LogLevel = config.DefaultLogLevel
|
||||
}
|
||||
c.LogSQL = boxChecked(u.logSQL)
|
||||
c.LogFile = strings.TrimSpace(lineText(u.logFile))
|
||||
if c.LogFile == "" {
|
||||
c.LogFile = config.DefaultLogFile
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
return &c, err
|
||||
}
|
||||
c.ResolvePaths()
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (u *ui) onSave() {
|
||||
c, err := u.collect()
|
||||
if err != nil {
|
||||
u.appendStatus("保存失败: " + err.Error())
|
||||
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
|
||||
return
|
||||
}
|
||||
if err := config.Save(u.cfgPath, c); err != nil {
|
||||
u.appendStatus("保存失败: " + err.Error())
|
||||
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
|
||||
return
|
||||
}
|
||||
u.appendStatus("已保存 " + u.cfgPath)
|
||||
for _, w := range c.Warnings {
|
||||
u.appendStatus("注意: " + w)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) onTest() {
|
||||
c, err := u.collect()
|
||||
if err != nil {
|
||||
u.appendStatus("无法测试: " + err.Error())
|
||||
walk.MsgBox(u.mw, "MySQL Tunnel", "无法测试:\n"+err.Error(), walk.MsgBoxIconError)
|
||||
return
|
||||
}
|
||||
u.setBusy(true)
|
||||
u.setHint("正在测试隧道…")
|
||||
u.appendStatus("正在测试 " + config.RedactedTunnelURL(c.TunnelURL) + " …")
|
||||
go func() {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
u.mw.Synchronize(func() {
|
||||
msg := fmt.Sprintf("测试异常: %v", rec)
|
||||
u.appendStatus(msg)
|
||||
u.setHint("测试异常")
|
||||
walk.MsgBox(u.mw, "测试结果", msg, walk.MsgBoxIconError)
|
||||
u.setBusy(false)
|
||||
})
|
||||
}
|
||||
}()
|
||||
log, _ := logger.New(c.LogFile, c.LogLevel, c.LogSQL)
|
||||
r := app.Test(context.Background(), c, log)
|
||||
if log != nil {
|
||||
_ = log.Close()
|
||||
}
|
||||
u.mw.Synchronize(func() {
|
||||
u.appendStatus(strings.ReplaceAll(r.Text(), "\n", "\r\n"))
|
||||
icon := walk.MsgBoxIconError
|
||||
hint := "测试未通过"
|
||||
if r.OK() {
|
||||
icon = walk.MsgBoxIconInformation
|
||||
hint = "测试通过"
|
||||
u.appendStatus("测试通过,可以启动监听。")
|
||||
} else {
|
||||
u.appendStatus("测试未通过。")
|
||||
}
|
||||
u.setHint(hint)
|
||||
walk.MsgBox(u.mw, "测试结果", strings.ReplaceAll(r.Text(), "\r\n", "\n"), icon)
|
||||
u.setBusy(false)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (u *ui) onStart() {
|
||||
u.mu.Lock()
|
||||
if u.running {
|
||||
u.mu.Unlock()
|
||||
return
|
||||
}
|
||||
u.mu.Unlock()
|
||||
c, err := u.collect()
|
||||
if err != nil {
|
||||
u.appendStatus("无法启动: " + err.Error())
|
||||
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
|
||||
return
|
||||
}
|
||||
log, err := logger.New(c.LogFile, c.LogLevel, c.LogSQL)
|
||||
if err != nil {
|
||||
u.appendStatus("日志: " + err.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
u.mu.Lock()
|
||||
u.cancel = cancel
|
||||
u.log = log
|
||||
u.running = true
|
||||
u.mu.Unlock()
|
||||
u.refreshButtons()
|
||||
u.appendStatus(fmt.Sprintf("正在监听 %s:%d 影刀用户=%s", c.ListenHost, c.ListenPort, c.LocalUsername))
|
||||
u.appendStatus("隧道: " + config.RedactedTunnelURL(c.TunnelURL))
|
||||
if u.ni != nil {
|
||||
_ = u.ni.SetToolTip(fmt.Sprintf("MySQL Tunnel · %s:%d", c.ListenHost, c.ListenPort))
|
||||
}
|
||||
go func() {
|
||||
err := app.Serve(ctx, c, log)
|
||||
u.mw.Synchronize(func() {
|
||||
u.mu.Lock()
|
||||
u.running = false
|
||||
u.cancel = nil
|
||||
if u.log != nil {
|
||||
_ = u.log.Close()
|
||||
u.log = nil
|
||||
}
|
||||
u.mu.Unlock()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
u.appendStatus("监听结束: " + logger.RedactError(err))
|
||||
} else {
|
||||
u.appendStatus("监听已停止")
|
||||
}
|
||||
if u.ni != nil {
|
||||
_ = u.ni.SetToolTip("MySQL Tunnel")
|
||||
}
|
||||
u.refreshButtons()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (u *ui) onStop() {
|
||||
u.stopServer()
|
||||
}
|
||||
|
||||
func (u *ui) stopServer() {
|
||||
u.mu.Lock()
|
||||
cancel := u.cancel
|
||||
u.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) setBusy(b bool) {
|
||||
u.mu.Lock()
|
||||
u.busy = b
|
||||
u.mu.Unlock()
|
||||
u.refreshButtons()
|
||||
}
|
||||
|
||||
func (u *ui) refreshButtons() {
|
||||
u.mu.Lock()
|
||||
running, busy := u.running, u.busy
|
||||
u.mu.Unlock()
|
||||
if u.saveBtn != nil {
|
||||
u.saveBtn.SetEnabled(!busy)
|
||||
}
|
||||
if u.testBtn != nil {
|
||||
u.testBtn.SetEnabled(!busy)
|
||||
if busy {
|
||||
_ = u.testBtn.SetText("测试中…")
|
||||
} else {
|
||||
_ = u.testBtn.SetText("测试连接")
|
||||
}
|
||||
}
|
||||
if u.startBtn != nil {
|
||||
u.startBtn.SetEnabled(!running && !busy)
|
||||
}
|
||||
if u.stopBtn != nil {
|
||||
u.stopBtn.SetEnabled(running)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) setHint(s string) {
|
||||
if u.hint == nil {
|
||||
return
|
||||
}
|
||||
_ = u.hint.SetText(s)
|
||||
}
|
||||
|
||||
func (u *ui) appendStatus(line string) {
|
||||
line = strings.ReplaceAll(strings.TrimRight(line, "\r\n"), "\n", "\r\n")
|
||||
if u.status != nil {
|
||||
prev := u.status.Text()
|
||||
if prev != "" {
|
||||
prev += "\r\n"
|
||||
}
|
||||
text := prev + time.Now().Format("15:04:05") + " " + line
|
||||
_ = u.status.SetText(text)
|
||||
end := len([]rune(text))
|
||||
u.status.SetTextSelection(end, end)
|
||||
}
|
||||
first := line
|
||||
if i := strings.Index(first, "\r\n"); i >= 0 {
|
||||
first = first[:i]
|
||||
}
|
||||
if len([]rune(first)) > 80 {
|
||||
first = string([]rune(first)[:80]) + "…"
|
||||
}
|
||||
u.setHint(first)
|
||||
}
|
||||
|
||||
func relativeLogFile(cfg *config.Config) string {
|
||||
if cfg == nil || cfg.LogFile == "" {
|
||||
return config.DefaultLogFile
|
||||
}
|
||||
if !filepath.IsAbs(cfg.LogFile) {
|
||||
return filepath.ToSlash(cfg.LogFile)
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Dir(cfg.Path), cfg.LogFile)
|
||||
if err != nil {
|
||||
return cfg.LogFile
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func lineText(e *walk.LineEdit) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Text()
|
||||
}
|
||||
|
||||
func numValue(e *walk.NumberEdit, def int) int {
|
||||
if e == nil {
|
||||
return def
|
||||
}
|
||||
v := int(e.Value())
|
||||
if v == 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func boxChecked(b *walk.CheckBox) bool {
|
||||
return b != nil && b.Checked()
|
||||
}
|
||||
|
||||
func nonzero(v, def int) int {
|
||||
if v == 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func orDefault(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func fieldLabel(text string) Label {
|
||||
return Label{Text: text, Alignment: AlignHFarVCenter, MinSize: Size{Width: 72}}
|
||||
}
|
||||
|
||||
func (u *ui) applyBranding() error {
|
||||
if len(logoPNG) == 0 {
|
||||
return nil
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(logoPNG))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if ico, err := walk.NewIconFromImage(img); err == nil {
|
||||
u.appIcon = ico
|
||||
if u.mw != nil {
|
||||
_ = u.mw.SetIcon(ico)
|
||||
}
|
||||
}
|
||||
if bmp, err := walk.NewBitmapFromImage(img); err == nil {
|
||||
u.appBmp = bmp
|
||||
if u.logoView != nil {
|
||||
_ = u.logoView.SetImage(bmp)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func timeoutSec(cfg *config.Config) int {
|
||||
if cfg == nil || cfg.RequestTimeout <= 0 {
|
||||
return config.DefaultTimeoutSec
|
||||
}
|
||||
return int(cfg.RequestTimeout.Seconds())
|
||||
}
|
||||
|
||||
func hideConsoleIfStandalone() {
|
||||
var pids [16]uint32
|
||||
n, _, _ := procGetConsoleProcessList.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
|
||||
if n == 0 || n > 1 {
|
||||
return
|
||||
}
|
||||
hwnd, _, _ := procGetConsoleWindow.Call()
|
||||
if hwnd == 0 {
|
||||
return
|
||||
}
|
||||
procShowWindow.Call(hwnd, 0)
|
||||
}
|
||||
|
||||
var (
|
||||
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procShowWindow = user32.NewProc("ShowWindow")
|
||||
procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow")
|
||||
procGetConsoleProcessList = kernel32.NewProc("GetConsoleProcessList")
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
@@ -0,0 +1,262 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
RetainDays = 7
|
||||
dateLayout = "2006-01-02"
|
||||
)
|
||||
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelDebug Level = iota
|
||||
LevelInfo
|
||||
LevelWarn
|
||||
LevelError
|
||||
)
|
||||
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "debug":
|
||||
return LevelDebug
|
||||
case "warn", "warning":
|
||||
return LevelWarn
|
||||
case "error":
|
||||
return LevelError
|
||||
default:
|
||||
return LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelDebug:
|
||||
return "DEBUG"
|
||||
case LevelWarn:
|
||||
return "WARN"
|
||||
case LevelError:
|
||||
return "ERROR"
|
||||
default:
|
||||
return "INFO"
|
||||
}
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
mu sync.Mutex
|
||||
level Level
|
||||
out io.Writer
|
||||
file *os.File
|
||||
basePath string // e.g. .../logs/tunnel.log → files tunnel-YYYY-MM-DD.log
|
||||
day string // current file date YYYY-MM-DD
|
||||
logSQL bool
|
||||
}
|
||||
|
||||
func New(path string, level string, logSQL bool) (*Logger, error) {
|
||||
l := &Logger{
|
||||
level: ParseLevel(level),
|
||||
logSQL: logSQL,
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
l.out = os.Stdout
|
||||
return l, nil
|
||||
}
|
||||
l.basePath = path
|
||||
if err := l.rotateLocked(time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *Logger) Close() error {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := l.file.Close()
|
||||
l.file = nil
|
||||
l.out = os.Stdout
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *Logger) LogSQLEnabled() bool {
|
||||
return l.logSQL
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(msg string, args ...any) { l.log(LevelDebug, msg, args...) }
|
||||
func (l *Logger) Info(msg string, args ...any) { l.log(LevelInfo, msg, args...) }
|
||||
func (l *Logger) Warn(msg string, args ...any) { l.log(LevelWarn, msg, args...) }
|
||||
func (l *Logger) Error(msg string, args ...any) { l.log(LevelError, msg, args...) }
|
||||
|
||||
func (l *Logger) log(level Level, msg string, args ...any) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
if level < l.level {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
line := fmt.Sprintf("%s %s %s", now.Format("2006-01-02 15:04:05"), level.String(), format(msg, args...))
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.basePath != "" {
|
||||
_ = l.rotateLocked(now)
|
||||
}
|
||||
fmt.Fprintln(l.out, line)
|
||||
}
|
||||
|
||||
// rotateLocked opens today's file when the calendar day changes. Caller holds mu.
|
||||
func (l *Logger) rotateLocked(now time.Time) error {
|
||||
day := now.Format(dateLayout)
|
||||
if l.file != nil && l.day == day {
|
||||
return nil
|
||||
}
|
||||
path := DatedPath(l.basePath, now)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return fmt.Errorf("create log dir: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log file: %w", err)
|
||||
}
|
||||
if l.file != nil {
|
||||
_ = l.file.Close()
|
||||
}
|
||||
l.file = f
|
||||
l.day = day
|
||||
l.out = io.MultiWriter(os.Stdout, f)
|
||||
_ = purgeOld(l.basePath, now, RetainDays)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DatedPath turns logs/tunnel.log into logs/tunnel-2006-01-02.log.
|
||||
func DatedPath(base string, day time.Time) string {
|
||||
dir := filepath.Dir(base)
|
||||
ext := filepath.Ext(base)
|
||||
stem := strings.TrimSuffix(filepath.Base(base), ext)
|
||||
if stem == "" || stem == "." {
|
||||
stem = "tunnel"
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".log"
|
||||
}
|
||||
return filepath.Join(dir, stem+"-"+day.Format(dateLayout)+ext)
|
||||
}
|
||||
|
||||
func purgeOld(base string, now time.Time, retainDays int) error {
|
||||
if retainDays < 1 {
|
||||
retainDays = RetainDays
|
||||
}
|
||||
dir := filepath.Dir(base)
|
||||
ext := filepath.Ext(base)
|
||||
stem := strings.TrimSuffix(filepath.Base(base), ext)
|
||||
if stem == "" || stem == "." {
|
||||
stem = "tunnel"
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".log"
|
||||
}
|
||||
re := regexp.MustCompile("^" + regexp.QuoteMeta(stem) + `-(\d{4}-\d{2}-\d{2})` + regexp.QuoteMeta(ext) + `$`)
|
||||
// Keep retainDays calendar days including today (e.g. 7 → today back through today-6).
|
||||
oldest := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
|
||||
AddDate(0, 0, -(retainDays - 1))
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
m := re.FindStringSubmatch(e.Name())
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
d, err := time.ParseInLocation(dateLayout, m[1], now.Location())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if d.Before(oldest) {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func format(msg string, args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return msg
|
||||
}
|
||||
return msg + " " + strings.TrimSpace(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
// RedactURL removes userinfo and known secret query keys.
|
||||
func RedactURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || raw == "" {
|
||||
return "(redacted-url)"
|
||||
}
|
||||
u.User = nil
|
||||
q := u.Query()
|
||||
changed := false
|
||||
for key := range q {
|
||||
lk := strings.ToLower(key)
|
||||
if lk == "token" || lk == "password" || lk == "pass" || strings.Contains(lk, "secret") || strings.Contains(lk, "access") {
|
||||
q.Set(key, "***")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
var secretKV = regexp.MustCompile(`(?i)(token|password|passwd|authorization)\s*[=:]\s*[^\s&;]+`)
|
||||
|
||||
func RedactError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return RedactText(err.Error())
|
||||
}
|
||||
|
||||
func RedactText(s string) string {
|
||||
out := secretKV.ReplaceAllString(s, "${1}=***")
|
||||
if u := extractURL(out); u != "" {
|
||||
out = strings.ReplaceAll(out, u, RedactURL(u))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractURL(s string) string {
|
||||
lower := strings.ToLower(s)
|
||||
start := strings.Index(lower, "https://")
|
||||
if start < 0 {
|
||||
start = strings.Index(lower, "http://")
|
||||
}
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
end := start
|
||||
for end < len(s) && !strings.ContainsRune(" \t\r\n<>\"'", rune(s[end])) {
|
||||
end++
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRedactURLToken(t *testing.T) {
|
||||
raw := "http://ntunnel.example.com/?token=secret-token-value"
|
||||
got := RedactURL(raw)
|
||||
if strings.Contains(got, "secret-token-value") {
|
||||
t.Fatalf("token leaked: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactTextPasswordAndToken(t *testing.T) {
|
||||
in := `login failed password=hunter2 token=abc123 url=https://x.example/?token=zzz`
|
||||
got := RedactText(in)
|
||||
for _, secret := range []string{"hunter2", "abc123", "zzz"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("secret %q leaked in %q", secret, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
if ParseLevel("DEBUG") != LevelDebug {
|
||||
t.Fatal("debug")
|
||||
}
|
||||
if ParseLevel("nope") != LevelInfo {
|
||||
t.Fatal("default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatedPath(t *testing.T) {
|
||||
day := time.Date(2026, 9, 21, 15, 0, 0, 0, time.Local)
|
||||
got := DatedPath(filepath.Join("logs", "tunnel.log"), day)
|
||||
want := filepath.Join("logs", "tunnel-2026-09-21.log")
|
||||
if got != want {
|
||||
t.Fatalf("got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyRotateAndPurge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "tunnel.log")
|
||||
now := time.Date(2026, 9, 21, 10, 0, 0, 0, time.Local)
|
||||
|
||||
keepDay := now.AddDate(0, 0, -3)
|
||||
dropDay := now.AddDate(0, 0, -8)
|
||||
keepPath := DatedPath(base, keepDay)
|
||||
dropPath := DatedPath(base, dropDay)
|
||||
if err := os.WriteFile(keepPath, []byte("keep\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dropPath, []byte("drop\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
l, err := New(base, "info", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
l.mu.Lock()
|
||||
if err := l.rotateLocked(now); err != nil {
|
||||
l.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
l.Info("hello")
|
||||
|
||||
today := DatedPath(base, now)
|
||||
if _, err := os.Stat(today); err != nil {
|
||||
t.Fatalf("today log missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(keepPath); err != nil {
|
||||
t.Fatalf("recent log should remain: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dropPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("old log should be purged, err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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} }
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mysql-tunnel/internal/logger"
|
||||
)
|
||||
|
||||
// HTTPClient is a generic HTTP client for the tunnel endpoint.
|
||||
// Navicat request/response encoding belongs in package navicat (Phase 2).
|
||||
type HTTPClient struct {
|
||||
base *http.Client
|
||||
insecure bool
|
||||
userAgent string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Timeout time.Duration
|
||||
TLSInsecureSkipVerify bool
|
||||
}
|
||||
|
||||
func NewHTTPClient(opt Options) *HTTPClient {
|
||||
if opt.Timeout <= 0 {
|
||||
opt.Timeout = 30 * time.Second
|
||||
}
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 8,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
if opt.TLSInsecureSkipVerify {
|
||||
transport.TLSClientConfig.InsecureSkipVerify = true
|
||||
}
|
||||
return &HTTPClient{
|
||||
base: &http.Client{
|
||||
Timeout: opt.Timeout,
|
||||
Transport: transport,
|
||||
},
|
||||
insecure: opt.TLSInsecureSkipVerify,
|
||||
userAgent: "mysql-tunnel/0.1",
|
||||
}
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
StatusCode int
|
||||
ContentType string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
func (c *HTTPClient) Do(ctx context.Context, method, rawURL string, contentType string, body io.Reader) (*Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", redactErr(err))
|
||||
}
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
resp, err := c.base.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http %s %s: %w", method, logger.RedactURL(rawURL), redactErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response from %s: %w", logger.RedactURL(rawURL), err)
|
||||
}
|
||||
return &Response{
|
||||
StatusCode: resp.StatusCode,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
Body: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) InsecureTLS() bool {
|
||||
return c.insecure
|
||||
}
|
||||
|
||||
func redactErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", logger.RedactError(err))
|
||||
}
|
||||
|
||||
func LooksLikeNavicatHeader(body []byte) bool {
|
||||
if len(body) < 16 {
|
||||
return false
|
||||
}
|
||||
magic := uint32(body[0])<<24 | uint32(body[1])<<16 | uint32(body[2])<<8 | uint32(body[3])
|
||||
return magic == 1111
|
||||
}
|
||||
|
||||
func HeaderErrno(body []byte) (uint32, bool) {
|
||||
if len(body) < 10 {
|
||||
return 0, false
|
||||
}
|
||||
errno := uint32(body[6])<<24 | uint32(body[7])<<16 | uint32(body[8])<<8 | uint32(body[9])
|
||||
return errno, true
|
||||
}
|
||||
|
||||
func IsProbablyHTML(body []byte) bool {
|
||||
s := strings.TrimSpace(string(body))
|
||||
if len(s) < 5 {
|
||||
return false
|
||||
}
|
||||
ls := strings.ToLower(s[:min(64, len(s))])
|
||||
return strings.HasPrefix(ls, "<!doctype") || strings.HasPrefix(ls, "<html")
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLooksLikeNavicatHeader(t *testing.T) {
|
||||
body := make([]byte, 16)
|
||||
body[2] = 0x04
|
||||
body[3] = 0x57 // 1111
|
||||
body[5] = 206
|
||||
if !LooksLikeNavicatHeader(body) {
|
||||
t.Fatal("expected magic 1111")
|
||||
}
|
||||
body[3] = 0x00
|
||||
if LooksLikeNavicatHeader(body) {
|
||||
t.Fatal("false positive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderErrno(t *testing.T) {
|
||||
body := make([]byte, 16)
|
||||
body[9] = 202
|
||||
errno, ok := HeaderErrno(body)
|
||||
if !ok || errno != 202 {
|
||||
t.Fatalf("errno=%d ok=%v", errno, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientPOST(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("ct %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if strings.Contains(string(b), "actn=C") == false {
|
||||
t.Errorf("body %s", b)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=x-user-defined")
|
||||
hdr := make([]byte, 16)
|
||||
hdr[2] = 0x04
|
||||
hdr[3] = 0x57
|
||||
hdr[5] = 206
|
||||
hdr[9] = 202
|
||||
_, _ = w.Write(hdr)
|
||||
_, _ = w.Write([]byte{0x12})
|
||||
_, _ = w.Write([]byte("invalid parameters"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewHTTPClient(Options{Timeout: 5 * time.Second})
|
||||
resp, err := c.Do(context.Background(), http.MethodPost, srv.URL, "application/x-www-form-urlencoded", strings.NewReader("actn=C&host=mysql&port=3306&login=root"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if !LooksLikeNavicatHeader(resp.Body) {
|
||||
t.Fatal("header")
|
||||
}
|
||||
errno, _ := HeaderErrno(resp.Body)
|
||||
if errno != 202 {
|
||||
t.Fatalf("errno %d", errno)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewHTTPClient(Options{Timeout: 50 * time.Millisecond})
|
||||
_, err := c.Do(context.Background(), http.MethodGet, srv.URL, "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package winsvc
|
||||
|
||||
const (
|
||||
Name = "mysql-tunnel"
|
||||
DisplayName = "MySQL Tunnel"
|
||||
Description = "Local MySQL proxy that translates MySQL protocol to Navicat HTTP Tunnel for Yingdao RPA."
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build !windows
|
||||
|
||||
package winsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func IsService() bool { return false }
|
||||
|
||||
func Run(func(ctx context.Context) error) error {
|
||||
return fmt.Errorf("Windows service is only supported on Windows")
|
||||
}
|
||||
|
||||
func Install(string, string) error { return fmt.Errorf("Windows service is only supported on Windows") }
|
||||
func Uninstall() error { return fmt.Errorf("Windows service is only supported on Windows") }
|
||||
func Start() error { return fmt.Errorf("Windows service is only supported on Windows") }
|
||||
func Stop() error { return fmt.Errorf("Windows service is only supported on Windows") }
|
||||
|
||||
func ExePath() (string, error) {
|
||||
p, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(p)
|
||||
}
|
||||
|
||||
func CopyFile(src, dst string) error {
|
||||
in, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, in, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//go:build windows
|
||||
|
||||
package winsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"golang.org/x/sys/windows/svc/mgr"
|
||||
)
|
||||
|
||||
func IsService() bool {
|
||||
ok, err := svc.IsWindowsService()
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
func Run(run func(ctx context.Context) error) error {
|
||||
return svc.Run(Name, &handler{run: run})
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
run func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func (h *handler) Execute(_ []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
|
||||
changes <- svc.Status{State: svc.StartPending}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- h.run(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
return true, 1
|
||||
}
|
||||
return false, 0
|
||||
case <-time.After(800 * time.Millisecond):
|
||||
changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
return true, 1
|
||||
}
|
||||
return false, 0
|
||||
case c := <-r:
|
||||
switch c.Cmd {
|
||||
case svc.Interrogate:
|
||||
changes <- c.CurrentStatus
|
||||
case svc.Stop, svc.Shutdown:
|
||||
changes <- svc.Status{State: svc.StopPending}
|
||||
cancel()
|
||||
select {
|
||||
case <-errCh:
|
||||
case <-time.After(8 * time.Second):
|
||||
}
|
||||
return false, 0
|
||||
default:
|
||||
changes <- c.CurrentStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Install(exePath, configPath string) error {
|
||||
m, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return wrapAccess(err, "connect to service manager")
|
||||
}
|
||||
defer m.Disconnect()
|
||||
|
||||
if s, err := m.OpenService(Name); err == nil {
|
||||
s.Close()
|
||||
return fmt.Errorf("service %s already exists; run --uninstall first", Name)
|
||||
}
|
||||
|
||||
s, err := m.CreateService(Name, exePath, mgr.Config{
|
||||
DisplayName: DisplayName,
|
||||
Description: Description,
|
||||
StartType: mgr.StartAutomatic,
|
||||
ErrorControl: mgr.ErrorNormal,
|
||||
}, "--config", configPath)
|
||||
if err != nil {
|
||||
return wrapAccess(err, "create service")
|
||||
}
|
||||
defer s.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func Uninstall() error {
|
||||
m, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return wrapAccess(err, "connect to service manager")
|
||||
}
|
||||
defer m.Disconnect()
|
||||
s, err := m.OpenService(Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service %s not installed", Name)
|
||||
}
|
||||
defer s.Close()
|
||||
st, err := s.Query()
|
||||
if err == nil && st.State != svc.Stopped {
|
||||
_ = control(s, svc.Stop, svc.Stopped)
|
||||
}
|
||||
if err := s.Delete(); err != nil {
|
||||
return wrapAccess(err, "delete service")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Start() error {
|
||||
m, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return wrapAccess(err, "connect to service manager")
|
||||
}
|
||||
defer m.Disconnect()
|
||||
s, err := m.OpenService(Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service %s not installed", Name)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := s.Start(); err != nil {
|
||||
if err == windows.ERROR_SERVICE_ALREADY_RUNNING {
|
||||
return nil
|
||||
}
|
||||
return wrapAccess(err, "start service")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Stop() error {
|
||||
m, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return wrapAccess(err, "connect to service manager")
|
||||
}
|
||||
defer m.Disconnect()
|
||||
s, err := m.OpenService(Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service %s not installed", Name)
|
||||
}
|
||||
defer s.Close()
|
||||
st, err := s.Query()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if st.State == svc.Stopped {
|
||||
return nil
|
||||
}
|
||||
return control(s, svc.Stop, svc.Stopped)
|
||||
}
|
||||
|
||||
func control(s *mgr.Service, c svc.Cmd, to svc.State) error {
|
||||
status, err := s.Control(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for status.State != to {
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timeout waiting for service state %v", to)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
status, err = s.Query()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wrapAccess(err error, op string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if err == windows.ERROR_ACCESS_DENIED {
|
||||
return fmt.Errorf("%s: access denied; run as Administrator", op)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
|
||||
func ExePath() (string, error) {
|
||||
p, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r, err := filepath.EvalSymlinks(p); err == nil {
|
||||
return r, nil
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func CopyFile(src, dst string) error {
|
||||
in, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, in, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Same usage as 影刀 RPA:
|
||||
|
||||
import pymysql
|
||||
conn = pymysql.connect(
|
||||
host="127.0.0.1",
|
||||
port=13306,
|
||||
user="root",
|
||||
password="123456",
|
||||
db="your_database",
|
||||
)
|
||||
|
||||
Values come from config.yaml: listen + local_auth + remote.database.
|
||||
mysql-tunnel.exe must already be running.
|
||||
|
||||
python tests/http_tunnel.py
|
||||
python tests/http_tunnel.py --sql "SHOW TABLES"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import ssl
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
MAGIC = 1111
|
||||
MARK_LONG = 0xFE
|
||||
MARK_NULL = 0xFF
|
||||
SEP_MORE = 0x01
|
||||
SEP_LAST = 0x00
|
||||
MAX_BLOCK = 64 << 20
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class TunnelError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def parse_scalar(raw: str) -> Any:
|
||||
v = raw.strip()
|
||||
if v == "":
|
||||
return ""
|
||||
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
||||
return v[1:-1]
|
||||
low = v.lower()
|
||||
if low in ("true", "yes"):
|
||||
return True
|
||||
if low in ("false", "no"):
|
||||
return False
|
||||
try:
|
||||
return int(v)
|
||||
except ValueError:
|
||||
return v
|
||||
|
||||
|
||||
def load_project_yaml(path: Path) -> dict[str, Any]:
|
||||
root: dict[str, Any] = {}
|
||||
section: str | None = None
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip(" \t"))
|
||||
if indent == 0 and stripped.endswith(":") and ":" not in stripped[:-1]:
|
||||
section = stripped[:-1]
|
||||
root[section] = {}
|
||||
continue
|
||||
if section is None or ":" not in stripped:
|
||||
continue
|
||||
key, _, val = stripped.partition(":")
|
||||
root[section][key.strip()] = parse_scalar(val)
|
||||
return root
|
||||
|
||||
|
||||
def redact_url(raw: str) -> str:
|
||||
u = urlparse(raw)
|
||||
q = []
|
||||
for k, v in parse_qsl(u.query, keep_blank_values=True):
|
||||
if k.lower() in ("token", "password", "pass", "access_token", "accesstoken"):
|
||||
q.append((k, "***"))
|
||||
else:
|
||||
q.append((k, v))
|
||||
return urlunparse(u._replace(query=urlencode(q), fragment=""))
|
||||
|
||||
|
||||
def looks_html(body: bytes) -> bool:
|
||||
s = body.lstrip()[:64].decode("latin-1", errors="replace").lower()
|
||||
return s.startswith("<!doctype") or s.startswith("<html")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Header:
|
||||
magic: int
|
||||
version: int
|
||||
errno: int
|
||||
message: str = ""
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.errno == 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Field:
|
||||
name: str
|
||||
table: str
|
||||
type: int = 0
|
||||
flags: int = 0
|
||||
length: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
errno: int = 0
|
||||
message: str = ""
|
||||
affected_rows: int = 0
|
||||
insert_id: int = 0
|
||||
num_fields: int = 0
|
||||
num_rows: int = 0
|
||||
info: str = ""
|
||||
fields: list[Field] = field(default_factory=list)
|
||||
rows: list[list[str | None]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_error(self) -> bool:
|
||||
return self.errno > 0
|
||||
|
||||
@property
|
||||
def is_result_set(self) -> bool:
|
||||
return self.errno == 0 and self.num_fields > 0
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, buf: bytes) -> None:
|
||||
self.buf = buf
|
||||
self.off = 0
|
||||
|
||||
def remaining(self) -> int:
|
||||
return len(self.buf) - self.off
|
||||
|
||||
def read(self, n: int) -> bytes:
|
||||
if n < 0 or self.off + n > len(self.buf):
|
||||
raise TunnelError("truncated Navicat binary")
|
||||
s = self.buf[self.off : self.off + n]
|
||||
self.off += n
|
||||
return s
|
||||
|
||||
def u8(self) -> int:
|
||||
return self.read(1)[0]
|
||||
|
||||
def u16(self) -> int:
|
||||
return struct.unpack(">H", self.read(2))[0]
|
||||
|
||||
def u32(self) -> int:
|
||||
return struct.unpack(">I", self.read(4))[0]
|
||||
|
||||
def block(self, nullable: bool = False) -> tuple[bytes | None, bool]:
|
||||
if self.remaining() == 0:
|
||||
raise TunnelError("truncated GetBlock")
|
||||
first = self.buf[self.off]
|
||||
if nullable and first == MARK_NULL:
|
||||
self.off += 1
|
||||
return None, True
|
||||
self.off += 1
|
||||
if first < 254:
|
||||
n = first
|
||||
elif first == MARK_LONG:
|
||||
n = self.u32()
|
||||
if n > MAX_BLOCK:
|
||||
raise TunnelError(f"block length {n} exceeds 64MiB")
|
||||
else:
|
||||
raise TunnelError(f"invalid block marker 0x{first:02x}")
|
||||
return self.read(n), False
|
||||
|
||||
def block_str(self) -> str:
|
||||
val, is_null = self.block(False)
|
||||
if is_null or val is None:
|
||||
raise TunnelError("unexpected NULL block")
|
||||
return val.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def parse_header(body: bytes) -> tuple[Header, Reader]:
|
||||
if looks_html(body):
|
||||
raise TunnelError("tunnel returned HTML, not Navicat binary")
|
||||
if len(body) < 16:
|
||||
raise TunnelError(f"response too short ({len(body)} bytes)")
|
||||
r = Reader(body)
|
||||
magic = r.u32()
|
||||
if magic != MAGIC:
|
||||
raise TunnelError(f"invalid Navicat magic {magic} (want {MAGIC})")
|
||||
version = r.u16()
|
||||
errno = r.u32()
|
||||
r.read(6)
|
||||
msg = ""
|
||||
if errno != 0:
|
||||
msg = r.block_str()
|
||||
return Header(magic, version, errno, msg), r
|
||||
|
||||
|
||||
def parse_connect(body: bytes) -> tuple[Header, dict[str, str]]:
|
||||
h, r = parse_header(body)
|
||||
if not h.ok:
|
||||
return h, {}
|
||||
return h, {
|
||||
"host_info": r.block_str(),
|
||||
"proto_info": r.block_str(),
|
||||
"server_info": r.block_str(),
|
||||
}
|
||||
|
||||
|
||||
def parse_one_result(r: Reader) -> Result:
|
||||
res = Result(
|
||||
errno=r.u32(),
|
||||
affected_rows=r.u32(),
|
||||
insert_id=r.u32(),
|
||||
num_fields=r.u32(),
|
||||
num_rows=r.u32(),
|
||||
)
|
||||
r.read(12)
|
||||
if res.errno > 0:
|
||||
res.message = r.block_str()
|
||||
return res
|
||||
if res.num_fields == 0:
|
||||
res.info = r.block_str()
|
||||
return res
|
||||
for _ in range(res.num_fields):
|
||||
res.fields.append(Field(r.block_str(), r.block_str(), r.u32(), r.u32(), r.u32()))
|
||||
for _ in range(res.num_rows):
|
||||
row: list[str | None] = []
|
||||
for _c in range(res.num_fields):
|
||||
val, is_null = r.block(True)
|
||||
row.append(None if is_null else (val or b"").decode("utf-8", errors="replace"))
|
||||
res.rows.append(row)
|
||||
return res
|
||||
|
||||
|
||||
def parse_query(body: bytes) -> tuple[Header, list[Result]]:
|
||||
h, r = parse_header(body)
|
||||
if not h.ok:
|
||||
return h, []
|
||||
results: list[Result] = []
|
||||
while r.remaining() > 0:
|
||||
results.append(parse_one_result(r))
|
||||
if r.remaining() == 0:
|
||||
break
|
||||
sep = r.u8()
|
||||
if sep == SEP_LAST:
|
||||
break
|
||||
if sep != SEP_MORE:
|
||||
raise TunnelError(f"invalid query separator 0x{sep:02x}")
|
||||
return h, results
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
path: Path
|
||||
listen_host: str
|
||||
listen_port: int
|
||||
local_user: str
|
||||
local_password: str
|
||||
url: str
|
||||
encode_base64: bool
|
||||
tls_insecure: bool
|
||||
remote_host: str
|
||||
remote_port: int
|
||||
remote_user: str
|
||||
remote_password: str
|
||||
database: str
|
||||
charset: str
|
||||
timeout: int
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> Config:
|
||||
data = load_project_yaml(path)
|
||||
listen = data.get("listen") or {}
|
||||
local = data.get("local_auth") or {}
|
||||
tunnel = data.get("tunnel") or {}
|
||||
remote = data.get("remote") or {}
|
||||
options = data.get("options") or {}
|
||||
url = str(tunnel.get("url") or "").strip()
|
||||
if not url:
|
||||
raise TunnelError(f"{path}: tunnel.url is required")
|
||||
local_user = str(local.get("username") or "").strip()
|
||||
if not local_user:
|
||||
raise TunnelError(f"{path}: local_auth.username is required")
|
||||
timeout = int(options.get("request_timeout") or 30)
|
||||
if timeout <= 0:
|
||||
timeout = 30
|
||||
return cls(
|
||||
path=path,
|
||||
listen_host=str(listen.get("host") or "127.0.0.1"),
|
||||
listen_port=int(listen.get("port") or 13306),
|
||||
local_user=local_user,
|
||||
local_password=str(local.get("password") or ""),
|
||||
url=url,
|
||||
encode_base64=bool(tunnel.get("encode_base64")),
|
||||
tls_insecure=bool(tunnel.get("tls_insecure_skip_verify")),
|
||||
remote_host=str(remote.get("host") or ""),
|
||||
remote_port=int(remote.get("port") or 3306),
|
||||
remote_user=str(remote.get("username") or ""),
|
||||
remote_password=str(remote.get("password") or ""),
|
||||
database=str(remote.get("database") or ""),
|
||||
charset=str(remote.get("charset") or "utf8mb4"),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class HTTPClient:
|
||||
"""POST actn=C/Q to ntunnel_mysql.php using remote.* (not local_auth)."""
|
||||
|
||||
def __init__(self, cfg: Config) -> None:
|
||||
self.cfg = cfg
|
||||
ctx = ssl.create_default_context()
|
||||
if cfg.tls_insecure:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
self._ssl = ctx
|
||||
|
||||
def post(self, actn: str, queries: list[str] | None = None) -> bytes:
|
||||
fields: list[tuple[str, str]] = [
|
||||
("actn", actn),
|
||||
("host", self.cfg.remote_host),
|
||||
("port", str(self.cfg.remote_port) if self.cfg.remote_port > 0 else ""),
|
||||
("login", self.cfg.remote_user),
|
||||
("password", self.cfg.remote_password),
|
||||
("db", self.cfg.database),
|
||||
]
|
||||
qs = [q for q in (queries or []) if q.strip()]
|
||||
if self.cfg.encode_base64 and qs:
|
||||
fields.append(("encodeBase64", "1"))
|
||||
for q in qs:
|
||||
fields.append(("q[]", base64.b64encode(q.encode("utf-8")).decode("ascii")))
|
||||
else:
|
||||
for q in qs:
|
||||
fields.append(("q[]", q))
|
||||
body = urlencode(fields).encode("utf-8")
|
||||
req = Request(
|
||||
self.cfg.url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "mysql-tunnel-python/0.5",
|
||||
},
|
||||
)
|
||||
kw: dict[str, Any] = {"timeout": self.cfg.timeout}
|
||||
if urlparse(self.cfg.url).scheme == "https":
|
||||
kw["context"] = self._ssl
|
||||
try:
|
||||
with urlopen(req, **kw) as resp:
|
||||
status = resp.status
|
||||
data = resp.read()
|
||||
except HTTPError as e:
|
||||
if e.code == 403:
|
||||
raise TunnelError("HTTP 403: invalid or missing token") from None
|
||||
raise TunnelError(f"HTTP {e.code}") from None
|
||||
except URLError as e:
|
||||
raise TunnelError(f"HTTP transport: {e.reason}") from None
|
||||
if status < 200 or status >= 300:
|
||||
raise TunnelError(f"HTTP {status}")
|
||||
if looks_html(data):
|
||||
raise TunnelError("tunnel returned HTML, not Navicat binary")
|
||||
return data
|
||||
|
||||
def connect(self) -> tuple[Header, dict[str, str]]:
|
||||
return parse_connect(self.post("C"))
|
||||
|
||||
def query(self, *sqls: str) -> tuple[Header, list[Result]]:
|
||||
qs: list[str] = []
|
||||
cs = (self.cfg.charset or "").strip()
|
||||
if cs and cs.lower() not in ("utf8", "utf8mb3") and cs.replace("_", "").isalnum():
|
||||
qs.append("SET NAMES " + cs)
|
||||
qs.extend(sqls)
|
||||
return parse_query(self.post("Q", qs))
|
||||
|
||||
|
||||
def mysql_connect(cfg: Config):
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError as e:
|
||||
raise TunnelError("install PyMySQL: pip install pymysql") from e
|
||||
import warnings
|
||||
|
||||
# Same signature as 影刀: host/port/user/password/db → listen + local_auth + remote.database
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
return pymysql.connect(
|
||||
host=cfg.listen_host,
|
||||
port=cfg.listen_port,
|
||||
user=cfg.local_user,
|
||||
password=cfg.local_password,
|
||||
db=cfg.database,
|
||||
)
|
||||
except pymysql.err.OperationalError as e:
|
||||
code = e.args[0] if e.args else 0
|
||||
if code in (2003, 2002):
|
||||
raise TunnelError(
|
||||
f"cannot connect {cfg.listen_host}:{cfg.listen_port} — "
|
||||
"start mysql-tunnel.exe first (GUI 「启动」 or --console)"
|
||||
) from None
|
||||
if code == 1045:
|
||||
raise TunnelError(
|
||||
f"local_auth rejected ({cfg.local_user}) — "
|
||||
"影刀/本脚本的 user/password 必须等于 config.yaml 的 local_auth"
|
||||
) from None
|
||||
raise TunnelError(f"mysql connect: {e}") from None
|
||||
|
||||
|
||||
def query_mysql(conn, sql: str) -> Result:
|
||||
import pymysql
|
||||
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
desc = cur.description or ()
|
||||
res = Result(
|
||||
affected_rows=cur.rowcount if not desc else 0,
|
||||
insert_id=int(getattr(cur, "lastrowid", 0) or 0),
|
||||
num_fields=len(desc),
|
||||
num_rows=len(rows) if desc else 0,
|
||||
)
|
||||
if desc:
|
||||
res.fields = [Field(str(col[0]), "") for col in desc]
|
||||
for row in rows:
|
||||
out: list[str | None] = []
|
||||
for v in row:
|
||||
if v is None:
|
||||
out.append(None)
|
||||
elif isinstance(v, bytes):
|
||||
out.append(v.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
out.append(str(v))
|
||||
res.rows.append(out)
|
||||
return res
|
||||
except pymysql.MySQLError as e:
|
||||
errno = int(e.args[0]) if e.args else 0
|
||||
msg = str(e.args[1]) if len(e.args) > 1 else str(e)
|
||||
return Result(errno=errno, message=msg)
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def find_config(explicit: str | None) -> Path:
|
||||
cands: list[Path] = []
|
||||
if explicit:
|
||||
cands.append(Path(explicit))
|
||||
cands.extend(
|
||||
[
|
||||
ROOT / "config.yaml",
|
||||
Path.cwd() / "config.yaml",
|
||||
ROOT / "dist" / "config.yaml",
|
||||
]
|
||||
)
|
||||
for p in cands:
|
||||
if p.is_file():
|
||||
return p.resolve()
|
||||
raise TunnelError("config.yaml not found; pass --config")
|
||||
|
||||
|
||||
def last_result_set(results: list[Result]) -> Result | None:
|
||||
for res in reversed(results):
|
||||
if res.is_result_set:
|
||||
return res
|
||||
return results[-1] if results else None
|
||||
|
||||
|
||||
def print_header(title: str) -> None:
|
||||
print()
|
||||
print("== " + title)
|
||||
|
||||
|
||||
def print_result(res: Result, max_rows: int = 40) -> None:
|
||||
if res.is_error:
|
||||
print(f" mysql errno {res.errno}: {res.message}")
|
||||
return
|
||||
if not res.is_result_set:
|
||||
extra = f" info={res.info!r}" if res.info else ""
|
||||
print(f" OK affected={res.affected_rows} insert_id={res.insert_id}{extra}")
|
||||
return
|
||||
names = [f.name or "?" for f in res.fields]
|
||||
print(f" {res.num_rows} row(s), columns: {', '.join(names)}")
|
||||
widths = [len(n) for n in names]
|
||||
shown = res.rows[:max_rows]
|
||||
for row in shown:
|
||||
for i, cell in enumerate(row):
|
||||
text = "NULL" if cell is None else cell.replace("\n", "\\n")
|
||||
widths[i] = max(widths[i], min(len(text), 48))
|
||||
|
||||
def fmt(val: str, w: int) -> str:
|
||||
if len(val) > w:
|
||||
val = val[: w - 1] + "…"
|
||||
return val.ljust(w)
|
||||
|
||||
print(" " + " | ".join(fmt(n, widths[i]) for i, n in enumerate(names)))
|
||||
print(" " + "-+-".join("-" * w for w in widths))
|
||||
for row in shown:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
text = "NULL" if cell is None else cell.replace("\n", "\\n")
|
||||
cells.append(fmt(text, widths[i]))
|
||||
print(" " + " | ".join(cells))
|
||||
if res.num_rows > max_rows:
|
||||
print(f" … {res.num_rows - max_rows} more row(s)")
|
||||
|
||||
|
||||
def default_sql() -> list[str]:
|
||||
return [
|
||||
"SELECT 1 AS ok",
|
||||
"SELECT VERSION() AS version, DATABASE() AS db, USER() AS user, "
|
||||
"@@character_set_connection AS charset",
|
||||
"SHOW TABLES",
|
||||
]
|
||||
|
||||
|
||||
def run_local(cfg: Config, extra_sql: list[str]) -> int:
|
||||
print("config:", cfg.path)
|
||||
print("pymysql.connect(")
|
||||
print(f' host="{cfg.listen_host}",')
|
||||
print(f" port={cfg.listen_port},")
|
||||
print(f' user="{cfg.local_user}",')
|
||||
print(' password="***",')
|
||||
print(f' db="{cfg.database}",')
|
||||
print(")")
|
||||
print("via: mysql-tunnel.exe →", redact_url(cfg.url))
|
||||
|
||||
try:
|
||||
conn = mysql_connect(cfg)
|
||||
except TunnelError as e:
|
||||
print()
|
||||
print(" Connect: FAIL")
|
||||
print(" ", e)
|
||||
return 1
|
||||
|
||||
print()
|
||||
print("MySQL数据库连接成功")
|
||||
print(" Connect: OK (local_auth)")
|
||||
code = 0
|
||||
try:
|
||||
for sql in extra_sql or default_sql():
|
||||
print_header(sql.replace("\n", " "))
|
||||
res = query_mysql(conn, sql)
|
||||
print_result(res)
|
||||
if res.is_error:
|
||||
code = 1
|
||||
finally:
|
||||
conn.close()
|
||||
return code
|
||||
|
||||
|
||||
def run_direct(cfg: Config, extra_sql: list[str]) -> int:
|
||||
print("config:", cfg.path)
|
||||
print("mode: --direct (POST ntunnel, remote.* not local_auth)")
|
||||
print("tunnel:", redact_url(cfg.url))
|
||||
print("remote:", f"{cfg.remote_user}@{cfg.remote_host}:{cfg.remote_port}/{cfg.database}")
|
||||
|
||||
client = HTTPClient(cfg)
|
||||
code = 0
|
||||
print_header("actn=C connect")
|
||||
try:
|
||||
h, conninfo = client.connect()
|
||||
except TunnelError as e:
|
||||
print(" Tunnel: FAIL")
|
||||
print(" ", e)
|
||||
return 1
|
||||
if not h.ok:
|
||||
print(f" errno {h.errno}: {h.message}")
|
||||
print(" Remote MySQL: FAIL")
|
||||
return 1
|
||||
print(f" Tunnel: OK (script version {h.version})")
|
||||
print(" Remote MySQL: OK")
|
||||
print(f" server_info: {conninfo.get('server_info', '')}")
|
||||
|
||||
for sql in extra_sql or default_sql():
|
||||
print_header("actn=Q " + sql.replace("\n", " "))
|
||||
try:
|
||||
h, results = client.query(sql)
|
||||
except TunnelError as e:
|
||||
print(" FAIL", e)
|
||||
code = 1
|
||||
continue
|
||||
if not h.ok:
|
||||
print(f" header errno {h.errno}: {h.message}")
|
||||
code = 1
|
||||
continue
|
||||
res = last_result_set(results)
|
||||
if res is None:
|
||||
print(" no result")
|
||||
code = 1
|
||||
continue
|
||||
print_result(res)
|
||||
if res.is_error:
|
||||
code = 1
|
||||
return code
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Connect through mysql-tunnel using local_auth (same as Yingdao)"
|
||||
)
|
||||
ap.add_argument("--config", default="", help="path to config.yaml")
|
||||
ap.add_argument("--sql", action="append", default=[], help="SQL to run (repeatable)")
|
||||
ap.add_argument(
|
||||
"--direct",
|
||||
action="store_true",
|
||||
help="POST ntunnel_mysql.php with remote.* instead of local_auth",
|
||||
)
|
||||
args = ap.parse_args(argv)
|
||||
try:
|
||||
cfg = Config.load(find_config(args.config or None))
|
||||
if args.direct:
|
||||
return run_direct(cfg, args.sql)
|
||||
return run_local(cfg, args.sql)
|
||||
except TunnelError as e:
|
||||
print("error:", e, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1 @@
|
||||
pymysql>=1.1
|
||||
@@ -0,0 +1,31 @@
|
||||
"""影刀 RPA:用 local_auth 连本机 mysql-tunnel,返回 conn。
|
||||
|
||||
须先启动 mysql-tunnel.exe(窗口点「启动」),listen / local_auth 与下面一致。
|
||||
默认值对齐 config.example.yaml;按你的 config.yaml 改 port / password / db。
|
||||
"""
|
||||
|
||||
import pymysql
|
||||
|
||||
|
||||
def main(args):
|
||||
conn = pymysql.connect(
|
||||
host="127.0.0.1",
|
||||
port=13306,
|
||||
user="root",
|
||||
password="123456",
|
||||
db="your_database",
|
||||
)
|
||||
print("MySQL数据库连接成功")
|
||||
args["conn"] = conn
|
||||
return args
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = main({})
|
||||
conn = out["conn"]
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
print("SELECT 1 ->", cur.fetchone())
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// mkico packs internal/gui/logo.png into a single-image ICO at cmd/tunnel/app.ico.
|
||||
// Prefer replacing app.ico with a proper multi-size .ico from a designer tool;
|
||||
// this helper is a quick path when you only have a PNG.
|
||||
func main() {
|
||||
root, err := findRoot()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
pngPath := filepath.Join(root, "internal", "gui", "logo.png")
|
||||
icoPath := filepath.Join(root, "cmd", "tunnel", "app.ico")
|
||||
|
||||
png, err := os.ReadFile(pngPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var w, h uint32
|
||||
if len(png) >= 24 {
|
||||
w = binary.BigEndian.Uint32(png[16:20])
|
||||
h = binary.BigEndian.Uint32(png[20:24])
|
||||
}
|
||||
wb, hb := byte(w), byte(h)
|
||||
if w >= 256 {
|
||||
wb = 0
|
||||
}
|
||||
if h >= 256 {
|
||||
hb = 0
|
||||
}
|
||||
out := make([]byte, 22+len(png))
|
||||
binary.LittleEndian.PutUint16(out[2:], 1)
|
||||
binary.LittleEndian.PutUint16(out[4:], 1)
|
||||
out[6] = wb
|
||||
out[7] = hb
|
||||
binary.LittleEndian.PutUint16(out[10:], 1)
|
||||
binary.LittleEndian.PutUint16(out[12:], 32)
|
||||
binary.LittleEndian.PutUint32(out[14:], uint32(len(png)))
|
||||
binary.LittleEndian.PutUint32(out[18:], 22)
|
||||
copy(out[22:], png)
|
||||
if err := os.WriteFile(icoPath, out, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("wrote", icoPath)
|
||||
}
|
||||
|
||||
func findRoot() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", fmt.Errorf("go.mod not found from %s", wd)
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
net session >nul 2>&1
|
||||
if %errorLevel% neq 0 (
|
||||
echo Requesting Administrator...
|
||||
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
||||
exit /b
|
||||
)
|
||||
|
||||
if exist "dist\mysql-tunnel.exe" (
|
||||
dist\mysql-tunnel.exe --uninstall
|
||||
) else (
|
||||
echo dist\mysql-tunnel.exe not found; trying sc.exe
|
||||
sc stop mysql-tunnel
|
||||
sc delete mysql-tunnel
|
||||
)
|
||||
pause
|
||||
endlocal
|
||||
Reference in New Issue
Block a user