Серия «RoutineOps»

20

Я написал свой self-hosted MDM для смешанного парка корпоративных устройств

Серия RoutineOps

Полгода назад я пришёл в новую организацию, и мне достался парк машин. Десяток на Windows, несколько маков, пачка линуксовых ноутов у разработчиков. Невыносимым было другое. Навешанные до меня политики и блокировки со стороны ИБ связывали руки так, что здраво администрировать домен было попросту нельзя: любое рутинное действие упиралось в чужие ограничения. Тогда я и полез искать решение, которое помогло бы мне нормально админить этот парк.

Готового, что легло бы на мою ситуацию, я так и не нашёл — и в итоге сел писать своё. Ниже — разбор инженерных решений, которые по дороге пришлось принять, и карта того, где у этой конструкции проходит настоящая граница безопасности. Последнее для меня важнее всей остальной механики: MDM по определению даёт слишком много власти над чужими машинами, и делать вид, что это не так, я не стал. Поэтому архитектуру ниже я описываю как ответ на вопрос «где это ломается».

Откуда взялась задача

Требований у меня было немного, но именно они всё и отсеяли: сервер должен стоять у меня, телеметрия парка не утекать на сторону, и всё это работать на смешанном парке сразу. Self-hosted-вариантов под такое оказалось немного, и те, что были, спотыкались об одно и то же: либо только маки, либо платные и при этом недоступны в России, либо разваливались на простом вопросе — а что с устройством, если агент две недели просидел офлайн. Ближе всего в этом поиске подобрался Fleet — серьёзный открытый проект, к нему я ещё вернусь ниже. Но развернуть его у себя оказалось тем ещё квестом: я в России, а fleetdm на российский IP не але, так что и сервер, и сборку агентов приходилось поднимать через VPN. Вдобавок агент на macOS в моём случае вставал через раз, а то и вовсе не ставился. Инструмент, который в итоге получился, я назвал RoutineOps; дальше по тексту буду говорить «агент», «сервер» и «панель».

Ещё одно соображение, которое я держал в голове: базовую защиту управляющего канала — mTLS, аудит, вменяемую политику паролей, подпись обновлений — я считаю БАЗОЙ, и держать её за пейволлом мне казалось неправильным. Это моё мнение, не претензия к рынку. Проект в итоге открыт под Apache-2.0; платный уровень существует, но вся базовая защита — в открытой части, и это не тема статьи.

Почему постоянный gRPC/mTLS-канал, а не опрос через VPN

Вечная головная боль: как дотянуться до ноутбука, который сейчас сидит в кафе за чужим NAT. Классических ответов два, и оба мне не понравились. Загнать всех в VPN — это лишняя инфраструктура и ещё одна точка отказа. Сделать агент, который раз в N минут дёргает HTTP-эндпоинт «не прилетело ли чего», — это шторм пустых запросов, а задержка команд упирается в период опроса.

Я пошёл третьим путём. Агент — Go-бинарь, который держит постоянный gRPC-стрим поверх mTLS наружу, по обычному интернету. Соединение инициирует сам агент: оно исходящее, а значит дружит с NAT. По этому же двунаправленному стриму сервер в любой момент проталкивает команду — запусти скрипт, заблокируй экран. Задержка доставки тут — это задержка сети, а не интервал, на который выставлен опрос.

Стек намеренно скучный — скучное не будит меня в три ночи.

  • Агент и сервер — Go, сервер монолитом, без зоопарка микросервисов.

  • Связь — gRPC + Protocol Buffers поверх mTLS: TLS 1.3, приватный CA с пиннингом на всём канале агентов.

  • База — PostgreSQL 16, источник правды.

  • Очередь задач — Redis + Asynq, с ретраями.

  • Веб-интерфейс — React + TypeScript (Vite), раздаётся nginx-контейнером.

  • Развёртывание — Docker Compose.

Порты минимальны: 443 — веб-интерфейс, REST API, enroll, отдача бинарей; 50051 — постоянный gRPC-канал агентов. Postgres и Redis наружу не торчат вообще. На парк до 50 устройств хватает 1 vCPU / 2 GB RAM / 20 GB SSD — и это стартовая планка. Heartbeat дешёвый, инвентарь редкий, поэтому один узел спокойно тянет тысячи устройств: предел задаёт железо машины, не архитектура.

