SSH 隧道视觉指南:本地和远程端口转发
TL;DR SSH 端口转发可打印备忘录。
SSH 是另一种古老技术,但至今仍被广泛使用的一个例子。学习几个 SSH 技巧,在长期来看,可能比掌握十几种下一季度就会被弃用的 Cloud Native 工具更有回报。
我最喜欢的部分之一就是 SSH 隧道。只需使用标准工具,通常只需一条命令,您就可以实现以下功能:
- 通过公共 EC2 实例访问内部 VPC 端点。
- 在主机浏览器中打开开发虚拟机 localhost 的端口。
- 将家庭/私有网络中的任何本地服务器暴露到外部世界。
还有更多 😍
尽管我每天都使用 SSH 隧道,但每次都需要花一段时间来想出正确的命令。是本地隧道还是远程隧道?有哪些标志?是 local_port:remote_port 还是反过来?所以,我决定彻底搞清楚它,这导致了一系列实验室和一个视觉备忘录 🙈
完整版本 1.5MB

前置条件
SSH 隧道涉及通过网络连接主机,因此下面的每个实验室预计都会涉及多个“机器”。然而,为每个实验室启动几个虚拟机可能很繁琐,所以我使用容器来模拟网络主机。因此,一台带有 Docker 引擎的 Linux 服务器(或类似)就可以运行所有实验室。
注意: 使用 Docker Desktop 直接运行下面的示例是不可能的,因为假设可以按 IP 地址访问机器容器。
专业提示: 像往常一样,我的推荐是使用临时服务器来运行博客中的示例。这里是如何快速获取一个 👉 labs.iximiuz.com/playgrounds/docker。
每个示例都需要在主机上有一个有效的无密码密钥对,然后挂载到容器中以简化访问管理。如果您没有,实验室提供了生成它的示例。
重要: 这里容器中的 SSH 守护进程仅用于教育目的 - 本文中的容器旨在代表带有 SSH 客户端和服务器的完整“机器”。请注意,在真实世界的容器中放置 SSH 东西通常不是一个好主意!
本地端口转发
从我最常用的那个开始。通常,可能有一个服务监听在机器的 localhost 或私有接口上,我只能通过其公共 IP 通过 SSH 访问它。而我迫切需要从外部访问这个端口。几个典型示例:
- 使用精美的 UI 工具从您的笔记本电脑访问数据库(MySQL、Postgres、Redis 等)。
- 使用浏览器访问仅暴露到私有网络的 Web 应用程序。
- 无需在服务器的公共接口上发布,就从笔记本电脑访问容器的端口。
以上所有用例都可以用单一条 ssh 命令解决:
ssh -L [local_addr:]local_port:remote_addr:remote_port [user@]sshd_addr
-L 标志表示我们正在启动 本地端口转发 。它的实际含义是:
- 在您的机器上,SSH 客户端将在
local_port 上开始监听(很可能在 localhost 上,但 取决于 - 检查 GatewayPorts 设置)。
- 任何到这个端口的流量将被转发到您 SSH 连接到的机器上的
remote_private_addr:remote_port。
下面是它的示意图:

