mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.
Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.
Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.
Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
196 lines
6.0 KiB
Go
196 lines
6.0 KiB
Go
package controller
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||
|
||
"github.com/nezhahq/nezha/model"
|
||
"github.com/nezhahq/nezha/service/singleton"
|
||
)
|
||
|
||
// MCP 协议兼容性集成测试:用 modelcontextprotocol/go-sdk 官方 Go MCP client
|
||
// 对 dashboard /mcp 跑完整 initialize + tools/list + tools/call。
|
||
// 协议层用官方 SDK 严格编解码 — 任何与 MCP spec 的偏差都会被立即报错。
|
||
|
||
type sdkPATRoundTripper struct {
|
||
base http.RoundTripper
|
||
token string
|
||
}
|
||
|
||
func (rt *sdkPATRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||
req.Header.Set("Authorization", "Bearer "+rt.token)
|
||
return rt.base.RoundTrip(req)
|
||
}
|
||
|
||
func sdkTransport(endpoint, token string) *mcp.StreamableClientTransport {
|
||
return &mcp.StreamableClientTransport{
|
||
Endpoint: endpoint,
|
||
HTTPClient: &http.Client{
|
||
Transport: &sdkPATRoundTripper{base: http.DefaultTransport, token: token},
|
||
Timeout: 5 * time.Second,
|
||
},
|
||
// /mcp 当前只实现 POST 半边 Streamable HTTP;GET SSE 通道未实现也不计划
|
||
// 短期内上线(不需要 server→client 主动推送)。SDK 默认会试图发 GET,
|
||
// 关掉 standalone SSE 即可严格互通。
|
||
DisableStandaloneSSE: true,
|
||
}
|
||
}
|
||
|
||
func setupSDKCompat(t *testing.T) (string, string, func()) {
|
||
t.Helper()
|
||
cleanupBase, uid := setupMCPTest(t)
|
||
|
||
srv, _ := singleton.ServerShared.Get(7)
|
||
srv.SetTaskStream(&e2eStream{dispatch: agentSim})
|
||
|
||
_, plain := mkToken(t, uid, []string{
|
||
model.ScopeServerRead,
|
||
model.ScopeServerWrite,
|
||
model.ScopeServerDelete,
|
||
model.ScopeServerExec,
|
||
}, nil)
|
||
|
||
gin.SetMode(gin.TestMode)
|
||
r := gin.New()
|
||
r.POST("/mcp", apiTokenAuthMiddleware(), mcpEndpoint)
|
||
ts := httptest.NewServer(r)
|
||
return ts.URL + "/mcp", plain, func() {
|
||
ts.Close()
|
||
cleanupBase()
|
||
}
|
||
}
|
||
|
||
func TestSDKClient_InitializeHandshake(t *testing.T) {
|
||
endpoint, token, cleanup := setupSDKCompat(t)
|
||
defer cleanup()
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
|
||
require.NoError(t, err, "official Go SDK must initialize against /mcp")
|
||
defer session.Close()
|
||
}
|
||
|
||
func TestSDKClient_ToolsList(t *testing.T) {
|
||
endpoint, token, cleanup := setupSDKCompat(t)
|
||
defer cleanup()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
|
||
require.NoError(t, err)
|
||
defer session.Close()
|
||
|
||
lst, err := session.ListTools(ctx, nil)
|
||
require.NoError(t, err)
|
||
names := make(map[string]bool, len(lst.Tools))
|
||
for _, tl := range lst.Tools {
|
||
names[tl.Name] = true
|
||
}
|
||
for _, must := range []string{
|
||
"meta.whoami",
|
||
"server.list", "server.get", "server.exec",
|
||
"fs.list", "fs.read", "fs.write", "fs.delete",
|
||
"fs.download_url", "fs.upload_url",
|
||
} {
|
||
require.Truef(t, names[must], "tools/list missing %q", must)
|
||
}
|
||
}
|
||
|
||
func TestSDKClient_Whoami(t *testing.T) {
|
||
endpoint, token, cleanup := setupSDKCompat(t)
|
||
defer cleanup()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
|
||
require.NoError(t, err)
|
||
defer session.Close()
|
||
|
||
res, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||
Name: "meta.whoami",
|
||
Arguments: map[string]any{},
|
||
})
|
||
require.NoError(t, err)
|
||
require.False(t, res.IsError)
|
||
tc, ok := res.Content[0].(*mcp.TextContent)
|
||
require.True(t, ok)
|
||
var payload map[string]any
|
||
require.NoError(t, json.Unmarshal([]byte(tc.Text), &payload))
|
||
require.NotZero(t, payload["user_id"])
|
||
require.NotEmpty(t, payload["scopes"])
|
||
}
|
||
|
||
func TestSDKClient_ServerExec(t *testing.T) {
|
||
endpoint, token, cleanup := setupSDKCompat(t)
|
||
defer cleanup()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
|
||
require.NoError(t, err)
|
||
defer session.Close()
|
||
|
||
res, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||
Name: "server.exec",
|
||
Arguments: map[string]any{
|
||
"server_id": 7,
|
||
"cmd": "echo",
|
||
},
|
||
})
|
||
require.NoError(t, err)
|
||
require.False(t, res.IsError, "exec failed: %v", res.Content)
|
||
tc := res.Content[0].(*mcp.TextContent)
|
||
require.Contains(t, tc.Text, "simulated")
|
||
}
|
||
|
||
func TestSDKClient_FSLifecycle(t *testing.T) {
|
||
endpoint, token, cleanup := setupSDKCompat(t)
|
||
defer cleanup()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
|
||
require.NoError(t, err)
|
||
defer session.Close()
|
||
|
||
path := t.TempDir() + "/sdk.txt"
|
||
for _, step := range []struct {
|
||
name string
|
||
args map[string]any
|
||
}{
|
||
{"fs.write", map[string]any{"server_id": 7, "path": path, "content": "via-sdk", "encoding": "utf8"}},
|
||
{"fs.read", map[string]any{"server_id": 7, "path": path}},
|
||
{"fs.delete", map[string]any{"server_id": 7, "path": path}},
|
||
} {
|
||
res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: step.name, Arguments: step.args})
|
||
require.NoError(t, err, step.name)
|
||
require.False(t, res.IsError, "%s failed: %v", step.name, res.Content)
|
||
}
|
||
}
|
||
|
||
func TestSDKClient_BadPAT(t *testing.T) {
|
||
endpoint, _, cleanup := setupSDKCompat(t)
|
||
defer cleanup()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
|
||
_, err := client.Connect(ctx, sdkTransport(endpoint, "nzp_invalid"), nil)
|
||
require.Error(t, err)
|
||
}
|