Back to blog

Building stamusctl · Part 1

Two commands to a working NDR stack

I wrote a Go CLI that pulls templates from an OCI registry, renders a Docker Compose config, and gives you a full Suricata-based network detection system in minutes.

Clear NDR Community Edition is an open-source network detection and response platform built on Suricata. It’s the successor to SELKS, which Stamus Networks maintained for years. Under the hood it’s a lot of software: Suricata for IDS/IPS, Fluentd for log processing, OpenSearch for indexing, Scirius for rule management and threat hunting, Arkime for PCAP analysis, Evebox for alert management, PostgreSQL, RabbitMQ, Celery workers, NGINX. That’s 10+ containers that all need to talk to each other, with configs that depend on your hardware and network.

I wrote stamusctl to make deploying all of that feel like nothing:

stamusctl compose init
stamusctl compose up

That’s it. First command asks you one question: which network interface to monitor. It pulls templates, generates everything. Second command brings it all up. A few minutes later you have Suricata watching your network, events flowing into OpenSearch, and a web UI where you can manage rules, hunt threats, and investigate alerts.

But the part that really sells it is PCAP replay:

stamusctl compose readpcap capture.pcap

You hand it a packet capture file and it replays it through Suricata. The events get indexed, and suddenly your Scout hunting interface is full of data. DNS queries, HTTP transactions, TLS handshakes, flow records, alerts. You can investigate a network incident from a PCAP without setting up any infrastructure manually. For demos, training, or forensics, this is the fastest path from “I have a PCAP” to “I can see what happened.”

The tool is open-source (GPL-3.0), written in Go, and the code is at github.com/StamusNetworks/stamusctl. The rest of this series is about how it works under the hood.

The pipeline

stamusctl is essentially this:

  1. Pull a configuration template from an OCI registry (or use an embedded one)
  2. Ask one question: which network interface
  3. Render the template with Go’s text/template + Sprig functions
  4. Output a Docker Compose config tailored to your setup
  5. Wrap docker compose to manage the stack

The templates live in a separate repo (stamusctl-templates) and are distributed as OCI artifacts. Same protocol as Docker images, which means any Docker registry can host them. The CE pulls from our public registry by default. Templates are versioned, so stamusctl compose init always gets the latest stable config.

The split between CLI and templates is deliberate. The tool logic and the product configuration have different release cycles. A new Suricata version might need template changes but zero CLI changes. A new CLI feature doesn’t touch any templates.

For offline environments, templates are also compiled into the binary via go:embed:

//go:embed clearndr/*
var AllConf embed.FS

So even without network access, stamusctl compose init works.

Template rendering

A template might look like:

services:
  suricata:
    image: {{ .SuricataImage }}
    network_mode: host
    cap_add:
      - net_admin
      - sys_nice
    volumes:
      - {{ .ConfigPath }}/suricata/etc:/etc/suricata
{{ if .EnablePCAP }}
      - {{ .PCAPPath }}:/data/pcap
{{ end }}

The parameters that fill these templates aren’t hardcoded. They’re extracted from the template files themselves. When a new template version adds a parameter, the CLI picks it up automatically. The extraction follows include directives with cycle detection (max depth 20, tracked via a visited map) so templates can compose each other without infinite loops.

Parameter values cascade: template defaults, then config file, then environment variables (STAMUS_ prefix), then CLI flags. Viper handles the merging. The config list command shows the resolved result for debugging.

Each parameter is typed, validated, and can present choices:

type Parameter struct {
    Name         string
    Type         string  // "string", "bool", "int"
    Default      Variable
    Choices      []Variable
    ValidateFunc func(Variable) bool
}

The Variable type uses pointers to distinguish “not set” from “set to zero value,” which matters when merging values from multiple sources. A nil string pointer means “use the default.” An empty string pointer means “the user explicitly set this to empty.”

The end result: one question for the user, dozens of configs generated correctly. Suricata gets the right interface, Fluentd gets the right output targets, OpenSearch gets reasonable heap sizes, NGINX gets TLS configured. All from one init.