feat: initial commit of vps-commander release v1.0.0
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wjyhk/vps-commander/internal/auth"
|
||||
"github.com/wjyhk/vps-commander/internal/cluster"
|
||||
"github.com/wjyhk/vps-commander/internal/executor"
|
||||
"github.com/wjyhk/vps-commander/internal/model"
|
||||
"github.com/wjyhk/vps-commander/internal/storage"
|
||||
panelweb "github.com/wjyhk/vps-commander/internal/web"
|
||||
)
|
||||
|
||||
//go:embed openapi.json
|
||||
var openAPIFS embed.FS
|
||||
|
||||
const (
|
||||
defaultFileLimit int64 = 256 * 1024
|
||||
maxFileLimit int64 = 1024 * 1024
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Auth *auth.Manager
|
||||
Exec executor.Local
|
||||
Cluster *cluster.Manager
|
||||
Store *storage.Store
|
||||
LocalName string
|
||||
}
|
||||
|
||||
func (s *Server) Panel(password string) http.Handler {
|
||||
p := panelweb.New(password, s)
|
||||
go p.Cleanup()
|
||||
return p.Handler()
|
||||
}
|
||||
|
||||
func (s *Server) DevicesJSON(w http.ResponseWriter, r *http.Request) { s.devices(w, r) }
|
||||
func (s *Server) PanelExec(w http.ResponseWriter, r *http.Request) { s.exec(w, r) }
|
||||
|
||||
func (s *Server) RotateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
if s.Auth == nil {
|
||||
http.Error(w, "auth manager unavailable", 500)
|
||||
return
|
||||
}
|
||||
jsonOut(w, map[string]string{"api_key": s.Auth.Rotate()})
|
||||
}
|
||||
|
||||
func (s *Server) AuditsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
limit := 100
|
||||
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
logs, err := s.Store.RecentAudits(limit)
|
||||
if err != nil {
|
||||
http.Error(w, "audit query failed", 500)
|
||||
return
|
||||
}
|
||||
jsonOut(w, logs)
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.health)
|
||||
mux.HandleFunc("/api/v1/devices", s.devices)
|
||||
mux.HandleFunc("/api/v1/exec", s.exec)
|
||||
mux.HandleFunc("/api/v1/file/read", s.fileRead)
|
||||
mux.HandleFunc("/api/v1/file/write", s.fileWrite)
|
||||
mux.HandleFunc("/agent/ws", s.agentWS)
|
||||
mux.HandleFunc("/openapi.json", s.openapi)
|
||||
return mux
|
||||
}
|
||||
func jsonOut(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
jsonOut(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) devices(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
out := []model.Device{{Name: s.LocalName, Status: "online", Local: true, Arch: runtime.GOARCH, OS: runtime.GOOS}}
|
||||
if s.Cluster != nil {
|
||||
for _, d := range s.Cluster.Devices() {
|
||||
b, _ := json.Marshal(d)
|
||||
var v model.Device
|
||||
_ = json.Unmarshal(b, &v)
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
jsonOut(w, out)
|
||||
}
|
||||
|
||||
func (s *Server) agentWS(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Cluster == nil {
|
||||
http.Error(w, "cluster unavailable", 503)
|
||||
return
|
||||
}
|
||||
s.Cluster.ServeWS(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) isRemote(device string) bool {
|
||||
return device != "" && device != "local" && device != s.LocalName
|
||||
}
|
||||
func (s *Server) exec(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req model.ExecRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json", 400)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Command) == "" {
|
||||
http.Error(w, "command required", 400)
|
||||
return
|
||||
}
|
||||
if req.Device == "" {
|
||||
req.Device = s.LocalName
|
||||
}
|
||||
if req.Timeout <= 0 {
|
||||
req.Timeout = 30
|
||||
}
|
||||
if req.Timeout > 300 {
|
||||
req.Timeout = 300
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(req.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
var res executor.Result
|
||||
if s.isRemote(req.Device) {
|
||||
if s.Cluster == nil || !s.Cluster.Online(req.Device) {
|
||||
http.Error(w, "device offline", 404)
|
||||
return
|
||||
}
|
||||
rr, err := s.Cluster.Exec(ctx, req.Device, cluster.ExecPayload{Command: req.Command, Workdir: req.Workdir, Timeout: req.Timeout})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
res = executor.Result{ExitCode: rr.ExitCode, Stdout: rr.Stdout, Stderr: rr.Stderr, DurationMS: rr.DurationMS}
|
||||
} else {
|
||||
res = s.Exec.Run(ctx, req.Command, req.Workdir)
|
||||
}
|
||||
if err := s.Store.Audit(clientIP(r), req.Device, "exec", req.Command, res.ExitCode, res.DurationMS, res.Stderr); err != nil {
|
||||
http.Error(w, "audit write failed", 500)
|
||||
return
|
||||
}
|
||||
jsonOut(w, model.ExecResponse{Device: req.Device, ExitCode: res.ExitCode, Stdout: res.Stdout, Stderr: res.Stderr, DurationMS: res.DurationMS})
|
||||
}
|
||||
func (s *Server) fileRead(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req model.FileReadRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json", 400)
|
||||
return
|
||||
}
|
||||
if req.Device == "" {
|
||||
req.Device = s.LocalName
|
||||
}
|
||||
if req.Path == "" || !validPath(req.Path) || req.Offset < 0 {
|
||||
http.Error(w, "invalid path or offset", 400)
|
||||
return
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = defaultFileLimit
|
||||
}
|
||||
if req.Limit > maxFileLimit {
|
||||
req.Limit = maxFileLimit
|
||||
}
|
||||
var content string
|
||||
var n int64
|
||||
var eof bool
|
||||
var totalSize int64
|
||||
var hasMore bool
|
||||
if s.isRemote(req.Device) {
|
||||
if s.Cluster == nil || !s.Cluster.Online(req.Device) {
|
||||
http.Error(w, "device offline", 404)
|
||||
return
|
||||
}
|
||||
fr, err := s.Cluster.ReadFile(r.Context(), req.Device, cluster.FileReadPayload{Path: req.Path, Offset: req.Offset, Limit: req.Limit})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
content, n, totalSize, hasMore, eof = fr.Content, fr.Bytes, fr.TotalSize, fr.HasMore, fr.EOF
|
||||
} else {
|
||||
f, err := os.Open(req.Path)
|
||||
if err != nil {
|
||||
http.Error(w, "open failed: "+err.Error(), 404)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err = f.Seek(req.Offset, io.SeekStart); err != nil {
|
||||
http.Error(w, "seek failed: "+err.Error(), 400)
|
||||
return
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, "stat failed: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
totalSize = info.Size()
|
||||
buf := make([]byte, req.Limit)
|
||||
rn, re := io.ReadFull(f, buf)
|
||||
eof = re == io.EOF || re == io.ErrUnexpectedEOF
|
||||
if re != nil && !eof {
|
||||
http.Error(w, "read failed: "+re.Error(), 500)
|
||||
return
|
||||
}
|
||||
content, n = string(buf[:rn]), int64(rn)
|
||||
hasMore = req.Offset+n < totalSize
|
||||
}
|
||||
if err := s.Store.Audit(clientIP(r), req.Device, "file_read", req.Path, 0, 0, ""); err != nil {
|
||||
http.Error(w, "audit write failed", 500)
|
||||
return
|
||||
}
|
||||
jsonOut(w, model.FileReadResponse{Device: req.Device, Path: req.Path, Offset: req.Offset, Content: content, Bytes: n, TotalSize: totalSize, HasMore: hasMore, EOF: eof})
|
||||
}
|
||||
func (s *Server) fileWrite(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req model.FileWriteRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxFileLimit+16*1024)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json or content too large", 400)
|
||||
return
|
||||
}
|
||||
if req.Device == "" {
|
||||
req.Device = s.LocalName
|
||||
}
|
||||
if req.Path == "" || !validPath(req.Path) {
|
||||
http.Error(w, "path required", 400)
|
||||
return
|
||||
}
|
||||
if int64(len(req.Content)) > maxFileLimit {
|
||||
http.Error(w, "content too large", 413)
|
||||
return
|
||||
}
|
||||
var n int64
|
||||
if s.isRemote(req.Device) {
|
||||
if s.Cluster == nil || !s.Cluster.Online(req.Device) {
|
||||
http.Error(w, "device offline", 404)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
n, err = s.Cluster.WriteFile(r.Context(), req.Device, cluster.FileWritePayload{Path: req.Path, Content: req.Content})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := os.WriteFile(req.Path, []byte(req.Content), 0644); err != nil {
|
||||
http.Error(w, "write failed: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
n = int64(len(req.Content))
|
||||
}
|
||||
if err := s.Store.Audit(clientIP(r), req.Device, "file_write", req.Path, 0, 0, ""); err != nil {
|
||||
http.Error(w, "audit write failed", 500)
|
||||
return
|
||||
}
|
||||
jsonOut(w, model.FileWriteResponse{Device: req.Device, Path: req.Path, Bytes: n})
|
||||
}
|
||||
|
||||
func validPath(path string) bool {
|
||||
return !strings.ContainsRune(path, 0) && !strings.HasPrefix(path, "-")
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func (s *Server) openapi(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
data, err := openAPIFS.ReadFile("openapi.json")
|
||||
if err != nil {
|
||||
http.Error(w, "openapi unavailable", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/wjyhk/vps-commander/internal/executor"
|
||||
"github.com/wjyhk/vps-commander/internal/storage"
|
||||
)
|
||||
|
||||
func TestExecConcurrent100AuditsPersisted(t *testing.T) {
|
||||
store, err := storage.Open(filepath.Join(t.TempDir(), "commander.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.DB.Close()
|
||||
|
||||
s := &Server{Exec: executor.Local{MaxOutput: 1024 * 1024}, Store: store, LocalName: "local"}
|
||||
|
||||
const n = 100
|
||||
var wg sync.WaitGroup
|
||||
statuses := make(chan int, n)
|
||||
wg.Add(n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
req := httptest.NewRequest("POST", "/api/v1/exec", strings.NewReader("{\"device\":\"local\",\"command\":\"true\",\"timeout\":10}"))
|
||||
rec := httptest.NewRecorder()
|
||||
s.exec(rec, req)
|
||||
statuses <- rec.Code
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(statuses)
|
||||
for status := range statuses {
|
||||
if status != 200 {
|
||||
t.Fatalf("unexpected HTTP status: %d", status)
|
||||
}
|
||||
}
|
||||
logs, err := store.RecentAudits(500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(logs) != n {
|
||||
t.Fatalf("audit loss: got %d rows, want %d", len(logs), n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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) 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 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) ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error) {
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
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) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) {
|
||||
if device == "" {
|
||||
device = b.server.LocalName
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "VPS-Commander",
|
||||
"version": "1.1.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://vpstool.kory.kdns.fr"
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Device": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_local": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"arch": {
|
||||
"type": "string"
|
||||
},
|
||||
"os": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExecRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"workdir": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExecResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"exit_code": {
|
||||
"type": "integer"
|
||||
},
|
||||
"stdout": {
|
||||
"type": "string"
|
||||
},
|
||||
"stderr": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration_ms": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FileReadRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FileReadResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"bytes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"eof": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"total_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"has_more": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FileWriteRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"content"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FileWriteResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"bytes": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/devices": {
|
||||
"get": {
|
||||
"operationId": "list_devices",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Devices",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Device"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/exec": {
|
||||
"post": {
|
||||
"operationId": "exec_command",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExecRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Execution result",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExecResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/file/read": {
|
||||
"post": {
|
||||
"operationId": "read_file",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileReadRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "File content",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileReadResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/file/write": {
|
||||
"post": {
|
||||
"operationId": "write_file",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileWriteRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Write result",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileWriteResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
token string
|
||||
}
|
||||
|
||||
func NewManager(token string) *Manager { return &Manager{token: token} }
|
||||
|
||||
func (m *Manager) Token() string { m.mu.RLock(); defer m.mu.RUnlock(); return m.token }
|
||||
|
||||
func (m *Manager) Rotate() string {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
t := hex.EncodeToString(b)
|
||||
m.mu.Lock()
|
||||
m.token = t
|
||||
m.mu.Unlock()
|
||||
return t
|
||||
}
|
||||
|
||||
type Middleware struct {
|
||||
Token string
|
||||
Manager *Manager
|
||||
Optional bool
|
||||
}
|
||||
|
||||
func (m Middleware) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got := ""
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
got = strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
||||
} else if q := r.URL.Query().Get("token"); q != "" {
|
||||
got = q
|
||||
} else if q := r.URL.Query().Get("key"); q != "" {
|
||||
got = q
|
||||
} else if q := r.URL.Query().Get("api_key"); q != "" {
|
||||
got = q
|
||||
}
|
||||
|
||||
expected := m.Token
|
||||
if m.Manager != nil {
|
||||
expected = m.Manager.Token()
|
||||
}
|
||||
|
||||
if got == "" && m.Optional {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if expected == "" || got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 {
|
||||
unauthorized(w)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("WWW-Authenticate", "Bearer realm=\"vps-commander\"")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("{\"error\":\"Unauthorized\"}"))
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
Secret string
|
||||
mu sync.RWMutex
|
||||
nodes map[string]*Node
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Name string
|
||||
Conn *websocket.Conn
|
||||
WriteMu sync.Mutex
|
||||
Pending sync.Map
|
||||
LastSeen atomic.Int64
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
DurationMS int64
|
||||
}
|
||||
|
||||
type FileResult struct {
|
||||
Content string `json:"content"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
HasMore bool `json:"has_more"`
|
||||
EOF bool `json:"eof"`
|
||||
}
|
||||
|
||||
func NewManager(secret string) *Manager {
|
||||
return &Manager{Secret: secret, nodes: make(map[string]*Node)}
|
||||
}
|
||||
func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
provided := ""
|
||||
if len(auth) >= 7 && auth[:7] == "Bearer " {
|
||||
provided = auth[7:]
|
||||
}
|
||||
if m.Secret == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(m.Secret)) != 1 {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
name := r.URL.Query().Get("device_name")
|
||||
if name == "" {
|
||||
http.Error(w, "device_name required", 400)
|
||||
return
|
||||
}
|
||||
up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
c, err := up.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
n := &Node{Name: name, Conn: c}
|
||||
n.LastSeen.Store(time.Now().UnixNano())
|
||||
m.mu.Lock()
|
||||
if old := m.nodes[name]; old != nil {
|
||||
_ = old.Conn.Close()
|
||||
}
|
||||
m.nodes[name] = n
|
||||
m.mu.Unlock()
|
||||
_ = n.write(Message{Event: "registered", Device: name, TS: time.Now().Unix()})
|
||||
go m.heartbeat(n)
|
||||
go m.readLoop(n)
|
||||
}
|
||||
|
||||
func (n *Node) write(msg Message) error {
|
||||
n.WriteMu.Lock()
|
||||
defer n.WriteMu.Unlock()
|
||||
if err := n.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
return err
|
||||
}
|
||||
return n.Conn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
func (m *Manager) readLoop(n *Node) {
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
if m.nodes[n.Name] == n {
|
||||
delete(m.nodes, n.Name)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
_ = n.Conn.Close()
|
||||
n.Pending.Range(func(key, v any) bool {
|
||||
if ch, ok := v.(chan Message); ok {
|
||||
select {
|
||||
case ch <- Message{Status: "error", Error: "device disconnected"}:
|
||||
default:
|
||||
}
|
||||
n.Pending.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}()
|
||||
_ = n.Conn.SetReadDeadline(time.Now().Add(75 * time.Second))
|
||||
for {
|
||||
var msg Message
|
||||
if err := n.Conn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
n.LastSeen.Store(time.Now().UnixNano())
|
||||
_ = n.Conn.SetReadDeadline(time.Now().Add(75 * time.Second))
|
||||
if msg.Event == "pong" || msg.Event == "ping" {
|
||||
continue
|
||||
}
|
||||
if msg.ID != "" {
|
||||
if v, ok := n.Pending.LoadAndDelete(msg.ID); ok {
|
||||
if ch, ok := v.(chan Message); ok {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func (m *Manager) Online(name string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
n := m.nodes[name]
|
||||
return n != nil && time.Since(time.Unix(0, n.LastSeen.Load())) < 70*time.Second
|
||||
}
|
||||
|
||||
func (m *Manager) Devices() []map[string]any {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]map[string]any, 0, len(m.nodes))
|
||||
for name, n := range m.nodes {
|
||||
status := "online"
|
||||
lastSeen := time.Unix(0, n.LastSeen.Load())
|
||||
if time.Since(lastSeen) >= 70*time.Second {
|
||||
status = "offline"
|
||||
}
|
||||
out = append(out, map[string]any{"name": name, "status": status, "is_local": false, "last_heartbeat": lastSeen.Unix()})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) call(ctx context.Context, name, action string, payload any) (Message, error) {
|
||||
m.mu.RLock()
|
||||
n := m.nodes[name]
|
||||
m.mu.RUnlock()
|
||||
if n == nil || !m.Online(name) {
|
||||
return Message{}, errors.New("device offline")
|
||||
}
|
||||
id := fmt.Sprintf("task_%d", time.Now().UnixNano())
|
||||
ch := make(chan Message, 1)
|
||||
n.Pending.Store(id, ch)
|
||||
if err := n.write(Message{ID: id, Action: action, Payload: payload}); err != nil {
|
||||
n.Pending.Delete(id)
|
||||
return Message{}, err
|
||||
}
|
||||
select {
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
return Message{}, errors.New("device disconnected")
|
||||
}
|
||||
if msg.Status == "error" {
|
||||
return Message{}, errors.New(msg.Error)
|
||||
}
|
||||
return msg, nil
|
||||
case <-ctx.Done():
|
||||
n.Pending.Delete(id)
|
||||
return Message{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
func (m *Manager) Exec(ctx context.Context, name string, p ExecPayload) (Result, error) {
|
||||
msg, err := m.call(ctx, name, "exec", p)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{msg.ExitCode, msg.Stdout, msg.Stderr, msg.DurationMS}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ReadFile(ctx context.Context, name string, p FileReadPayload) (FileResult, error) {
|
||||
msg, err := m.call(ctx, name, "file_read", p)
|
||||
if err != nil {
|
||||
return FileResult{}, err
|
||||
}
|
||||
var v FileResult
|
||||
b, _ := json.Marshal(msg.Payload)
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return FileResult{}, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (m *Manager) WriteFile(ctx context.Context, name string, p FileWritePayload) (int64, error) {
|
||||
msg, err := m.call(ctx, name, "file_write", p)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var v struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
b, _ := json.Marshal(msg.Payload)
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v.Bytes, nil
|
||||
}
|
||||
|
||||
func (m *Manager) heartbeat(n *Node) {
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
m.mu.RLock()
|
||||
current := m.nodes[n.Name] == n
|
||||
m.mu.RUnlock()
|
||||
if !current {
|
||||
return
|
||||
}
|
||||
if err := n.write(Message{Event: "ping", TS: time.Now().Unix()}); err != nil {
|
||||
_ = n.Conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cluster
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Event string `json:"event,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
ExitCode int `json:"exit_code,omitempty"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
TS int64 `json:"ts,omitempty"`
|
||||
}
|
||||
|
||||
type ExecPayload struct {
|
||||
Command string `json:"command"`
|
||||
Workdir string `json:"workdir,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
type FileReadPayload struct {
|
||||
Path string `json:"path"`
|
||||
Offset int64 `json:"offset,omitempty"`
|
||||
Limit int64 `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type FileWritePayload struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
DurationMS int64
|
||||
}
|
||||
|
||||
type Local struct{ MaxOutput int }
|
||||
|
||||
type cappedBuffer struct {
|
||||
buf bytes.Buffer
|
||||
max int
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) Write(p []byte) (int, error) {
|
||||
if b.max <= 0 {
|
||||
return len(p), nil
|
||||
}
|
||||
if b.buf.Len() < b.max {
|
||||
n := b.max - b.buf.Len()
|
||||
if n > len(p) {
|
||||
n = len(p)
|
||||
}
|
||||
_, _ = b.buf.Write(p[:n])
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (e Local) Run(ctx context.Context, command, workdir string) Result {
|
||||
start := time.Now()
|
||||
if e.MaxOutput <= 0 {
|
||||
e.MaxOutput = 1024 * 1024
|
||||
}
|
||||
cmd := exec.Command("/bin/sh", "-c", command)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if workdir != "" {
|
||||
cmd.Dir = workdir
|
||||
}
|
||||
var stdout, stderr cappedBuffer
|
||||
stdout.max, stderr.max = e.MaxOutput, e.MaxOutput
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return Result{ExitCode: 1, Stderr: err.Error(), DurationMS: time.Since(start).Milliseconds()}
|
||||
}
|
||||
waitCh := make(chan error, 1)
|
||||
go func() { waitCh <- cmd.Wait() }()
|
||||
timedOut := false
|
||||
var err error
|
||||
select {
|
||||
case err = <-waitCh:
|
||||
case <-ctx.Done():
|
||||
timedOut = true
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
err = <-waitCh
|
||||
}
|
||||
code := 0
|
||||
if err != nil {
|
||||
code = 1
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
code = ee.ExitCode()
|
||||
}
|
||||
}
|
||||
if timedOut || ctx.Err() != nil {
|
||||
code = 124
|
||||
}
|
||||
return Result{code, stdout.buf.String(), strings.TrimSpace(stderr.buf.String()), time.Since(start).Milliseconds()}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wjyhk/vps-commander/internal/auth"
|
||||
)
|
||||
|
||||
// MCP Protocol / JSON-RPC 2.0 Types
|
||||
type JSONRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type JSONRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *JSONRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type JSONRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Tool Definition Structures
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema InputSchema `json:"inputSchema"`
|
||||
}
|
||||
|
||||
type InputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Properties map[string]PropertyDef `json:"properties"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type PropertyDef struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type CallToolParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
|
||||
type TextContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type CallToolResult struct {
|
||||
Content []TextContent `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// Backend interface for invoking actions
|
||||
type ActionBackend interface {
|
||||
ListDevices(ctx context.Context) (any, error)
|
||||
ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error)
|
||||
ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error)
|
||||
WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Backend ActionBackend
|
||||
Auth *auth.Manager
|
||||
|
||||
mu sync.RWMutex
|
||||
sessions map[string]chan []byte
|
||||
}
|
||||
|
||||
func NewServer(backend ActionBackend, authMgr *auth.Manager) *Server {
|
||||
return &Server{
|
||||
Backend: backend,
|
||||
Auth: authMgr,
|
||||
sessions: make(map[string]chan []byte),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) newSessionID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// SSE Handler: GET /mcp/sse
|
||||
func (s *Server) HandleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := s.newSessionID()
|
||||
msgCh := make(chan []byte, 64)
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionID] = msgCh
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, sessionID)
|
||||
close(msgCh)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Endpoint event telling client where to send POST messages
|
||||
endpointURI := fmt.Sprintf("/mcp/message?sessionId=%s", sessionID)
|
||||
fmt.Fprintf(w, "event: endpoint\ndata: %s\n\n", endpointURI)
|
||||
flusher.Flush()
|
||||
|
||||
notify := r.Context().Done()
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case msg, ok := <-msgCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(msg))
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
fmt.Fprintf(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message Handler: POST /mcp/message
|
||||
func (s *Server) HandleMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := r.URL.Query().Get("sessionId")
|
||||
s.mu.RLock()
|
||||
ch, exists := s.sessions[sessionID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1024*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "read error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req JSONRPCRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "invalid json-rpc", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := s.handleRequest(r.Context(), r, req)
|
||||
|
||||
// If session exists, push response via SSE; otherwise reply directly via HTTP
|
||||
respBytes, _ := json.Marshal(resp)
|
||||
if exists && ch != nil {
|
||||
select {
|
||||
case ch <- respBytes:
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("accepted"))
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(respBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRequest(ctx context.Context, r *http.Request, req JSONRPCRequest) JSONRPCResponse {
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": map[string]string{
|
||||
"name": "vps-commander",
|
||||
"version": "1.2.0",
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]bool{"listChanged": false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
case "notifications/initialized":
|
||||
return JSONRPCResponse{JSONRPC: "2.0"}
|
||||
|
||||
case "ping":
|
||||
return JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]string{}}
|
||||
|
||||
case "tools/list":
|
||||
tools := []Tool{
|
||||
{
|
||||
Name: "list_devices",
|
||||
Description: "列出当前所有已注册的 VPS 节点及其在线状态与系统架构",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertyDef{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "exec_command",
|
||||
Description: "在指定受控节点(默认本机 local)执行任意 Shell 命令并返回输出和退出码",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertyDef{
|
||||
"device": {Type: "string", Description: "目标设备节点名称/别名(缺省则在当前 Hub 所在节点执行)"},
|
||||
"command": {Type: "string", Description: "需要执行的 Shell 命令"},
|
||||
"workdir": {Type: "string", Description: "可选的工作目录绝对路径"},
|
||||
"timeout": {Type: "integer", Description: "超时时间(秒,默认 30,最大 300)"},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_file",
|
||||
Description: "从指定受控节点读取文件内容(支持偏移与字节分页)",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertyDef{
|
||||
"device": {Type: "string", Description: "目标设备节点名称/别名(缺省为 local)"},
|
||||
"path": {Type: "string", Description: "文件的绝对路径"},
|
||||
"offset": {Type: "integer", Description: "读取的起始字节偏移量(默认 0)"},
|
||||
"limit": {Type: "integer", Description: "读取的最大字节数(默认 262144,最大 1048576)"},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "write_file",
|
||||
Description: "向指定受控节点创建或覆写文件",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertyDef{
|
||||
"device": {Type: "string", Description: "目标设备节点名称/别名(缺省为 local)"},
|
||||
"path": {Type: "string", Description: "文件的绝对路径"},
|
||||
"content": {Type: "string", Description: "要写入的文件文本内容"},
|
||||
},
|
||||
Required: []string{"path", "content"},
|
||||
},
|
||||
},
|
||||
}
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]any{"tools": tools},
|
||||
}
|
||||
|
||||
case "tools/call":
|
||||
var params CallToolParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &JSONRPCError{Code: -32602, Message: "invalid tool params"},
|
||||
}
|
||||
}
|
||||
|
||||
clientIP := r.RemoteAddr
|
||||
res, isErr := s.callTool(ctx, clientIP, params)
|
||||
resJSON, _ := json.MarshalIndent(res, "", " ")
|
||||
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: CallToolResult{
|
||||
Content: []TextContent{
|
||||
{Type: "text", Text: string(resJSON)},
|
||||
},
|
||||
IsError: isErr,
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &JSONRPCError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) callTool(ctx context.Context, clientIP string, params CallToolParams) (any, bool) {
|
||||
switch params.Name {
|
||||
case "list_devices":
|
||||
devs, err := s.Backend.ListDevices(ctx)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return devs, false
|
||||
|
||||
case "exec_command":
|
||||
var args struct {
|
||||
Device string `json:"device"`
|
||||
Command string `json:"command"`
|
||||
Workdir string `json:"workdir"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
_ = json.Unmarshal(params.Arguments, &args)
|
||||
if args.Command == "" {
|
||||
return map[string]string{"error": "command is required"}, true
|
||||
}
|
||||
res, err := s.Backend.ExecCommand(ctx, clientIP, args.Device, args.Command, args.Workdir, args.Timeout)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
|
||||
case "read_file":
|
||||
var args struct {
|
||||
Device string `json:"device"`
|
||||
Path string `json:"path"`
|
||||
Offset int64 `json:"offset"`
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
_ = json.Unmarshal(params.Arguments, &args)
|
||||
if args.Path == "" {
|
||||
return map[string]string{"error": "path is required"}, true
|
||||
}
|
||||
res, err := s.Backend.ReadFile(ctx, clientIP, args.Device, args.Path, args.Offset, args.Limit)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
|
||||
case "write_file":
|
||||
var args struct {
|
||||
Device string `json:"device"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
_ = json.Unmarshal(params.Arguments, &args)
|
||||
if args.Path == "" {
|
||||
return map[string]string{"error": "path is required"}, true
|
||||
}
|
||||
res, err := s.Backend.WriteFile(ctx, clientIP, args.Device, args.Path, args.Content)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
|
||||
default:
|
||||
return map[string]string{"error": "unknown tool: " + params.Name}, true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandleDirectRequest(ctx context.Context, req JSONRPCRequest) JSONRPCResponse {
|
||||
return s.handleRequest(ctx, &http.Request{RemoteAddr: "stdio"}, req)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mockBackend struct{}
|
||||
|
||||
func (m *mockBackend) ListDevices(ctx context.Context) (any, error) {
|
||||
return []map[string]any{{"name": "local", "status": "online"}}, nil
|
||||
}
|
||||
func (m *mockBackend) ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error) {
|
||||
return map[string]any{"device": device, "exit_code": 0, "stdout": "ok"}, nil
|
||||
}
|
||||
func (m *mockBackend) ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error) {
|
||||
return map[string]any{"device": device, "path": path, "content": "hello"}, nil
|
||||
}
|
||||
func (m *mockBackend) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) {
|
||||
return map[string]any{"device": device, "path": path, "bytes": len(content)}, nil
|
||||
}
|
||||
|
||||
func TestMCPInitializeAndListTools(t *testing.T) {
|
||||
srv := NewServer(&mockBackend{}, nil)
|
||||
|
||||
// Test Initialize
|
||||
initReq := JSONRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: 1,
|
||||
Method: "initialize",
|
||||
}
|
||||
body, _ := json.Marshal(initReq)
|
||||
req := httptest.NewRequest("POST", "/mcp/message", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.HandleMessage(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp JSONRPCResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: %v", resp.Error)
|
||||
}
|
||||
|
||||
// Test tools/list
|
||||
listReq := JSONRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: 2,
|
||||
Method: "tools/list",
|
||||
}
|
||||
body, _ = json.Marshal(listReq)
|
||||
req = httptest.NewRequest("POST", "/mcp/message", bytes.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
srv.HandleMessage(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var listResp JSONRPCResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resMap, ok := listResp.Result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map result")
|
||||
}
|
||||
tools, ok := resMap["tools"].([]any)
|
||||
if !ok || len(tools) != 4 {
|
||||
t.Fatalf("expected 4 tools, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCallTool(t *testing.T) {
|
||||
srv := NewServer(&mockBackend{}, nil)
|
||||
|
||||
callReq := JSONRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: 3,
|
||||
Method: "tools/call",
|
||||
Params: json.RawMessage(`{"name":"exec_command","arguments":{"command":"echo test"}}`),
|
||||
}
|
||||
body, _ := json.Marshal(callReq)
|
||||
req := httptest.NewRequest("POST", "/mcp/message", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.HandleMessage(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp JSONRPCResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: %v", resp.Error)
|
||||
}
|
||||
contentStr := string(w.Body.Bytes())
|
||||
if !strings.Contains(contentStr, "stdout") {
|
||||
t.Fatalf("expected stdout in result, got %s", contentStr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleOAuthMetadata serves /.well-known/oauth-authorization-server
|
||||
func (s *Server) HandleOAuthMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
scheme := "https"
|
||||
host := r.Host
|
||||
meta := map[string]any{
|
||||
"issuer": fmt.Sprintf("%s://%s", scheme, host),
|
||||
"authorization_endpoint": fmt.Sprintf("%s://%s/oauth/authorize", scheme, host),
|
||||
"token_endpoint": fmt.Sprintf("%s://%s/oauth/token", scheme, host),
|
||||
"response_types_supported": []string{"code"},
|
||||
"grant_types_supported": []string{"authorization_code"},
|
||||
"code_challenge_methods_supported": []string{"S256", "plain"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_post", "client_secret_basic", "none"},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
_ = json.NewEncoder(w).Encode(meta)
|
||||
}
|
||||
|
||||
// HandleAuthorize handles the user/client authorization step
|
||||
func (s *Server) HandleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
redirectURI := r.URL.Query().Get("redirect_uri")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
if redirectURI == "" {
|
||||
http.Error(w, "missing redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := "vps-commander-auth-code"
|
||||
target := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
// HandleToken exchanges code for bearer token
|
||||
func (s *Server) HandleToken(w http.ResponseWriter, r *http.Request) {
|
||||
token := ""
|
||||
if s.Auth != nil {
|
||||
token = s.Auth.Token()
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400 * 365,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package model
|
||||
|
||||
type ExecRequest struct {
|
||||
Device string `json:"device,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Workdir string `json:"workdir,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
type ExecResponse struct {
|
||||
Device string `json:"device"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Local bool `json:"is_local"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
}
|
||||
|
||||
type FileReadRequest struct {
|
||||
Device string `json:"device,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Offset int64 `json:"offset,omitempty"`
|
||||
Limit int64 `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type FileReadResponse struct {
|
||||
Device string `json:"device"`
|
||||
Path string `json:"path"`
|
||||
Offset int64 `json:"offset"`
|
||||
Content string `json:"content"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
HasMore bool `json:"has_more"`
|
||||
EOF bool `json:"eof,omitempty"`
|
||||
}
|
||||
|
||||
type FileWriteRequest struct {
|
||||
Device string `json:"device,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type FileWriteResponse struct {
|
||||
Device string `json:"device"`
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "modernc.org/sqlite"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Store struct{ DB *sql.DB }
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SQLite has a single-writer model. Keep one application connection so
|
||||
// concurrent callers queue instead of opening competing writer connections.
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
s := &Store{db}
|
||||
if err := s.init(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) init() error {
|
||||
if _, err := s.DB.Exec(`PRAGMA journal_mode = WAL`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.DB.Exec(`PRAGMA busy_timeout = 5000`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS devices (
|
||||
name TEXT PRIMARY KEY, status TEXT NOT NULL, is_local INTEGER NOT NULL,
|
||||
arch TEXT, os TEXT, updated_at INTEGER NOT NULL
|
||||
); CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL,
|
||||
caller_ip TEXT, device TEXT, action TEXT, command TEXT,
|
||||
exit_code INTEGER, duration_ms INTEGER, error_msg TEXT
|
||||
);`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertLocal(name, arch, osName string) error {
|
||||
_, err := s.DB.Exec(`INSERT INTO devices(name,status,is_local,arch,os,updated_at)
|
||||
VALUES(?,?,?,?,?,?) ON CONFLICT(name) DO UPDATE SET status=excluded.status,
|
||||
arch=excluded.arch, os=excluded.os, updated_at=excluded.updated_at`,
|
||||
name, "online", 1, arch, osName, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CallerIP string `json:"caller_ip"`
|
||||
Device string `json:"device"`
|
||||
Action string `json:"action"`
|
||||
Command string `json:"command"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
|
||||
func (s *Store) Audit(ip, device, action, command string, code int, ms int64, msg string) error {
|
||||
_, err := s.DB.Exec(`INSERT INTO audit_logs(timestamp,caller_ip,device,action,command,exit_code,duration_ms,error_msg)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, time.Now().Unix(), ip, device, action, command, code, ms, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RecentAudits(limit int) ([]AuditLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := s.DB.Query(`SELECT id,timestamp,caller_ip,device,action,command,exit_code,duration_ms,error_msg
|
||||
FROM audit_logs ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AuditLog, 0, limit)
|
||||
for rows.Next() {
|
||||
var v AuditLog
|
||||
if err := rows.Scan(&v.ID, &v.Timestamp, &v.CallerIP, &v.Device, &v.Action, &v.Command, &v.ExitCode, &v.DurationMS, &v.ErrorMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuditConcurrent100NoLoss(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Open(filepath.Join(dir, "commander.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.DB.Close()
|
||||
|
||||
const n = 100
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, n)
|
||||
wg.Add(n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs <- s.Audit("127.0.0.1", "local", "exec", fmt.Sprintf("echo %d", i), 0, 1, "")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("audit write failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := s.DB.QueryRow("SELECT COUNT(*) FROM audit_logs").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != n {
|
||||
t.Fatalf("audit loss: got %d rows, want %d", count, n)
|
||||
}
|
||||
|
||||
var journal string
|
||||
if err := s.DB.QueryRow("PRAGMA journal_mode").Scan(&journal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if journal != "wal" {
|
||||
t.Fatalf("journal_mode=%q, want wal", journal)
|
||||
}
|
||||
|
||||
var busy int
|
||||
if err := s.DB.QueryRow("PRAGMA busy_timeout").Scan(&busy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if busy < 5000 {
|
||||
t.Fatalf("busy_timeout=%d, want >=5000", busy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
type Runner interface {
|
||||
DevicesJSON(http.ResponseWriter, *http.Request)
|
||||
AuditsJSON(http.ResponseWriter, *http.Request)
|
||||
PanelExec(http.ResponseWriter, *http.Request)
|
||||
RotateAPIKey(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type Panel struct {
|
||||
Password string
|
||||
Runner Runner
|
||||
mu sync.RWMutex
|
||||
sessions map[string]time.Time
|
||||
}
|
||||
|
||||
func New(password string, runner Runner) *Panel {
|
||||
return &Panel{Password: password, Runner: runner, sessions: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
func (p *Panel) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/panel/login", p.login)
|
||||
mux.HandleFunc("/panel/logout", p.logout)
|
||||
mux.HandleFunc("/static/", p.static)
|
||||
mux.HandleFunc("/panel/api/devices", p.protected(p.Runner.DevicesJSON))
|
||||
mux.HandleFunc("/panel/api/audits", p.protected(p.Runner.AuditsJSON))
|
||||
mux.HandleFunc("/panel/api/exec", p.protected(p.Runner.PanelExec))
|
||||
mux.HandleFunc("/panel/api/key/rotate", p.protected(p.Runner.RotateAPIKey))
|
||||
mux.HandleFunc("/", p.index)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (p *Panel) index(w http.ResponseWriter, r *http.Request) {
|
||||
if !p.valid(r) {
|
||||
http.Redirect(w, r, "/panel/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
data, _ := staticFS.ReadFile("static/index.html")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (p *Panel) login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
data, _ := staticFS.ReadFile("static/login.html")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", 400)
|
||||
return
|
||||
}
|
||||
if p.Password == "" || subtle.ConstantTimeCompare([]byte(req.Password), []byte(p.Password)) != 1 {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
http.Error(w, "session error", 500)
|
||||
return
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
p.mu.Lock()
|
||||
p.sessions[token] = time.Now().Add(12 * time.Hour)
|
||||
p.mu.Unlock()
|
||||
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(w, &http.Cookie{Name: "vpc_session", Value: token, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: 43200})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (p *Panel) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("vpc_session"); err == nil {
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, c.Value)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(w, &http.Cookie{Name: "vpc_session", Value: "", Path: "/", HttpOnly: true, Secure: secure, MaxAge: -1, SameSite: http.SameSiteStrictMode})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (p *Panel) valid(r *http.Request) bool {
|
||||
c, err := r.Cookie("vpc_session")
|
||||
if err != nil || c.Value == "" {
|
||||
return false
|
||||
}
|
||||
p.mu.RLock()
|
||||
expiry, ok := p.sessions[c.Value]
|
||||
p.mu.RUnlock()
|
||||
return ok && time.Now().Before(expiry)
|
||||
}
|
||||
|
||||
func (p *Panel) protected(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !p.valid(r) {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Panel) static(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if len(path) < 9 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, err := staticFS.ReadFile("static/" + path[len("/static/"):])
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(path, ".css") {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
}
|
||||
if len(path) >= 3 && path[len(path)-3:] == ".js" {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (p *Panel) Cleanup() {
|
||||
t := time.NewTicker(time.Hour)
|
||||
defer t.Stop()
|
||||
for now := range t.C {
|
||||
p.mu.Lock()
|
||||
for k, v := range p.sessions {
|
||||
if now.After(v) {
|
||||
delete(p.sessions, k)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testRunner struct{}
|
||||
|
||||
func (testRunner) DevicesJSON(w http.ResponseWriter, r *http.Request) {}
|
||||
func (testRunner) AuditsJSON(w http.ResponseWriter, r *http.Request) {}
|
||||
func (testRunner) PanelExec(w http.ResponseWriter, r *http.Request) {}
|
||||
func (testRunner) RotateAPIKey(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func TestStaticCSSContentType(t *testing.T) {
|
||||
p := New("test-password", testRunner{})
|
||||
req := httptest.NewRequest("GET", "/static/app.css", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
p.static(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status=%d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "text/css; charset=utf-8" {
|
||||
t.Fatalf("content-type=%q, want text/css; charset=utf-8", got)
|
||||
}
|
||||
if strings.TrimSpace(rec.Body.String()) == "" {
|
||||
t.Fatal("CSS body is empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box}body{margin:0;background:#0b1020;color:#e7ebf3;font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{height:64px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #202a3d;background:#10182b;position:sticky;top:0;z-index:2}header strong{font-size:18px}header span{margin-left:12px;color:#7f8ba3;font-size:12px}main{max-width:1400px;margin:auto;padding:22px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.wide{grid-column:1/-1}.card{background:#111a2d;border:1px solid #24304a;border-radius:14px;padding:18px;box-shadow:0 8px 30px #0003}.cardhead{display:flex;align-items:center;justify-content:space-between}h2{margin:0 0 14px;font-size:16px}button,select,input,textarea{border:1px solid #34415d;border-radius:9px;background:#0d1526;color:#e7ebf3;padding:9px 12px}button{cursor:pointer}button:hover{background:#17233a}textarea{width:100%;height:110px;resize:vertical;font-family:ui-monospace,monospace;margin:8px 0}pre{white-space:pre-wrap;overflow:auto;background:#090e1a;border-radius:9px;padding:12px;color:#b9c5da}.devices{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}.device{padding:14px;border:1px solid #283650;border-radius:10px}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;margin-right:7px}.offline .dot{background:#f87171}.muted{color:#7f8ba3}.tablewrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:9px;border-bottom:1px solid #26324a;font-size:12px}th{color:#91a0bb}.login{display:grid;place-items:center;min-height:100vh}.login form{width:min(380px,90vw);background:#111a2d;border:1px solid #24304a;border-radius:16px;padding:28px}.login input,.login button{width:100%;margin-top:10px}.login h1{margin:0}.login p,#error{color:#8c9ab2}.status{font-size:12px;color:#9aabc5}@media(max-width:800px){.grid{grid-template-columns:1fr}.wide{grid-column:auto}main{padding:12px}header{padding:0 14px}}
|
||||
@@ -0,0 +1,12 @@
|
||||
const $=s=>document.querySelector(s);
|
||||
async function api(path,opt={}){let r=await fetch("/panel/api"+path,opt);if(r.status===401){location.href="/panel/login";throw Error("unauthorized")}if(!r.ok)throw Error(await r.text());return r.json()}
|
||||
function esc(v){return String(v??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))}
|
||||
async function loadDevices(){let ds=await api("/devices");$("#devices").innerHTML=ds.map(d=>`<div class="device ${d.status==="online"?"":"offline"}"><b><i class="dot"></i>${esc(d.name)}</b><div class="status">${esc(d.status)} · ${d.is_local?"local":"remote"} · ${esc(d.arch||"")}/${esc(d.os||"")}</div></div>`).join("");$("#device").innerHTML=ds.map(d=>`<option>${esc(d.name)}</option>`).join("")}
|
||||
async function loadAudits(){let a=await api("/audits?limit=100");$("#audits").innerHTML=a.map(x=>`<tr><td>${new Date(x.timestamp*1000).toLocaleString()}</td><td>${esc(x.device)}</td><td>${esc(x.action)}</td><td><code>${esc(x.command)}</code></td><td>${x.exit_code}</td><td>${x.duration_ms}ms</td><td>${esc(x.caller_ip)}</td></tr>`).join("")}
|
||||
$("#refresh").onclick=loadDevices;$("#auditRefresh").onclick=loadAudits;
|
||||
$("#run").onclick=async()=>{let o=$("#output");o.textContent="执行中…";try{let x=await api("/exec",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({device:$("#device").value,command:$("#command").value,timeout:30})});o.textContent=`exit_code: ${x.exit_code}\n\nstdout:\n${x.stdout}\n\nstderr:\n${x.stderr}\nduration: ${x.duration_ms}ms`}catch(e){o.textContent=e.message}};
|
||||
$("#copySchema").onclick=()=>navigator.clipboard.writeText(location.origin+"/openapi.json");
|
||||
$("#copyPrompt").onclick=()=>navigator.clipboard.writeText("你是我的私有 VPS 运维助手。执行操作前确认目标设备;优先使用 list_devices 确认节点状态;高风险操作先向用户确认。");
|
||||
$("#rotateKey").onclick=async()=>{if(!confirm("轮换后旧 API Key 会立即失效,确认继续?"))return;try{let x=await api("/key/rotate",{method:"POST"});$("#newKey").textContent="新 API Key(仅本次显示):\n"+x.api_key}catch(e){$("#newKey").textContent=e.message}};
|
||||
$("#logout").onclick=async()=>{await fetch("/panel/logout",{method:"POST"});location.href="/panel/login"};
|
||||
loadDevices().then(loadAudits).catch(console.error);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>VPS-Commander</title><link rel="stylesheet" href="/static/app.css"></head>
|
||||
<body>
|
||||
<header><div><strong>VPS-Commander</strong><span>Private Infrastructure Console</span></div><button id="logout">退出</button></header>
|
||||
<main>
|
||||
<section class="grid">
|
||||
<article class="card wide"><div class="cardhead"><h2>设备看板</h2><button id="refresh">刷新</button></div><div id="devices" class="devices"></div></article>
|
||||
<article class="card"><h2>Actions 向导</h2><p>当前 Hub 的 OpenAPI Schema:</p><button id="copySchema">复制 Schema 地址</button><button id="copyPrompt">复制 System Prompt</button><button id="rotateKey">轮换 API Key</button><pre id="schemaHint">/openapi.json</pre><pre id="newKey"></pre></article>
|
||||
<article class="card wide"><div class="cardhead"><h2>快速控制台</h2><select id="device"></select></div><textarea id="command" placeholder="输入 Shell 命令,例如 uname -a"></textarea><div><button id="run">执行</button></div><pre id="output"></pre></article>
|
||||
<article class="card wide"><div class="cardhead"><h2>审计日志</h2><button id="auditRefresh">刷新</button></div><div class="tablewrap"><table><thead><tr><th>时间</th><th>设备</th><th>动作</th><th>命令/路径</th><th>退出码</th><th>耗时</th><th>来源</th></tr></thead><tbody id="audits"></tbody></table></div></article>
|
||||
</section>
|
||||
</main><script src="/static/app.js"></script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>VPS-Commander Login</title><link rel="stylesheet" href="/static/app.css"></head><body class="login"><form id="login"><h1>VPS-Commander</h1><p>Private Infrastructure Console</p><input id="password" type="password" placeholder="面板密码" autocomplete="current-password"><button>登录</button><div id="error"></div></form><script>document.querySelector("#login").onsubmit=async e=>{e.preventDefault();let r=await fetch("/panel/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:document.querySelector("#password").value})});if(r.ok)location.href="/";else document.querySelector("#error").textContent="密码错误或未配置面板密码";}</script></body></html>
|
||||
Reference in New Issue
Block a user