Compare commits

..
Author SHA1 Message Date
Akizon 8165440839 Merge pull request #431 from komari-monitor/dev
refactor: 生命周期管理
2026-01-22 03:15:34 +08:00
Akizon77 356f6903a3 refactor: 使用生命周期管理
- Introduced a new `app` package to manage application lifecycle and dependency injection.
- Created `config` and `db` modules to handle configuration loading and database initialization.
- Removed legacy event-based configuration loading in favor of module initialization.
- Implemented hooks for module lifecycle events (Init, Start, Stop).
- Added a scheduler module for periodic tasks and event triggering.
- Updated server module to integrate with the new lifecycle system and handle HTTP server startup/shutdown.
- Ensured backward compatibility with existing configuration and database handling.
2026-01-22 03:01:27 +08:00
Akizon f693eaf764 Merge pull request #410 from RSJWY/dev
支持HarmonyOS系统的MeoW通知应用(统一使用华为Push)
2026-01-15 22:58:04 +08:00
Akizon 35b49222c0 Merge pull request #426 from komari-monitor/dev
wip: 插件支持
2026-01-15 22:56:37 +08:00
Akizon77 8b832e7ce5 chore: 注释掉dev分支的push触发器 2026-01-15 22:49:54 +08:00
Akizon77 fca23546f1 feat: js监听事件/发布事件 2026-01-15 22:45:40 +08:00
Akizon77 e187116d7a feat: JS运行支持async、实现Fetch和XHR 2026-01-15 21:43:08 +08:00
Akizon77 9af4cbb3f9 撤回首次加载引导(之后再做) 2026-01-15 20:19:29 +08:00
RSJWY 2a3e458f63 支持HarmonyOS系统的MeoW通知应用(统一使用华为Push) 2025-12-31 22:37:40 +08:00
Akizon ca350be7a5 Merge pull request #404 from flyflas/add-email-html-support
Add html support for email
2025-12-31 10:22:22 +08:00
xiaobai 0b8f2fc176 add html support for email 2025-12-23 18:57:52 +08:00
Akizon77 c2d8008abc wip: 添加插件管理功能,支持插件激活和清单获取 2025-12-12 00:05:21 +08:00
Akizon77 56b14320e8 refactor: 迁移client 2025-12-11 13:39:53 +08:00
Akizon77 76f6442fbe fix: CORS配置失效 2025-12-11 00:52:15 +08:00
Akizon77 446311f343 refactor: 将时间处理逻辑统一为 UTC 2025-12-10 21:11:53 +08:00
Akizon77 475b2352db feat: 添加监听地址配置,重构数据库迁移逻辑,优化模型字段定义 2025-12-10 20:43:28 +08:00
Akizon77 ebb024a361 refactor: 重构数据库核心模块 2025-12-08 18:22:02 +08:00
Akizon77 6c608b22f6 refactor: 移除过时的数据库配置,整合配置管理 2025-12-08 18:10:48 +08:00
Akizon77 c70dae1396 feat: 添加扩展配置支持,重构 Nezha 兼容字段 2025-12-08 17:21:58 +08:00
Akizon77 b980857d3e refactor: routers.go -> server.go 2025-12-08 15:57:45 +08:00
github-actions[bot] 2b8280cc48 Merge pull request #387 from komari-monitor/dev
feat: mjpeg图片推流、rpc内部调用
2025-12-07 10:27:48 +00:00
Akizon77 510d672f24 fix: 修复配置文件写入逻辑 2025-12-06 17:30:31 +08:00
Akizon77 ea53facb98 fix: 修复配置文件和数据库文件的存在性检查逻辑 2025-12-06 17:24:06 +08:00
Akizon77 a7a6a7b410 fix: v1.1.4配置表迁移功能 2025-12-06 17:20:39 +08:00
Akizon77 bf9ad3357e feat: mjpeg图片推流、rpc内部调用 2025-12-06 17:08:09 +08:00
Akizon77 48c6030393 wip: 添加插件、mjpeg流相关 2025-12-06 14:43:25 +08:00
Akizon 026b12e8ae Merge pull request #378 from komari-monitor/dev
refactor: 事件路由
2025-12-02 02:12:24 +08:00
Akizon77 90aad4b48a refactor: API initialization and routing
- Moved API route registration to dedicated init files for better organization.
- Introduced event listeners for server initialization to dynamically register routes.
- Removed redundant configuration loading in routers.go.
- Added new API routes for various functionalities including client management, admin tasks, and notifications.
- Implemented a standardized response structure for API responses.
- Established WebSocket connections for terminal sessions and improved session management.
- Created a new database initialization for default admin account creation.
- Enhanced gRPC server setup for Nezha compatibility with dynamic configuration updates.
2025-12-02 01:57:08 +08:00
Akizon77 84ec0a9d87 fix: oauth 初始化意外调用未初始化的 dbcore 2025-12-01 23:21:40 +08:00
Akizon77 801d9415ed refactor: 重构服务器启动、路由加载的初始化 2025-12-01 23:13:44 +08:00
Akizon77 f8b4e90d47 refactor: 移除不必要的依赖和初始化逻辑,简化配置更新处理 2025-12-01 21:51:14 +08:00
Akizon77 5b5cf32a28 refactor: 优化配置更新和定时任务管理 2025-12-01 21:20:22 +08:00
github-actions[bot] 0413c9aa80 Merge pull request #370 from komari-monitor/dev
refactor: 引入事件驱动
2025-11-25 07:07:10 +00:00
Akizon77 1672bac3de refactor: 优化权限检测逻辑,简化配置引用 2025-11-25 15:05:49 +08:00
Akizon77 24322b613d refactor: 添加事件触发逻辑以增强客户端和用户操作的跟踪 2025-11-21 15:31:01 +08:00
Akizon77 3729269fa3 refactor: 重写Config配置管理 2025-11-15 17:18:21 +08:00
Akizon77 0c8095ae11 refactor: 添加 RPC 路由注册 2025-11-15 13:22:45 +08:00
Akizon77 535acc99b8 refactor: api_v1/jsonRpc -> api_rpc 2025-11-15 13:14:55 +08:00
Akizon77 01912be70e refactor: 移除配置事件订阅 2025-11-14 23:42:46 +08:00
Akizon77 df62a87591 refactor: 优化 gRPC 服务器启动逻辑 2025-11-14 23:30:48 +08:00
Akizon77 a3faf856e0 refactor: 移动 PingSchedule 相关逻辑 2025-11-14 23:17:40 +08:00
Akizon77 39f849d03e wip: 引入事件驱动 2025-11-14 23:11:46 +08:00
Akizon77 53b3d7dd71 wip: 重构框架 2025-11-14 18:21:23 +08:00
Akizon eab4b513be Merge pull request #361 from komari-monitor/dev
feat: 添加 WAL 检查点优化数据库性能
2025-11-14 16:46:41 +08:00
Akizon77 4e91d62d48 feat: 添加 WAL 检查点优化数据库性能 2025-11-14 16:44:48 +08:00
Akizon a1b0b1e177 Merge pull request #355 from komari-monitor/dev
docs: 添加镜像站链接 https://github.com/komari-monitor/komari-document/issue…
2025-11-09 11:52:36 +08:00
Akizon77 147dd2011a docs: 添加镜像站链接 https://github.com/komari-monitor/komari-document/issues/10 2025-11-09 11:43:05 +08:00
Akizon77 d24b71d4af docs: 移除 ClawRun - 云平台未更新维护且开发者无法更改内容 2025-11-04 23:16:14 +08:00
Akizon 483e3a94e9 remove Claw Run 2025-11-04 22:10:48 +08:00
Akizon e612f5ed8f Add badges for GitHub and DeepWiki to README 2025-11-04 21:38:45 +08:00
github-actions[bot] 78f3c8fd4b Merge pull request #350 from komari-monitor/dev
fix: #344 引入更宽松的认证方式
2025-11-04 06:50:01 +00:00
Akizon77 5a4e073d63 fix: #344 引入更宽松的认证方式 2025-11-04 14:49:34 +08:00
Akizon77 22cbfecfa9 feat: 整合日志功能 2025-11-04 14:36:40 +08:00
github-actions[bot] d1b059fc2f Merge pull request #349 from komari-monitor/dev
feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey)
2025-11-04 05:02:09 +00:00
Akizon cf5c410aa6 Merge branch 'main' into dev 2025-11-04 13:01:53 +08:00
Akizon 9c46673a1a Merge pull request #348 from RSJWY/dev
feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey)
2025-11-04 00:50:10 +08:00
RSJWY 45c27ae981 feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey) 2025-11-03 22:29:55 +08:00
akizon77 b62fb1f708 feat: 添加 JavaScript 消息发送器,支持 fetch 和 XMLHttpRequest API 2025-10-25 20:43:14 +08:00
github-actions[bot] 579c196643 Merge pull request #335 from komari-monitor/dev
feat: 添加 NOTICE 文件,包含版权和许可证信息
2025-10-18 09:16:09 +00:00
Akizon77 e8da6e213a feat: 添加 NOTICE 文件,包含版权和许可证信息 2025-10-18 17:15:20 +08:00
github-actions[bot] d9426e43a9 Merge pull request #333 from komari-monitor/dev
feat: 添加 EULA 接受字段到配置模型
2025-10-17 17:15:38 +00:00
Akizon77 aad0274d56 feat: 添加 EULA 接受字段到配置模型 2025-10-18 00:15:05 +08:00
github-actions[bot] d65f7ca924 Merge pull request #331 from komari-monitor/dev
fix: 重复注册Bark
2025-10-15 05:14:01 +00:00
Akizon77 a4d95640a5 fix: 重复注册Bark 2025-10-15 13:13:31 +08:00
Akizon77 adda3a2488 docs: 移除Crowdin 2025-10-15 13:09:18 +08:00
github-actions[bot] 34654c0969 Merge pull request #320 from komari-monitor/dev
fix: #319 伪随机 token 可预测
2025-10-09 11:09:44 +00:00
Akizon77 b708f9817d fix: 伪随机 token 可预测 2025-10-09 19:09:11 +08:00
239 changed files with 10614 additions and 2988 deletions

No files matched your search

