mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
Carve a new nezha:inventory:{read,delete,*} resource family out of
nezha:server:*. Listing and deleting servers/server-groups (GET /server,
/server-group, /ws/server, batch-delete/server[-group], MCP server.list)
now require nezha:inventory:*, while nezha:server:* covers per-server
runtime operations (get, exec, fs, config, metrics, batch-move,
force-update).
Also declare MCP tool OutputSchema for exec/fs/meta/server/transfer and
wrap server.list output in {servers,count} so strict MCP clients accept
the structured result. Correct the /file all-of scope entry and the stale
server:read documentation.
206 lines
6.0 KiB
Go
206 lines
6.0 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/nezhahq/nezha/model"
|
|
"github.com/nezhahq/nezha/service/singleton"
|
|
)
|
|
|
|
// setupRESTScopeTest 准备一个 PAT + 一个最小路由表,用于测 REST scope enforce。
|
|
func setupRESTScopeTest(t *testing.T) (*httptest.Server, *model.APIToken, string, func()) {
|
|
t.Helper()
|
|
cleanupBase, uid := setupMCPTest(t)
|
|
|
|
tok, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
patMw := apiTokenAuthMiddleware()
|
|
|
|
r.GET("/server",
|
|
patMw,
|
|
restScopeMiddleware(model.ScopeInventoryRead),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
|
)
|
|
r.POST("/server/config",
|
|
patMw,
|
|
restScopeMiddleware(model.ScopeServerWrite),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
|
)
|
|
r.POST("/server-group",
|
|
patMw,
|
|
restScopeMiddleware(model.ScopeServerWrite),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
|
)
|
|
r.GET("/api-tokens",
|
|
patMw,
|
|
restPATForbiddenMiddleware(),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
|
)
|
|
ts := httptest.NewServer(r)
|
|
return ts, tok, plain, func() {
|
|
ts.Close()
|
|
cleanupBase()
|
|
}
|
|
}
|
|
|
|
func doReq(t *testing.T, ts *httptest.Server, method, path, token string) *http.Response {
|
|
t.Helper()
|
|
req, _ := http.NewRequest(method, ts.URL+path, bytes.NewReader([]byte("{}")))
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
return resp
|
|
}
|
|
|
|
func TestREST_PATWithMatchingScopeAllowed(t *testing.T) {
|
|
ts, _, tok, cleanup := setupRESTScopeTest(t)
|
|
defer cleanup()
|
|
resp := doReq(t, ts, "GET", "/server", tok)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
}
|
|
|
|
func TestREST_PATWithoutScopeDenied(t *testing.T) {
|
|
ts, _, tok, cleanup := setupRESTScopeTest(t)
|
|
defer cleanup()
|
|
resp := doReq(t, ts, "POST", "/server/config", tok)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
var body model.CommonResponse[any]
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
|
require.False(t, body.Success)
|
|
require.Contains(t, body.Error, "nezha:server:write")
|
|
}
|
|
|
|
func TestREST_SelfManagementForbidsPAT(t *testing.T) {
|
|
ts, _, tok, cleanup := setupRESTScopeTest(t)
|
|
defer cleanup()
|
|
resp := doReq(t, ts, "GET", "/api-tokens", tok)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
}
|
|
|
|
func TestREST_PATWildcardCoversAllVerbs(t *testing.T) {
|
|
cleanupBase, uid := setupMCPTest(t)
|
|
defer cleanupBase()
|
|
|
|
tok, plain := mkToken(t, uid, []string{"nezha:server:*"}, nil)
|
|
_ = tok
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
patMw := apiTokenAuthMiddleware()
|
|
r.GET("/server/config/0", patMw, restScopeMiddleware(model.ScopeServerRead),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
|
r.POST("/server/config", patMw, restScopeMiddleware(model.ScopeServerWrite),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
|
r.POST("/file", patMw, restScopeMiddleware(model.ScopeServerDelete),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
|
r.POST("/terminal", patMw, restScopeMiddleware(model.ScopeServerExec),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
|
ts := httptest.NewServer(r)
|
|
defer ts.Close()
|
|
|
|
for _, tc := range []struct {
|
|
method, path string
|
|
}{
|
|
{"GET", "/server/config/0"},
|
|
{"POST", "/server/config"},
|
|
{"POST", "/file"},
|
|
{"POST", "/terminal"},
|
|
} {
|
|
resp := doReq(t, ts, tc.method, tc.path, plain)
|
|
resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "%s %s should be allowed by nezha:server:*", tc.method, tc.path)
|
|
}
|
|
}
|
|
|
|
func TestREST_NezhaAllGrantsEverything(t *testing.T) {
|
|
cleanupBase, uid := setupMCPTest(t)
|
|
defer cleanupBase()
|
|
|
|
_, plain := mkToken(t, uid, []string{model.ScopeNezhaAll}, nil)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
r.POST("/maintenance",
|
|
apiTokenAuthMiddleware(),
|
|
restScopeMiddleware(model.ScopeAdminAll),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
|
)
|
|
ts := httptest.NewServer(r)
|
|
defer ts.Close()
|
|
|
|
resp := doReq(t, ts, "POST", "/maintenance", plain)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
}
|
|
|
|
func TestREST_NoAuthGoesToJWTChain(t *testing.T) {
|
|
cleanupBase, _ := setupMCPTest(t)
|
|
defer cleanupBase()
|
|
|
|
jwtCalled := false
|
|
fakeJwt := func(c *gin.Context) {
|
|
jwtCalled = true
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no jwt"})
|
|
}
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
r.GET("/server",
|
|
jwtOrPATAuthMiddleware(apiTokenAuthMiddleware(), fakeJwt),
|
|
restScopeMiddleware(model.ScopeInventoryRead),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
|
)
|
|
ts := httptest.NewServer(r)
|
|
defer ts.Close()
|
|
|
|
resp := doReq(t, ts, "GET", "/server", "")
|
|
resp.Body.Close()
|
|
require.True(t, jwtCalled, "JWT mw must be invoked when no PAT")
|
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
}
|
|
|
|
func TestREST_BadPATShortCircuitsBeforeJWT(t *testing.T) {
|
|
cleanupBase, _ := setupMCPTest(t)
|
|
defer cleanupBase()
|
|
|
|
jwtCalled := false
|
|
fakeJwt := func(c *gin.Context) { jwtCalled = true }
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set(model.CtxKeyRealIPStr, "203.0.113.99")
|
|
c.Next()
|
|
})
|
|
r.GET("/server",
|
|
jwtOrPATAuthMiddleware(apiTokenAuthMiddleware(), fakeJwt),
|
|
restScopeMiddleware(model.ScopeInventoryRead),
|
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
|
)
|
|
ts := httptest.NewServer(r)
|
|
defer ts.Close()
|
|
|
|
resp := doReq(t, ts, "GET", "/server", "nzp_bogus_token_value")
|
|
resp.Body.Close()
|
|
require.False(t, jwtCalled, "JWT mw must NOT run after bad PAT abort")
|
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
var blocked model.WAF
|
|
err := singleton.DB.Where("block_identifier = ?", model.BlockIDToken).First(&blocked).Error
|
|
require.NoError(t, err, "bad PAT must trigger WAF BlockIP")
|
|
require.GreaterOrEqual(t, blocked.Count, uint64(1))
|
|
}
|