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
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"flag"
"io"
"log"
"net/http"
"os"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/wjyhk/vps-commander/internal/cluster"
"github.com/wjyhk/vps-commander/internal/executor"
)
func main() {
hub := flag.String("hub", "", "Hub WebSocket URL")
name := flag.String("name", "", "device name")
token := flag.String("token", os.Getenv("VPS_COMMANDER_CLUSTER_SECRET"), "cluster secret")
insecure := flag.Bool("insecure", false, "skip TLS verification")
flag.Parse()
if *hub == "" || *name == "" || *token == "" {
log.Fatal("-hub, -name and -token are required")
}
backoff := time.Second
for {
if err := run(*hub, *name, *token, *insecure); err != nil {
log.Printf("agent connection: %v", err)
}
time.Sleep(backoff)
if backoff < 30*time.Second {
backoff *= 2
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
}
}
}
func run(hub, name, token string, insecure bool) error {
u := hub + "?device_name=" + name
dialer := websocket.DefaultDialer
if insecure {
dialer = &websocket.Dialer{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
}
header := http.Header{}
header.Set("Authorization", "Bearer "+token)
c, _, err := dialer.Dial(u, header)
if err != nil {
return err
}
defer c.Close()
var writeMu sync.Mutex
writeJSON := func(msg cluster.Message) error {
writeMu.Lock()
defer writeMu.Unlock()
if err := c.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil {
return err
}
return c.WriteJSON(msg)
}
if err := c.SetReadDeadline(time.Now().Add(75 * time.Second)); err != nil {
return err
}
exec := executor.Local{MaxOutput: 1024 * 1024}
if err := writeJSON(cluster.Message{Event: "hello", Device: name, TS: time.Now().Unix()}); err != nil {
return err
}
stopPing := make(chan struct{})
defer close(stopPing)
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-t.C:
_ = writeJSON(cluster.Message{Event: "ping", Device: name, TS: time.Now().Unix()})
case <-stopPing:
return
}
}
}()
for {
var msg cluster.Message
if err := c.ReadJSON(&msg); err != nil {
return err
}
_ = c.SetReadDeadline(time.Now().Add(75 * time.Second))
if msg.Event == "registered" {
continue
}
if msg.Event == "ping" {
if err := writeJSON(cluster.Message{Event: "pong", Device: name, TS: msg.TS}); err != nil {
return err
}
continue
}
if msg.Action == "" || msg.ID == "" {
continue
}
go func(msg cluster.Message) {
if err := handle(writeJSON, exec, msg); err != nil {
log.Printf("request %s failed: %v", msg.ID, err)
_ = c.Close()
}
}(msg)
}
}
func handle(writeJSON func(cluster.Message) error, exec executor.Local, msg cluster.Message) error {
switch msg.Action {
case "exec":
var p cluster.ExecPayload
if err := decodePayload(msg.Payload, &p); err != nil {
return sendError(writeJSON, msg.ID, err)
}
timeout := p.Timeout
if timeout <= 0 {
timeout = 30
}
if timeout > 300 {
timeout = 300
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
res := exec.Run(ctx, p.Command, p.Workdir)
cancel()
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", ExitCode: res.ExitCode, Stdout: res.Stdout, Stderr: res.Stderr, DurationMS: res.DurationMS})
case "file_read":
var p cluster.FileReadPayload
if err := decodePayload(msg.Payload, &p); err != nil {
return sendError(writeJSON, msg.ID, err)
}
if p.Offset < 0 {
return sendError(writeJSON, msg.ID, errors.New("negative offset"))
}
f, err := os.Open(p.Path)
if err != nil {
return sendError(writeJSON, msg.ID, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return sendError(writeJSON, msg.ID, err)
}
if _, err = f.Seek(p.Offset, io.SeekStart); err != nil {
return sendError(writeJSON, msg.ID, err)
}
limit := p.Limit
if limit <= 0 || limit > 1024*1024 {
limit = 256 * 1024
}
buf := make([]byte, limit)
n, err := io.ReadFull(f, buf)
eof := err == io.EOF || err == io.ErrUnexpectedEOF
if err != nil && !eof {
return sendError(writeJSON, msg.ID, err)
}
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: map[string]any{"content": string(buf[:n]), "bytes": n, "total_size": info.Size(), "has_more": p.Offset+int64(n) < info.Size(), "eof": eof}})
case "file_write":
var p cluster.FileWritePayload
if err := decodePayload(msg.Payload, &p); err != nil {
return sendError(writeJSON, msg.ID, err)
}
if len(p.Content) > 1024*1024 {
return sendError(writeJSON, msg.ID, errors.New("content too large"))
}
if err := os.WriteFile(p.Path, []byte(p.Content), 0644); err != nil {
return sendError(writeJSON, msg.ID, err)
}
return writeJSON(cluster.Message{ID: msg.ID, Status: "success", Payload: map[string]any{"bytes": len(p.Content)}})
default:
return sendError(writeJSON, msg.ID, errors.New("unsupported action"))
}
}
func decodePayload(v any, out any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return json.Unmarshal(b, out)
}
func sendError(writeJSON func(cluster.Message) error, id string, err error) error {
return writeJSON(cluster.Message{ID: id, Status: "error", Error: err.Error()})
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"flag"
"log"
"net/http"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/wjyhk/vps-commander/internal/api"
"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/mcp"
"github.com/wjyhk/vps-commander/internal/storage"
)
func token() string {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func main() {
addr := flag.String("addr", "127.0.0.1:9521", "listen address")
dbPath := flag.String("db", "data/commander.db", "sqlite database")
flag.Parse()
apiKey := os.Getenv("VPS_COMMANDER_API_KEY")
clusterSecret := os.Getenv("VPS_COMMANDER_CLUSTER_SECRET")
webPassword := os.Getenv("VPS_COMMANDER_WEB_PASSWORD")
if apiKey == "" || clusterSecret == "" || webPassword == "" {
log.Fatal("VPS_COMMANDER_API_KEY, VPS_COMMANDER_CLUSTER_SECRET and VPS_COMMANDER_WEB_PASSWORD are required via environment")
}
authManager := auth.NewManager(apiKey)
store, err := storage.Open(*dbPath)
if err != nil {
log.Fatal(err)
}
defer store.DB.Close()
local := "local"
if h, err := os.Hostname(); err == nil && h != "" {
local = h
}
if err := store.UpsertLocal(local, runtime.GOARCH, runtime.GOOS); err != nil {
log.Fatal(err)
}
s := &api.Server{
Auth: authManager,
Exec: executor.Local{MaxOutput: 1024 * 1024},
Cluster: cluster.NewManager(clusterSecret),
Store: store,
LocalName: local,
}
// MCP Server instance
mcpServer := mcp.NewServer(s.NewMCPBackend(), authManager)
protected := auth.Middleware{Manager: authManager}.Handler(s.Routes())
mux := http.NewServeMux()
mux.Handle("/api/", protected)
// MCP Endpoints
// Flexible Auth: allows optional token on SSE connect, but enforces token on Message calls
mux.HandleFunc("/mcp/sse", mcpServer.HandleSSE)
mux.Handle("/mcp/message", auth.Middleware{Manager: authManager, Optional: true}.Handler(http.HandlerFunc(mcpServer.HandleMessage)))
// OAuth Endpoints (For OpenAI OAuth auto-discovery)
mux.HandleFunc("/.well-known/oauth-authorization-server", mcpServer.HandleOAuthMetadata)
mux.HandleFunc("/.well-known/oauth-protected-resource", mcpServer.HandleOAuthMetadata)
mux.HandleFunc("/oauth/authorize", mcpServer.HandleAuthorize)
mux.HandleFunc("/oauth/token", mcpServer.HandleToken)
mux.HandleFunc("/healthz", s.Routes().ServeHTTP)
mux.HandleFunc("/openapi.json", s.Routes().ServeHTTP)
mux.HandleFunc("/agent/ws", s.Routes().ServeHTTP)
panel := s.Panel(webPassword)
mux.Handle("/", panel)
// Wrap root with logger and CORS
rootHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("[REQ] %s %s from %s (Auth: %v)", r.Method, r.URL.String(), r.RemoteAddr, r.Header.Get("Authorization") != "")
// Handle CORS Preflight
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept")
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Access-Control-Allow-Origin", "*")
mux.ServeHTTP(w, r)
})
server := &http.Server{
Addr: *addr,
Handler: rootHandler,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 90 * time.Second,
}
log.Printf("vps-commander-hub listening on %s as %s", *addr, local)
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("graceful shutdown: %v", err)
}
}
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/wjyhk/vps-commander/internal/mcp"
)
type HubRemoteBackend struct {
BaseURL string
APIKey string
HTTPClient *http.Client
}
func (h *HubRemoteBackend) post(path string, reqBody any, out any) error {
b, err := json.Marshal(reqBody)
if err != nil {
return err
}
url := strings.TrimRight(h.BaseURL, "/") + path
req, err := http.NewRequest("POST", url, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+h.APIKey)
resp, err := h.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
bs, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bs))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
func (h *HubRemoteBackend) ListDevices(ctx context.Context) (any, error) {
url := strings.TrimRight(h.BaseURL, "/") + "/api/v1/devices"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+h.APIKey)
resp, err := h.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
bs, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bs))
}
var res any
err = json.NewDecoder(resp.Body).Decode(&res)
return res, err
}
func (h *HubRemoteBackend) ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error) {
payload := map[string]any{
"device": device,
"command": command,
"workdir": workdir,
"timeout": timeout,
}
var res any
err := h.post("/api/v1/exec", payload, &res)
return res, err
}
func (h *HubRemoteBackend) ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error) {
payload := map[string]any{
"device": device,
"path": path,
"offset": offset,
"limit": limit,
}
var res any
err := h.post("/api/v1/file/read", payload, &res)
return res, err
}
func (h *HubRemoteBackend) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) {
payload := map[string]any{
"device": device,
"path": path,
"content": content,
}
var res any
err := h.post("/api/v1/file/write", payload, &res)
return res, err
}
func main() {
baseURL := flag.String("hub", "http://127.0.0.1:9521", "VPS-Commander Hub base URL")
apiKey := flag.String("key", os.Getenv("VPS_COMMANDER_API_KEY"), "VPS-Commander Hub API Key")
flag.Parse()
if *apiKey == "" {
fmt.Fprintln(os.Stderr, "Error: -key or VPS_COMMANDER_API_KEY environment variable is required")
os.Exit(1)
}
backend := &HubRemoteBackend{
BaseURL: *baseURL,
APIKey: *apiKey,
HTTPClient: &http.Client{Timeout: 310 * time.Second},
}
srv := mcp.NewServer(backend, nil)
scanner := bufio.NewScanner(os.Stdin)
// stdio line-by-line JSON-RPC
for scanner.Scan() {
line := scanner.Bytes()
if len(strings.TrimSpace(string(line))) == 0 {
continue
}
var req mcp.JSONRPCRequest
if err := json.Unmarshal(line, &req); err != nil {
continue
}
resp := srv.HandleDirectRequest(context.Background(), req)
respBytes, _ := json.Marshal(resp)
os.Stdout.Write(respBytes)
os.Stdout.WriteString("\n")
}
}