sqlbuddy-nwi 0.3.1__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.
- _version.py +10 -0
- cli.py +1164 -0
- connections/__init__.py +31 -0
- connections/config_util.py +159 -0
- connections/manager.py +205 -0
- connections/readonly.py +45 -0
- connections/runtime.py +51 -0
- connections/wrappers.py +105 -0
- db_query.py +735 -0
- db_security.py +146 -0
- init.py +528 -0
- server.py +275 -0
- sqlbuddy_nwi-0.3.1.dist-info/METADATA +705 -0
- sqlbuddy_nwi-0.3.1.dist-info/RECORD +20 -0
- sqlbuddy_nwi-0.3.1.dist-info/WHEEL +5 -0
- sqlbuddy_nwi-0.3.1.dist-info/entry_points.txt +3 -0
- sqlbuddy_nwi-0.3.1.dist-info/licenses/LICENSE +21 -0
- sqlbuddy_nwi-0.3.1.dist-info/top_level.txt +9 -0
- test_vendor.py +50 -0
- tunnel.py +345 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
_version.py,sha256=1eiY6aCIwIpr-2z7a8a_mIafcfROHIgB7o3LmtfWDBA,422
|
|
2
|
+
cli.py,sha256=pS_kxJIfx7MB3WK4QRBUzSFwaNUALwGxAhEDZgluJPo,47337
|
|
3
|
+
db_query.py,sha256=KzgCQeuSj4Q6yTf3Oqwn4uescR9ng91J6kosgfOMOHM,25951
|
|
4
|
+
db_security.py,sha256=cxVK17eqeD3e6UoK5wmdmTMPCiYzjGR9K7t9B2RBnjo,4510
|
|
5
|
+
init.py,sha256=hr290o_IHqk0HNl1MFEIVkzMTHyUjljDJ2wpwgENcv4,19885
|
|
6
|
+
server.py,sha256=afsewXZCoyKGwKpWlxLvYWAUxLlJJVa7q6OhhHsfHg0,11900
|
|
7
|
+
test_vendor.py,sha256=pY-5pCLvBw0vQFWrJ2b39IBOw7MVeeu2QCJvJI4Y2F4,1390
|
|
8
|
+
tunnel.py,sha256=LHDj75HUaQ7J4isvbnlyk6i_1ML6sNnwnDmzDsj1bM8,13047
|
|
9
|
+
connections/__init__.py,sha256=ZD5PdEy2zH6IttrHo44Vf0UteX3MucrmAUfmF0PkLRU,1035
|
|
10
|
+
connections/config_util.py,sha256=Co1yTc7WH2nWDQBDx37uJCju26egHIk08krwNFQu5OM,5768
|
|
11
|
+
connections/manager.py,sha256=ymNmormv6vymG-Ne7xTumqtGshcwpvhun_qt_YCnKSo,8110
|
|
12
|
+
connections/readonly.py,sha256=LhESIFiFxClBmlWLGUU6gXK4vum-UgAY6njtywjEdjM,1510
|
|
13
|
+
connections/runtime.py,sha256=IiEfYQv_qLr6YeGOJw3JhjTGJySAh7gd9KwLok6IB70,1525
|
|
14
|
+
connections/wrappers.py,sha256=HuMH3NHmNQEAVfmZ2ZESmxKDeUqMQUhPJN-o4FAvKdE,3099
|
|
15
|
+
sqlbuddy_nwi-0.3.1.dist-info/licenses/LICENSE,sha256=fgqAl1lZJDoYMWBcAy5mVE3ABFh3sY6KK0mqgcWzldU,1069
|
|
16
|
+
sqlbuddy_nwi-0.3.1.dist-info/METADATA,sha256=E2TaeWpndshNpcW2wIYQysEEI_O_sr4JTbhUZUCCmfE,33897
|
|
17
|
+
sqlbuddy_nwi-0.3.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
18
|
+
sqlbuddy_nwi-0.3.1.dist-info/entry_points.txt,sha256=Cyn50jUCXrPjjBMEgGudcT0EV91K1ioecNhlCOqA2RA,66
|
|
19
|
+
sqlbuddy_nwi-0.3.1.dist-info/top_level.txt,sha256=oXvsSUXss559LDH4TaluJRsblFqbJit9c8j2EemL7hg,77
|
|
20
|
+
sqlbuddy_nwi-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ying.yuxiang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
test_vendor.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Sanity-check that all vendored dependencies can be imported.
|
|
2
|
+
|
|
3
|
+
Run from the repo root (e.g. `python test_vendor.py`). Does NOT import the
|
|
4
|
+
application itself (which tries to read db.ini) — just the libraries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys, os
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "_vendor"))
|
|
10
|
+
if sys.platform == "win32":
|
|
11
|
+
for _extra in (
|
|
12
|
+
os.path.join("_vendor", "win32", "lib"),
|
|
13
|
+
os.path.join("_vendor", "win32"),
|
|
14
|
+
os.path.join("_vendor", "pythonwin"),
|
|
15
|
+
):
|
|
16
|
+
_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), _extra)
|
|
17
|
+
if os.path.isdir(_path) and _path not in sys.path:
|
|
18
|
+
sys.path.insert(0, _path)
|
|
19
|
+
_dll = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_vendor", "pywin32_system32")
|
|
20
|
+
if os.path.isdir(_dll):
|
|
21
|
+
try:
|
|
22
|
+
os.add_dll_directory(_dll)
|
|
23
|
+
except OSError:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
MODULES = [
|
|
27
|
+
"clickhouse_driver",
|
|
28
|
+
"pymysql",
|
|
29
|
+
"paramiko",
|
|
30
|
+
"mcp",
|
|
31
|
+
"mcp.server.fastmcp",
|
|
32
|
+
"pydantic",
|
|
33
|
+
"httpx",
|
|
34
|
+
"pythoncom",
|
|
35
|
+
"pywintypes",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
errors = []
|
|
39
|
+
for mod in MODULES:
|
|
40
|
+
try:
|
|
41
|
+
__import__(mod)
|
|
42
|
+
print(f" OK {mod}")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
errors.append((mod, str(e)))
|
|
45
|
+
print(f"FAIL {mod}: {e}")
|
|
46
|
+
|
|
47
|
+
if errors:
|
|
48
|
+
print(f"\n{len(errors)} module(s) failed.")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
print("\nAll vendored imports OK.")
|
tunnel.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Self-contained SSH tunnel manager for sql-buddy.
|
|
2
|
+
|
|
3
|
+
Reads `[ssh:<name>]` bastion blocks and per-connection tunnel mappings from
|
|
4
|
+
`db.ini`, spawns ONE `ssh -N` subprocess per bastion (lazy, on first use), keeps
|
|
5
|
+
it supervised for the lifetime of the process, and tears it down at exit.
|
|
6
|
+
|
|
7
|
+
Key-based auth only (the `-i <keyfile>` path). One bastion -> one tunnel, even
|
|
8
|
+
when many DB connections sit behind it: every connection referencing a given
|
|
9
|
+
`[ssh:*]` block contributes a `-L` forward to that single subprocess.
|
|
10
|
+
|
|
11
|
+
A DB connection opts in like this::
|
|
12
|
+
|
|
13
|
+
[ssh:prod]
|
|
14
|
+
host = 192.0.2.10
|
|
15
|
+
port = 22222
|
|
16
|
+
user = secret
|
|
17
|
+
key = ~/.ssh/prod_key
|
|
18
|
+
|
|
19
|
+
[ck]
|
|
20
|
+
tunnel = ssh:prod
|
|
21
|
+
remote_host = 10.0.0.21 # DB host AS SEEN FROM the bastion
|
|
22
|
+
remote_port = 9000 # DB port AS SEEN FROM the bastion
|
|
23
|
+
local_port = 10021 # localhost port ssh binds == driver connect port
|
|
24
|
+
|
|
25
|
+
The DB driver then connects to `localhost:local_port`; it never knows about SSH.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import atexit
|
|
29
|
+
import configparser
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import shutil
|
|
33
|
+
import socket
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# Import shared config helpers from the connections package, avoiding any
|
|
42
|
+
# circular-import risk by importing from the leaf module rather than the
|
|
43
|
+
# connections __init__ (which imports DBManager -> tunnel).
|
|
44
|
+
try:
|
|
45
|
+
from connections.config_util import (
|
|
46
|
+
read_config as _read_config,
|
|
47
|
+
resolve_env as _resolve_env,
|
|
48
|
+
config_path as _config_path,
|
|
49
|
+
)
|
|
50
|
+
except ImportError:
|
|
51
|
+
_resolve_env = None
|
|
52
|
+
_read_config = None
|
|
53
|
+
_config_path = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_CONFIG_FILE = (_config_path() if _config_path
|
|
57
|
+
else os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
58
|
+
'connections', 'config', 'db.ini'))
|
|
59
|
+
|
|
60
|
+
# ssh defaults
|
|
61
|
+
DEFAULT_SSH_PORT = 22
|
|
62
|
+
DEFAULT_ALIVE_INTERVAL = 30
|
|
63
|
+
DEFAULT_ALIVE_COUNT_MAX = 3
|
|
64
|
+
|
|
65
|
+
# How long to wait (s) for freshly-forwarded local ports to accept connections.
|
|
66
|
+
PORT_READY_TIMEOUT = 15.0
|
|
67
|
+
PORT_READY_POLL_INTERVAL = 0.1
|
|
68
|
+
# Tail length kept for failure diagnostics.
|
|
69
|
+
STDERR_TAIL_LINES = 50
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --- config parsing -------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def _normalize(section: str) -> str:
|
|
75
|
+
"""Match the connection manager's `_`/`-` interchangeability for connection names."""
|
|
76
|
+
return section.replace('_', '-')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ssh_blocks(cfg) -> dict:
|
|
80
|
+
"""Return {block_name: section_dict} for every `[ssh:*]` section."""
|
|
81
|
+
blocks = {}
|
|
82
|
+
prefix = 'ssh:'
|
|
83
|
+
for section in cfg.sections():
|
|
84
|
+
if section.lower().startswith(prefix):
|
|
85
|
+
blocks[section[len(prefix):]] = dict(cfg[section])
|
|
86
|
+
return blocks
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _conn_tunnels(cfg) -> dict:
|
|
90
|
+
"""Return {conn_section: (block, local_port, remote_host, remote_port)}
|
|
91
|
+
for every DB connection that declares a `tunnel` key. Raises on misconfig."""
|
|
92
|
+
out = {}
|
|
93
|
+
for section in cfg.sections():
|
|
94
|
+
if section.lower().startswith('ssh:'):
|
|
95
|
+
continue
|
|
96
|
+
if not cfg.has_option(section, 'tunnel'):
|
|
97
|
+
continue
|
|
98
|
+
block = cfg.get(section, 'tunnel').strip()
|
|
99
|
+
if block.lower().startswith('ssh:'):
|
|
100
|
+
block = block[4:] # `[ssh:prod]` section -> internal block name 'prod'
|
|
101
|
+
try:
|
|
102
|
+
local_port = int(cfg.get(section, 'local_port'))
|
|
103
|
+
remote_port = int(cfg.get(section, 'remote_port'))
|
|
104
|
+
except (configparser.NoOptionError, ValueError) as e:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"[{section}] declares tunnel='{block}' but needs integer "
|
|
107
|
+
f"`local_port` and `remote_port` ({e})"
|
|
108
|
+
)
|
|
109
|
+
if not cfg.has_option(section, 'remote_host'):
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"[{section}] declares tunnel='{block}' but is missing `remote_host`"
|
|
112
|
+
)
|
|
113
|
+
remote_host = cfg.get(section, 'remote_host').strip()
|
|
114
|
+
out[section] = (block, local_port, remote_host, remote_port)
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _port_open(port: int, host: str = '127.0.0.1', timeout: float = 0.5) -> bool:
|
|
119
|
+
try:
|
|
120
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
121
|
+
return True
|
|
122
|
+
except OSError:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- manager --------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
class TunnelManager:
|
|
129
|
+
"""Owns the ssh subprocesses for this process. Thread-safe singleton via get_manager()."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, config_file: str = None):
|
|
132
|
+
self._config_file = config_file or _CONFIG_FILE
|
|
133
|
+
self._lock = threading.RLock()
|
|
134
|
+
self._cfg = None
|
|
135
|
+
self._blocks: dict = {} # {block: section params}
|
|
136
|
+
self._conn_tunnels: dict = {} # {conn_section: (block, lp, rh, rp)}
|
|
137
|
+
self._procs: dict = {} # {block: Popen}
|
|
138
|
+
self._gen: dict = {} # {block: generation int}
|
|
139
|
+
self._stderr_tail: dict = {} # {block: [lines]}
|
|
140
|
+
self._loaded = False
|
|
141
|
+
self._atexit_registered = False
|
|
142
|
+
|
|
143
|
+
# -- config --
|
|
144
|
+
def _load(self):
|
|
145
|
+
if self._loaded:
|
|
146
|
+
return
|
|
147
|
+
cfg = _read_config(self._config_file)
|
|
148
|
+
self._cfg = cfg
|
|
149
|
+
self._blocks = _ssh_blocks(cfg)
|
|
150
|
+
self._conn_tunnels = _conn_tunnels(cfg)
|
|
151
|
+
self._loaded = True
|
|
152
|
+
if not self._atexit_registered:
|
|
153
|
+
atexit.register(self.stop_all)
|
|
154
|
+
self._atexit_registered = True
|
|
155
|
+
|
|
156
|
+
def reload(self):
|
|
157
|
+
"""Force a re-read of db.ini (e.g. after the user edits it)."""
|
|
158
|
+
with self._lock:
|
|
159
|
+
self._loaded = False
|
|
160
|
+
self._load()
|
|
161
|
+
|
|
162
|
+
# -- introspection (used by the connection manager) --
|
|
163
|
+
def tunnel_for(self, conn_section: str):
|
|
164
|
+
"""Return (block, local_port, remote_host, remote_port) or None if not tunneled."""
|
|
165
|
+
self._load()
|
|
166
|
+
return self._conn_tunnels.get(_normalize(conn_section))
|
|
167
|
+
|
|
168
|
+
def block_for(self, conn_section: str):
|
|
169
|
+
"""Return the ssh block name for a connection, or None if it isn't tunneled."""
|
|
170
|
+
t = self.tunnel_for(conn_section)
|
|
171
|
+
return t[0] if t else None
|
|
172
|
+
|
|
173
|
+
def local_port_for(self, conn_section: str):
|
|
174
|
+
"""Return the local forward port for a tunneled connection, or None."""
|
|
175
|
+
t = self.tunnel_for(conn_section)
|
|
176
|
+
return t[1] if t else None
|
|
177
|
+
|
|
178
|
+
def generation(self, block: str) -> int:
|
|
179
|
+
"""Bumped each time this block's tunnel is (re)started. 0 if never started."""
|
|
180
|
+
with self._lock:
|
|
181
|
+
return self._gen.get(block, 0)
|
|
182
|
+
|
|
183
|
+
# -- lifecycle --
|
|
184
|
+
def ensure_for(self, conn_section: str):
|
|
185
|
+
"""Make sure the bastion tunnel serving conn_section is up. Returns block or None."""
|
|
186
|
+
block = self.block_for(conn_section)
|
|
187
|
+
if block is None:
|
|
188
|
+
return None
|
|
189
|
+
self._ensure_block(block)
|
|
190
|
+
return block
|
|
191
|
+
|
|
192
|
+
def _forwards_for(self, block: str) -> list:
|
|
193
|
+
"""All (local_port, remote_host, remote_port) tuples using `block`."""
|
|
194
|
+
return [
|
|
195
|
+
(lp, rh, rp)
|
|
196
|
+
for (b, lp, rh, rp) in self._conn_tunnels.values()
|
|
197
|
+
if b == block
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
def _build_cmd(self, block: str, forwards: list) -> list:
|
|
201
|
+
p = self._blocks[block]
|
|
202
|
+
# expanduser handles ~/… and ~\… on Windows; normpath fixes mixed slashes.
|
|
203
|
+
key = os.path.normpath(os.path.expanduser(_resolve_env(p['key'])))
|
|
204
|
+
ssh_port = int(_resolve_env(p.get('port', DEFAULT_SSH_PORT)))
|
|
205
|
+
alive_int = _resolve_env(p.get('server_alive_interval', DEFAULT_ALIVE_INTERVAL))
|
|
206
|
+
alive_max = _resolve_env(p.get('server_alive_count_max', DEFAULT_ALIVE_COUNT_MAX))
|
|
207
|
+
user, host = _resolve_env(p['user']), _resolve_env(p['host'])
|
|
208
|
+
|
|
209
|
+
# Prefer the resolved binary so GUI-spawned MCP (Trae) finds OpenSSH even
|
|
210
|
+
# when the shell PATH differs from the IDE's PATH.
|
|
211
|
+
ssh_bin = shutil.which('ssh') or 'ssh'
|
|
212
|
+
cmd = [
|
|
213
|
+
ssh_bin, '-N',
|
|
214
|
+
'-o', f'ServerAliveInterval={alive_int}',
|
|
215
|
+
'-o', f'ServerAliveCountMax={alive_max}',
|
|
216
|
+
'-o', 'ExitOnForwardFailure=yes', # die loudly on a port collision
|
|
217
|
+
'-o', 'StrictHostKeyChecking=accept-new', # no first-run prompt hang
|
|
218
|
+
'-p', str(ssh_port),
|
|
219
|
+
'-i', key,
|
|
220
|
+
]
|
|
221
|
+
for lp, rh, rp in forwards:
|
|
222
|
+
cmd += ['-L', f'127.0.0.1:{lp}:{rh}:{rp}'] # localhost-only bind
|
|
223
|
+
cmd.append(f'{user}@{host}')
|
|
224
|
+
return cmd
|
|
225
|
+
|
|
226
|
+
def _popen_kwargs(self) -> dict:
|
|
227
|
+
"""Subprocess flags that work under GUI MCP hosts (esp. Windows/Trae)."""
|
|
228
|
+
kwargs: dict = {
|
|
229
|
+
"stdin": subprocess.DEVNULL,
|
|
230
|
+
"stdout": subprocess.DEVNULL,
|
|
231
|
+
"stderr": subprocess.PIPE,
|
|
232
|
+
}
|
|
233
|
+
if sys.platform == "win32":
|
|
234
|
+
# CREATE_NO_WINDOW: no flashing console when Trae/Claude spawns us.
|
|
235
|
+
# CREATE_NEW_PROCESS_GROUP: detach from parent's console ctrl-events.
|
|
236
|
+
flags = 0
|
|
237
|
+
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
238
|
+
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
239
|
+
if flags:
|
|
240
|
+
kwargs["creationflags"] = flags
|
|
241
|
+
else:
|
|
242
|
+
# Own process group -> survives parent's signal storm.
|
|
243
|
+
kwargs["start_new_session"] = True
|
|
244
|
+
return kwargs
|
|
245
|
+
|
|
246
|
+
def _ensure_block(self, block: str):
|
|
247
|
+
self._load()
|
|
248
|
+
if block not in self._blocks:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"Unknown ssh block '{block}'. Define an [ssh:{block}] section in db.ini."
|
|
251
|
+
)
|
|
252
|
+
forwards = self._forwards_for(block)
|
|
253
|
+
if not forwards:
|
|
254
|
+
return # nothing references this block yet -> nothing to forward
|
|
255
|
+
|
|
256
|
+
with self._lock:
|
|
257
|
+
proc = self._procs.get(block)
|
|
258
|
+
if proc is not None and proc.poll() is None:
|
|
259
|
+
return # already alive
|
|
260
|
+
|
|
261
|
+
cmd = self._build_cmd(block, forwards)
|
|
262
|
+
logger.info("Starting SSH tunnel for [%s]: %s", block, ' '.join(cmd))
|
|
263
|
+
try:
|
|
264
|
+
proc = subprocess.Popen(cmd, **self._popen_kwargs())
|
|
265
|
+
except FileNotFoundError as e:
|
|
266
|
+
raise RuntimeError(
|
|
267
|
+
f"SSH binary not found while starting tunnel [{block}]. "
|
|
268
|
+
f"On Windows install OpenSSH Client (Settings → Apps → "
|
|
269
|
+
f"Optional features). Original error: {e}"
|
|
270
|
+
) from e
|
|
271
|
+
self._procs[block] = proc
|
|
272
|
+
self._stderr_tail[block] = []
|
|
273
|
+
self._gen[block] = self._gen.get(block, 0) + 1
|
|
274
|
+
self._start_drain(block, proc)
|
|
275
|
+
|
|
276
|
+
ports = [lp for (lp, _rh, _rp) in forwards]
|
|
277
|
+
if not self._wait_ready(block, proc, ports):
|
|
278
|
+
err = self._read_stderr(block)
|
|
279
|
+
self._terminate(block)
|
|
280
|
+
raise RuntimeError(
|
|
281
|
+
f"SSH tunnel for [{block}] did not come up on local ports {ports}. "
|
|
282
|
+
f"ssh stderr: {err or '(empty)'}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def _start_drain(self, block: str, proc: subprocess.Popen):
|
|
286
|
+
"""Continuously drain ssh stderr into a bounded tail (prevents pipe-buffer deadlock)."""
|
|
287
|
+
def drain():
|
|
288
|
+
stream = proc.stderr
|
|
289
|
+
if stream is None:
|
|
290
|
+
return
|
|
291
|
+
for line in iter(stream.readline, ''):
|
|
292
|
+
if isinstance(line, bytes):
|
|
293
|
+
line = line.decode('utf-8', errors='replace')
|
|
294
|
+
with self._lock:
|
|
295
|
+
tail = self._stderr_tail.setdefault(block, [])
|
|
296
|
+
tail.append(line.rstrip())
|
|
297
|
+
if len(tail) > STDERR_TAIL_LINES:
|
|
298
|
+
del tail[:-STDERR_TAIL_LINES]
|
|
299
|
+
threading.Thread(target=drain, name=f"ssh-drain:{block}", daemon=True).start()
|
|
300
|
+
|
|
301
|
+
def _wait_ready(self, block: str, proc: subprocess.Popen, ports: list,
|
|
302
|
+
timeout: float = PORT_READY_TIMEOUT) -> bool:
|
|
303
|
+
deadline = time.monotonic() + timeout
|
|
304
|
+
while time.monotonic() < deadline:
|
|
305
|
+
if proc.poll() is not None:
|
|
306
|
+
return False # ssh exited before ports opened
|
|
307
|
+
if all(_port_open(p) for p in ports):
|
|
308
|
+
return True
|
|
309
|
+
time.sleep(PORT_READY_POLL_INTERVAL)
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
def _read_stderr(self, block: str) -> str:
|
|
313
|
+
with self._lock:
|
|
314
|
+
return '\n'.join(self._stderr_tail.get(block, []))
|
|
315
|
+
|
|
316
|
+
def _terminate(self, block: str):
|
|
317
|
+
with self._lock:
|
|
318
|
+
proc = self._procs.pop(block, None)
|
|
319
|
+
if proc is None:
|
|
320
|
+
return
|
|
321
|
+
if proc.poll() is None:
|
|
322
|
+
proc.terminate()
|
|
323
|
+
try:
|
|
324
|
+
proc.wait(timeout=5)
|
|
325
|
+
except subprocess.TimeoutExpired:
|
|
326
|
+
proc.kill()
|
|
327
|
+
|
|
328
|
+
def stop_all(self):
|
|
329
|
+
"""Tear down every tunnel. Registered via atexit."""
|
|
330
|
+
with self._lock:
|
|
331
|
+
blocks = list(self._procs.keys())
|
|
332
|
+
for block in blocks:
|
|
333
|
+
logger.info("Stopping SSH tunnel for [%s]", block)
|
|
334
|
+
self._terminate(block)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# process-wide singleton
|
|
338
|
+
_manager: TunnelManager | None = None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_manager() -> TunnelManager:
|
|
342
|
+
global _manager
|
|
343
|
+
if _manager is None:
|
|
344
|
+
_manager = TunnelManager()
|
|
345
|
+
return _manager
|