Статья человека, который десять лет администрировал чужие компьютеры, а теперь делает то, чем хочет администрировать сам.
Берем официальный RustDesk (AGPLv3), не делаем форк, патчим его на лету в GitHub Actions при каждой сборке клиента. Поверх - российская инфраструктура: серверы в РФ, оплата по счёту юр.лицам, корпоративный SSO через Active Directory и Яндекс ID, защита от мошенничества на Android.
Меня зовут Артур Валиев. Я делаю не «решение для импортозамещения с сертификацией ФСТЭК» ради закупок. Просто работающий продукт, который я бы сам хотел использовать десять лет назад, когда сидел на саппорте у клиентов.
Почему я вообще это делаю
Десять лет я был эникейщиком, потом сисадмином, потом инженером поддержки в IT-аутсорсе. Прошёл TeamViewer, AnyDesk, LiteManager, AeroAdmin, Ammyy, всё что вы видите в списке. У каждого свои тараканы:
AnyDesk считает вас коммерческим пользователем, если вы помогли маме настроить принтер дважды
LiteManager - пытались, но интерфейс из 2008 года плюс лицензия по штукам, неудобно
RuDesktop - про них отдельно ниже
Про честность с AGPLv3 (и почему я не «как RuDesktop»)
Когда я начал, посмотрел российских конкурентов. Один из заметных - RuDesktop. У них на сайте красивые бейджи «Реестр росПО», «Сертификация ФСТЭК», и публичные заявления про "собственную разработку". При этом - это форк RustDesk без публикации исходников, что прямо нарушает AGPLv3.
AGPL - это не «можно посмотреть и забыть». Это: используешь - публикуй изменения. Раздаёшь как сетевой сервис - публикуй. Любой может потребовать исходники, если узнает про использование.
Я не хочу так. Это краткосрочно выгодно (никто не проверит при тендере), но долгосрочно - тикающая бомба. Когда RustDesk проснётся и подаст в суд - все эти бейджи испарятся.
Поэтому я выбрал подход «не форк». Объяснение ниже.
Главная техническая идея: патчим upstream на лету
Обычная схема when ты делаешь продукт на основе open-source:
Форкаешь репо
Меняешь нужное прямо в коде
Поддерживаешь fork вечно: каждое обновление upstream'а вручную мержишь и решаешь конфликты
Это работает, но через год upstream уйдёт далеко, и поддерживать форк становится больно. У RustDesk коммиты прилетают каждый день.
Я делаю иначе. Репозиторий с workflow-сборками просто скачивает upstream и патчит sed-ом в момент сборки клиента:
- name: Checkout RustDesk source (master) uses: actions/checkout@v4 with: repository: rustdesk/rustdesk ref: master submodules: recursive - name: Aggressive rebrand if: ${{ inputs.rebrand_strings == 'true' }} run: | find ./flutter/lib -name "*.dart" -type f \ -exec sed -i "s|RustDesk|${APP_NAME}|g" {} + sed -i "s|hbb_common::config::APP_NAME.read().unwrap().clone()|\"${APP_NAME}\".to_string()|g" \ ./src/common.rs sed -i "s|android:scheme=\"rustdesk\"|android:scheme=\"${BRAND_LOWER}\"|g" \ ./flutter/android/app/src/main/AndroidManifest.xml
Каждая сборка тянет свежий upstream (или конкретный тег, какой клиент попросил), накатывает мои патчи, билдит, удаляет. Результат - .exe, .apk, .dmg, .deb под клиента.

