diff --git a/Dockerfile.dev b/Dockerfile.dev index 9f46ef1..2e85140 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -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"] diff --git a/cmd/apimain.go b/cmd/apimain.go index eddefb2..4147370 100644 --- a/cmd/apimain.go +++ b/cmd/apimain.go @@ -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) diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index fcd1a7f..1179b4e 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml index 75f3a47..1b51568 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 \ No newline at end of file diff --git a/docs/CLIENT_BUILD_INTEGRATION.md b/docs/CLIENT_BUILD_INTEGRATION.md new file mode 100644 index 0000000..f08c38c --- /dev/null +++ b/docs/CLIENT_BUILD_INTEGRATION.md @@ -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 +- Убедитесь, что компиляция завершилась успешно +- Проверьте права доступа к директориям + diff --git a/frontend-temp b/frontend-temp new file mode 160000 index 0000000..3998c2a --- /dev/null +++ b/frontend-temp @@ -0,0 +1 @@ +Subproject commit 3998c2a9213fcd047252776d0f0db33e6717026c diff --git a/http/controller/admin/clientBuild.go b/http/controller/admin/clientBuild.go new file mode 100644 index 0000000..4f5e0f0 --- /dev/null +++ b/http/controller/admin/clientBuild.go @@ -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) +} + diff --git a/http/controller/admin/clientBuildUpload.go b/http/controller/admin/clientBuildUpload.go new file mode 100644 index 0000000..0fbf55d --- /dev/null +++ b/http/controller/admin/clientBuildUpload.go @@ -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.", + }) +} + diff --git a/http/controller/admin/clientConfig.go b/http/controller/admin/clientConfig.go new file mode 100644 index 0000000..973d6fd --- /dev/null +++ b/http/controller/admin/clientConfig.go @@ -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) +} + diff --git a/http/request/admin/clientBuild.go b/http/request/admin/clientBuild.go new file mode 100644 index 0000000..52ebffc --- /dev/null +++ b/http/request/admin/clientBuild.go @@ -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 для скачивания, если готово +} + diff --git a/http/request/admin/clientConfig.go b/http/request/admin/clientConfig.go new file mode 100644 index 0000000..00e2a95 --- /dev/null +++ b/http/request/admin/clientConfig.go @@ -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"` +} + diff --git a/http/router/admin.go b/http/router/admin.go index 2cd01de..b3f43f9 100644 --- a/http/router/admin.go +++ b/http/router/admin.go @@ -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) + } +} diff --git a/model/clientBuild.go b/model/clientBuild.go new file mode 100644 index 0000000..127170a --- /dev/null +++ b/model/clientBuild.go @@ -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 +} + diff --git a/model/clientConfig.go b/model/clientConfig.go new file mode 100644 index 0000000..d638dd2 --- /dev/null +++ b/model/clientConfig.go @@ -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 +} + diff --git a/service/clientBuild.go b/service/clientBuild.go new file mode 100644 index 0000000..3e749bb --- /dev/null +++ b/service/clientBuild.go @@ -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(¤tBuild, 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 +} + diff --git a/service/clientConfig.go b/service/clientConfig.go new file mode 100644 index 0000000..1ac0c68 --- /dev/null +++ b/service/clientConfig.go @@ -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 +} + diff --git a/service/service.go b/service/service.go index b5fb1bf..1d53014 100644 --- a/service/service.go +++ b/service/service.go @@ -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 }