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=. 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 and /api/ptt) 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). Enabling and authenticating are separate on purpose: an // expanded-empty secret cannot silently turn a live integration off. ambientEnabled := flag.Bool("ambient-enabled", false, "enable POST /api/ambient notification ingest (requires -ambient-token)") ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest") flag.Parse() if err := validateAmbientConfig(*ambientEnabled, *ambientToken); err != nil { log.Fatal(err) } 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 // turnConn — a third connection, for POST /api/chat and nothing else, for // the same reason /models has one (V-638). A chat turn routes, phrases and // may act, bounded only by phraser.timeout at 60s, and every other handler // on this server queues behind it on the shared client's one mutex. Nil ⇒ // chat shares the main connection, which is how it behaved before. var turnConn ipc.CoreAPI 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 } if tc, err := ipc.Dial(*coreSock); err != nil { log.Printf("chat: third core connection failed (%v) — /api/chat will share the main one and a turn will block the other pages", err) } else { defer tc.Close() turnConn = tc } } // 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 explicitly enabled: a dark ingest has no route at all, while an // enabled ingest with no token was rejected before the server was built. if *ambientEnabled { 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 data surfaces. Every one of them 503s without -core. /reminders also // accepts an ID-bound cancellation POST. It is deliberately not step-up // gated: like dismissing a proposed routine, it can only make Maven quieter. 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 // 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 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", func(w http.ResponseWriter, r *http.Request) { c := turnConn if c == nil { c = core } handleChatAPI(w, r, c, stepUpSession, *requireStepUp) }) 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("/api/ptt", func(w http.ResponseWriter, r *http.Request) { handlePTT(w, r, *voiceAddr, stepUpSession, *requireStepUp) }) srv := mavwebHTTPServer(*addr, 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) } } func validateAmbientConfig(enabled bool, token string) error { if enabled && token == "" { return errors.New("mavweb: ambient ingest is enabled but -ambient-token is empty") } return nil } // 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", } 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.") } } const ( mavwebReadHeaderTimeout = 10 * time.Second mavwebReadTimeout = 2 * time.Minute mavwebIdleTimeout = 2 * time.Minute mavwebMaxHeaderBytes = 32 << 10 ) func mavwebHTTPServer(addr string, handler http.Handler) *http.Server { return &http.Server{ Addr: addr, Handler: withRequestID(handler), ReadHeaderTimeout: mavwebReadHeaderTimeout, ReadTimeout: mavwebReadTimeout, IdleTimeout: mavwebIdleTimeout, MaxHeaderBytes: mavwebMaxHeaderBytes, } }