habrahabr

VPN на минималках ч.2, или трое в docker не считая туннеля

  • суббота, 30 апреля 2022 г. в 00:36:14
https://habr.com/ru/company/otus/blog/663482/
  • Блог компании OTUS
  • Информационная безопасность
  • Python
  • *nix


-Ну что, закончил свой проект?

-Да, закончил, вот смотри…

Напал Дед! - Инженеры-проектировщики

Привет, хабр! 

К прошлой статье закономерно возник ряд вопросов, и, перед тем как продолжить рассказ о внутривенном курсе отечественного велосипедостроения внесу ряд важных уточнений. Как мне показалось, постановка задачи была достаточно понятной. Со временем оказалось, что всё-таки показалось.

В: Вы изобрели VDI (или {another_technology_name})

О: Как ни странно, вы правы! Однако предпосылки такого “изобретательства” заключаются в том, что надоело объяснять что делать, куда тыкать, и.т.д. Хочется, для своего же спокойствия, однокнопочный интерфейс.

В: Почему бы не использовать Apache Guacamole?

О: Спасибо за наводку, о таких вещах в принципе знаю, над вариантами применения не думал. Возьму на карандаш.

В: Все равно получается небезопасно, потому, что *args, **kwargs

О: Помните мем про “открыть ворота”, “закрыть ворота”, “открыть ворота чуть-чуть”? Аналогично, если хотим больше безопасности - будет меньше удобства. Больше удобства - меньше безопасности. Ключевым моментом, из-за которого это решение вообще родилось является количество людей, которым нужно помогать с настройкой. В компании для доступа к доменным ВМ постоянными сотрудниками используется VPN, но он настраивается один раз. Здесь же цель - удобство (в первую очередь админа), с точки зрения безопасности - задача не дать слишком простого и очевидного пути реализации угрозы, например, перебора пароля, либо тестирования эксплойта. Да, пути обхода есть. Однако, согласитесь, что это не forward port вовне.

План взятия Парижа

Закончив очередной сеанс рефлексии нужно обсудить более насущные вопросы:

  1. Из каких компонент должна состоять система

  2. Как компоненты будут между собой взаимодействовать

  3. Какими будут реальные уязвимости и риски

Оставив третий вопрос “на десерт”, начнем с проекта серверной части. Из постановки задачи следует, что клиент, переходя по ссылке, подключается к удаленному сеансу RDP, причём соединение осуществляется через ssh-туннель.

Итак, нам понадобится:

  1. SSH-туннели. На мой взгляд поднимать ssh-сервер для решения нашей задачи проще всего в docker, а значит нужен сервис, создающий, либо убивающий контейнеры по запросу. Теоретически, предельное количество контейнеров будет равным количеству доступных ВМ (считаем, что одновременно к ВМ может подключаться только один пользователь). Кроме того, сервис должен убивать контейнеры, которые по каким-либо причинам не были штатно остановлены и удалены при отключении пользователя.

  2. Хранить информацию об эндпоинте (IP, логин, пароль), уникальной ссылке для подключения.

  3. Подключаться, с использованием ссылки, от клиента.

Получается такая структура

С точки зрения клиента схема взаимодействия следующая:

  1. Получаем ссылку вида https://forward-me-to-rdp.ru/xUo64Zz

  2. Подключаемся по протоколу websocket к wss://forward-me-to-rdp.ru

  3. По протоколу JRPC запрашиваем данные для подключения к эндпоинту xUo64Zz

  4. Если эндпоинт занят - получаем информацию о его занятости, подключение невозможно

  5. Если эндпоинт свободен:

    1. Сервер поднимает контейнер с openssh-сервером

    2. Передает клиенту номер порта, уникальную пару (логин, пароль) от ssh, IP-адрес эндпоинта во внутренней сети, и данные для подключения к нему. Так как текущая реализация затачивается исключительно под RDP - будем хранить минимум данных, необходимых для подключения.

