Статья человека, который десять лет администрировал чужие компьютеры, а теперь делает то, чем хочет администрировать сам.
Берем официальный 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.dartecho "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() всегда truesed -i -E 's|SyncReturn\(config::is_outgoing_only\(\)\)|SyncReturn(true)|g' \ src/flutter_ffi.rs# 2. На уровне Dart: насильно скрываем Server tabsed -i 's|if (isAndroid && !bind.isOutgoingOnly())|if (false)|' \ flutter/lib/mobile/pages/home_page.dart# 3. Из AndroidManifest удаляем опасные permissionssed -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
ссылка на оригинал статьи https://habr.com/ru/articles/1038580/