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/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"
@@ -0,0 +1 @@
1
+ """Yearning MySQL Audit Platform CLI."""