Merge pull request #206 from AkkiaS7/feat-api [no ci]

feat: API支持

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
This commit is contained in:
naiba 2022-05-18 23:56:07 +08:00 committed by GitHub
commit dd79c7f5ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 572 additions and 5 deletions

View File

@ -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())
}

View File

@ -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()

View File

@ -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) {

8
model/api_token.go Normal file
View File

@ -0,0 +1,8 @@
package model
type ApiToken struct {
Common
UserID uint64 `json:"user_id"`
Token string `json:"token"`
Note string `json:"note"`
}

View File

@ -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)

View File

@ -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 {

View File

@ -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
}

View File

@ -469,6 +469,21 @@ other = "服务监控"
[ScheduledTasks]
other = "计划任务"
[ApiManagement]
other="API"
[IssueNewApiToken]
other="添加Token"
[Token]
other="Token"
[DeleteToken]
other="删除Token"
[ConfirmToDeleteThisToken]
other="确认删除Token"
[YouAreNotAuthorized]
other = "此页面需要登录"

View File

@ -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 + '<i class="edit icon"></i>' : LANG.Add + '<i class="add icon"></i>'
);
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);

View File

@ -29,6 +29,9 @@
<a class="item" href="/">
<i class="chart area icon"></i>{{tr "BackToHomepage"}}
</a>
<a class="item" href="/api">
<i class="chart key icon"></i>API Token
</a>
{{else}}
<a class="item" href="/server">
<i class="terminal icon"></i>{{tr "AdminPanel"}}

19
resource/template/component/api.html vendored Normal file
View File

@ -0,0 +1,19 @@
{{define "component/api"}}
<div class="ui tiny api modal transition hidden">
<div class="header">{{tr "IssueNewApiToken"}}</div>
<div class="content">
<form id="apiForm" class="ui form">
<input type="hidden" name="id">
<div class="field">
<label>{{tr "Note"}}</label>
<textarea name="Note"></textarea>
</div>
</form>
</div>
<div class=" actions">
<div class="ui negative button">{{tr "Cancel"}}</div>
<button class="ui positive nezha-primary-btn right labeled icon button">{{tr "Confirm"}}<i class="checkmark icon"></i>
</button>
</div>
</div>
{{end}}

View File

@ -0,0 +1,41 @@
{{define "dashboard/api"}}
{{template "common/header" .}}
{{template "common/menu" .}}
<div class="nb-container">
<div class="ui container">
<div class="ui grid">
<div class="right floated right aligned twelve wide column">
<button class="ui right labeled nezha-primary-btn icon button" onclick="issueNewApiToken()"><i class="add icon"></i>
{{tr "IssueNewApiToken"}}
</button>
</div>
</div>
<table class="ui very basic table">
<thead>
<tr>
<th>{{tr "Token"}}</th>
<th>{{tr "Note"}}</th>
</tr>
</thead>
<tbody>
{{range $token := .Tokens}}
<tr>
<td>{{$token.Token}}</td>
<td>{{$token.Note}}</td>
<td>
<div class="ui mini icon buttons">
<button class="ui button"
onclick="showConfirm('{{tr "DeleteToken"}}','{{tr "ConfirmToDeleteThisToken"}}',deleteRequest,'/api/token/'+{{$token.Token}})">
<i class="trash alternate outline icon"></i>
</button>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "component/api"}}
{{template "common/footer" .}}
{{end}}

200
service/singleton/api.go Normal file
View File

@ -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
}

View File

@ -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()
}

View File

@ -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)
}