Сертификат — это идентичность, и всё

Самый важный архитектурный вопрос: как агент доказывает, что он именно то устройство, за которое себя выдаёт. Ответ короткий. device_id — это CN клиентского сертификата, и только он. Идентификаторам в теле сообщений сервер не верит вообще. Прилетел heartbeat, а внутри указан чужой device_id? Игнорируется. Значение имеет одно — чем подписан TLS-хендшейк.

Отсюда и enrollment, устроенный так, чтобы приватный ключ устройства никогда не покидал устройство:

  1. Админ в панели заводит устройство и получает одноразовый токен: TTL 24 часа, single-use, гонка при погашении закрыта на уровне БД.

  2. Агент локально генерирует пару ключей и отправляет CSR на POST /api/v1/enroll.

  3. Сервер подписывает сертификат своим приватным CA и сам проставляет CN. Повлиять на свой CN агент не может.

Подделать чужое устройство без его ключа не выйдет — и не потому, что «мы проверяем поля», а потому, что проверять тут в принципе нечего. Криптография здесь вырезает целый класс авторизационной логики. Решение нравится мне тем, что после него кода становится меньше.

Heartbeat и инвентаризация разведены нарочно

Наивно было бы слить всё в один поток: раз в минуту слать полный отчёт о железе и заодно сообщать, что жив. На парке это дорого и бессмысленно — список установленного софта не меняется каждые тридцать секунд. Поэтому потока два, независимых.

Heartbeat — лёгкий и частый, примерно раз в 30 секунд, по постоянному стриму; по нему же едут задачи. Дёшево, потому что данных в нём кот наплакал. Инвентаризация — тяжёлая и редкая, примерно раз в 5 минут: ОС, железо, серийник, установленное ПО (на Linux — из dpkg/rpm/pacman/apk), версия агента.

Такое разделение держит постоянный канал дешёвым при любом размере парка. Если устройство пропало с радаров и heartbeat не приходит дольше порога (порог настраивается через AGENT_UNREACHABLE_MINUTES) — поднимается алерт agent_unreachable, с подавлением дребезга от сна и modern standby, иначе каждый закрытый на ночь ноут спамил бы в Telegram Max :).

Результаты скриптов идемпотентны. У каждого запуска есть run_id, и повторная доставка дедуплицируется на сервере. Связь рвётся, ack теряется, агент шлёт результат заново — без этой механики ловил бы дубли, а на неидемпотентных командах ещё и двойное исполнение.

Агенту не нужна постоянная связь

Постоянный канал удобен, но агент не должен превращаться в кирпич, стоит серверу отвалиться. Cron-скрипт-политики крутит локальный планировщик прямо на устройстве: сервер лёг на обновление — политики по расписанию всё равно отработают.

Сложнее всего с блокировкой экрана. В офлайне полноэкранный overlay держится, а разблокировка идёт по локально хранимому bcrypt-хешу пароля — ходить за ней к серверу не нужно. Это осознанный компромисс: если бы разблокировка требовала сервер, любой обрыв связи превращал бы блокировку в невозможность войти в систему — а это уже хуже самой угрозы. Результаты и события тем временем копятся локально и досылаются, когда связь вернётся.

Временные админ-права — без поездки к машине

Бытовой, но постоянный сценарий: пользователю нужно поставить программу, которая требует прав администратора. Раньше это значило либо дойти до машины ногами, либо подключиться к ней удалённо и вбить админский пароль руками — и так на каждую установку.

Теперь пользователь запрашивает временные локальные админ-права прямо из трея агента, я одобряю заявку в панели — и он ставит нужное сам, а по истечении срока повышение снимается. Ни подходить к машине, ни поднимать удалённую сессию ради одной инсталляции не надо. Мелочь на фоне остальной механики, но именно из таких мелочей и складывалось то самое «здраво администрировать парк», ради которого всё и затевалось.

Fail-closed self-update и миграции

