# 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