13 KiB
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
403text403 Forbidden: invalid or missing token - Valid token,
$allowTestMenu = false→ binary header errno 202invalid 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):
actnhostportlogin
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 hardcodesmysqli_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:
- Validates token
mysqli_connect(...)mysqli_set_charset(..., 'UTF8')- optional
mysqli_select_db CorQ- Script ends; PHP drops the MySQL connection
Consequences (not assumptions — this is what the PHP does):
START TRANSACTION/COMMITcannot span two HTTP requestsSETsession variables do not persist across COM_QUERY unless we put them in the same POSTq[]listlast_insert_idis only meaningful inside that same HTTP response- Local Yingdao connection state (
USE db) must be stored in mysql-tunnel and sent asdb=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 byte0xFF - 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 SELECTs 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)
- Does a reverse proxy / WAF wrap or modify the PHP output (gzip, extra HTTP headers, HTML error pages)?
- Does Navicat send
encodeBase64=1by default? Default in mysql-tunnel: off, configurable. - Does Navicat send a
charsetPOST field? This PHP ignores it. - Should
utf8mb4be applied viaSET NAMESin the sameq[]batch? (Client already does this when charset is not utf8/utf8mb3.) - Multi-statement in one
COM_QUERY(a; b;) vs oneq[]vs multipleq[]. COM_INIT_DB/USE: local sessiondbvs extra SQL. Current behavior: update sessiondband senddb=on later requests.- Prepared statements: tunnel has no binary protocol. Later: emulate prepare locally or send equivalent SQL.
insert_id/affected_rows32-bit wrap.- Trailing
0x01when the lastq[]is empty. - 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
tokenon 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