Самообновление — самый опасный канал из всех. Тот, кто им рулит, кладёт свой бинарь на все устройства как root. Поэтому здесь всё fail-closed. Примерно раз в 6 часов агент тянет манифест и проверяет sha256 и ed25519-подпись по полному манифесту: версия, ОС, архитектура, хеш подписаны одним набором сразу. Так нельзя подсунуть валидный бинарь под чужую версию или платформу. Дальше агент атомарно заменяет себя и перезапускается. Даунгрейд невозможен — есть anti-rollback floor: битый релиз чинится только версией вперёд, назад дороги нет. Приватный ключ подписи уникален для конкретной инсталляции; потеря не катастрофа — новый раздаётся через переэнролл.

На сервере тот же принцип. Схему накатывает отдельный migrate-сервис — до старта сервера. Не прошла миграция — сервер просто не поднимется на несовместимой схеме. Down-миграций нет, и это намеренно: откат — только из бэкапа, причём бэкап БД update.shснимает сам перед каждым обновлением. Пережить факап из снапшота я предпочту тому, чтобы полагаться на корректность down-скрипта, накатываемого поверх наполовину применённого состояния.

Модель доверия: god-mode by design

А вот тут льстить не буду — и это, пожалуй, самая важная часть текста. MDM по своей природе — это god-mode над парком. Скрипт-канал исполняет произвольный bash -c / powershell -Command как root/SYSTEM на каждом устройстве. Это не дыра, которую я забыл заткнуть. Это и есть продукт: весь смысл MDM в том, чтобы раскатать одну команду на сотню машин разом. А такой инструмент по определению — санкционированный RCE.

Скажу прямо.

  • Подписи на скрипт-канале нет. И не будет. Ed25519-подпись защищает только канал самообновления — анти-тампер и анти-даунгрейд бинаря. Она никак не ограничивает то, что вы запускаете на устройствах. Тезис «скомпрометированный сервер не сможет выполнить код на парке» — неверное прочтение. Ещё как сможет: выполнение произвольного кода на парке — это его штатная функция.

  • JWT_SECRET (симметричный HS256) — единственный корень доверия панели. Кто прочитал этот секрет, тот печатает себе сколько угодно валидных admin-токенов. Не «подобрать пароль», не «обойти MFA» — просто сгенерировать подписанный токен и зайти админом.

  • Отсюда единственный вывод: реальный периметр безопасности — это хост, на котором крутится сервер. Не TLS, не RBAC, не аудит. Они важны, но вторичны. Увели сервер — увели весь парк.

Харденинг этого хоста — работа оператора, и в SECURITY.md под неё лежит чеклист: SSH только по ключам, наружу открыты только два порта, Postgres и Redis — на localhost, JWT_SECRET генерируется через openssl rand -base64 48 и лежит в режиме 600, панель — за VPN или IP-allowlist, аудит-лог — в append-only хранилище с алертом на появление новых админов.

Я специально не прячу этот раздел в мелкий шрифт. Любой MDM устроен ровно так же — просто не каждый проговаривает это вслух. Мне важно, чтобы человек ставил такой инструмент с открытыми глазами: в безопасности честность — это техническое свойство, а не тон голоса.

Что закрыто по умолчанию

Периметр — на операторе. Но всё, что можно закрыть кодом, закрыто по умолчанию, без единой галочки:

  • Не стартует на слабом секрете. Требует JWT_SECRET от 32 байт и минимум 16 различных байт. Случайно уехать в прод на changeme не получится.

  • Admin-JWT живёт 8 часов. Logout реально ревокирует токен через jti-блоклист. Смена или сброс пароля обнуляет все ранее выданные токены пользователя разом — через token-epoch.

  • Lockout по IP и по аккаунту, bcrypt cost 12. Форма входа не выдаёт, какие аккаунты вообще существуют.

  • Политика сложности пароля — для всех, включая seed-админа: от 8 символов, минимум 3 класса символов из 4. Даже первый администратор не заведётся с admin/admin.

  • RBAC на две роли — it_admin (всё) и viewer (только чтение), с проверкой на сервере. Viewer, дёрнувший мутирующий эндпоинт прямо из DevTools, упрётся в 403 ещё на сервере.

  • Плюс одноразовые enroll-токены, журнал аудита на каждое привилегированное действие (retention по умолчанию 365 дней), security-заголовки (HSTS/CSP/X-Frame-Options/nosniff), rate-limit и cap на размер запроса.

Ни одна из этих механик не спасёт скомпрометированный хост — см. раздел про модель доверия. Они закрывают то, что реально можно закрыть на уровне приложения, и не притворяются, что закрывают больше.

