Files

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