package beach import ( "context" "embed" "encoding/json" "errors" "io/fs" "net" "net/http" "regexp" "strconv" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" ) //go:embed web var webFS embed.FS const cookieName = "beach_session" var dummyHash = func() string { h, _ := bcrypt.GenerateFromPassword([]byte("beach"), bcrypt.DefaultCost) return string(h) }() type Server struct { st *Store mux *http.ServeMux smart runner // replaceable in tests mu sync.Mutex fails map[string][]time.Time // login failures per client address } func NewServer(st *Store) *Server { s := &Server{st: st, mux: http.NewServeMux(), smart: execSmartctl, fails: map[string][]time.Time{}} web, _ := fs.Sub(webFS, "web") s.mux.Handle("GET /", http.FileServerFS(web)) s.mux.HandleFunc("GET /api/session", s.session) s.mux.HandleFunc("POST /api/setup", s.setup) s.mux.HandleFunc("POST /api/login", s.login) s.mux.HandleFunc("POST /api/logout", s.logout) s.mux.HandleFunc("GET /api/overview", s.auth(s.overview)) s.mux.HandleFunc("GET /api/settings", s.auth(s.getSettings)) s.mux.HandleFunc("PUT /api/settings", s.auth(s.putSettings)) s.mux.HandleFunc("POST /api/containers/{id}/{action}", s.auth(s.containerAction)) s.mux.HandleFunc("GET /api/containers/{id}/logs", s.auth(s.containerLogs)) return s } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:") // a custom header on every write keeps cross-site forms from reaching the API if r.Method != "GET" && r.Method != "HEAD" && r.Header.Get("X-Beach") == "" { fail(w, 403, errors.New("missing X-Beach header")) return } s.mux.ServeHTTP(w, r) } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(code) json.NewEncoder(w).Encode(v) } func fail(w http.ResponseWriter, code int, err error) { writeJSON(w, code, map[string]string{"error": err.Error()}) } func (s *Server) user(r *http.Request) string { c, err := r.Cookie(cookieName) if err != nil { return "" } u, err := s.st.SessionUser(c.Value) if err != nil { return "" } return u } func (s *Server) auth(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if s.user(r) == "" { fail(w, 401, errors.New("sign in to continue")) return } fn(w, r) } } func (s *Server) session(w http.ResponseWriter, r *http.Request) { u := s.user(r) writeJSON(w, 200, map[string]any{"setup": !s.st.HasUsers(), "user": u}) } type creds struct{ Name, Password string } func (s *Server) startSession(w http.ResponseWriter, r *http.Request, name string) { tok, err := s.st.NewSession(name, 30*24*time.Hour) if err != nil { fail(w, 500, err) return } http.SetCookie(w, &http.Cookie{Name: cookieName, Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", MaxAge: 30 * 24 * 3600}) writeJSON(w, 200, map[string]any{"user": name}) } func (s *Server) setup(w http.ResponseWriter, r *http.Request) { var c creds if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&c) != nil { fail(w, 400, errors.New("bad request")) return } c.Name = strings.TrimSpace(c.Name) if c.Name == "" || len(c.Password) < 8 { fail(w, 400, errors.New("choose a name and a password of at least 8 characters")) return } // serialize so two simultaneous first-run requests cannot both win s.mu.Lock() defer s.mu.Unlock() if s.st.HasUsers() { fail(w, 409, errors.New("Beach is already set up")) return } h, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost) if err != nil { fail(w, 500, err) return } if err := s.st.AddUser(c.Name, string(h)); err != nil { fail(w, 500, err) return } s.startSession(w, r, c.Name) } func clientIP(r *http.Request) string { h, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return h } // tooManyFails allows 8 failed sign-ins per client in 5 minutes. func (s *Server) tooManyFails(ip string, record bool) bool { s.mu.Lock() defer s.mu.Unlock() now := time.Now() kept := s.fails[ip][:0] for _, t := range s.fails[ip] { if now.Sub(t) < 5*time.Minute { kept = append(kept, t) } } if record { kept = append(kept, now) } s.fails[ip] = kept return len(kept) >= 8 } func (s *Server) login(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) if s.tooManyFails(ip, false) { fail(w, 429, errors.New("too many attempts, try again in a few minutes")) return } var c creds if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&c) != nil { fail(w, 400, errors.New("bad request")) return } hash, ok := s.st.UserHash(strings.TrimSpace(c.Name)) if !ok { hash = dummyHash // spend the same time as a real check so names cannot be probed } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(c.Password)) != nil || !ok { s.tooManyFails(ip, true) fail(w, 401, errors.New("wrong name or password")) return } s.startSession(w, r, strings.TrimSpace(c.Name)) } func (s *Server) logout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(cookieName); err == nil { s.st.DeleteSession(c.Value) } http.SetCookie(w, &http.Cookie{Name: cookieName, Path: "/", MaxAge: -1}) writeJSON(w, 200, map[string]bool{"ok": true}) } // settingsView never carries secrets back to the browser. type settingsView struct { Settings TrueNASKeySet bool `json:"truenasKeySet"` HammerheadKeySet bool `json:"hammerheadKeySet"` } func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { set := s.st.Settings() v := settingsView{Settings: set, TrueNASKeySet: set.TrueNAS.APIKey != "", HammerheadKeySet: set.Hammerhead.APIKey != ""} v.TrueNAS.APIKey, v.Hammerhead.APIKey = "", "" writeJSON(w, 200, v) } func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { var in Settings if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&in) != nil { fail(w, 400, errors.New("bad request")) return } old := s.st.Settings() // a blank key box means "keep the saved key" if in.TrueNAS.APIKey == "" { in.TrueNAS.APIKey = old.TrueNAS.APIKey } if in.Hammerhead.APIKey == "" { in.Hammerhead.APIKey = old.Hammerhead.APIKey } for _, u := range []string{in.TrueNAS.URL, in.Hammerhead.URL} { if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { fail(w, 400, errors.New("URLs must start with http:// or https://")) return } } if in.RefreshSeconds < 2 || in.RefreshSeconds > 3600 { in.RefreshSeconds = 10 } if err := s.st.SaveSettings(in); err != nil { fail(w, 500, err) return } s.getSettings(w, r) } // Overview is the single view: one object, each section collected independently. type Overview struct { Time time.Time `json:"time"` Host Host `json:"host"` Docker *DockerView `json:"docker"` Storage *Storage `json:"storage"` Tunnels *Tunnels `json:"tunnels"` Refresh int `json:"refreshSeconds"` } type DockerView struct { Containers []Container `json:"containers"` Error string `json:"error,omitempty"` } func (s *Server) overview(w http.ResponseWriter, r *http.Request) { set := s.st.Settings() ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() o := Overview{Time: time.Now(), Refresh: set.RefreshSeconds} var wg sync.WaitGroup run := func(f func()) { wg.Add(1); go func() { defer wg.Done(); f() }() } run(func() { o.Host = CollectHost() }) if set.DockerHost != "" { run(func() { v := &DockerView{Containers: []Container{}} cs, err := NewDocker(set.DockerHost).List(ctx) if err != nil { v.Error = err.Error() } else { v.Containers = cs } o.Docker = v }) } if (set.TrueNAS.URL != "" && set.TrueNAS.APIKey != "") || set.Smartctl { run(func() { st := Storage{Pools: []Pool{}, Disks: []Disk{}, Alerts: []Alert{}} if set.TrueNAS.URL != "" && set.TrueNAS.APIKey != "" { st = NewTrueNAS(set.TrueNAS.URL, set.TrueNAS.APIKey, set.TrueNAS.Insecure).Collect(ctx) } if set.Smartctl { disks, err := LocalSmart(ctx, s.smart) if err != nil && st.Error == "" { st.Error = "smartctl: " + err.Error() } st.Disks = mergeDisks(st.Disks, disks) } o.Storage = &st }) } if set.Hammerhead.URL != "" && set.Hammerhead.APIKey != "" && set.Hammerhead.Org != "" { run(func() { t := FetchTunnels(ctx, set.Hammerhead.URL, set.Hammerhead.APIKey, set.Hammerhead.Org) o.Tunnels = &t }) } wg.Wait() writeJSON(w, 200, o) } // mergeDisks lets smartctl fill in what TrueNAS did not report (health, temperature) for the same device, // and adds disks only smartctl can see. func mergeDisks(tn, local []Disk) []Disk { idx := map[string]int{} for i, d := range tn { idx[d.Serial+"|"+d.Name] = i if d.Serial != "" { idx[d.Serial] = i } } for _, l := range local { i, ok := -1, false if l.Serial != "" { i, ok = lookup(idx, l.Serial) } if !ok { i, ok = lookup(idx, "|"+strings.TrimPrefix(l.Name, "/dev/")) } if !ok { tn = append(tn, l) continue } if tn[i].Health == "" { tn[i].Health = l.Health } if tn[i].TempC == 0 { tn[i].TempC = l.TempC } } return tn } func lookup(m map[string]int, k string) (int, bool) { i, ok := m[k]; return i, ok } var idRe = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`) func (s *Server) docker(w http.ResponseWriter, r *http.Request) (*Docker, string, bool) { set := s.st.Settings() id := r.PathValue("id") if set.DockerHost == "" { fail(w, 400, errors.New("Docker is not configured")) return nil, "", false } if !idRe.MatchString(id) { fail(w, 400, errors.New("bad container id")) return nil, "", false } return NewDocker(set.DockerHost), id, true } func (s *Server) containerAction(w http.ResponseWriter, r *http.Request) { d, id, ok := s.docker(w, r) if !ok { return } if err := d.Action(r.Context(), id, r.PathValue("action")); err != nil { fail(w, 502, err) return } writeJSON(w, 200, map[string]bool{"ok": true}) } func (s *Server) containerLogs(w http.ResponseWriter, r *http.Request) { d, id, ok := s.docker(w, r) if !ok { return } tail, _ := strconv.Atoi(r.URL.Query().Get("tail")) out, err := d.Logs(r.Context(), id, tail) if err != nil { fail(w, 502, err) return } writeJSON(w, 200, map[string]string{"logs": out}) }