mirror of
https://github.com/komari-monitor/komari.git
synced 2026-10-29 18:11:42 +00:00
Merge pull request #550 from komari-monitor/refactor/rpc2-unified-pipeline
refactor(rpc): 统一 RPC2 管道并全量迁移 REST 接口
This commit is contained in:
58 files changed
+3221
-3253
No files matched your search
@@ -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,避免外部冲突
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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:": ""})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
// }
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(¬ifications); 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(¬ifications).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(¬ifications); 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
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -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, ¶ms); 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, ¶ms); 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
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 != ""})
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
// ... 业务逻辑
|
||||
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`。
|
||||
@@ -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(¶ms)
|
||||
|
||||
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(¶ms)
|
||||
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(¶ms)
|
||||
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(¶ms)
|
||||
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
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
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(¶ms)
|
||||
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(¶ms)
|
||||
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
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
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
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
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(¶ms)
|
||||
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(¶ms); 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(¬ifications).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(¬ifications); 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(¬ifications); 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
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
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(¶ms)
|
||||
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(¶ms)
|
||||
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
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
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(¶ms)
|
||||
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
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
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(¶ms)
|
||||
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(¶ms)
|
||||
|
||||
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(¶ms)
|
||||
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(¶ms)
|
||||
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
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
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(¶ms)
|
||||
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(¶ms)
|
||||
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(¶ms)
|
||||
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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms); 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(¶ms); 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
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(¶ms)
|
||||
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(¶ms)
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user