This commit is contained in:
Yangxt
2026-09-21 16:46:55 +08:00
parent 01842c59c6
commit ad2b0a7d3e
58 changed files with 6906 additions and 3 deletions
+36
View File
@@ -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.*
+21
View File
@@ -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.
+10
View File
@@ -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
+5 -3
View File
@@ -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)
+32
View File
@@ -0,0 +1,32 @@
@echo off
setlocal
cd /d "%~dp0"
REM Prefer a typical Windows install; do not rely on where.exe (it may be missing from PATH).
if exist "%ProgramFiles%\Go\bin\go.exe" set "PATH=%ProgramFiles%\Go\bin;%PATH%"
go version >nul 2>nul
if errorlevel 1 (
echo Go is not on PATH. Install Go 1.24+ and re-run build.bat.
echo If Go is already installed, add its bin directory to PATH and open a new terminal.
exit /b 1
)
if not exist dist mkdir dist
REM rsrc_windows_amd64.syso embeds Common Controls 6 and the app icon (required by the GUI).
REM After changing cmd\tunnel\app.ico or app.manifest, regenerate (see docs\DEVELOPMENT.md):
REM go run github.com/akavel/rsrc@latest -manifest cmd\tunnel\app.manifest -ico cmd\tunnel\app.ico -arch amd64 -o cmd\tunnel\rsrc_windows_amd64.syso
REM Optional PNG-to-ICO: go run .\tools\mkico
REM Keep internal\gui\logo.png in sync with app.ico for tray / in-window icon.
set CGO_ENABLED=0
go test ./...
if errorlevel 1 exit /b 1
go build -trimpath -ldflags="-s -w" -o dist\mysql-tunnel.exe .\cmd\tunnel
if errorlevel 1 exit /b 1
echo Built dist\mysql-tunnel.exe
dir dist\mysql-tunnel.exe
endlocal
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="0.5.0.0" processorArchitecture="*" name="mysql-tunnel" type="win32"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">True</dpiAware>
</windowsSettings>
</application>
</assembly>
+219
View File
@@ -0,0 +1,219 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"mysql-tunnel/internal/app"
"mysql-tunnel/internal/config"
"mysql-tunnel/internal/gui"
"mysql-tunnel/internal/logger"
"mysql-tunnel/internal/winsvc"
)
const version = "0.5.0"
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
fs := flag.NewFlagSet("mysql-tunnel", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
configPath := fs.String("config", "config.yaml", "path to config.yaml")
listenHost := fs.String("host", "", "override listen.host")
listenPort := fs.Int("port", 0, "override listen.port")
doTest := fs.Bool("test", false, "POST actn=C and SELECT 1 through the HTTP tunnel")
showVersion := fs.Bool("version", false, "print version and exit")
doGUI := fs.Bool("gui", false, "open the configuration window (Windows)")
doConsole := fs.Bool("console", false, "listen in the foreground without a window")
doInstall := fs.Bool("install", false, "install Windows service (Administrator)")
doUninstall := fs.Bool("uninstall", false, "uninstall Windows service (Administrator)")
doStart := fs.Bool("start", false, "start Windows service")
doStop := fs.Bool("stop", false, "stop Windows service")
doRestart := fs.Bool("restart", false, "restart Windows service")
if err := fs.Parse(args); err != nil {
return 2
}
if *showVersion {
fmt.Println("mysql-tunnel", version)
return 0
}
if *doInstall {
return cmdInstall(*configPath)
}
if *doUninstall {
return report(winsvc.Uninstall())
}
if *doStart {
return report(winsvc.Start())
}
if *doStop {
return report(winsvc.Stop())
}
if *doRestart {
_ = winsvc.Stop()
return report(winsvc.Start())
}
if winsvc.IsService() {
return runHeadless(*configPath, *listenHost, *listenPort, false)
}
if wantGUI(fs, *doGUI, *doConsole, *doTest) {
return gui.Run(*configPath)
}
return runHeadless(*configPath, *listenHost, *listenPort, *doTest)
}
func wantGUI(fs *flag.FlagSet, gui, console, doTest bool) bool {
if console || doTest {
return false
}
if gui {
return true
}
if runtime.GOOS != "windows" {
return false
}
// Double-click / no flags → GUI. `--config` without `--gui` stays CLI.
if flagWasSet(fs, "config") || flagWasSet(fs, "host") || flagWasSet(fs, "port") {
return false
}
return true
}
func flagWasSet(fs *flag.FlagSet, name string) bool {
found := false
fs.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
func runHeadless(configPath, listenHost string, listenPort int, doTest bool) int {
resolved, err := config.ResolveConfigPath(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "config error: %s\n", err)
return 1
}
cfg, err := config.Load(resolved, config.Overrides{
ListenHost: listenHost,
ListenPort: listenPort,
})
if err != nil {
fmt.Fprintf(os.Stderr, "config error: %s\n", logger.RedactError(err))
return 1
}
cfg.ResolvePaths()
log, err := logger.New(cfg.LogFile, cfg.LogLevel, cfg.LogSQL)
if err != nil {
fmt.Fprintf(os.Stderr, "logger error: %v\n", err)
return 1
}
defer log.Close()
for _, w := range cfg.Warnings {
log.Warn(w)
}
if doTest {
fmt.Println("mysql-tunnel --test")
r := app.Test(context.Background(), cfg, log)
fmt.Println(strings.ReplaceAll(r.Text(), "\r\n", "\n"))
if !r.OK() {
return 1
}
return 0
}
runSrv := func(ctx context.Context) error {
return app.Serve(ctx, cfg, log)
}
if winsvc.IsService() {
if err := winsvc.Run(runSrv); err != nil {
log.Error("service stopped", logger.RedactError(err))
return 1
}
return 0
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
fmt.Printf("mysql-tunnel listening on %s:%d\n", cfg.ListenHost, cfg.ListenPort)
fmt.Println("Yingdao: Host=127.0.0.1 Port=" + strconv.Itoa(cfg.ListenPort) + " User=" + cfg.LocalUsername)
fmt.Println("Tunnel:", config.RedactedTunnelURL(cfg.TunnelURL))
fmt.Println("Config:", cfg.Path)
fmt.Println("Log:", cfg.LogFile)
if err := runSrv(ctx); err != nil {
log.Error("server stopped", logger.RedactError(err))
return 1
}
log.Info("server stopped")
return 0
}
func cmdInstall(configFlag string) int {
exe, err := winsvc.ExePath()
if err != nil {
fmt.Fprintf(os.Stderr, "install: cannot locate exe: %v\n", err)
return 1
}
src, err := config.ResolveConfigPath(configFlag)
if err != nil {
fmt.Fprintf(os.Stderr, "install: %s\n", err)
fmt.Fprintln(os.Stderr, "put a filled config.yaml next to mysql-tunnel.exe or pass --config")
return 1
}
if _, err := config.Load(src, config.Overrides{}); err != nil {
fmt.Fprintf(os.Stderr, "install: invalid config: %s\n", logger.RedactError(err))
return 1
}
dest := filepath.Join(filepath.Dir(exe), "config.yaml")
srcAbs, _ := filepath.Abs(src)
destAbs, _ := filepath.Abs(dest)
if filepath.Clean(srcAbs) != filepath.Clean(destAbs) {
if err := winsvc.CopyFile(srcAbs, destAbs); err != nil {
fmt.Fprintf(os.Stderr, "install: copy config: %v\n", err)
return 1
}
fmt.Println("copied config.yaml ->", destAbs)
}
if err := winsvc.Install(exe, destAbs); err != nil {
fmt.Fprintf(os.Stderr, "install: %v\n", err)
return 1
}
if err := winsvc.Start(); err != nil {
fmt.Fprintf(os.Stderr, "service installed but start failed: %v\n", err)
fmt.Println("try: mysql-tunnel.exe --start")
return 1
}
fmt.Println("Windows service installed and started:", winsvc.Name)
fmt.Println(" exe: ", exe)
fmt.Println(" config:", destAbs)
fmt.Println(" stop: mysql-tunnel.exe --stop")
fmt.Println(" uninstall: mysql-tunnel.exe --uninstall")
return 0
}
func report(err error) int {
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
return 1
}
fmt.Println("ok")
return 0
}
Binary file not shown.
+30
View File
@@ -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
+303
View File
@@ -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
+16
View File
@@ -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
)
+17
View File
@@ -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=
+26
View File
@@ -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
+139
View File
@@ -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
}
+296
View File
@@ -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()
}
+154
View File
@@ -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)
}
}
+62
View File
@@ -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)
}
+50
View File
@@ -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)
}
}
}
+99
View File
@@ -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
}
+71
View File
@@ -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)
}
}
+7
View File
@@ -0,0 +1,7 @@
package config
import "gopkg.in/yaml.v3"
func unmarshalYAML(raw []byte, v any) error {
return yaml.Unmarshal(raw, v)
}
+13
View File
@@ -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
}
+692
View File
@@ -0,0 +1,692 @@
//go:build windows
package gui
import (
"bytes"
"context"
_ "embed"
"fmt"
"image/png"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"unsafe"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
"github.com/lxn/win"
"golang.org/x/sys/windows"
"mysql-tunnel/internal/app"
"mysql-tunnel/internal/config"
"mysql-tunnel/internal/logger"
)
//go:embed logo.png
var logoPNG []byte
func init() {
runtime.LockOSThread()
}
func Run(configFlag string) int {
hideConsoleIfStandalone()
path := strings.TrimSpace(configFlag)
if path == "" {
path = "config.yaml"
}
if resolved, err := config.ResolveConfigPath(path); err == nil {
path = resolved
} else if path == "config.yaml" {
path = config.DefaultSavePath()
} else if abs, err := filepath.Abs(path); err == nil {
path = abs
}
if err := runWindow(config.LoadOrEmpty(path)); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
type ui struct {
mw *walk.MainWindow
ni *walk.NotifyIcon
listenHost *walk.LineEdit
listenPort *walk.NumberEdit
localUser *walk.LineEdit
localPass *walk.LineEdit
tunnelURL *walk.LineEdit
encodeB64 *walk.CheckBox
tlsSkip *walk.CheckBox
remoteHost *walk.LineEdit
remotePort *walk.NumberEdit
remoteUser *walk.LineEdit
remotePass *walk.LineEdit
remoteDB *walk.LineEdit
charset *walk.LineEdit
timeout *walk.NumberEdit
maxConn *walk.NumberEdit
logLevel *walk.ComboBox
logSQL *walk.CheckBox
logFile *walk.LineEdit
status *walk.TextEdit
saveBtn *walk.PushButton
testBtn *walk.PushButton
startBtn *walk.PushButton
stopBtn *walk.PushButton
pathLabel *walk.Label
hint *walk.StatusBarItem
logoView *walk.ImageView
appIcon *walk.Icon
appBmp *walk.Bitmap
cfgPath string
mu sync.Mutex
running bool
busy bool
cancel context.CancelFunc
log *logger.Logger
exiting bool
hidOnce bool
}
func runWindow(cfg *config.Config) error {
u := &ui{cfgPath: cfg.Path}
if err := u.build(cfg); err != nil {
return err
}
defer u.dispose()
u.mw.Run()
return nil
}
func (u *ui) dispose() {
u.stopServer()
if u.ni != nil {
_ = u.ni.Dispose()
}
}
func (u *ui) build(cfg *config.Config) error {
logFile := relativeLogFile(cfg)
levels := []string{"debug", "info", "warn", "error"}
levelIdx := 1
for i, s := range levels {
if s == cfg.LogLevel {
levelIdx = i
break
}
}
if err := (MainWindow{
AssignTo: &u.mw,
Title: "MySQL Tunnel",
MinSize: Size{Width: 780, Height: 560},
Size: Size{Width: 840, Height: 620},
Layout: VBox{Margins: Margins{Left: 12, Top: 10, Right: 12, Bottom: 8}, Spacing: 8},
StatusBarItems: []StatusBarItem{
{AssignTo: &u.hint, Text: "就绪", Width: 800},
},
Children: []Widget{
Composite{
Layout: HBox{Spacing: 10, MarginsZero: true},
Children: []Widget{
ImageView{
AssignTo: &u.logoView,
MinSize: Size{Width: 40, Height: 40},
MaxSize: Size{Width: 40, Height: 40},
Mode: ImageViewModeZoom,
},
Composite{
Layout: VBox{MarginsZero: true, Spacing: 2},
Children: []Widget{
Label{Text: "MySQL Tunnel", Font: Font{Family: "Segoe UI", PointSize: 13, Bold: true}},
Label{AssignTo: &u.pathLabel, Text: cfg.Path},
},
},
HSpacer{},
},
},
Composite{
Layout: HBox{Spacing: 10, MarginsZero: true},
Children: []Widget{
Composite{
StretchFactor: 1,
MinSize: Size{Width: 380},
Layout: VBox{MarginsZero: true, Spacing: 8},
Children: []Widget{
GroupBox{
Title: "本机监听(影刀连这里)",
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("地址"),
LineEdit{AssignTo: &u.listenHost, Text: cfg.ListenHost},
fieldLabel("端口"),
NumberEdit{AssignTo: &u.listenPort, Value: float64(cfg.ListenPort), MinValue: 1, MaxValue: 65535, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("本机用户"),
LineEdit{AssignTo: &u.localUser, Text: cfg.LocalUsername},
fieldLabel("本机密码"),
LineEdit{AssignTo: &u.localPass, Text: cfg.LocalPassword, PasswordMode: true},
},
},
GroupBox{
Title: "HTTP 隧道",
StretchFactor: 1,
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("URL"),
LineEdit{AssignTo: &u.tunnelURL, Text: cfg.TunnelURL},
CheckBox{AssignTo: &u.encodeB64, Text: "SQL Base64(WAF)", Checked: cfg.EncodeBase64, ColumnSpan: 2},
CheckBox{AssignTo: &u.tlsSkip, Text: "跳过 TLS 证书校验(仅排障)", Checked: cfg.TLSInsecureSkipVerify, ColumnSpan: 2},
},
},
},
},
Composite{
StretchFactor: 1,
MinSize: Size{Width: 380},
Layout: VBox{MarginsZero: true, Spacing: 8},
Children: []Widget{
GroupBox{
Title: "远程 MySQL(PHP 主机访问)",
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("主机"),
LineEdit{AssignTo: &u.remoteHost, Text: cfg.RemoteHost},
fieldLabel("端口"),
NumberEdit{AssignTo: &u.remotePort, Value: float64(nonzero(cfg.RemotePort, 3306)), MinValue: 1, MaxValue: 65535, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("用户名"),
LineEdit{AssignTo: &u.remoteUser, Text: cfg.RemoteUsername},
fieldLabel("密码"),
LineEdit{AssignTo: &u.remotePass, Text: cfg.RemotePassword, PasswordMode: true},
fieldLabel("默认库"),
LineEdit{AssignTo: &u.remoteDB, Text: cfg.RemoteDatabase},
fieldLabel("字符集"),
LineEdit{AssignTo: &u.charset, Text: orDefault(cfg.RemoteCharset, "utf8mb4")},
},
},
GroupBox{
Title: "选项",
StretchFactor: 1,
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("超时(秒)"),
NumberEdit{AssignTo: &u.timeout, Value: float64(timeoutSec(cfg)), MinValue: 1, MaxValue: 600, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("最大连接"),
NumberEdit{AssignTo: &u.maxConn, Value: float64(nonzero(cfg.MaxConnections, 5)), MinValue: 1, MaxValue: 256, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("日志级别"),
ComboBox{AssignTo: &u.logLevel, Model: levels, CurrentIndex: levelIdx, Editable: false},
fieldLabel("日志文件"),
LineEdit{AssignTo: &u.logFile, Text: logFile},
CheckBox{AssignTo: &u.logSQL, Text: "记录 SQL(仍会脱敏)", Checked: cfg.LogSQL, ColumnSpan: 2},
},
},
},
},
},
},
Composite{
Layout: HBox{Spacing: 8},
Children: []Widget{
PushButton{AssignTo: &u.saveBtn, Text: "保存", MinSize: Size{Width: 88}},
PushButton{AssignTo: &u.testBtn, Text: "测试连接", MinSize: Size{Width: 88}},
PushButton{AssignTo: &u.startBtn, Text: "启动", MinSize: Size{Width: 88}},
PushButton{AssignTo: &u.stopBtn, Text: "停止", Enabled: false, MinSize: Size{Width: 88}},
HSpacer{},
},
},
GroupBox{
Title: "运行日志",
StretchFactor: 1,
MinSize: Size{Height: 140},
Layout: VBox{MarginsZero: true},
Children: []Widget{
TextEdit{AssignTo: &u.status, ReadOnly: true, VScroll: true, MaxLength: 64 * 1024},
},
},
},
}).Create(); err != nil {
return err
}
if u.saveBtn != nil {
u.saveBtn.Clicked().Attach(u.onSave)
}
if u.testBtn != nil {
u.testBtn.Clicked().Attach(u.onTest)
}
if u.startBtn != nil {
u.startBtn.Clicked().Attach(u.onStart)
}
if u.stopBtn != nil {
u.stopBtn.Clicked().Attach(u.onStop)
u.stopBtn.SetEnabled(false)
}
u.appendStatus("填写后点「保存」,再「测试连接」。通过后点「启动」,影刀连 本机地址:端口。")
u.appendStatus("最小化或关闭窗口会藏到系统托盘(监听可继续)。托盘左键打开,右键退出。")
for _, w := range cfg.Warnings {
u.appendStatus("注意: " + w)
}
if err := u.applyBranding(); err != nil {
return err
}
ni, err := walk.NewNotifyIcon(u.mw)
if err != nil {
return err
}
u.ni = ni
if u.appIcon != nil {
_ = ni.SetIcon(u.appIcon)
_ = u.mw.SetIcon(u.appIcon)
}
_ = ni.SetToolTip("MySQL Tunnel")
_ = ni.SetVisible(true)
ni.MouseDown().Attach(func(x, y int, button walk.MouseButton) {
if button == walk.LeftButton {
u.showWindow()
}
})
showAct := walk.NewAction()
_ = showAct.SetText("打开窗口")
showAct.Triggered().Attach(u.showWindow)
exitAct := walk.NewAction()
_ = exitAct.SetText("退出")
exitAct.Triggered().Attach(u.onExit)
if menu := ni.ContextMenu(); menu != nil {
_ = menu.Actions().Add(showAct)
_ = menu.Actions().Add(exitAct)
}
// Minimize → tray (not taskbar). Close also → tray unless exiting.
u.mw.SizeChanged().Attach(func() {
hwnd := win.HWND(u.mw.Handle())
if win.IsIconic(hwnd) {
u.hideToTray()
}
})
u.mw.Closing().Attach(func(canceled *bool, reason walk.CloseReason) {
u.mu.Lock()
exiting := u.exiting
u.mu.Unlock()
if exiting {
return
}
*canceled = true
u.hideToTray()
})
return nil
}
func (u *ui) hideToTray() {
if u.mw == nil {
return
}
u.mw.Hide()
hwnd := win.HWND(u.mw.Handle())
win.ShowWindow(hwnd, win.SW_HIDE)
if u.ni != nil && !u.hidOnce {
u.hidOnce = true
_ = u.ni.ShowInfo("MySQL Tunnel", "已放到系统托盘。左键打开窗口,右键退出。")
}
}
func (u *ui) showWindow() {
if u.mw == nil {
return
}
u.mw.Show()
hwnd := win.HWND(u.mw.Handle())
win.ShowWindow(hwnd, win.SW_RESTORE)
win.SetForegroundWindow(hwnd)
}
func (u *ui) onExit() {
u.mu.Lock()
u.exiting = true
u.mu.Unlock()
u.stopServer()
if u.ni != nil {
_ = u.ni.SetVisible(false)
}
u.mw.Close()
}
func (u *ui) collect() (*config.Config, error) {
c := config.Defaults()
c.Path = u.cfgPath
c.ListenHost = strings.TrimSpace(lineText(u.listenHost))
c.ListenPort = numValue(u.listenPort, config.DefaultListenPort)
c.LocalUsername = strings.TrimSpace(lineText(u.localUser))
c.LocalPassword = lineText(u.localPass)
c.TunnelURL = strings.TrimSpace(lineText(u.tunnelURL))
c.EncodeBase64 = boxChecked(u.encodeB64)
c.TLSInsecureSkipVerify = boxChecked(u.tlsSkip)
c.RemoteHost = strings.TrimSpace(lineText(u.remoteHost))
c.RemotePort = numValue(u.remotePort, 3306)
c.RemoteUsername = strings.TrimSpace(lineText(u.remoteUser))
c.RemotePassword = lineText(u.remotePass)
c.RemoteDatabase = strings.TrimSpace(lineText(u.remoteDB))
c.RemoteCharset = strings.TrimSpace(lineText(u.charset))
c.RequestTimeout = time.Duration(numValue(u.timeout, config.DefaultTimeoutSec)) * time.Second
c.MaxConnections = numValue(u.maxConn, config.DefaultMaxConnections)
if u.logLevel != nil {
c.LogLevel = strings.ToLower(strings.TrimSpace(u.logLevel.Text()))
}
if c.LogLevel == "" {
c.LogLevel = config.DefaultLogLevel
}
c.LogSQL = boxChecked(u.logSQL)
c.LogFile = strings.TrimSpace(lineText(u.logFile))
if c.LogFile == "" {
c.LogFile = config.DefaultLogFile
}
if err := c.Validate(); err != nil {
return &c, err
}
c.ResolvePaths()
return &c, nil
}
func (u *ui) onSave() {
c, err := u.collect()
if err != nil {
u.appendStatus("保存失败: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
return
}
if err := config.Save(u.cfgPath, c); err != nil {
u.appendStatus("保存失败: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
return
}
u.appendStatus("已保存 " + u.cfgPath)
for _, w := range c.Warnings {
u.appendStatus("注意: " + w)
}
}
func (u *ui) onTest() {
c, err := u.collect()
if err != nil {
u.appendStatus("无法测试: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", "无法测试:\n"+err.Error(), walk.MsgBoxIconError)
return
}
u.setBusy(true)
u.setHint("正在测试隧道…")
u.appendStatus("正在测试 " + config.RedactedTunnelURL(c.TunnelURL) + " …")
go func() {
defer func() {
if rec := recover(); rec != nil {
u.mw.Synchronize(func() {
msg := fmt.Sprintf("测试异常: %v", rec)
u.appendStatus(msg)
u.setHint("测试异常")
walk.MsgBox(u.mw, "测试结果", msg, walk.MsgBoxIconError)
u.setBusy(false)
})
}
}()
log, _ := logger.New(c.LogFile, c.LogLevel, c.LogSQL)
r := app.Test(context.Background(), c, log)
if log != nil {
_ = log.Close()
}
u.mw.Synchronize(func() {
u.appendStatus(strings.ReplaceAll(r.Text(), "\n", "\r\n"))
icon := walk.MsgBoxIconError
hint := "测试未通过"
if r.OK() {
icon = walk.MsgBoxIconInformation
hint = "测试通过"
u.appendStatus("测试通过,可以启动监听。")
} else {
u.appendStatus("测试未通过。")
}
u.setHint(hint)
walk.MsgBox(u.mw, "测试结果", strings.ReplaceAll(r.Text(), "\r\n", "\n"), icon)
u.setBusy(false)
})
}()
}
func (u *ui) onStart() {
u.mu.Lock()
if u.running {
u.mu.Unlock()
return
}
u.mu.Unlock()
c, err := u.collect()
if err != nil {
u.appendStatus("无法启动: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
return
}
log, err := logger.New(c.LogFile, c.LogLevel, c.LogSQL)
if err != nil {
u.appendStatus("日志: " + err.Error())
return
}
ctx, cancel := context.WithCancel(context.Background())
u.mu.Lock()
u.cancel = cancel
u.log = log
u.running = true
u.mu.Unlock()
u.refreshButtons()
u.appendStatus(fmt.Sprintf("正在监听 %s:%d 影刀用户=%s", c.ListenHost, c.ListenPort, c.LocalUsername))
u.appendStatus("隧道: " + config.RedactedTunnelURL(c.TunnelURL))
if u.ni != nil {
_ = u.ni.SetToolTip(fmt.Sprintf("MySQL Tunnel · %s:%d", c.ListenHost, c.ListenPort))
}
go func() {
err := app.Serve(ctx, c, log)
u.mw.Synchronize(func() {
u.mu.Lock()
u.running = false
u.cancel = nil
if u.log != nil {
_ = u.log.Close()
u.log = nil
}
u.mu.Unlock()
if err != nil && ctx.Err() == nil {
u.appendStatus("监听结束: " + logger.RedactError(err))
} else {
u.appendStatus("监听已停止")
}
if u.ni != nil {
_ = u.ni.SetToolTip("MySQL Tunnel")
}
u.refreshButtons()
})
}()
}
func (u *ui) onStop() {
u.stopServer()
}
func (u *ui) stopServer() {
u.mu.Lock()
cancel := u.cancel
u.mu.Unlock()
if cancel != nil {
cancel()
}
}
func (u *ui) setBusy(b bool) {
u.mu.Lock()
u.busy = b
u.mu.Unlock()
u.refreshButtons()
}
func (u *ui) refreshButtons() {
u.mu.Lock()
running, busy := u.running, u.busy
u.mu.Unlock()
if u.saveBtn != nil {
u.saveBtn.SetEnabled(!busy)
}
if u.testBtn != nil {
u.testBtn.SetEnabled(!busy)
if busy {
_ = u.testBtn.SetText("测试中…")
} else {
_ = u.testBtn.SetText("测试连接")
}
}
if u.startBtn != nil {
u.startBtn.SetEnabled(!running && !busy)
}
if u.stopBtn != nil {
u.stopBtn.SetEnabled(running)
}
}
func (u *ui) setHint(s string) {
if u.hint == nil {
return
}
_ = u.hint.SetText(s)
}
func (u *ui) appendStatus(line string) {
line = strings.ReplaceAll(strings.TrimRight(line, "\r\n"), "\n", "\r\n")
if u.status != nil {
prev := u.status.Text()
if prev != "" {
prev += "\r\n"
}
text := prev + time.Now().Format("15:04:05") + " " + line
_ = u.status.SetText(text)
end := len([]rune(text))
u.status.SetTextSelection(end, end)
}
first := line
if i := strings.Index(first, "\r\n"); i >= 0 {
first = first[:i]
}
if len([]rune(first)) > 80 {
first = string([]rune(first)[:80]) + "…"
}
u.setHint(first)
}
func relativeLogFile(cfg *config.Config) string {
if cfg == nil || cfg.LogFile == "" {
return config.DefaultLogFile
}
if !filepath.IsAbs(cfg.LogFile) {
return filepath.ToSlash(cfg.LogFile)
}
rel, err := filepath.Rel(filepath.Dir(cfg.Path), cfg.LogFile)
if err != nil {
return cfg.LogFile
}
return filepath.ToSlash(rel)
}
func lineText(e *walk.LineEdit) string {
if e == nil {
return ""
}
return e.Text()
}
func numValue(e *walk.NumberEdit, def int) int {
if e == nil {
return def
}
v := int(e.Value())
if v == 0 {
return def
}
return v
}
func boxChecked(b *walk.CheckBox) bool {
return b != nil && b.Checked()
}
func nonzero(v, def int) int {
if v == 0 {
return def
}
return v
}
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func fieldLabel(text string) Label {
return Label{Text: text, Alignment: AlignHFarVCenter, MinSize: Size{Width: 72}}
}
func (u *ui) applyBranding() error {
if len(logoPNG) == 0 {
return nil
}
img, err := png.Decode(bytes.NewReader(logoPNG))
if err != nil {
return nil
}
if ico, err := walk.NewIconFromImage(img); err == nil {
u.appIcon = ico
if u.mw != nil {
_ = u.mw.SetIcon(ico)
}
}
if bmp, err := walk.NewBitmapFromImage(img); err == nil {
u.appBmp = bmp
if u.logoView != nil {
_ = u.logoView.SetImage(bmp)
}
}
return nil
}
func timeoutSec(cfg *config.Config) int {
if cfg == nil || cfg.RequestTimeout <= 0 {
return config.DefaultTimeoutSec
}
return int(cfg.RequestTimeout.Seconds())
}
func hideConsoleIfStandalone() {
var pids [16]uint32
n, _, _ := procGetConsoleProcessList.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
if n == 0 || n > 1 {
return
}
hwnd, _, _ := procGetConsoleWindow.Call()
if hwnd == 0 {
return
}
procShowWindow.Call(hwnd, 0)
}
var (
user32 = windows.NewLazySystemDLL("user32.dll")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procShowWindow = user32.NewProc("ShowWindow")
procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow")
procGetConsoleProcessList = kernel32.NewProc("GetConsoleProcessList")
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

+262
View File
@@ -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]
}
+87
View File
@@ -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)
}
}
+44
View File
@@ -0,0 +1,44 @@
package mysqlserver
import (
"crypto/sha1"
"crypto/subtle"
)
func nativePasswordToken(password string, scramble []byte) []byte {
if password == "" {
return []byte{}
}
if len(scramble) > 20 {
scramble = scramble[:20]
}
hash1 := sha1.Sum([]byte(password))
hash2 := sha1.Sum(hash1[:])
h := sha1.New()
h.Write(scramble)
h.Write(hash2[:])
hash3 := h.Sum(nil)
out := make([]byte, 20)
for i := 0; i < 20; i++ {
out[i] = hash1[i] ^ hash3[i]
}
return out
}
func verifyNativePassword(password string, scramble, auth []byte) bool {
if password == "" {
return len(auth) == 0
}
want := nativePasswordToken(password, scramble)
if len(auth) != len(want) {
return false
}
return subtle.ConstantTimeCompare(auth, want) == 1
}
func usernamesEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
+105
View File
@@ -0,0 +1,105 @@
package mysqlserver
import (
"fmt"
"io"
"net"
"time"
)
type packetConn struct {
c net.Conn
seq uint8
max int
readTO time.Duration
writeTO time.Duration
}
func newPacketConn(c net.Conn, timeout time.Duration) *packetConn {
return &packetConn{c: c, max: MaxPacketSize, readTO: timeout, writeTO: timeout}
}
func (p *packetConn) resetSeq() { p.seq = 0 }
func (p *packetConn) readPacket() ([]byte, error) {
var payload []byte
for {
if p.readTO > 0 {
_ = p.c.SetReadDeadline(time.Now().Add(p.readTO))
}
var hdr [4]byte
if _, err := io.ReadFull(p.c, hdr[:]); err != nil {
return nil, err
}
n := int(readU24(hdr[:3]))
seq := hdr[3]
if seq != p.seq {
return nil, fmt.Errorf("sequence mismatch: got %d want %d", seq, p.seq)
}
p.seq++
if n == 0 {
return payload, nil
}
if n > p.max {
return nil, fmt.Errorf("packet too large: %d", n)
}
chunk := make([]byte, n)
if _, err := io.ReadFull(p.c, chunk); err != nil {
return nil, err
}
payload = append(payload, chunk...)
if n < 0xffffff {
return payload, nil
}
}
}
func (p *packetConn) writePacket(payload []byte) error {
off := 0
for {
n := len(payload) - off
if n > 0xffffff {
n = 0xffffff
}
var hdr [4]byte
hdr[0] = byte(n)
hdr[1] = byte(n >> 8)
hdr[2] = byte(n >> 16)
hdr[3] = p.seq
p.seq++
if p.writeTO > 0 {
_ = p.c.SetWriteDeadline(time.Now().Add(p.writeTO))
}
if _, err := p.c.Write(hdr[:]); err != nil {
return err
}
if n > 0 {
if _, err := p.c.Write(payload[off : off+n]); err != nil {
return err
}
}
off += n
if n < 0xffffff {
return nil
}
}
}
func (p *packetConn) writeOK(affected, lastInsertID uint64, status, warnings uint16) error {
return p.writePacket(encodeOK(affected, lastInsertID, status, warnings))
}
func (p *packetConn) writeERR(errno uint16, msg string) error {
return p.writePacket(encodeERR(errno, msg))
}
func (p *packetConn) remote() string {
if p.c.RemoteAddr() == nil {
return ""
}
return p.c.RemoteAddr().String()
}
func (p *packetConn) close() error {
return p.c.Close()
}
+3
View File
@@ -0,0 +1,3 @@
// Package mysqlserver implements a loopback MySQL protocol server.
// Yingdao connects here; COM_QUERY is forwarded through the Navicat HTTP tunnel.
package mysqlserver
+57
View File
@@ -0,0 +1,57 @@
package mysqlserver
import (
"database/sql"
"fmt"
"net"
"testing"
_ "github.com/go-sql-driver/mysql"
)
func TestGoSQLDriverCompat(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "123456")
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatal(err)
}
dsn := fmt.Sprintf("root:123456@tcp(%s:%s)/app?timeout=5s&readTimeout=5s&writeTimeout=5s&charset=utf8mb4&parseTime=false", host, port)
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
var v string
if err := db.QueryRow("SELECT 1").Scan(&v); err != nil {
t.Fatal(err)
}
if v != "1" {
t.Fatalf("got %q", v)
}
var n sql.NullString
if err := db.QueryRow("SELECT NULL AS n").Scan(&n); err != nil {
t.Fatal(err)
}
if n.Valid {
t.Fatal("expected NULL")
}
var big string
if err := db.QueryRow("SELECT bigint_val").Scan(&big); err != nil {
t.Fatal(err)
}
if big != "18446744073709551615" {
t.Fatalf("bigint %q", big)
}
var zh string
if err := db.QueryRow("SELECT zh_val").Scan(&zh); err != nil {
t.Fatal(err)
}
if zh != "你好世界" {
t.Fatalf("zh %q", zh)
}
}
+138
View File
@@ -0,0 +1,138 @@
package mysqlserver
import "crypto/rand"
func buildHandshake(connID uint32, scramble []byte) []byte {
if len(scramble) < 20 {
s := make([]byte, 20)
copy(s, scramble)
scramble = s
}
b := []byte{ProtocolVersion}
b = appendNUL(b, ServerVersion)
b = appendU32(b, connID)
b = append(b, scramble[:8]...)
b = append(b, 0x00)
b = appendU16(b, uint16(serverCapabilities))
b = append(b, charsetUTF8MB4)
b = appendU16(b, serverStatusAutocommit)
b = appendU16(b, uint16(serverCapabilities>>16))
b = append(b, 21) // auth plugin data len: 20 bytes + NUL
b = append(b, make([]byte, 10)...)
b = append(b, scramble[8:20]...)
b = append(b, 0x00)
b = appendNUL(b, AuthPluginNative)
return b
}
func randomScramble() ([]byte, error) {
s := make([]byte, 20)
if _, err := rand.Read(s); err != nil {
return nil, err
}
for i, c := range s {
if c == 0 {
s[i] = 1
}
}
return s, nil
}
type handshakeResponse struct {
Capabilities uint32
MaxPacket uint32
Charset byte
Username string
Auth []byte
Database string
Plugin string
SSL bool
}
func parseHandshakeResponse(payload []byte) (handshakeResponse, error) {
var hr handshakeResponse
if len(payload) < 32 {
return hr, errPacket("handshake response too short")
}
hr.Capabilities = uint32(payload[0]) | uint32(payload[1])<<8 | uint32(payload[2])<<16 | uint32(payload[3])<<24
hr.MaxPacket = uint32(payload[4]) | uint32(payload[5])<<8 | uint32(payload[6])<<16 | uint32(payload[7])<<24
hr.Charset = payload[8]
hr.SSL = hr.Capabilities&clientSSL != 0
if hr.SSL && len(payload) <= 32 {
return hr, nil
}
if hr.Capabilities&clientProtocol41 == 0 {
return hr, errPacket("CLIENT_PROTOCOL_41 required")
}
off := 32
user, off, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Username = user
switch {
case hr.Capabilities&clientPluginAuthLenencClientData != 0:
n, nOff, err := readLenencInt(payload, off)
if err != nil {
return hr, err
}
off = nOff
end := off + int(n)
if end > len(payload) {
return hr, errPacket("auth-response truncated")
}
hr.Auth = append([]byte(nil), payload[off:end]...)
off = end
case hr.Capabilities&clientSecureConnection != 0:
if off >= len(payload) {
return hr, errPacket("auth-response truncated")
}
n := int(payload[off])
off++
end := off + n
if end > len(payload) {
return hr, errPacket("auth-response truncated")
}
hr.Auth = append([]byte(nil), payload[off:end]...)
off = end
default:
s, nOff, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Auth = []byte(s)
off = nOff
}
if hr.Capabilities&clientConnectWithDB != 0 && off < len(payload) {
db, nOff, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Database = db
off = nOff
}
if hr.Capabilities&clientPluginAuth != 0 && off < len(payload) {
plugin, _, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Plugin = plugin
}
return hr, nil
}
func encodeAuthSwitch(plugin string, scramble []byte) []byte {
b := []byte{markerEOF} // 0xFE AuthSwitchRequest
b = appendNUL(b, plugin)
b = append(b, scramble...)
b = append(b, 0x00)
return b
}
type protoError struct{ s string }
func (e protoError) Error() string { return e.s }
func errPacket(s string) error { return protoError{s} }
+263
View File
@@ -0,0 +1,263 @@
package mysqlserver
import (
"encoding/binary"
"fmt"
"io"
)
const (
ProtocolVersion = 10
ServerVersion = "8.0.0-mysql-tunnel"
AuthPluginNative = "mysql_native_password"
AuthPluginSHA2 = "caching_sha2_password"
MaxPacketSize = 16 << 20
)
const (
ComQuit byte = 0x01
ComInitDB byte = 0x02
ComQuery byte = 0x03
ComFieldList byte = 0x04
ComPing byte = 0x0e
ComStmtPrepare byte = 0x16
ComStmtExecute byte = 0x17
ComStmtClose byte = 0x19
ComResetConnection byte = 0x1f
)
const (
clientLongPassword uint32 = 1
clientFoundRows uint32 = 1 << 1
clientLongFlag uint32 = 1 << 2
clientConnectWithDB uint32 = 1 << 3
clientProtocol41 uint32 = 1 << 9
clientSSL uint32 = 1 << 11
clientTransactions uint32 = 1 << 13
clientSecureConnection uint32 = 1 << 15
clientMultiStatements uint32 = 1 << 16
clientMultiResults uint32 = 1 << 17
clientPSMultiResults uint32 = 1 << 18
clientPluginAuth uint32 = 1 << 19
clientConnectAttrs uint32 = 1 << 20
clientPluginAuthLenencClientData uint32 = 1 << 21
clientSessionTrack uint32 = 1 << 23
clientDeprecateEOF uint32 = 1 << 24
)
const (
serverStatusAutocommit uint16 = 0x0002
)
const (
charsetUTF8MB4 uint8 = 45
charsetBinary uint8 = 63
)
const (
typeDecimal byte = 0
typeTiny byte = 1
typeShort byte = 2
typeLong byte = 3
typeFloat byte = 4
typeDouble byte = 5
typeNull byte = 6
typeTimestamp byte = 7
typeLongLong byte = 8
typeInt24 byte = 9
typeDate byte = 10
typeTime byte = 11
typeDatetime byte = 12
typeYear byte = 13
typeJSON byte = 245
typeNewDecimal byte = 246
typeTinyBlob byte = 249
typeMediumBlob byte = 250
typeLongBlob byte = 251
typeBlob byte = 252
typeVarString byte = 253
typeString byte = 254
)
const (
flagNotNull uint16 = 1
flagPriKey uint16 = 2
flagUniqueKey uint16 = 4
flagMultipleKey uint16 = 8
flagBlob uint16 = 16
flagUnsigned uint16 = 32
flagZerofill uint16 = 64
flagBinary uint16 = 128
flagEnum uint16 = 256
flagAutoIncrement uint16 = 512
flagTimestamp uint16 = 1024
flagSet uint16 = 2048
)
const (
errAccessDenied uint16 = 1045
errUnknownCommand uint16 = 1047
errNoDB uint16 = 1046
errParseError uint16 = 1064
errUnknown uint16 = 1105
errNotSupported uint16 = 1235
)
const (
markerOK byte = 0x00
markerEOF byte = 0xfe
markerERR byte = 0xff
markerNull byte = 0xfb
)
var serverCapabilities = clientLongPassword | clientFoundRows | clientLongFlag |
clientConnectWithDB | clientProtocol41 | clientTransactions | clientSecureConnection |
clientMultiResults | clientPluginAuth | clientPluginAuthLenencClientData |
clientConnectAttrs | clientDeprecateEOF | clientSessionTrack
func appendU24(dst []byte, n uint32) []byte {
return append(dst, byte(n), byte(n>>8), byte(n>>16))
}
func appendU32(dst []byte, n uint32) []byte {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, n)
return append(dst, b...)
}
func appendU16(dst []byte, n uint16) []byte {
return append(dst, byte(n), byte(n>>8))
}
func appendNUL(dst []byte, s string) []byte {
dst = append(dst, s...)
return append(dst, 0)
}
func appendLenencInt(dst []byte, n uint64) []byte {
switch {
case n < 251:
return append(dst, byte(n))
case n < 1<<16:
return append(dst, 0xfc, byte(n), byte(n>>8))
case n < 1<<24:
return append(dst, 0xfd, byte(n), byte(n>>8), byte(n>>16))
default:
b := make([]byte, 8)
binary.LittleEndian.PutUint64(b, n)
return append(append(dst, 0xfe), b...)
}
}
func appendLenencString(dst []byte, s []byte) []byte {
dst = appendLenencInt(dst, uint64(len(s)))
return append(dst, s...)
}
func readU24(b []byte) uint32 {
return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16
}
func readLenencInt(b []byte, off int) (uint64, int, error) {
if off >= len(b) {
return 0, off, io.ErrUnexpectedEOF
}
switch b[off] {
case 0xfb:
return 0, off + 1, fmt.Errorf("NULL lenenc")
case 0xfc:
if off+3 > len(b) {
return 0, off, io.ErrUnexpectedEOF
}
return uint64(b[off+1]) | uint64(b[off+2])<<8, off + 3, nil
case 0xfd:
if off+4 > len(b) {
return 0, off, io.ErrUnexpectedEOF
}
return uint64(b[off+1]) | uint64(b[off+2])<<8 | uint64(b[off+3])<<16, off + 4, nil
case 0xfe:
if off+9 > len(b) {
return 0, off, io.ErrUnexpectedEOF
}
return binary.LittleEndian.Uint64(b[off+1 : off+9]), off + 9, nil
default:
return uint64(b[off]), off + 1, nil
}
}
func readLenencString(b []byte, off int) ([]byte, int, error) {
n, off, err := readLenencInt(b, off)
if err != nil {
return nil, off, err
}
end := off + int(n)
if end > len(b) {
return nil, off, io.ErrUnexpectedEOF
}
return b[off:end], end, nil
}
func readNUL(b []byte, off int) (string, int, error) {
for i := off; i < len(b); i++ {
if b[i] == 0 {
return string(b[off:i]), i + 1, nil
}
}
return string(b[off:]), len(b), nil
}
func sqlState(errno uint16) string {
switch errno {
case errAccessDenied:
return "28000"
case errParseError:
return "42000"
case errNoDB:
return "3D000"
case errUnknownCommand, errNotSupported:
return "HY000"
default:
return "HY000"
}
}
func encodeOK(affected, lastInsertID uint64, status, warnings uint16) []byte {
b := []byte{markerOK}
b = appendLenencInt(b, affected)
b = appendLenencInt(b, lastInsertID)
b = appendU16(b, status)
b = appendU16(b, warnings)
return b
}
func encodeERR(errno uint16, msg string) []byte {
b := []byte{markerERR}
b = appendU16(b, errno)
b = append(b, '#')
state := sqlState(errno)
if len(state) != 5 {
state = "HY000"
}
b = append(b, state...)
b = append(b, msg...)
return b
}
func encodeEOF(warnings, status uint16) []byte {
b := []byte{markerEOF}
b = appendU16(b, warnings)
b = appendU16(b, status)
return b
}
func isNumericType(t byte) bool {
switch t {
case typeDecimal, typeTiny, typeShort, typeLong, typeFloat, typeDouble,
typeNull, typeLongLong, typeInt24, typeYear, typeNewDecimal, typeBit:
return true
}
return false
}
const typeBit byte = 16
+122
View File
@@ -0,0 +1,122 @@
package mysqlserver
import (
"bytes"
"testing"
"mysql-tunnel/internal/navicat"
)
func TestLenencInt(t *testing.T) {
cases := []uint64{0, 1, 250, 251, 0xffff, 0x10000, 1 << 32}
for _, n := range cases {
enc := appendLenencInt(nil, n)
got, _, err := readLenencInt(enc, 0)
if err != nil || got != n {
t.Fatalf("n=%d got=%d err=%v", n, got, err)
}
}
}
func TestOKERRPackets(t *testing.T) {
ok := encodeOK(3, 9, serverStatusAutocommit, 0)
if ok[0] != markerOK {
t.Fatal("ok header")
}
n, off, err := readLenencInt(ok, 1)
if err != nil || n != 3 {
t.Fatal(n, err)
}
id, _, err := readLenencInt(ok, off)
if err != nil || id != 9 {
t.Fatal(id, err)
}
er := encodeERR(1064, "syntax")
if er[0] != markerERR || er[1] != 0x28 || er[2] != 0x04 {
t.Fatalf("%x", er)
}
if !bytes.Contains(er, []byte("syntax")) {
t.Fatal(string(er))
}
}
func TestNativePasswordRoundtrip(t *testing.T) {
scramble := bytes.Repeat([]byte{3}, 20)
tok := nativePasswordToken("123456", scramble)
if !verifyNativePassword("123456", scramble, tok) {
t.Fatal("should match")
}
if verifyNativePassword("wrong", scramble, tok) {
t.Fatal("should fail")
}
if !verifyNativePassword("", scramble, nil) {
t.Fatal("empty")
}
}
func TestHandshakeBuildParseResponse(t *testing.T) {
scramble := bytes.Repeat([]byte{7}, 20)
hs := buildHandshake(42, scramble)
if hs[0] != ProtocolVersion {
t.Fatal("proto")
}
if !bytes.Contains(hs, []byte(ServerVersion)) {
t.Fatal("version")
}
if !bytes.Contains(hs, []byte(AuthPluginNative)) {
t.Fatal("plugin")
}
token := nativePasswordToken("p", scramble)
resp := buildClientAuth("root", token, "app", clientProtocol41|clientSecureConnection|clientPluginAuth|clientConnectWithDB|clientPluginAuthLenencClientData)
hr, err := parseHandshakeResponse(resp)
if err != nil {
t.Fatal(err)
}
if hr.Username != "root" || hr.Database != "app" || !bytes.Equal(hr.Auth, token) {
t.Fatalf("%+v", hr)
}
}
func TestTextRowNULLAndBigint(t *testing.T) {
row := encodeTextRow([]navicat.Cell{
{Null: true},
{Bytes: []byte("18446744073709551615")},
{Bytes: []byte("1.50")},
{Bytes: []byte("你好")},
})
if row[0] != markerNull {
t.Fatal("null marker")
}
off := 1
b, off, err := readLenencString(row, off)
if err != nil || string(b) != "18446744073709551615" {
t.Fatal(string(b), err)
}
b, off, err = readLenencString(row, off)
if string(b) != "1.50" {
t.Fatal(string(b))
}
b, _, err = readLenencString(row, off)
if string(b) != "你好" {
t.Fatal(string(b))
}
}
func buildClientAuth(user string, token []byte, db string, caps uint32) []byte {
b := appendU32(nil, caps)
b = appendU32(b, MaxPacketSize)
b = append(b, charsetUTF8MB4)
b = append(b, make([]byte, 23)...)
b = appendNUL(b, user)
b = appendLenencInt(b, uint64(len(token)))
b = append(b, token...)
if caps&clientConnectWithDB != 0 {
b = appendNUL(b, db)
}
if caps&clientPluginAuth != 0 {
b = appendNUL(b, AuthPluginNative)
}
return b
}
+95
View File
@@ -0,0 +1,95 @@
package mysqlserver
import (
"mysql-tunnel/internal/navicat"
)
func encodeColumnCount(n int) []byte {
return appendLenencInt(nil, uint64(n))
}
func encodeColumn(f navicat.Field) []byte {
typ := byte(f.Type)
charset := uint16(charsetUTF8MB4)
if isNumericType(typ) || f.Flags&uint32(flagBinary) != 0 && isBlobType(typ) {
charset = uint16(charsetBinary)
}
if isNumericType(typ) {
charset = uint16(charsetBinary)
}
length := f.Length
if length == 0 {
length = uint32(len(f.Name))
if length == 0 {
length = 64
}
}
flags := uint16(f.Flags)
decimals := byte(0)
if typ == typeFloat || typ == typeDouble || typ == typeNewDecimal || typ == typeDecimal {
decimals = 31
}
b := appendLenencString(nil, []byte("def"))
b = appendLenencString(b, nil) // schema
b = appendLenencString(b, []byte(f.Table))
b = appendLenencString(b, []byte(f.Table))
b = appendLenencString(b, []byte(f.Name))
b = appendLenencString(b, []byte(f.Name))
b = appendLenencInt(b, 0x0c)
b = appendU16(b, charset)
b = appendU32(b, length)
b = append(b, typ)
b = appendU16(b, flags)
b = append(b, decimals, 0x00, 0x00)
return b
}
func encodeTextRow(row []navicat.Cell) []byte {
var b []byte
for _, c := range row {
if c.Null {
b = append(b, markerNull)
continue
}
b = appendLenencString(b, c.Bytes)
}
return b
}
func isBlobType(t byte) bool {
switch t {
case typeTinyBlob, typeMediumBlob, typeLongBlob, typeBlob:
return true
}
return false
}
func (p *packetConn) writeResultSet(res navicat.Result, caps uint32, status uint16) error {
if err := p.writePacket(encodeColumnCount(len(res.Fields))); err != nil {
return err
}
for _, f := range res.Fields {
if err := p.writePacket(encodeColumn(f)); err != nil {
return err
}
}
if caps&clientDeprecateEOF != 0 {
if err := p.writeOK(0, 0, status, 0); err != nil {
return err
}
} else {
if err := p.writePacket(encodeEOF(0, status)); err != nil {
return err
}
}
for _, row := range res.Rows {
if err := p.writePacket(encodeTextRow(row)); err != nil {
return err
}
}
if caps&clientDeprecateEOF != 0 {
return p.writeOK(uint64(res.AffectedRows), uint64(res.InsertID), status, 0)
}
return p.writePacket(encodeEOF(0, status))
}
+194
View File
@@ -0,0 +1,194 @@
package mysqlserver
import (
"context"
"fmt"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
"mysql-tunnel/internal/logger"
)
type Options struct {
Host string
Port int
Username string
Password string
DefaultDB string
MaxConnections int
Timeout time.Duration
LogSQL bool
}
type Server struct {
opt Options
backend Backend
log *logger.Logger
ln net.Listener
connID atomic.Uint32
sem chan struct{}
wg sync.WaitGroup
}
func New(opt Options, backend Backend, log *logger.Logger) *Server {
if opt.Host == "" {
opt.Host = "127.0.0.1"
}
if opt.Port == 0 {
opt.Port = 13306
}
if opt.MaxConnections < 1 {
opt.MaxConnections = 5
}
if opt.Timeout <= 0 {
opt.Timeout = 30 * time.Second
}
return &Server{
opt: opt,
backend: backend,
log: log,
sem: make(chan struct{}, opt.MaxConnections),
}
}
func (s *Server) Addr() string {
if s.ln != nil {
return s.ln.Addr().String()
}
return net.JoinHostPort(s.opt.Host, strconv.Itoa(s.opt.Port))
}
func (s *Server) ListenAndServe(ctx context.Context) error {
ln, err := net.Listen("tcp", net.JoinHostPort(s.opt.Host, strconv.Itoa(s.opt.Port)))
if err != nil {
return err
}
return s.Serve(ctx, ln)
}
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
s.ln = ln
if s.log != nil {
s.log.Info("server started", ln.Addr().String())
}
go func() {
<-ctx.Done()
_ = ln.Close()
}()
for {
c, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
s.wg.Wait()
return nil
default:
return err
}
}
select {
case s.sem <- struct{}{}:
default:
_ = c.Close()
if s.log != nil {
s.log.Warn("max connections reached, rejected", c.RemoteAddr().String())
}
continue
}
s.wg.Add(1)
go func(c net.Conn) {
defer s.wg.Done()
defer func() { <-s.sem }()
s.handle(ctx, c)
}(c)
}
}
func (s *Server) handle(ctx context.Context, c net.Conn) {
pc := newPacketConn(c, s.opt.Timeout)
remote := pc.remote()
if s.log != nil {
s.log.Info("client connected", remote)
}
defer func() {
_ = pc.close()
if s.log != nil {
s.log.Info("client disconnected", remote)
}
}()
scramble, err := randomScramble()
if err != nil {
return
}
id := s.connID.Add(1)
if err := pc.writePacket(buildHandshake(id, scramble)); err != nil {
return
}
payload, err := pc.readPacket()
if err != nil {
return
}
hr, err := parseHandshakeResponse(payload)
if err != nil {
_ = pc.writeERR(errUnknown, "bad handshake")
return
}
if hr.SSL {
_ = pc.writeERR(errNotSupported, "SSL is not supported on the local tunnel")
return
}
auth := hr.Auth
plugin := hr.Plugin
if plugin == "" {
plugin = AuthPluginNative
}
if plugin != AuthPluginNative {
if err := pc.writePacket(encodeAuthSwitch(AuthPluginNative, scramble)); err != nil {
return
}
auth, err = pc.readPacket()
if err != nil {
return
}
if len(auth) > 0 && auth[len(auth)-1] == 0 && len(auth) != 20 {
auth = auth[:len(auth)-1]
}
}
db := hr.Database
if db == "" {
db = s.opt.DefaultDB
}
if len(auth) == 21 && auth[20] == 0 {
auth = auth[:20]
}
if !usernamesEqual(hr.Username, s.opt.Username) || !verifyNativePassword(s.opt.Password, scramble, auth) {
_ = pc.writeERR(errAccessDenied, fmt.Sprintf("Access denied for user '%s'@'localhost'", hr.Username))
return
}
if err := pc.writeOK(0, 0, serverStatusAutocommit, 0); err != nil {
return
}
sess := &session{
pc: pc,
backend: s.backend,
log: s.log,
logSQL: s.opt.LogSQL,
timeout: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, s.opt.Timeout)
},
user: hr.Username,
db: db,
defaultDB: s.opt.DefaultDB,
caps: hr.Capabilities,
status: serverStatusAutocommit,
scramble: scramble,
}
sess.serve(ctx)
}
+423
View File
@@ -0,0 +1,423 @@
package mysqlserver
import (
"context"
"fmt"
"net"
"net/http/httptest"
"strings"
"testing"
"time"
"mysql-tunnel/internal/navicat"
"mysql-tunnel/internal/tunnel"
)
type stubBackend struct{}
func (stubBackend) Query(_ context.Context, _ string, sqls ...string) (*navicat.QueryResponse, error) {
var results []navicat.Result
for _, sql := range sqls {
s := strings.TrimSpace(sql)
u := strings.ToUpper(s)
switch {
case strings.HasPrefix(u, "SET NAMES"), strings.HasPrefix(u, "USE "):
results = append(results, navicat.Result{})
case u == "SELECT 1" || strings.HasPrefix(u, "SELECT 1 "):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "1", Type: 8, Length: 20}},
Rows: [][]navicat.Cell{{{Bytes: []byte("1")}}},
})
case strings.Contains(u, "SELECT NULL"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "n", Type: 6}},
Rows: [][]navicat.Cell{{{Null: true}}},
})
case strings.Contains(strings.ToLower(s), "bigint_val"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "bigint_val", Type: 8, Flags: 32, Length: 20}},
Rows: [][]navicat.Cell{{{Bytes: []byte("18446744073709551615")}}},
})
case strings.Contains(strings.ToLower(s), "dec_val"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "dec_val", Type: 246, Length: 32}},
Rows: [][]navicat.Cell{{{Bytes: []byte("1234567890.123456789")}}},
})
case strings.Contains(s, "你好") || strings.Contains(strings.ToLower(s), "zh_val"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "zh_val", Type: 253, Length: 12}},
Rows: [][]navicat.Cell{{{Bytes: []byte("你好世界")}}},
})
case strings.HasPrefix(u, "UPDATE"), strings.HasPrefix(u, "INSERT"):
results = append(results, navicat.Result{AffectedRows: 2, InsertID: 7})
case strings.Contains(u, "SYNTAX"):
results = append(results, navicat.Result{Errno: 1064, Message: "You have an error in your SQL syntax"})
default:
results = append(results, navicat.Result{Errno: 1064, Message: "stub: unhandled SQL"})
}
}
return &navicat.QueryResponse{Results: results}, nil
}
func startServer(t *testing.T, backend Backend, user, pass string) (addr string, cancel context.CancelFunc) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
s := New(Options{
Host: "127.0.0.1", Port: 1, Username: user, Password: pass,
DefaultDB: "app", MaxConnections: 5, Timeout: 5 * time.Second,
}, backend, nil)
errCh := make(chan error, 1)
go func() { errCh <- s.Serve(ctx, ln) }()
t.Cleanup(func() {
cancel()
_ = ln.Close()
})
return ln.Addr().String(), cancel
}
type testClient struct {
pc *packetConn
caps uint32
}
func dialTest(t *testing.T, addr, user, pass, db string) *testClient {
t.Helper()
c, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
t.Fatal(err)
}
pc := newPacketConn(c, 5*time.Second)
hs, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
scramble, err := scrambleFromHandshake(hs)
if err != nil {
t.Fatal(err)
}
caps := clientProtocol41 | clientSecureConnection | clientPluginAuth |
clientConnectWithDB | clientPluginAuthLenencClientData | clientDeprecateEOF | clientMultiResults
token := nativePasswordToken(pass, scramble)
if err := pc.writePacket(buildClientAuth(user, token, db, caps)); err != nil {
t.Fatal(err)
}
ok, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if len(ok) == 0 || ok[0] != markerOK {
t.Fatalf("auth failed: %x %q", ok, ok)
}
return &testClient{pc: pc, caps: caps}
}
func scrambleFromHandshake(payload []byte) ([]byte, error) {
if len(payload) < 20 || payload[0] != ProtocolVersion {
return nil, fmt.Errorf("bad handshake")
}
off := 1
_, off, err := readNUL(payload, off)
if err != nil {
return nil, err
}
off += 4 // conn id
part1 := payload[off : off+8]
off += 8 + 1 + 2 + 1 + 2 + 2 + 1 + 10
if off+13 > len(payload) {
return nil, fmt.Errorf("short scramble")
}
part2 := payload[off : off+12]
s := append(append([]byte{}, part1...), part2...)
return s, nil
}
func (c *testClient) query(t *testing.T, sql string) (cols []string, rows [][]*string, affected, lastID uint64, errno int, errMsg string) {
t.Helper()
c.pc.resetSeq()
payload := append([]byte{ComQuery}, []byte(sql)...)
if err := c.pc.writePacket(payload); err != nil {
t.Fatal(err)
}
pkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
if len(pkt) == 0 {
t.Fatal("empty")
}
if pkt[0] == markerERR {
errno = int(pkt[1]) | int(pkt[2])<<8
if len(pkt) > 9 {
errMsg = string(pkt[9:])
}
return
}
if pkt[0] == markerOK {
n, off, _ := readLenencInt(pkt, 1)
id, _, _ := readLenencInt(pkt, off)
return nil, nil, n, id, 0, ""
}
nCols, _, err := readLenencInt(pkt, 0)
if err != nil {
t.Fatal(err)
}
for i := uint64(0); i < nCols; i++ {
colPkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
name := columnName(colPkt)
cols = append(cols, name)
}
// metadata end OK/EOF
if _, err := c.pc.readPacket(); err != nil {
t.Fatal(err)
}
for {
rowPkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
if len(rowPkt) == 0 {
break
}
if rowPkt[0] == markerERR {
errno = int(rowPkt[1]) | int(rowPkt[2])<<8
return
}
if rowPkt[0] == markerEOF && len(rowPkt) < 9 {
break
}
if rowPkt[0] == markerOK && len(rowPkt) >= 7 {
break
}
off := 0
row := make([]*string, nCols)
for i := uint64(0); i < nCols; i++ {
if off >= len(rowPkt) {
t.Fatal("short row")
}
if rowPkt[off] == markerNull {
row[i] = nil
off++
continue
}
b, nOff, err := readLenencString(rowPkt, off)
if err != nil {
t.Fatal(err)
}
s := string(b)
row[i] = &s
off = nOff
}
rows = append(rows, row)
}
return
}
func (c *testClient) ping(t *testing.T) {
t.Helper()
c.pc.resetSeq()
if err := c.pc.writePacket([]byte{ComPing}); err != nil {
t.Fatal(err)
}
pkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
if pkt[0] != markerOK {
t.Fatalf("ping %x", pkt)
}
}
func (c *testClient) quit() {
c.pc.resetSeq()
_ = c.pc.writePacket([]byte{ComQuit})
_ = c.pc.close()
}
func columnName(pkt []byte) string {
off := 0
skip := func() {
_, n, err := readLenencString(pkt, off)
if err != nil {
return
}
off = n
}
skip() // catalog
skip() // schema
skip() // table
skip() // org table
b, _, err := readLenencString(pkt, off)
if err != nil {
return ""
}
return string(b)
}
func TestServerAuthPingQueryQuit(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "123456")
cl := dialTest(t, addr, "root", "123456", "app")
cl.ping(t)
_, rows, _, _, errno, msg := cl.query(t, "SELECT 1")
if errno != 0 {
t.Fatalf("errno %d %s", errno, msg)
}
if len(rows) != 1 || rows[0][0] == nil || *rows[0][0] != "1" {
t.Fatalf("rows=%v", rows)
}
cl.quit()
}
func TestServerBadPassword(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "123456")
c, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
pc := newPacketConn(c, 5*time.Second)
hs, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
scramble, err := scrambleFromHandshake(hs)
if err != nil {
t.Fatal(err)
}
caps := clientProtocol41 | clientSecureConnection | clientPluginAuth | clientPluginAuthLenencClientData
tok := nativePasswordToken("wrong", scramble)
if err := pc.writePacket(buildClientAuth("root", tok, "", caps)); err != nil {
t.Fatal(err)
}
pkt, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if pkt[0] != markerERR {
t.Fatalf("want ERR got %x", pkt)
}
errno := int(pkt[1]) | int(pkt[2])<<8
if errno != int(errAccessDenied) {
t.Fatalf("errno %d", errno)
}
}
func TestServerResultTypes(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "p")
cl := dialTest(t, addr, "root", "p", "app")
defer cl.quit()
_, rows, _, _, errno, _ := cl.query(t, "SELECT NULL AS n")
if errno != 0 || rows[0][0] != nil {
t.Fatalf("null errno=%d row=%v", errno, rows)
}
_, rows, _, _, errno, _ = cl.query(t, "SELECT bigint_val")
if errno != 0 || *rows[0][0] != "18446744073709551615" {
t.Fatalf("bigint %v errno=%d", rows, errno)
}
_, rows, _, _, errno, _ = cl.query(t, "SELECT dec_val")
if errno != 0 || *rows[0][0] != "1234567890.123456789" {
t.Fatalf("dec %v", rows)
}
_, rows, _, _, errno, _ = cl.query(t, "SELECT zh_val")
if errno != 0 || *rows[0][0] != "你好世界" {
t.Fatalf("zh %v", rows)
}
_, _, affected, lastID, errno, msg := cl.query(t, "UPDATE t SET x=1")
if errno != 0 {
t.Fatal(msg)
}
if affected != 2 || lastID != 7 {
t.Fatalf("affected=%d id=%d", affected, lastID)
}
_, _, _, _, errno, msg = cl.query(t, "SELECT SYNTAX")
if errno != 1064 || !strings.Contains(msg, "syntax") {
t.Fatalf("errno=%d msg=%s", errno, msg)
}
}
func TestServerAuthSwitch(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "p")
c, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
pc := newPacketConn(c, 5*time.Second)
hs, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
scramble, err := scrambleFromHandshake(hs)
if err != nil {
t.Fatal(err)
}
caps := clientProtocol41 | clientSecureConnection | clientPluginAuth | clientPluginAuthLenencClientData
b := appendU32(nil, caps)
b = appendU32(b, MaxPacketSize)
b = append(b, charsetUTF8MB4)
b = append(b, make([]byte, 23)...)
b = appendNUL(b, "root")
b = appendLenencInt(b, 0) // empty, will switch
b = appendNUL(b, AuthPluginSHA2)
if err := pc.writePacket(b); err != nil {
t.Fatal(err)
}
sw, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if sw[0] != markerEOF {
t.Fatalf("want auth switch %x", sw)
}
tok := nativePasswordToken("p", scramble)
if err := pc.writePacket(tok); err != nil {
t.Fatal(err)
}
ok, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if ok[0] != markerOK {
t.Fatalf("auth after switch %x", ok)
}
}
func TestPhase4NavicatStack(t *testing.T) {
mock := httptest.NewServer(navicat.NewMockHandler(navicat.WithToken("t")))
t.Cleanup(mock.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
n := navicat.NewClient(httpClient, navicat.ClientOptions{
URL: mock.URL + "?token=t",
Target: navicat.Target{
Host: "mysql", Port: 3306, User: "remote", Password: "rp",
Database: "app", Charset: "utf8mb4",
},
})
addr, _ := startServer(t, n, "root", "local")
cl := dialTest(t, addr, "root", "local", "app")
defer cl.quit()
_, rows, _, _, errno, msg := cl.query(t, "SELECT 1")
if errno != 0 {
t.Fatal(msg)
}
if *rows[0][0] != "1" {
t.Fatalf("%v", rows)
}
}
+168
View File
@@ -0,0 +1,168 @@
package mysqlserver
import (
"context"
"fmt"
"regexp"
"strings"
"mysql-tunnel/internal/logger"
"mysql-tunnel/internal/navicat"
)
var useDBRe = regexp.MustCompile(`(?is)^\s*USE\s+(?:` + "`" + `([^` + "`" + `]+)` + "`" + `|([A-Za-z0-9_]+))\s*;?\s*$`)
type Backend interface {
Query(ctx context.Context, database string, sqls ...string) (*navicat.QueryResponse, error)
}
type session struct {
pc *packetConn
backend Backend
log *logger.Logger
logSQL bool
timeout ctxTimeout
user string
db string
defaultDB string
caps uint32
status uint16
scramble []byte
}
type ctxTimeout func() (context.Context, context.CancelFunc)
func (s *session) serve(ctx context.Context) {
defer s.pc.close()
for {
select {
case <-ctx.Done():
return
default:
}
s.pc.resetSeq()
payload, err := s.pc.readPacket()
if err != nil {
return
}
if len(payload) == 0 {
_ = s.pc.writeERR(errUnknownCommand, "empty command")
continue
}
if err := s.dispatch(ctx, payload[0], payload[1:]); err != nil {
return
}
}
}
func (s *session) dispatch(parent context.Context, cmd byte, body []byte) error {
switch cmd {
case ComQuit:
return fmt.Errorf("quit")
case ComPing:
return s.pc.writeOK(0, 0, s.status, 0)
case ComInitDB:
db := strings.TrimSpace(string(body))
return s.changeDB(parent, db)
case ComQuery:
return s.query(parent, string(body))
case ComResetConnection:
s.db = s.defaultDB
return s.pc.writeOK(0, 0, s.status, 0)
case ComFieldList, ComStmtPrepare, ComStmtExecute, ComStmtClose:
return s.pc.writeERR(errNotSupported, "command not implemented yet")
default:
return s.pc.writeERR(errUnknownCommand, "unknown command")
}
}
func (s *session) changeDB(parent context.Context, db string) error {
if db == "" {
return s.pc.writeERR(errNoDB, "no database selected")
}
s.db = db
return s.pc.writeOK(0, 0, s.status, 0)
}
func (s *session) query(parent context.Context, sql string) error {
sql = strings.TrimRight(sql, "\x00")
s.logQuery(sql)
pendingDB := ""
if m := useDBRe.FindStringSubmatch(sql); m != nil {
pendingDB = m[1]
if pendingDB == "" {
pendingDB = m[2]
}
}
ctx, cancel := s.timeout()
defer cancel()
if ctx == nil {
ctx = parent
}
resp, err := s.backend.Query(ctx, s.db, sql)
if err != nil {
return s.writeBackendErr(err)
}
for _, r := range resp.Results {
if r.IsError() {
return s.pc.writeERR(uint16(r.Errno), r.Message)
}
}
if pendingDB != "" {
s.db = pendingDB
}
res, ok := navicat.LastResultSet(resp)
if !ok {
return s.pc.writeOK(0, 0, s.status, 0)
}
if res.IsError() {
return s.pc.writeERR(uint16(res.Errno), res.Message)
}
if res.IsResultSet() {
if s.log != nil {
s.log.Info("query completed", fmt.Sprintf("rows=%d", len(res.Rows)))
}
return s.pc.writeResultSet(res, s.caps, s.status)
}
if s.log != nil {
s.log.Info("query completed", fmt.Sprintf("affected=%d", res.AffectedRows))
}
return s.pc.writeOK(uint64(res.AffectedRows), uint64(res.InsertID), s.status, 0)
}
func (s *session) writeBackendErr(err error) error {
msg := logger.RedactError(err)
if ne, ok := err.(*navicat.Error); ok {
errno := uint16(ne.Errno)
if errno == 0 {
errno = errUnknown
}
if ne.Kind == "http" || ne.Kind == "tunnel" {
errno = errUnknown
}
return s.pc.writeERR(errno, msg)
}
return s.pc.writeERR(errUnknown, msg)
}
func (s *session) logQuery(sql string) {
if s.log == nil {
return
}
if !s.logSQL {
s.log.Info("query")
return
}
s.log.Info("query", trimSQL(sql))
}
func trimSQL(sql string) string {
s := logger.RedactText(sql)
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > 240 {
return s[:240] + "..."
}
return s
}
+76
View File
@@ -0,0 +1,76 @@
package navicat
import (
"encoding/binary"
"fmt"
)
func putU32(n uint32) []byte {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, n)
return b
}
func putU16(n uint16) []byte {
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, n)
return b
}
// AppendBlock encodes GetBlock() from ntunnel_mysql.php.
// NULL is not represented here; use AppendNull for row cells.
func AppendBlock(dst []byte, val []byte) []byte {
if val == nil {
val = []byte{}
}
n := len(val)
if n < 254 {
dst = append(dst, byte(n))
return append(dst, val...)
}
dst = append(dst, markLong)
dst = append(dst, putU32(uint32(n))...)
return append(dst, val...)
}
func AppendNull(dst []byte) []byte {
return append(dst, markNull)
}
func AppendHeader(dst []byte, version uint16, errno uint32) []byte {
dst = append(dst, putU32(Magic)...)
dst = append(dst, putU16(version)...)
dst = append(dst, putU32(errno)...)
dst = append(dst, 0, 0, 0, 0, 0, 0)
return dst
}
func AppendResultSetHeader(dst []byte, errno, affected, insertID, numFields, numRows uint32) []byte {
dst = append(dst, putU32(errno)...)
dst = append(dst, putU32(affected)...)
dst = append(dst, putU32(insertID)...)
dst = append(dst, putU32(numFields)...)
dst = append(dst, putU32(numRows)...)
dst = append(dst, make([]byte, 12)...)
return dst
}
func AppendField(dst []byte, name, table string, typ, flags, length uint32) []byte {
dst = AppendBlock(dst, []byte(name))
dst = AppendBlock(dst, []byte(table))
dst = append(dst, putU32(typ)...)
dst = append(dst, putU32(flags)...)
dst = append(dst, putU32(length)...)
return dst
}
func knownVersion(v uint16) bool {
return v == Version202 || v == Version206
}
func fmtErrno(errno uint32, msg string) string {
if msg == "" {
return fmt.Sprintf("tunnel errno %d", errno)
}
return fmt.Sprintf("tunnel errno %d: %s", errno, msg)
}
+177
View File
@@ -0,0 +1,177 @@
package navicat
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"mysql-tunnel/internal/logger"
"mysql-tunnel/internal/tunnel"
)
var charsetIdent = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
// Target is the remote MySQL as seen by ntunnel_mysql.php.
type Target struct {
Host string
Port int
User string
Password string
Database string
Charset string
}
// Client talks Navicat HTTP Tunnel over the generic HTTP client.
type Client struct {
http *tunnel.HTTPClient
url string
target Target
encodeBase64 bool
retries int
}
type ClientOptions struct {
URL string
Target Target
EncodeBase64 bool
// Retries is extra attempts on transport failure only (not SQL errors). 0 means a single try.
Retries int
}
func NewClient(httpClient *tunnel.HTTPClient, opt ClientOptions) *Client {
retries := opt.Retries
if retries < 0 {
retries = 0
}
return &Client{
http: httpClient,
url: opt.URL,
target: opt.Target,
encodeBase64: opt.EncodeBase64,
retries: retries,
}
}
func (c *Client) Connect(ctx context.Context) (*ConnectResponse, error) {
body, status, err := c.post(ctx, Request{
Action: ActionConnect,
Host: c.target.Host,
Port: c.target.Port,
Login: c.target.User,
Password: c.target.Password,
Database: c.target.Database,
EncodeBase64: c.encodeBase64,
})
if err != nil {
return nil, err
}
resp, err := ParseConnect(body)
if err != nil {
return nil, err
}
if !resp.Header.OK() {
return resp, headerError(resp.Header, status)
}
return resp, nil
}
func (c *Client) Query(ctx context.Context, database string, sqls ...string) (*QueryResponse, error) {
db := database
if db == "" {
db = c.target.Database
}
queries := make([]string, 0, len(sqls)+1)
if s := charsetSQL(c.target.Charset); s != "" {
queries = append(queries, s)
}
queries = append(queries, nonEmpty(sqls)...)
if len(queries) == 0 {
return nil, fmt.Errorf("no SQL to execute")
}
body, status, err := c.post(ctx, Request{
Action: ActionQuery,
Host: c.target.Host,
Port: c.target.Port,
Login: c.target.User,
Password: c.target.Password,
Database: db,
Queries: queries,
EncodeBase64: c.encodeBase64,
})
if err != nil {
return nil, err
}
resp, err := ParseQuery(body)
if err != nil {
return nil, err
}
if !resp.Header.OK() {
return resp, headerError(resp.Header, status)
}
return resp, nil
}
func (c *Client) post(ctx context.Context, req Request) (body []byte, status int, err error) {
encoded := req.Encode()
attempts := 1 + c.retries
var last error
for i := 0; i < attempts; i++ {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
httpResp, err := c.http.Do(ctx, http.MethodPost, c.url, "application/x-www-form-urlencoded", strings.NewReader(encoded))
if err != nil {
last = err
continue
}
status = httpResp.StatusCode
body = httpResp.Body
if status == http.StatusForbidden {
return body, status, &Error{Kind: "http", HTTPStatus: status, Errno: ErrnoInvalidToken, Message: "invalid or missing token"}
}
if status < 200 || status >= 300 {
return body, status, &Error{Kind: "http", HTTPStatus: status, Message: "unexpected status"}
}
if looksHTML(body) {
return body, status, &Error{Kind: "http", HTTPStatus: status, Message: "tunnel returned HTML, not Navicat binary"}
}
return body, status, nil
}
return nil, 0, last
}
func charsetSQL(cs string) string {
cs = strings.TrimSpace(cs)
if cs == "" {
return ""
}
if strings.EqualFold(cs, "utf8") || strings.EqualFold(cs, "utf8mb3") {
return ""
}
if !charsetIdent.MatchString(cs) {
return ""
}
return "SET NAMES " + cs
}
// LastResultSet returns the last result that has columns (skipping SET NAMES OK packets).
func LastResultSet(resp *QueryResponse) (Result, bool) {
if resp == nil {
return Result{}, false
}
for i := len(resp.Results) - 1; i >= 0; i-- {
if resp.Results[i].IsResultSet() {
return resp.Results[i], true
}
}
if len(resp.Results) == 0 {
return Result{}, false
}
return resp.Results[len(resp.Results)-1], true
}
func RedactedURL(raw string) string {
return logger.RedactURL(raw)
}
+196
View File
@@ -0,0 +1,196 @@
package navicat
import (
"context"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"mysql-tunnel/internal/tunnel"
)
func testClient(t *testing.T, opts ...MockOption) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(NewMockHandler(opts...))
t.Cleanup(srv.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
c := NewClient(httpClient, ClientOptions{
URL: srv.URL + "?token=secret",
Target: Target{
Host: "mysql",
Port: 3306,
User: "root",
Password: "remote-pass",
Database: "app",
Charset: "utf8mb4",
},
})
return c, srv
}
func TestClientConnectAndSelect1(t *testing.T) {
var cap CapturedRequest
c, _ := testClient(t, WithToken("secret"), WithCapture(&cap))
ctx := context.Background()
conn, err := c.Connect(ctx)
if err != nil {
t.Fatal(err)
}
if conn.Conn.ServerInfo != "8.0.36-mock" {
t.Fatalf("%+v", conn.Conn)
}
if cap.Action != ActionConnect || cap.Host != "mysql" || cap.Login != "root" {
t.Fatalf("connect capture %+v", cap)
}
if cap.Port != "3306" {
t.Fatalf("port %s", cap.Port)
}
if !cap.HasPassword {
t.Fatal("password field missing")
}
q, err := c.Query(ctx, "", "SELECT 1")
if err != nil {
t.Fatal(err)
}
res, ok := LastResultSet(q)
if !ok || res.Rows[0][0].Text() != "1" {
t.Fatalf("select 1: %+v", q)
}
if cap.Action != ActionQuery {
t.Fatalf("query capture %+v", cap)
}
joined := strings.Join(cap.Queries, " | ")
if !strings.Contains(joined, "SET NAMES utf8mb4") {
t.Fatalf("expected SET NAMES in %q", joined)
}
if !strings.Contains(joined, "SELECT 1") {
t.Fatalf("expected SELECT 1 in %q", joined)
}
}
func TestClientTypesOverHTTP(t *testing.T) {
c, _ := testClient(t, WithToken("secret"))
ctx := context.Background()
q, err := c.Query(ctx, "app", "SELECT NULL AS n")
if err != nil {
t.Fatal(err)
}
res, _ := LastResultSet(q)
if !res.Rows[0][0].Null {
t.Fatal("NULL")
}
q, err = c.Query(ctx, "", "SELECT bigint_val")
if err != nil {
t.Fatal(err)
}
res, _ = LastResultSet(q)
got := res.Rows[0][0].Text()
if got != "18446744073709551615" {
t.Fatalf("bigint %q", got)
}
if _, err := strconv.ParseInt(got, 10, 64); err == nil {
t.Fatal("test value should overflow int64")
}
q, err = c.Query(ctx, "", "SELECT dec_val")
if err != nil {
t.Fatal(err)
}
res, _ = LastResultSet(q)
if res.Fields[0].Type != 246 || res.Rows[0][0].Text() != "1234567890.123456789" {
t.Fatalf("decimal %+v", res)
}
q, err = c.Query(ctx, "", "SELECT zh_val")
if err != nil {
t.Fatal(err)
}
res, _ = LastResultSet(q)
if res.Rows[0][0].Text() != "你好世界" {
t.Fatalf("zh %q", res.Rows[0][0].Text())
}
}
func TestClientSQLError(t *testing.T) {
c, _ := testClient(t, WithToken("secret"))
q, err := c.Query(context.Background(), "", "SELECT SYNTAX")
if err != nil {
t.Fatal(err)
}
res, ok := LastResultSet(q)
if !ok || !res.IsError() || res.Errno != 1064 {
t.Fatalf("%+v", q)
}
}
func TestClientDML(t *testing.T) {
c, _ := testClient(t, WithToken("secret"))
q, err := c.Query(context.Background(), "", "UPDATE product SET price=1")
if err != nil {
t.Fatal(err)
}
res := q.Results[len(q.Results)-1]
if res.IsResultSet() || res.AffectedRows != 1 || res.InsertID != 42 {
t.Fatalf("%+v", res)
}
}
func TestClientBadTokenPOST(t *testing.T) {
srv := httptest.NewServer(NewMockHandler(WithToken("right")))
t.Cleanup(srv.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
c := NewClient(httpClient, ClientOptions{
URL: srv.URL + "?token=wrong",
Target: Target{Host: "mysql", Port: 3306, User: "root"},
})
_, err := c.Connect(context.Background())
if err == nil {
t.Fatal("expected token error")
}
ne, ok := err.(*Error)
if !ok || ne.Errno != ErrnoInvalidToken {
t.Fatalf("%v", err)
}
}
func TestClientConnectDenied(t *testing.T) {
c, _ := testClient(t, WithToken("secret"), WithConnectError(1045, "Access denied for user 'root'@'localhost'"))
_, err := c.Connect(context.Background())
if err == nil {
t.Fatal("expected deny")
}
if !strings.Contains(err.Error(), "1045") {
t.Fatal(err)
}
if strings.Contains(err.Error(), "remote-pass") {
t.Fatal("password leaked")
}
}
func TestEncodeBase64Query(t *testing.T) {
var cap CapturedRequest
srv := httptest.NewServer(NewMockHandler(WithToken("secret"), WithCapture(&cap)))
t.Cleanup(srv.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
c := NewClient(httpClient, ClientOptions{
URL: srv.URL + "?token=secret",
Target: Target{Host: "mysql", Port: 3306, User: "root", Charset: "utf8"},
EncodeBase64: true,
})
_, err := c.Query(context.Background(), "", "SELECT 1")
if err != nil {
t.Fatal(err)
}
if cap.EncodeBase64 != "1" {
t.Fatalf("%+v", cap)
}
if strings.Contains(strings.Join(cap.Queries, ""), "SELECT 1") {
t.Fatalf("wire should be base64, got %v", cap.Queries)
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package navicat implements the Navicat HTTP Tunnel protocol used by
// ntunnel_mysql.php (script version 206 in this repository).
//
// Every request is stateless: the PHP script opens a new MySQL connection,
// runs actn=C or actn=Q, and disconnects. See docs/PROTOCOL.md.
package navicat
const (
Magic uint32 = 1111
Version202 uint16 = 202
Version206 uint16 = 206
HeaderSize = 16
ActionConnect = "C"
ActionQuery = "Q"
ErrnoUnsupportedPHP uint32 = 201
ErrnoInvalidParams uint32 = 202
ErrnoMySQLMissing uint32 = 203
ErrnoInvalidToken uint32 = 204
sepMore byte = 0x01
sepLast byte = 0x00
markLong byte = 0xFE
markNull byte = 0xFF
)
+45
View File
@@ -0,0 +1,45 @@
package navicat
import "fmt"
// Error is a tunnel-layer or remote MySQL error. It never includes passwords or tokens.
type Error struct {
Kind string // http, tunnel, mysql
HTTPStatus int
Errno uint32
Message string
}
func (e *Error) Error() string {
if e == nil {
return ""
}
msg := e.Message
if msg == "" {
msg = "unknown error"
}
switch e.Kind {
case "http":
if e.HTTPStatus != 0 {
return fmt.Sprintf("tunnel http %d: %s", e.HTTPStatus, msg)
}
return "tunnel http: " + msg
case "mysql":
return fmt.Sprintf("mysql errno %d: %s", e.Errno, msg)
default:
return fmtErrno(e.Errno, msg)
}
}
func headerError(h Header, httpStatus int) *Error {
kind := "tunnel"
switch h.Errno {
case ErrnoUnsupportedPHP, ErrnoInvalidParams, ErrnoMySQLMissing, ErrnoInvalidToken:
kind = "tunnel"
default:
if h.Errno > 0 {
kind = "mysql"
}
}
return &Error{Kind: kind, HTTPStatus: httpStatus, Errno: h.Errno, Message: h.Message}
}
+228
View File
@@ -0,0 +1,228 @@
package navicat
import (
"encoding/base64"
"net/http"
"strings"
)
// MockOption configures the in-process ntunnel_mysql.php stand-in.
type MockOption func(*mockState)
type mockState struct {
token string
connectFail *Error
queryFn func(sql string) Result
capture *CapturedRequest
}
// CapturedRequest is the last POST seen by the mock.
type CapturedRequest struct {
Action string
Host string
Port string
Login string
Database string
Queries []string
EncodeBase64 string
HasPassword bool
}
func WithToken(token string) MockOption {
return func(m *mockState) { m.token = token }
}
func WithConnectError(errno uint32, msg string) MockOption {
return func(m *mockState) {
m.connectFail = &Error{Kind: "mysql", Errno: errno, Message: msg}
}
}
func WithQueryHandler(fn func(sql string) Result) MockOption {
return func(m *mockState) { m.queryFn = fn }
}
func WithCapture(dst *CapturedRequest) MockOption {
return func(m *mockState) { m.capture = dst }
}
// NewMockHandler implements a subset of ntunnel_mysql.php for tests.
func NewMockHandler(opts ...MockOption) http.Handler {
st := &mockState{queryFn: defaultQuery}
for _, o := range opts {
o(st)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if st.token != "" && r.URL.Query().Get("token") != st.token {
if r.PostForm.Get("actn") != "" {
writeBytes(w, encodeHeaderError(ErrnoInvalidToken, "invalid access token"))
return
}
http.Error(w, "403 Forbidden: invalid or missing token", http.StatusForbidden)
return
}
if r.Method != http.MethodPost {
writeBytes(w, encodeHeaderError(ErrnoInvalidParams, "invalid parameters"))
return
}
actn := r.PostForm.Get("actn")
host := r.PostForm.Get("host")
login := r.PostForm.Get("login")
if actn == "" || host == "" || !r.PostForm.Has("port") || login == "" {
writeBytes(w, encodeHeaderError(ErrnoInvalidParams, "invalid parameters"))
return
}
if st.capture != nil {
*st.capture = CapturedRequest{
Action: actn,
Host: host,
Port: r.PostForm.Get("port"),
Login: login,
Database: r.PostForm.Get("db"),
Queries: append([]string(nil), r.PostForm["q[]"]...),
EncodeBase64: r.PostForm.Get("encodeBase64"),
HasPassword: r.PostForm.Has("password"),
}
}
if st.connectFail != nil {
writeBytes(w, encodeHeaderError(st.connectFail.Errno, st.connectFail.Message))
return
}
switch actn {
case ActionConnect:
writeBytes(w, encodeConnectOK("127.0.0.1 via TCP/IP", "10", "8.0.36-mock"))
case ActionQuery:
queries := append([]string(nil), r.PostForm["q[]"]...)
if r.PostForm.Get("encodeBase64") == "1" {
decoded := make([]string, 0, len(queries))
for _, q := range queries {
b, err := base64.StdEncoding.DecodeString(q)
if err != nil {
writeBytes(w, encodeQueryResults([]Result{{Errno: 1064, Message: "bad base64 query"}}))
return
}
decoded = append(decoded, string(b))
}
queries = decoded
}
var results []Result
for _, q := range queries {
if q == "" {
continue
}
results = append(results, st.queryFn(q))
}
writeBytes(w, encodeQueryResults(results))
default:
writeBytes(w, AppendHeader(nil, Version206, 0))
}
})
}
func defaultQuery(sql string) Result {
s := strings.TrimSpace(sql)
u := strings.ToUpper(s)
switch {
case strings.HasPrefix(u, "SET NAMES"):
return Result{}
case u == "SELECT 1" || strings.HasPrefix(u, "SELECT 1 "):
return selectLiteral("1", "1", 8, 20)
case strings.Contains(u, "SELECT NULL"):
return Result{
NumFields: 1,
NumRows: 1,
Fields: []Field{{Name: "n", Type: 6, Length: 0}},
Rows: [][]Cell{{{Null: true}}},
}
case strings.Contains(strings.ToLower(s), "bigint_val"):
r := selectLiteral("bigint_val", "18446744073709551615", 8, 20)
r.Fields[0].Flags = 32 // UNSIGNED
return r
case strings.Contains(strings.ToLower(s), "dec_val"):
return selectLiteral("dec_val", "1234567890.123456789", 246, 32)
case strings.Contains(s, "你好") || strings.Contains(strings.ToLower(s), "zh_val"):
return selectLiteral("zh_val", "你好世界", 253, 12)
case strings.HasPrefix(u, "INSERT") || strings.HasPrefix(u, "UPDATE") || strings.HasPrefix(u, "DELETE") || strings.HasPrefix(u, "REPLACE"):
return Result{AffectedRows: 1, InsertID: 42, Info: "Rows matched: 1 Changed: 1 Warnings: 0"}
case strings.Contains(u, "SYNTAX"):
return Result{Errno: 1064, Message: "You have an error in your SQL syntax"}
default:
return Result{Errno: 1064, Message: "mock: unhandled SQL"}
}
}
func selectLiteral(name, value string, typ, length uint32) Result {
return Result{
NumFields: 1,
NumRows: 1,
Fields: []Field{{Name: name, Type: typ, Length: length}},
Rows: [][]Cell{{{Bytes: []byte(value)}}},
}
}
func encodeHeaderError(errno uint32, msg string) []byte {
b := AppendHeader(nil, Version206, errno)
return AppendBlock(b, []byte(msg))
}
func encodeConnectOK(host, proto, server string) []byte {
b := AppendHeader(nil, Version206, 0)
b = AppendBlock(b, []byte(host))
b = AppendBlock(b, []byte(proto))
b = AppendBlock(b, []byte(server))
return b
}
func encodeQueryResults(results []Result) []byte {
b := AppendHeader(nil, Version206, 0)
if len(results) == 0 {
return b
}
for i, res := range results {
b = appendQueryResult(b, res)
if i < len(results)-1 {
b = append(b, sepMore)
} else {
b = append(b, sepLast)
}
}
return b
}
func appendQueryResult(b []byte, res Result) []byte {
nfields := res.NumFields
nrows := res.NumRows
if nfields == 0 {
nfields = uint32(len(res.Fields))
}
if nrows == 0 && len(res.Rows) > 0 {
nrows = uint32(len(res.Rows))
}
b = AppendResultSetHeader(b, res.Errno, res.AffectedRows, res.InsertID, nfields, nrows)
if res.Errno > 0 {
return AppendBlock(b, []byte(res.Message))
}
if nfields == 0 {
return AppendBlock(b, []byte(res.Info))
}
for _, f := range res.Fields {
b = AppendField(b, f.Name, f.Table, f.Type, f.Flags, f.Length)
}
for _, row := range res.Rows {
for _, cell := range row {
if cell.Null {
b = AppendNull(b)
} else {
b = AppendBlock(b, cell.Bytes)
}
}
}
return b
}
func writeBytes(w http.ResponseWriter, body []byte) {
w.Header().Set("Content-Type", "text/plain; charset=x-user-defined")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
+116
View File
@@ -0,0 +1,116 @@
package navicat
import (
"encoding/binary"
"fmt"
"io"
)
type reader struct {
buf []byte
off int
}
func newReader(b []byte) *reader {
return &reader{buf: b}
}
func (r *reader) remaining() int {
return len(r.buf) - r.off
}
func (r *reader) peek() (byte, error) {
if r.off >= len(r.buf) {
return 0, io.EOF
}
return r.buf[r.off], nil
}
func (r *reader) readByte() (byte, error) {
if r.off >= len(r.buf) {
return 0, io.ErrUnexpectedEOF
}
b := r.buf[r.off]
r.off++
return b, nil
}
func (r *reader) readN(n int) ([]byte, error) {
if n < 0 {
return nil, fmt.Errorf("negative length")
}
if r.off+n > len(r.buf) {
return nil, io.ErrUnexpectedEOF
}
s := r.buf[r.off : r.off+n]
r.off += n
return s, nil
}
func (r *reader) u16() (uint16, error) {
b, err := r.readN(2)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint16(b), nil
}
func (r *reader) u32() (uint32, error) {
b, err := r.readN(4)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint32(b), nil
}
// block reads GetBlock. nullable allows 0xFF as SQL NULL (EchoData only).
func (r *reader) block(nullable bool) (val []byte, isNull bool, err error) {
if r.off >= len(r.buf) {
return nil, false, io.ErrUnexpectedEOF
}
first := r.buf[r.off]
if nullable && first == markNull {
r.off++
return nil, true, nil
}
r.off++
var n int
if first < 254 {
n = int(first)
} else if first == markLong {
ln, err := r.u32()
if err != nil {
return nil, false, err
}
if ln > 64<<20 {
return nil, false, fmt.Errorf("block length %d exceeds 64MiB limit", ln)
}
n = int(ln)
} else {
return nil, false, fmt.Errorf("invalid block marker 0x%02x", first)
}
b, err := r.readN(n)
if err != nil {
return nil, false, err
}
return b, false, nil
}
func (r *reader) blockBytes() ([]byte, error) {
b, isNull, err := r.block(false)
if err != nil {
return nil, err
}
if isNull {
return nil, fmt.Errorf("unexpected NULL block")
}
return b, nil
}
func (r *reader) blockString() (string, error) {
b, err := r.blockBytes()
if err != nil {
return "", err
}
return string(b), nil
}
+205
View File
@@ -0,0 +1,205 @@
package navicat
import (
"bytes"
"strings"
"testing"
)
func TestBlockRoundTripShortAndLong(t *testing.T) {
cases := [][]byte{
{},
[]byte("a"),
bytes.Repeat([]byte("x"), 253),
bytes.Repeat([]byte("y"), 254),
bytes.Repeat([]byte("z"), 1024),
[]byte("你好世界"),
}
for _, in := range cases {
enc := AppendBlock(nil, in)
r := newReader(enc)
got, isNull, err := r.block(false)
if err != nil {
t.Fatalf("len=%d: %v", len(in), err)
}
if isNull {
t.Fatal("null")
}
if !bytes.Equal(got, in) {
t.Fatalf("mismatch len=%d", len(in))
}
}
}
func TestNullCell(t *testing.T) {
enc := AppendNull(nil)
r := newReader(enc)
_, isNull, err := r.block(true)
if err != nil || !isNull {
t.Fatalf("err=%v null=%v", err, isNull)
}
}
func TestHeader202And206(t *testing.T) {
for _, ver := range []uint16{Version202, Version206} {
body := AppendHeader(nil, ver, 0)
h, _, err := ParseHeader(body)
if err != nil {
t.Fatal(err)
}
if h.Version != ver || !h.OK() {
t.Fatalf("%+v", h)
}
}
}
func TestHeaderError204(t *testing.T) {
body := encodeHeaderError(ErrnoInvalidToken, "invalid access token")
h, _, err := ParseHeader(body)
if err != nil {
t.Fatal(err)
}
if h.Errno != ErrnoInvalidToken || h.Message != "invalid access token" {
t.Fatalf("%+v", h)
}
}
func TestConnectParse(t *testing.T) {
body := encodeConnectOK("127.0.0.1 via TCP/IP", "10", "8.0.36")
resp, err := ParseConnect(body)
if err != nil {
t.Fatal(err)
}
if resp.Conn.ProtoInfo != "10" || resp.Conn.ServerInfo != "8.0.36" {
t.Fatalf("%+v", resp.Conn)
}
}
func TestQuerySelect1(t *testing.T) {
body := encodeQueryResults([]Result{selectLiteral("1", "1", 8, 20)})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
if len(resp.Results) != 1 {
t.Fatalf("results=%d", len(resp.Results))
}
r := resp.Results[0]
if !r.IsResultSet() || r.NumRows != 1 {
t.Fatalf("%+v", r)
}
if r.Rows[0][0].Null || r.Rows[0][0].Text() != "1" {
t.Fatalf("cell=%+v", r.Rows[0][0])
}
}
func TestNullDecimalBigintUTF8(t *testing.T) {
body := encodeQueryResults([]Result{
{
NumFields: 4,
NumRows: 1,
Fields: []Field{
{Name: "n", Type: 6},
{Name: "b", Type: 8, Length: 20},
{Name: "d", Type: 246, Length: 32},
{Name: "z", Type: 253, Length: 12},
},
Rows: [][]Cell{{
{Null: true},
{Bytes: []byte("18446744073709551615")},
{Bytes: []byte("1234567890.123456789")},
{Bytes: []byte("你好世界")},
}},
},
})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
row := resp.Results[0].Rows[0]
if !row[0].Null {
t.Fatal("expected NULL")
}
if row[1].Text() != "18446744073709551615" {
t.Fatalf("bigint parsed badly: %q", row[1].Text())
}
if row[2].Text() != "1234567890.123456789" {
t.Fatalf("decimal parsed badly: %q", row[2].Text())
}
if row[3].Text() != "你好世界" {
t.Fatalf("utf8: %q", row[3].Text())
}
}
func TestSQLError(t *testing.T) {
body := encodeQueryResults([]Result{{Errno: 1064, Message: "You have an error in your SQL syntax"}})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
if !resp.Results[0].IsError() || !strings.Contains(resp.Results[0].Message, "syntax") {
t.Fatalf("%+v", resp.Results[0])
}
}
func TestDMLOk(t *testing.T) {
body := encodeQueryResults([]Result{{AffectedRows: 3, InsertID: 9, Info: "Rows matched: 3"}})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
r := resp.Results[0]
if r.IsResultSet() || r.AffectedRows != 3 || r.InsertID != 9 {
t.Fatalf("%+v", r)
}
}
func TestHTMLRejected(t *testing.T) {
_, err := ParseConnect([]byte("<html>nope</html>"))
if err == nil {
t.Fatal("expected error")
}
}
func TestRequestOmitsEmptyQueriesAndEncodesArray(t *testing.T) {
enc := Request{
Action: ActionQuery,
Host: "mysql",
Port: 3306,
Login: "root",
Queries: []string{"SELECT 1", "", "SELECT 2"},
}.Encode()
if strings.Contains(enc, "password=secret") {
t.Fatal("unexpected")
}
if !strings.Contains(enc, "q%5B%5D=SELECT+1") && !strings.Contains(enc, "q%5B%5D=SELECT%201") {
t.Fatalf("q[] missing: %s", enc)
}
if strings.Count(enc, "q%5B%5D=") != 2 {
t.Fatalf("empty query not skipped: %s", enc)
}
}
func TestRequestBase64(t *testing.T) {
enc := Request{
Action: ActionQuery,
Host: "mysql",
Port: 3306,
Login: "root",
Queries: []string{"SELECT 1"},
EncodeBase64: true,
}.Encode()
if !strings.Contains(enc, "encodeBase64=1") {
t.Fatal(enc)
}
if strings.Contains(enc, "SELECT+1") {
t.Fatalf("sql should be base64: %s", enc)
}
}
func TestErrorDoesNotEmbedPassword(t *testing.T) {
e := &Error{Kind: "mysql", Errno: 1045, Message: "Access denied for user 'root'@'localhost'"}
if strings.Contains(e.Error(), "password") && strings.Contains(e.Error(), "=") {
t.Fatal(e.Error())
}
}
+63
View File
@@ -0,0 +1,63 @@
package navicat
import (
"encoding/base64"
"net/url"
"strconv"
"strings"
)
// Request is one Navicat HTTP Tunnel POST body.
type Request struct {
Action string
Host string
Port int
Login string
Password string
Database string
Queries []string
EncodeBase64 bool
}
// Form builds application/x-www-form-urlencoded fields matching $_POST in ntunnel_mysql.php.
func (r Request) Form() url.Values {
v := url.Values{}
v.Set("actn", r.Action)
v.Set("host", r.Host)
if r.Port > 0 {
v.Set("port", strconv.Itoa(r.Port))
} else {
v.Set("port", "")
}
v.Set("login", r.Login)
v.Set("password", r.Password)
v.Set("db", r.Database)
queries := nonEmpty(r.Queries)
if r.EncodeBase64 {
v.Set("encodeBase64", "1")
for _, q := range queries {
v.Add("q[]", base64.StdEncoding.EncodeToString([]byte(q)))
}
} else {
for _, q := range queries {
v.Add("q[]", q)
}
}
return v
}
func (r Request) Encode() string {
return r.Form().Encode()
}
func nonEmpty(in []string) []string {
out := make([]string, 0, len(in))
for _, s := range in {
if strings.TrimSpace(s) == "" {
continue
}
out = append(out, s)
}
return out
}
+266
View File
@@ -0,0 +1,266 @@
package navicat
import (
"fmt"
"strings"
)
// Header is EchoHeader from ntunnel_mysql.php (16 bytes).
type Header struct {
Magic uint32
Version uint16
Errno uint32
Message string
UncertainVersion bool
}
func (h Header) OK() bool { return h.Errno == 0 }
// ConnInfo is actn=C payload after a successful header.
type ConnInfo struct {
HostInfo string
ProtoInfo string
ServerInfo string
}
// Field is one EchoFieldsHeader column. Type/flags/length are mysqli numeric values.
type Field struct {
Name string
Table string
Type uint32
Flags uint32
Length uint32
}
// Cell is one row field. Bytes are kept as the PHP string; never parsed as int64/float64.
type Cell struct {
Null bool
Bytes []byte
}
func (c Cell) Text() string {
if c.Null {
return ""
}
return string(c.Bytes)
}
// Result is one query in an actn=Q response.
type Result struct {
Errno uint32
Message string
AffectedRows uint32
InsertID uint32 // protocol is 32-bit; do not invent high bits
NumFields uint32
NumRows uint32
Info string
Fields []Field
Rows [][]Cell
}
func (r Result) IsError() bool { return r.Errno > 0 }
func (r Result) IsResultSet() bool {
return r.Errno == 0 && r.NumFields > 0
}
// ConnectResponse is a parsed actn=C reply.
type ConnectResponse struct {
Header Header
Conn ConnInfo
}
// QueryResponse is a parsed actn=Q reply.
type QueryResponse struct {
Header Header
Results []Result
}
func ParseHeader(body []byte) (Header, *reader, error) {
var h Header
if len(body) < HeaderSize {
if looksHTML(body) {
return h, nil, &Error{Kind: "http", Message: "tunnel returned HTML, not Navicat binary"}
}
return h, nil, fmt.Errorf("response too short (%d bytes)", len(body))
}
r := newReader(body)
magic, err := r.u32()
if err != nil {
return h, nil, err
}
if magic != Magic {
if looksHTML(body) {
return h, nil, &Error{Kind: "http", Message: "tunnel returned HTML, not Navicat binary"}
}
return h, nil, fmt.Errorf("invalid Navicat magic %d (want %d)", magic, Magic)
}
ver, err := r.u16()
if err != nil {
return h, nil, err
}
errno, err := r.u32()
if err != nil {
return h, nil, err
}
if _, err := r.readN(6); err != nil {
return h, nil, err
}
h.Magic = magic
h.Version = ver
h.Errno = errno
h.UncertainVersion = !knownVersion(ver)
if errno != 0 {
msg, err := r.blockString()
if err != nil {
return h, nil, fmt.Errorf("read error message: %w", err)
}
h.Message = msg
}
return h, r, nil
}
func ParseConnect(body []byte) (*ConnectResponse, error) {
h, r, err := ParseHeader(body)
if err != nil {
return nil, err
}
out := &ConnectResponse{Header: h}
if !h.OK() {
return out, nil
}
host, err := r.blockString()
if err != nil {
return nil, fmt.Errorf("conn host_info: %w", err)
}
proto, err := r.blockString()
if err != nil {
return nil, fmt.Errorf("conn proto_info: %w", err)
}
server, err := r.blockString()
if err != nil {
return nil, fmt.Errorf("conn server_info: %w", err)
}
out.Conn = ConnInfo{HostInfo: host, ProtoInfo: proto, ServerInfo: server}
return out, nil
}
func ParseQuery(body []byte) (*QueryResponse, error) {
h, r, err := ParseHeader(body)
if err != nil {
return nil, err
}
out := &QueryResponse{Header: h}
if !h.OK() {
return out, nil
}
for {
if r.remaining() == 0 {
break
}
res, err := parseOneResult(r)
if err != nil {
return nil, err
}
out.Results = append(out.Results, res)
if r.remaining() == 0 {
break
}
sep, err := r.readByte()
if err != nil {
return nil, err
}
if sep == sepLast {
break
}
if sep != sepMore {
return nil, fmt.Errorf("invalid query separator 0x%02x", sep)
}
}
return out, nil
}
func parseOneResult(r *reader) (Result, error) {
var res Result
var err error
if res.Errno, err = r.u32(); err != nil {
return res, err
}
if res.AffectedRows, err = r.u32(); err != nil {
return res, err
}
if res.InsertID, err = r.u32(); err != nil {
return res, err
}
if res.NumFields, err = r.u32(); err != nil {
return res, err
}
if res.NumRows, err = r.u32(); err != nil {
return res, err
}
if _, err = r.readN(12); err != nil {
return res, err
}
if res.Errno > 0 {
res.Message, err = r.blockString()
return res, err
}
if res.NumFields == 0 {
res.Info, err = r.blockString()
return res, err
}
res.Fields = make([]Field, 0, res.NumFields)
for i := uint32(0); i < res.NumFields; i++ {
f, err := parseField(r)
if err != nil {
return res, fmt.Errorf("field %d: %w", i, err)
}
res.Fields = append(res.Fields, f)
}
res.Rows = make([][]Cell, 0, res.NumRows)
for i := uint32(0); i < res.NumRows; i++ {
row := make([]Cell, res.NumFields)
for j := uint32(0); j < res.NumFields; j++ {
val, isNull, err := r.block(true)
if err != nil {
return res, fmt.Errorf("row %d col %d: %w", i, j, err)
}
row[j] = Cell{Null: isNull, Bytes: val}
}
res.Rows = append(res.Rows, row)
}
return res, nil
}
func parseField(r *reader) (Field, error) {
var f Field
var err error
if f.Name, err = r.blockString(); err != nil {
return f, err
}
if f.Table, err = r.blockString(); err != nil {
return f, err
}
if f.Type, err = r.u32(); err != nil {
return f, err
}
if f.Flags, err = r.u32(); err != nil {
return f, err
}
if f.Length, err = r.u32(); err != nil {
return f, err
}
return f, nil
}
func looksHTML(body []byte) bool {
s := strings.TrimSpace(string(body))
if len(s) < 5 {
return false
}
n := 64
if len(s) < n {
n = len(s)
}
ls := strings.ToLower(s[:n])
return strings.HasPrefix(ls, "<!doctype") || strings.HasPrefix(ls, "<html")
}
+131
View File
@@ -0,0 +1,131 @@
package tunnel
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"mysql-tunnel/internal/logger"
)
// HTTPClient is a generic HTTP client for the tunnel endpoint.
// Navicat request/response encoding belongs in package navicat (Phase 2).
type HTTPClient struct {
base *http.Client
insecure bool
userAgent string
}
type Options struct {
Timeout time.Duration
TLSInsecureSkipVerify bool
}
func NewHTTPClient(opt Options) *HTTPClient {
if opt.Timeout <= 0 {
opt.Timeout = 30 * time.Second
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 8,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
if opt.TLSInsecureSkipVerify {
transport.TLSClientConfig.InsecureSkipVerify = true
}
return &HTTPClient{
base: &http.Client{
Timeout: opt.Timeout,
Transport: transport,
},
insecure: opt.TLSInsecureSkipVerify,
userAgent: "mysql-tunnel/0.1",
}
}
type Response struct {
StatusCode int
ContentType string
Body []byte
}
func (c *HTTPClient) Do(ctx context.Context, method, rawURL string, contentType string, body io.Reader) (*Response, error) {
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return nil, fmt.Errorf("build request: %w", redactErr(err))
}
req.Header.Set("User-Agent", c.userAgent)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := c.base.Do(req)
if err != nil {
return nil, fmt.Errorf("http %s %s: %w", method, logger.RedactURL(rawURL), redactErr(err))
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, fmt.Errorf("read response from %s: %w", logger.RedactURL(rawURL), err)
}
return &Response{
StatusCode: resp.StatusCode,
ContentType: resp.Header.Get("Content-Type"),
Body: data,
}, nil
}
func (c *HTTPClient) InsecureTLS() bool {
return c.insecure
}
func redactErr(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s", logger.RedactError(err))
}
func LooksLikeNavicatHeader(body []byte) bool {
if len(body) < 16 {
return false
}
magic := uint32(body[0])<<24 | uint32(body[1])<<16 | uint32(body[2])<<8 | uint32(body[3])
return magic == 1111
}
func HeaderErrno(body []byte) (uint32, bool) {
if len(body) < 10 {
return 0, false
}
errno := uint32(body[6])<<24 | uint32(body[7])<<16 | uint32(body[8])<<8 | uint32(body[9])
return errno, true
}
func IsProbablyHTML(body []byte) bool {
s := strings.TrimSpace(string(body))
if len(s) < 5 {
return false
}
ls := strings.ToLower(s[:min(64, len(s))])
return strings.HasPrefix(ls, "<!doctype") || strings.HasPrefix(ls, "<html")
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+88
View File
@@ -0,0 +1,88 @@
package tunnel
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestLooksLikeNavicatHeader(t *testing.T) {
body := make([]byte, 16)
body[2] = 0x04
body[3] = 0x57 // 1111
body[5] = 206
if !LooksLikeNavicatHeader(body) {
t.Fatal("expected magic 1111")
}
body[3] = 0x00
if LooksLikeNavicatHeader(body) {
t.Fatal("false positive")
}
}
func TestHeaderErrno(t *testing.T) {
body := make([]byte, 16)
body[9] = 202
errno, ok := HeaderErrno(body)
if !ok || errno != 202 {
t.Fatalf("errno=%d ok=%v", errno, ok)
}
}
func TestHTTPClientPOST(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("ct %s", r.Header.Get("Content-Type"))
}
b, _ := io.ReadAll(r.Body)
if strings.Contains(string(b), "actn=C") == false {
t.Errorf("body %s", b)
}
w.Header().Set("Content-Type", "text/plain; charset=x-user-defined")
hdr := make([]byte, 16)
hdr[2] = 0x04
hdr[3] = 0x57
hdr[5] = 206
hdr[9] = 202
_, _ = w.Write(hdr)
_, _ = w.Write([]byte{0x12})
_, _ = w.Write([]byte("invalid parameters"))
}))
defer srv.Close()
c := NewHTTPClient(Options{Timeout: 5 * time.Second})
resp, err := c.Do(context.Background(), http.MethodPost, srv.URL, "application/x-www-form-urlencoded", strings.NewReader("actn=C&host=mysql&port=3306&login=root"))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
if !LooksLikeNavicatHeader(resp.Body) {
t.Fatal("header")
}
errno, _ := HeaderErrno(resp.Body)
if errno != 202 {
t.Fatalf("errno %d", errno)
}
}
func TestTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
w.WriteHeader(200)
}))
defer srv.Close()
c := NewHTTPClient(Options{Timeout: 50 * time.Millisecond})
_, err := c.Do(context.Background(), http.MethodGet, srv.URL, "", nil)
if err == nil {
t.Fatal("expected timeout")
}
}
+7
View File
@@ -0,0 +1,7 @@
package winsvc
const (
Name = "mysql-tunnel"
DisplayName = "MySQL Tunnel"
Description = "Local MySQL proxy that translates MySQL protocol to Navicat HTTP Tunnel for Yingdao RPA."
)
+40
View File
@@ -0,0 +1,40 @@
//go:build !windows
package winsvc
import (
"context"
"fmt"
"os"
"path/filepath"
)
func IsService() bool { return false }
func Run(func(ctx context.Context) error) error {
return fmt.Errorf("Windows service is only supported on Windows")
}
func Install(string, string) error { return fmt.Errorf("Windows service is only supported on Windows") }
func Uninstall() error { return fmt.Errorf("Windows service is only supported on Windows") }
func Start() error { return fmt.Errorf("Windows service is only supported on Windows") }
func Stop() error { return fmt.Errorf("Windows service is only supported on Windows") }
func ExePath() (string, error) {
p, err := os.Executable()
if err != nil {
return "", err
}
return filepath.EvalSymlinks(p)
}
func CopyFile(src, dst string) error {
in, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
return os.WriteFile(dst, in, 0o600)
}
+212
View File
@@ -0,0 +1,212 @@
//go:build windows
package winsvc
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
func IsService() bool {
ok, err := svc.IsWindowsService()
return err == nil && ok
}
func Run(run func(ctx context.Context) error) error {
return svc.Run(Name, &handler{run: run})
}
type handler struct {
run func(ctx context.Context) error
}
func (h *handler) Execute(_ []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
changes <- svc.Status{State: svc.StartPending}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- h.run(ctx)
}()
select {
case err := <-errCh:
if err != nil {
return true, 1
}
return false, 0
case <-time.After(800 * time.Millisecond):
changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
}
for {
select {
case err := <-errCh:
if err != nil {
return true, 1
}
return false, 0
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
changes <- svc.Status{State: svc.StopPending}
cancel()
select {
case <-errCh:
case <-time.After(8 * time.Second):
}
return false, 0
default:
changes <- c.CurrentStatus
}
}
}
}
func Install(exePath, configPath string) error {
m, err := mgr.Connect()
if err != nil {
return wrapAccess(err, "connect to service manager")
}
defer m.Disconnect()
if s, err := m.OpenService(Name); err == nil {
s.Close()
return fmt.Errorf("service %s already exists; run --uninstall first", Name)
}
s, err := m.CreateService(Name, exePath, mgr.Config{
DisplayName: DisplayName,
Description: Description,
StartType: mgr.StartAutomatic,
ErrorControl: mgr.ErrorNormal,
}, "--config", configPath)
if err != nil {
return wrapAccess(err, "create service")
}
defer s.Close()
return nil
}
func Uninstall() error {
m, err := mgr.Connect()
if err != nil {
return wrapAccess(err, "connect to service manager")
}
defer m.Disconnect()
s, err := m.OpenService(Name)
if err != nil {
return fmt.Errorf("service %s not installed", Name)
}
defer s.Close()
st, err := s.Query()
if err == nil && st.State != svc.Stopped {
_ = control(s, svc.Stop, svc.Stopped)
}
if err := s.Delete(); err != nil {
return wrapAccess(err, "delete service")
}
return nil
}
func Start() error {
m, err := mgr.Connect()
if err != nil {
return wrapAccess(err, "connect to service manager")
}
defer m.Disconnect()
s, err := m.OpenService(Name)
if err != nil {
return fmt.Errorf("service %s not installed", Name)
}
defer s.Close()
if err := s.Start(); err != nil {
if err == windows.ERROR_SERVICE_ALREADY_RUNNING {
return nil
}
return wrapAccess(err, "start service")
}
return nil
}
func Stop() error {
m, err := mgr.Connect()
if err != nil {
return wrapAccess(err, "connect to service manager")
}
defer m.Disconnect()
s, err := m.OpenService(Name)
if err != nil {
return fmt.Errorf("service %s not installed", Name)
}
defer s.Close()
st, err := s.Query()
if err != nil {
return err
}
if st.State == svc.Stopped {
return nil
}
return control(s, svc.Stop, svc.Stopped)
}
func control(s *mgr.Service, c svc.Cmd, to svc.State) error {
status, err := s.Control(c)
if err != nil {
return err
}
deadline := time.Now().Add(10 * time.Second)
for status.State != to {
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for service state %v", to)
}
time.Sleep(300 * time.Millisecond)
status, err = s.Query()
if err != nil {
return err
}
}
return nil
}
func wrapAccess(err error, op string) error {
if err == nil {
return nil
}
if err == windows.ERROR_ACCESS_DENIED {
return fmt.Errorf("%s: access denied; run as Administrator", op)
}
return fmt.Errorf("%s: %w", op, err)
}
func ExePath() (string, error) {
p, err := os.Executable()
if err != nil {
return "", err
}
if r, err := filepath.EvalSymlinks(p); err == nil {
return r, nil
}
return p, nil
}
func CopyFile(src, dst string) error {
in, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
return os.WriteFile(dst, in, 0o600)
}
+635
View File
@@ -0,0 +1,635 @@
#!/usr/bin/env python3
"""Same usage as 影刀 RPA:
import pymysql
conn = pymysql.connect(
host="127.0.0.1",
port=13306,
user="root",
password="123456",
db="your_database",
)
Values come from config.yaml: listen + local_auth + remote.database.
mysql-tunnel.exe must already be running.
python tests/http_tunnel.py
python tests/http_tunnel.py --sql "SHOW TABLES"
"""
from __future__ import annotations
import argparse
import base64
import ssl
import struct
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from urllib.request import Request, urlopen
MAGIC = 1111
MARK_LONG = 0xFE
MARK_NULL = 0xFF
SEP_MORE = 0x01
SEP_LAST = 0x00
MAX_BLOCK = 64 << 20
ROOT = Path(__file__).resolve().parents[1]
class TunnelError(Exception):
pass
def parse_scalar(raw: str) -> Any:
v = raw.strip()
if v == "":
return ""
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
return v[1:-1]
low = v.lower()
if low in ("true", "yes"):
return True
if low in ("false", "no"):
return False
try:
return int(v)
except ValueError:
return v
def load_project_yaml(path: Path) -> dict[str, Any]:
root: dict[str, Any] = {}
section: str | None = None
text = path.read_text(encoding="utf-8")
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
indent = len(line) - len(line.lstrip(" \t"))
if indent == 0 and stripped.endswith(":") and ":" not in stripped[:-1]:
section = stripped[:-1]
root[section] = {}
continue
if section is None or ":" not in stripped:
continue
key, _, val = stripped.partition(":")
root[section][key.strip()] = parse_scalar(val)
return root
def redact_url(raw: str) -> str:
u = urlparse(raw)
q = []
for k, v in parse_qsl(u.query, keep_blank_values=True):
if k.lower() in ("token", "password", "pass", "access_token", "accesstoken"):
q.append((k, "***"))
else:
q.append((k, v))
return urlunparse(u._replace(query=urlencode(q), fragment=""))
def looks_html(body: bytes) -> bool:
s = body.lstrip()[:64].decode("latin-1", errors="replace").lower()
return s.startswith("<!doctype") or s.startswith("<html")
@dataclass
class Header:
magic: int
version: int
errno: int
message: str = ""
@property
def ok(self) -> bool:
return self.errno == 0
@dataclass
class Field:
name: str
table: str
type: int = 0
flags: int = 0
length: int = 0
@dataclass
class Result:
errno: int = 0
message: str = ""
affected_rows: int = 0
insert_id: int = 0
num_fields: int = 0
num_rows: int = 0
info: str = ""
fields: list[Field] = field(default_factory=list)
rows: list[list[str | None]] = field(default_factory=list)
@property
def is_error(self) -> bool:
return self.errno > 0
@property
def is_result_set(self) -> bool:
return self.errno == 0 and self.num_fields > 0
class Reader:
def __init__(self, buf: bytes) -> None:
self.buf = buf
self.off = 0
def remaining(self) -> int:
return len(self.buf) - self.off
def read(self, n: int) -> bytes:
if n < 0 or self.off + n > len(self.buf):
raise TunnelError("truncated Navicat binary")
s = self.buf[self.off : self.off + n]
self.off += n
return s
def u8(self) -> int:
return self.read(1)[0]
def u16(self) -> int:
return struct.unpack(">H", self.read(2))[0]
def u32(self) -> int:
return struct.unpack(">I", self.read(4))[0]
def block(self, nullable: bool = False) -> tuple[bytes | None, bool]:
if self.remaining() == 0:
raise TunnelError("truncated GetBlock")
first = self.buf[self.off]
if nullable and first == MARK_NULL:
self.off += 1
return None, True
self.off += 1
if first < 254:
n = first
elif first == MARK_LONG:
n = self.u32()
if n > MAX_BLOCK:
raise TunnelError(f"block length {n} exceeds 64MiB")
else:
raise TunnelError(f"invalid block marker 0x{first:02x}")
return self.read(n), False
def block_str(self) -> str:
val, is_null = self.block(False)
if is_null or val is None:
raise TunnelError("unexpected NULL block")
return val.decode("utf-8", errors="replace")
def parse_header(body: bytes) -> tuple[Header, Reader]:
if looks_html(body):
raise TunnelError("tunnel returned HTML, not Navicat binary")
if len(body) < 16:
raise TunnelError(f"response too short ({len(body)} bytes)")
r = Reader(body)
magic = r.u32()
if magic != MAGIC:
raise TunnelError(f"invalid Navicat magic {magic} (want {MAGIC})")
version = r.u16()
errno = r.u32()
r.read(6)
msg = ""
if errno != 0:
msg = r.block_str()
return Header(magic, version, errno, msg), r
def parse_connect(body: bytes) -> tuple[Header, dict[str, str]]:
h, r = parse_header(body)
if not h.ok:
return h, {}
return h, {
"host_info": r.block_str(),
"proto_info": r.block_str(),
"server_info": r.block_str(),
}
def parse_one_result(r: Reader) -> Result:
res = Result(
errno=r.u32(),
affected_rows=r.u32(),
insert_id=r.u32(),
num_fields=r.u32(),
num_rows=r.u32(),
)
r.read(12)
if res.errno > 0:
res.message = r.block_str()
return res
if res.num_fields == 0:
res.info = r.block_str()
return res
for _ in range(res.num_fields):
res.fields.append(Field(r.block_str(), r.block_str(), r.u32(), r.u32(), r.u32()))
for _ in range(res.num_rows):
row: list[str | None] = []
for _c in range(res.num_fields):
val, is_null = r.block(True)
row.append(None if is_null else (val or b"").decode("utf-8", errors="replace"))
res.rows.append(row)
return res
def parse_query(body: bytes) -> tuple[Header, list[Result]]:
h, r = parse_header(body)
if not h.ok:
return h, []
results: list[Result] = []
while r.remaining() > 0:
results.append(parse_one_result(r))
if r.remaining() == 0:
break
sep = r.u8()
if sep == SEP_LAST:
break
if sep != SEP_MORE:
raise TunnelError(f"invalid query separator 0x{sep:02x}")
return h, results
@dataclass
class Config:
path: Path
listen_host: str
listen_port: int
local_user: str
local_password: str
url: str
encode_base64: bool
tls_insecure: bool
remote_host: str
remote_port: int
remote_user: str
remote_password: str
database: str
charset: str
timeout: int
@classmethod
def load(cls, path: Path) -> Config:
data = load_project_yaml(path)
listen = data.get("listen") or {}
local = data.get("local_auth") or {}
tunnel = data.get("tunnel") or {}
remote = data.get("remote") or {}
options = data.get("options") or {}
url = str(tunnel.get("url") or "").strip()
if not url:
raise TunnelError(f"{path}: tunnel.url is required")
local_user = str(local.get("username") or "").strip()
if not local_user:
raise TunnelError(f"{path}: local_auth.username is required")
timeout = int(options.get("request_timeout") or 30)
if timeout <= 0:
timeout = 30
return cls(
path=path,
listen_host=str(listen.get("host") or "127.0.0.1"),
listen_port=int(listen.get("port") or 13306),
local_user=local_user,
local_password=str(local.get("password") or ""),
url=url,
encode_base64=bool(tunnel.get("encode_base64")),
tls_insecure=bool(tunnel.get("tls_insecure_skip_verify")),
remote_host=str(remote.get("host") or ""),
remote_port=int(remote.get("port") or 3306),
remote_user=str(remote.get("username") or ""),
remote_password=str(remote.get("password") or ""),
database=str(remote.get("database") or ""),
charset=str(remote.get("charset") or "utf8mb4"),
timeout=timeout,
)
class HTTPClient:
"""POST actn=C/Q to ntunnel_mysql.php using remote.* (not local_auth)."""
def __init__(self, cfg: Config) -> None:
self.cfg = cfg
ctx = ssl.create_default_context()
if cfg.tls_insecure:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
self._ssl = ctx
def post(self, actn: str, queries: list[str] | None = None) -> bytes:
fields: list[tuple[str, str]] = [
("actn", actn),
("host", self.cfg.remote_host),
("port", str(self.cfg.remote_port) if self.cfg.remote_port > 0 else ""),
("login", self.cfg.remote_user),
("password", self.cfg.remote_password),
("db", self.cfg.database),
]
qs = [q for q in (queries or []) if q.strip()]
if self.cfg.encode_base64 and qs:
fields.append(("encodeBase64", "1"))
for q in qs:
fields.append(("q[]", base64.b64encode(q.encode("utf-8")).decode("ascii")))
else:
for q in qs:
fields.append(("q[]", q))
body = urlencode(fields).encode("utf-8")
req = Request(
self.cfg.url,
data=body,
method="POST",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "mysql-tunnel-python/0.5",
},
)
kw: dict[str, Any] = {"timeout": self.cfg.timeout}
if urlparse(self.cfg.url).scheme == "https":
kw["context"] = self._ssl
try:
with urlopen(req, **kw) as resp:
status = resp.status
data = resp.read()
except HTTPError as e:
if e.code == 403:
raise TunnelError("HTTP 403: invalid or missing token") from None
raise TunnelError(f"HTTP {e.code}") from None
except URLError as e:
raise TunnelError(f"HTTP transport: {e.reason}") from None
if status < 200 or status >= 300:
raise TunnelError(f"HTTP {status}")
if looks_html(data):
raise TunnelError("tunnel returned HTML, not Navicat binary")
return data
def connect(self) -> tuple[Header, dict[str, str]]:
return parse_connect(self.post("C"))
def query(self, *sqls: str) -> tuple[Header, list[Result]]:
qs: list[str] = []
cs = (self.cfg.charset or "").strip()
if cs and cs.lower() not in ("utf8", "utf8mb3") and cs.replace("_", "").isalnum():
qs.append("SET NAMES " + cs)
qs.extend(sqls)
return parse_query(self.post("Q", qs))
def mysql_connect(cfg: Config):
try:
import pymysql
except ImportError as e:
raise TunnelError("install PyMySQL: pip install pymysql") from e
import warnings
# Same signature as 影刀: host/port/user/password/db → listen + local_auth + remote.database
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
return pymysql.connect(
host=cfg.listen_host,
port=cfg.listen_port,
user=cfg.local_user,
password=cfg.local_password,
db=cfg.database,
)
except pymysql.err.OperationalError as e:
code = e.args[0] if e.args else 0
if code in (2003, 2002):
raise TunnelError(
f"cannot connect {cfg.listen_host}:{cfg.listen_port} — "
"start mysql-tunnel.exe first (GUI 「启动」 or --console)"
) from None
if code == 1045:
raise TunnelError(
f"local_auth rejected ({cfg.local_user}) — "
"影刀/本脚本的 user/password 必须等于 config.yaml 的 local_auth"
) from None
raise TunnelError(f"mysql connect: {e}") from None
def query_mysql(conn, sql: str) -> Result:
import pymysql
cur = conn.cursor()
try:
cur.execute(sql)
rows = cur.fetchall()
desc = cur.description or ()
res = Result(
affected_rows=cur.rowcount if not desc else 0,
insert_id=int(getattr(cur, "lastrowid", 0) or 0),
num_fields=len(desc),
num_rows=len(rows) if desc else 0,
)
if desc:
res.fields = [Field(str(col[0]), "") for col in desc]
for row in rows:
out: list[str | None] = []
for v in row:
if v is None:
out.append(None)
elif isinstance(v, bytes):
out.append(v.decode("utf-8", errors="replace"))
else:
out.append(str(v))
res.rows.append(out)
return res
except pymysql.MySQLError as e:
errno = int(e.args[0]) if e.args else 0
msg = str(e.args[1]) if len(e.args) > 1 else str(e)
return Result(errno=errno, message=msg)
finally:
cur.close()
def find_config(explicit: str | None) -> Path:
cands: list[Path] = []
if explicit:
cands.append(Path(explicit))
cands.extend(
[
ROOT / "config.yaml",
Path.cwd() / "config.yaml",
ROOT / "dist" / "config.yaml",
]
)
for p in cands:
if p.is_file():
return p.resolve()
raise TunnelError("config.yaml not found; pass --config")
def last_result_set(results: list[Result]) -> Result | None:
for res in reversed(results):
if res.is_result_set:
return res
return results[-1] if results else None
def print_header(title: str) -> None:
print()
print("== " + title)
def print_result(res: Result, max_rows: int = 40) -> None:
if res.is_error:
print(f" mysql errno {res.errno}: {res.message}")
return
if not res.is_result_set:
extra = f" info={res.info!r}" if res.info else ""
print(f" OK affected={res.affected_rows} insert_id={res.insert_id}{extra}")
return
names = [f.name or "?" for f in res.fields]
print(f" {res.num_rows} row(s), columns: {', '.join(names)}")
widths = [len(n) for n in names]
shown = res.rows[:max_rows]
for row in shown:
for i, cell in enumerate(row):
text = "NULL" if cell is None else cell.replace("\n", "\\n")
widths[i] = max(widths[i], min(len(text), 48))
def fmt(val: str, w: int) -> str:
if len(val) > w:
val = val[: w - 1] + "…"
return val.ljust(w)
print(" " + " | ".join(fmt(n, widths[i]) for i, n in enumerate(names)))
print(" " + "-+-".join("-" * w for w in widths))
for row in shown:
cells = []
for i, cell in enumerate(row):
text = "NULL" if cell is None else cell.replace("\n", "\\n")
cells.append(fmt(text, widths[i]))
print(" " + " | ".join(cells))
if res.num_rows > max_rows:
print(f" … {res.num_rows - max_rows} more row(s)")
def default_sql() -> list[str]:
return [
"SELECT 1 AS ok",
"SELECT VERSION() AS version, DATABASE() AS db, USER() AS user, "
"@@character_set_connection AS charset",
"SHOW TABLES",
]
def run_local(cfg: Config, extra_sql: list[str]) -> int:
print("config:", cfg.path)
print("pymysql.connect(")
print(f' host="{cfg.listen_host}",')
print(f" port={cfg.listen_port},")
print(f' user="{cfg.local_user}",')
print(' password="***",')
print(f' db="{cfg.database}",')
print(")")
print("via: mysql-tunnel.exe →", redact_url(cfg.url))
try:
conn = mysql_connect(cfg)
except TunnelError as e:
print()
print(" Connect: FAIL")
print(" ", e)
return 1
print()
print("MySQL数据库连接成功")
print(" Connect: OK (local_auth)")
code = 0
try:
for sql in extra_sql or default_sql():
print_header(sql.replace("\n", " "))
res = query_mysql(conn, sql)
print_result(res)
if res.is_error:
code = 1
finally:
conn.close()
return code
def run_direct(cfg: Config, extra_sql: list[str]) -> int:
print("config:", cfg.path)
print("mode: --direct (POST ntunnel, remote.* not local_auth)")
print("tunnel:", redact_url(cfg.url))
print("remote:", f"{cfg.remote_user}@{cfg.remote_host}:{cfg.remote_port}/{cfg.database}")
client = HTTPClient(cfg)
code = 0
print_header("actn=C connect")
try:
h, conninfo = client.connect()
except TunnelError as e:
print(" Tunnel: FAIL")
print(" ", e)
return 1
if not h.ok:
print(f" errno {h.errno}: {h.message}")
print(" Remote MySQL: FAIL")
return 1
print(f" Tunnel: OK (script version {h.version})")
print(" Remote MySQL: OK")
print(f" server_info: {conninfo.get('server_info', '')}")
for sql in extra_sql or default_sql():
print_header("actn=Q " + sql.replace("\n", " "))
try:
h, results = client.query(sql)
except TunnelError as e:
print(" FAIL", e)
code = 1
continue
if not h.ok:
print(f" header errno {h.errno}: {h.message}")
code = 1
continue
res = last_result_set(results)
if res is None:
print(" no result")
code = 1
continue
print_result(res)
if res.is_error:
code = 1
return code
def main(argv: list[str]) -> int:
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
ap = argparse.ArgumentParser(
description="Connect through mysql-tunnel using local_auth (same as Yingdao)"
)
ap.add_argument("--config", default="", help="path to config.yaml")
ap.add_argument("--sql", action="append", default=[], help="SQL to run (repeatable)")
ap.add_argument(
"--direct",
action="store_true",
help="POST ntunnel_mysql.php with remote.* instead of local_auth",
)
args = ap.parse_args(argv)
try:
cfg = Config.load(find_config(args.config or None))
if args.direct:
return run_direct(cfg, args.sql)
return run_local(cfg, args.sql)
except TunnelError as e:
print("error:", e, file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+1
View File
@@ -0,0 +1 @@
pymysql>=1.1
+31
View File
@@ -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()
+69
View File
@@ -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
}
}
+20
View File
@@ -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