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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user