Add local client compilation from source, build logs viewer, and submodules support

This commit is contained in:
shaytan
2025-11-10 17:55:03 +11:00
parent c5687e1506
commit d158e85d53
17 changed files with 1865 additions and 6 deletions

View File

@@ -44,9 +44,16 @@ RUN if [ "$COUNTRY" = "CN" ] ; then \
ARG FRONTEND_GIT_REPO=https://github.com/lejianwen/rustdesk-api-web.git
ARG FRONTEND_GIT_BRANCH=master
# Clone the frontend repository
RUN git clone -b $FRONTEND_GIT_BRANCH $FRONTEND_GIT_REPO .
# Copy local frontend if exists, otherwise clone from git
COPY frontend-temp /tmp/frontend-temp
RUN if [ -d "/tmp/frontend-temp" ] && [ -n "$(ls -A /tmp/frontend-temp 2>/dev/null)" ]; then \
echo "Using local frontend code"; \
cp -r /tmp/frontend-temp/* . 2>/dev/null || true; \
cp -r /tmp/frontend-temp/.[!.]* . 2>/dev/null || true; \
else \
echo "Cloning frontend from git"; \
git clone -b $FRONTEND_GIT_BRANCH $FRONTEND_GIT_REPO .; \
fi
# Install required tools without caching index to minimize image size
RUN if [ "$COUNTRY" = "CN" ] ; then \
@@ -69,7 +76,7 @@ RUN if [ "$COUNTRY" = "CN" ] ; then \
echo "It is in China, updating the repositories"; \
sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' /etc/apk/repositories; \
fi && \
apk update && apk add --no-cache tzdata file
apk update && apk add --no-cache tzdata file git curl build-base openssl-dev
# Copy the built application and resources from the builder stage
COPY --from=builder-backend /app/release /app/
@@ -79,10 +86,16 @@ COPY --from=builder-backend /app/docs /app/docs/
# Copy frontend build from builder2 stage
COPY --from=builder-admin-frontend /frontend/dist/ /app/resources/admin/
# Install Rust toolchain for client compilation
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable && \
. "$HOME/.cargo/env" && \
rustup target add x86_64-pc-windows-msvc x86_64-unknown-linux-gnu aarch64-apple-darwin x86_64-apple-darwin || true
# Ensure the binary is correctly built and linked
RUN file /app/apimain && \
mkdir -p /app/data && \
mkdir -p /app/runtime
mkdir -p /app/runtime && \
mkdir -p /app/resources/builds
# Set up a volume for persistent data
VOLUME /app/data
@@ -90,5 +103,10 @@ VOLUME /app/data
# Expose the necessary port
EXPOSE 21114
# Add Rust to PATH and source cargo env
ENV PATH="/root/.cargo/bin:${PATH}"
ENV CARGO_HOME="/root/.cargo"
ENV RUSTUP_HOME="/root/.rustup"
# Define the command to run the application
CMD ["./apimain"]

View File

@@ -283,6 +283,22 @@ func DatabaseAutoUpdate() {
if v.Version < 246 {
db.Exec("update oauths set issuer = 'https://accounts.google.com' where op = 'google' and issuer is null")
}
// 265迁移 - добавление полей для кастомных конфигов
// Проверяем наличие колонок независимо от версии, на случай если AutoMigrate не добавил их
if db.Migrator().HasTable(&model.ClientConfig{}) {
if !db.Migrator().HasColumn(&model.ClientConfig{}, "custom_config_file") {
global.Logger.Info("Adding column custom_config_file to client_configs table")
if err := db.Exec("ALTER TABLE client_configs ADD COLUMN custom_config_file TEXT").Error; err != nil {
global.Logger.Errorf("Failed to add custom_config_file column: %v", err)
}
}
if !db.Migrator().HasColumn(&model.ClientConfig{}, "use_custom_config") {
global.Logger.Info("Adding column use_custom_config to client_configs table")
if err := db.Exec("ALTER TABLE client_configs ADD COLUMN use_custom_config INTEGER DEFAULT 0 NOT NULL").Error; err != nil {
global.Logger.Errorf("Failed to add use_custom_config column: %v", err)
}
}
}
}
}
@@ -306,6 +322,8 @@ func Migrate(version uint) {
&model.AddressBookCollectionRule{},
&model.ServerCmd{},
&model.DeviceGroup{},
&model.ClientConfig{},
&model.ClientBuild{},
)
if err != nil {
global.Logger.Error("migrate err :=>", err)

View File

@@ -20,5 +20,8 @@ services:
volumes:
- ./data/rustdesk/api:/app/data #将数据库挂载出来方便备份
- ./conf:/app/conf # config
- ./resources/clients:/app/resources/clients # 生成的 клиенты
- ./resources/builds:/app/resources/builds # 编译任务
- ./resources/uploads:/app/resources/uploads # 上传的文件
# - ./resources:/app/resources # 静态资源
restart: unless-stopped

View File

@@ -12,6 +12,9 @@ services:
- 21114:21114
volumes:
- ./data/rustdesk/api:/app/data # database
- ./resources/clients:/app/resources/clients # 生成的客户端
- ./resources/builds:/app/resources/builds # 编译任务
- ./resources/uploads:/app/resources/uploads # 上传的文件
# - ./conf:/app/conf # config
# - ./resources:/app/resources # 静态资源
restart: unless-stopped

View File

@@ -0,0 +1,231 @@
# Интеграция генератора клиентов RustDesk
## Обзор
Интегрирован функционал генерации и компиляции кастомных клиентов RustDesk на основе проекта [VenimK/creator](https://github.com/VenimK/creator.git).
## API Endpoints
### Генерация клиента
```
POST /api/admin/client_build/generate
```
**Тело запроса:**
```json
{
"platform": "windows",
"version": "1.4.3",
"app_name": "MyRemoteDesktop",
"file_name": "myclient",
"server_ip": "192.168.1.66:21116",
"api_server": "http://192.168.1.66:21114",
"key": "your-key-here",
"description": "Описание клиента",
"config": "{}"
}
```
**Платформы:**
- `windows` - Windows 64-bit
- `linux` - Linux
- `macos` - macOS (Apple Silicon)
- `macos-x86` - macOS (Intel)
- `android` - Android
**Версии:**
- `master` - последняя версия из master ветки
- `1.4.3`, `1.4.2`, `1.4.1`, `1.4.0` - стабильные версии
- `1.3.9`, `1.3.8`, `1.3.7`, `1.3.6`, `1.3.5`, `1.3.4`, `1.3.3` - старые версии
### Список задач компиляции
```
GET /api/admin/client_build/list?page=1&page_size=10&platform=windows&status=success
```
### Статус компиляции
```
GET /api/admin/client_build/status/{uuid}
```
**Ответ:**
```json
{
"code": 0,
"message": "success",
"data": {
"build_uuid": "uuid-here",
"status": "building",
"progress": 50,
"log": "Build log...",
"error_msg": "",
"file_url": "/api/admin/client_build/download/{uuid}"
}
}
```
**Статусы:**
- `pending` - ожидает начала компиляции
- `building` - компилируется
- `success` - успешно скомпилирован
- `failed` - ошибка компиляции
### Скачивание клиента
```
GET /api/admin/client_build/download/{uuid}
```
### Удаление задачи
```
POST /api/admin/client_build/delete/{id}
```
## Настройка локальной компиляции
### Требования
Для локальной компиляции RustDesk клиентов необходимо:
1. **Rust toolchain** (для Windows, Linux, macOS):
```bash
# Установка Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
2. **Исходный код RustDesk**:
```bash
git clone https://github.com/rustdesk/rustdesk.git
cd rustdesk
```
3. **Дополнительные инструменты**:
- **Windows**: Visual Studio Build Tools, WiX Toolset (для установщика)
- **Linux**: build-essential, libssl-dev, libgtk-3-dev
- **macOS**: Xcode Command Line Tools
- **Android**: Android SDK, NDK
### Интеграция реальной компиляции
Для реализации реальной компиляции необходимо модифицировать методы в `service/clientBuild.go`:
#### Пример для Windows:
```go
func (cbs *ClientBuildService) buildWindows(build *model.ClientBuild, config BuildConfig, buildDir string) (string, error) {
cbs.appendLog(build, "Building Windows client...\n")
// Путь к исходникам RustDesk
rustdeskSourceDir := "/path/to/rustdesk"
// Создаем конфигурационный файл
customConfig := cbs.generateCustomConfig(config)
customPath := filepath.Join(buildDir, "custom.txt")
if err := os.WriteFile(customPath, []byte(customConfig), 0644); err != nil {
return "", fmt.Errorf("failed to write custom.txt: %v", err)
}
// Копируем custom.txt в исходники
targetCustomPath := filepath.Join(rustdeskSourceDir, "custom.txt")
if err := os.WriteFile(targetCustomPath, []byte(customConfig), 0644); err != nil {
return "", err
}
// Компилируем
cmd := exec.Command("cargo", "build", "--release", "--target", "x86_64-pc-windows-msvc")
cmd.Dir = rustdeskSourceDir
cmd.Env = append(os.Environ(), "RUSTFLAGS=-C target-feature=+crt-static")
output, err := cmd.CombinedOutput()
cbs.appendLog(build, string(output))
if err != nil {
return "", fmt.Errorf("build failed: %v", err)
}
// Копируем скомпилированный файл
exePath := filepath.Join(rustdeskSourceDir, "target", "x86_64-pc-windows-msvc", "release", "rustdesk.exe")
outputPath := filepath.Join(buildDir, config.FileName+".exe")
if err := copyFile(exePath, outputPath); err != nil {
return "", err
}
return outputPath, nil
}
```
### Конфигурационный файл custom.txt
Формат файла `custom.txt` для RustDesk:
```
rendezvous-server = 192.168.1.66:21116
api-server = http://192.168.1.66:21114
key = your-key-here
app-name = MyRemoteDesktop
password = default-password
approve-mode = password-click
enable-lan-discovery = Y
```
### Дополнительные настройки
В поле `config` можно передать JSON с дополнительными настройками:
```json
{
"config": "{\"password\":\"mypass\",\"approve-mode\":\"password\",\"enable-keyboard\":\"Y\",\"enable-clipboard\":\"Y\"}"
}
```
## Структура файлов
```
resources/
builds/
{build_uuid}/
custom.txt # Конфигурация
{filename}.exe # Скомпилированный клиент (Windows)
{filename}.dmg # Скомпилированный клиент (macOS)
{filename}.deb # Скомпилированный клиент (Linux)
{filename}.apk # Скомпилированный клиент (Android)
```
## Безопасность
- Все задачи компиляции привязаны к пользователю
- Пользователь может видеть и скачивать только свои клиенты
- Файлы хранятся в защищенной директории
- Логи компиляции доступны только владельцу задачи
## Примечания
1. **Время компиляции**: В зависимости от платформы компиляция может занимать 15-30 минут
2. **Ресурсы**: Компиляция требует значительных ресурсов (CPU, RAM, диск)
3. **Параллельные сборки**: Рекомендуется ограничить количество одновременных сборок
4. **Очистка**: Старые файлы сборок можно удалять через API или вручную
## Расширение функционала
Для добавления поддержки кастомных иконок и логотипов:
1. Добавьте поля `icon_path` и `logo_path` в `BuildConfig`
2. Реализуйте обработку изображений в методе `buildClient`
3. Интегрируйте с инструментами замены ресурсов RustDesk
## Troubleshooting
**Ошибка "Rust toolchain not found"**:
- Установите Rust: https://rustup.rs/
- Убедитесь, что `cargo` доступен в PATH
**Ошибка компиляции**:
- Проверьте логи в поле `build_log`
- Убедитесь, что исходники RustDesk актуальны
- Проверьте наличие всех зависимостей
**Файл не найден после компиляции**:
- Проверьте путь к исходникам RustDesk
- Убедитесь, что компиляция завершилась успешно
- Проверьте права доступа к директориям

1
frontend-temp Submodule

Submodule frontend-temp added at 3998c2a921

View File

@@ -0,0 +1,316 @@
package admin
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/lejianwen/rustdesk-api/v2/global"
"github.com/lejianwen/rustdesk-api/v2/http/request/admin"
"github.com/lejianwen/rustdesk-api/v2/http/response"
"github.com/lejianwen/rustdesk-api/v2/service"
"gorm.io/gorm"
)
type ClientBuild struct {
}
// Generate запускает генерацию/компиляцию клиента
// @Tags 客户端编译
// @Summary 生成客户端
// @Description 启动客户端编译任务
// @Accept json
// @Produce json
// @Param body body admin.ClientBuildGenerateForm true "客户端配置"
// @Success 200 {object} response.Response{data=model.ClientBuild}
// @Failure 500 {object} response.Response
// @Router /admin/client_build/generate [post]
// @Security token
func (ct *ClientBuild) Generate(c *gin.Context) {
f := &admin.ClientBuildGenerateForm{}
if err := c.ShouldBindJSON(f); err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+err.Error())
return
}
errList := global.Validator.ValidStruct(c, f)
if len(errList) > 0 {
response.Fail(c, 101, errList[0])
return
}
u := service.AllService.UserService.CurUser(c)
if u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "UserNotFound"))
return
}
// Парсим дополнительные настройки из config
var customConfig map[string]string
if f.Config != "" {
if err := json.Unmarshal([]byte(f.Config), &customConfig); err != nil {
customConfig = make(map[string]string)
}
} else {
customConfig = make(map[string]string)
}
// Используем настройки сервера из конфигурации, если не указаны (только если не используется кастомный конфиг)
serverIP := f.ServerIP
if serverIP == "" && !f.UseCustomConfig {
serverIP = global.Config.Rustdesk.IdServer
}
apiServer := f.ApiServer
if apiServer == "" && !f.UseCustomConfig {
apiServer = global.Config.Rustdesk.ApiServer
}
key := f.Key
if key == "" && !f.UseCustomConfig {
key = global.Config.Rustdesk.Key
}
// Обрабатываем кастомный конфигурационный файл
// Принимаем любой конфигурационный файл без проверки сертификатов/подписей
customConfigFile := f.CustomConfigFile
if f.UseCustomConfig && customConfigFile != "" {
// Пытаемся декодировать base64, если не получается - используем как есть
decoded, err := base64.StdEncoding.DecodeString(customConfigFile)
if err == nil {
customConfigFile = string(decoded)
}
// Если декодирование не удалось, используем исходную строку как текст конфига
}
buildConfig := &service.BuildConfig{
Platform: f.Platform,
Version: f.Version,
AppName: f.AppName,
FileName: f.FileName,
ServerIP: serverIP,
ApiServer: apiServer,
Key: key,
Custom: customConfig,
CustomConfigFile: customConfigFile,
UseCustomConfig: f.UseCustomConfig,
}
build, err := service.AllService.ClientBuildService.CreateBuild(u.Id, buildConfig)
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "OperationFailed")+err.Error())
return
}
response.Success(c, build)
}
// List получает список задач компиляции
// @Tags 客户端编译
// @Summary 编译任务列表
// @Description 获取当前用户的编译任务列表
// @Accept json
// @Produce json
// @Param page query int false "页码"
// @Param page_size query int false "页大小"
// @Param platform query string false "平台"
// @Param status query string false "状态"
// @Success 200 {object} response.Response{data=model.ClientBuildList}
// @Failure 500 {object} response.Response
// @Router /admin/client_build/list [get]
// @Security token
func (ct *ClientBuild) List(c *gin.Context) {
query := &admin.ClientBuildQuery{}
if err := c.ShouldBindQuery(query); err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+err.Error())
return
}
u := service.AllService.UserService.CurUser(c)
if u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "UserNotFound"))
return
}
res := service.AllService.ClientBuildService.List(uint(query.Page), uint(query.PageSize), func(tx *gorm.DB) *gorm.DB {
tx = tx.Where("user_id = ?", u.Id)
if query.Platform != "" {
tx = tx.Where("platform = ?", query.Platform)
}
if query.Status != "" {
tx = tx.Where("status = ?", query.Status)
}
return tx
})
response.Success(c, res)
}
// Status получает статус задачи компиляции
// @Tags 客户端编译
// @Summary 编译状态
// @Description 获取编译任务的状态
// @Accept json
// @Produce json
// @Param uuid path string true "Build UUID"
// @Success 200 {object} response.Response{data=admin.ClientBuildStatusResponse}
// @Failure 500 {object} response.Response
// @Router /admin/client_build/status/{uuid} [get]
// @Security token
func (ct *ClientBuild) Status(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError"))
return
}
u := service.AllService.UserService.CurUser(c)
if u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "UserNotFound"))
return
}
build := service.AllService.ClientBuildService.GetByUuid(uuid)
if build.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "ItemNotFound"))
return
}
// Проверяем, что задача принадлежит пользователю
if build.UserId != u.Id {
response.Fail(c, 101, response.TranslateMsg(c, "AccessDenied"))
return
}
// Вычисляем прогресс
progress := 0
if build.Status == "pending" {
progress = 10
} else if build.Status == "building" {
progress = 50
} else if build.Status == "success" {
progress = 100
} else if build.Status == "failed" {
progress = 0
}
statusResp := &admin.ClientBuildStatusResponse{
BuildUuid: build.BuildUuid,
Status: build.Status,
Progress: progress,
Log: build.BuildLog,
ErrorMsg: build.ErrorMsg,
}
// Если сборка успешна, добавляем URL для скачивания
if build.Status == "success" && build.FilePath != "" {
statusResp.FileUrl = "/api/admin/client_build/download/" + build.BuildUuid
}
response.Success(c, statusResp)
}
// Download скачивает скомпилированный клиент
// @Tags 客户端编译
// @Summary 下载客户端
// @Description 下载编译完成的客户端文件
// @Accept json
// @Produce application/octet-stream
// @Param uuid path string true "Build UUID"
// @Success 200 {file} file
// @Failure 500 {object} response.Response
// @Router /admin/client_build/download/{uuid} [get]
// @Security token
func (ct *ClientBuild) Download(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError"))
return
}
u := service.AllService.UserService.CurUser(c)
if u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "UserNotFound"))
return
}
build := service.AllService.ClientBuildService.GetByUuid(uuid)
if build.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "ItemNotFound"))
return
}
// Проверяем, что задача принадлежит пользователю
if build.UserId != u.Id {
response.Fail(c, 101, response.TranslateMsg(c, "AccessDenied"))
return
}
// Проверяем, что сборка успешна
if build.Status != "success" {
response.Fail(c, 101, "Build is not completed yet")
return
}
// Проверяем существование файла
if _, err := os.Stat(build.FilePath); os.IsNotExist(err) {
response.Fail(c, 101, response.TranslateMsg(c, "FileNotFound"))
return
}
// Отправляем файл
// Определяем Content-Type в зависимости от расширения файла
contentType := "application/octet-stream"
fileExt := strings.ToLower(filepath.Ext(build.FilePath))
switch fileExt {
case ".exe":
contentType = "application/x-msdownload"
case ".deb":
contentType = "application/vnd.debian.binary-package"
case ".dmg":
contentType = "application/x-apple-diskimage"
case ".apk":
contentType = "application/vnd.android.package-archive"
case ".zip":
contentType = "application/zip"
}
c.Header("Content-Disposition", "attachment; filename="+build.FileName)
c.Header("Content-Type", contentType)
c.File(build.FilePath)
}
// Delete удаляет задачу компиляции
// @Tags 客户端编译
// @Summary 删除编译任务
// @Description 删除编译任务及其文件
// @Accept json
// @Produce json
// @Param id path int true "Build ID"
// @Success 200 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /admin/client_build/delete/{id} [post]
// @Security token
func (ct *ClientBuild) Delete(c *gin.Context) {
id := c.Param("id")
idUint, err := strconv.ParseUint(id, 10, 32)
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError"))
return
}
u := service.AllService.UserService.CurUser(c)
if u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "UserNotFound"))
return
}
err = service.AllService.ClientBuildService.Delete(uint(idUint), u.Id)
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "OperationFailed")+err.Error())
return
}
response.Success(c, nil)
}

View File

@@ -0,0 +1,74 @@
package admin
import (
"encoding/base64"
"io"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/lejianwen/rustdesk-api/v2/global"
"github.com/lejianwen/rustdesk-api/v2/http/response"
"github.com/lejianwen/rustdesk-api/v2/service"
)
// UploadConfigFile загружает конфигурационный файл для использования в генерации клиента
// @Tags 客户端编译
// @Summary 上传配置文件
// @Description 上传自定义配置文件(无限制,任何文件都可以上传)
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "配置文件"
// @Success 200 {object} response.Response{data=object{file_content=string}}
// @Failure 500 {object} response.Response
// @Router /admin/client_build/upload_config [post]
// @Security token
func (ct *ClientBuild) UploadConfigFile(c *gin.Context) {
u := service.AllService.UserService.CurUser(c)
if u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "UserNotFound"))
return
}
// Получаем загруженный файл
file, err := c.FormFile("file")
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+": file is required")
return
}
// Открываем файл
src, err := file.Open()
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "OperationFailed")+": "+err.Error())
return
}
defer src.Close()
// Читаем содержимое файла
fileContent, err := io.ReadAll(src)
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "OperationFailed")+": "+err.Error())
return
}
// Кодируем в base64 для передачи в JSON
fileContentBase64 := base64.StdEncoding.EncodeToString(fileContent)
// Опционально: сохраняем файл для справки
uploadDir := filepath.Join(global.Config.Gin.ResourcesPath, "uploads", "configs")
if err := os.MkdirAll(uploadDir, 0755); err == nil {
dst := filepath.Join(uploadDir, file.Filename)
if err := c.SaveUploadedFile(file, dst); err == nil {
// Файл сохранен
}
}
response.Success(c, gin.H{
"file_name": file.Filename,
"file_size": file.Size,
"file_content": fileContentBase64,
"message": "File uploaded successfully. Use file_content in custom_config_file field.",
})
}

View File

@@ -0,0 +1,188 @@
package admin
import (
"encoding/base64"
"os"
"strconv"
"github.com/gin-gonic/gin"
"github.com/lejianwen/rustdesk-api/v2/global"
"github.com/lejianwen/rustdesk-api/v2/http/request/admin"
"github.com/lejianwen/rustdesk-api/v2/http/response"
"github.com/lejianwen/rustdesk-api/v2/service"
"gorm.io/gorm"
)
type ClientConfig struct {
}
// Generate генерирует клиент с настройками
// @Tags 客户端生成
// @Summary 生成客户端
// @Description 生成带有服务器配置的客户端
// @Accept json
// @Produce json
// @Param body body admin.ClientConfigGenerateForm true "客户端信息"
// @Success 200 {object} response.Response{data=model.ClientConfig}
// @Failure 500 {object} response.Response
// @Router /admin/client_config/generate [post]
// @Security token
func (ct *ClientConfig) Generate(c *gin.Context) {
f := &admin.ClientConfigGenerateForm{}
if err := c.ShouldBindJSON(f); err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+err.Error())
return
}
errList := global.Validator.ValidStruct(c, f)
if len(errList) > 0 {
response.Fail(c, 101, errList[0])
return
}
u := service.AllService.UserService.CurUser(c)
if u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "UserNotFound"))
return
}
// Обрабатываем кастомный конфигурационный файл
// Принимаем любой конфигурационный файл без проверки сертификатов/подписей
customConfigFile := ""
if f.UseCustomConfig && f.CustomConfigFile != "" {
customConfigFile = f.CustomConfigFile
// Пытаемся декодировать base64, если не получается - используем как есть
decoded, err := base64.StdEncoding.DecodeString(customConfigFile)
if err == nil {
customConfigFile = string(decoded)
}
// Если декодирование не удалось, используем исходную строку как текст конфига
}
clientConfig, err := service.AllService.ClientConfigService.GenerateClientConfig(u.Id, f.Password, f.Description, customConfigFile)
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "OperationFailed")+err.Error())
return
}
response.Success(c, clientConfig)
}
// List получает список сгенерированных клиентов
// @Tags 客户端生成
// @Summary 客户端列表
// @Description 获取当前用户的客户端列表
// @Accept json
// @Produce json
// @Param page query int false "页码"
// @Param page_size query int false "页大小"
// @Success 200 {object} response.Response{data=model.ClientConfigList}
// @Failure 500 {object} response.Response
// @Router /admin/client_config/list [get]
// @Security token
func (ct *ClientConfig) List(c *gin.Context) {
query := &admin.ClientConfigQuery{}
if err := c.ShouldBindQuery(query); err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+err.Error())
return
}
u := service.AllService.UserService.CurUser(c)
if u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "UserNotFound"))
return
}
res := service.AllService.ClientConfigService.List(uint(query.Page), uint(query.PageSize), func(tx *gorm.DB) *gorm.DB {
return tx.Where("user_id = ?", u.Id)
})
response.Success(c, res)
}
// Download скачивает сгенерированный клиент
// @Tags 客户端生成
// @Summary 下载客户端
// @Description 下载生成的客户端文件
// @Accept json
// @Produce application/zip
// @Param id path int true "客户端ID"
// @Success 200 {file} file
// @Failure 500 {object} response.Response
// @Router /admin/client_config/download/{id} [get]
// @Security token
func (ct *ClientConfig) Download(c *gin.Context) {
id := c.Param("id")
idUint, err := strconv.ParseUint(id, 10, 32)
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError"))
return
}
u := service.AllService.UserService.CurUser(c)
if u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "UserNotFound"))
return
}
clientConfig := service.AllService.ClientConfigService.GetById(uint(idUint))
if clientConfig.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "ItemNotFound"))
return
}
// Проверяем, что клиент принадлежит пользователю
if clientConfig.UserId != u.Id {
response.Fail(c, 101, response.TranslateMsg(c, "AccessDenied"))
return
}
// Проверяем существование файла
if _, err := os.Stat(clientConfig.FilePath); os.IsNotExist(err) {
response.Fail(c, 101, response.TranslateMsg(c, "FileNotFound"))
return
}
// Отправляем файл
c.Header("Content-Disposition", "attachment; filename="+clientConfig.FileName)
c.Header("Content-Type", "application/zip")
c.File(clientConfig.FilePath)
}
// Delete удаляет сгенерированный клиент
// @Tags 客户端生成
// @Summary 删除客户端
// @Description 删除生成的客户端
// @Accept json
// @Produce json
// @Param body body admin.ClientConfigDeleteForm true "客户端信息"
// @Success 200 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /admin/client_config/delete [post]
// @Security token
func (ct *ClientConfig) Delete(c *gin.Context) {
f := &admin.ClientConfigDeleteForm{}
if err := c.ShouldBindJSON(f); err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+err.Error())
return
}
errList := global.Validator.ValidStruct(c, f)
if len(errList) > 0 {
response.Fail(c, 101, errList[0])
return
}
u := service.AllService.UserService.CurUser(c)
if u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "UserNotFound"))
return
}
err := service.AllService.ClientConfigService.Delete(f.Id, u.Id)
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "OperationFailed")+err.Error())
return
}
response.Success(c, nil)
}

View File

@@ -0,0 +1,35 @@
package admin
import "github.com/lejianwen/rustdesk-api/v2/model"
type ClientBuildGenerateForm struct {
Platform string `json:"platform" validate:"required,oneof=windows linux macos android macos-x86"` // платформа
Version string `json:"version" validate:"required"` // версия RustDesk
AppName string `json:"app_name" validate:"required,gte=1,lte=100"` // имя приложения
FileName string `json:"file_name" validate:"required,gte=1,lte=100"` // имя файла
ServerIP string `json:"server_ip"` // IP сервера
ApiServer string `json:"api_server"` // API сервер
Key string `json:"key"` // ключ
Description string `json:"description"` // описание
// Дополнительные настройки
Config string `json:"config"` // JSON с дополнительными настройками
// Кастомный конфигурационный файл (base64 или текст)
CustomConfigFile string `json:"custom_config_file"` // содержимое конфигурационного файла (base64 или текст)
UseCustomConfig bool `json:"use_custom_config"` // использовать кастомный конфиг вместо автогенерации
}
type ClientBuildQuery struct {
model.Pagination
Platform string `form:"platform"`
Status string `form:"status"`
}
type ClientBuildStatusResponse struct {
BuildUuid string `json:"build_uuid"`
Status string `json:"status"`
Progress int `json:"progress"` // 0-100
Log string `json:"log"`
ErrorMsg string `json:"error_msg"`
FileUrl string `json:"file_url,omitempty"` // URL для скачивания, если готово
}

View File

@@ -0,0 +1,20 @@
package admin
import "github.com/lejianwen/rustdesk-api/v2/model"
type ClientConfigGenerateForm struct {
Password string `json:"password" validate:"required,gte=4,lte=32"` // пароль для клиента
Description string `json:"description"` // описание/комментарий
CustomConfigFile string `json:"custom_config_file"` // кастомный конфигурационный файл (опционально)
UseCustomConfig bool `json:"use_custom_config"` // использовать кастомный конфиг
}
type ClientConfigQuery struct {
model.Pagination
UserId uint `form:"user_id"`
}
type ClientConfigDeleteForm struct {
Id uint `json:"id" validate:"required"`
}

View File

@@ -50,6 +50,8 @@ func Init(g *gin.Engine) {
RustdeskCmdBind(adg)
DeviceGroupBind(adg)
ClientConfigBind(adg)
ClientBuildBind(adg)
//访问静态文件
//g.StaticFS("/upload", http.Dir(global.Config.Gin.ResourcesPath+"/upload"))
}
@@ -322,3 +324,27 @@ func ShareRecordBind(rg *gin.RouterGroup) {
}
}
func ClientConfigBind(rg *gin.RouterGroup) {
aR := rg.Group("/client_config")
{
cont := &admin.ClientConfig{}
aR.POST("/generate", cont.Generate)
aR.GET("/list", cont.List)
aR.GET("/download/:id", cont.Download)
aR.POST("/delete", cont.Delete)
}
}
func ClientBuildBind(rg *gin.RouterGroup) {
aR := rg.Group("/client_build")
{
cont := &admin.ClientBuild{}
aR.POST("/generate", cont.Generate)
aR.POST("/upload_config", cont.UploadConfigFile)
aR.GET("/list", cont.List)
aR.GET("/status/:uuid", cont.Status)
aR.GET("/download/:uuid", cont.Download)
aR.POST("/delete/:id", cont.Delete)
}
}

26
model/clientBuild.go Normal file
View File

@@ -0,0 +1,26 @@
package model
// ClientBuild модель для хранения задач компиляции клиентов
type ClientBuild struct {
IdModel
UserId uint `json:"user_id" gorm:"default:0;not null;index"`
BuildUuid string `json:"uuid" gorm:"default:'';not null;uniqueIndex"` // уникальный ID сборки
Platform string `json:"platform" gorm:"default:'';not null;"` // windows, linux, macos, android
Version string `json:"version" gorm:"default:'';not null;"` // версия RustDesk
AppName string `json:"app_name" gorm:"default:'';not null;"` // имя приложения
FileName string `json:"file_name" gorm:"default:'';not null;"` // имя файла клиента
Status string `json:"status" gorm:"default:'pending';not null;index"` // pending, building, success, failed
BuildConfig string `json:"-" gorm:"type:text;"` // JSON конфигурация сборки
BuildLog string `json:"build_log" gorm:"type:text;"` // лог сборки
FilePath string `json:"-" gorm:"default:'';not null;"` // путь к скомпилированному файлу
FileSize int64 `json:"file_size" gorm:"default:0;not null;"` // размер файла
ErrorMsg string `json:"error_msg" gorm:"type:text;"` // сообщение об ошибке
TimeModel
}
// ClientBuildList список задач компиляции
type ClientBuildList struct {
ClientBuilds []*ClientBuild `json:"list,omitempty"`
Pagination
}

24
model/clientConfig.go Normal file
View File

@@ -0,0 +1,24 @@
package model
// ClientConfig модель для хранения сгенерированных клиентов
type ClientConfig struct {
IdModel
UserId uint `json:"user_id" gorm:"default:0;not null;index"`
Password string `json:"-" gorm:"default:'';not null;"` // пароль в зашифрованном виде
FileName string `json:"file_name" gorm:"default:'';not null;"` // имя файла клиента
FileHash string `json:"file_hash" gorm:"default:'';not null;index"` // хеш файла для проверки
FileSize int64 `json:"file_size" gorm:"default:0;not null;"` // размер файла
FilePath string `json:"-" gorm:"default:'';not null;"` // путь к файлу на сервере
Description string `json:"description" gorm:"default:'';not null;"` // описание/комментарий
Status StatusCode `json:"status" gorm:"default:1;not null;"` // статус (1: активен, 2: удален)
CustomConfigFile string `json:"-" gorm:"type:text;"` // Store custom config content
UseCustomConfig bool `json:"-" gorm:"default:0;not null;"` // использовать кастомный конфиг
TimeModel
}
// ClientConfigList список сгенерированных клиентов
type ClientConfigList struct {
ClientConfigs []*ClientConfig `json:"list,omitempty"`
Pagination
}

627
service/clientBuild.go Normal file
View File

@@ -0,0 +1,627 @@
package service
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/lejianwen/rustdesk-api/v2/global"
"github.com/lejianwen/rustdesk-api/v2/model"
"gorm.io/gorm"
)
type ClientBuildService struct {
}
// BuildConfig конфигурация для сборки клиента
type BuildConfig struct {
Platform string `json:"platform"`
Version string `json:"version"`
AppName string `json:"app_name"`
FileName string `json:"file_name"`
ServerIP string `json:"server_ip"`
ApiServer string `json:"api_server"`
Key string `json:"key"`
Custom map[string]string `json:"custom"` // дополнительные настройки
IconPath string `json:"icon_path,omitempty"`
LogoPath string `json:"logo_path,omitempty"`
CustomConfigFile string `json:"custom_config_file,omitempty"` // кастомный конфигурационный файл
UseCustomConfig bool `json:"use_custom_config"` // использовать кастомный конфиг
}
// CreateBuild создает новую задачу компиляции
func (cbs *ClientBuildService) CreateBuild(userId uint, config *BuildConfig) (*model.ClientBuild, error) {
buildUuid := uuid.New().String()
// Сохраняем конфигурацию в JSON
configJson, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("failed to marshal config: %v", err)
}
build := &model.ClientBuild{
UserId: userId,
BuildUuid: buildUuid,
Platform: config.Platform,
Version: config.Version,
AppName: config.AppName,
FileName: config.FileName,
Status: "pending",
BuildConfig: string(configJson),
}
if err := DB.Create(build).Error; err != nil {
return nil, fmt.Errorf("failed to create build: %v", err)
}
// Запускаем компиляцию в фоне
go cbs.buildClient(build)
return build, nil
}
// buildClient выполняет компиляцию клиента
func (cbs *ClientBuildService) buildClient(build *model.ClientBuild) {
// Обновляем статус на "building"
DB.Model(build).Update("status", "building")
DB.Model(build).Update("build_log", "Starting build process...\n")
// Парсим конфигурацию
var config BuildConfig
if err := json.Unmarshal([]byte(build.BuildConfig), &config); err != nil {
cbs.updateBuildStatus(build, "failed", fmt.Sprintf("Failed to parse config: %v", err), "")
return
}
// Создаем директорию для сборки
buildDir := filepath.Join(global.Config.Gin.ResourcesPath, "builds", build.BuildUuid)
if err := os.MkdirAll(buildDir, 0755); err != nil {
cbs.updateBuildStatus(build, "failed", fmt.Sprintf("Failed to create build directory: %v", err), "")
return
}
// Выполняем компиляцию в зависимости от платформы
var err error
var outputPath string
switch config.Platform {
case "windows":
outputPath, err = cbs.buildWindows(build, config, buildDir)
case "linux":
outputPath, err = cbs.buildLinux(build, config, buildDir)
case "macos", "macos-x86":
outputPath, err = cbs.buildMacOS(build, config, buildDir)
case "android":
outputPath, err = cbs.buildAndroid(build, config, buildDir)
default:
err = fmt.Errorf("unsupported platform: %s", config.Platform)
}
if err != nil {
cbs.updateBuildStatus(build, "failed", err.Error(), "")
return
}
// Получаем размер файла
fileInfo, err := os.Stat(outputPath)
if err != nil {
cbs.updateBuildStatus(build, "failed", fmt.Sprintf("Failed to get file info: %v", err), "")
return
}
// Обновляем имя файла, если оно не соответствует расширению
fileName := build.FileName
// Получаем расширение из пути
if len(outputPath) > 4 {
ext := filepath.Ext(outputPath)
if ext != "" && !strings.HasSuffix(strings.ToLower(fileName), strings.ToLower(ext)) {
// Обновляем расширение
if strings.Contains(fileName, ".") {
fileName = fileName[:strings.LastIndex(fileName, ".")] + ext
} else {
fileName = fileName + ext
}
}
}
// Обновляем статус на успех
DB.Model(build).Updates(map[string]interface{}{
"status": "success",
"file_path": outputPath,
"file_name": fileName,
"file_size": fileInfo.Size(),
"build_log": build.BuildLog + "\nBuild completed successfully!",
})
}
// cloneRustDeskSource клонирует исходный код RustDesk
func (cbs *ClientBuildService) cloneRustDeskSource(build *model.ClientBuild, version string, sourceDir string) error {
cbs.appendLog(build, fmt.Sprintf("Cloning RustDesk source code (version: %s)...\n", version))
// Проверяем, существует ли уже клонированный репозиторий
if _, err := os.Stat(filepath.Join(sourceDir, ".git")); err == nil {
cbs.appendLog(build, "Repository already exists, updating...\n")
// Обновляем существующий репозиторий
cmd := exec.Command("git", "fetch", "origin")
cmd.Dir = sourceDir
if output, err := cmd.CombinedOutput(); err != nil {
cbs.appendLog(build, fmt.Sprintf("Warning: failed to fetch updates: %s\n", string(output)))
}
// Обновляем submodules
cbs.appendLog(build, "Updating submodules...\n")
cmd = exec.Command("git", "submodule", "update", "--init", "--recursive")
cmd.Dir = sourceDir
if output, err := cmd.CombinedOutput(); err != nil {
cbs.appendLog(build, fmt.Sprintf("Warning: failed to update submodules: %s\n", string(output)))
} else {
cbs.appendLog(build, "Submodules updated successfully\n")
}
} else {
// Клонируем репозиторий
cbs.appendLog(build, "Cloning repository...\n")
normalizedVersion := strings.TrimPrefix(version, "v")
// Клонируем репозиторий (без --depth 1, чтобы получить все файлы и submodules)
cmd := exec.Command("git", "clone", "--recursive", "https://github.com/rustdesk/rustdesk.git", sourceDir)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to clone repository: %v\nOutput: %s", err, string(output))
}
cbs.appendLog(build, "Repository cloned successfully\n")
// Переключаемся на нужную версию, если указана
if normalizedVersion != "master" && normalizedVersion != "" {
cbs.appendLog(build, fmt.Sprintf("Checking out version: %s\n", normalizedVersion))
cmd := exec.Command("git", "checkout", normalizedVersion)
cmd.Dir = sourceDir
if output, err := cmd.CombinedOutput(); err != nil {
// Пробуем с префиксом v
cmd = exec.Command("git", "checkout", "v"+normalizedVersion)
cmd.Dir = sourceDir
if output2, err2 := cmd.CombinedOutput(); err2 != nil {
return fmt.Errorf("failed to checkout version %s: %v\nOutput: %s\nTried v%s: %s", normalizedVersion, err, string(output), normalizedVersion, string(output2))
}
}
cbs.appendLog(build, fmt.Sprintf("Checked out version: %s\n", normalizedVersion))
// Обновляем submodules для конкретной версии
cbs.appendLog(build, "Updating submodules...\n")
cmd = exec.Command("git", "submodule", "update", "--init", "--recursive")
cmd.Dir = sourceDir
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to update submodules: %v\nOutput: %s", err, string(output))
}
cbs.appendLog(build, "Submodules updated successfully\n")
} else {
// Для master обновляем submodules
cbs.appendLog(build, "Updating submodules...\n")
cmd := exec.Command("git", "submodule", "update", "--init", "--recursive")
cmd.Dir = sourceDir
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to update submodules: %v\nOutput: %s", err, string(output))
}
cbs.appendLog(build, "Submodules updated successfully\n")
}
}
return nil
}
// copyFile копирует файл из источника в назначение
func (cbs *ClientBuildService) copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file: %v", err)
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return fmt.Errorf("failed to create destination file: %v", err)
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
if err != nil {
return fmt.Errorf("failed to copy file: %v", err)
}
return nil
}
// buildWindows компилирует клиент для Windows
func (cbs *ClientBuildService) buildWindows(build *model.ClientBuild, config BuildConfig, buildDir string) (string, error) {
cbs.appendLog(build, "Building Windows client from source...\n")
// Проверяем наличие Rust toolchain
if _, err := exec.LookPath("cargo"); err != nil {
return "", fmt.Errorf("Rust toolchain not found. Please install Rust: https://rustup.rs/")
}
// Создаем директорию для исходников
sourceDir := filepath.Join(buildDir, "rustdesk-source")
if err := os.MkdirAll(sourceDir, 0755); err != nil {
return "", fmt.Errorf("failed to create source directory: %v", err)
}
// Клонируем исходный код
if err := cbs.cloneRustDeskSource(build, config.Version, sourceDir); err != nil {
return "", fmt.Errorf("failed to clone source: %v", err)
}
// Создаем конфигурационный файл custom.txt
var customConfig string
if config.UseCustomConfig && config.CustomConfigFile != "" {
customConfig = config.CustomConfigFile
cbs.appendLog(build, "Using custom configuration file\n")
} else {
customConfig = cbs.generateCustomConfig(config)
cbs.appendLog(build, "Using auto-generated configuration\n")
}
// Записываем custom.txt в исходники
customPath := filepath.Join(sourceDir, "custom.txt")
if err := os.WriteFile(customPath, []byte(customConfig), 0644); err != nil {
return "", fmt.Errorf("failed to write custom.txt: %v", err)
}
// Компилируем
cbs.appendLog(build, "Compiling Windows client with cargo...\n")
cmd := exec.Command("cargo", "build", "--release", "--target", "x86_64-pc-windows-msvc")
cmd.Dir = sourceDir
cmd.Env = append(os.Environ(), "RUSTFLAGS=-C target-feature=+crt-static")
output, err := cmd.CombinedOutput()
cbs.appendLog(build, string(output))
if err != nil {
return "", fmt.Errorf("build failed: %v", err)
}
// Копируем скомпилированный файл
exePath := filepath.Join(sourceDir, "target", "x86_64-pc-windows-msvc", "release", "rustdesk.exe")
if _, err := os.Stat(exePath); os.IsNotExist(err) {
return "", fmt.Errorf("compiled file not found: %s", exePath)
}
// Определяем имя выходного файла
outputFileName := config.FileName
if !strings.HasSuffix(strings.ToLower(outputFileName), ".exe") {
outputFileName = outputFileName + ".exe"
}
outputPath := filepath.Join(buildDir, outputFileName)
if err := cbs.copyFile(exePath, outputPath); err != nil {
return "", fmt.Errorf("failed to copy compiled file: %v", err)
}
// Копируем custom.txt рядом с исполняемым файлом
customOutputPath := filepath.Join(buildDir, "custom.txt")
if err := os.WriteFile(customOutputPath, []byte(customConfig), 0644); err != nil {
cbs.appendLog(build, fmt.Sprintf("Warning: failed to copy custom.txt: %v\n", err))
}
cbs.appendLog(build, fmt.Sprintf("Windows client compiled successfully: %s\n", outputFileName))
return outputPath, nil
}
// buildLinux компилирует клиент для Linux
func (cbs *ClientBuildService) buildLinux(build *model.ClientBuild, config BuildConfig, buildDir string) (string, error) {
cbs.appendLog(build, "Building Linux client from source...\n")
// Проверяем наличие Rust toolchain
if _, err := exec.LookPath("cargo"); err != nil {
return "", fmt.Errorf("Rust toolchain not found. Please install Rust: https://rustup.rs/")
}
// Создаем директорию для исходников
sourceDir := filepath.Join(buildDir, "rustdesk-source")
if err := os.MkdirAll(sourceDir, 0755); err != nil {
return "", fmt.Errorf("failed to create source directory: %v", err)
}
// Клонируем исходный код
if err := cbs.cloneRustDeskSource(build, config.Version, sourceDir); err != nil {
return "", fmt.Errorf("failed to clone source: %v", err)
}
// Создаем конфигурационный файл custom.txt
var customConfig string
if config.UseCustomConfig && config.CustomConfigFile != "" {
customConfig = config.CustomConfigFile
cbs.appendLog(build, "Using custom configuration file\n")
} else {
customConfig = cbs.generateCustomConfig(config)
cbs.appendLog(build, "Using auto-generated configuration\n")
}
// Записываем custom.txt в исходники
customPath := filepath.Join(sourceDir, "custom.txt")
if err := os.WriteFile(customPath, []byte(customConfig), 0644); err != nil {
return "", fmt.Errorf("failed to write custom.txt: %v", err)
}
// Компилируем
cbs.appendLog(build, "Compiling Linux client with cargo...\n")
cmd := exec.Command("cargo", "build", "--release", "--target", "x86_64-unknown-linux-gnu")
cmd.Dir = sourceDir
output, err := cmd.CombinedOutput()
cbs.appendLog(build, string(output))
if err != nil {
return "", fmt.Errorf("build failed: %v", err)
}
// Копируем скомпилированный файл
binPath := filepath.Join(sourceDir, "target", "x86_64-unknown-linux-gnu", "release", "rustdesk")
if _, err := os.Stat(binPath); os.IsNotExist(err) {
return "", fmt.Errorf("compiled file not found: %s", binPath)
}
// Определяем имя выходного файла (без расширения для Linux бинарника)
outputFileName := config.FileName
if strings.HasSuffix(strings.ToLower(outputFileName), ".deb") || strings.HasSuffix(strings.ToLower(outputFileName), ".rpm") {
// Если указано расширение пакета, оставляем как есть, но создаем бинарник
outputFileName = strings.TrimSuffix(outputFileName, filepath.Ext(outputFileName))
}
outputPath := filepath.Join(buildDir, outputFileName)
if err := cbs.copyFile(binPath, outputPath); err != nil {
return "", fmt.Errorf("failed to copy compiled file: %v", err)
}
// Делаем файл исполняемым
if err := os.Chmod(outputPath, 0755); err != nil {
cbs.appendLog(build, fmt.Sprintf("Warning: failed to set executable permissions: %v\n", err))
}
// Копируем custom.txt рядом с исполняемым файлом
customOutputPath := filepath.Join(buildDir, "custom.txt")
if err := os.WriteFile(customOutputPath, []byte(customConfig), 0644); err != nil {
cbs.appendLog(build, fmt.Sprintf("Warning: failed to copy custom.txt: %v\n", err))
}
cbs.appendLog(build, fmt.Sprintf("Linux client compiled successfully: %s\n", outputFileName))
return outputPath, nil
}
// buildMacOS компилирует клиент для macOS
func (cbs *ClientBuildService) buildMacOS(build *model.ClientBuild, config BuildConfig, buildDir string) (string, error) {
cbs.appendLog(build, "Building macOS client from source...\n")
// Проверяем наличие Rust toolchain
if _, err := exec.LookPath("cargo"); err != nil {
return "", fmt.Errorf("Rust toolchain not found. Please install Rust: https://rustup.rs/")
}
// Определяем целевую архитектуру
target := "x86_64-apple-darwin"
if config.Platform == "macos" {
// Для Apple Silicon
target = "aarch64-apple-darwin"
}
// Создаем директорию для исходников
sourceDir := filepath.Join(buildDir, "rustdesk-source")
if err := os.MkdirAll(sourceDir, 0755); err != nil {
return "", fmt.Errorf("failed to create source directory: %v", err)
}
// Клонируем исходный код
if err := cbs.cloneRustDeskSource(build, config.Version, sourceDir); err != nil {
return "", fmt.Errorf("failed to clone source: %v", err)
}
// Создаем конфигурационный файл custom.txt
var customConfig string
if config.UseCustomConfig && config.CustomConfigFile != "" {
customConfig = config.CustomConfigFile
cbs.appendLog(build, "Using custom configuration file\n")
} else {
customConfig = cbs.generateCustomConfig(config)
cbs.appendLog(build, "Using auto-generated configuration\n")
}
// Записываем custom.txt в исходники
customPath := filepath.Join(sourceDir, "custom.txt")
if err := os.WriteFile(customPath, []byte(customConfig), 0644); err != nil {
return "", fmt.Errorf("failed to write custom.txt: %v", err)
}
// Компилируем
cbs.appendLog(build, fmt.Sprintf("Compiling macOS client with cargo (target: %s)...\n", target))
cmd := exec.Command("cargo", "build", "--release", "--target", target)
cmd.Dir = sourceDir
output, err := cmd.CombinedOutput()
cbs.appendLog(build, string(output))
if err != nil {
return "", fmt.Errorf("build failed: %v", err)
}
// Копируем скомпилированный файл
binPath := filepath.Join(sourceDir, "target", target, "release", "rustdesk")
if _, err := os.Stat(binPath); os.IsNotExist(err) {
return "", fmt.Errorf("compiled file not found: %s", binPath)
}
// Определяем имя выходного файла
outputFileName := config.FileName
if !strings.HasSuffix(strings.ToLower(outputFileName), ".app") && !strings.HasSuffix(strings.ToLower(outputFileName), ".dmg") {
outputFileName = outputFileName + ".app"
}
outputPath := filepath.Join(buildDir, outputFileName)
// Для macOS создаем .app bundle (упрощенная версия)
// В реальности нужно создать полноценный .app bundle, но для начала просто копируем бинарник
if err := cbs.copyFile(binPath, outputPath); err != nil {
return "", fmt.Errorf("failed to copy compiled file: %v", err)
}
// Делаем файл исполняемым
if err := os.Chmod(outputPath, 0755); err != nil {
cbs.appendLog(build, fmt.Sprintf("Warning: failed to set executable permissions: %v\n", err))
}
// Копируем custom.txt рядом с исполняемым файлом
customOutputPath := filepath.Join(buildDir, "custom.txt")
if err := os.WriteFile(customOutputPath, []byte(customConfig), 0644); err != nil {
cbs.appendLog(build, fmt.Sprintf("Warning: failed to copy custom.txt: %v\n", err))
}
cbs.appendLog(build, fmt.Sprintf("macOS client compiled successfully: %s\n", outputFileName))
return outputPath, nil
}
// buildAndroid компилирует клиент для Android
func (cbs *ClientBuildService) buildAndroid(build *model.ClientBuild, config BuildConfig, buildDir string) (string, error) {
cbs.appendLog(build, "Building Android client from source...\n")
// Проверяем наличие Rust toolchain
if _, err := exec.LookPath("cargo"); err != nil {
return "", fmt.Errorf("Rust toolchain not found. Please install Rust: https://rustup.rs/")
}
// Проверяем наличие Android SDK
if os.Getenv("ANDROID_HOME") == "" && os.Getenv("ANDROID_SDK_ROOT") == "" {
return "", fmt.Errorf("Android SDK not found. Please set ANDROID_HOME or ANDROID_SDK_ROOT environment variable")
}
cbs.appendLog(build, "Android compilation requires additional setup (NDK, etc.)\n")
cbs.appendLog(build, "This is a placeholder - full Android build requires Android NDK and Gradle setup\n")
// Для Android компиляция более сложная и требует настройки NDK
// Пока возвращаем ошибку с инструкциями
return "", fmt.Errorf("Android compilation from source requires Android NDK setup. Please refer to RustDesk documentation for Android build instructions")
}
// generateCustomConfig генерирует конфигурационный файл custom.txt
func (cbs *ClientBuildService) generateCustomConfig(config BuildConfig) string {
var lines []string
// Базовые настройки сервера
if config.ServerIP != "" {
lines = append(lines, fmt.Sprintf("rendezvous-server = %s", config.ServerIP))
}
if config.ApiServer != "" {
lines = append(lines, fmt.Sprintf("api-server = %s", config.ApiServer))
}
if config.Key != "" {
lines = append(lines, fmt.Sprintf("key = %s", config.Key))
}
if config.AppName != "" && strings.ToLower(config.AppName) != "rustdesk" {
lines = append(lines, fmt.Sprintf("app-name = %s", config.AppName))
}
// Добавляем дополнительные настройки из custom
for key, value := range config.Custom {
lines = append(lines, fmt.Sprintf("%s = %s", key, value))
}
return strings.Join(lines, "\n")
}
// updateBuildStatus обновляет статус сборки
func (cbs *ClientBuildService) updateBuildStatus(build *model.ClientBuild, status, errorMsg, log string) {
updates := map[string]interface{}{
"status": status,
}
if errorMsg != "" {
updates["error_msg"] = errorMsg
}
if log != "" {
updates["build_log"] = build.BuildLog + "\n" + log
}
DB.Model(build).Updates(updates)
}
// appendLog добавляет строку в лог сборки
func (cbs *ClientBuildService) appendLog(build *model.ClientBuild, logLine string) {
var currentBuild model.ClientBuild
DB.First(&currentBuild, build.Id)
newLog := currentBuild.BuildLog + logLine
DB.Model(build).Update("build_log", newLog)
build.BuildLog = newLog
}
// GetByUuid получает задачу компиляции по UUID
func (cbs *ClientBuildService) GetByUuid(uuid string) *model.ClientBuild {
build := &model.ClientBuild{}
DB.Where("build_uuid = ?", uuid).First(build)
return build
}
// GetByUserId получает список задач компиляции пользователя
func (cbs *ClientBuildService) GetByUserId(userId uint) []*model.ClientBuild {
var builds []*model.ClientBuild
DB.Where("user_id = ?", userId).Order("created_at DESC").Find(&builds)
return builds
}
// List получает список задач компиляции с пагинацией
func (cbs *ClientBuildService) List(page, pageSize uint, scopes ...func(*gorm.DB) *gorm.DB) *model.ClientBuildList {
var builds []*model.ClientBuild
var total int64
query := DB.Model(&model.ClientBuild{})
for _, scope := range scopes {
query = scope(query)
}
query.Count(&total)
query = Paginate(page, pageSize)(query)
query.Order("created_at DESC").Find(&builds)
return &model.ClientBuildList{
ClientBuilds: builds,
Pagination: model.Pagination{
Page: int64(page),
PageSize: int64(pageSize),
Total: total,
},
}
}
// Delete удаляет задачу компиляции
func (cbs *ClientBuildService) Delete(id, userId uint) error {
build := &model.ClientBuild{}
DB.Where("id = ? AND user_id = ?", id, userId).First(build)
if build.Id == 0 {
return fmt.Errorf("build not found")
}
// Удаляем файлы сборки
if build.FilePath != "" {
os.Remove(build.FilePath)
// Удаляем директорию сборки
buildDir := filepath.Dir(build.FilePath)
os.RemoveAll(buildDir)
}
return DB.Delete(build).Error
}
// checkBuildTools проверяет наличие инструментов для сборки
func (cbs *ClientBuildService) checkBuildTools(platform string) error {
switch platform {
case "windows", "linux", "macos":
// Проверяем наличие Rust
if _, err := exec.LookPath("cargo"); err != nil {
return fmt.Errorf("Rust toolchain not found. Please install Rust: https://rustup.rs/")
}
case "android":
// Проверяем наличие Android SDK
if os.Getenv("ANDROID_HOME") == "" {
return fmt.Errorf("Android SDK not found. Please set ANDROID_HOME environment variable")
}
}
return nil
}

232
service/clientConfig.go Normal file
View File

@@ -0,0 +1,232 @@
package service
import (
"archive/zip"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/lejianwen/rustdesk-api/v2/global"
"github.com/lejianwen/rustdesk-api/v2/model"
"github.com/lejianwen/rustdesk-api/v2/utils"
"gorm.io/gorm"
)
type ClientConfigService struct {
}
// GenerateClientConfig генерирует конфигурационный файл клиента
// customConfigFile - опциональный кастомный конфигурационный файл (если пустой, генерируется автоматически)
func (ccs *ClientConfigService) GenerateClientConfig(userId uint, password, description, customConfigFile string) (*model.ClientConfig, error) {
// Создаем директорию для хранения клиентов
clientsDir := filepath.Join(global.Config.Gin.ResourcesPath, "clients")
if err := os.MkdirAll(clientsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create clients directory: %v", err)
}
// Генерируем уникальное имя файла
timestamp := time.Now().Format("20060102150405")
fileName := fmt.Sprintf("rustdesk-client-%d-%s.zip", userId, timestamp)
filePath := filepath.Join(clientsDir, fileName)
// Создаем ZIP архив
zipFile, err := os.Create(filePath)
if err != nil {
return nil, fmt.Errorf("failed to create zip file: %v", err)
}
defer zipFile.Close()
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
// Создаем конфигурационный файл TOML
var configContent string
if customConfigFile != "" {
// Используем кастомный конфигурационный файл
configContent = customConfigFile
} else {
// Генерируем автоматически
configContent = ccs.generateTomlConfig()
}
configFile, err := zipWriter.Create("RustDesk.toml")
if err != nil {
return nil, fmt.Errorf("failed to create config file in zip: %v", err)
}
_, err = configFile.Write([]byte(configContent))
if err != nil {
return nil, fmt.Errorf("failed to write config to zip: %v", err)
}
// Создаем файл с паролем (опционально, для справки)
if password != "" {
passwordFile, err := zipWriter.Create("password.txt")
if err == nil {
passwordFile.Write([]byte(fmt.Sprintf("Password: %s\n", password)))
}
}
// Получаем информацию о файле
fileInfo, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %v", err)
}
// Вычисляем хеш файла
fileHash, err := ccs.calculateFileHash(filePath)
if err != nil {
return nil, fmt.Errorf("failed to calculate file hash: %v", err)
}
// Хешируем пароль для хранения в БД
hashedPassword, err := utils.EncryptPassword(password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %v", err)
}
// Создаем запись в БД
clientConfig := &model.ClientConfig{
UserId: userId,
Password: hashedPassword,
FileName: fileName,
FileHash: fileHash,
FileSize: fileInfo.Size(),
FilePath: filePath,
Description: description,
Status: model.COMMON_STATUS_ENABLE,
}
// Создаем базовую запись
if err := DB.Create(clientConfig).Error; err != nil {
// Удаляем файл в случае ошибки
os.Remove(filePath)
return nil, fmt.Errorf("failed to save client config to database: %v", err)
}
// Обновляем кастомные поля, если колонки существуют
if customConfigFile != "" {
// Проверяем наличие колонки перед обновлением
if DB.Migrator().HasColumn(&model.ClientConfig{}, "custom_config_file") {
DB.Model(clientConfig).Updates(map[string]interface{}{
"custom_config_file": customConfigFile,
"use_custom_config": true,
})
}
}
return clientConfig, nil
}
// generateTomlConfig генерирует TOML конфигурацию для RustDesk клиента
func (ccs *ClientConfigService) generateTomlConfig() string {
// Формируем TOML строку с настройками сервера
tomlStr := fmt.Sprintf(`rendezvous_server = "%s"
relay_server = "%s"
api_server = "%s"
key = "%s"
`, global.Config.Rustdesk.IdServer, global.Config.Rustdesk.RelayServer, global.Config.Rustdesk.ApiServer, global.Config.Rustdesk.Key)
return tomlStr
}
// calculateFileHash вычисляет MD5 хеш файла
func (ccs *ClientConfigService) calculateFileHash(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hash := md5.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GetByUserId получает список клиентов пользователя
func (ccs *ClientConfigService) GetByUserId(userId uint) []*model.ClientConfig {
var configs []*model.ClientConfig
DB.Where("user_id = ? AND status = ?", userId, model.COMMON_STATUS_ENABLE).
Order("created_at DESC").
Find(&configs)
return configs
}
// GetById получает клиент по ID
func (ccs *ClientConfigService) GetById(id uint) *model.ClientConfig {
config := &model.ClientConfig{}
DB.Where("id = ? AND status = ?", id, model.COMMON_STATUS_ENABLE).First(config)
return config
}
// List получает список клиентов с пагинацией
func (ccs *ClientConfigService) List(page, pageSize uint, scopes ...func(*gorm.DB) *gorm.DB) *model.ClientConfigList {
var configs []*model.ClientConfig
var total int64
query := DB.Model(&model.ClientConfig{}).Where("status = ?", model.COMMON_STATUS_ENABLE)
for _, scope := range scopes {
query = scope(query)
}
query.Count(&total)
query = Paginate(page, pageSize)(query)
query.Order("created_at DESC").Find(&configs)
return &model.ClientConfigList{
ClientConfigs: configs,
Pagination: model.Pagination{
Page: int64(page),
PageSize: int64(pageSize),
Total: total,
},
}
}
// Delete удаляет клиент (помечает как удаленный)
func (ccs *ClientConfigService) Delete(id, userId uint) error {
// Ищем запись без фильтра по status, чтобы можно было удалить даже если она уже помечена как удаленная
config := &model.ClientConfig{}
if err := DB.Where("id = ?", id).First(config).Error; err != nil {
return fmt.Errorf("client config not found")
}
// Проверяем, что клиент принадлежит пользователю
if config.UserId != userId {
return fmt.Errorf("access denied")
}
// Если уже удален, просто возвращаем успех
if config.Status == model.COMMON_STATUS_DISABLED {
return nil
}
// Помечаем как удаленный - обновляем только поле status, чтобы избежать проблем с отсутствующими колонками
if err := DB.Model(&model.ClientConfig{}).Where("id = ?", id).Update("status", model.COMMON_STATUS_DISABLED).Error; err != nil {
return err
}
// Удаляем файл
if config.FilePath != "" {
os.Remove(config.FilePath)
}
return nil
}
// VerifyPassword проверяет пароль клиента
func (ccs *ClientConfigService) VerifyPassword(configId uint, password string) bool {
config := ccs.GetById(configId)
if config.Id == 0 {
return false
}
ok, _, err := utils.VerifyPassword(config.Password, password)
return err == nil && ok
}

View File

@@ -24,6 +24,8 @@ type Service struct {
*ServerCmdService
*LdapService
*AppService
*ClientConfigService
*ClientBuildService
}
type Dependencies struct {
@@ -48,7 +50,22 @@ func New(c *config.Config, g *gorm.DB, l *log.Logger, j *jwt.Jwt, lo lock.Locker
Logger = l
Jwt = j
Lock = lo
AllService = new(Service)
AllService = &Service{
UserService: &UserService{},
AddressBookService: &AddressBookService{},
TagService: &TagService{},
PeerService: &PeerService{},
GroupService: &GroupService{},
OauthService: &OauthService{},
LoginLogService: &LoginLogService{},
AuditService: &AuditService{},
ShareRecordService: &ShareRecordService{},
ServerCmdService: &ServerCmdService{},
LdapService: &LdapService{},
AppService: &AppService{},
ClientConfigService: &ClientConfigService{},
ClientBuildService: &ClientBuildService{},
}
return AllService
}