389 lines
12 KiB
Go
389 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"runtime"
|
|
"time"
|
|
|
|
"github.com/wjyhk/vps-commander/internal/cluster"
|
|
"github.com/wjyhk/vps-commander/internal/executor"
|
|
"github.com/wjyhk/vps-commander/internal/model"
|
|
)
|
|
|
|
// MCPBackend implements mcp.ActionBackend
|
|
type MCPBackend struct {
|
|
server *Server
|
|
}
|
|
|
|
func (s *Server) NewMCPBackend() *MCPBackend {
|
|
return &MCPBackend{server: s}
|
|
}
|
|
|
|
func (b *MCPBackend) authorize(device, action, clientIP string) error {
|
|
if device == "" {
|
|
device = b.server.LocalName
|
|
}
|
|
mode := b.server.GetEffectiveSecurityMode(device)
|
|
if !securityAllows(mode, action) {
|
|
if b.server.Store != nil {
|
|
_ = b.server.Store.Audit(clientIP, device, "security_denied", action, 403, 0, "MCP security gate")
|
|
}
|
|
return fmt.Errorf("security mode %s denies %s", mode, action)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *MCPBackend) ListDevices(ctx context.Context) (any, error) {
|
|
out := []model.Device{{Name: b.server.LocalName, Status: "online", Local: true, Arch: runtime.GOARCH, OS: runtime.GOOS}}
|
|
if b.server.Cluster != nil {
|
|
for _, d := range b.server.Cluster.Devices() {
|
|
data, _ := json.Marshal(d)
|
|
var dev model.Device
|
|
_ = json.Unmarshal(data, &dev)
|
|
out = append(out, dev)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (b *MCPBackend) ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error) {
|
|
if command == "" {
|
|
return nil, errors.New("command required")
|
|
}
|
|
if device == "" {
|
|
device = b.server.LocalName
|
|
}
|
|
if err := b.authorize(device, "exec", clientIP); err != nil {
|
|
return nil, err
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = 30
|
|
}
|
|
if timeout > 300 {
|
|
timeout = 300
|
|
}
|
|
|
|
execCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
var res executor.Result
|
|
if b.server.isRemote(device) {
|
|
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
rr, err := b.server.Cluster.Exec(execCtx, device, cluster.ExecPayload{Command: command, Workdir: workdir, Timeout: timeout})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res = executor.Result{ExitCode: rr.ExitCode, Stdout: rr.Stdout, Stderr: rr.Stderr, DurationMS: rr.DurationMS}
|
|
} else {
|
|
res = b.server.Exec.Run(execCtx, command, workdir)
|
|
}
|
|
|
|
if b.server.Store != nil {
|
|
_ = b.server.Store.Audit(clientIP, device, "exec", command, res.ExitCode, res.DurationMS, res.Stderr)
|
|
}
|
|
|
|
return model.ExecResponse{
|
|
Device: device,
|
|
ExitCode: res.ExitCode,
|
|
Stdout: res.Stdout,
|
|
Stderr: res.Stderr,
|
|
DurationMS: res.DurationMS,
|
|
}, nil
|
|
}
|
|
|
|
func (b *MCPBackend) DiagnosticCommand(ctx context.Context, clientIP, device string, req executor.DiagnosticRequest) (any, error) {
|
|
if device == "" {
|
|
device = b.server.LocalName
|
|
}
|
|
if err := b.authorize(device, "diagnostic", clientIP); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Action == "" {
|
|
return nil, errors.New("diagnostic action required")
|
|
}
|
|
if b.server.isRemote(device) {
|
|
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
res, err := b.server.Cluster.Diagnostic(ctx, device, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res, nil
|
|
}
|
|
res, err := executor.Diagnostic(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (b *MCPBackend) ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error) {
|
|
if device == "" {
|
|
device = b.server.LocalName
|
|
}
|
|
if err := b.authorize(device, "read", clientIP); err != nil {
|
|
return nil, err
|
|
}
|
|
if path == "" || !validPath(path) || offset < 0 {
|
|
return nil, errors.New("invalid path or offset")
|
|
}
|
|
if limit <= 0 {
|
|
limit = defaultFileLimit
|
|
}
|
|
if limit > maxFileLimit {
|
|
limit = maxFileLimit
|
|
}
|
|
|
|
var content string
|
|
var n int64
|
|
var eof bool
|
|
var totalSize int64
|
|
var hasMore bool
|
|
|
|
if b.server.isRemote(device) {
|
|
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
fr, err := b.server.Cluster.ReadFile(ctx, device, cluster.FileReadPayload{Path: path, Offset: offset, Limit: limit})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content, n, totalSize, hasMore, eof = fr.Content, fr.Bytes, fr.TotalSize, fr.HasMore, fr.EOF
|
|
} else {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open failed: %w", err)
|
|
}
|
|
defer f.Close()
|
|
if _, err = f.Seek(offset, io.SeekStart); err != nil {
|
|
return nil, fmt.Errorf("seek failed: %w", err)
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat failed: %w", err)
|
|
}
|
|
totalSize = info.Size()
|
|
buf := make([]byte, limit)
|
|
rn, re := io.ReadFull(f, buf)
|
|
eof = re == io.EOF || re == io.ErrUnexpectedEOF
|
|
if re != nil && !eof {
|
|
return nil, fmt.Errorf("read failed: %w", re)
|
|
}
|
|
content, n = string(buf[:rn]), int64(rn)
|
|
hasMore = offset+n < totalSize
|
|
}
|
|
|
|
if b.server.Store != nil {
|
|
_ = b.server.Store.Audit(clientIP, device, "file_read", path, 0, 0, "")
|
|
}
|
|
|
|
return model.FileReadResponse{
|
|
Device: device,
|
|
Path: path,
|
|
Offset: offset,
|
|
Content: content,
|
|
Bytes: n,
|
|
TotalSize: totalSize,
|
|
HasMore: hasMore,
|
|
EOF: eof,
|
|
}, nil
|
|
}
|
|
|
|
func (b *MCPBackend) EditBlock(ctx context.Context, clientIP, device, path, oldText, newText string) (any, error) {
|
|
if device == "" {
|
|
device = b.server.LocalName
|
|
}
|
|
if err := b.authorize(device, "write", clientIP); err != nil {
|
|
return nil, err
|
|
}
|
|
if path == "" || !validPath(path) || oldText == "" {
|
|
return nil, errors.New("path and old_text required")
|
|
}
|
|
if len(oldText) > int(maxFileLimit) || len(newText) > int(maxFileLimit) {
|
|
return nil, errors.New("edit block too large")
|
|
}
|
|
if b.server.isRemote(device) {
|
|
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
res, err := b.server.Cluster.EditBlock(ctx, device, cluster.EditBlockPayload{Path: path, OldText: oldText, NewText: newText})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = b.server.Store.Audit(clientIP, device, "edit_block", path, 0, 0, fmt.Sprintf("bytes_written=%d", res.BytesWritten))
|
|
return map[string]any{"device": device, "path": path, "bytes_written": res.BytesWritten}, nil
|
|
}
|
|
res, err := executor.EditBlock(path, oldText, newText)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = b.server.Store.Audit(clientIP, device, "edit_block", path, 0, 0, fmt.Sprintf("bytes_written=%d", res.BytesWritten))
|
|
return map[string]any{"device": device, "path": path, "bytes_written": res.BytesWritten}, nil
|
|
}
|
|
|
|
func (b *MCPBackend) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) {
|
|
if device == "" {
|
|
device = b.server.LocalName
|
|
}
|
|
if err := b.authorize(device, "write", clientIP); err != nil {
|
|
return nil, err
|
|
}
|
|
if path == "" || !validPath(path) {
|
|
return nil, errors.New("path required")
|
|
}
|
|
if int64(len(content)) > maxFileLimit {
|
|
return nil, errors.New("content too large")
|
|
}
|
|
|
|
var n int64
|
|
if b.server.isRemote(device) {
|
|
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
var err error
|
|
n, err = b.server.Cluster.WriteFile(ctx, device, cluster.FileWritePayload{Path: path, Content: content})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
return nil, fmt.Errorf("write failed: %w", err)
|
|
}
|
|
n = int64(len(content))
|
|
}
|
|
|
|
if b.server.Store != nil {
|
|
_ = b.server.Store.Audit(clientIP, device, "file_write", path, 0, 0, "")
|
|
}
|
|
|
|
return model.FileWriteResponse{
|
|
Device: device,
|
|
Path: path,
|
|
Bytes: n,
|
|
}, nil
|
|
}
|
|
|
|
func (b *MCPBackend) ListAgentMCP(ctx context.Context, device string) (any, error) {
|
|
if device == "" {
|
|
return nil, errors.New("device required")
|
|
}
|
|
if b.server.isRemote(device) && (b.server.Cluster == nil || !b.server.Cluster.Online(device)) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
if !b.server.isRemote(device) {
|
|
return map[string]any{"device": b.server.LocalName, "services": []any{}}, nil
|
|
}
|
|
return b.server.Cluster.ListMCP(device)
|
|
}
|
|
func (b *MCPBackend) CallAgentMCP(ctx context.Context, clientIP, device, server, tool string, args any) (any, error) {
|
|
if device == "" || server == "" || tool == "" {
|
|
return nil, errors.New("device, server and tool required")
|
|
}
|
|
if b.server.isRemote(device) && (b.server.Cluster == nil || !b.server.Cluster.Online(device)) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
if !b.server.isRemote(device) {
|
|
return nil, errors.New("agent MCP requires a remote device")
|
|
}
|
|
start := time.Now()
|
|
v, err := b.server.Cluster.CallMCP(ctx, device, server, tool, args)
|
|
ms := time.Since(start).Milliseconds()
|
|
if b.server.Store != nil {
|
|
if err != nil {
|
|
_ = b.server.Store.Audit(clientIP, device, "mcp_call", server+"/"+tool, 1, ms, err.Error())
|
|
} else {
|
|
_ = b.server.Store.Audit(clientIP, device, "mcp_call", server+"/"+tool, 0, ms, "")
|
|
}
|
|
}
|
|
return v, err
|
|
}
|
|
|
|
// AuthorizeMCP exposes the same Hub-side gate to the MCP transport layer.
|
|
func (b *MCPBackend) AuthorizeMCP(device, action, clientIP string) error {
|
|
return b.authorize(device, action, clientIP)
|
|
}
|
|
|
|
func (b *MCPBackend) ReadMultipleFiles(ctx context.Context, clientIP, device string, paths []string, limit int64) (any, error) {
|
|
if device == "" {
|
|
device = b.server.LocalName
|
|
}
|
|
if err := b.authorize(device, "read", clientIP); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(paths) > 32 {
|
|
return nil, errors.New("too many paths")
|
|
}
|
|
if b.server.isRemote(device) {
|
|
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
return b.server.Cluster.ReadMultipleFiles(ctx, device, cluster.ReadMultipleFilesPayload{Paths: paths, Limit: limit})
|
|
}
|
|
return executor.ReadMultipleFiles(paths, limit), nil
|
|
}
|
|
func (b *MCPBackend) CreateDirectory(ctx context.Context, clientIP, device, path string) (any, error) {
|
|
if device == "" {
|
|
device = b.server.LocalName
|
|
}
|
|
if err := b.authorize(device, "write", clientIP); err != nil {
|
|
return nil, err
|
|
}
|
|
if b.server.isRemote(device) {
|
|
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
if err := b.server.Cluster.CreateDirectory(ctx, device, cluster.CreateDirectoryPayload{Path: path}); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if err := executor.CreateDirectory(path); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return map[string]any{"device": device, "path": path, "ok": true}, nil
|
|
}
|
|
func (b *MCPBackend) MoveFile(ctx context.Context, clientIP, device, source, destination string) (any, error) {
|
|
if device == "" {
|
|
device = b.server.LocalName
|
|
}
|
|
if err := b.authorize(device, "write", clientIP); err != nil {
|
|
return nil, err
|
|
}
|
|
if b.server.isRemote(device) {
|
|
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
if err := b.server.Cluster.MoveFile(ctx, device, cluster.MoveFilePayload{Source: source, Destination: destination}); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if err := executor.MoveFile(source, destination); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return map[string]any{"device": device, "source": source, "destination": destination, "ok": true}, nil
|
|
}
|
|
func (b *MCPBackend) ListProcesses(ctx context.Context, clientIP, device string) (any, error) {
|
|
if device == "" {
|
|
device = b.server.LocalName
|
|
}
|
|
if err := b.authorize(device, "read", clientIP); err != nil {
|
|
return nil, err
|
|
}
|
|
if b.server.isRemote(device) {
|
|
if b.server.Cluster == nil || !b.server.Cluster.Online(device) {
|
|
return nil, fmt.Errorf("device %s is offline", device)
|
|
}
|
|
return b.server.Cluster.ListProcesses(ctx, device)
|
|
}
|
|
return executor.ListProcesses()
|
|
}
|