被堡垒机封了端口转发?把 SSH 终端通道封成 MySQL 可视化客户端
一个被 JumpServer 协议层封死端口转发的合规内网,最终用 paramiko + Flask 在不绕开授权的前提下给运维和开发提供了一套"准 Navicat"体验。本文复盘从踩坑到全栈实现的关键技术细节。
一、痛点:堡垒机协议层封了端口转发,下游全死
公司用的是市面上最常见的 JumpServer 堡垒机,登录路径是这样的:
markdown
本地 → 堡垒机域名:端口
→ 输动态码(keyboard-interactive MFA,TOTP)
→ 在堡垒机 shell 里输服务器 ID
→ 跳转到目标内网机器
→ sudo su / 执行业务命令
数据库则散布在内网两类机器上:
- 每台业务机本机自带 MySQL(分片库,业务数据要按分片查)
- 集中 DB 机 一台,放公共业务库 A / B / C / D 等
合规要求所有 DB 访问都必须走堡垒机。痛点开始浮现:
- Navicat / Workbench / DBeaver 的 SSH 隧道全部走不通;
ssh -L 13306:127.0.0.1:3306 <jump>也走不通;- 哪怕用
ProxyJump二段链,依然走不通。
报错全是一句:
arduino
channel 3: open failed: administratively prohibited: direct-tcpip not support
关键事实 :JumpServer 在协议层 禁用了
direct-tcpip通道。这是合规要求,不是网络配置------别再试隧道方案了,整条路是死的。
二、思路换轨:放弃端口转发,复用终端通道
走不通隧道,那就老老实实走终端:
css
本地 paramiko → 跳板机 PTY → 跳转目标机 → 跑 mysql --batch → 抓文本 → 本地解析
这个思路有几个看似不起眼但要命的细节:
- MFA 不能让用户每次输一遍------浏览器点一个 SQL 就要输一次动态码,谁忍得了?
- mysql 命令在很多机器上不在
PATH(业务机往往装在自定义路径,比如/data/mysql/bin/mysql); - SQL 里的引号、反引号、换行塞进 shell 命令会被双重转义吃掉;
- 密码不能进 argv ,否则
ps -ef一抓就泄露; - 多个并发查询不能互相串扰,且不希望每次都重新认证;
- PTY 终端输出夹杂着命令回显、shell prompt、ANSI 转义码,得把"真正的查询结果"从这一坨里挑出来。
把这些坑挨个填了,就有了一套相对稳的方案。
三、核心设计:单 Transport + 多 PTY Channel
paramiko 的设计哲学很对胃口:一个已认证的 Transport(TCP 长连接 + 加密会话)上可以开任意多个独立的 channel,每个 channel 各自跑一个 PTY shell。
python
class JumpHostSession:
"""跳板机的持久 Transport,全程只认证一次。"""
def connect(self):
sock = socket.create_connection((host, port), timeout=self._timeout)
self._transport = paramiko.Transport(sock)
self._transport.connect()
# 密码 + keyboard-interactive (MFA) 二段认证
remaining = self._transport.auth_password(username, password)
if remaining:
self._transport.auth_interactive(username, self._mfa_handler)
def open_worker(self) -> "JumpWorker":
"""同一 transport 上开一个独立 PTY shell,可并发开 N 个。"""
channel = self._transport.open_session()
channel.get_pty(term="xterm", width=220, height=50)
channel.invoke_shell()
return JumpWorker(channel, self._timeout)
调用关系长这样:
markdown
JumpHostSession ── 跳板机持久 Transport(只认证一次)
└─ JumpWorker ── 同一 Transport 上的独立 PTY shell,可并发多个
└─ ServerSession ── 单台目标机会话(context manager)
这个结构的好处:
- 认证一次,全程复用:动态码只在工具启动时输一次,浏览器后续点 SQL 不再 prompt;
- 并发不串台:每个 worker 独占自己的 channel 和读缓冲;
- 跨机器查询可以缓存 worker:把"已经跳转进 ssh_host A 的那个 worker"留着,下次查 A 上的 DB 不重新跳。
四、关键技术点(真干货)
1. MFA 自动化:TOTP 种子 + auth_interactive
主流堡垒机的动态码本质是 RFC 6238 的 TOTP(常见 30 秒周期,部分企业方案配 60 秒)。paramiko 的 auth_interactive 接受一个 prompt handler,由我们填回应:
python
def _mfa_handler(self, title, instructions, prompts):
responses = []
for prompt, echo in prompts:
if self._totp: # 配了 totp_secret
responses.append(self._totp.now())
else: # 没配 → 终端手输
responses.append(input(f"\n[动态码] {prompt.strip()} ").strip())
return responses
totp_secret 仅落在本地 config.json,文件入 _secrets/ 子目录且 git 忽略。
2. keep-alive 心跳,避免会话被堡垒机 idle kill
很多堡垒机 idle 5 分钟就掐连接,本机开着 Web 界面但用户中午吃饭去了------回来一刷 SQL 全报"connection lost"。
paramiko 提供了 SSH IGNORE 包,作为低开销心跳:
python
def start_keepalive(self, interval: int = 30):
def _ka():
while not self._ka_stop.wait(interval):
if self._transport and self._transport.is_active():
self._transport.send_ignore()
self._ka_thread = threading.Thread(target=_ka, daemon=True, name="ssh-keepalive")
self._ka_thread.start()
注意 daemon=True------主进程退出时心跳线程跟着死,不留僵尸。
3. marker 包裹 + ANSI 剥离:从 PTY 噪音里抽结果
PTY 输出长这样(缩简版):
ruby
\x1b[?2004h[root@db ~]# echo X3f7a2; mysql -uadmin ...; echo Y8d1c4:$?
X3f7a2
id name
1 Alice
2 Bob
Y8d1c4:0
\x1b[?2004l[root@db ~]#
里面同时混着:命令回显、shell prompt、ANSI 控制序列、真正的查询结果、退出码。
解法是用随机 marker 把真正的输出夹起来:
python
def _exec(self, w, cmd: str, timeout: int = 120):
start = "X" + uuid.uuid4().hex[:10]
end = "Y" + uuid.uuid4().hex[:10]
w._send(f"echo {start}; {cmd}; echo {end}:$?\n")
raw = _strip_ansi(w._read_until(rf"{re.escape(end)}:\d", timeout=timeout))
si = raw.rfind(start)
ei = raw.rfind(end)
code = int(re.search(rf"{re.escape(end)}:(\d+)", raw[ei:]).group(1))
return code, raw[si + len(start):ei].strip("\n")
uuid 保证 marker 不会和数据撞车,rfind 而不是 find 是为了对付命令本身也回显了 marker 字符串的情况------只有最后一对 marker 才是真分隔符。
ANSI 转义码用一条经过实战验证的正则吃掉:
python
_ANSI_RE = re.compile(r'\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
4. base64 转送 SQL:彻底躲过 shell 转义地狱
用户在 Web 上敲的 SQL 可能长这样:
sql
SELECT id, JSON_EXTRACT(data, '$.items[*].id') AS items
FROM t_user
WHERE nickname LIKE '%admin\'s alt%' AND created_at > '2026-01-01';
里面有单引号、双引号、反斜杠、$、*、%------任何一个塞进 mysql -e "..." 都得二次转义,错一个就废。
解法简单粗暴:本地 base64 编码,远端解码后管道喂给 mysql:
python
sql_b64 = base64.b64encode(sql.encode("utf-8")).decode("ascii")
mysql_cmd = (f"echo {sql_b64} | base64 -d | "
f"{mysql_bin} -h{host} -P{port} -u{user} {db_arg} "
f"--batch --column-names 2>&1")
--batch 让 mysql 输出 tab 分隔(机器可读),--column-names 第一行是列名。
5. MYSQL_PWD:密码不进 argv
mysql -p"$pwd" 是错的------ps -ef 任何同机用户都能看到完整密码。正解是用环境变量:
python
w._send(f"export MYSQL_PWD='{pw}'\n")
export 完的环境变量只活在这个 worker 的 shell 进程里,关闭 worker 即销毁。
6. mysql 客户端路径探测
业务机为了不让 PATH 污染应用环境,常把 mysql 客户端塞在自定义路径(比如 /data/mysql/bin/mysql);集中 DB 机则一般在 /usr/bin/mysql。直接 mysql 就会 command not found,且这个错误信息会被新人误判成"机器没装 mysql"。
按优先级探测,找到为止:
python
_MYSQL_PATHS = ["/data/mysql/bin/mysql", "/usr/local/mysql/bin/mysql",
"/usr/bin/mysql", "/usr/local/bin/mysql"]
def _detect_mysql(self, w) -> str:
cands = " ".join(_MYSQL_PATHS)
code, out = self._exec(
w, f"command -v mysql 2>/dev/null || ls {cands} 2>/dev/null | head -1")
return out.strip().split("\n")[0].strip() or "mysql"
探到的路径按 ssh_host 缓存,第二次查同一机器直接命中,省一轮往返。
7. sudo 智能降级:免密则切、要密码则取消
部分机器需要 root 才能跑 mysql(账户文件 600 给 root)。但有的账户配了 NOPASSWD,有的没有。脚本里既不能死等密码 prompt 把整个 worker 卡死,也不能一上来就把所有用户当无 sudo:
python
def _enter_root(self, w):
w._send("sudo su\n")
got = _strip_ansi(w._read_until(r"#\s*$|[Pp]assword", timeout=8))
if re.search(r"[Pp]assword", got):
w._send("\x03") # Ctrl-C 取消 sudo password prompt
w._read_until(_PROMPT, timeout=5)
# else: 顺利切到 root(提示符已经变成 `#`)
\x03 是 Ctrl-C 的字节序列,在 PTY 里等价于真按了一下。
五、上层:Flask + 模板 = 浏览器可视化
底层 DbSession.query(sql, ssh_host, database) 返回的是 {columns, rows, rowcount, error} 字典,套个 Flask 就是 Web 客户端:
arduino
启动 python db_web.py → 终端输一次动态码
浏览器 http://127.0.0.1:5000
├── 左侧:库表树(点表名出 schema)
├── 中间:SQL 编辑器 + 历史
├── 右侧:结果表格(前端分页 + 排序)
└── 顶栏:server_host 下拉 / 只读开关 / 导出 CSV·Excel
界面没什么花活,关键是调用链稳 :浏览器 fetch → Flask 路由 → DbSession.query → paramiko worker → mysql → tab 解析 → JSON 返回。整链路 < 300ms,比 Navicat 慢一点点(Navicat 走 native 协议),但合规、不耗第二张令牌。
六、复盘
回看整个工具,最大的省力点不是 Web 界面,而是承认"端口转发死路一条"早------前面几年陆陆续续有同事试过 sshuttle、autossh、frp 反代,没一个走通。承认这一点之后,问题立刻从"怎么打通隧道"退化成"怎么从一个交互式 PTY 里精确抽出结构化数据"------这是个有成熟解法的工程问题。
技术栈也很朴素:
paramiko:SSH 客户端,绕开 git bash MSYSssh在 PowerShell 里 TTY 卡死的坑;pyotp:TOTP 自动生成;Flask+Jinja2:最薄的 Web 层;- 一条 ANSI 正则 + 一对 marker:解析 PTY 噪音。
没用任何 ORM、没装任何数据库 driver------所有结构化全来自 mysql --batch 的 tab 输出 + 一个手写 unescape:
python
def _unescape(s: str) -> str:
"""Reverse mysql --batch escaping (\\t \\n \\0 \\\\)."""
out, i = [], 0
while i < len(s):
if s[i] == "\\" and i + 1 < len(s):
out.append({"t": "\t", "n": "\n", "0": "\0", "\\": "\\"}.get(s[i+1], s[i+1]))
i += 2
else:
out.append(s[i]); i += 1
return "".join(out)
写工具的快慢,往往不取决于你写了多快,而取决于你多快放弃错的方向。
文中工具是个人产能工具,未开源;如果你也在被堡垒机封掉的内网里挣扎,希望这套思路能省你几个小时。