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

Лабораторные к визуальному гайду по туннелям SSH
👋
Хочешь поучаствовать в жизни сайта? Мы ищем авторов!
Это лабораторные вот к этому гайду-переводу: Визуальный гайд по туннелям 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.

Олег Ямников

Олег Ямников

Главный кухонный корреспондент.