SSH 把“不可信网络上的远程操作”拆成三个问题:连接的是哪台主机,远端接受哪个用户,已认证用户能打开哪些功能。理解这三个边界,比背诵一条密钥交换流程更有助于定位 Host key verification failed、Permission denied 和转发被拒绝之间的区别。
| 层次 | 完成的工作 | 成功后仍未保证什么 |
| Transport | 算法协商、主机认证、加密和完整性、换钥 | 用户可以登录 |
| User Authentication | 证明用户身份并满足认证策略 | 所有会话功能都被授权 |
| Connection | 多路复用 channel、流控、会话和转发请求 | 应用操作一定完成或持久化 |
密钥交换:共享秘密、交换摘要与主机密钥
SSH 通常先建立 TCP,再发送 SSH-2.0-软件标识 与 CRLF 结束符。版本行不是加密内容;随后使用二进制包交换 KEXINIT,分别协商密钥交换、主机密钥及两个方向的传输算法。算法列表的交集和顺序会影响结果,不能把某一个 OpenSSH 版本的默认值当作 SSH 协议的唯一算法。
以 ECDH 类交换说明:客户端、服务端各生成临时密钥,共同计算共享秘密 K;交换摘要 H 则覆盖版本信息、双方 KEXINIT、服务端主机公钥、临时交换值及 K 等规定字段。服务端用主机私钥签署 H,客户端验签,以此把协商过程与这把主机密钥绑定。H 不是简单给共享秘密改名,也不是直接用作流量加密密钥。
只有第一次密钥交换的 H 被固定为 session ID。 后续 rekey 产生新的 K、H 和传输密钥,session ID 保持不变。双方分别发送 NEWKEYS,在对应发送和接收方向切换密钥,不能把它理解为一条全局开关同步改变两个方向。RFC 4253 §7–9
现代实现还支持混合后量子密钥交换。例如 OpenSSH 10.0 将 mlkem768x25519-sha256 设为默认首选;这是密钥交换的变化,不意味着主机认证也自动变成同一种算法。另一方面,禁用 ssh-rsa 签名通常指禁用 RSA/SHA-1,并非所有 RSA 公钥失效;同一 RSA 密钥可以使用 RSA/SHA-2 签名,前提是双方支持并允许。OpenSSH 发布说明
验签通过,为什么还要 known_hosts
攻击者也可以生成自己的密钥,并正确签署自己的交换。验签只能证明对方持有所提供公钥的私钥;要确认“这是我要连接的那台主机”,还需要将主机名与可信密钥或可信签发者绑定。
| 信任来源 | 如何建立绑定 | 需要维护的内容 |
| 预置主机公钥 | 经可信渠道把公钥写入 known_hosts | 主机变化、换钥、撤销 |
| 首次使用信任 TOFU | 首次核对或接受后保存,后续比较 | 第一次接受的可信程度 |
| OpenSSH 主机证书 | 对匹配主机范围信任 CA,验证证书约束 | CA、principal、有效期及撤销 |
| DNS SSHFP | 在符合配置与验证条件时利用 DNS 中的指纹 | DNSSEC 验证链与记录维护 |
因此首次信任可以由配置分发或主机 CA 自动完成,不是“SSH 唯一不能自动化的一步”。ssh-keyscan 收集的只是网络上得到的公钥,单独使用不构成可信验证;清空旧记录也不能解释一次主机密钥变化。ssh 主机认证、VerifyHostKeyDNS
OpenSSH 证书是 SSH 自己的证书格式,不是 TLS 的 X.509 证书。主机证书回答服务器身份问题,用户证书回答登录者身份问题;信任某个主机 CA 不意味着这个 CA 签发的任意用户都获得登录权限。
用户认证:签名请求与“部分成功”
publickey 认证签署的数据包括 session ID 和规定的用户认证请求字段,把证明绑定到当前连接和请求身份。客户端可以先询问服务端是否接受某把公钥,再发送签名;收到 SSH_MSG_USERAUTH_PK_OK 只是认可该候选,不是登录已经完成。完整成功由 SSH_MSG_USERAUTH_SUCCESS 表示。RFC 4252 §5、7
| 方法或机制 | 实际作用 |
| publickey | 验证对对应私钥的控制,同时检查该用户的授权规则 |
| password | 在已保护的 SSH 传输中提交密码;服务端仍接触密码 |
| keyboard-interactive | 通用挑战/应答,可承载 OTP、PAM 等交互 |
| 用户证书 | 为公钥增加 CA 签名、principal、有效期和约束,并仍需私钥证明 |
TOTP 并非基础 SSH 用户认证协议中一个直接同名的方法。认证尝试顺序也不是协议规定的“公钥、OTP、密码”;客户端偏好、服务端允许的方法和多因素策略共同决定过程。服务端可报告某个方法已经部分成功但还需另一种方法;这与私钥错误导致的完全失败不同。
OpenSSH 的 AuthenticationMethods publickey,keyboard-interactive 可以表达需要两步的策略,但第二步到底是否为 TOTP 还取决于服务端交互认证配置。authorized_keys 中的密钥也可以附带命令或转发限制;拥有一把被认可的密钥不等于获得不受限的 shell。sshd_config
二进制包:先有边界,才有消息
基础传输包包括 4 B packet_length、1 B padding_length、payload、随机 padding,以及按算法定义处理的完整性数据。packet_length 不包含自己的 4 B,也不包含传统格式尾部的 MAC;padding 至少 4 B。不要将这个基础描述机械套到所有 AEAD 或 Encrypt-then-MAC 算法的加密范围上。
下面构造初始未加密阶段的 SSH_MSG_IGNORE 教学包:消息类型 1 B,字符串长度 4 B,hello 5 B,共 10 B payload。按 8 B 对齐,5 B 包头加 10 B payload 需要 9 B padding,完整包为 24 B,packet_length 为 20。
import struct
payload = bytes([2]) + struct.pack('>I', 5) + b'hello'
padding = 4
while (5 + len(payload) + padding) % 8:
padding += 1
packet = struct.pack('>IB', 1 + len(payload) + padding, padding)
packet += payload + bytes(range(padding))
assert len(payload) == 10 and padding == 9
assert len(packet) == 24
assert struct.unpack('>I', packet[:4])[0] == 20
assert packet[5] == 2 and packet[10:15] == b'hello'
print('基础 SSH 包:payload 10 B,padding 9 B,总长 24 B')
TCP 的一次读取可能只拿到部分包,也可能包含多个包。接收端必须按长度积累并验证,而不是以 socket 读取次数判断 SSH 消息数。RFC 4253 §6
Channel:独立编号和窗口,共享 TCP
session channel 可以申请 shell、exec 或 subsystem;SFTP 是 subsystem 中的一种应用协议。端口转发则常用 direct-tcpip 或 forwarded-tcpip。这不是“channel 0 永远是 shell、channel 1 永远是转发”的固定编号表。
双方分别分配本地 channel ID。若客户端打开时使用 7,服务端确认并分配 42,则客户端后续给服务端的数据用 recipient 42,服务端发回的数据用 recipient 7。编号只在相应连接和端点内有意义。
每个方向都有接收窗口和最大 channel 数据包大小,发送量同时受两者限制。接收方处理并释放缓冲后,使用 WINDOW_ADJUST 增加对方可发送额度;stdout 和扩展数据如 stderr 也要遵守 channel 窗口,不能无限堆积。
window, max_packet, queued = 65536, 32768, 90000
sent = []
while window and queued:
n = min(window, max_packet, queued)
sent.append(n)
window -= n
queued -= n
assert sent == [32768, 32768] and queued == 24464
assert window == 0 window += 16384
n = min(window, max_packet, queued)
assert n == 16384 and queued - n == 8080
print('窗口耗尽暂停,WINDOW_ADJUST 只恢复对应额度')
独立窗口使一个慢 channel 不必吃光其他 channel 的应用缓冲,却不能消除底层 TCP 丢包造成的队头阻塞。CHANNEL_EOF 表示该方向不再发送数据,反向仍可继续;CHANNEL_CLOSE 才参与关闭整个 channel。命令的 exit-status 是另一类 channel 请求,不能用“TCP 正常关闭”替代业务退出码。RFC 4254 §5–6
发送给42,为什么回包却写7?
方向箭头改变时,recipient取对端保存的本地编号。把窗口调大不会突破32768的单包上限;把待发数据调小也不需要凑满窗口。这三个量有不同的管理者和生命周期。
正在呈现知识画面
发送给42,为什么回包却写7?channel编号由两端各自分配,发送使用对端编号。单次数据量同时受方向窗口、最大包与待发量限制。
{"id":"ssh-channel-direction","title":"发送给42,为什么回包却写7?","summary":"channel编号由两端各自分配,发送使用对端编号。单次数据量同时受方向窗口、最大包与待发量限制。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button data-mode=\"cs\" data-i18n=\"cs\"></button><button data-mode=\"sc\" data-i18n=\"sc\"></button></div><div class=\"control\"><label for=\"window\" data-i18n=\"window\"></label><output for=\"window\" id=\"window-value\"></output><input id=\"window\" type=\"range\" min=\"0\" max=\"65536\" value=\"65536\" step=\"1024\"></div><div class=\"control\"><label for=\"queued\" data-i18n=\"queued\"></label><output for=\"queued\" id=\"queued-value\"></output><input id=\"queued\" type=\"range\" min=\"0\" max=\"98304\" value=\"90112\" step=\"1024\"></div><svg id=\"direction\" class=\"diagram\" viewBox=\"0 0 360 180\" role=\"img\" data-i18n-label=\"heading\"></svg><div class=\"metrics\"><div><span data-i18n=\"recipient\"></span><strong id=\"recipient\"></strong></div><div><span data-i18n=\"size\"></span><strong id=\"size\"></strong></div></div><p id=\"equation\" class=\"formula\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}let mode='cs';function draw(){const w=+$('#window').value,q=+$('#queued').value,n=Math.min(w,q,32768);set('window-value',w);set('queued-value',q);graph('direction',[{name:'C',x:70,y:75,sub:'local 7'},{name:'S',x:290,y:75,sub:'local 42'}],mode==='cs'?[[0,1,true]]:[[1,0,true]]);set('recipient',mode==='cs'?42:7);set('size',n);set('equation','send = min('+w+',32768,'+q+') = '+n);$('#size').dataset.value=n;pressed(mode);}document.querySelectorAll('input').forEach(e=>e.oninput=draw);document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>{mode=b.dataset.mode;draw()});draw();","audio":false,"strings":{"cs":"Client → Server","heading":"发送给42,为什么回包却写7?","intro":"选择发送方向、待发数据和接收方额度,查看本次recipient与最大可发片段。","mode":"发送方向","queued":"待发数据(B)","recipient":"消息recipient编号","reset":"重置","sc":"Server → Client","scope":"单channel,客户端本地7、服务端本地42;max packet32768,窗口滑块表示当前所选发送方向的接收额度。独立快照,不模拟TCP和认证。","scopeLabel":"模型范围与约定","size":"可发送 / B","window":"该方向剩余窗口(B)"}}
转发:谁监听,谁解析目标,哪里终止加密
下面两条是按需使用的命令示例;gateway.example、internal.example 需要替换为自己的主机。
ssh -N -o ExitOnForwardFailure=yes \
-L 127.0.0.1:8080:internal.example:80 user@gateway.example
ssh -N -o ExitOnForwardFailure=yes \
-R 127.0.0.1:3000:localhost:22 user@gateway.example
-L 中目标名字通常由 SSH 服务端解析并连接;上述 -R 中的 localhost 则是 SSH 客户端那一侧。监听地址限制谁能接入,不决定目标在哪一侧。远程监听能否绑定非回环地址还受服务端 GatewayPorts 等策略影响。
SSH 加密终止在 SSH 的两端;服务端再连接 internal.example:80 的一段不自动获得 SSH 加密。如果目标运行 HTTPS,应用 TLS 可继续保护到目标服务。-N 不请求远程命令,ExitOnForwardFailure 可检测监听等转发建立失败,却不保证未来每次连接目标都成功。ssh 转发选项
| 需求 | 机制 | 边界 |
| 固定 TCP 服务 | -L / -R | 映射连接,不是完整 IP VPN |
| 应用选择多个 TCP 目标 | -D 动态 SOCKS | 应用需使用代理;不是通用 UDP 转发 |
| 经跳板登录目标主机 | -J ProxyJump | 仍需验证目标主机身份 |
| 远端调用本地签名代理 | agent forwarding | 私钥不直接复制,但远端可在授权范围内请求签名 |
ProxyJump 通常不需要把 agent 转发给跳板:客户端可经跳板转发的 TCP 通道,与最终目标建立自己的 SSH 连接。agent forwarding 的作用不同,不能把它当作“私钥文件没离开本机,所以远端不能使用身份”。ssh agent 与跳板
按阶段定位,而不是统一归因“密钥不对”
| 现象 | 优先核对 |
| 连不上 TCP | 地址、端口、路由、服务监听及连接策略 |
| 无共同算法 | 报错所属 KEX、主机签名或传输算法类别 |
| 主机验证失败 | 目标名称、端口映射、可信记录与计划内换钥 |
| 用户认证失败 | 登录用户名、候选身份、服务端方法和授权约束 |
| 登录后功能被拒绝 | subsystem、命令限制、转发策略与目标连接 |
| 传输卡住或连接中断 | channel 背压、TCP 丢包、空闲状态及超时 |
只想检查命令解析时,可用 ssh -G -F /dev/null 输出不读取个人配置的有效选项;-G 不建立 SSH 会话。实际连接调试时再用 -v 或 -vvv 对照失败阶段。保活用于发现不响应的连接,不是业务处理完成证明;重连后也不能假定之前启动的命令没有执行。