Compare commits

...

57 Commits

Author SHA1 Message Date
lejianwen
e318f1fd58 feat: WebClient 1.4.2 2025-09-12 15:11:49 +08:00
nomoneynolife
ee19eb9729 feat:ldap allow-group (#388) 2025-09-05 12:53:45 +08:00
Tom
fcdea1ad6d feat: add TLS configuration option for MySQL (#384) 2025-09-03 19:43:16 +08:00
lejianwen
c88d8cc359 docs: readme 2025-09-01 21:36:27 +08:00
lejianwen
ade6e6355a feat(peer): add alias field and support filtering by alias 2025-08-31 13:39:22 +08:00
lejianwen
9b769b99dc fix!: Update peer to use ID instead of UUID 2025-08-31 12:46:54 +08:00
lejianwen
c14c4d478b fix: The callback URL is based on the configured API SERVER because the project might be behind an Nginx reverse proxy. If the Origin/Host is forgotten to configure the reverse proxy, it will be incorrect 2025-08-10 15:48:11 +08:00
lejianwen
9d08c61390 fix: Normal user can not change the name of their own address book (#341) 2025-08-10 11:17:51 +08:00
Tao Chen
6f092472b1 feat: Optimize login workflow (#345)
* add "disable_pwd" and "auto_oidc" at /admin/login-options

* fix: build RedirectURL by host and scheme, not Origin
2025-07-31 10:46:11 +08:00
caicob
4876746f7a docs: README_EN.md (#340) 2025-07-31 10:42:56 +08:00
startgo
05d2d1642a feat: Update zh_TW.toml (#322)
Corrected translation to match Taiwanese Traditional Chinese usage
2025-07-19 21:08:40 +08:00
lejianwen
59fdd6424b feat(captcha): The captcha generates uppercase letter images, but it can only recognize them as lowercase (#319) 2025-07-14 20:36:33 +08:00
lejianwen
0feee5115f fix: Oauth callback url is fixed to host+/api/oidc/callback (#314) 2025-07-11 09:55:48 +08:00
Plynksiy Nikita
65f0a9e3cf docs: add note about requiring conf and resources directories (or specifying paths via -c and RUSTDESK_API_GIN_RESOURCES_PATH) when running without docker (#311) 2025-07-09 09:43:56 +08:00
lejianwen
77836a4e56 feat(user): add remark field to User model and form (#307) 2025-07-08 12:14:46 +08:00
lejianwen
09f8316bf1 feat: Update database version constant to 264 2025-07-08 12:13:31 +08:00
k3-cat
c52706e621 feat: Improve oauth redirect (#303)
* fix: redirects after oauth can potentially misalign with server's actually hostname

* feat: remove `RedirectURL` from oauth config, as it should checked by provider rather than client

* feat: align oauth endpoint with the hostname in requests
2025-07-04 14:30:07 +08:00
k3-cat
17dcff4f43 feat: better autorenewal (#305) 2025-07-04 14:27:47 +08:00
Plynksiy Nikita
0b39c4e104 feat(password): Password hashing with bcrypt (#290)
* feat(password): add configurable password hashing with md5 and bcrypt

* docs: add password hashing algorithm configuration (bcrypt/md5)

* feat(password): better bcrypt fallback and minor refactoring

* feat(password): handle errors in password encryption and verification

* feat(password): remove password hashing algorithm configuration
2025-06-24 17:23:36 +08:00
Plynksiy Nikita
ee176b314e feat: Use crypto/rand for secure random string generation (#293) 2025-06-18 20:47:24 +08:00
Plynksiy Nikita
1ffc9c4a5b fix: correct typo in build arg FRONTEND_GIT_REPO (#292) 2025-06-18 20:42:13 +08:00
Plynksiy Nikita
1257246552 feat(i18n): replace hardcoded messages with translated strings (#289) 2025-06-17 09:05:10 +08:00
lejianwen
2948eaaa5c chore: Update Go version to 1.23 in build configurations 2025-06-16 15:41:16 +08:00
lejianwen
8641ba5c0c docs: Update swagger docs 2025-06-16 12:31:48 +08:00
lejianwen
60b7a18fe7 feat: Add PostgreSQL support and refactor MySQL DSN handling (#284) 2025-06-16 12:26:08 +08:00
lejianwen
ca068816ae feat: Add start time in /api/sysinfover 2025-06-16 12:23:48 +08:00
lejianwen
06648d9a6c fix(admin): Use admin-hello first
(#274) (#255)
2025-06-15 15:33:12 +08:00
puyujian
8a8abd5163 feat(oauth): 支持linux.do登录 (#280)
* 支持linux.do登录

* 修正
2025-06-15 15:32:20 +08:00
lejianwen
97f98cd6ce chore: update download links for musl cross-compilers 2025-06-05 12:14:17 +08:00
lejianwen
51f2920661 fix: Init sqlite fail(#266) 2025-06-04 09:31:43 +08:00
lejianwen
7a5d141ce8 fix(server): Port custom (#257) 2025-05-30 12:27:37 +08:00
lejianwen
3cef02a0bb fix(webclient): Peer online status 2025-05-29 18:51:37 +08:00
lejianwen
46a7ecc1ba fix: Captcha some problem when users login with same ip 2025-05-27 17:36:20 +08:00
lejianwen
4d2b037f5e docs: Readme 2025-05-25 17:44:29 +08:00
lejianwen
323364b24e feat(register): Register status can be set (#223) 2025-05-25 17:03:13 +08:00
lejianwen
f19109cdf8 feat(login): Captcha upgrade and add the function to ban IP addresses (#250) 2025-05-25 16:52:58 +08:00
Tao Chen
527260d60a fix: dn should be case-insensitive (#250) 2025-05-21 09:07:08 +08:00
lejianwen
46bb44f0ab fix(webclient): DefaultIdServerPort undefined (#238) 2025-05-16 20:14:36 +08:00
lejianwen
2f1380f24a fix(webclient): Remove license warning (#235) 2025-05-13 13:11:19 +08:00
lejianwen
ece3328e94 feat(webclient): Web client to 1.4.0 2025-05-12 20:16:08 +08:00
lejianwen
fdd26d87be fix: PageSize (#225) 2025-05-06 19:08:18 +08:00
lejianwen
2ade0dda42 chore: Noelware/docker-manifest-action 2025-04-25 16:20:36 +08:00
lejianwen
a87ae5cf65 chore: Noelware/docker-manifest-action 2025-04-25 14:34:45 +08:00
lejianwen
fe7b8b53a6 style: Oauth page languages 2025-04-24 21:52:43 +08:00
lejianwen
b929f3efdb style: Remove useless configurations 2025-04-15 10:52:46 +08:00
lejianwen
f847fc076f fix: Low case (#149) 2025-04-15 10:46:21 +08:00
lejianwen
60d0a701ce fix: Share pwd 2025-04-15 10:09:56 +08:00
lejianwen
0dedaf6824 feat: Peer share to group 2025-04-14 19:12:40 +08:00
lejianwen
ab231b3fed feat: Add SysInfoVer endpoint and AppService for version retrieval 2025-04-07 16:38:21 +08:00
lejianwen
e7f28cca36 fix: Update peer based on the UUID (#180) 2025-04-02 09:50:16 +08:00
lejianwen
505e8aac4b feat: Add Korean translations validator (#168) 2025-04-02 09:42:29 +08:00
lejianwen
746e2a6052 fix: Get Uuids 2025-03-15 21:02:47 +08:00
lejianwen
dc03d5d83d style: Update peer last online time logic (#173) 2025-03-15 21:02:08 +08:00
lejianwen
b770ab178d feat(admin): Add filter by ip and username (#172) 2025-03-15 19:49:49 +08:00
Tao Chen
fd7e022e88 fix: rm varify password accidentally (#176) 2025-03-15 19:40:02 +08:00
lejianwen
ac5df6826b fix: Init database err (#166) 2025-03-04 18:14:25 +08:00
lejianwen
91908859bc docs: Readme 2025-03-04 16:30:12 +08:00
90 changed files with 114199 additions and 99297 deletions

View File

@@ -66,7 +66,7 @@ jobs:
- name: Set up Go environment
uses: actions/setup-go@v4
with:
go-version: '1.22' # 选择 Go 版本
go-version: '1.23' # 选择 Go 版本
- name: Set up npm
uses: actions/setup-node@v2
@@ -115,12 +115,12 @@ jobs:
zip -r ${{ matrix.job.goos}}-${{ matrix.job.platform }}.${{matrix.job.file_ext}} ./release
else
if [ "${{ matrix.job.platform }}" = "arm64" ]; then
wget https://musl.cc/aarch64-linux-musl-cross.tgz
wget https://musl.ljw.red/aarch64-linux-musl-cross.tgz
tar -xf aarch64-linux-musl-cross.tgz
export PATH=$PATH:$PWD/aarch64-linux-musl-cross/bin
GOOS=${{ matrix.job.goos }} GOARCH=${{ matrix.job.platform }} CC=aarch64-linux-musl-gcc CGO_LDFLAGS="-static" CGO_ENABLED=1 go build -ldflags "-s -w" -o ./release/apimain ./cmd/apimain.go
elif [ "${{ matrix.job.platform }}" = "armv7l" ]; then
wget https://musl.cc/armv7l-linux-musleabihf-cross.tgz
wget https://musl.ljw.red/armv7l-linux-musleabihf-cross.tgz
tar -xf armv7l-linux-musleabihf-cross.tgz
export PATH=$PATH:$PWD/armv7l-linux-musleabihf-cross/bin
GOOS=${{ matrix.job.goos }} GOARCH=arm GOARM=7 CC=armv7l-linux-musleabihf-gcc CGO_LDFLAGS="-static" CGO_ENABLED=1 go build -ldflags "-s -w" -o ./release/apimain ./cmd/apimain.go
@@ -147,6 +147,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate Changelog
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
run: npx changelogithub # or changelogithub@0.12 if ensure the stable result
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
@@ -380,7 +381,7 @@ jobs:
- name: Create and push manifest Docker Hub (:version)
if: ${{ env.SKIP_DOCKER_HUB == 'false' }}
uses: Noelware/docker-manifest-action@master
uses: Noelware/docker-manifest-action@v0.2.3
with:
base-image: ${{ env.BASE_IMAGE_NAMESPACE }}/rustdesk-api:${{ env.TAG }}
extra-images: ${{ env.DOCKERHUB_IMAGE_NAMESPACE }}/rustdesk-api:${{ env.TAG }}-amd64,
@@ -390,7 +391,7 @@ jobs:
- name: Create and push manifest GHCR (:version)
if: ${{ env.SKIP_GHCR == 'false' }}
uses: Noelware/docker-manifest-action@master
uses: Noelware/docker-manifest-action@v0.2.3
with:
base-image: ghcr.io/${{ env.BASE_IMAGE_NAMESPACE }}/rustdesk-api:${{ env.TAG }}
extra-images: ghcr.io/${{ env.GHCR_IMAGE_NAMESPACE }}/rustdesk-api:${{ env.TAG }}-amd64,
@@ -401,7 +402,7 @@ jobs:
- name: Create and push manifest Docker Hub (:latest)
if: ${{ env.SKIP_DOCKER_HUB == 'false' }}
uses: Noelware/docker-manifest-action@master
uses: Noelware/docker-manifest-action@v0.2.3
with:
base-image: ${{ env.BASE_IMAGE_NAMESPACE }}/rustdesk-api:latest
extra-images: ${{ env.DOCKERHUB_IMAGE_NAMESPACE }}/rustdesk-api:latest-amd64,
@@ -411,7 +412,7 @@ jobs:
- name: Create and push manifest GHCR (:latest)
if: ${{ env.SKIP_GHCR == 'false' }}
uses: Noelware/docker-manifest-action@master
uses: Noelware/docker-manifest-action@v0.2.3
with:
base-image: ghcr.io/${{ env.BASE_IMAGE_NAMESPACE }}/rustdesk-api:latest
extra-images: ghcr.io/${{ env.GHCR_IMAGE_NAMESPACE }}/rustdesk-api:latest-amd64,
@@ -422,7 +423,7 @@ jobs:
- name: Create and push Full S6 manifest Docker Hub (:version)
if: ${{ env.SKIP_DOCKER_HUB == 'false' }}
uses: Noelware/docker-manifest-action@master
uses: Noelware/docker-manifest-action@v0.2.3
with:
base-image: ${{ env.BASE_IMAGE_NAMESPACE }}/rustdesk-api:full-s6
extra-images: ${{ env.DOCKERHUB_IMAGE_NAMESPACE }}/rustdesk-api:full-s6-amd64,
@@ -433,7 +434,7 @@ jobs:
- name: Create and push Full S6 manifest GHCR (:latest)
if: ${{ env.SKIP_GHCR == 'false' }}
uses: Noelware/docker-manifest-action@master
uses: Noelware/docker-manifest-action@v0.2.3
with:
base-image: ghcr.io/${{ env.BASE_IMAGE_NAMESPACE }}/rustdesk-api:full-s6
extra-images: ghcr.io/${{ env.GHCR_IMAGE_NAMESPACE }}/rustdesk-api:full-s6-amd64,

View File

@@ -61,7 +61,7 @@ jobs:
- name: Set up Go environment
uses: actions/setup-go@v4
with:
go-version: '1.22' # 选择 Go 版本
go-version: '1.23' # 选择 Go 版本
- name: Set up npm
uses: actions/setup-node@v2
@@ -101,12 +101,12 @@ jobs:
zip -r ${{ matrix.job.goos}}-${{ matrix.job.platform }}.${{matrix.job.file_ext}} ./release
else
if [ "${{ matrix.job.platform }}" = "arm64" ]; then
wget https://musl.cc/aarch64-linux-musl-cross.tgz
wget https://musl.ljw.red/aarch64-linux-musl-cross.tgz
tar -xf aarch64-linux-musl-cross.tgz
export PATH=$PATH:$PWD/aarch64-linux-musl-cross/bin
GOOS=${{ matrix.job.goos }} GOARCH=${{ matrix.job.platform }} CC=aarch64-linux-musl-gcc CGO_LDFLAGS="-static" CGO_ENABLED=1 go build -ldflags "-s -w" -o ./release/apimain ./cmd/apimain.go
elif [ "${{ matrix.job.platform }}" = "armv7l" ]; then
wget https://musl.cc/armv7l-linux-musleabihf-cross.tgz
wget https://musl.ljw.red/armv7l-linux-musleabihf-cross.tgz
tar -xf armv7l-linux-musleabihf-cross.tgz
export PATH=$PATH:$PWD/armv7l-linux-musleabihf-cross/bin
GOOS=${{ matrix.job.goos }} GOARCH=arm GOARM=7 CC=armv7l-linux-musleabihf-gcc CGO_LDFLAGS="-static" CGO_ENABLED=1 go build -ldflags "-s -w" -o ./release/apimain ./cmd/apimain.go
@@ -317,7 +317,7 @@ jobs:
- name: Create and push manifest Docker Hub (:version)
if: ${{ env.SKIP_DOCKER_HUB == 'false' }}
uses: Noelware/docker-manifest-action@master
uses: Noelware/docker-manifest-action@v0.2.3
with:
base-image: ${{ env.BASE_IMAGE_NAMESPACE }}/rustdesk-api:${{ env.TAG }}
extra-images: ${{ env.DOCKERHUB_IMAGE_NAMESPACE }}/rustdesk-api:${{ env.TAG }}-amd64,
@@ -327,7 +327,7 @@ jobs:
- name: Create and push manifest GHCR (:version)
if: ${{ env.SKIP_GHCR == 'false' }}
uses: Noelware/docker-manifest-action@master
uses: Noelware/docker-manifest-action@v0.2.3
with:
base-image: ghcr.io/${{ env.BASE_IMAGE_NAMESPACE }}/rustdesk-api:${{ env.TAG }}
extra-images: ghcr.io/${{ env.GHCR_IMAGE_NAMESPACE }}/rustdesk-api:${{ env.TAG }}-amd64,

2
.gitignore vendored
View File

@@ -5,4 +5,4 @@ runtime/*
go.sum
resources/admin
release
data
data/rustdeskapi.db

View File

@@ -42,11 +42,11 @@ RUN if [ "$COUNTRY" = "CN" ] ; then \
fi && \
apk update && apk add --no-cache git
ARG FREONTEND_GIT_REPO=https://github.com/lejianwen/rustdesk-api-web.git
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 $FREONTEND_GIT_REPO .
RUN git clone -b $FRONTEND_GIT_BRANCH $FRONTEND_GIT_REPO .
# Install required tools without caching index to minimize image size
RUN if [ "$COUNTRY" = "CN" ] ; then \
@@ -76,7 +76,6 @@ COPY --from=builder-backend /app/release /app/
COPY --from=builder-backend /app/conf /app/conf/
COPY --from=builder-backend /app/resources /app/resources/
COPY --from=builder-backend /app/docs /app/docs/
COPY --from=builder-backend /app/http/templates /app/http/templates
# Copy frontend build from builder2 stage
COPY --from=builder-admin-frontend /frontend/dist/ /app/resources/admin/
@@ -92,4 +91,4 @@ VOLUME /app/data
EXPOSE 21114
# Define the command to run the application
CMD ["./apimain"]
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
@@ -94,8 +103,8 @@
- 对于`OIDC`, `Issuer`是必须的。`Scopes`是可选的,默认为 `openid,profile,email`. 确保可以获取 `sub`,`email``preferred_username`
- `github oauth app``Settings`->`Developer settings`->`OAuth Apps`->`New OAuth App`
中创建,地址 [https://github.com/settings/developers](https://github.com/settings/developers)
- `Authorization callback URL`填写`http://<your server[:port]>/api/oauth/callback`
,比如`http://127.0.0.1:21114/api/oauth/callback`
- `Authorization callback URL`填写`http://<your server[:port]>/api/oidc/callback`
,比如`http://127.0.0.1:21114/api/oidc/callback`
7. 登录日志
8. 链接日志
9. 文件传输日志
@@ -163,6 +172,9 @@
| RUSTDESK_API_APP_SHOW_SWAGGER | 是否可见swagger文档;`1`显示,`0`不显示,默认`0`不显示 | `1` |
| RUSTDESK_API_APP_TOKEN_EXPIRE | token有效时长 | `168h` |
| RUSTDESK_API_APP_DISABLE_PWD_LOGIN | 是否禁用密码登录; `true`, `false` 默认`false` | `false` |
| RUSTDESK_API_APP_REGISTER_STATUS | 注册用户默认状态; 1 启用2 禁用, 默认 1 | `1` |
| RUSTDESK_API_APP_CAPTCHA_THRESHOLD | 验证码触发次数; -1 不启用, 0 一直启用, >0 登录错误次数后启用 ;默认 `3` | `3` |
| RUSTDESK_API_APP_BAN_THRESHOLD | 封禁IP触发次数; 0 不启用, >0 登录错误次数后封禁IP; 默认 `0` | `0` |
| -----ADMIN配置----- | ---------- | ---------- |
| RUSTDESK_API_ADMIN_TITLE | 后台标题 | `RustDesk Api Admin` |
| RUSTDESK_API_ADMIN_HELLO | 后台欢迎语,可以使用`html` | |
@@ -179,6 +191,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 |
@@ -186,7 +199,7 @@
| RUSTDESK_API_RUSTDESK_KEY | Rustdesk的key | 123456789 |
| RUSTDESK_API_RUSTDESK_KEY_FILE | Rustdesk存放key的文件 | `./conf/data/id_ed25519.pub` |
| RUSTDESK_API_RUSTDESK_WEBCLIENT<br/>_MAGIC_QUERYONLINE | Web client v2 中是否启用新的在线状态查询方法; `1`:启用,`0`:不启用,默认不启用 | `0` |
| RUSTDESK_API_RUSTDESK_WS_HOST | 自定义Websocket Host | |
| RUSTDESK_API_RUSTDESK_WS_HOST | 自定义Websocket Host | `wss://192.168.1.123:1234` |
| ----PROXY配置----- | ---------- | ---------- |
| RUSTDESK_API_PROXY_ENABLE | 是否启用代理:`false`, `true` | `false` |
| RUSTDESK_API_PROXY_HOST | 代理地址 | `http://127.0.0.1:1080` |
@@ -252,6 +265,12 @@
#或者使用generate_api.go生成api并运行
go generate generate_api.go
```
> 注意:使用 `go run` 或编译后的二进制时,当前目录下必须存在 `conf` 和 `resources`
> 目录。如果在其他目录运行,可通过 `-c` 和环境变量
> `RUSTDESK_API_GIN_RESOURCES_PATH` 指定绝对路径,例如:
> ```bash
> RUSTDESK_API_GIN_RESOURCES_PATH=/opt/rustdesk-api/resources ./apimain -c /opt/rustdesk-api/conf/config.yaml
> ```
5. 编译,如果想自己编译,先cd到项目根目录然后windows下直接运行`build.bat`,linux下运行`build.sh`,编译后会在`release`
目录下生成对应的可执行文件。直接运行编译后的可执行文件即可。
@@ -316,3 +335,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
@@ -94,8 +101,8 @@ displaying data.Frontend code is available at [rustdesk-api-web](https://github.
- For `OIDC`, you must set the `Issuer`. And `Scopes` is optional which default is `openid,email,profile`, please make sure this `Oauth App` can access `sub`, `email` and `preferred_username`
- Create a `GitHub OAuth App`
at `Settings` -> `Developer settings` -> `OAuth Apps` -> `New OAuth App` [here](https://github.com/settings/developers).
- Set the `Authorization callback URL` to `http://<your server[:port]>/api/oauth/callback`,
e.g., `http://127.0.0.1:21114/api/oauth/callback`.
- Set the `Authorization callback URL` to `http://<your server[:port]>/api/oidc/callback`,
e.g., `http://127.0.0.1:21114/api/oidc/callback`.
7. Login logs
8. Connection logs
@@ -162,6 +169,9 @@ The table below does not list all configurations. Please refer to the configurat
| RUSTDESK_API_APP_SHOW_SWAGGER | swagger visible; 1: yes, 0: no; default: 0 | `0` |
| RUSTDESK_API_APP_TOKEN_EXPIRE | token expire duration | `168h` |
| RUSTDESK_API_APP_DISABLE_PWD_LOGIN | disable password login | `false` |
| RUSTDESK_API_APP_REGISTER_STATUS | register user default status ; 1 enabled , 2 disabled ; default 1 | `1` |
| RUSTDESK_API_APP_CAPTCHA_THRESHOLD | captcha threshold; -1 disabled, 0 always enable, >0 threshold ;default `3` | `3` |
| RUSTDESK_API_APP_BAN_THRESHOLD | ban ip threshold; 0 disabled, >0 threshold ; default `0` | `0` |
| ----- ADMIN Configuration----- | ---------- | ---------- |
| RUSTDESK_API_ADMIN_TITLE | Admin Title | `RustDesk Api Admin` |
| RUSTDESK_API_ADMIN_HELLO | Admin welcome message, you can use `html` | |
@@ -178,6 +188,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 |
@@ -185,7 +196,7 @@ The table below does not list all configurations. Please refer to the configurat
| RUSTDESK_API_RUSTDESK_KEY | Rustdesk key | 123456789 |
| RUSTDESK_API_RUSTDESK_KEY_FILE | Rustdesk key file | `./conf/data/id_ed25519.pub` |
| RUSTDESK_API_RUSTDESK<br/>_WEBCLIENT_MAGIC_QUERYONLINE | New online query method is enabled in the web client v2; '1': Enabled, '0': Disabled, not enabled by default | `0` |
| RUSTDESK_API_RUSTDESK_WS_HOST | Custom Websocket Host | |
| RUSTDESK_API_RUSTDESK_WS_HOST | Custom Websocket Host | `wss://192.168.1.123:1234` |
| ---- PROXY ----- | --------------- | ---------- |
| RUSTDESK_API_PROXY_ENABLE | proxy_enable :`false`, `true` | `false` |
| RUSTDESK_API_PROXY_HOST | proxy_host | `http://127.0.0.1:1080` |
@@ -248,10 +259,17 @@ Download the release from [release](https://github.com/lejianwen/rustdesk-api/re
4. Run:
```bash
# Run directly
go run cmd/apimain.go
# Or generate and run the API using generate_api.go
go generate generate_api.go
```
go run cmd/apimain.go
# Or generate and run the API using generate_api.go
go generate generate_api.go
```
> **Note:** When using `go run` or the compiled binary, the `conf` and `resources`
> directories must exist relative to the current working directory. If you run
> the program from another location, specify absolute paths with `-c` and the
> `RUSTDESK_API_GIN_RESOURCES_PATH` environment variable. Example:
> ```bash
> RUSTDESK_API_GIN_RESOURCES_PATH=/opt/rustdesk-api/resources ./apimain -c /opt/rustdesk-api/conf/config.yaml
> ```
5. To compile, change to the project root directory. For Windows, run `build.bat`, and for Linux, run `build.sh`. After
compiling, the corresponding executables will be generated in the `release` directory. Run the compiled executables
@@ -314,4 +332,7 @@ Thanks to everyone who contributed!
<img src="https://contrib.rocks/image?repo=lejianwen/rustdesk-api" />
</a>
## Thanks for your support! If you find this project useful, please give it a ⭐️. Thank you!
## 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

@@ -1,6 +1,11 @@
package main
import (
"fmt"
"os"
"strconv"
"time"
"github.com/go-redis/redis/v8"
"github.com/lejianwen/rustdesk-api/v2/config"
"github.com/lejianwen/rustdesk-api/v2/global"
@@ -16,10 +21,10 @@ import (
"github.com/lejianwen/rustdesk-api/v2/utils"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/spf13/cobra"
"os"
"strconv"
)
const DatabaseVersion = 265
// @title 管理系统API
// @version 1.0
// @description 接口
@@ -51,6 +56,10 @@ var resetPwdCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
pwd := args[0]
admin := service.AllService.UserService.InfoById(1)
if admin.Id == 0 {
global.Logger.Warn("user not found! ")
return
}
err := service.AllService.UserService.UpdatePassword(admin, pwd)
if err != nil {
global.Logger.Error("reset password fail! ", err)
@@ -77,6 +86,10 @@ var resetUserPwdCmd = &cobra.Command{
return
}
u := service.AllService.UserService.InfoById(uint(uid))
if u.Id == 0 {
global.Logger.Warn("user not found! ")
return
}
err = service.AllService.UserService.UpdatePassword(u, pwd)
if err != nil {
global.Logger.Warn("reset password fail! ", err)
@@ -131,20 +144,42 @@ func InitGlobal() {
}
//gorm
if global.Config.Gorm.Type == config.TypeMysql {
dns := global.Config.Mysql.Username + ":" + global.Config.Mysql.Password + "@(" + global.Config.Mysql.Addr + ")/" + global.Config.Mysql.Dbname + "?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{
Dns: dns,
Dsn: dsn,
MaxIdleConns: global.Config.Gorm.MaxIdleConns,
MaxOpenConns: global.Config.Gorm.MaxOpenConns,
})
}, global.Logger)
} else if global.Config.Gorm.Type == config.TypePostgresql {
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=%s",
global.Config.Postgresql.Host,
global.Config.Postgresql.Port,
global.Config.Postgresql.User,
global.Config.Postgresql.Password,
global.Config.Postgresql.Dbname,
global.Config.Postgresql.Sslmode,
global.Config.Postgresql.TimeZone,
)
global.DB = orm.NewPostgresql(&orm.PostgresqlConfig{
Dsn: dsn,
MaxIdleConns: global.Config.Gorm.MaxIdleConns,
MaxOpenConns: global.Config.Gorm.MaxOpenConns,
}, global.Logger)
} else {
//sqlite
global.DB = orm.NewSqlite(&orm.SqliteConfig{
MaxIdleConns: global.Config.Gorm.MaxIdleConns,
MaxOpenConns: global.Config.Gorm.MaxOpenConns,
})
}, global.Logger)
}
DatabaseAutoUpdate()
//validator
global.ApiInitValidator()
@@ -167,9 +202,19 @@ func InitGlobal() {
//service
service.New(&global.Config, global.DB, global.Logger, global.Jwt, global.Lock)
global.LoginLimiter = utils.NewLoginLimiter(utils.SecurityPolicy{
CaptchaThreshold: global.Config.App.CaptchaThreshold,
BanThreshold: global.Config.App.BanThreshold,
AttemptsWindow: 10 * time.Minute,
BanDuration: 30 * time.Minute,
})
global.LoginLimiter.RegisterProvider(utils.B64StringCaptchaProvider{})
DatabaseAutoUpdate()
}
func DatabaseAutoUpdate() {
version := 262
version := DatabaseVersion
db := global.DB
@@ -179,11 +224,17 @@ func DatabaseAutoUpdate() {
if dbName == "" {
dbName = global.Config.Mysql.Dbname
// 移除 DSN 中的数据库名称,以便初始连接时不指定数据库
dsnWithoutDB := global.Config.Mysql.Username + ":" + global.Config.Mysql.Password + "@(" + global.Config.Mysql.Addr + ")/?charset=utf8mb4&parseTime=True&loc=Local"
dsnWithoutDB := fmt.Sprintf("%s:%s@(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
global.Config.Mysql.Username,
global.Config.Mysql.Password,
global.Config.Mysql.Addr,
"",
)
//新链接
dbWithoutDB := orm.NewMysql(&orm.MysqlConfig{
Dns: dsnWithoutDB,
})
Dsn: dsnWithoutDB,
}, global.Logger)
// 获取底层的 *sql.DB 对象,并确保在程序退出时关闭连接
sqlDBWithoutDB, err := dbWithoutDB.DB()
if err != nil {
@@ -295,7 +346,11 @@ func Migrate(version uint) {
// 生成随机密码
pwd := utils.RandomString(8)
global.Logger.Info("Admin Password Is: ", pwd)
admin.Password = service.AllService.UserService.EncryptPassword(pwd)
var err error
admin.Password, err = utils.EncryptPassword(pwd)
if err != nil {
global.Logger.Fatalf("failed to generate admin password: %v", err)
}
global.DB.Create(admin)
}

View File

@@ -2,14 +2,21 @@ lang: "zh-CN"
app:
web-client: 1 # 1:启用 0:禁用
register: false #是否开启注册
register-status: 1 # 注册用户默认状态 1:启用 2:禁用
captcha-threshold: 3 # <0:disabled, 0 always, >0:enabled
ban-threshold: 0 # 0:disabled, >0:enabled
show-swagger: 0 # 1:启用 0:禁用
token-expire: 168h
web-sso: true #web auth sso
disable-pwd-login: false #禁用密码登录
admin:
title: "RustDesk Api Admin"
title: "RustDesk API Admin"
hello-file: "./conf/admin/hello.html" #优先使用file
hello: ""
# ID Server and Relay Server ports https://github.com/lejianwen/rustdesk-api/issues/257
id-server-port: 21116 # ID Server port (for server cmd)
relay-server-port: 21117 # ID Server port (for server cmd)
gin:
api-addr: "0.0.0.0:21114"
mode: "release" #release,debug,test
@@ -24,6 +31,17 @@ mysql:
password: ""
addr: ""
dbname: ""
tls: "false" # true / false / skip-verify / custom
postgresql:
host: "127.0.0.1"
port: "5432"
user: ""
password: ""
dbname: "postgres"
sslmode: "disable" # disable, require, verify-ca, verify-full
time-zone: "Asia/Shanghai" # Time zone for PostgreSQL connection
rustdesk:
id-server: "192.168.1.66:21116"
relay-server: "192.168.1.66:21117"
@@ -63,22 +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.
redis:
addr: "127.0.0.1:6379"
password: ""
db: 0
cache:
type: "file"
file-dir: "./runtime/cache"
redis-addr: "127.0.0.1:6379"
redis-pwd: ""
redis-db: 0
oss:
access-key-id: ""
access-key-secret: ""
host: ""
callback-url: ""
expire-time: 30
max-byte: 10240
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

@@ -14,33 +14,48 @@ const (
)
type App struct {
WebClient int `mapstructure:"web-client"`
Register bool `mapstructure:"register"`
ShowSwagger int `mapstructure:"show-swagger"`
TokenExpire time.Duration `mapstructure:"token-expire"`
WebSso bool `mapstructure:"web-sso"`
DisablePwdLogin bool `mapstructure:"disable-pwd-login"`
WebClient int `mapstructure:"web-client"`
Register bool `mapstructure:"register"`
RegisterStatus int `mapstructure:"register-status"`
ShowSwagger int `mapstructure:"show-swagger"`
TokenExpire time.Duration `mapstructure:"token-expire"`
WebSso bool `mapstructure:"web-sso"`
DisablePwdLogin bool `mapstructure:"disable-pwd-login"`
CaptchaThreshold int `mapstructure:"captcha-threshold"`
BanThreshold int `mapstructure:"ban-threshold"`
}
type Admin struct {
Title string `mapstructure:"title"`
Hello string `mapstructure:"hello"`
HelloFile string `mapstructure:"hello-file"`
Title string `mapstructure:"title"`
Hello string `mapstructure:"hello"`
HelloFile string `mapstructure:"hello-file"`
IdServerPort int `mapstructure:"id-server-port"`
RelayServerPort int `mapstructure:"relay-server-port"`
}
type Config struct {
Lang string `mapstructure:"lang"`
App App
Admin Admin
Gorm Gorm
Mysql Mysql
Gin Gin
Logger Logger
Redis Redis
Cache Cache
Oss Oss
Jwt Jwt
Rustdesk Rustdesk
Proxy Proxy
Ldap Ldap
Lang string `mapstructure:"lang"`
App App
Admin Admin
Gorm Gorm
Mysql Mysql
Postgresql Postgresql
Gin Gin
Logger Logger
Redis Redis
Cache Cache
Oss Oss
Jwt Jwt
Rustdesk Rustdesk
Proxy Proxy
Ldap Ldap
}
func (a *Admin) Init() {
if a.IdServerPort == 0 {
a.IdServerPort = DefaultIdServerPort
}
if a.RelayServerPort == 0 {
a.RelayServerPort = DefaultRelayServerPort
}
}
// Init 初始化配置
@@ -77,7 +92,7 @@ func Init(rowVal *Config, path string) *viper.Viper {
panic(fmt.Errorf("Fatal error config: %s \n", err))
}
rowVal.Rustdesk.LoadKeyFile()
rowVal.Rustdesk.ParsePort()
rowVal.Admin.Init()
return v
}

View File

@@ -1,8 +1,9 @@
package config
const (
TypeSqlite = "sqlite"
TypeMysql = "mysql"
TypeSqlite = "sqlite"
TypeMysql = "mysql"
TypePostgresql = "postgresql"
)
type Gorm struct {
@@ -16,4 +17,15 @@ 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 {
Host string `mapstructure:"host"`
Port string `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Dbname string `mapstructure:"dbname"`
Sslmode string `mapstructure:"sslmode"` // "disable", "require", "verify-ca", "verify-full"
TimeZone string `mapstructure:"time-zone"` // e.g., "Asia/Shanghai"
}

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

@@ -3,18 +3,20 @@ package config
type GithubOauth struct {
ClientId string `mapstructure:"client-id"`
ClientSecret string `mapstructure:"client-secret"`
RedirectUrl string `mapstructure:"redirect-url"`
}
type GoogleOauth struct {
ClientId string `mapstructure:"client-id"`
ClientSecret string `mapstructure:"client-secret"`
RedirectUrl string `mapstructure:"redirect-url"`
}
type OidcOauth struct {
Issuer string `mapstructure:"issuer"`
ClientId string `mapstructure:"client-id"`
ClientSecret string `mapstructure:"client-secret"`
RedirectUrl string `mapstructure:"redirect-url"`
}
type LinuxdoOauth struct {
ClientId string `mapstructure:"client-id"`
ClientSecret string `mapstructure:"client-secret"`
}

View File

@@ -2,8 +2,6 @@ package config
import (
"os"
"strconv"
"strings"
)
const (
@@ -40,19 +38,3 @@ func (rd *Rustdesk) LoadKeyFile() {
return
}
}
func (rd *Rustdesk) ParsePort() {
// Parse port
idres := strings.Split(rd.IdServer, ":")
if len(idres) == 1 {
rd.IdServerPort = DefaultIdServerPort
} else if len(idres) == 2 {
rd.IdServerPort, _ = strconv.Atoi(idres[1])
}
relayres := strings.Split(rd.RelayServer, ":")
if len(relayres) == 1 {
rd.RelayServerPort = DefaultRelayServerPort
} else if len(relayres) == 2 {
rd.RelayServerPort, _ = strconv.Atoi(relayres[1])
}
}

0
data/.gitkeep Normal file
View File

View File

@@ -5,7 +5,7 @@ services:
dockerfile: Dockerfile.dev
args:
COUNTRY: CN
FREONTEND_GIT_REPO: https://github.com/lejianwen/rustdesk-api-web.git
FRONTEND_GIT_REPO: https://github.com/lejianwen/rustdesk-api-web.git
FRONTEND_GIT_BRANCH: master
# image: lejianwen/rustdesk-api
container_name: rustdesk-api
@@ -21,4 +21,4 @@ services:
- ./data/rustdesk/api:/app/data #将数据库挂载出来方便备份
- ./conf:/app/conf # config
# - ./resources:/app/resources # 静态资源
restart: unless-stopped
restart: unless-stopped

View File

@@ -5569,8 +5569,7 @@ const docTemplateadmin = `{
"required": [
"client_id",
"client_secret",
"oauth_type",
"redirect_url"
"oauth_type"
],
"properties": {
"auto_register": {
@@ -5600,9 +5599,6 @@ const docTemplateadmin = `{
"pkce_method": {
"type": "string"
},
"redirect_url": {
"type": "string"
},
"scopes": {
"type": "string"
}
@@ -5828,6 +5824,9 @@ const docTemplateadmin = `{
"captcha": {
"type": "string"
},
"captcha_id": {
"type": "string"
},
"password": {
"type": "string"
},
@@ -6293,9 +6292,6 @@ const docTemplateadmin = `{
"pkce_method": {
"type": "string"
},
"redirect_url": {
"type": "string"
},
"scopes": {
"type": "string"
},

View File

@@ -5562,8 +5562,7 @@
"required": [
"client_id",
"client_secret",
"oauth_type",
"redirect_url"
"oauth_type"
],
"properties": {
"auto_register": {
@@ -5593,9 +5592,6 @@
"pkce_method": {
"type": "string"
},
"redirect_url": {
"type": "string"
},
"scopes": {
"type": "string"
}
@@ -5821,6 +5817,9 @@
"captcha": {
"type": "string"
},
"captcha_id": {
"type": "string"
},
"password": {
"type": "string"
},
@@ -6286,9 +6285,6 @@
"pkce_method": {
"type": "string"
},
"redirect_url": {
"type": "string"
},
"scopes": {
"type": "string"
},
@@ -6592,4 +6588,4 @@
"in": "header"
}
}
}
}

View File

@@ -143,15 +143,12 @@ definitions:
type: boolean
pkce_method:
type: string
redirect_url:
type: string
scopes:
type: string
required:
- client_id
- client_secret
- oauth_type
- redirect_url
type: object
admin.PeerBatchDeleteForm:
properties:
@@ -297,6 +294,8 @@ definitions:
properties:
captcha:
type: string
captcha_id:
type: string
password:
type: string
platform:
@@ -609,8 +608,6 @@ definitions:
type: boolean
pkce_method:
type: string
redirect_url:
type: string
scopes:
type: string
updated_at:

View File

@@ -954,35 +954,6 @@ const docTemplateapi = `{
}
}
},
"/oauth/callback": {
"get": {
"description": "OauthCallback",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Oauth"
],
"summary": "OauthCallback",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/api.LoginRes"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
}
}
}
},
"/oidc/auth": {
"post": {
"description": "OidcAuth",
@@ -1041,6 +1012,35 @@ const docTemplateapi = `{
}
}
},
"/oidc/callback": {
"get": {
"description": "OauthCallback",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Oauth"
],
"summary": "OauthCallback",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/api.LoginRes"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
}
}
}
},
"/peers": {
"get": {
"security": [
@@ -1208,7 +1208,7 @@ const docTemplateapi = `{
"application/json"
],
"tags": [
"地址"
"System"
],
"summary": "提交系统信息",
"parameters": [
@@ -1238,6 +1238,35 @@ const docTemplateapi = `{
}
}
},
"/sysinfo_ver": {
"post": {
"description": "获取系统版本信息",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"System"
],
"summary": "获取系统版本信息",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
}
}
}
},
"/users": {
"get": {
"security": [

View File

@@ -947,35 +947,6 @@
}
}
},
"/oauth/callback": {
"get": {
"description": "OauthCallback",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Oauth"
],
"summary": "OauthCallback",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/api.LoginRes"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
}
}
}
},
"/oidc/auth": {
"post": {
"description": "OidcAuth",
@@ -1034,6 +1005,35 @@
}
}
},
"/oidc/callback": {
"get": {
"description": "OauthCallback",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Oauth"
],
"summary": "OauthCallback",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/api.LoginRes"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
}
}
}
},
"/peers": {
"get": {
"security": [
@@ -1201,7 +1201,7 @@
"application/json"
],
"tags": [
"地址"
"System"
],
"summary": "提交系统信息",
"parameters": [
@@ -1231,6 +1231,35 @@
}
}
},
"/sysinfo_ver": {
"post": {
"description": "获取系统版本信息",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"System"
],
"summary": "获取系统版本信息",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
}
}
}
},
"/users": {
"get": {
"security": [

View File

@@ -792,25 +792,6 @@ paths:
summary: 登出
tags:
- 登录
/oauth/callback:
get:
consumes:
- application/json
description: OauthCallback
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/api.LoginRes'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.ErrorResponse'
summary: OauthCallback
tags:
- Oauth
/oidc/auth:
post:
consumes:
@@ -849,6 +830,25 @@ paths:
summary: OidcAuthQuery
tags:
- Oauth
/oidc/callback:
get:
consumes:
- application/json
description: OauthCallback
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/api.LoginRes'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.ErrorResponse'
summary: OauthCallback
tags:
- Oauth
/peers:
get:
consumes:
@@ -973,7 +973,26 @@ paths:
$ref: '#/definitions/response.ErrorResponse'
summary: 提交系统信息
tags:
- 地址
- System
/sysinfo_ver:
post:
consumes:
- application/json
description: 获取系统版本信息
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.ErrorResponse'
summary: 获取系统版本信息
tags:
- System
/users:
get:
consumes:

View File

@@ -14,6 +14,7 @@ import (
en_translations "github.com/go-playground/validator/v10/translations/en"
es_translations "github.com/go-playground/validator/v10/translations/es"
fr_translations "github.com/go-playground/validator/v10/translations/fr"
ko_translations "github.com/go-playground/validator/v10/translations/ko"
ru_translations "github.com/go-playground/validator/v10/translations/ru"
zh_translations "github.com/go-playground/validator/v10/translations/zh"
zh_tw_translations "github.com/go-playground/validator/v10/translations/zh_tw"
@@ -51,8 +52,7 @@ func ApiInitValidator() {
panic(err)
}
//validate没有ko的翻译使用zh的翻译
err = zh_translations.RegisterDefaultTranslations(validate, koTrans)
err = ko_translations.RegisterDefaultTranslations(validate, koTrans)
if err != nil {
panic(err)
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/lejianwen/rustdesk-api/v2/lib/jwt"
"github.com/lejianwen/rustdesk-api/v2/lib/lock"
"github.com/lejianwen/rustdesk-api/v2/lib/upload"
"github.com/lejianwen/rustdesk-api/v2/utils"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
@@ -31,8 +32,9 @@ var (
ValidStruct func(*gin.Context, interface{}) []string
ValidVar func(ctx *gin.Context, field interface{}, tag string) []string
}
Oss *upload.Oss
Jwt *jwt.Jwt
Lock lock.Locker
Localizer func(lang string) *i18n.Localizer
Oss *upload.Oss
Jwt *jwt.Jwt
Lock lock.Locker
Localizer func(lang string) *i18n.Localizer
LoginLimiter *utils.LoginLimiter
)

36
go.mod
View File

@@ -1,19 +1,23 @@
module github.com/lejianwen/rustdesk-api/v2
go 1.22
go 1.23
toolchain go1.23.10
require (
github.com/BurntSushi/toml v1.3.2
github.com/antonfisher/nested-logrus-formatter v1.3.1
github.com/fsnotify/fsnotify v1.5.1
github.com/coreos/go-oidc/v3 v3.12.0
github.com/fvbock/endless v0.0.0-20170109170031-447134032cb6
github.com/gin-gonic/gin v1.9.0
github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.11.2
github.com/go-playground/validator/v10 v10.26.0
github.com/go-redis/redis/v8 v8.11.4
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/mojocn/base64Captcha v1.3.6
github.com/nicksnyder/go-i18n/v2 v2.4.0
github.com/sirupsen/logrus v1.8.1
github.com/spf13/cobra v1.8.1
@@ -21,11 +25,13 @@ 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.21.0
golang.org/x/text v0.22.0
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.7
gorm.io/gorm v1.25.10
)
require (
@@ -36,12 +42,12 @@ require (
github.com/bytedance/sonic v1.8.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/coreos/go-oidc/v3 v3.12.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.5.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/go-ldap/ldap/v3 v3.4.10 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
@@ -51,12 +57,16 @@ require (
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.5 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
@@ -64,9 +74,9 @@ require (
github.com/mitchellh/mapstructure v1.4.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mojocn/base64Captcha v1.3.6 // indirect
github.com/pelletier/go-toml v1.9.4 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/afero v1.6.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
@@ -75,11 +85,11 @@ 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.31.0 // indirect
golang.org/x/image v0.13.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/tools v0.26.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/ini.v1 v1.63.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

View File

@@ -120,7 +120,7 @@ func (abcr *AddressBookCollectionRule) CheckForm(t *model.AddressBookCollectionR
//check to_id
if t.Type == model.ShareAddressBookRuleTypePersonal {
if t.ToId == t.UserId {
return "ParamsError", false
return "CannotShareToSelf", false
}
tou := service.AllService.UserService.InfoById(t.ToId)
if tou.Id == 0 {
@@ -135,7 +135,7 @@ func (abcr *AddressBookCollectionRule) CheckForm(t *model.AddressBookCollectionR
return "ParamsError", false
}
// 重复检查
ex := service.AllService.AddressBookService.RulePersonalInfoByToIdAndCid(t.ToId, t.CollectionId)
ex := service.AllService.AddressBookService.RuleInfoByToIdAndCid(t.Type, t.ToId, t.CollectionId)
if t.Id == 0 && ex.Id > 0 {
return "ItemExists", false
}

View File

@@ -78,11 +78,13 @@ func (co *Config) AdminConfig(c *gin.Context) {
}
hello := global.Config.Admin.Hello
helloFile := global.Config.Admin.HelloFile
if helloFile != "" {
b, err := os.ReadFile(helloFile)
if err == nil && len(b) > 0 {
hello = string(b)
if hello == "" {
helloFile := global.Config.Admin.HelloFile
if helloFile != "" {
b, err := os.ReadFile(helloFile)
if err == nil && len(b) > 0 {
hello = string(b)
}
}
}

View File

@@ -38,7 +38,7 @@ func (f *File) Notify(c *gin.Context) {
res := global.Oss.Verify(c.Request)
if !res {
response.Fail(c, 101, "权限错误")
response.Fail(c, 101, response.TranslateMsg(c, "NoAccess"))
return
}
fm := &FileBack{}

View File

@@ -2,6 +2,7 @@ package admin
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/lejianwen/rustdesk-api/v2/global"
"github.com/lejianwen/rustdesk-api/v2/http/controller/api"
@@ -11,135 +12,11 @@ import (
adResp "github.com/lejianwen/rustdesk-api/v2/http/response/admin"
"github.com/lejianwen/rustdesk-api/v2/model"
"github.com/lejianwen/rustdesk-api/v2/service"
"github.com/mojocn/base64Captcha"
"sync"
"time"
)
type Login struct {
}
// Captcha 验证码结构
type Captcha struct {
Id string `json:"id"` // 验证码 ID
B64 string `json:"b64"` // base64 验证码
Code string `json:"-"` // 验证码内容
ExpiresAt time.Time `json:"-"` // 过期时间
}
type LoginLimiter struct {
mu sync.RWMutex
failCount map[string]int // 记录每个 IP 的失败次数
timestamp map[string]time.Time // 记录每个 IP 的最后失败时间
captchas map[string]Captcha // 每个 IP 的验证码
threshold int // 失败阈值
expiry time.Duration // 失败记录过期时间
}
func NewLoginLimiter(threshold int, expiry time.Duration) *LoginLimiter {
return &LoginLimiter{
failCount: make(map[string]int),
timestamp: make(map[string]time.Time),
captchas: make(map[string]Captcha),
threshold: threshold,
expiry: expiry,
}
}
// RecordFailure 记录登录失败
func (l *LoginLimiter) RecordFailure(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
// 如果该 IP 的记录已经过期,重置计数
if lastTime, exists := l.timestamp[ip]; exists && time.Since(lastTime) > l.expiry {
l.failCount[ip] = 0
}
// 更新失败次数和时间戳
l.failCount[ip]++
l.timestamp[ip] = time.Now()
}
// NeedsCaptcha 检查是否需要验证码
func (l *LoginLimiter) NeedsCaptcha(ip string) bool {
l.mu.RLock()
defer l.mu.RUnlock()
// 检查记录是否存在且未过期
if lastTime, exists := l.timestamp[ip]; exists && time.Since(lastTime) <= l.expiry {
return l.failCount[ip] >= l.threshold
}
return false
}
// GenerateCaptcha 为指定 IP 生成验证码
func (l *LoginLimiter) GenerateCaptcha(ip string) Captcha {
l.mu.Lock()
defer l.mu.Unlock()
capd := base64Captcha.NewDriverString(50, 150, 5, 10, 4, "1234567890abcdefghijklmnopqrstuvwxyz", nil, nil, nil)
b64cap := base64Captcha.NewCaptcha(capd, base64Captcha.DefaultMemStore)
id, b64s, answer, err := b64cap.Generate()
if err != nil {
global.Logger.Error("Generate captcha failed: " + err.Error())
return Captcha{}
}
// 保存验证码到对应 IP
l.captchas[ip] = Captcha{
Id: id,
B64: b64s,
Code: answer,
ExpiresAt: time.Now().Add(5 * time.Minute),
}
return l.captchas[ip]
}
// VerifyCaptcha 验证指定 IP 的验证码
func (l *LoginLimiter) VerifyCaptcha(ip, code string) bool {
l.mu.RLock()
defer l.mu.RUnlock()
// 检查验证码是否存在且未过期
if captcha, exists := l.captchas[ip]; exists && time.Now().Before(captcha.ExpiresAt) {
return captcha.Code == code
}
return false
}
// RemoveCaptcha 移除指定 IP 的验证码
func (l *LoginLimiter) RemoveCaptcha(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.captchas, ip)
}
// CleanupExpired 清理过期的记录
func (l *LoginLimiter) CleanupExpired() {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
for ip, lastTime := range l.timestamp {
if now.Sub(lastTime) > l.expiry {
delete(l.failCount, ip)
delete(l.timestamp, ip)
delete(l.captchas, ip)
}
}
}
func (l *LoginLimiter) RemoveRecord(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.failCount, ip)
delete(l.timestamp, ip)
delete(l.captchas, ip)
}
var loginLimiter = NewLoginLimiter(3, 5*time.Minute)
// Login 登录
// @Tags 登录
// @Summary 登录
@@ -156,10 +33,16 @@ func (ct *Login) Login(c *gin.Context) {
response.Fail(c, 101, response.TranslateMsg(c, "PwdLoginDisabled"))
return
}
// 检查登录限制
loginLimiter := global.LoginLimiter
clientIp := c.ClientIP()
_, needCaptcha := loginLimiter.CheckSecurityStatus(clientIp)
f := &admin.Login{}
err := c.ShouldBindJSON(f)
clientIp := c.ClientIP()
if err != nil {
loginLimiter.RecordFailedAttempt(clientIp)
global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), clientIp))
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+err.Error())
return
@@ -167,14 +50,15 @@ func (ct *Login) Login(c *gin.Context) {
errList := global.Validator.ValidStruct(c, f)
if len(errList) > 0 {
loginLimiter.RecordFailedAttempt(clientIp)
global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), clientIp))
response.Fail(c, 101, errList[0])
return
}
// 检查是否需要验证码
if loginLimiter.NeedsCaptcha(clientIp) {
if f.Captcha == "" || !loginLimiter.VerifyCaptcha(clientIp, f.Captcha) {
if needCaptcha {
if f.CaptchaId == "" || f.Captcha == "" || !loginLimiter.VerifyCaptcha(f.CaptchaId, f.Captcha) {
response.Fail(c, 101, response.TranslateMsg(c, "CaptchaError"))
return
}
@@ -184,17 +68,19 @@ func (ct *Login) Login(c *gin.Context) {
if u.Id == 0 {
global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "UsernameOrPasswordError", c.RemoteIP(), clientIp))
loginLimiter.RecordFailure(clientIp)
if loginLimiter.NeedsCaptcha(clientIp) {
loginLimiter.RemoveCaptcha(clientIp)
loginLimiter.RecordFailedAttempt(clientIp)
if _, needCaptcha = loginLimiter.CheckSecurityStatus(clientIp); needCaptcha {
response.Fail(c, 110, response.TranslateMsg(c, "UsernameOrPasswordError"))
} else {
response.Fail(c, 101, response.TranslateMsg(c, "UsernameOrPasswordError"))
}
response.Fail(c, 101, response.TranslateMsg(c, "UsernameOrPasswordError"))
return
}
if !service.AllService.UserService.CheckUserEnable(u) {
if loginLimiter.NeedsCaptcha(clientIp) {
loginLimiter.RemoveCaptcha(clientIp)
if needCaptcha {
response.Fail(c, 110, response.TranslateMsg(c, "UserDisabled"))
return
}
response.Fail(c, 101, response.TranslateMsg(c, "UserDisabled"))
return
@@ -209,23 +95,37 @@ func (ct *Login) Login(c *gin.Context) {
Platform: f.Platform,
})
// 成功清除记录
loginLimiter.RemoveRecord(clientIp)
// 清理过期记录
go loginLimiter.CleanupExpired()
// 登录成功清除登录限制
loginLimiter.RemoveAttempts(clientIp)
responseLoginSuccess(c, u, ut.Token)
}
func (ct *Login) Captcha(c *gin.Context) {
loginLimiter := global.LoginLimiter
clientIp := c.ClientIP()
if !loginLimiter.NeedsCaptcha(clientIp) {
banned, needCaptcha := loginLimiter.CheckSecurityStatus(clientIp)
if banned {
response.Fail(c, 101, response.TranslateMsg(c, "LoginBanned"))
return
}
if !needCaptcha {
response.Fail(c, 101, response.TranslateMsg(c, "NoCaptchaRequired"))
return
}
captcha := loginLimiter.GenerateCaptcha(clientIp)
err, captcha := loginLimiter.RequireCaptcha()
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "CaptchaError")+err.Error())
return
}
err, b64 := loginLimiter.DrawCaptcha(captcha.Content)
if err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "CaptchaError")+err.Error())
return
}
response.Success(c, gin.H{
"captcha": captcha,
"captcha": gin.H{
"id": captcha.Id,
"b64": b64,
},
})
}
@@ -257,12 +157,20 @@ func (ct *Login) Logout(c *gin.Context) {
// @Failure 500 {object} response.ErrorResponse
// @Router /admin/login-options [post]
func (ct *Login) LoginOptions(c *gin.Context) {
ip := c.ClientIP()
loginLimiter := global.LoginLimiter
clientIp := c.ClientIP()
banned, needCaptcha := loginLimiter.CheckSecurityStatus(clientIp)
if banned {
response.Fail(c, 101, response.TranslateMsg(c, "LoginBanned"))
return
}
ops := service.AllService.OauthService.GetOauthProviders()
response.Success(c, gin.H{
"ops": ops,
"register": global.Config.App.Register,
"need_captcha": loginLimiter.NeedsCaptcha(ip),
"need_captcha": needCaptcha,
"disable_pwd": global.Config.App.DisablePwdLogin,
"auto_oidc": global.Config.App.DisablePwdLogin && len(ops) == 1,
})
}

View File

@@ -98,10 +98,10 @@ func (abc *AddressBookCollection) Update(c *gin.Context) {
return
}
u := service.AllService.UserService.CurUser(c)
if f.UserId != u.Id {
response.Fail(c, 101, response.TranslateMsg(c, "NoAccess"))
return
}
//if f.UserId != u.Id {
// response.Fail(c, 101, response.TranslateMsg(c, "NoAccess"))
// return
//}
ex := service.AllService.AddressBookService.CollectionInfoById(f.Id)
if ex.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "ItemNotFound"))

View File

@@ -100,21 +100,21 @@ func (abcr *AddressBookCollectionRule) CheckForm(u *model.User, t *model.Address
//check to_id
if t.Type == model.ShareAddressBookRuleTypePersonal {
if t.ToId == t.UserId {
return "ParamsError", false
return "CannotShareToSelf", false
}
tou := service.AllService.UserService.InfoById(t.ToId)
if tou.Id == 0 {
return "ItemNotFound", false
}
//非管理员不能分享给非本组织用户
if tou.GroupId != u.GroupId {
return "NoAccess", false
}
//if tou.GroupId != u.GroupId {
// return "NoAccess", false
//}
} else if t.Type == model.ShareAddressBookRuleTypeGroup {
//非管理员不能分享给其他组
if t.ToId != u.GroupId {
return "NoAccess", false
}
//if t.ToId != u.GroupId {
// return "NoAccess", false
//}
tog := service.AllService.GroupService.InfoById(t.ToId)
if tog.Id == 0 {
@@ -124,7 +124,7 @@ func (abcr *AddressBookCollectionRule) CheckForm(u *model.User, t *model.Address
return "ParamsError", false
}
// 重复检查
ex := service.AllService.AddressBookService.RulePersonalInfoByToIdAndCid(t.ToId, t.CollectionId)
ex := service.AllService.AddressBookService.RuleInfoByToIdAndCid(t.Type, t.ToId, t.CollectionId)
if t.Id == 0 && ex.Id > 0 {
return "ItemExists", false
}

View File

@@ -1,13 +1,14 @@
package admin
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/lejianwen/rustdesk-api/v2/global"
"github.com/lejianwen/rustdesk-api/v2/http/request/admin"
adminReq "github.com/lejianwen/rustdesk-api/v2/http/request/admin"
"github.com/lejianwen/rustdesk-api/v2/http/response"
"github.com/lejianwen/rustdesk-api/v2/service"
"strconv"
)
type Oauth struct {
@@ -68,16 +69,16 @@ func (o *Oauth) Confirm(c *gin.Context) {
j := &adminReq.OauthConfirmForm{}
err := c.ShouldBindJSON(j)
if err != nil {
response.Fail(c, 101, "参数错误"+err.Error())
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+err.Error())
return
}
if j.Code == "" {
response.Fail(c, 101, "参数错误: code 不存在")
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError"))
return
}
v := service.AllService.OauthService.GetOauthCache(j.Code)
if v == nil {
response.Fail(c, 101, "授权已过期")
response.Fail(c, 101, response.TranslateMsg(c, "OauthExpired"))
return
}
u := service.AllService.UserService.CurUser(c)

View File

@@ -108,6 +108,15 @@ func (ct *Peer) List(c *gin.Context) {
if query.Uuids != "" {
tx.Where("uuid in (?)", query.Uuids)
}
if query.Username != "" {
tx.Where("username like ?", "%"+query.Username+"%")
}
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

@@ -119,7 +119,16 @@ func (r *Rustdesk) SendCmd(c *gin.Context) {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError"))
return
}
res, err := service.AllService.ServerCmdService.SendCmd(rc.Target, rc.Cmd, rc.Option)
port := 0
switch rc.Target {
case model.ServerCmdTargetIdServer:
port = global.Config.Admin.IdServerPort - 1
case model.ServerCmdTargetRelayServer:
port = global.Config.Admin.RelayServerPort
}
res, err := service.AllService.ServerCmdService.SendCmd(port, rc.Cmd, rc.Option)
if err != nil {
response.Fail(c, 101, err.Error())
return

View File

@@ -8,6 +8,7 @@ import (
adResp "github.com/lejianwen/rustdesk-api/v2/http/response/admin"
"github.com/lejianwen/rustdesk-api/v2/model"
"github.com/lejianwen/rustdesk-api/v2/service"
"github.com/lejianwen/rustdesk-api/v2/utils"
"gorm.io/gorm"
"strconv"
)
@@ -243,11 +244,10 @@ func (ct *User) ChangeCurPwd(c *gin.Context) {
return
}
u := service.AllService.UserService.CurUser(c)
// If the password is not empty, the old password is verified
// otherwise, the old password is not verified
// Verify the old password only when the account already has one set
if !service.AllService.UserService.IsPasswordEmptyByUser(u) {
oldPwd := service.AllService.UserService.EncryptPassword(f.OldPassword)
if u.Password != oldPwd {
ok, _, err := utils.VerifyPassword(u.Password, f.OldPassword)
if err != nil || !ok {
response.Fail(c, 101, response.TranslateMsg(c, "OldPasswordError"))
return
}
@@ -296,32 +296,12 @@ func (ct *User) MyOauth(c *gin.Context) {
// groupUsers
func (ct *User) GroupUsers(c *gin.Context) {
q := &admin.GroupUsersQuery{}
if err := c.ShouldBindJSON(q); err != nil {
response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+err.Error())
return
}
u := service.AllService.UserService.CurUser(c)
gid := u.GroupId
uid := u.Id
if service.AllService.UserService.IsAdmin(u) && q.UserId > 0 {
nu := service.AllService.UserService.InfoById(q.UserId)
gid = nu.GroupId
uid = q.UserId
}
res := service.AllService.UserService.List(1, 999, func(tx *gorm.DB) {
tx.Where("group_id = ?", gid)
aG := service.AllService.GroupService.List(1, 999, nil)
aU := service.AllService.UserService.List(1, 9999, nil)
response.Success(c, gin.H{
"groups": aG.Groups,
"users": aU.Users,
})
var data []*adResp.GroupUsersPayload
for _, _u := range res.Users {
gup := &adResp.GroupUsersPayload{}
gup.FromUser(_u)
if _u.Id == uid {
gup.Status = 0
}
data = append(data, gup)
}
response.Success(c, data)
}
// Register
@@ -340,11 +320,22 @@ func (ct *User) Register(c *gin.Context) {
response.Fail(c, 101, errList[0])
return
}
u := service.AllService.UserService.Register(f.Username, f.Email, f.Password)
regStatus := model.StatusCode(global.Config.App.RegisterStatus)
// 注册状态可能未配置,默认启用
if regStatus != model.COMMON_STATUS_DISABLED && regStatus != model.COMMON_STATUS_ENABLE {
regStatus = model.COMMON_STATUS_ENABLE
}
u := service.AllService.UserService.Register(f.Username, f.Email, f.Password, regStatus)
if u == nil || u.Id == 0 {
response.Fail(c, 101, response.TranslateMsg(c, "OperationFailed"))
return
}
if regStatus == model.COMMON_STATUS_DISABLED {
// 需要管理员审核
response.Fail(c, 101, response.TranslateMsg(c, "RegisterSuccessWaitAdminConfirm"))
return
}
// 注册成功后自动登录
ut := service.AllService.UserService.Login(u, &model.LoginLog{
UserId: u.Id,

View File

@@ -7,7 +7,6 @@ import (
"github.com/lejianwen/rustdesk-api/v2/model"
"github.com/lejianwen/rustdesk-api/v2/service"
"net/http"
"os"
"time"
)
@@ -50,13 +49,13 @@ 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
}
//如果在40s以内则不更新
if time.Now().Unix()-peer.LastOnlineTime > 40 {
if time.Now().Unix()-peer.LastOnlineTime >= 30 {
upp := &model.Peer{RowId: peer.RowId, LastOnlineTime: time.Now().Unix(), LastOnlineIp: c.ClientIP()}
service.AllService.PeerService.Update(upp)
}
@@ -74,13 +73,9 @@ func (i *Index) Heartbeat(c *gin.Context) {
// @Router /version [get]
func (i *Index) Version(c *gin.Context) {
//读取resources/version文件
v, err := os.ReadFile("resources/version")
if err != nil {
response.Fail(c, 101, err.Error())
return
}
v := service.AllService.AppService.GetAppVersion()
response.Success(
c,
string(v),
v,
)
}

View File

@@ -31,10 +31,16 @@ func (l *Login) Login(c *gin.Context) {
response.Error(c, response.TranslateMsg(c, "PwdLoginDisabled"))
return
}
// 检查登录限制
loginLimiter := global.LoginLimiter
clientIp := c.ClientIP()
f := &api.LoginForm{}
err := c.ShouldBindJSON(f)
//fmt.Println(f)
if err != nil {
loginLimiter.RecordFailedAttempt(clientIp)
global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), c.ClientIP()))
response.Error(c, response.TranslateMsg(c, "ParamsError")+err.Error())
return
@@ -42,6 +48,7 @@ func (l *Login) Login(c *gin.Context) {
errList := global.Validator.ValidStruct(c, f)
if len(errList) > 0 {
loginLimiter.RecordFailedAttempt(clientIp)
global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), c.ClientIP()))
response.Error(c, errList[0])
return
@@ -50,6 +57,7 @@ func (l *Login) Login(c *gin.Context) {
u := service.AllService.UserService.InfoByUsernamePassword(f.Username, f.Password)
if u.Id == 0 {
loginLimiter.RecordFailedAttempt(clientIp)
global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "UsernameOrPasswordError", c.RemoteIP(), c.ClientIP()))
response.Error(c, response.TranslateMsg(c, "UsernameOrPasswordError"))
return

View File

@@ -1,6 +1,8 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/lejianwen/rustdesk-api/v2/global"
"github.com/lejianwen/rustdesk-api/v2/http/request/api"
@@ -8,7 +10,8 @@ import (
apiResp "github.com/lejianwen/rustdesk-api/v2/http/response/api"
"github.com/lejianwen/rustdesk-api/v2/model"
"github.com/lejianwen/rustdesk-api/v2/service"
"net/http"
"github.com/lejianwen/rustdesk-api/v2/utils"
"github.com/nicksnyder/go-i18n/v2/i18n"
)
type Oauth struct {
@@ -77,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
}
@@ -140,12 +144,13 @@ func (o *Oauth) OidcAuthQuery(c *gin.Context) {
// @Produce json
// @Success 200 {object} apiResp.LoginRes
// @Failure 500 {object} response.ErrorResponse
// @Router /oauth/callback [get]
// @Router /oidc/callback [get]
func (o *Oauth) OauthCallback(c *gin.Context) {
state := c.Query("state")
if state == "" {
c.HTML(http.StatusOK, "oauth_fail.html", gin.H{
"message": response.TranslateParamMsg(c, "ParamIsEmpty", "state"),
"message": "ParamIsEmpty",
"sub_message": "state",
})
return
}
@@ -155,7 +160,7 @@ func (o *Oauth) OauthCallback(c *gin.Context) {
oauthCache := oauthService.GetOauthCache(cacheKey)
if oauthCache == nil {
c.HTML(http.StatusOK, "oauth_fail.html", gin.H{
"message": response.TranslateMsg(c, "OauthExpired"),
"message": "OauthExpired",
})
return
}
@@ -169,7 +174,8 @@ func (o *Oauth) OauthCallback(c *gin.Context) {
err, oauthUser := oauthService.Callback(code, verifier, op, nonce)
if err != nil {
c.HTML(http.StatusOK, "oauth_fail.html", gin.H{
"message": response.TranslateMsg(c, "OauthFailed") + response.TranslateMsg(c, err.Error()),
"message": "OauthFailed",
"sub_message": err.Error(),
})
return
}
@@ -182,7 +188,7 @@ func (o *Oauth) OauthCallback(c *gin.Context) {
utr := oauthService.UserThirdInfo(op, openid)
if utr.UserId > 0 {
c.HTML(http.StatusOK, "oauth_fail.html", gin.H{
"message": response.TranslateMsg(c, "OauthHasBindOtherUser"),
"message": "OauthHasBindOtherUser",
})
return
}
@@ -190,7 +196,7 @@ func (o *Oauth) OauthCallback(c *gin.Context) {
user = service.AllService.UserService.InfoById(userId)
if user == nil {
c.HTML(http.StatusOK, "oauth_fail.html", gin.H{
"message": response.TranslateMsg(c, "ItemNotFound"),
"message": "ItemNotFound",
})
return
}
@@ -198,12 +204,12 @@ func (o *Oauth) OauthCallback(c *gin.Context) {
err := oauthService.BindOauthUser(userId, oauthUser, op)
if err != nil {
c.HTML(http.StatusOK, "oauth_fail.html", gin.H{
"message": response.TranslateMsg(c, "BindFail"),
"message": "BindFail",
})
return
}
c.HTML(http.StatusOK, "oauth_success.html", gin.H{
"message": response.TranslateMsg(c, "BindSuccess"),
"message": "BindSuccess",
})
return
@@ -211,7 +217,7 @@ func (o *Oauth) OauthCallback(c *gin.Context) {
//登录
if userId != 0 {
c.HTML(http.StatusOK, "oauth_fail.html", gin.H{
"message": response.TranslateMsg(c, "OauthHasBeenSuccess"),
"message": "OauthHasBeenSuccess",
})
return
}
@@ -221,8 +227,7 @@ func (o *Oauth) OauthCallback(c *gin.Context) {
if !*oauthConfig.AutoRegister {
//c.String(http.StatusInternalServerError, "还未绑定用户,请先绑定")
oauthCache.UpdateFromOauthUser(oauthUser)
url := global.Config.Rustdesk.ApiServer + "/_admin/#/oauth/bind/" + cacheKey
c.Redirect(http.StatusFound, url)
c.Redirect(http.StatusFound, "/_admin/#/oauth/bind/"+cacheKey)
return
}
@@ -230,7 +235,7 @@ func (o *Oauth) OauthCallback(c *gin.Context) {
err, user = service.AllService.UserService.RegisterByOauth(oauthUser, op)
if err != nil {
c.HTML(http.StatusOK, "oauth_fail.html", gin.H{
"message": response.TranslateMsg(c, err.Error()),
"message": err.Error(),
})
return
}
@@ -247,19 +252,54 @@ func (o *Oauth) OauthCallback(c *gin.Context) {
Type: model.LoginLogTypeOauth,
Platform: oauthService.DeviceOs,
})*/
url := global.Config.Rustdesk.ApiServer + "/_admin/#/"
c.Redirect(http.StatusFound, url)
c.Redirect(http.StatusFound, "/_admin/#/")
return
}
c.HTML(http.StatusOK, "oauth_success.html", gin.H{
"message": response.TranslateMsg(c, "OauthSuccess"),
"message": "OauthSuccess",
})
return
} else {
c.HTML(http.StatusOK, "oauth_fail.html", gin.H{
"message": response.TranslateMsg(c, "ParamsError"),
"message": "ParamsError",
})
return
}
}
type MessageParams struct {
Lang string `json:"lang" form:"lang"`
Title string `json:"title" form:"title"`
Msg string `json:"msg" form:"msg"`
}
func (o *Oauth) Message(c *gin.Context) {
mp := &MessageParams{}
if err := c.ShouldBindQuery(mp); err != nil {
return
}
localizer := global.Localizer(mp.Lang)
res := ""
if mp.Title != "" {
title, err := localizer.LocalizeMessage(&i18n.Message{
ID: mp.Title,
})
if err == nil {
res = utils.StringConcat(";title='", title, "';")
}
}
if mp.Msg != "" {
msg, err := localizer.LocalizeMessage(&i18n.Message{
ID: mp.Msg,
})
if err == nil {
res = utils.StringConcat(res, "msg = '", msg, "';")
}
}
//返回js内容
c.Header("Content-Type", "application/javascript")
c.String(http.StatusOK, res)
}

View File

@@ -1,6 +1,7 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
requstform "github.com/lejianwen/rustdesk-api/v2/http/request/api"
@@ -13,7 +14,7 @@ type Peer struct {
}
// SysInfo
// @Tags 地址
// @Tags System
// @Summary 提交系统信息
// @Description 提交系统信息
// @Accept json
@@ -33,7 +34,7 @@ func (p *Peer) SysInfo(c *gin.Context) {
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())
@@ -41,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
@@ -56,3 +57,20 @@ func (p *Peer) SysInfo(c *gin.Context) {
//直接响应文本
c.String(http.StatusOK, "SYSINFO_UPDATED")
}
// SysInfoVer
// @Tags System
// @Summary 获取系统版本信息
// @Description 获取系统版本信息
// @Accept json
// @Produce json
// @Success 200 {string} string ""
// @Failure 500 {object} response.ErrorResponse
// @Router /sysinfo_ver [post]
func (p *Peer) SysInfoVer(c *gin.Context) {
//读取resources/version文件
v := service.AllService.AppService.GetAppVersion()
// 加上启动时间方便client上传信息
v = fmt.Sprintf("%s\n%s", v, service.AllService.AppService.GetStartTime())
c.String(http.StatusOK, v)
}

View File

@@ -33,7 +33,7 @@ func ApiInit() {
g.NoRoute(func(c *gin.Context) {
c.String(http.StatusNotFound, "404 not found")
})
g.Use(middleware.Logger(), gin.Recovery())
g.Use(middleware.Logger(), middleware.Limiter(), gin.Recovery())
router.WebInit(g)
router.Init(g)
router.ApiInit(g)

View File

@@ -13,13 +13,13 @@ func BackendUserAuth() gin.HandlerFunc {
//测试先关闭
token := c.GetHeader("api-token")
if token == "" {
response.Fail(c, 403, "请先登录")
response.Fail(c, 403, response.TranslateMsg(c, "NeedLogin"))
c.Abort()
return
}
user, ut := service.AllService.UserService.InfoByAccessToken(token)
if user.Id == 0 {
response.Fail(c, 403, "请先登录")
response.Fail(c, 403, response.TranslateMsg(c, "NeedLogin"))
c.Abort()
return
}

View File

@@ -12,7 +12,7 @@ func AdminPrivilege() gin.HandlerFunc {
u := service.AllService.UserService.CurUser(c)
if !service.AllService.UserService.IsAdmin(u) {
response.Fail(c, 403, "无权限")
response.Fail(c, 403, response.TranslateMsg(c, "NoAccess"))
c.Abort()
return
}

View File

@@ -12,18 +12,18 @@ func JwtAuth() gin.HandlerFunc {
//测试先关闭
token := c.GetHeader("api-token")
if token == "" {
response.Fail(c, 403, "请先登录")
response.Fail(c, 403, response.TranslateMsg(c, "NeedLogin"))
c.Abort()
return
}
uid, err := global.Jwt.ParseToken(token)
if err != nil {
response.Fail(c, 403, "请先登录")
response.Fail(c, 403, response.TranslateMsg(c, "NeedLogin"))
c.Abort()
return
}
if uid == 0 {
response.Fail(c, 403, "请先登录")
response.Fail(c, 403, response.TranslateMsg(c, "NeedLogin"))
c.Abort()
return
}
@@ -34,12 +34,12 @@ func JwtAuth() gin.HandlerFunc {
// Username: "测试用户",
//}
if user.Id == 0 {
response.Fail(c, 403, "请先登录")
response.Fail(c, 403, response.TranslateMsg(c, "NeedLogin"))
c.Abort()
return
}
if !service.AllService.UserService.CheckUserEnable(user) {
response.Fail(c, 101, "你已被禁用")
response.Fail(c, 101, response.TranslateMsg(c, "Banned"))
c.Abort()
return
}

View File

@@ -0,0 +1,22 @@
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/lejianwen/rustdesk-api/v2/global"
"github.com/lejianwen/rustdesk-api/v2/http/response"
"net/http"
)
func Limiter() gin.HandlerFunc {
return func(c *gin.Context) {
loginLimiter := global.LoginLimiter
clientIp := c.ClientIP()
banned, _ := loginLimiter.CheckSecurityStatus(clientIp)
if banned {
response.Fail(c, http.StatusLocked, response.TranslateMsg(c, "Banned"))
c.Abort()
return
}
c.Next()
}
}

View File

@@ -1,10 +1,11 @@
package admin
type Login struct {
Username string `json:"username" validate:"required" label:"用户名"`
Password string `json:"password,omitempty" validate:"required" label:"密码"`
Platform string `json:"platform" label:"平台"`
Captcha string `json:"captcha,omitempty" label:"验证码"`
Username string `json:"username" validate:"required" label:"用户名"`
Password string `json:"password,omitempty" validate:"required" label:"密码"`
Platform string `json:"platform" label:"平台"`
Captcha string `json:"captcha,omitempty" label:"验证码"`
CaptchaId string `json:"captcha_id,omitempty"`
}
type LoginLogQuery struct {

View File

@@ -22,7 +22,6 @@ type OauthForm struct {
Scopes string `json:"scopes" validate:"omitempty"`
ClientId string `json:"client_id" validate:"required"`
ClientSecret string `json:"client_secret" validate:"required"`
RedirectUrl string `json:"redirect_url" validate:"required"`
AutoRegister *bool `json:"auto_register"`
PkceEnable *bool `json:"pkce_enable"`
PkceMethod string `json:"pkce_method"`
@@ -34,7 +33,6 @@ func (of *OauthForm) ToOauth() *model.Oauth {
OauthType: of.OauthType,
ClientId: of.ClientId,
ClientSecret: of.ClientSecret,
RedirectUrl: of.RedirectUrl,
AutoRegister: of.AutoRegister,
Issuer: of.Issuer,
Scopes: of.Scopes,

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,
}
}
@@ -41,6 +43,9 @@ type PeerQuery struct {
Id string `json:"id" form:"id"`
Hostname string `json:"hostname" form:"hostname"`
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

@@ -14,6 +14,7 @@ type UserForm struct {
GroupId uint `json:"group_id" validate:"required"`
IsAdmin *bool `json:"is_admin" `
Status model.StatusCode `json:"status" validate:"required,gte=0"`
Remark string `json:"remark"`
}
func (uf *UserForm) FromUser(user *model.User) *UserForm {
@@ -25,6 +26,7 @@ func (uf *UserForm) FromUser(user *model.User) *UserForm {
uf.GroupId = user.GroupId
uf.IsAdmin = user.IsAdmin
uf.Status = user.Status
uf.Remark = user.Remark
return uf
}
func (uf *UserForm) ToUser() *model.User {
@@ -37,6 +39,7 @@ func (uf *UserForm) ToUser() *model.User {
user.GroupId = uf.GroupId
user.IsAdmin = uf.IsAdmin
user.Status = uf.Status
user.Remark = uf.Remark
return user
}

View File

@@ -40,14 +40,14 @@ type LoginForm struct {
type UserListQuery struct {
Page uint `json:"page" form:"page" validate:"required" label:"页码"`
PageSize uint `json:"page_size" form:"page_size" validate:"required" label:"每页数量"`
PageSize uint `json:"pageSize" form:"pageSize" validate:"required" label:"每页数量"`
Status int `json:"status" form:"status" label:"状态"`
Accessible string `json:"accessible" form:"accessible"`
}
type PeerListQuery struct {
Page uint `json:"page" form:"page" validate:"required" label:"页码"`
PageSize uint `json:"page_size" form:"page_size" validate:"required" label:"每页数量"`
PageSize uint `json:"pageSize" form:"pageSize" validate:"required" label:"每页数量"`
Status int `json:"status" form:"status" label:"状态"`
Accessible string `json:"accessible" form:"accessible"`
}

View File

@@ -22,15 +22,3 @@ type UserOauthItem struct {
Op string `json:"op"`
Status int `json:"status"`
}
type GroupUsersPayload struct {
Id uint `json:"id"`
Username string `json:"username"`
Status int `json:"status"`
}
func (g *GroupUsersPayload) FromUser(user *model.User) {
g.Id = user.Id
g.Username = user.Username
g.Status = 1
}

View File

@@ -48,11 +48,17 @@ func ApiInit(g *gin.Engine) {
//api/oauth/callback
frg.GET("/oauth/callback", o.OauthCallback)
frg.GET("/oauth/login", o.OauthCallback)
frg.GET("/oauth/msg", o.Message)
frg.GET("/oidc/callback", o.OauthCallback)
frg.GET("/oidc/login", o.OauthCallback)
frg.GET("/oidc/msg", o.Message)
}
{
pe := &api.Peer{}
//提交系统信息
frg.POST("/sysinfo", pe.SysInfo)
frg.POST("/sysinfo_ver", pe.SysInfoVer)
}
if global.Config.App.WebClient == 1 {

View File

@@ -2,7 +2,6 @@ package orm
import (
"fmt"
"github.com/lejianwen/rustdesk-api/v2/global"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -10,14 +9,14 @@ import (
)
type MysqlConfig struct {
Dns string
Dsn string
MaxIdleConns int
MaxOpenConns int
}
func NewMysql(mysqlConf *MysqlConfig) *gorm.DB {
func NewMysql(mysqlConf *MysqlConfig, logwriter logger.Writer) *gorm.DB {
db, err := gorm.Open(mysql.New(mysql.Config{
DSN: mysqlConf.Dns, // DSN data source name
DSN: mysqlConf.Dsn, // DSN data source name
DefaultStringSize: 256, // string 类型字段的默认长度
//DisableDatetimePrecision: true, // 禁用 datetime 精度MySQL 5.6 之前的数据库不支持
//DontSupportRenameIndex: true, // 重命名索引时采用删除并新建的方式MySQL 5.7 之前的数据库和 MariaDB 不支持重命名索引
@@ -26,7 +25,7 @@ func NewMysql(mysqlConf *MysqlConfig) *gorm.DB {
}), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
Logger: logger.New(
global.Logger, // io writer
logwriter, // io writer
logger.Config{
SlowThreshold: time.Second, // Slow SQL threshold
LogLevel: logger.Warn, // Log level

45
lib/orm/postgresql.go Normal file
View File

@@ -0,0 +1,45 @@
package orm
import (
"fmt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"time"
)
type PostgresqlConfig struct {
Dsn string
MaxIdleConns int
MaxOpenConns int
}
func NewPostgresql(conf *PostgresqlConfig, logwriter logger.Writer) *gorm.DB {
db, err := gorm.Open(postgres.Open(conf.Dsn), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
Logger: logger.New(
logwriter, // io writer
logger.Config{
SlowThreshold: time.Second, // Slow SQL threshold
LogLevel: logger.Warn, // Log level
//IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
ParameterizedQueries: true, // Don't include params in the SQL log
Colorful: true,
},
),
})
if err != nil {
fmt.Println(err)
}
sqlDB, err2 := db.DB()
if err2 != nil {
fmt.Println(err2)
}
// SetMaxIdleConns 设置空闲连接池中连接的最大数量
sqlDB.SetMaxIdleConns(conf.MaxIdleConns)
// SetMaxOpenConns 设置打开数据库连接的最大数量。
sqlDB.SetMaxOpenConns(conf.MaxOpenConns)
return db
}

View File

@@ -2,7 +2,6 @@ package orm
import (
"fmt"
"github.com/lejianwen/rustdesk-api/v2/global"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -14,11 +13,11 @@ type SqliteConfig struct {
MaxOpenConns int
}
func NewSqlite(sqliteConf *SqliteConfig) *gorm.DB {
func NewSqlite(sqliteConf *SqliteConfig, logwriter logger.Writer) *gorm.DB {
db, err := gorm.Open(sqlite.Open("./data/rustdeskapi.db"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
Logger: logger.New(
global.Logger, // io writer
logwriter, // io writer
logger.Config{
SlowThreshold: time.Second, // Slow SQL threshold
LogLevel: logger.Warn, // Log level

View File

@@ -14,6 +14,7 @@ const (
OauthTypeGoogle string = "google"
OauthTypeOidc string = "oidc"
OauthTypeWebauth string = "webauth"
OauthTypeLinuxdo string = "linuxdo"
PKCEMethodS256 string = "S256"
PKCEMethodPlain string = "plain"
)
@@ -21,7 +22,7 @@ const (
// Validate the oauth type
func ValidateOauthType(oauthType string) error {
switch oauthType {
case OauthTypeGithub, OauthTypeGoogle, OauthTypeOidc, OauthTypeWebauth:
case OauthTypeGithub, OauthTypeGoogle, OauthTypeOidc, OauthTypeWebauth, OauthTypeLinuxdo:
return nil
default:
return errors.New("invalid Oauth type")
@@ -29,8 +30,9 @@ func ValidateOauthType(oauthType string) error {
}
const (
UserEndpointGithub string = "https://api.github.com/user"
IssuerGoogle string = "https://accounts.google.com"
UserEndpointGithub string = "https://api.github.com/user"
UserEndpointLinuxdo string = "https://connect.linux.do/api/user"
IssuerGoogle string = "https://accounts.google.com"
)
type Oauth struct {
@@ -39,12 +41,12 @@ type Oauth struct {
OauthType string `json:"oauth_type"`
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectUrl string `json:"redirect_url"`
//RedirectUrl string `json:"redirect_url"`
AutoRegister *bool `json:"auto_register"`
Scopes string `json:"scopes"`
Issuer string `json:"issuer"`
PkceEnable *bool `json:"pkce_enable"`
PkceMethod string `json:"pkce_method"`
PkceEnable *bool `json:"pkce_enable"`
PkceMethod string `json:"pkce_method"`
TimeModel
}
@@ -60,6 +62,8 @@ func (oa *Oauth) FormatOauthInfo() error {
oa.Op = OauthTypeGithub
case OauthTypeGoogle:
oa.Op = OauthTypeGoogle
case OauthTypeLinuxdo:
oa.Op = OauthTypeLinuxdo
}
// check if the op is empty, set the default value
op := strings.TrimSpace(oa.Op)
@@ -152,6 +156,24 @@ func (gu *GithubUser) ToOauthUser() *OauthUser {
}
}
type LinuxdoUser struct {
OauthUserBase
Id int `json:"id"`
Username string `json:"username"`
Avatar string `json:"avatar_url"`
}
func (lu *LinuxdoUser) ToOauthUser() *OauthUser {
return &OauthUser{
OpenId: strconv.Itoa(lu.Id),
Name: lu.Name,
Username: strings.ToLower(lu.Username),
Email: lu.Email,
VerifiedEmail: true, // linux.do 用户邮箱默认已验证
Picture: lu.Avatar,
}
}
type OauthList struct {
Oauths []*Oauth `json:"list"`
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
}

View File

@@ -11,6 +11,7 @@ type User struct {
GroupId uint `json:"group_id" gorm:"default:0;not null;index"`
IsAdmin *bool `json:"is_admin" gorm:"default:0;not null;"`
Status StatusCode `json:"status" gorm:"default:1;not null;"`
Remark string `json:"remark" gorm:"default:'';not null;"`
TimeModel
}

View File

@@ -33,6 +33,11 @@ description = "No access."
one = "No access."
other = "No access."
[NeedLogin]
description = "Need login."
one = "Please log in first."
other = "Please log in first."
[UsernameOrPasswordError]
description = "Username or password error."
one = "Username or password error."
@@ -137,4 +142,19 @@ other = "Captcha error."
[PwdLoginDisabled]
description = "Password login disabled."
one = "Password login disabled."
other = "Password login disabled."
other = "Password login disabled."
[CannotShareToSelf]
description = "Cannot share to self."
one = "Cannot share to self."
other = "Cannot share to self."
[Banned]
description = "Banned."
one = "Banned."
other = "Banned."
[RegisterSuccessWaitAdminConfirm]
description = "Register success, wait admin confirm."
one = "Register success, wait admin confirm."
other = "Register success, wait admin confirm."

View File

@@ -33,6 +33,11 @@ description = "No access."
one = "Sin acceso."
other = "Sin acceso."
[NeedLogin]
description = "Need login."
one = "Por favor inicie sesión primero."
other = "Por favor inicie sesión primero."
[UsernameOrPasswordError]
description = "Username or password error."
one = "Error de usuario o contraseña."
@@ -146,4 +151,19 @@ other = "Error de captcha."
[PwdLoginDisabled]
description = "Password login disabled."
one = "Inicio de sesión con contraseña deshabilitado."
other = "Inicio de sesión con contraseña deshabilitado."
other = "Inicio de sesión con contraseña deshabilitado."
[CannotShareToSelf]
description = "Cannot share to self."
one = "No se puede compartir con uno mismo."
other = "No se puede compartir con uno mismo."
[Banned]
description = "Banned."
one = "Prohibido."
other = "Prohibido."
[RegisterSuccessWaitAdminConfirm]
description = "Register success, wait admin confirm."
one = "Registro exitoso, espere la confirmación del administrador."
other = "Registro exitoso, espere la confirmación del administrador."

View File

@@ -33,6 +33,11 @@ description = "No access."
one = "Aucun d'access."
other = "Aucun d'access."
[NeedLogin]
description = "Need login."
one = "Veuillez d'abord vous connecter."
other = "Veuillez d'abord vous connecter."
[UsernameOrPasswordError]
description = "Username or password error."
one = "Nom d'utilisateur ou de mot de passe incorrect."
@@ -146,4 +151,19 @@ other = "Erreur de captcha."
[PwdLoginDisabled]
description = "Password login disabled."
one = "Connexion par mot de passe désactivée."
other = "Connexion par mot de passe désactivée."
other = "Connexion par mot de passe désactivée."
[CannotShareToSelf]
description = "Cannot share to self."
one = "Impossible de partager avec soi-même."
other = "Impossible de partager avec soi-même."
[Banned]
description = "Banned."
one = "Banni."
other = "Banni."
[RegisterSuccessWaitAdminConfirm]
description = "Register success wait admin confirm."
one = "Inscription réussie, veuillez attendre la confirmation de l'administrateur."
other = "Inscription réussie, veuillez attendre la confirmation de l'administrateur."

View File

@@ -33,6 +33,11 @@ description = "No access."
one = "접근할 수 없습니다."
other = "접근할 수 없습니다."
[NeedLogin]
description = "Need login."
one = "먼저 로그인해주세요."
other = "먼저 로그인해주세요."
[UsernameOrPasswordError]
description = "Username or password error."
one = "사용자 이름이나 비밀번호가 올바르지 않습니다."
@@ -141,3 +146,18 @@ other = "Captcha 오류."
description = "Password login disabled."
one = "비밀번호 로그인이 비활성화되었습니다."
other = "비밀번호 로그인이 비활성화되었습니다."
[CannotShareToSelf]
description = "Cannot share to self."
one = "자기 자신에게 공유할 수 없습니다."
other = "자기 자신에게 공유할 수 없습니다."
[Banned]
description = "Banned."
one = "금지됨."
other = "금지됨."
[RegisterSuccessWaitAdminConfirm]
description = "Register success wait admin confirm."
one = "가입 성공, 관리자 확인 대기 중."
other = "가입 성공, 관리자 확인 대기 중."

View File

@@ -33,6 +33,11 @@ description = "No access."
one = "Нет доступа."
other = "Нет доступа."
[NeedLogin]
description = "Need login."
one = "Пожалуйста, войдите в систему."
other = "Пожалуйста, войдите в систему."
[UsernameOrPasswordError]
description = "Username or password error."
one = "Неправильное имя пользователя или пароль."
@@ -146,4 +151,19 @@ other = "Ошибка капчи."
[PwdLoginDisabled]
description = "Password login disabled."
one = "Вход по паролю отключен."
other = "Вход по паролю отключен."
other = "Вход по паролю отключен."
[CannotShareToSelf]
description = "Cannot share to self."
one = "Нельзя поделиться с собой."
other = "Нельзя поделиться с собой."
[Banned]
description = "Banned."
one = "Заблокировано."
other = "Заблокировано."
[RegisterSuccessWaitAdminConfirm]
description = "Register success wait admin confirm."
one = "Регистрация прошла успешно, ожидайте подтверждения администратора."
other = "Регистрация прошла успешно, ожидайте подтверждения администратора."

View File

@@ -33,6 +33,11 @@ description = "No access."
one = "无权限。"
other = "无权限。"
[NeedLogin]
description = "Need login."
one = "请先登录。"
other = "请先登录。"
[UsernameOrPasswordError]
description = "Username or password error."
one = "用户名或密码错误。"
@@ -139,4 +144,19 @@ other = "验证码错误。"
[PwdLoginDisabled]
description = "Password login disabled."
one = "密码登录已禁用。"
other = "密码登录已禁用。"
other = "密码登录已禁用。"
[CannotShareToSelf]
description = "Cannot share to self."
one = "不能共享给自己。"
other = "不能共享给自己。"
[Banned]
description = "Banned."
one = "已被封禁。"
other = "已被封禁。"
[RegisterSuccessWaitAdminConfirm]
description = "Register success, wait for admin confirm."
one = "注册成功,请等待管理员审核。"
other = "注册成功,请等待管理员审核。"

View File

@@ -5,8 +5,8 @@ other = "測試2 {{.P0}}"
[ParamsError]
description = "Params validation failed."
one = "引數錯誤。"
other = "引數錯誤。"
one = "參數驗證失敗。"
other = "參數驗證失敗。"
[OperationFailed]
description = "OperationFailed."
@@ -20,18 +20,23 @@ other = "操作成功。"
[ItemExists]
description = "Item already exists."
one = "資料已存在。"
other = "資料已存在。"
one = "項目已存在。"
other = "項目已存在。"
[ItemNotFound]
description = "Item not found."
one = "資料不存在。"
other = "資料不存在。"
one = "找不到項目。"
other = "找不到項目。"
[NoAccess]
description = "No access."
one = "無許可權。"
other = "無許可權。"
one = "無權限存取。"
other = "無權限存取。"
[NeedLogin]
description = "Need login."
one = "請先登入。"
other = "請先登入。"
[UsernameOrPasswordError]
description = "Username or password error."
@@ -45,24 +50,23 @@ other = "系統錯誤。"
[ConfigNotFound]
description = "Config not found."
one = "配置不存在。"
other = "配置不存在。"
one = "找不到設定。"
other = "找不到設定。"
#授權過期
[OauthExpired]
description = "Oauth expired."
one = "授權過期,請重新授權。"
other = "授權過期,請重新授權。"
one = "OAuth 已過期,請重。"
other = "OAuth 已過期,請重。"
[OauthFailed]
description = "Oauth failed."
one = "授權失敗。"
other = "授權失敗。"
one = "OAuth 失敗。"
other = "OAuth 失敗。"
[OauthHasBindOtherUser]
description = "Oauth has bind other user."
one = "授權已繫結其他使用者。"
other = "授權已繫結其他使用者。"
one = "OAuth 已綁定其他使用者。"
other = "OAuth 已綁定其他使用者。"
[ParamIsEmpty]
description = "Param is empty."
@@ -71,56 +75,64 @@ other = "{{.P0}} 為空。"
[BindFail]
description = "Bind fail."
one = "繫結失敗。"
other = "繫結失敗。"
one = "綁定失敗。"
other = "綁定失敗。"
[BindSuccess]
description = "Bind success."
one = "繫結成功。"
other = "繫結成功。"
one = "綁定成功。"
other = "綁定成功。"
[OauthHasBeenSuccess]
description = "Oauth has been success."
one = "授權已成功。"
other = "授權已成功。"
one = "OAuth 已成功。"
other = "OAuth 已成功。"
[OauthSuccess]
description = "Oauth success."
one = "授權成功。"
other = "授權成功。"
one = "OAuth 成功。"
other = "OAuth 成功。"
[OauthRegisterSuccess]
description = "Oauth register success."
one = "授權註冊成功。"
other = "授權註冊成功。"
one = "OAuth 註冊成功。"
other = "OAuth 註冊成功。"
[OauthRegisterFailed]
description = "Oauth register failed."
one = "授權註冊失敗。"
other = "授權註冊失敗。"
one = "OAuth 註冊失敗。"
other = "OAuth 註冊失敗。"
[GetOauthTokenError]
description = "Get oauth token error."
one = "獲取授權token失敗。"
other = "獲取授權token失敗。"
one = "取得 OAuth 權杖錯誤。"
other = "取得 OAuth 權杖錯誤。"
[GetOauthUserInfoError]
description = "Get oauth user info error."
one = "獲取授權使用者資訊失敗。"
other = "獲取授權使用者資訊失敗。"
one = "取得 OAuth 使用者資訊錯誤。"
other = "取得 OAuth 使用者資訊錯誤。"
[DecodeOauthUserInfoError]
description = "Decode oauth user info error."
one = "解析授權使用者資訊失敗。"
other = "解析授權使用者資訊失敗。"
one = "解析 OAuth 使用者資訊錯誤。"
other = "解析 OAuth 使用者資訊錯誤。"
[OldPasswordError]
description = "Old password error."
one = "舊密碼錯誤。"
other = "舊密碼錯誤。"
[DefaultGroup]
description = "Default group."
one = "預設組"
other = "預設組"
one = "預設組"
other = "預設組"
[ShareGroup]
description = "Share group."
one = "共享組"
other = "共享組"
one = "共享組"
other = "共享組"
[RegisterClosed]
description = "Register closed."
one = "註冊已關閉。"
@@ -138,5 +150,20 @@ other = "驗證碼錯誤。"
[PwdLoginDisabled]
description = "Password login disabled."
one = "密碼登錄已禁用。"
other = "密碼登錄已禁用。"
one = "密碼登入已停用。"
other = "密碼登入已停用。"
[CannotShareToSelf]
description = "Cannot share to self."
one = "無法分享給自己。"
other = "無法分享給自己。"
[Banned]
description = "Banned."
one = "已被禁用。"
other = "已被禁用。"
[RegisterSuccessWaitAdminConfirm]
description = "Register success, wait admin confirm."
one = "註冊成功,等待管理員確認。"
other = "註冊成功,等待管理員確認。"

View File

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>授权失败 - RustDesk API</title>
<title>OauthFailed - RustDesk API</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
@@ -57,17 +57,25 @@
}
</style>
<link rel="stylesheet" href="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/font-awesome/6.0.0/css/all.min.css">
<script>
var lang = navigator.language || navigator.userLanguage || 'zh-CN';
var title = 'OauthFailed'
var msg = '{{.message}}'
var btn = 'Close'
document.writeln('<script src="/api/oidc/msg?lang=' + lang + '&msg=' + msg + '&title=OauthFailed"><\/script>');
</script>
</head>
<body>
<div class="success-container">
<i class="fas fa-triangle-exclamation checkmark"></i>
<h1>授权失败!</h1>
<p>{{.message}}</p>
<a href="javascript:window.close()" class="return-link">关闭页面</a>
<i class="fas fa-triangle-exclamation checkmark"></i>
<h1 id="h1"></h1>
<p id="msg"></p>
<a href="javascript:window.close()" class="return-link" id="btn">Close</a>
</div>
<script>
document.title = title + ' - RustDesk API';
document.getElementById('h1').innerText = title;
document.getElementById('msg').innerText = msg;
</script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>授权成功 - RustDesk API</title>
<title>OauthSuccess - RustDesk API</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
@@ -56,18 +56,27 @@
background-color: #45a049;
}
</style>
<script>
var lang = navigator.language || navigator.userLanguage || 'zh-CN';
var title = 'OauthSuccess'
var msg = '{{.message}}'
var btn = 'Close'
document.writeln('<script src="/api/oidc/msg?lang=' + lang + '&msg=' + msg + '&title=OauthSuccess"><\/script>');
</script>
</head>
<body>
<div class="success-container">
<i class="fas fa-check-circle checkmark"></i>
<h1>授权成功!</h1>
<p>您已成功授权访问您的账户。</p>
<p>现在可以关闭本页面或返回应用继续操作。</p>
<a href="javascript:window.close()" class="return-link">关闭页面</a>
<h1 id="h1"></h1>
<!-- <p>您已成功授权访问您的账户。</p>-->
<!-- <p>现在可以关闭本页面或返回应用继续操作。</p>-->
<a href="javascript:window.close()" class="return-link">Close</a>
</div>
<script>
document.title = title + ' - RustDesk API';
document.getElementById('h1').innerText = title;
document.getElementById('msg').innerText = msg;
</script>
</body>
</html>

View File

@@ -38,5 +38,21 @@
"asset": "assets/address_book.ttf"
}
]
},
{
"family": "DeviceGroup",
"fonts": [
{
"asset": "assets/device_group.ttf"
}
]
},
{
"family": "More",
"fonts": [
{
"asset": "assets/more.ttf"
}
]
}
]

Binary file not shown.

BIN
resources/web2/assets/assets/more.ttf vendored Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
<!DOCTYPE html>
<html>
<head>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
@@ -16,195 +16,196 @@
-->
<base href="/webclient2/" />
<meta charset="UTF-8" />
<meta content="IE=Edge" http-equiv="X-UA-Compatible" />
<meta name="description" content="Remote Desktop." />
<meta charset="UTF-8"/>
<meta content="IE=Edge" http-equiv="X-UA-Compatible"/>
<meta name="description" content="Remote Desktop."/>
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="RustDesk" />
<link rel="apple-touch-icon" href="icons/Icon-192.png?v=1a7ad736" />
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black"/>
<meta name="apple-mobile-web-app-title" content="RustDesk"/>
<link rel="apple-touch-icon" href="icons/Icon-192.png?v=1a7ad736"/>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="favicon.svg?v=8fcccd9a" />
<link rel="icon" type="image/svg+xml" href="favicon.svg?v=8fcccd9a"/>
<title>RustDesk</title>
<script src="/webclient-config/index.js"></script>
<link rel="manifest" href="manifest.json" />
<script type="module" crossorigin src="js/dist/index.js?v=cabfd933"></script>
<link rel="modulepreload" href="js/dist/vendor.js?v=0b990c6e" />
<link rel="manifest" href="manifest.json"/>
<script type="module" crossorigin src="js/dist/index.js?v=bd4ac5e9"></script>
<link rel="modulepreload" href="js/dist/vendor.js?v=0b990c6e"/>
<style>
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
}
#root {
background-repeat: no-repeat;
background-size: 100% auto;
}
.loading-title {
font-size: 1.1rem;
}
.loading-sub-title {
margin-top: 20px;
font-size: 1rem;
color: #888;
}
.page-loading-warp {
display: flex;
align-items: center;
justify-content: center;
padding: 26px;
}
.ant-spin {
position: absolute;
display: none;
-webkit-box-sizing: border-box;
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.65);
color: #1890ff;
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5;
text-align: center;
list-style: none;
opacity: 0;
-webkit-transition: -webkit-transform 0.3s
cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
-webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
-webkit-font-feature-settings: "tnum";
font-feature-settings: "tnum";
}
.ant-spin-spinning {
position: static;
display: inline-block;
opacity: 1;
}
.ant-spin-dot {
position: relative;
display: inline-block;
width: 20px;
height: 20px;
font-size: 20px;
}
.ant-spin-dot-item {
position: absolute;
display: block;
width: 9px;
height: 9px;
background-color: #1890ff;
border-radius: 100%;
-webkit-transform: scale(0.75);
-ms-transform: scale(0.75);
transform: scale(0.75);
-webkit-transform-origin: 50% 50%;
-ms-transform-origin: 50% 50%;
transform-origin: 50% 50%;
opacity: 0.3;
-webkit-animation: antspinmove 1s infinite linear alternate;
animation: antSpinMove 1s infinite linear alternate;
}
.ant-spin-dot-item:nth-child(1) {
top: 0;
left: 0;
}
.ant-spin-dot-item:nth-child(2) {
top: 0;
right: 0;
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.ant-spin-dot-item:nth-child(3) {
right: 0;
bottom: 0;
-webkit-animation-delay: 0.8s;
animation-delay: 0.8s;
}
.ant-spin-dot-item:nth-child(4) {
bottom: 0;
left: 0;
-webkit-animation-delay: 1.2s;
animation-delay: 1.2s;
}
.ant-spin-dot-spin {
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
-webkit-animation: antrotate 1.2s infinite linear;
animation: antRotate 1.2s infinite linear;
}
.ant-spin-lg .ant-spin-dot {
width: 32px;
height: 32px;
font-size: 32px;
}
.ant-spin-lg .ant-spin-dot i {
width: 14px;
height: 14px;
}
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.ant-spin-blur {
background: #fff;
opacity: 0.5;
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
}
}
@-webkit-keyframes antSpinMove {
to {
opacity: 1;
#root {
background-repeat: no-repeat;
background-size: 100% auto;
}
}
@keyframes antSpinMove {
to {
opacity: 1;
.loading-title {
font-size: 1.1rem;
}
}
@-webkit-keyframes antRotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
.loading-sub-title {
margin-top: 20px;
font-size: 1rem;
color: #888;
}
}
@keyframes antRotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
.page-loading-warp {
display: flex;
align-items: center;
justify-content: center;
padding: 26px;
}
.ant-spin {
position: absolute;
display: none;
-webkit-box-sizing: border-box;
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.65);
color: #1890ff;
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5;
text-align: center;
list-style: none;
opacity: 0;
-webkit-transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
-webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
-webkit-font-feature-settings: "tnum";
font-feature-settings: "tnum";
}
.ant-spin-spinning {
position: static;
display: inline-block;
opacity: 1;
}
.ant-spin-dot {
position: relative;
display: inline-block;
width: 20px;
height: 20px;
font-size: 20px;
}
.ant-spin-dot-item {
position: absolute;
display: block;
width: 9px;
height: 9px;
background-color: #1890ff;
border-radius: 100%;
-webkit-transform: scale(0.75);
-ms-transform: scale(0.75);
transform: scale(0.75);
-webkit-transform-origin: 50% 50%;
-ms-transform-origin: 50% 50%;
transform-origin: 50% 50%;
opacity: 0.3;
-webkit-animation: antspinmove 1s infinite linear alternate;
animation: antSpinMove 1s infinite linear alternate;
}
.ant-spin-dot-item:nth-child(1) {
top: 0;
left: 0;
}
.ant-spin-dot-item:nth-child(2) {
top: 0;
right: 0;
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.ant-spin-dot-item:nth-child(3) {
right: 0;
bottom: 0;
-webkit-animation-delay: 0.8s;
animation-delay: 0.8s;
}
.ant-spin-dot-item:nth-child(4) {
bottom: 0;
left: 0;
-webkit-animation-delay: 1.2s;
animation-delay: 1.2s;
}
.ant-spin-dot-spin {
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
-webkit-animation: antrotate 1.2s infinite linear;
animation: antRotate 1.2s infinite linear;
}
.ant-spin-lg .ant-spin-dot {
width: 32px;
height: 32px;
font-size: 32px;
}
.ant-spin-lg .ant-spin-dot i {
width: 14px;
height: 14px;
}
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.ant-spin-blur {
background: #fff;
opacity: 0.5;
}
}
@-webkit-keyframes antSpinMove {
to {
opacity: 1;
}
}
@keyframes antSpinMove {
to {
opacity: 1;
}
}
@-webkit-keyframes antRotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
}
}
@keyframes antRotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
}
}
}
</style>
</head>
</head>
<body>
<div id="root">
<div
id="div-background"
style="
<body>
<div id="root">
<div
id="div-background"
style="
display: flex;
flex-direction: column;
align-items: center;
@@ -212,130 +213,111 @@
height: 100%;
min-height: 420px;
"
>
<img src="./favicon.svg?v=8fcccd9a" alt="logo" width="256" />
>
<img src="./favicon.svg?v=8fcccd9a" alt="logo" width="256"/>
<div class="page-loading-warp">
<div class="ant-spin ant-spin-lg ant-spin-spinning">
<div class="ant-spin ant-spin-lg ant-spin-spinning">
<span class="ant-spin-dot ant-spin-dot-spin">
<i class="ant-spin-dot-item"></i>
<i class="ant-spin-dot-item"></i>
<i class="ant-spin-dot-item"></i><i class="ant-spin-dot-item"></i>
</span>
</div>
</div>
</div>
<div
style="display: flex; align-items: center; justify-content: center"
style="display: flex; align-items: center; justify-content: center"
>
<img src="./favicon.svg?v=8fcccd9a" width="32" style="margin-right: 8px" />
<span id="span-text">RustDesk Web Client V2 Preview</span>
<img src="./favicon.svg?v=8fcccd9a" width="32" style="margin-right: 8px"/>
<span id="span-text">RustDesk Web Client V2 Preview</span>
</div>
</div>
</div>
<!-- This script installs service_worker.js to provide PWA functionality to
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script>
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
</div>
<!-- This script installs service_worker.js to provide PWA functionality to
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script>
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
const myTheme = localStorage.getItem("wc-option:local:theme");
const them = myTheme || systemTheme;
const myTheme = localStorage.getItem("wc-option:local:theme");
const them = myTheme || systemTheme;
const divBackground = document.querySelector("#div-background");
if (divBackground) {
const divBackground = document.querySelector("#div-background");
if (divBackground) {
divBackground.style.backgroundColor = them === "dark" ? "#000" : "#fff";
}
const spanConsole = document.querySelector("#span-text");
if (spanConsole) {
}
const spanConsole = document.querySelector("#span-text");
if (spanConsole) {
spanConsole.style.color = them === "dark" ? "#fff" : "#000";
}
}
const serviceWorkerVersion = "3267265270";
var scriptLoaded = false;
function loadMainDartJs() {
const serviceWorkerVersion = "461457302";
var scriptLoaded = false;
function loadMainDartJs() {
if (scriptLoaded) {
return;
return;
}
scriptLoaded = true;
var scriptTag = document.createElement("script");
scriptTag.src = "main.dart.js?v=060a626e";
scriptTag.src = "main.dart.js?v=6d16cb80";
scriptTag.type = "application/javascript";
document.body.append(scriptTag);
}
}
if ("serviceWorker" in navigator) {
if ("serviceWorker" in navigator) {
// Service workers are supported. Use them.
window.addEventListener("load", function () {
// Wait for registration to finish before dropping the <script> tag.
// Otherwise, the browser will load the script multiple times,
// potentially different versions.
var serviceWorkerUrl =
"flutter_service_worker.js?v=" + serviceWorkerVersion;
navigator.serviceWorker.register(serviceWorkerUrl).then((reg) => {
function waitForActivation(serviceWorker) {
serviceWorker.addEventListener("statechange", () => {
if (serviceWorker.state == "activated") {
console.log("Installed new service worker.");
loadMainDartJs();
// Wait for registration to finish before dropping the <script> tag.
// Otherwise, the browser will load the script multiple times,
// potentially different versions.
var serviceWorkerUrl =
"flutter_service_worker.js?v=" + serviceWorkerVersion;
navigator.serviceWorker.register(serviceWorkerUrl).then((reg) => {
function waitForActivation(serviceWorker) {
serviceWorker.addEventListener("statechange", () => {
if (serviceWorker.state == "activated") {
console.log("Installed new service worker.");
loadMainDartJs();
}
});
}
});
}
if (!reg.active && (reg.installing || reg.waiting)) {
// No active web worker and we have installed or are installing
// one for the first time. Simply wait for it to activate.
waitForActivation(reg.installing || reg.waiting);
} else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
// When the app updates the serviceWorkerVersion changes, so we
// need to ask the service worker to update.
console.log("New service worker available.");
reg.update();
waitForActivation(reg.installing);
} else {
// Existing service worker is still good.
console.log("Loading app from service worker.");
loadMainDartJs();
}
});
// If service worker doesn't succeed in a reasonable amount of time,
// fallback to plaint <script> tag.
setTimeout(() => {
if (!scriptLoaded) {
console.warn(
"Failed to load app from service worker. Falling back to plain <script> tag."
);
loadMainDartJs();
}
}, 4000);
if (!reg.active && (reg.installing || reg.waiting)) {
// No active web worker and we have installed or are installing
// one for the first time. Simply wait for it to activate.
waitForActivation(reg.installing || reg.waiting);
} else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
// When the app updates the serviceWorkerVersion changes, so we
// need to ask the service worker to update.
console.log("New service worker available.");
reg.update();
waitForActivation(reg.installing);
} else {
// Existing service worker is still good.
console.log("Loading app from service worker.");
loadMainDartJs();
}
});
// If service worker doesn't succeed in a reasonable amount of time,
// fallback to plaint <script> tag.
setTimeout(() => {
if (!scriptLoaded) {
console.warn(
"Failed to load app from service worker. Falling back to plain <script> tag."
);
loadMainDartJs();
}
}, 4000);
});
} else {
} else {
// Service workers not supported. Just drop the <script> tag.
loadMainDartJs();
}
</script>
<script src="libs/stream/ponyfill.min.js"></script>
<script src="libs/stream/StreamSaver.min.js"></script>
<script src="libs/firebase-app.js?8.10.1"></script>
<script src="libs/firebase-analytics.js?8.10.1"></script>
<script>
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: "AIzaSyCgehIZk1aFP0E7wZtYRRqrfvNiNAF39-A",
authDomain: "rustdesk.firebaseapp.com",
databaseURL: "https://rustdesk.firebaseio.com",
projectId: "rustdesk",
storageBucket: "rustdesk.appspot.com",
messagingSenderId: "768133699366",
appId: "1:768133699366:web:d50faf0792cb208d7993e7",
measurementId: "G-9PEH85N6ZQ",
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);
firebase.analytics();
</script>
</body>
}
</script>
<script src="libs/stream/ponyfill.min.js"></script>
<script src="libs/stream/StreamSaver.min.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
window._gwen = {}
window._gwen.kv = {}
//fix 语言
if(!localStorage.getItem('wc-option:local:lang') && navigator.language){
localStorage.setItem('wc-option:local:lang', navigator.language.toLowerCase())
}
const storage_prefix = 'wc-'
const apiserver = localStorage.getItem('wc-api-server')
@@ -46,7 +52,7 @@ if (share_token) {
password: peer.tmppwd,
}*/
//修改location
window.location.href = `/webclient2/#/${peer.info.id}?password=${peer.tmppwd}`
window.location.href = `/webclient2/#/${peer.info.id}?password=${encodeURIComponent(peer.tmppwd)}`
}
})
}

191527
resources/web2/main.dart.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -293,8 +293,11 @@ func (s *AddressBookService) RuleInfoById(u uint) *model.AddressBookCollectionRu
return p
}
func (s *AddressBookService) RulePersonalInfoByToIdAndCid(toid, cid uint) *model.AddressBookCollectionRule {
return s.RuleInfoByToIdAndCid(model.ShareAddressBookRuleTypePersonal, toid, cid)
}
func (s *AddressBookService) RuleInfoByToIdAndCid(t int, toid, cid uint) *model.AddressBookCollectionRule {
p := &model.AddressBookCollectionRule{}
DB.Where("type = ? and to_id = ? and collection_id = ?", model.ShareAddressBookRuleTypePersonal, toid, cid).First(p)
DB.Where("type = ? and to_id = ? and collection_id = ?", t, toid, cid).First(p)
return p
}
func (s *AddressBookService) CreateRule(t *model.AddressBookCollectionRule) error {

39
service/app.go Normal file
View File

@@ -0,0 +1,39 @@
package service
import (
"os"
"sync"
"time"
)
type AppService struct {
}
var version = ""
var startTime = ""
var once = &sync.Once{}
func (a *AppService) GetAppVersion() string {
if version != "" {
return version
}
once.Do(func() {
v, err := os.ReadFile("resources/version")
if err != nil {
return
}
version = string(v)
})
return version
}
func init() {
// Initialize the AppService if needed
startTime = time.Now().Format("2006-01-02 15:04:05")
}
// GetStartTime
func (a *AppService) GetStartTime() string {
return startTime
}

33
service/app_test.go Normal file
View File

@@ -0,0 +1,33 @@
package service
import (
"sync"
"testing"
)
// TestGetAppVersion
func TestGetAppVersion(t *testing.T) {
s := &AppService{}
v := s.GetAppVersion()
// 打印结果
t.Logf("App Version: %s", v)
}
func TestMultipleGetAppVersion(t *testing.T) {
s := &AppService{}
//并发测试
// 使用 WaitGroup 等待所有 goroutine 完成
wg := sync.WaitGroup{}
wg.Add(10) // 启动 10 个 goroutine
// 启动 10 个 goroutine
for i := 0; i < 10; i++ {
go func() {
defer wg.Done() // 完成后减少计数
v := s.GetAppVersion()
// 打印结果
t.Logf("App Version: %s", v)
}()
}
// 等待所有 goroutine 完成
wg.Wait()
}

View File

@@ -30,6 +30,7 @@ var (
ErrLdapBindFailed = errors.New("LdapBindFailed")
ErrLdapToLocalUserFailed = errors.New("LdapToLocalUserFailed")
ErrLdapCreateUserFailed = errors.New("LdapCreateUserFailed")
ErrLdapPasswordNotMatch = errors.New("PasswordNotMatch")
)
// LdapService is responsible for LDAP authentication and user synchronization.
@@ -119,7 +120,7 @@ func (ls *LdapService) connectAndBindAdmin(cfg *config.Ldap) (*ldap.Conn, error)
func (ls *LdapService) verifyCredentials(cfg *config.Ldap, username, password string) error {
ldapConn, err := ls.connectAndBind(cfg, username, password)
if err != nil {
return err
return ErrLdapPasswordNotMatch
}
defer ldapConn.Close()
return nil
@@ -136,6 +137,21 @@ 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
}
user, err := ls.mapToLocalUser(cfg, ldapUser)
if err != nil {
return nil, errors.Join(ErrLdapToLocalUserFailed, err)
@@ -143,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) {
@@ -406,7 +462,7 @@ func (ls *LdapService) isUserAdmin(cfg *config.Ldap, ldapUser *LdapUser) bool {
// Check "memberOf" directly
if len(ldapUser.MemberOf) > 0 {
for _, group := range ldapUser.MemberOf {
if group == adminGroup {
if strings.EqualFold(group, adminGroup) {
return true
}
}

View File

@@ -4,11 +4,13 @@ import (
"context"
"encoding/json"
"errors"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/lejianwen/rustdesk-api/v2/model"
"github.com/lejianwen/rustdesk-api/v2/utils"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
// "golang.org/x/oauth2/google"
"gorm.io/gorm"
// "io"
@@ -154,6 +156,18 @@ func (os *OauthService) GithubProvider() *oidc.Provider {
}).NewProvider(context.Background())
}
func (os *OauthService) LinuxdoProvider() *oidc.Provider {
return (&oidc.ProviderConfig{
IssuerURL: "",
AuthURL: "https://connect.linux.do/oauth2/authorize",
TokenURL: "https://connect.linux.do/oauth2/token",
DeviceAuthURL: "",
UserInfoURL: model.UserEndpointLinuxdo,
JWKSURL: "",
Algorithms: nil,
}).NewProvider(context.Background())
}
// GetOauthConfig retrieves the OAuth2 configuration based on the provider name
func (os *OauthService) GetOauthConfig(op string) (err error, oauthInfo *model.Oauth, oauthConfig *oauth2.Config, provider *oidc.Provider) {
//err, oauthInfo, oauthConfig = os.getOauthConfigGeneral(op)
@@ -161,14 +175,10 @@ func (os *OauthService) GetOauthConfig(op string) (err error, oauthInfo *model.O
if oauthInfo.Id == 0 || oauthInfo.ClientId == "" || oauthInfo.ClientSecret == "" {
return errors.New("ConfigNotFound"), nil, nil, nil
}
// If the redirect URL is empty, use the default redirect URL
if oauthInfo.RedirectUrl == "" {
oauthInfo.RedirectUrl = Config.Rustdesk.ApiServer + "/api/oidc/callback"
}
oauthConfig = &oauth2.Config{
ClientID: oauthInfo.ClientId,
ClientSecret: oauthInfo.ClientSecret,
RedirectURL: oauthInfo.RedirectUrl,
RedirectURL: Config.Rustdesk.ApiServer + "/api/oidc/callback",
}
// Maybe should validate the oauthConfig here
@@ -182,6 +192,10 @@ func (os *OauthService) GetOauthConfig(op string) (err error, oauthInfo *model.O
oauthConfig.Endpoint = github.Endpoint
oauthConfig.Scopes = []string{"read:user", "user:email"}
provider = os.GithubProvider()
case model.OauthTypeLinuxdo:
provider = os.LinuxdoProvider()
oauthConfig.Endpoint = provider.Endpoint()
oauthConfig.Scopes = []string{"profile"}
//case model.OauthTypeGoogle: //google单独出来可以少一次FetchOidcEndpoint请求
// oauthConfig.Endpoint = google.Endpoint
// oauthConfig.Scopes = os.constructScopes(oauthInfo.Scopes)
@@ -299,6 +313,16 @@ func (os *OauthService) githubCallback(oauthConfig *oauth2.Config, provider *oid
return nil, user.ToOauthUser()
}
// linuxdoCallback linux.do回调
func (os *OauthService) linuxdoCallback(oauthConfig *oauth2.Config, provider *oidc.Provider, code, verifier, nonce string) (error, *model.OauthUser) {
var user = &model.LinuxdoUser{}
err, _ := os.callbackBase(oauthConfig, provider, code, verifier, nonce, user)
if err != nil {
return err, nil
}
return nil, user.ToOauthUser()
}
// oidcCallback oidc回调, 通过code获取用户信息
func (os *OauthService) oidcCallback(oauthConfig *oauth2.Config, provider *oidc.Provider, code, verifier, nonce string) (error, *model.OauthUser) {
var user = &model.OidcUser{}
@@ -319,6 +343,8 @@ func (os *OauthService) Callback(code, verifier, op, nonce string) (err error, o
switch oauthType {
case model.OauthTypeGithub:
err, oauthUser = os.githubCallback(oauthConfig, provider, code, verifier, nonce)
case model.OauthTypeLinuxdo:
err, oauthUser = os.linuxdoCallback(oauthConfig, provider, code, verifier, nonce)
case model.OauthTypeOidc, model.OauthTypeGoogle:
err, oauthUser = os.oidcCallback(oauthConfig, provider, code, verifier, nonce)
default:

View File

@@ -126,7 +126,14 @@ func (ps *PeerService) GetUuidListByIDs(ids []uint) ([]string, error) {
err := DB.Model(&model.Peer{}).
Where("row_id in (?)", ids).
Pluck("uuid", &uuids).Error
return uuids, err
//过滤uuids中的空字符串
var newUuids []string
for _, uuid := range uuids {
if uuid != "" {
newUuids = append(newUuids, uuid)
}
}
return newUuids, err
}
// BatchDelete 批量删除, 同时也应该删除token

View File

@@ -40,14 +40,7 @@ func (is *ServerCmdService) Create(u *model.ServerCmd) error {
}
// SendCmd 发送命令
func (is *ServerCmdService) SendCmd(target string, cmd string, arg string) (string, error) {
port := 0
switch target {
case model.ServerCmdTargetIdServer:
port = Config.Rustdesk.IdServerPort - 1
case model.ServerCmdTargetRelayServer:
port = Config.Rustdesk.RelayServerPort
}
func (is *ServerCmdService) SendCmd(port int, cmd string, arg string) (string, error) {
//组装命令
cmd = cmd + " " + arg
res, err := is.SendSocketCmd("v6", port, cmd)

View File

@@ -23,6 +23,7 @@ type Service struct {
*ShareRecordService
*ServerCmdService
*LdapService
*AppService
}
type Dependencies struct {

View File

@@ -2,14 +2,14 @@ package service
import (
"errors"
"github.com/lejianwen/rustdesk-api/v2/model"
"github.com/lejianwen/rustdesk-api/v2/utils"
"math/rand"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/lejianwen/rustdesk-api/v2/model"
"github.com/lejianwen/rustdesk-api/v2/utils"
"gorm.io/gorm"
)
@@ -55,7 +55,18 @@ func (us *UserService) InfoByUsernamePassword(username, password string) *model.
Logger.Warn("Fallback to local database")
}
u := &model.User{}
DB.Where("username = ? and password = ?", username, us.EncryptPassword(password)).First(u)
DB.Where("username = ?", username).First(u)
if u.Id == 0 {
return u
}
ok, newHash, err := utils.VerifyPassword(u.Password, password)
if err != nil || !ok {
return &model.User{}
}
if newHash != "" {
DB.Model(u).Update("password", newHash)
u.Password = newHash
}
return u
}
@@ -151,11 +162,6 @@ func (us *UserService) ListIdAndNameByGroupId(groupId uint) (res []*model.User)
return res
}
// EncryptPassword 加密密码
func (us *UserService) EncryptPassword(password string) string {
return utils.Md5(password + "rustdesk-api")
}
// CheckUserEnable 判断用户是否禁用
func (us *UserService) CheckUserEnable(u *model.User) bool {
return u.Status == model.COMMON_STATUS_ENABLE
@@ -168,7 +174,11 @@ func (us *UserService) Create(u *model.User) error {
return errors.New("UsernameExists")
}
u.Username = us.formatUsername(u.Username)
u.Password = us.EncryptPassword(u.Password)
var err error
u.Password, err = utils.EncryptPassword(u.Password)
if err != nil {
return err
}
res := DB.Create(u).Error
return res
}
@@ -268,8 +278,12 @@ func (us *UserService) FlushTokenByUuids(uuids []string) error {
// UpdatePassword 更新密码
func (us *UserService) UpdatePassword(u *model.User, password string) error {
u.Password = us.EncryptPassword(password)
err := DB.Model(u).Update("password", u.Password).Error
var err error
u.Password, err = utils.EncryptPassword(password)
if err != nil {
return err
}
err = DB.Model(u).Update("password", u.Password).Error
if err != nil {
return err
}
@@ -381,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
}
@@ -412,12 +426,13 @@ func (us *UserService) IsPasswordEmptyByUser(u *model.User) bool {
}
// Register 注册, 如果用户名已存在则返回nil
func (us *UserService) Register(username string, email string, password string) *model.User {
func (us *UserService) Register(username string, email string, password string, status model.StatusCode) *model.User {
u := &model.User{
Username: username,
Email: email,
Password: password,
GroupId: 1,
Status: status,
}
err := us.Create(u)
if err != nil {
@@ -485,8 +500,9 @@ func (us *UserService) RefreshAccessToken(ut *model.UserToken) {
ut.ExpiredAt = us.UserTokenExpireTimestamp()
DB.Model(ut).Update("expired_at", ut.ExpiredAt)
}
func (us *UserService) AutoRefreshAccessToken(ut *model.UserToken) {
if ut.ExpiredAt-time.Now().Unix() < 86400 {
if ut.ExpiredAt-time.Now().Unix() < Config.App.TokenExpire.Milliseconds()/3000 {
us.RefreshAccessToken(ut)
}
}

49
utils/captcha.go Normal file
View File

@@ -0,0 +1,49 @@
package utils
import (
"github.com/mojocn/base64Captcha"
"time"
)
var capdString = base64Captcha.NewDriverString(50, 150, 0, 5, 4, "123456789abcdefghijklmnopqrstuvwxyz", nil, nil,
[]string{"3Dumb.ttf", "ApothecaryFont.ttf", "Comismsh.ttf", "Flim-Flam.ttf", "RitaSmith.ttf", "wqy-microhei.ttc"})
var capdMath = base64Captcha.NewDriverMath(50, 150, 3, 10, nil, nil, nil)
type B64StringCaptchaProvider struct{}
func (p B64StringCaptchaProvider) Generate() (string, string, string, error) {
id, content, answer := capdString.GenerateIdQuestionAnswer()
return id, content, answer, nil
}
func (p B64StringCaptchaProvider) Expiration() time.Duration {
return 5 * time.Minute
}
func (p B64StringCaptchaProvider) Draw(content string) (string, error) {
item, err := capdString.DrawCaptcha(content)
if err != nil {
return "", err
}
b64str := item.EncodeB64string()
return b64str, nil
}
type B64MathCaptchaProvider struct{}
func (p B64MathCaptchaProvider) Generate() (string, string, string, error) {
id, content, answer := capdMath.GenerateIdQuestionAnswer()
return id, content, answer, nil
}
func (p B64MathCaptchaProvider) Expiration() time.Duration {
return 5 * time.Minute
}
func (p B64MathCaptchaProvider) Draw(content string) (string, error) {
item, err := capdMath.DrawCaptcha(content)
if err != nil {
return "", err
}
b64str := item.EncodeB64string()
return b64str, nil
}

296
utils/login_limiter.go Normal file
View File

@@ -0,0 +1,296 @@
package utils
import (
"errors"
"sync"
"time"
)
// 安全策略配置
type SecurityPolicy struct {
CaptchaThreshold int // 尝试失败次数达到验证码阈值小于0表示不启用, 0表示强制启用
BanThreshold int // 尝试失败次数达到封禁阈值为0表示不启用
AttemptsWindow time.Duration
BanDuration time.Duration
}
// 验证码提供者接口
type CaptchaProvider interface {
Generate() (id string, content string, answer string, err error)
//Validate(ip, code string) bool
Expiration() time.Duration // 验证码过期时间, 应该小于 AttemptsWindow
Draw(content string) (string, error) // 绘制验证码
}
// 验证码元数据
type CaptchaMeta struct {
Id string
Content string
Answer string
ExpiresAt time.Time
}
// IP封禁记录
type BanRecord struct {
ExpiresAt time.Time
Reason string
}
// 登录限制器
type LoginLimiter struct {
mu sync.Mutex
policy SecurityPolicy
attempts map[string][]time.Time //
captchas map[string]CaptchaMeta
bannedIPs map[string]BanRecord
provider CaptchaProvider
cleanupStop chan struct{}
}
var defaultSecurityPolicy = SecurityPolicy{
CaptchaThreshold: 3,
BanThreshold: 5,
AttemptsWindow: 5 * time.Minute,
BanDuration: 30 * time.Minute,
}
func NewLoginLimiter(policy SecurityPolicy) *LoginLimiter {
// 设置默认值
if policy.AttemptsWindow == 0 {
policy.AttemptsWindow = 5 * time.Minute
}
if policy.BanDuration == 0 {
policy.BanDuration = 30 * time.Minute
}
ll := &LoginLimiter{
policy: policy,
attempts: make(map[string][]time.Time),
captchas: make(map[string]CaptchaMeta),
bannedIPs: make(map[string]BanRecord),
cleanupStop: make(chan struct{}),
}
go ll.cleanupRoutine()
return ll
}
// 注册验证码提供者
func (ll *LoginLimiter) RegisterProvider(p CaptchaProvider) {
ll.mu.Lock()
defer ll.mu.Unlock()
ll.provider = p
}
// isDisabled 检查是否禁用登录限制
func (ll *LoginLimiter) isDisabled() bool {
return ll.policy.CaptchaThreshold < 0 && ll.policy.BanThreshold == 0
}
// 记录登录失败尝试
func (ll *LoginLimiter) RecordFailedAttempt(ip string) {
if ll.isDisabled() {
return
}
ll.mu.Lock()
defer ll.mu.Unlock()
if banned, _ := ll.isBanned(ip); banned {
return
}
now := time.Now()
windowStart := now.Add(-ll.policy.AttemptsWindow)
// 清理过期尝试
validAttempts := ll.pruneAttempts(ip, windowStart)
// 记录新尝试
validAttempts = append(validAttempts, now)
ll.attempts[ip] = validAttempts
// 检查封禁条件
if ll.policy.BanThreshold > 0 && len(validAttempts) >= ll.policy.BanThreshold {
ll.banIP(ip, "excessive failed attempts")
return
}
return
}
// 生成验证码
func (ll *LoginLimiter) RequireCaptcha() (error, CaptchaMeta) {
ll.mu.Lock()
defer ll.mu.Unlock()
if ll.provider == nil {
return errors.New("no captcha provider available"), CaptchaMeta{}
}
id, content, answer, err := ll.provider.Generate()
if err != nil {
return err, CaptchaMeta{}
}
// 存储验证码
ll.captchas[id] = CaptchaMeta{
Id: id,
Content: content,
Answer: answer,
ExpiresAt: time.Now().Add(ll.provider.Expiration()),
}
return nil, ll.captchas[id]
}
// 验证验证码
func (ll *LoginLimiter) VerifyCaptcha(id, answer string) bool {
ll.mu.Lock()
defer ll.mu.Unlock()
// 查找匹配验证码
if ll.provider == nil {
return false
}
// 获取并验证验证码
captcha, exists := ll.captchas[id]
if !exists {
return false
}
// 清理过期验证码
if time.Now().After(captcha.ExpiresAt) {
delete(ll.captchas, id)
return false
}
// 验证并清理状态
if answer == captcha.Answer {
delete(ll.captchas, id)
return true
}
return false
}
func (ll *LoginLimiter) DrawCaptcha(content string) (err error, str string) {
str, err = ll.provider.Draw(content)
return
}
// 清除记录窗口
func (ll *LoginLimiter) RemoveAttempts(ip string) {
ll.mu.Lock()
defer ll.mu.Unlock()
_, exists := ll.attempts[ip]
if exists {
delete(ll.attempts, ip)
}
}
// CheckSecurityStatus 检查安全状态
func (ll *LoginLimiter) CheckSecurityStatus(ip string) (banned bool, captchaRequired bool) {
if ll.isDisabled() {
return
}
ll.mu.Lock()
defer ll.mu.Unlock()
// 检查封禁状态
if banned, _ = ll.isBanned(ip); banned {
return
}
// 清理过期数据
ll.pruneAttempts(ip, time.Now().Add(-ll.policy.AttemptsWindow))
// 检查验证码要求
captchaRequired = len(ll.attempts[ip]) >= ll.policy.CaptchaThreshold
return
}
// 后台清理任务
func (ll *LoginLimiter) cleanupRoutine() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ll.cleanupExpired()
case <-ll.cleanupStop:
return
}
}
}
// 内部工具方法
func (ll *LoginLimiter) isBanned(ip string) (bool, BanRecord) {
record, exists := ll.bannedIPs[ip]
if !exists {
return false, BanRecord{}
}
if time.Now().After(record.ExpiresAt) {
delete(ll.bannedIPs, ip)
return false, BanRecord{}
}
return true, record
}
func (ll *LoginLimiter) banIP(ip, reason string) {
ll.bannedIPs[ip] = BanRecord{
ExpiresAt: time.Now().Add(ll.policy.BanDuration),
Reason: reason,
}
delete(ll.attempts, ip)
delete(ll.captchas, ip)
}
func (ll *LoginLimiter) pruneAttempts(ip string, cutoff time.Time) []time.Time {
var valid []time.Time
for _, t := range ll.attempts[ip] {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(ll.attempts, ip)
} else {
ll.attempts[ip] = valid
}
return valid
}
func (ll *LoginLimiter) pruneCaptchas(id string) {
if captcha, exists := ll.captchas[id]; exists {
if time.Now().After(captcha.ExpiresAt) {
delete(ll.captchas, id)
}
}
}
func (ll *LoginLimiter) cleanupExpired() {
ll.mu.Lock()
defer ll.mu.Unlock()
now := time.Now()
// 清理封禁记录
for ip, record := range ll.bannedIPs {
if now.After(record.ExpiresAt) {
delete(ll.bannedIPs, ip)
}
}
// 清理尝试记录
for ip := range ll.attempts {
ll.pruneAttempts(ip, now.Add(-ll.policy.AttemptsWindow))
}
// 清理验证码
for id := range ll.captchas {
ll.pruneCaptchas(id)
}
}

290
utils/login_limiter_test.go Normal file
View File

@@ -0,0 +1,290 @@
package utils
import (
"fmt"
"github.com/google/uuid"
"testing"
"time"
)
type MockCaptchaProvider struct{}
func (p *MockCaptchaProvider) Generate() (string, string, string, error) {
id := uuid.New().String()
content := uuid.New().String()
answer := uuid.New().String()
return id, content, answer, nil
}
func (p *MockCaptchaProvider) Expiration() time.Duration {
return 2 * time.Second
}
func (p *MockCaptchaProvider) Draw(content string) (string, error) {
return "MOCK", nil
}
func TestSecurityWorkflow(t *testing.T) {
policy := SecurityPolicy{
CaptchaThreshold: 3,
BanThreshold: 5,
AttemptsWindow: 5 * time.Minute,
BanDuration: 5 * time.Minute,
}
limiter := NewLoginLimiter(policy)
ip := "192.168.1.100"
// 测试正常失败记录
for i := 0; i < 3; i++ {
limiter.RecordFailedAttempt(ip)
}
isBanned, capRequired := limiter.CheckSecurityStatus(ip)
fmt.Printf("IP: %s, Banned: %v, Captcha Required: %v\n", ip, isBanned, capRequired)
if isBanned {
t.Error("IP should not be banned yet")
}
if !capRequired {
t.Error("Captcha should be required")
}
// 测试触发封禁
for i := 0; i < 3; i++ {
limiter.RecordFailedAttempt(ip)
isBanned, capRequired = limiter.CheckSecurityStatus(ip)
fmt.Printf("IP: %s, Banned: %v, Captcha Required: %v\n", ip, isBanned, capRequired)
}
// 测试封禁状态
if isBanned, _ = limiter.CheckSecurityStatus(ip); !isBanned {
t.Error("IP should be banned")
}
}
func TestCaptchaFlow(t *testing.T) {
policy := SecurityPolicy{CaptchaThreshold: 2}
limiter := NewLoginLimiter(policy)
limiter.RegisterProvider(&MockCaptchaProvider{})
ip := "10.0.0.1"
// 触发验证码要求
limiter.RecordFailedAttempt(ip)
limiter.RecordFailedAttempt(ip)
// 检查状态
if _, need := limiter.CheckSecurityStatus(ip); !need {
t.Error("应该需要验证码")
}
// 生成验证码
err, capc := limiter.RequireCaptcha()
if err != nil {
t.Fatalf("生成验证码失败: %v", err)
}
fmt.Printf("验证码内容: %#v\n", capc)
// 验证成功
if !limiter.VerifyCaptcha(capc.Id, capc.Answer) {
t.Error("验证码应该验证成功")
}
// 验证已删除
if limiter.VerifyCaptcha(capc.Id, capc.Answer) {
t.Error("验证码应该已删除")
}
limiter.RemoveAttempts(ip)
// 验证后状态
if banned, need := limiter.CheckSecurityStatus(ip); banned || need {
t.Error("验证成功后应该重置状态")
}
}
func TestCaptchaMustFlow(t *testing.T) {
policy := SecurityPolicy{CaptchaThreshold: 0}
limiter := NewLoginLimiter(policy)
limiter.RegisterProvider(&MockCaptchaProvider{})
ip := "10.0.0.1"
// 检查状态
if _, need := limiter.CheckSecurityStatus(ip); !need {
t.Error("应该需要验证码")
}
// 生成验证码
err, capc := limiter.RequireCaptcha()
if err != nil {
t.Fatalf("生成验证码失败: %v", err)
}
fmt.Printf("验证码内容: %#v\n", capc)
// 验证成功
if !limiter.VerifyCaptcha(capc.Id, capc.Answer) {
t.Error("验证码应该验证成功")
}
// 验证后状态
if _, need := limiter.CheckSecurityStatus(ip); !need {
t.Error("应该需要验证码")
}
}
func TestAttemptTimeout(t *testing.T) {
policy := SecurityPolicy{CaptchaThreshold: 2, AttemptsWindow: 1 * time.Second}
limiter := NewLoginLimiter(policy)
limiter.RegisterProvider(&MockCaptchaProvider{})
ip := "10.0.0.1"
// 触发验证码要求
limiter.RecordFailedAttempt(ip)
limiter.RecordFailedAttempt(ip)
// 检查状态
if _, need := limiter.CheckSecurityStatus(ip); !need {
t.Error("应该需要验证码")
}
// 生成验证码
err, _ := limiter.RequireCaptcha()
if err != nil {
t.Fatalf("生成验证码失败: %v", err)
}
// 等待超过 AttemptsWindow
time.Sleep(2 * time.Second)
// 触发验证码要求
limiter.RecordFailedAttempt(ip)
// 检查状态
if _, need := limiter.CheckSecurityStatus(ip); need {
t.Error("不应该需要验证码")
}
}
func TestCaptchaTimeout(t *testing.T) {
policy := SecurityPolicy{CaptchaThreshold: 2}
limiter := NewLoginLimiter(policy)
limiter.RegisterProvider(&MockCaptchaProvider{})
ip := "10.0.0.1"
// 触发验证码要求
limiter.RecordFailedAttempt(ip)
limiter.RecordFailedAttempt(ip)
// 检查状态
if _, need := limiter.CheckSecurityStatus(ip); !need {
t.Error("应该需要验证码")
}
// 生成验证码
err, capc := limiter.RequireCaptcha()
if err != nil {
t.Fatalf("生成验证码失败: %v", err)
}
// 等待超过 CaptchaValidPeriod
time.Sleep(3 * time.Second)
// 验证成功
if limiter.VerifyCaptcha(capc.Id, capc.Answer) {
t.Error("验证码应该已过期")
}
}
func TestBanFlow(t *testing.T) {
policy := SecurityPolicy{BanThreshold: 5}
limiter := NewLoginLimiter(policy)
ip := "10.0.0.1"
// 触发ban
for i := 0; i < 5; i++ {
limiter.RecordFailedAttempt(ip)
}
// 检查状态
if banned, _ := limiter.CheckSecurityStatus(ip); !banned {
t.Error("should be banned")
}
}
func TestBanDisableFlow(t *testing.T) {
policy := SecurityPolicy{BanThreshold: 0}
limiter := NewLoginLimiter(policy)
ip := "10.0.0.1"
// 触发ban
for i := 0; i < 5; i++ {
limiter.RecordFailedAttempt(ip)
}
// 检查状态
if banned, _ := limiter.CheckSecurityStatus(ip); banned {
t.Error("should not be banned")
}
}
func TestBanTimeout(t *testing.T) {
policy := SecurityPolicy{BanThreshold: 5, BanDuration: 1 * time.Second}
limiter := NewLoginLimiter(policy)
ip := "10.0.0.1"
// 触发ban
// 触发ban
for i := 0; i < 5; i++ {
limiter.RecordFailedAttempt(ip)
}
time.Sleep(2 * time.Second)
// 检查状态
if banned, _ := limiter.CheckSecurityStatus(ip); banned {
t.Error("should not be banned")
}
}
func TestLimiterDisabled(t *testing.T) {
policy := SecurityPolicy{BanThreshold: 0, CaptchaThreshold: -1}
limiter := NewLoginLimiter(policy)
ip := "10.0.0.1"
// 触发ban
for i := 0; i < 5; i++ {
limiter.RecordFailedAttempt(ip)
}
// 检查状态
if banned, capNeed := limiter.CheckSecurityStatus(ip); banned || capNeed {
fmt.Printf("IP: %s, Banned: %v, Captcha Required: %v\n", ip, banned, capNeed)
t.Error("should not be banned or need captcha")
}
}
func TestB64CaptchaFlow(t *testing.T) {
limiter := NewLoginLimiter(defaultSecurityPolicy)
limiter.RegisterProvider(B64StringCaptchaProvider{})
ip := "10.0.0.1"
// 触发验证码要求
limiter.RecordFailedAttempt(ip)
limiter.RecordFailedAttempt(ip)
limiter.RecordFailedAttempt(ip)
// 检查状态
if _, need := limiter.CheckSecurityStatus(ip); !need {
t.Error("应该需要验证码")
}
// 生成验证码
err, capc := limiter.RequireCaptcha()
if err != nil {
t.Fatalf("生成验证码失败: %v", err)
}
fmt.Printf("验证码内容: %#v\n", capc)
//draw
err, b64 := limiter.DrawCaptcha(capc.Content)
if err != nil {
t.Fatalf("绘制验证码失败: %v", err)
}
fmt.Printf("验证码内容: %#v\n", b64)
// 验证成功
if !limiter.VerifyCaptcha(capc.Id, capc.Answer) {
t.Error("验证码应该验证成功")
}
limiter.RemoveAttempts(ip)
// 验证后状态
if banned, need := limiter.CheckSecurityStatus(ip); banned || need {
t.Error("验证成功后应该重置状态")
}
}

42
utils/password.go Normal file
View File

@@ -0,0 +1,42 @@
package utils
import (
"errors"
"golang.org/x/crypto/bcrypt"
)
// EncryptPassword hashes the input password using bcrypt.
// An error is returned if hashing fails.
func EncryptPassword(password string) (string, error) {
bs, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(bs), nil
}
// VerifyPassword checks the input password against the stored hash.
// When a legacy MD5 hash is provided, the password is rehashed with bcrypt
// and the new hash is returned. Any internal bcrypt error is returned.
func VerifyPassword(hash, input string) (bool, string, error) {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(input))
if err == nil {
return true, "", nil
}
var invalidPrefixErr bcrypt.InvalidHashPrefixError
if errors.As(err, &invalidPrefixErr) || errors.Is(err, bcrypt.ErrHashTooShort) {
// Try fallback to legacy MD5 hash verification
if hash == Md5(input+"rustdesk-api") {
newHash, err2 := bcrypt.GenerateFromPassword([]byte(input), bcrypt.DefaultCost)
if err2 != nil {
return true, "", err2
}
return true, string(newHash), nil
}
}
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, "", nil
}
return false, "", err
}

40
utils/password_test.go Normal file
View File

@@ -0,0 +1,40 @@
package utils
import (
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestVerifyPasswordMD5(t *testing.T) {
hash := Md5("secret" + "rustdesk-api")
ok, newHash, err := VerifyPassword(hash, "secret")
if err != nil {
t.Fatalf("md5 verify failed: %v", err)
}
if !ok || newHash == "" {
t.Fatalf("md5 migration failed")
}
if bcrypt.CompareHashAndPassword([]byte(newHash), []byte("secret")) != nil {
t.Fatalf("invalid rehash")
}
}
func TestVerifyPasswordBcrypt(t *testing.T) {
b, _ := bcrypt.GenerateFromPassword([]byte("pass"), bcrypt.DefaultCost)
ok, newHash, err := VerifyPassword(string(b), "pass")
if err != nil || !ok || newHash != "" {
t.Fatalf("bcrypt verify failed")
}
}
func TestVerifyPasswordMigrate(t *testing.T) {
md5hash := Md5("mypass" + "rustdesk-api")
ok, newHash, err := VerifyPassword(md5hash, "mypass")
if err != nil || !ok || newHash == "" {
t.Fatalf("expected bcrypt rehash")
}
if bcrypt.CompareHashAndPassword([]byte(newHash), []byte("mypass")) != nil {
t.Fatalf("rehash not valid bcrypt")
}
}

View File

@@ -2,11 +2,12 @@ package utils
import (
"crypto/md5"
crand "crypto/rand"
"encoding/json"
"fmt"
"math/rand"
"reflect"
"runtime/debug"
"strings"
)
func Md5(str string) string {
@@ -68,8 +69,12 @@ func RandomString(n int) string {
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
length := len(letterBytes)
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(length)]
randomBytes := make([]byte, n)
if _, err := crand.Read(randomBytes); err != nil {
return ""
}
for i, rb := range randomBytes {
b[i] = letterBytes[int(rb)%length]
}
return string(b)
}
@@ -100,3 +105,11 @@ func InArray(k string, arr []string) bool {
}
return false
}
func StringConcat(strs ...string) string {
var builder strings.Builder
for _, str := range strs {
builder.WriteString(str)
}
return builder.String()
}