личный блог

Статьи · devops

OpsDeck: терминал, Kubernetes, базы и KeePass в одном окне — разбор по исходникам

·15 мин чтения ·Статьи·

Обычный день дежурного: терминал с тремя SSH-сессиями, Lens с продом, вкладка Grafana, вкладка ArgoCD, DBeaver, KeePassXC и заметки, где записано, как в прошлый раз чинили то же самое. Семь окон, и нужное всегда под шестью остальными. OpsDeck обещает собрать всё это в одно нативное приложение для Linux, Windows и macOS.

Обещание амбициозное, а проекту две недели: первый коммит сделан 25 сентября 2026 года, версия 0.6.1 вышла 8 октября. За это время — 194 коммита, 8 релизов, 154 звезды. Приложение просит доступ ко всему, что у инженера есть ценного: kubeconfig, пароли, SSH, базы. Поэтому я не стал смотреть скриншоты, а пошёл читать код.

Сразу про метод: я разбирал исходники версии 0.6.1 и само приложение не запускал. Всё, что ниже, — это то, что написано в коде, а не то, что я увидел на экране. Главный вопрос у меня был один: можно ли такому приложению доверить доступы. Ответ в конце, и он не «да» и не «нет».

Что это и из чего состоит

OpsDeck — приложение на Tauri 2. Это значит: бэкенд на Rust, интерфейс — веб-страница в системном webview, никакого Electron с собственным Chromium. Отсюда размер: установщик для Windows весит 10 МБ, .deb — 18 МБ, .dmg — 14–16 МБ. Только AppImage тянет на 90 МБ, потому что возит с собой WebKitGTK.

Внутри две части почти равного размера:

Часть Где лежит Объём
Бэкенд src-tauri/src/, 32 файла на Rust 12 405 строк
Интерфейс src/, TypeScript без фреймворка 10 852 строки
Интеграция с shell src-tauri/shell/ bash, zsh, PowerShell
Тесты tests/, плюс тесты в файлах Rust 68 на Rust, 139 на TypeScript

Интерфейс общается с бэкендом через команды Tauri — в lib.rs их зарегистрирована 171. Самые толстые модули: базы данных (db.rs, 1504 строки), Kubernetes (k8s.rs, 1454) и алерты (alerts.rs, 1196).

Разделов много: терминал, Kubernetes, базы, редактор кода с git, веб-панели, алерты, KeePass, SSH, MikroTik, заметки с задачами, сетевые утилиты, доска мониторинга хостов. По умолчанию включены пять — терминал, Kubernetes, SSH, KeePass и заметки, остальные ставятся галочкой.

Примечательно, чего в зависимостях нет: SSH-библиотеки. OpsDeck не изобретает SSH заново, а запускает системный ssh в своём терминале; так же он поступает с kubectl для shell в под и port-forward. Kubernetes API, наоборот, он читает сам, через библиотеку kube-rs, и даже список Helm-релизов достаёт напрямую из секретов кластера типа helm.sh/release.v1 — системный helm нужен только для отката. Это разумная граница: таблицы и логи быстрее рисовать из API, а интерактивный exec проще отдать kubectl.

Kubernetes: собственное хранилище kubeconfig

Первое, что мне понравилось. OpsDeck не трогает ~/.kube/config. Выбранные контексты копируются в его собственное хранилище, по одному файлу на контекст. А терминалы внутри приложения получают переменную KUBECONFIG, которая указывает только на это хранилище:

