Лабораторные к визуальному гайду по туннелям SSH

Подготовка
Смысл туннелей SSH в том, чтобы соединять хосты по сети, так что все лабы ниже очевидно работают с несколькими "машинами". Тем не менее, мне лень запускать полноценные инстансы, когда легче использовать контейнеры. Поэтому я решил использовать одну виртуальную машину Vagrant с установленным Docker.
В теории, подойдет любая машина с Linux и Docker на борту. Но запуск примеров ниже на Docker Desktop на сработает, потому что в них подразумевается, что хост может обращаться к машинам контейнерам по их IP.
Альтернативно лабы можно делать в Lima (QEMU + nerdctl + containerd + BuildKit), но не забудьте выполнить limactl shell bash
.
Каждый пример требует наличие валидной ключевой пары без парольной фразы на хосте. Эта пара маунтится в контейнеры, чтобы упростить управление доступом. Если у вас нет ключей - они просто генерируются командой ssh-keygen
на хосте.
Важно: демоны SSH установлены в контейнеры чисто для образовательных целей - контейнеры здесь призваны быть примерами полноценных машин с установленными серверами и клиентами SSH. Имейте в виду, что в реальности ставить SSH в контейнеры - это обычно не лучшая затея!
Лаба 1: Проброс локального порта (Local Port Forwarding)

Эта лабораторная воспроизводит ситуацию из диаграммы выше. Первым делом нам надо подготовить сервер: машину с демоном SSH и простым веб-сервисом, который слушает на 127.0.0.1:80
:
$ docker buildx build -t server:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3
# Устанавливаем зависимости:
RUN apk add --no-cache openssh-server curl python3
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A
# Подготавливаем entrypoint, который запустит демонов:
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
# Минимальный конфиг для сервера SSH:
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
# Запуск:
CMD ["/entrypoint.sh"]
EOD
Запускаем сервер и записываем его IP:
$ 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
)
Так как веб-сервис слушает на localhost, он не будет доступен снаружи машины/контейнера (т.е. не будет доступен с хоста в данном случае):
$ curl ${SERVER_IP}
curl: (7) Failed to connect to 172.17.0.2 port 80: Connection refused
Но изнутри нашего "сервера" все работает:
$ ssh -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:8080
хоста к localhost:80
сервера с помощью проброса локального порта:
$ ssh -o StrictHostKeyChecking=no -f -N -L 8080:localhost:80 root@${SERVER_IP}
Теперь веб-сервис должен быть доступен на локальном порте системы хоста:
$ 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 -o StrictHostKeyChecking=no -f -N -L \
localhost:8080:localhost:80 \
root@${SERVER_IP}
Лаба 2: Проброс локального порта с хостом-бастионом (Local Port Forwarding with a Bastion Host)

Как и в прошлый раз, лабораторная воспроизводит ситуацию на диаграмме выше. Сначала нам нужно подготовить хост-бастион - машину только с демоном SSH:
$ docker buildx build -t bastion:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3
# Устанавливаем зависимости:
RUN apk add --no-cache openssh-server
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A
# Подготавливаем entrypoint, который запустит демона SSH:
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
# Минимальный конфиг для сервера SSH:
sed -i '/AllowTcpForwarding/d' /etc/ssh/sshd_config
sed -i '/PermitOpen/d' /etc/ssh/sshd_config
/usr/sbin/sshd -e -D &
sleep infinity
EOF
# Запуск:
CMD ["/entrypoint.sh"]
EOD
Запускаем бастион и записываем его IP:
$ docker run -d --rm \
-v $HOME/.ssh:/tmp/ssh \
--name bastion \
bastion:latest
BASTION_IP=$(
docker inspect \
-f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
bastion
)
Теперь запускаем веб-сервис, к которому будем подключаться, на отдельной "машине":
$ 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
)
Давайте представим, что вызывать curl ${SERVER_IP}
с хоста по какой-то причине невозможно (например, нет маршрута от хоста к этому IP). Поэтому нам нужно сделать проброс порта:
$ ssh -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>
...
Лаба 3: Проброс удаленного порта (Remote Port Forwarding)

Лабораторная воспроизводит ситуацию на диаграмме. Сначала подготовим "машину разработчика" - компьютер с клиентом SSH и локальным веб-сервисом:
$ docker buildx build -t devel:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3
# Устанавливаем зависимости:
RUN apk add --no-cache openssh-client curl python3
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh
# Подготавливаем entrypoint, который запустит веб-сервис:
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
# Запуск:
CMD ["/entrypoint.sh"]
EOD
Запускаем машину разработчика:
$ docker run -d --rm \
-e PORT=80 \
-v $HOME/.ssh:/tmp/ssh \
--name devel \
devel:latest
Подготавливаем входной шлюз - простой сервер SSH с параметром GatewayPorts
, заданным на yes
в sshd_config
:
$ docker buildx build -t gateway:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3
# Устанавливаем зависимости:
RUN apk add --no-cache openssh-server
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A
# Подготавливаем entrypoint, который запустит сервер SSH:
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
# Запуск:
CMD ["/entrypoint.sh"]
EOD
Запускаем шлюз и запоминаем его 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 -o StrictHostKeyChecking=no -f -N -R 0.0.0.0:8080:localhost:80 root@${GATEWAY_IP}
/ $# exit # или сделайте detach с помощью 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>
...
Лаба 4: Проброс удаленного порта в домашнюю/частную сеть (Remote Port Forwarding from a Home/Private Network)

Как всегда, лабораторная воспроизводит ситуацию на диаграмме. Сначала нам нужно подготовить "тонкую машину разработчика":
$ docker buildx build -t devel:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3
# Устанавливаем зависимости:
RUN apk add --no-cache openssh-client
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh
# В этот раз мы ничего не делаем (по началу):
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail
cp /tmp/ssh/* /root/.ssh
chmod 600 /root/.ssh/*
sleep infinity
EOF
# Запуск:
CMD ["/entrypoint.sh"]
EOD
Запускаем машину разработчика:
$ 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
)
Подготавливаем входной шлюз:
$ docker buildx build -t gateway:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3
# Устанавливаем зависимости:
RUN apk add --no-cache openssh-server
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A
# Подготавливаем entrypoint, который запустит демона SSH:
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
# Запуск:
CMD ["/entrypoint.sh"]
EOD
И запускаем его:
$ 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 -o StrictHostKeyChecking=no -f -N -R 0.0.0.0:8080:${SERVER_IP}:80 root@${GATEWAY_IP}
/ $# exit # или сделайте detach с помощью ctrl-p, ctrl-q
Наконец проверяем, что приватный сервис стал доступен хосту через публичный интерфейс шлюза:
$ curl ${GATEWAY_IP}:8080
<!DOCTYPE HTML>
<html lang="en">
<head>
...
Материал подготовлен с ❤️ редакцией Кухни IT.