Compare commits

..
35 Commits
Author SHA1 Message Date
Akizon d19b64ec1d Merge pull request #144 from komari-monitor/dev
feat: 优化静态资源服务
2025-07-24 16:25:33 +08:00
Akizon77 6ea22e3241 feat: 优化静态资源服务 2025-07-24 08:10:09 +00:00
Akizon77 ea99966e7a feat: 配置获取添加并发控制 2025-07-24 08:09:57 +00:00
Akizon77 fa13d4d1f9 feat: 自定义端口 2025-07-23 14:31:45 +00:00
Akizon77 15e4f583e0 chore: 删除待办清单文件 2025-07-23 14:25:40 +00:00
Akizon77 3fbbc7bdce docs: 添加前端构建说明文档 2025-07-23 14:23:54 +00:00
Akizon77 5fcd1d313c fix: #138 Docker容器添加时区数据 2025-07-23 04:00:24 +00:00
Akizon 967b7ab8a7 docs: 更新文档链接 2025-07-22 20:52:24 +08:00
Akizon77 d113dde3cc docs: 添加容器云一键部署 2025-07-21 07:52:12 +00:00
Akizon77 60c24a5ee1 docs: 添加一键脚本的指南 2025-07-21 06:48:51 +00:00
Akizon77 d5b8982763 docs: 添加Telegram群组链接 2025-07-21 06:40:45 +00:00
Akizon77 88a441da23 feat: 添加manifest.json路由 2025-07-20 08:28:15 +00:00
akizon77 03f27e7eb1 docs: 更新鸣谢 2025-07-19 01:01:56 +08:00
Akizon 6bba4526f0 Merge pull request #129 from komari-monitor/dev
feat: 定时自动续费
2025-07-16 11:26:14 +08:00
Akizon77 ebb84862a2 feat: 定时自动续费 2025-07-16 11:16:05 +08:00
Akizon77 28480a93d4 feat: 自动续费 2025-07-16 11:07:27 +08:00
Akizon 5c7c5a4e99 Merge pull request #128 from komari-monitor/main
Sync to dev branch
2025-07-16 10:21:57 +08:00
Akizon 90ee1da9c7 Merge pull request #127 from ours1505/dev
feat: 自动续费
2025-07-16 10:18:56 +08:00
Nya 596dbd0c2d feat: 自动续费 2025-07-16 10:02:26 +08:00
Akizon de5db97505 Merge pull request #124 from komari-monitor/dev
docs: 更新前端开发指南
2025-07-15 16:33:32 +08:00
Akizon77 0db40294ba docs: 更新前端开发指南 2025-07-15 16:32:17 +08:00
Akizon 74cd074c98 Merge pull request #123 from komari-monitor/dev
feat: 终端页面不使用主题
2025-07-15 16:05:01 +08:00
Akizon77 bb43b25e37 fix: 主题short不可以为default 2025-07-15 14:44:14 +08:00
Akizon77 b28431e4bd feat: 终端页面不使用主题 2025-07-15 11:37:42 +08:00
Akizon77 ee999810af feat: 在删除客户端时同步删除ws报告 2025-07-15 11:32:36 +08:00
Akizon 860f498e21 Merge pull request #122 from komari-monitor/dev
docs: 更新readme
2025-07-14 23:55:07 +08:00
Akizon77 798e900a9a docs: 更新readme 2025-07-14 23:54:38 +08:00
Akizon 9617cf575f Merge pull request #121 from komari-monitor/dev
feat: 添加主题管理
2025-07-14 23:48:29 +08:00
Akizon77 0ebba0f591 feat: 添加主题管理功能,包括上传、列出、删除和设置主题 2025-07-14 23:23:47 +08:00
Akizon77 01fd429ec3 refactor: 提取RunServer 2025-07-14 19:18:32 +08:00
Akizon 701aacf45c Merge pull request #119 from komari-monitor/dev
feat: #107 支持自定义Tags
2025-07-14 19:13:13 +08:00
Akizon77 2120168d1f feat: #107 支持自定义Tags 2025-07-14 18:53:19 +08:00
Akizon 171e4ceece Merge pull request #117 from komari-monitor/dev
refactor: 替换logOperation为auditlog、将notification模块重构为notifier
2025-07-13 15:47:09 +08:00
Akizon77 1d4c394ad8 refactor: 将notification模块重构为notifier 2025-07-13 15:39:21 +08:00
Akizon77 837313e146 refactor: 替换logOperation为auditlog 2025-07-13 15:36:56 +08:00
34 changed files with 1147 additions and 445 deletions

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ WORKDIR /app
ARG TARGETOS
ARG TARGETARCH
RUN apk add --no-cache tzdata
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
RUN chmod +x /app/komari
+26 -9
View File
@@ -1,9 +1,12 @@
# 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-monitor.github.io/komari-document/)
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
@@ -12,11 +15,19 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
## 快速开始
### 依赖
- Docker(快速部署)
- 或者 Go 1.18+ 和 Node.js 20+(手工构建)
### 0. 容器云一键部署
### Docker 部署
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 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
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
@@ -38,7 +49,7 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 二进制文件部署
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
@@ -52,6 +63,11 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
### 手工构建
#### 依赖
- Go 1.18+ 和 Node.js 20+(手工构建)
1. 构建前端静态文件:
```bash
git clone https://github.com/komari-monitor/komari-web
@@ -75,7 +91,7 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
默认监听 `25774` 端口,访问 `http://localhost:25774`。
## 前端开发指南
这个坑晚点再填吧 ヽ( ̄ω ̄( ̄ω ̄〃)ゝ
[Komari 主题开发指南 | Komari](https://komari-monitor.github.io/komari-document/dev/theme.html)
## 客户端 Agent 开发指南
[Komari Agent 信息上报与事件处理文档](https://komari-monitor.github.io/komari-document/dev/agent.html)
@@ -84,8 +100,9 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
欢迎提交 Issue 或 Pull Request!
## 鸣谢
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感谢我自己能这么闲
- 广告位招租喵
- 提交PR、制作主题的各位开发者
## Star History
+7 -5
View File
@@ -4,9 +4,10 @@ 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/logOperation"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/ws"
)
func AddClient(c *gin.Context) {
@@ -28,7 +29,7 @@ func AddClient(c *gin.Context) {
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
auditlog.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
}
@@ -50,7 +51,7 @@ func EditClient(c *gin.Context) {
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
auditlog.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
@@ -65,8 +66,9 @@ func RemoveClient(c *gin.Context) {
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
auditlog.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
c.JSON(200, gin.H{"status": "success"})
ws.DeleteLatestReport(uuid)
}
func ClearRecord(c *gin.Context) {
@@ -78,7 +80,7 @@ func ClearRecord(c *gin.Context) {
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
auditlog.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
c.JSON(200, gin.H{"status": "success"})
}
+2 -2
View File
@@ -3,8 +3,8 @@ 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/logOperation"
"github.com/komari-monitor/komari/database/models"
)
@@ -23,6 +23,6 @@ func OrderWeight(c *gin.Context) {
}
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "order clients", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "order clients", "info")
api.RespondSuccess(c, nil)
}
+5 -5
View File
@@ -6,8 +6,8 @@ import (
"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/logOperation"
"github.com/komari-monitor/komari/database/models"
)
@@ -49,7 +49,7 @@ func CreateClipboard(c *gin.Context) {
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
auditlog.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
api.RespondSuccess(c, req)
}
@@ -71,7 +71,7 @@ func UpdateClipboard(c *gin.Context) {
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
auditlog.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
api.RespondSuccess(c, nil)
}
@@ -88,7 +88,7 @@ func DeleteClipboard(c *gin.Context) {
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
auditlog.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
api.RespondSuccess(c, nil)
}
@@ -110,6 +110,6 @@ func BatchDeleteClipboard(c *gin.Context) {
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
auditlog.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
api.RespondSuccess(c, nil)
}
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
@@ -62,7 +62,7 @@ func Exec(c *gin.Context) {
}
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
auditlog.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
api.RespondSuccess(c, gin.H{
"task_id": taskId,
"clients": onlineClients,
+3 -3
View File
@@ -3,7 +3,7 @@ package admin
import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/gin-gonic/gin"
)
@@ -33,7 +33,7 @@ func DeleteSession(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "delete session", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "delete session", "info")
api.RespondSuccess(c, nil)
}
@@ -45,6 +45,6 @@ func DeleteAllSession(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
auditlog.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
api.RespondSuccess(c, nil)
}
+3 -3
View File
@@ -4,8 +4,8 @@ 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/logOperation"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
@@ -59,7 +59,7 @@ func EditSettings(c *gin.Context) {
if len(message) > 2 {
message = message[:len(message)-2]
}
logOperation.Log(c.ClientIP(), uuid.(string), message, "info")
auditlog.Log(c.ClientIP(), uuid.(string), message, "info")
api.RespondSuccess(c, nil)
}
@@ -76,6 +76,6 @@ func ClearAllRecords(c *gin.Context) {
records.DeleteAll()
tasks.DeleteAllPingRecords()
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
api.RespondSuccess(c, nil)
}
+283
View File
@@ -0,0 +1,283 @@
package admin
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"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/models"
)
// UploadTheme 上传主题
func UploadTheme(c *gin.Context) {
// 读取上传的文件内容
data, err := io.ReadAll(c.Request.Body)
if err != nil || len(data) == 0 {
api.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())
return
}
defer os.Remove(tempFile)
// 检查文件扩展名(这里假定上传的就是zip)
if !strings.HasSuffix(strings.ToLower(tempFile), ".zip") {
api.RespondError(c, http.StatusBadRequest, "只支持ZIP格式的主题文件")
return
}
// 解压ZIP文件并验证
themeInfo, err := extractAndValidateTheme(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
api.RespondSuccessMessage(c, "主题上传成功", themeInfo)
}
// ListThemes 列出所有主题
func ListThemes(c *gin.Context) {
dataDir := "./data/theme"
// 确保主题目录存在
if _, err := os.Stat(dataDir); os.IsNotExist(err) {
api.RespondSuccess(c, []models.Theme{})
return
}
entries, err := os.ReadDir(dataDir)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "读取主题目录失败: "+err.Error())
return
}
var themes []models.Theme
for _, entry := range entries {
if entry.IsDir() {
themeConfigPath := filepath.Join(dataDir, entry.Name(), "komari-theme.json")
if themeInfo, err := loadThemeConfig(themeConfigPath); err == nil {
themes = append(themes, themeInfo)
}
}
}
api.RespondSuccess(c, themes)
}
// DeleteTheme 删除主题
func DeleteTheme(c *gin.Context) {
var req struct {
Short string `json:"short" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if req.Short == "default" {
api.RespondError(c, http.StatusBadRequest, "默认主题不能删除")
return
}
themeDir := filepath.Join("./data/theme", req.Short)
// 检查主题是否存在
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
// 删除主题目录
if err := os.RemoveAll(themeDir); err != nil {
api.RespondError(c, http.StatusInternalServerError, "删除主题失败: "+err.Error())
return
}
api.RespondSuccessMessage(c, "主题删除成功", nil)
}
// SetTheme 设置主题
func SetTheme(c *gin.Context) {
themeName := c.Query("theme")
if themeName == "" {
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
return
}
// 如果不是default主题,检查主题是否存在
if themeName != "default" {
themeDir := filepath.Join("./data/theme", themeName)
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
}
// 更新配置
updateData := map[string]interface{}{
"theme": themeName,
}
if err := config.Update(updateData); err != nil {
api.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
return
}
api.RespondSuccessMessage(c, "主题设置成功", gin.H{"theme": themeName})
}
// extractAndValidateTheme 解压并验证主题
func extractAndValidateTheme(zipPath string) (models.Theme, error) {
var themeInfo models.Theme
// 打开ZIP文件
r, err := zip.OpenReader(zipPath)
if err != nil {
return themeInfo, fmt.Errorf("无法打开ZIP文件: %v", err)
}
defer r.Close()
// 查找komari-theme.json文件
var themeConfigFile *zip.File
for _, f := range r.File {
if f.Name == "komari-theme.json" {
themeConfigFile = f
break
}
}
if themeConfigFile == nil {
return themeInfo, fmt.Errorf("主题配置文件 komari-theme.json 不存在")
}
// 读取主题配置
rc, err := themeConfigFile.Open()
if err != nil {
return themeInfo, fmt.Errorf("无法读取主题配置文件: %v", err)
}
defer rc.Close()
configData, err := io.ReadAll(rc)
if err != nil {
return themeInfo, fmt.Errorf("读取主题配置失败: %v", err)
}
if err := json.Unmarshal(configData, &themeInfo); err != nil {
return themeInfo, fmt.Errorf("主题配置格式错误: %v", err)
}
// 验证必填字段
if themeInfo.Name == "" || themeInfo.Short == "" {
return themeInfo, fmt.Errorf("主题配置缺少必填字段(name、short)")
}
// 验证Short字段格式(只允许字母、数字、下划线、连字符)
if !isValidThemeShort(themeInfo.Short) {
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
}
// 创建主题目录
themeDir := filepath.Join("./data/theme", themeInfo.Short)
// 如果目录已存在,先删除
if _, err := os.Stat(themeDir); err == nil {
if err := os.RemoveAll(themeDir); err != nil {
return themeInfo, fmt.Errorf("删除原有主题失败: %v", err)
}
}
if err := os.MkdirAll(themeDir, 0755); err != nil {
return themeInfo, fmt.Errorf("创建主题目录失败: %v", err)
}
// 解压文件到主题目录
for _, f := range r.File {
path := filepath.Join(themeDir, f.Name)
// 安全检查,防止路径遍历攻击
if !strings.HasPrefix(path, filepath.Clean(themeDir)+string(os.PathSeparator)) {
continue
}
if f.FileInfo().IsDir() {
os.MkdirAll(path, f.FileInfo().Mode())
continue
}
// 创建目录
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return themeInfo, fmt.Errorf("创建目录失败: %v", err)
}
// 解压文件
rc, err := f.Open()
if err != nil {
return themeInfo, fmt.Errorf("打开压缩文件失败: %v", err)
}
outFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.FileInfo().Mode())
if err != nil {
rc.Close()
return themeInfo, fmt.Errorf("创建文件失败: %v", err)
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return themeInfo, fmt.Errorf("解压文件失败: %v", err)
}
}
return themeInfo, nil
}
// loadThemeConfig 加载主题配置
func loadThemeConfig(configPath string) (models.Theme, error) {
var themeInfo models.Theme
data, err := os.ReadFile(configPath)
if err != nil {
return themeInfo, err
}
if err := json.Unmarshal(data, &themeInfo); err != nil {
return themeInfo, err
}
return themeInfo, nil
}
// isValidThemeShort 验证主题short字段格式
func isValidThemeShort(short string) bool {
if short == "" || short == "default" {
return false
}
for _, r := range short {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-') {
return false
}
}
return true
}
+3 -3
View File
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/auditlog"
)
func UploadFavicon(c *gin.Context) {
@@ -27,7 +27,7 @@ func UploadFavicon(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
api.RespondSuccess(c, nil)
}
@@ -41,6 +41,6 @@ func DeleteFavicon(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
api.RespondSuccess(c, nil)
}
+2 -2
View File
@@ -3,7 +3,7 @@ package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/utils/geoip"
)
@@ -13,6 +13,6 @@ func UpdateMmdbGeoIP(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
api.RespondSuccess(c, nil)
}
+2 -2
View File
@@ -4,7 +4,7 @@ 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/logOperation"
"github.com/komari-monitor/komari/database/auditlog"
)
func UpdateUser(c *gin.Context) {
@@ -35,6 +35,6 @@ func UpdateUser(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
auditlog.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
}
+3 -3
View File
@@ -17,7 +17,7 @@ import (
"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/notification"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/ws"
"github.com/patrickmn/go-cache"
)
@@ -115,10 +115,10 @@ func WebSocketReport(c *gin.Context) {
return
}
ws.SetConnectedClients(uuid, conn)
go notification.OnlineNotification(uuid)
go notifier.OnlineNotification(uuid)
defer func() {
ws.DeleteConnectedClients(uuid)
notification.OfflineNotification(uuid)
notifier.OfflineNotification(uuid)
}()
for {
+3 -3
View File
@@ -6,8 +6,8 @@ import (
"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/komari-monitor/komari/database/logOperation"
"github.com/gin-gonic/gin"
)
@@ -63,13 +63,13 @@ func Login(c *gin.Context) {
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
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)
logOperation.Log(c.ClientIP(), "", "logged out", "logout")
auditlog.Log(c.ClientIP(), "", "logged out", "logout")
c.Redirect(302, "/")
}
+3 -3
View File
@@ -5,8 +5,8 @@ import (
"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/database/logOperation"
"github.com/komari-monitor/komari/utils/oauth"
)
@@ -68,7 +68,7 @@ func OAuthCallback(c *gin.Context) {
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
return
}
logOperation.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
auditlog.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
c.Redirect(302, "/manage")
return
}
@@ -92,6 +92,6 @@ func OAuthCallback(c *gin.Context) {
// 设置cookie并返回
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
auditlog.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
c.Redirect(302, "/manage")
}
+4 -4
View File
@@ -7,8 +7,8 @@ import (
"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/database/logOperation"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
)
@@ -96,7 +96,7 @@ func RequestTerminal(c *gin.Context) {
}
TerminalSessionsMutex.Unlock()
})
//logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
//auditlog.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
}
func ForwardTerminal(id string) {
@@ -105,7 +105,7 @@ func ForwardTerminal(id string) {
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
return
}
logOperation.Log(session.RequesterIp, session.UserUUID, "established, terminal id:"+id, "terminal")
auditlog.Log(session.RequesterIp, session.UserUUID, "established, terminal id:"+id, "terminal")
established_time := time.Now()
errChan := make(chan error, 1)
@@ -162,7 +162,7 @@ func ForwardTerminal(id string) {
session.Browser.Close()
}
disconnect_time := time.Now()
logOperation.Log(session.RequesterIp, session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
auditlog.Log(session.RequesterIp, session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
+246 -229
View File
@@ -22,9 +22,9 @@ import (
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/cmd/flags"
"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/logOperation"
"github.com/komari-monitor/komari/database/models"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/records"
@@ -34,7 +34,7 @@ import (
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
u_notification "github.com/komari-monitor/komari/utils/notification"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/ws"
"github.com/spf13/cobra"
)
@@ -48,229 +48,7 @@ var ServerCmd = &cobra.Command{
Short: "Start the server",
Long: `Start the server`,
Run: func(cmd *cobra.Command, args []string) {
// #region 初始化
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
InitDatabase()
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
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 中间件
conf, err := config.Get()
if err != nil {
log.Fatal(err)
}
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(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", ws.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)
// #region Agent
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)
// 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
adminAuthrized.GET("/settings", admin.GetSettings)
adminAuthrized.POST("/settings", admin.EditSettings)
// 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,
}
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)
}
RunServer()
},
}
@@ -281,6 +59,245 @@ func init() {
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)
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
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 中间件
conf, err := config.Get()
if err != nil {
log.Fatal(err)
}
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(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", ws.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)
// #region Agent
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)
// 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
adminAuthrized.GET("/settings", admin.GetSettings)
adminAuthrized.POST("/settings", admin.EditSettings)
// 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)
}
// 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,
}
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" {
@@ -313,7 +330,7 @@ func DoScheduledWork() {
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
cfg, _ := config.Get()
go u_notification.CheckExpireScheduledWork()
go notifier.CheckExpireScheduledWork()
for {
select {
case <-ticker.C:
@@ -321,7 +338,7 @@ func DoScheduledWork() {
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
logOperation.RemoveOldLogs()
auditlog.RemoveOldLogs()
case <-minute.C:
api.SaveClientReportToDB()
if !cfg.RecordEnabled {
@@ -334,11 +351,11 @@ func DoScheduledWork() {
}
func OnShutdown() {
logOperation.Log("", "", "server is shutting down", "info")
auditlog.Log("", "", "server is shutting down", "info")
cloudflared.Kill()
}
func OnFatal(err error) {
logOperation.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
cloudflared.Kill()
}
@@ -1,4 +1,4 @@
package logOperation
package auditlog
import (
"log"
@@ -21,6 +21,10 @@ func Log(ip, uuid, message, msgType string) {
db.Create(logEntry)
}
func EventLog(eventType, message string) {
Log("", "", message, eventType)
}
// Delete logs older than 30 days
func RemoveOldLogs() {
db := dbcore.GetDBInstance()
+9
View File
@@ -3,6 +3,7 @@ package config
import (
"errors"
"log"
"sync"
"time"
"github.com/komari-monitor/komari/database/dbcore"
@@ -10,7 +11,15 @@ import (
"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 {
+1
View File
@@ -5,6 +5,7 @@ type Config struct {
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'
// 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
+2
View File
@@ -29,9 +29,11 @@ type Client struct {
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt UTCTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
CreatedAt UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
}
+12
View File
@@ -0,0 +1,12 @@
package models
// Theme represents a komari theme information
type Theme struct {
Name string `json:"name"` // 主题名称
Short string `json:"short"` // 短名称,用作文件夹名
Description string `json:"description"` // 主题描述
Version string `json:"version"` // 版本号
Author string `json:"author"` // 作者
URL string `json:"url"` // 主题URL
Preview string `json:"preview"` // 预览图片相对路径
}
+2 -2
View File
@@ -3,7 +3,7 @@ package notification
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
load_notification "github.com/komari-monitor/komari/utils/notification"
"github.com/komari-monitor/komari/utils/notifier"
"gorm.io/gorm"
)
@@ -64,5 +64,5 @@ func ReloadLoadNotificationSchedule() error {
if err := db.Find(&loadNotifications).Error; err != nil {
return err
}
return load_notification.ReloadLoadNotificationSchedule(loadNotifications)
return notifier.ReloadLoadNotificationSchedule(loadNotifications)
}
+26 -6
View File
@@ -23,11 +23,14 @@ log_step() {
echo -e "${YELLOW}$1${NC}"
}
# Global variables
INSTALL_DIR="/opt/komari"
DATA_DIR="/opt/komari/data"
SERVICE_NAME="komari"
BINARY_PATH="$INSTALL_DIR/komari"
DEFAULT_PORT="25774"
LISTEN_PORT=""
# Show banner
show_banner() {
@@ -119,6 +122,21 @@ install_binary() {
return
fi
# 监听端口输入,校验范围 1-65535
while true; do
read -p "请输入监听端口 [默认: $DEFAULT_PORT]: " input_port
if [[ -z "$input_port" ]]; then
LISTEN_PORT="$DEFAULT_PORT"
break
elif [[ "$input_port" =~ ^[0-9]+$ ]] && (( input_port >= 1 && input_port <= 65535 )); then
LISTEN_PORT="$input_port"
break
else
log_error "端口号无效,请输入 1-65535 之间的数字。"
fi
done
install_dependencies
local arch=$(detect_arch)
@@ -147,13 +165,13 @@ install_binary() {
if ! check_systemd; then
log_step "警告:未检测到 systemd,跳过服务创建。"
log_step "您可以从命令行手动运行 Komari:"
log_step " $BINARY_PATH server -l 0.0.0.0:25774"
log_step " $BINARY_PATH server -l 0.0.0.0:$LISTEN_PORT"
echo
log_success "安装完成!"
return
fi
create_systemd_service
create_systemd_service "$LISTEN_PORT"
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}.service
@@ -168,7 +186,7 @@ install_binary() {
if [ -z "$password" ]; then
log_error "未能获取初始密码,请检查日志"
fi
show_access_info "$password"
show_access_info "$password" "$LISTEN_PORT"
else
log_error "Komari 服务启动失败"
log_info "查看日志: journalctl -u ${SERVICE_NAME} -f"
@@ -178,6 +196,7 @@ install_binary() {
# Create systemd service file
create_systemd_service() {
local port="$1"
log_step "创建 systemd 服务..."
local service_file="/etc/systemd/system/${SERVICE_NAME}.service"
@@ -188,7 +207,7 @@ After=network.target
[Service]
Type=simple
ExecStart=${BINARY_PATH} server -l 0.0.0.0:25774
ExecStart=${BINARY_PATH} server -l 0.0.0.0:${port}
WorkingDirectory=${DATA_DIR}
Restart=always
User=root
@@ -203,13 +222,14 @@ EOF
# Show access information
show_access_info() {
local password=$1
local port=${2:-$DEFAULT_PORT}
echo
log_success "安装完成!"
echo
log_info "访问信息:"
log_info " URL: http://$(hostname -I | awk '{print $1}'):25774"
log_info " URL: http://$(hostname -I | awk '{print $1}'):${port}"
if [ -n "$password" ]; then
log_info "初始登录信息: $password"
log_info "初始登录信息(仅显示一次): $password"
fi
echo
log_info "服务管理命令:"
+212 -28
View File
@@ -9,10 +9,12 @@ import (
"io/fs"
"log"
"net/http"
"os" // added for file existence check
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
)
@@ -25,6 +27,11 @@ var RawIndexFile string
var IndexFile string
func initIndex() {
err := os.MkdirAll("./data/theme", 0755)
if err != nil {
log.Println("Failed to create theme directory:", err)
return
}
dist, err := fs.Sub(PublicFS, "dist")
if err != nil {
log.Println("Failed to create dist subdirectory:", err)
@@ -48,6 +55,11 @@ func initIndex() {
RawIndexFile = string(index)
}
func UpdateIndex(cfg models.Config) {
IndexFile = applyCustomizations(RawIndexFile, cfg)
}
// applyCustomizations 应用自定义内容到HTML字符串
func applyCustomizations(htmlContent string, cfg models.Config) string {
var titleReplacement string
if cfg.Sitename == "Komari" {
titleReplacement = "<title>Komari Monitor</title>"
@@ -61,30 +73,17 @@ func UpdateIndex(cfg models.Config) {
"</head>": cfg.CustomHead + "</head>",
"</body>": cfg.CustomBody + "</body>",
}
updated := RawIndexFile
updated := htmlContent
for k, v := range replaceMap {
updated = strings.Replace(updated, k, v, -1)
}
IndexFile = updated
return updated
}
func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
initIndex()
folders := []string{"assets", "images", "streamer", "static"}
r.Use(func(c *gin.Context) {
for i := range folders {
if strings.HasPrefix(c.Request.RequestURI, fmt.Sprintf("/%s/", folders[i])) {
c.Header("Cache-Control", "public, max-age=15552000")
}
}
})
for i, folder := range folders {
sub, err := fs.Sub(DistFS, folder)
if err != nil {
log.Fatalf("can't find folder: %s", folder)
}
r.StaticFS(fmt.Sprintf("/%s/", folders[i]), http.FS(sub))
}
// Serve favicon.ico: use local file if exists, fallback to embedded
r.GET("/favicon.ico", func(c *gin.Context) {
if _, err := os.Stat("data/favicon.ico"); err == nil {
@@ -104,22 +103,207 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
}
c.Data(http.StatusOK, "image/x-icon", data)
})
// r.GET("/manifest.json", func(c *gin.Context) {
// cfg, err := config.Get()
// if cfg.Theme == "default" || cfg.Theme == "" || err != nil {
// // 使用默认主题的manifest.json
// f, err := DistFS.Open("manifest.json")
// if err != nil {
// c.Status(http.StatusNotFound)
// return
// }
// defer f.Close()
// data, err := io.ReadAll(f)
// if err != nil {
// c.Status(http.StatusInternalServerError)
// return
// }
// c.Data(http.StatusOK, "application/json", data)
// } else {
// // 使用自定义主题的manifest.json
// themePath := filepath.Join("./data/theme", cfg.Theme, "dist", "manifest.json")
// if _, err := os.Stat(themePath); os.IsNotExist(err) {
// c.JSON(http.StatusNotFound, gin.H{"error": "Manifest not found"})
// } else {
// c.File(themePath)
// }
// }
// })
// Serve theme files from data/theme directory (for theme previews and static assets)
r.Static("/themes", "./data/theme")
// 使用传入的noRoute函数来处理未匹配的路由
noRoute(func(c *gin.Context) {
if c.Request.Method != "GET" && c.Request.Method != "POST" {
c.Status(405)
path := c.Request.URL.Path
// 对于admin页面,直接使用embedded文件
if strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/terminal") {
serveFromEmbedded(c, path)
return
}
c.Header("Content-Type", "text/html")
c.Status(200)
if strings.HasPrefix(c.Request.RequestURI, "/admin") {
c.Writer.WriteString(RawIndexFile)
} else {
c.Writer.WriteString(IndexFile)
// 获取当前主题配置
cfg, err := config.Get()
if err != nil || cfg.Theme == "default" || cfg.Theme == "" {
// 使用默认主题(embedded文件)
serveFromEmbedded(c, path)
return
}
c.Writer.Flush()
c.Writer.WriteHeaderNow()
// 使用自定义主题
serveFromTheme(c, path, cfg.Theme)
})
}
// serveFromEmbedded 从嵌入的文件系统服务文件
func serveFromEmbedded(c *gin.Context, path string) {
// 处理静态资源文件夹
// folders := []string{"assets", "images", "streamer", "static"}
// for _, folder := range folders {
// if strings.HasPrefix(path, "/"+folder+"/") {
// c.Header("Cache-Control", "public, max-age=15552000")
// sub, err := fs.Sub(DistFS, folder)
// if err == nil {
// relativePath := strings.TrimPrefix(path, "/"+folder+"/")
// file, err := sub.Open(relativePath)
// if err == nil {
// defer file.Close()
// data, err := io.ReadAll(file)
// if err == nil {
// // 设置正确的Content-Type
// contentType := getContentType(path)
// c.Header("Content-Type", contentType)
// c.Data(http.StatusOK, contentType, data)
// return
// }
// }
// }
// }
// }
// 如果文件存在,直接返回文件内容
cleanPath := strings.TrimPrefix(path, "/")
file, err := DistFS.Open(cleanPath)
if err == nil {
defer file.Close()
data, err := io.ReadAll(file)
if err == nil {
contentType := getContentType(path)
c.Header("Content-Type", contentType)
// 静态资源设置缓存
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
c.Header("Cache-Control", "public, max-age=15552000")
}
c.Data(http.StatusOK, contentType, data)
return
}
}
// 处理HTML页面
if c.Request.Method != "GET" && c.Request.Method != "POST" {
c.Status(405)
return
}
c.Header("Content-Type", "text/html")
c.Status(200)
if strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/terminal") {
c.Writer.WriteString(RawIndexFile)
} else {
c.Writer.WriteString(IndexFile)
}
c.Writer.Flush()
c.Writer.WriteHeaderNow()
}
// serveFromTheme 从主题目录服务文件
func serveFromTheme(c *gin.Context, path string, themeName string) {
themeDir := filepath.Join("./data/theme", themeName, "dist")
// 构建完整的文件路径
var filePath string
if path == "/" || path == "" {
filePath = filepath.Join(themeDir, "index.html")
} else {
filePath = filepath.Join(themeDir, strings.TrimPrefix(path, "/"))
}
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// 如果文件不存在,尝试serve index.html(用于SPA路由)
if !strings.Contains(path, ".") {
indexPath := filepath.Join(themeDir, "index.html")
if _, err := os.Stat(indexPath); err == nil {
serveThemeIndexWithCustomizations(c, indexPath)
return
}
}
// 回退到默认主题
serveFromEmbedded(c, path)
return
}
// 设置缓存头
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
c.Header("Cache-Control", "public, max-age=15552000")
}
// 如果是index.html文件,需要处理自定义内容
if strings.HasSuffix(filePath, "index.html") {
serveThemeIndexWithCustomizations(c, filePath)
return
}
c.File(filePath)
}
// serveThemeIndexWithCustomizations 服务主题的index.html并应用自定义内容
func serveThemeIndexWithCustomizations(c *gin.Context, indexPath string) {
// 读取主题的index.html文件
data, err := os.ReadFile(indexPath)
if err != nil {
c.Status(http.StatusNotFound)
return
}
// 获取配置以应用自定义内容
cfg, err := config.Get()
if err != nil {
// 如果获取配置失败,直接返回原始文件
c.Header("Content-Type", "text/html")
c.Data(http.StatusOK, "text/html", data)
return
}
// 使用通用的自定义内容应用函数
content := applyCustomizations(string(data), cfg)
c.Header("Content-Type", "text/html")
c.Data(http.StatusOK, "text/html", []byte(content))
}
// getContentType 根据文件扩展名返回Content-Type
func getContentType(path string) string {
if strings.HasSuffix(path, ".css") {
return "text/css"
} else if strings.HasSuffix(path, ".js") {
return "application/javascript"
} else if strings.HasSuffix(path, ".png") {
return "image/png"
} else if strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg") {
return "image/jpeg"
} else if strings.HasSuffix(path, ".gif") {
return "image/gif"
} else if strings.HasSuffix(path, ".svg") {
return "image/svg+xml"
} else if strings.HasSuffix(path, ".ico") {
return "image/x-icon"
} else if strings.HasSuffix(path, ".woff") {
return "font/woff"
} else if strings.HasSuffix(path, ".woff2") {
return "font/woff2"
}
return "application/octet-stream"
}
+61
View File
@@ -0,0 +1,61 @@
# Frontend Build Instructions / 前端构建说明 / フロントエンド構築手順
## English
### Frontend Repository
- **Frontend project repository**: https://github.com/komari-monitor/komari-web
### Build Requirements
1. Clone the frontend repository and build the static files
2. Copy the generated static files to the `dist` folder in the Komari backend project root directory
3. Ensure the `dist` folder contains `index.html`
### Important Note
⚠️ **The projects under Akizon77's personal repository are no longer maintained. Please use the projects under the organization (komari-monitor).**
---
## 中文
### 前端项目仓库
- **前端项目地址**: https://github.com/komari-monitor/komari-web
### 构建要求
1. 克隆前端仓库并构建静态文件
2. 将生成的静态文件复制到 Komari 后端项目根目录下的 `dist` 文件夹
3. 确保 `dist` 文件夹内包含 `index.html`
### 重要提醒
⚠️ **Akizon77 个人仓库的项目已经不再使用,请使用组织(komari-monitor)下的项目。**
---
## 日本語
### フロントエンドプロジェクトリポジトリ
- **フロントエンドプロジェクトアドレス**: https://github.com/komari-monitor/komari-web
### ビルド要件
1. フロントエンドリポジトリをクローンして静的ファイルをビルドする
2. 生成された静的ファイルを Komari バックエンドプロジェクトのルートディレクトリ下の `dist` フォルダーにコピーする
3. `dist` フォルダー内に `index.html` が含まれていることを確認する
### 重要な注意事項
⚠️ **Akizon77 の個人リポジトリのプロジェクトは使用されなくなりました。組織(komari-monitor)下のプロジェクトを使用してください。**
---
## Quick Setup / 快速设置 / クイックセットアップ
```bash
# Clone frontend repository / 克隆前端仓库 / フロントエンドリポジトリをクローン
git clone https://github.com/komari-monitor/komari-web
cd komari-web
# Install dependencies and build / 安装依赖并构建 / 依存関係をインストールしてビルド
npm install
npm run build
# Copy dist folder to Komari backend project / 将 dist 文件夹复制到 Komari 后端项目 / dist フォルダーを Komari バックエンドプロジェクトにコピー
cp -r dist /path/to/komari/public/
```
-39
View File
@@ -1,39 +0,0 @@
# komari 待办清单
## 通用
- [ ] 低带宽模式
- [x] Web终端
- [x] 一键部署脚本
- [x] 远程命令执行
## Serser
- [x] Docker 部署
- [ ] Web前端
- [x] Web前端嵌入二进制
- [x] 命令行指令 chpasswd 强制更换密码
- [x] Github单点登录、绑定、解绑
- [x] 2FA
- [ ] 负载通知
- [x] 离线通知
- [ ] DDNS
- [x] Agent 备注
## Agent
- [x] 自动更新
- [x] 慢速模式(调整interval)
+4 -4
View File
@@ -10,7 +10,7 @@ import (
"path/filepath" // 新增导入,用于处理文件路径
"sync"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/oschwald/maxminddb-golang"
)
@@ -55,21 +55,21 @@ func NewMaxMindGeoIPService() (*MaxMindGeoIPService, error) {
// 确保数据目录存在
if err := os.MkdirAll(filepath.Dir(dbFilePath), os.ModePerm); err != nil {
logOperation.Log("", "", "Failed to create data directory for MaxMind database: "+err.Error(), "error")
auditlog.Log("", "", "Failed to create data directory for MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to create data directory for MaxMind database: %w", err)
}
// 检查数据库文件是否存在,如果不存在则尝试下载
if _, err := os.Stat(dbFilePath); os.IsNotExist(err) {
if err := service.UpdateDatabase(); err != nil {
logOperation.Log("", "", "Failed to download initial MaxMind database: "+err.Error(), "error")
auditlog.Log("", "", "Failed to download initial MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to download initial MaxMind database: %w", err)
}
}
// 初始化或重新加载 MaxMind 数据库。
if err := service.initialize(); err != nil {
logOperation.Log("", "", "Failed to initialize MaxMind database: "+err.Error(), "error")
auditlog.Log("", "", "Failed to initialize MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to initialize MaxMind database: %w", err)
}
return service, nil
+10 -3
View File
@@ -1,8 +1,8 @@
package messageSender
import (
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
)
var CurrentProvider MessageSender
@@ -36,13 +36,20 @@ type MessageSender interface {
func SendTextMessage(message string, title string) error {
var err error
cfg, err := config.Get()
if err != nil {
return err
}
if !cfg.NotificationEnabled {
return nil
}
for i := 0; i < 3; i++ {
err = CurrentProvider.SendTextMessage(message, title)
if err == nil {
logOperation.Log("", "", "Message sent: "+title, "info")
auditlog.Log("", "", "Message sent: "+title, "info")
return nil
}
}
logOperation.Log("", "", "Failed to send message after 3 attempts: "+err.Error()+","+title, "error")
auditlog.Log("", "", "Failed to send message after 3 attempts: "+err.Error()+","+title, "error")
return err
}
-81
View File
@@ -1,81 +0,0 @@
package notification
import (
"fmt"
"math"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/messageSender"
)
func CheckExpireScheduledWork() {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location()) // UTC 9AM = CST 17PM
if now.After(next) {
next = next.Add(24 * time.Hour)
}
duration := next.Sub(now)
time.Sleep(duration)
cfg, err := config.Get()
if err != nil {
time.Sleep(time.Second)
continue
}
if !cfg.ExpireNotificationEnabled {
time.Sleep(time.Second)
continue
}
notificationLeadDays := cfg.ExpireNotificationLeadDays
clients_all, err := clients.GetAllClientBasicInfo()
if err != nil {
time.Sleep(time.Second)
continue
}
type clientToExpireInfo struct {
Name string
DaysLeft int
}
var clientLeadToExpire []clientToExpireInfo
checkTime := time.Now()
for _, client := range clients_all {
clientExpireTime := client.ExpiredAt.ToTime()
if clientExpireTime.Before(checkTime) {
continue
}
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
}
}
if len(clientLeadToExpire) > 0 {
message := "The following clients are about to expire: \n\n"
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("%s in %d days.\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendTextMessage(message, "Komari Expiration Notification")
}
// 等待1秒,防止多次触发
time.Sleep(time.Second)
}
}
+85
View File
@@ -0,0 +1,85 @@
package notifier
import (
"fmt"
"math"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
func CheckExpireScheduledWork() {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location()) // UTC 9AM = CST 17PM
if now.After(next) {
next = next.Add(24 * time.Hour)
}
duration := next.Sub(now)
time.Sleep(duration)
cfg, err := config.Get()
if err != nil {
time.Sleep(time.Second)
continue
}
clients_all, err := clients.GetAllClientBasicInfo()
if err != nil {
time.Sleep(time.Second)
continue
}
checkTime := time.Now()
// 过期提醒检查(仅当启用过期通知时)
if cfg.ExpireNotificationEnabled {
notificationLeadDays := cfg.ExpireNotificationLeadDays
type clientToExpireInfo struct {
Name string
DaysLeft int
}
var clientLeadToExpire []clientToExpireInfo
for _, client := range clients_all {
clientExpireTime := client.ExpiredAt.ToTime()
if clientExpireTime.Before(checkTime) {
continue
}
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
}
}
if len(clientLeadToExpire) > 0 {
message := "The following clients are about to expire: \n\n"
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("• %s in %d days\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendTextMessage(message, "Komari Expiration Notification")
}
}
// 等待1秒,防止多次触发
time.Sleep(time.Second)
for _, client := range clients_all {
renewal.CheckAndAutoRenewal(client)
}
}
}
@@ -1,4 +1,4 @@
package notification
package notifier
import (
"fmt"
@@ -1,4 +1,4 @@
package notification
package notifier
import (
"fmt"
@@ -11,6 +11,7 @@ import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
// notificationState 保存单个客户端的通知状态。
@@ -119,7 +120,8 @@ func OnlineNotification(clientID string) {
if err != nil {
return
}
// 上线时检测续费
renewal.CheckAndAutoRenewal(client)
_, enabled := getNotificationConfig(clientID)
if !enabled {
return
+114
View File
@@ -0,0 +1,114 @@
package renewal
import (
"fmt"
"time"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/ws"
)
func CheckAndAutoRenewal(client models.Client) {
// 自动续费检查
type renewedClient struct {
Name string
NewExpireTime time.Time
}
var renewedClients []renewedClient
if !client.AutoRenewal {
return
}
// 不在线则不续费
if _, ok := ws.GetConnectedClients()[client.UUID]; !ok {
return
}
clientExpireTime := client.ExpiredAt.ToTime()
checkTime := time.Now()
// 如果到期时间小于0002年,跳过
if clientExpireTime.Year() < 2 {
return
}
// 检查是否已过期或当天过期
if clientExpireTime.Before(checkTime) || clientExpireTime.Format("2006-01-02") == checkTime.Format("2006-01-02") {
// 计算过期时间距离创建时间的总天数,判断是否为长期账单
now := time.Now()
hundredYearsFromNow := now.AddDate(100, 0, 0)
// 如果过期时间超过当前时间100年,视为长期/一次性账单,不续费
if clientExpireTime.After(hundredYearsFromNow) {
return
}
// 如果有账单周期且不为0,进行自动续费
if client.BillingCycle > 0 {
// 根据账单周期计算新的过期时间
var newExpireTime time.Time
billingCycle := client.BillingCycle
// 如果服务器的过期时间太早了,那么直接设置为从当前时间算的下一个到期时间
baseTime := clientExpireTime
if clientExpireTime.Before(now.AddDate(0, 0, -30)) { // 过期时间超过30天前
baseTime = now
}
if billingCycle >= 27 && billingCycle <= 32 {
// 月度计费 - 加1个月
newExpireTime = baseTime.AddDate(0, 1, 0)
} else if billingCycle >= 87 && billingCycle <= 95 {
// 季度计费 - 加3个月
newExpireTime = baseTime.AddDate(0, 3, 0)
} else if billingCycle >= 175 && billingCycle <= 185 {
// 半年计费 - 加6个月
newExpireTime = baseTime.AddDate(0, 6, 0)
} else if billingCycle >= 360 && billingCycle <= 370 {
// 年度计费 - 加1年
newExpireTime = baseTime.AddDate(1, 0, 0)
} else if billingCycle >= 720 && billingCycle <= 750 {
// 两年计费 - 加2年
newExpireTime = baseTime.AddDate(2, 0, 0)
} else if billingCycle >= 1080 && billingCycle <= 1150 {
// 三年计费 - 加3年
newExpireTime = baseTime.AddDate(3, 0, 0)
} else {
// 其他情况,直接加上账单周期天数
newExpireTime = baseTime.AddDate(0, 0, billingCycle)
}
// 更新客户端过期时间
updates := map[string]interface{}{
"uuid": client.UUID,
"expired_at": models.FromTime(newExpireTime),
}
err := clients.SaveClient(updates)
if err != nil {
auditlog.EventLog("renewal", fmt.Sprintf("Failed to renew client %s (%s): %v", client.Name, client.UUID, err))
return
}
renewedClients = append(renewedClients, renewedClient{
Name: client.Name,
NewExpireTime: newExpireTime,
})
auditlog.EventLog("renewal", fmt.Sprintf("Auto-renewed client: %s until %s",
client.Name, newExpireTime.Format("2006-01-02")))
}
}
// 发送续费通知
if len(renewedClients) > 0 {
message := "The following clients have been automatically renewed: \n\n"
for _, clientInfo := range renewedClients {
message += fmt.Sprintf("• %s until %s\n", clientInfo.Name, clientInfo.NewExpireTime.Format("2006-01-02"))
}
messageSender.SendTextMessage(message, "Komari Auto-Renewal Notification")
}
}