diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index 6d8f984..b10c457 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -98,7 +98,7 @@ code{font-family:monospace;font-size:12px;color:#a5b4fc} - +
@@ -111,7 +111,7 @@ code{font-family:monospace;font-size:12px;color:#a5b4fc}
diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 97c4381..cd8155d 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -1,7 +1,9 @@ package webui import ( + "crypto/rand" "embed" + "encoding/hex" "encoding/json" "fmt" "io" @@ -10,6 +12,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" @@ -32,6 +35,8 @@ var dashboardFS embed.FS var dashboardHTML string +const loginHTML = `HomeAgent Login

HomeAgent

` + func init() { data, err := dashboardFS.ReadFile("dashboard.html") if err == nil { @@ -40,21 +45,23 @@ func init() { } type Handler struct { - supervisor *supervisor.Daemon - memory *memory.GraphDB - indexer *memory.Indexer - skills *skill.Manager - lua *luaVM.VM - config *types.Config - startTime time.Time - iom *agentIO.IOManager - textMem *text.Memory - knowledge *knowledge.Store - tracker *tracker.Tracker - cfgReg *internalConfig.ConfigRegistry - pluginReg *plugin.Registry - eventBus *events.Bus + supervisor *supervisor.Daemon + memory *memory.GraphDB + indexer *memory.Indexer + skills *skill.Manager + lua *luaVM.VM + config *types.Config + startTime time.Time + iom *agentIO.IOManager + textMem *text.Memory + knowledge *knowledge.Store + tracker *tracker.Tracker + cfgReg *internalConfig.ConfigRegistry + pluginReg *plugin.Registry + eventBus *events.Bus statusProvider agentCore.StatusProvider + sessionMu sync.Mutex + sessions map[string]time.Time } func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, sp agentCore.StatusProvider) *Handler { @@ -78,36 +85,199 @@ func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, pluginReg: pr, eventBus: evBus, statusProvider: sp, + sessions: make(map[string]time.Time), + } +} + +func (h *Handler) getWebUIConfig() (apiKey, username, password string, ttl time.Duration) { + ttl = 24 * time.Hour + if h.cfgReg == nil { + return + } + ps := h.cfgReg.PluginConfig("webui") + if v, _ := ps.Get("api_key"); v != nil { + apiKey, _ = v.(string) + } + if v, _ := ps.Get("username"); v != nil { + username, _ = v.(string) + } + if v, _ := ps.Get("password"); v != nil { + password, _ = v.(string) + } + if v, _ := ps.Get("session_ttl_hours"); v != nil { + switch n := v.(type) { + case float64: + if n > 0 { ttl = time.Duration(n) * time.Hour } + case string: + if i, err := strconv.Atoi(n); err == nil && i > 0 { ttl = time.Duration(i) * time.Hour } + } + } + if username == "" { username = "admin" } + return +} + +func (h *Handler) createSession() (string, time.Time, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", time.Time{}, err + } + _, _, _, ttl := h.getWebUIConfig() + expires := time.Now().Add(ttl) + token := hex.EncodeToString(buf) + h.sessionMu.Lock() + h.sessions[token] = expires + h.sessionMu.Unlock() + return token, expires, nil +} + +func (h *Handler) validSession(r *http.Request) bool { + cookie, err := r.Cookie("homeagent_session") + if err != nil || cookie.Value == "" { + return false + } + h.sessionMu.Lock() + defer h.sessionMu.Unlock() + expires, ok := h.sessions[cookie.Value] + if !ok { + return false + } + if time.Now().After(expires) { + delete(h.sessions, cookie.Value) + return false + } + return true +} + +func (h *Handler) validAPIKey(r *http.Request) bool { + apiKey, _, _, _ := h.getWebUIConfig() + if apiKey == "" { + return false + } + got := strings.TrimSpace(r.Header.Get("X-API-Key")) + if got == "" { + auth := strings.TrimSpace(r.Header.Get("Authorization")) + if strings.HasPrefix(auth, "Bearer ") { + got = strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) + } + } + return got != "" && got == apiKey +} + +func (h *Handler) requireAPI(fn http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + apiKey, _, _, _ := h.getWebUIConfig() + if apiKey == "" && !h.validSession(r) { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "webui api_key not configured"}) + return + } + if h.validAPIKey(r) || h.validSession(r) { + fn(w, r) + return + } + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + } +} + +func (h *Handler) requireWeb(fn http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, username, password, _ := h.getWebUIConfig() + if username == "" || password == "" { + http.Error(w, "webui username/password not configured", http.StatusServiceUnavailable) + return + } + if h.validSession(r) { + fn(w, r) + return + } + http.Redirect(w, r, "/login", http.StatusFound) } } func (h *Handler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("/api/v1/status", h.handleStatus) - mux.HandleFunc("/api/v1/agents", h.handleAgents) - mux.HandleFunc("/api/v1/agents/", h.handleAgentByID) - mux.HandleFunc("/api/v1/skills", h.handleSkills) - mux.HandleFunc("/api/v1/memory", h.handleMemory) - mux.HandleFunc("/api/v1/memory/", h.handleMemory) - mux.HandleFunc("/api/v1/memory/context", h.handleMemoryContext) - mux.HandleFunc("/api/v1/memory/tools", h.handleMemoryTools) - mux.HandleFunc("/api/v1/memory/text", h.handleTextMemory) - mux.HandleFunc("/api/v1/network", h.handleNetwork) - mux.HandleFunc("/api/v1/config", h.handleConfig) - mux.HandleFunc("/api/v1/settings", h.handleSettings) - mux.HandleFunc("/api/v1/settings/", h.handleSettings) - mux.HandleFunc("/api/v1/knowledge", h.handleKnowledge) - mux.HandleFunc("/api/v1/knowledge/", h.handleKnowledge) - mux.HandleFunc("/api/v1/adapters", h.handleAdapters) - mux.HandleFunc("/api/v1/adapters/", h.handleAdapterByID) - mux.HandleFunc("/api/v1/tracker", h.handleTracker) - mux.HandleFunc("/api/v1/tracker/", h.handleTracker) - mux.HandleFunc("/api/v1/chat", h.handleChat) - mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents) - mux.HandleFunc("/api/v1/kernel", h.handleKernel) - mux.HandleFunc("/api/v1/plugins", h.handlePlugins) - mux.HandleFunc("/api/v1/plugins/", h.handlePluginByID) - mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions) - mux.HandleFunc("/", h.handleStatic) + mux.HandleFunc("/login", h.handleLoginPage) + mux.HandleFunc("/api/v1/login", h.handleLogin) + mux.HandleFunc("/api/v1/logout", h.handleLogout) + mux.HandleFunc("/api/v1/status", h.requireAPI(h.handleStatus)) + mux.HandleFunc("/api/v1/agents", h.requireAPI(h.handleAgents)) + mux.HandleFunc("/api/v1/agents/", h.requireAPI(h.handleAgentByID)) + mux.HandleFunc("/api/v1/skills", h.requireAPI(h.handleSkills)) + mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory)) + mux.HandleFunc("/api/v1/memory/", h.requireAPI(h.handleMemory)) + mux.HandleFunc("/api/v1/memory/context", h.requireAPI(h.handleMemoryContext)) + mux.HandleFunc("/api/v1/memory/tools", h.requireAPI(h.handleMemoryTools)) + mux.HandleFunc("/api/v1/memory/text", h.requireAPI(h.handleTextMemory)) + mux.HandleFunc("/api/v1/network", h.requireAPI(h.handleNetwork)) + mux.HandleFunc("/api/v1/config", h.requireAPI(h.handleConfig)) + mux.HandleFunc("/api/v1/settings", h.requireAPI(h.handleSettings)) + mux.HandleFunc("/api/v1/settings/", h.requireAPI(h.handleSettings)) + mux.HandleFunc("/api/v1/knowledge", h.requireAPI(h.handleKnowledge)) + mux.HandleFunc("/api/v1/knowledge/", h.requireAPI(h.handleKnowledge)) + mux.HandleFunc("/api/v1/adapters", h.requireAPI(h.handleAdapters)) + mux.HandleFunc("/api/v1/adapters/", h.requireAPI(h.handleAdapterByID)) + mux.HandleFunc("/api/v1/tracker", h.requireAPI(h.handleTracker)) + mux.HandleFunc("/api/v1/tracker/", h.requireAPI(h.handleTracker)) + mux.HandleFunc("/api/v1/chat", h.requireAPI(h.handleChat)) + mux.HandleFunc("/api/v1/chat/events", h.requireAPI(h.handleChatEvents)) + mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel)) + mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins)) + mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID)) + mux.HandleFunc("/v1/chat/completions", h.requireAPI(h.handleOpenAICompletions)) + mux.HandleFunc("/", h.requireWeb(h.handleStatic)) +} + +func (h *Handler) handleLoginPage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.validSession(r) { + http.Redirect(w, r, "/", http.StatusFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(loginHTML)) +} + +func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + _, username, password, _ := h.getWebUIConfig() + if username == "" || password == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "webui username/password not configured"}) + return + } + var body struct { Username string `json:"username"`; Password string `json:"password"` } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if body.Username != username || body.Password != password { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "用户名或密码错误"}) + return + } + token, expires, err := h.createSession() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + http.SetCookie(w, &http.Cookie{Name: "homeagent_session", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Expires: expires}) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if cookie, err := r.Cookie("homeagent_session"); err == nil { + h.sessionMu.Lock() + delete(h.sessions, cookie.Value) + h.sessionMu.Unlock() + http.SetCookie(w, &http.Cookie{Name: "homeagent_session", Value: "", Path: "/", Expires: time.Unix(0, 0), MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index 844aab1..1be864e 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -1,6 +1,9 @@ package webui import ( + "crypto/rand" + "encoding/hex" + "fmt" "log" "net/http" @@ -109,9 +112,45 @@ func New(name, addr string, } } +func randomSecret(n int) string { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "" + } + return hex.EncodeToString(buf) +} + +func (p *Plugin) ensureAuthBootstrap(s *sdk.PluginSDK) { + sett := s.Settings() + if sett == nil { + return + } + if v, _ := sett.Get("username"); v == nil || fmt.Sprint(v) == "" { + _ = sett.Set("username", "admin") + } + if v, _ := sett.Get("password"); v == nil || fmt.Sprint(v) == "" { + pw := randomSecret(12) + _ = sett.Set("password", pw) + log.Printf("[webui] bootstrap password generated for user admin: %s", pw) + } + if v, _ := sett.Get("api_key"); v == nil || fmt.Sprint(v) == "" { + key := randomSecret(16) + _ = sett.Set("api_key", key) + log.Printf("[webui] bootstrap api_key generated: %s", key) + } + if v, _ := sett.Get("session_ttl_hours"); v == nil || fmt.Sprint(v) == "" { + _ = sett.Set("session_ttl_hours", "24") + } +} + func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.Settings().RegisterDef(sdk.ConfigDef{Key: "api_key", Default: "", Type: "password", DisplayName: "API 密钥", Description: "访问 API 时需要的密钥", Category: "webui"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "username", Default: "admin", Type: "string", DisplayName: "登录用户名", Description: "Web 控制台登录用户名", Category: "webui"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"}) + p.ensureAuthBootstrap(s) h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider) p.handler = h h.RegisterRoutes(p.mux)