mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
cap 与系统信息
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
)
|
||||
|
||||
// startTime 记录服务启动时间
|
||||
var startTime = time.Now()
|
||||
|
||||
// SystemStatusResponse 系统状态响应结构体
|
||||
type SystemStatusResponse struct {
|
||||
Uptime string `json:"uptime"`
|
||||
NumGoroutine int `json:"num_goroutine"`
|
||||
Alloc string `json:"alloc"`
|
||||
TotalAlloc string `json:"total_alloc"`
|
||||
Sys string `json:"sys"`
|
||||
Lookups uint64 `json:"lookups"`
|
||||
Mallocs uint64 `json:"mallocs"`
|
||||
Frees uint64 `json:"frees"`
|
||||
HeapAlloc string `json:"heap_alloc"`
|
||||
HeapSys string `json:"heap_sys"`
|
||||
HeapIdle string `json:"heap_idle"`
|
||||
HeapInuse string `json:"heap_inuse"`
|
||||
HeapReleased string `json:"heap_released"`
|
||||
HeapObjects uint64 `json:"heap_objects"`
|
||||
StackInuse string `json:"stack_inuse"`
|
||||
StackSys string `json:"stack_sys"`
|
||||
MSpanInuse string `json:"mspan_inuse"`
|
||||
MSpanSys string `json:"mspan_sys"`
|
||||
MCacheInuse string `json:"mcache_inuse"`
|
||||
MCacheSys string `json:"mcache_sys"`
|
||||
BuckHashSys string `json:"buck_hash_sys"`
|
||||
GCSys string `json:"gc_sys"`
|
||||
OtherSys string `json:"other_sys"`
|
||||
NextGC string `json:"next_gc"`
|
||||
LastGCTime string `json:"last_gc_time"`
|
||||
PauseTotalNs string `json:"pause_total_ns"`
|
||||
LastPause string `json:"last_pause"`
|
||||
NumGC uint32 `json:"num_gc"`
|
||||
}
|
||||
|
||||
// formatBytes 格式化字节大小
|
||||
func formatBytes(bytes uint64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
value := float64(bytes) / float64(div)
|
||||
var suffix string
|
||||
switch exp {
|
||||
case 0:
|
||||
suffix = "KiB"
|
||||
case 1:
|
||||
suffix = "MiB"
|
||||
case 2:
|
||||
suffix = "GiB"
|
||||
default:
|
||||
suffix = "TiB"
|
||||
}
|
||||
|
||||
// 格式化规则:
|
||||
// - 如果是整数(如 16, 73, 105, 986, 112):
|
||||
// - 如果 >= 10,则格式化为 "%.0f" (e.g. "16 KiB")
|
||||
// - 如果 < 10,则格式化为 "%.1f" (e.g. "9.0 KiB")
|
||||
// - 如果不是整数(如 5.8, 9.1, 7.6, 4.8):格式化为 "%.1f"
|
||||
if value == math.Trunc(value) {
|
||||
if value >= 10 {
|
||||
return fmt.Sprintf("%.0f %s", value, suffix)
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
|
||||
// formatDuration 格式化时间持续时间
|
||||
func formatDuration(d time.Duration) string {
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
seconds := int(d.Seconds()) % 60
|
||||
|
||||
var res string
|
||||
if days > 0 {
|
||||
res += fmt.Sprintf("%d天", days)
|
||||
}
|
||||
if hours > 0 {
|
||||
res += fmt.Sprintf("%d小时", hours)
|
||||
}
|
||||
if minutes > 0 {
|
||||
res += fmt.Sprintf("%d分钟", minutes)
|
||||
}
|
||||
if seconds > 0 || res == "" {
|
||||
res += fmt.Sprintf("%d秒钟", seconds)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// GetSystemStatus 获取系统状态信息
|
||||
// @Summary 获取系统状态信息
|
||||
// @Description 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} util.ResponseAny{data=status.SystemStatusResponse} "获取成功"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Router /api/v1/admin/status [get]
|
||||
func GetSystemStatus(c *gin.Context) {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
uptime := formatDuration(time.Since(startTime))
|
||||
numGoroutine := runtime.NumGoroutine()
|
||||
|
||||
var lastGCTime string
|
||||
if m.LastGC > 0 {
|
||||
lastGCTime = formatDuration(time.Since(time.Unix(0, int64(m.LastGC))))
|
||||
} else {
|
||||
lastGCTime = "无"
|
||||
}
|
||||
|
||||
var lastPause string
|
||||
if m.NumGC > 0 {
|
||||
lastPause = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC-1)%256])/1e9)
|
||||
} else {
|
||||
lastPause = "0.000s"
|
||||
}
|
||||
|
||||
res := SystemStatusResponse{
|
||||
Uptime: uptime,
|
||||
NumGoroutine: numGoroutine,
|
||||
Alloc: formatBytes(m.Alloc),
|
||||
TotalAlloc: formatBytes(m.TotalAlloc),
|
||||
Sys: formatBytes(m.Sys),
|
||||
Lookups: m.Lookups,
|
||||
Mallocs: m.Mallocs,
|
||||
Frees: m.Frees,
|
||||
HeapAlloc: formatBytes(m.HeapAlloc),
|
||||
HeapSys: formatBytes(m.HeapSys),
|
||||
HeapIdle: formatBytes(m.HeapIdle),
|
||||
HeapInuse: formatBytes(m.HeapInuse),
|
||||
HeapReleased: formatBytes(m.HeapReleased),
|
||||
HeapObjects: m.HeapObjects,
|
||||
StackInuse: formatBytes(m.StackInuse),
|
||||
StackSys: formatBytes(m.StackSys),
|
||||
MSpanInuse: formatBytes(m.MSpanInuse),
|
||||
MSpanSys: formatBytes(m.MSpanSys),
|
||||
MCacheInuse: formatBytes(m.MCacheInuse),
|
||||
MCacheSys: formatBytes(m.MCacheSys),
|
||||
BuckHashSys: formatBytes(m.BuckHashSys),
|
||||
GCSys: formatBytes(m.GCSys),
|
||||
OtherSys: formatBytes(m.OtherSys),
|
||||
NextGC: formatBytes(m.NextGC),
|
||||
LastGCTime: lastGCTime,
|
||||
PauseTotalNs: fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1e9),
|
||||
LastPause: lastPause,
|
||||
NumGC: m.NumGC,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(res))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cap
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
caputil "github.com/linux-do/credit/internal/util/cap"
|
||||
)
|
||||
|
||||
// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header.
|
||||
// enabledFunc is an optional callback allowing dynamic check of whether captcha protection is turned on.
|
||||
func VerifyMiddleware(mgr *caputil.Manager, scope string, enabledFunc func() bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if enabledFunc != nil && !enabledFunc() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
token := c.GetHeader("X-Cap-Token")
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码验证失败,缺少验证码凭证"))
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := mgr.VerifyToken(c.Request.Context(), token, scope)
|
||||
if err != nil || !valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码校验失败或已过期,请重试"))
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package cap
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/util/cap"
|
||||
)
|
||||
|
||||
type challengeRequest struct {
|
||||
Scope string `json:"scope" form:"scope"`
|
||||
}
|
||||
|
||||
type redeemRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Solutions []int `json:"solutions" binding:"required"`
|
||||
Scope string `json:"scope" form:"scope"`
|
||||
}
|
||||
|
||||
// Challenge 生成 PoW 人机验证难题
|
||||
// @Summary 生成人机验证难题
|
||||
// @Description 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。
|
||||
// @Tags cap
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body challengeRequest false "可选范围限制参数"
|
||||
// @Success 200 {object} cap.ChallengeResponse "成功返回 PoW 难题"
|
||||
// @Failure 500 {object} cap.RedeemResponse "内部服务错误"
|
||||
// @Router /api/cap/challenge [post]
|
||||
func Challenge(c *gin.Context) {
|
||||
var req challengeRequest
|
||||
_ = c.ShouldBind(&req) // 允许不传 body,默认使用 login scope
|
||||
|
||||
if req.Scope == "" {
|
||||
req.Scope = "login"
|
||||
}
|
||||
|
||||
mgr := cap.GetDefaultManager()
|
||||
resp, err := mgr.Generate(c.Request.Context(), req.Scope)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, cap.RedeemResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Redeem 提交 PoW 解答并兑换一次性凭证 Token
|
||||
// @Summary 校验人机验证解答
|
||||
// @Description 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证
|
||||
// @Tags cap
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body redeemRequest true "难题 Token 与解答 solutions 数组"
|
||||
// @Success 200 {object} cap.RedeemResponse "核销成功,返回 X-Cap-Token"
|
||||
// @Failure 400 {object} cap.RedeemResponse "参数错误或核销失败"
|
||||
// @Failure 500 {object} cap.RedeemResponse "内部服务错误"
|
||||
// @Router /api/cap/redeem [post]
|
||||
func Redeem(c *gin.Context) {
|
||||
var req redeemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, cap.RedeemResponse{
|
||||
Success: false,
|
||||
Error: "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Scope == "" {
|
||||
req.Scope = "login"
|
||||
}
|
||||
|
||||
mgr := cap.GetDefaultManager()
|
||||
resp, err := mgr.Redeem(c.Request.Context(), req.Token, req.Solutions, req.Scope)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, cap.RedeemResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusBadRequest, resp)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package cap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/testhelper"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
capUtil "github.com/linux-do/credit/internal/util/cap"
|
||||
)
|
||||
|
||||
func TestCapEndpointsAndMiddleware(t *testing.T) {
|
||||
sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
|
||||
// Mount CAPTCHA API endpoints
|
||||
capGroup := r.Group("/api/cap")
|
||||
{
|
||||
capGroup.POST("/challenge", Challenge)
|
||||
capGroup.POST("/redeem", Redeem)
|
||||
}
|
||||
|
||||
// Login endpoint with CAPTCHA middleware
|
||||
r.POST("/api/v1/user/login", VerifyMiddleware(capUtil.GetDefaultManager(), "login", func() bool {
|
||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, util.OK("login success"))
|
||||
})
|
||||
|
||||
// 1. Test challenge generation
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/cap/challenge", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var challengeResp capUtil.ChallengeResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &challengeResp); err != nil {
|
||||
t.Fatalf("failed to unmarshal challenge response: %v", err)
|
||||
}
|
||||
|
||||
if challengeResp.Token == "" {
|
||||
t.Fatalf("expected token in challenge response")
|
||||
}
|
||||
|
||||
// 2. Test login with CAPTCHA disabled (should pass)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK when CAPTCHA is disabled, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 3. Enable CAPTCHA in DB
|
||||
err := sqliteDB.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyCapLoginEnabled).Update("value", "true").Error
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable cap_login_enabled in DB: %v", err)
|
||||
}
|
||||
// Update cache
|
||||
var sysCfg model.SystemConfig
|
||||
sqliteDB.Where("key = ?", model.ConfigKeyCapLoginEnabled).First(&sysCfg)
|
||||
_ = db.HSetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeyCapLoginEnabled, &sysCfg)
|
||||
|
||||
// 4. Test login with CAPTCHA enabled but no header (should be blocked)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 5. Solve the challenge
|
||||
solutions := capUtil.Solve(challengeResp.Token, challengeResp.Challenge.C, challengeResp.Challenge.S, challengeResp.Challenge.D)
|
||||
|
||||
// 6. Redeem solutions
|
||||
redeemReqPayload := redeemRequest{
|
||||
Token: challengeResp.Token,
|
||||
Solutions: solutions,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(redeemReqPayload)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/api/cap/redeem", bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for redeem, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var redeemResp capUtil.RedeemResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &redeemResp); err != nil {
|
||||
t.Fatalf("failed to unmarshal redeem response: %v", err)
|
||||
}
|
||||
|
||||
if !redeemResp.Success || redeemResp.Token == "" {
|
||||
t.Fatalf("redeem failed or returned empty token: %+v", redeemResp)
|
||||
}
|
||||
|
||||
// 7. Login with valid redeem token (should pass)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
|
||||
req.Header.Set("X-Cap-Token", redeemResp.Token)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK with valid cap token, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 8. Replay attack: Login with the same redeem token again (should be blocked as it is single-use)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
|
||||
req.Header.Set("X-Cap-Token", redeemResp.Token)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized on replayed token, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ type PublicConfigResponse struct {
|
||||
PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册
|
||||
OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录
|
||||
MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量
|
||||
CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证
|
||||
CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算
|
||||
}
|
||||
|
||||
// GetPublicConfig 获取公共配置
|
||||
@@ -83,6 +85,18 @@ func GetPublicConfig(c *gin.Context) {
|
||||
oidcLoginEnabled = val
|
||||
}
|
||||
|
||||
// 3.4 cap_login_enabled
|
||||
var capLoginEnabled bool
|
||||
if val, err := model.GetBoolByKey(ctx, model.ConfigKeyCapLoginEnabled); err == nil {
|
||||
capLoginEnabled = val
|
||||
}
|
||||
|
||||
// 3.5 cap_auto_solve
|
||||
capAutoSolve := true // 默认自动开始
|
||||
if val, err := model.GetBoolByKey(ctx, model.ConfigKeyCapAutoSolve); err == nil {
|
||||
capAutoSolve = val
|
||||
}
|
||||
|
||||
// 4. max_api_keys_per_user
|
||||
var maxAPIKeys int
|
||||
if val, err := model.GetIntByKey(ctx, model.ConfigKeyMaxAPIKeysPerUser); err == nil {
|
||||
@@ -97,6 +111,8 @@ func GetPublicConfig(c *gin.Context) {
|
||||
PasswordRegisterEnabled: passwordRegisterEnabled,
|
||||
OIDCLoginEnabled: oidcLoginEnabled,
|
||||
MaxAPIKeysPerUser: maxAPIKeys,
|
||||
CapLoginEnabled: capLoginEnabled,
|
||||
CapAutoSolve: capAutoSolve,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(response))
|
||||
|
||||
Reference in New Issue
Block a user