Честные ограничения

Раз обещал честность — вот граница, без прикрас.

  • Один узел — одна точка отказа. Для парка в режиме «поставил и работает» этого достаточно, но иллюзий про отказоустойчивость держать не стоит.

  • Нет SSO и MFA. Вход по паролю плюс RBAC. Пока разумно держать панель за VPN или allowlist.

  • macOS .pkg не подписан Apple. По двойному клику встретит Gatekeeper — ставится через installer из терминала.

  • Скрипт-канал — это RCE by design. Подробно — в разделе про модель доверия выше.

Про Fleet

Ближайший открытый аналог, который я смотрел, — Fleet, тоже open source, ядро под MIT. Инструмент серьёзный и зрелый, и во многом он сильнее моего: настоящий нативный MDM с профилями конфигурации и zero-touch-энроллментом, live-запросы osquery по всему парку, расчёт на масштаб в сотни тысяч машин. Построен вокруг osquery, инфраструктура — MySQL плюс Redis за балансировщиком, под горизонтальное масштабирование.

Под мой случай он просто не сошёлся по устройству. Чтобы реально управлять маками, Fleet опирается на нативный Apple MDM — а это APNs-сертификат от Apple с ежегодным продлением плюс Apple Business Manager для zero-touch. Мне не нужна была SQL-аналитика по всему парку; нужен был лёгкий агент, офлайн-лок без APNs, инвентарь и скрипты. А поверх этого архитектурного несовпадения легли те самые бытовые проблемы с развёртыванием из России и капризным macOS-агентом, о которых я писал в начале.

Чему это меня научило

Самое неожиданное в проекте — что львиная доля инженерных решений оказалась не про «как добавить», а про «как убрать». Идентичность через CN вырезала целый класс серверной авторизации; fail-closed на миграциях и обновлениях — ветки «а что если накатилось наполовину». Разведённые heartbeat и инвентарь сняли лишний трафик. А карта модели доверия избавила меня самого от иллюзии, будто TLS и RBAC — это и есть безопасность. Настоящий периметр оказался в одном месте — на хосте сервера.

Исходники я открыл под Apache-2.0; прямую ссылку на репозиторий оставлю тут :). Если вам будет интересно я бы с радостью пообщался по замечаниям именно по модели доверия — по местам, где приложение должно что-то enforce-ить, но не делает.

Показать полностью 5
3

«Выполнено» — самый дорогой неверный ответ. Как enterprise-функции вскрыли дефекты не в себе, а в стыке со старым кодом

Серия RoutineOps

Продолжение моей эпопеи с self-hosted MDM решением

В прошлый раз я писал, как из за ограничений и необходимости админить смешанный парк корп устройств, появился собственный MDM. Несмотря на то что предыдущий пост вышел совсем недавно, упорная работа над проектом шла параллельно, поэтому готов представить вам продолжение. Параллельно буду прикреплять скрины нового веб интерфейса, чтобы можно было почувствовать разницу.

Dashboard

Dashboard

Изменения

С тех пор в базовой версии изменилось немного, чуть обновил дизайн, добавил необходимый функционал с массовой миграцией с других MDM, групповой энролмент, удаление ПО из веб интерфейса, поправил пару десяток багов и чуть подлотал безопасность. Но теперь о главном: появилась пилотная организация, которая захотела поставить его у себя, а вместе с ней - запрос enterprise версии и чеклист функционала, котрый в больнишстве своем и так был в роадмапе, но добавилось и еще пару новых фич, а главное, что они должны были быть ЖБ, поэтому я и мой коллега, приступили к работе. Итоговый функционал, который мы подготовили к enterprise 1.0: SSO, второй фактор, изоляция подразделений, выгрузка в SIEM, отчётность, бэкапы, LDAP, синхрон с AD, FileVault блокировка мака, тенанты, INFRA like a code. Интересным оказалось другое: почти каждая из этих функций, будучи написанной, вскрывала дефект не в себе, а в стыке со старым кодом. Ниже про то, что при этом сломалось и как чинилось.

Коротко: что появилось

