连接不上时,先判断停在网络、主机身份还是用户认证;需要转发时,先确定谁监听、谁连接目标。这两件事弄清楚,再选命令通常就很直接。
本文面向 OpenSSH 客户端和 Linux 服务器。ops 是示例远程用户,server.example.com 是示例主机;后文的 server、jump、internal 是你在 ~/.ssh/config 中定义的别名。命令标注“本机”或“服务器”的地方,请在对应机器执行。
按当前任务找入口
连接与配置
ssh ops@server.example.com
首次连接先核验主机指纹;ops 是目标账户,不是本机用户名。需要非默认参数时改选下面的完整形式。
ssh -p 2222 -i ~/.ssh/id_ed25519_ops -o IdentitiesOnly=yes ops@server.example.com
-p 是服务器端口,-i 是本机私钥路径,IdentitiesOnly 限制尝试的身份;与上一条是备选连接方式。
下一份配置定义 server 别名;保存配置后,登录可简化为 ssh server。只执行命令时使用下一段,执行位置仍是本机。
ssh server 'uname -a'
引号内命令由远端 shell 解释;核对输出来自预期主机。脚本使用时检查 SSH 返回码,255 通常是连接/SSH 错误,其他值可能是远端命令退出码。
经常连接的机器写入 ~/.ssh/config,后续 ssh、scp 和 rsync 都可复用别名:
Host server
HostName server.example.com
User ops
Port 2222
IdentityFile ~/.ssh/id_ed25519_ops
IdentitiesOnly yes
Host jump
HostName bastion.example.com
User ops
IdentityFile ~/.ssh/id_ed25519_ops
IdentitiesOnly yes
Host internal
HostName 10.0.0.20
User ops
ProxyJump jump
IdentityFile ~/.ssh/id_ed25519_ops
IdentitiesOnly yes
Host *
ServerAliveInterval 60
ServerAliveCountMax 3
具体主机放前面,通用默认值放后面:多数配置项采用先取得的值,不是后面的块覆盖前面;IdentityFile 等可累加的项有自己的规则。IdentitiesOnly yes 限制所尝试的配置身份,避免把 agent 中所有密钥逐个试过去。OpenSSH 配置规则
ServerAliveInterval 在没有收到服务器数据时发起 SSH 层探测,连续无应答达到限制后断开。它有助于发现失联,也可能维持中间设备的连接状态,但不会自动重连或恢复远程进程。
配置写了却没有生效,先看最终值:
ssh -G server | grep -E '^(hostname|user|port|identityfile|identitiesonly|proxyjump) '
-G 展示匹配 Host/Match 后的客户端值;逐项核对输出,特别是 ProxyJump 与 IdentityFile。
如果输出仍指向旧 IP、旧端口或错误用户,先查别名拼写、匹配到的 Host 块和命令行参数;此时改服务器的 authorized_keys 没有帮助。
跳板连接
ssh internal
先保证 internal、jump 两个别名都定义正确;用 ssh -G internal 核对。
ssh -J ops@bastion.example.com ops@10.0.0.20
-J 指定跳板,最后一个参数才是最终目标;不要先执行上一条后在远端继续这条。
路径是“本机 → 跳板机 → 内网机器”。本机客户端分别处理跳板与最终目标的认证,跳板承担到目标的 TCP 转发;不需要先把私钥复制到跳板,也不需要为了 -J 开启 agent forwarding。两台机器的端口和密钥应分别写在各自的 Host 块中。OpenSSH 跳板连接
分清三种密钥文件
公钥登录同时涉及两个方向的身份确认。下面两条线分开读,能避免把“服务器身份变化”和“用户没有登录权限”混为一谈。
| 文件 | 放在哪里 | 解决什么问题 |
id_ed25519_ops | 本机,或由本机 agent 使用 | 用户私钥,参与证明“我是这个用户” |
authorized_keys | 服务器上要登录的那个用户的 .ssh/ 下 | 允许哪些用户公钥登录该账户 |
known_hosts | 本机 | 记录信任的服务器主机密钥 |
新增一把登录密钥
ssh-keygen -t ed25519 -C "ops-laptop" -f ~/.ssh/id_ed25519_ops
-f 指定新文件名,已有文件时取消而不覆盖;-C 是识别说明。完成后会有私钥与同名 .pub 公钥,私钥保留在本机。
ssh-copy-id -i ~/.ssh/id_ed25519_ops.pub -p 2222 ops@server.example.com
需要先有密码等可用认证方式,目标是远程 ops 的 authorized_keys。只传 .pub;新密钥创建成功不代表已经安装。
ssh -o PreferredAuthentications=publickey server 'id -un'
核对远端输出 ops,原有会话在新身份验证通过前保持可用。
成功时应输出远程用户名 ops。ssh-copy-id 需要你已有一种可用的登录方式;它不能绕过访问权限。私钥的 passphrase 在本机解锁密钥,与服务器账户密码不同。受合规或兼容性限制的环境应使用其支持的密钥类型。
使用 agent,减少反复输入 passphrase
ssh-add -l
需要完整公钥时另用 ssh-add -L;两者只读,不载入文件。
ssh-add -t 1h ~/.ssh/id_ed25519_ops
输入的是私钥 passphrase。用 ssh-add -l 对照新增指纹;仅影响当前 SSH_AUTH_SOCK 指向的 agent。
ssh-add -d ~/.ssh/id_ed25519_ops
这是稍后撤销;-D 会清空该 agent 的全部身份,不能当成单密钥删除。
若提示无法连接 authentication agent,先检查当前会话是否有有效的 SSH_AUTH_SOCK。桌面会话通常已提供 agent;只在确实没有时,在当前 shell 执行 eval "$(ssh-agent -s)",避免每个终端都重复启动一个。
端口转发:先找监听端,再找目标端
-L 与 -R 按监听端口所在位置命名。它们都通过同一条 SSH 连接传输双向数据;图中箭头表示新的应用连接从哪边进入,不表示响应只能单向流动。
正在呈现知识画面
端口开在哪边,谁连接目标?-L 在本机监听,由服务器连接目标;-R 在服务器监听,由本机连接目标;-D 在本机提供 SOCKS 入口,由服务器连接应用指定的目标。箭头表示应用连接的进入方向,隧道中的数据双向流动。
{"id":"ssh-forwarding-map","title":"端口开在哪边,谁连接目标?","summary":"-L 在本机监听,由服务器连接目标;-R 在服务器监听,由本机连接目标;-D 在本机提供 SOCKS 入口,由服务器连接应用指定的目标。箭头表示应用连接的进入方向,隧道中的数据双向流动。","height":620,"html":"<div id=\"forwarding\" data-state=\"L\"><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button type=\"button\" data-mode=\"L\" aria-pressed=\"true\" data-i18n=\"L\"></button><button type=\"button\" data-mode=\"R\" aria-pressed=\"false\" data-i18n=\"R\"></button><button type=\"button\" data-mode=\"D\" aria-pressed=\"false\" data-i18n=\"D\"></button></div><p class=\"tunnel-label\" data-i18n=\"tunnel\"></p><div class=\"scene-stack\"><div class=\"scene\" data-for=\"L\" aria-hidden=\"true\"><section class=\"machine listener\"><h3><span data-i18n=\"local\"></span></h3><div class=\"endpoint\"><p class=\"role\"><span data-i18n=\"listener\"></span></p><code>127.0.0.1:8080</code><p class=\"endpoint-hint\"><span data-i18n=\"localApp\"></span></p></div></section><div class=\"wire\" aria-hidden=\"true\"><span>→</span></div><section class=\"machine connector\"><h3><span data-i18n=\"remote\"></span></h3><div class=\"endpoint\"><p class=\"role\"><span data-i18n=\"connector\"></span></p><code>127.0.0.1:8000</code><p class=\"endpoint-hint\"><span data-i18n=\"remoteTarget\"></span></p></div></section></div><div class=\"scene\" data-for=\"R\" aria-hidden=\"true\"><section class=\"machine connector\"><h3><span data-i18n=\"local\"></span></h3><div class=\"endpoint\"><p class=\"role\"><span data-i18n=\"connector\"></span></p><code>127.0.0.1:8000</code><p class=\"endpoint-hint\"><span data-i18n=\"localTarget\"></span></p></div></section><div class=\"wire\" aria-hidden=\"true\"><span>←</span></div><section class=\"machine listener\"><h3><span data-i18n=\"remote\"></span></h3><div class=\"endpoint\"><p class=\"role\"><span data-i18n=\"listener\"></span></p><code>127.0.0.1:9000</code><p class=\"endpoint-hint\"><span data-i18n=\"remoteApp\"></span></p></div></section></div><div class=\"scene\" data-for=\"D\" aria-hidden=\"true\"><section class=\"machine listener\"><h3><span data-i18n=\"local\"></span></h3><div class=\"endpoint\"><p class=\"role\"><span data-i18n=\"listener\"></span></p><code>127.0.0.1:1080</code><p class=\"endpoint-hint\"><span data-i18n=\"socksApp\"></span></p></div></section><div class=\"wire\" aria-hidden=\"true\"><span>→</span></div><section class=\"machine connector\"><h3><span data-i18n=\"remote\"></span></h3><div class=\"endpoint\"><p class=\"role\"><span data-i18n=\"connector\"></span></p><code>example.com:443</code><p class=\"endpoint-hint\"><span data-i18n=\"socksTarget\"></span></p></div></section></div></div><p class=\"direction\" data-i18n=\"direction\"></p><p class=\"command-label\" data-i18n=\"run\"></p><div class=\"command-stack\"><pre data-for=\"L\" aria-hidden=\"true\"><code>ssh -N -T -o ExitOnForwardFailure=yes \\\n -L <span class=\"listen-token\">127.0.0.1:8080</span>:<span class=\"target-token\">127.0.0.1:8000</span> server</code></pre><pre data-for=\"R\" aria-hidden=\"true\"><code>ssh -N -T -o ExitOnForwardFailure=yes \\\n -R <span class=\"listen-token\">127.0.0.1:9000</span>:<span class=\"target-token\">127.0.0.1:8000</span> server</code></pre><pre data-for=\"D\" aria-hidden=\"true\"><code>ssh -N -T -o ExitOnForwardFailure=yes \\\n -D <span class=\"listen-token\">127.0.0.1:1080</span> server</code></pre></div><div class=\"note-stack\"><p id=\"explanation\" aria-live=\"polite\"></p><p class=\"measure\" aria-hidden=\"true\" data-i18n=\"Lnote\"></p><p class=\"measure\" aria-hidden=\"true\" data-i18n=\"Rnote\"></p><p class=\"measure\" aria-hidden=\"true\" data-i18n=\"Dnote\"></p></div><p class=\"scope\" data-i18n=\"scope\"></p></div>","css":"\n.presets { display:flex; margin:0; }\n.presets button { min-height:44px; }\n.tunnel-label { text-align:center; margin:18px 0 12px; color:var(--muted); font-size:14px; }\n.scene-stack,.command-stack,.note-stack { display:grid; }\n.scene-stack>.scene,.command-stack>pre,.note-stack>p { grid-area:1/1; min-width:0; }\n[data-for] { visibility:hidden; }\n[data-state=\"L\"] [data-for=\"L\"],[data-state=\"R\"] [data-for=\"R\"],[data-state=\"D\"] [data-for=\"D\"] { visibility:visible; }\n.scene { display:grid; grid-template-columns:minmax(0,1fr) 36px minmax(0,1fr); align-items:stretch; }\n.machine { min-width:0; display:flex; flex-direction:column; border:1px solid var(--rule); border-radius:12px; overflow-wrap:anywhere; }\n.machine h3 { padding:12px 14px; font-size:16px; font-weight:600; }\n.endpoint { flex:1; margin:0 10px 10px; border-radius:8px; padding:12px; border:1px solid; }\n.listener .endpoint { background:var(--accent-soft); border-color:var(--accent); }\n.connector .endpoint { background:var(--second-soft); border-color:var(--second); }\n.role { font-size:14px; margin-bottom:8px; }\n.listener .role,.listen-token { color:var(--accent); }\n.connector .role,.target-token { color:var(--second); }\n.endpoint code { display:block; font-size:16px; overflow-wrap:anywhere; font-weight:500; }\n.endpoint-hint { margin-top:8px; font-size:14px; color:var(--muted); }\n.wire { align-self:center; text-align:center; color:var(--muted); font-size:28px; }\n.direction { text-align:center; margin:10px 0 20px; color:var(--muted); font-size:13px; }\n.command-label { font-size:14px; margin-bottom:8px; }\npre { margin:0; padding:14px; background:var(--surface); border:1px solid var(--rule); border-radius:8px; white-space:pre-wrap; overflow-wrap:anywhere; font-size:14px; }\n.listen-token,.target-token { text-decoration:underline; text-underline-offset:4px; text-decoration-thickness:2px; }\n.note-stack { margin-top:16px; border-left:2px solid var(--accent); padding-left:12px; font-size:16px; }\n.measure { visibility:hidden; }\n.scope { margin-top:14px; color:var(--muted); font-size:13px; }\n@media(max-width:500px) {\n.scene { grid-template-columns:minmax(0,1fr); grid-template-rows:1fr 30px 1fr; }\n.wire span { display:block; transform:rotate(90deg); }\n.machine h3 { padding:8px 12px; }\n.endpoint { padding:10px; }\n.endpoint-hint { margin-top:5px; }\n}\n","js":"const root=document.querySelector('#forwarding');\nfunction select(mode) {\n root.dataset.state=mode;\n root.querySelectorAll('[data-mode]').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.mode===mode)));\n root.querySelectorAll('[data-for]').forEach(panel=>panel.setAttribute('aria-hidden',String(panel.dataset.for!==mode)));\n document.querySelector('#explanation').textContent=viz.t(mode+'note');\n}\nroot.querySelectorAll('[data-mode]').forEach(button=>button.addEventListener('click',()=>select(button.dataset.mode)));\nselect('L');","audio":false,"strings":{"D":"SOCKS · -D","Dnote":"-D:本机监听 1080;服务器连接应用请求的目标。用 socks5h 时,目标域名也交给代理一侧解析。","L":"本地转发 · -L","Lnote":"-L:先连本机的 8080,再由服务器连接它自己的 8000。目标地址 127.0.0.1 属于服务器。","R":"远程转发 · -R","Rnote":"-R:先连服务器的 9000,再由本机连接自己的 8000。目标地址 127.0.0.1 属于本机。","connector":"发起目标连接","direction":"箭头:应用连接的进入方向;数据双向传输","listener":"监听入口","local":"本机客户端","localApp":"本机应用访问此端口","localTarget":"本机自己的 8000 端口","mode":"转发方式","remote":"SSH 服务器","remoteApp":"服务器上的应用访问此端口","remoteTarget":"服务器自己的 8000 端口","run":"以下命令始终在本机执行","scope":"地址关系示意,不建立真实连接。远程监听受服务器策略约束;目标服务仍需单独验证。","socksApp":"本机应用使用 SOCKS 代理","socksTarget":"应用在 SOCKS 请求中指定目标","tunnel":"SSH 加密隧道"}}
本地转发:从本机访问远程一侧的服务
ssh -N -T -o ExitOnForwardFailure=yes \
-L 127.0.0.1:8080:127.0.0.1:8000 server
该命令占用终端,出现监听失败先检查本机端口;不要等待它退出后才测试。
curl -v http://127.0.0.1:8080/
读取状态与正文确认服务;结束测试后在终端一 Ctrl-C 关闭隧道。
两个 127.0.0.1 指向不同机器:第一个是本机的监听地址;第二个由SSH 服务器用于连接目标,所以这里指服务器自己的回环地址。若目标是服务器能访问的内网机器,可把后半段改成 db.internal:5432,再用数据库客户端连接本机 8080;名称解析也发生在服务器一侧。
远程转发:让服务器访问本机服务
ssh -N -T -o ExitOnForwardFailure=yes \
-R 127.0.0.1:9000:127.0.0.1:8000 server
先确认本机服务可访问,保持此终端运行。
curl -v http://127.0.0.1:9000/
在服务器的另一连接执行,不是在运行隧道的本机执行。
此时第一个地址请求在服务器上监听,第二个地址由本机客户端连接。因此默认用途是“服务器本身访问本机服务”,不等于已经把服务发布到公网。
服务器的 GatewayPorts 决定远程监听的地址策略:no 限于回环,clientspecified 允许客户端选择,yes 强制通配地址。需要其他机器访问时,应由管理员明确配置监听地址、转发权限和入口访问控制,并检查实际监听结果;不要直接把 yes 当作通用修复。远程监听地址策略
动态转发:由应用通过 SOCKS 指定目标
ssh -N -T -o ExitOnForwardFailure=yes -D 127.0.0.1:1080 server
保持前台运行;这是 -L/-R 的另一种使用方式,不要求同时建立三条隧道。
curl --proxy socks5h://127.0.0.1:1080 https://example.com/
socks5h 的 h 指代理侧解析;核对实际响应,结束后在终端一 Ctrl-C。
应用必须配置 SOCKS 代理;仅建立 -D 不会自动代理整台机器的流量。命令里没有固定目标地址,每条代理请求各自指定目标。OpenSSH 转发选项
隧道建好了,不代表目标可用
-N 不执行远程命令,-T 不分配伪终端。ExitOnForwardFailure=yes 让监听建立失败时退出,不会因为后续目标连接失败而自动退出。转发失败的边界
按这三个位置检查,不要只看 SSH 进程是否存在:
| 检查位置 | 观察什么 | 能确认什么 |
| 监听所在机器 | Linux 上 ss -ltn,找 8080、9000 或 1080 | 入口是否存在、绑定哪个地址;必要时用有权限的 ss -ltnp 确认进程 |
| 发起目标连接的机器 | 用该协议的客户端直接访问目标;本例可 curl -v http://127.0.0.1:8000/ | 目标服务是否能从正确的一侧访问 |
| 应用入口 | 通过转发端口或 SOCKS 发起请求 | 一次实际请求是否走通 |
-L / -D 的目标侧是服务器,-R 的目标侧是本机。若看到 administratively prohibited,查服务器的转发策略及该密钥的限制;若是 connect failed: Connection refused,查目标地址和目标服务监听。
文件传输:先确认方向,再确认目录层次
| 任务 | 在本机执行 |
| 上传一个文件 | scp report.txt server:/tmp/ |
| 下载一个文件 | scp server:/tmp/report.txt ./ |
| 上传整个目录 | scp -r assets server:/tmp/ |
| 不用别名,指定 SCP 的 SSH 端口 | scp -P 2222 report.txt ops@server.example.com:/tmp/ |
scp 的端口参数是大写 -P,ssh 的是小写 -p。使用主机别名可以避免反复写错。
同步目录:把预期结果写出来
假设本机已有 site/index.html,远程目录 /srv/www/ 已存在且当前用户可写:
| 源参数 | 命令 | 目标中的文件 |
site/:目录内容 | rsync -av site/ server:/srv/www/ | /srv/www/index.html |
site:目录本身 | rsync -av site server:/srv/www/ | /srv/www/site/index.html |
尾斜杠改变的是源目录是否多包一层。rsync 路径语义
rsync -av --dry-run --itemize-changes site/ server:/srv/www/
审阅 itemize 输出和目标层次,确认不存在路径多包一层;dry-run 不写文件。
rsync -av --partial --progress site/ server:/srv/www/
检查退出状态,再以同一 dry-run 复查是否仍有非预期差异,并请求实际站点。若远端文件被覆盖,恢复依赖事先保留的副本。
-a 包含递归和常用元数据保留,实际能否保留所有者等属性取决于权限;它不自动包含 ACL、扩展属性或硬链接保留。--partial 帮助后续重试利用已有部分数据,不意味着任何情况下都简单从断点追加。需要压缩时再加 -z。这些命令不会删除远程多余文件;只有确实要做镜像时才另行考虑 --delete,并先预览删除列表。
按失败阶段排查
先在本机执行 ssh -vvv server,找最后成功的阶段。下表的日志是典型片段,不同版本的文字可能略有差别;查看目标登录失败时,也要分清跳板和最终目标各自的日志。
| 现象或日志 | 已经知道什么 | 下一步查哪里 |
Could not resolve hostname | 还没有连接到目标 | ssh -G 的 HostName、DNS、别名拼写 |
Connection timed out / No route to host | TCP 尚未成功,不能归因于公钥 | 地址、路由、安全组、防火墙及主机可达性 |
Connection refused | 连接被主动拒绝 | 正确机器和端口是否监听;也可能有主动拒绝规则 |
Connection established | TCP 已建立,登录尚未完成 | 继续找密钥交换、主机身份及认证阶段 |
REMOTE HOST IDENTIFICATION HAS CHANGED | 当前主机密钥与记录不符 | 先独立核验服务器身份,见下节 |
Offering public key 后仍被拒绝 | 客户端尝试了这把公钥,尚未获准登录 | 远程用户名、该账户的公钥文件、权限和认证策略 |
Too many authentication failures | 认证尝试次数用尽 | 检查配置身份;用 IdentitiesOnly 限定需要的密钥 |
Authenticated to ... | 用户认证已成功 | 若随后断开,查远程 shell、强制命令、会话限制和服务器日志 |
不要看到认证失败就批量改权限,也不要看到卡住就先设 UseDNS no。先用日志确定卡在哪一层,再改变对应配置。
公钥被拒绝:沿着具体身份查
ssh-keygen -lf ~/.ssh/id_ed25519_ops.pub
-l 显示指纹,-f 指定公钥文件;接着在目标账户侧核对同一指纹。
ssh-keygen -lf ~/.ssh/authorized_keys
与本机公钥对照,确认核对的是远程目标用户的家目录。
ls -ld ~ ~/.ssh ~/.ssh/authorized_keys
查看 owner/group 与其他用户写权限;仅修复实际不符合要求的路径,不批量递归 chmod 整个家目录。
默认文件布局下,常用权限是 .ssh 为 700、authorized_keys 为 600,所有者应正确,家目录不能被其他用户随意写入。若服务端配置了不同的 AuthorizedKeysFile、集中式密钥获取或证书认证,应检查实际来源,不能只盯默认文件。
需要管理员排查时,在服务器查实际使用的服务单元(常见为 ssh.service 或 sshd.service):
sudo journalctl -u ssh.service -u sshd.service --since "10 min ago" --no-pager
按源地址、用户名与时间关联;两种 unit 名适配不同发行版,不表示都必须运行。
sudo sshd -t
通过通常无输出;失败先修配置,不 reload。确需应用新配置时重载实际 ssh/sshd unit,保留原会话并另开登录验证。
第一条找具体拒绝理由;非 systemd 系统查对应认证日志。第二条验证服务端配置及主机密钥的基本有效性,不证明某个用户一定能登录。修改配置后保留现有会话,另开连接验证,再退出旧会话。
主机密钥变化:先核验,再更新记录
通过已有可信控制台或管理员提供的独立渠道核对新主机指纹。例如,在服务器控制台读取 Ed25519 主机公钥指纹:
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
核对的是服务器主机公钥,而不是用户 authorized_keys;算法需与客户端警告一致。
它必须与本机提示中的算法、指纹对应;若协商的是其他类型,应核对对应主机公钥。确认确实是预期服务器且变更合理后,才在本机删除报错中对应主机和端口的旧项:
ssh-keygen -R '[server.example.com]:2222'
按报错的主机与端口精确匹配,不删除整个 known_hosts。
ssh server
只有与可信渠道核对一致后才接受;仅执行 -R 不会证明新端点可信。
默认 22 端口通常用 ssh-keygen -R server.example.com。使用 HostKeyAlias 或自定义 known-hosts 文件时,按实际配置处理。重新连接时核对新指纹;ssh-keyscan 只能收集当前网络端点给出的密钥,不能独立证明它可信。主机密钥记录操作
进一步查阅
完整选项以当前安装版本的 man ssh、man ssh_config、man sshd_config 为准。想理解 SSH 的协议层次与通道机制,可继续读 SSH 协议。本篇保留常见任务入口,协议原理放在对应知识文章中展开。