Что это даёт
Я не обязан публиковать форк - потому что форка нет. Есть downstream-патчи, которые публичные (в моём workflow-репо) и AGPL-совместимые.
Upstream автоматически обновляется - захотел собрать с RustDesk 1.4.5? Указал
version: 1.4.5- workflow сам подтянет.Каждый клиент компании А - это независимая сборка со своим брендом, своим зашитым tenant slug, своим набором фичей.
Какие проблемы
Главная - upstream ломает имена. Я однажды добавил sed-патч под анкер PopupMenuButton<String>, а upstream переименовал виджет в следующем релизе. Sed молча не нашёл паттерн (continue-on-error: true), сборка прошла, но функционал не добавился. Открыл собранный клиент - нет кнопки «Запросить помощь». Долго искал почему.
Решение: писать defensive multi-anchor patches и проверять что патч реально применился:
sed -i "s|buildTip(context),|buildTip(context),\n Padding(...)|" desktop_home_page.dart echo "Verification:" grep -c "showSupportRequestDialog" desktop_home_page.dart || \ echo "⚠️ Patch did not apply!"
Сейчас все патчи валидируются - если grep вернул 0, в логах сборки видно проблему, и можно быстро поправить регексп.
Защита от мошенничества: убираем приём входящих с Android
Это та фишка, ради которой Google Play, RuStore и сертификаторы безопасности должны полюбить нашу сборку.

Сценарий российского мошенничества:
Бабушке звонят «из банка»
Скачайте приложение / AnyDesk / RustDesk
Бабушка диктует свой ID
Мошенник заходит, переводит деньги через Сбербанк Онлайн
Я физически вырезаю входящий режим из Android-сборки. При сборке с флагом outgoing_only=true:
# 1. На уровне Rust: is_outgoing_only() всегда true sed -i -E 's|SyncReturn\(config::is_outgoing_only\(\)\)|SyncReturn(true)|g' \ src/flutter_ffi.rs # 2. На уровне Dart: насильно скрываем Server tab sed -i 's|if (isAndroid && !bind.isOutgoingOnly())|if (false)|' \ flutter/lib/mobile/pages/home_page.dart # 3. Из AndroidManifest удаляем опасные permissions sed -i '/<uses-permission[^>]*FOREGROUND_SERVICE_MEDIA_PROJECTION[^>]*/d' "$MANIFEST" sed -i '/<uses-permission[^>]*RECORD_AUDIO[^>]*/d' "$MANIFEST" sed -i '/<uses-permission[^>]*SYSTEM_ALERT_WINDOW[^>]*/d' "$MANIFEST"
В результирующем APK физически нет ни UI для принятия подключения, ни разрешений на захват экрана/звука/overlay. Скачал, поставил, открыл - единственное что можно делать: набрать ID и подключиться к ПК. Принять подключение невозможно. От слова совсем.
Это:
Защищает бабушек ;)
Позволяет публиковаться в Google Play / RuStore без блокировок (теоретически - Kaspersky всё ещё ругается, см. ниже)
Подходит к подаче в Реестр российского ПО как «безопасный инструмент удалённого администрирования»
А что с Kaspersky?
Когда я подал APK в RuStore, Касперский задетектил его как not-a-virus:HEUR:RemoteAdmin.AndroidOS.RustDesk.a. Префикс not-a-virus означает PUA (Potentially Unwanted Application) - тот же класс что и AnyDesk/TeamViewer получают.
Эвристика срабатывает на сигнатуру librustdesk.so в APK. Решение - переименовать нативную библиотеку на лету:
NATIVE_NAME="evertydesk" # Файл .so копируется в jniLibs под новым именем cp ./target/release/liblibrustdesk.so \ ./flutter/android/app/src/main/jniLibs/arm64-v8a/lib${NATIVE_NAME}.so # И Kotlin загружает по новому имени sed -i "s|System.loadLibrary(\"rustdesk\")|System.loadLibrary(\"${NATIVE_NAME}\")|" \ ./flutter/android/app/src/main/kotlin/com/.../ffi.kt
Плюс апелляция в RuStore с объяснением что это PUA, что наш клиент outgoing-only, и что вообще «not-a-virus» - это не вирус. Должно решить. Ждемс.
Smart Agent - peer-to-peer помощь между сотрудниками
Эта фича - то, чего нет ни у TeamViewer, ни у AnyDesk, ни у самого RustDesk.