Чтобы дальше было понятно, о чём речь, вот список без подробностей. Мультитенантность - несколько организаций или подразделений на одном сервере, у каждого свои устройства, группы, скрипты и политики. Каталог пользователей: синхронизация людей из LDAP или Active Directory и вход в панель доменным паролем. Корпоративный вход - OIDC, SAML, второй фактор по TOTP, автоматическое заведение и увольнение администраторов через SCIM. Эскроу ключей восстановления FileVault на macOS и принудительная блокировка диска. Экспорт журнала и событий в SIEM, архивирование журнала перед чисткой по сроку хранения, дашборд соответствия, сопоставление установленного ПО с базой уязвимостей. Аудит с хеш-цепочкой. Удалённая перезагрузка машины и группы с отсрочкой для сотрудника. Удаление программ с устройства из панели. Бэкап по расписанию с проверкой восстановления. Конфигурация парка как код, в YAML, в гите.

Journal

Journal

Изоляция, которой не было

Первым большим куском была мультитенантность. Постановка звучит просто: администратор одного подразделения не должен видеть устройства другого. Простой и неправильный способ - добавить колонку tenant_id и не забывать фильтровать по ней в каждом запросе. Ключевое слово тут «не забывать». Один пропущенный WHERE - и это уже не баг выдачи, а утечка между организациями. Хуже того: пропущенный WHERE в коде, который напишут через полгода, ничем не отличается от обычной невнимательности, а последствия у него другие.

Поэтому изоляция ушла в базу - построчные политики PostgreSQL, причём с FORCE ROW LEVEL SECURITY, чтобы политика действовала и на владельца таблицы. Роль, под которой ходит сервер, лишена права обходить RLS. Смысл в том, что теперь запрос, написанный завтра и забывший про тенанта, вернёт не чужие строки, а ничего.

Звучит хорошо. Дальше начинается интересное.

Пустая строка вместо «не задан»

Тенант передаётся в базу через кастомный параметр сессии, который выставляется в начале транзакции: set_config с флагом «на время транзакции». Политика читает его и сравнивает с колонкой. Всё честно ровно до того момента, когда транзакция заканчивается.

Оказалось, что такой параметр после транзакционного set_config возвращается не в состояние «не задан», а в пустую строку. Для PostgreSQL это разные вещи, и для приведения к uuid - тем более. Дальше срабатывает соединение из пула: первый же запрос с привязкой к тенанту «отравляет» соединение, и любой следующий запрос к таблице под RLS, сделанный мимо скоупа, ловит пустую строку вместо идентификатора.

Самое неприятное в этом дефекте - что у него два разных лица. На отравленном соединении запрос падает с ошибкой приведения типа, то есть даёт живую пятисотку в интерфейсе. На свежем соединении из пула тот же самый запрос молча возвращает ноль строк. Мы неделю смотрели на эти два симптома как на две несвязанные проблемы: где-то интерфейс ругается ошибкой, где-то список просто пустой, и оба поведения плавают. Пока не выяснилось, что это одна и та же строчка кода.

Лечение простое: запрос к таблице под RLS обязан идти внутри открытого скоупа тенанта, а если вызывающий код скоуп не открывает вовсе, функция обязана открыть его сама. Второй случай важнее, чем кажется. Когда команда приходит от агента по gRPC, никакого «текущего пользователя» и его тенанта в контексте нет и быть не может: агент аутентифицирован сертификатом устройства, а не человеком. Значит, привязку берём из строки самого устройства.

И отдельно почему тесты этого не ловили. В тестовом окружении роли базы был выставлен параметр по умолчанию, чтобы фикстуры не разваливались. То есть в тестах запрос мимо скоупа всегда «попадал» в тенанта по умолчанию и вёл себя прилично. На боевом сервере параметра у роли нет. Локальный стенд, отличающийся от прода одной строчкой конфигурации, проверял не то, что мы думали. Регресс переписан так, что он проверяет чужой скоуп, а не «работает ли вообще», и обязан падать на коде до правки.

Enrollment

Enrollment

Ключ восстановления, который терялся молча

Второй кусок — эскроу ключей восстановления FileVault. Сценарий такой: администратор включает шифрование диска на маке, ключ восстановления шифруется на публичный ключ получателя и уезжает на сервер в кастодию. Никто, кроме держателя приватного ключа, его прочитать не может; сервер хранит непрозрачный блоб.

