Core contract (Source / Sink / Detector / Processor / Executor interfaces, lifecycle, init+blank-import pattern, exec+JSON protocol) lives in
arx-core/docs/plugin-development.md. This document is the product-layer companion: ArxSentinel-specific examples and the things only an ArxSentinel contributor needs to know. If you're writing a new plugin, read the core contract first, then come back here for the product-side wiring.
- Sink Plugins vs Executor Plugins
- Sentinel source/sink — NCS bridge
- Cloudflare executor (compiled-in)
- MikroTik executor (compiled-in)
- Nginx blocklist executor
- exec+JSON — product examples
- Security model
- Testing your plugin
- Troubleshooting
ArxSentinel exposes two ways to react to a scored threat event: Sinks (passive log writers) and Executors (stateful action managers). Picking the wrong one leads to subtle bugs — duplicate API calls, lost events, race conditions on dedup state, or an entire pipeline that does nothing.
Sinks are stateless log writers. Executors are stateful action managers that enforce policy via external APIs.
| Aspect | Sink | Executor |
|---|---|---|
| Role | Passive — writes event data | Active — enforces policy via external resource |
| Input | Sink.Write(ctx, *plugin.Event) direct call |
ncs://<name> queue via NCS (EventSource.Pop) |
| State | Stateless | Holds dedup map, TTL timers, ban list |
| Deduplication | None | Built-in (prevents duplicate API calls) |
| TTL expiry | None | Automatic unban / cleanup after configured duration |
| Persistence | None | Optional (bbolt/redis queue backend) |
| Routing | Direct Go channel | Named Channel Switch (Work Queue) |
| Backpressure | None | Queue buffer (configurable backend) |
| Startup sync | Not applicable | Loads remote state on Init (e.g. existing ban list) |
| Failure handling | Log error, continue | Retry / circuit-breaker, increment Errors counter |
Create an Executor when your integration:
- Modifies external state — firewall rules, IP lists, databases, CDN configs.
- Needs deduplication — you must not call the external API twice for the same IP.
- Requires TTL-based cleanup — automatic reversal (auto-unban after 24h).
- Must survive restarts — queue persistence means no event loss on crash.
- Targets distributed environments — multi-replica K8s with shared Redis queue.
- Needs startup state sync — load the current remote state (existing ban list).
Create a Sink when your integration:
- Only writes/forwards event data (files, syslog, webhooks, Kafka, Slack, Telegram).
- Is stateless and idempotent at the I/O level.
- Does not need deduplication, TTL, or cross-process delivery.
Detector-pipeline
└─ SentinelThreatSink (formatter = product JSON)
└─ executor.AttachWriter("ncs://threats") → NCS queue
│
executor.AttachReader("ncs://threats")
│
SentinelSource.Run() → Executor.Run()
│ │
*plugin.Event ├─ Startup sync
(Payload: ThreatEvent) ├─ Dedup check
├─ External API call
├─ Mark in dedup map
└─ Schedule TTL expiry
- Sink →
plugin.Sinkfromgithub.com/mr-addams/arx-core/pkg/plugin/sink.go. The sink receives*plugin.Event; concrete byte-level serialisation goes through a product-sideFormatter(interface ingithub.com/mr-addams/arx-core/pkg/sink/format). - Executor →
plugin.Executorfromgithub.com/mr-addams/arx-core/pkg/plugin/executor.go. The executor reads*plugin.EventfromEventSource.Popand type-assertsevent.Payloadto its product-owned type (typically*threat.ThreatEvent).
arx-core/docs/plugin-development.md— interfaces, lifecycle, init+blank-import pattern.docs/executors.md— full executor framework overview.pkg/executorplugins/— reference implementations.
The sentinel Source/Sink pair wires two ArxSentinel pipelines together
through NCS. The sink serialises scored *plugin.Event (with
*threat.ThreatEvent payload) into NCS queue bytes via a product-side
Formatter; the source reads those bytes back from the queue and emits
*plugin.Event (with json.RawMessage payload) into another pipeline.
This is the mechanism that decouples detection from enforcement.
streams:
- name: executor-side
pipelines:
- name: cf-exec
inputs:
- type: sentinel
addr: ncs://cf-threats # queue name registered by sink
outputs: [] # executors consume in-process via EventSourceThe source Run loop: queue.Pop(ctx) → JSON-decoded bytes → json.RawMessage
wrapped in Event.Payload → sent to out chan. The downstream
executor (in same process or another process reading the same NCS queue)
type-asserts to its product-owned type.
streams:
- name: detector-side
pipelines:
- name: detector
inputs:
- type: file
path: /var/log/nginx/access.log
outputs:
- type: sentinel-threat
name: cf-threats # arbitrary channel name, matches source.addrThe sink Write(ctx, *plugin.Event) is called per surviving event.
It calls the injected product-side Formatter.Format(event) to get
bytes, then pushes bytes onto the NCS queue. Back-pressure is
non-blocking send: when queue is full, the event is dropped and
Stats().Dropped is incremented.
A Sink alone is synchronous — it runs in the same goroutine as the
detector. If it makes a slow API call (Cloudflare), the detector blocks.
By pushing events into NCS via sentinel-threat sink, the detector
returns immediately; the executor reads from NCS at its own pace,
holds dedup/TTL state, and survives restarts (with bbolt/redis backend).
Work-queue warning: NCS is a Work Queue, not a Pub/Sub. If two executors share
ncs://threats, they get round-robin distribution (~50% each). One channel per executor.
arx-core/pkg/source/sentinel/README.mdarx-core/pkg/sink/sentinel/README.mdinternal/threat/format/—Formatterimpls.
Reference: pkg/executorplugins/cloudflare/. Adds threat IPs to a
Cloudflare IP List via the /accounts/{id}/rules/lists API; auto-removes
expired entries via TTL sweep.
executors:
- name: cf-ban
type: cloudflare
sources: [{ name: cf-threats }] # NCS queue name (matches sentinel-threat sink)
api_token: "${CF_API_TOKEN}" # env-var ref, do NOT hardcode
account_id: "${CF_ACCOUNT_ID}"
list_id: "${CF_LIST_ID}"
ttl: 24h
min_level: WARN # default — skip "" (no event) entriesBehaviour:
- Dedup: IPs already in the CF list are skipped (no second API call).
- TTL sweep: every
ttl, IPs added by this executor withadded_at < now-ttlare removed. The executor only removes its own entries (filtered bycommentmetadata). - Startup sync: loads current CF list and primes the dedup map so already-banned IPs don't trigger duplicate adds after restart.
- Retry / circuit-breaker: 5xx / network errors retry with backoff; persistent failures open the circuit for 30s.
See docs/executor-cloudflare.md for full
config reference, troubleshooting, and arxsentinel cleanup --cf usage.
Reference: pkg/executorplugins/mikrotik/. Manages a RouterOS v7
firewall address-list over the REST API. TTL-based auto-unban, removes
only ArxSentinel-owned entries (filtered by comment field).
executors:
- name: mtk-ban
type: mikrotik
sources: [{ name: mtk-threats }]
address: 10.99.99.1
username: api-user
password: "${MIKROTIK_PASSWORD}"
address_list: arxsentinel-blocked
ttl: 24h
insecure_skip_verify: false # set true only for self-signed test certsBehaviour:
- REST API:
/rest/ip/firewall/address-list/addwithaddress=<ip>,list=arxsentinel-blocked,comment=arxsentinel:<timestamp>. - TTL sweep: every
ttl, removes entries whose comment starts witharxsentinel:and whose timestamp is older thanttl. Other entries in the same address-list are untouched. - CHR / ARM compatible: uses stdlib HTTP client, no RouterOS-specific binary libraries.
See docs/providers/mikrotik/ for full config
reference, troubleshooting, and CHR setup notes.
Reference: pkg/executorplugins/nginx/. Atomic file write of banned IPs
to a plain blocklist file; you include the file in nginx however suits
your setup. Optional reload command after each write.
executors:
- name: nginx-ban
type: nginx
sources: [{ name: nginx-threats }]
path: /etc/nginx/conf.d/arxsentinel-blocklist.conf
reload_command: "nginx -s reload" # optional
ttl: 24hThe file format is deny <ip>; per line. Atomic writes via
rename(tmp, path). The executor only manages IPs whose line
includes the arxsentinel: marker, so manual allow / deny lines
in the file are preserved.
The exec+JSON protocol lets you write plugins in any language. The
host binary spawns the plugin as a subprocess and pipes NDJSON through
github.com/mr-addams/arx-core/pkg/execplugin/. Protocol spec and message shapes are in
arx-core/docs/plugin-development.md §8.
#!/usr/bin/env python3
# /opt/plugins/ml_detector.py — receives detect request, returns score
import json, os, sys
params = json.loads(os.environ.get('ARXSENTINEL_PLUGIN_PARAMS', '{}'))
THRESHOLD = float(params.get('threshold', 0.7))
for line in sys.stdin:
msg = json.loads(line.strip())
if msg.get('action') != 'detect':
continue
entry = msg.get('entry', {})
state = msg.get('state', {})
# ... feature extraction + model.predict_proba ...
score = int(prob * 100) if prob >= THRESHOLD else 0
print(json.dumps({
'score': max(0, min(100, score)),
'module': 'ml-classifier',
'reason': f'ML prediction: {prob:.2%} threat probability',
}))
sys.stdout.flush()detectors:
ml-classifier:
enabled: true
exec: /opt/plugins/ml_detector.py
score: 45
params:
threshold: 0.75
model_path: /opt/models/threat-classifier.pkl#!/bin/bash
# /opt/plugins/telegram_notifier.sh
BOT_TOKEN=$(echo "$ARXSENTINEL_PLUGIN_PARAMS" | python3 -c "import json,sys;print(json.load(sys.stdin).get('bot_token',''))")
CHAT_ID=$(echo "$ARXSENTINEL_PLUGIN_PARAMS" | python3 -c "import json,sys;print(json.load(sys.stdin).get('chat_id',''))")
API_URL="https://api.telegram.org/bot${BOT_TOKEN}/sendMessage"
while read -r line; do
ip=$(echo "$line" | jq -r '.event.ip // empty')
[ -z "$ip" ] && continue
msg="⚠️ Threat detected
IP: $ip
Score: $(echo "$line" | jq -r '.event.score // 0')
Reason: $(echo "$line" | jq -r '.event.reason // "unknown"')"
curl -s -X POST "$API_URL" \
-d "chat_id=${CHAT_ID}" -d "text=${msg}" -d "parse_mode=Markdown" > /dev/null
doneoutputs:
- type: exec
exec: /opt/plugins/telegram_notifier.sh
params:
bot_token: "${TELEGRAM_BOT_TOKEN}"
chat_id: "-1001234567890"See arx-core/docs/plugin-development.md §8 for a full CloudWatch
example using boto3 and the start/stop reverse-stream protocol.
When ArxSentinel spawns a plugin, it sets:
| Variable | Value |
|---|---|
ARXSENTINEL_PLUGIN_PARAMS |
JSON-encoded map of YAML params: block |
Plugin decodes this in main() and uses parameters during init.
- Compiled-in plugins (
pkg/detectorplugins/*,pkg/executorplugins/*): run in same process and memory space as ArxSentinel. Assume they are trusted (reviewed product code). - External exec+JSON plugins: run as separate processes. Consider untrusted (user-supplied, third-party). Sandbox them as needed.
*plugin.Eventenvelope: validated by the engine before reaching any plugin (well-formed transport metadata).*plugin.Event.Payload(e.g.*threat.ThreatEvent): trusted only if the producer is trusted. Bad values from untrusted plugin should be clamped in the plugin receiver.
For security-critical plugins (ML models, third-party detectors):
# Container
exec: docker run --rm -i --net none my-plugin:latest
# cgroup limits via systemd-run
exec: systemd-run --scope -p MemoryLimit=256M /opt/plugins/ml_detector.py
# Run as dedicated user
useradd -r arxsentinel-plugins
chmod -R o-rwx /opt/pluginsDo NOT run plugins as root.
Do NOT embed secrets in config.yaml. Use env-var refs:
outputs:
- type: exec
exec: /opt/plugins/slack_notifier.sh
params:
webhook_url_env: SLACK_WEBHOOK_URL # plugin reads from process envPlugin reads via os.environ.get('SLACK_WEBHOOK_URL') (Python) or
$SLACK_WEBHOOK_URL (bash).
For product-side compiled-in plugins, use a mock IPView for detectors
and a mock tracker.Tracker for stateful plugins:
type mockView struct {
ip string; total int; fourOhFour int
paths []string; rate float64
}
func (m *mockView) GetIP() string { return m.ip }
func (m *mockView) GetTotalRequests() int { return m.total }
func (m *mockView) GetRequests404() int { return m.fourOhFour }
func (m *mockView) RecentPaths() []string { return m.paths }
func (m *mockView) ApproxRate(time.Duration) float64 { return m.rate }Place impl_test.go next to impl.go. Use table-driven tests focused
on behaviour, not implementation.
Pipe NDJSON into the plugin binary and assert on its stdout:
echo '{"v":"1","action":"detect","entry":{...},"state":{...}}' \
| /opt/plugins/my_detector.pyWrap in a Go integration test that uses os/exec to spawn the binary
and bufio.Scanner to read the response. Test against a real
*plugin.Event shape (Envelope + Payload).
For sink/executor plugins that read from NCS:
// 1. Build a memory queue.Queue, write sample *plugin.Event into it
// 2. Build your plugin against the queue
// 3. Run plugin.Run(ctx, queue) in a goroutine
// 4. Assert on side effects (file write, API mock call, etc.)
// 5. Cancel ctx, assert on clean shutdownSee pkg/executorplugins/*/*_test.go for working examples.
Compiled-in: ensure the import is added to cmd/arxsentinel/plugins_full.go
(blank import) — for always-linked plugins. For tree-shakeable plugins,
also update profiles/full.yaml. Run bash scripts/check-build-profiles.sh
to catch drift.
External: verify the binary exists, is executable, has correct shebang.
ls -la /opt/plugins/, chmod +x if needed.
- Detector: verify
enabled: truein config. Check ArxSentinel operational log for[DETECTOR]lines (enablelogging.debug: true). - Source: verify the source is registered and appears in the config under
inputs:.arxsentinel validatecatches misconfigured names. - Sink: ensure threat events are being generated (detectors must trigger).
Use
sentinel-threatsink withname: <queue>to see events flow.
- Compiled-in: add
defer recover()and log the stack trace. Profile withpprofif hot path is slow. - External: add
stderrlogging — visible in ArxSentinel operational log. Test locally:echo '...' | ./plugin.py.
- Compiled-in: profile with
pprof. Expensive detectors should move to external. - External: add latency metrics to your plugin output. Consider batching (multiple events per request) in a future protocol version.
arx-core/docs/plugin-development.md— full plugin contract.docs/developer/build-profiles.md— tree-shaking, build tags.docs/executors.md— executor framework overview.