Same tokens, sidebar shell, components and voice as Hammerhead: teal accent, warm neutrals, status badges with text, dialogs with focus trapping that name what they affect, empty states, skeleton loading, outline icons, 44px touch targets and a slide-in drawer on phones. The strict CSP stays: no inline styles or scripts, so icons and sizes use classes. Adds docs/design.md and scripts/lint-design.mjs (run in CI), capitalizes server error messages in one place, and rewrites interface copy to the voice rules (sentence case, buttons that name the result, no dashes). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
319 lines
9.8 KiB
Go
319 lines
9.8 KiB
Go
// 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) }
|