yearning-cli 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mysql_proxy/README.md +32 -0
- mysql_proxy/__init__.py +1 -0
- mysql_proxy/protocol.py +342 -0
- mysql_proxy/server.py +661 -0
- pyproject.toml +33 -0
- yearning_cli/__init__.py +1 -0
- yearning_cli/__main__.py +3 -0
- yearning_cli/cli.py +459 -0
- yearning_cli/client.py +636 -0
- yearning_cli-0.1.5.dist-info/METADATA +192 -0
- yearning_cli-0.1.5.dist-info/RECORD +13 -0
- yearning_cli-0.1.5.dist-info/WHEEL +4 -0
- yearning_cli-0.1.5.dist-info/entry_points.txt +2 -0
mysql_proxy/server.py
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
"""MySQL protocol proxy server — bridges MySQL clients to Yearning."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
# Add parent dir to path so we can import yearning_cli
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
14
|
+
|
|
15
|
+
from yearning_cli.client import (
|
|
16
|
+
STATE_FILE,
|
|
17
|
+
TokenExpiredError,
|
|
18
|
+
YearningClient,
|
|
19
|
+
read_state,
|
|
20
|
+
write_state,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from mysql_proxy.protocol import (
|
|
24
|
+
DEFAULT_CAPABILITIES,
|
|
25
|
+
make_err,
|
|
26
|
+
make_handshake,
|
|
27
|
+
make_ok,
|
|
28
|
+
make_result_set,
|
|
29
|
+
make_result_header,
|
|
30
|
+
stream_rows,
|
|
31
|
+
parse_handshake_response,
|
|
32
|
+
parse_packet,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("mysql_proxy")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConnectionHandler:
|
|
39
|
+
"""Handle a single MySQL client connection."""
|
|
40
|
+
|
|
41
|
+
_conn_id_counter = 0
|
|
42
|
+
_lock = threading.Lock()
|
|
43
|
+
_cache_lock = threading.Lock()
|
|
44
|
+
_source_cache = {}
|
|
45
|
+
_query_order_cache = {}
|
|
46
|
+
SOURCE_CACHE_TTL = 60
|
|
47
|
+
QUERY_ORDER_TTL = 600
|
|
48
|
+
|
|
49
|
+
def __init__(self, conn: socket.socket, addr, config: dict):
|
|
50
|
+
self.conn = conn
|
|
51
|
+
self.addr = addr
|
|
52
|
+
self.config = config
|
|
53
|
+
self.connection_id = self._next_id()
|
|
54
|
+
self.salt = bytes(random.getrandbits(8) for _ in range(20))
|
|
55
|
+
self.authenticated = False
|
|
56
|
+
self.current_db = config.get("database", "")
|
|
57
|
+
self.source_id = config.get("source_id", "")
|
|
58
|
+
self.source_name = config.get("source_name", "")
|
|
59
|
+
self.yearning = None
|
|
60
|
+
self.buffer = b""
|
|
61
|
+
# Socket tuning: disable Nagle's algorithm for lower latency
|
|
62
|
+
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
63
|
+
# Increase send buffer for large result sets
|
|
64
|
+
try:
|
|
65
|
+
conn.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 256 * 1024)
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
# Set a generous socket timeout to avoid premature disconnects
|
|
69
|
+
conn.settimeout(300)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def _next_id(cls):
|
|
73
|
+
with cls._lock:
|
|
74
|
+
cls._conn_id_counter += 1
|
|
75
|
+
return cls._conn_id_counter
|
|
76
|
+
|
|
77
|
+
def run(self):
|
|
78
|
+
"""Main connection loop."""
|
|
79
|
+
try:
|
|
80
|
+
self._handshake()
|
|
81
|
+
if not self.authenticated:
|
|
82
|
+
return
|
|
83
|
+
self._command_loop()
|
|
84
|
+
except (ConnectionResetError, BrokenPipeError):
|
|
85
|
+
logger.info("Client disconnected: %s", self.addr)
|
|
86
|
+
except OSError as e:
|
|
87
|
+
logger.info("Connection error %s: %s", self.addr, e)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.error("Connection error %s: %s", self.addr, e)
|
|
90
|
+
finally:
|
|
91
|
+
try:
|
|
92
|
+
self.conn.close()
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def _send(self, data: bytes):
|
|
97
|
+
try:
|
|
98
|
+
self.conn.sendall(data)
|
|
99
|
+
except (ConnectionResetError, BrokenPipeError, OSError) as e:
|
|
100
|
+
logger.warning("Send failed, client may have disconnected: %s", e)
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
def _recv(self, n=4096) -> bytes:
|
|
104
|
+
return self.conn.recv(n)
|
|
105
|
+
|
|
106
|
+
def _read_packet(self) -> bytes:
|
|
107
|
+
"""Read a complete MySQL packet, handling buffering."""
|
|
108
|
+
while True:
|
|
109
|
+
if len(self.buffer) >= 4:
|
|
110
|
+
length = int.from_bytes(self.buffer[:3], "little")
|
|
111
|
+
if len(self.buffer) >= 4 + length:
|
|
112
|
+
payload = self.buffer[4 : 4 + length]
|
|
113
|
+
self.buffer = self.buffer[4 + length :]
|
|
114
|
+
return payload
|
|
115
|
+
chunk = self._recv(8192)
|
|
116
|
+
if not chunk:
|
|
117
|
+
raise ConnectionResetError("Client closed connection")
|
|
118
|
+
self.buffer += chunk
|
|
119
|
+
|
|
120
|
+
def _handshake(self):
|
|
121
|
+
"""Perform MySQL handshake."""
|
|
122
|
+
# Send initial handshake
|
|
123
|
+
handshake = make_handshake(self.connection_id, self.salt)
|
|
124
|
+
self._send(handshake)
|
|
125
|
+
|
|
126
|
+
# Read client response
|
|
127
|
+
payload = self._read_packet()
|
|
128
|
+
username, auth_response, database = parse_handshake_response(payload)
|
|
129
|
+
|
|
130
|
+
if database:
|
|
131
|
+
self.current_db = database
|
|
132
|
+
|
|
133
|
+
# Use username as source name for data source switching
|
|
134
|
+
# No password verification — any user/password accepted
|
|
135
|
+
self.source_name = username if username else ""
|
|
136
|
+
|
|
137
|
+
# Auth OK
|
|
138
|
+
self.authenticated = True
|
|
139
|
+
logger.info("Authenticated: user=%s (source=%s) db=%s from=%s",
|
|
140
|
+
username, self.source_name, self.current_db, self.addr)
|
|
141
|
+
self._send(make_ok(message="Connected to Yearning proxy", seq=2))
|
|
142
|
+
|
|
143
|
+
def _init_yearning(self):
|
|
144
|
+
"""Initialize Yearning client and resolve the selected source."""
|
|
145
|
+
if self.yearning:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
base_url = self.config.get("base_url", "http://192.168.1.135")
|
|
149
|
+
self.yearning = YearningClient(base_url)
|
|
150
|
+
try:
|
|
151
|
+
self.yearning.ensure_authenticated()
|
|
152
|
+
except TokenExpiredError as e:
|
|
153
|
+
logger.error("Yearning authentication failed: %s", e)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# Fast path: when the source is already an ID, avoid fetching the full
|
|
157
|
+
# source list for the common fallback user.
|
|
158
|
+
config_source = self.config.get("source_name", "") or self.config.get("source_id", "")
|
|
159
|
+
if self.source_name and self._looks_like_source_id(self.source_name):
|
|
160
|
+
self.source_id = self.source_name
|
|
161
|
+
logger.info("Using Yearning source %s", self.source_id[:12])
|
|
162
|
+
return
|
|
163
|
+
if config_source and self._looks_like_source_id(config_source) and self.source_name in ("", "root"):
|
|
164
|
+
self.source_id = config_source
|
|
165
|
+
logger.info("Using Yearning source %s", self.source_id[:12])
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# Fetch all available sources. GUI clients often open several MySQL
|
|
169
|
+
# connections, so keep this metadata briefly in process.
|
|
170
|
+
sources = self._list_sources_cached()
|
|
171
|
+
source_name_to_id = {src.get("source"): src["source_id"] for src in sources}
|
|
172
|
+
|
|
173
|
+
# Default source from config (could be name or ID)
|
|
174
|
+
default_source_id = ""
|
|
175
|
+
if config_source:
|
|
176
|
+
# Check if it's a source name
|
|
177
|
+
if config_source in source_name_to_id:
|
|
178
|
+
default_source_id = source_name_to_id[config_source]
|
|
179
|
+
else:
|
|
180
|
+
# Assume it's already a source ID
|
|
181
|
+
default_source_id = config_source
|
|
182
|
+
|
|
183
|
+
# If username was provided, try to resolve it as a source name
|
|
184
|
+
if self.source_name:
|
|
185
|
+
if self.source_name in source_name_to_id:
|
|
186
|
+
self.source_id = source_name_to_id[self.source_name]
|
|
187
|
+
logger.info("Resolved username '%s' -> source %s", self.source_name, self.source_id[:12])
|
|
188
|
+
else:
|
|
189
|
+
# Fallback to default config source
|
|
190
|
+
self.source_id = default_source_id
|
|
191
|
+
logger.info("Username '%s' not found as source, falling back to default %s",
|
|
192
|
+
self.source_name, self.source_id[:12] if self.source_id else "NONE")
|
|
193
|
+
else:
|
|
194
|
+
# No username, use default
|
|
195
|
+
self.source_id = default_source_id
|
|
196
|
+
|
|
197
|
+
if self.source_id:
|
|
198
|
+
logger.info("Using Yearning source %s", self.source_id[:12])
|
|
199
|
+
|
|
200
|
+
def _ensure_yearning_initialized(self):
|
|
201
|
+
if not self.yearning:
|
|
202
|
+
self._init_yearning()
|
|
203
|
+
return self.yearning and self.yearning.token
|
|
204
|
+
|
|
205
|
+
def _ensure_query_order(self):
|
|
206
|
+
if not self._ensure_yearning_initialized() or not self.source_id:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
if self._has_recent_query_order():
|
|
210
|
+
logger.debug("Using recent Yearning query order for source %s", self.source_id[:12])
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
logger.info("Creating Yearning query order for source %s", self.source_id[:12])
|
|
214
|
+
if self.yearning.create_query_order(self.source_id, verify_wait=0.1):
|
|
215
|
+
self._mark_query_order_ready()
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _looks_like_source_id(value: str):
|
|
222
|
+
return len(value) > 30 and "-" in value
|
|
223
|
+
|
|
224
|
+
def _cache_key(self, *parts):
|
|
225
|
+
token = self.yearning.token if self.yearning else ""
|
|
226
|
+
return (self.config.get("base_url", "http://192.168.1.135"), token, *parts)
|
|
227
|
+
|
|
228
|
+
def _list_sources_cached(self):
|
|
229
|
+
key = self._cache_key("sources")
|
|
230
|
+
now = time.monotonic()
|
|
231
|
+
with self._cache_lock:
|
|
232
|
+
entry = self._source_cache.get(key)
|
|
233
|
+
if entry and entry["expires_at"] > now:
|
|
234
|
+
return entry["sources"]
|
|
235
|
+
|
|
236
|
+
sources = self.yearning.list_sources()
|
|
237
|
+
with self._cache_lock:
|
|
238
|
+
self._source_cache[key] = {
|
|
239
|
+
"sources": sources,
|
|
240
|
+
"expires_at": now + self.SOURCE_CACHE_TTL,
|
|
241
|
+
}
|
|
242
|
+
return sources
|
|
243
|
+
|
|
244
|
+
def _query_order_key(self):
|
|
245
|
+
return self._cache_key("query_order", self.source_id)
|
|
246
|
+
|
|
247
|
+
def _query_order_disk_key(self):
|
|
248
|
+
raw = "|".join(str(part) for part in self._query_order_key())
|
|
249
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
250
|
+
|
|
251
|
+
def _has_recent_query_order(self):
|
|
252
|
+
key = self._query_order_key()
|
|
253
|
+
now = time.monotonic()
|
|
254
|
+
with self._cache_lock:
|
|
255
|
+
expires_at = self._query_order_cache.get(key, 0)
|
|
256
|
+
if expires_at > now:
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
disk_expires_at = self._read_query_order_disk_cache().get(self._query_order_disk_key(), 0)
|
|
260
|
+
remaining = disk_expires_at - time.time()
|
|
261
|
+
if remaining > 0:
|
|
262
|
+
with self._cache_lock:
|
|
263
|
+
self._query_order_cache[key] = time.monotonic() + min(
|
|
264
|
+
remaining,
|
|
265
|
+
self.QUERY_ORDER_TTL,
|
|
266
|
+
)
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
def _mark_query_order_ready(self):
|
|
272
|
+
key = self._query_order_key()
|
|
273
|
+
disk_key = self._query_order_disk_key()
|
|
274
|
+
disk_expires_at = time.time() + self.QUERY_ORDER_TTL
|
|
275
|
+
with self._cache_lock:
|
|
276
|
+
self._query_order_cache[key] = time.monotonic() + self.QUERY_ORDER_TTL
|
|
277
|
+
self._write_query_order_disk_cache(disk_key, disk_expires_at)
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _read_query_order_disk_cache():
|
|
281
|
+
data = read_state()
|
|
282
|
+
now = time.time()
|
|
283
|
+
entries = data.get("query_orders", {})
|
|
284
|
+
if not isinstance(entries, dict):
|
|
285
|
+
return {}
|
|
286
|
+
return {
|
|
287
|
+
key: expires_at
|
|
288
|
+
for key, expires_at in entries.items()
|
|
289
|
+
if isinstance(key, str)
|
|
290
|
+
and isinstance(expires_at, (int, float))
|
|
291
|
+
and expires_at > now
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
@classmethod
|
|
295
|
+
def _write_query_order_disk_cache(cls, key, expires_at):
|
|
296
|
+
state = read_state()
|
|
297
|
+
entries = cls._read_query_order_disk_cache()
|
|
298
|
+
entries[key] = expires_at
|
|
299
|
+
state["query_orders"] = entries
|
|
300
|
+
try:
|
|
301
|
+
write_state(state)
|
|
302
|
+
except OSError as e:
|
|
303
|
+
logger.debug("Failed to write query order cache to %s: %s", STATE_FILE, e)
|
|
304
|
+
|
|
305
|
+
def _command_loop(self):
|
|
306
|
+
"""Process client commands."""
|
|
307
|
+
while True:
|
|
308
|
+
payload = self._read_packet()
|
|
309
|
+
if not payload:
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
cmd = payload[0]
|
|
313
|
+
|
|
314
|
+
if cmd == 0x03: # COM_QUERY
|
|
315
|
+
sql = payload[1:].decode("utf-8", errors="replace").strip()
|
|
316
|
+
self._handle_query(sql)
|
|
317
|
+
elif cmd == 0x02: # COM_INIT_DB (USE database)
|
|
318
|
+
db = payload[1:].decode("utf-8", errors="replace").strip()
|
|
319
|
+
self.current_db = db
|
|
320
|
+
self._send(make_ok(message=f"Changed to database '{db}'"))
|
|
321
|
+
elif cmd == 0x0E: # COM_PING
|
|
322
|
+
self._send(make_ok())
|
|
323
|
+
elif cmd == 0x01: # COM_QUIT
|
|
324
|
+
break
|
|
325
|
+
elif cmd == 0x00: # COM_SLEEP / COM_STMT_CLOSE (ignore)
|
|
326
|
+
pass
|
|
327
|
+
elif cmd == 0x16: # COM_STMT_PREPARE (not supported)
|
|
328
|
+
self._send(make_err(1295, "Prepared statements not supported", "HY000"))
|
|
329
|
+
elif cmd == 0x17: # COM_STMT_EXECUTE (not supported)
|
|
330
|
+
self._send(make_err(1295, "Prepared statements not supported", "HY000"))
|
|
331
|
+
elif cmd == 0x14: # COM_STMT_FETCH
|
|
332
|
+
self._send(make_err(1295, "Prepared statements not supported", "HY000"))
|
|
333
|
+
elif cmd == 0x19: # COM_STMT_RESET
|
|
334
|
+
self._send(make_ok())
|
|
335
|
+
elif cmd == 0x1A: # COM_STMT_SEND_LONG_DATA
|
|
336
|
+
pass
|
|
337
|
+
elif cmd == 0x04: # COM_FIELD_LIST
|
|
338
|
+
self._send(make_err(1105, "COM_FIELD_LIST not supported", "HY000"))
|
|
339
|
+
elif cmd == 0x09: # COM_STATISTICS
|
|
340
|
+
stats = "Uptime: 999999 Threads: 1 Questions: 0 Slow queries: 0"
|
|
341
|
+
self._send(make_ok(message=stats))
|
|
342
|
+
else:
|
|
343
|
+
logger.warning("Unknown command: 0x%02x", cmd)
|
|
344
|
+
self._send(make_err(1047, f"Unknown command 0x{cmd:02x}", "08S01"))
|
|
345
|
+
|
|
346
|
+
def _handle_query(self, sql: str):
|
|
347
|
+
"""Execute SQL via Yearning and return results."""
|
|
348
|
+
logger.info("Query: %s", sql[:200])
|
|
349
|
+
|
|
350
|
+
# Intercept MySQL client initialization commands — return OK without Yearning
|
|
351
|
+
upper = sql.upper().lstrip()
|
|
352
|
+
if upper.startswith(("SET ", "SET\t", "SET\n")):
|
|
353
|
+
self._send(make_ok(message="OK"))
|
|
354
|
+
return
|
|
355
|
+
if upper.startswith("SHOW ") and any(
|
|
356
|
+
kw in upper for kw in ("VARIABLES", "COLLATION", "SESSION", "GLOBAL", "CHARACTER SET", "ENGINES", "PLUGINS", "WARNINGS", "STATUS")
|
|
357
|
+
):
|
|
358
|
+
self._send(make_ok(message="OK"))
|
|
359
|
+
return
|
|
360
|
+
if upper.startswith("USE "):
|
|
361
|
+
db = sql[3:].strip().strip("`\"'")
|
|
362
|
+
self.current_db = db
|
|
363
|
+
self._send(make_ok(message=f"Changed to database '{db}'"))
|
|
364
|
+
return
|
|
365
|
+
# SELECT @@version, SELECT @@version_comment, etc.
|
|
366
|
+
if upper.startswith("SELECT @@"):
|
|
367
|
+
var = upper[7:].strip().split()[0].rstrip(",")
|
|
368
|
+
val = self._fake_sysvar(var)
|
|
369
|
+
self._send_simple_result(var, val)
|
|
370
|
+
return
|
|
371
|
+
# SELECT DATABASE(), SELECT VERSION(), etc.
|
|
372
|
+
if upper in ("SELECT DATABASE()", "SELECT DATABASE() ;"):
|
|
373
|
+
self._send_simple_result("DATABASE()", self.current_db or "NULL")
|
|
374
|
+
return
|
|
375
|
+
if upper in ("SELECT VERSION()", "SELECT VERSION() ;"):
|
|
376
|
+
self._send_simple_result("VERSION()", "8.0.0-proxy")
|
|
377
|
+
return
|
|
378
|
+
if upper.startswith("SELECT CONNECTION_ID()"):
|
|
379
|
+
self._send_simple_result("CONNECTION_ID()", str(self.connection_id))
|
|
380
|
+
return
|
|
381
|
+
# SHOW DATABASES / SHOW TABLES
|
|
382
|
+
if upper in ("SHOW DATABASES", "SHOW DATABASES;"):
|
|
383
|
+
self._handle_show_databases()
|
|
384
|
+
return
|
|
385
|
+
if upper in ("SHOW TABLES", "SHOW TABLES;") or upper.startswith("SHOW TABLES "):
|
|
386
|
+
self._handle_show_tables()
|
|
387
|
+
return
|
|
388
|
+
if upper.startswith("SHOW FULL TABLES"):
|
|
389
|
+
self._handle_show_tables(full=True)
|
|
390
|
+
return
|
|
391
|
+
if upper.startswith("SHOW COLUMNS") or upper.startswith("SHOW FULL COLUMNS"):
|
|
392
|
+
self._handle_show_columns(sql)
|
|
393
|
+
return
|
|
394
|
+
if upper.startswith("DESCRIBE ") or upper.startswith("DESC "):
|
|
395
|
+
self._handle_describe(sql)
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
self._ensure_yearning_initialized()
|
|
399
|
+
if not self.yearning or not self.yearning.token:
|
|
400
|
+
self._send(make_err(1045, "Yearning not authenticated. Run 'sql login' first."))
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
if not self.source_id:
|
|
404
|
+
self._send(make_err(1046, "No data source configured. Set source_id in config."))
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
if not self.current_db:
|
|
408
|
+
self._send(make_err(1046, "No database selected"))
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
self._ensure_query_order()
|
|
412
|
+
try:
|
|
413
|
+
result = self.yearning.execute_query(self.source_id, self.current_db, sql, timeout=120)
|
|
414
|
+
except Exception as e:
|
|
415
|
+
logger.error("Query error: %s", e)
|
|
416
|
+
self._send(make_err(1105, str(e), "HY000"))
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
if not result["success"]:
|
|
420
|
+
error_msg = result.get("error", "Unknown error")
|
|
421
|
+
# If query order expired, try to recreate
|
|
422
|
+
if "工单" in error_msg or "expired" in error_msg.lower():
|
|
423
|
+
logger.info("Query order expired, recreating...")
|
|
424
|
+
try:
|
|
425
|
+
if self.yearning.create_query_order(self.source_id, verify_wait=0.1):
|
|
426
|
+
self._mark_query_order_ready()
|
|
427
|
+
result = self.yearning.execute_query(self.source_id, self.current_db, sql, timeout=120)
|
|
428
|
+
if result["success"]:
|
|
429
|
+
self._mark_query_order_ready()
|
|
430
|
+
self._send_result(result)
|
|
431
|
+
return
|
|
432
|
+
except Exception as e:
|
|
433
|
+
error_msg = str(e)
|
|
434
|
+
self._send(make_err(1105, error_msg, "HY000"))
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
self._mark_query_order_ready()
|
|
438
|
+
self._send_result(result)
|
|
439
|
+
|
|
440
|
+
def _fake_sysvar(self, var: str) -> str:
|
|
441
|
+
"""Return fake system variable values for client compatibility."""
|
|
442
|
+
var = var.upper().lstrip("@")
|
|
443
|
+
mapping = {
|
|
444
|
+
"VERSION": "8.0.0-proxy",
|
|
445
|
+
"VERSION_COMMENT": "Yearning MySQL Proxy",
|
|
446
|
+
"VERSION_COMPILE_MACHINE": "x86_64",
|
|
447
|
+
"VERSION_COMPILE_OS": "Linux",
|
|
448
|
+
"CHARACTER_SET_CLIENT": "utf8mb4",
|
|
449
|
+
"CHARACTER_SET_CONNECTION": "utf8mb4",
|
|
450
|
+
"CHARACTER_SET_RESULTS": "utf8mb4",
|
|
451
|
+
"COLLATION_CONNECTION": "utf8mb4_general_ci",
|
|
452
|
+
"AUTOCOMMIT": "1",
|
|
453
|
+
"SQL_MODE": "",
|
|
454
|
+
"TIME_ZONE": "+08:00",
|
|
455
|
+
"SYSTEM_TIME_ZONE": "CST",
|
|
456
|
+
"TX_ISOLATION": "REPEATABLE-READ",
|
|
457
|
+
"TRANSACTION_ISOLATION": "REPEATABLE-READ",
|
|
458
|
+
"MAX_ALLOWED_PACKET": "67108864",
|
|
459
|
+
"NET_WRITE_TIMEOUT": "60",
|
|
460
|
+
"WAIT_TIMEOUT": "28800",
|
|
461
|
+
"INTERACTIVE_TIMEOUT": "28800",
|
|
462
|
+
"LOWER_CASE_TABLE_NAMES": "0",
|
|
463
|
+
"PORT": "3307",
|
|
464
|
+
"BASEDIR": "/",
|
|
465
|
+
"DATADIR": "/data/",
|
|
466
|
+
"SOCKET": "/tmp/mysql.sock",
|
|
467
|
+
}
|
|
468
|
+
return mapping.get(var, "")
|
|
469
|
+
|
|
470
|
+
def _send_simple_result(self, name: str, value: str):
|
|
471
|
+
"""Send a single-column, single-row result."""
|
|
472
|
+
packets = make_result_set([name], [[value]], seq_start=1)
|
|
473
|
+
for pkt, _ in packets:
|
|
474
|
+
self._send(pkt)
|
|
475
|
+
|
|
476
|
+
def _handle_show_databases(self):
|
|
477
|
+
"""SHOW DATABASES — list databases from Yearning."""
|
|
478
|
+
self._ensure_yearning_initialized()
|
|
479
|
+
if not self.yearning or not self.source_id:
|
|
480
|
+
self._send(make_err(1046, "No data source configured"))
|
|
481
|
+
return
|
|
482
|
+
try:
|
|
483
|
+
dbs = self.yearning.list_databases(self.source_id)
|
|
484
|
+
rows = [[db] for db in dbs]
|
|
485
|
+
packets = make_result_set(["Database"], rows, seq_start=1)
|
|
486
|
+
for pkt, _ in packets:
|
|
487
|
+
self._send(pkt)
|
|
488
|
+
except Exception as e:
|
|
489
|
+
self._send(make_err(1105, str(e)))
|
|
490
|
+
|
|
491
|
+
def _handle_show_tables(self, full=False):
|
|
492
|
+
"""SHOW TABLES — list tables from Yearning."""
|
|
493
|
+
self._ensure_yearning_initialized()
|
|
494
|
+
if not self.yearning or not self.source_id or not self.current_db:
|
|
495
|
+
self._send(make_err(1046, "No database selected"))
|
|
496
|
+
return
|
|
497
|
+
try:
|
|
498
|
+
tables = self.yearning.list_tables(self.source_id, self.current_db)
|
|
499
|
+
col_name = f"Tables_in_{self.current_db}"
|
|
500
|
+
if full:
|
|
501
|
+
rows = [[t, "BASE TABLE"] for t in tables]
|
|
502
|
+
packets = make_result_set([col_name, "Table_type"], rows, seq_start=1)
|
|
503
|
+
else:
|
|
504
|
+
rows = [[t] for t in tables]
|
|
505
|
+
packets = make_result_set([col_name], rows, seq_start=1)
|
|
506
|
+
for pkt, _ in packets:
|
|
507
|
+
self._send(pkt)
|
|
508
|
+
except Exception as e:
|
|
509
|
+
self._send(make_err(1105, str(e)))
|
|
510
|
+
|
|
511
|
+
def _handle_show_columns(self, sql: str):
|
|
512
|
+
"""SHOW COLUMNS FROM table — execute via Yearning."""
|
|
513
|
+
self._execute_direct(f"SHOW COLUMNS FROM {sql.split('FROM')[-1].strip()}")
|
|
514
|
+
|
|
515
|
+
def _handle_describe(self, sql: str):
|
|
516
|
+
"""DESCRIBE table — execute via Yearning."""
|
|
517
|
+
parts = sql.split(None, 1)
|
|
518
|
+
if len(parts) < 2:
|
|
519
|
+
self._send(make_err(1064, "Syntax error"))
|
|
520
|
+
return
|
|
521
|
+
table = parts[1].strip().rstrip(";")
|
|
522
|
+
self._execute_direct(f"DESCRIBE {table}")
|
|
523
|
+
|
|
524
|
+
def _execute_direct(self, sql: str):
|
|
525
|
+
"""Execute SQL directly via Yearning, bypassing command interception."""
|
|
526
|
+
self._ensure_yearning_initialized()
|
|
527
|
+
if not self.yearning or not self.yearning.token:
|
|
528
|
+
self._send(make_err(1045, "Yearning not authenticated"))
|
|
529
|
+
return
|
|
530
|
+
if not self.source_id:
|
|
531
|
+
self._send(make_err(1046, "No data source configured"))
|
|
532
|
+
return
|
|
533
|
+
if not self.current_db:
|
|
534
|
+
self._send(make_err(1046, "No database selected"))
|
|
535
|
+
return
|
|
536
|
+
self._ensure_query_order()
|
|
537
|
+
try:
|
|
538
|
+
result = self.yearning.execute_query(self.source_id, self.current_db, sql, timeout=120)
|
|
539
|
+
except Exception as e:
|
|
540
|
+
self._send(make_err(1105, str(e)))
|
|
541
|
+
return
|
|
542
|
+
if not result["success"]:
|
|
543
|
+
self._send(make_err(1105, result.get("error", "Unknown error")))
|
|
544
|
+
return
|
|
545
|
+
self._mark_query_order_ready()
|
|
546
|
+
self._send_result(result)
|
|
547
|
+
|
|
548
|
+
def _send_result(self, result: dict):
|
|
549
|
+
"""Send query result as MySQL result set.
|
|
550
|
+
|
|
551
|
+
For small result sets (< 100 rows), build all packets in memory.
|
|
552
|
+
For large result sets, stream rows one by one to avoid socket buffer
|
|
553
|
+
issues and memory pressure.
|
|
554
|
+
"""
|
|
555
|
+
columns = result.get("columns", [])
|
|
556
|
+
rows = result.get("rows", [])
|
|
557
|
+
|
|
558
|
+
if not columns:
|
|
559
|
+
# DML or empty result
|
|
560
|
+
msg = f"OK ({result.get('query_time', 0)}ms)"
|
|
561
|
+
self._send(make_ok(message=msg))
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
# Use streaming for large result sets to avoid "Lost connection" errors
|
|
565
|
+
STREAM_THRESHOLD = 100
|
|
566
|
+
if len(rows) > STREAM_THRESHOLD:
|
|
567
|
+
self._stream_result(columns, rows)
|
|
568
|
+
else:
|
|
569
|
+
packets = make_result_set(columns, rows, seq_start=1)
|
|
570
|
+
for pkt, _ in packets:
|
|
571
|
+
self._send(pkt)
|
|
572
|
+
|
|
573
|
+
def _stream_result(self, columns: list, rows: list):
|
|
574
|
+
"""Stream result set row by row to avoid large memory allocations."""
|
|
575
|
+
logger.debug("Streaming %d rows", len(rows))
|
|
576
|
+
# Send header (column count + column defs + EOF)
|
|
577
|
+
header_packets, next_seq = make_result_header(columns, rows[:1], seq_start=1)
|
|
578
|
+
for pkt, _ in header_packets:
|
|
579
|
+
self._send(pkt)
|
|
580
|
+
|
|
581
|
+
# Stream rows one by one
|
|
582
|
+
try:
|
|
583
|
+
for pkt, _ in stream_rows(columns, rows, seq_start=next_seq):
|
|
584
|
+
self._send(pkt)
|
|
585
|
+
except (ConnectionResetError, BrokenPipeError, OSError) as e:
|
|
586
|
+
logger.warning("Client disconnected during streaming: %s", e)
|
|
587
|
+
raise
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _create_server_socket(host: str, port: int) -> socket.socket:
|
|
591
|
+
"""Create a listening TCP socket for IPv4 or IPv6 hosts."""
|
|
592
|
+
infos = socket.getaddrinfo(
|
|
593
|
+
host,
|
|
594
|
+
port,
|
|
595
|
+
socket.AF_UNSPEC,
|
|
596
|
+
socket.SOCK_STREAM,
|
|
597
|
+
0,
|
|
598
|
+
socket.AI_PASSIVE,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
last_error = None
|
|
602
|
+
for family, socktype, proto, _, sockaddr in infos:
|
|
603
|
+
server = socket.socket(family, socktype, proto)
|
|
604
|
+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
605
|
+
|
|
606
|
+
if family == socket.AF_INET6:
|
|
607
|
+
try:
|
|
608
|
+
server.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
|
|
609
|
+
except OSError:
|
|
610
|
+
logger.debug("Could not enable IPv4-mapped IPv6 sockets", exc_info=True)
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
server.bind(sockaddr)
|
|
614
|
+
server.listen(10)
|
|
615
|
+
return server
|
|
616
|
+
except OSError as exc:
|
|
617
|
+
last_error = exc
|
|
618
|
+
server.close()
|
|
619
|
+
|
|
620
|
+
raise last_error or OSError(f"Could not bind MySQL proxy to {host}:{port}")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _client_host_hint(host: str) -> str:
|
|
624
|
+
if host in ("0.0.0.0", ""):
|
|
625
|
+
return "127.0.0.1 或本机局域网 IPv4"
|
|
626
|
+
if host == "::":
|
|
627
|
+
return "::1 或本机 IPv6"
|
|
628
|
+
return host
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def serve(host="0.0.0.0", port=3307, config=None):
|
|
632
|
+
"""Start MySQL proxy server."""
|
|
633
|
+
if config is None:
|
|
634
|
+
config = {}
|
|
635
|
+
|
|
636
|
+
server = _create_server_socket(host, port)
|
|
637
|
+
|
|
638
|
+
logger.info("MySQL proxy listening on %s:%d", host, port)
|
|
639
|
+
logger.info(" Yearning URL: %s", config.get("base_url", "http://192.168.1.135"))
|
|
640
|
+
logger.info(" Source: %s", config.get("source_name", config.get("source_id", "N/A")))
|
|
641
|
+
logger.info(" Database: %s", config.get("database", "N/A"))
|
|
642
|
+
if config.get("user"):
|
|
643
|
+
logger.info(" Auth user: %s", config["user"])
|
|
644
|
+
else:
|
|
645
|
+
logger.info(" Auth: disabled (accept any credentials)")
|
|
646
|
+
|
|
647
|
+
print(f"MySQL proxy 已启动: {host}:{port}")
|
|
648
|
+
print(f"连接命令: mysql -h {_client_host_hint(host)} -P {port} -u root")
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
while True:
|
|
652
|
+
conn, addr = server.accept()
|
|
653
|
+
logger.info("New connection from %s", addr)
|
|
654
|
+
handler = ConnectionHandler(conn, addr, config)
|
|
655
|
+
t = threading.Thread(target=handler.run, daemon=True)
|
|
656
|
+
t.start()
|
|
657
|
+
except KeyboardInterrupt:
|
|
658
|
+
logger.info("Shutting down...")
|
|
659
|
+
print("\nBye!")
|
|
660
|
+
finally:
|
|
661
|
+
server.close()
|
pyproject.toml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "yearning-cli"
|
|
3
|
+
version = "0.1.5"
|
|
4
|
+
description = "Yearning MySQL Audit Platform CLI Tool"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "la3rence" },
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"msgpack>=1.1.2",
|
|
13
|
+
"openpyxl>=3.1",
|
|
14
|
+
"requests>=2.31",
|
|
15
|
+
"websocket-client>=1.9.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
sql = "yearning_cli.cli:main"
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/la3rence/yearning-cli"
|
|
23
|
+
Repository = "https://github.com/la3rence/yearning-cli"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["yearning_cli", "mysql_proxy"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
33
|
+
"pyproject.toml" = "pyproject.toml"
|
yearning_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Yearning MySQL Audit Platform CLI."""
|