Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9190f897a3 | |||
| d29e7ba813 |
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user