+2 -2
View File
@@ -101,10 +101,10 @@ jobs:
fi
VERSION="${{ github.sha }}"
VERSION_HASH="${{ github.sha }}"
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}" -o $BINARY_NAME
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
+7 -7
View File
@@ -1,9 +1,9 @@
name: Development Environment
on:
push:
branches:
- dev
# push:
# branches:
# - dev
workflow_dispatch:
jobs:
@@ -12,13 +12,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v4
#- name: Create dummy index.html
# run: |
# mkdir -p public/dist
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/dist/index.html
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
@@ -40,7 +40,7 @@ jobs:
GOARCH: amd64
run: |
VERSION_SHA="${{ github.sha }}"
go build -ldflags "-X github.com/komari-monitor/komari/utils.CurrentVersion=dev -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_SHA}" -o komari .
go build -ldflags "-X github.com/komari-monitor/komari/internal/conf.Version=dev -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_SHA}" -o komari .
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
run: |
VERSION="${{ github.ref_name }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
run: |
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
fi
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}" -o $BINARY_NAME
- name: Upload binary to release
env:
+27
View File
@@ -0,0 +1,27 @@
CoreOS Project
Copyright 2014 CoreOS, Inc
This product includes software developed at CoreOS, Inc.
(http://www.coreos.com/).
otp
Copyright (c) 2014, Paul Querna
This product includes software developed by
Paul Querna (http://paul.querna.org/).
Copyright 2014 gRPC authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+18 -16
View File
@@ -1,6 +1,7 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/komari-monitor/komari)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
@@ -8,9 +9,10 @@
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
[Documentation](https://komari-document.pages.dev/) | [Telegram Group](https://t.me/komari_monitor)
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
## Features
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
- **Self-hosted**: Complete control over data privacy, easy to deploy.
- **Web Interface**: Intuitive monitoring dashboard, easy to use.
@@ -18,16 +20,15 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
## Quick Start
### 0. One-click Deployment with Cloud Hosting
- Rainyun - CNY 4.5/month
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Rainyun - CNY 4.5/month
- Claw Run - USD 1.5/month
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
### 1. Use the One-click Install Script
Suitable for distributions using systemd (Ubuntu, Debian...).
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
@@ -35,6 +36,7 @@ sudo ./install-komari.sh
```
### 2. Docker Deployment
1. Create a data directory:
```bash
mkdir -p ./data
@@ -57,6 +59,7 @@ sudo ./install-komari.sh
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
### 3. Binary File Deployment
1. Visit Komari's [GitHub Release page](https://github.com/komari-monitor/komari/releases) to download the latest binary for your operating system.
2. Run Komari:
```bash
@@ -68,12 +71,11 @@ sudo ./install-komari.sh
> [!NOTE]
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
### Manual Build
#### Dependencies
- Go 1.18+ and Node.js 20+ (for manual build)
- Go 1.24+ and Node.js 20+ (for manual build)
1. Build the frontend static files:
```bash
@@ -88,24 +90,25 @@ sudo ./install-komari.sh
cd komari
```
Copy the static files generated in step 1 to the `/public/dist` folder in the root of the `komari` project.
```bash
```bash
go build -o komari
```
4. Run:
3. Run:
```bash
./komari server -l 0.0.0.0:25774
```
The default listening port is `25774`. Access `http://localhost:25774`.
## Frontend Development Guide
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
[Translate Komari on Crowdin](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## Client Agent Development Guide
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
## Contributing
Issues and Pull Requests are welcome!
## Acknowledgements
@@ -116,11 +119,11 @@ Issues and Pull Requests are welcome!
### 🚀 Sponsored by SharonNetworks
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供 Tbps 级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
### The open source software community
@@ -129,4 +132,3 @@ All the developers who submitted PRs and created themes
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
-75
View File
@@ -1,75 +0,0 @@
package api
import (
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/gin-gonic/gin"
)
var (
publicPaths = []string{
"/ping",
"/api/public",
"/api/login",
"/api/me",
"/api/oauth",
"/api/oauth_callback",
"/api/version",
"/api/recent",
"/api/admin", // 由AdminAuthMiddleware处理
"/api/clients/", // 由TokenAuthMiddleware处理
}
)
func PrivateSiteMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
// 如果是公开的路径,直接放行
for _, path := range publicPaths {
if len(c.Request.URL.Path) >= len(path) && c.Request.URL.Path[:len(path)] == path {
c.Next()
return
}
}
// 如果不是 /api,直接放行
if len(c.Request.URL.Path) < 4 || c.Request.URL.Path[:4] != "/api" {
c.Next()
return
}
conf, err := config.Get()
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
c.Abort()
return
}
// 验证私有, 如果不是私有站点,直接放行
if !conf.PrivateSite {
c.Next()
return
}
// 如果是私有站点,检查是否有 session
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
c.Abort()
return
}
_, err = accounts.GetSession(session)
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
c.Next()
}
}
-63
View File
@@ -1,63 +0,0 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func GetMessageSenderProvider(c *gin.Context) {
provider := c.Query("provider")
if provider != "" {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetMessageSenderConfigByName(provider)
if err != nil {
api.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
api.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置项模板
providers := factory.GetSenderConfigs()
if len(providers) == 0 {
api.RespondError(c, 404, "No message sender providers found")
return
}
api.RespondSuccess(c, providers)
}
func SetMessageSenderProvider(c *gin.Context) {
var senderConfig models.MessageSenderProvider
if err := c.ShouldBindJSON(&senderConfig); err != nil {
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if senderConfig.Name == "" {
api.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(senderConfig.Name)
if !exists {
api.RespondError(c, 404, "Provider not found: "+senderConfig.Name)
return
}
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
api.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
return
}
cfg, _ := config.Get()
// 正在使用,重载
if cfg.NotificationMethod == senderConfig.Name {
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
if err != nil {
api.RespondError(c, 500, "Failed to load message sender provider: "+err.Error())
return
}
}
api.RespondSuccess(c, gin.H{"message": "Message sender provider set successfully"})
}
-50
View File
@@ -1,50 +0,0 @@
package test
import (
"net"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
)
func TestSendMessage(c *gin.Context) {
err := messageSender.SendEvent(models.EventMessage{
Event: "Test",
Message: "This is a test message from Komari.",
})
if err != nil {
api.RespondError(c, 500, "Failed to send message: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func TestGeoIp(c *gin.Context) {
ip := c.Query("ip")
if ip == "" {
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
ip = cfIP
} else {
ip = c.ClientIP()
}
}
conf, err := config.Get()
if err != nil {
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if !conf.GeoIpEnabled {
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
return
}
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
if err != nil {
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
return
}
api.RespondSuccess(c, GeoIpRecord)
}
-18
View File
@@ -1,18 +0,0 @@
package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/utils/geoip"
)
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.UpdateDatabase(); err != nil {
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
api.RespondSuccess(c, nil)
}
-41
View File
@@ -1,41 +0,0 @@
package client
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils"
)
func RegisterClient(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
cfg, err := config.Get()
if err != nil {
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if cfg.AutoDiscoveryKey == "" ||
len(cfg.AutoDiscoveryKey) < 12 ||
"Bearer "+cfg.AutoDiscoveryKey != auth {
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
name := c.Query("name")
if name == "" {
name = utils.GenerateRandomString(8)
}
name = "Auto-" + name
uuid, token, err := clients.CreateClientWithName(name)
if err != nil {
api.RespondError(c, 500, "Failed to create client: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{"uuid": uuid, "token": token})
}
-77
View File
@@ -1,77 +0,0 @@
package api
import (
"encoding/json"
"io"
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/gin-gonic/gin"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TwoFa string `json:"2fa_code"`
}
func Login(c *gin.Context) {
conf, _ := config.Get()
if conf.DisablePasswordLogin {
RespondError(c, http.StatusForbidden, "Password login is disabled")
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
var data LoginRequest
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
if data.Username == "" || data.Password == "" {
RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
return
}
uuid, success := accounts.CheckPassword(data.Username, data.Password)
if !success {
RespondError(c, http.StatusUnauthorized, "Invalid credentials")
return
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" { // 开启了2FA
if data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
auditlog.Log(c.ClientIP(), uuid, "logged in (password)", "login")
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
}
func Logout(c *gin.Context) {
session, _ := c.Cookie("session_token")
accounts.DeleteSession(session)
c.SetCookie("session_token", "", -1, "/", "", false, true)
auditlog.Log(c.ClientIP(), "", "logged out", "logout")
c.Redirect(302, "/")
}
-15
View File
@@ -1,15 +0,0 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database"
)
func GetPublicSettings(c *gin.Context) {
p, e := database.GetPublicInfo()
if e != nil {
RespondError(c, 500, e.Error())
return
}
RespondSuccess(c, p)
}
+29 -21
View File
@@ -1,12 +1,14 @@
package cmd
import (
"context"
"os"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/app"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/spf13/cobra"
)
@@ -25,25 +27,31 @@ var ChpasswdCmd = &cobra.Command{
cmd.Help()
return
}
if _, err := os.Stat(flags.DatabaseFile); os.IsNotExist(err) {
cmd.Println("Database file does not exist.")
return
}
user := &models.User{}
dbcore.GetDBInstance().Model(&models.User{}).First(user)
cmd.Println("Changing password for user:", user.Username)
if err := accounts.ForceResetPassword(user.Username, NewPassword); err != nil {
a := app.New().With(dbcore.NewDBModule())
err := a.RunWith(context.Background(), func(ctx context.Context) error {
if _, err := os.Stat(conf.Conf.Database.DatabaseFile); os.IsNotExist(err) {
cmd.Println("Database file does not exist.")
return nil
}
user := &models.User{}
dbcore.GetDBInstance().Model(&models.User{}).First(user)
cmd.Println("Changing password for user:", user.Username)
if err := accounts.ForceResetPassword(user.Username, NewPassword); err != nil {
cmd.Println("Error:", err)
return nil
}
cmd.Println("Password changed successfully, new password:", NewPassword)
if err := accounts.DeleteAllSessions(); err != nil {
cmd.Println("Unable to force logout of other devices:", err)
return nil
}
cmd.Println("Please restart the server to apply the changes.")
return nil
})
if err != nil {
cmd.Println("Error:", err)
return
}
cmd.Println("Password changed successfully, new password:", NewPassword)
if err := accounts.DeleteAllSessions(); err != nil {
cmd.Println("Unable to force logout of other devices:", err)
return
}
cmd.Println("Please restart the server to apply the changes.")
},
}
+11 -6
View File
@@ -1,10 +1,12 @@
package cmd
import (
"context"
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/app"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
@@ -14,10 +16,13 @@ var Disable2FA = &cobra.Command{
Short: "Force disable 2FA",
Long: `Force disable 2FA`,
Run: func(cmd *cobra.Command, args []string) {
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&models.User{}).Where("two_factor != ?", "").
Update("two_factor", "").Error
a := app.New().With(dbcore.NewDBModule())
err := a.RunWith(context.Background(), func(ctx context.Context) error {
db := dbcore.GetDBInstance()
return db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&models.User{}).Where("two_factor != ?", "").
Update("two_factor", "").Error
})
})
if err != nil {
cmd.Println("Error:", err)
+2 -10
View File
@@ -1,14 +1,6 @@
package flags
var (
// 数据库配置
DatabaseType string // 数据库类型:sqlite, mysql
DatabaseFile string // SQLite数据库文件路径
DatabaseHost string // MySQL/其他数据库主机地址
DatabasePort string // MySQL/其他数据库端口
DatabaseUser string // MySQL/其他数据库用户名
DatabasePass string // MySQL/其他数据库密码
DatabaseName string // MySQL/其他数据库名称
Listen string
ConfigFile string // 配置文件路径
Listen string
)
+12 -12
View File
@@ -11,17 +11,17 @@ import (
"sync"
"time"
apiClient "github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/ws"
apiClient "github.com/komari-monitor/komari/internal/api_v1/client"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/geoip"
"github.com/komari-monitor/komari/internal/notifier"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/compat/nezha/proto"
"github.com/komari-monitor/komari/internal/nezha/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/metadata"
@@ -370,7 +370,7 @@ func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*
if in != nil && in.Ip != nil {
if v4 := strings.TrimSpace(in.Ip.Ipv4); v4 != "" {
updates["ipv4"] = v4
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if cfg, err := conf.GetWithV1Format(); err == nil && cfg.GeoIpEnabled {
if ip := net.ParseIP(v4); ip != nil {
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
iso = gi.ISOCode
@@ -381,7 +381,7 @@ func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*
if v6 := strings.TrimSpace(in.Ip.Ipv6); v6 != "" {
updates["ipv6"] = v6
if iso == "" { // 优先使用 v4 的国家码
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if cfg, err := conf.GetWithV1Format(); err == nil && cfg.GeoIpEnabled {
if ip := net.ParseIP(v6); ip != nil {
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
iso = gi.ISOCode
+11 -6
View File
@@ -1,10 +1,12 @@
package cmd
import (
"context"
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/app"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
@@ -14,10 +16,13 @@ var PermitPasswordLoginCmd = &cobra.Command{
Short: "Force permit password login",
Long: `Force permit password login`,
Run: func(cmd *cobra.Command, args []string) {
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&models.Config{}).Where("id = ?", 1).
Update("disable_password_login", false).Error
a := app.New().With(dbcore.NewDBModule())
err := a.RunWith(context.Background(), func(ctx context.Context) error {
db := dbcore.GetDBInstance()
return db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&conf.V1Struct{}).Where("id = ?", 1).
Update("disable_password_login", false).Error
})
})
if err != nil {
cmd.Println("Error:", err)
+51 -18
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"log/slog"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/spf13/cobra"
@@ -19,13 +21,7 @@ func GetEnv(key, defaultValue string) string {
// 从环境变量获取默认值
var (
dbTypeEnv = GetEnv("KOMARI_DB_TYPE", "sqlite")
dbFileEnv = GetEnv("KOMARI_DB_FILE", "./data/komari.db")
dbHostEnv = GetEnv("KOMARI_DB_HOST", "localhost")
dbPortEnv = GetEnv("KOMARI_DB_PORT", "3306")
dbUserEnv = GetEnv("KOMARI_DB_USER", "root")
dbPassEnv = GetEnv("KOMARI_DB_PASS", "")
dbNameEnv = GetEnv("KOMARI_DB_NAME", "komari")
configFileEnv = GetEnv("KOMARI_CONFIG_FILE", "./data/komari.json")
)
var RootCmd = &cobra.Command{
@@ -33,26 +29,63 @@ var RootCmd = &cobra.Command{
Short: "Komari is a simple server monitoring tool",
Long: `Komari is a simple server monitoring tool.
Made by Akizon77 with love.`,
Run: func(cmd *cobra.Command, args []string) {
cmd.SetArgs([]string{"server"})
cmd.Execute()
},
}
func Execute() {
detectDeprecatedFlags()
// Default to server when no subcommand is provided.
if len(os.Args) <= 1 {
RootCmd.SetArgs([]string{"server"})
}
// 创建目录
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
slog.Error("Failed to create theme directory", slog.Any("error", err))
}
if err := RootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// detectDeprecatedFlags 检测过时的数据库相关命令行参数
func detectDeprecatedFlags() {
deprecatedFlags := map[string]bool{
"db-host": false,
"database": false,
"db-user": false,
"db-pass": false,
"db-port": false,
"db-name": false,
"db-type": false,
}
// 检查命令行参数
for _, arg := range os.Args[1:] {
for flagName := range deprecatedFlags {
if arg == "--"+flagName || arg == "-"+string(flagName[0]) {
deprecatedFlags[flagName] = true
break
}
}
}
// 如果检测到过时参数,记录警告
hasDeprecated := false
for flagName, found := range deprecatedFlags {
if found {
hasDeprecated = true
slog.Warn("Deprecated command-line flag detected", slog.String("flag", "--"+flagName))
}
}
if hasDeprecated {
slog.Warn("Command-line database configuration flags are deprecated. " +
"Please migrate to komari.json file. ")
}
}
func init() {
// 设置命令行参数,提供环境变量作为默认值
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite, mysql) [env: KOMARI_DB_TYPE]")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", dbFileEnv, "SQLite database file path [env: KOMARI_DB_FILE]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "MySQL/Other database host address [env: KOMARI_DB_HOST]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "MySQL/Other database port [env: KOMARI_DB_PORT]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "MySQL/Other database username [env: KOMARI_DB_USER]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "MySQL/Other database password [env: KOMARI_DB_PASS]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "MySQL/Other database name [env: KOMARI_DB_NAME]")
RootCmd.PersistentFlags().StringVarP(&flags.ConfigFile, "config", "c", configFileEnv, "Configuration file path [env: KOMARI_CONFIG_FILE]")
RootCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", GetEnv("KOMARI_LISTEN", ":8080"), "Listen address [env: KOMARI_LISTEN]")
}
+11 -407
View File
@@ -2,427 +2,31 @@ package cmd
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/api/admin"
"github.com/komari-monitor/komari/api/admin/clipboard"
log_api "github.com/komari-monitor/komari/api/admin/log"
"github.com/komari-monitor/komari/api/admin/notification"
"github.com/komari-monitor/komari/api/admin/test"
"github.com/komari-monitor/komari/api/admin/update"
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/jsonRpc"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/api/task"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/komari-monitor/komari/internal/app"
"github.com/komari-monitor/komari/internal/scheduler"
"github.com/komari-monitor/komari/internal/server"
"github.com/spf13/cobra"
)
var (
DynamicCorsEnabled bool = false
)
var ServerCmd = &cobra.Command{
Use: "server",
Short: "Start the server",
Long: `Start the server`,
Run: func(cmd *cobra.Command, args []string) {
RunServer()
a := app.New().
WithShutdownTimeout(5 * time.Second).
With(server.NewHTTPModule()).
With(scheduler.NewModule())
if err := a.RunUntilSignal(context.Background()); err != nil {
cmd.PrintErrln(err)
os.Exit(1)
}
},
}
func init() {
// 从环境变量获取监听地址
listenAddr := GetEnv("KOMARI_LISTEN", "0.0.0.0:25774")
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", listenAddr, "监听地址 [env: KOMARI_LISTEN]")
RootCmd.AddCommand(ServerCmd)
}
func RunServer() {
// #region 初始化
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
// 创建主题目录
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
log.Fatalf("Failed to create theme directory: %v", err)
}
InitDatabase()
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
conf, err := config.Get()
if err != nil {
log.Fatal(err)
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
// oidcInit
go oauth.Initialize()
if conf.NezhaCompatEnabled {
go func() {
if err := StartNezhaCompat(conf.NezhaCompatListen); err != nil {
log.Printf("Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
}
}()
}
config.Subscribe(func(event config.ConfigEvent) {
if event.New.OAuthProvider != event.Old.OAuthProvider {
oidcProvider, err := database.GetOidcConfigByName(event.New.OAuthProvider)
if err != nil {
log.Printf("Failed to get OIDC provider config: %v", err)
} else {
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
}
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
if err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
}
}
if event.New.NotificationMethod != event.Old.NotificationMethod {
messageSender.Initialize()
}
if event.New.NezhaCompatEnabled != event.Old.NezhaCompatEnabled {
if event.New.NezhaCompatEnabled {
if err := StartNezhaCompat(event.New.NezhaCompatListen); err != nil {
log.Printf("start Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
}
} else {
if err := StopNezhaCompat(); err != nil {
log.Printf("stop Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
}
}
}
})
// 初始化 cloudflared
if strings.ToLower(GetEnv("KOMARI_ENABLE_CLOUDFLARED", "false")) == "true" {
err := cloudflared.RunCloudflared() // 阻塞,确保cloudflared跑起来
if err != nil {
log.Fatalf("Failed to run cloudflared: %v", err)
}
}
r := gin.Default()
// 动态 CORS 中间件
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
DynamicCorsEnabled = event.New.AllowCors
if event.New.GeoIpProvider != event.Old.GeoIpProvider {
go geoip.InitGeoIp()
}
if event.New.NotificationMethod != event.Old.NotificationMethod {
go messageSender.Initialize()
}
})
r.Use(func(c *gin.Context) {
if DynamicCorsEnabled {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "43200") // 12 hours
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
}
c.Next()
})
r.Use(api.PrivateSiteMiddleware())
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", api.Login)
r.GET("/api/me", api.GetMe)
r.GET("/api/clients", api.GetClients)
r.GET("/api/nodes", api.GetNodesInformation)
r.GET("/api/public", api.GetPublicSettings)
r.GET("/api/oauth", api.OAuth)
r.GET("/api/oauth_callback", api.OAuthCallback)
r.GET("/api/logout", api.Logout)
r.GET("/api/version", api.GetVersion)
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
r.GET("/api/task/ping", task.GetPublicPingTasks)
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/geoip", test.TestGeoIp)
testGroup.POST("/sendMessage", test.TestSendMessage)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
settingsGroup := adminAuthrized.Group("/settings")
{
settingsGroup.GET("/", admin.GetSettings)
settingsGroup.POST("/", admin.EditSettings)
settingsGroup.POST("/oidc", admin.SetOidcProvider)
settingsGroup.GET("/oidc", admin.GetOidcProvider)
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
}
// themes
themeGroup := adminAuthrized.Group("/theme")
{
themeGroup.PUT("/upload", admin.UploadTheme)
themeGroup.GET("/list", admin.ListThemes)
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
recordGroup.POST("/clear/all", admin.ClearAllRecords)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
// clipboard
clipboardGroup := adminAuthrized.Group("/clipboard")
{
clipboardGroup.GET("/:id", clipboard.GetClipboard)
clipboardGroup.GET("", clipboard.ListClipboard)
clipboardGroup.POST("", clipboard.CreateClipboard)
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
}
notificationGroup := adminAuthrized.Group("/notification")
{
// offline notifications
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
loadAlertGroup := notificationGroup.Group("/load")
{
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
loadAlertGroup.POST("/add", notification.AddLoadNotification)
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
}
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
}
}
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
// #region 静态文件服务
public.UpdateIndex(conf)
config.Subscribe(func(event config.ConfigEvent) {
public.UpdateIndex(event.New)
})
srv := &http.Server{
Addr: flags.Listen,
Handler: r,
}
log.Printf("Starting server on %s ...", flags.Listen)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
OnFatal(err)
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
OnShutdown()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
}
func InitDatabase() {
// // 打印数据库类型和连接信息
// if flags.DatabaseType == "mysql" {
// log.Printf("使用 MySQL 数据库连接: %s@%s:%s/%s",
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_HOST=%s] [KOMARI_DB_PORT=%s] [KOMARI_DB_USER=%s] [KOMARI_DB_NAME=%s]",
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_HOST"), os.Getenv("KOMARI_DB_PORT"),
// os.Getenv("KOMARI_DB_USER"), os.Getenv("KOMARI_DB_NAME"))
// } else {
// log.Printf("使用 SQLite 数据库文件: %s", flags.DatabaseFile)
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_FILE=%s]",
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_FILE"))
// }
var count int64 = 0
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
user, passwd, err := accounts.CreateDefaultAdminAccount()
if err != nil {
panic(err)
}
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
}
}
// #region 定时任务
func DoScheduledWork() {
tasks.ReloadPingSchedule()
d_notification.ReloadLoadNotificationSchedule()
ticker := time.NewTicker(time.Minute * 30)
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
cfg, _ := config.Get()
go notifier.CheckExpireScheduledWork()
for {
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
auditlog.RemoveOldLogs()
case <-minute.C:
api.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
// 每分钟检查一次流量提醒
go notifier.CheckTraffic()
}
}
}
func OnShutdown() {
auditlog.Log("", "", "server is shutting down", "info")
cloudflared.Kill()
}
func OnFatal(err error) {
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
cloudflared.Kill()
}
-319
View File
@@ -1,319 +0,0 @@
package clients
import (
"math"
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fmt"
"github.com/google/uuid"
)
// Deprecated: DeleteClientConfig is deprecated and will be removed in a future release. Use DeleteClient instead.
func DeleteClientConfig(clientUuid string) error {
db := dbcore.GetDBInstance()
err := db.Delete(&common.ClientConfig{ClientUUID: clientUuid}).Error
if err != nil {
return err
}
return nil
}
func DeleteClient(clientUuid string) error {
db := dbcore.GetDBInstance()
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
if err != nil {
return err
}
return nil
}
// Deprecated: UpdateOrInsertBasicInfo is deprecated and will be removed in a future release. Use SaveClientInfo instead.
func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
db := dbcore.GetDBInstance()
updates := make(map[string]interface{})
if cbi.Name != "" {
updates["name"] = cbi.Name
}
if cbi.CpuName != "" {
updates["cpu_name"] = cbi.CpuName
}
if cbi.Arch != "" {
updates["arch"] = cbi.Arch
}
if cbi.CpuCores > 0 || cbi.CpuCores < math.MaxInt-1 {
updates["cpu_cores"] = cbi.CpuCores
}
if cbi.OS != "" {
updates["os"] = cbi.OS
}
if cbi.GpuName != "" {
updates["gpu_name"] = cbi.GpuName
}
if cbi.IPv4 != "" {
updates["ipv4"] = cbi.IPv4
}
if cbi.IPv6 != "" {
updates["ipv6"] = cbi.IPv6
}
if cbi.Region != "" {
updates["region"] = cbi.Region
}
if cbi.Remark != "" {
updates["remark"] = cbi.Remark
}
updates["mem_total"] = cbi.MemTotal
updates["swap_total"] = cbi.SwapTotal
updates["disk_total"] = cbi.DiskTotal
updates["version"] = cbi.Version
updates["updated_at"] = time.Now()
// 转换为更新Client表
client := models.Client{
UUID: cbi.UUID,
}
err := db.Model(&client).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.Assignments(updates),
}).Create(map[string]interface{}{
"uuid": cbi.UUID,
"name": cbi.Name,
"cpu_name": cbi.CpuName,
"arch": cbi.Arch,
"cpu_cores": cbi.CpuCores,
"os": cbi.OS,
"gpu_name": cbi.GpuName,
"ipv4": cbi.IPv4,
"ipv6": cbi.IPv6,
"region": cbi.Region,
"remark": cbi.Remark,
"mem_total": cbi.MemTotal,
"swap_total": cbi.SwapTotal,
"disk_total": cbi.DiskTotal,
"version": cbi.Version,
"updated_at": time.Now(),
}).Error
if err != nil {
return err
}
return nil
}
func SaveClientInfo(update map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := update["uuid"].(string)
if !ok || clientUUID == "" {
return fmt.Errorf("invalid client UUID")
}
// 确保更新的字段不为空
if len(update) == 0 {
return fmt.Errorf("no fields to update")
}
update["updated_at"] = time.Now()
checkInt64 := func(name string, val float64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
}
return nil
}
verify := func(update map[string]interface{}) error {
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
}
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
return err
}
return nil
}
if err := verify(update); err != nil {
return err
}
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
return err
}
return nil
}
// 更新客户端设置
func UpdateClientConfig(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
err := db.Save(&config).Error
if err != nil {
return err
}
return nil
}
func EditClientName(clientUUID, clientName string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
if err != nil {
return err
}
return nil
}
/*
// UpdateClientByUUID 更新指定 UUID 的客户端配置
func UpdateClientByUUID(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
result := db.Model(&common.ClientConfig{}).Where("client_uuid = ?", config.ClientUUID).Updates(config)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
*/
func EditClientToken(clientUUID, token string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
if err != nil {
return err
}
return nil
}
// CreateClient 创建新客户端
func CreateClient() (clientUUID, token string, err error) {
db := dbcore.GetDBInstance()
token = utils.GenerateToken()
clientUUID = uuid.New().String()
client := models.Client{
UUID: clientUUID,
Token: token,
Name: "client_" + clientUUID[0:8],
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
return clientUUID, token, nil
}
func CreateClientWithName(name string) (clientUUID, token string, err error) {
if name == "" {
return CreateClient()
}
db := dbcore.GetDBInstance()
token = utils.GenerateToken()
clientUUID = uuid.New().String()
client := models.Client{
UUID: clientUUID,
Token: token,
Name: name,
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
return clientUUID, token, nil
}
/*
// GetAllClients 获取所有客户端配置
func getAllClients() (clients []models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Find(&clients).Error
if err != nil {
return nil, err
}
return clients, nil
}
*/
func GetClientByUUID(uuid string) (client models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return models.Client{}, err
}
return client, nil
}
// GetClientBasicInfo 获取指定 UUID 的客户端基本信息
func GetClientBasicInfo(uuid string) (client models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return models.Client{}, fmt.Errorf("客户端不存在: %s", uuid)
}
return models.Client{}, err
}
return client, nil
}
func GetClientTokenByUUID(uuid string) (token string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return "", err
}
return client.Token, nil
}
func GetAllClientBasicInfo() (clients []models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Find(&clients).Error
if err != nil {
return nil, err
}
return clients, nil
}
func SaveClient(updates map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := updates["uuid"].(string)
if !ok || clientUUID == "" {
return fmt.Errorf("invalid client UUID")
}
// 确保更新的字段不为空
if len(updates) == 0 {
return fmt.Errorf("no fields to update")
}
updates["updated_at"] = time.Now()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
if err != nil {
return err
}
return nil
}
-113
View File
@@ -1,113 +0,0 @@
package config
import (
"errors"
"log"
"sync"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm"
)
var mu sync.Mutex
func init() {
mu = sync.Mutex{}
}
func Get() (models.Config, error) {
mu.Lock()
defer mu.Unlock()
db := dbcore.GetDBInstance()
var config models.Config
if err := db.First(&config).Error; err != nil {
if err == gorm.ErrRecordNotFound {
config = models.Config{
ID: 1,
Sitename: "Komari",
Description: "Komari Monitor, a simple server monitoring tool.",
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "ipinfo",
NezhaCompatEnabled: false,
NezhaCompatListen: "",
NotificationTemplate: "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}",
UpdatedAt: models.FromTime(time.Now()),
CreatedAt: models.FromTime(time.Now()),
}
if err := db.Create(&config).Error; err != nil {
log.Fatal("Failed to create default config:", err)
}
return config, nil
}
return config, err
}
return config, nil
}
func Save(cst models.Config) error {
db := dbcore.GetDBInstance()
oldConfig, _ := Get()
// Only one records
cst.ID = 1
cst.UpdatedAt = models.FromTime(time.Now())
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
Select("*").
Updates(cst).Error; err != nil {
return err
}
newConfig, _ := Get()
publishEvent(oldConfig, newConfig)
return nil
}
func Update(cst map[string]interface{}) error {
db := dbcore.GetDBInstance()
oldConfig, _ := Get()
// Proceed with update
cst["id"] = 1
cst["updated_at"] = time.Now()
delete(cst, "created_at")
delete(cst, "CreatedAt")
// 至少有一种登录方式启用
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
newOAuthEnabled := oldConfig.OAuthEnabled
if val, exists := cst["disable_password_login"]; exists {
newDisablePasswordLogin = val.(bool)
}
if val, exists := cst["o_auth_enabled"]; exists {
newOAuthEnabled = val.(bool)
}
if newDisablePasswordLogin && !newOAuthEnabled {
return errors.New("at least one login method must be enabled (password/oauth)")
}
// 没绑定账号也不能禁用
if newDisablePasswordLogin {
usr := &models.User{}
if err := db.Model(&models.User{}).First(usr).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve user"))
}
if usr.SSOID == "" {
return errors.New("cannot disable password login when no SSO-bound account exists")
}
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
return errors.Join(err, errors.New("failed to update configuration"))
}
newConfig := &models.Config{}
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
}
publishEvent(oldConfig, *newConfig)
return nil
})
if err != nil {
return err
}
return nil
}
-38
View File
@@ -1,38 +0,0 @@
package config
import (
"sync"
"github.com/komari-monitor/komari/database/models"
)
// ConfigEvent represents a configuration change event
type ConfigEvent struct {
Old models.Config
New models.Config
}
// ConfigSubscriber handles config events
type ConfigSubscriber func(event ConfigEvent)
var (
subscribersMu sync.RWMutex
subscribers []ConfigSubscriber
)
// Subscribe registers a subscriber for all config events.
func Subscribe(subscriber ConfigSubscriber) {
subscribersMu.Lock()
defer subscribersMu.Unlock()
subscribers = append(subscribers, subscriber)
}
// publishEvent notifies all subscribers of a config change.
func publishEvent(oldVal, newVal models.Config) {
subscribersMu.RLock()
defer subscribersMu.RUnlock()
event := ConfigEvent{Old: oldVal, New: newVal}
for _, sub := range subscribers {
go sub(event)
}
}
-499
View File
@@ -1,499 +0,0 @@
package dbcore
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
func zipDirectoryExcluding(srcDir, dstZip string, exclude map[string]struct{}) error {
// 规范化排除路径为绝对路径
normExclude := make(map[string]struct{}, len(exclude))
for p := range exclude {
abs, _ := filepath.Abs(p)
normExclude[abs] = struct{}{}
}
out, err := os.Create(dstZip)
if err != nil {
return err
}
defer out.Close()
zw := zip.NewWriter(out)
defer zw.Close()
absSrc, _ := filepath.Abs(srcDir)
walkErr := filepath.Walk(absSrc, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 排除 backup.zip 本身
if _, ok := normExclude[path]; ok {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
// 计算 zip 内相对路径
rel, err := filepath.Rel(absSrc, path)
if err != nil {
return err
}
// 根目录跳过
if rel == "." {
return nil
}
// 替换为正斜杠
zipName := filepath.ToSlash(rel)
if info.IsDir() {
_, err := zw.Create(zipName + "/")
return err
}
// 普通文件
fh, err := os.Open(path)
if err != nil {
return err
}
w, err := zw.Create(zipName)
if err != nil {
fh.Close()
return err
}
if _, err := io.Copy(w, fh); err != nil {
fh.Close()
return err
}
fh.Close()
return nil
})
if walkErr != nil {
return walkErr
}
return zw.Close()
}
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}
normExclude := make(map[string]struct{}, len(exclude))
for p := range exclude {
abs, _ := filepath.Abs(p)
normExclude[abs] = struct{}{}
}
entries, err := os.ReadDir(absDir)
if err != nil {
return err
}
for _, e := range entries {
full := filepath.Join(absDir, e.Name())
if _, ok := normExclude[full]; ok {
continue
}
if err := os.RemoveAll(full); err != nil {
return err
}
}
return nil
}
// unzipToDir 将 zipPath 解压到 dstDir,包含路径遍历保护
func unzipToDir(zipPath, dstDir string) error {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer zr.Close()
if err := os.MkdirAll(dstDir, 0755); err != nil {
return err
}
absDst, _ := filepath.Abs(dstDir)
for _, f := range zr.File {
// 构造目标路径并做路径遍历保护
cleanName := filepath.Clean(f.Name)
targetPath := filepath.Join(absDst, cleanName)
if !strings.HasPrefix(targetPath, absDst+string(os.PathSeparator)) && targetPath != absDst {
return fmt.Errorf("illegal file path in zip: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.Create(targetPath)
if err != nil {
rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
rc.Close()
return err
}
out.Close()
rc.Close()
}
return nil
}
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
func mergeClientInfo(db *gorm.DB) {
var clientInfos []common.ClientInfo
if err := db.Find(&clientInfos).Error; err != nil {
log.Printf("Failed to read ClientInfo table: %v", err)
return
}
for _, info := range clientInfos {
var client models.Client
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
continue
}
// 更新Client记录
client.Name = info.Name
client.CpuName = info.CpuName
client.Virtualization = info.Virtualization
client.Arch = info.Arch
client.CpuCores = info.CpuCores
client.OS = info.OS
client.GpuName = info.GpuName
client.IPv4 = info.IPv4
client.IPv6 = info.IPv6
client.Region = info.Region
client.Remark = info.Remark
client.PublicRemark = info.PublicRemark
client.MemTotal = info.MemTotal
client.SwapTotal = info.SwapTotal
client.DiskTotal = info.DiskTotal
client.Version = info.Version
client.Weight = info.Weight
client.Price = info.Price
client.BillingCycle = info.BillingCycle
client.ExpiredAt = models.FromTime(info.ExpiredAt)
// Save updated Client record
if err := db.Save(&client).Error; err != nil {
log.Printf("Failed to update Client record: %v", err)
continue
}
}
// Backup and rename old table after migration
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
log.Printf("Failed to backup ClientInfo table: %v", err)
return
}
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
}
func MergeDatabase(db *gorm.DB) {
if db.Migrator().HasTable("client_infos") {
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
mergeClientInfo(db)
}
if db.Migrator().HasColumn(&models.Config{}, "allow_cros") {
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
}
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
db.Migrator().DropTable(&models.LoadNotification{})
}
if !db.Migrator().HasTable(&models.OidcProvider{}) && db.Migrator().HasTable(&models.Config{}) {
log.Println("[>1.0.2] Merge OidcProvider table....")
var config struct {
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
log.Println("Failed to get config for OIDC provider migration:", err)
}
db.AutoMigrate(&models.OidcProvider{})
j, err := json.Marshal(&map[string]string{
"client_id": config.OAuthClientID,
"client_secret": config.OAuthClientSecret,
})
if err != nil {
log.Println("Failed to marshal OIDC provider config:", err)
return
}
db.Save(&models.OidcProvider{
Name: "github",
Addition: string(j),
})
db.AutoMigrate(&models.Config{})
db.Model(&models.Config{}).Where("id = 1").Update("o_auth_provider", "github")
}
if !db.Migrator().HasTable(&models.MessageSenderProvider{}) && db.Migrator().HasTable(&models.Config{}) {
log.Println("[>1.0.2] Migrate MessageSender configuration....")
var config struct {
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255)"`
EmailHost string `json:"email_host" gorm:"type:varchar(255)"`
EmailPort int `json:"email_port" gorm:"type:int"`
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"`
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"`
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"`
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"`
EmailUseSSL bool `json:"email_use_ssl" gorm:"type:boolean"`
NotificationMethod string `json:"notification_method" gorm:"type:varchar(50)"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
log.Println("Failed to get config for MessageSender migration:", err)
}
db.AutoMigrate(&models.MessageSenderProvider{})
// 迁移Telegram配置
if config.NotificationMethod == "telegram" && config.TelegramBotToken != "" {
telegramConfig := map[string]interface{}{
"bot_token": config.TelegramBotToken,
"chat_id": config.TelegramChatID,
"endpoint": config.TelegramEndpoint,
}
if telegramConfig["endpoint"] == "" {
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
}
telegramConfigJSON, err := json.Marshal(telegramConfig)
if err != nil {
log.Println("Failed to marshal Telegram config:", err)
} else {
db.Save(&models.MessageSenderProvider{
Name: "telegram",
Addition: string(telegramConfigJSON),
})
}
}
// 迁移Email配置
if config.NotificationMethod == "email" && config.EmailHost != "" {
emailConfig := map[string]interface{}{
"host": config.EmailHost,
"port": config.EmailPort,
"username": config.EmailUsername,
"password": config.EmailPassword,
"sender": config.EmailSender,
"receiver": config.EmailReceiver,
"use_ssl": config.EmailUseSSL,
}
emailConfigJSON, err := json.Marshal(emailConfig)
if err != nil {
log.Println("Failed to marshal Email config:", err)
} else {
db.Save(&models.MessageSenderProvider{
Name: "email",
Addition: string(emailConfigJSON),
})
}
}
// 删除旧的配置字段
if db.Migrator().HasColumn(&models.Config{}, "telegram_bot_token") {
db.Migrator().DropColumn(&models.Config{}, "telegram_bot_token")
}
if db.Migrator().HasColumn(&models.Config{}, "telegram_chat_id") {
db.Migrator().DropColumn(&models.Config{}, "telegram_chat_id")
}
if db.Migrator().HasColumn(&models.Config{}, "telegram_endpoint") {
db.Migrator().DropColumn(&models.Config{}, "telegram_endpoint")
}
if db.Migrator().HasColumn(&models.Config{}, "email_host") {
db.Migrator().DropColumn(&models.Config{}, "email_host")
}
if db.Migrator().HasColumn(&models.Config{}, "email_port") {
db.Migrator().DropColumn(&models.Config{}, "email_port")
}
if db.Migrator().HasColumn(&models.Config{}, "email_username") {
db.Migrator().DropColumn(&models.Config{}, "email_username")
}
if db.Migrator().HasColumn(&models.Config{}, "email_password") {
db.Migrator().DropColumn(&models.Config{}, "email_password")
}
if db.Migrator().HasColumn(&models.Config{}, "email_sender") {
db.Migrator().DropColumn(&models.Config{}, "email_sender")
}
if db.Migrator().HasColumn(&models.Config{}, "email_receiver") {
db.Migrator().DropColumn(&models.Config{}, "email_receiver")
}
if db.Migrator().HasColumn(&models.Config{}, "email_use_ssl") {
db.Migrator().DropColumn(&models.Config{}, "email_use_ssl")
}
}
}
var (
instance *gorm.DB
once sync.Once
)
func GetDBInstance() *gorm.DB {
once.Do(func() {
var err error
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
func() {
backupZipPath := filepath.Join(".", "data", "backup.zip")
if _, statErr := os.Stat(backupZipPath); statErr == nil {
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
if err := os.MkdirAll("./backup", 0755); err != nil {
log.Printf("[restore] failed to create backup dir: %v", err)
} else {
tsName := time.Now().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
log.Printf("[restore] failed to zip current data: %v", zipErr)
} else {
log.Printf("[restore] current data zipped to %s", bakPath)
}
}
// 5. 删除除了 ./data/backup.zip 之外的所有文件
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
}
// 6. 解压 ./data/backup.zip 到 ./data
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
} else {
log.Printf("[restore] backup.zip extracted to ./data")
}
// 7. 删除 ./data/backup.zip
if rmErr := os.Remove(backupZipPath); rmErr != nil {
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
} else {
log.Printf("[restore] backup.zip removed")
}
// 8. 删除标记
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
} else {
log.Printf("[restore] komari-backup-markup removed")
}
}
}()
logConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
if utils.VersionHash == "unknown" {
logConfig = &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
}
// 根据数据库类型选择不同的连接方式
switch flags.DatabaseType {
case "sqlite", "":
// SQLite 连接
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), logConfig)
if err != nil {
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
}
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
instance.Exec("PRAGMA wal = ON;")
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
}
instance.Exec("VACUUM;")
case "mysql":
// MySQL 连接
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
flags.DatabaseUser,
flags.DatabasePass,
flags.DatabaseHost,
flags.DatabasePort,
flags.DatabaseName)
instance, err = gorm.Open(mysql.Open(dsn), logConfig)
if err != nil {
log.Fatalf("Failed to connect to MySQL database: %v", err)
}
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
default:
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
}
MergeDatabase(instance)
// 自动迁移模型
err = instance.AutoMigrate(
&models.User{},
&models.Client{},
&models.Record{},
&models.GPURecord{},
&models.Config{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.PingRecord{},
&models.PingTask{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
&models.ThemeConfiguration{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
}
err = instance.Table("records_long_term").AutoMigrate(
&models.Record{},
)
if err != nil {
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
}
err = instance.Table("gpu_records_long_term").AutoMigrate(
&models.GPURecord{},
)
if err != nil {
log.Printf("Failed to create gpu_records_long_term table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Session{},
)
if err != nil {
log.Printf("Failed to create Session table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Task{},
&models.TaskResult{},
)
if err != nil {
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
}
})
return instance
}
-121
View File
@@ -1,121 +0,0 @@
package models
import (
"database/sql/driver"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
)
// LocalTime is a custom time type for GORM to handle time.Time correctly.
// It ensures that all times are stored and retrieved based on the application's configured timezone.
type LocalTime time.Time
// Value implements the driver.Valuer interface.
// It converts UTCTime to a TEXT format that the database understands.
// The time is formatted as a string in the application's local timezone, without timezone information.
func (t LocalTime) Value() (driver.Value, error) {
if time.Time(t).IsZero() {
return nil, nil
}
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
}
// Scan implements the sql.Scanner interface.
// It scans a value from the database into a UTCTime object.
func (t *LocalTime) Scan(v interface{}) error {
if v == nil {
*t = LocalTime(time.Time{})
return nil
}
loc := GetAppLocation()
switch val := v.(type) {
case time.Time:
// CRITICAL FIX: When the driver reads a timezone-less string (e.g., "16:00:00"),
// it often incorrectly assumes it's UTC. We must correct this by re-interpreting
// the date and clock values in the application's actual timezone.
year, month, day := val.Date()
hour, min, sec := val.Clock()
nanosec := val.Nanosecond()
*t = LocalTime(time.Date(year, month, day, hour, min, sec, nanosec, loc))
return nil
case []byte:
return t.parseTime(string(val), loc)
case string:
return t.parseTime(val, loc)
default:
return fmt.Errorf("UTCTime scan source was not string, []byte or time.Time: %T (%v)", v, v)
}
}
// parseTime handles parsing a string into UTCTime.
func (t *LocalTime) parseTime(timeStr string, loc *time.Location) error {
timeStr = strings.TrimSpace(timeStr)
if timeStr == "" {
*t = LocalTime(time.Time{})
return nil
}
layouts := []string{
time.RFC3339Nano, time.RFC3339,
"2006-01-02 15:04:05.0000000-07:00", "2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05.0000000", "2006-01-02 15:04:05", "2006-01-02",
}
for _, layout := range layouts {
if parsedTime, err := time.ParseInLocation(layout, timeStr, loc); err == nil {
*t = LocalTime(parsedTime)
return nil
}
}
return fmt.Errorf("unable to parse time string '%s' into UTCTime", timeStr)
}
// MarshalJSON implements the json.Marshaler interface.
// Serializes UTCTime to JSON in RFC3339 format with the correct timezone offset.
func (t LocalTime) MarshalJSON() ([]byte, error) {
if time.Time(t).IsZero() {
return []byte("null"), nil
}
formattedTime := time.Time(t).In(GetAppLocation()).Format(time.RFC3339)
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
}
// ToTime converts UTCTime to Go's native time.Time type.
func (t LocalTime) ToTime() time.Time { return time.Time(t) }
// FromTime converts Go's native time.Time type to UTCTime.
func FromTime(t time.Time) LocalTime { return LocalTime(t) }
// Now returns the current time in the application's configured location.
func Now() LocalTime { return LocalTime(time.Now().In(GetAppLocation())) }
var (
appLocation *time.Location
locationOnce sync.Once
)
// GetAppLocation retrieves the application's global timezone from the "TZ" environment variable.
func GetAppLocation() *time.Location {
locationOnce.Do(func() {
tz := os.Getenv("TZ")
if tz == "" {
tz = "UTC"
}
loc, err := time.LoadLocation(tz)
if err != nil {
log.Printf("Warning: Failed to load timezone '%s', falling back to UTC. Error: %v", tz, err)
appLocation = time.UTC
} else {
appLocation = loc
}
time.Local = appLocation
log.Printf("Application timezone is set to '%s'.", appLocation.String())
})
return appLocation
}
-41
View File
@@ -1,41 +0,0 @@
package models
type Config struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt LocalTime
UpdatedAt LocalTime
}
+29 -25
View File
@@ -1,43 +1,44 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komariは、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Webインターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
Komari は、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Web インターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
[ドキュメント](https://komari-document.pages.dev/) | [Telegram グループ](https://t.me/komari_monitor)
## 特徴
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
- **Web インターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
## クイックスタート
### 0. クラウドホスティングによるワンクリック展開
- Rainyun - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Rainyun - CNY 4.5/月
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
### 1. ワンクリックインストールスクリプトを使用する
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
systemd を使用するディストリビューション(Ubuntu、Debian など)に適しています。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker展開
### 2. Docker 展開
1. データディレクトリを作成します:
```bash
mkdir -p ./data
```
2. Dockerコンテナを実行します:
2. Docker コンテナを実行します:
```bash
docker run -d \
-p 25774:25774 \
@@ -55,8 +56,9 @@ sudo ./install-komari.sh
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
### 3. バイナリファイル展開
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komariを実行します:
1. Komari の[GitHub リリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komari を実行します:
```bash
./komari server -l 0.0.0.0:25774
```
@@ -66,12 +68,11 @@ sudo ./install-komari.sh
> [!NOTE]
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
### 手動ビルド
#### 依存関係
- Go 1.18+ および Node.js 20+(手動ビルド用)
- Go 1.24+ および Node.js 20+(手動ビルド用)
1. フロントエンドの静的ファイルをビルドします:
```bash
@@ -85,26 +86,29 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
```bash
ステップ 1 で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
```bash
go build -o komari
```
4. 実行:
3. 実行:
```bash
./komari server -l 0.0.0.0:25774
```
デフォルトのリスニングポートは `25774` です。`http://localhost:25774` にアクセスします。
## フロントエンド開発ガイド
[Komariテーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
[CrowdinでKomariを翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
[Komari テーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
[Crowdin で Komari を翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## クライアントエージェント開発ガイド
[Komariエージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
[Komari エージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
## 貢献
IssueやPull Requestを歓迎します!
Issue や Pull Request を歓迎します!
## 謝辞
@@ -114,7 +118,7 @@ IssueやPull Requestを歓迎します!
### 🚀 SharonNetworks スポンサー
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
@@ -126,6 +130,6 @@ PR を送ってくれた方、テーマを作成してくれた全ての開発
—— そして:こんなに暇でいられる自分に感謝
## Star履歴
## Star 履歴
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+16 -14
View File
@@ -1,5 +1,5 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
@@ -9,6 +9,7 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
## 特性
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
- **自託管**:完全掌控數據隱私,部署簡單。
- **Web 介面**:直觀的監控儀表盤,易於使用。
@@ -17,16 +18,14 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
### 0. 容器雲一鍵部署
- 雨雲雲應用 - CNY 4.5/月
- 雨雲雲應用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
### 1. 使用一鍵安裝腳本
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
@@ -34,6 +33,7 @@ sudo ./install-komari.sh
```
### 2. Docker 部署
1. 建立資料目錄:
```bash
mkdir -p ./data
@@ -56,6 +56,7 @@ sudo ./install-komari.sh
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
### 3. 二進位檔案部署
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
2. 執行 Komari:
```bash
@@ -67,12 +68,11 @@ sudo ./install-komari.sh
> [!NOTE]
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
### 手工建置
#### 依賴
- Go 1.18+ 和 Node.js 20+(手工建置)
- Go 1.24+ 和 Node.js 20+(手工建置)
1. 建置前端靜態檔案:
```bash
@@ -86,25 +86,28 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
```bash
將步驟 1 中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
```bash
go build -o komari
```
4. 執行:
3. 執行:
```bash
./komari server -l 0.0.0.0:25774
```
預設監聽 `25774` 連接埠,存取 `http://localhost:25774`。
## 前端開發指南
[Komari 主題開發指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻譯 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客戶端 Agent 開發指南
[Komari Agent 資訊上報與事件處理文檔](https://komari-document.pages.dev/dev/agent.html)
## 貢獻
歡迎提交 Issue 或 Pull Request!
## 鳴謝
@@ -115,7 +118,7 @@ sudo ./install-komari.sh
### 🚀 由 SharonNetworks 贊助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 為您的業務起飛保駕護航!
@@ -130,4 +133,3 @@ SharonNetworks 為您的業務起飛保駕護航!
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+17 -14
View File
@@ -1,14 +1,15 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
@@ -17,16 +18,14 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
### 0. 容器云一键部署
- 雨云云应用 - CNY 4.5/月
- 雨云云应用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
@@ -34,6 +33,7 @@ sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
@@ -56,6 +56,7 @@ sudo ./install-komari.sh
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
@@ -67,12 +68,11 @@ sudo ./install-komari.sh
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
### 手工构建
#### 依赖
- Go 1.18+ 和 Node.js 20+(手工构建)
- Go 1.24+ 和 Node.js 20+(手工构建)
1. 构建前端静态文件:
```bash
@@ -86,25 +86,28 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
```bash
将步骤 1 中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
```bash
go build -o komari
```
4. 运行:
3. 运行:
```bash
./komari server -l 0.0.0.0:25774
```
默认监听 `25774` 端口,访问 `http://localhost:25774`。
## 前端开发指南
[Komari 主题开发指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻译 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客户端 Agent 开发指南
[Komari Agent 信息上报与事件处理文档](https://komari-document.pages.dev/dev/agent.html)
## 贡献
欢迎提交 Issue 或 Pull Request!
## 鸣谢
@@ -115,7 +118,7 @@ sudo ./install-komari.sh
### 🚀 由 SharonNetworks 赞助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
+13 -4
View File
@@ -1,23 +1,28 @@
module github.com/komari-monitor/komari
go 1.23.2
go 1.24.0
toolchain go1.24.11
require (
github.com/coreos/go-oidc/v3 v3.15.0
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gookit/event v1.2.0
github.com/gorilla/websocket v1.5.3
github.com/oschwald/maxminddb-golang v1.13.1
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/image v0.33.0
google.golang.org/grpc v1.75.0
google.golang.org/protobuf v1.36.6
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
)
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
@@ -35,20 +40,24 @@ require (
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/gookit/goutil v0.7.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.27 // indirect
@@ -63,7 +72,7 @@ require (
golang.org/x/arch v0.15.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+24 -3
View File
@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
@@ -17,6 +19,12 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
@@ -37,6 +45,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
@@ -47,8 +57,14 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/event v1.2.0 h1:aa8ouNVlo4E/NRhHVXNU/JbWvlr91Gjh423WtCDYQ4Q=
github.com/gookit/event v1.2.0/go.mod h1:gGYybJL0HEEo/+UmBN+MgLqUBIxcCGOP8FrLPk+J8w4=
github.com/gookit/goutil v0.7.1 h1:AaFJPN9mrdeYBv8HOybri26EHGCC34WJVT7jUStGJsI=
github.com/gookit/goutil v0.7.1/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -93,7 +109,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -133,6 +148,8 @@ golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@@ -140,8 +157,10 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
@@ -155,6 +174,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+29
View File
@@ -0,0 +1,29 @@
package internal
import (
// Import all internal packages to ensure their init() functions are executed
_ "github.com/komari-monitor/komari/internal/api_rpc"
_ "github.com/komari-monitor/komari/internal/api_v1"
_ "github.com/komari-monitor/komari/internal/client"
_ "github.com/komari-monitor/komari/internal/cloudflared"
_ "github.com/komari-monitor/komari/internal/common"
_ "github.com/komari-monitor/komari/internal/conf"
_ "github.com/komari-monitor/komari/internal/database"
_ "github.com/komari-monitor/komari/internal/eventType"
_ "github.com/komari-monitor/komari/internal/geoip"
_ "github.com/komari-monitor/komari/internal/jsruntime"
_ "github.com/komari-monitor/komari/internal/log"
_ "github.com/komari-monitor/komari/internal/messageSender"
_ "github.com/komari-monitor/komari/internal/mjpeg"
_ "github.com/komari-monitor/komari/internal/nezha"
_ "github.com/komari-monitor/komari/internal/notifier"
_ "github.com/komari-monitor/komari/internal/oauth"
_ "github.com/komari-monitor/komari/internal/patch"
_ "github.com/komari-monitor/komari/internal/pingSchedule"
_ "github.com/komari-monitor/komari/internal/plugin"
_ "github.com/komari-monitor/komari/internal/renewal"
_ "github.com/komari-monitor/komari/internal/restore"
_ "github.com/komari-monitor/komari/internal/ws"
)
func All() {}
@@ -1,4 +1,4 @@
package jsonRpc
package api_rpc
import (
"context"
@@ -11,22 +11,25 @@ import (
"sync"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/rpc"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/pkg/rpc"
)
func RegisterRouters(path string, r *gin.Engine) {
r.GET(path, OnRpcRequest)
r.POST(path, OnRpcRequest)
}
// Json Rpc2 over websocket, /api/rpc2
func OnRpcRequest(c *gin.Context) {
cfg, _ := config.Get()
// GET -> WebSocket
if c.Request.Method == http.MethodGet {
_conn, err := ws.UpgradeRequest(c, func(r *http.Request) bool {
if cfg.AllowCors {
if conf.Conf.Site.AllowCors {
return true
}
return ws.CheckOrigin(r)
@@ -36,7 +39,7 @@ func OnRpcRequest(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
permissionGroup := detectPermissionGroup(c, cfg)
permissionGroup := detectPermissionGroup(c)
meta := buildContextMeta(c, permissionGroup)
defer conn.Close()
for {
@@ -77,7 +80,7 @@ func OnRpcRequest(c *gin.Context) {
c.JSON(http.StatusBadRequest, jerr.Response())
return
}
permissionGroup := detectPermissionGroup(c, cfg)
permissionGroup := detectPermissionGroup(c)
meta := buildContextMeta(c, permissionGroup)
// 批量
responses := make([]*rpc.JsonRpcResponse, 0, len(requests))
@@ -91,8 +94,8 @@ func OnRpcRequest(c *gin.Context) {
switch fc[0] {
case "guest", "", "rpc", "common":
allowed = true
case "client":
if permissionGroup == "client" || permissionGroup == "admin" {
case GroupClient:
if permissionGroup == GroupClient || permissionGroup == "admin" {
allowed = true
}
case "admin":
@@ -100,11 +103,11 @@ func OnRpcRequest(c *gin.Context) {
allowed = true
}
default:
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
responses = append(responses, rpc.ErrorResponse(rreq.ID, rpc.Unauthenticated, "Unauthorized", nil))
continue
}
if !allowed {
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
responses = append(responses, rpc.ErrorResponse(rreq.ID, rpc.PermissionDenied, "Permission denied", nil))
continue
}
responses = append(responses, rpc.CallWithContext(rpc.NewContextWithMeta(context.TODO(), meta), rreq.ID, rreq.Method, rreq.Params))
@@ -118,11 +121,11 @@ func OnRpcRequest(c *gin.Context) {
}
// detectPermissionGroup 提取权限分组,与原逻辑保持一致
func detectPermissionGroup(c *gin.Context, cfg models.Config) string {
func detectPermissionGroup(c *gin.Context) string {
permissionGroup := "guest"
token := c.Query("Authorization")
if _, err := clients.GetClientUUIDByToken(token); err == nil {
permissionGroup = "client"
token := c.Query("token")
if _, err := client.GetClientUUIDByToken(token); err == nil {
permissionGroup = GroupClient
}
if session_token, _ := c.Cookie("session_token"); session_token != "" {
if _, err := accounts.GetUserBySession(session_token); err == nil {
@@ -130,7 +133,7 @@ func detectPermissionGroup(c *gin.Context, cfg models.Config) string {
}
}
apiKey := c.GetHeader("Authorization")
if apiKey == "Bearer "+cfg.ApiKey {
if apiKey == "Bearer "+conf.Conf.Login.ApiKey {
permissionGroup = "admin"
}
return permissionGroup
@@ -139,8 +142,8 @@ func detectPermissionGroup(c *gin.Context, cfg models.Config) string {
// buildContextMeta 从 gin.Context 构建 *rpc.ContextMeta
func buildContextMeta(c *gin.Context, permissionGroup string) *rpc.ContextMeta {
meta := &rpc.ContextMeta{Permission: permissionGroup}
// 提取客户端 token (query Authorization / Header Authorization Bearer token)
token := c.Query("Authorization")
// 提取客户端 token (query token / Header Authorization Bearer token)
token := c.Query("token")
if token == "" {
// 兼容 header Bearer <token>
hAuth := c.GetHeader("Authorization")
@@ -149,7 +152,7 @@ func buildContextMeta(c *gin.Context, permissionGroup string) *rpc.ContextMeta {
}
}
if token != "" {
if uuid, err := clients.GetClientUUIDByToken(token); err == nil {
if uuid, err := client.GetClientUUIDByToken(token); err == nil {
meta.ClientToken = token
meta.ClientUUID = uuid
}
@@ -172,27 +175,69 @@ func dispatchByPermissionWithMeta(conn *ws.SafeConn, permissionGroup string, met
if len(fc) == 1 {
fc[0] = "common"
}
ctx := rpc.NewContextWithMeta(context.TODO(), meta)
ctx := rpc.NewContextWithMeta(context.Background(), meta)
switch fc[0] {
case "guest", "", "rpc", "common":
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
case "client":
if permissionGroup == "client" || permissionGroup == "admin" {
case GroupClient:
if permissionGroup == GroupClient || permissionGroup == "admin" {
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
} else {
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
conn.WriteJSON(rpc.ErrorResponse(req.ID, rpc.PermissionDenied, "Permission Denied", nil))
}
case "admin":
if permissionGroup == "admin" {
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
} else {
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
conn.WriteJSON(rpc.ErrorResponse(req.ID, rpc.PermissionDenied, "Permission Denied", nil))
}
default:
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
conn.WriteJSON(rpc.ErrorResponse(req.ID, rpc.Unauthenticated, "Unauthenticated", nil))
}
}
// OnInternalRequest 内部调用 RPC 方法,支持权限控制
// group: 调用者的权限分组 (guest/client/admin)
// method: RPC 方法名 (如 "common:getNodes")
// params: 方法参数
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
// 检查私有站点
if conf.Conf.Site.PrivateSite && group == "guest" {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Private site enabled, please login first", nil)
}
// 解析方法命名空间
fc := strings.Split(method, ":")
namespace := "common"
if len(fc) >= 2 {
namespace = fc[0]
}
// 权限检查
allowed := false
switch namespace {
case "guest", "", "rpc", "common":
allowed = true
case GroupClient:
if group == GroupClient || group == "admin" {
allowed = true
}
case "admin":
if group == "admin" {
allowed = true
}
}
if !allowed {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Permission denied", nil)
}
// 构建 context meta
meta := &rpc.ContextMeta{Permission: group}
return rpc.CallWithContext(rpc.NewContextWithMeta(ctx, meta), nil, method, params)
}
// registry holds method handlers keyed by "namespace:MethodName"
var (
registryMu sync.RWMutex
+31
View File
@@ -0,0 +1,31 @@
package api_rpc
import (
"context"
"github.com/komari-monitor/komari/pkg/rpc"
)
const GroupClient = "client"
func init() {
RegisterWithGroupAndMeta("register", GroupClient, registerClient, &rpc.MethodMeta{
Name: "RegisterClient",
Description: "Register a new client with the server (Auto discovery).",
Params: []rpc.ParamMeta{
{
Name: "name",
Type: "string",
Description: "The name of the client to register.",
},
},
})
}
func registerClient(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Name string `json:"name"`
}
req.BindParams(&params)
return nil, rpc.MakeError(rpc.InternalError, "Not impl yet", nil)
}
@@ -1,4 +1,4 @@
package jsonRpc
package api_rpc
import (
"context"
@@ -8,17 +8,16 @@ import (
"strings"
"time"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/rpc"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/internal/api_v1/vars"
"github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/pkg/rpc"
cache "github.com/patrickmn/go-cache"
)
@@ -223,13 +222,13 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
UUID string `json:"uuid"`
}
req.BindParams(&params)
cinfo, err := clients.GetAllClientBasicInfo()
cinfo, err := client.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", cinfo)
}
meta := rpc.MetaFromContext(ctx)
cfg, _ := config.Get()
cfg, _ := conf.GetWithV1Format()
if meta.Permission != "admin" {
// 过滤 Hidden 节点并隐藏敏感字段
filtered := make([]models.Client, 0, len(cinfo))
@@ -297,7 +296,7 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
// Hidden 过滤
if meta.Permission != "admin" {
cinfo, err := clients.GetAllClientBasicInfo()
cinfo, err := client.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
}
@@ -440,7 +439,7 @@ func getMe(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError)
resp.SSOType = "client"
resp.Username = "client"
resp.UUID = meta.ClientToken
client, err := clients.GetClientUUIDByToken(meta.ClientToken)
client, err := client.GetClientUUIDByToken(meta.ClientToken)
if err != nil {
resp.UUID = client
}
@@ -455,8 +454,8 @@ func getVersion(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcErro
Version string `json:"version"`
Hash string `json:"hash"`
}{
Version: utils.CurrentVersion,
Hash: utils.VersionHash,
Version: conf.Version,
Hash: conf.CommitHash,
}, nil
}
@@ -490,7 +489,7 @@ func getNodeRecentStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rp
}
}
raw, _ := api.Records.Get(params.UUID)
raw, _ := vars.Records.Get(params.UUID)
reports, _ := raw.([]common.Report)
// 扁平化为 { count, records: [] }
@@ -1,4 +1,4 @@
package jsonRpc
package api_rpc
import (
"context"
@@ -7,12 +7,12 @@ import (
"sort"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
recordsdb "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils/rpc"
"github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/database/models"
recordsdb "github.com/komari-monitor/komari/internal/database/records"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/pkg/rpc"
)
func init() {
@@ -73,7 +73,7 @@ func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpc
isAdmin := meta.Permission == "admin"
hidden := map[string]bool{}
if !isAdmin {
cinfo, err := clients.GetAllClientBasicInfo()
cinfo, err := client.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
}
+15
View File
@@ -0,0 +1,15 @@
package api_rpc
import (
"github.com/gin-gonic/gin"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/eventType"
)
func init() {
event.On(eventType.ServerInitializeStart, event.ListenerFunc(func(e event.Event) error {
r := e.Get("engine").(*gin.Engine)
RegisterRouters("/api/rpc2", r)
return nil
}))
}
@@ -1,15 +1,21 @@
package api
package api_v1
import (
"net/http"
"strings"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/gin-gonic/gin"
)
func AdminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !strings.HasPrefix(c.Request.URL.Path, "/api/admin") {
c.Next()
return
}
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
@@ -20,7 +26,7 @@ func AdminAuthMiddleware() gin.HandlerFunc {
// session-based authentication
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
resp.RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
@@ -28,7 +34,7 @@ func AdminAuthMiddleware() gin.HandlerFunc {
// Komari is a single user system
uuid, err := accounts.GetSession(session)
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
resp.RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
+10 -60
View File
@@ -1,46 +1,28 @@
package api
package api_v1
import (
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/internal/api_v1/vars"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/conf"
"github.com/patrickmn/go-cache"
"strconv"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/pkg/utils"
)
var (
Records = cache.New(1*time.Minute, 1*time.Minute)
)
type TerminalSession struct {
UUID string
UserUUID string
Browser *websocket.Conn
Agent *websocket.Conn
RequesterIp string
}
var TerminalSessionsMutex = &sync.Mutex{}
var TerminalSessions = make(map[string]*TerminalSession)
func SaveClientReportToDB() error {
lastMinute := time.Now().Add(-time.Minute).Unix()
var records []models.Record
var gpuRecords []models.GPURecord
// 遍历所有客户端记录
for uuid, x := range Records.Items() {
for uuid, x := range vars.Records.Items() {
if uuid == "" {
continue
}
@@ -60,7 +42,7 @@ func SaveClientReportToDB() error {
}
// 更新缓存
Records.Set(uuid, filtered, cache.DefaultExpiration)
vars.Records.Set(uuid, filtered, cache.DefaultExpiration)
// 计算平均报告并添加到记录列表
if len(filtered) > 0 {
@@ -113,40 +95,8 @@ func SaveClientReportToDB() error {
return nil
}
type Response struct {
Status string `json:"status"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Respond sends a standardized JSON response.
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
}
// RespondSuccess sends a success response with data.
func RespondSuccess(c *gin.Context, data interface{}) {
Respond(c, http.StatusOK, "success", "", data)
}
// RespondSuccessMessage sends a success response with message and data.
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
Respond(c, http.StatusOK, "success", message, data)
}
// RespondError sends an error response with message.
func RespondError(c *gin.Context, httpStatus int, message string) {
Respond(c, httpStatus, "error", message, nil)
}
func GetVersion(c *gin.Context) {
RespondSuccess(c, gin.H{
"version": utils.CurrentVersion,
"hash": utils.VersionHash,
})
}
func isApiKeyValid(apiKey string) bool {
cfg, err := config.Get()
cfg, err := conf.GetWithV1Format()
if err != nil {
return false
}
+105
View File
@@ -0,0 +1,105 @@
package api_v1
import (
"net/http"
"strings"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/gin-gonic/gin"
)
var (
requireAuthPaths = []string{
// v1 API at 2025-12-06
// "/api/rpc2",
// "/ping",
// "/api/login",
// "/api/me",
"/api/clients",
"/api/nodes",
// "/api/public",
// "/api/oauth",
// "/api/oauth_callback",
// "/api/logout",
// "/api/version",
"/api/recent/",
"/api/records/",
"/api/task/",
// "/api/clients/register",
// "/api/clients/report",
// "/api/clients/uploadBasicInfo",
// "/api/clients/terminal",
// "/api/clients/task/result",
// "/api/admin/download/backup",
}
)
func PrivateSiteMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
path := c.Request.URL.Path
// 如果不是 /api 开头的路径,直接放行
if !strings.HasPrefix(path, "/api") {
c.Next()
return
}
// /api/clients/* 由 TokenAuthMiddleware 处理
// /api/rpc2 由 api_rpc 处理
// /api/admin/* 由 AdminAuthMiddleware 处理
if strings.HasPrefix(path, "/api/clients/") ||
strings.HasPrefix(path, "/api/admin") ||
strings.HasPrefix(path, "/api/rpc2") {
c.Next()
return
}
require := false
for _, p := range requireAuthPaths {
if strings.HasPrefix(path, p) {
require = true
break
}
}
if !require {
c.Next()
return
}
conf, err := conf.GetWithV1Format()
if err != nil {
resp.RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
c.Abort()
return
}
// 验证私有, 如果不是私有站点,直接放行
if !conf.PrivateSite {
c.Next()
return
}
// 如果是私有站点,检查是否有 session
session, err := c.Cookie("session_token")
if err != nil {
resp.RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
c.Abort()
return
}
_, err = accounts.GetSession(session)
if err != nil {
resp.RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
c.Abort()
return
}
c.Next()
}
}
@@ -1,4 +1,4 @@
package api
package api_v1
import (
"bytes"
@@ -7,7 +7,7 @@ import (
"io"
"net/http"
"github.com/komari-monitor/komari/database/clients"
clients "github.com/komari-monitor/komari/internal/client"
"gorm.io/gorm"
"github.com/gin-gonic/gin"
@@ -4,15 +4,15 @@ import (
"image/png"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/pquerna/otp/totp"
)
func Generate2FA(c *gin.Context) {
secret, img, err := accounts.Generate2Fa()
if err != nil {
api.RespondError(c, 500, "Failed to generate 2FA: "+err.Error())
resp.RespondError(c, 500, "Failed to generate 2FA: "+err.Error())
return
}
c.SetCookie("2fa_secret", secret, 1800, "/", "", false, true)
@@ -26,29 +26,29 @@ func Enable2FA(c *gin.Context) {
secret, _ := c.Cookie("2fa_secret")
code := c.Query("code")
if secret == "" || uuid == nil || code == "" {
api.RespondError(c, 400, "2FA secret or code not provided")
resp.RespondError(c, 400, "2FA secret or code not provided")
return
}
if !totp.Validate(code, secret) {
api.RespondError(c, 400, "Invalid 2FA code")
resp.RespondError(c, 400, "Invalid 2FA code")
return
}
err := accounts.Enable2Fa(uuid.(string), secret)
if err != nil {
api.RespondError(c, 500, "Failed to enable 2FA: "+err.Error())
resp.RespondError(c, 500, "Failed to enable 2FA: "+err.Error())
return
}
c.SetCookie("2fa_secret", "", -1, "/", "", false, true)
api.RespondSuccess(c, "2FA enabled successfully")
resp.RespondSuccess(c, "2FA enabled successfully")
}
func Disable2FA(c *gin.Context) {
uuid, _ := c.Get("uuid")
err := accounts.Disable2Fa(uuid.(string))
if err != nil {
api.RespondError(c, 500, "Failed to disable 2FA: "+err.Error())
resp.RespondError(c, 500, "Failed to disable 2FA: "+err.Error())
return
}
api.RespondSuccess(c, "")
resp.RespondSuccess(c, "")
}
@@ -4,10 +4,12 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/ws"
"github.com/gookit/event"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/database/records"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/internal/ws"
)
func AddClient(c *gin.Context) {
@@ -30,6 +32,10 @@ func AddClient(c *gin.Context) {
}
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
event.Trigger(eventType.ClientCreated, event.M{
"client": uuid,
"token": token,
})
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
}
@@ -45,13 +51,17 @@ func EditClient(c *gin.Context) {
return
}
req["uuid"] = uuid
err := clients.SaveClient(req)
err := clients.PartialUpdate(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
event.Trigger(eventType.ClientUpdated, event.M{
"client": uuid,
"data": req,
})
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
@@ -70,6 +80,9 @@ func RemoveClient(c *gin.Context) {
c.JSON(200, gin.H{"status": "success"})
ws.DeleteConnectedClients(uuid)
ws.DeleteLatestReport(uuid)
event.Trigger(eventType.ClientDeleted, event.M{
"client": uuid,
})
}
func ClearRecord(c *gin.Context) {
@@ -2,27 +2,27 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
)
func OrderWeight(c *gin.Context) {
var req = make(map[string]int)
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
resp.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
db := dbcore.GetDBInstance()
for uuid, weight := range req {
err := db.Model(&models.Client{}).Where("uuid = ?", uuid).Update("weight", weight).Error
if err != nil {
api.RespondError(c, 500, "Failed to update client weight: "+err.Error())
resp.RespondError(c, 500, "Failed to update client weight: "+err.Error())
return
}
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "order clients", "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
@@ -5,10 +5,10 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/auditlog"
clipboardDB "github.com/komari-monitor/komari/internal/database/clipboard"
"github.com/komari-monitor/komari/internal/database/models"
)
// GetClipboard retrieves a clipboard entry by ID
@@ -16,41 +16,41 @@ func GetClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
resp.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
cb, err := clipboardDB.GetClipboardByID(id)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to get clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to get clipboard: "+err.Error())
return
}
api.RespondSuccess(c, cb)
resp.RespondSuccess(c, cb)
}
// ListClipboard lists all clipboard entries
func ListClipboard(c *gin.Context) {
list, err := clipboardDB.ListClipboard()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to list clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to list clipboard: "+err.Error())
return
}
api.RespondSuccess(c, list)
resp.RespondSuccess(c, list)
}
// CreateClipboard creates a new clipboard entry
func CreateClipboard(c *gin.Context) {
var req models.Clipboard
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := clipboardDB.CreateClipboard(&req); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to create clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to create clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
api.RespondSuccess(c, req)
resp.RespondSuccess(c, req)
}
// UpdateClipboard updates an existing clipboard entry
@@ -58,21 +58,21 @@ func UpdateClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
resp.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
var fields map[string]interface{}
if err := c.ShouldBindJSON(&fields); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := clipboardDB.UpdateClipboardFields(id, fields); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to update clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to update clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
// DeleteClipboard deletes a clipboard entry
@@ -80,16 +80,16 @@ func DeleteClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
resp.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
if err := clipboardDB.DeleteClipboard(id); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to delete clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
// BatchDeleteClipboard deletes multiple clipboard entries
@@ -98,18 +98,18 @@ func BatchDeleteClipboard(c *gin.Context) {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if len(req.IDs) == 0 {
api.RespondError(c, http.StatusBadRequest, "IDs cannot be empty")
resp.RespondError(c, http.StatusBadRequest, "IDs cannot be empty")
return
}
if err := clipboardDB.DeleteClipboardBatch(req.IDs); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to batch delete clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to batch delete clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
@@ -11,9 +11,9 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/dbcore"
)
// copyFile 复制单个文件到目标路径(会确保父目录存在)
@@ -108,35 +108,35 @@ func DownloadBackup(c *gin.Context) {
// 1) 创建临时目录
tempDir, err := os.MkdirTemp("", "komari-backup-*")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
return
}
defer os.RemoveAll(tempDir)
// 2) 复制 ./data 下除 .db/.db-wal/.db-shm 外的所有文件到临时目录
if err := copyDataToTempExcludingDB(tempDir); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying data to temp: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying data to temp: %v", err))
return
}
// 3) 处理数据库备份 -> 临时目录/komari.db
destDB := filepath.Join(tempDir, "komari.db")
dbFilePath := flags.DatabaseFile
dbFilePath := conf.Conf.Database.DatabaseFile
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
if conf.Conf.Database.DatabaseType == "sqlite" || conf.Conf.Database.DatabaseType == "" {
if err := backupSQLiteTo(destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
return
}
} else if dbFilePath != "" {
// 非 sqlite 的情况:若配置了文件路径且存在,则直接复制(按用户需求仍然将名称固定为 komari.db)
if _, err := os.Stat(dbFilePath); err == nil {
if err := copyFile(dbFilePath, destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
return
}
} else if !os.IsNotExist(err) {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
}
@@ -188,7 +188,7 @@ func DownloadBackup(c *gin.Context) {
return err
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
return
}
@@ -200,11 +200,11 @@ func DownloadBackup(c *gin.Context) {
Modified: time.Now(),
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
return
}
if _, err = markupWriter.Write([]byte(markupContent)); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
return
}
}
@@ -6,12 +6,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/pkg/utils"
)
// 接受数据类型:
@@ -25,7 +25,7 @@ func Exec(c *gin.Context) {
var onlineClients []string
var offlineClients []string
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
resp.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
// for uuid := range ws.GetConnectedClients() {
@@ -33,7 +33,7 @@ func Exec(c *gin.Context) {
// onlineClients = append(onlineClients, uuid)
// }
// // else {
// // api.RespondError(c, 400, "Client not connected: "+uuid)
// // resp.RespondError(c, 400, "Client not connected: "+uuid)
// // return
// // }
// }
@@ -45,12 +45,12 @@ func Exec(c *gin.Context) {
}
}
if len(onlineClients) == 0 {
api.RespondError(c, 400, "No clients connected")
resp.RespondError(c, 400, "No clients connected")
return
}
taskId := utils.GenerateRandomString(16)
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
api.RespondError(c, 500, "Failed to create task: "+err.Error())
resp.RespondError(c, 500, "Failed to create task: "+err.Error())
return
}
for _, uuid := range onlineClients {
@@ -67,17 +67,17 @@ func Exec(c *gin.Context) {
client := ws.GetConnectedClients()[uuid]
if client != nil {
if err := client.WriteMessage(websocket.TextMessage, payload); err != nil {
api.RespondError(c, 400, "Client connection is broke: "+uuid)
resp.RespondError(c, 400, "Client connection is broke: "+uuid)
return
}
} else {
api.RespondError(c, 400, "Client connection is null: "+uuid)
resp.RespondError(c, 400, "Client connection is null: "+uuid)
return
}
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
api.RespondSuccess(c, gin.H{
resp.RespondSuccess(c, gin.H{
"task_id": taskId,
"clients": onlineClients,
})
@@ -4,9 +4,9 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
)
func GetLogs(c *gin.Context) {
@@ -22,12 +22,12 @@ func GetLogs(c *gin.Context) {
// If conversion fails, return an error
limitInt, err := strconv.Atoi(limit)
if err != nil || limitInt <= 0 {
api.RespondError(c, 400, "Invalid limit: "+limit)
resp.RespondError(c, 400, "Invalid limit: "+limit)
return
}
pageInt, err := strconv.Atoi(page)
if err != nil || pageInt <= 0 {
api.RespondError(c, 400, "Invalid page: "+page)
resp.RespondError(c, 400, "Invalid page: "+page)
return
}
db := dbcore.GetDBInstance()
@@ -37,13 +37,13 @@ func GetLogs(c *gin.Context) {
var total int64
if err := db.Model(&models.Log{}).Count(&total).Error; err != nil {
api.RespondError(c, 500, "Failed to count logs: "+err.Error())
resp.RespondError(c, 500, "Failed to count logs: "+err.Error())
return
}
if err := db.Order("time desc").Limit(limitInt).Offset(offset).Find(&logs).Error; err != nil {
api.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{"logs": logs, "total": total})
resp.RespondSuccess(c, gin.H{"logs": logs, "total": total})
}
+63
View File
@@ -0,0 +1,63 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/messageSender"
"github.com/komari-monitor/komari/internal/messageSender/factory"
)
func GetMessageSenderProvider(c *gin.Context) {
provider := c.Query("provider")
if provider != "" {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetMessageSenderConfigByName(provider)
if err != nil {
resp.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
resp.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置项模板
providers := factory.GetSenderConfigs()
if len(providers) == 0 {
resp.RespondError(c, 404, "No message sender providers found")
return
}
resp.RespondSuccess(c, providers)
}
func SetMessageSenderProvider(c *gin.Context) {
var senderConfig models.MessageSenderProvider
if err := c.ShouldBindJSON(&senderConfig); err != nil {
resp.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if senderConfig.Name == "" {
resp.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(senderConfig.Name)
if !exists {
resp.RespondError(c, 404, "Provider not found: "+senderConfig.Name)
return
}
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
resp.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
return
}
cfg, _ := conf.GetWithV1Format()
// 正在使用,重载
if cfg.NotificationMethod == senderConfig.Name {
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
if err != nil {
resp.RespondError(c, 500, "Failed to load message sender provider: "+err.Error())
return
}
}
resp.RespondSuccess(c, gin.H{"message": "Message sender provider set successfully"})
}
@@ -4,9 +4,9 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/notification"
)
// POST body: clients []string, name string, metric string, threshold float32, ratio float32, interval int
@@ -21,23 +21,23 @@ func AddLoadNotification(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if req.Interval > 4*60 || req.Interval <= 0 {
api.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
resp.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
return
}
if req.Ratio <= 0 || req.Ratio > 1 {
api.RespondError(c, http.StatusBadRequest, "Ratio must be between 0 and 1")
resp.RespondError(c, http.StatusBadRequest, "Ratio must be between 0 and 1")
return
}
if taskID, err := notification.AddLoadNotification(req.Clients, req.Name, req.Metric, req.Threshold, req.Ratio, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
resp.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
@@ -47,14 +47,14 @@ func DeleteLoadNotification(c *gin.Context) {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := notification.DeleteLoadNotification(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
}
@@ -65,26 +65,26 @@ func EditLoadNotification(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
resp.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
if err := notification.EditLoadNotification(req.Notifications); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
// for _, notification := range req.Notifications {
// notification.DeleteLoadNotification([]uint{notification.Id})
// }
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
}
func GetAllLoadNotifications(c *gin.Context) {
notifications, err := notification.GetAllLoadNotifications()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, notifications)
resp.RespondSuccess(c, notifications)
}
@@ -2,9 +2,9 @@ package notification
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
resp "github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"gorm.io/gorm/clause"
)
@@ -12,7 +12,7 @@ import (
func EnableOfflineNotification(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
resp.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
var notifications []models.OfflineNotification
@@ -30,17 +30,17 @@ func EnableOfflineNotification(c *gin.Context) {
Select("client", "enable").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
resp.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
// POST body : []uuid
func DisableOfflineNotification(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
resp.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
var notifications []models.OfflineNotification
@@ -58,29 +58,29 @@ func DisableOfflineNotification(c *gin.Context) {
Select("client", "enable").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
resp.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func EditOfflineNotification(c *gin.Context) {
var notifications []models.OfflineNotification
if err := c.ShouldBindJSON(&notifications); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
resp.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
if len(notifications) == 0 {
api.RespondError(c, 400, "At least one notification is required")
resp.RespondError(c, 400, "At least one notification is required")
return
}
for _, noti := range notifications {
if noti.Client == "" {
api.RespondError(c, 400, "Client UUID cannot be empty")
resp.RespondError(c, 400, "Client UUID cannot be empty")
return
}
if noti.GracePeriod <= 0 {
api.RespondError(c, 400, "GracePeriod must be a positive integer")
resp.RespondError(c, 400, "GracePeriod must be a positive integer")
return
}
}
@@ -92,18 +92,18 @@ func EditOfflineNotification(c *gin.Context) {
Select("*").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
resp.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func ListOfflineNotifications(c *gin.Context) {
var notifications []models.OfflineNotification
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(&notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
resp.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, notifications)
resp.RespondSuccess(c, notifications)
}
@@ -2,20 +2,20 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/oauth"
"github.com/komari-monitor/komari/internal/oauth/factory"
)
func BindingExternalAccount(c *gin.Context) {
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil {
api.RespondError(c, 500, "No user found: "+err.Error())
resp.RespondError(c, 500, "No user found: "+err.Error())
return
}
@@ -26,17 +26,17 @@ func UnbindExternalAccount(c *gin.Context) {
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil {
api.RespondError(c, 500, "No user found: "+err.Error())
resp.RespondError(c, 500, "No user found: "+err.Error())
return
}
err = accounts.UnbindExternalAccount(user.UUID)
if err != nil {
api.RespondError(c, 500, "Failed to unbind external account: "+err.Error())
resp.RespondError(c, 500, "Failed to unbind external account: "+err.Error())
return
}
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func GetOidcProvider(c *gin.Context) {
@@ -45,49 +45,49 @@ func GetOidcProvider(c *gin.Context) {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetOidcConfigByName(provider)
if err != nil {
api.RespondError(c, 404, "Provider not found: "+err.Error())
resp.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
api.RespondSuccess(c, config)
resp.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置
providers := factory.GetProviderConfigs()
if len(providers) == 0 {
api.RespondError(c, 404, "No OIDC providers found")
resp.RespondError(c, 404, "No OIDC providers found")
return
}
api.RespondSuccess(c, providers)
resp.RespondSuccess(c, providers)
}
func SetOidcProvider(c *gin.Context) {
var oidcConfig models.OidcProvider
if err := c.ShouldBindJSON(&oidcConfig); err != nil {
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
resp.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if oidcConfig.Name == "" {
api.RespondError(c, 400, "Provider name is required")
resp.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(oidcConfig.Name)
if !exists {
api.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
resp.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
return
}
if err := database.SaveOidcConfig(&oidcConfig); err != nil {
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
resp.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
return
}
cfg, _ := config.Get()
cfg, _ := conf.GetWithV1Format()
// 正在使用,重载
if cfg.OAuthProvider == oidcConfig.Name {
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
if err != nil {
api.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
resp.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
return
}
}
api.RespondSuccess(c, gin.H{"message": "OIDC provider set successfully"})
resp.RespondSuccess(c, gin.H{"message": "OIDC provider set successfully"})
}
@@ -4,9 +4,9 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
)
// POST body: clients []string, target, task_type string, interval int
@@ -20,14 +20,14 @@ func AddPingTask(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if taskID, err := tasks.AddPingTask(req.Clients, req.Name, req.Target, req.TaskType, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
resp.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
@@ -37,14 +37,14 @@ func DeletePingTask(c *gin.Context) {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := tasks.DeletePingTask(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
}
@@ -55,26 +55,26 @@ func EditPingTask(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
resp.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
if err := tasks.EditPingTask(req.Tasks); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
// for _, task := range req.Tasks {
// tasks.DeletePingRecords([]uint{task.Id})
// }
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
}
func GetAllPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, tasks)
resp.RespondSuccess(c, tasks)
}
@@ -1,9 +1,9 @@
package admin
import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/gin-gonic/gin"
)
@@ -12,7 +12,7 @@ func GetSessions(c *gin.Context) {
ss, err := accounts.GetAllSessions()
if err != nil {
api.RespondError(c, 500, "Failed to retrieve sessions: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve sessions: "+err.Error())
return
}
current, _ := c.Cookie("session_token")
@@ -24,27 +24,27 @@ func DeleteSession(c *gin.Context) {
Session string `json:"session" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid request: "+err.Error())
resp.RespondError(c, 400, "Invalid request: "+err.Error())
return
}
err := accounts.DeleteSession(req.Session)
if err != nil {
api.RespondError(c, 500, "Failed to delete session: "+err.Error())
resp.RespondError(c, 500, "Failed to delete session: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "delete session", "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func DeleteAllSession(c *gin.Context) {
err := accounts.DeleteAllSessions()
if err != nil {
api.RespondError(c, 500, "Failed to delete all sessions: "+err.Error())
resp.RespondError(c, 500, "Failed to delete all sessions: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
@@ -3,26 +3,25 @@ package admin
import (
"database/sql"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/database/records"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/gin-gonic/gin"
)
// GetSettings 获取自定义配置
func GetSettings(c *gin.Context) {
cst, err := config.Get()
cst, err := conf.GetWithV1Format()
if err != nil {
if err == sql.ErrNoRows {
//override
cst = models.Config{Sitename: "Komari"}
cst = conf.V1Struct{Sitename: "Komari"}
cst.ID = 1
config.Save(cst)
api.RespondSuccess(c, cst)
conf.Save(cst)
resp.RespondSuccess(c, cst)
return
}
c.JSON(500, gin.H{
@@ -30,20 +29,20 @@ func GetSettings(c *gin.Context) {
"message": "Internal Server Error: " + err.Error(),
})
}
api.RespondSuccess(c, cst)
resp.RespondSuccess(c, cst)
}
// EditSettings 更新自定义配置
func EditSettings(c *gin.Context) {
cfg := make(map[string]interface{})
if err := c.ShouldBindJSON(&cfg); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
resp.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
cfg["id"] = 1 // Only one record
if err := config.Update(cfg); err != nil {
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
if err := conf.Update(cfg); err != nil {
resp.RespondError(c, 500, "Failed to update settings: "+err.Error())
return
}
@@ -60,7 +59,7 @@ func EditSettings(c *gin.Context) {
message = message[:len(message)-2]
}
auditlog.Log(c.ClientIP(), uuid.(string), message, "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func contains(slice []string, item string) bool {
@@ -77,5 +76,5 @@ func ClearAllRecords(c *gin.Context) {
tasks.DeleteAllPingRecords()
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
@@ -2,21 +2,21 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/tasks"
)
func GetTasks(c *gin.Context) {
dbTasks, err := tasks.GetAllTasks()
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
var responseTasks []gin.H
for _, t := range dbTasks {
results, err := tasks.GetTaskResultsByTaskId(t.TaskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
@@ -38,27 +38,27 @@ func GetTasks(c *gin.Context) {
"results": filteredResults,
})
}
api.RespondSuccess(c, responseTasks)
resp.RespondSuccess(c, responseTasks)
}
func GetTaskById(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
api.RespondError(c, 400, "Task ID is required")
resp.RespondError(c, 400, "Task ID is required")
return
}
task, err := tasks.GetTaskByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
return
}
if task == nil {
api.RespondError(c, 404, "Task not found")
resp.RespondError(c, 404, "Task not found")
return
}
results, err := tasks.GetTaskResultsByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
var filteredResults []gin.H
@@ -71,7 +71,7 @@ func GetTaskById(c *gin.Context) {
"created_at": r.CreatedAt,
})
}
api.RespondSuccess(c, gin.H{
resp.RespondSuccess(c, gin.H{
"task_id": task.TaskId,
"clients": task.Clients,
"command": task.Command,
@@ -82,73 +82,73 @@ func GetTaskById(c *gin.Context) {
func GetTasksByClientId(c *gin.Context) {
clientId := c.Param("uuid")
if clientId == "" {
api.RespondError(c, 400, "Client ID is required")
resp.RespondError(c, 400, "Client ID is required")
return
}
tasks, err := tasks.GetTasksByClientId(clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
if len(tasks) == 0 {
api.RespondError(c, 404, "No tasks found for this client")
resp.RespondError(c, 404, "No tasks found for this client")
return
}
api.RespondSuccess(c, tasks)
resp.RespondSuccess(c, tasks)
}
func GetSpecificTaskResult(c *gin.Context) {
taskId := c.Param("task_id")
clientId := c.Param("uuid")
if taskId == "" || clientId == "" {
api.RespondError(c, 400, "Task ID and Client ID are required")
resp.RespondError(c, 400, "Task ID and Client ID are required")
return
}
result, err := tasks.GetSpecificTaskResult(taskId, clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
return
}
if result == nil {
api.RespondError(c, 404, "No result found for this task and client")
resp.RespondError(c, 404, "No result found for this task and client")
return
}
api.RespondSuccess(c, result)
resp.RespondSuccess(c, result)
}
// Param: task_id
func GetTaskResultsByTaskId(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
api.RespondError(c, 400, "Task ID is required")
resp.RespondError(c, 400, "Task ID is required")
return
}
results, err := tasks.GetTaskResultsByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
if len(results) == 0 {
api.RespondError(c, 404, "No results found for this task")
resp.RespondError(c, 404, "No results found for this task")
return
}
api.RespondSuccess(c, results)
resp.RespondSuccess(c, results)
}
func GetAllTaskResultByUUID(c *gin.Context) {
clientId := c.Param("uuid")
if clientId == "" {
api.RespondError(c, 400, "Client ID is required")
resp.RespondError(c, 400, "Client ID is required")
return
}
results, err := tasks.GetAllTasksResultByUUID(clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
if len(results) == 0 {
api.RespondError(c, 404, "No tasks found for this client")
resp.RespondError(c, 404, "No tasks found for this client")
return
}
api.RespondSuccess(c, results)
resp.RespondSuccess(c, results)
}
+50
View File
@@ -0,0 +1,50 @@
package test
import (
"net"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/geoip"
"github.com/komari-monitor/komari/internal/messageSender"
)
func TestSendMessage(c *gin.Context) {
err := messageSender.SendEvent(models.EventMessage{
Event: "Test",
Message: "This is a test message from Komari.",
})
if err != nil {
resp.RespondError(c, 500, "Failed to send message: "+err.Error())
return
}
resp.RespondSuccess(c, nil)
}
func TestGeoIp(c *gin.Context) {
ip := c.Query("ip")
if ip == "" {
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
ip = cfIP
} else {
ip = c.ClientIP()
}
}
conf, err := conf.GetWithV1Format()
if err != nil {
resp.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if !conf.GeoIpEnabled {
resp.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
return
}
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
if err != nil {
resp.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
return
}
resp.RespondSuccess(c, GeoIpRecord)
}
@@ -13,10 +13,10 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
)
// UploadTheme 上传主题
@@ -24,32 +24,32 @@ func UploadTheme(c *gin.Context) {
// 读取上传的文件内容
data, err := io.ReadAll(c.Request.Body)
if err != nil || len(data) == 0 {
api.RespondError(c, http.StatusBadRequest, "请选择要上传的主题文件")
resp.RespondError(c, http.StatusBadRequest, "请选择要上传的主题文件")
return
}
// 临时文件名
tempFile := filepath.Join(os.TempDir(), "uploaded_theme.zip")
if err := os.WriteFile(tempFile, data, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
return
}
defer os.Remove(tempFile)
// 检查文件扩展名(这里假定上传的就是zip)
if !strings.HasSuffix(strings.ToLower(tempFile), ".zip") {
api.RespondError(c, http.StatusBadRequest, "只支持ZIP格式的主题文件")
resp.RespondError(c, http.StatusBadRequest, "只支持ZIP格式的主题文件")
return
}
// 解压ZIP文件并验证
themeInfo, err := extractAndValidateTheme(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
api.RespondSuccessMessage(c, "主题上传成功", themeInfo)
resp.RespondSuccessMessage(c, "主题上传成功", themeInfo)
}
// ListThemes 列出所有主题
@@ -58,13 +58,13 @@ func ListThemes(c *gin.Context) {
// 确保主题目录存在
if _, err := os.Stat(dataDir); os.IsNotExist(err) {
api.RespondSuccess(c, []models.Theme{})
resp.RespondSuccess(c, []models.Theme{})
return
}
entries, err := os.ReadDir(dataDir)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "读取主题目录失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "读取主题目录失败: "+err.Error())
return
}
@@ -78,7 +78,7 @@ func ListThemes(c *gin.Context) {
}
}
api.RespondSuccess(c, themes)
resp.RespondSuccess(c, themes)
}
// DeleteTheme 删除主题
@@ -88,12 +88,12 @@ func DeleteTheme(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if req.Short == "default" {
api.RespondError(c, http.StatusBadRequest, "默认主题不能删除")
resp.RespondError(c, http.StatusBadRequest, "默认主题不能删除")
return
}
@@ -101,24 +101,24 @@ func DeleteTheme(c *gin.Context) {
// 检查主题是否存在
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
resp.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
// 删除主题目录
if err := os.RemoveAll(themeDir); err != nil {
api.RespondError(c, http.StatusInternalServerError, "删除主题失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "删除主题失败: "+err.Error())
return
}
api.RespondSuccessMessage(c, "主题删除成功", nil)
resp.RespondSuccessMessage(c, "主题删除成功", nil)
}
// SetTheme 设置主题
func SetTheme(c *gin.Context) {
themeName := c.Query("theme")
if themeName == "" {
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
resp.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
return
}
@@ -128,7 +128,7 @@ func SetTheme(c *gin.Context) {
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
resp.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
}
@@ -138,12 +138,12 @@ func SetTheme(c *gin.Context) {
"theme": themeName,
}
if err := config.Update(updateData); err != nil {
api.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
if err := conf.Update(updateData); err != nil {
resp.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
return
}
api.RespondSuccessMessage(c, "主题设置成功", gin.H{"theme": themeName})
resp.RespondSuccessMessage(c, "主题设置成功", gin.H{"theme": themeName})
}
// extractAndValidateTheme 解压并验证主题
@@ -431,7 +431,7 @@ func UpdateTheme(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
@@ -440,14 +440,14 @@ func UpdateTheme(c *gin.Context) {
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
resp.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
// 加载现有主题配置
themeInfo, err := loadThemeConfig(themeConfigPath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
return
}
@@ -494,14 +494,14 @@ func UpdateTheme(c *gin.Context) {
// 相当于: DOWNLOAD_URL=$(curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url")
gitHubURL, err := getGitHubReleaseDownloadURL(req.GitOwner, req.GitRepo)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
return
}
// 使用获取到的链接下载主题
themeData, err = downloadThemeFromURL(gitHubURL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
return
}
// 保存下载链接,稍后更新到主题配置中
@@ -515,14 +515,14 @@ func UpdateTheme(c *gin.Context) {
// 这里也应用了自动检测GitHub仓库并下载最新release的功能
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
return
}
// 使用获取到的链接下载主题
themeData, err = downloadThemeFromURL(gitHubURL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
return
}
// 保存GitHub仓库URL,而不是release下载链接,以便将来可以获取最新版本
@@ -533,7 +533,7 @@ func UpdateTheme(c *gin.Context) {
// 新URL不是GitHub仓库地址,直接尝试下载
themeData, err = downloadThemeFromURL(req.URL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
return
}
// downloadURL = req.URL
@@ -543,7 +543,7 @@ func UpdateTheme(c *gin.Context) {
// 如果没有成功下载主题数据
if themeData == nil || len(themeData) == 0 {
api.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
resp.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
return
}
@@ -556,7 +556,7 @@ func UpdateTheme(c *gin.Context) {
// 临时文件名
tempFile := filepath.Join(os.TempDir(), "downloaded_theme.zip")
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
return
}
defer os.Remove(tempFile)
@@ -564,7 +564,7 @@ func UpdateTheme(c *gin.Context) {
// 解压ZIP文件并验证
updatedThemeInfo, err := extractAndValidateTheme(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
@@ -576,23 +576,23 @@ func UpdateTheme(c *gin.Context) {
// updatedConfigPath := filepath.Join("./data/theme", updatedThemeInfo.Short, "komari-theme.json")
// updatedConfigData, err := json.MarshalIndent(updatedThemeInfo, "", " ")
// if err != nil {
// api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
// resp.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
// return
// }
// if err := os.WriteFile(updatedConfigPath, updatedConfigData, 0644); err != nil {
// api.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
// resp.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
// return
// }
// }
api.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
resp.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
}
func UpdateThemeSettings(c *gin.Context) {
theme := c.Query("theme")
if theme == "" || theme == "default" {
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空或不能是默认主题")
resp.RespondError(c, http.StatusBadRequest, "主题名称不能为空或不能是默认主题")
return
}
@@ -600,14 +600,14 @@ func UpdateThemeSettings(c *gin.Context) {
err := c.ShouldBindJSON(&req)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
db := dbcore.GetDBInstance()
data, err := json.Marshal(&req)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
return
}
@@ -615,5 +615,5 @@ func UpdateThemeSettings(c *gin.Context) {
db.Where("short = ?", theme).
Assign(models.ThemeConfiguration{Short: theme, Data: string(data)}).
FirstOrCreate(&themeCfg)
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
@@ -7,8 +7,8 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/auditlog"
)
func UploadFavicon(c *gin.Context) {
@@ -16,31 +16,31 @@ func UploadFavicon(c *gin.Context) {
data, err := io.ReadAll(c.Request.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
api.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
resp.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
} else {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
}
return
}
if err := os.WriteFile("./data/favicon.ico", data, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func DeleteFavicon(c *gin.Context) {
if err := os.Remove("./data/favicon.ico"); err != nil {
if os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "Favicon not found")
resp.RespondError(c, http.StatusNotFound, "Favicon not found")
} else {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
}
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
+18
View File
@@ -0,0 +1,18 @@
package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/geoip"
)
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.UpdateDatabase(); err != nil {
resp.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
resp.RespondSuccess(c, nil)
}
@@ -2,9 +2,9 @@ package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/auditlog"
)
func UpdateUser(c *gin.Context) {
@@ -15,26 +15,26 @@ func UpdateUser(c *gin.Context) {
SsoType *string `json:"sso_type"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
resp.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
if req.Password == nil && req.Name == nil {
api.RespondError(c, 400, "At least one field (username or password) must be provided")
resp.RespondError(c, 400, "At least one field (username or password) must be provided")
return
}
if req.Name != nil && len(*req.Name) < 3 {
api.RespondError(c, 400, "Username must be at least 3 characters long")
resp.RespondError(c, 400, "Username must be at least 3 characters long")
return
}
if req.Password != nil && len(*req.Password) < 6 {
api.RespondError(c, 400, "Password must be at least 6 characters long")
resp.RespondError(c, 400, "Password must be at least 6 characters long")
return
}
if err := accounts.UpdateUser(req.Uuid, req.Name, req.Password, req.SsoType); err != nil {
api.RespondError(c, 500, "Failed to update user: "+err.Error())
resp.RespondError(c, 500, "Failed to update user: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
resp.RespondSuccess(c, gin.H{"uuid": req.Uuid})
}
@@ -13,7 +13,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/internal/api_v1/resp"
)
// 只有一个备份恢复操作在进行
@@ -23,7 +23,7 @@ var restoreMutex sync.Mutex
func UploadBackup(c *gin.Context) {
// 尝试获取锁,如果已有恢复操作在进行,则立即返回错误
if !restoreMutex.TryLock() {
api.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
resp.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
return
}
defer restoreMutex.Unlock()
@@ -31,27 +31,27 @@ func UploadBackup(c *gin.Context) {
// 获取上传的文件
file, header, err := c.Request.FormFile("backup")
if err != nil {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
resp.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
return
}
defer file.Close()
// 检查文件是否为zip格式
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
api.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
resp.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
return
}
// 确保data目录存在
if err := os.MkdirAll("./data", 0755); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
return
}
// 创建临时文件保存上传的zip(先校验,再落地到固定位置)
tempFile, err := os.CreateTemp("", "backup-upload-*.zip")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
return
}
tempFilePath := tempFile.Name()
@@ -61,7 +61,7 @@ func UploadBackup(c *gin.Context) {
_, err = io.Copy(tempFile, file)
if err != nil {
tempFile.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
return
}
tempFile.Close() // 关闭文件以便后续操作
@@ -77,11 +77,11 @@ func UploadBackup(c *gin.Context) {
}
zr.Close()
if !hasMarkup {
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
resp.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
return
}
} else {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
return
}
@@ -93,18 +93,18 @@ func UploadBackup(c *gin.Context) {
// fallback:拷贝
in, err2 := os.Open(tempFilePath)
if err2 != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error preparing backup file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error preparing backup file: %v", err))
return
}
defer in.Close()
out, err2 := os.Create(finalPath)
if err2 != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating target backup file: %v", err2))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating target backup file: %v", err2))
return
}
if _, err2 = io.Copy(out, in); err2 != nil {
out.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing target backup file: %v", err2))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing target backup file: %v", err2))
return
}
out.Close()
+47
View File
@@ -0,0 +1,47 @@
package client
import (
"github.com/gin-gonic/gin"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/api_v1/resp"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/pkg/utils"
)
func RegisterClient(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
resp.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
cfg, err := conf.GetWithV1Format()
if err != nil {
resp.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if cfg.AutoDiscoveryKey == "" ||
len(cfg.AutoDiscoveryKey) < 12 ||
"Bearer "+cfg.AutoDiscoveryKey != auth {
resp.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
name := c.Query("name")
if name == "" {
name = utils.GenerateRandomString(8)
}
name = "Auto-" + name
uuid, token, err := clients.CreateClientWithName(name)
if err != nil {
resp.RespondError(c, 500, "Failed to create client: "+err.Error())
return
}
event.Trigger(eventType.ClientCreated, event.M{
"client": uuid,
"token": token,
})
resp.RespondSuccess(c, gin.H{"uuid": uuid, "token": token})
}
@@ -11,14 +11,16 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gookit/event"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/internal/api_v1/vars"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/internal/notifier"
"github.com/komari-monitor/komari/internal/ws"
"github.com/patrickmn/go-cache"
)
@@ -123,13 +125,18 @@ func WebSocketReport(c *gin.Context) {
go oldConn.Close()
}
ws.SetConnectedClients(uuid, conn)
log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
//log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
go notifier.OnlineNotification(uuid, conn.ID)
defer func() {
ws.DeleteClientConditionally(uuid, conn)
notifier.OfflineNotification(uuid, conn.ID)
}()
event.Trigger(eventType.ClientWebsocketConnected, event.M{
"client": uuid,
"online": len(ws.GetConnectedClients()),
"connID": conn.ID,
})
// 首先处理第一次ws conn收到的消息
processMessage(conn, message, uuid)
@@ -143,8 +150,18 @@ func WebSocketReport(c *gin.Context) {
}
break // 任何读错误(包括超时)都意味着连接已断开,退出循环
}
event.Trigger(eventType.ClientMessageReceived, event.M{
"client": uuid,
"type": "websocket",
"message": string(message),
})
processMessage(conn, message, uuid)
}
event.Trigger(eventType.ClientWebsocketDisconnected, event.M{
"client": uuid,
"online": len(ws.GetConnectedClients()),
"connID": conn.ID,
})
}
// 将消息处理逻辑提取到一个函数中,方便复用
@@ -200,7 +217,7 @@ func processMessage(conn *ws.SafeConn, message []byte, uuid string) {
}
func SaveClientReport(uuid string, report common.Report) error {
reports, _ := api.Records.Get(uuid)
reports, _ := vars.Records.Get(uuid)
if reports == nil {
reports = []common.Report{}
}
@@ -208,7 +225,7 @@ func SaveClientReport(uuid string, report common.Report) error {
report.CPU.Usage = 0.01
}
reports = append(reports.([]common.Report), report)
api.Records.Set(uuid, reports, cache.DefaultExpiration)
vars.Records.Set(uuid, reports, cache.DefaultExpiration)
return nil
}
@@ -4,9 +4,11 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/gookit/event"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/eventType"
)
func TaskResult(c *gin.Context) {
@@ -33,4 +35,9 @@ func TaskResult(c *gin.Context) {
}
c.JSON(200, gin.H{"status": "success", "message": "Task result updated successfully"})
event.Trigger(eventType.TaskUpdated, event.M{
"task": req.TaskId,
"client": clientId,
"data": req,
})
}
@@ -5,12 +5,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/internal/api_v1/terminal"
)
func EstablishConnection(c *gin.Context) {
session_id := c.Query("id")
session, exists := api.TerminalSessions[session_id]
session, exists := terminal.TerminalSessions[session_id]
if !exists || session == nil || session.Browser == nil {
c.JSON(404, gin.H{"status": "error", "error": "Session not found"})
return
@@ -27,22 +27,22 @@ func EstablishConnection(c *gin.Context) {
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
api.TerminalSessionsMutex.Lock()
terminal.TerminalSessionsMutex.Lock()
if session.Browser != nil {
session.Browser.Close()
}
delete(api.TerminalSessions, session_id)
api.TerminalSessionsMutex.Unlock()
delete(terminal.TerminalSessions, session_id)
terminal.TerminalSessionsMutex.Unlock()
return
}
session.Agent = conn
conn.SetCloseHandler(func(code int, text string) error {
delete(api.TerminalSessions, session_id)
delete(terminal.TerminalSessions, session_id)
// 通知 Browser 关闭终端连接
if session.Browser != nil {
session.Browser.Close()
}
return nil
})
go api.ForwardTerminal(session_id)
go terminal.ForwardTerminal(session_id)
}
@@ -3,9 +3,11 @@ package client
import (
"net"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/gookit/event"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/internal/geoip"
"github.com/gin-gonic/gin"
)
@@ -60,7 +62,7 @@ func UploadBasicInfo(c *gin.Context) {
}
}
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if cfg, err := conf.GetWithV1Format(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.GetGeoInfo(ip4)
@@ -76,10 +78,15 @@ func UploadBasicInfo(c *gin.Context) {
}
}
if err := clients.SaveClientInfo(cbi); err != nil {
if err := clients.PartialUpdate(cbi); err != nil {
c.JSON(500, gin.H{"status": "error", "error": err})
return
}
event.Trigger(eventType.ClientMessageReceived, event.M{
"client": cbi["uuid"],
"type": "basic_info",
"data": cbi,
})
c.JSON(200, gin.H{"status": "success"})
}
+200
View File
@@ -0,0 +1,200 @@
package api_v1
import (
"github.com/gin-gonic/gin"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/api_v1/admin"
"github.com/komari-monitor/komari/internal/api_v1/admin/clipboard"
log_api "github.com/komari-monitor/komari/internal/api_v1/admin/log"
"github.com/komari-monitor/komari/internal/api_v1/admin/notification"
"github.com/komari-monitor/komari/internal/api_v1/admin/test"
"github.com/komari-monitor/komari/internal/api_v1/admin/update"
"github.com/komari-monitor/komari/internal/api_v1/client"
"github.com/komari-monitor/komari/internal/api_v1/record"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/api_v1/task"
"github.com/komari-monitor/komari/internal/api_v1/terminal"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/eventType"
)
func init() {
event.On(eventType.ServerInitializeStart, event.ListenerFunc(func(e event.Event) error {
r := e.Get("engine").(*gin.Engine)
config, _ := conf.GetWithV1Format()
LoadApiV1Routes(r, config)
return nil
}), event.Normal+5)
event.On(eventType.SchedulerEveryMinute, event.ListenerFunc(func(e event.Event) error {
SaveClientReportToDB()
return nil
}))
}
func LoadApiV1Routes(r *gin.Engine, conf conf.V1Struct) {
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
r.Use(PrivateSiteMiddleware())
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", Login)
r.GET("/api/me", GetMe)
r.GET("/api/clients", GetClients)
r.GET("/api/nodes", GetNodesInformation)
r.GET("/api/public", GetPublicSettings)
r.GET("/api/oauth", OAuth)
r.GET("/api/oauth_callback", OAuthCallback)
r.GET("/api/logout", Logout)
r.GET("/api/version", resp.GetVersion)
r.GET("/api/recent/:uuid", GetClientRecentRecords)
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
r.GET("/api/task/ping", task.GetPublicPingTasks)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
}
// #region 管理员
r.Use(AdminAuthMiddleware())
adminAuthrized := r.Group("/api/admin")
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/geoip", test.TestGeoIp)
testGroup.POST("/sendMessage", test.TestSendMessage)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
settingsGroup := adminAuthrized.Group("/settings")
{
settingsGroup.GET("/", admin.GetSettings)
settingsGroup.POST("/", admin.EditSettings)
settingsGroup.POST("/oidc", admin.SetOidcProvider)
settingsGroup.GET("/oidc", admin.GetOidcProvider)
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
}
// themes
themeGroup := adminAuthrized.Group("/theme")
{
themeGroup.PUT("/upload", admin.UploadTheme)
themeGroup.GET("/list", admin.ListThemes)
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", terminal.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
recordGroup.POST("/clear/all", admin.ClearAllRecords)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
// clipboard
clipboardGroup := adminAuthrized.Group("/clipboard")
{
clipboardGroup.GET("/:id", clipboard.GetClipboard)
clipboardGroup.GET("", clipboard.ListClipboard)
clipboardGroup.POST("", clipboard.CreateClipboard)
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
}
notificationGroup := adminAuthrized.Group("/notification")
{
// offline notifications
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
loadAlertGroup := notificationGroup.Group("/load")
{
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
loadAlertGroup.POST("/add", notification.AddLoadNotification)
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
}
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
}
}
}
@@ -1,17 +1,19 @@
package api
package api_v1
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/api_v1/vars"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
)
func GetClientRecentRecords(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
RespondError(c, 400, "UUID is required")
resp.RespondError(c, 400, "UUID is required")
return
}
@@ -34,11 +36,11 @@ func GetClientRecentRecords(c *gin.Context) {
}
if hiddenMap[uuid] {
RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
resp.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
records, _ := Records.Get(uuid)
RespondSuccess(c, records)
records, _ := vars.Records.Get(uuid)
resp.RespondSuccess(c, records)
}
+106
View File
@@ -0,0 +1,106 @@
package api_v1
import (
"encoding/json"
"io"
"net/http"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/gin-gonic/gin"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TwoFa string `json:"2fa_code"`
}
func Login(c *gin.Context) {
conf, _ := conf.GetWithV1Format()
if conf.DisablePasswordLogin {
resp.RespondError(c, http.StatusForbidden, "Password login is disabled")
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
resp.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
var data LoginRequest
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
resp.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
if data.Username == "" || data.Password == "" {
resp.RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
return
}
uuid, success := accounts.CheckPassword(data.Username, data.Password)
if !success {
resp.RespondError(c, http.StatusUnauthorized, "Invalid credentials")
event.Trigger(eventType.LoginFailed, event.M{
"username": data.Username,
"method": "password",
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
})
return
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" { // 开启了2FA
if data.TwoFa == "" {
resp.RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
resp.RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
if err != nil {
resp.RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
auditlog.Log(c.ClientIP(), uuid, "logged in (password)", "login")
resp.RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
event.Trigger(eventType.UserLogin, event.M{
"username": data.Username,
"method": "password",
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
})
}
func Logout(c *gin.Context) {
session, _ := c.Cookie("session_token")
accounts.DeleteSession(session)
c.SetCookie("session_token", "", -1, "/", "", false, true)
auditlog.Log(c.ClientIP(), "", "logged out", "logout")
event.Trigger(eventType.UserLogout, event.M{
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
})
c.Redirect(302, "/")
}
@@ -1,4 +1,4 @@
package api
package api_v1
import (
"bytes"
@@ -8,7 +8,7 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/stretchr/testify/assert"
)
+2 -2
View File
@@ -1,7 +1,7 @@
package api
package api_v1
import (
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/gin-gonic/gin"
)
@@ -1,4 +1,4 @@
package api
package api_v1
import (
"encoding/json"
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/stretchr/testify/assert"
)
+6 -5
View File
@@ -1,15 +1,16 @@
package api
package api_v1
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/internal/api_v1/resp"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/database/accounts"
)
func GetNodesInformation(c *gin.Context) {
clientList, err := clients.GetAllClientBasicInfo()
if err != nil {
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
return
}
isLogin := false
@@ -35,5 +36,5 @@ func GetNodesInformation(c *gin.Context) {
}
clientList = clientList[:j]
RespondSuccess(c, clientList)
resp.RespondSuccess(c, clientList)
}
+46 -7
View File
@@ -1,20 +1,22 @@
package api
package api_v1
import (
"fmt"
"slices"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/internal/oauth"
"github.com/komari-monitor/komari/pkg/utils"
)
// /api/oauth
func OAuth(c *gin.Context) {
cfg, _ := config.Get()
cfg, _ := conf.GetWithV1Format()
if !cfg.OAuthEnabled {
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
return
@@ -86,7 +88,12 @@ func OAuthCallback(c *gin.Context) {
return
}
auditlog.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
event.Trigger(eventType.UserOidcBound, event.M{
"user": user.UUID,
"sso_id": sso_id,
})
c.Redirect(302, "/admin")
return
}
@@ -97,6 +104,17 @@ func OAuthCallback(c *gin.Context) {
"status": "error",
"message": "please log in and bind your external account first.",
})
event.Trigger(eventType.UserLogin, event.M{
"username": user.Username,
"method": "oauth",
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
"error": "no linked account",
"sso_id": sso_id,
})
return
}
@@ -104,11 +122,32 @@ func OAuthCallback(c *gin.Context) {
session, err := accounts.CreateSession(user.UUID, 2592000, c.Request.UserAgent(), c.ClientIP(), "oauth")
if err != nil {
c.JSON(500, gin.H{"status": "error", "message": err.Error()})
event.Trigger(eventType.LoginFailed, event.M{
"username": user.Username,
"method": "oauth",
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
"error": err.Error(),
"sso_id": sso_id,
})
return
}
// 设置cookie并返回
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
auditlog.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
event.Trigger(eventType.UserLogin, event.M{
"username": user.Username,
"method": "oauth",
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
})
c.Redirect(302, "/admin")
}
+16
View File
@@ -0,0 +1,16 @@
package api_v1
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database"
)
func GetPublicSettings(c *gin.Context) {
p, e := database.GetPublicInfo()
if e != nil {
resp.RespondError(c, 500, e.Error())
return
}
resp.RespondSuccess(c, p)
}
@@ -6,12 +6,12 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
records "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/models"
records "github.com/komari-monitor/komari/internal/database/records"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/dbcore"
)
func GetRecordsByUUID(c *gin.Context) {
@@ -37,14 +37,14 @@ func GetRecordsByUUID(c *gin.Context) {
}
if hiddenMap[uuid] {
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
resp.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
resp.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
@@ -53,7 +53,7 @@ func GetRecordsByUUID(c *gin.Context) {
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
resp.RespondError(c, 400, "Invalid hours parameter")
return
}
@@ -65,13 +65,13 @@ func GetRecordsByUUID(c *gin.Context) {
}
if !validLoadTypes[loadType] {
api.RespondError(c, 400, "Invalid load_type parameter")
resp.RespondError(c, 400, "Invalid load_type parameter")
return
}
clientRecords, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
resp.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
@@ -125,19 +125,19 @@ func GetRecordsByUUID(c *gin.Context) {
}
}
api.RespondSuccess(c, response)
resp.RespondSuccess(c, response)
}
// filterRecordsByLoadType 根据 load_type 过滤记录,只返回相关字段
func filterRecordsByLoadType(records []models.Record, loadType string) []gin.H {
filteredRecords := make([]gin.H, 0, len(records))
for _, r := range records {
record := gin.H{
"client": r.Client,
"time": r.Time,
}
switch loadType {
case "cpu":
record["cpu"] = r.Cpu
@@ -177,10 +177,10 @@ func filterRecordsByLoadType(records []models.Record, loadType string) []gin.H {
record["connections_udp"] = r.ConnectionsUdp
record["connections_tcp"] = r.Connections - r.ConnectionsUdp
}
filteredRecords = append(filteredRecords, record)
}
return filteredRecords
}
@@ -195,7 +195,7 @@ func GetPingRecords(c *gin.Context) {
// 必须提供 uuid 或 task_id 其中至少一个
if uuid == "" && taskIdStr == "" {
api.RespondError(c, 400, "UUID or task_id is required")
resp.RespondError(c, 400, "UUID or task_id is required")
return
}
@@ -242,7 +242,7 @@ func GetPingRecords(c *gin.Context) {
}
if uuid != "" {
if hiddenMap[uuid] {
api.RespondSuccess(c, response) // 对于尝试获取隐藏uuid一键哈气
resp.RespondSuccess(c, response) // 对于尝试获取隐藏uuid一键哈气
return
}
}
@@ -267,7 +267,7 @@ func GetPingRecords(c *gin.Context) {
if taskIdStr != "" {
taskId, err = strconv.Atoi(taskIdStr)
if err != nil {
api.RespondError(c, 400, "Invalid task_id parameter")
resp.RespondError(c, 400, "Invalid task_id parameter")
return
}
}
@@ -275,7 +275,7 @@ func GetPingRecords(c *gin.Context) {
// 查询记录,现在支持 uuid + task_id 组合查询
records, err = tasks.GetPingRecords(uuid, taskId, startTime, endTime)
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping records: "+err.Error())
resp.RespondError(c, 500, "Failed to fetch ping records: "+err.Error())
return
}
@@ -341,7 +341,7 @@ func GetPingRecords(c *gin.Context) {
Max: stats.max,
})
}
// 如果同时指定了 uuid 和 task_id,BasicInfo 应该只有一条记录
// 这种情况下可以在响应中标记查询模式
if uuid != "" && taskId != -1 && len(response.BasicInfo) == 1 {
@@ -357,7 +357,7 @@ func GetPingRecords(c *gin.Context) {
// 获取所有 pingTasks
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
resp.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
return
}
@@ -396,7 +396,7 @@ func GetPingRecords(c *gin.Context) {
if uuid != "" && r.Client != uuid {
continue
}
totalCount++
if r.Value < 0 {
lossCount++
@@ -431,17 +431,17 @@ func GetPingRecords(c *gin.Context) {
"avg": avgLatency,
"total": totalCount,
}
// 如果是仅 task_id 查询,添加客户端列表信息
if uuid == "" && taskId != -1 {
taskInfo["clients"] = t.Clients
}
tasksList = append(tasksList, taskInfo)
}
response.Tasks = tasksList
}
response.Count = len(response.Records) // 计算最后结果保持计数一致
api.RespondSuccess(c, response)
resp.RespondSuccess(c, response)
}
+40
View File
@@ -0,0 +1,40 @@
package resp
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/conf"
)
type Response struct {
Status string `json:"status"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Respond sends a standardized JSON response.
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
}
// RespondSuccess sends a success response with data.
func RespondSuccess(c *gin.Context, data interface{}) {
Respond(c, http.StatusOK, "success", "", data)
}
// RespondSuccessMessage sends a success response with message and data.
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
Respond(c, http.StatusOK, "success", message, data)
}
// RespondError sends an error response with message.
func RespondError(c *gin.Context, httpStatus int, message string) {
Respond(c, httpStatus, "error", message, nil)
}
func GetVersion(c *gin.Context) {
RespondSuccess(c, gin.H{
"version": conf.Version,
"hash": conf.CommitHash,
})
}
@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/tasks"
)
type PublicPingTask struct {
@@ -19,7 +19,7 @@ type PublicPingTask struct {
func GetPublicPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
@@ -34,5 +34,5 @@ func GetPublicPingTasks(c *gin.Context) {
}
}
api.RespondSuccess(c, publicTasks)
}
resp.RespondSuccess(c, publicTasks)
}
@@ -1,18 +1,32 @@
package api
package terminal
import (
"sync"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
"github.com/gin-gonic/gin"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/pkg/utils"
)
type TerminalSession struct {
UUID string
UserUUID string
Browser *websocket.Conn
Agent *websocket.Conn
RequesterIp string
}
var TerminalSessionsMutex = &sync.Mutex{}
var TerminalSessions = make(map[string]*TerminalSession)
func RequestTerminal(c *gin.Context) {
uuid := c.Param("uuid")
user_uuid, _ := c.Get("uuid")
+11
View File
@@ -0,0 +1,11 @@
package vars
import (
"time"
"github.com/patrickmn/go-cache"
)
var (
Records = cache.New(1*time.Minute, 1*time.Minute)
)
+8 -8
View File
@@ -1,4 +1,4 @@
package api
package api_v1
import (
"net/http"
@@ -6,12 +6,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/ws"
)
func GetClients(c *gin.Context) {
@@ -20,7 +20,7 @@ func GetClients(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
cfg, _ := config.Get()
cfg, _ := conf.GetWithV1Format()
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
if cfg.AllowCors {
+287
View File
@@ -0,0 +1,287 @@
package app
import (
"context"
"fmt"
"os"
"os/signal"
"sort"
"syscall"
"time"
)
type Hooks struct {
Init any
Start any
Stop any
}
// Module is the single, explicit lifecycle unit.
//
// - Provide registers DI providers (optional; return nil if none).
// - Hooks returns optional hook functions; dependencies are expressed as
// parameters and injected by the container.
//
// Hook function shapes:
//
// func(context.Context, ...deps) error
// func(...deps) error
// func(context.Context, ...deps)
// func(...deps)
type Module interface {
Name() string
Depends() []string
Provide(r Registry) error
Hooks() Hooks
}
type App struct {
container *container
shutdownTimeout time.Duration
started bool
regErr error
modules map[string]Module
moduleList []Module
order []string
startedOrder []string
}
func New() *App {
return &App{container: newContainer("root"), shutdownTimeout: 5 * time.Second, modules: make(map[string]Module)}
}
func (a *App) With(mod Module) *App {
if a.regErr != nil {
return a
}
if a.container == nil {
a.container = newContainer("root")
}
a.regErr = a.register(mod)
if a.regErr == nil {
a.moduleList = append(a.moduleList, mod)
a.ensureDeps(mod, map[string]bool{})
}
return a
}
func (a *App) WithShutdownTimeout(d time.Duration) *App {
if d > 0 {
a.shutdownTimeout = d
}
return a
}
func (a *App) Start(ctx context.Context) error {
if a.regErr != nil {
return a.regErr
}
if a.container == nil {
a.container = newContainer("root")
}
if ctx == nil {
ctx = context.Background()
}
ctx = withContainerInContext(ctx, a.container)
// Register providers before hooks.
for _, mod := range a.moduleList {
if err := mod.Provide(a.container); err != nil {
return fmt.Errorf("provide module %s: %w", mod.Name(), err)
}
}
order, err := a.resolveOrder()
if err != nil {
return err
}
// Init hooks.
for _, name := range order {
h := a.modules[name].Hooks()
if h.Init == nil {
continue
}
if err := a.container.Invoke(ctx, h.Init); err != nil {
return fmt.Errorf("init module %s: %w", name, err)
}
}
// Start hooks.
a.startedOrder = a.startedOrder[:0]
for _, name := range order {
h := a.modules[name].Hooks()
if h.Start == nil {
a.startedOrder = append(a.startedOrder, name)
continue
}
if err := a.container.Invoke(ctx, h.Start); err != nil {
_ = a.stopHooks(ctx)
return fmt.Errorf("start module %s: %w", name, err)
}
a.startedOrder = append(a.startedOrder, name)
}
a.started = true
return nil
}
func (a *App) Stop(ctx context.Context) error {
if !a.started {
return nil
}
stopCtx := ctx
cancel := func() {}
if stopCtx == nil {
stopCtx, cancel = context.WithTimeout(context.Background(), a.shutdownTimeout)
} else {
stopCtx, cancel = context.WithTimeout(stopCtx, a.shutdownTimeout)
}
defer cancel()
if stopCtx == nil {
stopCtx = context.Background()
}
stopCtx = withContainerInContext(stopCtx, a.container)
err := a.stopHooks(stopCtx)
_ = a.container.Shutdown(stopCtx)
a.started = false
return err
}
// RunWith starts the app, executes fn, then stops the app.
func (a *App) RunWith(ctx context.Context, fn func(context.Context) error) error {
if ctx == nil {
ctx = context.Background()
}
ctx = withContainerInContext(ctx, a.container)
if err := a.Start(ctx); err != nil {
_ = a.Stop(context.Background())
return err
}
defer func() { _ = a.Stop(context.Background()) }()
if fn == nil {
return nil
}
return fn(ctx)
}
// RunUntilSignal starts the app and blocks until ctx cancellation or SIGINT/SIGTERM.
func (a *App) RunUntilSignal(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
ctx = withContainerInContext(ctx, a.container)
if err := a.Start(ctx); err != nil {
_ = a.Stop(context.Background())
return err
}
defer func() { _ = a.Stop(context.Background()) }()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)
select {
case <-ctx.Done():
return ctx.Err()
case <-quit:
return nil
}
}
func (a *App) register(mod Module) error {
if mod == nil {
return fmt.Errorf("module is nil")
}
name := mod.Name()
if name == "" {
return fmt.Errorf("module name is empty")
}
if _, exists := a.modules[name]; exists {
return fmt.Errorf("duplicate module name: %s", name)
}
a.modules[name] = mod
a.order = nil
return nil
}
func (a *App) has(name string) bool {
_, ok := a.modules[name]
return ok
}
func (a *App) resolveOrder() ([]string, error) {
if a.order != nil {
return a.order, nil
}
inDegree := make(map[string]int, len(a.modules))
deps := make(map[string][]string, len(a.modules))
reverse := make(map[string][]string, len(a.modules))
for name, mod := range a.modules {
inDegree[name] = 0
deps[name] = append([]string(nil), mod.Depends()...)
}
for name := range a.modules {
for _, dep := range deps[name] {
if _, ok := a.modules[dep]; !ok {
return nil, fmt.Errorf("module %s depends on missing module %s", name, dep)
}
inDegree[name]++
reverse[dep] = append(reverse[dep], name)
}
}
q := make([]string, 0, len(a.modules))
for name, d := range inDegree {
if d == 0 {
q = append(q, name)
}
}
sort.Strings(q)
order := make([]string, 0, len(a.modules))
for len(q) > 0 {
n := q[0]
q = q[1:]
order = append(order, n)
for _, child := range reverse[n] {
inDegree[child]--
if inDegree[child] == 0 {
q = append(q, child)
}
}
sort.Strings(q)
}
if len(order) != len(a.modules) {
remaining := make([]string, 0)
for name, d := range inDegree {
if d != 0 {
remaining = append(remaining, name)
}
}
sort.Strings(remaining)
return nil, fmt.Errorf("dependency cycle detected among: %v", remaining)
}
a.order = order
return order, nil
}
func (a *App) stopHooks(ctx context.Context) error {
var firstErr error
for i := len(a.startedOrder) - 1; i >= 0; i-- {
name := a.startedOrder[i]
h := a.modules[name].Hooks()
if h.Stop == nil {
continue
}
if err := a.container.Invoke(ctx, h.Stop); err != nil && firstErr == nil {
firstErr = fmt.Errorf("stop module %s: %w", name, err)
}
}
a.startedOrder = a.startedOrder[:0]
return firstErr
}
+355
View File
@@ -0,0 +1,355 @@
package app
import (
"context"
"fmt"
"reflect"
"sync"
)
type containerCtxKey struct{}
func withContainerInContext(ctx context.Context, c *container) context.Context {
if ctx == nil {
ctx = context.Background()
}
if c == nil {
return ctx
}
return context.WithValue(ctx, containerCtxKey{}, c)
}
func containerFromContext(ctx context.Context) (*container, bool) {
if ctx == nil {
return nil, false
}
c, ok := ctx.Value(containerCtxKey{}).(*container)
return c, ok && c != nil
}
// Resolve is an explicit escape hatch for the rare cases where passing
// dependencies via hook parameters is not ergonomic.
//
// Prefer declaring dependencies on hook function parameters instead.
func Resolve[T any](ctx context.Context) (T, error) {
var zero T
c, ok := containerFromContext(ctx)
if !ok {
return zero, fmt.Errorf("no container in context")
}
t := reflect.TypeOf((*T)(nil)).Elem()
v, err := c.resolve(ctx, key{t: t})
if err != nil {
return zero, err
}
return v.Interface().(T), nil
}
// ResolveNamed resolves a named registration from ctx.
func ResolveNamed[T any](ctx context.Context, name string) (T, error) {
var zero T
c, ok := containerFromContext(ctx)
if !ok {
return zero, fmt.Errorf("no container in context")
}
t := reflect.TypeOf((*T)(nil)).Elem()
v, err := c.resolve(ctx, key{t: t, name: name})
if err != nil {
return zero, err
}
return v.Interface().(T), nil
}
// Registry is the minimal surface a module needs to register providers.
//
// It intentionally does NOT expose resolution, so modules can't turn DI into a
// service locator.
type Registry interface {
Provide(fn any, opts ...ProvideOption) error
}
type key struct {
t reflect.Type
name string
}
// container is a lightweight DI container with optional named registrations and scopes.
//
// It supports:
// - Singleton instances per-scope.
// - Providers declared as functions with injectable parameters.
// - Scopes for plugin hot-plug (child container inherits parent providers/values).
// - Optional shutdown hooks for providers (for scope disposal).
//
// Provider function shapes:
// - func(...) T
// - func(...) (T, error)
// - func(...) (T, func(context.Context) error)
// - func(...) (T, func(context.Context) error, error)
//
// Injectable parameters:
// - context.Context: passed through from Invoke.
// - any provided type (resolved recursively).
//
// Notes:
// - For multiple implementations, use Named registrations.
// - Cycles are detected during resolution.
type container struct {
name string
parent *container
mu sync.Mutex
values map[key]reflect.Value
providers map[key]provider
resolving map[key]bool
closers []func(context.Context) error
closed bool
}
type provider struct {
fn reflect.Value
outKey key
hasErr bool
hasCloser bool
closerIndex int
}
func newContainer(name string) *container {
return &container{
name: name,
values: make(map[key]reflect.Value),
providers: make(map[key]provider),
resolving: make(map[key]bool),
}
}
type ProvideOption func(*provideOptions)
type provideOptions struct {
name string
}
func Named(name string) ProvideOption {
return func(o *provideOptions) { o.name = name }
}
func (c *container) Provide(fn any, opts ...ProvideOption) error {
if fn == nil {
return fmt.Errorf("provider is nil")
}
var o provideOptions
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
v := reflect.ValueOf(fn)
t := v.Type()
if t.Kind() != reflect.Func {
return fmt.Errorf("provider must be a func, got %s", t.Kind())
}
if t.NumOut() < 1 || t.NumOut() > 3 {
return fmt.Errorf("provider must return 1..3 values, got %d", t.NumOut())
}
outT := t.Out(0)
p := provider{fn: v, outKey: key{t: outT, name: o.name}, closerIndex: -1}
switch t.NumOut() {
case 1:
// T
case 2:
// (T, error) or (T, closer)
if isErrorType(t.Out(1)) {
p.hasErr = true
} else if isCloserType(t.Out(1)) {
p.hasCloser = true
p.closerIndex = 1
} else {
return fmt.Errorf("provider second return must be error or func(context.Context) error")
}
case 3:
// (T, closer, error)
if !isCloserType(t.Out(1)) || !isErrorType(t.Out(2)) {
return fmt.Errorf("provider must return (T, func(context.Context) error, error)")
}
p.hasCloser = true
p.closerIndex = 1
p.hasErr = true
}
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return fmt.Errorf("container %s is closed", c.name)
}
if _, exists := c.providers[p.outKey]; exists {
return fmt.Errorf("duplicate provider for %s (name=%q)", outT, o.name)
}
c.providers[p.outKey] = p
return nil
}
func (c *container) Invoke(ctx context.Context, fn any) error {
if fn == nil {
return fmt.Errorf("invoke fn is nil")
}
v := reflect.ValueOf(fn)
if v.Kind() != reflect.Func {
return fmt.Errorf("invoke target must be func")
}
args, err := c.buildArgs(ctx, v.Type())
if err != nil {
return err
}
outs := v.Call(args)
if len(outs) == 0 {
return nil
}
last := outs[len(outs)-1]
if isErrorType(last.Type()) && !last.IsNil() {
return last.Interface().(error)
}
return nil
}
func (c *container) Shutdown(ctx context.Context) error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
closers := append([]func(context.Context) error(nil), c.closers...)
c.closed = true
c.mu.Unlock()
var firstErr error
for i := len(closers) - 1; i >= 0; i-- {
if err := closers[i](ctx); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (c *container) resolve(ctx context.Context, k key) (reflect.Value, error) {
// Fast path: check local cache.
c.mu.Lock()
if v, ok := c.values[k]; ok {
c.mu.Unlock()
return v, nil
}
if c.resolving[k] {
c.mu.Unlock()
return reflect.Value{}, fmt.Errorf("dependency cycle resolving %s (name=%q)", k.t, k.name)
}
p, ok := c.providers[k]
if !ok {
parent := c.parent
c.mu.Unlock()
if parent != nil {
return parent.resolve(ctx, k)
}
return reflect.Value{}, fmt.Errorf("no provider for %s (name=%q)", k.t, k.name)
}
c.resolving[k] = true
c.mu.Unlock()
// Build args and call provider.
args, err := c.buildArgs(ctx, p.fn.Type())
if err != nil {
c.mu.Lock()
delete(c.resolving, k)
c.mu.Unlock()
return reflect.Value{}, err
}
outs := p.fn.Call(args)
var outV reflect.Value
var closerFn reflect.Value
var errV reflect.Value
switch len(outs) {
case 1:
outV = outs[0]
case 2:
outV = outs[0]
if p.hasErr {
errV = outs[1]
} else if p.hasCloser {
closerFn = outs[1]
}
case 3:
outV = outs[0]
closerFn = outs[1]
errV = outs[2]
}
if p.hasErr && !errV.IsNil() {
err := errV.Interface().(error)
c.mu.Lock()
delete(c.resolving, k)
c.mu.Unlock()
return reflect.Value{}, err
}
c.mu.Lock()
delete(c.resolving, k)
// Another goroutine could have resolved it while we were building; keep singleton semantics.
if existing, ok := c.values[k]; ok {
c.mu.Unlock()
return existing, nil
}
c.values[k] = outV
if p.hasCloser {
cf := closerFn
if cf.IsValid() && !cf.IsNil() {
c.closers = append(c.closers, func(ctx context.Context) error {
outs := cf.Call([]reflect.Value{reflect.ValueOf(ctx)})
if len(outs) == 1 && !outs[0].IsNil() {
return outs[0].Interface().(error)
}
return nil
})
}
}
c.mu.Unlock()
return outV, nil
}
func (c *container) buildArgs(ctx context.Context, fnType reflect.Type) ([]reflect.Value, error) {
in := fnType.NumIn()
args := make([]reflect.Value, 0, in)
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
for i := 0; i < in; i++ {
paramT := fnType.In(i)
switch {
case paramT == ctxType:
args = append(args, reflect.ValueOf(ctx))
default:
v, err := c.resolve(ctx, key{t: paramT})
if err != nil {
return nil, err
}
args = append(args, v)
}
}
return args, nil
}
func isErrorType(t reflect.Type) bool {
errT := reflect.TypeOf((*error)(nil)).Elem()
return t == errT
}
func isCloserType(t reflect.Type) bool {
closerT := reflect.TypeOf((func(context.Context) error)(nil))
return t == closerT
}
+76
View File
@@ -0,0 +1,76 @@
package app
import (
"fmt"
"sync"
)
var (
factoryMu sync.RWMutex
factories = make(map[string]func() Module)
)
// RegisterModuleFactory registers a factory used by App to auto-materialize missing dependencies.
// Intended for core modules (config/db) and plugin-provided modules.
func RegisterModuleFactory(name string, factory func() Module) {
if name == "" || factory == nil {
return
}
factoryMu.Lock()
factories[name] = factory
factoryMu.Unlock()
}
func getModuleFactory(name string) (func() Module, bool) {
factoryMu.RLock()
f, ok := factories[name]
factoryMu.RUnlock()
return f, ok
}
func (a *App) ensureDeps(mod Module, visiting map[string]bool) {
if a.regErr != nil {
return
}
if mod == nil {
a.regErr = fmt.Errorf("module is nil")
return
}
name := mod.Name()
if visiting[name] {
a.regErr = fmt.Errorf("dependency cycle while materializing: %s", name)
return
}
visiting[name] = true
defer func() { visiting[name] = false }()
for _, dep := range mod.Depends() {
if dep == "" {
continue
}
if a.has(dep) {
continue
}
f, ok := getModuleFactory(dep)
if !ok {
a.regErr = fmt.Errorf("missing module %q (no factory registered)", dep)
return
}
depMod := f()
if depMod == nil {
a.regErr = fmt.Errorf("module factory for %q returned nil", dep)
return
}
// Register dependency module and its dependencies recursively.
a.regErr = a.register(depMod)
if a.regErr != nil {
return
}
a.moduleList = append(a.moduleList, depMod)
a.ensureDeps(depMod, visiting)
if a.regErr != nil {
return
}
}
}
+178
View File
@@ -0,0 +1,178 @@
package client
import (
"math"
"time"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/pkg/utils"
"fmt"
"github.com/google/uuid"
)
func DeleteClient(clientUuid string) error {
db := dbcore.GetDBInstance()
c := models.Client{}
err := db.Model(&models.Client{}).Where("uuid = ?", clientUuid).First(&c).Error
if err != nil {
return err
}
err = db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
if err != nil {
return err
}
event.Trigger(eventType.ClientDeleted, event.M{
"client": c})
return nil
}
func PartialUpdate(update map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := update["uuid"].(string)
if !ok || clientUUID == "" {
return fmt.Errorf("invalid client UUID")
}
// 确保更新的字段不为空
if len(update) == 0 {
return fmt.Errorf("no fields to update")
}
c := models.Client{}
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).First(&c).Error
if err != nil {
return err
}
update["updated_at"] = time.Now()
checkInt64 := func(name string, val float64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
}
return nil
}
verify := func(update map[string]interface{}) error {
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
}
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
return err
}
return nil
}
if err := verify(update); err != nil {
return err
}
err = db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
return err
}
new_c := models.Client{}
err = db.Model(&models.Client{}).Where("uuid = ?", clientUUID).First(&new_c).Error
if err != nil {
return err
}
event.Trigger(eventType.ClientUpdated, event.M{
"old": c,
"new": new_c,
})
return nil
}
// CreateClient 创建新客户端
func CreateClient() (clientUUID, token string, err error) {
db := dbcore.GetDBInstance()
token = utils.GenerateToken()
clientUUID = uuid.New().String()
client := models.Client{
UUID: clientUUID,
Token: token,
Name: "client_" + clientUUID[0:8],
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
event.Trigger(eventType.ClientCreated, event.M{
"client": client,
})
return clientUUID, token, nil
}
func CreateClientWithName(name string) (clientUUID, token string, err error) {
if name == "" {
return CreateClient()
}
db := dbcore.GetDBInstance()
token = utils.GenerateToken()
clientUUID = uuid.New().String()
client := models.Client{
UUID: clientUUID,
Token: token,
Name: name,
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
event.Trigger(eventType.ClientCreated, event.M{
"client": client,
})
return clientUUID, token, nil
}
func GetClientByUUID(uuid string) (client models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return models.Client{}, err
}
return client, nil
}
func GetClientTokenByUUID(uuid string) (token string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return "", err
}
return client.Token, nil
}
func GetAllClientBasicInfo() (clients []models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Find(&clients).Error
if err != nil {
return nil, err
}
return clients, nil
}
@@ -1,35 +1,14 @@
package clients
package client
import (
"encoding/json"
"fmt"
"math"
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
)
// Report 表示客户端报告数据
// SaveReport 保存报告数据
func SaveReport(uuid string, data map[string]interface{}) (err error) {
report, err := ParseReport(data)
if err != nil {
return err
}
err = SaveClientReport(uuid, report)
if err != nil {
return err
}
return nil
}
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
@@ -40,18 +19,6 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
return client.UUID, nil
}
func ParseReport(data map[string]interface{}) (report common.Report, err error) {
jsonData, err := json.Marshal(data)
if err != nil {
return common.Report{}, err
}
err = json.Unmarshal(jsonData, &report)
if err != nil {
return common.Report{}, err
}
return report, nil
}
// 检查数据防止异常数据导致数据库损坏
func ReportVerify(report common.Report) error {
// 防止输入不合理范围
@@ -125,7 +92,7 @@ func ReportVerify(report common.Report) error {
}
// 拒绝所有负数Int
if report.Process < 0 {
return fmt.Errorf("Process must be non-negative: %d", report.Process)
return fmt.Errorf("process must be non-negative: %d", report.Process)
}
if report.Connections.TCP < 0 {
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
@@ -136,78 +103,78 @@ func ReportVerify(report common.Report) error {
return nil
}
// SaveClientReport 保存客户端报告到 Record 表
func SaveClientReport(clientUUID string, report common.Report) (err error) {
db := dbcore.GetDBInstance()
// // SaveClientReport 保存客户端报告到 Record 表
// func SaveClientReport(clientUUID string, report common.Report) (err error) {
// db := dbcore.GetDBInstance()
if err := ReportVerify(report); err != nil {
return fmt.Errorf("failed to save Record: %v", err)
}
// if err := ReportVerify(report); err != nil {
// return fmt.Errorf("failed to save Record: %v", err)
// }
// 保存GPU详细记录到独立表
currentTime := time.Now()
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
for idx, gpu := range report.GPU.DetailedInfo {
gpuRecord := models.GPURecord{
Client: clientUUID,
Time: models.FromTime(currentTime),
DeviceIndex: idx,
DeviceName: gpu.Name,
MemTotal: gpu.MemoryTotal,
MemUsed: gpu.MemoryUsed,
Utilization: float32(gpu.Utilization),
Temperature: gpu.Temperature,
}
if err := db.Create(&gpuRecord).Error; err != nil {
return fmt.Errorf("failed to save GPU record: %v", err)
}
}
}
// // 保存GPU详细记录到独立表
// currentTime := time.Now()
// if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
// for idx, gpu := range report.GPU.DetailedInfo {
// gpuRecord := models.GPURecord{
// Client: clientUUID,
// Time: models.FromTime(currentTime),
// DeviceIndex: idx,
// DeviceName: gpu.Name,
// MemTotal: gpu.MemoryTotal,
// MemUsed: gpu.MemoryUsed,
// Utilization: float32(gpu.Utilization),
// Temperature: gpu.Temperature,
// }
// if err := db.Create(&gpuRecord).Error; err != nil {
// return fmt.Errorf("failed to save GPU record: %v", err)
// }
// }
// }
// 计算平均GPU使用率,用于向后兼容
averageGPUUsage := float32(0)
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
averageGPUUsage = float32(report.GPU.AverageUsage)
}
// // 计算平均GPU使用率,用于向后兼容
// averageGPUUsage := float32(0)
// if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
// averageGPUUsage = float32(report.GPU.AverageUsage)
// }
Record := models.Record{
Client: clientUUID,
Time: models.FromTime(currentTime),
Cpu: float32(report.CPU.Usage),
Gpu: averageGPUUsage, // 使用平均GPU使用率
Ram: report.Ram.Used,
RamTotal: report.Ram.Total,
Swap: report.Swap.Used,
SwapTotal: report.Swap.Total,
Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
Disk: report.Disk.Used,
DiskTotal: report.Disk.Total,
NetIn: report.Network.Down,
NetOut: report.Network.Up,
NetTotalUp: report.Network.TotalUp,
NetTotalDown: report.Network.TotalDown,
Process: report.Process,
Connections: report.Connections.TCP,
ConnectionsUdp: report.Connections.UDP,
//Uptime: report.Uptime,
}
// Record := models.Record{
// Client: clientUUID,
// Time: models.FromTime(currentTime),
// Cpu: float32(report.CPU.Usage),
// Gpu: averageGPUUsage, // 使用平均GPU使用率
// Ram: report.Ram.Used,
// RamTotal: report.Ram.Total,
// Swap: report.Swap.Used,
// SwapTotal: report.Swap.Total,
// Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
// Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
// Disk: report.Disk.Used,
// DiskTotal: report.Disk.Total,
// NetIn: report.Network.Down,
// NetOut: report.Network.Up,
// NetTotalUp: report.Network.TotalUp,
// NetTotalDown: report.Network.TotalDown,
// Process: report.Process,
// Connections: report.Connections.TCP,
// ConnectionsUdp: report.Connections.UDP,
// //Uptime: report.Uptime,
// }
// 使用事务确保 Record 和 ClientsInfo 一致性
err = db.Transaction(func(tx *gorm.DB) error {
// 保存 Record
if err := tx.Create(&Record).Error; err != nil {
return fmt.Errorf("failed to save Record: %v", err)
}
return nil
})
// // 使用事务确保 Record 和 ClientsInfo 一致性
// err = db.Transaction(func(tx *gorm.DB) error {
// // 保存 Record
// if err := tx.Create(&Record).Error; err != nil {
// return fmt.Errorf("failed to save Record: %v", err)
// }
// return nil
// })
if err != nil {
return err
}
// if err != nil {
// return err
// }
return nil
}
// return nil
// }
/*
// getString 从 map 中获取字符串
File renamed without changes.
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/internal/cloudflared"
)
func TestRunCloudflared(t *testing.T) {
+28
View File
@@ -0,0 +1,28 @@
package cloudflared
import (
"fmt"
"os"
"strings"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/eventType"
)
func init() {
event.On(eventType.ServerInitializeStart, event.ListenerFunc(func(e event.Event) error {
if strings.ToLower(strings.ToLower(os.Getenv("KOMARI_ENABLE_CLOUDFLARED"))) == "true" {
err := RunCloudflared()
if err != nil {
// Error in ServerInitializeStart will cause the process to exit
return fmt.Errorf("failed to run cloudflared: %v", err)
}
}
return nil
}))
event.On(eventType.ProcessExit, event.ListenerFunc(func(e event.Event) error {
Kill()
return nil
}))
}
@@ -86,18 +86,18 @@ type CPUReport struct {
// GPUDetailReport 详细GPU信息报告
type GPUDetailReport struct {
Count int `json:"count"` // GPU数量
AverageUsage float64 `json:"average_usage"` // 平均使用率
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
Count int `json:"count"` // GPU数量
AverageUsage float64 `json:"average_usage"` // 平均使用率
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
}
// GPUDeviceInfo 单个GPU设备信息
type GPUDeviceInfo struct {
Name string `json:"name"` // GPU型号名称
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
Utilization float64 `json:"utilization"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
Name string `json:"name"` // GPU型号名称
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
Utilization float64 `json:"utilization"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// 保持向后兼容的旧GPUReport结构
+280
View File
@@ -0,0 +1,280 @@
package conf
import (
"encoding/json"
"fmt"
"os"
"log/slog"
"github.com/gookit/event"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/internal/eventType"
)
// 返回默认配置对象
func Default() Config {
return Config{
Site: Site{
Sitename: "Komari",
Description: "Komari Monitor, a simple server monitoring tool.",
AllowCors: false,
Theme: "default",
},
GeoIp: GeoIp{
GeoIpEnabled: true,
GeoIpProvider: GeoIp_IPInfo,
},
Notification: Notification{
NotificationEnabled: true,
TrafficLimitPercentage: 80.00,
NotificationTemplate: "{{emoji}}{{emoji}}{{emoji}}\n{{event}}\n{{client}}\n{{message}}\n{{time}}",
NotificationMethod: "empty",
ExpireNotificationEnabled: true,
ExpireNotificationLeadDays: 7,
LoginNotification: true,
},
Login: Login{
OAuthEnabled: false,
OAuthProvider: "github",
},
Record: Record{
RecordEnabled: true,
RecordPreserveTime: 720,
PingRecordPreserveTime: 24,
},
Database: Database{
DatabaseType: "sqlite",
DatabaseFile: "./data/komari.db",
},
Listen: "0.0.0.0:25774",
Extensions: map[string]interface{}{},
}
}
// 强制覆盖当前内存中的配置并写入配置文件
func Override(cst Config) error {
ensureExtensionsDefaults(&cst)
b, err := json.MarshalIndent(cst, "", " ")
if err != nil {
return err
}
oldConf := *Conf
Conf = &cst
err, _ = event.Trigger(eventType.ConfigUpdated, event.M{
"old": oldConf,
"new": cst,
})
if err != nil {
// 撤回配置修改
Conf = &oldConf
b, _ = json.MarshalIndent(oldConf, "", " ")
//TODO: 循环引用,解耦dbcore与其他数据库模块
//auditlog.EventLog("error", fmt.Sprintf("Configuration update reverted due to error in ConfigUpdated event: %v", err))
slog.Error("Configuration update reverted due to error in ConfigUpdated event.", slog.Any("error", err))
}
if err := os.WriteFile(flags.ConfigFile, b, 0644); err != nil {
return err
}
return err
}
// 保存部分配置更改到文件,会对V1的旧字段进行转换
func SavePartial(cst map[string]interface{}) error {
// 将当前内存中的配置转换为通用 map,便于合并
baseBytes, err := json.Marshal(Conf)
if err != nil {
return err
}
var base map[string]interface{}
if err := json.Unmarshal(baseBytes, &base); err != nil {
return err
}
// 兼容旧版扁平字段:把扁平键映射到新版分组结构
normalized := normalizePartialMap(cst)
// 深度合并(normalized 覆盖 base)
merged := deepMerge(base, normalized)
// 回写到强类型 Config
mergedBytes, err := json.Marshal(merged)
if err != nil {
return err
}
var newConf Config
if err := json.Unmarshal(mergedBytes, &newConf); err != nil {
return err
}
// 更新内存并落盘
return Override(newConf)
}
func EditAndTrigger(fn func()) error {
oldConf := *Conf
fn()
event.Trigger(eventType.ConfigUpdated, event.M{
"old": oldConf,
"new": *Conf,
})
return nil
}
// 保存完整配置到文件 同Override
func SaveFull(cst Config) error {
return Override(cst)
}
// 以 v1 API 格式获取配置对象,使用 Conf 直接获取对象引用
func GetWithV1Format() (V1Struct, error) {
return Conf.ToV1Format(), nil
}
// 以 v1 API 格式保存配置对象
func Save(cst V1Struct) error {
cfg := cst.ToConfig()
return Override(cfg)
}
// 语义等同于 SavePartial,保持对旧数据格式兼容
func Update(cst map[string]interface{}) error {
// Update 的语义等同于 SavePartial,保持对旧数据格式兼容
return SavePartial(cst)
}
// 将V1字段的输入映射为Config结构。
// 包含 site/login/... 等嵌套结构,则原样保留并与映射结果合并。
func normalizePartialMap(in map[string]interface{}) map[string]interface{} {
if in == nil {
return map[string]interface{}{}
}
// 先复制一份,避免修改入参
out := make(map[string]interface{})
for k, v := range in {
out[k] = v
}
// 准备确保分组 map 存在
ensureGroup := func(name string) map[string]interface{} {
v, ok := out[name]
if !ok || v == nil {
m := map[string]interface{}{}
out[name] = m
return m
}
if m, ok := v.(map[string]interface{}); ok {
return m
}
// 若类型非 map,则覆盖为 map
m := map[string]interface{}{}
out[name] = m
return m
}
site := ensureGroup("site")
login := ensureGroup("login")
geo := ensureGroup("geo_ip")
notif := ensureGroup("notification")
record := ensureGroup("record")
extensions := ensureGroup("extensions")
// 扁平 -> 分组字段映射表
move := func(flatKey string, group map[string]interface{}, groupKey string) {
if v, ok := out[flatKey]; ok {
group[groupKey] = v
delete(out, flatKey)
}
}
// Site
move("sitename", site, "sitename")
move("description", site, "description")
move("allow_cors", site, "allow_cors")
move("theme", site, "theme")
move("private_site", site, "private_site")
move("script_domain", site, "script_domain")
move("send_ip_addr_to_guest", site, "send_ip_addr_to_guest")
move("eula_accepted", site, "eula_accepted")
move("custom_head", site, "custom_head")
move("custom_body", site, "custom_body")
// Login
move("api_key", login, "api_key")
move("auto_discovery_key", login, "auto_discovery_key")
move("o_auth_enabled", login, "o_auth_enabled")
move("o_auth_provider", login, "o_auth_provider")
move("disable_password_login", login, "disable_password_login")
// GeoIP
move("geo_ip_enabled", geo, "geo_ip_enabled")
move("geo_ip_provider", geo, "geo_ip_provider")
// Notification
move("notification_enabled", notif, "notification_enabled")
move("notification_method", notif, "notification_method")
move("notification_template", notif, "notification_template")
move("expire_notification_enabled", notif, "expire_notification_enabled")
move("expire_notification_lead_days", notif, "expire_notification_lead_days")
move("login_notification", notif, "login_notification")
move("traffic_limit_percentage", notif, "traffic_limit_percentage")
// Record
move("record_enabled", record, "record_enabled")
move("record_preserve_time", record, "record_preserve_time")
move("ping_record_preserve_time", record, "ping_record_preserve_time")
// Nezha 兼容字段
nezha := map[string]interface{}{}
move("nezha_compat_enabled", nezha, "nezha_compat_enabled")
move("nezha_compat_listen", nezha, "nezha_compat_listen")
extensions["nezha"] = nezha
return out
}
// = 以src更新dst,返回合并结果
func deepMerge(dst, src map[string]interface{}) map[string]interface{} {
if dst == nil {
dst = map[string]interface{}{}
}
for k, v := range src {
if v == nil {
// 忽略空覆盖,避免误删值
continue
}
if dv, ok := dst[k]; ok {
dm, dIsMap := dv.(map[string]interface{})
sm, sIsMap := v.(map[string]interface{})
if dIsMap && sIsMap {
dst[k] = deepMerge(dm, sm)
continue
}
}
dst[k] = v
}
return dst
}
// FromEvent 从事件对象中提取旧配置和新配置 returns (old,new,error)。
func FromEvent(e event.Event) (Config, Config, error) {
oldVal := e.Get("old")
newVal := e.Get("new")
oldConf, ok := oldVal.(Config)
if !ok {
return Config{}, Config{}, fmt.Errorf("FromEvent: 'old' key value is not of type Config. Got: %T", oldVal)
}
newConf, ok := newVal.(Config)
if !ok {
return Config{}, Config{}, fmt.Errorf("FromEvent: 'new' key value is not of type Config. Got: %T", newVal)
}
return oldConf, newConf, nil
}
+10
View File
@@ -0,0 +1,10 @@
package conf
const (
GeoIp_MMDB = "mmdb"
GeoIp_IPAPI = "ip-api"
GeoIp_GeoJS = "geojs"
GeoIp_IPInfo = "ipinfo"
GeoIp_Empty = "empty"
Version_Development = "development"
)
+146
View File
@@ -0,0 +1,146 @@
package conf
import (
"io/fs"
"log/slog"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/app"
)
func init() {
app.RegisterModuleFactory(NewConfigModule().Name(), NewConfigModule)
}
// InstallGuideFS 用于存储安装引导页面的静态文件系统,由外部包注入以避免循环引用
var InstallGuideFS fs.FS
func installGuide() {
// 未来支持
/*
listen := flags.Listen
if envListen := os.Getenv("LISTEN"); envListen != "" {
listen = envListen
}
if listen == "" {
listen = "0.0.0.0:25774"
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
if InstallGuideFS == nil {
panic("InstallGuideFS is not set, please inject it before starting the server")
}
// 注册 API handlers
api := r.Group("/api/init")
api.POST("/set_https", handleSetHTTPS)
// 处理所有请求
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
cleanPath := strings.TrimPrefix(path, "/")
file, err := InstallGuideFS.Open(cleanPath)
if err == nil {
defer file.Close()
data, err := io.ReadAll(file)
if err == nil {
c.Data(http.StatusOK, getContentType(path), data)
return
}
}
// 文件不存在,如果是 GET 请求返回 index.html(SPA 路由)
if c.Request.Method == "GET" && strings.HasPrefix(path, "/init") {
indexFile, err := InstallGuideFS.Open("index.html")
if err == nil {
defer indexFile.Close()
data, err := io.ReadAll(indexFile)
if err == nil {
c.Header("Content-Type", "text/html")
c.Data(http.StatusOK, "text/html", data)
return
}
}
}
// 其他情况返回 404
c.Redirect(302, "/init")
})
// 启动服务器(阻塞)
slog.Info("Starting install guide server...", slog.String("listen", listen))
if err := r.Run(listen); err != nil {
panic("failed to start install guide server: " + err.Error())
}
*/
}
// getContentType 根据路径获取Content-Type
func getContentType(path string) string {
if strings.HasSuffix(path, ".html") {
return "text/html"
}
if strings.HasSuffix(path, ".css") {
return "text/css"
}
if strings.HasSuffix(path, ".js") {
return "application/javascript"
}
if strings.HasSuffix(path, ".json") {
return "application/json"
}
if strings.HasSuffix(path, ".png") {
return "image/png"
}
if strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg") {
return "image/jpeg"
}
if strings.HasSuffix(path, ".gif") {
return "image/gif"
}
if strings.HasSuffix(path, ".svg") {
return "image/svg+xml"
}
if strings.HasSuffix(path, ".ico") {
return "image/x-icon"
}
if strings.HasSuffix(path, ".woff") {
return "font/woff"
}
if strings.HasSuffix(path, ".woff2") {
return "font/woff2"
}
if strings.HasSuffix(path, ".ttf") {
return "font/ttf"
}
return "application/octet-stream"
}
// handleSetHTTPS 处理 HTTPS 配置
func handleSetHTTPS(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
CertURL string `json:"cert_url,omitempty"`
KeyURL string `json:"key_url,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: 实现 HTTPS 配置逻辑
slog.Info("Setting HTTPS configuration", slog.Bool("enabled", req.Enabled))
c.JSON(http.StatusOK, gin.H{
"message": "HTTPS configuration updated",
"enabled": req.Enabled,
})
}
+84
View File
@@ -0,0 +1,84 @@
package conf
import (
"encoding/json"
"os"
"github.com/gookit/event"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/internal/app"
"github.com/komari-monitor/komari/internal/eventType"
)
type configModule struct{}
var _ app.Module = (*configModule)(nil)
// NewConfigModule provides the "config" module for the lifecycle app.
//
// It loads configuration and also keeps the legacy global variable Conf updated
// to minimize downstream changes.
func NewConfigModule() app.Module { return &configModule{} }
func (m *configModule) Name() string { return "config" }
func (m *configModule) Depends() []string { return nil }
func (m *configModule) Provide(r app.Registry) error {
return r.Provide(func() (*Config, error) {
var cst *Config
if _, err := os.Stat(flags.ConfigFile); os.IsNotExist(err) {
// --- 初始化流程 ---
installGuide()
t := Default()
cst = &t
// 序列化默认配置
b, err := json.MarshalIndent(cst, "", " ")
if err != nil {
return nil, err
}
// 写入文件
if err := os.WriteFile(flags.ConfigFile, b, 0644); err != nil {
return nil, err
}
} else {
// --- 读取流程 ---
b, err := os.ReadFile(flags.ConfigFile)
if err != nil {
return nil, err
}
cst = &Config{}
if err := json.Unmarshal(b, cst); err != nil {
return nil, err
}
}
ensureExtensionsDefaults(cst)
Conf = cst
return Conf, nil
})
}
func (m *configModule) Hooks() app.Hooks {
return app.Hooks{
Init: func(_ *Config) error { return nil },
Start: func(cfg *Config) {
// Keep legacy behavior: once the HTTP stack is initialized,
// broadcast a ConfigUpdated to populate consumers (e.g. CORS, public index).
event.On(eventType.ServerInitializeDone, event.ListenerFunc(func(e event.Event) error {
event.Trigger(eventType.ConfigUpdated, event.M{
"old": Config{},
"new": *cfg,
})
return nil
}), event.Low)
},
}
}
func init() {
app.RegisterModuleFactory("config", NewConfigModule)
}
+341
View File
@@ -0,0 +1,341 @@
package conf
import (
"encoding/json"
"fmt"
"sync"
)
// CustomFieldProvider 是自定义字段提供者需要实现的接口
type CustomFieldProvider interface {
// Key 返回配置中的唯一键名(JSON 字段名)
Key() string
// Default 返回默认值
Default() interface{}
// Validate 验证配置值是否有效(可选,返回 nil 表示验证通过)
Validate(value interface{}) error
}
// BaseFieldProvider 提供一个基础实现,简化自定义字段的创建
type BaseFieldProvider struct {
key string
defaultValue interface{}
validator func(interface{}) error
}
// NewFieldProvider 创建一个基础字段提供者
func NewFieldProvider(key string, defaultValue interface{}, validator func(interface{}) error) *BaseFieldProvider {
return &BaseFieldProvider{
key: key,
defaultValue: defaultValue,
validator: validator,
}
}
func (p *BaseFieldProvider) Key() string {
return p.key
}
func (p *BaseFieldProvider) Default() interface{} {
return p.defaultValue
}
func (p *BaseFieldProvider) Validate(value interface{}) error {
if p.validator != nil {
return p.validator(value)
}
return nil
}
// registry 存储所有注册的自定义字段提供者(只存储 provider,值存在 Conf.Extensions 中)
var (
registryMu sync.RWMutex
customProviders = make(map[string]CustomFieldProvider)
)
// RegisterField 注册一个自定义字段提供者
// 应在 init() 函数中调用,以确保在配置加载前完成注册
func RegisterField(provider CustomFieldProvider) error {
registryMu.Lock()
defer registryMu.Unlock()
key := provider.Key()
if key == "" {
return fmt.Errorf("custom field key cannot be empty")
}
if _, exists := customProviders[key]; exists {
return fmt.Errorf("custom field '%s' is already registered", key)
}
customProviders[key] = provider
return nil
}
// MustRegisterField 注册一个自定义字段提供者,失败时 panic
func MustRegisterField(provider CustomFieldProvider) {
if err := RegisterField(provider); err != nil {
panic(err)
}
}
// RegisterSimpleField 简化的字段注册,无需实现接口
func RegisterSimpleField(key string, defaultValue interface{}) error {
return RegisterField(NewFieldProvider(key, defaultValue, nil))
}
// MustRegisterSimpleField 简化的字段注册,失败时 panic
func MustRegisterSimpleField(key string, defaultValue interface{}) {
MustRegisterField(NewFieldProvider(key, defaultValue, nil))
}
// GetExtension 获取扩展字段的值
func GetExtension(key string) (interface{}, bool) {
if Conf == nil || Conf.Extensions == nil {
return nil, false
}
val, ok := Conf.Extensions[key]
return val, ok
}
// GetExtensionAs 获取扩展字段并转换为指定类型
// 使用示例: val, ok := GetExtensionAs[MyConfig]("my_module")
func GetExtensionAs[T any](key string) (T, bool) {
val, ok := GetExtension(key)
if !ok {
var zero T
return zero, false
}
// 尝试直接类型断言
if typed, ok := val.(T); ok {
return typed, true
}
// 尝试通过 JSON 反序列化转换
var result T
bytes, err := json.Marshal(val)
if err != nil {
var zero T
return zero, false
}
if err := json.Unmarshal(bytes, &result); err != nil {
var zero T
return zero, false
}
return result, true
}
// SetExtension 设置扩展字段的值(不自动保存)
func SetExtension(key string, value interface{}) error {
registryMu.RLock()
provider, exists := customProviders[key]
registryMu.RUnlock()
if !exists {
return fmt.Errorf("extension field '%s' is not registered", key)
}
if err := provider.Validate(value); err != nil {
return fmt.Errorf("validation failed for field '%s': %w", key, err)
}
if Conf.Extensions == nil {
Conf.Extensions = make(map[string]interface{})
}
Conf.Extensions[key] = value
return nil
}
// SetExtensionAndSave 设置扩展字段的值并保存到文件
func SetExtensionAndSave(key string, value interface{}) error {
if err := SetExtension(key, value); err != nil {
return err
}
return Override(*Conf)
}
// GetAllExtensions 获取所有扩展字段
func GetAllExtensions() map[string]interface{} {
if Conf == nil || Conf.Extensions == nil {
return make(map[string]interface{})
}
result := make(map[string]interface{}, len(Conf.Extensions))
for k, v := range Conf.Extensions {
result[k] = v
}
return result
}
// GetRegisteredKeys 获取所有已注册的字段键名
func GetRegisteredKeys() []string {
registryMu.RLock()
defer registryMu.RUnlock()
keys := make([]string, 0, len(customProviders))
for k := range customProviders {
keys = append(keys, k)
}
return keys
}
// IsFieldRegistered 检查字段是否已注册
func IsFieldRegistered(key string) bool {
registryMu.RLock()
defer registryMu.RUnlock()
_, exists := customProviders[key]
return exists
}
// ResetExtension 重置扩展字段为默认值
func ResetExtension(key string) error {
registryMu.RLock()
provider, exists := customProviders[key]
registryMu.RUnlock()
if !exists {
return fmt.Errorf("extension field '%s' is not registered", key)
}
if Conf.Extensions == nil {
Conf.Extensions = make(map[string]interface{})
}
Conf.Extensions[key] = provider.Default()
return nil
}
// ResetAllExtensions 重置所有扩展字段为默认值
func ResetAllExtensions() {
registryMu.RLock()
defer registryMu.RUnlock()
if Conf.Extensions == nil {
Conf.Extensions = make(map[string]interface{})
}
for key, provider := range customProviders {
Conf.Extensions[key] = provider.Default()
}
}
// ensureExtensionsDefaults 确保所有已注册的扩展字段都有值
func ensureExtensionsDefaults(cfg *Config) {
registryMu.RLock()
defer registryMu.RUnlock()
if cfg.Extensions == nil {
cfg.Extensions = make(map[string]interface{})
}
for key, provider := range customProviders {
if _, exists := cfg.Extensions[key]; !exists {
cfg.Extensions[key] = provider.Default()
}
}
}
// GetExtensionDefault 获取扩展字段的默认值
func GetExtensionDefault(key string) (interface{}, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
provider, exists := customProviders[key]
if !exists {
return nil, false
}
return provider.Default(), true
}
// FromEventExtension 从 ConfigUpdated 事件中获取指定扩展字段的旧值和新值
// 返回 (oldValue, newValue, exists)
func FromEventExtension(e interface{ Get(string) interface{} }, key string) (interface{}, interface{}, bool) {
oldConf, oldOk := e.Get("old").(Config)
newConf, newOk := e.Get("new").(Config)
if !oldOk || !newOk {
return nil, nil, false
}
var oldVal, newVal interface{}
var oldExists, newExists bool
if oldConf.Extensions != nil {
oldVal, oldExists = oldConf.Extensions[key]
}
if newConf.Extensions != nil {
newVal, newExists = newConf.Extensions[key]
}
if !oldExists && !newExists {
return nil, nil, false
}
return oldVal, newVal, true
}
// FromEventExtensionAs 从 ConfigUpdated 事件中获取指定扩展字段的旧值和新值(泛型版本)
// 使用示例: old, new, ok := FromEventExtensionAs[MyConfig](e, "my_module")
func FromEventExtensionAs[T any](e interface{ Get(string) interface{} }, key string) (T, T, bool) {
oldVal, newVal, exists := FromEventExtension(e, key)
if !exists {
var zero T
return zero, zero, false
}
convertTo := func(val interface{}) (T, bool) {
if val == nil {
var zero T
return zero, true
}
if typed, ok := val.(T); ok {
return typed, true
}
// JSON 转换
var result T
bytes, err := json.Marshal(val)
if err != nil {
var zero T
return zero, false
}
if err := json.Unmarshal(bytes, &result); err != nil {
var zero T
return zero, false
}
return result, true
}
oldTyped, oldOk := convertTo(oldVal)
newTyped, newOk := convertTo(newVal)
if !oldOk || !newOk {
var zero T
return zero, zero, false
}
return oldTyped, newTyped, true
}
// ---- 兼容旧 API(已废弃,建议使用新的 Extension 系列函数)----
// GetCustomField 已废弃,请使用 GetExtension
func GetCustomField(key string) (interface{}, bool) {
return GetExtension(key)
}
// GetCustomFieldAs 已废弃,请使用 GetExtensionAs
func GetCustomFieldAs[T any](key string) (T, bool) {
return GetExtensionAs[T](key)
}
// SetCustomField 已废弃,请使用 SetExtension
func SetCustomField(key string, value interface{}) error {
return SetExtension(key, value)
}
// SetCustomFieldAndSave 已废弃,请使用 SetExtensionAndSave
func SetCustomFieldAndSave(key string, value interface{}) error {
return SetExtensionAndSave(key, value)
}
+227
View File
@@ -0,0 +1,227 @@
package conf
import "time"
var (
Version = Version_Development
CommitHash = "unknown"
)
var (
// Conf is the global config for legacy code paths.
// Lifecycle-managed startup overwrites it by calling Boot().
Conf = func() *Config {
c := Default()
return &c
}()
)
type Config struct {
Site Site `json:"site"`
Login Login `json:"login"`
GeoIp GeoIp `json:"geo_ip"`
Notification Notification `json:"notification"`
Record Record `json:"record"`
Listen string `json:"listen"`
Database Database `json:"database"`
Extensions map[string]interface{} `json:"extensions,omitempty"` // 扩展配置,由各模块注册
}
type Site struct {
Sitename string `json:"sitename"`
Description string `json:"description"`
AllowCors bool `json:"allow_cors"`
PrivateSite bool `json:"private_site"` // 是否为私有站点,默认 false
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest"` // 是否向访客页面发送 IP 地址,默认 false
ScriptDomain string `json:"script_domain"` // 自定义脚本域名
EulaAccepted bool `json:"eula_accepted"`
// 自定义美化
CustomHead string `json:"custom_head"`
CustomBody string `json:"custom_body"`
Theme string `json:"theme"` // 主题名称,默认 'default'
}
type Login struct {
ApiKey string `json:"api_key"`
AutoDiscoveryKey string `json:"auto_discovery_key"` // 自动发现密钥
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled"`
OAuthProvider string `json:"o_auth_provider"`
DisablePasswordLogin bool `json:"disable_password_login"`
}
type Database struct {
DatabaseType string `json:"database_type"` // 数据库类型:sqlite, mysql
DatabaseFile string `json:"database_file"` // SQLite数据库文件路径
DatabaseHost string `json:"database_host"` // MySQL/其他数据库主机地址
DatabasePort string `json:"database_port"` // MySQL/其他数据库端口
DatabaseUser string `json:"database_user"` // MySQL/其他数据库用户名
DatabasePass string `json:"database_pass"` // MySQL/其他数据库密码
DatabaseName string `json:"database_name"` // MySQL/其他数据库名称
}
type GeoIp struct {
GeoIpEnabled bool `json:"geo_ip_enabled"`
GeoIpProvider string `json:"geo_ip_provider"` // empty, mmdb, ip-api, geojs
}
type Notification struct {
NotificationEnabled bool `json:"notification_enabled"` // 通知总开关
NotificationMethod string `json:"notification_method"`
NotificationTemplate string `json:"notification_template"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage"` // 流量限制百分比,默认80.00%
}
type Record struct {
RecordEnabled bool `json:"record_enabled"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time"` // Ping 记录保留时间,单位小时,默认1天
}
// [DEPRECATED] 旧的数据结构,将不再维护,请考虑使用 conf.Config 结构体
type V1Struct struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// [DEPRECATED] Nezha 兼容字段已迁移到 extensions,保留用于数据库迁移
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt time.Time
UpdatedAt time.Time
}
func (cst *V1Struct) ToConfig() Config {
// 同步 V1 中的 Nezha 字段到 extensions
if IsFieldRegistered("nezha") {
nezhaCfg := map[string]interface{}{
"nezha_compat_enabled": cst.NezhaCompatEnabled,
"nezha_compat_listen": cst.NezhaCompatListen,
}
SetCustomField("nezha", nezhaCfg)
}
return Config{
Site: Site{
Sitename: cst.Sitename,
Description: cst.Description,
AllowCors: cst.AllowCors,
PrivateSite: cst.PrivateSite,
SendIpAddrToGuest: cst.SendIpAddrToGuest,
ScriptDomain: cst.ScriptDomain,
EulaAccepted: cst.EulaAccepted,
CustomHead: cst.CustomHead,
CustomBody: cst.CustomBody,
Theme: cst.Theme,
},
Login: Login{
ApiKey: cst.ApiKey,
AutoDiscoveryKey: cst.AutoDiscoveryKey,
OAuthEnabled: cst.OAuthEnabled,
OAuthProvider: cst.OAuthProvider,
DisablePasswordLogin: cst.DisablePasswordLogin,
},
GeoIp: GeoIp{
GeoIpEnabled: cst.GeoIpEnabled,
GeoIpProvider: cst.GeoIpProvider,
},
Notification: Notification{
NotificationEnabled: cst.NotificationEnabled,
NotificationMethod: cst.NotificationMethod,
NotificationTemplate: cst.NotificationTemplate,
ExpireNotificationEnabled: cst.ExpireNotificationEnabled,
ExpireNotificationLeadDays: cst.ExpireNotificationLeadDays,
LoginNotification: cst.LoginNotification,
TrafficLimitPercentage: cst.TrafficLimitPercentage,
},
Record: Record{
RecordEnabled: cst.RecordEnabled,
RecordPreserveTime: cst.RecordPreserveTime,
PingRecordPreserveTime: cst.PingRecordPreserveTime,
},
}
}
func (cfg *Config) ToV1Format() V1Struct {
v1 := V1Struct{
ID: 1,
Sitename: cfg.Site.Sitename,
Description: cfg.Site.Description,
AllowCors: cfg.Site.AllowCors,
Theme: cfg.Site.Theme,
PrivateSite: cfg.Site.PrivateSite,
ApiKey: cfg.Login.ApiKey,
AutoDiscoveryKey: cfg.Login.AutoDiscoveryKey,
ScriptDomain: cfg.Site.ScriptDomain,
SendIpAddrToGuest: cfg.Site.SendIpAddrToGuest,
EulaAccepted: cfg.Site.EulaAccepted,
GeoIpEnabled: cfg.GeoIp.GeoIpEnabled,
GeoIpProvider: cfg.GeoIp.GeoIpProvider,
OAuthEnabled: cfg.Login.OAuthEnabled,
OAuthProvider: cfg.Login.OAuthProvider,
DisablePasswordLogin: cfg.Login.DisablePasswordLogin,
CustomHead: cfg.Site.CustomHead,
CustomBody: cfg.Site.CustomBody,
NotificationEnabled: cfg.Notification.NotificationEnabled,
NotificationMethod: cfg.Notification.NotificationMethod,
NotificationTemplate: cfg.Notification.NotificationTemplate,
ExpireNotificationEnabled: cfg.Notification.ExpireNotificationEnabled,
ExpireNotificationLeadDays: cfg.Notification.ExpireNotificationLeadDays,
LoginNotification: cfg.Notification.LoginNotification,
TrafficLimitPercentage: cfg.Notification.TrafficLimitPercentage,
RecordEnabled: cfg.Record.RecordEnabled,
RecordPreserveTime: cfg.Record.RecordPreserveTime,
PingRecordPreserveTime: cfg.Record.PingRecordPreserveTime,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 从 extensions 中填充 Nezha 兼容字段
if cfg.Extensions != nil {
if nezha, ok := cfg.Extensions["nezha"]; ok {
if nezhaMap, ok := nezha.(map[string]interface{}); ok {
if enabled, ok := nezhaMap["nezha_compat_enabled"].(bool); ok {
v1.NezhaCompatEnabled = enabled
}
if listen, ok := nezhaMap["nezha_compat_listen"].(string); ok {
v1.NezhaCompatListen = listen
}
}
}
}
return v1
}
@@ -3,8 +3,10 @@ package accounts
import (
"image"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/pquerna/otp/totp"
)
@@ -29,7 +31,14 @@ func Generate2Fa() (string, image.Image, error) {
func Enable2Fa(uuid, secret string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", secret).Error
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", secret).Error
if err != nil {
event.Trigger(eventType.UserTwoFaAdded, event.M{
"user": uuid,
})
return err
}
return nil
}
func Verify2Fa(uuid, code string) (bool, error) {
@@ -54,5 +63,12 @@ func Verify2Fa(uuid, code string) (bool, error) {
func Disable2Fa(uuid string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", "").Error
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", "").Error
if err != nil {
return err
}
event.Trigger(eventType.UserTwoFaRemoved, event.M{
"user": uuid,
})
return nil
}
@@ -7,9 +7,11 @@ import (
"os"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/pkg/utils"
"github.com/google/uuid"
)
@@ -179,5 +181,18 @@ func UpdateUser(uuid string, name, password, sso_type *string) error {
if password != nil {
DeleteAllSessions()
}
if name != nil {
event.Trigger(eventType.UserUpdateUsername, event.M{
"user": uuid,
})
} else if password != nil {
event.Trigger(eventType.UserUpdatePassword, event.M{
"user": uuid,
})
} else if sso_type != nil && *sso_type == "" {
event.Trigger(eventType.UserOidcUnbound, event.M{
"user": uuid,
})
}
return nil
}
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff. Show more