Merge pull request #550 from komari-monitor/refactor/rpc2-unified-pipeline

refactor(rpc): 统一 RPC2 管道并全量迁移 REST 接口
This commit is contained in:
Akizon authored and GitHub committed 2026-06-11 16:36:57 +08:00
commit cbe665591f
58 files changed
+3221 -3253

No files matched your search

+4
View File
@@ -24,10 +24,14 @@ type ContextMeta struct {
ClientToken string
// ClientUUID 解析出的客户端 UUID(若 token 合法)
ClientUUID string
// SessionToken 当前管理员会话的 session token(仅 admin 会话存在,用于区分当前会话等场景)
SessionToken string
// RemoteIP 请求来源 IP(可选)
RemoteIP string
// UserAgent 请求 UA(可选)
UserAgent string
// TempShareValid 临时分享访问许可是否有效(基于 temp_key cookie 校验,由传输层填充)
TempShareValid bool
}
// 私有类型做 key,避免外部冲突
+183
View File
@@ -0,0 +1,183 @@
package rpc
// permission.go
// 声明式 ACL 权限模型。权限由一组规则 (pattern, minRole) 声明,pattern 支持 "*" 通配符,
// 匹配完整方法名(如 "admin:addClient"、"rpc.ping")。判权时在所有匹配规则中取
// 特异性(specificity)最高者:精确匹配 > 字面前缀更长的通配 > 全局 "*"。
//
// 该模型面向插件扩展:插件可用 Allow 声明任意粒度的规则(命名空间级 "ns:*" 或方法级),
// 无需修改核心代码。
//
// 角色采用分级语义:guest < client < admin,规则声明所需最低角色。
import (
"strings"
"sync"
)
// 角色常量。与 web/api 中的角色保持一致(guest/client/admin)。
const (
RoleGuest = "guest"
RoleClient = "client"
RoleAdmin = "admin"
)
// roleLevel 定义角色的权限等级。数值越大权限越高。未知角色按 guest(最低)处理。
var roleLevel = map[string]int{
RoleGuest: 0,
RoleClient: 1,
RoleAdmin: 2,
}
// DefaultNamespace 是方法名不含 ":" 时归入的命名空间。
const DefaultNamespace = "common"
// aclRule 一条声明式 ACL 规则。
type aclRule struct {
pattern string // 方法名匹配模式,支持 "*" 通配
minRole string // 匹配时所需的最低角色
// 预计算的特异性:精确匹配(无通配)给高位 bonus,其余按字面字符数。
specificity int
hasWildcard bool
}
var (
muACL sync.RWMutex
aclList []aclRule
)
func init() {
// 默认规则覆盖内置命名空间语义。"*" 兜底要求 admin,确保未声明的方法默认最严格。
Allow("*", RoleAdmin)
Allow("common:*", RoleGuest)
Allow("guest:*", RoleGuest)
Allow("rpc.*", RoleGuest) // 内部方法 rpc.ping/rpc.help 等(用 "." 分隔)
Allow("rpc:*", RoleGuest)
Allow("client:*", RoleClient)
Allow("admin:*", RoleAdmin)
}
// levelOf 返回角色的权限等级,未知角色视为 guest。
func levelOf(role string) int {
if lv, ok := roleLevel[role]; ok {
return lv
}
return roleLevel[RoleGuest]
}
// NamespaceOf 解析方法名的命名空间。"ns:method" 返回 "ns";无 ":" 返回 DefaultNamespace。
func NamespaceOf(method string) string {
if i := strings.IndexByte(method, ':'); i >= 0 {
return method[:i]
}
return DefaultNamespace
}
// computeSpecificity 计算 pattern 的特异性。精确匹配(不含通配符)给一个大 bonus
// 以保证优先级最高;含通配符的按字面(非 "*")字符数排序,前缀越长越具体。
func computeSpecificity(pattern string) (spec int, hasWildcard bool) {
literal := 0
for _, r := range pattern {
if r != '*' {
literal++
} else {
hasWildcard = true
}
}
if !hasWildcard {
return 1 << 20, false // 精确匹配最高优先
}
return literal, true
}
// Allow 声明一条 ACL 规则:匹配 pattern 的方法允许 minRole 及以上等级的角色调用。
// 同一 pattern 重复声明时覆盖原规则。供插件声明自定义权限。
func Allow(pattern, minRole string) {
spec, hasWildcard := computeSpecificity(pattern)
rule := aclRule{pattern: pattern, minRole: minRole, specificity: spec, hasWildcard: hasWildcard}
muACL.Lock()
defer muACL.Unlock()
for i := range aclList {
if aclList[i].pattern == pattern {
aclList[i] = rule
return
}
}
aclList = append(aclList, rule)
}
// RegisterNamespace 便捷封装:为整个命名空间声明所需最低角色(等价于 Allow("ns:*", role))。
func RegisterNamespace(namespace, requiredRole string) {
Allow(namespace+":*", requiredRole)
}
// wildcardMatch 判断 s 是否匹配只含 "*" 通配符的 pattern。"*" 匹配任意(含空)字符序列。
func wildcardMatch(pattern, s string) bool {
// 双指针 + 回溯,O(len(pattern)+len(s))。
var (
p, str = 0, 0
star = -1
strBackup = 0
lenP, lenStr = len(pattern), len(s)
)
for str < lenStr {
if p < lenP && (pattern[p] == s[str]) {
p++
str++
} else if p < lenP && pattern[p] == '*' {
star = p
strBackup = str
p++
} else if star != -1 {
p = star + 1
strBackup++
str = strBackup
} else {
return false
}
}
for p < lenP && pattern[p] == '*' {
p++
}
return p == lenP
}
// resolveMinRole 返回 method 适用规则中特异性最高者的所需角色。无匹配规则时默认 admin。
func resolveMinRole(method string) string {
// 归一化:无命名空间分隔符(既无 ":" 也无 "rpc." 内部前缀)的裸方法名归入默认命名空间,
// 以便被 "common:*" 等规则匹配,保持与历史行为一致。
if !strings.ContainsAny(method, ":") && !strings.HasPrefix(method, "rpc.") {
method = DefaultNamespace + ":" + method
}
muACL.RLock()
defer muACL.RUnlock()
bestSpec := -1
bestRole := RoleAdmin
matched := false
for i := range aclList {
r := &aclList[i]
if !wildcardMatch(r.pattern, method) {
continue
}
// 特异性更高者胜出;相同特异性时取更严格(等级更高)的角色,偏向安全。
if r.specificity > bestSpec || (r.specificity == bestSpec && levelOf(r.minRole) > levelOf(bestRole)) {
bestSpec = r.specificity
bestRole = r.minRole
matched = true
}
}
if !matched {
return RoleAdmin
}
return bestRole
}
// RequiredRole 返回调用 method 所需的最低角色。
func RequiredRole(method string) string {
return resolveMinRole(method)
}
// CheckPermission 判定 group 角色是否有权调用 method。
func CheckPermission(group, method string) bool {
return levelOf(group) >= levelOf(resolveMinRole(method))
}
+128
View File
@@ -0,0 +1,128 @@
package rpc
import (
"context"
"testing"
)
func TestNamespaceOf(t *testing.T) {
cases := map[string]string{
"getNodes": DefaultNamespace,
"admin:addClient": "admin",
"client:report": "client",
"rpc.ping": DefaultNamespace, // 无 ":",归入默认命名空间
":bare": "",
}
for method, want := range cases {
if got := NamespaceOf(method); got != want {
t.Errorf("NamespaceOf(%q) = %q, want %q", method, got, want)
}
}
}
func TestCheckPermission(t *testing.T) {
cases := []struct {
group string
method string
want bool
}{
{RoleGuest, "common:getNodes", true},
{RoleGuest, "getNodes", true},
{RoleGuest, "admin:addClient", false},
{RoleGuest, "client:report", false},
{RoleClient, "client:report", true},
{RoleClient, "admin:addClient", false},
{RoleAdmin, "admin:addClient", true},
{RoleAdmin, "client:report", true},
{RoleAdmin, "common:getNodes", true},
// 未知命名空间默认要求 admin
{RoleGuest, "plugin:foo", false},
{RoleAdmin, "plugin:foo", true},
}
for _, c := range cases {
if got := CheckPermission(c.group, c.method); got != c.want {
t.Errorf("CheckPermission(%q, %q) = %v, want %v", c.group, c.method, got, c.want)
}
}
}
func TestRegisterNamespace(t *testing.T) {
RegisterNamespace("myplugin", RoleClient)
if !CheckPermission(RoleClient, "myplugin:doThing") {
t.Error("client should be allowed in myplugin namespace after registration")
}
if CheckPermission(RoleGuest, "myplugin:doThing") {
t.Error("guest should not be allowed in myplugin namespace")
}
}
func TestAllowWildcardSpecificity(t *testing.T) {
// 命名空间默认 admin,但更具体的方法级规则可放宽。
RegisterNamespace("acltest", RoleAdmin)
Allow("acltest:public*", RoleGuest) // 前缀通配,比 "acltest:*" 更具体
if !CheckPermission(RoleGuest, "acltest:publicInfo") {
t.Error("guest should be allowed: acltest:public* is more specific than acltest:*")
}
if CheckPermission(RoleGuest, "acltest:secret") {
t.Error("guest should be denied acltest:secret (falls back to acltest:* = admin)")
}
// 精确规则优先于任何通配。
Allow("acltest:secret", RoleClient)
if !CheckPermission(RoleClient, "acltest:secret") {
t.Error("client should be allowed by exact rule acltest:secret")
}
if CheckPermission(RoleGuest, "acltest:secret") {
t.Error("guest still denied acltest:secret (exact rule requires client)")
}
}
func TestAllowOverride(t *testing.T) {
Allow("override:x", RoleAdmin)
if CheckPermission(RoleGuest, "override:x") {
t.Fatal("precondition: guest denied")
}
Allow("override:x", RoleGuest) // 覆盖同一 pattern
if !CheckPermission(RoleGuest, "override:x") {
t.Error("override should relax override:x to guest")
}
}
func TestInternalMethodsPermission(t *testing.T) {
// 内部 rpc.* 方法对 guest 开放。
if !CheckPermission(RoleGuest, "rpc.ping") {
t.Error("guest should be allowed to call rpc.ping")
}
// 裸方法名归入 common,对 guest 开放。
if !CheckPermission(RoleGuest, "getNodes") {
t.Error("guest should be allowed to call bare common method getNodes")
}
}
func TestUnregister(t *testing.T) {
method := "unreg.sample"
MustRegister(method, func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
return "ok", nil
})
if getHandler(method) == nil {
t.Fatal("method should be registered")
}
if !Unregister(method) {
t.Fatal("Unregister should return true for existing method")
}
if getHandler(method) != nil {
t.Fatal("method should be removed after Unregister")
}
// 重复注销返回 false
if Unregister(method) {
t.Fatal("Unregister should return false for missing method")
}
// 保留前缀禁止注销
if Unregister("rpc.ping") {
t.Fatal("Unregister must refuse rpc.* reserved methods")
}
if getHandler("rpc.ping") == nil {
t.Fatal("rpc.ping must still be registered")
}
}
+22
View File
@@ -41,6 +41,28 @@ func MustRegister(method string, h Handler) {
}
}
// Unregister 注销已注册的方法,并清理其元数据。
// 保留前缀 "rpc." 的内部方法禁止注销。返回是否存在并被移除。
// 主要供插件卸载时动态移除其注册的方法。
func Unregister(method string) bool {
method = strings.TrimSpace(method)
if method == "" || strings.HasPrefix(method, "rpc.") {
return false
}
muHandlers.Lock()
_, exists := handlers[method]
if exists {
delete(handlers, method)
}
muHandlers.Unlock()
if exists {
muMetas.Lock()
delete(methodMetas, method)
muMetas.Unlock()
}
return exists
}
// ListMethods 列出当前已注册的方法名(副本)
func ListMethods() []string {
muHandlers.RLock()
-137
View File
@@ -1,137 +0,0 @@
package admin
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/records"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
func AddClient(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
uuid, token, err := clients.CreateClient()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token})
return
}
uuid, token, err := clients.CreateClientWithName(req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
}
func EditClient(c *gin.Context) {
var req = make(map[string]interface{})
uuid := c.Param("uuid")
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": err.Error()})
return
}
if uuid == "" {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Invalid or missing UUID"})
return
}
req["uuid"] = uuid
err := clients.SaveClient(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
func RemoveClient(c *gin.Context) {
uuid := c.Param("uuid")
err := clients.DeleteClient(uuid)
if err != nil {
c.JSON(500, gin.H{
"status": "error",
"error": "Failed to delete client" + err.Error(),
})
return
}
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
c.JSON(200, gin.H{"status": "success"})
agent_runtime.DeleteConnectedClients(uuid)
agent_runtime.DeleteLatestReport(uuid)
}
func ClearRecord(c *gin.Context) {
if err := records.DeleteAll(); err != nil {
c.JSON(500, gin.H{
"status": "error",
"message": "Failed to delete Record" + err.Error(),
})
return
}
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
c.JSON(200, gin.H{"status": "success"})
}
func GetClient(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
c.JSON(400, gin.H{
"status": "error",
"message": "Invalid or missing UUID",
})
return
}
result, err := clients.GetClientByUUID(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": "error",
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
}
func ListClients(c *gin.Context) {
cls, err := clients.GetAllClientBasicInfo()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
c.JSON(http.StatusOK, cls)
}
func GetClientToken(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
c.JSON(400, gin.H{
"status": "error",
"message": "Invalid or missing UUID",
})
return
}
token, err := clients.GetClientTokenByUUID(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "success", "token": token, "message:": ""})
}
-28
View File
@@ -1,28 +0,0 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/web/api"
)
func OrderWeight(c *gin.Context) {
var req = make(map[string]int)
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
db := dbcore.GetDBInstance()
for uuid, weight := range req {
err := db.Model(&models.Client{}).Where("uuid = ?", uuid).Update("weight", weight).Error
if err != nil {
api.RespondError(c, 500, "Failed to update client weight: "+err.Error())
return
}
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "order clients", "info")
api.RespondSuccess(c, nil)
}
-115
View File
@@ -1,115 +0,0 @@
package clipboard
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/auditlog"
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/web/api"
)
// GetClipboard retrieves a clipboard entry by ID
func GetClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
cb, err := clipboardDB.GetClipboardByID(id)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to get clipboard: "+err.Error())
return
}
api.RespondSuccess(c, cb)
}
// ListClipboard lists all clipboard entries
func ListClipboard(c *gin.Context) {
list, err := clipboardDB.ListClipboard()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to list clipboard: "+err.Error())
return
}
api.RespondSuccess(c, list)
}
// CreateClipboard creates a new clipboard entry
func CreateClipboard(c *gin.Context) {
var req models.Clipboard
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := clipboardDB.CreateClipboard(&req); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to create clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
api.RespondSuccess(c, req)
}
// UpdateClipboard updates an existing clipboard entry
func UpdateClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
var fields map[string]interface{}
if err := c.ShouldBindJSON(&fields); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := clipboardDB.UpdateClipboardFields(id, fields); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to update clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
api.RespondSuccess(c, nil)
}
// DeleteClipboard deletes a clipboard entry
func DeleteClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
if err := clipboardDB.DeleteClipboard(id); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
api.RespondSuccess(c, nil)
}
// BatchDeleteClipboard deletes multiple clipboard entries
func BatchDeleteClipboard(c *gin.Context) {
var req struct {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if len(req.IDs) == 0 {
api.RespondError(c, http.StatusBadRequest, "IDs cannot be empty")
return
}
if err := clipboardDB.DeleteClipboardBatch(req.IDs); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to batch delete clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
api.RespondSuccess(c, nil)
}
-104
View File
@@ -1,104 +0,0 @@
package admin
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/web/api"
)
const cloudflaredStopConfirmText = "STOP CLOUDFLARED"
func GetCloudflaredStatus(c *gin.Context) {
api.RespondSuccess(c, cloudflared.Status())
}
func StartCloudflared(c *gin.Context) {
var req struct {
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&req); err != nil && err.Error() != "EOF" {
api.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
token := strings.TrimSpace(req.Token)
if token != "" {
if err := cloudflared.SaveToken(token); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to save Cloudflare Tunnel token: "+err.Error())
return
}
}
if err := cloudflared.Start(token); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if uuid, ok := c.Get("uuid"); ok {
auditlog.Log(c.ClientIP(), uuid.(string), "started cloudflared tunnel", "warn")
}
api.RespondSuccess(c, cloudflared.Status())
}
func StopCloudflared(c *gin.Context) {
var req struct {
CurrentPassword string `json:"current_password"`
ConfirmText string `json:"confirm_text"`
}
if err := c.ShouldBindJSON(&req); err != nil && err.Error() != "EOF" {
api.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
disablePasswordLogin, _ := config.GetAs[bool](config.DisablePasswordLoginKey, false)
if !disablePasswordLogin {
rawUUID, exists := c.Get("uuid")
if !exists {
api.RespondError(c, http.StatusUnauthorized, "Unauthorized.")
return
}
user, err := accounts.GetUserByUUID(rawUUID.(string))
if err != nil {
api.RespondError(c, http.StatusUnauthorized, "Failed to verify current user")
return
}
if strings.TrimSpace(req.CurrentPassword) == "" {
api.RespondError(c, http.StatusBadRequest, "Current password is required")
return
}
if _, ok := accounts.CheckPassword(user.Username, req.CurrentPassword); !ok {
api.RespondError(c, http.StatusUnauthorized, "Current password is incorrect")
return
}
} else if strings.TrimSpace(req.ConfirmText) != cloudflaredStopConfirmText {
api.RespondError(c, http.StatusBadRequest, "Type STOP CLOUDFLARED to confirm stopping cloudflared")
return
}
if err := cloudflared.Stop(); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to stop cloudflared: "+err.Error())
return
}
if uuid, ok := c.Get("uuid"); ok {
auditlog.Log(c.ClientIP(), uuid.(string), "stopped cloudflared tunnel", "warn")
}
api.RespondSuccess(c, cloudflared.Status())
}
func RemoveCloudflaredToken(c *gin.Context) {
if err := cloudflared.RemoveToken(); err != nil {
api.RespondError(c, http.StatusBadRequest, "Failed to remove Cloudflare Tunnel token: "+err.Error())
return
}
if uuid, ok := c.Get("uuid"); ok {
auditlog.Log(c.ClientIP(), uuid.(string), "removed cloudflared tunnel token", "warn")
}
api.RespondSuccess(c, cloudflared.Status())
}
-112
View File
@@ -1,112 +0,0 @@
package admin
import (
"encoding/json"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
v2 "github.com/komari-monitor/komari/protocol/v2"
"github.com/komari-monitor/komari/utils"
agent_runtime "github.com/komari-monitor/komari/web/agent"
"github.com/komari-monitor/komari/web/api"
)
// 接受数据类型:
// - command: string
// - clients: []string (客户端 UUID 列表)
func Exec(c *gin.Context) {
var req struct {
Command string `json:"command" binding:"required"`
Clients []string `json:"clients" binding:"required"`
}
var onlineClients []string
var queuedClients []string
var offlineClients []string
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
if strings.TrimSpace(req.Command) == "" {
api.RespondError(c, 400, "Command cannot be empty")
return
}
// for uuid := range agent_runtime.GetConnectedClients() {
// if contain(req.Clients, uuid) {
// onlineClients = append(onlineClients, uuid)
// }
// // else {
// // api.RespondError(c, 400, "Client not connected: "+uuid)
// // return
// // }
// }
for _, uuid := range req.Clients {
if client := agent_runtime.GetConnectedClients()[uuid]; client != nil {
onlineClients = append(onlineClients, uuid)
} else if agent_runtime.IsAgentOnline(uuid) {
queuedClients = append(queuedClients, uuid)
} else {
offlineClients = append(offlineClients, uuid)
}
}
if len(onlineClients) == 0 && len(queuedClients) == 0 {
api.RespondError(c, 400, "No clients connected")
return
}
taskId := utils.GenerateRandomString(16)
taskClients := append(append([]string{}, onlineClients...), queuedClients...)
taskClients = append(taskClients, offlineClients...)
if err := tasks.CreateTask(taskId, taskClients, req.Command); err != nil {
api.RespondError(c, 500, "Failed to create task: "+err.Error())
return
}
for _, uuid := range onlineClients {
legacy := struct {
Message string `json:"message"`
Command string `json:"command"`
TaskId string `json:"task_id"`
}{Message: "exec", Command: req.Command, TaskId: taskId}
payload, _ := json.Marshal(legacy)
if agent_runtime.IsV2Client(uuid) {
payload, _ = json.Marshal(v2.Request{JSONRPC: v2.Version, Method: v2.MethodAgentExec, Params: v2.ExecParams{TaskID: taskId, Command: req.Command}})
}
client := agent_runtime.GetConnectedClients()[uuid]
if client != nil {
if err := client.WriteMessage(websocket.TextMessage, payload); err != nil {
api.RespondError(c, 400, "Client connection is broke: "+uuid)
return
}
} else {
api.RespondError(c, 400, "Client connection is null: "+uuid)
return
}
}
for _, uuid := range queuedClients {
agent_runtime.DispatchV2Event(uuid, v2.MethodAgentExec, v2.ExecParams{TaskID: taskId, Command: req.Command})
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
api.RespondSuccess(c, gin.H{
"task_id": taskId,
"clients": onlineClients,
"queued_clients": queuedClients,
})
if len(offlineClients) > 0 {
for _, uuid := range offlineClients {
tasks.SaveTaskResult(taskId, uuid, "Client offline!", -1, models.FromTime(time.Now()))
}
}
}
// func contain(clients []string, uuid string) bool {
// for _, client := range clients {
// if client == uuid {
// return true
// }
// }
// return false
// }
-49
View File
@@ -1,49 +0,0 @@
package log
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/web/api"
)
func GetLogs(c *gin.Context) {
limit := c.Query("limit")
if limit == "" {
limit = "100" // Default to 100 logs if not specified
}
page := c.Query("page")
if page == "" {
page = "1" // Default to page 1 if not specified
}
// Convert limit and page to integers
// If conversion fails, return an error
limitInt, err := strconv.Atoi(limit)
if err != nil || limitInt <= 0 {
api.RespondError(c, 400, "Invalid limit: "+limit)
return
}
pageInt, err := strconv.Atoi(page)
if err != nil || pageInt <= 0 {
api.RespondError(c, 400, "Invalid page: "+page)
return
}
db := dbcore.GetDBInstance()
var logs []models.Log
// 添加分页:计算偏移量并限制数量
offset := (pageInt - 1) * limitInt
var total int64
if err := db.Model(&models.Log{}).Count(&total).Error; err != nil {
api.RespondError(c, 500, "Failed to count logs: "+err.Error())
return
}
if err := db.Order("time desc").Limit(limitInt).Offset(offset).Find(&logs).Error; err != nil {
api.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{"logs": logs, "total": total})
}
-64
View File
@@ -1,64 +0,0 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/factory"
"github.com/komari-monitor/komari/web/api"
)
func GetMessageSenderProvider(c *gin.Context) {
provider := c.Query("provider")
if provider != "" {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetMessageSenderConfigByName(provider)
if err != nil {
api.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
api.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置项模板
providers := factory.GetSenderConfigs()
if len(providers) == 0 {
api.RespondError(c, 404, "No message sender providers found")
return
}
api.RespondSuccess(c, providers)
}
func SetMessageSenderProvider(c *gin.Context) {
var senderConfig models.MessageSenderProvider
if err := c.ShouldBindJSON(&senderConfig); err != nil {
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if senderConfig.Name == "" {
api.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(senderConfig.Name)
if !exists {
api.RespondError(c, 404, "Provider not found: "+senderConfig.Name)
return
}
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
api.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
return
}
NotificationMethod, _ := config.GetAs[string](config.NotificationMethodKey, "none")
// 正在使用,重载
if NotificationMethod == senderConfig.Name {
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
if err != nil {
api.RespondError(c, 500, "Failed to load message sender provider: "+err.Error())
return
}
}
api.RespondSuccess(c, gin.H{"message": "Message sender provider set successfully"})
}
-90
View File
@@ -1,90 +0,0 @@
package notification
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/web/api"
)
// POST body: clients []string, name string, metric string, threshold float32, ratio float32, interval int
func AddLoadNotification(c *gin.Context) {
var req struct {
Clients []string `json:"clients" binding:"required"`
Name string `json:"name"`
Metric string `json:"metric" binding:"required"`
Threshold float32 `json:"threshold" binding:"required"` // 阈值百分比
Ratio float32 `json:"ratio" binding:"required"` // 达标时间比
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if req.Interval > 4*60 || req.Interval <= 0 {
api.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
return
}
if req.Ratio <= 0 || req.Ratio > 1 {
api.RespondError(c, http.StatusBadRequest, "Ratio must be between 0 and 1")
return
}
if taskID, err := notification.AddLoadNotification(req.Clients, req.Name, req.Metric, req.Threshold, req.Ratio, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
// POST body: id []uint
func DeleteLoadNotification(c *gin.Context) {
var req struct {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := notification.DeleteLoadNotification(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
}
}
// POST body: id []uint, updates map[string]interface{}
func EditLoadNotification(c *gin.Context) {
var req struct {
Notifications []*models.LoadNotification `json:"notifications" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
if err := notification.EditLoadNotification(req.Notifications); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
// for _, notification := range req.Notifications {
// notification.DeleteLoadNotification([]uint{notification.Id})
// }
api.RespondSuccess(c, nil)
}
}
func GetAllLoadNotifications(c *gin.Context) {
notifications, err := notification.GetAllLoadNotifications()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, notifications)
}
-109
View File
@@ -1,109 +0,0 @@
package notification
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/web/api"
"gorm.io/gorm/clause"
)
// POST body : []uuid
func EnableOfflineNotification(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
var notifications []models.OfflineNotification
for _, uuid := range uuids {
notifications = append(notifications, models.OfflineNotification{
Client: uuid,
Enable: true,
})
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
// POST body : []uuid
func DisableOfflineNotification(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
var notifications []models.OfflineNotification
for _, uuid := range uuids {
notifications = append(notifications, models.OfflineNotification{
Client: uuid,
Enable: false,
})
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func EditOfflineNotification(c *gin.Context) {
var notifications []models.OfflineNotification
if err := c.ShouldBindJSON(&notifications); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
if len(notifications) == 0 {
api.RespondError(c, 400, "At least one notification is required")
return
}
for _, noti := range notifications {
if noti.Client == "" {
api.RespondError(c, 400, "Client UUID cannot be empty")
return
}
if noti.GracePeriod <= 0 {
api.RespondError(c, 400, "GracePeriod must be a positive integer")
return
}
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable", "grace_period"}),
}).
Select("*").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func ListOfflineNotifications(c *gin.Context) {
var notifications []models.OfflineNotification
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(&notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, notifications)
}
@@ -1,71 +0,0 @@
package notification
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/web/api"
)
// ListTrafficReportNotifications GET /api/admin/notification/traffic-report
func ListTrafficReportNotifications(c *gin.Context) {
notifications, err := notification.ListTrafficReportNotifications()
if err != nil {
api.RespondError(c, 500, "Failed to list traffic report notifications: "+err.Error())
return
}
api.RespondSuccess(c, notifications)
}
// EditTrafficReportNotifications POST /api/admin/notification/traffic-report/edit
// Body: []TrafficReportNotification
func EditTrafficReportNotifications(c *gin.Context) {
var notifications []models.TrafficReportNotification
if err := c.ShouldBindJSON(&notifications); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
if len(notifications) == 0 {
api.RespondError(c, 400, "At least one notification is required")
return
}
if err := notification.ValidateTrafficReportNotifications(notifications); err != nil {
api.RespondError(c, 400, err.Error())
return
}
if err := notification.EditTrafficReportNotifications(notifications); err != nil {
api.RespondError(c, 500, "Failed to edit traffic report notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
// EnableTrafficReportNotifications POST /api/admin/notification/traffic-report/enable
// Body: []string (uuid list)
func EnableTrafficReportNotifications(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
if err := notification.EnableTrafficReportNotifications(uuids); err != nil {
api.RespondError(c, 500, "Failed to enable traffic report notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
// DisableTrafficReportNotifications POST /api/admin/notification/traffic-report/disable
// Body: []string (uuid list)
func DisableTrafficReportNotifications(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
if err := notification.DisableTrafficReportNotifications(uuids); err != nil {
api.RespondError(c, 500, "Failed to disable traffic report notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
+6 -63
View File
@@ -2,15 +2,14 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/oauth"
"github.com/komari-monitor/komari/web/oauth/factory"
)
// oauth.go
// 外部账号绑定/解绑:绑定走 302 重定向,保留为 REST handler(不走 RPC 桥)。
// OIDC provider 配置的 get/set 已迁移为 RPC2 方法(见 web/rpc/jsonrpc/admin.provider.go)。
func BindingExternalAccount(c *gin.Context) {
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
@@ -18,10 +17,10 @@ func BindingExternalAccount(c *gin.Context) {
api.RespondError(c, 500, "No user found: "+err.Error())
return
}
c.SetCookie("binding_external_account", user.UUID, 3600, "/", "", false, true)
c.Redirect(302, "/api/oauth")
}
func UnbindExternalAccount(c *gin.Context) {
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
@@ -29,65 +28,9 @@ func UnbindExternalAccount(c *gin.Context) {
api.RespondError(c, 500, "No user found: "+err.Error())
return
}
err = accounts.UnbindExternalAccount(user.UUID)
if err != nil {
if err := accounts.UnbindExternalAccount(user.UUID); err != nil {
api.RespondError(c, 500, "Failed to unbind external account: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func GetOidcProvider(c *gin.Context) {
provider := c.Query("provider")
if provider != "" {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetOidcConfigByName(provider)
if err != nil {
api.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
api.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置
providers := factory.GetProviderConfigs()
if len(providers) == 0 {
api.RespondError(c, 404, "No OIDC providers found")
return
}
api.RespondSuccess(c, providers)
}
func SetOidcProvider(c *gin.Context) {
var oidcConfig models.OidcProvider
if err := c.ShouldBindJSON(&oidcConfig); err != nil {
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if oidcConfig.Name == "" {
api.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(oidcConfig.Name)
if !exists {
api.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
return
}
if err := database.SaveOidcConfig(&oidcConfig); err != nil {
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
return
}
OAuthProvider, _ := config.GetAs[string](config.OAuthProviderKey, "github")
// 正在使用,重载
if OAuthProvider == oidcConfig.Name {
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
if err != nil {
api.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
return
}
}
api.RespondSuccess(c, gin.H{"message": "OIDC provider set successfully"})
}
-120
View File
@@ -1,120 +0,0 @@
package admin
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/web/api"
)
// AddPingTask 处理新增延迟监测任务请求。
// POST body: clients []string, default_on bool, target, task_type string, interval int
func AddPingTask(c *gin.Context) {
var req struct {
Clients []string `json:"clients"`
DefaultOn bool `json:"default_on"`
Name string `json:"name" binding:"required"`
Target string `json:"target" binding:"required"`
TaskType string `json:"type" binding:"required"` // icmp, tcp, http
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if !req.DefaultOn && len(req.Clients) == 0 {
api.RespondError(c, http.StatusBadRequest, "clients is required when default_on is false")
return
}
if taskID, err := tasks.AddPingTask(req.Clients, req.DefaultOn, req.Name, req.Target, req.TaskType, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
// POST body: id []uint
func DeletePingTask(c *gin.Context) {
var req struct {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := tasks.DeletePingTask(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
}
}
// EditPingTask 处理延迟监测任务编辑请求。
// POST body: id []uint, updates map[string]interface{}
func EditPingTask(c *gin.Context) {
var req struct {
Tasks []*models.PingTask `json:"tasks" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
for _, task := range req.Tasks {
// 编辑时只拦截空任务对象,允许把任务临时保存为未绑定服务器状态。
if task == nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
}
if err := tasks.EditPingTask(req.Tasks); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
// for _, task := range req.Tasks {
// tasks.DeletePingRecords([]uint{task.Id})
// }
api.RespondSuccess(c, nil)
}
}
func GetAllPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
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)
}
-50
View File
@@ -1,50 +0,0 @@
package admin
import (
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/web/api"
"github.com/gin-gonic/gin"
)
func GetSessions(c *gin.Context) {
ss, err := accounts.GetAllSessions()
if err != nil {
api.RespondError(c, 500, "Failed to retrieve sessions: "+err.Error())
return
}
current, _ := c.Cookie("session_token")
c.JSON(200, gin.H{"status": "success", "current": current, "data": ss})
}
func DeleteSession(c *gin.Context) {
var req struct {
Session string `json:"session" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid request: "+err.Error())
return
}
err := accounts.DeleteSession(req.Session)
if err != nil {
api.RespondError(c, 500, "Failed to delete session: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "delete session", "info")
api.RespondSuccess(c, nil)
}
func DeleteAllSession(c *gin.Context) {
err := accounts.DeleteAllSessions()
if err != nil {
api.RespondError(c, 500, "Failed to delete all sessions: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
api.RespondSuccess(c, nil)
}
-54
View File
@@ -1,54 +0,0 @@
package admin
import (
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/web/api"
"github.com/gin-gonic/gin"
)
// GetSettings 获取自定义配置
func GetSettings(c *gin.Context) {
cst, err := config.GetAll()
if err != nil {
api.RespondError(c, 500, "Failed to get settings: "+err.Error())
return
}
api.RespondSuccess(c, cst)
}
// EditSettings 更新自定义配置
func EditSettings(c *gin.Context) {
cfg := make(map[string]interface{})
if err := c.ShouldBindJSON(&cfg); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
if err := config.SetMany(cfg); err != nil {
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
message := "update settings: "
for key := range cfg {
message += key + ", "
}
if len(message) > 2 {
message = message[:len(message)-2]
}
auditlog.Log(c.ClientIP(), uuid.(string), message, "info")
api.RespondSuccess(c, nil)
}
func ClearAllRecords(c *gin.Context) {
records.DeleteAll()
tasks.DeleteAllPingRecords()
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
api.RespondSuccess(c, nil)
}
-375
View File
@@ -1,375 +0,0 @@
// Do not use t.Parallel(): config.SetDb mutates package-global state.
package admin
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var xtermJSAuditDBOnce sync.Once
type xtermJSAPIResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func ensureXtermJSAuditDB() {
xtermJSAuditDBOnce.Do(func() {
flags.DatabaseType = "sqlite"
flags.DatabaseFile = "file:xtermjs_audit?mode=memory&cache=shared"
_ = dbcore.GetDBInstance()
})
}
func setupXtermJSTestDB(t *testing.T) (*gorm.DB, *sql.DB) {
t.Helper()
gin.SetMode(gin.TestMode)
testDBName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
testDSN := fmt.Sprintf("file:%s?mode=memory&cache=shared", testDBName)
testDB, err := gorm.Open(sqlite.Open(testDSN), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := testDB.DB()
require.NoError(t, err)
// Initialize the shared audit DB first, then restore the per-test config DB.
ensureXtermJSAuditDB()
config.SetDb(testDB)
t.Cleanup(func() {
_ = sqlDB.Close()
})
return testDB, sqlDB
}
func newXtermJSRouter() *gin.Engine {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("uuid", "test-uuid")
c.Next()
})
router.GET("/xtermjs", GetXtermJSSettings)
router.POST("/xtermjs", SetXtermJSSettings)
return router
}
func performXtermJSRequest(t *testing.T, router *gin.Engine, method, target string, body []byte) *httptest.ResponseRecorder {
t.Helper()
req, err := http.NewRequest(method, target, bytes.NewReader(body))
require.NoError(t, err)
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func decodeXtermJSResponse(t *testing.T, w *httptest.ResponseRecorder) xtermJSAPIResponse {
t.Helper()
var resp xtermJSAPIResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
return resp
}
func decodeXtermJSSettings(t *testing.T, raw json.RawMessage) XtermJSSettings {
t.Helper()
var settings XtermJSSettings
require.NoError(t, json.Unmarshal(raw, &settings))
return settings
}
func ptr[T any](v T) *T {
return &v
}
func TestGetXtermJSSettingsReturnsDefaultWhenMissing(t *testing.T) {
testDB, _ := setupXtermJSTestDB(t)
router := newXtermJSRouter()
w := performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)
require.Equal(t, http.StatusOK, w.Code)
resp := decodeXtermJSResponse(t, w)
assert.Equal(t, "success", resp.Status)
assert.Empty(t, resp.Message)
got := decodeXtermJSSettings(t, resp.Data)
expected := defaultXtermJSSettings()
assert.Equal(t, expected, got)
var stored config.ConfigItem
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
var persisted XtermJSSettings
require.NoError(t, json.Unmarshal([]byte(stored.Value), &persisted))
assert.Equal(t, expected, persisted)
}
func TestXtermJSSettingsRoundTripAndThemeVariants(t *testing.T) {
t.Run("full config round trip", func(t *testing.T) {
setupXtermJSTestDB(t)
router := newXtermJSRouter()
input := XtermJSSettings{
TerminalOptions: &TerminalOptions{
CursorBlink: ptr(false),
ConvertEol: ptr(false),
FontFamily: "JetBrains Mono",
FontSize: 18,
MacOptionIsMeta: ptr(false),
Scrollback: ptr(9000),
Theme: &ThemeConfig{
Foreground: "#f8f8f2",
Background: "#282a36",
Cursor: "#f8f8f2",
SelectionBackground: "#44475a",
Blue: "#6272a4",
ExtendedAnsi: []string{"#111111", "#222222"},
},
},
TerminalPadding: ptr(20),
TransparentBackground: true,
CustomCss: "body { color: red; }",
}
body, err := json.Marshal(input)
require.NoError(t, err)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusOK, w.Code)
resp := decodeXtermJSResponse(t, w)
assert.Equal(t, "success", resp.Status)
assert.Equal(t, "settings saved", resp.Message)
getResp := decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil))
got := decodeXtermJSSettings(t, getResp.Data)
assert.Equal(t, input, got)
})
t.Run("theme null stays null", func(t *testing.T) {
setupXtermJSTestDB(t)
router := newXtermJSRouter()
body := []byte(`{"terminalOptions":{"cursorBlink":true,"convertEol":true,"fontFamily":"JetBrains Mono","fontSize":17,"macOptionIsMeta":true,"scrollback":1000,"theme":null},"terminalPadding":16,"transparentBackground":false,"customCss":"."}`)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusOK, w.Code)
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)).Data)
require.NotNil(t, got.TerminalOptions)
assert.Nil(t, got.TerminalOptions.Theme)
})
t.Run("terminalOptions null uses defaults", func(t *testing.T) {
setupXtermJSTestDB(t)
router := newXtermJSRouter()
body := []byte(`{"terminalOptions":null,"terminalPadding":24,"transparentBackground":true,"customCss":"body { background: black; }"}`)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusOK, w.Code)
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)).Data)
expected := defaultXtermJSSettings()
expected.TerminalPadding = ptr(24)
expected.TransparentBackground = true
expected.CustomCss = "body { background: black; }"
assert.Equal(t, expected, got)
})
}
func TestXtermJSSettingsUnknownThemeFieldsAreDropped(t *testing.T) {
testDB, _ := setupXtermJSTestDB(t)
router := newXtermJSRouter()
body := []byte(`{"terminalOptions":{"fontFamily":"JetBrains Mono","fontSize":16,"theme":{"foreground":"#ffffff","unknownField":"keep-out","extendedAnsi":["#111111"]}}}`)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusOK, w.Code)
resp := decodeXtermJSResponse(t, w)
assert.Equal(t, "success", resp.Status)
getResp := decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil))
got := decodeXtermJSSettings(t, getResp.Data)
require.NotNil(t, got.TerminalOptions)
require.NotNil(t, got.TerminalOptions.Theme)
assert.Equal(t, "#ffffff", got.TerminalOptions.Theme.Foreground)
var stored config.ConfigItem
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
assert.NotContains(t, stored.Value, "unknownField")
}
func TestXtermJSSettingsValidationAndFallbacks(t *testing.T) {
t.Run("extendedAnsi and body validation", func(t *testing.T) {
cases := []struct {
name string
body []byte
}{
{
name: "empty string",
body: []byte(`{"terminalOptions":{"theme":{"extendedAnsi":[""]}}}`),
},
{
name: "space string",
body: []byte(`{"terminalOptions":{"theme":{"extendedAnsi":[" "]}}}`),
},
{
name: "illegal type",
body: []byte(`{"terminalOptions":{"theme":{"extendedAnsi":[1]}}}`),
},
{
name: "invalid json",
body: []byte(`{"terminalOptions":`),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
setupXtermJSTestDB(t)
router := newXtermJSRouter()
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", tc.body)
require.Equal(t, http.StatusBadRequest, w.Code)
resp := decodeXtermJSResponse(t, w)
assert.Equal(t, "error", resp.Status)
assert.NotEmpty(t, resp.Message)
})
}
})
t.Run("fallbacks are normalized on save and read", func(t *testing.T) {
testDB, _ := setupXtermJSTestDB(t)
router := newXtermJSRouter()
neg := -1
body, err := json.Marshal(XtermJSSettings{
TerminalOptions: &TerminalOptions{
CursorBlink: ptr(true),
ConvertEol: ptr(true),
FontFamily: " ",
FontSize: 0,
MacOptionIsMeta: ptr(true),
Scrollback: &neg,
Theme: &ThemeConfig{
Foreground: " ",
},
},
TerminalPadding: &neg,
TransparentBackground: true,
CustomCss: "body { color: blue; }",
})
require.NoError(t, err)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusOK, w.Code)
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)).Data)
expected := defaultXtermJSSettings()
expected.TransparentBackground = true
expected.CustomCss = "body { color: blue; }"
assert.Equal(t, expected, got)
var stored config.ConfigItem
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
var persisted XtermJSSettings
require.NoError(t, json.Unmarshal([]byte(stored.Value), &persisted))
assert.Equal(t, expected, persisted)
})
t.Run("corrupt JSON is repaired on get", func(t *testing.T) {
testDB, _ := setupXtermJSTestDB(t)
router := newXtermJSRouter()
raw := `{"terminalOptions":`
require.NoError(t, testDB.Create(&config.ConfigItem{
Key: config.XtermjsSettingsKey,
Value: raw,
}).Error)
w := performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)
require.Equal(t, http.StatusOK, w.Code)
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, w).Data)
expected := defaultXtermJSSettings()
assert.Equal(t, expected, got)
var stored config.ConfigItem
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
var persisted XtermJSSettings
require.NoError(t, json.Unmarshal([]byte(stored.Value), &persisted))
assert.Equal(t, expected, persisted)
})
}
func TestXtermJSSettingsNormalizesSeededLegacyConfig(t *testing.T) {
testDB, _ := setupXtermJSTestDB(t)
router := newXtermJSRouter()
seeded := `{"terminalOptions":{"cursorBlink":true,"convertEol":true,"fontFamily":"JetBrains Mono","fontSize":0,"macOptionIsMeta":true,"scrollback":1200,"theme":null},"terminalPadding":12,"transparentBackground":true,"customCss":"seeded"}`
require.NoError(t, testDB.Create(&config.ConfigItem{
Key: config.XtermjsSettingsKey,
Value: seeded,
}).Error)
w := performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)
require.Equal(t, http.StatusOK, w.Code)
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, w).Data)
expected := XtermJSSettings{
TerminalOptions: &TerminalOptions{
CursorBlink: ptr(true),
ConvertEol: ptr(true),
FontFamily: "JetBrains Mono",
FontSize: 16,
MacOptionIsMeta: ptr(true),
Scrollback: ptr(1200),
Theme: nil,
},
TerminalPadding: ptr(12),
TransparentBackground: true,
CustomCss: "seeded",
}
assert.Equal(t, expected, got)
}
func TestXtermJSSettingsSaveFailureReturns500(t *testing.T) {
_, sqlDB := setupXtermJSTestDB(t)
router := newXtermJSRouter()
require.NoError(t, sqlDB.Close())
body := []byte(`{"terminalOptions":{"fontFamily":"JetBrains Mono","fontSize":16,"theme":null}}`)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusInternalServerError, w.Code)
resp := decodeXtermJSResponse(t, w)
assert.Equal(t, "error", resp.Status)
assert.Contains(t, resp.Message, "Failed to save settings:")
}
-154
View File
@@ -1,154 +0,0 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/web/api"
)
func GetTasks(c *gin.Context) {
dbTasks, err := tasks.GetAllTasks()
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
var responseTasks []gin.H
for _, t := range dbTasks {
results, err := tasks.GetTaskResultsByTaskId(t.TaskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
var filteredResults []gin.H
for _, r := range results {
filteredResults = append(filteredResults, gin.H{
"client": r.Client,
"result": r.Result,
"exit_code": r.ExitCode,
"finished_at": r.FinishedAt,
"created_at": r.CreatedAt,
})
}
responseTasks = append(responseTasks, gin.H{
"task_id": t.TaskId,
"clients": t.Clients,
"command": t.Command,
"results": filteredResults,
})
}
api.RespondSuccess(c, responseTasks)
}
func GetTaskById(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
api.RespondError(c, 400, "Task ID is required")
return
}
task, err := tasks.GetTaskByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
return
}
if task == nil {
api.RespondError(c, 404, "Task not found")
return
}
results, err := tasks.GetTaskResultsByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
var filteredResults []gin.H
for _, r := range results {
filteredResults = append(filteredResults, gin.H{
"client": r.Client,
"result": r.Result,
"exit_code": r.ExitCode,
"finished_at": r.FinishedAt,
"created_at": r.CreatedAt,
})
}
api.RespondSuccess(c, gin.H{
"task_id": task.TaskId,
"clients": task.Clients,
"command": task.Command,
"results": filteredResults,
})
}
func GetTasksByClientId(c *gin.Context) {
clientId := c.Param("uuid")
if clientId == "" {
api.RespondError(c, 400, "Client ID is required")
return
}
tasks, err := tasks.GetTasksByClientId(clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
if len(tasks) == 0 {
api.RespondError(c, 404, "No tasks found for this client")
return
}
api.RespondSuccess(c, tasks)
}
func GetSpecificTaskResult(c *gin.Context) {
taskId := c.Param("task_id")
clientId := c.Param("uuid")
if taskId == "" || clientId == "" {
api.RespondError(c, 400, "Task ID and Client ID are required")
return
}
result, err := tasks.GetSpecificTaskResult(taskId, clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
return
}
if result == nil {
api.RespondError(c, 404, "No result found for this task and client")
return
}
api.RespondSuccess(c, result)
}
// Param: task_id
func GetTaskResultsByTaskId(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
api.RespondError(c, 400, "Task ID is required")
return
}
results, err := tasks.GetTaskResultsByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
if len(results) == 0 {
api.RespondError(c, 404, "No results found for this task")
return
}
api.RespondSuccess(c, results)
}
func GetAllTaskResultByUUID(c *gin.Context) {
clientId := c.Param("uuid")
if clientId == "" {
api.RespondError(c, 400, "Client ID is required")
return
}
results, err := tasks.GetAllTasksResultByUUID(clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
if len(results) == 0 {
api.RespondError(c, 404, "No tasks found for this client")
return
}
api.RespondSuccess(c, results)
}
-50
View File
@@ -1,50 +0,0 @@
package test
import (
"net"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/web/api"
)
func TestSendMessage(c *gin.Context) {
err := messageSender.SendEvent(models.EventMessage{
Event: "Test",
Message: "This is a test message from Komari.",
})
if err != nil {
api.RespondError(c, 500, "Failed to send message: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func TestGeoIp(c *gin.Context) {
ip := c.Query("ip")
if ip == "" {
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
ip = cfIP
} else {
ip = c.ClientIP()
}
}
cfg, err := config.GetAs[bool](config.GeoIpEnabledKey, false)
if err != nil {
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if !cfg {
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
return
}
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
if err != nil {
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
return
}
api.RespondSuccess(c, GeoIpRecord)
}
+102
View File
@@ -0,0 +1,102 @@
package admin
import (
"io"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/web/api"
)
// update.go
// 文件/二进制/敏感操作类的更新接口,保留为 REST handler(不走 RPC 桥)。
// 由原 web/api/admin/update/ 子包合并而来。
func UpdateUser(c *gin.Context) {
var req struct {
Uuid string `json:"uuid" binding:"required"`
Name *string `json:"username"`
Password *string `json:"password"`
SsoType *string `json:"sso_type"`
TwoFa string `json:"2fa_code"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
if req.Password == nil && req.Name == nil {
api.RespondError(c, 400, "At least one field (username or password) must be provided")
return
}
if req.Name != nil && len(*req.Name) < 3 {
api.RespondError(c, 400, "Username must be at least 3 characters long")
return
}
if req.Password != nil && len(*req.Password) < 6 {
api.RespondError(c, 400, "Password must be at least 6 characters long")
return
}
if req.Password != nil {
c.Set("2fa_code", req.TwoFa)
if err := api.VerifySensitive2FA(c); err != nil {
api.RespondError(c, 401, err.Error())
return
}
}
if err := accounts.UpdateUser(req.Uuid, req.Name, req.Password, req.SsoType); err != nil {
api.RespondError(c, 500, "Failed to update user: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
}
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.UpdateDatabase(); err != nil {
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
api.RespondSuccess(c, nil)
}
func UploadFavicon(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20) // 5MB
data, err := io.ReadAll(c.Request.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
api.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
} else {
api.RespondError(c, http.StatusBadRequest, err.Error())
}
return
}
if err := os.WriteFile("./data/favicon.ico", data, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
api.RespondSuccess(c, nil)
}
func DeleteFavicon(c *gin.Context) {
if err := os.Remove("./data/favicon.ico"); err != nil {
if os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "Favicon not found")
} else {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
}
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
api.RespondSuccess(c, nil)
}
-46
View File
@@ -1,46 +0,0 @@
package update
import (
"io"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/web/api"
)
func UploadFavicon(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20) // 5MB
data, err := io.ReadAll(c.Request.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
api.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
} else {
api.RespondError(c, http.StatusBadRequest, err.Error())
}
return
}
if err := os.WriteFile("./data/favicon.ico", data, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
api.RespondSuccess(c, nil)
}
func DeleteFavicon(c *gin.Context) {
if err := os.Remove("./data/favicon.ico"); err != nil {
if os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "Favicon not found")
} else {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
}
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
api.RespondSuccess(c, nil)
}
-18
View File
@@ -1,18 +0,0 @@
package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/web/api"
)
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.UpdateDatabase(); err != nil {
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
api.RespondSuccess(c, nil)
}
-48
View File
@@ -1,48 +0,0 @@
package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/web/api"
)
func UpdateUser(c *gin.Context) {
var req struct {
Uuid string `json:"uuid" binding:"required"`
Name *string `json:"username"`
Password *string `json:"password"`
SsoType *string `json:"sso_type"`
TwoFa string `json:"2fa_code"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
if req.Password == nil && req.Name == nil {
api.RespondError(c, 400, "At least one field (username or password) must be provided")
return
}
if req.Name != nil && len(*req.Name) < 3 {
api.RespondError(c, 400, "Username must be at least 3 characters long")
return
}
if req.Password != nil && len(*req.Password) < 6 {
api.RespondError(c, 400, "Password must be at least 6 characters long")
return
}
if req.Password != nil {
c.Set("2fa_code", req.TwoFa)
if err := api.VerifySensitive2FA(c); err != nil {
api.RespondError(c, 401, err.Error())
return
}
}
if err := accounts.UpdateUser(req.Uuid, req.Name, req.Password, req.SsoType); err != nil {
api.RespondError(c, 500, "Failed to update user: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
}
+49
View File
@@ -0,0 +1,49 @@
package client
import (
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
v1 "github.com/komari-monitor/komari/protocol/v1"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
// ingest.go
// agent 上报数据的传输无关处理逻辑。v1 (REST/WS) 与 v2 (JSON-RPC) 两套上报入口
// 经过各自的协议解析后,统一调用这里的函数落库并更新运行时状态,消除重复。
// ingestReport 保存一次负载上报并刷新运行时状态。
// protocolVersion 标记上报所用协议(1 或 2),用于运行时区分客户端能力。
// markPresence 为 true 时按 POST 上报会话刷新在线状态(WS 连接自行管理在线状态,应传 false)。
func ingestReport(uuid string, report v1.Report, protocolVersion int, markPresence bool) error {
report.UUID = uuid
report.UpdatedAt = time.Now()
if err := SaveClientReport(uuid, report); err != nil {
return err
}
agent_runtime.SetLatestReport(uuid, &report)
agent_runtime.SetClientProtocolVersion(uuid, protocolVersion)
if markPresence {
refreshPostPresence(uuid)
}
return nil
}
// ingestBasicInfo 保存客户端基础信息。fallbackIP 在上报未携带 IP 时用作兜底。
func ingestBasicInfo(uuid string, info map[string]interface{}, fallbackIP string) error {
if info == nil {
info = map[string]interface{}{}
}
return saveClientBasicInfo(info, uuid, fallbackIP)
}
// ingestPingResult 保存一条 ping 探测结果。
func ingestPingResult(uuid string, taskID uint, value int, finishedAt time.Time) {
tasks.SavePingRecord(models.PingRecord{
Client: uuid,
TaskId: taskID,
Value: value,
Time: models.FromTime(finishedAt),
})
}
-55
View File
@@ -1,55 +0,0 @@
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"})
}
+5 -22
View File
@@ -14,8 +14,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
v1 "github.com/komari-monitor/komari/protocol/v1"
"github.com/komari-monitor/komari/utils/notifier"
agent_runtime "github.com/komari-monitor/komari/web/agent"
@@ -134,19 +132,12 @@ func UploadReport(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "UUID is required"})
return
}
report.UUID = uuid
err = SaveClientReport(uuid, report)
if err != nil {
// POST 上报:落库、更新运行时状态并刷新在线状态
if err := ingestReport(uuid, report, 1, true); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
return
}
// Update report with method and token
agent_runtime.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"})
@@ -251,13 +242,11 @@ func processMessage(conn *connection.SafeConn, message []byte, uuid string) {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
return
}
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
// WS 连接自行管理在线状态,无需刷新 POST presence
if err := ingestReport(uuid, report, 1, false); err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
return
}
agent_runtime.SetLatestReport(uuid, &report)
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
@@ -270,13 +259,7 @@ func processMessage(conn *connection.SafeConn, message []byte, uuid string) {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
return
}
pingResult := models.PingRecord{
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: models.FromTime(reqBody.FinishedAt),
}
tasks.SavePingRecord(pingResult)
ingestPingResult(uuid, reqBody.PingTaskID, reqBody.PingResult, reqBody.FinishedAt)
default:
log.Printf("Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
+3 -19
View File
@@ -13,8 +13,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
v2 "github.com/komari-monitor/komari/protocol/v2"
"github.com/komari-monitor/komari/utils/notifier"
agent_runtime "github.com/komari-monitor/komari/web/agent"
@@ -53,15 +51,9 @@ func handleV2RPC(uuid string, req v2.Request, allowWait bool) v2.Response {
if err := bindV2Params(req.Params, &params); err != nil {
return v2.Error(req.ID, -32602, "invalid report params", err.Error())
}
report := params.Report
report.UUID = uuid
report.UpdatedAt = time.Now()
if err := SaveClientReport(uuid, report); err != nil {
if err := ingestReport(uuid, params.Report, 2, true); err != nil {
return v2.Error(req.ID, -32000, "failed to save report", err.Error())
}
agent_runtime.SetLatestReport(uuid, &report)
refreshPostPresence(uuid)
agent_runtime.SetClientProtocolVersion(uuid, 2)
return v2.Success(req.ID, gin.H{
"status": "success",
"events": agent_runtime.TakeV2Events(uuid, params.AckEventIDs, 8),
@@ -71,10 +63,7 @@ func handleV2RPC(uuid string, req v2.Request, allowWait bool) v2.Response {
if err := bindV2Params(req.Params, &params); err != nil {
return v2.Error(req.ID, -32602, "invalid basic info params", err.Error())
}
if params.Info == nil {
params.Info = map[string]interface{}{}
}
if err := saveClientBasicInfo(params.Info, uuid, ""); err != nil {
if err := ingestBasicInfo(uuid, params.Info, ""); err != nil {
return v2.Error(req.ID, -32000, "failed to save basic info", err.Error())
}
return v2.Success(req.ID, gin.H{"status": "success"})
@@ -89,12 +78,7 @@ func handleV2RPC(uuid string, req v2.Request, allowWait bool) v2.Response {
finishedAt = t
}
}
tasks.SavePingRecord(models.PingRecord{
Client: uuid,
TaskId: params.TaskID,
Value: params.Value,
Time: models.FromTime(finishedAt),
})
ingestPingResult(uuid, params.TaskID, params.Value, finishedAt)
return v2.Success(req.ID, gin.H{"status": "success"})
case v2.MethodAgentPull:
var params v2.PullParams
-36
View File
@@ -1,36 +0,0 @@
package client
import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
)
func TaskResult(c *gin.Context) {
token := c.Query("token")
clientId, _ := clients.GetClientUUIDByToken(token)
if clientId == "" {
c.JSON(400, gin.H{"status": "error", "message": "Invalid or missing token"})
return
}
var req struct {
TaskId string `json:"task_id" binding:"required"`
Result string `json:"result"`
ExitCode int `json:"exit_code"`
FinishedAt time.Time `json:"finished_at" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"status": "error", "message": "Invalid request"})
return
}
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, models.FromTime(req.FinishedAt)); err != nil {
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
return
}
c.JSON(200, gin.H{"status": "success", "message": "Task result updated successfully"})
}
+1 -1
View File
@@ -95,7 +95,7 @@ func UploadBasicInfo(c *gin.Context) {
return
}
if err := saveClientBasicInfo(cbi, uuid, c.ClientIP()); err != nil {
if err := ingestBasicInfo(uuid, cbi, c.ClientIP()); err != nil {
c.JSON(500, gin.H{"status": "error", "error": err})
return
}
-46
View File
@@ -1,46 +0,0 @@
package public
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/web/api"
report_cache "github.com/komari-monitor/komari/web/report"
)
func GetClientRecentRecords(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
return
}
// 登录状态检查
isLogin := false
session, _ := c.Cookie("session_token")
_, err := accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
// 仅在未登录时需要 Hidden 信息做过滤
hiddenMap := map[string]bool{}
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if hiddenMap[uuid] {
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
records, _ := report_cache.Records.Get(uuid)
api.RespondSuccess(c, records)
}
-27
View File
@@ -1,27 +0,0 @@
package public
import (
"github.com/komari-monitor/komari/database/accounts"
"github.com/gin-gonic/gin"
)
func GetMe(c *gin.Context) {
session, err := c.Cookie("session_token")
if err != nil {
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
return
}
uuid, err := accounts.GetSession(session)
if err != nil {
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
return
}
user, err := accounts.GetUserByUUID(uuid)
if err != nil {
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
return
}
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID, "sso_type": user.SSOType, "sso_id": user.SSOID, "2fa_enabled": user.TwoFactor != ""})
}
-102
View File
@@ -1,102 +0,0 @@
package public
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/stretchr/testify/assert"
)
func TestGetMe(t *testing.T) {
// 设置测试模式
gin.SetMode(gin.TestMode)
// 创建测试用户
user, err := accounts.CreateAccount("testuser", "password")
if err != nil {
t.Fatalf("Failed to create test user: %v", err)
}
uuid := user.UUID
sessionToken, _ := accounts.CreateSession(uuid, 2592000, "test_user_agent", "127.0.0.1", "oauth")
tests := []struct {
name string
sessionToken string
expectedStatus int
expectedBody map[string]interface{}
}{
{
name: "有效的会话",
sessionToken: sessionToken,
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"username": "testuser",
"logged_in": true,
"uuid": uuid,
"sso_type": "",
"sso_id": "",
"2fa_enabled": false,
},
},
{
name: "无效的会话",
sessionToken: "invalid_token",
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"username": "Guest",
"logged_in": false,
},
},
{
name: "无会话",
sessionToken: "",
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"username": "Guest",
"logged_in": false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 创建测试路由
router := gin.New()
router.GET("/me", GetMe)
// 创建测试请求
req, _ := http.NewRequest("GET", "/me", nil)
if tt.sessionToken != "" {
req.AddCookie(&http.Cookie{
Name: "session_token",
Value: tt.sessionToken,
})
}
// 创建响应记录器
w := httptest.NewRecorder()
// 执行请求
router.ServeHTTP(w, req)
// 断言状态码
assert.Equal(t, tt.expectedStatus, w.Code)
// 解析响应体
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
// 断言响应体
assert.Equal(t, tt.expectedBody, response)
})
}
// 清除测试数据
accounts.DeleteAccountByUsername("testuser")
accounts.DeleteAllSessions()
}
-40
View File
@@ -1,40 +0,0 @@
package public
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/web/api"
)
func GetNodesInformation(c *gin.Context) {
clientList, err := clients.GetAllClientBasicInfo()
if err != nil {
api.RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
return
}
isLogin := false
session, _ := c.Cookie("session_token")
_, err = accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
// 过滤掉 Hidden 的客户端,并清理需要隐藏的字段
j := 0
for i := 0; i < len(clientList); i++ {
if clientList[i].Hidden && !isLogin { // 不返回 Hidden 客户端
continue
}
clientList[i].IPv4 = ""
clientList[i].IPv6 = ""
clientList[i].Remark = "" // 私有备注不展示
clientList[i].Version = ""
clientList[i].Token = ""
clientList[j] = clientList[i]
j++
}
clientList = clientList[:j]
api.RespondSuccess(c, clientList)
}
-41
View File
@@ -1,41 +0,0 @@
package public
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/web/api"
)
// PublicPingTask 是对外暴露的延迟监测任务信息。
type PublicPingTask struct {
Id uint `json:"id"`
Name string `json:"name"`
Clients []string `json:"clients"`
DefaultOn bool `json:"default_on"`
Type string `json:"type"`
Interval int `json:"interval"`
}
func GetPublicPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
publicTasks := make([]PublicPingTask, len(tasks))
for i, task := range tasks {
publicTasks[i] = PublicPingTask{
Id: task.Id,
Name: task.Name,
Clients: task.Clients,
DefaultOn: task.DefaultOn,
Type: task.Type,
Interval: task.Interval,
}
}
api.RespondSuccess(c, publicTasks)
}
-47
View File
@@ -1,47 +0,0 @@
package public
import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/web/api"
)
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)
}
-446
View File
@@ -1,446 +0,0 @@
package public
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
records "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/web/api"
)
func GetRecordsByUUID(c *gin.Context) {
uuid := c.Query("uuid")
loadType := c.Query("load_type")
// 登录状态检查
isLogin := false
session, _ := c.Cookie("session_token")
_, err := accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
// 仅在未登录时需要 Hidden 信息做过滤
hiddenMap := map[string]bool{}
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if hiddenMap[uuid] {
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
}
// 验证 load_type 参数
validLoadTypes := map[string]bool{
"cpu": true, "ram": true, "swap": true,
"load": true, "temp": true, "disk": true, "network": true,
"process": true, "connections": true, "all": true, "": true,
}
if !validLoadTypes[loadType] {
api.RespondError(c, 400, "Invalid load_type parameter")
return
}
clientRecords, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
// 准备基本响应
response := gin.H{
"records": clientRecords,
"count": len(clientRecords),
}
// 如果有load_type过滤,应用过滤
if loadType != "" && loadType != "all" {
filteredRecords := filterRecordsByLoadType(clientRecords, loadType)
response = gin.H{
"records": filteredRecords,
"count": len(filteredRecords),
"load_type": loadType,
}
}
// 自动检测是否有GPU数据并附加到响应中
if loadType == "" || loadType == "all" || loadType == "gpu" {
gpuRecords, err := records.GetGPURecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err == nil && len(gpuRecords) > 0 {
// 按设备索引分组数据,构建简化的设备结构
gpuDevices := make(map[string]interface{})
for _, record := range gpuRecords {
deviceKey := strconv.Itoa(record.DeviceIndex)
// 如果设备还没有初始化,创建设备信息
if gpuDevices[deviceKey] == nil {
gpuDevices[deviceKey] = gin.H{
"device_index": record.DeviceIndex,
"device_name": record.DeviceName,
"records": []models.GPURecord{},
}
}
// 添加记录到设备
device := gpuDevices[deviceKey].(gin.H)
records := device["records"].([]models.GPURecord)
device["records"] = append(records, record)
gpuDevices[deviceKey] = device
}
// 添加优化后的GPU数据结构到响应
response["gpu_devices"] = gpuDevices
response["has_gpu_data"] = true
} else {
response["has_gpu_data"] = false
}
}
api.RespondSuccess(c, response)
}
// filterRecordsByLoadType 根据 load_type 过滤记录,只返回相关字段
func filterRecordsByLoadType(records []models.Record, loadType string) []gin.H {
filteredRecords := make([]gin.H, 0, len(records))
for _, r := range records {
record := gin.H{
"client": r.Client,
"time": r.Time,
}
switch loadType {
case "cpu":
record["cpu"] = r.Cpu
case "gpu":
record["gpu"] = r.Gpu
case "ram":
record["ram"] = r.Ram
record["ram_total"] = r.RamTotal
if r.RamTotal > 0 {
record["ram_percent"] = float32(r.Ram) / float32(r.RamTotal) * 100
}
case "swap":
record["swap"] = r.Swap
record["swap_total"] = r.SwapTotal
if r.SwapTotal > 0 {
record["swap_percent"] = float32(r.Swap) / float32(r.SwapTotal) * 100
}
case "load":
record["load"] = r.Load
case "temp":
record["temp"] = r.Temp
case "disk":
record["disk"] = r.Disk
record["disk_total"] = r.DiskTotal
if r.DiskTotal > 0 {
record["disk_percent"] = float32(r.Disk) / float32(r.DiskTotal) * 100
}
case "network":
record["net_in"] = r.NetIn
record["net_out"] = r.NetOut
record["net_total_up"] = r.NetTotalUp
record["net_total_down"] = r.NetTotalDown
case "process":
record["process"] = r.Process
case "connections":
record["connections"] = r.Connections
record["connections_udp"] = r.ConnectionsUdp
record["connections_tcp"] = r.Connections - r.ConnectionsUdp
}
filteredRecords = append(filteredRecords, record)
}
return filteredRecords
}
// GET query: uuid string, task_id int, hours int
// 支持三种查询模式:
// 1. 仅 uuid - 获取该客户端的所有 ping 任务记录
// 2. 仅 task_id - 获取该任务的所有客户端记录
// 3. uuid + task_id - 获取特定客户端在特定任务的记录
func GetPingRecords(c *gin.Context) {
uuid := c.Query("uuid")
taskIdStr := c.Query("task_id")
// 必须提供 uuid 或 task_id 其中至少一个
if uuid == "" && taskIdStr == "" {
api.RespondError(c, 400, "UUID or task_id is required")
return
}
// 登录状态检查
isLogin := false
session, _ := c.Cookie("session_token")
_, err := accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
type RecordsResp struct {
TaskId uint `json:"task_id,omitempty"`
Time string `json:"time"`
Value int `json:"value"`
Client string `json:"client,omitempty"`
}
type ClientBasicInfo struct {
Client string `json:"client"`
Loss float64 `json:"loss"`
Min int `json:"min"`
Max int `json:"max"`
}
type Resp struct {
Count int `json:"count"`
BasicInfo []ClientBasicInfo `json:"basic_info,omitempty"`
Records []RecordsResp `json:"records"`
Tasks []gin.H `json:"tasks,omitempty"`
}
var records []models.PingRecord
hiddenMap := map[string]bool{}
response := &Resp{
Count: 0,
Records: []RecordsResp{},
}
// 仅在未登录时需要 Hidden 信息做过滤
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if uuid != "" {
if hiddenMap[uuid] {
api.RespondSuccess(c, response) // 对于尝试获取隐藏uuid一键哈气
return
}
}
}
hours := c.Query("hours")
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
hoursInt = 4
}
endTime := time.Now()
startTime := endTime.Add(-time.Duration(hoursInt) * time.Hour)
// 解析 task_id,支持同时传入 uuid 和 task_id
taskId := -1
if taskIdStr != "" {
taskId, err = strconv.Atoi(taskIdStr)
if err != nil {
api.RespondError(c, 400, "Invalid task_id parameter")
return
}
}
// 查询记录,现在支持 uuid + task_id 组合查询
records, err = tasks.GetPingRecords(uuid, taskId, startTime, endTime)
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping records: "+err.Error())
return
}
// 用于统计每个客户端的信息(按 task_id 查询时使用)
clientStats := make(map[string]struct {
total int
loss int
min int
max int
})
for _, r := range records {
if r.Client != "" && !isLogin {
if hiddenMap[r.Client] {
continue // 跳过隐藏的节点
}
}
toTime := r.Time.ToTime()
rec := RecordsResp{
Time: toTime.Format(time.RFC3339),
Value: r.Value,
}
rec.Client = r.Client
stats := clientStats[r.Client]
stats.total++
if r.Value < 0 {
stats.loss++
} else {
if stats.min == 0 || r.Value < stats.min {
stats.min = r.Value
}
if r.Value > stats.max {
stats.max = r.Value
}
}
clientStats[r.Client] = stats
rec.TaskId = r.TaskId
response.Records = append(response.Records, rec)
}
// 返回 BasicInfo - 按客户端分组的统计信息
// 在以下情况下特别有用:
// 1. 仅 task_id 查询 - 查看该任务下所有客户端的表现
// 2. uuid + task_id 查询 - 查看特定客户端在特定任务的表现
if len(clientStats) > 0 {
response.BasicInfo = make([]ClientBasicInfo, 0, len(clientStats))
for client, stats := range clientStats {
if client != "" && !isLogin {
if hiddenMap[client] {
continue // 跳过隐藏的节点
}
}
loss := float64(0)
if stats.total > 0 {
loss = float64(stats.loss) / float64(stats.total) * 100
}
response.BasicInfo = append(response.BasicInfo, ClientBasicInfo{
Client: client,
Loss: loss,
Min: stats.min,
Max: stats.max,
})
}
// 如果同时指定了 uuid 和 task_id,BasicInfo 应该只有一条记录
// 这种情况下可以在响应中标记查询模式
if uuid != "" && taskId != -1 && len(response.BasicInfo) == 1 {
// 这是精确查询模式
}
}
// 优化后的任务信息返回逻辑
// 1. uuid != "" - 返回该客户端参与的所有任务信息
// 2. uuid != "" && taskId != -1 - 返回该客户端在指定任务的信息
// 3. taskId != -1 && uuid == "" - 返回该任务的所有客户端统计(通过 BasicInfo)
if uuid != "" || taskId != -1 {
// 获取所有 pingTasks
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
return
}
tasksList := make([]gin.H, 0, len(pingTasks))
for _, t := range pingTasks {
// 如果指定了 taskId,只处理该任务
if taskId != -1 {
if t.Id != uint(taskId) {
continue
}
}
// 如果指定了 uuid,检查任务是否分配给该客户端
if uuid != "" {
if !t.AppliesToClient(uuid) {
continue
}
}
// 计算该任务的丢包率和延迟统计
totalCount := 0
lossCount := 0
minLatency := 0
maxLatency := 0
avgLatency := 0
sumLatency := 0
validCount := 0
for _, r := range records {
// 根据查询模式过滤记录
if r.TaskId != t.Id {
continue
}
// 如果同时指定了 uuid 和 task_id,只统计该客户端的记录
if uuid != "" && r.Client != uuid {
continue
}
totalCount++
if r.Value < 0 {
lossCount++
} else {
validCount++
sumLatency += r.Value
if minLatency == 0 || r.Value < minLatency {
minLatency = r.Value
}
if r.Value > maxLatency {
maxLatency = r.Value
}
}
}
var lossRate float64 = 0
if totalCount > 0 {
lossRate = float64(lossCount) / float64(totalCount) * 100
}
if validCount > 0 {
avgLatency = sumLatency / validCount
}
taskInfo := gin.H{
"id": t.Id,
"name": t.Name,
"type": t.Type,
"interval": t.Interval,
"default_on": t.DefaultOn,
"loss": lossRate,
"min": minLatency,
"max": maxLatency,
"avg": avgLatency,
"total": totalCount,
}
// 如果是仅 task_id 查询,添加客户端列表信息
if uuid == "" && taskId != -1 {
taskInfo["clients"] = t.Clients
}
tasksList = append(tasksList, taskInfo)
}
response.Tasks = tasksList
}
response.Count = len(response.Records) // 计算最后结果保持计数一致
api.RespondSuccess(c, response)
}
-14
View File
@@ -1,14 +0,0 @@
package public
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/web/api"
)
func GetVersion(c *gin.Context) {
api.RespondSuccess(c, gin.H{
"version": utils.CurrentVersion,
"hash": utils.VersionHash,
})
}
+199 -178
View File
@@ -4,11 +4,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/api/admin"
"github.com/komari-monitor/komari/web/api/admin/clipboard"
log_api "github.com/komari-monitor/komari/web/api/admin/log"
"github.com/komari-monitor/komari/web/api/admin/notification"
"github.com/komari-monitor/komari/web/api/admin/test"
"github.com/komari-monitor/komari/web/api/admin/update"
"github.com/komari-monitor/komari/web/api/client"
public_api "github.com/komari-monitor/komari/web/api/public"
"github.com/komari-monitor/komari/web/api/terminal"
@@ -17,186 +12,212 @@ import (
)
// Register binds all HTTP, WebSocket, JSON-RPC and static frontend routes.
//
// 设计:JSON 类接口统一经声明式路由桥 jsonRpc.Bind 绑定到对应 RPC2 方法,
// 不再有 per-resource gin handler 层。仅二进制/流/重定向/特殊鉴权类接口保留为 REST handler。
func Register(r *gin.Engine) {
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", public_api.Login)
r.GET("/api/me", public_api.GetMe)
r.GET("/api/clients", api.GetClients)
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", 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.RequireRole(api.RoleAdmin, api.RoleClient))
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/v2/rpc", client.WebSocketV2RPC)
tokenAuthrized.POST("/v2/rpc", client.UploadV2RPC)
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.RequireRole(api.RoleAdmin))
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/geoip", test.TestGeoIp)
testGroup.POST("/sendMessage", test.TestSendMessage)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", api.RequireSensitive2FA(), admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
settingsGroup := adminAuthrized.Group("/settings")
{
settingsGroup.GET("/", admin.GetSettings)
settingsGroup.POST("/", admin.EditSettings)
settingsGroup.GET("/xtermjs", admin.GetXtermJSSettings)
settingsGroup.POST("/xtermjs", admin.SetXtermJSSettings)
settingsGroup.POST("/oidc", admin.SetOidcProvider)
settingsGroup.GET("/oidc", admin.GetOidcProvider)
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
settingsGroup.GET("/cloudflared", admin.GetCloudflaredStatus)
settingsGroup.POST("/cloudflared/start", admin.StartCloudflared)
settingsGroup.POST("/cloudflared/stop", admin.StopCloudflared)
settingsGroup.POST("/cloudflared/remove-token", admin.RemoveCloudflaredToken)
}
// themes
themeGroup := adminAuthrized.Group("/theme")
{
themeGroup.PUT("/upload", admin.UploadTheme)
themeGroup.GET("/list", admin.ListThemes)
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/import", admin.ImportTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", api.RequireSensitive2FA(), terminal.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
recordGroup.POST("/clear/all", admin.ClearAllRecords)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", api.RequireSensitive2FA(), admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
// clipboard
clipboardGroup := adminAuthrized.Group("/clipboard")
{
clipboardGroup.GET("/:id", clipboard.GetClipboard)
clipboardGroup.GET("", clipboard.ListClipboard)
clipboardGroup.POST("", clipboard.CreateClipboard)
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
}
notificationGroup := adminAuthrized.Group("/notification")
{
// offline notifications
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
loadAlertGroup := notificationGroup.Group("/load")
{
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
loadAlertGroup.POST("/add", notification.AddLoadNotification)
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
}
// traffic report notifications
trafficReportGroup := notificationGroup.Group("/traffic-report")
{
trafficReportGroup.GET("/", notification.ListTrafficReportNotifications)
trafficReportGroup.POST("/edit", notification.EditTrafficReportNotifications)
trafficReportGroup.POST("/enable", notification.EnableTrafficReportNotifications)
trafficReportGroup.POST("/disable", notification.DisableTrafficReportNotifications)
}
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
pingTaskGroup.POST("/order", admin.OrderPingTask)
}
}
registerPublicRoutes(r)
registerAgentRoutes(r)
registerAdminRoutes(r)
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
}
// registerPublicRoutes 公开路由。JSON 读接口经 Bind 绑定到 public: 命名空间方法。
func registerPublicRoutes(r *gin.Engine) {
// 非 JSON / 特殊流程,保留 REST handler。
r.POST("/api/login", public_api.Login)
r.GET("/api/logout", public_api.Logout)
r.GET("/api/oauth", public_api.OAuth)
r.GET("/api/oauth_callback", public_api.OAuthCallback)
r.GET("/api/mjpeg_live", public_api.MjpegLiveHandler)
// JSON 接口 -> RPC2。
r.GET("/api/me", jsonRpc.Bind("public:getMe", jsonRpc.WithRaw()))
r.GET("/api/clients", jsonRpc.Bind("public:getNodesInformation"))
r.GET("/api/nodes", jsonRpc.Bind("public:getNodesInformation"))
r.GET("/api/public", jsonRpc.Bind("public:getPublicSettings"))
r.GET("/api/version", jsonRpc.Bind("public:getVersion"))
r.GET("/api/recent/:uuid", jsonRpc.Bind("public:getClientRecentRecords", jsonRpc.WithPath("uuid")))
r.GET("/api/records/load", jsonRpc.Bind("public:getRecordsByUUID", jsonRpc.WithQuery("uuid", "load_type", "hours")))
r.GET("/api/records/ping", jsonRpc.Bind("public:getPingRecords", jsonRpc.WithQuery("uuid", "task_id", "hours")))
r.GET("/api/task/ping", jsonRpc.Bind("public:getPublicPingTasks"))
// JSON-RPC 直连入口。
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
}
// registerAgentRoutes agent(客户端)上报与拉取路由。
func registerAgentRoutes(r *gin.Engine) {
// AutoDiscovery 注册使用独立的 Authorization key 鉴权,保留 REST handler。
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthorized := r.Group("/api/clients", api.RequireRole(api.RoleAdmin, api.RoleClient))
{
// 上报类(WS / 原始流 / 兼容协议)保留 REST handler。
tokenAuthorized.GET("/report", client.WebSocketReport)
tokenAuthorized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthorized.POST("/report", client.UploadReport)
tokenAuthorized.GET("/v2/rpc", client.WebSocketV2RPC)
tokenAuthorized.POST("/v2/rpc", client.UploadV2RPC)
tokenAuthorized.GET("/terminal", terminal.EstablishConnection)
// JSON 接口 -> RPC2 (client: 命名空间)。
tokenAuthorized.POST("/task/result", jsonRpc.Bind("client:taskResult", jsonRpc.WithRaw()))
tokenAuthorized.GET("/ping/tasks", jsonRpc.Bind("client:getPingTasks", jsonRpc.WithRaw()))
tokenAuthorized.POST("/ping/result", jsonRpc.Bind("client:uploadPingResult", jsonRpc.WithRaw()))
}
}
// registerAdminRoutes 管理员路由。除二进制/流类外全部经 Bind 绑定到 admin: 命名空间方法。
func registerAdminRoutes(r *gin.Engine) {
g := r.Group("/api/admin", api.RequireRole(api.RoleAdmin))
// --- 二进制/流/重定向类,保留 REST handler ---
g.GET("/download/backup", admin.DownloadBackup)
g.POST("/upload/backup", admin.UploadBackup)
g.GET("/test/geoip", jsonRpc.Bind("admin:testGeoip", jsonRpc.WithQuery("ip")))
g.POST("/test/sendMessage", jsonRpc.Bind("admin:testSendMessage"))
g.POST("/update/mmdb", admin.UpdateMmdbGeoIP)
g.POST("/update/user", admin.UpdateUser)
g.PUT("/update/favicon", admin.UploadFavicon)
g.POST("/update/favicon", admin.DeleteFavicon)
// theme 含文件上传,保留 REST handler。
theme := g.Group("/theme")
{
theme.PUT("/upload", admin.UploadTheme)
theme.GET("/list", admin.ListThemes)
theme.POST("/delete", admin.DeleteTheme)
theme.GET("/set", admin.SetTheme)
theme.POST("/update", admin.UpdateTheme)
theme.POST("/import", admin.ImportTheme)
theme.POST("/settings", admin.UpdateThemeSettings)
}
// 2FA 含二维码 PNG / 敏感操作,保留 REST handler。
twoFactor := g.Group("/2fa")
{
twoFactor.GET("/generate", admin.Generate2FA)
twoFactor.POST("/enable", admin.Enable2FA)
twoFactor.POST("/disable", api.RequireSensitive2FA(), admin.Disable2FA)
}
// oauth2 绑定走重定向,保留 REST handler。
oauth2 := g.Group("/oauth2")
{
oauth2.GET("/bind", admin.BindingExternalAccount)
oauth2.POST("/unbind", admin.UnbindExternalAccount)
}
// --- 以下全部 JSON -> RPC2 ---
// tasks(远程执行)
task := g.Group("/task")
{
task.GET("/all", jsonRpc.Bind("admin:getTasks"))
task.POST("/exec", api.RequireSensitive2FA(), jsonRpc.Bind("admin:exec"))
task.GET("/:task_id", jsonRpc.Bind("admin:getTaskById", jsonRpc.WithPath("task_id")))
task.GET("/:task_id/result", jsonRpc.Bind("admin:getTaskResultsByTaskId", jsonRpc.WithPath("task_id")))
task.GET("/:task_id/result/:uuid", jsonRpc.Bind("admin:getSpecificTaskResult", jsonRpc.WithPath("task_id", "uuid")))
task.GET("/client/:uuid", jsonRpc.Bind("admin:getTasksByClientId", jsonRpc.WithPath("uuid")))
}
// settings
settings := g.Group("/settings")
{
settings.GET("/", jsonRpc.Bind("admin:getSettings"))
settings.POST("/", jsonRpc.Bind("admin:editSettings"))
settings.GET("/xtermjs", jsonRpc.Bind("admin:getXtermjsSettings"))
settings.POST("/xtermjs", jsonRpc.Bind("admin:setXtermjsSettings", jsonRpc.WithMessage("settings saved")))
settings.POST("/oidc", jsonRpc.Bind("admin:setOidcProvider", jsonRpc.WithFlat()))
settings.GET("/oidc", jsonRpc.Bind("admin:getOidcProvider", jsonRpc.WithQuery("provider")))
settings.POST("/message-sender", jsonRpc.Bind("admin:setMessageSenderProvider", jsonRpc.WithFlat()))
settings.GET("/message-sender", jsonRpc.Bind("admin:getMessageSenderProvider", jsonRpc.WithQuery("provider")))
settings.GET("/cloudflared", jsonRpc.Bind("admin:getCloudflaredStatus"))
settings.POST("/cloudflared/start", jsonRpc.Bind("admin:startCloudflared"))
settings.POST("/cloudflared/stop", jsonRpc.Bind("admin:stopCloudflared"))
settings.POST("/cloudflared/remove-token", jsonRpc.Bind("admin:removeCloudflaredToken"))
}
// clients
clientGroup := g.Group("/client")
{
clientGroup.POST("/add", jsonRpc.Bind("admin:addClient", jsonRpc.WithFlat()))
clientGroup.GET("/list", jsonRpc.Bind("admin:listClients", jsonRpc.WithRaw()))
clientGroup.GET("/:uuid", jsonRpc.Bind("admin:getClient", jsonRpc.WithPath("uuid"), jsonRpc.WithRaw()))
clientGroup.POST("/:uuid/edit", jsonRpc.Bind("admin:editClient", jsonRpc.WithPath("uuid")))
clientGroup.POST("/:uuid/remove", jsonRpc.Bind("admin:removeClient", jsonRpc.WithPath("uuid")))
clientGroup.GET("/:uuid/token", jsonRpc.Bind("admin:getClientToken", jsonRpc.WithPath("uuid"), jsonRpc.WithFlat()))
clientGroup.POST("/order", jsonRpc.Bind("admin:orderClients"))
clientGroup.GET("/:uuid/terminal", api.RequireSensitive2FA(), terminal.RequestTerminal)
}
// records
record := g.Group("/record")
{
record.POST("/clear", jsonRpc.Bind("admin:clearRecords"))
record.POST("/clear/all", jsonRpc.Bind("admin:clearAllRecords"))
}
// sessions
session := g.Group("/session")
{
session.GET("/get", jsonRpc.Bind("admin:getSessions", jsonRpc.WithFlat()))
session.POST("/remove", jsonRpc.Bind("admin:deleteSession"))
session.POST("/remove/all", jsonRpc.Bind("admin:deleteAllSessions"))
}
g.GET("/logs", jsonRpc.Bind("admin:getLogs", jsonRpc.WithQuery("limit", "page")))
// clipboard
clipboardGroup := g.Group("/clipboard")
{
clipboardGroup.GET("/:id", jsonRpc.Bind("admin:getClipboard", jsonRpc.WithPath("id")))
clipboardGroup.GET("", jsonRpc.Bind("admin:listClipboard"))
clipboardGroup.POST("", jsonRpc.Bind("admin:createClipboard"))
clipboardGroup.POST("/:id", jsonRpc.Bind("admin:updateClipboard", jsonRpc.WithPath("id")))
clipboardGroup.POST("/remove", jsonRpc.Bind("admin:batchDeleteClipboard"))
clipboardGroup.POST("/:id/remove", jsonRpc.Bind("admin:deleteClipboard", jsonRpc.WithPath("id")))
}
// notifications
notificationGroup := g.Group("/notification")
{
notificationGroup.GET("/offline", jsonRpc.Bind("admin:listOfflineNotifications"))
notificationGroup.POST("/offline/edit", jsonRpc.Bind("admin:editOfflineNotification"))
notificationGroup.POST("/offline/enable", jsonRpc.Bind("admin:enableOfflineNotification"))
notificationGroup.POST("/offline/disable", jsonRpc.Bind("admin:disableOfflineNotification"))
loadAlert := notificationGroup.Group("/load")
{
loadAlert.GET("/", jsonRpc.Bind("admin:getAllLoadNotifications"))
loadAlert.POST("/add", jsonRpc.Bind("admin:addLoadNotification"))
loadAlert.POST("/delete", jsonRpc.Bind("admin:deleteLoadNotification"))
loadAlert.POST("/edit", jsonRpc.Bind("admin:editLoadNotification"))
}
trafficReport := notificationGroup.Group("/traffic-report")
{
trafficReport.GET("/", jsonRpc.Bind("admin:listTrafficReportNotifications"))
trafficReport.POST("/edit", jsonRpc.Bind("admin:editTrafficReportNotifications"))
trafficReport.POST("/enable", jsonRpc.Bind("admin:enableTrafficReportNotifications"))
trafficReport.POST("/disable", jsonRpc.Bind("admin:disableTrafficReportNotifications"))
}
}
// ping tasks
pingTask := g.Group("/ping")
{
pingTask.GET("/", jsonRpc.Bind("admin:getAllPingTasks"))
pingTask.POST("/add", jsonRpc.Bind("admin:addPingTask"))
pingTask.POST("/delete", jsonRpc.Bind("admin:deletePingTask"))
pingTask.POST("/edit", jsonRpc.Bind("admin:editPingTask"))
pingTask.POST("/order", jsonRpc.Bind("admin:orderPingTask"))
}
}
-267
View File
@@ -1,267 +0,0 @@
package jsonrpc
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/rpc"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/connection"
)
// OnInternalRequest 内部调用 RPC 方法,支持权限控制
// group: 调用者的权限分组 (guest/client/admin)
// method: RPC 方法名 (如 "common:getNodes")
// params: 方法参数
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
// 检查私有站点
PrivateSite, _ := config.GetAs[bool](config.PrivateSiteKey)
if PrivateSite && group == "guest" {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Private site enabled, please login first", nil)
}
// 解析方法命名空间
fc := strings.Split(method, ":")
namespace := "common"
if len(fc) >= 2 {
namespace = fc[0]
}
// 权限检查
allowed := false
switch namespace {
case "guest", "", "rpc", "common":
allowed = true
case "client":
if group == "client" || group == "admin" {
allowed = true
}
case "admin":
if group == "admin" {
allowed = true
}
}
if !allowed {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Permission denied", nil)
}
// 构建 context meta
meta := &rpc.ContextMeta{Permission: group}
return rpc.CallWithContext(rpc.NewContextWithMeta(ctx, meta), nil, method, params)
}
// Json Rpc2 over websocket, /api/rpc2
func OnRpcRequest(c *gin.Context) {
// GET -> WebSocket
if c.Request.Method == http.MethodGet {
_conn, err := api.UpgradeWebSocket(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
conn := connection.NewSafeConn(_conn)
permissionGroup := detectPermissionGroup(c)
meta := buildContextMeta(c, permissionGroup)
defer conn.Close()
for {
var req rpc.JsonRpcRequest
err := conn.ReadJSON(&req)
if err != nil {
// JSON 解析
var se *json.SyntaxError
var ute *json.UnmarshalTypeError
if errors.As(err, &se) || errors.As(err, &ute) {
conn.WriteJSON(rpc.ErrorResponse(nil, rpc.InvalidRequest, "bad request: "+err.Error(), nil))
continue
}
// 其它视为连接/IO 错误
break
}
if jerr := req.Validate(); jerr != nil {
conn.WriteJSON(jerr.ResponseWithID(req.ID))
continue
}
dispatchByPermissionWithMeta(conn, permissionGroup, meta, &req)
}
return
}
// 否则按 HTTP POST JSON-RPC 处理 (支持单个或批量)
if c.Request.Method != http.MethodPost {
c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "method not allowed"})
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, rpc.ErrorResponse(nil, rpc.ParseError, "read body error", err.Error()))
return
}
requests, jerr := rpc.ParseRequests(body)
if jerr != nil {
c.JSON(http.StatusBadRequest, jerr.Response())
return
}
permissionGroup := detectPermissionGroup(c)
meta := buildContextMeta(c, permissionGroup)
// 批量
responses := make([]*rpc.JsonRpcResponse, 0, len(requests))
for _, rreq := range requests {
// 权限与命名空间
fc := strings.Split(rreq.Method, ":")
if len(fc) == 1 {
fc[0] = "common"
}
allowed := false
switch fc[0] {
case "guest", "", "rpc", "common":
allowed = true
case "client":
if permissionGroup == "client" || permissionGroup == "admin" {
allowed = true
}
case "admin":
if permissionGroup == "admin" {
allowed = true
}
default:
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
continue
}
if !allowed {
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
continue
}
responses = append(responses, rpc.CallWithContext(rpc.NewContextWithMeta(context.TODO(), meta), rreq.ID, rreq.Method, rreq.Params))
}
// 单个请求直接对象,批量请求数组 (符合 JSON-RPC 2.0)
if len(responses) == 1 {
c.JSON(http.StatusOK, responses[0])
} else {
c.JSON(http.StatusOK, responses)
}
}
// detectPermissionGroup 提取权限分组,与原逻辑保持一致
func detectPermissionGroup(c *gin.Context) string {
permissionGroup := "guest"
token := c.Query("Authorization")
if _, err := clients.GetClientUUIDByToken(token); err == nil {
permissionGroup = "client"
}
if session_token, _ := c.Cookie("session_token"); session_token != "" {
if _, err := accounts.GetUserBySession(session_token); err == nil {
permissionGroup = "admin"
}
}
apiKey := c.GetHeader("Authorization")
if len(apiKey) <= len("Bearer ") {
return permissionGroup
}
cfg, _ := config.GetAs[string](config.ApiKeyKey, "")
if len(cfg) > 8 && apiKey == "Bearer "+cfg {
permissionGroup = "admin"
}
return permissionGroup
}
// buildContextMeta 从 gin.Context 构建 *rpc.ContextMeta
func buildContextMeta(c *gin.Context, permissionGroup string) *rpc.ContextMeta {
meta := &rpc.ContextMeta{Permission: permissionGroup}
// 提取客户端 token (query Authorization / Header Authorization Bearer token)
token := c.Query("Authorization")
if token == "" {
// 兼容 header Bearer <token>
hAuth := c.GetHeader("Authorization")
if strings.HasPrefix(hAuth, "Bearer ") {
token = strings.TrimPrefix(hAuth, "Bearer ")
}
}
if token != "" {
if uuid, err := clients.GetClientUUIDByToken(token); err == nil {
meta.ClientToken = token
meta.ClientUUID = uuid
}
}
// 提取用户 (session cookie)
if session_token, _ := c.Cookie("session_token"); session_token != "" {
if user, err := accounts.GetUserBySession(session_token); err == nil {
meta.User = &user
meta.UserUUID = user.UUID
}
}
meta.RemoteIP = c.ClientIP()
meta.UserAgent = c.GetHeader("User-Agent")
return meta
}
// dispatchByPermissionWithMeta 与原函数类似,但会携带 meta 上下文给 handler
func dispatchByPermissionWithMeta(conn *connection.SafeConn, permissionGroup string, meta *rpc.ContextMeta, req *rpc.JsonRpcRequest) {
fc := strings.Split(req.Method, ":")
if len(fc) == 1 {
fc[0] = "common"
}
ctx := rpc.NewContextWithMeta(context.TODO(), meta)
switch fc[0] {
case "guest", "", "rpc", "common":
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
case "client":
if permissionGroup == "client" || permissionGroup == "admin" {
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
} else {
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
}
case "admin":
if permissionGroup == "admin" {
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
} else {
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
}
default:
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
}
}
// registry holds method handlers keyed by "namespace:MethodName"
var (
registryMu sync.RWMutex
registry = make(map[string]reflect.Value)
)
// Register 将以分组common注册
func Register(name string, cb rpc.Handler) error {
return RegisterWithGroupAndMeta(name, "common", cb, &rpc.MethodMeta{
Name: name,
Summary: "This method does not provide a summary",
Description: "This method does not provide a description",
})
}
// RegisterWithGroup 将回调按分组注册。
// 传入函数: Register("ns", someFunc) -> 注册 ns:SomeFunc
// group 为空时使用默认分组 "common"。
func RegisterWithGroupAndMeta(name, group string, cb rpc.Handler, meta *rpc.MethodMeta) error {
return rpc.RegisterWithMeta(group+":"+name, cb, meta)
}
// GetRegisteredKeys 返回已注册的方法 key 列表 (调试用)。
func GetRegisteredKeys() []string {
registryMu.RLock()
defer registryMu.RUnlock()
keys := make([]string, 0, len(registry))
for k := range registry {
keys = append(keys, k)
}
return keys
}
+141
View File
@@ -0,0 +1,141 @@
# RPC2 内部架构
本目录是 komari 前端面 RPC(JSON-RPC 2.0)的服务端实现。所有对外接口最终都收敛为一条统一管道:
```
对外接口 (REST / WebSocket / 内部调用)
│ 生成 RPC 请求
▼
Dispatch (私有站点检查 → 命名空间权限校验)
│
▼
rpc.CallWithContext → 已注册的 RPC handler (业务逻辑)
```
## 分层
| 文件 | 职责 |
| --- | --- |
| `pkg/rpc/*` | 协议内核:请求/响应类型、注册表、调用 (`Call`/`Invoke`)、权限模型 (`permission.go`) |
| `transport.go` | 传输适配:`/api/rpc2` 的 HTTP POST 与 WebSocket,身份识别,`CallFromGin` |
| `dispatch.go` | 统一分发入口 `Dispatch`,内部调用入口 `OnInternalRequest` |
| `register.go` | 注册便捷函数 `Register` / `RegisterWithGroupAndMeta` |
| `common*.go` | `common` 命名空间业务方法 |
| `admin.*.go` | `admin` 命名空间业务方法 |
## 命名空间与权限(声明式 ACL + 通配符)
方法名约定为 `namespace:method`,不含 `:` 时归入默认命名空间 `common`(内部方法用 `rpc.` 前缀)。
权限采用**声明式 ACL**:一组规则 `(pattern, minRole)`,pattern 支持 `*` 通配符,匹配完整方法名。
判权时在所有匹配规则中取**特异性最高**者:精确匹配 > 字面前缀更长的通配 > 全局 `*`;
特异性相同时取更严格(等级更高)的角色,偏向安全。
角色等级:`guest (0) < client (1) < admin (2)`。
内置默认规则(见 `pkg/rpc/permission.go` 的 `init`):
| 规则 pattern | 所需角色 |
| --- | --- |
| `*`(兜底) | admin |
| `common:*` / `guest:*` / `rpc.*` / `rpc:*` | guest |
| `client:*` | client |
| `admin:*` | admin |
声明自定义权限(供插件使用):
```go
rpc.Allow("plugin:*", rpc.RoleClient) // 整个命名空间
rpc.Allow("plugin:publicStat", rpc.RoleGuest) // 更具体的方法级规则可放宽
rpc.RegisterNamespace("plugin", rpc.RoleAdmin) // 等价于 Allow("plugin:*", admin)
```
由于按特异性裁决,`plugin:*`=admin 与 `plugin:publicStat`=guest 可共存:访客能调用 `publicStat`,
其余 `plugin:*` 方法仍要求 admin。
## 注册一个 RPC 方法
```go
func init() {
jsonrpc.RegisterWithGroupAndMeta("addClient", rpc.RoleAdmin, adminAddClient,
&rpc.MethodMeta{
Name: "admin:addClient",
Summary: "Create a new client",
Params: []rpc.ParamMeta{{Name: "name", Type: "string"}},
Returns: "{ uuid, token }",
})
}
func adminAddClient(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct{ Name string `json:"name"` }
req.BindParams(&params)
// ... 业务逻辑
return result, nil
}
```
- handler 返回 `(result, nil)` 表示成功,`(nil, *rpc.JsonRpcError)` 表示失败。
- 通过 `rpc.MetaFromContext(ctx)` 获取调用者身份(权限分组、用户 UUID、客户端 token、来源 IP 等)。
- 用 `req.BindParams(&struct)` 绑定参数,兼容具名对象与位置数组。
## 让传统 REST handler 转调 RPC
迁移中的 gin handler 用 `jsonrpc.CallFromGin` 复用同一套权限/审计上下文:
```go
func GetClient(c *gin.Context) {
resp := jsonRpc.CallFromGin(c, "admin:getClient", map[string]any{"uuid": c.Param("uuid")})
if resp.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": resp.Error.Message})
return
}
c.JSON(http.StatusOK, resp.Result)
}
```
handler 退化为薄适配层:解析 gin 参数 → 调 RPC → 把响应映射回原 REST JSON 形状(保持前端契约不变)。
## 插件扩展点(地基已就位,插件本身待实现)
`pkg/rpc` 已提供供插件运行时使用的接口:
- `rpc.Register(method, handler)` / `rpc.MustRegister` — 注册方法(禁止 `rpc.` 保留前缀,重复注册报错)。
- `rpc.Unregister(method) bool` — 动态注销方法并清理元数据(供插件卸载),保留前缀不可注销。
- `rpc.Allow(pattern, minRole)` — 声明式 ACL 规则,支持通配符。
- `rpc.RegisterNamespace(namespace, requiredRole)` — 为整个命名空间声明所需角色。
插件既可 `rpc.Invoke` 调用已有方法,也可注册自己的命名空间方法并用 `Allow` 声明权限,复用统一的权限校验与分发。
## 已迁移接口(REST → RPC2)
几乎所有 JSON 类接口已迁移为 RPC2 方法,router 通过声明式路由桥 `Bind` 直接绑定,
不再有 per-resource gin handler 层。
| 命名空间 | 方法(文件) |
| --- | --- |
| `admin` | client CRUD、ping task、session/settings/weight、notification(load/offline/traffic)、clipboard、provider(messageSender/oidc)、task 查询、system(logs/cloudflared/exec/test)、xtermjs |
| `public` | getMe、getNodesInformation、getPublicSettings、getVersion、getClientRecentRecords、getRecordsByUUID、getPingRecords、getPublicPingTasks |
| `client` | getPingTasks、uploadPingResult、taskResult |
### 声明式路由桥 `Bind`
`web/rpc/jsonrpc/bridge.go` 提供:
```go
r.GET("/api/admin/client/:uuid", jsonRpc.Bind("admin:getClient", jsonRpc.WithPath("uuid"), jsonRpc.WithRaw()))
```
- 参数装配:JSON body(对象/数组)+ `WithPath(...)` 路径参数 + `WithQuery(...)` 查询参数,合并为 RPC 参数。
- 响应渲染器(保契约):
- 默认 `renderStandard` → `{status:"success", message, data}`(data 为空时省略,对齐 `api.Response`)。
- `WithFlat()` → 把 result(map) 平铺到顶层 + `{status:"success"}`(addClient/getClientToken/getSessions/provider set)。
- `WithRaw()` → 直接输出 result(agent 裸 JSON / me / listClients / getClient)。
- `WithMessage(msg)` → 成功带固定 message(xtermjs 保存)。
- 错误:统一 `{status:"error", message}` + JSON-RPC 错误码到 HTTP 码映射。
### 保留为 REST 的接口(不走 RPC 桥)
二进制/流/重定向/特殊鉴权类,集中在 `web/api/admin`(2fa/theme/backup/update/oauth 绑定)、
`web/api/public`(login/logout/oauth/mjpeg)、`web/api/client`(report WS+POST、v2 RPC、uploadBasicInfo、terminal、AutoDiscovery 注册)。
agent v1/v2 上报的核心逻辑已统一到 `web/api/client/ingest.go`。
+184
View File
@@ -0,0 +1,184 @@
package jsonrpc
import (
"context"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/pkg/rpc"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
// admin.client.go
// client 资源的 RPC2 方法(admin 命名空间)。承载原 web/api/admin/client.go 的业务逻辑,
// 包含审计日志与运行时副作用。传统 REST handler 经 CallFromGin 转调这些方法。
func init() {
RegisterWithGroupAndMeta("addClient", rpc.RoleAdmin, adminAddClient, &rpc.MethodMeta{
Name: "admin:addClient",
Summary: "Create a new client",
Params: []rpc.ParamMeta{
{Name: "name", Type: "string", Required: false, Description: "Optional client name"},
},
Returns: "{ uuid: string, token: string }",
})
RegisterWithGroupAndMeta("editClient", rpc.RoleAdmin, adminEditClient, &rpc.MethodMeta{
Name: "admin:editClient",
Summary: "Edit a client (partial update)",
Params: []rpc.ParamMeta{
{Name: "uuid", Type: "string", Required: true, Description: "Client UUID"},
},
Returns: "null",
})
RegisterWithGroupAndMeta("removeClient", rpc.RoleAdmin, adminRemoveClient, &rpc.MethodMeta{
Name: "admin:removeClient",
Summary: "Delete a client",
Params: []rpc.ParamMeta{
{Name: "uuid", Type: "string", Required: true, Description: "Client UUID"},
},
Returns: "null",
})
RegisterWithGroupAndMeta("getClient", rpc.RoleAdmin, adminGetClient, &rpc.MethodMeta{
Name: "admin:getClient",
Summary: "Get a client by UUID",
Params: []rpc.ParamMeta{
{Name: "uuid", Type: "string", Required: true, Description: "Client UUID"},
},
Returns: "Client",
})
RegisterWithGroupAndMeta("listClients", rpc.RoleAdmin, adminListClients, &rpc.MethodMeta{
Name: "admin:listClients",
Summary: "List all clients (basic info)",
Returns: "Client[]",
})
RegisterWithGroupAndMeta("getClientToken", rpc.RoleAdmin, adminGetClientToken, &rpc.MethodMeta{
Name: "admin:getClientToken",
Summary: "Get a client's token by UUID",
Params: []rpc.ParamMeta{
{Name: "uuid", Type: "string", Required: true, Description: "Client UUID"},
},
Returns: "{ token: string }",
})
RegisterWithGroupAndMeta("clearRecords", rpc.RoleAdmin, adminClearRecords, &rpc.MethodMeta{
Name: "admin:clearRecords",
Summary: "Delete all load records",
Returns: "null",
})
}
// auditActor 从上下文提取审计用的 actor UUID 与来源 IP。
func auditActor(ctx context.Context) (uuid, ip string) {
if meta := rpc.MetaFromContext(ctx); meta != nil {
uuid = meta.UserUUID
ip = meta.RemoteIP
}
return uuid, ip
}
func adminAddClient(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Name string `json:"name"`
}
req.BindParams(&params)
var (
uuid, token string
err error
)
if params.Name == "" {
uuid, token, err = clients.CreateClient()
} else {
uuid, token, err = clients.CreateClientWithName(params.Name)
}
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
if params.Name != "" {
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "create client:"+uuid, "info")
}
return map[string]any{"uuid": uuid, "token": token}, nil
}
func adminEditClient(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var update map[string]interface{}
if err := req.BindParams(&update); err != nil || update == nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid params", nil)
}
uuid, _ := update["uuid"].(string)
if uuid == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing UUID", nil)
}
if err := clients.SaveClient(update); err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "edit client:"+uuid, "info")
return nil, nil
}
func adminRemoveClient(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
}
req.BindParams(&params)
if params.UUID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing UUID", nil)
}
if err := clients.DeleteClient(params.UUID); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to delete client"+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "delete client:"+params.UUID, "warn")
agent_runtime.DeleteConnectedClients(params.UUID)
agent_runtime.DeleteLatestReport(params.UUID)
return nil, nil
}
func adminGetClient(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
}
req.BindParams(&params)
if params.UUID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing UUID", nil)
}
result, err := clients.GetClientByUUID(params.UUID)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return result, nil
}
func adminListClients(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
cls, err := clients.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return cls, nil
}
func adminGetClientToken(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
}
req.BindParams(&params)
if params.UUID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing UUID", nil)
}
token, err := clients.GetClientTokenByUUID(params.UUID)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return map[string]any{"token": token}, nil
}
func adminClearRecords(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
if err := records.DeleteAll(); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to delete Record"+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "clear records", "warn")
return nil, nil
}
+113
View File
@@ -0,0 +1,113 @@
package jsonrpc
import (
"context"
"strconv"
"github.com/komari-monitor/komari/database/auditlog"
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/rpc"
)
// admin.clipboard.go
// 剪贴板 RPC2 方法(admin 命名空间)。
func init() {
reg("getClipboard", adminGetClipboard, "Get a clipboard entry by id")
reg("listClipboard", adminListClipboard, "List clipboard entries")
reg("createClipboard", adminCreateClipboard, "Create a clipboard entry")
reg("updateClipboard", adminUpdateClipboard, "Update a clipboard entry")
reg("deleteClipboard", adminDeleteClipboard, "Delete a clipboard entry")
reg("batchDeleteClipboard", adminBatchDeleteClipboard, "Batch delete clipboard entries")
}
func adminGetClipboard(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
ID string `json:"id"`
}
req.BindParams(&params)
id, err := strconv.Atoi(params.ID)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid ID", nil)
}
cb, err := clipboardDB.GetClipboardByID(id)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get clipboard: "+err.Error(), nil)
}
return cb, nil
}
func adminListClipboard(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
list, err := clipboardDB.ListClipboard()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to list clipboard: "+err.Error(), nil)
}
return list, nil
}
func adminCreateClipboard(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var cb models.Clipboard
if err := req.BindParams(&cb); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request: "+err.Error(), nil)
}
if err := clipboardDB.CreateClipboard(&cb); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to create clipboard: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "create clipboard:"+strconv.Itoa(cb.Id), "info")
return cb, nil
}
func adminUpdateClipboard(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
// id 来自路径参数(bridge 注入),其余字段为更新内容。
fields := map[string]interface{}{}
if err := req.BindParams(&fields); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request: "+err.Error(), nil)
}
idStr, _ := fields["id"].(string)
id, err := strconv.Atoi(idStr)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid ID", nil)
}
delete(fields, "id") // 路径注入的 id 不作为更新字段
if err := clipboardDB.UpdateClipboardFields(id, fields); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to update clipboard: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "update clipboard:"+strconv.Itoa(id), "info")
return nil, nil
}
func adminDeleteClipboard(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
ID string `json:"id"`
}
req.BindParams(&params)
id, err := strconv.Atoi(params.ID)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid ID", nil)
}
if err := clipboardDB.DeleteClipboard(id); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to delete clipboard: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "delete clipboard:"+strconv.Itoa(id), "warn")
return nil, nil
}
func adminBatchDeleteClipboard(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
IDs []int `json:"ids"`
}
req.BindParams(&params)
if len(params.IDs) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "IDs cannot be empty", nil)
}
if err := clipboardDB.DeleteClipboardBatch(params.IDs); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to batch delete clipboard: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "batch delete clipboard: "+strconv.Itoa(len(params.IDs))+" items", "warn")
return nil, nil
}
+150
View File
@@ -0,0 +1,150 @@
package jsonrpc
import (
"context"
"strconv"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/rpc"
)
// admin.misc.go
// 杂项 admin RPC2 方法:会话管理、设置、客户端排序。
func parseUintKey(s string) (uint, error) {
v, err := strconv.ParseUint(s, 10, 64)
return uint(v), err
}
func init() {
RegisterWithGroupAndMeta("getSessions", rpc.RoleAdmin, adminGetSessions, &rpc.MethodMeta{
Name: "admin:getSessions",
Summary: "List all login sessions",
Returns: "{ current: string, data: Session[] }",
})
RegisterWithGroupAndMeta("deleteSession", rpc.RoleAdmin, adminDeleteSession, &rpc.MethodMeta{
Name: "admin:deleteSession",
Summary: "Delete a session by token",
Returns: "null",
})
RegisterWithGroupAndMeta("deleteAllSessions", rpc.RoleAdmin, adminDeleteAllSessions, &rpc.MethodMeta{
Name: "admin:deleteAllSessions",
Summary: "Delete all sessions",
Returns: "null",
})
RegisterWithGroupAndMeta("getSettings", rpc.RoleAdmin, adminGetSettings, &rpc.MethodMeta{
Name: "admin:getSettings",
Summary: "Get all settings",
Returns: "object",
})
RegisterWithGroupAndMeta("editSettings", rpc.RoleAdmin, adminEditSettings, &rpc.MethodMeta{
Name: "admin:editSettings",
Summary: "Update settings (partial)",
Returns: "null",
})
RegisterWithGroupAndMeta("clearAllRecords", rpc.RoleAdmin, adminClearAllRecords, &rpc.MethodMeta{
Name: "admin:clearAllRecords",
Summary: "Delete all load and ping records",
Returns: "null",
})
RegisterWithGroupAndMeta("orderClients", rpc.RoleAdmin, adminOrderClients, &rpc.MethodMeta{
Name: "admin:orderClients",
Summary: "Reorder clients (map of uuid->weight)",
Returns: "null",
})
}
func adminGetSessions(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
ss, err := accounts.GetAllSessions()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to retrieve sessions: "+err.Error(), nil)
}
current := ""
if meta := rpc.MetaFromContext(ctx); meta != nil {
current = meta.SessionToken
}
return map[string]any{"current": current, "data": ss}, nil
}
func adminDeleteSession(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Session string `json:"session"`
}
req.BindParams(&params)
if params.Session == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "session is required", nil)
}
if err := accounts.DeleteSession(params.Session); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to delete session: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "delete session", "info")
return nil, nil
}
func adminDeleteAllSessions(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
if err := accounts.DeleteAllSessions(); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to delete all sessions: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "delete all sessions", "warn")
return nil, nil
}
func adminGetSettings(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
cst, err := config.GetAll()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get settings: "+err.Error(), nil)
}
return cst, nil
}
func adminEditSettings(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
cfg := make(map[string]interface{})
if err := req.BindParams(&cfg); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing request body: "+err.Error(), nil)
}
if err := config.SetMany(cfg); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to update settings: "+err.Error(), nil)
}
message := "update settings: "
for key := range cfg {
message += key + ", "
}
if len(message) > 2 {
message = message[:len(message)-2]
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, message, "info")
return nil, nil
}
func adminClearAllRecords(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
records.DeleteAll()
tasks.DeleteAllPingRecords()
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "clear all records", "info")
return nil, nil
}
func adminOrderClients(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var order map[string]int
if err := req.BindParams(&order); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing request body: "+err.Error(), nil)
}
db := dbcore.GetDBInstance()
for uuid, weight := range order {
if err := db.Model(&models.Client{}).Where("uuid = ?", uuid).Update("weight", weight).Error; err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to update client weight: "+err.Error(), nil)
}
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "order clients", "info")
return nil, nil
}
+217
View File
@@ -0,0 +1,217 @@
package jsonrpc
import (
"context"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/pkg/rpc"
"gorm.io/gorm/clause"
)
// admin.notification.go
// 通知相关 RPC2 方法(admin 命名空间):负载告警、离线通知、流量报告。
func init() {
// load notifications
reg("addLoadNotification", adminAddLoadNotification, "Create a load notification")
reg("deleteLoadNotification", adminDeleteLoadNotification, "Delete load notifications by ids")
reg("editLoadNotification", adminEditLoadNotification, "Edit load notifications")
reg("getAllLoadNotifications", adminGetAllLoadNotifications, "List all load notifications")
// offline notifications
reg("listOfflineNotifications", adminListOfflineNotifications, "List offline notifications")
reg("editOfflineNotification", adminEditOfflineNotification, "Edit offline notifications")
reg("enableOfflineNotification", adminEnableOfflineNotification, "Enable offline notifications for clients")
reg("disableOfflineNotification", adminDisableOfflineNotification, "Disable offline notifications for clients")
// traffic report notifications
reg("listTrafficReportNotifications", adminListTrafficReport, "List traffic report notifications")
reg("editTrafficReportNotifications", adminEditTrafficReport, "Edit traffic report notifications")
reg("enableTrafficReportNotifications", adminEnableTrafficReport, "Enable traffic report notifications")
reg("disableTrafficReportNotifications", adminDisableTrafficReport, "Disable traffic report notifications")
}
// reg 是 admin 命名空间方法的注册便捷封装。
func reg(name string, h rpc.Handler, summary string) {
RegisterWithGroupAndMeta(name, rpc.RoleAdmin, h, &rpc.MethodMeta{Name: "admin:" + name, Summary: summary})
}
func adminAddLoadNotification(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Clients []string `json:"clients"`
Name string `json:"name"`
Metric string `json:"metric"`
Threshold float32 `json:"threshold"`
Ratio float32 `json:"ratio"`
Interval int `json:"interval"`
}
req.BindParams(&params)
if len(params.Clients) == 0 || params.Metric == "" || params.Threshold == 0 || params.Ratio == 0 || params.Interval == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "clients, metric, threshold, ratio and interval are required", nil)
}
if params.Interval > 4*60 || params.Interval <= 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "Interval must be between 1 and 240 minutes", nil)
}
if params.Ratio <= 0 || params.Ratio > 1 {
return nil, rpc.MakeError(rpc.InvalidParams, "Ratio must be between 0 and 1", nil)
}
taskID, err := notification.AddLoadNotification(params.Clients, params.Name, params.Metric, params.Threshold, params.Ratio, params.Interval)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return map[string]any{"task_id": taskID}, nil
}
func adminDeleteLoadNotification(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
ID []uint `json:"id"`
}
req.BindParams(&params)
if len(params.ID) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "id is required", nil)
}
if err := notification.DeleteLoadNotification(params.ID); err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return nil, nil
}
func adminEditLoadNotification(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Notifications []*models.LoadNotification `json:"notifications"`
}
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request data", nil)
}
if err := notification.EditLoadNotification(params.Notifications); err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return nil, nil
}
func adminGetAllLoadNotifications(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
list, err := notification.GetAllLoadNotifications()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return list, nil
}
func adminListOfflineNotifications(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var notifications []models.OfflineNotification
if err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(&notifications).Error; err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to list offline notifications: "+err.Error(), nil)
}
return notifications, nil
}
func adminEditOfflineNotification(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var notifications []models.OfflineNotification
if err := req.BindParams(&notifications); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request body: "+err.Error(), nil)
}
if len(notifications) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "At least one notification is required", nil)
}
for _, noti := range notifications {
if noti.Client == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Client UUID cannot be empty", nil)
}
if noti.GracePeriod <= 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "GracePeriod must be a positive integer", nil)
}
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable", "grace_period"}),
}).
Select("*").Create(notifications).Error
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to edit offline notifications: "+err.Error(), nil)
}
return nil, nil
}
// setOfflineNotificationEnable 是 enable/disable 的共享实现。
func setOfflineNotificationEnable(req *rpc.JsonRpcRequest, enable bool) *rpc.JsonRpcError {
var uuids []string
if err := req.BindParams(&uuids); err != nil {
return rpc.MakeError(rpc.InvalidParams, "Invalid request body: "+err.Error(), nil)
}
notifications := make([]models.OfflineNotification, 0, len(uuids))
for _, uuid := range uuids {
notifications = append(notifications, models.OfflineNotification{Client: uuid, Enable: enable})
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").Create(notifications).Error
if err != nil {
return rpc.MakeError(rpc.InternalError, "Failed to update offline notifications: "+err.Error(), nil)
}
return nil
}
func adminEnableOfflineNotification(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
if e := setOfflineNotificationEnable(req, true); e != nil {
return nil, e
}
return nil, nil
}
func adminDisableOfflineNotification(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
if e := setOfflineNotificationEnable(req, false); e != nil {
return nil, e
}
return nil, nil
}
func adminListTrafficReport(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
list, err := notification.ListTrafficReportNotifications()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to list traffic report notifications: "+err.Error(), nil)
}
return list, nil
}
func adminEditTrafficReport(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var notifications []models.TrafficReportNotification
if err := req.BindParams(&notifications); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request body: "+err.Error(), nil)
}
if len(notifications) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "At least one notification is required", nil)
}
if err := notification.ValidateTrafficReportNotifications(notifications); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, err.Error(), nil)
}
if err := notification.EditTrafficReportNotifications(notifications); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to edit traffic report notifications: "+err.Error(), nil)
}
return nil, nil
}
func adminEnableTrafficReport(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var uuids []string
if err := req.BindParams(&uuids); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request body: "+err.Error(), nil)
}
if err := notification.EnableTrafficReportNotifications(uuids); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to enable traffic report notifications: "+err.Error(), nil)
}
return nil, nil
}
func adminDisableTrafficReport(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var uuids []string
if err := req.BindParams(&uuids); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request body: "+err.Error(), nil)
}
if err := notification.DisableTrafficReportNotifications(uuids); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to disable traffic report notifications: "+err.Error(), nil)
}
return nil, nil
}
+124
View File
@@ -0,0 +1,124 @@
package jsonrpc
import (
"context"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/rpc"
)
// admin.ping.go
// 延迟监测任务(ping task)的 RPC2 方法(admin 命名空间)。
func init() {
RegisterWithGroupAndMeta("addPingTask", rpc.RoleAdmin, adminAddPingTask, &rpc.MethodMeta{
Name: "admin:addPingTask",
Summary: "Create a ping task",
Returns: "{ task_id: uint }",
})
RegisterWithGroupAndMeta("deletePingTask", rpc.RoleAdmin, adminDeletePingTask, &rpc.MethodMeta{
Name: "admin:deletePingTask",
Summary: "Delete ping tasks by ids",
Returns: "null",
})
RegisterWithGroupAndMeta("editPingTask", rpc.RoleAdmin, adminEditPingTask, &rpc.MethodMeta{
Name: "admin:editPingTask",
Summary: "Edit ping tasks",
Returns: "null",
})
RegisterWithGroupAndMeta("getAllPingTasks", rpc.RoleAdmin, adminGetAllPingTasks, &rpc.MethodMeta{
Name: "admin:getAllPingTasks",
Summary: "List all ping tasks",
Returns: "PingTask[]",
})
RegisterWithGroupAndMeta("orderPingTask", rpc.RoleAdmin, adminOrderPingTask, &rpc.MethodMeta{
Name: "admin:orderPingTask",
Summary: "Reorder ping tasks (map of id->weight)",
Returns: "null",
})
}
func adminAddPingTask(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Clients []string `json:"clients"`
DefaultOn bool `json:"default_on"`
Name string `json:"name"`
Target string `json:"target"`
TaskType string `json:"type"`
Interval int `json:"interval"`
}
req.BindParams(&params)
if params.Name == "" || params.Target == "" || params.TaskType == "" || params.Interval == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "name, target, type and interval are required", nil)
}
if !params.DefaultOn && len(params.Clients) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "clients is required when default_on is false", nil)
}
taskID, err := tasks.AddPingTask(params.Clients, params.DefaultOn, params.Name, params.Target, params.TaskType, params.Interval)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return map[string]any{"task_id": taskID}, nil
}
func adminDeletePingTask(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
ID []uint `json:"id"`
}
req.BindParams(&params)
if len(params.ID) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "id is required", nil)
}
if err := tasks.DeletePingTask(params.ID); err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return nil, nil
}
func adminEditPingTask(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Tasks []*models.PingTask `json:"tasks"`
}
req.BindParams(&params)
if len(params.Tasks) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request data", nil)
}
for _, task := range params.Tasks {
if task == nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request data", nil)
}
}
if err := tasks.EditPingTask(params.Tasks); err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return nil, nil
}
func adminGetAllPingTasks(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
list, err := tasks.GetAllPingTasks()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return list, nil
}
func adminOrderPingTask(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
// 参数为 { idStr: weight } 映射。
order := map[uint]int{}
var raw map[string]int
if err := req.BindParams(&raw); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing request body: "+err.Error(), nil)
}
for idStr, weight := range raw {
id, err := parseUintKey(idStr)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid task id: "+idStr, nil)
}
order[id] = weight
}
if err := tasks.UpdatePingTaskOrder(order); err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return nil, nil
}
+108
View File
@@ -0,0 +1,108 @@
package jsonrpc
import (
"context"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/rpc"
"github.com/komari-monitor/komari/utils/messageSender"
msfactory "github.com/komari-monitor/komari/utils/messageSender/factory"
"github.com/komari-monitor/komari/web/oauth"
oauthfactory "github.com/komari-monitor/komari/web/oauth/factory"
)
// admin.provider.go
// 消息发送器与 OIDC 提供者配置 RPC2 方法(admin 命名空间)。
func init() {
reg("getMessageSenderProvider", adminGetMessageSender, "Get message sender provider config or templates")
reg("setMessageSenderProvider", adminSetMessageSender, "Set message sender provider config")
reg("getOidcProvider", adminGetOidc, "Get OIDC provider config or templates")
reg("setOidcProvider", adminSetOidc, "Set OIDC provider config")
}
func adminGetMessageSender(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Provider string `json:"provider"`
}
req.BindParams(&params)
if params.Provider != "" {
cfg, err := database.GetMessageSenderConfigByName(params.Provider)
if err != nil {
return nil, rpc.MakeError(rpc.NotFound, "Provider not found: "+err.Error(), nil)
}
return cfg, nil
}
providers := msfactory.GetSenderConfigs()
if len(providers) == 0 {
return nil, rpc.MakeError(rpc.NotFound, "No message sender providers found", nil)
}
return providers, nil
}
func adminSetMessageSender(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var senderConfig models.MessageSenderProvider
if err := req.BindParams(&senderConfig); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid configuration: "+err.Error(), nil)
}
if senderConfig.Name == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Provider name is required", nil)
}
if _, exists := msfactory.GetConstructor(senderConfig.Name); !exists {
return nil, rpc.MakeError(rpc.NotFound, "Provider not found: "+senderConfig.Name, nil)
}
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to save message sender provider configuration: "+err.Error(), nil)
}
method, _ := config.GetAs[string](config.NotificationMethodKey, "none")
if method == senderConfig.Name { // 正在使用,重载
if err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to load message sender provider: "+err.Error(), nil)
}
}
return map[string]any{"message": "Message sender provider set successfully"}, nil
}
func adminGetOidc(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Provider string `json:"provider"`
}
req.BindParams(&params)
if params.Provider != "" {
cfg, err := database.GetOidcConfigByName(params.Provider)
if err != nil {
return nil, rpc.MakeError(rpc.NotFound, "Provider not found: "+err.Error(), nil)
}
return cfg, nil
}
providers := oauthfactory.GetProviderConfigs()
if len(providers) == 0 {
return nil, rpc.MakeError(rpc.NotFound, "No OIDC providers found", nil)
}
return providers, nil
}
func adminSetOidc(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var oidcConfig models.OidcProvider
if err := req.BindParams(&oidcConfig); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid configuration: "+err.Error(), nil)
}
if oidcConfig.Name == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Provider name is required", nil)
}
if _, exists := oauthfactory.GetConstructor(oidcConfig.Name); !exists {
return nil, rpc.MakeError(rpc.NotFound, "Provider not found: "+oidcConfig.Name, nil)
}
if err := database.SaveOidcConfig(&oidcConfig); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to save OIDC provider configuration: "+err.Error(), nil)
}
provider, _ := config.GetAs[string](config.OAuthProviderKey, "github")
if provider == oidcConfig.Name { // 正在使用,重载
if err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to load OIDC provider: "+err.Error(), nil)
}
}
return map[string]any{"message": "OIDC provider set successfully"}, nil
}
+244
View File
@@ -0,0 +1,244 @@
package jsonrpc
import (
"context"
"encoding/json"
"net"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/rpc"
v2 "github.com/komari-monitor/komari/protocol/v2"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
// admin.system.go
// 系统/运维类 RPC2 方法(admin 命名空间):日志、cloudflared、远程执行、测试。
const cloudflaredStopConfirmText = "STOP CLOUDFLARED"
func init() {
reg("getLogs", adminGetLogs, "Get audit logs (paged)")
reg("getCloudflaredStatus", adminCloudflaredStatus, "Get cloudflared tunnel status")
reg("startCloudflared", adminStartCloudflared, "Start cloudflared tunnel")
reg("stopCloudflared", adminStopCloudflared, "Stop cloudflared tunnel")
reg("removeCloudflaredToken", adminRemoveCloudflaredToken, "Remove cloudflared token")
reg("exec", adminExec, "Execute a command on clients")
reg("testSendMessage", adminTestSendMessage, "Send a test notification")
reg("testGeoip", adminTestGeoip, "Test GeoIP lookup")
}
func adminGetLogs(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Limit string `json:"limit"`
Page string `json:"page"`
}
req.BindParams(&params)
if params.Limit == "" {
params.Limit = "100"
}
if params.Page == "" {
params.Page = "1"
}
limitInt, err := strconv.Atoi(params.Limit)
if err != nil || limitInt <= 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid limit: "+params.Limit, nil)
}
pageInt, err := strconv.Atoi(params.Page)
if err != nil || pageInt <= 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid page: "+params.Page, nil)
}
db := dbcore.GetDBInstance()
var logs []models.Log
offset := (pageInt - 1) * limitInt
var total int64
if err := db.Model(&models.Log{}).Count(&total).Error; err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to count logs: "+err.Error(), nil)
}
if err := db.Order("time desc").Limit(limitInt).Offset(offset).Find(&logs).Error; err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to retrieve logs: "+err.Error(), nil)
}
return map[string]any{"logs": logs, "total": total}, nil
}
func adminCloudflaredStatus(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return cloudflared.Status(), nil
}
func adminStartCloudflared(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Token string `json:"token"`
}
req.BindParams(&params)
token := strings.TrimSpace(params.Token)
if token != "" {
if err := cloudflared.SaveToken(token); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to save Cloudflare Tunnel token: "+err.Error(), nil)
}
}
if err := cloudflared.Start(token); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "started cloudflared tunnel", "warn")
return cloudflared.Status(), nil
}
func adminStopCloudflared(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
CurrentPassword string `json:"current_password"`
ConfirmText string `json:"confirm_text"`
}
req.BindParams(&params)
disablePasswordLogin, _ := config.GetAs[bool](config.DisablePasswordLoginKey, false)
if !disablePasswordLogin {
actor, _ := auditActor(ctx)
if actor == "" {
return nil, rpc.MakeError(rpc.Unauthenticated, "Unauthorized.", nil)
}
user, err := accounts.GetUserByUUID(actor)
if err != nil {
return nil, rpc.MakeError(rpc.Unauthenticated, "Failed to verify current user", nil)
}
if strings.TrimSpace(params.CurrentPassword) == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Current password is required", nil)
}
if _, ok := accounts.CheckPassword(user.Username, params.CurrentPassword); !ok {
return nil, rpc.MakeError(rpc.Unauthenticated, "Current password is incorrect", nil)
}
} else if strings.TrimSpace(params.ConfirmText) != cloudflaredStopConfirmText {
return nil, rpc.MakeError(rpc.InvalidParams, "Type STOP CLOUDFLARED to confirm stopping cloudflared", nil)
}
if err := cloudflared.Stop(); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to stop cloudflared: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "stopped cloudflared tunnel", "warn")
return cloudflared.Status(), nil
}
func adminRemoveCloudflaredToken(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
if err := cloudflared.RemoveToken(); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Failed to remove Cloudflare Tunnel token: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "removed cloudflared tunnel token", "warn")
return cloudflared.Status(), nil
}
func adminExec(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Command string `json:"command"`
Clients []string `json:"clients"`
}
req.BindParams(&params)
if strings.TrimSpace(params.Command) == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Command cannot be empty", nil)
}
if len(params.Clients) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "clients is required", nil)
}
var onlineClients, queuedClients, offlineClients []string
for _, uuid := range params.Clients {
if client := agent_runtime.GetConnectedClients()[uuid]; client != nil {
onlineClients = append(onlineClients, uuid)
} else if agent_runtime.IsAgentOnline(uuid) {
queuedClients = append(queuedClients, uuid)
} else {
offlineClients = append(offlineClients, uuid)
}
}
if len(onlineClients) == 0 && len(queuedClients) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "No clients connected", nil)
}
taskId := utils.GenerateRandomString(16)
taskClients := append(append([]string{}, onlineClients...), queuedClients...)
taskClients = append(taskClients, offlineClients...)
if err := tasks.CreateTask(taskId, taskClients, params.Command); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to create task: "+err.Error(), nil)
}
for _, uuid := range onlineClients {
legacy := struct {
Message string `json:"message"`
Command string `json:"command"`
TaskId string `json:"task_id"`
}{Message: "exec", Command: params.Command, TaskId: taskId}
payload, _ := json.Marshal(legacy)
if agent_runtime.IsV2Client(uuid) {
payload, _ = json.Marshal(v2.Request{JSONRPC: v2.Version, Method: v2.MethodAgentExec, Params: v2.ExecParams{TaskID: taskId, Command: params.Command}})
}
client := agent_runtime.GetConnectedClients()[uuid]
if client == nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Client connection is null: "+uuid, nil)
}
if err := client.WriteMessage(websocket.TextMessage, payload); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Client connection is broke: "+uuid, nil)
}
}
for _, uuid := range queuedClients {
agent_runtime.DispatchV2Event(uuid, v2.MethodAgentExec, v2.ExecParams{TaskID: taskId, Command: params.Command})
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "REC, task id: "+taskId, "warn")
if len(offlineClients) > 0 {
for _, uuid := range offlineClients {
tasks.SaveTaskResult(taskId, uuid, "Client offline!", -1, models.FromTime(time.Now()))
}
}
return map[string]any{
"task_id": taskId,
"clients": onlineClients,
"queued_clients": queuedClients,
}, nil
}
func adminTestSendMessage(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
err := messageSender.SendEvent(models.EventMessage{
Event: "Test",
Message: "This is a test message from Komari.",
})
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to send message: "+err.Error(), nil)
}
return nil, nil
}
func adminTestGeoip(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
IP string `json:"ip"`
}
req.BindParams(&params)
ip := params.IP
if ip == "" {
if meta := rpc.MetaFromContext(ctx); meta != nil {
ip = meta.RemoteIP
}
}
cfg, err := config.GetAs[bool](config.GeoIpEnabledKey, false)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get configuration: "+err.Error(), nil)
}
if !cfg {
return nil, rpc.MakeError(rpc.InvalidParams, "GeoIP is not enabled in the configuration.", nil)
}
record, err := geoip.GetGeoInfo(net.ParseIP(ip))
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get GeoIP record: "+err.Error(), nil)
}
return record, nil
}
+138
View File
@@ -0,0 +1,138 @@
package jsonrpc
import (
"context"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/rpc"
)
// admin.task.go
// 任务查询 RPC2 方法(admin 命名空间)。
func init() {
reg("getTasks", adminGetTasks, "List all exec tasks with results")
reg("getTaskById", adminGetTaskById, "Get an exec task by task_id")
reg("getTasksByClientId", adminGetTasksByClientId, "List tasks assigned to a client")
reg("getSpecificTaskResult", adminGetSpecificTaskResult, "Get a task result for a client")
reg("getTaskResultsByTaskId", adminGetTaskResultsByTaskId, "List results of a task")
}
// projectTaskResults 投影任务结果,保持与原 REST 输出字段一致。
func projectTaskResults(results []models.TaskResult) []map[string]any {
out := []map[string]any{}
for _, r := range results {
out = append(out, map[string]any{
"client": r.Client,
"result": r.Result,
"exit_code": r.ExitCode,
"finished_at": r.FinishedAt,
"created_at": r.CreatedAt,
})
}
return out
}
func adminGetTasks(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
dbTasks, err := tasks.GetAllTasks()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to retrieve tasks: "+err.Error(), nil)
}
responseTasks := []map[string]any{}
for _, t := range dbTasks {
results, err := tasks.GetTaskResultsByTaskId(t.TaskId)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to retrieve task results: "+err.Error(), nil)
}
responseTasks = append(responseTasks, map[string]any{
"task_id": t.TaskId,
"clients": t.Clients,
"command": t.Command,
"results": projectTaskResults(results),
})
}
return responseTasks, nil
}
func adminGetTaskById(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
TaskID string `json:"task_id"`
}
req.BindParams(&params)
if params.TaskID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Task ID is required", nil)
}
task, err := tasks.GetTaskByTaskId(params.TaskID)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to retrieve task: "+err.Error(), nil)
}
if task == nil {
return nil, rpc.MakeError(rpc.NotFound, "Task not found", nil)
}
results, err := tasks.GetTaskResultsByTaskId(params.TaskID)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to retrieve task results: "+err.Error(), nil)
}
return map[string]any{
"task_id": task.TaskId,
"clients": task.Clients,
"command": task.Command,
"results": projectTaskResults(results),
}, nil
}
func adminGetTasksByClientId(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
}
req.BindParams(&params)
if params.UUID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Client ID is required", nil)
}
list, err := tasks.GetTasksByClientId(params.UUID)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to retrieve tasks: "+err.Error(), nil)
}
if len(list) == 0 {
return nil, rpc.MakeError(rpc.NotFound, "No tasks found for this client", nil)
}
return list, nil
}
func adminGetSpecificTaskResult(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
TaskID string `json:"task_id"`
UUID string `json:"uuid"`
}
req.BindParams(&params)
if params.TaskID == "" || params.UUID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Task ID and Client ID are required", nil)
}
result, err := tasks.GetSpecificTaskResult(params.TaskID, params.UUID)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to retrieve task result: "+err.Error(), nil)
}
if result == nil {
return nil, rpc.MakeError(rpc.NotFound, "No result found for this task and client", nil)
}
return result, nil
}
func adminGetTaskResultsByTaskId(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
TaskID string `json:"task_id"`
}
req.BindParams(&params)
if params.TaskID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Task ID is required", nil)
}
results, err := tasks.GetTaskResultsByTaskId(params.TaskID)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to retrieve task results: "+err.Error(), nil)
}
if len(results) == 0 {
return nil, rpc.MakeError(rpc.NotFound, "No results found for this task", nil)
}
return results, nil
}
@@ -1,15 +1,18 @@
package admin
package jsonrpc
import (
"context"
"errors"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/pkg/rpc"
)
// admin.xtermjs.go
// xterm.js 终端设置 RPC2 方法(admin 命名空间),含类型定义与归一化逻辑。
const (
defaultXtermJSFontFamily = "'Cascadia Mono', 'Noto Sans SC', monospace"
defaultXtermJSFontSize = 16
@@ -64,6 +67,45 @@ type ThemeConfig struct {
ExtendedAnsi []string `json:"extendedAnsi,omitempty"`
}
func init() {
reg("getXtermjsSettings", adminGetXtermjs, "Get xterm.js terminal settings")
RegisterWithGroupAndMeta("setXtermjsSettings", rpc.RoleAdmin, adminSetXtermjs, &rpc.MethodMeta{
Name: "admin:setXtermjsSettings",
Summary: "Set xterm.js terminal settings",
})
}
func adminGetXtermjs(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
defaultSettings := defaultXtermJSSettings()
settings, err := config.GetAs[XtermJSSettings](config.XtermjsSettingsKey, defaultSettings)
if err != nil {
_ = config.Set(config.XtermjsSettingsKey, defaultSettings)
settings = defaultSettings
}
normalized, err := normalizeXtermJSSettings(settings)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get xtermjs settings: "+err.Error(), nil)
}
return normalized, nil
}
func adminSetXtermjs(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var settings XtermJSSettings
if err := req.BindParams(&settings); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing request body: "+err.Error(), nil)
}
normalized, err := normalizeXtermJSSettings(settings)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid xtermjs settings: "+err.Error(), nil)
}
if err := config.Set(config.XtermjsSettingsKey, normalized); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to save settings: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "update xtermjs settings", "info")
return normalized, nil
}
func defaultXtermJSSettings() XtermJSSettings {
return XtermJSSettings{
TerminalOptions: &TerminalOptions{
@@ -175,13 +217,9 @@ func normalizeThemeConfig(input *ThemeConfig) (*ThemeConfig, error) {
return normalized, nil
}
func boolPtr(value bool) *bool {
return &value
}
func boolPtr(value bool) *bool { return &value }
func intPtr(value int) *int {
return &value
}
func intPtr(value int) *int { return &value }
func boolOrDefault(value *bool, defaultValue bool) *bool {
if value == nil {
@@ -191,18 +229,13 @@ func boolOrDefault(value *bool, defaultValue bool) *bool {
}
func cleanThemeColor(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
return trimmed
return strings.TrimSpace(value)
}
func themeIsEmpty(theme *ThemeConfig) bool {
if theme == nil {
return true
}
return theme.Foreground == "" &&
theme.Background == "" &&
theme.Cursor == "" &&
@@ -228,43 +261,3 @@ func themeIsEmpty(theme *ThemeConfig) bool {
theme.BrightWhite == "" &&
len(theme.ExtendedAnsi) == 0
}
func GetXtermJSSettings(c *gin.Context) {
defaultSettings := defaultXtermJSSettings()
settings, err := config.GetAs[XtermJSSettings](config.XtermjsSettingsKey, defaultSettings)
if err != nil {
_ = config.Set(config.XtermjsSettingsKey, defaultSettings)
settings = defaultSettings
}
normalized, err := normalizeXtermJSSettings(settings)
if err != nil {
api.RespondError(c, 500, "Failed to get xtermjs settings: "+err.Error())
return
}
api.RespondSuccess(c, normalized)
}
func SetXtermJSSettings(c *gin.Context) {
var settings XtermJSSettings
if err := c.ShouldBindJSON(&settings); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
normalized, err := normalizeXtermJSSettings(settings)
if err != nil {
api.RespondError(c, 400, "Invalid xtermjs settings: "+err.Error())
return
}
if err := config.Set(config.XtermjsSettingsKey, normalized); err != nil {
api.RespondError(c, 500, "Failed to save settings: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "update xtermjs settings", "info")
api.RespondSuccessMessage(c, "settings saved", nil)
}
+101
View File
@@ -0,0 +1,101 @@
package jsonrpc
import (
"encoding/json"
"testing"
"github.com/komari-monitor/komari/pkg/rpc"
)
// xtermjs 设置归一化逻辑的单元测试(迁移自 web/api/admin/settings_xtermjs_test.go,
// 改为直接测试归一化函数,不依赖 HTTP 层)。
func TestNormalizeXtermJSDefaults(t *testing.T) {
// 空输入(TerminalOptions 为 nil)应回退到默认值的非 options 部分。
out, err := normalizeXtermJSSettings(XtermJSSettings{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.TerminalOptions == nil {
t.Fatal("expected default TerminalOptions")
}
if out.TerminalOptions.FontFamily != defaultXtermJSFontFamily {
t.Errorf("font family = %q, want default", out.TerminalOptions.FontFamily)
}
if out.TerminalPadding == nil || *out.TerminalPadding != defaultXtermJSPadding {
t.Error("expected default padding")
}
}
func TestNormalizeXtermJSFallbacks(t *testing.T) {
neg := -1
in := XtermJSSettings{
TerminalOptions: &TerminalOptions{
FontFamily: " ", // 空白 -> 默认
FontSize: 0, // 非法 -> 默认
Scrollback: &neg, // 负数 -> 默认
Theme: &ThemeConfig{Foreground: " "}, // 全空 -> nil
},
TerminalPadding: &neg, // 负数 -> 保持默认
}
out, err := normalizeXtermJSSettings(in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.TerminalOptions.FontFamily != defaultXtermJSFontFamily {
t.Errorf("blank font family should fall back to default, got %q", out.TerminalOptions.FontFamily)
}
if out.TerminalOptions.FontSize != defaultXtermJSFontSize {
t.Errorf("invalid font size should fall back to default, got %d", out.TerminalOptions.FontSize)
}
if *out.TerminalOptions.Scrollback != defaultXtermJSScrollback {
t.Errorf("negative scrollback should fall back to default")
}
if out.TerminalOptions.Theme != nil {
t.Error("all-empty theme should normalize to nil")
}
if *out.TerminalPadding != defaultXtermJSPadding {
t.Error("negative padding should fall back to default")
}
}
func TestNormalizeXtermJSExtendedAnsiRejectsEmpty(t *testing.T) {
_, err := normalizeXtermJSSettings(XtermJSSettings{
TerminalOptions: &TerminalOptions{Theme: &ThemeConfig{ExtendedAnsi: []string{" "}}},
})
if err == nil {
t.Fatal("expected error for empty extendedAnsi entry")
}
}
func TestNormalizeXtermJSThemeRoundTrip(t *testing.T) {
in := XtermJSSettings{
TerminalOptions: &TerminalOptions{
FontFamily: "JetBrains Mono",
FontSize: 18,
Theme: &ThemeConfig{Foreground: "#ffffff", ExtendedAnsi: []string{"#111111"}},
},
}
out, err := normalizeXtermJSSettings(in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.TerminalOptions.Theme == nil || out.TerminalOptions.Theme.Foreground != "#ffffff" {
t.Error("theme foreground should survive normalization")
}
// 序列化不应包含未知字段(ThemeConfig 是白名单结构)。
b, _ := json.Marshal(out)
if len(b) == 0 {
t.Fatal("marshal failed")
}
}
func TestXtermjsPermission(t *testing.T) {
// xtermjs 方法属于 admin 命名空间。
if rpc.CheckPermission(rpc.RoleGuest, "admin:getXtermjsSettings") {
t.Error("guest must not access admin:getXtermjsSettings")
}
if !rpc.CheckPermission(rpc.RoleAdmin, "admin:setXtermjsSettings") {
t.Error("admin must access admin:setXtermjsSettings")
}
}
+152
View File
@@ -0,0 +1,152 @@
package jsonrpc
import (
"encoding/json"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/pkg/rpc"
)
// bridge.go
// 声明式路由桥:把一个 gin 路由直接绑定到 RPC2 方法,无需手写 handler。
// 负责从 gin 请求装配参数、调用 RPC、并按指定渲染器把响应映射回原有 HTTP/JSON 契约。
// renderKind 决定成功响应如何映射回 HTTP body。
type renderKind int
const (
// renderStandard: { "status":"success", "message":<msg>, "data":<result> }(api.Respond 约定)
renderStandard renderKind = iota
// renderFlat: 把 result(map) 平铺到顶层,并附加 { "status":"success" }
renderFlat
// renderRaw: 直接输出 result 作为 body,无任何包装
renderRaw
)
type bindConfig struct {
render renderKind
successMsg string
pathParams []string // 合并到参数对象的路径参数名
queryParams []string // 合并到参数对象的查询参数名
}
// BindOption 配置 Bind 行为。
type BindOption func(*bindConfig)
// WithFlat 使成功响应把 result(map) 平铺到顶层(如 { status, uuid, token })。
func WithFlat() BindOption { return func(c *bindConfig) { c.render = renderFlat } }
// WithRaw 使成功响应直接输出 result,无包装(用于 agent 裸 JSON 接口)。
func WithRaw() BindOption { return func(c *bindConfig) { c.render = renderRaw } }
// WithMessage 使成功响应带固定 message(standard 渲染)。
func WithMessage(msg string) BindOption {
return func(c *bindConfig) { c.successMsg = msg }
}
// WithPath 声明要合并进参数对象的路径参数(gin c.Param)。
func WithPath(names ...string) BindOption {
return func(c *bindConfig) { c.pathParams = append(c.pathParams, names...) }
}
// WithQuery 声明要合并进参数对象的查询参数(gin c.Query)。
func WithQuery(names ...string) BindOption {
return func(c *bindConfig) { c.queryParams = append(c.queryParams, names...) }
}
// Bind 返回一个 gin.HandlerFunc,把请求转发到指定 RPC 方法。
func Bind(method string, opts ...BindOption) gin.HandlerFunc {
cfg := &bindConfig{render: renderStandard}
for _, o := range opts {
o(cfg)
}
return func(c *gin.Context) {
params, ok := assembleParams(c, cfg)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Invalid or missing request body"})
return
}
resp := CallFromGin(c, method, params)
renderResponse(c, cfg, resp)
}
}
// assembleParams 从 body + path + query 装配 RPC 参数。
// body 为 JSON 对象时与 path/query 合并为一个对象;body 为数组(且无 path/query)时直接透传数组。
// 返回 ok=false 表示请求体存在但无法解析为合法 JSON(畸形 body)。
func assembleParams(c *gin.Context, cfg *bindConfig) (any, bool) {
var bodyVal any
if c.Request.Body != nil {
if raw, err := io.ReadAll(c.Request.Body); err == nil && len(raw) > 0 {
if err := json.Unmarshal(raw, &bodyVal); err != nil {
return nil, false
}
}
}
// 数组 body 且无附加参数:直接透传。
if arr, ok := bodyVal.([]any); ok && len(cfg.pathParams) == 0 && len(cfg.queryParams) == 0 {
return arr, true
}
obj := map[string]any{}
if m, ok := bodyVal.(map[string]any); ok {
for k, v := range m {
obj[k] = v
}
}
for _, name := range cfg.pathParams {
if v := c.Param(name); v != "" {
obj[name] = v
}
}
for _, name := range cfg.queryParams {
if v := c.Query(name); v != "" {
obj[name] = v
}
}
return obj, true
}
// rpcErrorHTTPStatus 将 JSON-RPC 错误码映射为合适的 HTTP 状态码。
func rpcErrorHTTPStatus(code int) int {
switch code {
case rpc.InvalidParams, rpc.InvalidRequest, rpc.ParseError:
return http.StatusBadRequest
case rpc.PermissionDenied, rpc.Unauthenticated:
return http.StatusUnauthorized
case rpc.NotFound:
return http.StatusNotFound
default:
return http.StatusInternalServerError
}
}
func renderResponse(c *gin.Context, cfg *bindConfig, resp *rpc.JsonRpcResponse) {
if resp.Error != nil {
// 统一错误形状:{ status:"error", message } —— 与 api.RespondError 一致。
c.JSON(rpcErrorHTTPStatus(resp.Error.Code), gin.H{"status": "error", "message": resp.Error.Message})
return
}
switch cfg.render {
case renderRaw:
c.JSON(http.StatusOK, resp.Result)
case renderFlat:
out := gin.H{"status": "success"}
if m, ok := resp.Result.(map[string]any); ok {
for k, v := range m {
out[k] = v
}
}
c.JSON(http.StatusOK, out)
default: // renderStandard
// 与 api.Response 一致:data 为 nil 时省略该字段(omitempty 语义)。
out := gin.H{"status": "success", "message": cfg.successMsg}
if resp.Result != nil {
out["data"] = resp.Result
}
c.JSON(http.StatusOK, out)
}
}
+86
View File
@@ -0,0 +1,86 @@
package jsonrpc
import (
"context"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/rpc"
)
// client.go
// 客户端(agent)面向的 RPC2 方法(client 命名空间,client/admin 可调用)。
// 这些方法依赖 meta.ClientUUID 标识调用方客户端。
func init() {
regClient("getPingTasks", clientGetPingTasks, "Get ping tasks assigned to the calling client")
regClient("uploadPingResult", clientUploadPingResult, "Upload a ping result")
regClient("taskResult", clientTaskResult, "Upload an exec task result")
}
func regClient(name string, h rpc.Handler, summary string) {
RegisterWithGroupAndMeta(name, rpc.RoleClient, h, &rpc.MethodMeta{Name: "client:" + name, Summary: summary})
}
// callingClientUUID 返回发起调用的客户端 UUID。
func callingClientUUID(ctx context.Context) string {
if meta := rpc.MetaFromContext(ctx); meta != nil {
return meta.ClientUUID
}
return ""
}
func clientGetPingTasks(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
uuid := callingClientUUID(ctx)
if uuid == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "client_uuid not found", nil)
}
return tasks.GetPingTasksByClient(uuid), nil
}
func clientUploadPingResult(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
uuid := callingClientUUID(ctx)
if uuid == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "client_uuid not found", nil)
}
var params struct {
TaskID uint `json:"task_id"`
Value int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at"`
}
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request: "+err.Error(), nil)
}
record := models.PingRecord{
Client: uuid,
TaskId: params.TaskID,
Value: params.Value,
Time: models.FromTime(params.FinishedAt),
}
if err := tasks.SavePingRecord(record); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to save ping result: "+err.Error(), nil)
}
return map[string]any{"status": "success"}, nil
}
func clientTaskResult(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
uuid := callingClientUUID(ctx)
if uuid == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing token", nil)
}
var params struct {
TaskId string `json:"task_id"`
Result string `json:"result"`
ExitCode int `json:"exit_code"`
FinishedAt time.Time `json:"finished_at"`
}
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request", nil)
}
if err := tasks.SaveTaskResult(params.TaskId, uuid, params.Result, params.ExitCode, models.FromTime(params.FinishedAt)); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to update task result: "+err.Error(), nil)
}
return map[string]any{"status": "success", "message": "Task result updated successfully"}, nil
}
+46
View File
@@ -0,0 +1,46 @@
package jsonrpc
import (
"context"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/rpc"
)
// Dispatch 是所有传输入口的统一分发点:私有站点检查 → 权限校验 → 执行方法。
// ctx 携带可选的取消/超时;meta 为调用者身份元数据(其中 Permission 为权限分组)。
// 始终返回完整的 JsonRpcResponse(包含错误)。
func Dispatch(ctx context.Context, meta *rpc.ContextMeta, req *rpc.JsonRpcRequest) *rpc.JsonRpcResponse {
if ctx == nil {
ctx = context.Background()
}
if meta == nil {
meta = &rpc.ContextMeta{Permission: rpc.RoleGuest}
}
group := meta.Permission
if group == "" {
group = rpc.RoleGuest
}
// 私有站点:未登录访客一律拒绝。
if group == rpc.RoleGuest {
if privateSite, _ := config.GetAs[bool](config.PrivateSiteKey); privateSite {
return rpc.ErrorResponse(req.ID, rpc.PermissionDenied, "Private site enabled, please login first", nil)
}
}
// 命名空间权限校验。
if !rpc.CheckPermission(group, req.Method) {
return rpc.ErrorResponse(req.ID, rpc.PermissionDenied, "Permission denied", nil)
}
return rpc.CallWithContext(rpc.NewContextWithMeta(ctx, meta), req.ID, req.Method, req.Params)
}
// OnInternalRequest 内部调用 RPC 方法(如服务端代码代发请求),仅携带权限分组。
// group: 调用者权限分组 (guest/client/admin);method: "namespace:method";params: 参数。
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
meta := &rpc.ContextMeta{Permission: group}
req := &rpc.JsonRpcRequest{Version: rpc.RPC_VERSION, Method: method, Params: params}
return Dispatch(ctx, meta, req)
}
+452
View File
@@ -0,0 +1,452 @@
package jsonrpc
import (
"context"
"strconv"
"time"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/rpc"
"github.com/komari-monitor/komari/utils"
report_cache "github.com/komari-monitor/komari/web/report"
)
// public.go
// 公开(guest 可访问)的只读 RPC2 方法。命名空间 public:* 对 guest 开放。
// 这些方法保持与原 REST 接口完全一致的响应形状。
func init() {
rpc.Allow("public:*", rpc.RoleGuest)
regPublic("getMe", publicGetMe, "Get current user info (guest-aware)")
regPublic("getNodesInformation", publicGetNodesInformation, "List visible nodes (basic info)")
regPublic("getPublicSettings", publicGetPublicSettings, "Get public site settings")
regPublic("getVersion", publicGetVersion, "Get server version")
regPublic("getClientRecentRecords", publicGetClientRecentRecords, "Get a client's recent records")
regPublic("getRecordsByUUID", publicGetRecordsByUUID, "Get load records for a client")
regPublic("getPingRecords", publicGetPingRecords, "Get ping records")
regPublic("getPublicPingTasks", publicGetPublicPingTasks, "List public ping tasks")
}
func regPublic(name string, h rpc.Handler, summary string) {
RegisterWithGroupAndMeta(name, "public", h, &rpc.MethodMeta{Name: "public:" + name, Summary: summary})
}
// isLoginFromCtx 依据 meta 判断是否为已登录管理员。
func isLoginFromCtx(ctx context.Context) bool {
if meta := rpc.MetaFromContext(ctx); meta != nil {
return meta.Permission == rpc.RoleAdmin
}
return false
}
func publicGetNodesInformation(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
clientList, err := clients.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to retrieve client information: "+err.Error(), nil)
}
isLogin := isLoginFromCtx(ctx)
j := 0
for i := 0; i < len(clientList); i++ {
if clientList[i].Hidden && !isLogin {
continue
}
clientList[i].IPv4 = ""
clientList[i].IPv6 = ""
clientList[i].Remark = ""
clientList[i].Version = ""
clientList[i].Token = ""
clientList[j] = clientList[i]
j++
}
clientList = clientList[:j]
return clientList, nil
}
func publicGetPublicSettings(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
p, e := database.GetPublicInfo()
if e != nil {
return nil, rpc.MakeError(rpc.InternalError, e.Error(), nil)
}
// 临时访问许可由 transport 层在 meta 标注;此处沿用原逻辑判断 temp_key。
if meta := rpc.MetaFromContext(ctx); meta != nil && meta.TempShareValid {
p["private_site"] = false
}
return p, nil
}
func publicGetVersion(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return map[string]any{
"version": utils.CurrentVersion,
"hash": utils.VersionHash,
}, nil
}
// publicGetMe 返回当前用户信息;未登录时返回 Guest 占位,保持原 /api/me 的扁平形状。
func publicGetMe(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
guest := map[string]any{"username": "Guest", "logged_in": false}
meta := rpc.MetaFromContext(ctx)
if meta == nil || meta.User == nil {
return guest, nil
}
u := meta.User
return map[string]any{
"username": u.Username,
"logged_in": true,
"uuid": u.UUID,
"sso_type": u.SSOType,
"sso_id": u.SSOID,
"2fa_enabled": u.TwoFactor != "",
}, nil
}
func publicGetClientRecentRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
}
req.BindParams(&params)
if params.UUID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID is required", nil)
}
if !isLoginFromCtx(ctx) && isHiddenClient(params.UUID) {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID is required", nil) // 防止未登录获取隐藏客户端
}
recs, _ := report_cache.Records.Get(params.UUID)
return recs, nil
}
// isHiddenClient 查询指定 uuid 是否为隐藏节点。
func isHiddenClient(uuid string) bool {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
if cli.UUID == uuid {
return true
}
}
return false
}
func publicGetRecordsByUUID(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
LoadType string `json:"load_type"`
Hours string `json:"hours"`
}
req.BindParams(&params)
isLogin := isLoginFromCtx(ctx)
if !isLogin && params.UUID != "" && isHiddenClient(params.UUID) {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID is required", nil)
}
if params.UUID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID is required", nil)
}
hours := params.Hours
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid hours parameter", nil)
}
validLoadTypes := map[string]bool{
"cpu": true, "ram": true, "swap": true,
"load": true, "temp": true, "disk": true, "network": true,
"process": true, "connections": true, "all": true, "": true,
}
if !validLoadTypes[params.LoadType] {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid load_type parameter", nil)
}
clientRecords, err := records.GetRecordsByClientAndTime(params.UUID, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch records: "+err.Error(), nil)
}
response := map[string]any{
"records": clientRecords,
"count": len(clientRecords),
}
if params.LoadType != "" && params.LoadType != "all" {
filtered := filterPublicRecordsByLoadType(clientRecords, params.LoadType)
response = map[string]any{
"records": filtered,
"count": len(filtered),
"load_type": params.LoadType,
}
}
if params.LoadType == "" || params.LoadType == "all" || params.LoadType == "gpu" {
gpuRecords, err := records.GetGPURecordsByClientAndTime(params.UUID, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err == nil && len(gpuRecords) > 0 {
gpuDevices := make(map[string]any)
for _, record := range gpuRecords {
deviceKey := strconv.Itoa(record.DeviceIndex)
if gpuDevices[deviceKey] == nil {
gpuDevices[deviceKey] = map[string]any{
"device_index": record.DeviceIndex,
"device_name": record.DeviceName,
"records": []models.GPURecord{},
}
}
device := gpuDevices[deviceKey].(map[string]any)
recs := device["records"].([]models.GPURecord)
device["records"] = append(recs, record)
gpuDevices[deviceKey] = device
}
response["gpu_devices"] = gpuDevices
response["has_gpu_data"] = true
} else {
response["has_gpu_data"] = false
}
}
return response, nil
}
func publicGetPublicPingTasks(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
type publicPingTask struct {
Id uint `json:"id"`
Name string `json:"name"`
Clients []string `json:"clients"`
DefaultOn bool `json:"default_on"`
Type string `json:"type"`
Interval int `json:"interval"`
}
out := make([]publicPingTask, len(pingTasks))
for i, task := range pingTasks {
out[i] = publicPingTask{
Id: task.Id,
Name: task.Name,
Clients: task.Clients,
DefaultOn: task.DefaultOn,
Type: task.Type,
Interval: task.Interval,
}
}
return out, nil
}
// filterPublicRecordsByLoadType 复刻原 public 接口的字段投影逻辑。
func filterPublicRecordsByLoadType(recs []models.Record, loadType string) []map[string]any {
out := make([]map[string]any, 0, len(recs))
for _, r := range recs {
record := map[string]any{"client": r.Client, "time": r.Time}
switch loadType {
case "cpu":
record["cpu"] = r.Cpu
case "gpu":
record["gpu"] = r.Gpu
case "ram":
record["ram"] = r.Ram
record["ram_total"] = r.RamTotal
if r.RamTotal > 0 {
record["ram_percent"] = float32(r.Ram) / float32(r.RamTotal) * 100
}
case "swap":
record["swap"] = r.Swap
record["swap_total"] = r.SwapTotal
if r.SwapTotal > 0 {
record["swap_percent"] = float32(r.Swap) / float32(r.SwapTotal) * 100
}
case "load":
record["load"] = r.Load
case "temp":
record["temp"] = r.Temp
case "disk":
record["disk"] = r.Disk
record["disk_total"] = r.DiskTotal
if r.DiskTotal > 0 {
record["disk_percent"] = float32(r.Disk) / float32(r.DiskTotal) * 100
}
case "network":
record["net_in"] = r.NetIn
record["net_out"] = r.NetOut
record["net_total_up"] = r.NetTotalUp
record["net_total_down"] = r.NetTotalDown
case "process":
record["process"] = r.Process
case "connections":
record["connections"] = r.Connections
record["connections_udp"] = r.ConnectionsUdp
record["connections_tcp"] = r.Connections - r.ConnectionsUdp
}
out = append(out, record)
}
return out
}
// PUBLIC_PING_RECORDS_PLACEHOLDER
func publicGetPingRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
TaskID string `json:"task_id"`
Hours string `json:"hours"`
}
req.BindParams(&params)
if params.UUID == "" && params.TaskID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID or task_id is required", nil)
}
isLogin := isLoginFromCtx(ctx)
type recordsResp struct {
TaskId uint `json:"task_id,omitempty"`
Time string `json:"time"`
Value int `json:"value"`
Client string `json:"client,omitempty"`
}
type clientBasicInfo struct {
Client string `json:"client"`
Loss float64 `json:"loss"`
Min int `json:"min"`
Max int `json:"max"`
}
type resp struct {
Count int `json:"count"`
BasicInfo []clientBasicInfo `json:"basic_info,omitempty"`
Records []recordsResp `json:"records"`
Tasks []map[string]any `json:"tasks,omitempty"`
}
hiddenMap := map[string]bool{}
response := &resp{Count: 0, Records: []recordsResp{}}
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if params.UUID != "" && hiddenMap[params.UUID] {
return response, nil // 对尝试获取隐藏 uuid 返回空
}
}
hours := params.Hours
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
hoursInt = 4
}
endTime := time.Now()
startTime := endTime.Add(-time.Duration(hoursInt) * time.Hour)
taskId := -1
if params.TaskID != "" {
taskId, err = strconv.Atoi(params.TaskID)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid task_id parameter", nil)
}
}
recs, err := tasks.GetPingRecords(params.UUID, taskId, startTime, endTime)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping records: "+err.Error(), nil)
}
clientStats := make(map[string]struct {
total, loss, min, max int
})
for _, r := range recs {
if r.Client != "" && !isLogin && hiddenMap[r.Client] {
continue
}
rec := recordsResp{Time: r.Time.ToTime().Format(time.RFC3339), Value: r.Value, Client: r.Client, TaskId: r.TaskId}
stats := clientStats[r.Client]
stats.total++
if r.Value < 0 {
stats.loss++
} else {
if stats.min == 0 || r.Value < stats.min {
stats.min = r.Value
}
if r.Value > stats.max {
stats.max = r.Value
}
}
clientStats[r.Client] = stats
response.Records = append(response.Records, rec)
}
if len(clientStats) > 0 {
response.BasicInfo = make([]clientBasicInfo, 0, len(clientStats))
for client, stats := range clientStats {
if client != "" && !isLogin && hiddenMap[client] {
continue
}
loss := float64(0)
if stats.total > 0 {
loss = float64(stats.loss) / float64(stats.total) * 100
}
response.BasicInfo = append(response.BasicInfo, clientBasicInfo{Client: client, Loss: loss, Min: stats.min, Max: stats.max})
}
}
if params.UUID != "" || taskId != -1 {
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping tasks: "+err.Error(), nil)
}
tasksList := make([]map[string]any, 0, len(pingTasks))
for _, t := range pingTasks {
if taskId != -1 && t.Id != uint(taskId) {
continue
}
if params.UUID != "" && !t.AppliesToClient(params.UUID) {
continue
}
totalCount, lossCount, minLatency, maxLatency, sumLatency, validCount := 0, 0, 0, 0, 0, 0
for _, r := range recs {
if r.TaskId != t.Id {
continue
}
if params.UUID != "" && r.Client != params.UUID {
continue
}
totalCount++
if r.Value < 0 {
lossCount++
} else {
validCount++
sumLatency += r.Value
if minLatency == 0 || r.Value < minLatency {
minLatency = r.Value
}
if r.Value > maxLatency {
maxLatency = r.Value
}
}
}
lossRate := float64(0)
if totalCount > 0 {
lossRate = float64(lossCount) / float64(totalCount) * 100
}
avgLatency := 0
if validCount > 0 {
avgLatency = sumLatency / validCount
}
taskInfo := map[string]any{
"id": t.Id, "name": t.Name, "type": t.Type, "interval": t.Interval,
"default_on": t.DefaultOn, "loss": lossRate, "min": minLatency,
"max": maxLatency, "avg": avgLatency, "total": totalCount,
}
if params.UUID == "" && taskId != -1 {
taskInfo["clients"] = t.Clients
}
tasksList = append(tasksList, taskInfo)
}
response.Tasks = tasksList
}
response.Count = len(response.Records)
return response, nil
}
+21
View File
@@ -0,0 +1,21 @@
package jsonrpc
import "github.com/komari-monitor/komari/pkg/rpc"
// Register 以默认分组 "common" 注册方法。
func Register(name string, cb rpc.Handler) error {
return RegisterWithGroupAndMeta(name, "common", cb, &rpc.MethodMeta{
Name: name,
Summary: "This method does not provide a summary",
Description: "This method does not provide a description",
})
}
// RegisterWithGroupAndMeta 将回调按分组注册为 "group:name",并附加元数据。
// group 为空时使用默认分组 "common"。
func RegisterWithGroupAndMeta(name, group string, cb rpc.Handler, meta *rpc.MethodMeta) error {
if group == "" {
group = "common"
}
return rpc.RegisterWithMeta(group+":"+name, cb, meta)
}
+194
View File
@@ -0,0 +1,194 @@
package jsonrpc
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/rpc"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/connection"
)
// OnRpcRequest 是 /api/rpc2 的统一入口:GET 升级为 WebSocket,POST 处理单条/批量 JSON-RPC。
func OnRpcRequest(c *gin.Context) {
// GET -> WebSocket
if c.Request.Method == http.MethodGet {
serveWebSocket(c)
return
}
if c.Request.Method != http.MethodPost {
c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "method not allowed"})
return
}
servePost(c)
}
// CallFromGin 供传统 gin handler / 路由桥转调 RPC 方法。
// 优先采用 IdentityMiddleware 已确立的角色(c.GetString("role")),其与路由的 RequireRole 一致;
// 缺失时回退到 detectPermissionGroup 自行识别。再经统一 Dispatch 执行。
func CallFromGin(c *gin.Context, method string, params any) *rpc.JsonRpcResponse {
group := c.GetString("role")
if group == "" {
group = detectPermissionGroup(c)
}
meta := buildContextMeta(c, group)
// API Key 等身份下 buildContextMeta 拿不到用户 UUID,回退到中间件已识别的值,
// 保证审计日志的 actor 与原有 REST 行为一致。
if meta.UserUUID == "" {
if v, ok := c.Get("uuid"); ok {
if s, ok := v.(string); ok {
meta.UserUUID = s
}
}
}
// 客户端 token 经 ?token= / body 传入时 buildContextMeta 取不到,
// 回退到 IdentityMiddleware 已解析的 client_uuid。
if meta.ClientUUID == "" {
if v, ok := c.Get("client_uuid"); ok {
if s, ok := v.(string); ok {
meta.ClientUUID = s
}
}
}
req := &rpc.JsonRpcRequest{Version: rpc.RPC_VERSION, Method: method, Params: params}
return Dispatch(c.Request.Context(), meta, req)
}
func serveWebSocket(c *gin.Context) {
_conn, err := api.UpgradeWebSocket(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
conn := connection.NewSafeConn(_conn)
defer conn.Close()
permissionGroup := detectPermissionGroup(c)
meta := buildContextMeta(c, permissionGroup)
for {
var req rpc.JsonRpcRequest
if err := conn.ReadJSON(&req); err != nil {
var se *json.SyntaxError
var ute *json.UnmarshalTypeError
if errors.As(err, &se) || errors.As(err, &ute) {
conn.WriteJSON(rpc.ErrorResponse(nil, rpc.InvalidRequest, "bad request: "+err.Error(), nil))
continue
}
// 其它视为连接/IO 错误,结束循环
break
}
if jerr := req.Validate(); jerr != nil {
conn.WriteJSON(jerr.ResponseWithID(req.ID))
continue
}
// 同步写:SafeConn 内部有锁,串行写避免响应乱序与并发竞态。
conn.WriteJSON(Dispatch(context.Background(), meta, &req))
}
}
func servePost(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, rpc.ErrorResponse(nil, rpc.ParseError, "read body error", err.Error()))
return
}
requests, jerr := rpc.ParseRequests(body)
if jerr != nil {
c.JSON(http.StatusBadRequest, jerr.Response())
return
}
permissionGroup := detectPermissionGroup(c)
meta := buildContextMeta(c, permissionGroup)
responses := make([]*rpc.JsonRpcResponse, 0, len(requests))
for _, rreq := range requests {
responses = append(responses, Dispatch(c.Request.Context(), meta, rreq))
}
// 单条直接对象,批量数组(符合 JSON-RPC 2.0)。
if len(responses) == 1 {
c.JSON(http.StatusOK, responses[0])
} else {
c.JSON(http.StatusOK, responses)
}
}
// detectPermissionGroup 识别请求者权限分组 (guest/client/admin)。
func detectPermissionGroup(c *gin.Context) string {
permissionGroup := rpc.RoleGuest
token := c.Query("Authorization")
if _, err := clients.GetClientUUIDByToken(token); err == nil {
permissionGroup = rpc.RoleClient
}
if sessionToken, _ := c.Cookie("session_token"); sessionToken != "" {
if _, err := accounts.GetUserBySession(sessionToken); err == nil {
permissionGroup = rpc.RoleAdmin
}
}
apiKey := c.GetHeader("Authorization")
if len(apiKey) <= len("Bearer ") {
return permissionGroup
}
cfg, _ := config.GetAs[string](config.ApiKeyKey, "")
if len(cfg) > 8 && apiKey == "Bearer "+cfg {
permissionGroup = rpc.RoleAdmin
}
return permissionGroup
}
// buildContextMeta 从 gin.Context 构建 *rpc.ContextMeta。
func buildContextMeta(c *gin.Context, permissionGroup string) *rpc.ContextMeta {
meta := &rpc.ContextMeta{Permission: permissionGroup}
// 客户端 token:query Authorization 或 header Bearer。
token := c.Query("Authorization")
if token == "" {
hAuth := c.GetHeader("Authorization")
if strings.HasPrefix(hAuth, "Bearer ") {
token = strings.TrimPrefix(hAuth, "Bearer ")
}
}
if token != "" {
if uuid, err := clients.GetClientUUIDByToken(token); err == nil {
meta.ClientToken = token
meta.ClientUUID = uuid
}
}
// 用户身份:session cookie。
if sessionToken, _ := c.Cookie("session_token"); sessionToken != "" {
meta.SessionToken = sessionToken
if user, err := accounts.GetUserBySession(sessionToken); err == nil {
meta.User = &user
meta.UserUUID = user.UUID
}
}
meta.RemoteIP = c.ClientIP()
meta.UserAgent = c.GetHeader("User-Agent")
meta.TempShareValid = hasTempShareAccess(c)
return meta
}
// hasTempShareAccess 校验 temp_key cookie 是否为有效的临时分享访问许可。
func hasTempShareAccess(c *gin.Context) bool {
tempKey, err := c.Cookie("temp_key")
if err != nil || tempKey == "" {
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 || allowKey == "" || tempKey != allowKey {
return false
}
return expireAt >= time.Now().Unix()
}