Compare commits

...
29 Commits
Author SHA1 Message Date
Akizon 3415c06256 Merge pull request #485 from airium/main
feat: 支持自定义排序延迟检测任务
2026-03-30 16:38:14 +08:00
Akizon 9f3da4c17a Merge pull request #495 from tranquac/fix/ssrf-theme-download
fix: prevent SSRF in theme download by blocking private/internal IPs
2026-03-30 16:37:43 +08:00
quactv 3d0c83557e fix: prevent SSRF in theme download by blocking private/internal IPs
Signed-off-by: tranquac <tranquac@users.noreply.github.com>
2026-03-27 21:50:41 +07:00
airium e319eb7f20 fix: harden ping task ordering update semantics 2026-03-14 23:31:27 +08:00
airium 7f007b7eb9 feat: Add reorder API to ping tasks 2026-03-13 18:18:57 +08:00
Akizon 9e665005e6 Merge pull request #484 from komari-monitor/fix/流量限制溢出
fix: #393 验证流量限制字段为有效的非负整数值
2026-03-10 15:41:33 +08:00
Akizon77 b5b32b5f64 fix: #393 验证流量限制字段为有效的非负整数值 2026-03-10 15:40:53 +08:00
Akizon 89a699b6cb Merge pull request #483 from komari-monitor/fix/IP地址乱码
fix: #363 IP地址出现乱码
2026-03-10 15:29:22 +08:00
Akizon77 79cbf2d2d1 fix: #363 IP地址出现乱码 2026-03-10 15:28:38 +08:00
Akizon ab00c9529e refactor: 按照业务整理api接口 (#482)
* refactor(wip): 按照业务整理API接口

* refactor: 整合认证中间件
fix: #481 Api 鉴权获取UUID为空
2026-03-10 15:23:54 +08:00
Akizon77 21500a6727 refactor: 整合认证中间件
fix: #481 Api 鉴权获取UUID为空
2026-03-10 15:21:02 +08:00
Akizon77 066b944789 refactor(wip): 按照业务整理API接口 2026-03-10 14:37:22 +08:00
Akizon d7dd816a26 Merge pull request #478 from Real-Wanger/main
fix: reset traffic cache when traffic drops
2026-03-04 14:17:34 +08:00
Wanger c5b464c000 fix: reset traffic cache when traffic drops
修复当节点流量被清零重置时,内存中的告警进度缓存未同步清理,导致无法触发后续阈值通知的 Bug。
2026-03-02 14:27:42 +08:00
Akizon 645c733393 Merge pull request #468 from wayjam/main
feat: import theme api #467
2026-02-26 19:46:09 +08:00
Akizon 85088e64ca Merge pull request #471 from vhvy/feat/keepalive-via-post
feat: 支持 HTTP POST 上报实时信息和在线状态管理,添加 HTTP 接口获取 Ping 任务和提交 Ping 结果
2026-02-26 19:40:05 +08:00
Akizon77 896b3e45fc fix: 添加 base_scripts_url 字段到 Legacy 结构体 #472 2026-02-26 19:04:18 +08:00
vhvy 490f951b59 feat: 添加 HTTP 接口获取 Ping 任务和提交 Ping 结果 2026-02-23 21:27:03 +08:00
vhvy 69c96255d7 feat: 支持 HTTP POST 上报实时信息保活及在线状态管理 2026-02-23 21:24:14 +08:00
WayJam So 487542e55d feat: import theme api #467 2026-02-21 16:55:50 +08:00
Akizon ca8fe76c06 Merge pull request #463 from komari-monitor/refactor/增强临时分享的兼容性
refactor: 增强临时分享的兼容性
2026-02-19 00:15:29 +08:00
Akizon77 e6e3619cd2 refactor: 增强临时分享的兼容性 2026-02-19 00:14:23 +08:00
Akizon77 92ab67d5f3 feat: 添加临时访问许可功能,允许使用临时密钥访问私有站点 2026-02-16 00:34:43 +08:00
Akizon77 77f5bef1a5 fix: 配置默认值 2026-02-15 14:17:03 +08:00
Akizon77 58e43a0862 fix: 增强 WebSocket 升级错误消息,包含详细错误信息 2026-02-12 13:41:01 +08:00
Akizon 80c1f2747a Merge pull request #455 from komari-monitor/fix/expire-notification-panic
fix: 修复过期通知的通知提前天数类型转换错误
2026-02-12 13:13:12 +08:00
Akizon77 b7b2d8b050 fix: 修复过期通知的通知提前天数类型转换错误 2026-02-12 13:12:16 +08:00
Akizon77 b9eabd7cdc docs: 添加 1Panel 应用商店的安装说明 2026-02-10 22:54:48 +08:00
Akizon77 0a19465140 docs: 添加破碎工坊云的介绍和链接 2026-02-09 23:08:57 +08:00
44 changed files with 1217 additions and 492 deletions

No files matched your search

+8
View File
@@ -25,6 +25,10 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel App Store
Available on 1Panel App Store. Install via **App Store > Utilities > Komari**.
### 1. Use the One-click Install Script
Suitable for distributions using systemd (Ubuntu, Debian...).
@@ -113,6 +117,10 @@ Issues and Pull Requests are welcome!
## Acknowledgements
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
-42
View File
@@ -1,42 +0,0 @@
package api
import (
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/gin-gonic/gin"
)
func AdminAuthMiddleware() 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
}
// session-based authentication
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
// Komari is a single user system
uuid, err := accounts.GetSession(session)
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
accounts.UpdateLatest(session, c.Request.UserAgent(), c.ClientIP())
// 将 session 和 用户 UUID 传递到后续处理器
c.Set("session", session)
c.Set("uuid", uuid)
c.Next()
}
}
+234
View File
@@ -0,0 +1,234 @@
package api
import (
"bytes"
"database/sql"
"encoding/json"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"gorm.io/gorm"
)
const (
RoleAdmin = "admin"
RoleClient = "client"
RoleGuest = "guest"
)
// IdentityMiddleware 统一身份识别中间件,在路由栈最外层运行。
// 负责识别当前请求者身份(Admin / Client / Guest),并写入 Context。
func IdentityMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 1. API Key 认证
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("role", RoleAdmin)
c.Set("api_key", apiKey[7:])
c.Set("uuid", "00000000-0000-0000-0000-000000000000") // API Key
c.Next()
return
}
// 2. Session 认证
session, err := c.Cookie("session_token")
if err == nil && session != "" {
uuid, err := accounts.GetSession(session)
if err == nil {
c.Set("role", RoleAdmin)
c.Set("session", session)
c.Set("uuid", uuid)
accounts.UpdateLatest(session, c.Request.UserAgent(), c.ClientIP())
c.Next()
return
}
}
// 3. Client Token 认证
token := extractClientToken(c)
if token != "" {
uuid, err := checkTokenAndGetUUID(token)
if err == nil && uuid != "" {
c.Set("role", RoleClient)
c.Set("client_uuid", uuid)
c.Next()
return
}
}
// 4. 未识别身份,标记为访客
c.Set("role", RoleGuest)
c.Next()
}
}
// RequireRole 声明式权限校验中间件,仅允许指定角色通过。
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
current := GetRole(c)
for _, role := range allowedRoles {
if current == role {
c.Next()
return
}
}
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
}
}
// GetRole 获取当前请求的角色
func GetRole(c *gin.Context) string {
role, exists := c.Get("role")
if !exists {
return RoleGuest
}
if s, ok := role.(string); ok {
return s
}
return RoleGuest
}
// --- 私有站点访问控制 ---
var publicPaths = []string{
"/ping",
"/api/public",
"/api/login",
"/api/me",
"/api/oauth",
"/api/oauth_callback",
"/api/version",
"/api/recent",
"/api/admin", // 由 RequireRole 处理
"/api/clients/", // 由 RequireRole 处理
}
// PrivateSiteMiddleware 私有站点访问控制。
// 依赖 IdentityMiddleware 已设置的 role,对未认证的访客在私有站点模式下进行拦截。
func PrivateSiteMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 已认证用户直接放行
if GetRole(c) != RoleGuest {
c.Next()
return
}
path := c.Request.URL.Path
// 公开路径直接放行
for _, p := range publicPaths {
if strings.HasPrefix(path, p) {
c.Next()
return
}
}
// 非 API 路径直接放行(静态资源等)
if !strings.HasPrefix(path, "/api") {
c.Next()
return
}
// 非私有站点直接放行
privateSite, err := config.GetAs[bool](config.PrivateSiteKey, false)
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
c.Abort()
return
}
if !privateSite {
c.Next()
return
}
// 临时访问许可
if hasTempAccess(c) {
c.Next()
return
}
RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
c.Abort()
}
}
func hasTempAccess(c *gin.Context) bool {
tempKey, err := c.Cookie("temp_key")
if err != nil {
return false
}
expireAt, err := config.GetAs[int64]("tempory_share_token_expire_at", 0)
if err != nil {
return false
}
allowKey, err := config.GetAs[string]("tempory_share_token", "")
if err != nil {
return false
}
if allowKey == "" || tempKey != allowKey {
return false
}
return expireAt >= time.Now().Unix()
}
func extractClientToken(c *gin.Context) string {
token := c.Query("token")
if token != "" {
return token
}
if c.Request.Method != http.MethodGet {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return ""
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
var bodyMap map[string]interface{}
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &bodyMap); err == nil {
if tokenVal, exists := bodyMap["token"]; exists {
if str, ok := tokenVal.(string); ok && str != "" {
return str
}
}
}
}
}
return ""
}
func checkTokenAndGetUUID(token string) (string, error) {
uuid, err := clients.GetClientUUIDByToken(token)
if err == sql.ErrNoRows {
return "", nil
}
if err == gorm.ErrRecordNotFound {
return "", nil
}
if err != nil {
return "", err
}
return uuid, nil
}
func isApiKeyValid(apiKey string) bool {
apiKeyConfig, err := config.GetAs[string](config.ApiKeyKey, "")
if err != nil {
return false
}
if apiKeyConfig == "" || len(apiKeyConfig) < 12 {
return false
}
return apiKey == "Bearer "+apiKeyConfig
}
-32
View File
@@ -3,17 +3,14 @@ package api
import (
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
"github.com/patrickmn/go-cache"
"strconv"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
@@ -23,17 +20,6 @@ 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
@@ -138,21 +124,3 @@ func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
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 {
apiKeyConfig, err := config.GetAs[string](config.ApiKeyKey, "")
if err != nil {
return false
}
if apiKeyConfig == "" || len(apiKeyConfig) < 12 {
return false
}
return apiKey == "Bearer "+apiKeyConfig
}
-75
View File
@@ -1,75 +0,0 @@
package api
import (
"net/http"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/accounts"
"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
}
PrivateSite, err := config.GetAs[bool](config.PrivateSiteKey, false)
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
c.Abort()
return
}
// 验证私有, 如果不是私有站点,直接放行
if !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()
}
}
-89
View File
@@ -1,89 +0,0 @@
package api
import (
"bytes"
"database/sql"
"encoding/json"
"io"
"net/http"
"github.com/komari-monitor/komari/database/clients"
"gorm.io/gorm"
"github.com/gin-gonic/gin"
)
// TokenAuthMiddleware creates a Gin middleware that validates a token from query parameters or request body.
func TokenAuthMiddleware() 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
}
var token string
// Step 1: Check query parameter for token
token = c.Query("token")
// Step 2: If no token in query, check request body for non-GET requests
if token == "" && c.Request.Method != http.MethodGet {
// Read the body
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to read request body"})
return
}
// Restore the body for downstream handlers
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// Try to extract token from JSON body
var bodyMap map[string]interface{}
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &bodyMap); err == nil {
if tokenVal, exists := bodyMap["token"]; exists {
if str, ok := tokenVal.(string); ok && str != "" {
token = str
}
}
}
}
}
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "token is required"})
return
}
exists, err := checkTokenExists(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to validate token"})
return
}
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "invalid token"})
return
}
c.Next()
}
}
func checkTokenExists(token string) (bool, error) {
_, err := clients.GetClientUUIDByToken(token)
if err == sql.ErrNoRows {
return false, nil
}
if err == gorm.ErrRecordNotFound {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
+26
View File
@@ -2,6 +2,7 @@ package admin
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
@@ -78,3 +79,28 @@ func GetAllPingTasks(c *gin.Context) {
api.RespondSuccess(c, tasks)
}
func OrderPingTask(c *gin.Context) {
var req map[string]int
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid or missing request body: "+err.Error())
return
}
order := make(map[uint]int, len(req))
for idStr, weight := range req {
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid task id: "+idStr)
return
}
order[uint(id)] = weight
}
if err := tasks.UpdatePingTaskOrder(order); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, nil)
}
-9
View File
@@ -45,15 +45,6 @@ func EditSettings(c *gin.Context) {
api.RespondSuccess(c, nil)
}
func contains(slice []string, item string) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
func ClearAllRecords(c *gin.Context) {
records.DeleteAll()
tasks.DeleteAllPingRecords()
+175 -2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
@@ -291,9 +292,39 @@ func isValidThemeShort(short string) bool {
}
// downloadThemeFromURL 从URL下载主题文件
func downloadThemeFromURL(url string) ([]byte, error) {
// isPrivateIP checks if the resolved IP addresses are private/internal
func isPrivateIP(host string) bool {
ips, err := net.LookupHost(host)
if err != nil {
return true // fail closed
}
for _, ipStr := range ips {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
}
return false
}
func downloadThemeFromURL(rawURL string) ([]byte, error) {
// SSRF protection: block requests to private/internal IPs
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %v", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return nil, fmt.Errorf("only http and https schemes are allowed")
}
if isPrivateIP(parsedURL.Hostname()) {
return nil, fmt.Errorf("requests to private/internal addresses are not allowed")
}
// 发送HTTP GET请求
resp, err := http.Get(url)
resp, err := http.Get(rawURL)
if err != nil {
return nil, fmt.Errorf("下载主题文件失败: %v", err)
}
@@ -594,6 +625,148 @@ func UpdateTheme(c *gin.Context) {
api.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
}
// peekThemeFromZip 仅从ZIP文件中读取komari-theme.json并解析主题信息
// 不执行解压安装,用于preview模式
func peekThemeFromZip(zipPath string) (models.Theme, error) {
var themeInfo models.Theme
r, err := zip.OpenReader(zipPath)
if err != nil {
return themeInfo, fmt.Errorf("无法打开ZIP文件: %v", err)
}
defer r.Close()
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)")
}
if !isValidThemeShort(themeInfo.Short) {
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
}
return themeInfo, nil
}
// ImportTheme 导入远程主题
// 支持preview查询参数:preview=true时仅返回主题信息,否则下载安装
// 请求body: {"url": "https://..."}
// URL支持GitHub仓库地址(自动取latest release)和直接ZIP下载链接
func ImportTheme(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
// 解析下载链接
downloadURL := req.URL
isGitHub, owner, repo := isGitHubRepoURL(req.URL)
if isGitHub {
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
return
}
downloadURL = gitHubURL
}
// 下载主题ZIP
themeData, err := downloadThemeFromURL(downloadURL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "下载主题失败: "+err.Error())
return
}
// 保存到临时文件
tempFile := filepath.Join(os.TempDir(), "import_theme.zip")
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
return
}
defer os.Remove(tempFile)
// preview模式:仅解析并返回主题信息
preview := c.Query("preview")
if preview == "true" {
themeInfo, err := peekThemeFromZip(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
// 检查是否已存在同名主题
exists := false
themeDir := filepath.Join("./data/theme", themeInfo.Short)
if _, err := os.Stat(themeDir); err == nil {
exists = true
}
api.RespondSuccess(c, gin.H{
"theme": themeInfo,
"exists": exists,
})
return
}
// 安装模式:检查是否存在同名主题
// 先peek一下获取short名称用于检测冲突
themeInfo, err := peekThemeFromZip(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
overwritten := false
themeDir := filepath.Join("./data/theme", themeInfo.Short)
if _, err := os.Stat(themeDir); err == nil {
overwritten = true
}
// 解压安装
installedTheme, err := extractAndValidateTheme(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
msg := "主题导入成功"
if overwritten {
msg = "主题导入成功(已覆盖同名主题)"
}
api.RespondSuccessMessage(c, msg, installedTheme)
}
func UpdateThemeSettings(c *gin.Context) {
theme := c.Query("theme")
if theme == "" {
+55
View File
@@ -0,0 +1,55 @@
package client
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
)
func GetPingTasks(c *gin.Context) {
uuid, _ := c.Get("client_uuid")
clientUUID, _ := uuid.(string)
if clientUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "client_uuid not found"})
return
}
result := tasks.GetPingTasksByClient(clientUUID)
c.JSON(http.StatusOK, result)
}
func UploadPingResult(c *gin.Context) {
uuid, _ := c.Get("client_uuid")
clientUUID, _ := uuid.(string)
if clientUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "client_uuid not found"})
return
}
var req struct {
TaskID uint `json:"task_id" binding:"required"`
Value int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
record := models.PingRecord{
Client: clientUUID,
TaskId: req.TaskID,
Value: req.Value,
Time: models.FromTime(req.FinishedAt),
}
if err := tasks.SavePingRecord(record); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save ping result: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
+88 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"sync"
"time"
"log"
@@ -28,6 +29,73 @@ const (
readWait = 11 * time.Second
)
// postPresenceEntry 保存单个客户端的 POST 上报会话状态
type postPresenceEntry struct {
connID int64
timer *time.Timer
generation uint64 // 每次 Reset 递增,用于回调中判断是否为过期的旧回调
}
var (
postPresenceMu sync.Mutex
postPresenceStates = make(map[string]*postPresenceEntry)
)
// refreshPostPresence 管理 HTTP POST 上报者的在线/离线状态。
// 每次 POST 刷新 TTL 定时器;定时器到期后触发离线通知。
func refreshPostPresence(uuid string) {
postPresenceMu.Lock()
defer postPresenceMu.Unlock()
if entry, exists := postPresenceStates[uuid]; exists {
// 已在线:递增 generation 使可能正在执行的旧回调失效
entry.generation++
entry.timer.Stop()
// 重新创建 AfterFunc 以在闭包中捕获新的 generation
gen := entry.generation
entry.timer = time.AfterFunc(readWait, func() {
postPresenceExpired(uuid, entry.connID, gen)
})
ws.KeepAlivePresence(uuid, entry.connID, readWait)
return
}
// 新 POST 会话:生成 connID,标记在线,启动超时定时器
connID := time.Now().UnixNano()
ws.KeepAlivePresence(uuid, connID, readWait)
go notifier.OnlineNotification(uuid, connID)
defaultGeneration := uint64(0)
entry := &postPresenceEntry{
connID: connID,
generation: defaultGeneration,
}
entry.timer = time.AfterFunc(readWait, func() {
postPresenceExpired(uuid, connID, defaultGeneration)
})
postPresenceStates[uuid] = entry
}
// postPresenceExpired 是定时器到期的回调。
// 只有当 connID 和 generation 都与当前 entry 匹配时才执行离线清理,
// 避免 timer.Reset 竞态导致过期回调错误地清除仍活跃的会话。
func postPresenceExpired(uuid string, connID int64, gen uint64) {
postPresenceMu.Lock()
e, ok := postPresenceStates[uuid]
if !ok || e.connID != connID || e.generation != gen {
postPresenceMu.Unlock()
return
}
delete(postPresenceStates, uuid)
postPresenceMu.Unlock()
ws.SetPresence(uuid, connID, false)
notifier.OfflineNotification(uuid, connID)
}
func UploadReport(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
@@ -50,14 +118,31 @@ func UploadReport(c *gin.Context) {
return
}
report.UpdatedAt = time.Now()
err = SaveClientReport(report.UUID, report)
// 优先使用 body 中的 UUID,若为空则从中间件注入的上下文中获取
uuid := report.UUID
if uuid == "" {
if v, ok := c.Get("client_uuid"); ok {
uuid, _ = v.(string)
}
}
if uuid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "UUID is required"})
return
}
report.UUID = uuid
err = SaveClientReport(uuid, report)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
return
}
// Update report with method and token
ws.SetLatestReport(report.UUID, &report)
ws.SetLatestReport(uuid, &report)
// POST 上报后标记节点在线,超时未收到新 POST 则触发离线
refreshPostPresence(uuid)
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
c.JSON(200, gin.H{"status": "success"})
@@ -77,7 +162,7 @@ func WebSocketReport(c *gin.Context) {
// Upgrade the HTTP connection to a WebSocket connection
unsafeConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
conn := ws.NewSafeConn(unsafeConn)
+3 -3
View File
@@ -37,7 +37,7 @@ func UploadBasicInfo(c *gin.Context) {
}
cbi["uuid"] = uuid
// 如果没有传入 IP 信息,尝试从请求中获取
if (func() bool {
if v4, ok := cbi["ipv4"].(string); !ok || v4 == "" {
if v6, ok := cbi["ipv6"].(string); !ok || v6 == "" {
@@ -52,9 +52,9 @@ func UploadBasicInfo(c *gin.Context) {
switch ipType {
case 0:
cbi["ipv4"] = ip
cbi["ipv4"] = ipStr
case 1:
cbi["ipv6"] = ip
cbi["ipv6"] = ipStr
default:
break
}
+1 -1
View File
@@ -75,7 +75,7 @@ func OnRpcRequest(c *gin.Context) {
})
conn := ws.NewSafeConn(_conn)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
permissionGroup := detectPermissionGroup(c)
+3 -3
View File
@@ -289,9 +289,9 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
meta := rpc.MetaFromContext(ctx)
latest := ws.GetLatestReport() // map[string]*common.Report (copy)
connected := ws.GetConnectedClients()
onlineSet := make(map[string]bool, len(connected))
for uuid := range connected {
onlineUUIDs := ws.GetAllOnlineUUIDs()
onlineSet := make(map[string]bool, len(onlineUUIDs))
for _, uuid := range onlineUUIDs {
onlineSet[uuid] = true
}
-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)
}
@@ -1,7 +1,8 @@
package api
package public
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/dbcore"
"github.com/komari-monitor/komari/database/models"
@@ -11,7 +12,7 @@ func GetClientRecentRecords(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
RespondError(c, 400, "UUID is required")
api.RespondError(c, 400, "UUID is required")
return
}
@@ -34,11 +35,11 @@ func GetClientRecentRecords(c *gin.Context) {
}
if hiddenMap[uuid] {
RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
records, _ := Records.Get(uuid)
RespondSuccess(c, records)
records, _ := api.Records.Get(uuid)
api.RespondSuccess(c, records)
}
+11 -10
View File
@@ -1,10 +1,11 @@
package api
package public
import (
"encoding/json"
"io"
"net/http"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
@@ -21,52 +22,52 @@ type LoginRequest struct {
func Login(c *gin.Context) {
DisablePasswordLogin, _ := config.GetAs[bool](config.DisablePasswordLoginKey, false)
if DisablePasswordLogin {
RespondError(c, http.StatusForbidden, "Password login is disabled")
api.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())
api.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())
api.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")
api.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")
api.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")
api.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")
api.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())
api.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}})
api.RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
}
func Logout(c *gin.Context) {
session, _ := c.Cookie("session_token")
@@ -1,4 +1,4 @@
package api
package public
import (
"bytes"
+1 -1
View File
@@ -1,4 +1,4 @@
package api
package public
import (
"github.com/komari-monitor/komari/database/accounts"
+1 -1
View File
@@ -1,4 +1,4 @@
package api
package public
import (
"encoding/json"
+4 -3
View File
@@ -1,7 +1,8 @@
package api
package public
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/clients"
)
@@ -9,7 +10,7 @@ import (
func GetNodesInformation(c *gin.Context) {
clientList, err := clients.GetAllClientBasicInfo()
if err != nil {
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
api.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)
api.RespondSuccess(c, clientList)
}
+1 -1
View File
@@ -1,4 +1,4 @@
package api
package public
import (
"fmt"
+2 -2
View File
@@ -1,4 +1,4 @@
package task
package public
import (
"net/http"
@@ -35,4 +35,4 @@ func GetPublicPingTasks(c *gin.Context) {
}
api.RespondSuccess(c, publicTasks)
}
}
+47
View File
@@ -0,0 +1,47 @@
package public
import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database"
)
func GetPublicSettings(c *gin.Context) {
p, e := database.GetPublicInfo()
if e != nil {
api.RespondError(c, 500, e.Error())
return
}
// 临时访问许可
if func() bool {
tempKey, err := c.Cookie("temp_key")
if err != nil {
return false
}
tempKeyExpireTime, err := config.GetAs[int64]("tempory_share_token_expire_at", 0)
if err != nil {
return false
}
allowTempKey, err := config.GetAs[string]("tempory_share_token", "")
if err != nil {
return false
}
if allowTempKey == "" || tempKey != allowTempKey {
return false
}
now := time.Now().Unix()
if tempKeyExpireTime < now {
return false
}
return true
}() {
p["private_site"] = false
}
api.RespondSuccess(c, p)
}
@@ -1,4 +1,4 @@
package record
package public
import (
"slices"
@@ -131,13 +131,13 @@ func GetRecordsByUUID(c *gin.Context) {
// 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
}
@@ -341,7 +341,7 @@ func GetPingRecords(c *gin.Context) {
Max: stats.max,
})
}
// 如果同时指定了 uuid 和 task_id,BasicInfo 应该只有一条记录
// 这种情况下可以在响应中标记查询模式
if uuid != "" && taskId != -1 && len(response.BasicInfo) == 1 {
@@ -396,7 +396,7 @@ func GetPingRecords(c *gin.Context) {
if uuid != "" && r.Client != uuid {
continue
}
totalCount++
if r.Value < 0 {
lossCount++
@@ -431,12 +431,12 @@ 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
+14
View File
@@ -0,0 +1,14 @@
package public
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/utils"
)
func GetVersion(c *gin.Context) {
api.RespondSuccess(c, gin.H{
"version": utils.CurrentVersion,
"hash": utils.VersionHash,
})
}
@@ -1,16 +1,15 @@
package client
package terminal
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
)
func EstablishConnection(c *gin.Context) {
session_id := c.Query("id")
session, exists := api.TerminalSessions[session_id]
session, exists := TerminalSessions[session_id]
if !exists || session == nil || session.Browser == nil {
c.JSON(404, gin.H{"status": "error", "error": "Session not found"})
return
@@ -27,22 +26,22 @@ func EstablishConnection(c *gin.Context) {
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
api.TerminalSessionsMutex.Lock()
TerminalSessionsMutex.Lock()
if session.Browser != nil {
session.Browser.Close()
}
delete(api.TerminalSessions, session_id)
api.TerminalSessionsMutex.Unlock()
delete(TerminalSessions, session_id)
TerminalSessionsMutex.Unlock()
return
}
session.Agent = conn
conn.SetCloseHandler(func(code int, text string) error {
delete(api.TerminalSessions, session_id)
delete(TerminalSessions, session_id)
// 通知 Browser 关闭终端连接
if session.Browser != nil {
session.Browser.Close()
}
return nil
})
go api.ForwardTerminal(session_id)
go ForwardTerminal(session_id)
}
+77
View File
@@ -0,0 +1,77 @@
package terminal
import (
"time"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/auditlog"
)
func ForwardTerminal(id string) {
session, exists := TerminalSessions[id]
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
return
}
auditlog.Log(session.RequesterIp, session.UserUUID, "established, terminal id:"+id, "terminal")
established_time := time.Now()
errChan := make(chan error, 1)
go func() {
for {
messageType, data, err := session.Browser.ReadMessage()
if err != nil {
errChan <- err
return
}
if messageType == websocket.TextMessage {
if session.Agent != nil && string(data[0:1]) == "{" {
err = session.Agent.WriteMessage(websocket.TextMessage, data)
} else if session.Agent != nil {
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
}
} else if session.Agent != nil {
// 二进制消息,原样传递
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
}
if err != nil {
errChan <- err
return
}
}
}()
go func() {
for {
_, data, err := session.Agent.ReadMessage()
if err != nil {
errChan <- err
return
}
if session.Browser != nil {
err = session.Browser.WriteMessage(websocket.BinaryMessage, data)
if err != nil {
errChan <- err
return
}
}
}
}()
// 等待错误或主动关闭
<-errChan
// 关闭连接
if session.Agent != nil {
session.Agent.Close()
}
if session.Browser != nil {
session.Browser.Close()
}
disconnect_time := time.Now()
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()
}
+4 -74
View File
@@ -1,4 +1,4 @@
package api
package terminal
import (
"log"
@@ -7,7 +7,6 @@ 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/utils"
"github.com/komari-monitor/komari/ws"
@@ -62,7 +61,7 @@ func RequestTerminal(c *gin.Context) {
})
if ws.GetConnectedClients()[uuid] == nil {
conn.WriteMessage(1, []byte("Client offline!\n被控端离线!"))
conn.WriteMessage(1, []byte("Client offline!\n被控端离线!\n"))
conn.Close()
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
@@ -80,13 +79,13 @@ func RequestTerminal(c *gin.Context) {
TerminalSessionsMutex.Unlock()
return
}
conn.WriteMessage(1, []byte("等待被控端连接 waiting for agent..."))
conn.WriteMessage(1, []byte("等待被控端连接 waiting for agent...\n"))
// 如果没有连接上,则关闭连接
time.AfterFunc(30*time.Second, func() {
TerminalSessionsMutex.Lock()
if session.Agent == nil {
if session.Browser != nil {
session.Browser.WriteMessage(1, []byte("被控端连接超时 timeout"))
session.Browser.WriteMessage(1, []byte("被控端连接超时 timeout\n"))
session.Browser.Close()
}
conn.Close()
@@ -96,72 +95,3 @@ func RequestTerminal(c *gin.Context) {
})
//auditlog.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
}
func ForwardTerminal(id string) {
session, exists := TerminalSessions[id]
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
return
}
auditlog.Log(session.RequesterIp, session.UserUUID, "established, terminal id:"+id, "terminal")
established_time := time.Now()
errChan := make(chan error, 1)
go func() {
for {
messageType, data, err := session.Browser.ReadMessage()
if err != nil {
errChan <- err
return
}
if messageType == websocket.TextMessage {
if session.Agent != nil && string(data[0:1]) == "{" {
err = session.Agent.WriteMessage(websocket.TextMessage, data)
} else if session.Agent != nil {
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
}
} else if session.Agent != nil {
// 二进制消息,原样传递
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
}
if err != nil {
errChan <- err
return
}
}
}()
go func() {
for {
_, data, err := session.Agent.ReadMessage()
if err != nil {
errChan <- err
return
}
if session.Browser != nil {
err = session.Browser.WriteMessage(websocket.BinaryMessage, data)
if err != nil {
errChan <- err
return
}
}
}
}()
// 等待错误或主动关闭
<-errChan
// 关闭连接
if session.Agent != nil {
session.Agent.Close()
}
if session.Browser != nil {
session.Browser.Close()
}
disconnect_time := time.Now()
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()
}
+18
View File
@@ -0,0 +1,18 @@
package terminal
import (
"sync"
"github.com/gorilla/websocket"
)
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)
+1 -1
View File
@@ -32,7 +32,7 @@ func GetClients(c *gin.Context) {
// Upgrade the HTTP connection to a WebSocket connection
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
defer conn.Close()
+22 -22
View File
@@ -22,8 +22,7 @@ import (
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/jsonRpc"
public_api "github.com/komari-monitor/komari/api/public"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/api/task"
"github.com/komari-monitor/komari/api/terminal"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/config"
@@ -68,10 +67,6 @@ func init() {
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)
}
@@ -175,6 +170,7 @@ func RunServer() {
c.Next()
})
r.Use(api.IdentityMiddleware())
r.Use(api.PrivateSiteMiddleware())
r.Use(func(c *gin.Context) {
@@ -188,35 +184,37 @@ func RunServer() {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", api.Login)
r.GET("/api/me", api.GetMe)
r.POST("/api/login", public_api.Login)
r.GET("/api/me", public_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/nodes", public_api.GetNodesInformation)
r.GET("/api/public", public_api.GetPublicSettings)
r.GET("/api/oauth", public_api.OAuth)
r.GET("/api/oauth_callback", public_api.OAuthCallback)
r.GET("/api/logout", public_api.Logout)
r.GET("/api/version", public_api.GetVersion)
r.GET("/api/recent/:uuid", public_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/records/load", public_api.GetRecordsByUUID)
r.GET("/api/records/ping", public_api.GetPingRecords)
r.GET("/api/task/ping", public_api.GetPublicPingTasks)
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
r.GET("/api/mjpeg_live", public_api.MjpegLiveHandler)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
tokenAuthrized := r.Group("/api/clients", api.RequireRole(api.RoleAdmin, api.RoleClient))
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.GET("/terminal", terminal.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
tokenAuthrized.GET("/ping/tasks", client.GetPingTasks)
tokenAuthrized.POST("/ping/result", client.UploadPingResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
adminAuthrized := r.Group("/api/admin", api.RequireRole(api.RoleAdmin))
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
@@ -262,6 +260,7 @@ func RunServer() {
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/import", admin.ImportTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
@@ -275,7 +274,7 @@ func RunServer() {
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
clientGroup.GET("/:uuid/terminal", terminal.RequestTerminal)
}
// records
@@ -337,6 +336,7 @@ func RunServer() {
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
pingTaskGroup.POST("/order", admin.OrderPingTask)
}
+240 -23
View File
@@ -103,7 +103,7 @@ func Get(key string, defaul ...any) (any, error) {
return val, nil
}
// GetAs 获取并转换为指定类型 (泛型)
// GetAs 获取并转换为指定类型 (泛型),支持数值类型自动转换
func GetAs[T any](key string, defaul ...any) (T, error) {
var t T
var item ConfigItem
@@ -111,21 +111,40 @@ func GetAs[T any](key string, defaul ...any) (T, error) {
err := db.First(&item, "key = ?", key).Error
if err != nil {
if len(defaul) > 0 {
v, ok := defaul[0].(T)
if !ok {
// 尝试直接类型断言
if v, ok := defaul[0].(T); ok {
err = Set(key, v)
return v, err
}
// 尝试类型转换
val := reflect.ValueOf(&t).Elem()
if err := convertAndSet(defaul[0], val); err != nil {
return t, fmt.Errorf("default value type mismatch: expected %T, got %T", t, defaul[0])
}
err = Set(key, v)
return v, err
err = Set(key, t)
return t, err
}
return t, err
}
err = json.Unmarshal([]byte(item.Value), &t)
return t, err
// 先尝试直接反序列化
if err = json.Unmarshal([]byte(item.Value), &t); err != nil {
// 尝试通用解析后转换
var generic any
if err := json.Unmarshal([]byte(item.Value), &generic); err != nil {
return t, err
}
val := reflect.ValueOf(&t).Elem()
if err := convertAndSet(generic, val); err != nil {
return t, err
}
}
return t, nil
}
// key[defaults]
// GetMany 获取多个配置项,keys 为 map[key]defaultValue
// 如果 defaultValue 为 nil,则数据库不存在时不写入
// 如果 defaultValue 不为 nil,则数据库不存在时写入默认值
func GetMany(keys map[string]any) (map[string]any, error) {
var items []ConfigItem
result := make(map[string]any)
@@ -149,35 +168,81 @@ func GetMany(keys map[string]any) (map[string]any, error) {
}
}
// Fill in defaults for missing keys
// 收集需要写入数据库的默认值
var toInsert []ConfigItem
for k, def := range keys {
if _, found := foundKeys[k]; !found {
if def != nil {
result[k] = def
_ = Set(k, def) // Ignore error
// 序列化后加入待写入列表
jsonBytes, err := json.Marshal(def)
if err != nil {
slog.Warn("marshal default value failed", "key", k, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
Key: k,
Value: string(jsonBytes),
})
}
}
}
// 批量写入默认值到数据库
if len(toInsert) > 0 {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
slog.Warn("batch insert default config failed", "error", err)
}
}
return result, nil
}
// GetManyAs 将多个配置项映射到一个结构体中,json tag 作为 Key
// 支持 default tag 作为默认值,如果数据库中不存在且有 default tag 则写入数据库
// 没有 default tag 的字段使用零值,不写入数据库
func GetManyAs[T any]() (*T, error) {
var t T
val := reflect.ValueOf(&t).Elem()
typ := val.Type()
tagToFieldMap := make(map[string]int) // Key -> FieldIndex
type fieldInfo struct {
index int
key string
hasDefault bool
defaultVal string
}
fields := make([]fieldInfo, 0)
keys := make([]string, 0)
for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
tag := field.Tag.Get("json")
if tag != "" && tag != "-" {
keys = append(keys, tag)
tagToFieldMap[tag] = i
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// 解析 json tag,处理 "key,omitempty" 格式
key := strings.Split(jsonTag, ",")[0]
if key == "" || key == "-" {
continue
}
defaultTag := field.Tag.Get("default")
hasDefault := defaultTag != "" || field.Tag.Get("default") == ""
// 检查是否显式定义了 default tag (即使值为空)
_, hasDefault = field.Tag.Lookup("default")
fields = append(fields, fieldInfo{
index: i,
key: key,
hasDefault: hasDefault,
defaultVal: defaultTag,
})
keys = append(keys, key)
}
if len(keys) == 0 {
@@ -189,27 +254,179 @@ func GetManyAs[T any]() (*T, error) {
return nil, err
}
// 建立数据库中存在的 key 映射
foundItems := make(map[string]string) // key -> value
for _, item := range items {
fieldIndex, ok := tagToFieldMap[item.Key]
if !ok {
continue
}
foundItems[item.Key] = item.Value
}
fieldVal := val.Field(fieldIndex)
// 需要写入数据库的新配置项
var toInsert []ConfigItem
for _, fi := range fields {
fieldVal := val.Field(fi.index)
if !fieldVal.CanSet() {
continue
}
target := reflect.New(fieldVal.Type()).Interface()
if dbValue, found := foundItems[fi.key]; found {
// 数据库中存在,使用数据库值
if err := unmarshalToField(dbValue, fieldVal); err != nil {
slog.Warn("unmarshal config failed", "key", fi.key, "error", err)
}
} else if fi.hasDefault {
// 数据库中不存在,但有 default tag,解析默认值并写入数据库
if err := parseDefaultToField(fi.defaultVal, fieldVal); err != nil {
slog.Warn("parse default value failed", "key", fi.key, "error", err)
continue
}
// 序列化后写入数据库
jsonBytes, err := json.Marshal(fieldVal.Interface())
if err != nil {
slog.Warn("marshal default value failed", "key", fi.key, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
Key: fi.key,
Value: string(jsonBytes),
})
}
// 没有 default tag 且数据库中不存在,保持零值,不写入数据库
}
if err := json.Unmarshal([]byte(item.Value), target); err == nil {
fieldVal.Set(reflect.ValueOf(target).Elem())
// 批量写入默认值到数据库
if len(toInsert) > 0 {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
slog.Warn("batch insert default config failed", "error", err)
}
}
return &t, nil
}
// unmarshalToField 将 JSON 字符串反序列化到字段,支持数值类型转换
func unmarshalToField(jsonStr string, fieldVal reflect.Value) error {
target := reflect.New(fieldVal.Type()).Interface()
if err := json.Unmarshal([]byte(jsonStr), target); err != nil {
// 尝试通用解析后转换
var generic any
if err := json.Unmarshal([]byte(jsonStr), &generic); err != nil {
return err
}
return convertAndSet(generic, fieldVal)
}
fieldVal.Set(reflect.ValueOf(target).Elem())
return nil
}
// parseDefaultToField 解析 default tag 值到字段
func parseDefaultToField(defaultVal string, fieldVal reflect.Value) error {
kind := fieldVal.Kind()
switch kind {
case reflect.String:
fieldVal.SetString(defaultVal)
case reflect.Bool:
fieldVal.SetBool(defaultVal == "true" || defaultVal == "1")
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
var v int64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
// 尝试解析浮点数后转换
var f float64
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
return err
}
v = int64(f)
}
}
fieldVal.SetInt(v)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
var v uint64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
var f float64
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
return err
}
v = uint64(f)
}
}
fieldVal.SetUint(v)
case reflect.Float32, reflect.Float64:
var v float64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%f", &v); err != nil {
return err
}
}
fieldVal.SetFloat(v)
default:
// 对于复杂类型,尝试 JSON 解析
if defaultVal == "" {
return nil // 保持零值
}
target := reflect.New(fieldVal.Type()).Interface()
if err := json.Unmarshal([]byte(defaultVal), target); err != nil {
return err
}
fieldVal.Set(reflect.ValueOf(target).Elem())
}
return nil
}
// convertAndSet 通用类型转换并设置字段值
func convertAndSet(val any, fieldVal reflect.Value) error {
if val == nil {
return nil
}
targetType := fieldVal.Type()
v := reflect.ValueOf(val)
// 直接类型匹配
if v.Type().AssignableTo(targetType) {
fieldVal.Set(v)
return nil
}
// 类型可转换
if v.Type().ConvertibleTo(targetType) {
fieldVal.Set(v.Convert(targetType))
return nil
}
// 数值类型特殊处理 (JSON 数字默认解析为 float64)
if f, ok := val.(float64); ok {
switch fieldVal.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
fieldVal.SetInt(int64(f))
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
fieldVal.SetUint(uint64(f))
return nil
case reflect.Float32, reflect.Float64:
fieldVal.SetFloat(f)
return nil
}
}
// JSON 回环转换
b, err := json.Marshal(val)
if err != nil {
return err
}
target := reflect.New(targetType).Interface()
if err := json.Unmarshal(b, target); err != nil {
return err
}
fieldVal.Set(reflect.ValueOf(target).Elem())
return nil
}
func GetAll() (map[string]any, error) {
var items []ConfigItem
result := make(map[string]any)
+32 -30
View File
@@ -3,42 +3,43 @@ package config
import "time"
type Legacy 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"`
ID uint `json:"id,omitempty"` // 1
Sitename string `json:"sitename" default:"Komari"` // 站点名称,默认 "Komari"
Description string `json:"description" default:"A simple server monitor tool."` // 站点描述
AllowCors bool `json:"allow_cors" default:"false"` // 是否允许跨域,默认 false
Theme string `json:"theme" default:"default"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" default:"false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" default:""` // API 密钥,默认空字符串
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" default:"false"`
BaseScriptsURLKey string `json:"base_scripts_url" 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
GeoIpEnabled bool `json:"geo_ip_enabled" default:"true"`
GeoIpProvider string `json:"geo_ip_provider" default:"ipinfo"` // 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
NezhaCompatEnabled bool `json:"nezha_compat_enabled" default:"false"`
NezhaCompatListen string `json:"nezha_compat_listen" 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"`
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
OAuthProvider string `json:"o_auth_provider" default:"github"`
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
CustomHead string `json:"custom_head" default:""`
CustomBody string `json:"custom_body" default:""`
// 通知
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%
NotificationEnabled bool `json:"notification_enabled" default:"true"` // 通知总开关
NotificationMethod string `json:"notification_method" default:"none"`
NotificationTemplate string `json:"notification_template" default:"{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" default:"true"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" default:"7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" default:"true"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" 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天
RecordEnabled bool `json:"record_enabled" default:"true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" default:"720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" default:"24"` // Ping 记录保留时间,单位小时,默认1天
UpdatedAt time.Time
}
@@ -53,6 +54,7 @@ const (
ScriptDomainKey = "script_domain"
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
EulaAcceptedKey = "eula_accepted"
BaseScriptsURLKey = "base_scripts_url"
GeoIpEnabledKey = "geo_ip_enabled"
GeoIpProviderKey = "geo_ip_provider"
NezhaCompatEnabledKey = "nezha_compat_enabled"
+8
View File
@@ -309,6 +309,14 @@ func SaveClient(updates map[string]interface{}) error {
return fmt.Errorf("no fields to update")
}
if v, exists := updates["traffic_limit"]; exists {
if val, ok := v.(float64); ok {
if val < 0 || val > math.MaxInt64-1 {
return fmt.Errorf("traffic_limit must be a valid non-negative int64 value, got %v", val)
}
}
}
updates["updated_at"] = time.Now()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
+1
View File
@@ -11,6 +11,7 @@ type PingRecord struct {
type PingTask struct {
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Weight int `json:"weight" gorm:"type:int;not null;default:0;index"`
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
+48 -2
View File
@@ -18,7 +18,23 @@ func AddPingTask(clients []string, name string, target, task_type string, interv
Target: target,
Interval: interval,
}
if err := db.Create(&task).Error; err != nil {
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&task).Error; err != nil {
return err
}
// Append by id to avoid races between concurrent create requests.
result := tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
if err != nil {
return 0, err
}
ReloadPingSchedule()
@@ -50,12 +66,42 @@ func EditPingTask(tasks []*models.PingTask) error {
func GetAllPingTasks() ([]models.PingTask, error) {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Find(&tasks).Error; err != nil {
if err := db.Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
return nil, err
}
return tasks, nil
}
func GetPingTasksByClient(uuid string) []models.PingTask {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Where("clients LIKE ?", `%"`+uuid+`"%`).Find(&tasks).Error; err != nil {
return nil
}
return tasks
}
func UpdatePingTaskOrder(order map[uint]int) error {
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
for id, weight := range order {
result := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", weight)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
}
return nil
})
if err != nil {
return err
}
ReloadPingSchedule()
return nil
}
func SavePingRecord(record models.PingRecord) error {
db := dbcore.GetDBInstance()
return db.Create(&record).Error
+2 -23
View File
@@ -13,8 +13,8 @@ import (
"github.com/komari-monitor/komari/database/models"
)
func GetPublicInfo() (any, error) {
cstPtr, err := config.GetManyAs[models.Config]()
func GetPublicInfo() (map[string]interface{}, error) {
cstPtr, err := config.GetManyAs[config.Legacy]()
if err != nil {
return nil, err
}
@@ -42,15 +42,6 @@ func GetPublicInfo() (any, error) {
if !hasKey("o_auth_provider") {
cst.OAuthProvider = "github"
}
if !hasKey("geo_ip_enabled") {
cst.GeoIpEnabled = true
}
if !hasKey("geo_ip_provider") {
cst.GeoIpProvider = "ip-api"
}
if !hasKey("notification_method") {
cst.NotificationMethod = "none"
}
if !hasKey("record_enabled") {
cst.RecordEnabled = true
}
@@ -69,18 +60,6 @@ func GetPublicInfo() (any, error) {
if cst.Description == "" {
cst.Description = "Komari Monitor, a simple server monitoring tool."
}
if cst.Theme == "" {
cst.Theme = "default"
}
if cst.OAuthProvider == "" {
cst.OAuthProvider = "github"
}
if cst.RecordPreserveTime == 0 {
cst.RecordPreserveTime = 720
}
if cst.PingRecordPreserveTime == 0 {
cst.PingRecordPreserveTime = 24
}
}
db := dbcore.GetDBInstance()
+8
View File
@@ -22,6 +22,10 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel アプリストア
1Panel アプリストアで利用可能です。「アプリストア」>「ユーティリティ」>「Komari」からインストールしてください。
### 1. ワンクリックインストールスクリプトを使用する
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
@@ -112,6 +116,10 @@ IssueやPull Requestを歓迎します!
## 謝辞
### 破碎工坊云 (Crash Work)
[破碎工坊云 - 効率的で安定した、安全な高防御サーバーとCDNソリューションを提供する専門的なクラウドコンピューティングプラットフォーム](https://www.crash.work/)
### DreamCloud
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
+8
View File
@@ -22,6 +22,10 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 應用商店
已上架 1Panel 應用商店,應用商店-實用工具-Komari 即可安裝
### 1. 使用一鍵安裝腳本
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
@@ -112,6 +116,10 @@ sudo ./install-komari.sh
## 鳴謝
### 破碎工坊雲
[破碎工坊雲 - 專業雲計算服務平台,提供高效、穩定、安全的高防伺服器與CDN解決方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
+8
View File
@@ -22,6 +22,10 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 应用商店
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
@@ -112,6 +116,10 @@ sudo ./install-komari.sh
## 鸣谢
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
+36
View File
@@ -9,6 +9,7 @@ import (
"path"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/config"
@@ -215,7 +216,42 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
c.Status(http.StatusNotFound)
return
}
//
func() {
tempKey := c.Query("temp_key")
if tempKey == "" {
return
}
tempKeyExpireTime, err := config.GetAs[int64]("tempory_share_token_expire_at", 0)
if err != nil {
return
}
allowTempKey, err := config.GetAs[string]("tempory_share_token", "")
if err != nil {
return
}
if allowTempKey == "" || tempKey != allowTempKey {
return
}
now := time.Now().Unix()
if tempKeyExpireTime < now {
return
}
expireSeconds := int(tempKeyExpireTime - now)
if expireSeconds > 0 {
c.SetCookie(
"temp_key", // key
tempKey, // value
expireSeconds, // maxAge(秒)
"/", // path
"", // domain
false, // secure
false, // httpOnly
)
}
}()
reqPath := c.Request.URL.Path
cfg := getConfig()
currentTheme := cfg[config.ThemeKey].(string)
+1 -1
View File
@@ -42,7 +42,7 @@ func CheckExpireScheduledWork() {
// 过期提醒检查(仅当启用过期通知时)
if cfg[config.ExpireNotificationEnabledKey].(bool) {
notificationLeadDays := cfg[config.ExpireNotificationLeadDaysKey].(int)
notificationLeadDays := int(cfg[config.ExpireNotificationLeadDaysKey].(float64)) // Json unmarshal 会将数字解析为 float64
type clientToExpireInfo struct {
Name string
+6 -1
View File
@@ -85,9 +85,14 @@ func CheckTraffic() {
last, _ := trafficCache.Get(key)
lastStep, _ := last.(int)
// 修复:当检测到当前进度小于历史记录时,说明流量已重置,将基准归零
if curStep < lastStep {
lastStep = 0
}
if curStep > lastStep { // 只在进入新步进时提醒一次
trafficCache.SetDefault(key, curStep)
msg := fmt.Sprintf("used %d%% (%s / %s), type=%s", curStep, humanBytes(used), humanBytes(c.TrafficLimit), strings.ToLower(c.TrafficLimitType))
// 发送通知(内部会检查 NotificationEnabled)
_ = messageSender.SendEvent(models.EventMessage{