Compare commits

...
12 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
27 changed files with 523 additions and 413 deletions

No files matched your search

-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
}
-106
View File
@@ -1,106 +0,0 @@
package api
import (
"net/http"
"time"
"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
}
// 临时访问许可
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
}() {
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()
}
}
-90
View File
@@ -1,90 +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
}
uuid, err := checkTokenAndGetUUID(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to validate token"})
return
}
if uuid == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "invalid token"})
return
}
c.Set("client_uuid", uuid)
c.Next()
}
}
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
}
+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)
}
+33 -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)
}
+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,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)
}
}
+4 -3
View File
@@ -1,9 +1,10 @@
package api
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"
)
@@ -11,7 +12,7 @@ import (
func GetPublicSettings(c *gin.Context) {
p, e := database.GetPublicInfo()
if e != nil {
RespondError(c, 500, e.Error())
api.RespondError(c, 500, e.Error())
return
}
// 临时访问许可
@@ -42,5 +43,5 @@ func GetPublicSettings(c *gin.Context) {
}() {
p["private_site"] = false
}
RespondSuccess(c, p)
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)
+19 -18
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"
@@ -171,6 +170,7 @@ func RunServer() {
c.Next()
})
r.Use(api.IdentityMiddleware())
r.Use(api.PrivateSiteMiddleware())
r.Use(func(c *gin.Context) {
@@ -184,37 +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)
@@ -274,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
@@ -336,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)
}
+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
+39 -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,7 +66,7 @@ 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
@@ -65,6 +81,27 @@ func GetPingTasksByClient(uuid string) []models.PingTask {
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