The maven block was first in nginx.conf, and nginx serves the first block for a listen address when no server_name matches. Those two ports used to default to nexus. After the maven block landed, a request with an unknown or absent Host header reached mavweb instead, which is the one surface in the file that can define and run argv. The ACL still held, so this was not an exposure, but it is the wrong default to acquire by accident. The nexus block is now marked default_server so the choice is explicit, and the maven block moved last as a second guard. Also raises client_body_timeout and proxy_send_timeout to match client_max_body_size 32m, since a slow push-to-talk upload was cut at the 60s default on both while proxy_read_timeout was already 300s. Found in review of #52.
Maven — Docker deployment
One image, one container per daemon (docker-compose.yml). Core (mavend)
holds the encryption key and the db; the modules mount only the shared socket
dir and read-only models.
First run
# 1. generate the at-rest db key (32 bytes, base64) — keep it safe, losing it loses the db
cp deploy/db_key.env.example deploy/db_key.env
printf 'MAVEN_DB_KEY=%s\n' "$(openssl rand 32 | base64 -w0)" > deploy/db_key.env
# 2. build + start
docker compose build
docker compose up -d
# 3. logs
docker compose logs -f mavend
models/ and deps/ are bind-mounted / baked from the host — they are NOT in
git (fetched via make deps + downloaded models). The build context needs
deps/lib, deps/piper, deps/include, and deps/whisper.cpp/ggml/include
present (see .dockerignore).
Layout
| Path (in container) | What |
|---|---|
/opt/maven/bin |
the six daemons |
/opt/maven/lib |
native .so (whisper+vulkan, onnxruntime) |
/opt/maven/piper |
piper binary + espeak data |
/opt/maven/models (ro) |
bind-mount of ./models |
/run/maven (volume) |
shared IPC sockets |
/var/lib/maven (volume) |
encrypted db at rest |
/dev/shm (tmpfs) |
decrypted db working copy (RAM only) |
Reading the outside world (off by default)
mavend.json ships without a feeds block, which means no RSS/Atom feed is
fetched and no outbound request is made. Switching it on is adding the block:
"feeds": {
"poll_interval": "30m",
"max_items": 5,
"max_age": "24h",
"sources": [
{ "name": "habr", "url": "https://habr.com/ru/rss/best/daily/",
"category": "технологии", "exclude": ["реклама"] }
]
}
What it does and does not do:
- items are written as notes with source
rss:<name>, visible on/dash; - nothing is announced. She reads them back when asked — "что нового в лентах?", "что нового по технологиям?" — and never on arrival. There is no severity or channel knob here on purpose;
- the fetcher is allowlisted to the hosts of the configured feeds, plus any
allow_hosts. It refuses non-http(s) schemes and every private address (loopback, the LAN, the10.42.0.0/24wg range, cloud metadata). It caps the response at 2 MiB and redirects at 3, and makes at most one request per host per second. Seeinternal/webfetch; - how far each feed was read is stored as a config fact
rss:latest:<name>, so a restart does not re-note yesterday's headlines.
Reading a page (crawl, also off by default)
There is no crawl block either, so no page is fetched. Two halves, separately
switched:
"crawl": {
"on_demand": true,
"interval": "6h",
"max_runes": 4000,
"watches": [
{ "name": "changelog", "url": "https://example.org/changelog", "interval": "12h" }
]
}
on_demandlets her read a page he names in the utterance: "посмотри https://example.org/x — что там?". The page becomes context for his question, and only the URL leaves the box. Without a URL nothing is fetched, so this is a fallback and not a habit;watchesre-reads a fixed list on its interval and writes a note when the text changed. Like the feeds, it announces nothing;- the answer path sits last in the query chain, behind his memory, his notes and (once wired) the local Kiwix ZIMs. A local read costs nothing;
robots.txtis fetched first and obeyed with no override; aDisallowis a refusal she says out loud.Crawl-delayis honoured;- same guarded fetcher as the feeds: allowlist/denylist, no private addresses, size cap, redirect cap, timeout, one request per host per second;
- dedup state is the config fact
crawl:hash:<name>.
Not yet verified / host-dependent
This stack is correct-by-construction but has not been build-tested here (no docker in the authoring env; ~1GB context; GPU). Expect a tweak on first build on the target host, most likely in one of these:
- GPU passthrough —
mavsttdmaps/dev/drifor Vulkan. On an NVIDIA host you'd swap to the nvidia container runtime instead of/dev/dri. - onnxruntime lib path —
mavend's embedder needslibonnxruntime.so(onLD_LIBRARY_PATH=/opt/maven/lib). If the embedder wants an explicit path, set it in the config's embedder block. - cross-container voice —
mavweb -voice mavend:9100only works oncemavendbinds its voice server on0.0.0.0:9100(Voice config, currently unset). Until then, voice-over-web is inert;/tools, passkey, and the dash work fine over the core socket. - netdata —
mavpollreaches it viahost.docker.internal; adjust if netdata runs elsewhere.
Updating her (mavupdate, Vikunja #249)
Off unless configured, and there is deliberately no button for it. There is no
IPC method, no web route, no timer and no act that starts an update — the trigger
is a human running mavupdate on the host, which needs shell access, a strictly
higher bar than the step-up passkey gate that guards /tools. She cannot update
herself; she can be updated. Nothing here ever fetches code: the new version is
whatever you pulled into the working tree yourself.
Add an update block to mavend.json (mavend ignores it — only the CLI reads
it), with paths as they exist on the host, not inside a container:
"update": {
"source_dir": "/home/kami/apps/Maven",
"install_dir": "/home/kami/apps/Maven",
"snapshot_dir": "/var/lib/maven-snapshots",
"binaries": ["mavend", "mavweb", "mavsttd", "mavttsd", "mavwaked",
"mavenclient", "mavpoll", "mavcaldav", "mavmaild"],
"config_files": ["deploy/mavend.json"],
"restart_cmd": ["docker", "compose", "up", "-d", "--build"],
"health_socket": "/var/lib/docker/volumes/maven_sockets/_data/mavend.sock",
"health_timeout_sec": 120
}
snapshot_dir must be outside install_dir (a restore must not read from what
the install writes) and health_socket is required: an update that cannot check
its own result cannot roll itself back, so the config is refused without one.
Then:
mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
mavupdate -config deploy/mavend.json apply -yes # snapshot, verify, install, restart, health-check
mavupdate -config deploy/mavend.json list # what you can roll back to
mavupdate -config deploy/mavend.json rollback -yes # restore the previous artifacts and restart
apply refuses to start if she is not already answering — otherwise a failed
update and a box that was already broken are indistinguishable afterwards. On any
failure after the install it restores the snapshot, restarts, and checks again;
if that also fails it says so loudly and names the directory to copy back by hand.
The database is never snapshotted or rolled back (see the package comment in
internal/update); schema compatibility is store.Migrate's job.