-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
675 lines (663 loc) · 36.6 KB
/
Copy pathdocker-compose.yml
File metadata and controls
675 lines (663 loc) · 36.6 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
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
# Pin the Compose project name so the stack is always "pithead" — its images, network and
# volumes are prefixed `pithead*` regardless of the checkout directory's name. Without this,
# Compose derives the project from the directory, which left older checkouts named after the
# repo's previous name. `pithead up`/`apply`/`upgrade` migrate an old-named stack automatically.
name: pithead
x-logging: &default-logging
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
services:
# --- Tor Anonymity Service ---
tor:
# A source checkout builds the 5 first-party images locally from build/; a release install pulls
# these published images instead. pithead chooses build-vs-pull via its `--pull` policy and sets
# PITHEAD_REGISTRY / STACK_VERSION (export_build_provenance, #44); the defaults below keep a bare
# `docker compose` working (tag `:dev`).
image: ${PITHEAD_REGISTRY:-ghcr.io/p2pool-starter-stack}/pithead-tor:${STACK_VERSION:-dev}
build: ./build/tor
container_name: tor
# Memory ceiling (#132 — see monerod). Generous so an OOM-restart, which would blip every onion
# service, stays very unlikely (~0.07 GiB observed).
mem_limit: 512m
memswap_limit: 512m
restart: unless-stopped
logging: *default-logging
environment:
# Subnet prefix (#180): the entrypoint envsubst's it into the rendered torrc's bridge IPs.
- NETWORK_PREFIX=${NETWORK_PREFIX:-172.28.0}
# Dashboard onion (#343): opt-in, default off. When true the entrypoint appends a hidden
# service for the dashboard (fronting Caddy on the bridge gateway, never the LAN).
- DASHBOARD_ONION_ENABLED=${DASHBOARD_ONION_ENABLED:-false}
# Client-auth toggle (#343): the entrypoint doesn't read it, but listing it here means a change
# recreates tor so it re-reads authorized_clients/ (pithead writes/clears that before recreate).
- DASHBOARD_ONION_CLIENT_AUTH=${DASHBOARD_ONION_CLIENT_AUTH:-false}
# Immutable root filesystem (#377). The entrypoint renders torrc to /tmp (the unprivileged
# 'tor' user can't write /etc/tor); everything else Tor writes — onion keys, the control auth
# cookie, its state — lives in DataDirectory /var/lib/tor, the bind mount below. No PidFile,
# logs go to stdout.
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
volumes:
- ${TOR_DATA_DIR}:/var/lib/tor
networks:
mining_net:
ipv4_address: ${NETWORK_PREFIX:-172.28.0}.25
# Healthy only once Tor has finished bootstrapping (circuits built), not merely
# when the SOCKS port is open — so tari/monerod don't start against a Tor that
# can't route yet. start_period covers a cold bootstrap without counting failures.
healthcheck:
test: ["CMD", "/usr/local/bin/tor-healthcheck.sh"]
interval: 10s
timeout: 10s
retries: 5
start_period: 90s
# --- Monero Daemon (monerod) ---
# Only starts if "local_node" profile is active
monerod:
profiles: ["local_node"]
image: ${PITHEAD_REGISTRY:-ghcr.io/p2pool-starter-stack}/pithead-monero:${STACK_VERSION:-dev}
build: ./build/monero
container_name: monerod
# Memory CEILINGS (#132): cap each service so a leak/spike OOM-restarts the offender in its own
# cgroup instead of the kernel's host-wide OOM-killer picking a victim (e.g. monerod, the revenue
# service). These are ceilings, NOT reservations — steady-state is far lower (observed on a synced
# 32 GB host: monerod ~0.3 GiB RSS since its LMDB is reclaimable page cache; dashboard ~0.06;
# p2pool ~0.35). memswap_limit == mem_limit => zero swap => clean OOM-kill, no disk thrash (same as
# Tari). monerod's ceiling is generous + tunable via monero.mem_limit so heavy initial-sync
# verification never trips it — raise it if a low-RAM host OOMs monerod during IBD.
mem_limit: ${MONERO_MEM_LIMIT:-6g}
memswap_limit: ${MONERO_MEM_LIMIT:-6g}
restart: unless-stopped
stop_grace_period: 1m
logging: *default-logging
environment:
- MONERO_NODE_USERNAME=${MONERO_NODE_USERNAME}
- MONERO_NODE_PASSWORD=${MONERO_NODE_PASSWORD}
- MONERO_ONION_ADDRESS=${MONERO_ONION_ADDRESS}
- MONERO_PRUNE=${MONERO_PRUNE}
# Optional clearnet initial sync (#183): default off. When true the entrypoint strips the Tor
# P2P proxy from the rendered config so IBD runs over clearnet (tx broadcast stays on Tor).
- MONERO_CLEARNET_SYNC=${MONERO_CLEARNET_SYNC:-false}
- MONERO_PREP_THREADS=${MONERO_PREP_THREADS}
# Subnet prefix (#180): the entrypoint envsubst's it into the Tor SOCKS IP in bitmonero.conf.
- NETWORK_PREFIX=${NETWORK_PREFIX:-172.28.0}
# Immutable root filesystem (#377). The entrypoint renders bitmonero.conf INTO the bind-mounted
# data dir (/home/ubuntu/.bitmonero/bitmonero.conf), and monerod keeps everything — chain DB,
# logs, p2pstate — under that same data-dir mount, so /tmp scratch is the only tmpfs needed.
# /dev/hugepages is a bind mount, unaffected by the read-only rootfs.
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
volumes:
- ${MONERO_DATA_DIR}:/home/ubuntu/.bitmonero
- /dev/hugepages:/dev/hugepages
- ./build/monero/bitmonero.conf.template:/home/ubuntu/bitmonero.conf.template:ro
# Read-only view of the shared clearnet-state dir (#234): the entrypoint reads the
# dashboard-written "sync complete" marker to decide clearnet-vs-Tor at startup.
- ${CLEARNET_STATE_DIR:-./data/clearnet-state}:/clearnet-state:ro
ports:
# Default 127.0.0.1 = host-local only (no LAN exposure); p2pool reaches monerod over
# the internal Docker network regardless. Set monero.rpc_lan_access:true in config.json
# to publish on the LAN (0.0.0.0) for wallets on other machines.
- "${MONERO_RPC_BIND:-127.0.0.1}:18081:18081"
networks:
mining_net:
ipv4_address: ${NETWORK_PREFIX:-172.28.0}.26
depends_on:
tor:
condition: service_healthy
healthcheck:
# The RPC username/password are read from the container's env inside the script, NOT
# interpolated into the test command — so they no longer leak via `docker inspect` (#90).
test: ["CMD", "/usr/local/bin/monerod-healthcheck.sh"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
# --- Tari Base Node ---
tari:
# Pinned by digest (#135) so the v5.3.1-mainnet tag can't be silently re-pointed.
image: quay.io/tarilabs/minotari_node:v5.3.1-mainnet@sha256:824fd6ec21d618805317d7eede374d6782906eeae17d2fc8aaad4df6205f94e0
container_name: tari
# Run non-root (#255). Pulled image (not our Dockerfile), so set the uid via compose to match the
# uid pithead chowns ${TARI_DATA_DIR} to. The wrapper entrypoint reads the canonical config
# read-only and writes its runtime copy to /tmp, so only the data dir needs to be owned by 1000.
user: "1000:1000"
restart: unless-stopped
stop_grace_period: 1m
logging: *default-logging
# Tari's memory grows unbounded over time (#55, first reported in #14); left uncapped it can
# pressure or OOM the whole host on small (e.g. 16 GB) machines. Cap it as a SAFETY CEILING so a
# runaway OOM-restarts cleanly instead of dragging the stack down — monerod/p2pool keep mining
# throughout. These are the legacy keys on purpose: deploy.resources has no swap field, and
# memswap_limit equal to mem_limit means zero swap => clean OOM-kill, no disk thrash. (Capping
# swap needs swapaccount=1 on older cgroup-v1 hosts; cgroup-v2 honors it by default.) pithead
# sets TARI_MEM_LIMIT from tari.mem_limit — "auto" gives Tari most of RAM after subtracting the
# ~6 GB RandomX HugePages reservation and a reserve for the rest of the stack. The :-7680m
# fallback only applies to a stale .env that predates this and hasn't been re-rendered (=> a safe
# value for a 16 GB host with HugePages on, until the next `pithead apply`).
mem_limit: ${TARI_MEM_LIMIT:-7680m}
memswap_limit: ${TARI_MEM_LIMIT:-7680m}
# Tari needs NO clearnet DNS: dns_seeds = [] (config.toml), onion peer_seeds resolve via Tor, and
# the Tor SOCKS is reached by IP (this container already overrode Docker's 127.0.0.11, so nothing
# here depends on service discovery). The one OS-resolver lookup left is the Tari Pulse service's
# checkpoints.tari.com TXT (~every 120 s) — an ADVISORY deep-reorg check, no in-binary off-switch,
# that TOLERATES a DNS failure (returns "passed", verified in tari_pulse_service/mod.rs). Point the
# resolver at a dead local address so that lookup fails WITHOUT a packet leaving the host — zero
# clearnet DNS, no functional impact (#162; #160 decision-4). Trade-off: lose the Pulse deep-reorg
# advisory, the same class as monerod's disable-dns-checkpoints (#161).
dns:
- 127.0.0.1
environment:
- WAIT_FOR_TOR=1
# Optional clearnet initial sync (#183/#234): the wrapper entrypoint reads this flag and the
# shared marker to decide clearnet-vs-Tor per start. Surfaced here so a flag change recreates
# tari (a bind-mount content change alone won't trigger a recreate).
- TARI_CLEARNET_SYNC=${TARI_CLEARNET_SYNC:-false}
# Immutable root filesystem (#377). The wrapper entrypoint copies the canonical config to
# /tmp/tari-runtime-config.toml; the upstream start_tari_app.sh (verified in the pinned image:
# APP_NAME=node → TARI_BASE=/var/tari/node, it only mkdir+cd there) then runs minotari_node with
# --base-path /var/tari/node — the data bind mount, which holds the DB, logs and peer DB.
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
volumes:
- ${TARI_DATA_DIR}:/var/tari/node
- ./build/tari:/var/tari/config
# Read-only view of the shared clearnet-state dir (#234): the wrapper entrypoint reads the
# dashboard-written "sync complete" marker to decide clearnet-vs-Tor at startup.
- ${CLEARNET_STATE_DIR:-./data/clearnet-state}:/clearnet-state:ro
# Pithead wrapper (#183/#234): renders the runtime config (clearnet-vs-Tor, marker-gated) from
# pithead's canonical Tor config, then chains to the upstream start_tari_app.sh unchanged.
entrypoint:
- /var/tari/config/entrypoint.sh
command:
- --disable-splash-screen
- --non-interactive
networks:
mining_net:
ipv4_address: ${NETWORK_PREFIX:-172.28.0}.27
depends_on:
tor:
condition: service_healthy
healthcheck:
# Liveness only — deliberately NOT a sync/gRPC check: that would block p2pool from ever
# starting while Tari is down or still syncing, breaking "Tari is optional" (#35/#51). The
# '[m]' bracket stops grep matching its own argv — the old `grep minotari_node` matched
# itself, so the check reported "healthy" even when the daemon had died.
test: ["CMD-SHELL", "ps | grep '[m]inotari_node' || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 90s # survive the post-OOM DB integrity check without flapping unhealthy
# --- P2Pool Node ---
p2pool:
image: ${PITHEAD_REGISTRY:-ghcr.io/p2pool-starter-stack}/pithead-p2pool:${STACK_VERSION:-dev}
build: ./build/p2pool
container_name: p2pool
# Memory ceiling (#132 — see monerod). RandomX HugePages are separate; this caps p2pool's own
# heap (~0.35 GiB observed).
mem_limit: 1g
memswap_limit: 1g
restart: unless-stopped
logging: *default-logging
# Replaces 'privileged: true'. p2pool only needs to lock HugePages for the RandomX dataset
# (IPC_LOCK) and raise thread priority (SYS_NICE). NOTE (#255): the container now runs non-root,
# so these caps aren't in the process's effective set — but mlock is covered by the unlimited
# memlock ulimit below, and SYS_NICE is best-effort (p2pool warns and continues without it).
cap_add:
- IPC_LOCK
- SYS_NICE
ulimits:
memlock:
soft: -1
hard: -1
# Immutable root filesystem (#377). p2pool writes its cache, p2pool.log and peer list to its
# cwd /home/ubuntu and the data-api to /stats — both bind mounts below. The entrypoint's socat
# bridges (#278) write nothing; /dev/hugepages is a bind mount, unaffected.
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
volumes:
- ${P2POOL_DATA_DIR}:/home/ubuntu
- ${P2POOL_DATA_DIR}/stats:/stats
- /dev/hugepages:/dev/hugepages
command:
- --host
- ${MONERO_NODE_HOST} # Dynamic Host
- --rpc-port
- "${MONERO_RPC_PORT}"
- --rpc-login
- "${MONERO_NODE_USERNAME}:${MONERO_NODE_PASSWORD}" # Only used if auth required
- --zmq-port
- "${MONERO_ZMQ_PORT}"
- --wallet
- ${MONERO_WALLET_ADDRESS}
- --merge-mine
- tari://${NETWORK_PREFIX:-172.28.0}.27:18142
- ${TARI_WALLET_ADDRESS}
- --onion-address
- ${P2POOL_ONION_ADDRESS}
- --local-api
- --stratum
- 0.0.0.0:3333
- --p2p
- 0.0.0.0:${P2POOL_PORT}
- --data-api
- /stats
# P2POOL_FLAGS (pool type + the #165 Tor SOCKS routing) is passed via the environment, not as a
# `- ${P2POOL_FLAGS}` command item: Compose passes such an item as a SINGLE argument (no
# word-splitting), which mangles a multi-flag value. The entrypoint word-splits this env var.
environment:
- P2POOL_FLAGS=${P2POOL_FLAGS:-}
networks:
mining_net:
ipv4_address: ${NETWORK_PREFIX:-172.28.0}.28
depends_on:
# monerod dependency removed to allow remote node operation
tari:
condition: service_healthy
healthcheck:
# Liveness for the core revenue service (#90). Probe the stratum listener directly: a TCP
# connect to :3333 proves p2pool is up and accepting miners, which is the failure miners
# actually feel. /dev/tcp is a bash builtin (the image ships bash) so it needs no extra
# tooling. Surfaced in `pithead status` / the dashboard; it does NOT gate startup or
# auto-restart — xmrig-proxy still depends on p2pool only being *started*.
test: ["CMD", "/usr/local/bin/p2pool-healthcheck.sh"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
# --- XMRig Proxy ---
xmrig-proxy:
image: ${PITHEAD_REGISTRY:-ghcr.io/p2pool-starter-stack}/pithead-xmrig-proxy:${STACK_VERSION:-dev}
build: ./build/xmrig-proxy
container_name: xmrig-proxy
# Memory ceiling (#132 — see monerod). Tiny in practice (~2 MiB observed).
mem_limit: 512m
memswap_limit: 512m
restart: unless-stopped
logging: *default-logging
# Defense-in-depth: this leaf service never needs extra privileges or Linux capabilities
# (it only listens on :3333/:3344 and forwards to p2pool). Drop them all (#90).
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# Immutable root filesystem (#377). Pure network proxy, no bind mounts; the wrapper entrypoint
# writes nothing, but the cwd /home/ubuntu exists only in the image and xmrig-proxy may drop a
# config/log there (e.g. persisting an API-driven pool switch), so give it uid-1000 tmpfs
# scratch. Ephemeral by design — the config is re-rendered from the compose command every start.
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
- /home/ubuntu:size=64m,uid=1000,gid=1000
ports:
# The stratum endpoint your rigs connect to. Published on 0.0.0.0 (all interfaces) by
# default so LAN workers can reach it; set p2pool.stratum_bind in config.json to a specific
# address (e.g. your LAN IP, or 127.0.0.1 to disable LAN access) to narrow exposure on a
# public-IP host. Either way, firewall :3333 to your LAN — see docs/workers.md.
- "${STRATUM_BIND:-0.0.0.0}:3333:3333"
environment:
- PROXY_API_PORT=${PROXY_API_PORT}
- PROXY_AUTH_TOKEN=${PROXY_AUTH_TOKEN}
# Optional stratum-miner auth (#152). Passed as an env var — NOT a `- ${VAR:+--flag}` command
# item — because a compose command LIST can't drop an empty element: when the password is unset
# that item renders a stray '' positional arg (xmrig-proxy warns `unsupported non-option
# argument ''`). The wrapper entrypoint appends the flag only when this is non-empty.
- PROXY_STRATUM_PASSWORD=${PROXY_STRATUM_PASSWORD:-}
command:
- -o
- ${P2POOL_URL}
- -u
- ${MONERO_WALLET_ADDRESS}
- -b
- 0.0.0.0:3333
- -m
- simple
- --coin
- monero
- --verbose
- --http-host
- 0.0.0.0
- --http-port
- "${PROXY_API_PORT:?PROXY_API_PORT is empty; run 'pithead apply' (#153)}"
# Fail closed (#153): this control API is writable (--http-no-restricted, required for XvB
# pool-switching) and reachable on the mining_net bridge + the host, so it must NEVER run
# unauthenticated. ':?' makes the stack REFUSE TO START — instead of exposing an open API —
# if a hand-edited or pre-token stale .env left PROXY_AUTH_TOKEN empty (note 'pithead up'
# does not re-render .env, only setup/apply do). pithead auto-generates the token on apply.
- --http-access-token
- "${PROXY_AUTH_TOKEN:?PROXY_AUTH_TOKEN is empty; refusing to start an unauthenticated xmrig-proxy control API. Run 'pithead apply' to generate one (#153)}"
- --http-no-restricted
# Optional miner authentication on the stratum port (#152) is applied by the wrapper entrypoint
# from $PROXY_STRATUM_PASSWORD (see the environment block above) — NOT as a command item here,
# because a compose command LIST can't omit an empty element. When the password is set,
# xmrig-proxy rejects rigs whose stratum 'pass' doesn't match, so only devices that know the
# secret can mine (this also shrinks the #122 SSRF surface). Cleartext over stratum — pair with a
# LAN-only stratum_bind / firewall; this is access control, not encryption.
# xmrig-proxy dev-fee donation level (#173). Rendered from proxy.donate_level; xmrig-proxy's
# own default is 0% (no fee), which we now pass EXPLICITLY so the effective level is visible
# and operator-controllable instead of an invisible compiled-in default. NOT the XvB donation.
- --donate-level=${PROXY_DONATE_LEVEL:-0}
networks:
mining_net:
ipv4_address: ${NETWORK_PREFIX:-172.28.0}.29
depends_on:
- p2pool
# --- Monitoring Dashboard ---
dashboard:
image: ${PITHEAD_REGISTRY:-ghcr.io/p2pool-starter-stack}/pithead-dashboard:${STACK_VERSION:-dev}
build:
context: ./build/dashboard
# Stack version baked into the image so the dashboard header can show it (Issue #58).
# pithead exports these for the build (PITHEAD_VERSION from the top-level VERSION file;
# git branch/commit for dev builds — see export_build_provenance); empty defaults keep a
# bare `docker compose build` working (the badge then falls back to a generic dev indicator).
args:
PITHEAD_VERSION: ${PITHEAD_VERSION:-}
PITHEAD_GIT_COMMIT: ${PITHEAD_GIT_COMMIT:-}
PITHEAD_GIT_BRANCH: ${PITHEAD_GIT_BRANCH:-}
PITHEAD_RELEASE: ${PITHEAD_RELEASE:-}
container_name: dashboard
# Memory ceiling (#132 — see monerod). A small Flask + SQLite app (~0.06 GiB observed); a tight
# cap is pure upside — a memory regression OOM-restarts the dashboard, not the revenue services.
mem_limit: 512m
memswap_limit: 512m
restart: unless-stopped
logging: *default-logging
# Host networking, kept deliberately (#91 P5, evaluated & closed): the dashboard reaches the
# local monerod RPC on 127.0.0.1:18081 (Issue #29) plus the host-published proxy/Tor-SOCKS
# endpoints, and Caddy proxies it on the host. Moving to the bridge would mean re-plumbing the
# monerod address, the dashboard's bind, Caddy's upstream and LAN-worker reachability for a
# marginal gain — net negative, so host networking stays. It binds only 127.0.0.1 (HOST_IP).
network_mode: "host"
# Now runs non-root (uid 1000, #255) and owns ${DASHBOARD_DATA_DIR}, so it no longer needs
# root's CAP_DAC_OVERRIDE to write its SQLite history — drop all capabilities like the other
# leaf services (#90). no-new-privileges stays as defense-in-depth.
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# Immutable root filesystem (#377). The dashboard writes its SQLite history to /data and the
# clearnet-sync markers to /clearnet-state — both bind mounts below; /app and the venv are only
# read/executed. /tmp covers Python's tempfile default and SQLite's temp-file fallback.
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
volumes:
- ${P2POOL_DATA_DIR}/stats:/app/stats:ro
- ${DASHBOARD_DATA_DIR}:/data
# Read-WRITE: the supervisor drops the per-chain "clearnet sync complete" marker here once a
# node finishes a clearnet initial sync, then restarts it onto Tor (#234). monerod/tari mount
# the same dir read-only. Holds only non-secret state markers.
- ${CLEARNET_STATE_DIR:-./data/clearnet-state}:/clearnet-state
# Control-channel spool (#33). requests/ is the container's ONLY writable leg — the dashboard
# drops typed JSON intents there; the host-side runner answers via the read-only results/ and
# audit/ mounts. staged/ is never mounted at all. This rw/ro split is the trust boundary: the
# container can only ask; it cannot forge a result, rewrite the audit log, or alter a staged
# intent between preview and commit.
- ${CONTROL_DIR:-./data/control}/requests:/control/requests
- ${CONTROL_DIR:-./data/control}/results:/control/results:ro
- ${CONTROL_DIR:-./data/control}/audit:/control/audit:ro
# Caddy's JSON access log (#349), read-only: the dashboard tails it to show recent accesses
# and a failed-login count (a burst of 401s = rotate the password / onion). Caddy owns the
# write side and the rotation; every field read from here is treated as hostile input.
- ${CADDY_LOG_DIR:-./data/caddy-logs}:/access-log:ro
# The live config, read-only, prefills the Configuration view (#33). Secret leaves are masked
# server-side (control_service.read_config); the raw values never leave the API. NOTE: this
# bind-mount — not the API masking — is the real secret trust boundary; a backend compromise
# reads config.json plaintext here (see SECURITY.md).
- ./config.json:/host-config/config.json:ro
# config.reference.json (every key with its default), read-only, so the editor form covers the
# full schema even for keys the operator's sparse config.json omits (#33). No secrets.
- ./config.reference.json:/host-config/config.reference.json:ro
environment:
- HOST_IP=${HOST_IP:-127.0.0.1}
# Timezone for dashboard timestamps/charts. Set via `dashboard.timezone` in config.json
# (`auto` = the host's timezone, auto-detected); pithead renders it to DASHBOARD_TZ in
# .env. The Etc/UTC here is only a last-resort fallback if DASHBOARD_TZ is unset.
- TZ=${DASHBOARD_TZ:-Etc/UTC}
- MONERO_NODE_HOST=${MONERO_NODE_HOST:-172.28.0.26}
# monerod get_info RPC creds (Issue #29): the dashboard reads sync height/target over
# 127.0.0.1:18081 (reachable via network_mode: host) using digest auth, instead of
# scraping docker logs. Only used for a local node; harmless for remote-node setups.
- MONERO_NODE_USERNAME=${MONERO_NODE_USERNAME}
- MONERO_NODE_PASSWORD=${MONERO_NODE_PASSWORD}
# Whether the local node prunes — shown as Pruned/Full in the Monero panel (Issue #32).
- MONERO_PRUNE=${MONERO_PRUNE:-true}
# Clearnet initial sync (#183/#234): the supervisor watches these flags and, once a clearnet
# node is synced, drops a marker in /clearnet-state and restarts it back onto Tor.
- MONERO_CLEARNET_SYNC=${MONERO_CLEARNET_SYNC:-false}
- TARI_CLEARNET_SYNC=${TARI_CLEARNET_SYNC:-false}
- CLEARNET_STATE_DIR=/clearnet-state
# Egress-posture panel (#170): the #270 firewall state + p2pool's clearnet toggle, so the
# dashboard can show each component's real egress route rather than a guess.
- TOR_EGRESS_FIREWALL=${TOR_EGRESS_FIREWALL:-true}
# Tor guard self-heal (#424): opt-in; when true the dashboard probes Tor clearnet egress
# and restarts tor (via docker-control) if it's stuck on a failing guard. Default off.
- TOR_AUTO_HEAL=${TOR_AUTO_HEAL:-false}
- P2POOL_CLEARNET=${P2POOL_CLEARNET:-false}
- P2POOL_URL=${P2POOL_URL}
- MONERO_WALLET_ADDRESS=${MONERO_WALLET_ADDRESS}
- XVB_ENABLED=${XVB_ENABLED}
- XVB_POOL_URL=${XVB_POOL_URL}
- XVB_DONOR_ID=${XVB_DONOR_ID}
# Route XvB donation mining over Tor by default (#166); xvb.tor:false opts out.
- XVB_TOR_ENABLED=${XVB_TOR_ENABLED:-true}
- XVB_DONATION_LEVEL=${XVB_DONATION_LEVEL:-auto}
# XvB raffle auto-registration endpoint (#263). Defaults to the real endpoint (in the dashboard
# config) so registration just works. Override here to point at a different/test endpoint, or
# set a disable sentinel (off/none/false/disabled) to turn auto-registration off while keeping
# XvB on. The call carries the full wallet and is always Tor-routed like the stats fetch.
- XVB_SUBMIT_URL=${XVB_SUBMIT_URL:-}
- PROXY_HOST=${NETWORK_PREFIX:-172.28.0}.29
- PROXY_API_PORT=${PROXY_API_PORT}
- PROXY_AUTH_TOKEN=${PROXY_AUTH_TOKEN}
# How the dashboard reads each worker's own xmrig /1/summary (config.json: workers.*). One
# configured way, no auto-detection. Defaults = stock RigForge worker (open read-only API,
# no token, port 8080). api_auth: none (default) | name (Bearer=worker name) | token.
- XMRIG_API_PORT=${XMRIG_API_PORT:-8080}
- XMRIG_API_AUTH=${XMRIG_API_AUTH:-none}
- XMRIG_API_TOKEN=${XMRIG_API_TOKEN}
# Socket proxies are isolated on proxy_net and published only to the host loopback (#345), so
# the host-networked dashboard reaches them here — mining containers cannot.
- DOCKER_PROXY_URL=tcp://127.0.0.1:12375
- DOCKER_CONTROL_URL=tcp://127.0.0.1:12376
- TARI_GRPC_ADDRESS=${NETWORK_PREFIX:-172.28.0}.27:18142
# Local monerod bridge IP (local-vs-remote detection), the internal SSRF-guard CIDR, and the
# Tor SOCKS endpoint all track the configured subnet prefix (#180).
- LOCAL_MONERO_HOST=${NETWORK_PREFIX:-172.28.0}.26
- MINING_NET_CIDR=${NETWORK_SUBNET:-172.28.0.0/24}
- TOR_SOCKS_PROXY=socks5h://${NETWORK_PREFIX:-172.28.0}.25:9050
# Notify-only new-release check (#224, config.json: dashboard.check_for_updates). Default ON;
# the dashboard checks GitHub for a newer release and shows a link badge — routed over the same
# bridge Tor SOCKS as TOR_SOCKS_PROXY so it leaks neither the host IP nor a DNS lookup (safe to
# default on). Set dashboard.check_for_updates:false to opt out. See docs/privacy.md.
- DASHBOARD_CHECK_UPDATES=${DASHBOARD_CHECK_UPDATES:-true}
# Dashboard config editing (#33, config.json: dashboard.control.enabled). Default OFF.
# When off, the control routes are not registered at all (they 404); the spool mounts above
# are inert because no host-side runner is installed.
- DASHBOARD_CONTROL_ENABLED=${DASHBOARD_CONTROL_ENABLED:-false}
# monerod is required: a monerod outage always rejects workers (Issue #31) and the miner
# is always held until monerod syncs (Issue #35) — not configurable. Tari is optional:
# TARI_REQUIRED (default true) decides whether a Tari outage rejects workers, whether the
# miner waits for Tari's sync, and whether a Tari-only sync takes over the dashboard
# (Issue #51). Set dashboard.tari_required:false to make Tari non-blocking.
- TARI_REQUIRED=${TARI_REQUIRED:-true}
# Healthchecks.io dead-man's switch (#79). A ping URL is the on/off switch (blank = off);
# when set, the dashboard pings it each loop, so if the host dies the pings stop and
# Healthchecks.io alerts externally — the one failure mode an in-stack notifier can't
# report. The ping always rides the bridge Tor SOCKS (TOR_SOCKS_PROXY). See docs/monitoring.md.
- HEALTHCHECKS_PING_URL=${HEALTHCHECKS_PING_URL:-}
# --- Operator alerts: Telegram (Issues #121, #45) ---
# Notifications-only push alerter plus an optional on-demand command interface; both
# disabled by default. bot_token is a secret sourced from the owner-only .env and is never
# logged by the dashboard. On a Tor-only / no-clearnet host the Telegram API is unreachable
# and both sends and command polling fail silently. Per-event toggles default on; the
# command interface is opt-in via TELEGRAM_COMMANDS_ENABLED. See docs/telegram.md.
- TELEGRAM_ENABLED=${TELEGRAM_ENABLED:-false}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- TELEGRAM_EVENT_NODE_DOWN=${TELEGRAM_EVENT_NODE_DOWN:-true}
- TELEGRAM_EVENT_NODE_RECOVERED=${TELEGRAM_EVENT_NODE_RECOVERED:-true}
- TELEGRAM_EVENT_WORKER_OFFLINE=${TELEGRAM_EVENT_WORKER_OFFLINE:-true}
- TELEGRAM_EVENT_WORKER_RECOVERED=${TELEGRAM_EVENT_WORKER_RECOVERED:-true}
- TELEGRAM_EVENT_WORKER_JOINED=${TELEGRAM_EVENT_WORKER_JOINED:-true}
- TELEGRAM_EVENT_WORKER_LEFT=${TELEGRAM_EVENT_WORKER_LEFT:-true}
- TELEGRAM_EVENT_SYNC_FINISHED=${TELEGRAM_EVENT_SYNC_FINISHED:-true}
- TELEGRAM_EVENT_DISK_SPACE=${TELEGRAM_EVENT_DISK_SPACE:-true}
- TELEGRAM_EVENT_DB_UNHEALTHY=${TELEGRAM_EVENT_DB_UNHEALTHY:-true}
- TELEGRAM_EVENT_XVB_NO_SHARE=${TELEGRAM_EVENT_XVB_NO_SHARE:-true}
- TELEGRAM_EVENT_CLEARNET_EXPOSED=${TELEGRAM_EVENT_CLEARNET_EXPOSED:-true}
- TELEGRAM_EVENT_XVB_REGISTRATION=${TELEGRAM_EVENT_XVB_REGISTRATION:-true}
- TELEGRAM_EVENT_NEW_RELEASE=${TELEGRAM_EVENT_NEW_RELEASE:-true}
- TELEGRAM_EVENT_STACK_ONLINE=${TELEGRAM_EVENT_STACK_ONLINE:-true}
- TELEGRAM_EVENT_DAILY_SUMMARY=${TELEGRAM_EVENT_DAILY_SUMMARY:-true}
- TELEGRAM_EVENT_HASHRATE_LOW=${TELEGRAM_EVENT_HASHRATE_LOW:-true}
- TELEGRAM_EVENT_HASHRATE_LOSS=${TELEGRAM_EVENT_HASHRATE_LOSS:-true}
- TELEGRAM_EVENT_HUGEPAGES=${TELEGRAM_EVENT_HUGEPAGES:-true}
- TELEGRAM_EVENT_LOW_RAM=${TELEGRAM_EVENT_LOW_RAM:-true}
- TELEGRAM_EVENT_WALLET_CHANGED=${TELEGRAM_EVENT_WALLET_CHANGED:-true}
- TELEGRAM_EVENT_HIGH_REJECT_RATE=${TELEGRAM_EVENT_HIGH_REJECT_RATE:-true}
- TELEGRAM_EVENT_BLOCK_FOUND=${TELEGRAM_EVENT_BLOCK_FOUND:-true}
- TELEGRAM_EVENT_PAYOUT_FOUND=${TELEGRAM_EVENT_PAYOUT_FOUND:-true}
- TELEGRAM_EVENT_CONTAINER_UNHEALTHY=${TELEGRAM_EVENT_CONTAINER_UNHEALTHY:-true}
- TELEGRAM_DAILY_SUMMARY_TIME=${TELEGRAM_DAILY_SUMMARY_TIME:-08:00}
# Hashrate-degradation detector (#99).
- HASHRATE_DROP_THRESHOLD_PCT=${HASHRATE_DROP_THRESHOLD_PCT:-50}
- HASHRATE_DROP_MINUTES=${HASHRATE_DROP_MINUTES:-10}
- TELEGRAM_COMMANDS_ENABLED=${TELEGRAM_COMMANDS_ENABLED:-false}
# --- Docker Socket Proxy (read-only) ---
# Read-only window onto the Docker API for the dashboard's container stats/logs.
# POST stays off, so this proxy can never write — container control goes through the
# separate, tightly-scoped docker-control proxy below.
docker-proxy:
image: tecnativa/docker-socket-proxy:v0.4.2@sha256:1f3a6f303320723d199d2316a3e82b2e2685d86c275d5e3deeaf182573b47476
container_name: docker-proxy
# Memory ceiling (#132 — see monerod). A tiny socket proxy (~13 MiB observed).
mem_limit: 128m
memswap_limit: 128m
restart: unless-stopped
logging: *default-logging
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# Immutable root filesystem (#90): HAProxy reads its config and the (read-only) Docker socket
# and writes nothing persistent — only ephemeral scratch is needed.
read_only: true
tmpfs:
- /run
- /tmp
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- CONTAINERS=1
- LOGS=1
# Isolated from the mining bridge (#345): on its own `proxy_net`, reachable ONLY via a port
# published to the host loopback — the host-networked dashboard reaches it at 127.0.0.1:12375,
# but no mining container (monerod/tari/p2pool/xmrig-proxy) can. Inspect (`/containers/<id>/json`
# → Config.Env → secrets) is thus unreachable from a compromised mining container. The dashboard
# calls `GET /containers/<id>/logs` and, for the container-health alert (#337), inspect itself —
# so the shim contemplated in #345 would need a logs+inspect allowance, not logs-only.
ports:
- "127.0.0.1:12375:2375"
networks:
- proxy_net
# --- Docker Control Proxy (start/stop only) ---
# A second, minimal proxy used ONLY to stop/start p2pool + xmrig-proxy: node-down worker
# failover (Issue #31) and holding the miner until the nodes finish syncing (Issue #35).
# With POST=1 but every section (CONTAINERS, EXEC, IMAGES, …) left off, the v0.4.2 ruleset
# allows exactly `POST /containers/<id>/{start,stop}` and denies everything else
# (create/kill/exec/reads). Kept separate from the read-only proxy above so opening POST
# here can't widen that proxy's container access.
docker-control:
image: tecnativa/docker-socket-proxy:v0.4.2@sha256:1f3a6f303320723d199d2316a3e82b2e2685d86c275d5e3deeaf182573b47476
container_name: docker-control
# Memory ceiling (#132 — see monerod). A tiny socket proxy (~13 MiB observed).
mem_limit: 128m
memswap_limit: 128m
restart: unless-stopped
logging: *default-logging
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# Immutable root filesystem (#90): HAProxy reads its config and the (read-only) Docker socket
# and writes nothing persistent — only ephemeral scratch is needed.
read_only: true
tmpfs:
- /run
- /tmp
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- POST=1
- ALLOW_START=1
- ALLOW_STOP=1
# Isolated from the mining bridge (#345): same posture as docker-proxy. The tecnativa ruleset
# can't scope start/stop to specific containers, so keeping this proxy off `mining_net` and only
# on the host loopback is what stops a compromised mining container from start/stopping the whole
# stack. The dashboard reaches it at 127.0.0.1:12376; it only ever names p2pool/xmrig-proxy —
# plus tor for the opt-in guard self-heal restart (#424).
ports:
- "127.0.0.1:12376:2375"
networks:
- proxy_net
# --- Caddy Reverse Proxy ---
# Bridges the local 127.0.0.1 application binding to the LAN
# Serves HTTPS or HTTP depending on configuration
caddy:
image: caddy:2.11.4@sha256:cfeb0b281bc44a5a51fecde39e9e577c60d863c0b6196e6bbdf58fd00960887f
container_name: caddy
# Memory ceiling (#132 — see monerod). Reverse proxy + local TLS only (~13 MiB observed).
mem_limit: 128m
memswap_limit: 128m
restart: unless-stopped
logging: *default-logging
network_mode: "host"
# Drop all capabilities, then add back only NET_BIND_SERVICE — Caddy serves the dashboard on
# :80/:443 (privileged ports) and would otherwise fail to bind once ALL caps are dropped (#90).
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
# Immutable root filesystem (#90). Persistent state (the internal CA + issued certs) lives in
# the caddy_data volume; the only other writes are Caddy's config autosave (/config) and temp
# scratch (/tmp), which are ephemeral tmpfs. Nothing else on the rootfs can be modified.
read_only: true
tmpfs:
- /tmp
- /config
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
# JSON access log (#349): Caddy rolls it natively (4 MiB × 3 files, see generate_caddyfile);
# the dashboard mounts the same dir read-only to show recent accesses and failed logins.
- ${CADDY_LOG_DIR:-./data/caddy-logs}:/var/log/caddy
# --- Volumes ---
volumes:
caddy_data:
# --- Network Configuration ---
networks:
mining_net:
driver: bridge
name: mining_net
ipam:
config:
- subnet: ${NETWORK_SUBNET:-172.28.0.0/24}
# Socket-proxy isolation (#345): the two Docker-socket proxies live here, off the mining bridge, so
# no mining container can reach them. The dashboard (host networking) reaches them via their
# loopback-published ports, not this network. NOT `internal` — Docker doesn't set up port
# publishing on internal networks, and the loopback publish is how the dashboard reaches them. The
# proxies never dial out (pinned tecnativa image, cap_drop ALL, read-only rootfs), so proxy_net not
# being under the #270 egress firewall is not a leak surface. Docker auto-assigns the subnet.
proxy_net:
driver: bridge
name: proxy_net