a4b4733767
Trace ids are sequential integers and the label table is the one thing the routing heads will be fitted on, so an ungated POST let anyone past the transport gate mislabel turns the owner never touched. The cost argument for leaving it open does not hold: he tapped to send the turn he is correcting, so the session is already up when the buttons appear.
267 lines
12 KiB
Go
267 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"embed"
|
|
"errors"
|
|
"flag"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/webauthn"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var staticFiles embed.FS
|
|
|
|
func noCache(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
h.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func main() {
|
|
addr := flag.String("addr", "127.0.0.1:9200", "HTTP listen address (loopback by default; pass e.g. \":9200\" or a LAN IP deliberately for wider exposure — POST /chat and /routines are state-changing)")
|
|
voiceAddr := flag.String("voice", "127.0.0.1:9100", "voice server TCP addr (host:port)")
|
|
// ntfyWS: the ntfy WebSocket subscribe URL the PWA connects to for in-app
|
|
// nudge delivery, e.g. wss://ntfy.kvmx.ru/maven/ws?auth=<base64-token>. The
|
|
// client subscribes directly (lowest overhead — mavweb isn't in the path);
|
|
// we only serve it the URL so the deny-all auth token stays deployment
|
|
// config, never baked into the static JS. Empty ⇒ /api/ntfy returns 204 and
|
|
// the PWA skips subscription (voice-only, as before).
|
|
ntfyWS := flag.String("ntfy", "", "ntfy WebSocket subscribe URL served to the PWA (e.g. wss://host/topic/ws?auth=...)")
|
|
// coreSock: mavend's IPC socket. When set, /api/signal writes presence
|
|
// facts through CoreAPI (page heartbeat from the PWA, desk_active from a PC
|
|
// script). Empty ⇒ /api/signal returns 503 and presence stays unfed.
|
|
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)")
|
|
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 (POST /tools, /routines, /models, /api/revert, /api/chat, /api/ptt and GET /ws) 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)")
|
|
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)")
|
|
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
|
// Shared secret for POST /api/ambient, the notification-relay ingest that
|
|
// reads the work calendar as a signal instead of holding a work credential
|
|
// (see ambient.go). Empty ⇒ the route is not registered at all.
|
|
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)")
|
|
flag.Parse()
|
|
|
|
var core ipc.CoreAPI
|
|
// swapConn — a second connection, for /models and nothing else. A model swap
|
|
// is a multi-minute IPC call and ipc.Client serialises everything on one
|
|
// mutex, so sharing the connection would freeze every other page for the
|
|
// length of the load. See handleModels.
|
|
var swapConn modelController
|
|
if *coreSock != "" {
|
|
c, err := ipc.DialWait(*coreSock, 60*time.Second)
|
|
if err != nil {
|
|
log.Fatalf("dial core %s: %v", *coreSock, err)
|
|
}
|
|
defer c.Close()
|
|
core = c
|
|
if sc, err := ipc.Dial(*coreSock); err != nil {
|
|
log.Printf("models: second core connection failed (%v) — /models will share the main one and a swap will block the other pages", err)
|
|
} else {
|
|
defer sc.Close()
|
|
swapConn = sc
|
|
}
|
|
}
|
|
|
|
// stepUpSession stays nil unless the passkey endpoints are wired below — it
|
|
// can only ever be asserted via AssertFinish, so gating POST /tools on it
|
|
// without those endpoints would make tool enable/disable permanently 403.
|
|
// Declared here because the gated routes close over it.
|
|
var stepUpSession *webauthn.PasskeySession
|
|
|
|
// The two handler shapes on this server, so the route table below reads as
|
|
// a table rather than as twenty identical closures. Both close over `core`
|
|
// and `stepUpSession`, which is what lets the gated routes be registered
|
|
// before the passkey endpoints decide whether step-up exists at all.
|
|
corePage := func(h func(http.ResponseWriter, *http.Request, ipc.CoreAPI)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) { h(w, r, core) }
|
|
}
|
|
gatedPage := func(h func(http.ResponseWriter, *http.Request, ipc.CoreAPI, *webauthn.PasskeySession, bool)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) { h(w, r, core, stepUpSession, *requireStepUp) }
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
sub, err := fs.Sub(staticFiles, "static")
|
|
if err != nil {
|
|
log.Fatalf("static fs: %v", err)
|
|
}
|
|
staticHandler := noCache(http.FileServer(http.FS(sub)))
|
|
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
staticHandler.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
handleVoice(w, r)
|
|
}))
|
|
|
|
mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("pong"))
|
|
})
|
|
mux.HandleFunc("/api/ntfy", func(w http.ResponseWriter, r *http.Request) {
|
|
if *ntfyWS == "" {
|
|
w.WriteHeader(http.StatusNoContent) // not configured → PWA skips
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte(*ntfyWS))
|
|
})
|
|
mux.HandleFunc("/api/signal", corePage(handleSignal))
|
|
// Off unless configured: no token, no route — an unconfigured ingest is not
|
|
// a 503 waiting to be probed, it does not exist.
|
|
if *ambientToken != "" {
|
|
mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) {
|
|
handleAmbient(w, r, core, *ambientToken)
|
|
})
|
|
log.Printf("mavweb: ambient notification ingest enabled at POST /api/ambient")
|
|
}
|
|
|
|
// The read surfaces. Every one of them 503s without -core.
|
|
mux.HandleFunc("/dash", corePage(handleDash))
|
|
mux.HandleFunc("/history", corePage(handleHistory))
|
|
mux.HandleFunc("/trace", corePage(handleTrace))
|
|
mux.HandleFunc("/notifications", corePage(handleNotifications))
|
|
mux.HandleFunc("/reminders", corePage(handleReminders))
|
|
mux.HandleFunc("/morning", corePage(handleMorning))
|
|
mux.HandleFunc("/events", corePage(handleEvents))
|
|
// /tasks — capture + review. POST is not step-up gated; see handleTasks for
|
|
// why a task write is not in the same class as /tools or /routines.
|
|
mux.HandleFunc("/tasks", corePage(handleTasks))
|
|
// GET /chat only renders the page and echoes back the q/r query params the
|
|
// POST redirect set — nothing to gate.
|
|
mux.HandleFunc("/chat", corePage(handleChatPage))
|
|
|
|
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
|
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
|
handleEcosystem(w, r, ecoURLsCfg, core)
|
|
})
|
|
|
|
// ----- passkey (WebAuthn) endpoints -----
|
|
// Wired when both -core and a configured origin are present. The origin
|
|
// must match the browser's view of mavweb (e.g. https://maven.kvmx.ru).
|
|
// Passkey registration + assertion are the step-up mechanism for
|
|
// AuthStepUp actions (tool enable). Without -webauthn-origin, these
|
|
// endpoints return 503 and step-up is unavailable (FloorSession), and
|
|
// /tools falls back to the transport-level auth it sits behind
|
|
// (wg+nginx+auth), same as before step-up existed.
|
|
if *pkOrigin != "" && *pkRPID != "" && core != nil {
|
|
stepUpSession = webauthn.NewPasskeySession(5 * time.Minute)
|
|
pk, err := newPasskeyHandle(webauthn.Config{
|
|
Origin: *pkOrigin,
|
|
RPID: *pkRPID,
|
|
RPName: "maven",
|
|
}, core, *pkFile, stepUpSession)
|
|
if err != nil {
|
|
log.Fatalf("passkey store: %v", err)
|
|
}
|
|
mux.HandleFunc("/auth/passkey", pk.Page)
|
|
mux.HandleFunc("/auth/webauthn/register/begin", pk.RegisterBegin)
|
|
mux.HandleFunc("/auth/webauthn/register/finish", pk.RegisterFinish)
|
|
mux.HandleFunc("/auth/webauthn/assert/begin", pk.AssertBegin)
|
|
mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish)
|
|
} else {
|
|
logUnguardedSurfaces(*requireStepUp)
|
|
}
|
|
|
|
// State-changing routes on this server, and their gate (Vikunja #317):
|
|
//
|
|
// POST /tools step-up — defines argv that internal/tool executes
|
|
// POST /routines step-up — accepting schedules recurring firing
|
|
// POST /models step-up — replaces the model that routes and phrases
|
|
// 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/ptt step-up — audio into runTurn, so the same router,
|
|
// LLM and act path as /api/chat
|
|
// GET /ws step-up — same, streamed
|
|
// POST /api/signal none — appends a presence fact, no argv, no act
|
|
// POST /api/ambient shared secret — notification relay, constant-time
|
|
// token compare, poster is a phone service
|
|
// and not a browser, so step-up cannot apply
|
|
//
|
|
// "step-up" means stepUpOK: asserted passkey when WebAuthn is configured,
|
|
// otherwise fail-open unless -require-stepup, which denies.
|
|
//
|
|
// /api/ptt and /ws used to be ungated, justified by mavend's voice port
|
|
// being reachable only inside the deploy. That argument does not hold:
|
|
// mavweb is the thing proxying into it from outside. Speaking "выключи
|
|
// свет" is not a smaller act than typing it (Vikunja #317).
|
|
//
|
|
// The gate here is per-request, which costs the hands-free case a passkey
|
|
// assertion per turn whenever WebAuthn is configured. A session-scoped
|
|
// assertion covering a run of turns is the right shape and is its own task.
|
|
//
|
|
// /tools is the authed enable surface: maven proposes acts she can't run,
|
|
// and this page is where a human reviews and enables them. Enabling is the
|
|
// boundary-moving act (docs/design.md § Tool registration — drafting is
|
|
// suggest, enabling is act), so it lives ONLY here, never on voice or chat.
|
|
// /models is the same tier for a comparable reason: which model is loaded
|
|
// decides how every utterance is routed and how every reply is worded.
|
|
mux.HandleFunc("/tools", gatedPage(handleTools))
|
|
mux.HandleFunc("/routines", gatedPage(handleRoutines))
|
|
mux.HandleFunc("/api/chat", gatedPage(handleChatAPI))
|
|
mux.HandleFunc("/api/revert", gatedPage(handleRevert))
|
|
mux.HandleFunc("/api/correct", gatedPage(handleCorrectAPI))
|
|
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
|
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
|
|
})
|
|
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
handleWS(w, r, *voiceAddr, stepUpSession, *requireStepUp)
|
|
})
|
|
mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) {
|
|
handlePTT(w, r, *voiceAddr, stepUpSession, *requireStepUp)
|
|
})
|
|
|
|
srv := &http.Server{Addr: *addr, Handler: mux}
|
|
|
|
go func() {
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, os.Interrupt)
|
|
<-sig
|
|
log.Println("shutting down...")
|
|
srv.Close()
|
|
}()
|
|
|
|
log.Printf("mavweb listening on %s, voice → %s", *addr, *voiceAddr)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// logUnguardedSurfaces names, at startup, what step-up would have covered had
|
|
// WebAuthn been configured. One surface per line: these are read in a terminal
|
|
// at the moment someone is deciding whether the box is safe to expose.
|
|
func logUnguardedSurfaces(requireStepUp bool) {
|
|
surfaces := []string{
|
|
"POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES",
|
|
"POST /routines accepting schedules recurring firing",
|
|
"POST /models chooses the resident model that routes and words every turn",
|
|
"POST /api/revert voids the latest fact for a key",
|
|
"POST /api/chat reaches the router, the LLM and, through applyAction, the act path",
|
|
"POST /api/ptt the same, from audio",
|
|
"GET /ws the same, streamed",
|
|
}
|
|
if requireStepUp {
|
|
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):")
|
|
} else {
|
|
log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:")
|
|
}
|
|
for _, s := range surfaces {
|
|
log.Printf("SECURITY: %s", s)
|
|
}
|
|
if requireStepUp {
|
|
log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
|
} else {
|
|
log.Printf("SECURITY: they rest on the transport-level auth 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.")
|
|
}
|
|
}
|