diff --git a/cmd/dashboard/controller/api_v1.go b/cmd/dashboard/controller/api_v1.go new file mode 100644 index 0000000..999fd92 --- /dev/null +++ b/cmd/dashboard/controller/api_v1.go @@ -0,0 +1,67 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + "github.com/naiba/nezha/pkg/mygin" + "github.com/naiba/nezha/service/singleton" + "strconv" + "strings" +) + +type apiV1 struct { + r gin.IRouter +} + +func (v *apiV1) serve() { + r := v.r.Group("") + // API + r.Use(mygin.Authorize(mygin.AuthorizeOption{ + Member: true, + IsPage: false, + AllowAPI: true, + Msg: "访问此接口需要认证", + Btn: "点此登录", + Redirect: "/login", + })) + r.GET("/server/list", v.serverList) + r.GET("/server/details", v.serverDetails) + +} + +// serverList 获取服务器列表 不传入Query参数则获取全部 +// header: Authorization: Token +// query: tag (服务器分组) +func (v *apiV1) serverList(c *gin.Context) { + tag := c.Query("tag") + if tag != "" { + c.JSON(200, singleton.ServerAPI.GetListByTag(tag)) + return + } + c.JSON(200, singleton.ServerAPI.GetAllList()) +} + +// serverDetails 获取服务器信息 不传入Query参数则获取全部 +// header: Authorization: Token +// query: id (服务器ID,逗号分隔,优先级高于tag查询) +// query: tag (服务器分组) +func (v *apiV1) serverDetails(c *gin.Context) { + var idList []uint64 + idListStr := strings.Split(c.Query("id"), ",") + if c.Query("id") != "" { + idList = make([]uint64, len(idListStr)) + for i, v := range idListStr { + id, _ := strconv.ParseUint(v, 10, 64) + idList[i] = id + } + } + tag := c.Query("tag") + if tag != "" { + c.JSON(200, singleton.ServerAPI.GetStatusByTag(tag)) + return + } + if len(idList) != 0 { + c.JSON(200, singleton.ServerAPI.GetStatusByIDList(idList)) + return + } + c.JSON(200, singleton.ServerAPI.GetAllStatus()) +} diff --git a/cmd/dashboard/controller/member_api.go b/cmd/dashboard/controller/member_api.go index 1b4f2c8..c446e71 100644 --- a/cmd/dashboard/controller/member_api.go +++ b/cmd/dashboard/controller/member_api.go @@ -43,6 +43,120 @@ func (ma *memberAPI) serve() { mr.POST("/setting", ma.updateSetting) mr.DELETE("/:model/:id", ma.delete) mr.POST("/logout", ma.logout) + mr.GET("/token", ma.getToken) + mr.POST("/token", ma.issueNewToken) + mr.DELETE("/token/:token", ma.deleteToken) + + // API + v1 := ma.r.Group("v1") + { + apiv1 := &apiV1{v1} + apiv1.serve() + } +} + +type apiResult struct { + Token string `json:"token"` + Note string `json:"note"` +} + +// getToken 获取 Token +func (ma *memberAPI) getToken(c *gin.Context) { + u := c.MustGet(model.CtxKeyAuthorizedUser).(*model.User) + singleton.ApiLock.RLock() + defer singleton.ApiLock.RUnlock() + + tokenList := singleton.UserIDToApiTokenList[u.ID] + res := make([]*apiResult, len(tokenList)) + for i, token := range tokenList { + res[i] = &apiResult{ + Token: token, + Note: singleton.ApiTokenList[token].Note, + } + } + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "message": "success", + "result": res, + }) +} + +type TokenForm struct { + Note string +} + +// issueNewToken 生成新的 token +func (ma *memberAPI) issueNewToken(c *gin.Context) { + u := c.MustGet(model.CtxKeyAuthorizedUser).(*model.User) + tf := &TokenForm{} + err := c.ShouldBindJSON(tf) + if err != nil { + c.JSON(http.StatusOK, model.Response{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("请求错误:%s", err), + }) + return + } + token := &model.ApiToken{ + UserID: u.ID, + Token: utils.MD5(fmt.Sprintf("%d%d%s", time.Now().UnixNano(), u.ID, u.Login)), + Note: tf.Note, + } + singleton.DB.Create(token) + + singleton.ApiLock.Lock() + singleton.ApiTokenList[token.Token] = token + singleton.UserIDToApiTokenList[u.ID] = append(singleton.UserIDToApiTokenList[u.ID], token.Token) + singleton.ApiLock.Unlock() + + c.JSON(http.StatusOK, model.Response{ + Code: http.StatusOK, + Message: "success", + Result: map[string]string{ + "token": token.Token, + "note": token.Note, + }, + }) +} + +// deleteToken 删除 token +func (ma *memberAPI) deleteToken(c *gin.Context) { + token := c.Param("token") + if token == "" { + c.JSON(http.StatusOK, model.Response{ + Code: http.StatusBadRequest, + Message: "token 不能为空", + }) + return + } + singleton.ApiLock.Lock() + defer singleton.ApiLock.Unlock() + if _, ok := singleton.ApiTokenList[token]; !ok { + c.JSON(http.StatusOK, model.Response{ + Code: http.StatusBadRequest, + Message: "token 不存在", + }) + return + } + // 在数据库中删除该Token + singleton.DB.Unscoped().Delete(&model.ApiToken{}, "token = ?", token) + + // 在UserIDToApiTokenList中删除该Token + for i, t := range singleton.UserIDToApiTokenList[singleton.ApiTokenList[token].UserID] { + if t == token { + singleton.UserIDToApiTokenList[singleton.ApiTokenList[token].UserID] = append(singleton.UserIDToApiTokenList[singleton.ApiTokenList[token].UserID][:i], singleton.UserIDToApiTokenList[singleton.ApiTokenList[token].UserID][i+1:]...) + break + } + } + if len(singleton.UserIDToApiTokenList[singleton.ApiTokenList[token].UserID]) == 0 { + delete(singleton.UserIDToApiTokenList, singleton.ApiTokenList[token].UserID) + } + // 在ApiTokenList中删除该Token + delete(singleton.ApiTokenList, token) + c.JSON(http.StatusOK, model.Response{ + Code: http.StatusOK, + Message: "success", + }) } func (ma *memberAPI) delete(c *gin.Context) { @@ -62,8 +176,21 @@ func (ma *memberAPI) delete(c *gin.Context) { if err == nil { // 删除服务器 singleton.ServerLock.Lock() + tag := singleton.ServerList[id].Tag delete(singleton.SecretToID, singleton.ServerList[id].Secret) delete(singleton.ServerList, id) + index := 0 + for index < len(singleton.ServerTagToIDList[tag]) { + if singleton.ServerTagToIDList[tag][index] == id { + break + } + index++ + } + // 删除旧 Tag-ID 绑定关系 + singleton.ServerTagToIDList[tag] = append(singleton.ServerTagToIDList[tag][:index], singleton.ServerTagToIDList[tag][index+1:]...) + if len(singleton.ServerTagToIDList[tag]) == 0 { + delete(singleton.ServerTagToIDList, tag) + } singleton.ServerLock.Unlock() singleton.ReSortServer() // 删除循环流量状态中的此服务器相关的记录 @@ -194,6 +321,23 @@ func (ma *memberAPI) addOrEditServer(c *gin.Context) { // 设置新的 Secret-ID 绑定关系 delete(singleton.SecretToID, singleton.ServerList[s.ID].Secret) } + // 如果修改了Tag + if s.Tag != singleton.ServerList[s.ID].Tag { + index := 0 + for index < len(singleton.ServerTagToIDList[s.Tag]) { + if singleton.ServerTagToIDList[s.Tag][index] == s.ID { + break + } + index++ + } + // 删除旧 Tag-ID 绑定关系 + singleton.ServerTagToIDList[singleton.ServerList[s.ID].Tag] = append(singleton.ServerTagToIDList[singleton.ServerList[s.ID].Tag][:index], singleton.ServerTagToIDList[singleton.ServerList[s.ID].Tag][index+1:]...) + // 设置新的 Tag-ID 绑定关系 + singleton.ServerTagToIDList[s.Tag] = append(singleton.ServerTagToIDList[s.Tag], s.ID) + if len(singleton.ServerTagToIDList[s.Tag]) == 0 { + delete(singleton.ServerTagToIDList, s.Tag) + } + } singleton.ServerList[s.ID] = &s singleton.ServerLock.Unlock() } else { @@ -202,6 +346,7 @@ func (ma *memberAPI) addOrEditServer(c *gin.Context) { singleton.ServerLock.Lock() singleton.SecretToID[s.Secret] = s.ID singleton.ServerList[s.ID] = &s + singleton.ServerTagToIDList[s.Tag] = append(singleton.ServerTagToIDList[s.Tag], s.ID) singleton.ServerLock.Unlock() } singleton.ReSortServer() diff --git a/cmd/dashboard/controller/member_page.go b/cmd/dashboard/controller/member_page.go index df0cf7d..7f18823 100644 --- a/cmd/dashboard/controller/member_page.go +++ b/cmd/dashboard/controller/member_page.go @@ -28,6 +28,16 @@ func (mp *memberPage) serve() { mr.GET("/cron", mp.cron) mr.GET("/notification", mp.notification) mr.GET("/setting", mp.setting) + mr.GET("/api", mp.api) +} + +func (mp *memberPage) api(c *gin.Context) { + singleton.ApiLock.RLock() + defer singleton.ApiLock.RUnlock() + c.HTML(http.StatusOK, "dashboard/api", mygin.CommonEnvironment(c, gin.H{ + "title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "ApiManagement"}), + "Tokens": singleton.ApiTokenList, + })) } func (mp *memberPage) server(c *gin.Context) { diff --git a/model/api_token.go b/model/api_token.go new file mode 100644 index 0000000..1a777c9 --- /dev/null +++ b/model/api_token.go @@ -0,0 +1,8 @@ +package model + +type ApiToken struct { + Common + UserID uint64 `json:"user_id"` + Token string `json:"token"` + Note string `json:"note"` +} diff --git a/pkg/mygin/auth.go b/pkg/mygin/auth.go index 38123f8..0f28974 100644 --- a/pkg/mygin/auth.go +++ b/pkg/mygin/auth.go @@ -15,6 +15,7 @@ type AuthorizeOption struct { Guest bool Member bool IsPage bool + AllowAPI bool Msg string Redirect string Btn string @@ -34,7 +35,6 @@ func Authorize(opt AuthorizeOption) func(*gin.Context) { Link: opt.Redirect, Btn: opt.Btn, } - var isLogin bool // 用户鉴权 @@ -50,6 +50,23 @@ func Authorize(opt AuthorizeOption) func(*gin.Context) { } } + // API鉴权 + if opt.AllowAPI { + apiToken := c.GetHeader("Authorization") + if apiToken != "" { + var u model.User + singleton.ApiLock.RLock() + if _, ok := singleton.ApiTokenList[apiToken]; ok { + err := singleton.DB.First(&u).Where("id = ?", singleton.ApiTokenList[apiToken].UserID).Error + isLogin = err == nil + } + singleton.ApiLock.RUnlock() + if isLogin { + c.Set(model.CtxKeyAuthorizedUser, &u) + c.Set("isAPI", true) + } + } + } // 已登录且只能游客访问 if isLogin && opt.Guest { ShowErrorPage(c, commonErr, opt.IsPage) diff --git a/pkg/mygin/mygin.go b/pkg/mygin/mygin.go index d2afde0..47593cb 100644 --- a/pkg/mygin/mygin.go +++ b/pkg/mygin/mygin.go @@ -17,6 +17,7 @@ var adminPage = map[string]bool{ "/setting": true, "/notification": true, "/cron": true, + "/api": true, } func CommonEnvironment(c *gin.Context, data map[string]interface{}) gin.H { diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 5989423..64adc44 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -6,6 +6,7 @@ import ( "math/rand" "os" "regexp" + "strings" "time" "unsafe" @@ -68,3 +69,27 @@ func IPDesensitize(ipAddr string) string { ipAddr = ipv6Desensitize(ipAddr) return ipAddr } + +// SplitIPAddr 传入/分割的v4v6混合地址,返回v4和v6地址与有效地址 +func SplitIPAddr(v4v6Bundle string) (string, string, string) { + ipList := strings.Split(v4v6Bundle, "/") + ipv4 := "" + ipv6 := "" + validIP := "" + if len(ipList) > 1 { + // 双栈 + ipv4 = ipList[0] + ipv6 = ipList[1] + validIP = ipv4 + } else if len(ipList) == 1 { + // 仅ipv4|ipv6 + if strings.Contains(ipList[0], ":") { + ipv6 = ipList[0] + validIP = ipv6 + } else { + ipv4 = ipList[0] + validIP = ipv4 + } + } + return ipv4, ipv6, validIP +} diff --git a/resource/l10n/zh-CN.toml b/resource/l10n/zh-CN.toml index fb8fd3f..c135bd2 100644 --- a/resource/l10n/zh-CN.toml +++ b/resource/l10n/zh-CN.toml @@ -469,6 +469,21 @@ other = "服务监控" [ScheduledTasks] other = "计划任务" +[ApiManagement] +other="API" + +[IssueNewApiToken] +other="添加Token" + +[Token] +other="Token" + +[DeleteToken] +other="删除Token" + +[ConfirmToDeleteThisToken] +other="确认删除Token" + [YouAreNotAuthorized] other = "此页面需要登录" diff --git a/resource/static/main.js b/resource/static/main.js index 6b15125..d7da66e 100644 --- a/resource/static/main.js +++ b/resource/static/main.js @@ -202,6 +202,18 @@ function post(path, params, method = 'post') { document.body.removeChild(form); } +function issueNewApiToken(apiToken) { + const modal = $(".api.modal"); + modal.children(".header").text((apiToken ? LANG.Edit : LANG.Add) + ' ' + "API Token"); + modal + .find(".nezha-primary-btn.button") + .html( + apiToken ? LANG.Edit + '' : LANG.Add + '' + ); + modal.find("textarea[name=Note]").val(apiToken ? apiToken.Note : null); + showFormModal(".api.modal", "#apiForm", "/api/token"); +} + function addOrEditServer(server, conf) { const modal = $(".server.modal"); modal.children(".header").text((server ? LANG.Edit : LANG.Add) + ' ' + LANG.Server); diff --git a/resource/template/common/menu.html b/resource/template/common/menu.html index f909103..ba746c4 100644 --- a/resource/template/common/menu.html +++ b/resource/template/common/menu.html @@ -29,6 +29,9 @@ {{tr "BackToHomepage"}} + + API Token + {{else}} {{tr "AdminPanel"}} diff --git a/resource/template/component/api.html b/resource/template/component/api.html new file mode 100644 index 0000000..02b6c6c --- /dev/null +++ b/resource/template/component/api.html @@ -0,0 +1,19 @@ +{{define "component/api"}} + + {{tr "IssueNewApiToken"}} + + + + + {{tr "Note"}} + + + + + + {{tr "Cancel"}} + {{tr "Confirm"}} + + + +{{end}} \ No newline at end of file diff --git a/resource/template/dashboard/api.html b/resource/template/dashboard/api.html new file mode 100644 index 0000000..b9b7795 --- /dev/null +++ b/resource/template/dashboard/api.html @@ -0,0 +1,41 @@ +{{define "dashboard/api"}} +{{template "common/header" .}} +{{template "common/menu" .}} + + + + + + {{tr "IssueNewApiToken"}} + + + + + + + {{tr "Token"}} + {{tr "Note"}} + + + + {{range $token := .Tokens}} + + {{$token.Token}} + {{$token.Note}} + + + + + + + + + {{end}} + + + + +{{template "component/api"}} +{{template "common/footer" .}} +{{end}} \ No newline at end of file diff --git a/service/singleton/api.go b/service/singleton/api.go new file mode 100644 index 0000000..060ab02 --- /dev/null +++ b/service/singleton/api.go @@ -0,0 +1,200 @@ +package singleton + +import ( + "github.com/naiba/nezha/model" + "github.com/naiba/nezha/pkg/utils" + "sync" +) + +var ( + ApiTokenList = make(map[string]*model.ApiToken) + UserIDToApiTokenList = make(map[uint64][]string) + ApiLock sync.RWMutex + + ServerAPI = &ServerAPIService{} +) + +type ServerAPIService struct{} + +// CommonResponse 常规返回结构 包含状态码 和 状态信息 +type CommonResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type CommonServerInfo struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Tag string `json:"tag"` + IPV4 string `json:"ipv4"` + IPV6 string `json:"ipv6"` + ValidIP string `json:"valid_ip"` +} + +// StatusResponse 服务器状态子结构 包含服务器信息与状态信息 +type StatusResponse struct { + CommonServerInfo + Host *model.Host `json:"host"` + Status *model.HostState `json:"status"` +} + +// ServerStatusResponse 服务器状态返回结构 包含常规返回结构 和 服务器状态子结构 +type ServerStatusResponse struct { + CommonResponse + Result []*StatusResponse `json:"result"` +} + +// ServerInfoResponse 服务器信息返回结构 包含常规返回结构 和 服务器信息子结构 +type ServerInfoResponse struct { + CommonResponse + Result []*CommonServerInfo `json:"result"` +} + +func InitAPI() { + ApiTokenList = make(map[string]*model.ApiToken) + UserIDToApiTokenList = make(map[uint64][]string) +} + +func LoadAPI() { + InitAPI() + var tokenList []*model.ApiToken + DB.Find(&tokenList) + for _, token := range tokenList { + ApiTokenList[token.Token] = token + UserIDToApiTokenList[token.UserID] = append(UserIDToApiTokenList[token.UserID], token.Token) + } +} + +// GetStatusByIDList 获取传入IDList的服务器状态信息 +func (s *ServerAPIService) GetStatusByIDList(idList []uint64) *ServerStatusResponse { + res := &ServerStatusResponse{} + res.Result = make([]*StatusResponse, 0) + + ServerLock.RLock() + defer ServerLock.RUnlock() + + for _, v := range idList { + server := ServerList[v] + if server == nil { + continue + } + ipv4, ipv6, validIP := utils.SplitIPAddr(server.Host.IP) + info := CommonServerInfo{ + ID: server.ID, + Name: server.Name, + Tag: server.Tag, + IPV4: ipv4, + IPV6: ipv6, + ValidIP: validIP, + } + res.Result = append(res.Result, &StatusResponse{ + CommonServerInfo: info, + Host: server.Host, + Status: server.State, + }) + } + res.CommonResponse = CommonResponse{ + Code: 0, + Message: "success", + } + return res +} + +// GetStatusByTag 获取传入分组的所有服务器状态信息 +func (s *ServerAPIService) GetStatusByTag(tag string) *ServerStatusResponse { + return s.GetStatusByIDList(ServerTagToIDList[tag]) +} + +// GetAllStatus 获取所有服务器状态信息 +func (s *ServerAPIService) GetAllStatus() *ServerStatusResponse { + res := &ServerStatusResponse{} + res.Result = make([]*StatusResponse, 0) + ServerLock.RLock() + defer ServerLock.RUnlock() + for _, v := range ServerList { + host := v.Host + state := v.State + if host == nil || state == nil { + continue + } + ipv4, ipv6, validIP := utils.SplitIPAddr(host.IP) + info := CommonServerInfo{ + ID: v.ID, + Name: v.Name, + Tag: v.Tag, + IPV4: ipv4, + IPV6: ipv6, + ValidIP: validIP, + } + res.Result = append(res.Result, &StatusResponse{ + CommonServerInfo: info, + Host: v.Host, + Status: v.State, + }) + } + res.CommonResponse = CommonResponse{ + Code: 0, + Message: "success", + } + return res +} + +// GetListByTag 获取传入分组的所有服务器信息 +func (s *ServerAPIService) GetListByTag(tag string) *ServerInfoResponse { + res := &ServerInfoResponse{} + res.Result = make([]*CommonServerInfo, 0) + + ServerLock.RLock() + defer ServerLock.RUnlock() + for _, v := range ServerTagToIDList[tag] { + host := ServerList[v].Host + if host == nil { + continue + } + ipv4, ipv6, validIP := utils.SplitIPAddr(host.IP) + info := &CommonServerInfo{ + ID: v, + Name: ServerList[v].Name, + Tag: ServerList[v].Tag, + IPV4: ipv4, + IPV6: ipv6, + ValidIP: validIP, + } + res.Result = append(res.Result, info) + } + res.CommonResponse = CommonResponse{ + Code: 0, + Message: "success", + } + return res +} + +// GetAllList 获取所有服务器信息 +func (s *ServerAPIService) GetAllList() *ServerInfoResponse { + res := &ServerInfoResponse{} + res.Result = make([]*CommonServerInfo, 0) + + ServerLock.RLock() + defer ServerLock.RUnlock() + for _, v := range ServerList { + host := v.Host + if host == nil { + continue + } + ipv4, ipv6, validIP := utils.SplitIPAddr(host.IP) + info := &CommonServerInfo{ + ID: v.ID, + Name: v.Name, + Tag: v.Tag, + IPV4: ipv4, + IPV6: ipv6, + ValidIP: validIP, + } + res.Result = append(res.Result, info) + } + res.CommonResponse = CommonResponse{ + Code: 0, + Message: "success", + } + return res +} diff --git a/service/singleton/server.go b/service/singleton/server.go index 9dfcf72..31ad29b 100644 --- a/service/singleton/server.go +++ b/service/singleton/server.go @@ -8,9 +8,10 @@ import ( ) var ( - ServerList map[uint64]*model.Server // [ServerID] -> model.Server - SecretToID map[string]uint64 // [ServerSecret] -> ServerID - ServerLock sync.RWMutex + ServerList map[uint64]*model.Server // [ServerID] -> model.Server + SecretToID map[string]uint64 // [ServerSecret] -> ServerID + ServerTagToIDList map[string][]uint64 // [ServerTag] -> ServerID + ServerLock sync.RWMutex SortedServerList []*model.Server // 用于存储服务器列表的 slice,按照服务器 ID 排序 SortedServerLock sync.RWMutex @@ -20,6 +21,7 @@ var ( func InitServer() { ServerList = make(map[uint64]*model.Server) SecretToID = make(map[string]uint64) + ServerTagToIDList = make(map[string][]uint64) } //LoadServers 加载服务器列表并根据ID排序 @@ -33,6 +35,7 @@ func LoadServers() { innerS.State = &model.HostState{} ServerList[innerS.ID] = &innerS SecretToID[innerS.Secret] = innerS.ID + ServerTagToIDList[innerS.Tag] = append(ServerTagToIDList[innerS.Tag], innerS.ID) } ReSortServer() } diff --git a/service/singleton/singleton.go b/service/singleton/singleton.go index a5fd477..d8eec66 100644 --- a/service/singleton/singleton.go +++ b/service/singleton/singleton.go @@ -38,6 +38,7 @@ func LoadSingleton() { LoadNotifications() // 加载通知服务 LoadServers() // 加载服务器列表 LoadCronTasks() // 加载定时任务 + LoadAPI() } // InitConfigFromPath 从给出的文件路径中加载配置 @@ -63,7 +64,7 @@ func InitDBFromPath(path string) { } err = DB.AutoMigrate(model.Server{}, model.User{}, model.Notification{}, model.AlertRule{}, model.Monitor{}, - model.MonitorHistory{}, model.Cron{}, model.Transfer{}) + model.MonitorHistory{}, model.Cron{}, model.Transfer{}, model.ApiToken{}) if err != nil { panic(err) }