feat: initial commit of vps-commander release v1.0.0

This commit is contained in:
kory
2026-09-28 07:48:01 +08:00
commit 467e28a6a1
48 changed files with 3851 additions and 0 deletions
+319
View File
@@ -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)
}
+50
View File
@@ -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)
}
}
+190
View File
@@ -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
}
+286
View File
@@ -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"
}
}
}
}
}
}
}
}
}
+76
View File
@@ -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\"}"))
}
+234
View File
@@ -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
}
}
}
+33
View File
@@ -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"`
}
+79
View File
@@ -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()}
}
+382
View File
@@ -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, &params); 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)
}
+112
View File
@@ -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)
}
}
+58
View File
@@ -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)
}
+54
View File
@@ -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"`
}
+99
View File
@@ -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()
}
+60
View File
@@ -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)
}
}
+159
View File
@@ -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()
}
}
+33
View File
@@ -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")
}
}
+1
View File
@@ -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}}
+12
View File
@@ -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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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);
+13
View File
@@ -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>
+1
View File
@@ -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>