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
db_security.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Prevent passwords, keys, and tokens from leaking into logs or error output.
|
|
2
|
+
|
|
3
|
+
Usage (call once, early in the server startup):
|
|
4
|
+
|
|
5
|
+
import db_security
|
|
6
|
+
db_security.install()
|
|
7
|
+
|
|
8
|
+
After install():
|
|
9
|
+
- The root logger and all descendant loggers get a SensitiveValueFilter that
|
|
10
|
+
replaces known sensitive env-var values with `***` in log messages.
|
|
11
|
+
- sys.excepthook + threading.excepthook are overridden to sanitize traceback
|
|
12
|
+
text before it reaches stderr.
|
|
13
|
+
- Exception __str__ / __repr__ on errors that embed env-var values (e.g.
|
|
14
|
+
connection errors that include the full config string) is left alone; the
|
|
15
|
+
hook strips those values from the traceback output only.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_SENSITIVE_VARS: set[str] = set()
|
|
25
|
+
_SENSITIVE_VALUES: list[str] = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _collect_sensitive() -> tuple[set[str], list[str]]:
|
|
29
|
+
sensitive_keys = set()
|
|
30
|
+
sensitive_vals: list[str] = []
|
|
31
|
+
for key, value in os.environ.items():
|
|
32
|
+
upper = key.upper()
|
|
33
|
+
if any(
|
|
34
|
+
tag in upper
|
|
35
|
+
for tag in ("_PASS", "_PASSWORD", "_KEY", "_TOKEN", "_SECRET", "_CRED")
|
|
36
|
+
):
|
|
37
|
+
sensitive_keys.add(key)
|
|
38
|
+
if value:
|
|
39
|
+
sensitive_vals.append(value)
|
|
40
|
+
return sensitive_keys, sensitive_vals
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SensitiveValueFilter(logging.Filter):
|
|
44
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
45
|
+
msg = record.getMessage()
|
|
46
|
+
if not _SENSITIVE_VALUES:
|
|
47
|
+
return True
|
|
48
|
+
for val in _SENSITIVE_VALUES:
|
|
49
|
+
if val and val in msg:
|
|
50
|
+
msg = msg.replace(val, "***")
|
|
51
|
+
record.msg = msg
|
|
52
|
+
record.args = None
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_original_excepthook = sys.excepthook
|
|
57
|
+
_original_threading_excepthook = threading.excepthook
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sanitize_excepthook(exc_type, exc_value, exc_tb):
|
|
61
|
+
exc_value = _sanitize_exception(exc_value)
|
|
62
|
+
_original_excepthook(exc_type, exc_value, exc_tb)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sanitize_threading_excepthook(args):
|
|
66
|
+
if hasattr(args, "exc_value") and args.exc_value is not None:
|
|
67
|
+
args.exc_value = _sanitize_exception(args.exc_value)
|
|
68
|
+
_original_threading_excepthook(args)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sanitize_exception(exc_value):
|
|
72
|
+
if exc_value is None:
|
|
73
|
+
return exc_value
|
|
74
|
+
try:
|
|
75
|
+
msg = str(exc_value)
|
|
76
|
+
if not _SENSITIVE_VALUES:
|
|
77
|
+
return exc_value
|
|
78
|
+
for val in _SENSITIVE_VALUES:
|
|
79
|
+
if val and val in msg:
|
|
80
|
+
msg = msg.replace(val, "***")
|
|
81
|
+
exc_value.args = (msg,)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
return exc_value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_env_file() -> str | None:
|
|
88
|
+
"""Load .env from the configured path. Returns the path used, or None."""
|
|
89
|
+
env_path = os.environ.get("SQL_BUDDY_ENV_FILE")
|
|
90
|
+
if env_path:
|
|
91
|
+
env_path = os.path.expanduser(env_path)
|
|
92
|
+
_load_env(env_path)
|
|
93
|
+
return env_path
|
|
94
|
+
repo_root = os.path.dirname(os.path.abspath(__file__))
|
|
95
|
+
for name in (".env", ".env.local"):
|
|
96
|
+
candidate = os.path.join(repo_root, name)
|
|
97
|
+
if os.path.isfile(candidate):
|
|
98
|
+
_load_env(candidate)
|
|
99
|
+
return candidate
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _load_env(path: str):
|
|
104
|
+
if not os.path.isfile(path):
|
|
105
|
+
return
|
|
106
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
107
|
+
for line in f:
|
|
108
|
+
line = line.strip()
|
|
109
|
+
if not line or line.startswith("#"):
|
|
110
|
+
continue
|
|
111
|
+
if "=" not in line:
|
|
112
|
+
continue
|
|
113
|
+
key, _, value = line.partition("=")
|
|
114
|
+
key = key.strip()
|
|
115
|
+
value = value.strip().strip('"').strip("'")
|
|
116
|
+
if key and key not in os.environ:
|
|
117
|
+
os.environ[key] = value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def install():
|
|
121
|
+
"""Activate all protections: .env loading, log filters, excepthooks."""
|
|
122
|
+
_load_env_file()
|
|
123
|
+
keys, vals = _collect_sensitive()
|
|
124
|
+
_SENSITIVE_VARS.update(keys)
|
|
125
|
+
_SENSITIVE_VALUES.extend(v for v in vals if v and v not in _SENSITIVE_VALUES)
|
|
126
|
+
|
|
127
|
+
root = logging.getLogger()
|
|
128
|
+
root.addFilter(SensitiveValueFilter())
|
|
129
|
+
|
|
130
|
+
sys.excepthook = _sanitize_excepthook
|
|
131
|
+
threading.excepthook = _sanitize_threading_excepthook
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def sanitize_text(text: str) -> str:
|
|
135
|
+
"""Replace all known sensitive values with `***` in arbitrary text.
|
|
136
|
+
|
|
137
|
+
Call this before persisting SQL queries to history, returning data in MCP
|
|
138
|
+
responses, or writing to any durable log. Idempotent — if values are already
|
|
139
|
+
masked, no harm.
|
|
140
|
+
"""
|
|
141
|
+
if not text:
|
|
142
|
+
return text
|
|
143
|
+
for val in _SENSITIVE_VALUES:
|
|
144
|
+
if val and val in text:
|
|
145
|
+
text = text.replace(val, "***")
|
|
146
|
+
return text
|
init.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""sql-buddy interactive setup wizard.
|
|
3
|
+
|
|
4
|
+
Asks how you reach your databases and writes `connections/config/db.ini` in
|
|
5
|
+
the right shape:
|
|
6
|
+
|
|
7
|
+
- Auto : sql-buddy opens the SSH tunnel itself. You give a bastion + key
|
|
8
|
+
file + per-DB remote mapping. Connections get `tunnel = ssh:*`.
|
|
9
|
+
- External : you already run a tunnel/connection elsewhere (e.g. an `ssh -N`
|
|
10
|
+
terminal on Windows, or a DB exposed locally). sql-buddy just
|
|
11
|
+
connects to a local host:port. No `tunnel` keys, so the
|
|
12
|
+
self-managed tunnel code stays dormant.
|
|
13
|
+
|
|
14
|
+
Run: python init.py (or, after `uv pip install -e .`: sql-buddy-init)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import configparser
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import sys
|
|
22
|
+
from getpass import getpass
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from connections.config_util import config_write_path as _config_write_path
|
|
26
|
+
except ImportError:
|
|
27
|
+
_config_write_path = None
|
|
28
|
+
|
|
29
|
+
# Resolved via the shared helper (env > user dir > checkout-relative; see
|
|
30
|
+
# connections/config_util.py). Falls back to the checkout-relative path if the
|
|
31
|
+
# package isn't importable (e.g. a bare `python init.py` against a vendored tree).
|
|
32
|
+
CONFIG_FILE = (_config_write_path() if _config_write_path
|
|
33
|
+
else os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
34
|
+
'connections', 'config', 'db.ini'))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- WSL detection (lightweight, no heavy imports) ------------------------
|
|
38
|
+
|
|
39
|
+
def _is_wsl() -> bool:
|
|
40
|
+
try:
|
|
41
|
+
with open('/proc/version') as f:
|
|
42
|
+
return 'microsoft' in f.read().lower()
|
|
43
|
+
except OSError:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- prompt helpers -------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _ask(prompt: str, default: str = None, optional: bool = False) -> str:
|
|
50
|
+
suffix = f" [{default}]" if default is not None else ""
|
|
51
|
+
while True:
|
|
52
|
+
val = input(f"{prompt}{suffix}: ").strip()
|
|
53
|
+
if val:
|
|
54
|
+
return val
|
|
55
|
+
if default is not None:
|
|
56
|
+
return default
|
|
57
|
+
if optional:
|
|
58
|
+
return ""
|
|
59
|
+
print(" (required - please answer)")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ask_int(prompt: str, default: int = None) -> int:
|
|
63
|
+
while True:
|
|
64
|
+
raw = _ask(prompt, str(default) if default is not None else None)
|
|
65
|
+
try:
|
|
66
|
+
return int(raw)
|
|
67
|
+
except ValueError:
|
|
68
|
+
print(" (must be an integer)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _ask_password(prompt: str) -> str:
|
|
72
|
+
try:
|
|
73
|
+
return getpass(f"{prompt}: ")
|
|
74
|
+
except EOFError:
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _yes_no(prompt: str, default: bool = False) -> bool:
|
|
79
|
+
raw = input(f"{prompt} [{'y' if default else 'n'}]: ").strip().lower()
|
|
80
|
+
if not raw:
|
|
81
|
+
return default
|
|
82
|
+
return raw.startswith('y')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- mode selection -------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _choose_mode() -> int:
|
|
88
|
+
print(
|
|
89
|
+
"\nHow do you reach your databases?\n"
|
|
90
|
+
" 1) Auto - sql-buddy opens the SSH tunnel itself\n"
|
|
91
|
+
" (you provide a bastion/jump-host + a private key file)\n"
|
|
92
|
+
" 2) External - you already have a tunnel/connection running\n"
|
|
93
|
+
" (e.g. an `ssh -N` terminal on Windows, or a local DB)\n"
|
|
94
|
+
" sql-buddy just connects to a local host:port"
|
|
95
|
+
)
|
|
96
|
+
while True:
|
|
97
|
+
c = input("\nChoose [1/2]: ").strip()
|
|
98
|
+
if c in ('1', '2'):
|
|
99
|
+
return int(c)
|
|
100
|
+
print(" (enter 1 or 2)")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- bastion (auto mode) --------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _ask_bastion(cfg: configparser.RawConfigParser) -> str:
|
|
106
|
+
print("\n--- Bastion / jump-host ---")
|
|
107
|
+
if sys.platform == "win32" and not shutil.which("ssh"):
|
|
108
|
+
print(
|
|
109
|
+
" note: `ssh` not on PATH. Auto mode needs OpenSSH Client\n"
|
|
110
|
+
" (Windows Settings → Apps → Optional features → OpenSSH Client).\n"
|
|
111
|
+
" Or pick External mode and run `ssh -N` yourself."
|
|
112
|
+
)
|
|
113
|
+
name = _ask("bastion name", "prod")
|
|
114
|
+
section = f"ssh:{name}"
|
|
115
|
+
cfg.add_section(section)
|
|
116
|
+
cfg.set(section, "host", _ask("SSH host"))
|
|
117
|
+
cfg.set(section, "port", str(_ask_int("SSH port", 22)))
|
|
118
|
+
cfg.set(section, "user", _ask("SSH user"))
|
|
119
|
+
key_default = os.path.join("~", ".ssh", "id_ed25519")
|
|
120
|
+
key_raw = _ask(f"Private key path (e.g. {key_default})")
|
|
121
|
+
# Expand ~ and normalize slashes so Windows paths like
|
|
122
|
+
# C:\Users\…\.ssh\id_ed25519 and ~/ .ssh/… both work at runtime.
|
|
123
|
+
key = os.path.normpath(os.path.expanduser(key_raw.strip().strip('"')))
|
|
124
|
+
cfg.set(section, "key", key)
|
|
125
|
+
cfg.set(section, "server_alive_interval", str(_ask_int("ServerAliveInterval (seconds)", 30)))
|
|
126
|
+
cfg.set(section, "server_alive_count_max", str(_ask_int("ServerAliveCountMax", 3)))
|
|
127
|
+
return name
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _default_remote_port(ctype: str) -> int:
|
|
131
|
+
return 9000 if ctype == 'clickhouse' else 3306
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _add_connection_auto(cfg: configparser.RawConfigParser, block: str):
|
|
135
|
+
print("\n--- Database connection ---")
|
|
136
|
+
name = _ask("Connection name (e.g. ck, mysql)")
|
|
137
|
+
cfg.add_section(name)
|
|
138
|
+
ctype = _ask("Type [clickhouse/mysql]", "clickhouse")
|
|
139
|
+
cfg.set(name, "type", ctype)
|
|
140
|
+
cfg.set(name, "tunnel", f"ssh:{block}")
|
|
141
|
+
cfg.set(name, "remote_host", _ask("Remote DB host (as seen FROM the bastion)"))
|
|
142
|
+
cfg.set(name, "remote_port", str(_ask_int("Remote DB port", _default_remote_port(ctype))))
|
|
143
|
+
cfg.set(name, "local_port", str(_ask_int("Local port (on THIS machine, must be free)")))
|
|
144
|
+
cfg.set(name, "user", _ask("DB user (read-only recommended)"))
|
|
145
|
+
cfg.set(name, "password", _ask_password("DB password"))
|
|
146
|
+
db = _ask("Default database", optional=True)
|
|
147
|
+
if db:
|
|
148
|
+
cfg.set(name, "database", db)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- external mode: edit db.ini by hand -----------------------------------
|
|
152
|
+
# External config is simple (host/port/user/password) and often already exists,
|
|
153
|
+
# so we don't prompt for it - we point the user at db.ini to edit directly.
|
|
154
|
+
# Pasting passwords with special chars into a prompt is error-prone; a file edit
|
|
155
|
+
# is safer and lets the user reuse values they already have.
|
|
156
|
+
|
|
157
|
+
_EXTERNAL_TEMPLATE = """\
|
|
158
|
+
# db.ini - EXTERNAL tunnel mode. NEVER commit this file (gitignored).
|
|
159
|
+
#
|
|
160
|
+
# You run the SSH tunnel/connection elsewhere (e.g. `ssh -N` on Windows);
|
|
161
|
+
# sql-buddy connects to host:port below directly. No `tunnel` keys here.
|
|
162
|
+
# Add one [section] per database. See db.ini.example for the full format.
|
|
163
|
+
|
|
164
|
+
[ck]
|
|
165
|
+
type = clickhouse
|
|
166
|
+
host = localhost # local end of your external tunnel
|
|
167
|
+
port = 9000 # LOCAL tunnel port (not the remote DB port)
|
|
168
|
+
user = your_readonly_user
|
|
169
|
+
password = your_password
|
|
170
|
+
database = default
|
|
171
|
+
|
|
172
|
+
# Example MySQL connection - copy & rename to add more:
|
|
173
|
+
# [mysql]
|
|
174
|
+
# type = mysql
|
|
175
|
+
# host = localhost
|
|
176
|
+
# port = 3306
|
|
177
|
+
# user = your_readonly_user
|
|
178
|
+
# password = your_password
|
|
179
|
+
# database = myapp
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
_EXTERNAL_HELP = (
|
|
183
|
+
"Edit db.ini: one [section] per database, with host/port/user/password\n"
|
|
184
|
+
" (and `database` for ClickHouse).\n"
|
|
185
|
+
" - host: local end of your external tunnel (usually localhost)\n"
|
|
186
|
+
" - port: the LOCAL port your tunnel forwards (not the remote DB port)\n"
|
|
187
|
+
f"See {os.path.basename(CONFIG_FILE)}.example for the full format."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
_WSL_HINT = (
|
|
191
|
+
"WSL detected: sql-buddy auto-rewrites `host` to the Windows host IP, so your\n"
|
|
192
|
+
"Windows-side tunnels must bind 0.0.0.0 (your `-L 0.0.0.0:...` already does)."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
_VERIFY_HINT = (
|
|
196
|
+
"Next, verify with:\n"
|
|
197
|
+
" python -c \"import db_query; print(db_query.list_connections())\"\n"
|
|
198
|
+
" python -c \"import db_query; print(db_query.execute_limit_query('SELECT 1','<conn>'))\""
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _write_external_template():
|
|
203
|
+
os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
|
|
204
|
+
with open(CONFIG_FILE, 'w') as f:
|
|
205
|
+
f.write(_EXTERNAL_TEMPLATE)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --- write ----------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def _backup_existing() -> str | None:
|
|
211
|
+
"""Copy current db.ini to a 0600 backup next to it (gitignored as db.ini.bak*)."""
|
|
212
|
+
if not os.path.exists(CONFIG_FILE):
|
|
213
|
+
return None
|
|
214
|
+
n = 1
|
|
215
|
+
while os.path.exists(f"{CONFIG_FILE}.bak{n}"):
|
|
216
|
+
n += 1
|
|
217
|
+
bak = f"{CONFIG_FILE}.bak{n}"
|
|
218
|
+
shutil.copy2(CONFIG_FILE, bak)
|
|
219
|
+
try:
|
|
220
|
+
os.chmod(bak, 0o600)
|
|
221
|
+
except OSError:
|
|
222
|
+
pass
|
|
223
|
+
return bak
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _setup_auto():
|
|
227
|
+
cfg = configparser.RawConfigParser()
|
|
228
|
+
block = _ask_bastion(cfg)
|
|
229
|
+
_add_connection_auto(cfg, block)
|
|
230
|
+
while _yes_no("\nAdd another connection?", default=False):
|
|
231
|
+
_add_connection_auto(cfg, block)
|
|
232
|
+
_write_cfg(cfg, "auto - self-managed SSH tunnels")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _write_cfg(cfg, mode_label: str):
|
|
236
|
+
"""Back up any existing db.ini, then write `cfg` to CONFIG_FILE at mode 0600.
|
|
237
|
+
|
|
238
|
+
Shared by the interactive and non-interactive paths so they can never
|
|
239
|
+
drift in how they persist + verify. Mode 0600 so plaintext DB passwords
|
|
240
|
+
are never world-readable under a permissive umask.
|
|
241
|
+
"""
|
|
242
|
+
import stat
|
|
243
|
+
import tempfile
|
|
244
|
+
bak = _backup_existing()
|
|
245
|
+
d = os.path.dirname(CONFIG_FILE)
|
|
246
|
+
os.makedirs(d, exist_ok=True)
|
|
247
|
+
# Preserve existing mode if already strict; otherwise force 0600.
|
|
248
|
+
mode = 0o600
|
|
249
|
+
if os.path.exists(CONFIG_FILE):
|
|
250
|
+
mode = stat.S_IMODE(os.stat(CONFIG_FILE).st_mode) | 0o600
|
|
251
|
+
# never widen beyond owner-only for a credential file
|
|
252
|
+
mode = mode & 0o700
|
|
253
|
+
if mode == 0:
|
|
254
|
+
mode = 0o600
|
|
255
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".db.ini.")
|
|
256
|
+
try:
|
|
257
|
+
try:
|
|
258
|
+
os.chmod(tmp, 0o600)
|
|
259
|
+
except OSError:
|
|
260
|
+
pass # Windows: chmod is best-effort
|
|
261
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
|
262
|
+
cfg.write(f)
|
|
263
|
+
os.replace(tmp, CONFIG_FILE)
|
|
264
|
+
try:
|
|
265
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
266
|
+
except OSError:
|
|
267
|
+
pass
|
|
268
|
+
finally:
|
|
269
|
+
if os.path.exists(tmp):
|
|
270
|
+
try:
|
|
271
|
+
os.remove(tmp)
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
print(f"\nWrote {CONFIG_FILE} (mode: {mode_label}, perms 0600)")
|
|
275
|
+
if bak:
|
|
276
|
+
print(f"Previous config backed up to {bak}")
|
|
277
|
+
print("\n" + _VERIFY_HINT)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _setup_external():
|
|
281
|
+
# Don't prompt for connection details - external config is simple and often
|
|
282
|
+
# already exists. Point the user at db.ini to edit by hand instead.
|
|
283
|
+
if os.path.exists(CONFIG_FILE):
|
|
284
|
+
print(f"\nYou already have a db.ini - edit it directly:\n {CONFIG_FILE}")
|
|
285
|
+
else:
|
|
286
|
+
_write_external_template()
|
|
287
|
+
print(f"\nWrote a starter db.ini - edit it to fill in your connections:\n {CONFIG_FILE}")
|
|
288
|
+
print("\n" + _EXTERNAL_HELP)
|
|
289
|
+
if _is_wsl():
|
|
290
|
+
print("\n" + _WSL_HINT)
|
|
291
|
+
print("\n" + _VERIFY_HINT)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def main(argv: list[str] | None = None):
|
|
295
|
+
import argparse
|
|
296
|
+
parser = argparse.ArgumentParser(
|
|
297
|
+
prog="sql-buddy-init",
|
|
298
|
+
description="sql-buddy setup wizard — writes connections/config/db.ini",
|
|
299
|
+
)
|
|
300
|
+
parser.add_argument(
|
|
301
|
+
"--non-interactive", action="store_true",
|
|
302
|
+
help="write db.ini from a JSON spec without prompting (for agents / CI)",
|
|
303
|
+
)
|
|
304
|
+
# No --json '<inline>': argv is visible in process listings and shell/agent
|
|
305
|
+
# history, so credentials must never travel that way. Use stdin or a file.
|
|
306
|
+
parser.add_argument("--json-file", dest="json_file",
|
|
307
|
+
help="path to a setup-spec JSON file (use '-' for stdin)")
|
|
308
|
+
parser.add_argument("--json-stdin", action="store_true",
|
|
309
|
+
help="read the setup spec from stdin (preferred for secrets)")
|
|
310
|
+
args = parser.parse_args(argv)
|
|
311
|
+
|
|
312
|
+
if args.non_interactive:
|
|
313
|
+
_setup_non_interactive(_load_spec(args.json_file, args.json_stdin))
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
print(
|
|
317
|
+
"=== sql-buddy setup ===\n"
|
|
318
|
+
f"Configures {CONFIG_FILE}"
|
|
319
|
+
)
|
|
320
|
+
mode = _choose_mode()
|
|
321
|
+
if mode == 1:
|
|
322
|
+
_setup_auto()
|
|
323
|
+
else:
|
|
324
|
+
_setup_external()
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# --- non-interactive spec -> db.ini ---------------------------------------
|
|
329
|
+
# Driven by a JSON object so an agent (or CI) can produce db.ini without the
|
|
330
|
+
# interactive prompts. Secrets stay local: they go straight from the spec into
|
|
331
|
+
# db.ini, never echoed.
|
|
332
|
+
#
|
|
333
|
+
# Schema (auto mode):
|
|
334
|
+
# {"mode":"auto",
|
|
335
|
+
# "ssh": {"name":"prod","host":"...","port":22,"user":"...","key":"~/.ssh/...",
|
|
336
|
+
# "server_alive_interval":30,"server_alive_count_max":3},
|
|
337
|
+
# "connections":[{"name":"ck","type":"clickhouse",
|
|
338
|
+
# "remote_host":"10.0.0.21","remote_port":9000,"local_port":10021,
|
|
339
|
+
# "user":"ro","password":"...","database":"default"}]}
|
|
340
|
+
#
|
|
341
|
+
# Schema (external mode):
|
|
342
|
+
# {"mode":"external",
|
|
343
|
+
# "connections":[{"name":"ck","type":"clickhouse","host":"localhost","port":9000,
|
|
344
|
+
# "user":"ro","password":"...","database":"default"}]}
|
|
345
|
+
|
|
346
|
+
def _guess_type(name: str) -> str:
|
|
347
|
+
"""Mirror the section-name inference used at query time (ck*->clickhouse, else mysql)."""
|
|
348
|
+
low = name.lower()
|
|
349
|
+
if low.startswith("ck"):
|
|
350
|
+
return "clickhouse"
|
|
351
|
+
if low.startswith(("db", "mysql")):
|
|
352
|
+
return "mysql"
|
|
353
|
+
return "mysql"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
_VALID_TYPES = frozenset({"clickhouse", "mysql", "mariadb"})
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _require(d: dict, key: str, where: str, *, allow_empty: bool = False):
|
|
360
|
+
"""Require a field. `allow_empty=True` permits '' (passwordless DB users)."""
|
|
361
|
+
if key not in d or d[key] is None:
|
|
362
|
+
raise SystemExit(f"{where}: missing required field '{key}'")
|
|
363
|
+
if d[key] == "" and not allow_empty:
|
|
364
|
+
raise SystemExit(f"{where}: missing required field '{key}'")
|
|
365
|
+
return d[key]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _require_port(d: dict, key: str, where: str, default=None) -> int:
|
|
369
|
+
"""Require an integer port in 1..65535 (matches runtime int(port))."""
|
|
370
|
+
if key not in d or d[key] in (None, ""):
|
|
371
|
+
if default is not None:
|
|
372
|
+
return int(default)
|
|
373
|
+
raise SystemExit(f"{where}: missing required field '{key}'")
|
|
374
|
+
try:
|
|
375
|
+
p = int(d[key])
|
|
376
|
+
except (TypeError, ValueError):
|
|
377
|
+
raise SystemExit(f"{where}: '{key}' must be an integer port, got {d[key]!r}")
|
|
378
|
+
if not (1 <= p <= 65535):
|
|
379
|
+
raise SystemExit(f"{where}: '{key}' must be 1..65535, got {p}")
|
|
380
|
+
return p
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _resolve_type(conn: dict, name: str) -> str:
|
|
384
|
+
raw = conn.get("type")
|
|
385
|
+
if raw in (None, ""):
|
|
386
|
+
return _guess_type(name)
|
|
387
|
+
ctype = str(raw).strip().lower()
|
|
388
|
+
if ctype not in _VALID_TYPES:
|
|
389
|
+
raise SystemExit(
|
|
390
|
+
f"connection [{name}]: unsupported type '{ctype}' "
|
|
391
|
+
f"(want clickhouse|mysql|mariadb)"
|
|
392
|
+
)
|
|
393
|
+
return ctype
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _add_conn_section(cfg, conn: dict, *, auto_block: str | None):
|
|
397
|
+
name = _require(conn, "name", "connection")
|
|
398
|
+
# Runtime skips every [ssh:*] section as a bastion block (case-insensitive).
|
|
399
|
+
if str(name).lower().startswith("ssh:"):
|
|
400
|
+
raise SystemExit(
|
|
401
|
+
f"connection name '{name}' is reserved (ssh:* sections are bastions, "
|
|
402
|
+
"not Connections)"
|
|
403
|
+
)
|
|
404
|
+
if cfg.has_section(name) or cfg.has_section(f"ssh:{name}"):
|
|
405
|
+
raise SystemExit(f"connection name '{name}' is duplicated or reserved")
|
|
406
|
+
cfg.add_section(name)
|
|
407
|
+
cfg.set(name, "type", _resolve_type(conn, name))
|
|
408
|
+
if auto_block is not None:
|
|
409
|
+
cfg.set(name, "tunnel", f"ssh:{auto_block}")
|
|
410
|
+
cfg.set(name, "remote_host",
|
|
411
|
+
str(_require(conn, "remote_host", f"connection [{name}]")))
|
|
412
|
+
cfg.set(name, "remote_port",
|
|
413
|
+
str(_require_port(conn, "remote_port", f"connection [{name}]")))
|
|
414
|
+
cfg.set(name, "local_port",
|
|
415
|
+
str(_require_port(conn, "local_port", f"connection [{name}]")))
|
|
416
|
+
cfg.set(name, "user", str(_require(conn, "user", f"connection [{name}]")))
|
|
417
|
+
# Empty password is valid (passwordless local MySQL / some ClickHouse setups).
|
|
418
|
+
cfg.set(name, "password",
|
|
419
|
+
str(_require(conn, "password", f"connection [{name}]",
|
|
420
|
+
allow_empty=True)))
|
|
421
|
+
else:
|
|
422
|
+
cfg.set(name, "host", str(_require(conn, "host", f"connection [{name}]")))
|
|
423
|
+
cfg.set(name, "port",
|
|
424
|
+
str(_require_port(conn, "port", f"connection [{name}]")))
|
|
425
|
+
cfg.set(name, "user", str(_require(conn, "user", f"connection [{name}]")))
|
|
426
|
+
cfg.set(name, "password",
|
|
427
|
+
str(_require(conn, "password", f"connection [{name}]",
|
|
428
|
+
allow_empty=True)))
|
|
429
|
+
if conn.get("database"):
|
|
430
|
+
cfg.set(name, "database", str(conn["database"]))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _cfg_from_auto_spec(spec: dict) -> tuple[configparser.RawConfigParser, str]:
|
|
434
|
+
ssh = spec.get("ssh") or {}
|
|
435
|
+
block = ssh.get("name") or "prod"
|
|
436
|
+
section = f"ssh:{block}"
|
|
437
|
+
cfg = configparser.RawConfigParser()
|
|
438
|
+
cfg.add_section(section)
|
|
439
|
+
cfg.set(section, "host", str(_require(ssh, "host", "ssh")))
|
|
440
|
+
cfg.set(section, "port", str(_require_port(ssh, "port", "ssh", default=22)))
|
|
441
|
+
cfg.set(section, "user", str(_require(ssh, "user", "ssh")))
|
|
442
|
+
key = str(_require(ssh, "key", "ssh")).strip().strip('"')
|
|
443
|
+
cfg.set(section, "key", os.path.normpath(os.path.expanduser(key)))
|
|
444
|
+
# keepalive is seconds (not a TCP port) — any non-negative / positive int.
|
|
445
|
+
if "server_alive_interval" in ssh:
|
|
446
|
+
try:
|
|
447
|
+
sai = int(ssh["server_alive_interval"])
|
|
448
|
+
if sai < 0:
|
|
449
|
+
raise ValueError
|
|
450
|
+
except (TypeError, ValueError):
|
|
451
|
+
raise SystemExit("ssh: server_alive_interval must be a non-negative integer")
|
|
452
|
+
cfg.set(section, "server_alive_interval", str(sai))
|
|
453
|
+
else:
|
|
454
|
+
cfg.set(section, "server_alive_interval", "30")
|
|
455
|
+
if "server_alive_count_max" in ssh:
|
|
456
|
+
try:
|
|
457
|
+
sac = int(ssh["server_alive_count_max"])
|
|
458
|
+
if sac < 1:
|
|
459
|
+
raise ValueError
|
|
460
|
+
except (TypeError, ValueError):
|
|
461
|
+
raise SystemExit("ssh: server_alive_count_max must be a positive integer")
|
|
462
|
+
cfg.set(section, "server_alive_count_max", str(sac))
|
|
463
|
+
else:
|
|
464
|
+
cfg.set(section, "server_alive_count_max", "3")
|
|
465
|
+
conns = spec.get("connections") or []
|
|
466
|
+
if not conns:
|
|
467
|
+
raise SystemExit("at least one connection is required")
|
|
468
|
+
for c in conns:
|
|
469
|
+
_add_conn_section(cfg, c, auto_block=block)
|
|
470
|
+
return cfg, "auto - self-managed SSH tunnels"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _cfg_from_external_spec(spec: dict) -> tuple[configparser.RawConfigParser, str]:
|
|
474
|
+
cfg = configparser.RawConfigParser()
|
|
475
|
+
conns = spec.get("connections") or []
|
|
476
|
+
if not conns:
|
|
477
|
+
raise SystemExit("at least one connection is required")
|
|
478
|
+
for c in conns:
|
|
479
|
+
_add_conn_section(cfg, c, auto_block=None)
|
|
480
|
+
return cfg, "external"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _load_spec(json_file: str | None, json_stdin: bool) -> dict:
|
|
484
|
+
"""Resolve the setup spec to a JSON object.
|
|
485
|
+
|
|
486
|
+
Secret-safe sources only (no inline argv JSON):
|
|
487
|
+
stdin (--json-stdin, or --json-file -) > protected file (--json-file)
|
|
488
|
+
> $SQL_BUDDY_SETUP_JSON env.
|
|
489
|
+
"""
|
|
490
|
+
raw: str | None = None
|
|
491
|
+
if json_stdin or json_file == "-":
|
|
492
|
+
raw = sys.stdin.read()
|
|
493
|
+
elif json_file:
|
|
494
|
+
with open(os.path.expanduser(json_file)) as f:
|
|
495
|
+
raw = f.read()
|
|
496
|
+
elif os.environ.get("SQL_BUDDY_SETUP_JSON") is not None:
|
|
497
|
+
raw = os.environ.get("SQL_BUDDY_SETUP_JSON")
|
|
498
|
+
if raw is None:
|
|
499
|
+
raise SystemExit(
|
|
500
|
+
"--non-interactive needs one of: --json-stdin / --json-file <path|-> "
|
|
501
|
+
"/ $SQL_BUDDY_SETUP_JSON (credentials must not travel via argv)"
|
|
502
|
+
)
|
|
503
|
+
try:
|
|
504
|
+
spec = json.loads(raw)
|
|
505
|
+
except json.JSONDecodeError as e:
|
|
506
|
+
raise SystemExit(f"invalid setup JSON: {e}")
|
|
507
|
+
if not isinstance(spec, dict):
|
|
508
|
+
raise SystemExit("setup spec must be a JSON object")
|
|
509
|
+
return spec
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _setup_non_interactive(spec: dict):
|
|
513
|
+
mode = (spec.get("mode") or "auto").lower()
|
|
514
|
+
if mode == "auto":
|
|
515
|
+
cfg, label = _cfg_from_auto_spec(spec)
|
|
516
|
+
elif mode == "external":
|
|
517
|
+
cfg, label = _cfg_from_external_spec(spec)
|
|
518
|
+
else:
|
|
519
|
+
raise SystemExit(f"unknown mode '{mode}' (want 'auto' or 'external')")
|
|
520
|
+
_write_cfg(cfg, label)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
if __name__ == "__main__":
|
|
524
|
+
try:
|
|
525
|
+
main()
|
|
526
|
+
except KeyboardInterrupt:
|
|
527
|
+
print("\nAborted - db.ini unchanged.")
|
|
528
|
+
sys.exit(1)
|