Adding a New Agent, Handler, and Listener

December 28, 2025 · View on GitHub

Use this guide when you need to stand up a fresh agent type and plumb it through the Ankou stack (server ↔ ghost relay ↔ agent) for the first time.

1. Know the Moving Pieces

  • Server stores state, enforces auth, and exposes both GraphQL and REST endpoints (see server/main.go for the Listener model, AgentHandler, and the /api/register handler).
  • Ghost Relay terminates transports and forwards traffic to the server with relay-level HMAC authentication (ghost-relay/accept.go). Agents declare their type in request bodies—no port-based coupling.
  • Agents live under agents/ and are regular Go programs that register, poll, and push command output (use agents/geist/main.go as the reference template).

Everything hinges on three configuration touch points:

  1. Shared HMAC secret
  2. HTTPS listener that the relay talks to
  3. Agent handler metadata so the server knows supported commands

2. Align the Shared Secrets

  1. Start the server once so it generates server/hmac.key. You can also supply HMAC_KEY via environment variables at runtime (server/main.go).
  2. Copy that same hex value into the relay and the agent:
    • Relay: export RELAY_HMAC_KEY before launching (ghost-relay/internal/config/config.go).
    • Agent: set the hmacKeyHex constant in your agent's config block (see agents/geist/main.go).
  3. If you regenerate the key later, restart the server, relay, and rebuild the agent with the new value so everyone agrees.

3. Provision an HTTPS Listener

The server only accepts agent traffic on listeners. You can create one through the UI or by dropping a JSON file under server/listeners/.

  • Each listener file is shaped like:
{
  "id": "listener_1760913734",
  "name": "wiki",
  "type": "https",
  "endpoint": "/wiki",
  "status": "stopped",
  "description": "Primary agent ingress",
  "createdAt": "2025-01-27T00:00:00Z"
}
  • Fields map directly to the Listener struct (server/main.go). Only https is supported today (server/listeners.go).
  • Always normalize the endpoint with a leading slash. The helper in server/listeners.go shows the accepted formats.
  • After creating the file, either restart the server or trigger createListener via GraphQL so loadListenersFromConfig() (server/listeners.go) picks it up.
  • Start the listener (UI: "Start"; CLI: GraphQL startListener mutation). It will listen with the TLS certificate created in step 2.

The relay's UPSTREAM_URL (in relay.config) or GHOST_RELAY_UPSTREAM_BASE_URL environment variable must point at the base C2 server URL (without the listener endpoint), e.g. https://c2.example.com:8444. The relay will automatically append the listener endpoint path (e.g., /wiki) when forwarding requests.

4. Register an Agent Handler

Handlers live in server/agent_handlers/ and let the server match an incoming agent to a feature set.

  1. Create a JSON file named after your handler, e.g. handler_myagent.json:
    {
      "id": "handler_myagent",
      "agentName": "myagent",
      "agentHttpHeaderId": "myagent",
      "supportedCommands": ["ls", "exec", "reconnect"]
    }
    
  2. Field reference:
    • id – optional; autogenerated if omitted (server/agent_handlers.go).
    • agentName – display name shown in the UI.
    • agentHttpHeaderId – lowercase identifier agents include in request bodies as agent_type; must match what your agent declares.
    • supportedCommands – string list surfaced to operators (server/agent_handlers.go sanitizes for duplicates).
    • createdAt – RFC3339 timestamp; purely informational.
  3. Restart the server or call the upsertAgentHandler GraphQL mutation with the same JSON so loadAgentHandlersFromConfig() (server/agent_handlers.go) registers it at runtime.

If your agent needs additional metadata, extend both AgentHandler (server) and the JSON schema to keep everything in sync.

Handler-to-Agent Relationships

  • Handlers are indexed by agentHttpHeaderId via agentHandlersByHeader (server/agent_handlers.go). Agents declare "agent_type": "myagent" in their request bodies, and the server matches that to the right handler.
  • A single handler can back any number of binaries as long as they declare the same agent type. This is how you ship variants (e.g., different OS builds) without duplicating configuration.
  • Any agent can use any transport—HTTPS, QUIC, SSH—since the agent type lives in the body, not the port or protocol. One HTTPS handler on port 8080 can serve multiple agent families.

5. Implement the Agent

