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.gofor theListenermodel,AgentHandler, and the/api/registerhandler). - 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 (useagents/geist/main.goas the reference template).
Everything hinges on three configuration touch points:
- Shared HMAC secret
- HTTPS listener that the relay talks to
- Agent handler metadata so the server knows supported commands
2. Align the Shared Secrets
- Start the server once so it generates
server/hmac.key. You can also supplyHMAC_KEYvia environment variables at runtime (server/main.go). - Copy that same hex value into the relay and the agent:
- Relay: export
RELAY_HMAC_KEYbefore launching (ghost-relay/internal/config/config.go). - Agent: set the
hmacKeyHexconstant in your agent's config block (seeagents/geist/main.go).
- Relay: export
- 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
Listenerstruct (server/main.go). Onlyhttpsis supported today (server/listeners.go). - Always normalize the endpoint with a leading slash. The helper in
server/listeners.goshows the accepted formats. - After creating the file, either restart the server or trigger
createListenervia GraphQL soloadListenersFromConfig()(server/listeners.go) picks it up. - Start the listener (UI: "Start"; CLI: GraphQL
startListenermutation). 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.
- Create a JSON file named after your handler, e.g.
handler_myagent.json:{ "id": "handler_myagent", "agentName": "myagent", "agentHttpHeaderId": "myagent", "supportedCommands": ["ls", "exec", "reconnect"] } - 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 asagent_type; must match what your agent declares.supportedCommands– string list surfaced to operators (server/agent_handlers.gosanitizes for duplicates).createdAt– RFC3339 timestamp; purely informational.
- Restart the server or call the
upsertAgentHandlerGraphQL mutation with the same JSON soloadAgentHandlersFromConfig()(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
agentHttpHeaderIdviaagentHandlersByHeader(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.
-
Define runtime parameters
Mirror the config block that Geist exposes: protocol selector, relay host/port, listener endpoint, reconnect interval, jitter window, andhmacKeyHex. Persist these values in constants or a struct so both the network layer and your command loop can reuse them. -
Produce HMAC-authenticated envelopes
Every interaction with the relay must carry the headersX-Timestamp(Unix seconds) andX-Signature(hex HMAC-SHA256). Geist centralises this insignRequest(), 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()inghost-relay/accept.go.
-
Register the implant
Serialize theAgentRegistrationstruct defined inserver/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_typefield 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/registerroute and verify a 200 response before proceeding. -
Implement the beacon loop
Use the reconnect interval plus jitter to control your polling cadence. Geist'ssubscribeToCommands()calculates a new jittered duration for each loop, then callsgetPendingCommands()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. -
Map supported commands to execution logic
supportedCommandsin the handler config advertises capabilities to the UI, so your agent must honour each verb. Geist routes commands throughhandleBuiltinCommand()and exposes helpers such ashandleLs,handlePut, andhandleExec. Replicate that structure: parse the command string, dispatch to the correct implementation, and return stdout/stderr plus any metadata (loot entries, hashes, status strings). -
Send command responses (and loot) back upstream
Wrap the results in theCommandResponseschema (commandId,output,status). For file transfers or directory listings that surface loot, set thetypeheader tolootand provide a JSON payload inloot-data; the relay copies both into the HTTPS request soserver/main.gocan 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
- Start/restart the server so it loads the new listener and handler.
- Launch the relay with
UPSTREAM_URLinrelay.config(orGHOST_RELAY_UPSTREAM_BASE_URLenv var) set to the base C2 server URL (e.g.,https://c2.example.com:8444) and the correctSERVER_HMAC_KEYmatching the server'sHMAC_KEY. - Deploy the agent, confirm successful registration (
registerAgentlog on the server) and heartbeat updates (server/main.go). - Run a test command from the operator UI and confirm it executes successfully.
- Inspect
loot/for any artifacts captured by the agent (server/loot.gohandles 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.