// Package web serves Megalodon's dashboard and its JSON API. package web import ( "crypto/rand" "crypto/subtle" "embed" "encoding/base64" "encoding/json" "errors" "fmt" "io/fs" "net" "net/http" "strings" "sync" "time" "git.veillax.com/Veillax/megalodon/internal/config" "git.veillax.com/Veillax/megalodon/internal/manager" ) //go:embed static var staticFiles embed.FS const ( cookieName = "megalodon_session" sessionTTL = 12 * time.Hour maxLoginFails = 6 loginFailSpan = 5 * time.Minute maxRequestBody = 1 << 20 ) type Server struct { cfg *config.Holder m *manager.Manager version string mu sync.Mutex sessions map[string]session fails map[string]failure dummyOnce sync.Once dummyHash string } type session struct { user string expires time.Time } type failure struct { n int until time.Time } // principal is who is making a request. type principal struct { Name string Role config.Role API bool // came with an API token rather than a browser session } func New(h *config.Holder, m *manager.Manager, version string) *Server { return &Server{cfg: h, m: m, version: version, sessions: map[string]session{}, fails: map[string]failure{}} } // Handler returns the whole site. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) mux.HandleFunc("POST /api/login", s.login) mux.HandleFunc("POST /api/logout", s.api(config.Viewer, s.logout)) mux.HandleFunc("GET /api/me", s.api(config.Viewer, s.me)) mux.HandleFunc("GET /api/servers", s.api(config.Viewer, s.listServers)) mux.HandleFunc("POST /api/servers", s.api(config.Admin, s.addServer)) mux.HandleFunc("PUT /api/servers/{id}", s.api(config.Admin, s.editServer)) mux.HandleFunc("DELETE /api/servers/{id}", s.api(config.Admin, s.deleteServer)) mux.HandleFunc("GET /api/servers/{id}/samples", s.api(config.Viewer, s.samples)) mux.HandleFunc("POST /api/servers/{id}/action", s.api(config.Viewer, s.serverAction)) mux.HandleFunc("POST /api/servers/{id}/macro", s.api(config.Viewer, s.runMacro)) mux.HandleFunc("POST /api/actions", s.api(config.Viewer, s.clusterAction)) mux.HandleFunc("GET /api/events", s.api(config.Viewer, s.events)) mux.HandleFunc("GET /api/stream", s.api(config.Viewer, s.stream)) mux.HandleFunc("GET /api/players", s.api(config.Viewer, s.players)) mux.HandleFunc("PUT /api/players/{id}", s.api(config.Moderator, s.annotate)) mux.HandleFunc("GET /api/bans", s.api(config.Moderator, s.bans)) mux.HandleFunc("GET /api/settings", s.api(config.Admin, s.getSettings)) mux.HandleFunc("PUT /api/settings", s.api(config.Admin, s.putSettings)) mux.HandleFunc("POST /api/jobs/{id}/run", s.api(config.Admin, s.runJob)) mux.HandleFunc("POST /api/notify/test", s.api(config.Admin, s.testNotify)) mux.HandleFunc("GET /api/users", s.api(config.Admin, s.listUsers)) mux.HandleFunc("POST /api/users", s.api(config.Admin, s.saveUser)) mux.HandleFunc("DELETE /api/users/{name}", s.api(config.Admin, s.deleteUser)) mux.HandleFunc("GET /api/tokens", s.api(config.Admin, s.listTokens)) mux.HandleFunc("POST /api/tokens", s.api(config.Admin, s.newToken)) mux.HandleFunc("DELETE /api/tokens/{name}", s.api(config.Admin, s.deleteToken)) mux.HandleFunc("GET /metrics", s.api(config.Viewer, func(w http.ResponseWriter, r *http.Request, _ principal) { w.Header().Set("Content-Type", "text/plain; version=0.0.4") fmt.Fprint(w, s.m.Metrics()) })) static, _ := fs.Sub(staticFiles, "static") mux.Handle("GET /", http.FileServerFS(static)) return secure(mux) } // secure adds headers that keep the dashboard from being framed, sniffed or fed outside scripts. func secure(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") if strings.HasPrefix(r.URL.Path, "/api/") { h.Set("Cache-Control", "no-store") } next.ServeHTTP(w, r) }) } // ---- authentication ---- type handlerFunc func(w http.ResponseWriter, r *http.Request, p principal) // api wraps a handler with sign-in, a role check and CSRF protection. func (s *Server) api(min config.Role, fn handlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p, ok := s.authenticate(r) if !ok { fail(w, http.StatusUnauthorized, "sign in required") return } // Browser sessions must send a header a web page on another site cannot add. if !p.API && r.Method != http.MethodGet && r.Header.Get("X-Megalodon") != "1" { fail(w, http.StatusForbidden, "missing X-Megalodon header") return } if !p.Role.Allows(min) { fail(w, http.StatusForbidden, "your role cannot do this") return } r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) fn(w, r, p) } } func (s *Server) authenticate(r *http.Request) (principal, bool) { if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { sum := config.HashToken(strings.TrimPrefix(h, "Bearer ")) for _, t := range s.cfg.Get().Tokens { if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(sum)) == 1 { return principal{Name: "token:" + t.Name, Role: t.Role, API: true}, true } } return principal{}, false } c, err := r.Cookie(cookieName) if err != nil { return principal{}, false } s.mu.Lock() sess, ok := s.sessions[c.Value] if ok && time.Now().After(sess.expires) { delete(s.sessions, c.Value) ok = false } s.mu.Unlock() if !ok { return principal{}, false } // Look the user up again so a deleted user or a changed role takes effect at once. for _, u := range s.cfg.Get().Users { if u.Name == sess.user { return principal{Name: u.Name, Role: u.Role}, true } } return principal{}, false } func (s *Server) clientIP(r *http.Request) string { if s.cfg.Get().TrustProxy { if f := r.Header.Get("X-Forwarded-For"); f != "" { return strings.TrimSpace(strings.Split(f, ",")[0]) } } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func (s *Server) secureCookie(r *http.Request) bool { c := s.cfg.Get() return c.CookieSecure || r.TLS != nil || (c.TrustProxy && r.Header.Get("X-Forwarded-Proto") == "https") } func (s *Server) login(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 4096) if r.Header.Get("X-Megalodon") != "1" { fail(w, http.StatusForbidden, "missing X-Megalodon header") return } var in struct{ Username, Password string } if json.NewDecoder(r.Body).Decode(&in) != nil { fail(w, http.StatusBadRequest, "bad request") return } ip := s.clientIP(r) s.mu.Lock() f := s.fails[ip] if f.n >= maxLoginFails && time.Now().Before(f.until) { s.mu.Unlock() fail(w, http.StatusTooManyRequests, "too many failed attempts; wait a few minutes") return } s.mu.Unlock() var user *config.User for _, u := range s.cfg.Get().Users { if u.Name == in.Username { u := u user = &u } } hash := "" if user != nil { hash = user.Hash } else { // Spend the same time as for a real user, so the answer does not reveal who exists. s.dummyOnce.Do(func() { s.dummyHash, _ = config.HashPassword("dummy") }) hash = s.dummyHash } ok := config.CheckPassword(hash, in.Password) && user != nil s.mu.Lock() if !ok { if time.Now().After(f.until) { f = failure{} } f.n++ f.until = time.Now().Add(loginFailSpan) s.fails[ip] = f s.mu.Unlock() s.m.Audit("", "", fmt.Sprintf("failed sign-in for %q from %s", truncate(in.Username, 40), ip)) fail(w, http.StatusUnauthorized, "wrong username or password") return } delete(s.fails, ip) b := make([]byte, 32) rand.Read(b) id := base64.RawURLEncoding.EncodeToString(b) for k, v := range s.sessions { // drop old sessions while we are here if time.Now().After(v.expires) { delete(s.sessions, k) } } s.sessions[id] = session{user: user.Name, expires: time.Now().Add(sessionTTL)} s.mu.Unlock() http.SetCookie(w, &http.Cookie{Name: cookieName, Value: id, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: s.secureCookie(r), MaxAge: int(sessionTTL.Seconds())}) s.m.Audit(user.Name, "", "signed in from "+ip) writeJSON(w, map[string]any{"name": user.Name, "role": user.Role}) } func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) { if c, err := r.Cookie(cookieName); err == nil { s.mu.Lock() delete(s.sessions, c.Value) s.mu.Unlock() } http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) writeJSON(w, map[string]bool{"ok": true}) } // ---- helpers ---- func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } // fail answers with an error. Messages are capitalized here, once, so they read as sentences. func fail(w http.ResponseWriter, code int, msg string) { if r := []rune(msg); len(r) > 0 { msg = strings.ToUpper(string(r[:1])) + string(r[1:]) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func decode(w http.ResponseWriter, r *http.Request, v any) bool { if err := json.NewDecoder(r.Body).Decode(v); err != nil { var mbe *http.MaxBytesError if errors.As(err, &mbe) { fail(w, http.StatusRequestEntityTooLarge, "request too large") } else { fail(w, http.StatusBadRequest, "bad request: "+err.Error()) } return false } return true } func truncate(s string, n int) string { if r := []rune(s); len(r) > n { return string(r[:n]) } return s } func jsonBytes(v any) ([]byte, error) { return json.Marshal(v) }