Repository navigation
Expand file tree
/
Copy pathdocker-compose.yaml
More file actions
393 lines (378 loc) · 16.3 KB
/
Copy pathdocker-compose.yaml
File metadata and controls
393 lines (378 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
# Port-binding policy
# -------------------
# Only two services are meant to be reachable from outside the host: `app`
# (the UI/API) and `ingest` (the agent's TCP channel). Everything else binds
# to ${BIND_ADDR}, which defaults to 127.0.0.1 — reachable for local debugging
# and `docker exec`, not from the LAN.
#
# This matters because most of these services have no authentication at all in
# the default configuration: OpenSearch runs with DISABLE_SECURITY_PLUGIN, and
# OpenSearch Dashboards is an unauthenticated window onto every log the
# platform has collected. Publishing them on 0.0.0.0 hands over the fleet's
# telemetry to anyone who can route to the box.
#
# To expose one deliberately, put it behind the same reverse proxy and auth as
# `app` rather than setting BIND_ADDR=0.0.0.0.
services:
db:
image: mysql:8.0
container_name: sentora-db
restart: always
# no-new-privileges is applied only to the images built from this repo.
# The upstream mysql / opensearch / rabbitmq entrypoints drop privileges
# during startup, and hardening those is not worth an untested boot
# failure — they are unreachable from outside the host either way.
command: [ "--max_connections=500", "--wait_timeout=120", "--interactive_timeout=120" ]
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set in .env}
ports:
- "${BIND_ADDR:-127.0.0.1}:3307:3306"
volumes:
# Only init_userdb.sql belongs here. It creates and selects `userdb`.
#
# db/init.sql used to be mounted beside it and it is not a server-init
# script at all - it is the per-agent schema template, applied by
# server.create_tables_if_not_exist() *after* connecting to that agent's
# own database, which is why it contains no CREATE DATABASE or USE.
#
# Each file in this directory runs in its own mysql session, so the
# `USE userdb` at the end of the first one does not carry over. On a
# first boot the second file therefore failed at line 5 with "No
# database selected", MySQL aborted initialisation, and the container
# came up unhealthy. Nobody hit it because the volume already existed on
# every machine that had ever run this - it only breaks for someone
# setting the project up for the first time.
- ./db/init_userdb.sql:/docker-entrypoint-initdb.d/1-init_userdb.sql
- mysql_data:/var/lib/mysql
healthcheck:
# Expanded by the shell *inside* the db container, so it has to name a
# variable that exists there. It previously read $${DB_PASSWORD}, which
# is only set on the host — it fell through to the literal default and
# so only ever matched when DB_PASSWORD was left at that default.
test: [ "CMD-SHELL", "mysqladmin ping -h localhost -u root -p\"$$MYSQL_ROOT_PASSWORD\"" ]
interval: 10s
timeout: 10s
retries: 20
start_period: 60s
ollama:
image: ollama/ollama:latest
container_name: sentora-ollama
restart: always
ports:
- "${BIND_ADDR:-127.0.0.1}:11434:11434"
volumes:
- ./ollama:/root/.ollama
environment:
OLLAMA_KEEP_ALIVE: 24h
healthcheck:
test: [ "CMD", "ollama", "list" ]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 8G
ollama-init:
image: ollama/ollama:latest
container_name: sentora-ollama-init
restart: "no"
depends_on:
ollama:
condition: service_healthy
environment:
OLLAMA_HOST: http://ollama:11434
entrypoint: [ "/bin/sh" ]
command:
- "-c"
- "echo '[ollama-init] pulling ${OLLAMA_MODEL:-llama3.2:3b}...' && ollama pull ${OLLAMA_MODEL:-llama3.2:3b} && echo '[ollama-init] verifying...' && ollama list && ollama list | grep -F '${OLLAMA_MODEL:-llama3.2:3b}' && echo '[ollama-init] done.'"
app:
build: .
container_name: sentora-server
restart: always
# Reaching an agent that runs on this machine.
#
# Docker Desktop defines this name; plain Linux Docker does not, and
# `host-gateway` is the supported way to ask for it. The server needs it
# because an agent on the container host is not reachable at the host's
# own LAN address: that goes through a NAT which does not forward back,
# so the connection is refused against a machine that is listening.
#
# See `app._agent_http_bases` - it is one candidate among several, tried
# after the addresses actually recorded for the agent.
extra_hosts:
- "host.docker.internal:host-gateway"
security_opt:
- no-new-privileges:true
env_file:
- .env
environment:
OLLAMA_BASE_URL: http://ollama:11434/api
OLLAMA_MODEL: llama3.2:3b
WORKERS: 1
# Generated TLS material goes in the data volume, beside data/fernet.key
# and for the same reasons: it is state, it has to survive a rebuild,
# and it is the one directory this image prepares for the unprivileged
# user the container runs as.
#
# `/app/certs` is where it went first, and that failed on the first boot
# with TLS on: the directory ships inside the image owned by root, the
# server runs as `sentora`, and generation died with
# "Permission denied: '/app/certs/rootCA.key'". Refusing to fall back to
# plain HTTP is correct, so the container restart-looped instead - the
# right behaviour making a packaging mistake impossible to miss.
TLS_DIR: /app/data/certs
# core/mq.py falls back to guest:guest when this is unset, which would
# silently break publishing the moment the broker credentials change.
RABBITMQ_URL: amqp://${RABBITMQ_USER:-sentora}:${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set in .env}@rabbitmq/
depends_on:
db:
condition: service_healthy
ollama-init:
condition: service_completed_successfully
ports:
- "8000:8000"
volumes:
# The agent Fernet key lives here (data/fernet.key). Without a volume it
# was regenerated on every `docker compose down && up`, which silently
# made all previously encrypted agent telemetry unreadable. Back this
# volume up — there is no key rotation path.
- sentora_data:/app/data
# The whole host filesystem used to be bind-mounted here as
# `/:/host_disk:ro`, so that the dashboard could show one disk-usage
# percentage. That handed /etc/shadow, every SSH key and every other
# container's volume to anything that achieved code execution in this
# container. `_server_disk_percent()` falls back to the container's own
# filesystem when /host_disk is absent, so the gauge still works — it now
# reports the container's view rather than the host's.
#
# If you need the host figure, mount something narrow that lives on the
# host root filesystem; statvfs reports the whole filesystem's stats from
# any path on it, so no directory contents need to be exposed:
# volumes:
# - /etc/hostname:/host_disk:ro
ingest:
build: .
container_name: sentora-ingest
restart: always
security_opt:
- no-new-privileges:true
command: [ "python", "server.py" ]
env_file:
- .env
environment:
OLLAMA_BASE_URL: http://ollama:11434/api
OLLAMA_MODEL: llama3.2:3b
# The same directory `app` writes the certificate to. A container path,
# not a deployment preference, which is why it is here rather than in
# .env - and it has to be on both services or this one looks in
# /app/certs and reports a certificate that was never missing.
TLS_DIR: /app/data/certs
RABBITMQ_URL: amqp://${RABBITMQ_USER:-sentora}:${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set in .env}@rabbitmq/
depends_on:
db:
condition: service_healthy
ollama-init:
condition: service_completed_successfully
ports:
# 5001 is the original plaintext listener; 5011 carries the same
# protocol inside TLS. Both are published because a fleet does not move
# in one step, and closing 5001 before the agents have been rebuilt
# makes their telemetry stop with no symptom on either side - a host
# that went quiet looks exactly like a host with nothing to say.
#
# server.py names every agent still arriving in the clear, once each.
# When that list is empty, close the plaintext path with two settings in
# .env, both of which are needed:
#
# INGEST_TLS_REQUIRED=1 stops the listener inside the container
# INGEST_PLAINTEXT_BIND=127.0.0.1 stops Docker publishing the port
#
# The second is not tidiness. INGEST_TLS_REQUIRED alone closes the
# listener while Docker goes on answering on the host port: the proxy
# accepts the connection, the batch is written into a socket nobody
# reads, and the close looks exactly like a server that stored
# everything. Verified on a live stack - the bytes were accepted and
# nothing was ingested.
#
# Bound rather than removed, because this file is tracked and a fresh
# install that is not on TLS yet still needs 5001. Same mechanism as
# BIND_ADDR above.
- "${INGEST_PLAINTEXT_BIND:-0.0.0.0}:5001:5001"
- "5011:5011"
volumes:
# Read-only, and required. The agent encrypts `message` before sending;
# this key is what turns it back into a log. Without it the worker
# serialises `enc::gAAAA...` into the prompt and the model answers
# anyway - which is how ciphertext went to the model unnoticed.
#
# `app` owns the volume and creates the key on first boot. Mounting it
# read-only here means a container that starts before that cannot
# quietly generate a different one and decrypt nothing.
# Read-only, which is also what makes the TLS arrangement work: `app`
# generates the certificate into this volume and this process only reads
# it. Two processes generating independently would each hold a
# certificate the other does not, and an agent that verified the console
# would then fail against ingest for no visible reason.
- sentora_data:/app/data:ro
rabbitmq:
image: rabbitmq:3-management
container_name: sentora-rabbitmq
restart: always
ports:
- "${BIND_ADDR:-127.0.0.1}:5672:5672"
- "${BIND_ADDR:-127.0.0.1}:15672:15672"
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-sentora}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set in .env}
ai-worker-automation:
build: .
container_name: sentora-ai-worker-automation
restart: always
security_opt:
- no-new-privileges:true
command: [ "python", "ai_worker.py" ]
env_file:
- .env
environment:
- WORKER_TYPE=automation
- OLLAMA_BASE_URL=http://ollama:11434/api
- OLLAMA_MODEL=llama3.2:3b
- RABBITMQ_URL=amqp://${RABBITMQ_USER:-sentora}:${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set in .env}@rabbitmq/
depends_on:
db:
condition: service_healthy
rabbitmq:
condition: service_started
ollama-init:
condition: service_completed_successfully
volumes:
# Read-only, and required. The agent encrypts `message` before sending;
# this key is what turns it back into a log. Without it the worker
# serialises `enc::gAAAA...` into the prompt and the model answers
# anyway - which is how ciphertext went to the model unnoticed.
#
# `app` owns the volume and creates the key on first boot. Mounting it
# read-only here means a container that starts before that cannot
# quietly generate a different one and decrypt nothing.
- sentora_data:/app/data:ro
ai-worker-manual:
build: .
container_name: sentora-ai-worker-manual
restart: always
security_opt:
- no-new-privileges:true
command: [ "python", "ai_worker.py" ]
env_file:
- .env
environment:
- WORKER_TYPE=manual
- OLLAMA_BASE_URL=http://ollama:11434/api
- OLLAMA_MODEL=llama3.2:3b
- RABBITMQ_URL=amqp://${RABBITMQ_USER:-sentora}:${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set in .env}@rabbitmq/
depends_on:
db:
condition: service_healthy
rabbitmq:
condition: service_started
ollama-init:
condition: service_completed_successfully
volumes:
# Read-only, and required. The agent encrypts `message` before sending;
# this key is what turns it back into a log. Without it the worker
# serialises `enc::gAAAA...` into the prompt and the model answers
# anyway - which is how ciphertext went to the model unnoticed.
#
# `app` owns the volume and creates the key on first boot. Mounting it
# read-only here means a container that starts before that cannot
# quietly generate a different one and decrypt nothing.
- sentora_data:/app/data:ro
ai-worker-defensive:
build: .
container_name: sentora-ai-worker-defensive
restart: always
security_opt:
- no-new-privileges:true
command: [ "python", "ai_worker.py" ]
env_file:
- .env
environment:
- WORKER_TYPE=defensive
- OLLAMA_BASE_URL=http://ollama:11434/api
- OLLAMA_MODEL=llama3.2:3b
- RABBITMQ_URL=amqp://${RABBITMQ_USER:-sentora}:${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set in .env}@rabbitmq/
# AI_SHADOW_MODE=1 makes the defensive worker stage proposals
# ('AI_DEFENSIVE_SHADOW' with shadow_status='pending') instead of
# dispatching real SOAR actions. Operator approves/rejects from the
# SOAR Hub → Shadow Queue tab. Leave unset for normal autonomy.
- AI_SHADOW_MODE=${AI_SHADOW_MODE:-0}
depends_on:
db:
condition: service_healthy
rabbitmq:
condition: service_started
ollama-init:
condition: service_completed_successfully
volumes:
# Read-only, and required. The agent encrypts `message` before sending;
# this key is what turns it back into a log. Without it the worker
# serialises `enc::gAAAA...` into the prompt and the model answers
# anyway - which is how ciphertext went to the model unnoticed.
#
# `app` owns the volume and creates the key on first boot. Mounting it
# read-only here means a container that starts before that cannot
# quietly generate a different one and decrypt nothing.
- sentora_data:/app/data:ro
opensearch:
image: opensearchproject/opensearch:2.12.0
container_name: sentora-opensearch
restart: always
environment:
- cluster.name=sentora-cluster
- node.name=sentora-node1
- discovery.type=single-node
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g"
- DISABLE_INSTALL_DEMO_CONFIG=true
# No authentication on the cluster. Acceptable only because the port is
# bound to BIND_ADDR and nothing outside the compose network reaches it.
# Enabling the security plugin means issuing node/client certs and
# teaching core/opensearch.py to present them — worth doing before this
# is ever exposed beyond the host.
- DISABLE_SECURITY_PLUGIN=true
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
ports:
- "${BIND_ADDR:-127.0.0.1}:9200:9200"
- "${BIND_ADDR:-127.0.0.1}:9600:9600"
volumes:
- opensearch_data:/usr/share/opensearch/data
opensearch-dashboards:
image: opensearchproject/opensearch-dashboards:2.12.0
container_name: sentora-dashboards
restart: always
ports:
# Unauthenticated read access to every collected log. The "Open
# Dashboards" button in Log Explorer links to port 5601 on the server's
# hostname, so that button only works from the host itself unless you
# deliberately publish this — which should be through an authenticating
# reverse proxy, not by widening BIND_ADDR.
- "${BIND_ADDR:-127.0.0.1}:5601:5601"
environment:
- 'OPENSEARCH_HOSTS=["http://opensearch:9200"]'
- "DISABLE_SECURITY_DASHBOARDS_PLUGIN=true"
depends_on:
- opensearch
volumes:
mysql_data:
opensearch_data:
# Holds data/fernet.key and data/certs/. Losing the key makes previously
# encrypted telemetry unreadable; losing the certificates is survivable - a
# new pair is generated - but every agent holding the old CA stops verifying
# the server and stops sending until it is re-pointed.
sentora_data: