diff --git a/cmd/talkd/main.go b/cmd/talkd/main.go index 9d175c1..98aab23 100644 --- a/cmd/talkd/main.go +++ b/cmd/talkd/main.go @@ -5,7 +5,9 @@ // Default 1 Ansage / 30 s; low über Budget wird verworfen, normal parkt, // high darf mit queue.allow_high_burst verstoßen) plus Ruhezeiten // (quiet_hours/quiet_mode; now:true bricht bewusst aus beiden aus). -// CORS/MCP folgen in Task 06. +// Task 06 ergänzt die CORS-Tür für POST /speak (server.allowed_origins / +// TALK_ALLOWED_ORIGINS; leer = kein CORS) und den MCP-Endpoint POST /mcp +// (Streamable HTTP, ein speak-Tool). package main import ( @@ -120,6 +122,7 @@ func run() error { Mute: mute, Version: versionString(), KnownProviders: provider.Names(), + AllowedOrigins: cfg.Server.AllowedOrigins, Provider: func() string { if cur, ok := q.Current(); ok { return resolveProviderName(baseChain, cur) @@ -139,9 +142,13 @@ func run() error { srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second} errCh := make(chan error, 1) go func() { errCh <- srv.Serve(ln) }() - log.Printf("listening on http://%s (version %s, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, ruhezeit=%q modus %s)", + corsLog := "zu (kein CORS)" + if len(cfg.Server.AllowedOrigins) > 0 { + corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins) + } + log.Printf("listening on http://%s (version %s, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, ruhezeit=%q modus %s, cors=%s, mcp=/mcp)", addr, versionString(), sink.Name(), mute.Muted(), - cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.QuietHours, cfg.QuietMode) + cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.QuietHours, cfg.QuietMode, corsLog) sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) diff --git a/internal/api/cors.go b/internal/api/cors.go new file mode 100644 index 0000000..ecbf297 --- /dev/null +++ b/internal/api/cors.go @@ -0,0 +1,78 @@ +package api + +import ( + "net/http" +) + +// CORS-Tür für POST /speak (Task 06) — und NUR für diesen Endpunkt: Ein +// Browser (z. B. eine spätere LibreChat-Integration) darf genau die Ansage- +// Schnittstelle cross-origin rufen, keine Steuer-Endpunkte. Die Erlaubnis +// kommt ausschließlich aus der Config-Liste server.allowed_origins +// (Env TALK_ALLOWED_ORIGINS): exakte Origins wie +// "https://chat.az-gruppe.com"; der Eintrag "*" erlaubt jede Origin +// ausdrücklich. Bewusster Default: LEER — dann sendet der Daemon gar keine +// CORS-Header und der Browser blockt (Tür zu, bis sie konfiguriert wird). +// +// Ungelistete Origins bekommen bewusst trotzdem 204/202 ohne CORS-Header: +// HTTP-seitig ist die Anfrage sauber, die Blockade macht der Browser (so +// verrät der Daemon nichts über seine Erlaubnisliste). + +// CORS-Antwort-Header (Namen zentral, damit Preflight und echter POST +// dieselben setzen). +const ( + headerAllowOrigin = "Access-Control-Allow-Origin" + headerAllowMethods = "Access-Control-Allow-Methods" + headerAllowHeaders = "Access-Control-Allow-Headers" + headerMaxAge = "Access-Control-Max-Age" + headerVary = "Vary" +) + +// preflightAge ist die Preflight-Cache-Dauer: 10 Minuten reichen für eine +// Chat-Session und halten den OPTIONS-Chatter klein. +const preflightAge = "600" + +// corsACAOLiefert den Wert für Access-Control-Allow-Origin: bei Wildcard- +// Konfiguration das literale "*", sonst die exakt gematchte Origin (die +// identisch mit dem Config-Eintrag ist). Leer heißt: nicht erlaubt. +func (h *handler) corsACAO(r *http.Request) string { + origin := r.Header.Get("Origin") + if origin == "" { + return "" + } + for _, allowed := range h.allowedOrigins { + if allowed == "*" { + return "*" + } + if allowed == origin { + return allowed + } + } + return "" +} + +// setSpeakCors setzt die CORS-Header auf echten POST-/speak-Antworten, +// wenn die Anfrage-Origin erlaubt ist. Vary: Origin steht immer (auch bei +// geschlossener Tür), damit Caches die Antwort nie fälschlich origin- +// übergreifend wiederverwenden. +func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) { + w.Header().Add(headerVary, "Origin") + if acao := h.corsACAO(r); acao != "" { + w.Header().Set(headerAllowOrigin, acao) + } +} + +// speakPreflight behandelt OPTIONS /speak (CORS-Preflight): 204 mit den +// Freigabe-Headern bei gelisteter Origin, 204 ohne jegliche CORS-Header +// sonst (ungelistet, fehlend oder Tür komplett zu) — der Browser verwirft +// den eigentlichen Request, während der Daemon für Nicht-Browser-Clients +// erreichbar bleibt. +func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) { + w.Header().Add(headerVary, "Origin") + if acao := h.corsACAO(r); acao != "" { + w.Header().Set(headerAllowOrigin, acao) + w.Header().Set(headerAllowMethods, "POST, OPTIONS") + w.Header().Set(headerAllowHeaders, "Content-Type") + w.Header().Set(headerMaxAge, preflightAge) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/doc.go b/internal/api/doc.go index b06bb2e..52f44d7 100644 --- a/internal/api/doc.go +++ b/internal/api/doc.go @@ -1,8 +1,11 @@ // Package api definiert die HTTP-Oberfläche des Daemons auf 127.0.0.1: // POST /speak (enqueue, 202 {id}; bei Mute 202 {muted:true}; bei Gesetz- // Verwurf 202 {discarded:"rate_limit"|"quiet_hours"} — Task 05), -// GET /status (inkl. Rate-Limit- und Ruhezeit-State), POST /cancel, -// POST /mute, POST /unmute. CORS für /speak und der MCP-Endpoint -// (Streamable HTTP) folgen in Task 06. Der Client-Teil ist die CLI-Seite -// derselben Vertragsschnittstelle. +// GET /status (inkl. Gesetz-State, mcp/cors — Task 06), POST /cancel, +// POST /mute, POST /unmute. Task 06 ergänzt die CORS-Tür für POST /speak +// (OPTIONS-Preflight + ACAO aus server.allowed_origins, siehe cors.go) +// und den MCP-Endpoint POST /mcp als schlankes eigenes JSON-RPC-Subset +// (initialize, notifications/initialized, tools/list, tools/call mit dem +// speak-Tool — siehe mcp.go). Der Client-Teil ist die CLI-Seite derselben +// Vertragsschnittstelle. package api diff --git a/internal/api/server.go b/internal/api/server.go index 88ca369..3a92c86 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -24,7 +24,7 @@ type HandlerConfig struct { // Mute ist der persistente Mute-State (Datei mute im Config-Dir). Mute queue.MuteStore - // Version ist die Daemon-Version für GET /status. + // Version ist die Daemon-Version für GET /status und serverInfo. Version string // KnownProviders sind die gültigen Provider-Namen (Validierung des @@ -34,73 +34,106 @@ type HandlerConfig struct { // Provider löst den aktiven Provider für GET /status auf (Daemon-seitig: // Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional. Provider func() string + + // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer = + // keine CORS-Header, der Browser blockt; siehe cors.go). + AllowedOrigins []string } // NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per // ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404. func NewHandler(cfg HandlerConfig) http.Handler { h := &handler{ - queue: cfg.Queue, - mute: cfg.Mute, - version: cfg.Version, - known: make(map[string]bool, len(cfg.KnownProviders)), - provider: cfg.Provider, + queue: cfg.Queue, + mute: cfg.Mute, + version: cfg.Version, + known: make(map[string]bool, len(cfg.KnownProviders)), + provider: cfg.Provider, + allowedOrigins: cfg.AllowedOrigins, } for _, name := range cfg.KnownProviders { h.known[name] = true } mux := http.NewServeMux() mux.HandleFunc("POST /speak", h.speak) + mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS-Preflight (Task 06) mux.HandleFunc("GET /status", h.status) mux.HandleFunc("POST /cancel", h.cancel) mux.HandleFunc("POST /mute", h.muteEndpoint(true)) mux.HandleFunc("POST /unmute", h.muteEndpoint(false)) + mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06) + mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams return mux } // handler ist der interne Zustand der HTTP-Oberfläche. type handler struct { - queue *queue.Queue - mute queue.MuteStore - version string - known map[string]bool - provider func() string + queue *queue.Queue + mute queue.MuteStore + version string + known map[string]bool + provider func() string + allowedOrigins []string } // speak behandelt POST /speak: validieren, bei Mute verwerfen (202, // dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine // Sonderbehandlung brauchen), sonst enqueue → 202 {id}. Das Gesetz // (Rate-Limit-Budget, Ruhezeit) kann beim Eintritt ebenfalls verwerfen — -// die Antwort nennt dann den Grund ({"discarded":"…"}). +// die Antwort nennt dann den Grund ({"discarded":"…"}). CORS-Header nur +// hier und nur bei gelisteter Origin (siehe cors.go). func (h *handler) speak(w http.ResponseWriter, r *http.Request) { + h.setSpeakCors(w, r) + var req SpeakRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "ungültiger JSON-Body: "+err.Error()) return } - if strings.TrimSpace(req.Text) == "" { - writeError(w, http.StatusBadRequest, "text fehlt oder ist leer") + adm := h.admitSpeak(req) + if adm.errMsg != "" { + writeError(w, adm.status, adm.errMsg) return } + writeJSON(w, adm.status, adm.resp) +} + +// speakAdmission ist das Ergebnis des gemeinsamen Aufnahme-Pfads von +// POST /speak und dem MCP-Tool speak: HTTP-Status-Äquivalent plus Body — +// 202 mit {id}/{muted}/{discarded} als Aufnahme-Ergebnis, 400/503 mit +// errMsg als Fehler (MCP übersetzt beides, siehe mcp.go). +type speakAdmission struct { + status int + resp SpeakResponse + errMsg string +} + +// admitSpeak ist der eine Aufnahme-Pfad hinter POST /speak und dem +// MCP-Tool speak (Task 06): Validierung, Mute-Check, Enqueue inklusive +// Gesetz (Rate-Limit-Budget, Ruhezeit) — identische Semantik für beide +// Türen, kein Contract-Drift. +func (h *handler) admitSpeak(req SpeakRequest) speakAdmission { + if strings.TrimSpace(req.Text) == "" { + return speakAdmission{status: http.StatusBadRequest, errMsg: "text fehlt oder ist leer"} + } if req.Priority == "" { req.Priority = string(queue.PriorityNormal) } if !queue.Priority(req.Priority).Valid() { - writeError(w, http.StatusBadRequest, fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority)) - return + return speakAdmission{status: http.StatusBadRequest, + errMsg: fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority)} } if req.Provider != "" && !h.known[req.Provider] { - writeError(w, http.StatusBadRequest, fmt.Sprintf("provider: unbekannter Wert %q", req.Provider)) - return + return speakAdmission{status: http.StatusBadRequest, + errMsg: fmt.Sprintf("provider: unbekannter Wert %q", req.Provider)} } if req.Lang != "" && req.Lang != "de" && req.Lang != "en" { - writeError(w, http.StatusBadRequest, fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang)) - return + return speakAdmission{status: http.StatusBadRequest, + errMsg: fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang)} } if h.mute.Muted() { - writeJSON(w, http.StatusAccepted, SpeakResponse{Muted: true}) - return + return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{Muted: true}} } adm, err := h.queue.Enqueue(queue.Item{ @@ -112,17 +145,16 @@ func (h *handler) speak(w http.ResponseWriter, r *http.Request) { Now: req.Now, }) if err != nil { - writeError(w, http.StatusServiceUnavailable, "enqueue: "+err.Error()) - return + return speakAdmission{status: http.StatusServiceUnavailable, errMsg: "enqueue: " + err.Error()} } if adm.Discarded != "" { // Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes // Verhalten wie bei Mute — 202 mit Grund statt Fehler, damit // Aufrufer keine Sonderbehandlung brauchen. - writeJSON(w, http.StatusAccepted, SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast}) - return + return speakAdmission{status: http.StatusAccepted, + resp: SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast}} } - writeJSON(w, http.StatusAccepted, SpeakResponse{ID: adm.ID}) + return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{ID: adm.ID}} } // status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles @@ -139,6 +171,8 @@ func (h *handler) status(w http.ResponseWriter, r *http.Request) { Queue: len(pending), QueueIDs: ids, Muted: h.mute.Muted(), + MCP: true, + Cors: corsList(h.allowedOrigins), } if cur, ok := h.queue.Current(); ok { st.Current = &CurrentItem{ @@ -155,6 +189,14 @@ func (h *handler) status(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, st) } +// corsList kopiert die Erlaubnisliste als JSON-[] statt null: Der Status +// soll "cors": [] zeigen, wenn die Tür zu ist. +func corsList(origins []string) []string { + out := make([]string, len(origins)) + copy(out, origins) + return out +} + // lawState überführt die Gesetz-Sicht der Queue in die JSON-Form von // /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot // frei / Ruhezeit inaktiv). diff --git a/internal/api/types.go b/internal/api/types.go index 308632d..3752861 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -55,7 +55,8 @@ type CurrentItem struct { // Item (null im Leerlauf), Mute-State, aktiver Provider, Daemon-Version. // QueueIDs ist die Ergänzung der wartenden IDs in Abarbeitungs-Reihenfolge // — die CLI braucht sie für ihr --wait-Polling. Task 05 ergänzt additiv -// den Gesetz-State (rate_limit, quiet_hours). +// den Gesetz-State (rate_limit, quiet_hours); Task 06 additiv mcp/cors +// (Compat-Felder: MCP-Endpoint vorhanden, konfigurierte CORS-Origins). type StatusResponse struct { Version string `json:"version"` Queue int `json:"queue"` @@ -65,6 +66,14 @@ type StatusResponse struct { Provider string `json:"provider"` RateLimit *RateLimitState `json:"rate_limit,omitempty"` QuietHours *QuietHoursState `json:"quiet_hours,omitempty"` + + // MCP meldet, dass der Daemon den MCP-Endpoint anbietet (immer true; + // Feld existiert für additive Compat-Sicht von Clients/Browsern). + MCP bool `json:"mcp"` + + // Cors ist die konfigurierte CORS-Erlaubnisliste von POST /speak + // (leeres [] = Tür zu). Immer vorhanden (auch leer), nicht omitempty. + Cors []string `json:"cors"` } // RateLimitState ist der Rate-Limit-State in GET /status (Task 05).