脚本里的 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。
  • 后台启动的东西必须自带连通性自检,退出码要能反映结果,否则"没报错"会骗人很久。
查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。