Initial Beach: homelab control panel

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 16:57:53 -06:00
commit 6938da4d7a
18 files changed
+2071

No files matched your search

+385
View File
@@ -0,0 +1,385 @@
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})
}