Клиент взаимодействует с сервисом администрирования именно через вебсокет, а не REST API, для того, чтобы отследить момент его отключения, остановить и удалить контейнер с openssh, дабы он не болтался.

Для реализации задуманного будем использовать Python, конкретно фреймворк FastAPI, так как на нем можно достаточно быстро реализовать обычное HTTP API (при создании MVP оно нам конечно не сильно нужно, но для реализации админки понадобится), еще он умеет в вебсокеты, что нам собственно говоря очень полезно.

Ехал docker через docker

Начнем с контейнера с openssh-сервером. Нам понадобится: alpine, openssh, и скрипт для запуска, задающий нужные настройки (создание пользователя, дописывание в sshd_config строчки с параметром PermitOpen, размещающим поднятие туннеля до нужного host:port).

FROM alpine:latest
LABEL org.opencontainers.image.authors="dmitry8912@gmail.com"
COPY sshd_config /etc/ssh/sshd_config
COPY run.sh /root/run.sh
RUN apk add openssh && chmod +x /root/run.sh
ENTRYPOINT ["/root/run.sh"]

В самом Dockerfile ничего интересного не происходит, копируем внутрь sshd_config и скрипт для запуска.

PermitRootLogin no
MaxSessions 1
AuthorizedKeysFile	.ssh/authorized_keys
PasswordAuthentication yes
PermitEmptyPasswords no
AllowTcpForwarding yes
GatewayPorts no
X11Forwarding no
Subsystem	sftp	/usr/lib/ssh/sftp-server

Для конфигурации ssh-демона на всякий случай (хотя лучше так делать всегда) запрещаем логин от лица root (параметр PermitRootLogin), устанавливаем максимальное количество одновременных сессий равное единице (параметр MaxSessions), и разрешаем туннелирование (параметр AllowTcpForwarding). При запуске контейнера в конфигурационный файл будет дописываться строка вида:

PermitOpen my-remote-vm:3389

Где my-remote-mv заменится на IP-адрес конкретной машины во внутренней сети.

И последнее, что нам нужно - это скрипт для запуска сервера:

#!/bin/sh
# Сгенерируем ключи для сервера
ssh-keygen -A
# Добавим нового пользователя, имя пользователя и пароль возьмем из переменных окружения
adduser -D $SSH_USER && echo "$SSH_USER:$SSH_PASS" | chpasswd
# Добавим директиву, ограничивающую направления для туннелирования трафика через sshd
printf "\nPermitOpen $TO_HOST:3389" >> /etc/ssh/sshd_config
# Запустим ssh-сервер
/usr/sbin/sshd -D

Теперь у нас есть возможность с помощью docker run запустить ssh-сервер внутри контейнера, передав ему имя пользователя, пароль, и ip-адрес для туннеля.

# Для начала построим образ с помощью dockerfile
docker build -t miniv .

# После успешного простоения можно запустить контейнер и проверить его работу
docker run --env SSH_USER=habr45j7 --env SSH_PASSWORD=sByj9FvZgQumQj4T --env TO_HOST=172.22.0.109 -it --rm -p "3022:22" minivtest

Главная часть проекта готова, перейдем к реализации MVP нашего сервиса. Для pyhton существует пакет docker-py, с помощью которого можно строить образы, запускать и останавливать контейнеры, по сути делать всё, что нам нужно. Для работы с docker реализуем класс-обертку:

import docker