Когда сверху легла мультитенантность, у этой подсистемы обнаружилось два дефекта, и тот, что в выдаче, был менее опасным из двух.

Выдача ходила в базу мимо скоупа - то самое отравленное соединение из предыдущей главы, с пятисоткой на одном пути и тихим нулём строк на другом. Плюс отдельная тонкость: скоуп нужно открывать по тенанту того, кто спрашивает, а не по устройству из адреса запроса. Идентификатор устройства приходит снаружи, и привязка к нему отдала бы администратору одного подразделения кастодию чужого.

А вот приём был сломан хуже. У обработчика, принимающего блоб от агента, тенанта в контексте нет. Значит, вставка шла с тем, что база подставляет по умолчанию. И вот тут FORCE RLS начинает все портить: блоб устройства чужого тенанта ложился в тенанта по умолчанию, а под построчной политикой его не видел уже никто. Вообще никто. Ключ восстановления не «попадал не туда» - ОН ИСЧЕЗАЛ, и обнаружилось бы это ровно в тот день, когда его понадобится достать.

Заодно поправили ещё одну вещь из той же оперы. Если сервер присылал агенту задачу типа, которого эта версия агента не знает, агент выполнял пустой скрипт и рапортовал об успехе. В панели — «выполнено», на устройстве — ничего. Теперь такая задача завершается ошибкой с текстом про неподдерживаемый тип и версию агента. Отставание агента должно быть видно как отставание, а не как выполненная работа.

И общее правило, которое из этого выросло: задача не имеет права заканчиваться молчаливым успехом. Сотрудник нажал «Отмена», не ответил за десять минут, за машиной никого нет, шифрование выключено — оператор обязан увидеть именно эту причину. «Выполнено» — самый дорогой из возможных неверных ответов, потому что после него никто не приходит проверять.

Groups

Groups

Корпоративный вход и цена быстрого кода

Блок идентичности — второй фактор, SCIM, SAML — писался быстро. Он же оказался самым богатым на находки при ревью.

Коды восстановления для второго фактора лежали открытым текстом и представляли собой обрезок uuid — около тридцати двух бит энтропии. То есть механизм, который должен спасать при потере телефона, сам был слабым звеном и в базе, и по стойкости.

Второй фактор снимался без подтверждения: чтобы выключить MFA, достаточно было действующей сессии. Захват сессии в такой схеме означает и снятие второго фактора — то есть весь смысл второго фактора.

Политика «требовать MFA у всех» была ловушкой. Сервер требовал второй фактор для доступа к ручке привязки второго фактора. Включив политику, администратор запирал сам себя и всех остальных: привязаться нельзя, потому что не привязан.

SAML принимал любую подписанную assertion. Разбор ответа вызывался с пустым списком ожидаемых идентификаторов запроса, то есть проверка «это ответ на наш запрос» отсутствовала. Пользователь при этом попадал в первое попавшееся членство вместо организации своего провайдера. Плюс пара ключей нашего сервис-провайдера генерировалась заново при каждом старте сервера — то есть настройка в IdP переставала быть валидной после перезапуска.

SCIM не писал аудит (в колонку с идентификатором пользователя уезжала строка «scim») и глотал ошибку удаления при увольнении. Увольнение, завершившееся ошибкой и отрапортовавшее успех, — это лучший подарок аудитору из всех возможных.

Миграции, которые сносили сами себя

Отдельная история, короткая и поучительная.

Схему базы у меня накатывает простой скрипт, который прогоняет SQL-файлы через psql. Down-миграций нет намеренно: откат только из бэкапа. Разработчик, писавший новые миграции, оформил их в популярном формате с аннотациями-комментариями: секция up, секция down.

Для инструмента, понимающего эти аннотации, файл делится на две части, и вторая при обычном накате не выполняется. Для psql аннотация — это просто комментарий. То есть выполняется весь файл целиком, включая то, что после «секции down». Миграция создавала таблицы и тут же их удаляла, а факт применения записывался честно. Схема получалась пустой, номер миграции — свежим.

Ошибка ловится за минуту, если знать, куда смотреть, и не ловится вообще, если не знать. Мораль тут не про goose и не про psql: любой формат, где часть файла «не должна выполняться», опасен ровно настолько, насколько инструмент, который это обеспечивает, отличается от того, который вы реально запускаете.

