脚本里的 ssh -L 从来没成功过:非交互终端读不到密码
同一份脚本、同一个口令,终端里一次就过,放到任务面板里连问两遍密码然后退出。没有 TTY 时 ssh 会从标准输入读密码,而那里不是密码。
为了省事,我写了一个 tunnels.sh,一条命令把十个中间件端口全转发到本地。在终端里跑,十秒就绪,ss -lntp 能看到十个监听。后来把它挂到 IDE 的任务面板上,方便每天开机点一下,结果点了之后什么也没发生。
脚本没报错,退出码是 0,127.0.0.1:8848 上什么都没有。同一份脚本、同一台机器、同一个账号口令,换个启动方式就失效 —— 这种事最费时间,因为"没有报错"会让人先怀疑自己记错了端口。
一、先确认它到底有没有连上
脚本里我加了一行日志,把 ssh 的 stderr 打出来:
ssh -N -o ExitOnForwardFailure=yes \
-L 127.0.0.1:8848:10.96.73.146:8848 \
-L 127.0.0.1:9200:10.96.44.12:9200 \
ops@192.0.2.10 2>&1 | tee /tmp/tunnel.log
从任务面板启动时,日志里是这两行:
ops@192.0.2.10's password:
Permission denied, please try again.
ops@192.0.2.10's password:
Permission denied (publickey,password).
问了两遍密码,两遍都错。而我在终端里手敲同一个密码,一次就过。所以不是密码错,是它拿到的东西不是密码。
二、把端口占用和网络先排掉
这两项排除掉之后,才能安心怀疑认证方式:
# 十个端口是否已被别的进程占了
ss -lntp | awk 'NR==1 || /:(8848|9200|9000|29100|8091|8858|9120|27017|6379|9848)\s/'
# 从本机能不能到跳板机的 22
nc -vz -w3 192.0.2.10 22
端口全空、22 可达。如果这两步有问题,ssh -L 也会失败,但报错是 bind: Address already in use 或者 Connection timed out,和密码错误完全是两回事。
三、非 TTY 环境下 ssh 读不到密码
关键在 ssh 取密码的方式。它优先打开 /dev/tty 直接和终端对话,这条路径不经过 stdin,所以密码不会被管道吞掉。当进程没有控制终端时,/dev/tty 打不开,ssh 退化成从 stdin 读。
而任务面板、| tee、nohup、双击运行这些方式,要么根本没有 TTY,要么 stdin 已经被占用(比如被管道接走、被设成 /dev/null)。于是 ssh 读到的是空字符串或者一段别的内容,认证自然失败。它还会重试一次,所以日志里是两遍 Permission denied。
这也解释了为什么"手敲能过":终端里有 TTY,密码从终端读,stdin 是什么都不影响。
| 启动方式 | 有 TTY | ssh 从哪里读密码 | 结果 |
|---|---|---|---|
| 交互终端里直接跑 | 是 | /dev/tty |
正常 |
| 任务面板 / CI 步骤 | 否 | stdin(空或已占用) | 两次失败后退出 |
bash tunnels.sh > log 2>&1 |
是 | /dev/tty |
正常,提示会打到终端 |
bash tunnels.sh | tee log |
否(stdin 被接走) | stdin | 失败 |
setsid / 后台 & |
否 | stdin | 失败 |
顺带说一句:sshpass、SSH_ASKPASS 这类方案能绕过去,但都是把口令塞进环境变量或者命令行参数,ps 里能看到,本机多用户环境下不合适。Expect 也能用,可维护性差一些。
四、改用 paramiko 做转发
最后换成 Python,口令从环境变量取,转发用库自己实现,不依赖 TTY:
import os, select, socketserver, paramiko, threading
HOST, USER = "192.0.2.10", "ops"
PASS = os.environ["LH_SSH_PASS"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASS, look_for_keys=False, timeout=8)
class Handler(socketserver.BaseRequestHandler):
def handle(self):
chan = client.get_transport().open_channel(
"direct-tcpip", (self.server.target_host, self.server.target_port),
self.request.getpeername())
while True:
r, _, _ = select.select([self.request, chan], [], [], 0.5)
if self.request in r:
data = self.request.recv(4096)
if not data: break
chan.send(data)
if chan in r:
data = chan.recv(4096)
if not data: break
self.request.send(data)
chan.close()
class Forward(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
def serve(local_port, target_host, target_port):
srv = Forward(("127.0.0.1", local_port), Handler)
srv.target_host, srv.target_port = target_host, target_port
threading.Thread(target=srv.serve_forever, daemon=True).start()
serve(8848, "10.96.73.146", 8848)
serve(9200, "10.96.44.12", 9200)
print("ready")
threading.Event().wait()
direct-tcpip 就是 ssh -L 在协议层的等价物:告诉远端"帮我连这个地址和端口,把字节流转回来"。区别是认证在 Python 里完成,和有没有终端无关。
五、目标地址要写 ClusterIP,不要写服务名
第一版我把目标写成了 mysql.app.svc.cluster.local:3306,隧道能建立,一连就报:
socket.gaierror: [Errno -2] Name or service not known
原因是名字解析发生在跳板机上,不是集群里。跳板机是普通主机,没有 cluster DNS,*.svc.cluster.local 解析不了。
解决办法是写 Service 的 ClusterIP,并且在启动时向集群查一次,不要硬编码:
kubectl -n app get svc mysql -o jsonpath='{.spec.clusterIP}'
ClusterIP 在 Service 重建后会变,写死在脚本里迟早失效。我把它改成启动时用 kubectl 查一遍再生成转发表,Service 重建也不用改脚本。
六、Windows 侧还有两个坑
开发机是 Windows,除了 TTY 之外还有两处会绊人。
第一个是 localhost。隧道只监听 IPv4 回环,而 Windows 上 localhost 会先尝试 ::1,连不上再回退 IPv4,某些客户端不做回退,表现就是"端口在监听但连不上"。一律写 127.0.0.1:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8848/nacos/
第二个是 Git Bash 的路径改写。用 scp 往远端传文件时,/home/ops/ 这种以斜杠开头的参数会被自动转换成 D:/Program Files/Git/home/ops/,报错是 D:/Program: Is a directory,看起来完全不相干。加一个环境变量关掉:
export MSYS_NO_PATHCONV=1
scp ./dist.tar.gz ops@192.0.2.10:/home/ops/dist.tar.gz
七、现在怎么用
十个隧道统一由一个入口启动,启动后自检一遍,全部通了才返回:
export LH_SSH_PASS='***'
python lh-dev/tunnels.py --check
# 8848 ok / 9200 ok / 9000 ok / 29100 ok / 8091 ok ...
# 10/10 ready
--check 会逐个 connect_ex,输出里出现 fail 就退出码非 0,挂到开机任务里也不会"静默失败"。这一点比原来的脚本重要:没有报错不等于成功,尤其是后台启动的东西。
小结
ssh -L在非 TTY 环境会退化成从 stdin 读密码,标准输入不是终端时必然失败,报错却只是Permission denied。- 排查顺序是端口占用、网络可达、认证方式,前两步各自有明确报错,不要一上来就怀疑密码。
- 用 paramiko 的
direct-tcpip替代,认证不依赖终端,口令走环境变量而不是命令行参数。 - 隧道目标写 ClusterIP,名字解析发生在跳板机上,
*.svc.cluster.local在那边不存在。 - Windows 上客户端一律写
127.0.0.1,localhost可能先走 IPv6;Git Bash 传文件前export MSYS_NO_PATHCONV=1。 - 后台启动的东西必须自带连通性自检,退出码要能反映结果,否则"没报错"会骗人很久。
正在加载评论…