Статья человека, который десять лет администрировал чужие компьютеры, а теперь делает то, чем хочет администрировать сам.

Берем официальный 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:

  1. Форкаешь репо

  2. Меняешь нужное прямо в коде

  3. Поддерживаешь 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 и сертификаторы безопасности должны полюбить нашу сборку.

Часть окна сборки клиента Android
Часть окна сборки клиента Android

Сценарий российского мошенничества:

  1. Бабушке звонят «из банка»

  2. Скачайте приложение / AnyDesk / RustDesk

  3. Бабушка диктует свой ID

  4. Мошенник заходит, переводит деньги через Сбербанк Онлайн

Я физически вырезаю входящий режим из 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-процесса. Делает три вещи:

  1. Heartbeat на наш сервер каждую минуту (онлайн-статус машины)

  2. Inbox-полл каждые 30 секунд (входящие уведомления - например push от админа)

  3. Запрос помощи - пользователь жмёт кнопку в 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

Часть страницы для настройки AD
Часть страницы для настройки 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:

    1. Клиент в кабинете жмёт «Оплатить по счёту», вводит ИНН/КПП/реквизиты

    2. Сервер создаёт InvoiceRequest с уникальным номером 2026-0042

    3. Я в админке вижу заявку, выписываю PDF в банке (у меня Точка), загружаю в систему

    4. Клиент получает email со ссылкой на скачивание счёта

    5. Платит → деньги падают на расчётный счёт

    6. Я отмечаю в админке «Оплачен» → подписка активируется автоматом

Не сказать что это бизнес-инновация, но никакого готового решения для 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)


  1. achekalin
    23.05.2026 22:21

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

    1. Почему LLM так любят слово "честный"?

    2. После такого в заголовке читать дальше не очень хочется, хотя умом понимаю, что статья, наверное, и по делу.


    1. arturwise Автор
      23.05.2026 22:21

      Спасибо за отзыв, честно сказать я никогда не умел писать байтовые заголовки.

      Да на самом деле и статьи я не умелец писать, поэтому честно прошу прощения.

      Но в целом статья должна понравиться, я постарался рассказать об основных выжимках проекта.


  1. MikalaiR
    23.05.2026 22:21

    При этом - это форк RustDesk без публикации исходников, что прямо нарушает AGPLv3.

    Это не так. Вы обязаны предоставить исходники только тем, кому предоставляете бинарники или SaaS. Публиковать - не обязаны.


    1. arturwise Автор
      23.05.2026 22:21

      Вы правы. Спасибо за уточнение.


  1. AngryEvilCookie
    23.05.2026 22:21

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


  1. 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 — не самое разумное решение, особенно в российских реалиях...