class MiniVContainerManager:
    __containers = dict()
    __instance = None
    __client = None
    __image_name = 'miniv-gw'

    @staticmethod
    def get_instance():
        if MiniVContainerManager.__instance is None:
            MiniVContainerManager.__instance = MiniVContainerManager()
        return MiniVContainerManager.__instance

    def __int__(self):
        self.__client = docker.DockerClient(base_url='unix://var/run/docker.sock')

    def build_image(self):
        return self.__client.images.build(path='../build', tag=MiniVContainerManager.__image_name)

    def get_image(self):
        self.__client = docker.DockerClient(base_url='unix://var/run/docker.sock')
        return self.__client.images.get(MiniVContainerManager.__image_name)

    def run_container(self, client_id: str, external_port: int, ssh_username: str, ssh_password: str,
                      tunnel_destination: str):
        self.__client = docker.DockerClient(base_url='unix://var/run/docker.sock')
        img = self.get_image()
        container = self.__client.containers.run(img,
                                                 detach=True,
                                                 remove=True,
                                                 environment={
                                                     "SSH_USER": ssh_username,
                                                     "SSH_PASS": ssh_password,
                                                     "TO_HOST": tunnel_destination
                                                 },
                                                 ports={'22/tcp': external_port})
        self.__containers[client_id] = container

    def stop_container(self, client_id: str):
        self.__containers[client_id].stop()

Реализуем простой websocket-endpoint на fastapi:

import json
import string
from contextlib import closing
import random
import socket
from typing import List
from fastapi import FastAPI, WebSocket
from starlette.websockets import WebSocketDisconnect
from app.container_manager import MiniVContainerManager

app = FastAPI()

class ConnectionManager:
    def __init__(self):
        self.active_connections: List[WebSocket] = []

    async def connect(self, websocket: WebSocket, endpoint_id: str, **kwargs):
        await websocket.accept()
        self.active_connections.append(websocket)
        MiniVContainerManager.get_instance().run_container(endpoint_id,
                                                           kwargs['external_port'],
                                                           kwargs['username'],
                                                           kwargs['password'],
                                                           kwargs['to_host'])

    def disconnect(self, websocket: WebSocket, endpoint_id: str):
        self.active_connections.remove(websocket)
        MiniVContainerManager.get_instance().stop_container(endpoint_id)

    async def send(self, message: str, websocket: WebSocket):
        await websocket.send_text(message)


manager = ConnectionManager()


def get_port():
    with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
        s.bind(('', 0))
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        return s.getsockname()[1]


@app.websocket("/cjrpc/{endpoint_id}")
async def websocket_endpoint(websocket: WebSocket, endpoint_id: str):
    host = '172.27.2.2'
    port = get_port()
    username = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(10))
    password = ''.join(random.choice(string.ascii_lowercase + string.digits + string.punctuation) for _ in range(23))
    await manager.connect(websocket, endpoint_id, external_port=port, username=username, password=password,
                          to_host=host)
    try:
        while True:
            result = json.dumps({
                'host': host,
                'ssh_username': username,
                'ssh_password': password,
                'external_port': port
            })
            await manager.send(result, websocket)
            await websocket.receive_text()
    except WebSocketDisconnect:
        manager.disconnect(websocket, endpoint_id)

Схема работы MVP следующая:

  1. Клиент получает ссылку, вида https://forward-me-to-rdp.ru/xUo64Zz

  2. Клиент подключается на ws://server/cjrpc/xUo64Zz

  3. Сервер при подключении нового клиента:

    1. Генерирует случайные имя пользователя и пароль

    2. Выбирает свободный порт для проброса в контейнер

    3. Запускает контейнер

    4. Передает данные для подключения клиенту в ответ

  4. Клиент, получая данные, подключается к серверу

  5. При отключении клиента по exception`у WebSocketDisconnect контейнер, ассоциированный с клиентом, останавливается, и автоматически удаляется

  6. Установление туннеля возможно только к тому IP и порту, который указан в директиве PermitOpen, конечный пользователь на это повлиять не в состоянии.

В следующей, завершающей статье реализуем клиент для пользователей, веб-интерфейс для управления, и детерминируем риски в плане безопасности.

GitHub проекта

Хочу порекомендовать бесплатный урок по теме: "Работа с сетью", который 16 мая проведут мои коллеги из OTUS. Зарегистрироваться на урок можно тут.