Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ This framework was built by someone running on way too much caffeine. If you enc
./server
```
2. **Access the web interface:**
- Open your browser and go to: [https://localhost:8080/home/](https://localhost:8080/home/) (or the port you configured).
- Open your browser and go to: [https://localhost:8443/home/](https://localhost:8443/home/) with the default config. Port `8080` redirects to HTTPS when redirects are enabled.
- The operator UI/API and WebSocket routes live on the web/API port. Agent polling routes live on the listener ports you create.

### Configuration
- Edit `server/config/settings.yaml` for server settings.
Expand Down
31 changes: 19 additions & 12 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ Rust agents
In-memory task/result state on the server
```

The server hosts both operator-facing routes and agent-facing routes in one
process. Listener instances can bind additional ports for agent polling, but
operator UI/API routes, WebSockets, and listener management are still registered
on the same default `net/http` mux. Splitting operator and agent surfaces is
tracked by #75.
The server process now keeps an explicit operator mux for the browser UI,
operator APIs, and WebSockets. Agent polling routes stay on listener-owned muxes
bound to listener ports. This does not add authentication yet, but it makes route
ownership explicit so operator-only controls and agent-facing endpoints can be
hardened independently.

## Repository Layout

Expand Down Expand Up @@ -66,10 +66,18 @@ enabled, a separate HTTP listener redirects to the configured HTTPS port.
Current implementation note: `server.tls.enabled` exists in configuration, but
the entry point always starts the main server with `ListenAndServeTLS`.

The current server uses Go's default HTTP mux for most routes. That keeps the
prototype simple, but it also means route ownership and trust boundaries are not
strongly expressed in code yet. Unknown `/api/*` paths currently fall through to
a generic `{"status":"ok"}` response, which can hide unsupported UI calls.
The operator server uses an explicit `http.ServeMux`. Unknown `/api/*` paths
return `404`, so unsupported operator calls and accidental agent endpoint calls
are visible during development.

### Route Surfaces

| Surface | Served by | Route families |
| --- | --- | --- |
| Operator UI | operator web/API port | `/`, `/home/`, `/static/` |
| Operator API | operator web/API port | `/api/agents/*`, `/api/listeners/*`, `/api/payload/*`, `/api/file_drop/*`, `/api/socks5/*` |
| Operator WebSockets | operator web/API port | `/ws/logs`, `/ws/terminal` |
| Agent listener API | listener ports | `/api/agent/{agent_id}/heartbeat`, `/command`, `/result`, `/tasks`, `/results` |

### Operator Routes

Expand All @@ -83,6 +91,7 @@ a generic `{"status":"ok"}` response, which can hide unsupported UI calls.
| `/api/listeners/{id}/start` | `internal/handlers/api` | Start a stopped listener. |
| `/api/listeners/{id}/stop` | `internal/handlers/api` | Stop a running listener. |
| `/api/agents/list` | `internal/handlers/api` | Aggregate agents across listeners. |
| `/api/agents/command` | `internal/handlers/api` | Queue a raw command string for an agent using a JSON `agent_id`. |
| `/api/agents/{id}/command` | `internal/handlers/api` | Queue a raw command string for an agent. |
| `/api/agents/{id}/results` | `internal/handlers/api` | Fetch stored command results. |
| `/api/file_drop/upload` | `internal/handlers/api` | Upload operator files into the server file store. |
Expand All @@ -97,7 +106,7 @@ a generic `{"status":"ok"}` response, which can hide unsupported UI calls.
The operator APIs and WebSockets currently lack a complete authentication,
authorization, origin, and CSRF boundary. The server terminal can execute shell
commands on the host running MicroC2 and should be treated as a local lab-only
tool until #75 and #78 are complete.
tool until #78 is complete.

### Agent Listener Routes

Expand Down Expand Up @@ -216,8 +225,6 @@ The UI talks directly to the REST and WebSocket routes listed above. It does not
currently have a separate frontend build system. A few UI paths are ahead of the
backend:

- dashboard command submission still contains a hard-coded `8080` API base in
one path, while the default HTTPS UI/API port is `8443`;
- agent removal calls `DELETE /api/agents/{id}`, which has no real handler yet;
- listener and file-drop JavaScript reference EventSource streams that are not
registered by the server.
Expand Down
6 changes: 5 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ Current constraints:
- The server terminal is powerful and should remain local-lab only until safety
controls exist.