Agents update

Agents update

Гейты, зелёные на бумаге

А это самая неприятная глава, потому что тут я подставился сам.

Перед пушем у меня прогоняется набор проверок: тесты сервера, линтер, линтер отдельно для корпоративной сборки, сборка и тесты фронтенда. Всё зелёное. Выкатываю. Сервер падает при старте с паникой и лежит шесть минут, пока я откатываюсь.

Причина оказалась в порядке сборки приложения: опции роутера стали применяться раньше, чем объявлялись группы маршрутов, и одна из зависимостей приезжала пустой. Тесты этого не увидели, потому что ни один из них не собирал приложение тем же набором опций, что боевой запуск. Каждый тест собирал свой маленький роутер с тем подмножеством, которое было нужно конкретному тесту.

Вывод: подмножество проверяет то, что вы помнили, а ломается то, что вы забыли. Тест точки сборки обязан собирать систему тем же самым набором опций, что и прод, а не представительным набором. И вторая часть того же вывода: у каждой новой проверки нужно один раз убедиться, что она умеет краснеть. У меня в тот период три проверки подряд оказались зелёными просто потому, что не проверяли ничего — одна сравнивала файл сам с собой, другая молча выходила при отсутствии инструмента.

Бэкап, который не восстанавливался

Пункт чеклиста «бэкапы» я считал скучным и дешёвым. Написать дамп по расписанию, ротацию, положить в отдельный том - фигня.

Проблема нашлась при попытке восстановиться на чистой машине. Дамп базы не содержит ролей кластера — их там нет по устройству, это объект уровня кластера, а не базы. При этом роль, под которой ходит сервер, создаётся миграцией без пароля: пароль задаётся руками при развёртывании. Итого процедура восстановления давала живую базу со всеми данными, к которой сервер не может подключиться. И выяснилось бы это ровно в момент аварии — то есть в момент, когда права на ошибку нет.

Теперь в бэкап входят роли кластера отдельным дампом, в документации они накатываются до базы, а в сам прогон встроена проверка: восстановление в одноразовую базу того же кластера, сверка номера применённой миграции с живой, проверка наличия таблиц и пользователей. Сверяем при этом только то, что не меняется само по себе: счётчики журнала и устройств растут прямо во время бэкапа и мигали бы ложными расхождениями. Потолок у такой проверки понятный — тот же кластер, роли и расширения уже на месте, — и он честно записан в документации. Полноценная проверка на чистой машине остаётся ручной процедурой.

Аудит, который можно проверить

Журнал действий администраторов был с самого начала. Корпоративный чеклист требует другого: доказательства, что журнал не переписывали.

Сделано хеш-цепочкой: каждая запись включает хеш предыдущей. Такая цепочка ловит три вещи — правку записи, удаление из середины и перестановку. Чего она не ловит сама по себе — это переписывания журнала целиком: злоумышленник с доступом к базе может пересчитать всю цепочку заново, и она будет согласованной.

Поэтому голова цепочки ежесуточно публикуется наружу, в лог сервера, откуда её забирает внешний сборщик логов. Сверка с опубликованной головой и отличает «журнал внутренне согласован» от «журнал не переписывали». Это, кстати, хороший пример того, как выглядит честная формулировка свойства: не «журнал защищён от подделки», а «подделка обнаруживается при условии, что якорь уехал наружу до неё».

Второй момент: у журнала есть срок хранения, и чистка по сроку убивала бы ту самую цепочку, ради которой всё затевалось. Поэтому перед удалением записи выгружаются в архив, а в цепочке остаются якоря, переживающие чистку.

Scripts

Scripts

Про границу enterprise

Теперь та часть, которую я в прошлый раз сознательно обошёл одним абзацем.

Инструмент открыт под Apache-2.0 и остаётся открытым. Есть корпоративная редакция. Границу я проводил по одному правилу: базовая безопасность не продаётся. Взаимный TLS, идентичность по сертификату, одноразовые токены подключения, журнал аудита, подпись обновлений агента, политика паролей, блокировки при переборе — всё это в открытой части и никуда оттуда не уедет. Модель pay to secure кажется мне порочной по сути: незащищённый MDM не является продуктом, который вообще стоит ставить.

