Compare commits

...

10 Commits

Author SHA1 Message Date
shaytan
d158e85d53 Add local client compilation from source, build logs viewer, and submodules support 2025-11-10 17:55:03 +11:00
lejianwen
c5687e1506 docs: Removed webclient2 because of DMCA 2025-09-29 09:41:07 +08:00
lejianwen
222792419f feat!: Removed webclient2 because of DMCA 2025-09-28 20:08:28 +08:00
lejianwen
04789e08d5 feat!: Removed webclient2 because of DMCA 2025-09-27 11:30:37 +08:00
lejianwen
929f6ddf20 feat: WebClient 1.4.2 2025-09-12 15:11:49 +08:00
nomoneynolife
6e3b16d425 feat:ldap allow-group (#388) 2025-09-05 12:53:45 +08:00
Tom
ce0291a8f9 feat: add TLS configuration option for MySQL (#384) 2025-09-03 19:43:16 +08:00
lejianwen
faee954f6d docs: readme 2025-09-01 21:36:27 +08:00
lejianwen
f09c898f16 feat(peer): add alias field and support filtering by alias 2025-08-31 13:39:22 +08:00
lejianwen
a7c087afbb fix!: Update peer to use ID instead of UUID 2025-08-31 12:46:54 +08:00
31 changed files with 1964 additions and 26 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

@@ -2,7 +2,8 @@
[English Doc](README_EN.md)
本项目使用 Go 实现了 RustDesk 的 API并包含了 Web Admin 和 Web 客户端。RustDesk 是一个远程桌面软件,提供了自托管的解决方案。
本项目使用 Go 实现了 RustDesk 的 API并包含了 Web Admin 和 Web 客户端。
<div align=center>
<img src="https://img.shields.io/badge/golang-1.22-blue"/>
@@ -13,6 +14,14 @@
<img src="https://github.com/lejianwen/rustdesk-api/actions/workflows/build.yml/badge.svg"/>
</div>
## 搭配[lejianwen/rustdesk-server]使用更佳。
> [lejianwen/rustdesk-server]fork自RustDesk Server官方仓库
> 1. 解决了使用API链接超时问题
> 2. 可以强制登录后才能发起链接
> 3. 支持客户端websocket
# 特性
- PC端API
@@ -45,7 +54,6 @@
- 自动获取ID服务器和KEY
- 自动获取地址簿
- 游客通过临时分享链接直接远程到设备
- v2 Preview
- CLI
- 重置管理员密码
@@ -118,9 +126,6 @@
2. 如果没登录后台点击右上角登录即可api server已经自动配置好了
3. 登录后会自动同步ID服务器和KEY
4. 登录后会将地址簿自动保存到web client中方便使用
5. 现已支持`v2 Preview`,访问路径是`/webclient2`
![webclientv2](./docs/webclientv2.png)
6. `v2 preview` 部署,参考[WIKI](https://github.com/lejianwen/rustdesk-api/wiki)
### 自动化文档: 使用 Swag 生成 API 文档,方便开发者理解和使用 API。
@@ -182,6 +187,7 @@
| RUSTDESK_API_MYSQL_PASSWORD | mysql密码 | 111111 |
| RUSTDESK_API_MYSQL_ADDR | mysql地址 | 192.168.1.66:3306 |
| RUSTDESK_API_MYSQL_DBNAME | mysql数据库名 | rustdesk |
| RUSTDESK_API_MYSQL_TLS | 是否启用TLS, 可选值: `true`, `false`, `skip-verify`, `custom` | `false` |
| -----RUSTDESK配置----- | ---------- | ---------- |
| RUSTDESK_API_RUSTDESK_ID_SERVER | Rustdesk的id服务器地址 | 192.168.1.66:21116 |
| RUSTDESK_API_RUSTDESK_RELAY_SERVER | Rustdesk的relay服务器地址 | 192.168.1.66:21117 |
@@ -325,3 +331,5 @@
</a>
## 感谢你的支持!如果这个项目对你有帮助,请点个⭐️鼓励一下,谢谢!
[lejianwen/rustdesk-server]: https://github.com/lejianwen/rustdesk-server

View File

@@ -12,6 +12,13 @@ desktop software that provides self-hosted solutions.
<img src="https://github.com/lejianwen/rustdesk-api/actions/workflows/build.yml/badge.svg"/>
</div>
## Better used with [lejianwen/rustdesk-server].
> [lejianwen/rustdesk-server] is a fork of the official RustDesk Server repository.
> 1. Solves the API connection timeout issue.
> 2. Can enforce login before initiating a connection.
> 3. Supports client websocket.
# Features
- PC API
@@ -118,9 +125,6 @@ displaying data.Frontend code is available at [rustdesk-api-web](https://github.
pre-configured.
3. After logging in, the ID server and key will be automatically synced.
4. The address book will also be automatically saved to the web client for convenient use.
5. Now supports `v2 Preview`, accessible at `/webclient2`
![webclientv2](./docs/webclientv2.png)
6. `v2 preview` deployment, [WIKI](https://github.com/lejianwen/rustdesk-api/wiki)
### Automated Documentation : API documentation is generated using Swag, making it easier for developers to understand and use the API.
@@ -181,6 +185,7 @@ The table below does not list all configurations. Please refer to the configurat
| RUSTDESK_API_MYSQL_PASSWORD | MySQL password | 111111 |
| RUSTDESK_API_MYSQL_ADDR | MySQL address | 192.168.1.66:3306 |
| RUSTDESK_API_MYSQL_DBNAME | MySQL database name | rustdesk |
| RUSTDESK_API_MYSQL_TLS | Whether to enable TLS, optional values: `true`, `false`, `skip-verify`, `custom` | `false` |
| ----- RUSTDESK Configuration ----- | --------------------------------------- | ----------------------------- |
| RUSTDESK_API_RUSTDESK_ID_SERVER | Rustdesk ID server address | 192.168.1.66:21116 |
| RUSTDESK_API_RUSTDESK_RELAY_SERVER | Rustdesk relay server address | 192.168.1.66:21117 |
@@ -325,3 +330,6 @@ Thanks to everyone who contributed!
</a>
## Thanks for your support! If you find this project useful, please give it a ⭐️. Thank you!
[lejianwen/rustdesk-server]: https://github.com/lejianwen/rustdesk-server

View File

@@ -23,7 +23,7 @@ import (
"github.com/spf13/cobra"
)
const DatabaseVersion = 264
const DatabaseVersion = 265
// @title 管理系统API
// @version 1.0
@@ -145,11 +145,12 @@ func InitGlobal() {
//gorm
if global.Config.Gorm.Type == config.TypeMysql {
dsn := fmt.Sprintf("%s:%s@(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
dsn := fmt.Sprintf("%s:%s@(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&tls=%s",
global.Config.Mysql.Username,
global.Config.Mysql.Password,
global.Config.Mysql.Addr,
global.Config.Mysql.Dbname,
global.Config.Mysql.Tls,
)
global.DB = orm.NewMysql(&orm.MysqlConfig{
@@ -282,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)
}
}
}
}
}
@@ -305,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

@@ -31,6 +31,7 @@ mysql:
password: ""
addr: ""
dbname: ""
tls: "false" # true / false / skip-verify / custom
postgresql:
host: "127.0.0.1"
@@ -80,4 +81,4 @@ ldap:
last-name: "sn"
sync: false # If true, the user will be synchronized to the database when the user logs in. If false, the user will be synchronized to the database when the user be created.
admin-group: "cn=admin,dc=example,dc=com" # The group name of the admin group, if the user is in this group, the user will be an admin.
allow-group: "cn=users,dc=example,dc=com" # The group name of the users group, if the user is in this group, the user will be an login.

View File

@@ -17,6 +17,7 @@ type Mysql struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Dbname string `mapstructure:"dbname"`
Tls string `mapstructure:"tls"` // true / false / skip-verify / custom
}
type Postgresql struct {

View File

@@ -11,6 +11,7 @@ type LdapUser struct {
LastName string `mapstructure:"last-name"`
Sync bool `mapstructure:"sync"` // Will sync the user's information to the internal database
AdminGroup string `mapstructure:"admin-group"` // Which group is the admin group
AllowGroup string `mapstructure:"allow-group"` // Which group is allowed to login
}
// type LdapGroup struct {

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

2
go.mod
View File

@@ -25,6 +25,7 @@ require (
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.3
golang.org/x/crypto v0.33.0
golang.org/x/oauth2 v0.23.0
golang.org/x/text v0.22.0
gorm.io/driver/mysql v1.5.7
@@ -84,7 +85,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.9 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/image v0.13.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.11.0 // indirect

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

@@ -114,6 +114,9 @@ func (ct *Peer) List(c *gin.Context) {
if query.Ip != "" {
tx.Where("last_online_ip like ?", "%"+query.Ip+"%")
}
if query.Alias != "" {
tx.Where("alias like ?", "%"+query.Alias+"%")
}
})
response.Success(c, res)
}

View File

@@ -49,7 +49,7 @@ func (i *Index) Heartbeat(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{})
return
}
peer := service.AllService.PeerService.FindByUuid(info.Uuid)
peer := service.AllService.PeerService.FindById(info.Id)
if peer == nil || peer.RowId == 0 {
c.JSON(http.StatusOK, gin.H{})
return

View File

@@ -80,7 +80,8 @@ func (o *Oauth) OidcAuthQueryPre(c *gin.Context) (*model.User, *model.UserToken)
// 如果 UserId 为 0说明还在授权中
if v.UserId == 0 {
c.JSON(http.StatusOK, gin.H{"message": "Authorization in progress, please login and bind"})
//fix: 1.4.2 webclient oidc
c.JSON(http.StatusOK, gin.H{"message": "Authorization in progress, please login and bind", "error": "No authed oidc is found"})
return nil, nil
}

View File

@@ -31,10 +31,10 @@ func (p *Peer) SysInfo(c *gin.Context) {
return
}
fpe := f.ToPeer()
pe := service.AllService.PeerService.FindByUuid(f.Uuid)
pe := service.AllService.PeerService.FindById(f.Id)
if pe.RowId == 0 {
pe = f.ToPeer()
pe.UserId = service.AllService.UserService.FindLatestUserIdFromLoginLogByUuid(pe.Uuid)
pe.UserId = service.AllService.UserService.FindLatestUserIdFromLoginLogByUuid(pe.Uuid, pe.Id)
err = service.AllService.PeerService.Create(pe)
if err != nil {
response.Error(c, response.TranslateMsg(c, "OperationFailed")+err.Error())
@@ -42,7 +42,7 @@ func (p *Peer) SysInfo(c *gin.Context) {
}
} else {
if pe.UserId == 0 {
pe.UserId = service.AllService.UserService.FindLatestUserIdFromLoginLogByUuid(pe.Uuid)
pe.UserId = service.AllService.UserService.FindLatestUserIdFromLoginLogByUuid(pe.Uuid, pe.Id)
}
fpe.RowId = pe.RowId
fpe.UserId = pe.UserId

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

@@ -13,6 +13,7 @@ type PeerForm struct {
Uuid string `json:"uuid"`
Version string `json:"version"`
GroupId uint `json:"group_id"`
Alias string `json:"alias"`
}
type PeerBatchDeleteForm struct {
@@ -32,6 +33,7 @@ func (f *PeerForm) ToPeer() *model.Peer {
Uuid: f.Uuid,
Version: f.Version,
GroupId: f.GroupId,
Alias: f.Alias,
}
}
@@ -43,6 +45,7 @@ type PeerQuery struct {
Uuids string `json:"uuids" form:"uuids"`
Ip string `json:"ip" form:"ip"`
Username string `json:"username" form:"username"`
Alias string `json:"alias" form:"alias"`
}
type SimpleDataQuery struct {

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
}

View File

@@ -15,6 +15,7 @@ type Peer struct {
LastOnlineTime int64 `json:"last_online_time" gorm:"default:0;not null;"`
LastOnlineIp string `json:"last_online_ip" gorm:"default:'';not null;"`
GroupId uint `json:"group_id" gorm:"default:0;not null;index"`
Alias string `json:"alias" gorm:"default:'';not null;index"`
TimeModel
}

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

@@ -137,6 +137,17 @@ func (ls *LdapService) Authenticate(username, password string) (*model.User, err
return nil, ErrLdapUserDisabled
}
cfg := &Config.Ldap
// Skip allow-group check for admins
isAdmin := ls.isUserAdmin(cfg, ldapUser)
// non-admins only check if allow-group is configured
if !isAdmin && cfg.User.AllowGroup != "" {
if !ls.isUserInGroup(cfg, ldapUser, cfg.User.AllowGroup) {
return nil, errors.New("user not in allowed group")
}
}
err = ls.verifyCredentials(cfg, ldapUser.Dn, password)
if err != nil {
return nil, err
@@ -148,6 +159,46 @@ func (ls *LdapService) Authenticate(username, password string) (*model.User, err
return user, nil
}
// isUserInGroup checks if the user is a member of the specified group. by_sw
func (ls *LdapService) isUserInGroup(cfg *config.Ldap, ldapUser *LdapUser, groupDN string) bool {
// Check "memberOf" directly
if len(ldapUser.MemberOf) > 0 {
for _, group := range ldapUser.MemberOf {
if strings.EqualFold(group, groupDN) {
return true
}
}
}
// For "member" attribute, perform a reverse search on the group
member := "member"
userDN := ldap.EscapeFilter(ldapUser.Dn)
groupDN = ldap.EscapeFilter(groupDN)
groupFilter := fmt.Sprintf("(%s=%s)", member, userDN)
// Create the LDAP search request
groupSearchRequest := ldap.NewSearchRequest(
groupDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, // Unlimited search results
0, // No time limit
false, // Return both attributes and DN
groupFilter,
[]string{"dn"},
nil,
)
// Perform the group search
groupResult, err := ls.searchResult(cfg, groupSearchRequest)
if err != nil {
return false
}
// If any results are returned, the user is part of the group
return len(groupResult.Entries) > 0
}
// mapToLocalUser checks whether the user exists locally; if not, creates one.
// If the user exists and Ldap.Sync is enabled, it updates local info.
func (ls *LdapService) mapToLocalUser(cfg *config.Ldap, lu *LdapUser) (*model.User, error) {

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
}

View File

@@ -395,10 +395,10 @@ func (us *UserService) UserThirdInfo(userId uint, op string) *model.UserThird {
return ut
}
// FindLatestUserIdFromLoginLogByUuid 根据uuid查找最后登录的用户id
func (us *UserService) FindLatestUserIdFromLoginLogByUuid(uuid string) uint {
// FindLatestUserIdFromLoginLogByUuid 根据uuid和设备id查找最后登录的用户id
func (us *UserService) FindLatestUserIdFromLoginLogByUuid(uuid string, deviceId string) uint {
llog := &model.LoginLog{}
DB.Where("uuid = ?", uuid).Order("id desc").First(llog)
DB.Where("uuid = ? and device_id = ?", uuid, deviceId).Order("id desc").First(llog)
return llog.UserId
}