35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
310 lines
14 KiB
Go
310 lines
14 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 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 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
|
|
// 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,
|
|
}
|
|
}
|