专业提示: 使用 ssh -f -N -L 在后台运行端口转发会话。
实验室 1:使用 SSH 隧道进行本地端口转发 👨🔬
启动在线 playground 💻
该实验室重现了上面示意图中的设置。首先,我们需要准备服务器 - 一台带有 SSH 守护进程和一个监听在 127.0.0.1:80 的简单 Web 服务的机器:
# syntax=docker/dockerfile:1
FROM alpine:3
# Install the dependencies:
RUN apk add --no-cache openssh-server curl python3
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A
# Prepare the entrypoint that starts the daemons:
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail
for file in /tmp/ssh/*.pub; do
cat ${file} >> /root/.ssh/authorized_keys
done
chmod 600 /root/.ssh/authorized_keys
# Minimal config for the SSH server:
sed -i '/AllowTcpForwarding/d' /etc/ssh/sshd_config
sed -i '/PermitOpen/d' /etc/ssh/sshd_config
/usr/sbin/sshd -e -D &
python3 -m http.server --bind 127.0.0.1 ${PORT} &
sleep infinity
EOF
# Run it:
CMD ["/entrypoint.sh"]
启动服务器并记录其 IP 地址:
$ yes no | ssh-keygen -t rsa -N "" -f ~/.ssh/id_iximiuz_lab
$ docker run -d --rm \
-e PORT=80 \
-v $HOME/.ssh:/tmp/ssh \
--name server \
server:latest
SERVER_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' server)
由于 Web 服务监听在 localhost 上,它将无法从外部访问(在本例中,即从主机系统):
$ curl ${SERVER_IP}
curl: (7) Failed to connect to 172.17.0.2 port 80: Connection refused
但从“服务器”内部,它工作得很好:
$ ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
root@${SERVER_IP}
7b3e49181769:# curl localhost
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
...
这就是技巧: 使用本地端口转发将服务器的 localhost:80 绑定到主机的 localhost:8080:
$ ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
-f -N -L 8080:localhost:80 root@${SERVER_IP}
现在,您应该能够在主机系统的本地端口上访问 Web 服务:
$ curl localhost:8080
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
...
一种稍微更详细(但更明确和灵活)的方式来实现相同目标 - 使用 local_addr:local_port:remote_addr:remote_port 形式:
$ ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
-f -N -L localhost:8080:localhost:80 root@${SERVER_IP}
带有堡垒主机的本地端口转发
一开始可能不明显,但 ssh -L 命令允许将本地端口转发到 任何机器 的远程端口,而不仅仅是 SSH 服务器本身。请注意 remote_addr 和 sshd_addr 可能相同也可能不同:
ssh -L [local_addr:]local_port:remote_addr:remote_port [user@]sshd_addr
我不确定在这里使用 堡垒主机 这个术语是否合适,但这就是我为自己可视化的场景:

我经常使用上面的技巧来调用从 堡垒主机 可访问但从我的笔记本电脑不可访问的端点(例如,使用具有私有和公共接口的 EC2 实例连接到完全部署在 VPC 内的 OpenSearch 集群)。
实验室 2:带有堡垒主机的本地端口转发 👨🔬
启动在线 playground 💻
同样,该实验室重现了上面示意图中的设置。首先,我们需要准备堡垒主机 - 一台只有 SSH 守护进程的机器:
# syntax=docker/dockerfile:1
FROM alpine:3
# Install the dependencies:
RUN apk add --no-cache openssh-server
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A
# Prepare the entrypoint that starts the SSH daemon:
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail
for file in /tmp/ssh/*.pub; do
cat ${file} >> /root/.ssh/authorized_keys
done
chmod 600 /root/.ssh/authorized_keys
# Minimal config for the SSH server:
sed -i '/AllowTcpForwarding/d' /etc/ssh/sshd_config
sed -i '/PermitOpen/d' /etc/ssh/sshd_config
/usr/sbin/sshd -e -D &
sleep infinity
EOF
# Run it:
CMD ["/entrypoint.sh"]
启动堡垒主机并记录其 IP:
$ yes no | ssh-keygen -t rsa -N "" -f ~/.ssh/id_iximiuz_lab
$ docker run -d --rm \
-v $HOME/.ssh:/tmp/ssh \
--name bastion \
bastion:latest
BASTION_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' bastion)
现在,在单独的“机器”上启动目标 Web 服务:
$ docker run -d --rm \
--name server \
python:3-alpine \
python3 -m http.server 80
SERVER_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' server)
假设出于某种原因(例如,仿佛主机到该 IP 地址没有路由),无法从主机直接调用 curl ${SERVER_IP}。所以,我们需要启动端口转发:
$ ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
-f -N -L 8080:${SERVER_IP}:80 root@${BASTION_IP}
请注意,上面的命令中 SERVER_IP 和 BASTION_IP 变量的值不同。
检查它是否工作:
$ curl localhost:8080
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
...
远程端口转发
另一个流行(但相反)的场景是当您想暂时将本地服务暴露到外部世界时。当然,为此,您需要一个 面向公众的入口网关服务器 。但别担心!任何带有 SSH 守护进程的面向公众的服务器都可以用作这样的网关:
ssh -R [remote_addr:]remote_port:local_addr:local_port [user@]gateway_addr
上面的命令看起来并不比它的 ssh -L 对等物更复杂。但有一个陷阱...
默认情况下,上面的 SSH 隧道将只允许使用网关的 localhost 作为远程地址。 换句话说,您的本地端口将仅从网关服务器内部可访问,而这很可能不是您实际需要的。例如,我通常想使用网关的公共地址作为远程地址,将我的本地服务暴露到公共互联网。为此,SSH 服务器需要配置 GatewayPorts yes 设置。
以下是远程端口转发可以用于什么:
- 将笔记本电脑上的开发服务暴露到公共互联网进行演示。
- 嗯... 我能想到几个深奥的例子,但我不确定是否值得在这里分享。好奇其他人可能用远程端口转发做什么!
下面是远程端口转发的示意图:

专业提示: 使用 ssh -f -N -R 在后台运行端口转发会话。
实验室 3:使用 SSH 隧道进行远程端口转发 👨🔬
启动在线 playground 💻
该实验室重现了上面示意图中的设置。首先,我们需要准备“开发机器” - 一台带有 SSH 客户端和本地 Web 服务器的计算机:
# syntax=docker/dockerfile:1
FROM alpine:3
# Install dependencies:
RUN apk add --no-cache openssh-client curl python3
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh
# Prepare the entrypoint that starts the web service:
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail
cp /tmp/ssh/* /root/.ssh
chmod 600 /root/.ssh/*
python3 -m http.server --bind 127.0.0.1 ${PORT} &
sleep infinity
EOF
# Run it:
CMD ["/entrypoint.sh"]
启动开发机器:
$ yes no | ssh-keygen -t rsa -N "" -f ~/.ssh/id_iximiuz_lab
$ docker run -d --rm \
-e PORT=80 \
-v $HOME/.ssh:/tmp/ssh \
--name devel \
devel:latest
准备入口网关服务器 - 一个简单的 SSH 服务器,在 sshd_config 中将 GatewayPorts 设置为 yes:
# syntax=docker/dockerfile:1
FROM alpine:3
# Install the dependencies:
RUN apk add --no-cache openssh-server
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A
# Prepare the entrypoint that starts the SSH server:
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail
for file in /tmp/ssh/*.pub; do
cat ${file} >> /root/.ssh/authorized_keys
done
chmod 600 /root/.ssh/authorized_keys
sed -i '/AllowTcpForwarding/d' /etc/ssh/sshd_config
sed -i '/PermitOpen/d' /etc/ssh/sshd_config
sed -i '/GatewayPorts/d' /etc/ssh/sshd_config
echo 'GatewayPorts yes' >> /etc/ssh/sshd_config
/usr/sbin/sshd -e -D &
sleep infinity
EOF
# Run it:
CMD ["/entrypoint.sh"]
启动网关服务器并记录其 IP 地址:
$ docker run -d --rm \
-v $HOME/.ssh:/tmp/ssh \
--name gateway \
gateway:latest
GATEWAY_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' gateway)
现在 从开发机器内部,启动远程端口转发:
$ docker exec -it -e GATEWAY_IP=${GATEWAY_IP} devel sh
/ # ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
-f -N -R 0.0.0.0:8080:localhost:80 root@${GATEWAY_IP}
/ # exit # or detach with ctrl-p, ctrl-q
验证开发机器的本地端口是否暴露在网关的公共接口上(从主机系统):
$ curl ${GATEWAY_IP}:8080
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
...
从家庭/私有网络的远程端口转发
类似于本地端口转发,远程端口转发也有自己的 堡垒主机 模式。但这一次,带有 SSH 客户端的机器(例如您的开发笔记本电脑)扮演堡垒的角色。特别是,它允许通过入口网关将笔记本电脑有权访问的家庭(或私有)网络中的端口暴露到外部世界:
ssh -R [remote_addr:]remote_port:local_addr:local_port [user@]gateway_addr
看起来几乎与简单的远程 SSH 隧道相同,但 local_addr:local_port 对成为家庭网络中设备的地址。下面是它的示意图:

由于我通常将笔记本电脑用作瘦客户端,而实际开发发生在家庭服务器上,当我需要将家庭服务器的开发服务暴露到公共互联网,并且唯一有网关访问权限的机器是我的瘦笔记本电脑时,我会依赖这种远程端口转发。
实验室 4:从家庭/私有网络的远程端口转发 👨🔬
启动在线 playground 💻
像往常一样,该实验室重现了上面示意图中的设置。首先,我们需要准备“瘦开发机器”:
# syntax=docker/dockerfile:1
FROM alpine:3
# Install the dependencies:
RUN apk add --no-cache openssh-client
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh
# This time we run nothing (at first):
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail
cp /tmp/ssh/* /root/.ssh
chmod 600 /root/.ssh/*
sleep infinity
EOF
# Run it:
CMD ["/entrypoint.sh"]
启动“开发机器”:
$ yes no | ssh-keygen -t rsa -N "" -f ~/.ssh/id_iximiuz_lab
$ docker run -d --rm \
-v $HOME/.ssh:/tmp/ssh \
--name devel \
devel:latest
使用单独的“机器”启动私有开发服务器并记录其 IP 地址:
$ docker run -d --rm \
--name server \
python:3-alpine \
python3 -m http.server 80
SERVER_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' server)
准备入口网关服务器:
# syntax=docker/dockerfile:1
FROM alpine:3
# Install the dependencies:
RUN apk add --no-cache openssh-server
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A
# Prepare the entrypoint that starts the SSH daemon:
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail
for file in /tmp/ssh/*.pub; do
cat ${file} >> /root/.ssh/authorized_keys
done
chmod 600 /root/.ssh/authorized_keys
sed -i '/AllowTcpForwarding/d' /etc/ssh/sshd_config
sed -i '/PermitOpen/d' /etc/ssh/sshd_config
sed -i '/GatewayPorts/d' /etc/ssh/sshd_config
echo 'GatewayPorts yes' >> /etc/ssh/sshd_config
/usr/sbin/sshd -e -D &
sleep infinity
EOF
# Run it:
CMD ["/entrypoint.sh"]
启动它:
$ docker run -d --rm \
-v $HOME/.ssh:/tmp/ssh \
--name gateway \
gateway:latest
GATEWAY_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' gateway)
现在,从“开发机器”内部,启动远程端口转发 SERVER-GATEWAY:
$ docker exec -it -e GATEWAY_IP=${GATEWAY_IP} -e SERVER_IP=${SERVER_IP} devel sh
/ # ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
-f -N -R 0.0.0.0:8080:${SERVER_IP}:80 root@${GATEWAY_IP}
/ # exit # or detach with ctrl-p, ctrl-q
最后,验证开发服务器是否可在网关的公共接口上访问(从主机系统):
$ curl ${GATEWAY_IP}:8080
<!DOCTYPE HTML>
<html lang="en">
<head>
...
总结
在完成所有这些实验室和绘图后,我注意到:
- “local” 一词可以指 SSH 客户端机器 或从该机器可访问的上游主机。
- “remote” 一词可以指 SSH 服务器机器 (sshd) 或从它可访问的上游主机。
- 本地端口转发 (
ssh -L) 意味着是 ssh 客户端开始在新端口上监听。
- 远程端口转发 (
ssh -R) 意味着是 sshd 服务器开始在新端口上监听。
- 助记符是 "ssh -L local:remote" 和 "ssh -R remote:local",并且总是左侧打开新端口。
英文原文:https://iximiuz.com/en/posts/ssh-tunnels/