The intended direction is to split surfaces first, then harden them:
The server now keeps the route surfaces explicit so they can be hardened
independently:

```text
Operator UI/API/WebSockets -> auth, origin checks, audit, local-lab defaults
Expand All @@ -82,6 +83,9 @@ Payload builder -> reproducible profiles and build provenance
Storage -> durable agents, tasks, results, files, events
```

Operator routes are served by the web/API port. Agent polling routes are served
by listener ports and should not be mounted on the operator mux.

## Agent Design

The Rust agent was chosen for memory safety, static builds, and cross-platform
Expand Down
14 changes: 14 additions & 0 deletions docs/development-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ go vet ./...
go build -o /tmp/microc2-server ./cmd
```

For route-boundary changes, also use this smoke path in a controlled local lab:

1. Start the operator server and confirm
`https://localhost:8443/api/agents/list` responds.
2. Confirm the operator port does not serve agent polling by checking
`https://localhost:8443/api/agent/test/heartbeat` returns `404`.
3. Create or start an HTTP listener on a separate port.
4. POST a heartbeat to the listener at `/api/agent/test/heartbeat`.
5. Queue a command through the operator API at `/api/agents/command` with
`agent_id` in the JSON body.
6. Poll the command from the listener at `/api/agent/test/command`.
7. POST a result to the listener at `/api/agent/test/result`.
8. Read results through the operator API at `/api/agents/test/results`.

```sh
cd agent
cargo test --locked
Expand Down
46 changes: 32 additions & 14 deletions server/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"os"
"path/filepath"
"time"

"microc2/server/config"
"microc2/server/internal/filestore"
Expand Down Expand Up @@ -89,39 +90,40 @@ func main() {
payloadDir := filepath.Join(cfg.Server.StaticDir, "payloads")
agentSourceDir := "../agent" // Relative path to agent source code
payloadHandler := api.PayloadHandlerSetup(payloadDir, agentSourceDir, serverManager.GetListenerManager())
operatorMux := http.NewServeMux()

// Set up HTTP routes
staticHandlers.SetupStaticRoutes()
staticHandlers.RegisterRoutes(operatorMux)

// Set up file handling routes
http.HandleFunc("/api/file_drop/upload", fileHandlers.HandleFileUpload)
http.HandleFunc("/api/file_drop/list", fileHandlers.HandleFileList)
http.HandleFunc("/api/file_drop/download/", fileHandlers.HandleFileDownload)
http.HandleFunc("/api/file_drop/delete/", fileHandlers.HandleFileDelete)
operatorMux.HandleFunc("/api/file_drop/upload", fileHandlers.HandleFileUpload)
operatorMux.HandleFunc("/api/file_drop/list", fileHandlers.HandleFileList)
operatorMux.HandleFunc("/api/file_drop/download/", fileHandlers.HandleFileDownload)
operatorMux.HandleFunc("/api/file_drop/delete/", fileHandlers.HandleFileDelete)

// Set up WebSocket routes
http.HandleFunc("/ws/logs", wsHandlers.HandleLogStream)
http.HandleFunc("/ws/terminal", wsHandlers.HandleTerminal)
operatorMux.HandleFunc("/ws/logs", wsHandlers.HandleLogStream)
operatorMux.HandleFunc("/ws/terminal", wsHandlers.HandleTerminal)

// Set up listener management routes
listenerHandlers.SetupRoutes()
listenerHandlers.RegisterRoutes(operatorMux)

// Set up payload generator routes
payloadHandler.SetupRoutes()
payloadHandler.RegisterRoutes(operatorMux)

// Set up root route to redirect / to /home/
http.HandleFunc("/", staticHandlers.HandleRoot)
operatorMux.HandleFunc("/", staticHandlers.HandleRoot)

// Set up API routes
apiHandler := api.NewAPIHandler(serverManager)
http.HandleFunc("/api/", apiHandler.HandleRequest)
operatorMux.HandleFunc("/api/", apiHandler.HandleRequest)

// Set up SOCKS5 management routes if protocol is SOCKS5
if cfg.Communication.Protocol == "socks5" {
if socks5Protocol, ok := serverManager.GetProtocol().(*protocols.SOCKS5Protocol); ok {
socks5Handler := api.NewSOCKS5Handler(socks5Protocol)
for route, handler := range socks5Handler.RegisterRoutes() {
http.HandleFunc(route, handler)
operatorMux.HandleFunc(route, handler)
}
}
}
Expand Down Expand Up @@ -170,15 +172,31 @@ func main() {
http.Redirect(w, r, target, http.StatusMovedPermanently)
})

if err := http.ListenAndServe(httpAddr, redirectHandler); err != nil {
redirectServer := &http.Server{
Addr: httpAddr,
Handler: redirectHandler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
if err := redirectServer.ListenAndServe(); err != nil {
log.Printf("[ERROR] HTTP redirect server error: %v", err)
}
}()
}

// Start HTTPS server
log.Printf("[STARTUP] Starting HTTPS server on %s ...", httpsAddr)
if err := http.ListenAndServeTLS(httpsAddr, certFile, keyFile, nil); err != nil {
operatorServer := &http.Server{
Addr: httpsAddr,
Handler: operatorMux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := operatorServer.ListenAndServeTLS(certFile, keyFile); err != nil {
log.Fatalf("[ERROR] HTTPS server error: %v", err)
}
// Remove or comment out the old serverManager.Start() call:
Expand Down
39 changes: 31 additions & 8 deletions server/internal/handlers/api/api_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ func (h *APIHandler) HandleRequest(w http.ResponseWriter, r *http.Request) {
return
}

if r.Method == http.MethodPost && r.URL.Path == "/api/agents/command" {
h.handleQueueAgentCommandFromBody(w, r)
return
}

// Handle POST /api/agents/{AgentID}/command
if r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/agents/") && strings.HasSuffix(r.URL.Path, "/command") {
trimmed := strings.TrimPrefix(r.URL.Path, "/api/agents/")
Expand All @@ -44,7 +49,8 @@ func (h *APIHandler) HandleRequest(w http.ResponseWriter, r *http.Request) {

// Default handler for API requests
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"unknown operator API route"}`))
}