Enterprise версия — это то, что нужно организации, а не администратору: корпоративная идентичность, изоляция подразделений, работа с ключами восстановления дисков, compliance-отчётность и выгрузка в SIEM, работа с каталогом пользователей. Всё, что описано выше в этом тексте как «блок идентичности» и «эскроу», — оттуда.

Пара технических решений про саму лицензию.

Проверка полностью офлайновая. Сервер несёт вшитый на сборке публичный ключ и проверяет подпись файла лицензии сам. Ни установка, ни работа, ни истечение не требуют связи с моей инфраструктурой — иначе развёртывание в закрытом контуре, которого у корпоративных клиентов хватает, было бы невозможно. Мне это ещё и удобно: инфраструктура, которой нет, не падает и не утекает.

Лицензия двухкомпонентная: подписанный файл плюс пароль активации, которые передаются раздельно. Файл сам по себе бесполезен — это защита от «переезда» ключа между покупателями.

Истечение лицензии ничего не ломает. Корпоративные функции выключаются, операционная часть продолжает работать как открытая редакция, данные остаются на месте. Сервер не превращается в тыкву в полночь.

Код корпоративной части физически отсутствует в открытой сборке — он собирается по сборочному тегу, а не выключается флагом. Причина прагматичная: выключенную проверку в открытых исходниках просто удаляют форком за пять минут.

Polices

Polices

Что по-прежнему не сделано

Один узел — по-прежнему одна точка отказа. И блокер тут не абстрактный «нужно потрудиться», а вполне конкретное место: реестр подключённых устройств живёт в памяти процесса, обычной картой с мьютексом. Вторая нода физически не может послать команду устройству, которое подключено к первой: heartbeat и инвентарь размажутся по общей базе, а командный канал — нет. Вариантов ровно два — маршрутизировать команды через шину (очередь у меня и так стоит, половина дела сделана) или прибивать устройство к ноде на балансировщике. Это проектное решение, и делать его на бегу я не хочу.

Сопоставление установленного ПО с базой уязвимостей сейчас наивное — по вхождению строки версии. На нестандартных схемах версий оно даёт и ложные срабатывания, и пропуски. Это годится как подсказка администратору, куда посмотреть, и не годится как отчёт, который показывают аудитору. Так и написано в документации.

На текущем этапе идет разработка удаленного подключения! Следите за новостями, думаю в дальнейшем буду ограничиваться просто небольшими постами с кратким описанием новых фич!

И главное, что не изменилось: скрипт-канал исполняет произвольный код с максимальными правами на каждом устройстве. Реальный периметр безопасности — хост, на котором стоит сервер, а не TLS и не разграничение ролей внутри панели. Всё, что я написал выше про изоляцию тенантов и второй фактор, не отменяет этой строчки ни на грамм.

Исходники открыты, ссылку оставляю. Как и в прошлый раз, готов к любым обсуждениям!

Показать полностью 7
Отличная работа, все прочитано!

Темы

Политика

Теги

Популярные авторы

Сообщества

18+

Теги

Популярные авторы

Сообщества

Игры

Теги

Популярные авторы

Сообщества

Юмор

Теги

Популярные авторы

Сообщества

Отношения

Теги

Популярные авторы

Сообщества

Здоровье

Теги

Популярные авторы

Сообщества

Путешествия

Теги

Популярные авторы

Сообщества

Спорт

Теги

Популярные авторы

Сообщества

Хобби

Теги

Популярные авторы

Сообщества

Сервис

Теги

Популярные авторы

Сообщества

Природа

Теги

Популярные авторы

Сообщества

Бизнес

Теги

Популярные авторы

Сообщества

Транспорт

Теги

Популярные авторы

Сообщества

Общение

Теги

Популярные авторы

Сообщества

Юриспруденция

Теги

Популярные авторы

Сообщества

Наука

Теги

Популярные авторы

Сообщества

IT

Теги

Популярные авторы

Сообщества

Животные

Теги

Популярные авторы

Сообщества

Кино и сериалы

Теги

Популярные авторы

Сообщества

Экономика

Теги

Популярные авторы

Сообщества

Кулинария

Теги

Популярные авторы

Сообщества

История

Теги

Популярные авторы

Сообщества

Недвижимость и ремонт

Теги

Популярные авторы

Сообщества