feat: initial commit of vps-commander release v1.0.0
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user