diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c349ae --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a9c97d7 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..371ab26 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index d75b1cd..d28dba7 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..de09d9c --- /dev/null +++ b/build.bat @@ -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 \ No newline at end of file diff --git a/cmd/tunnel/app.ico b/cmd/tunnel/app.ico new file mode 100644 index 0000000..0309dfe Binary files /dev/null and b/cmd/tunnel/app.ico differ diff --git a/cmd/tunnel/app.manifest b/cmd/tunnel/app.manifest new file mode 100644 index 0000000..94a5aae --- /dev/null +++ b/cmd/tunnel/app.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + PerMonitorV2, PerMonitor + True + + + diff --git a/cmd/tunnel/main.go b/cmd/tunnel/main.go new file mode 100644 index 0000000..bdaa174 --- /dev/null +++ b/cmd/tunnel/main.go @@ -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 +} diff --git a/cmd/tunnel/rsrc_windows_amd64.syso b/cmd/tunnel/rsrc_windows_amd64.syso new file mode 100644 index 0000000..4f7b884 Binary files /dev/null and b/cmd/tunnel/rsrc_windows_amd64.syso differ diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..58b36b5 --- /dev/null +++ b/config.example.yaml @@ -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 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..cfd82ad --- /dev/null +++ b/docs/PROTOCOL.md @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c092948 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1424abd --- /dev/null +++ b/go.sum @@ -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= diff --git a/install-service.bat b/install-service.bat new file mode 100644 index 0000000..529b420 --- /dev/null +++ b/install-service.bat @@ -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 diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..3f16bfd --- /dev/null +++ b/internal/app/app.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..73949fd --- /dev/null +++ b/internal/config/config.go @@ -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() +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..880ef05 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/config/path.go b/internal/config/path.go new file mode 100644 index 0000000..42b8263 --- /dev/null +++ b/internal/config/path.go @@ -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) +} diff --git a/internal/config/path_test.go b/internal/config/path_test.go new file mode 100644 index 0000000..1e1668e --- /dev/null +++ b/internal/config/path_test.go @@ -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) + } + } +} diff --git a/internal/config/save.go b/internal/config/save.go new file mode 100644 index 0000000..4e64e2d --- /dev/null +++ b/internal/config/save.go @@ -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 +} diff --git a/internal/config/save_test.go b/internal/config/save_test.go new file mode 100644 index 0000000..1627de4 --- /dev/null +++ b/internal/config/save_test.go @@ -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) + } +} diff --git a/internal/config/yaml.go b/internal/config/yaml.go new file mode 100644 index 0000000..cc3a021 --- /dev/null +++ b/internal/config/yaml.go @@ -0,0 +1,7 @@ +package config + +import "gopkg.in/yaml.v3" + +func unmarshalYAML(raw []byte, v any) error { + return yaml.Unmarshal(raw, v) +} diff --git a/internal/gui/gui.go b/internal/gui/gui.go new file mode 100644 index 0000000..ef84a94 --- /dev/null +++ b/internal/gui/gui.go @@ -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 +} diff --git a/internal/gui/gui_windows.go b/internal/gui/gui_windows.go new file mode 100644 index 0000000..bda7e30 --- /dev/null +++ b/internal/gui/gui_windows.go @@ -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") +) diff --git a/internal/gui/logo.png b/internal/gui/logo.png new file mode 100644 index 0000000..4332a41 Binary files /dev/null and b/internal/gui/logo.png differ diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..a0d489f --- /dev/null +++ b/internal/logger/logger.go @@ -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] +} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 0000000..7e02ea3 --- /dev/null +++ b/internal/logger/logger_test.go @@ -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) + } +} diff --git a/internal/mysqlserver/auth.go b/internal/mysqlserver/auth.go new file mode 100644 index 0000000..192eba0 --- /dev/null +++ b/internal/mysqlserver/auth.go @@ -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 +} diff --git a/internal/mysqlserver/conn.go b/internal/mysqlserver/conn.go new file mode 100644 index 0000000..33f7e3d --- /dev/null +++ b/internal/mysqlserver/conn.go @@ -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() +} diff --git a/internal/mysqlserver/doc.go b/internal/mysqlserver/doc.go new file mode 100644 index 0000000..2e35ead --- /dev/null +++ b/internal/mysqlserver/doc.go @@ -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 diff --git a/internal/mysqlserver/driver_test.go b/internal/mysqlserver/driver_test.go new file mode 100644 index 0000000..5deb4eb --- /dev/null +++ b/internal/mysqlserver/driver_test.go @@ -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) + } +} diff --git a/internal/mysqlserver/handshake.go b/internal/mysqlserver/handshake.go new file mode 100644 index 0000000..b266f23 --- /dev/null +++ b/internal/mysqlserver/handshake.go @@ -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} } diff --git a/internal/mysqlserver/protocol.go b/internal/mysqlserver/protocol.go new file mode 100644 index 0000000..194c7d2 --- /dev/null +++ b/internal/mysqlserver/protocol.go @@ -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 diff --git a/internal/mysqlserver/protocol_test.go b/internal/mysqlserver/protocol_test.go new file mode 100644 index 0000000..3e5ae6b --- /dev/null +++ b/internal/mysqlserver/protocol_test.go @@ -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 +} diff --git a/internal/mysqlserver/result.go b/internal/mysqlserver/result.go new file mode 100644 index 0000000..503e9a2 --- /dev/null +++ b/internal/mysqlserver/result.go @@ -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)) +} diff --git a/internal/mysqlserver/server.go b/internal/mysqlserver/server.go new file mode 100644 index 0000000..21ab517 --- /dev/null +++ b/internal/mysqlserver/server.go @@ -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) +} diff --git a/internal/mysqlserver/server_test.go b/internal/mysqlserver/server_test.go new file mode 100644 index 0000000..9664e61 --- /dev/null +++ b/internal/mysqlserver/server_test.go @@ -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) + } +} diff --git a/internal/mysqlserver/session.go b/internal/mysqlserver/session.go new file mode 100644 index 0000000..b67a596 --- /dev/null +++ b/internal/mysqlserver/session.go @@ -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 +} diff --git a/internal/navicat/binary.go b/internal/navicat/binary.go new file mode 100644 index 0000000..4584317 --- /dev/null +++ b/internal/navicat/binary.go @@ -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) +} diff --git a/internal/navicat/client.go b/internal/navicat/client.go new file mode 100644 index 0000000..2e16ca5 --- /dev/null +++ b/internal/navicat/client.go @@ -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) +} diff --git a/internal/navicat/client_test.go b/internal/navicat/client_test.go new file mode 100644 index 0000000..0ca421d --- /dev/null +++ b/internal/navicat/client_test.go @@ -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) + } +} diff --git a/internal/navicat/doc.go b/internal/navicat/doc.go new file mode 100644 index 0000000..4806d39 --- /dev/null +++ b/internal/navicat/doc.go @@ -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 +) diff --git a/internal/navicat/error.go b/internal/navicat/error.go new file mode 100644 index 0000000..cb25107 --- /dev/null +++ b/internal/navicat/error.go @@ -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} +} diff --git a/internal/navicat/mock.go b/internal/navicat/mock.go new file mode 100644 index 0000000..3a77a43 --- /dev/null +++ b/internal/navicat/mock.go @@ -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) +} diff --git a/internal/navicat/parser.go b/internal/navicat/parser.go new file mode 100644 index 0000000..3728b30 --- /dev/null +++ b/internal/navicat/parser.go @@ -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 +} diff --git a/internal/navicat/parser_test.go b/internal/navicat/parser_test.go new file mode 100644 index 0000000..a1836b2 --- /dev/null +++ b/internal/navicat/parser_test.go @@ -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("nope")) + 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()) + } +} diff --git a/internal/navicat/request.go b/internal/navicat/request.go new file mode 100644 index 0000000..e40b401 --- /dev/null +++ b/internal/navicat/request.go @@ -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 +} diff --git a/internal/navicat/response.go b/internal/navicat/response.go new file mode 100644 index 0000000..d5bab50 --- /dev/null +++ b/internal/navicat/response.go @@ -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, " 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(" 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:])) diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..4696e4a --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +pymysql>=1.1 diff --git a/tests/rpa_tunnel.py b/tests/rpa_tunnel.py new file mode 100644 index 0000000..b87157d --- /dev/null +++ b/tests/rpa_tunnel.py @@ -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() diff --git a/tools/mkico/main.go b/tools/mkico/main.go new file mode 100644 index 0000000..e0dc55e --- /dev/null +++ b/tools/mkico/main.go @@ -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 + } +} diff --git a/uninstall-service.bat b/uninstall-service.bat new file mode 100644 index 0000000..e9f9782 --- /dev/null +++ b/uninstall-service.bat @@ -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