Тому, кто хостит приложение у провайдера наподобие Fly.io (далее — просто Fly), вполне может понадобиться подключиться к серверу, на котором работает это приложение, по SSH.
Но Fly — это вроде как белая ворона среди других подобных платформ. Наше железо работает в дата-центрах, разбросанных по всему миру. Наши серверы подключены к интернету через Anycast-сеть, а друг с другом они связаны с помощью WireGuard-сети. Мы берём у пользователей Docker-контейнеры и превращаем их в микровиртуальные машины Firecracker. И, когда мы только начали работать, мы поступали именно так для того чтобы дать нашим клиентам возможность запускать «пограничные приложения». Такие приложения обычно представляют собой сравнительно небольшие, самодостаточные фрагменты кода, которые весьма чувствительны к качеству работы сетей. Эти фрагменты кода, в результате, нужно запускать на серверах, расположенных как можно ближе к пользователям. В такой среде возможность подключения к серверу по SSH не так уж и важна.
Но теперь не все наши клиенты пользуются Fly по такой схеме. В наши дни в среде Fly можно без труда выполнять весь код, имеющий отношение к некоему приложению. Мы упростили процедуру запуска ансамбля сервисов в кластерной среде. Такие сервисы могут, используя защищённые каналы связи, взаимодействовать друг с другом, могут хранить данные на постоянной основе, могут, по WireGuard-сети, связываться со своими операторами. Если я продолжу рассказ о нашей системе в том же духе, то мне придётся дать ссылки на все материалы, которые мы написали за последние пару месяцев.
Но, в любом случае, нормальной поддержки SSH у нас не было.
Ясно, конечно, что можно было просто собрать контейнер со службой SSH, к которой можно подключиться по SSH. Платформа Fly поддерживает работу с обычными TCP-портами (и с UDP-портами тоже). Если клиент, пользуясь файлом fly.toml
, «расскажет» нашей Anycast-сети о своём странном SSH-порте, система организует маршрутизацию его SSH-подключений, после чего всё будет работать как надо.
Но те, кто создают контейнеры, обычно так не поступают, да мы и не предлагаем им поступать именно так. В результате мы оснастили Fly поддержкой SSH. То, что у нас получилось, устроено довольно необычно. В данном материале, состоящем из двух частей, я об этом расскажу.
Часть 1: 6PN и Hallpass
Я много написал о том, как во Fly устроены частные сети. Если рассказать об этом в двух словах, то окажется, что то, что есть у нас, можно сравнить с упрощённой IPv6-версией «виртуальных частных облаков» GCP или AWS. Мы называем эту систему 6PN. Когда во Fly запускается экземпляр приложения (микровиртуальная машина Firecracker), мы назначаем этому экземпляру особый IPv6-префикс. В префиксе закодировано несколько идентификаторов: идентификатор приложения, организации, которой принадлежит приложение, и аппаратных ресурсов, на которых приложение работает. Мы используем немного eBPF-кода для организации статической маршрутизации подобных IPv6-пакетов в нашей внутренней WireGuard-сети и для обеспечения того, чтобы клиенты не могли бы подключаться к системам организаций, к которым они не имеют отношения.
Кроме того, можно, используя WireGuard, посредством мостов, соединять создаваемые нами частные IPv6-сети с другими сетями. Наш API умеет создавать WireGuard-конфигурации, которые можно, например, применять на EC2-хостах для проксирования RDS Postgres. Или, если надо, можно пользоваться WireGuard-клиентами (в Windows, Linux или macOS) для подключения компьютера, на котором ведётся разработка, к собственной частной сети.
Вероятно, вы уже поняли, к чему я клоню.
Мы написали очень маленький и очень простой SSH-сервер на Go, который назвали Hallpass. Его можно сравнить с «Hello, World!», созданным с использованием Go-библиотеки x/crypto/ssh
. (Если бы я снова это делал, я, вероятно, просто использовал бы пакет Glider Labs, предназначенный для создания SSH-серверов. С использованием этого пакета наш сервер, в буквальном смысле, был бы программой уровня «Hello, World!».) При инициализации всех экземпляров микровиртуальных машин Firecracker выполняется и запуск Hallpass с привязкой к их 6PN-адресам.
Если вы способны работать в 6PN-сети своей организации (предположим, через WireGuard-соединение), это значит, что вы можете войти в экземпляр микровиртуальной машины с помощью Hallpass.
В том, как именно работает Hallpass, есть лишь одна интересная деталь. Она касается аутентификации. Инфраструктурные элементы в нашей рабочей сети обычно не имеют прямого доступа к нашим API или к лежащим в их основе базам данных. И у самих экземпляров Firecracker, конечно, такого доступа тоже нет. Это приводит к некоторым сложностям, связанным с изменением коммуникационных настроек. Как, например, ответить на вопрос о том, какими именно ключами нужно обладать для подключения к неким экземплярам микровиртуальных машин?
Мы нашли обходной путь решения этой задачи, прибегнув к SSH-сертификатам клиентов. Вместо того, чтобы заниматься передачей ключей каждый раз, когда пользователь хочет войти в систему с нового хоста, мы создаём корневой сертификат для организации этого пользователя. Публичный ключ для этого корневого сертификата размещается в нашей частной DNS-системе, а Hallpass обращается к DNS для получения этого сертификата каждый раз, когда происходит попытка входа в систему. Наш API подписывает новые сертификаты для пользователей, эти сертификаты можно применять для входа в систему.
Возможно, у вас есть вопросы по поводу этого решения. Поэтому я раскрою ещё некоторые подробности о нём.
Во-первых — поговорим о сертификатах. Десятилетия «безумства X.509», возможно, привели к тому, что слово «сертификат» вызывает у вас неприятное послевкусие. И я вас за это не виню. Но сертификаты стоит использовать при организации SSH-соединений, так как подобные сертификаты в данном случае — это хорошее решение. При этом SSH-сертификаты — это не X.509-сертификаты. Тут используется собственный формат OpenSSH, и, в общем-то, об этих сертификатах нельзя рассказать больше ничего особенного. У них, как и у всех остальных сертификатов, есть «срок годности», что позволяет создавать короткоживущие ключи (а это, почти всегда, именно то, что нужно). И, конечно, они позволяют назначить один публичный ключ целой группе серверов, который может авторизовывать произвольное количество приватных ключей. При этом нет необходимости постоянно обновлять соответствующие серверы.
Далее — наш API и подписание сертификатов. Ну что ж! Мы очень осторожны, но эти сертификаты, в целом, так же безопасны, как токены доступа к Fly. В настоящий момент сертификаты не могут быть защищены лучше, чем токены, так как токен позволяет развёртывать новые версии контейнеров приложений. Работа с Web PKI X.509 CA предусматривает массу формальностей. Мы обходимся без них.
И наконец — наша DNS. Она, соглашусь, выглядит как полная ерунда. Но она, на самом деле, не так уж и плоха. На каждом хосте, на котором выполняются экземпляры микровиртуальных машин Firecracker, работает и локальная версия нашего частного DNS-сервера (маленькая программа, написанная на Rust). eBPF-код обеспечивает то, что Firecracker-машины могут взаимодействовать только с этим DNS-сервером, обращаясь к нему с 6PN-адреса своего сервера. (С технической точки зрения — пользователь может выполнять запросы только к приватному API DNS этого сервера, а запросы всех остальных пользователей будут обрабатываться в рекурсивном режиме.) DNS-сервер может (знаю — выглядит это необычно) надёжно идентифицировать организацию, анализируя IP-адреса источников запросов. В общем-то — именно так мы и работаем.
Всё это происходит в недрах нашей системы, пользователям всего этого не видно. Пользователи видели лишь команду flyctl ssh issue -a
, которая запрашивала новый сертификат у нашего API, а потом передавала его локальному SSH-агенту, после чего SSH-подключения, в общем-то, оказывались работоспособными. Всё это было устроено достаточно аккуратно. Но любое дело всегда можно сделать аккуратнее, чем прежде.
Часть 2: работа в WireGuard-сети из пользовательского режима с применением TCP/IP
В вышеописанной схеме использования SSH кроется одна проблема, которая заключается в том, что не у всех установлен WireGuard. Соответствующую программу, правда, стоило бы установить всем. WireGuard — это замечательная технология, она очень помогает управлять приложениями, работающими на платформе Fly. Но, как бы там ни было, у некоторых наших пользователей нет WireGuard.
Правда, и такие пользователи нуждаются в работе со своими системами по SSH.
На первый взгляд то, что у кого-то не установлен WireGuard, может показаться непреодолимой помехой. Ведь как работает WireGuard? На компьютере пользователя создаётся новый сетевой интерфейс. Это — либо WireGuard-интерфейс уровня ядра (в Linux), либо — туннель с прикреплённым к нему WireGuard-сервисом пользовательского режима (во всех остальных ОС). Без этого сетевого интерфейса работать с WireGuard-сетью нельзя.
Но, если взглянуть на WireGuard под правильным углом, то можно увидеть, что, с технической точки зрения, это не так. А именно, для настройки нового сетевого интерфейса нужны привилегии уровня операционной системы. А вот для отправки пакетов на 51820/udp
никаких привилегий не нужно. Всё, что нужно для обеспечения работы протокола WireGuard, можно запустить в виде непривилегированного процесса, функционирующего в пользовательском режиме. Именно так работает пакет wireguard-go.
Это позволит лишь пройти процедуру WireGuard-рукопожатия. Но при этом не идёт речь об обмене информацией с узлами WireGuard-сети, так как нельзя просто взять и отправить некие произвольно оформленные данные другой системе, подключённой к этой сети. Подобная система ожидает пакетов, которые обычно передают по TCP/IP-сетям. Стандартные системные средства, поддерживающие работу UDP-сокетов, ничем не помогут в деле установки TCP-соединения с использованием подобных сокетов.
Сложно ли будет написать небольшой фрагмент кода, обеспечивающий работу протокола TCP в пользовательском режиме, рассчитанный исключительно на то, чтобы поддерживать обмен данными по WireGuard-сети, опять же, в пользовательском режиме? Такой код позволил бы пользователям Fly подключаться к своим системам по SSH и при этом не устанавливать у себя того, что обеспечивает работу WireGuard.
Я поступил опрометчиво, рассуждая обо всём этом в Slack-канале, в котором присутствовал Джейсон Доненфельд. А именно, я, поразмышляв вслух, отправился спать. Когда я проснулся, Джейсон уже всё это реализовал, используя gVisor, и включил в состав библиотеки WireGuard.
Самое интересное тут — это gVisor. Мы уже о нём писали. Если кто не знает — gVisor — это, в сущности, ОС Linux, работающая в пользовательском пространстве, Linux, реализованная на Golang, используемая в качестве замены runc
для выполнения контейнеров. Это, на самом деле, совершенно безумный проект. И если вы им воспользуетесь, то, полагаю, вы можете с гордостью рассказать об этом окружающим, потому что это — ну просто шикарная штука. В его недрах имеется полная реализация TCP/IP, написанная на Go, которая оперирует входными и выходными данными, представленными в виде обычных буферов []byte
.
Тогда было твитнуто несколько твитов, а потом, через пару часов, мне пришло очень приятное электронное письмо от Бена Баркерта. Бен уже занимался разными задачами, связанными с сетевой подсистемой gVisor, его заинтересовало то, над чем мы работали, он желал узнать, хотим ли мы с ним скооперироваться. Нам понравилась его идея по совместной работе над этим проектом. И теперь, если не вдаваться в подробности, у нас имеется реализация SSH, основанная на сертификатах, которая работает через gVisor-реализацию TCP/IP пользовательского режима. Всё это взаимодействует с WireGuard-сетью посредством пакета пользовательского режима wireguard-go
. И, наконец, эта штука встроена во flyctl
.
Для того чтобы воспользоваться SSH с помощью flyctl
— достаточно ввести команду такого вида:
flyctl ssh shell personal dogmatic-potato-342.internal
А теперь, чтобы вы могли осознать всю невероятность происходящего, немного расскажу об этой команде. Так — dogmatic-potato-342.internal
— это внутреннее DNS-имя, разрешающееся только силами приватного DNS-сервера в сети 6PN. Всё это работоспособно из-за того, что в режиме ssh shell
утилита flyctl
использует TCP/IP-стек gVisor пользовательского режима. Но в gVisor нет кода для выполнения DNS-поиска. Это — всего лишь стандартная Go-библиотека, которую мы обдурили, подсунув ей наш особенный TCP/IP-интерфейс.
Flyctl
, между прочим, это — опенсорсный проект (он и должен таким быть, так как клиентам нужно пользоваться им на собственных компьютерах, на которых они занимаются разработкой). Поэтому, если интересно, можете просто почитать его код. Бен написал хороший код, находящийся в папке pkg. А весь остальной код, кошмарный, написал я. В Go обеспечение обмена данными по протоколу IP в сети WireGuard выглядит на удивление просто. Если вы когда-нибудь занимались низкоуровневым TCP/IP-программированием, то вам, возможно, эта простота покажется невероятной. Объекты из TCP-стека gVisor подключаются прямо к сетевому коду стандартной библиотеки.
Взгляните на этот код:
tunDev, gNet, err := netstack.CreateNetTUN(localIPs, []net.IP{dnsIP}, mtu)
if err != nil {
return nil, err
}
// ...
wgDev := device.NewDevice(tunDev, device.NewLogger(cfg.LogLevel, "(fly-ssh) "))
CreateNetTUN
— это часть wireguard-go
. Тут используются возможности gVisor. В нашем распоряжении оказывается, во-первых, синтетическое туннельное устройство, которое можно использовать для чтения и записи обычных пакетов, обеспечивающих работу WireGuard. Во-вторых — у нас имеется функция net.Dialer, обёртка для gVisor, которую можно использовать в Go-коде и через неё взаимодействовать с соответствующей WireGuard-сетью.
Это всё? В общем-то — да. Вот, например, как мы используем эти механизмы для работы с DNS:
resolv: &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
return gNet.DialContext(ctx, network, net.JoinHostPort(dnsIP.String(), "53"))
},
},
Это — обычный сетевой код, написанный на Go. В общем — хорошо получилось.
Очевидно, все должны поступать именно так
Благодаря парам сотен строк кода (это — если не считать код реализации Linux для пользовательского режима, которая достаётся нам от gVisor; но что делать — от зависимостей никуда не деться) можно получить в своё распоряжение новую сеть с криптографической аутентификацией. Сеть, которая доступна в любое время и практически из любой программы.
Понятно, что такая сеть значительно медленнее, чем та, что основана на реализации TCP/IP уровня ядра. Но часто ли это по-настоящему важно? И, в особенности, часто ли это имеет какое-то значение при решении периодически возникающих задач, для решения которых обычно строят странные, неизвестно из чего собранные, TLS-туннели? Когда скорость имеет значение, можно просто перейти на обычную реализацию WireGuard.
В любом случае, то, о чём я рассказал, решило огромную нашу проблему. Эта система ведь подходит не только для организации работы SSH. Мы, кроме того, хостим базы данных Postgres. Очень удобно, когда есть возможность, выполнив простую команду, буквально откуда угодно открыть оболочку psql
, независимо от того, можно ли, именно в нужный момент, установить WireGuard для macOS.
Пользуетесь ли вы WireGuard?
Автор: ru_vds