/// KUBECONFIG for terminals started by OpsDeck: only OpsDeck's store, so kubectl/helm/…
/// there never touch the shared ~/.kube/config. None when the user opted into system configs.
pub fn terminal_kubeconfig() -> Option<String> {

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

Второе — импорт kubeconfig с блоком exec. Такой конфиг при каждом обращении запускает внешнюю команду, чтобы получить токен, и чужой файл с exec — классический способ выполнить код на машине инженера. OpsDeck при импорте вытаскивает эти команды и показывает их. Мелочь, но я не помню, когда в последний раз видел её в GUI для Kubernetes.

Режимы «только чтение» и их границы

README говорит: для продовых контекстов есть режим «только чтение», и изменения блокирует бэкенд. Проверяю. В k8s.rs есть функция ensure_writable, и она вызывается в пяти местах: применение YAML, удаление, масштабирование, перезапуск и действия Argo CD.

fn ensure_writable(ctx: &Ctx) -> Result<(), String> {
    if k8s_prefs_get().readonly.contains(&ctx_key(ctx)) {
        return Err(format!("контекст «{}» в режиме только чтения — снимите 🔒, чтобы менять ресурсы", ctx.context));
    }
    Ok(())
}

Проверка действительно на стороне Rust, а не в интерфейсе — обойти её через DevTools не получится. Но замок закрывает только кнопки самого OpsDeck. Shell в под и kubectl в терминале приложения работают с теми же учётными данными, и команда k8s_shell_config, которая готовит для них kubeconfig, режим «только чтение» не проверяет. То есть замок защищает от случайного клика по «Delete», а не от kubectl delete в соседней вкладке. Для настоящего запрета записи нужен отдельный пользователь с ролью на чтение — и никакой клиент этого не заменит.

С базами картина разная, и README это честно оговаривает — проверил по db.rs:

СУБД Как сделано «только чтение» Чья это защита
PostgreSQL параметр сессии default_transaction_read_only=on сервера
ClickHouse параметр запроса readonly=2 сервера
MySQL / MariaDB SET SESSION TRANSACTION READ ONLY и фильтр по первому слову запроса сервера и клиента
Redis список разрешённых команд клиента
MongoDB список разрешённых команд, запрет $out и $merge клиента

Фильтр для SQL — это список из десяти разрешённых первых слов (SELECT, SHOW, EXPLAIN, WITH и так далее) плюс запрет INTO OUTFILE и FOR UPDATE. На тестах он ловит select 1; drop table t. Как страховка от опечатки — годится. Как граница безопасности — нет: это разбор SQL по первому слову.

Там же есть вещь, которую легко проглядеть в форме подключения. У TLS три режима: off, require и verify. require шифрует соединение, но сертификат сервера не проверяет — в коде для него заведён отдельный верификатор, который принимает любой сертификат. Имя режима взято из PostgreSQL, где sslmode=require значит то же самое, так что это не ошибка. Но для прода я бы ставил только verify.

Где лежат секреты

README обещает: секреты — только в системном хранилище или KeePass. По коду это почти так.

Пароли коннекторов, баз, SSH и ключ к внешней языковой модели уходят в системное хранилище через библиотеку keyring: Secret Service на Linux, Keychain на macOS, Credential Manager на Windows. Настройки без секретов лежат в JSON в ~/.config/opsdeck/ с правами 0600.

KeePass открывается только на чтение, база расшифрована в памяти, мастер-пароль затирается через zeroize. Скопированный пароль стирается из буфера через 30 секунд. Файл базы отслеживается: если его изменили в KeePassXC, OpsDeck перечитает его тем же ключом. Автоблокировка по времени есть, проверяется каждые три секунды.

Теперь про «почти». Три оговорки:

И одна вещь, которую README называет сам: WinBox принимает пароль только аргументом командной строки. Пока он запущен, пароль роутера виден в списке процессов. Автор не стал это прятать, и за это ему плюс.

Веб-панели с автоматическим входом

Grafana, ArgoCD и GitLab открываются вкладками внутри окна и сами логинятся. Звучит опасно: приложение подставляет пароль в чужую страницу. Смотрю, как сделано.

Каждая панель — отдельный webview, и в него перед скриптами страницы внедряется небольшой скрипт входа. Первая его строка:

if (location.origin !== cfg.origin) return;

Учётные данные лежат в замыкании, а не в window, и скрипт сохраняет ссылку на родной fetch до того, как страница успеет его подменить. Попытка входа — не чаще раза в минуту, чтобы неверный пароль не заблокировал учётку. Адрес, на который панель переводят из алерта, сверяется с origin коннектора. На экранирование есть тест с паролем вида p"a\ss</script><script>alert(1).

Вторая половина защиты — файл разрешений Tauri. В нём одна запись: доступ к командам приложения есть только у окна main. У webview с Grafana его нет:

{
  "description": "IPC for the main OpsDeck window only; connector windows (Grafana/ArgoCD/GitLab) get no IPC.",
  "windows": ["main"],
  "permissions": ["core:default"]
}

Это правильная архитектура. Взломанная Grafana получит то же, что получила бы в браузере, — свой пароль. До kubeconfig и KeePass она не дотянется.

На Linux, кстати, встраивание сделано через GTK: главный webview заворачивается в GtkOverlay, и панели перекладываются поверх него. Автор сам называет это хрупким местом и оставил переменную OPSDECK_NO_EMBED=1, с которой панели открываются отдельными окнами.

Какие порты открывает приложение

README: «OpsDeck не слушает внешние интерфейсы». Это правда — оба слушателя привязаны к 127.0.0.1. Но их два, и оба работают с первого запуска.

Приём алертов, порт 9095. HTTP-сервер для локальных анализаторов логов. Включён по умолчанию (ingest_enabled: true) и запускается при старте приложения безусловно — в lib.rs нет проверки, включён ли раздел алертов. Защищён он прилично: токен в заголовке, сравнение за постоянное время, тело не больше 2 МБ и проверка заголовка Host против DNS rebinding — страница из браузера, чей домен указывает на 127.0.0.1, получит отказ.

Мост для ИИ-агента, случайный порт. OpsDeck притворяется редактором кода: поднимает WebSocket-сервер по протоколу MCP и кладёт в домашний каталог lock-файл с номером порта и токеном. Консольный агент, запущенный в терминале приложения, находит его и подключается. Запросы с заголовком Origin отклоняются — так отсекаются браузеры.

Мост стартует всегда, даже если агентом никто не пользуется. Рабочей папкой он объявляет весь домашний каталог. А его набор инструментов наполовину состоит из заглушек, и некоторые из них отвечают успехом, ничего не делая:

"saveDocument" => text(json!({ "success": true, "filePath": args["filePath"], "saved": true, "message": "Document saved" }).to_string()),

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

Локальная языковая модель

По Ctrl+Shift+K можно описать задачу словами и получить команду. Модель ставится по желанию и работает на llama.cpp.

Что уходит модели, видно в ai_command: текущая папка, тип оболочки, десять последних команд и до восьми похожих команд из заметок. Команды собирает отдельный индекс — он читает .bash_history, .zsh_history, историю fish и блоки кода из заметок.

С локальной моделью всё остаётся на машине: llama-server запускается на 127.0.0.1 на случайном порту и гасится через 15 минут простоя. Но в настройках можно указать внешний сервер с OpenAI-совместимым API. Тогда тот же контекст — папка, история, команды из заметок с именами хостов и неймспейсов — уезжает туда. И если адрес введён без схемы, подставляется http:// и порт 11434. Для Ollama на соседней машине в своей сети это нормально. Для сервера в интернете — нет.

Загрузки проверяются, но по-разному:

Я бы зашил хеши движка в код так же, как хеши моделей: тег всё равно зафиксирован.

Хорошая деталь: команда, которую предложила модель, сама не выполняется. Она подставляется в терминал, и нажать Enter должен человек.

Что уходит в сеть

Я выписал все адреса из кода бэкенда. Кроме того, что настроил сам пользователь, приложение обращается к трём местам:

Телеметрии и счётчиков нет. Журнал пишется в файл на диске — пять файлов по 5 МБ с ротацией.

Интерфейс зажат политикой CSP: скрипты только свои (script-src 'self'), сетевые запросы из интерфейса запрещены вовсе (connect-src — только IPC), фреймы и отправка форм закрыты. Единственная щель — картинки: img-src разрешает https: и http:. Заметка с внешней картинкой при открытии сходит за ней в сеть. Для совместимости с Obsidian это понятно, но знать об этом стоит.

Интерфейс написан без фреймворка и собирает разметку строками: в src/ 153 обращения к innerHTML и 310 вызовов функции экранирования esc. Markdown заметок проходит через DOMPurify. Проверить руками все 153 места я не возьмусь, но CSP без unsafe-inline для скриптов здесь страхует: пропущенное экранирование не превратится в выполнение кода.

Обновления и подпись

Приложение обновляется само, из GitHub Releases. Обновления подписаны ключом minisign, открытый ключ зашит в tauri.conf.json, файл с чужой подписью не установится. Релиз публикуется, только если прошли все сборки и тесты; до этого он остаётся черновиком.

Есть и аварийный откат: отдельный workflow помечает версию как pre-release, и установленные копии предлагают вернуться на предыдущую. Для проекта, который выпускает по релизу раз в полтора дня, это нужная вещь.

Чего нет: подписи кода для операционных систем. Сборки для macOS подписаны «ничем» ("signingIdentity": "-"), и README предлагает снимать карантин командой xattr -cr. Это честно описано, но означает, что подлинность установщика при первой установке подтверждает только то, что он скачан со страницы релизов на GitHub.

В CI — npm audit, cargo audit, CodeQL и clippy. Сторонние действия закреплены наполовину: rust-toolchain и rust-cache — по хешу коммита, а tauri-action@v0, который собирает и подписывает релиз, — по плавающему тегу. Я бы начал закреплять именно с него.

Установка и запуск

Готовые сборки лежат в Releases: .deb, .rpm, .AppImage, .msi, .exe, .dmg. Требования жёстче, чем кажется: на Linux нужны glibc 2.35 и WebKitGTK 4.1, поэтому Ubuntu 20.04, Debian 11 и RHEL 9 с клонами не подходят.

Из исходников, по README:

git clone https://github.com/LeoAlecksey/opsdeck.git && cd opsdeck
npm install
npm run tauri dev      # режим разработки
npm run tauri build    # установщики → src-tauri/target/release/bundle/

Нужны Rust stable и Node.js 20+, на Linux — пакеты разработчика WebKitGTK и GTK. Зависимости ставятся из npm и crates.io, оба lock-файла в репозитории есть.

Что остаётся работать, пока приложение открыто: два локальных порта, опрос источников алертов раз в минуту, а если установлена модель — процесс llama-server. При выходе lock-файл моста удаляется, сервер модели останавливается.

Внешние программы — kubectl, helm, ssh, mtr, dig, WinBox — не обязательны: OpsDeck использует их, если найдёт. Сетевые утилиты запускаются без shell, а адрес проверяется функцией valid_host: всё, что начинается с дефиса, отвергается, чтобы имя хоста не стало флагом.

Лицензии

Проект под MIT. Зависимости обычные для такого стека: Tauri, kube-rs, xterm.js, CodeMirror, DOMPurify. Движок llama.cpp и модели Qwen в поставку не входят и скачиваются отдельно, со своими лицензиями. Собранных без исходников частей в репозитории нет.

Ограничения и риски

Кому подойдёт

Возвращаюсь к вопросу из начала: можно ли доверить этому приложению доступы.

По коду видно, что автор понимает, с чем работает. Изоляция webview от команд приложения, собственное хранилище kubeconfig, показ exec-команд при импорте, сравнение токенов за постоянное время, проверка Host, подписанные обновления, честный список известных проблем — так не пишут случайно. Для двухнедельного проекта это необычно аккуратно.

Но двухнедельный проект остаётся двухнедельным. Поэтому мой ответ такой:

Через полгода к проекту стоит вернуться. Если темп сохранится, а хеши движка и подпись кода появятся, разговор будет уже другим.

Олег Букатчук
Олег Букатчук
DevOps- и SRE-инженер. Строил отказоустойчивую инфраструктуру для классифайда, e-commerce и госсектора: cian.ru, aliexpress.ru, mos.ru.