В кастомный клиент я инжектирую отдельный Dart-сервис (agent_service.dart), который работает фоном внутри RustDesk-процесса. Делает три вещи:
Heartbeat на наш сервер каждую минуту (онлайн-статус машины)
Inbox-полл каждые 30 секунд (входящие уведомления - например push от админа)
Запрос помощи - пользователь жмёт кнопку в UI клиента, выбирает конкретного оператора, тот получает popup с кнопками
[Принять] [Через 10 мин] [Через час] [Отклонить]
Архитектурно это надстройка над RustDesk-протоколом, отдельный канал, не использующий relay. Через наш HTTP API.
class AgentService { static const Duration kInboxInterval = Duration(seconds: 30); static const Duration kHeartbeatInterval = Duration(minutes: 1); Future<void> _checkInbox() async { final resp = await http.get( Uri.parse('$_apiServer/admin/agent/inbox').replace(queryParameters: { 'machine_id': _machineId, 'service_key': _serviceKey, }), ).timeout(kHttpTimeout); if (resp.statusCode != 200) { _inboxFailures++; return; } _inboxFailures = 0; final items = jsonDecode(resp.body)['items'] as List; for (final item in items) { if (item['type'] == 'support_ping') { showSupportPingDialog(ctx, item); // Popup сдействиями } // ... друге типы: banner, poll, config_update } } }
Здесь была интересная боль: первый раз я просто хранил target_machine_id как строку в поле target_ids AgentNotification. А серверный inbox-фильтр парсит это как JSON-массив. Json.Unmarshal падал → continue → уведомление молча не доставлялось. Симптом - «нажимаю Запросить помощь, ничего не происходит». Диагностика заняла пару часов:
// Было: TargetIds: resolvedTarget, // ← "abc123" // Стало: targetIdsJson, _ := json.Marshal([]string{resolvedTarget}) TargetIds: string(targetIdsJson), // ← `["abc123"]`
Урок: если ваш парсер строгий, и у вас есть continue-on-error, ошибки прячутся. Поэтому я везде, где раньше было silent-fail, теперь добавляю counter (_inboxFailures++) и логирую.
Уровень 2 - устройство привязывается к тенанту при подключении. Это та часть, где RustDesk родной из коробки ничего не знает про тенанта. У меня смешно: heartbeat от RustDesk-клиента (/api/heartbeat) не несёт никакого tenant-id'а - там только {id, uuid, conns}. То есть все 10 компаний шлют heartbeat в один и тот же default-аккаунт.
Решение нашлось через Smart Agent. Он-то знает свой service_key (slug компании, зашитый при сборке) - каждые 60 секунд шлёт его на /admin/agent/heartbeat. Сервер:
// на каждом агентском heartbeat: if saId > 0 { // Находим Device с тем же rustdesk_id и перепривязывем к правильному тенанту c.Db.Where("rustdesk_id = ?", machineId). Cols("service_account_id", "is_pending"). Update(&model.Device{ ServiceAccountId: saId, IsPending: false, }) }
То есть устройство сначала попадает в "pending pool" (не видно никому), а потом Smart Agent его «забирает» в нужный тенант. Через 30-60 секунд после установки клиента машина появляется в кабинете нужной компании.
Дополнительно - pending-pool работает как «модерация»: если по какой-то причине агент не запустился, владелец видит в админке кнопку «Принять устройство» и может вручную привязать к клиенту. Это AnyDesk-style enrollment на минималках.
Корпоративный SSO: Active Directory + Яндекс ID

LDAP/AD реализован так, что не требует патча клиента. Юзер в RustDesk жмёт «Sign in», вводит доменный логин/пароль. Сервер видит в его tenant'е есть LDAP - пробует bind. Если успех -создаёт User, возвращает токен. Klein client doesn't know it talked to LDAP - просто принял.
// В service/ldap.go: func LdapAuthenticate(cfg *model.LdapConfig, username, password string) (*LdapAuthResult, error) { conn, _ := ldap.DialURL(cfg.ServerUrl) defer conn.Close() // 1 Bind как сервисный аккаунт для поиска conn.Bind(cfg.BindDn, cfg.BindPassword) // 2 Найти пользователя по фильтру filter := strings.ReplaceAll(cfg.UserFilter, "{username}", ldap.EscapeFilter(username)) res, _ := conn.Search(...) // 3 Bind КАК этот пользователь это и есть проверка пароля if err := conn.Bind(res.Entries[0].DN, password); err != nil { return nil, fmt.Errorf("invalid_credentials") } // 4 Опционально проверить вхождение в разрешёную группу return &LdapAuthResult{...}, nil }
И в стандартном /api/login:
if !get { // Пользователь не существует локально res, accId, err := LdapTryAllAccounts(db, username, password) if err == nil && res != nil { // LDAP принял! Создаём User под нужным тенантом user = autoProvisionFromLdap(res, accId) } }
Результат: компания-клиент включает LDAP в кабинете, все её сотрудники могут логиниться в RustDesk-клиент доменными учётками без отдельной регистрации. Когда сотрудника увольняют (отключают в AD), его следующий вход в RustDesk просто не пройдёт.
Яндекс ID реализован через стандартный OAuth 2.0 + Device Authorization Grant (RFC 8628). Для веб-кабинета - кнопка «Войти через Яндекс», редирект, callback. Для desktop-клиента - Device Code flow: клиент получает code, показывает диалог «Откройте yandex.com/device, введите ABCD-EFGH», пользователь вводит, клиент получает токен. Это стандарт для CLI-инструментов (gh auth login, aws sso login).
Биллинг и оплата для российских юрлиц
Это та область, где западные SaaS-стартапы экономят время используя Stripe. В РФ Stripe не работает.
У меня:
YooKassa для физлиц - встраивается напрямую в кабинет через их API
-
Оплата по счёту юрлицам - отдельный flow:
Клиент в кабинете жмёт «Оплатить по счёту», вводит ИНН/КПП/реквизиты
Сервер создаёт
InvoiceRequestс уникальным номером2026-0042Я в админке вижу заявку, выписываю PDF в банке (у меня Точка), загружаю в систему
Клиент получает email со ссылкой на скачивание счёта
Платит → деньги падают на расчётный счёт
Я отмечаю в админке «Оплачен» → подписка активируется автоматом
Не сказать что это бизнес-инновация, но никакого готового решения для embedded B2B-биллинга в РФ просто нет. У всех костыли.
Стек, цифры, обещания
Компонент |
Технология |
|---|---|
Backend API |
Go + Iris MVC |
База |
PostgreSQL 16 |
Frontend (кабинет, landing) |
Vue 3 + Naive UI + Vite |
Клиент (десктоп) |
RustDesk official + patches |
Smart Agent (внутри клиента) |
Flutter/Dart |
Сборка |
GitHub Actions |
Деплой |
Docker Compose |
Сборка одного клиента - 25-40 минут (большая часть - Rust компилируется). Параллельно собирается под все 4 платформы (Win/Linux/macOS/Android).
К концу мая 2026 года планирую:
Стабильный SaaS-релиз
Андроид в Google Play + RuStore (RuStore сейчас на апелляции из-за
HEUR:RustDeskдетекта)
Тарифы весят тестовые,
В отличие от RuDesktop, я не строю компанию с инвесторами, бизнес-планом на круги Эйлера, и сертификацией ФСТЭК в первом квартале как KPI.
Я просто решаю проблему которая меня саму годами бесила: «дайте мне нормальный российский remote desktop с честной лицензионной чистотой, безопасный для конечных пользователей, и оплачиваемый по российским способам». К тому же, признаюсь я всегда жадно читал статьи про RustDesk на хабре, чтож, я набрал уже 4 "клиента - тестировщика" так сказать, будем развивать дальше.
Ну и самое важное дума, Self-hosted версия: для тех кому облако не подходит
Не каждой компании можно загнать удалённую поддержку в чужое облако. Госы, банки, медицинские учреждения, ВПК-сегмент, корпорации с собственной службой ИБ - у них в политиках чёрным по белому: данные обработки внутри периметра, точка. С этим бесполезно спорить.
Поэтому параллельно с SaaS я делаю self-hosted версию того же продукта. Та же кодовая база, тот же функционал, но всё запускается у клиента на его железе или в его частном облаке.
Что входит
# docker-compose.yml у клиента после установки services: everty-desk-api: # Go backend + Vue cabinet image: everty-desk/api:1.0 everty-desk-postgres: # PostgreSQL image: postgres:16-alpine everty-desk-hbbs: # RustDesk ID server image: rustdesk/rustdesk-server:latest everty-desk-hbbr: # RustDesk relay image: rustdesk/rustdesk-server:latest
Один docker compose up -d, скрипт-установщик pro сертификаты Let's Encrypt (или загрузка своих), миграции БД - всё. Никаких внешних звонков на наши серверы. Полная изоляция.
Как лицензируется
Лицензия - годовая, активируется офлайн через подписанный JWT-ключ. Никакого «онлайн-чекина», который вырубит сервер если интернет лёг. Есть пока простая документация тут.
Лицензия привязана к доменам (whitelisted hostnames в самом JWT), что предотвращает копирование на другой инстанс. При истечении - graceful degradation:за месяц до конца показываются предупреждения, после истечения новые подключения блокируются, но уже существующие машины и адресная книга сохраняются до момента продления. Без «всё пропало».
В self-hosted входит буквально всё, только все настройки, авторизации, токены все на вас.
Статус: бета, релиз - конец июня
Прямо сейчас self-hosted версия в тестировании - у меня запущена параллельная инсталляция на отдельной машине, проверяю миграции, лицензии, изоляцию, обновления. С тестировщиками конечно беда, если кому интересно пишите с пометкой на info@everty.ru.
К концу июня 2026 - стабильный релиз 1.0 с:
Полным installer-скриптом (
curl install.everty.ru/selfhost.sh | sh)Подробной документацией (от docker compose до nginx reverse-proxy и Let's Encrypt)
Lifecycle-менеджментом лицензий через мою административную панель
SLA на исправление багов в первый год
Каналом обновлений:
stable/beta/edge
Спасибо тем кто дочитал до конца, если хочется обсудить технические детали реализации - буду рад ответить в комментариях.
info@everty.ru
desk.everty.ru
Комментарии (6)

AngryEvilCookie
23.05.2026 22:21Да по сути такая же обёртка над видео кодеком без магии, в отличии от энидеск. Поправьте, если не прав.

cpud47
23.05.2026 22:21Я не обязан публиковать форк - потому что форка нет. Есть downstream-патчи, которые публичные (в моём workflow-репо) и AGPL-совместимые.
Форк(т.е. derived work) есть, просто не в самой традиционной форме. Но исходники Вы в любом случае публикуете, поэтому с этим проблемы нет.
Главная - upstream ломает имена. Я однажды добавил sed-патч под анкер PopupMenuButton<String>, а upstream переименовал виджет в следующем релизе. Sed молча не нашёл паттерн (continue-on-error: true), сборка прошла, но функционал не добавился.
По-хорошему, вместо sed лучше бы перейти на diff+patch. Таким образом будет более надёжная и внятная проверка, что всё применялось корректно. Ну и создавать это удобнее. См. suckless как практический пример.
Это работает, но через год upstream уйдёт далеко, и поддерживать форк становится больно. У RustDesk коммиты прилетают каждый день.
git rebase и всё поддержка сводиться к минимуму. +git rerere. Всё ещё нужно разрешать конфликты, но это и в Вашем подходе нужно, т.к. Вы довольно таргетированные замены делаете.
По сути, это сильно похоже на подход с патчами, только ещё более автоматизированный.
Сборка одного клиента - 25-40 минут
Зачем собирать клиент под каждого пользователя/потребителя?
Можно вынести все конфигурации в (embedded) конфиг и просто перешивать конфиг под потребителя.
Ну и привязывать работоспособность прода к gh actions — не самое разумное решение, особенно в российских реалиях...
achekalin
Почему LLM так любят слово "честный"?
После такого в заголовке читать дальше не очень хочется, хотя умом понимаю, что статья, наверное, и по делу.
arturwise Автор
Спасибо за отзыв, честно сказать я никогда не умел писать байтовые заголовки.
Да на самом деле и статьи я не умелец писать, поэтому честно прошу прощения.
Но в целом статья должна понравиться, я постарался рассказать об основных выжимках проекта.