func (h *APIHandler) handleListAgents(w http.ResponseWriter, r *http.Request) {
Expand All @@ -59,20 +65,37 @@ func (h *APIHandler) handleListAgents(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(agents)
}

type queueCommandRequest struct {
AgentID string `json:"agent_id,omitempty"`
Command string `json:"command"`
}

func (h *APIHandler) handleQueueAgentCommandFromBody(w http.ResponseWriter, r *http.Request) {
var req queueCommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AgentID == "" || req.Command == "" {
log.Printf("[DEBUG] Invalid command request")
http.Error(w, "Invalid command request", http.StatusBadRequest)
return
}

h.queueAgentCommandResponse(w, req.AgentID, req.Command)
}

// handleQueueAgentCommand handles POST /api/agents/{AgentID}/command
func (h *APIHandler) handleQueueAgentCommand(w http.ResponseWriter, r *http.Request, AgentID string) {
// log.Printf("[DEBUG] handleQueueAgentCommand entered for AgentID=%s", AgentID)
type cmdReq struct {
Command string `json:"command"`
}
var req cmdReq
var req queueCommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Command == "" {
log.Printf("[DEBUG] JSON decode failed or empty command: err=%v, req=%+v", err, req)
log.Printf("[DEBUG] Invalid path-based command request")
http.Error(w, "Invalid command", http.StatusBadRequest)
return
}
// log.Printf("[DEBUG] handleQueueAgentCommand: AgentID=%s, command=%s", AgentID, req.Command)

h.queueAgentCommandResponse(w, AgentID, req.Command)
}

func (h *APIHandler) queueAgentCommandResponse(w http.ResponseWriter, AgentID, command string) {
// Find the listener/protocol for this agent
listenerMgr := h.serverManager.GetListenerManager()
var queued bool
Expand All @@ -85,7 +108,7 @@ func (h *APIHandler) handleQueueAgentCommand(w http.ResponseWriter, r *http.Requ
if commander, ok := listener.Protocol.(interface {
QueueCommand(AgentID, cmd string)
}); ok {
commander.QueueCommand(AgentID, req.Command)
commander.QueueCommand(AgentID, command)
queued = true
break
}
Expand All @@ -94,7 +117,7 @@ func (h *APIHandler) handleQueueAgentCommand(w http.ResponseWriter, r *http.Requ
}
}

// log.Printf("[DEBUG] Command queued for agent %s: %s (queued=%v)", AgentID, req.Command, queued)
// log.Printf("[DEBUG] Command queued for agent %s: %s (queued=%v)", AgentID, command, queued)

if queued {
w.WriteHeader(http.StatusOK)
Expand Down
19 changes: 19 additions & 0 deletions server/internal/handlers/api/api_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package api

import (
"net/http"
"net/http/httptest"
"testing"
)

func TestOperatorAPIDoesNotServeAgentPollingRoutes(t *testing.T) {
handler := NewAPIHandler(nil)
req := httptest.NewRequest(http.MethodPost, "/api/agent/test/heartbeat", nil)
rec := httptest.NewRecorder()

handler.HandleRequest(rec, req)

if rec.Code != http.StatusNotFound {
t.Fatalf("expected operator API to reject agent polling route with 404, got %d", rec.Code)
}
}
15 changes: 10 additions & 5 deletions server/internal/handlers/api/listener_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,11 @@ func sendJSONResponse(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data)
}

// SetupRoutes registers all listener-related routes
func (h *ListenerHandlers) SetupRoutes() {
http.HandleFunc("/api/listeners/create", h.HandleCreateListener)
http.HandleFunc("/api/listeners/list", h.HandleListListeners)
http.HandleFunc("/api/listeners/", func(w http.ResponseWriter, r *http.Request) {
// RegisterRoutes registers all listener-related routes on the provided mux.
func (h *ListenerHandlers) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/listeners/create", h.HandleCreateListener)
mux.HandleFunc("/api/listeners/list", h.HandleListListeners)
mux.HandleFunc("/api/listeners/", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/listeners/")
if strings.HasSuffix(path, "/stop") {
h.HandleStopListener(w, r)
Expand All @@ -201,3 +201,8 @@ func (h *ListenerHandlers) SetupRoutes() {
}
})
}

// SetupRoutes registers listener routes on the default mux for legacy callers.
func (h *ListenerHandlers) SetupRoutes() {
h.RegisterRoutes(http.DefaultServeMux)
}
11 changes: 8 additions & 3 deletions server/internal/handlers/api/payload/payload_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -503,15 +503,20 @@ func (h *PayloadHandler) loadListenerConfig(listenerID string) (ListenerConfig,
return ListenerConfig{}, fmt.Errorf("no listener found with ID %s", listenerID)
}

// SetupRoutes registers all payload-related routes
// RegisterRoutes registers all payload-related routes on the provided mux.
//
// Pre-conditions:
// - HTTP server is initialized and ready to accept route registrations
//
// Post-conditions:
// - Routes for payload generation and download are registered
// - Requests to these routes will be handled by the appropriate methods
func (h *PayloadHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/payload/generate", h.HandleGeneratePayload)
mux.HandleFunc("/api/payload/download/", h.HandleDownloadPayload)
}

// SetupRoutes registers payload routes on the default mux for legacy callers.
func (h *PayloadHandler) SetupRoutes() {
http.HandleFunc("/api/payload/generate", h.HandleGeneratePayload)
http.HandleFunc("/api/payload/download/", h.HandleDownloadPayload)
h.RegisterRoutes(http.DefaultServeMux)
}
13 changes: 9 additions & 4 deletions server/internal/handlers/web/static_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func (h *StaticHandler) HandleRoot(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}

// SetupStaticRoutes sets up routes for static file serving
// RegisterRoutes sets up routes for static file serving on the provided mux.
//
// Pre-conditions:
// - staticDir and webDir exist and contain necessary files
Expand All @@ -61,12 +61,17 @@ func (h *StaticHandler) HandleRoot(w http.ResponseWriter, r *http.Request) {
// - Routes are registered with the HTTP server
// - /static/ paths are served from staticDir
// - /home/ paths are served from webDir
func (h *StaticHandler) SetupStaticRoutes() {
func (h *StaticHandler) RegisterRoutes(mux *http.ServeMux) {
// Handle /static/ paths for backward compatibility
fs := http.FileServer(http.Dir(h.staticDir))
http.Handle("/static/", http.StripPrefix("/static/", fs))
mux.Handle("/static/", http.StripPrefix("/static/", fs))

// Serve web assets from the web directory
webFs := http.FileServer(http.Dir(h.webDir))
http.Handle("/home/", http.StripPrefix("/home/", webFs))
mux.Handle("/home/", http.StripPrefix("/home/", webFs))
}

// SetupStaticRoutes registers static routes on the default mux for legacy callers.
func (h *StaticHandler) SetupStaticRoutes() {
h.RegisterRoutes(http.DefaultServeMux)
}
Loading
Loading