Compare commits

...

2 Commits

Author SHA1 Message Date
kami 9190f897a3 Add a locked-down maven.<domain> block to the nginx template (#354)
The template's wildcard `listen 80` with no ACL was fixed in 50cc17f, but it
still only covered nexus/praxis/hexis. mavweb — the one service in the set
that serves an RCE surface (POST /tools defines argv internal/tool executes)
— had no block at all, so anyone wiring it up wrote their own, which is how
the wildcard got there the first time.

Adds a maven.kvmx.ru server with the same wg+LAN bind and allow/deny,
proxying 127.0.0.1:9201, with the WebSocket upgrade /ws needs, a 32m body
limit for push-to-talk PCM, and a 300s read timeout because an LLM turn on
the iGPU is slow.

Also records in deploy/ecosystem/docker-compose.yml that the sibling
`build:` paths pin nothing and ship the sibling working tree, with the
command to check what is about to be deployed. The stale public DNS records
(item 2) are outside the repo.

Verified: nginx -t on the template inside a minimal http{} accepts it.
2026-08-01 01:27:11 +04:00
kami d29e7ba813 Gate POST /api/chat on the same step-up as /tools (#317)
/api/chat reaches the router, the LLM and, through applyAction, the whole
act path, so it is the widest state-changing surface mavweb serves. It was
the only one with no gate. It now goes through stepUpOK like POST /tools,
POST /routines and POST /api/revert: unchanged in the default deploy
(WebAuthn unconfigured, fail-open behind wg+nginx), 403 under
-require-stepup or an unasserted passkey session.

The route table now carries an explicit enumeration of every state-changing
route and its gate, and the two startup SECURITY log lines name /routines
and /api/chat alongside /tools and /api/revert.

The loopback -addr default the task also asked for landed earlier in
d12de58; the compose already publishes mavweb on 127.0.0.1 only.
2026-08-01 01:24:42 +04:00
4 changed files with 164 additions and 11 deletions
+73
View File
@@ -63,6 +63,18 @@ type fakeCore struct {
// for handleTrace tests // for handleTrace tests
tickTrace ipc.TickTrace tickTrace ipc.TickTrace
traceErr error traceErr error
// for handleChatAPI tests
chatText string
chatErr error
}
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
f.chatText = text
if f.chatErr != nil {
return "", f.chatErr
}
return "поняла", nil
} }
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error { func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
@@ -1045,3 +1057,64 @@ func TestHandleRoutines_NilCore_503(t *testing.T) {
t.Fatalf("status = %d, want 503", rr.Code) t.Fatalf("status = %d, want 503", rr.Code)
} }
} }
// --- handleChatAPI step-up gate (Vikunja #317) ---
//
// POST /api/chat reaches the router, the LLM and the act path, so it carries
// the same gate as POST /tools and POST /api/revert.
func postChat(text string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader("text="+url.QueryEscape(text)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}
func TestHandleChatAPI_RequireStepUp_FailsClosed(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
handleChatAPI(rr, postChat("выключи свет"), core, nil, true)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
}
if core.chatText != "" {
t.Errorf("core.Chat called with %q, but -require-stepup should deny", core.chatText)
}
}
func TestHandleChatAPI_UnassertedSession_Denied(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
handleChatAPI(rr, postChat("выключи свет"), core, webauthn.NewPasskeySession(5*time.Minute), false)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rr.Code)
}
if core.chatText != "" {
t.Errorf("core.Chat called with %q despite an unasserted session", core.chatText)
}
}
func TestHandleChatAPI_AssertedSession_PassesGate(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
handleChatAPI(rr, postChat("привет"), core, stepUpSession(), true)
if rr.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body=%s", rr.Code, rr.Body.String())
}
if core.chatText != "привет" {
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
}
}
// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ chat keeps
// working, resting on the transport-level auth in front of mavweb.
func TestHandleChatAPI_FailOpenByDefault(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
handleChatAPI(rr, postChat("привет"), core, nil, false)
if rr.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rr.Code)
}
if core.chatText != "привет" {
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
}
}
+31 -8
View File
@@ -329,7 +329,7 @@ func main() {
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)") coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)") pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)") pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (/tools POST, /api/revert) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour") requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /api/revert, /api/chat) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)") pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)") nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)") praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
@@ -434,9 +434,9 @@ func main() {
} }
if stepUpSession == nil { if stepUpSession == nil {
if *requireStepUp { if *requireStepUp {
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv) and POST /api/revert will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
} else { } else {
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES) and POST /api/revert (voids the latest fact for a key). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.") log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
} }
} }
@@ -454,14 +454,26 @@ func main() {
handleRoutines(w, r, core, stepUpSession, *requireStepUp) handleRoutines(w, r, core, stepUpSession, *requireStepUp)
}) })
// /api/revert voids the latest fact for a key — a store mutation, so it // State-changing routes on this server, and their gate (Vikunja #317):
// sits behind the same passkey step-up as tool enable (nil session ⇒ //
// WebAuthn unconfigured ⇒ transport-level auth only, same as /tools). // POST /tools step-up — defines argv that internal/tool executes
// POST /routines step-up — accepting schedules recurring firing
// POST /api/revert step-up — voids the latest fact for a key
// POST /api/chat step-up — reaches the router, LLM and the act path
// POST /api/signal none — appends a presence fact, no argv, no act
// POST /api/ptt, /ws none — proxy audio to mavend's voice port, which
// is itself only reachable inside the deploy
//
// "step-up" means stepUpOK: asserted passkey when WebAuthn is configured,
// otherwise fail-open unless -require-stepup, which denies.
//
// GET /chat only renders the page and echoes back the q/r query params the
// POST redirect set — nothing to gate.
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
handleChatPage(w, r, core) handleChatPage(w, r, core)
}) })
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
handleChatAPI(w, r, core) handleChatAPI(w, r, core, stepUpSession, *requireStepUp)
}) })
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
handleRevert(w, r, core, stepUpSession, *requireStepUp) handleRevert(w, r, core, stepUpSession, *requireStepUp)
@@ -1258,7 +1270,14 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
} }
// handleChatAPI processes a chat message POST and redirects back to /chat. // handleChatAPI processes a chat message POST and redirects back to /chat.
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { //
// State-changing, and the widest surface on this server: the text reaches the
// router, the LLM, and through mavend's applyAction the whole action path
// including `act` — so it is gated on the same step-up as POST /tools and
// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is
// fail-open exactly like the others (see stepUpOK); with -require-stepup it
// denies, which is the point of that flag.
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed) http.Error(w, "POST only", http.StatusMethodNotAllowed)
return return
@@ -1267,6 +1286,10 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable) http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable)
return return
} }
if !stepUpOK(session, requireStepUp) {
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
return
}
text := strings.TrimSpace(r.FormValue("text")) text := strings.TrimSpace(r.FormValue("text"))
if text == "" { if text == "" {
http.Redirect(w, r, "/chat", http.StatusSeeOther) http.Redirect(w, r, "/chat", http.StatusSeeOther)
+12
View File
@@ -8,6 +8,18 @@
# #
# Maven's own compose joins this same network (add `ecosystem` as an external # Maven's own compose joins this same network (add `ecosystem` as an external
# network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly. # network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly.
#
# NO RELEASE PINNING (Vikunja #354): each `build:` below points at a sibling
# WORKING TREE, so `up --build` ships whatever is checked out there, including
# uncommitted edits. Before bringing this up, check what you are about to
# deploy:
#
# for r in nexus praxis hexis; do git -C ../../../$r status --short; \
# git -C ../../../$r log -1 --oneline; done
#
# The host nginx that fronts these is deploy/ecosystem/nginx.conf — it binds
# the wg and LAN addresses only, with allow/deny. Keep it that way: none of
# these containers has auth of its own.
name: ecosystem name: ecosystem
services: services:
+48 -3
View File
@@ -1,6 +1,7 @@
# Reverse-proxy the three sibling admin UIs. Drop into your nginx sites (or the # Reverse-proxy Maven's own web UI plus the three sibling admin UIs. Drop into
# nginx-panel app) and reload. Assumes the compose publishes each service on # your nginx sites (or the nginx-panel app) and reload. Assumes the compose
# 127.0.0.1:<port>. Add TLS (certbot / your existing cert block) per server. # publishes each service on 127.0.0.1:<port>. Add TLS (certbot / your existing
# cert block) per server.
# #
# NOTE: hexis.<domain> previously pointed at the MCP tool — repoint that # NOTE: hexis.<domain> previously pointed at the MCP tool — repoint that
# elsewhere first (the app now owns hexis.*). # elsewhere first (the app now owns hexis.*).
@@ -12,6 +13,50 @@
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) — # Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) —
# that removes the only access control these containers have. # that removes the only access control these containers have.
# maven.<domain> → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
# POST /tools, which defines argv that internal/tool EXECUTES, plus POST
# /routines, /api/revert and /api/chat (Vikunja #317). Without
# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block
# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the
# allow/deny anyway — belt and braces on an RCE surface.
#
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
# Upgrade/Connection headers below are required, not decoration. The map keeps
# `Connection: upgrade` off plain requests; it sits in the http context, which
# is where sites-available files are included — if your nginx already defines
# $connection_upgrade, drop this block.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 10.42.0.1:80;
listen 192.168.1.104:80;
server_name maven.kvmx.ru;
allow 10.42.0.0/24;
allow 192.168.1.0/24;
deny all;
# push-to-talk uploads raw PCM; the default 1m is enough for a short
# utterance but not for a long one.
client_max_body_size 32m;
location / {
proxy_pass http://127.0.0.1:9201;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU
}
}
server { server {
listen 10.42.0.1:80; listen 10.42.0.1:80;
listen 192.168.1.104:80; listen 192.168.1.104:80;