Use the existing agents as blueprints. Even if you introduce a new transport, the runtime contract between agent, relay, and server is consistent.

  1. Define runtime parameters
    Mirror the config block that Geist exposes: protocol selector, relay host/port, listener endpoint, reconnect interval, jitter window, and hmacKeyHex. Persist these values in constants or a struct so both the network layer and your command loop can reuse them.

  2. Produce HMAC-authenticated envelopes
    Every interaction with the relay must carry the headers X-Timestamp (Unix seconds) and X-Signature (hex HMAC-SHA256). Geist centralises this in signRequest(), which takes the HTTP verb, canonical listener path (for example /wiki/api/register), and exact JSON body before hashing with the shared key.

    • For native HTTP transports, attach the headers directly and POST to the listener URL.
    • For custom transports (QUIC, DNS, SSH, etc.) pack the verb/path/body/timestamp/signature into your protocol frame. The relay will unpack that frame and replay the original POST upstream via sendToC2() in ghost-relay/accept.go.
  3. Register the implant
    Serialize the AgentRegistration struct defined in server/main.go:

    {
      "uuid": "<agent GUID>",
      "name": "<operator-facing label>",
      "ip": "<current outward-facing IP>",
      "os": "<runtime.GOOS runtime.GOARCH>",
      "agent_type": "myagent",
      "reconnectInterval": <seconds>
    }
    

    The agent_type field tells the server which handler to use. Wrap this in your HMAC envelope with "agent_type" at the root level too (see existing agents for the wrapper structure). Dispatch through your transport to the relay's /api/register route and verify a 200 response before proceeding.

  4. Implement the beacon loop
    Use the reconnect interval plus jitter to control your polling cadence. Geist's subscribeToCommands() calculates a new jittered duration for each loop, then calls getPendingCommands() which issues a signed request to /api/poll. The relay forwards the call, the server updates heartbeat timestamps, and returns an array of pending commands. Follow this pattern so reconnect updates from operators propagate automatically.

  5. Map supported commands to execution logic
    supportedCommands in the handler config advertises capabilities to the UI, so your agent must honour each verb. Geist routes commands through handleBuiltinCommand() and exposes helpers such as handleLs, handlePut, and handleExec. Replicate that structure: parse the command string, dispatch to the correct implementation, and return stdout/stderr plus any metadata (loot entries, hashes, status strings).

  6. Send command responses (and loot) back upstream
    Wrap the results in the CommandResponse schema (commandId, output, status). For file transfers or directory listings that surface loot, set the type header to loot and provide a JSON payload in loot-data; the relay copies both into the HTTPS request so server/main.go can archive the entries. Ensure each response is HMAC-signed with the same procedure used during registration and polling.

Heads-up: Many agents will never open a direct HTTPS connection to the C2 server. Instead, they speak their native protocol to a relay handler which performs the HTTP translation and HMAC signing on their behalf. Geist's QUIC client is one example, but the same pattern applies to DNS, ICMP, or any custom module you add under ghost-relay/internal/accept/.

Build the agent with go build ./agents/myagent and ship the resulting binary with the correct config values baked in.

6. Bind the Agent in the Ghost Relay (Only for New Transports)

If your agent can reuse an existing relay transport (HTTPS on 8080, QUIC on 8081, SSH on 2222), you're done—just declare your agent type in the request body and pick a transport. No relay changes needed. Only build a new accept_*.go module when you introduce a protocol the relay doesn't support (WebSockets, DNS, carrier pigeon, etc.). If you're in that situation, follow the detailed recipe in docs/ghost-relay-new-transport.md.

When you do need a fresh transport, copy the pattern from ghost-relay/accept_geist.go:

func setupMyTransportHandler(ctx context.Context, tlsConfig *tls.Config) {
	cfg := &accept.HandlerConfig{
		UpstreamURL:      cfg.UpstreamBaseURL.String(),
		Timeout:          int(cfg.ClientTimeout.Seconds()),
		InsecureTLS:      cfg.InsecureSkipVerify,
		RequestReadLimit: cfg.RequestReadLimit,
		AgentType:        "any", // accepts any agent type from body
	}

	myHandler := accept.NewQUICHandler(sendToC2, logger, cfg, tlsConfig)
	go func() {
		if err := myHandler.Start(ctx, "0.0.0.0:8085"); err != nil {
			logger.Printf("MyTransport handler error: %v", err)
		}
	}()

	logger.Printf("✓ MyTransport handler on :8085 (accepts any agent type)")
}
  • Set AgentType: "any" to accept all agent types through this transport.
  • For HTTP-family transports you can call BaseHandler.RegisterEndpoints() to expose /api/register, /api/poll, etc. automatically (ghost-relay/internal/accept/base.go).
  • Register your new setup function inside setupAcceptHandlers() (ghost-relay/accept.go).

Tip: Built-in handlers cover HTTPS (port 8080), QUIC (port 8081), and SSH (port 2222). Each accepts any agent type via body declaration. Only add a new transport when you introduce a protocol we don't ship—WebSockets, DNS tunneling, etc.

Once the relay is running, any request the agent makes through this transport gets re-signed with relay-level HMAC (ghost-relay/accept.go) and forwarded to the listener you created earlier.

7. Verify End-to-End

  1. Start/restart the server so it loads the new listener and handler.
  2. Launch the relay with UPSTREAM_URL in relay.config (or GHOST_RELAY_UPSTREAM_BASE_URL env var) set to the base C2 server URL (e.g., https://c2.example.com:8444) and the correct SERVER_HMAC_KEY matching the server's HMAC_KEY.
  3. Deploy the agent, confirm successful registration (registerAgent log on the server) and heartbeat updates (server/main.go).
  4. Run a test command from the operator UI and confirm it executes successfully.
  5. Inspect loot/ for any artifacts captured by the agent (server/loot.go handles storage).

With these pieces in place you can iterate on either side (new transports or new agent capabilities) without touching the rest of the system.