RustDesk Pro в России не купить. После долгих лет администрирования мы собрали своё честное решение

Страницы:  1

Ответить
 

Professor Seleznov


Статья человека, который десять лет администрировал чужие компьютеры, а теперь делает то, чем хочет администрировать сам.
Берем официальный 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 под клиента.
pic
Создание клиента
Что это даёт
  • Я не обязан публиковать форк - потому что форка нет. Есть 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 и сертификаторы безопасности должны полюбить нашу сборку.
pic
Часть окна сборки клиента Android
Сценарий российского мошенничества:
  • Бабушке звонят «из банка»
  • Скачайте приложение / 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.
pic
Окно собранного клиента
В кастомный клиент я инжектирую отдельный 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
pic
Часть страницы для настройки AD
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 loginaws 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-Источник
 
Loading...
Error