commit 6938da4d7af7dcea1ba27e889b12852865df456f Author: Veillax <126523099+Veillax@users.noreply.github.com> Date: Tue Oct 27 16:57:53 2026 -0600 Initial Beach: homelab control panel Co-Authored-By: Claude Sonnet 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ce8b96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +*.db +*.db-wal +*.db-shm diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50e8d7b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7aed3e2 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Beach + +A one-page control panel for a homelab. One binary, one SQLite file, and everything is done in the web UI. + +| Section | Source | +|---|---| +| **Containers** | Docker Engine API: state, ports, health, start / stop / restart, last 200 log lines | +| **Pools, disks, SMART** | TrueNAS REST API (`/api/v2.0`: pools, scrub state, disks, temperatures, alerts), and/or local `smartctl` | +| **Host** | CPU, memory, swap, load, uptime and mounted filesystems from `/proc` (Linux) | +| **Tunnels & sites** | [Hammerhead](../hammerhead)'s `GET /api/orgs/{org}/state`: site agents online/offline, port mappings up/down, target health | + +A section appears only when it is configured, and one failing source never takes down the page: it shows its own error. + +It is built in the same style as Hammerhead, Blacktip, Bamboo and Sandbar, but has no dependency on Reef. + +## Quick start + +``` +go build -o bin/ ./cmd/beach +bin/beach -listen :8095 -data beach.db +``` + +Open `http://localhost:8095`, create the administrator account, then open **Settings**: + +- **Docker**: the socket path (default `/var/run/docker.sock`) or `tcp://host:2375`. Beach must be able to read the socket. +- **TrueNAS**: the URL and an API key (Credentials → API Keys). Tick "self-signed certificate" if needed. +- **smartctl**: reads SMART from the machine Beach runs on (needs `smartmontools` and permission to open the devices). Results are merged with TrueNAS by serial number. +- **Hammerhead**: the URL, your organization ID and an API key for that organization. Read access is enough. + +There are two flags only: `-listen` and `-data`. There is no config file or environment variable. + +## Running in Docker + +Mount the Docker socket (this gives the container control of Docker, which is what the start/stop buttons need) and, for host metrics, run it with the host's `/proc` visible: + +``` +docker run -d --name beach -p 127.0.0.1:8095:8095 -v beach:/data \ + -v /var/run/docker.sock:/var/run/docker.sock \ + beach -data /data/beach.db +``` + +(There is no published image yet: build one from `go build` output in a `scratch`/`distroless` image.) + +## Security notes + +- Sign-in is a name and password (bcrypt), with a 30-day session cookie (`HttpOnly`, `SameSite=Strict`, `Secure` behind HTTPS) and a lockout after 8 failed attempts per address in 5 minutes. Every write needs an `X-Beach` header. +- Anyone who can sign in can start, stop and restart every container and read its logs. Put Beach behind Hammerhead/Blacktip or bind it to `127.0.0.1`; do not expose it plainly. +- API keys for TrueNAS and Hammerhead are stored in the SQLite file as plain text (they are never sent back to the browser). Protect the file like the keys themselves. +- Beach only reads from TrueNAS and Hammerhead. + +## Development + +``` +go test ./... +``` + +The collectors parse recorded API shapes and `/proc` text, so the tests run anywhere. Host collection itself is Linux only (`host_linux.go`); other platforms report just the CPU count. + +## License + +MIT, see [LICENSE](LICENSE). diff --git a/cmd/beach/main.go b/cmd/beach/main.go new file mode 100644 index 0000000..3d778ae --- /dev/null +++ b/cmd/beach/main.go @@ -0,0 +1,26 @@ +// Beach is a one-page control panel for a homelab: containers, disks, host load and tunnels. +package main + +import ( + "flag" + "log" + "net/http" + "time" + + "beach/internal/beach" +) + +func main() { + listen := flag.String("listen", ":8095", "address to listen on") + data := flag.String("data", "beach.db", "path of the SQLite file") + flag.Parse() + + st, err := beach.OpenStore(*data) + if err != nil { + log.Fatal(err) + } + defer st.Close() + srv := &http.Server{Addr: *listen, Handler: beach.NewServer(st), ReadHeaderTimeout: 10 * time.Second} + log.Printf("Beach listening on %s (data: %s)", *listen, *data) + log.Fatal(srv.ListenAndServe()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..592b6fd --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module beach + +go 1.27.3 + +require ( + golang.org/x/crypto v0.58.0 + modernc.org/sqlite v1.61.0 +) + +require ( + github.com/dustin/go-humanize v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.1.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.49.0 // indirect + modernc.org/libc v1.79.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..612879d --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= +github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= +github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs= +github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk= +github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys= +modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4= +modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o= +modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8= +modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY= +modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/beach/collectors_test.go b/internal/beach/collectors_test.go new file mode 100644 index 0000000..f8cad95 --- /dev/null +++ b/internal/beach/collectors_test.go @@ -0,0 +1,253 @@ +package beach + +import ( + "context" + "encoding/binary" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseContainers(t *testing.T) { + body := `[ + {"Id":"b","Names":["/zeta"],"Image":"nginx","State":"exited","Status":"Exited (0) 2 days ago","Created":1}, + {"Id":"a","Names":["/alpha"],"Image":"redis","State":"running","Status":"Up 3 hours (healthy)","Created":2, + "Ports":[{"PrivatePort":6379,"PublicPort":6380,"Type":"tcp"},{"PrivatePort":6379,"PublicPort":6380,"Type":"tcp"},{"PrivatePort":9,"Type":"tcp"}]}]` + cs, err := parseContainers([]byte(body)) + if err != nil { + t.Fatal(err) + } + if len(cs) != 2 || cs[0].Name != "alpha" { + t.Fatalf("running containers should sort first: %+v", cs) + } + if len(cs[0].Ports) != 1 || cs[0].Ports[0] != "6380:6379/tcp" { + t.Fatalf("ports not deduplicated or unpublished port kept: %v", cs[0].Ports) + } + if cs[1].Ports == nil { + t.Fatal("ports must be an empty array, not null") + } +} + +func frame(stream byte, s string) []byte { + h := make([]byte, 8) + h[0] = stream + binary.BigEndian.PutUint32(h[4:], uint32(len(s))) + return append(h, s...) +} + +func TestDemux(t *testing.T) { + in := append(frame(1, "hello\n"), frame(2, "oops\n")...) + if got := demux(in); got != "hello\noops\n" { + t.Fatalf("got %q", got) + } + if got := demux([]byte("2026-01-01T00:00:00Z plain tty line\n")); !strings.Contains(got, "plain tty line") { + t.Fatalf("unframed text lost: %q", got) + } +} + +func TestDockerAPI(t *testing.T) { + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + switch { + case r.URL.Path == "/containers/json": + w.Write([]byte(`[{"Id":"abc","Names":["/web"],"State":"running","Status":"Up"}]`)) + case strings.HasSuffix(r.URL.Path, "/json"): + w.Write([]byte(`{"Config":{"Tty":false}}`)) + case strings.HasSuffix(r.URL.Path, "/logs"): + w.Write(frame(1, "line one\n")) + case strings.HasSuffix(r.URL.Path, "/stop"): + w.WriteHeader(304) + case strings.HasSuffix(r.URL.Path, "/start"): + w.WriteHeader(404) + w.Write([]byte(`{"message":"No such container: abc"}`)) + } + })) + defer srv.Close() + d := NewDocker("tcp://" + strings.TrimPrefix(srv.URL, "http://")) + ctx := context.Background() + cs, err := d.List(ctx) + if err != nil || len(cs) != 1 || cs[0].Name != "web" { + t.Fatalf("list: %v %+v", err, cs) + } + if out, err := d.Logs(ctx, "abc", 50); err != nil || out != "line one\n" { + t.Fatalf("logs: %q %v", out, err) + } + if err := d.Action(ctx, "abc", "stop"); err != nil { + t.Fatalf("304 means already stopped, not an error: %v", err) + } + if err := d.Action(ctx, "abc", "start"); err == nil || !strings.Contains(err.Error(), "No such container") { + t.Fatalf("engine message should surface: %v", err) + } + if err := d.Action(ctx, "abc", "rm"); err == nil { + t.Fatal("unknown action must be refused") + } +} + +func TestTrueNAS(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer secret" { + w.WriteHeader(401) + return + } + switch r.URL.Path { + case "/api/v2.0/pool": + w.Write([]byte(`[{"name":"tank","status":"ONLINE","healthy":true,"size":1000,"allocated":250, + "scan":{"function":"SCRUB","state":"FINISHED","errors":0}}, + {"name":"fast","status":"DEGRADED","healthy":false,"size":0,"allocated":0,"scan":{"function":"SCRUB","state":"SCANNING","percentage":41.2}}]`)) + case "/api/v2.0/disk": + w.Write([]byte(`[{"name":"sdb","model":"WD","serial":"S2","size":4000,"pool":"tank"},{"name":"sda","model":"WD","serial":"S1","size":4000,"pool":null}]`)) + case "/api/v2.0/disk/temperatures": + w.Write([]byte(`{"sda":34,"sdb":null}`)) + case "/api/v2.0/alert/list": + w.Write([]byte(`[{"level":"WARNING","formatted":"Pool degraded","dismissed":false},{"level":"INFO","formatted":"old","dismissed":true}]`)) + default: + w.WriteHeader(404) + } + })) + defer srv.Close() + + st := NewTrueNAS(srv.URL, "secret", false).Collect(context.Background()) + if st.Error != "" { + t.Fatal(st.Error) + } + if len(st.Pools) != 2 || st.Pools[0].Name != "fast" { + t.Fatalf("pools: %+v", st.Pools) + } + tank := st.Pools[1] + if tank.UsedPct != 25 || tank.Scan != "scrub finished, 0 errors" || !tank.Healthy { + t.Fatalf("tank: %+v", tank) + } + if st.Pools[0].Scan != "scrub in progress 41%" { + t.Fatalf("scan progress: %q", st.Pools[0].Scan) + } + if len(st.Disks) != 2 || st.Disks[0].Name != "sda" || st.Disks[0].TempC != 34 || st.Disks[1].TempC != 0 { + t.Fatalf("disks: %+v", st.Disks) + } + if len(st.Alerts) != 1 || st.Alerts[0].Message != "Pool degraded" { + t.Fatalf("dismissed alerts must be hidden: %+v", st.Alerts) + } + + bad := NewTrueNAS(srv.URL, "wrong", false).Collect(context.Background()) + if !strings.Contains(bad.Error, "API key rejected") { + t.Fatalf("bad key error: %q", bad.Error) + } +} + +const smartJSON = `{"device":{"name":"/dev/sda"},"model_name":"ST4000","serial_number":"Z1","user_capacity":{"bytes":4000787030016}, + "smart_status":{"passed":%s},"temperature":{"current":38}}` + +func TestParseSmartctl(t *testing.T) { + d, err := parseSmartctl([]byte(strings.Replace(smartJSON, "%s", "true", 1))) + if err != nil || d.Health != "PASSED" || d.TempC != 38 || d.Model != "ST4000" { + t.Fatalf("%+v %v", d, err) + } + d, _ = parseSmartctl([]byte(strings.Replace(smartJSON, "%s", "false", 1))) + if d.Health != "FAILED" { + t.Fatalf("failed disk reported as %q", d.Health) + } + d, _ = parseSmartctl([]byte(`{"device":{"name":"/dev/sdc"}}`)) + if d.Health != "" { + t.Fatalf("no verdict must stay unknown, got %q", d.Health) + } +} + +func TestLocalSmartAndMerge(t *testing.T) { + fake := func(ctx context.Context, args ...string) ([]byte, error) { + if args[0] == "--scan" { + return []byte(`{"devices":[{"name":"/dev/sda"},{"name":"/dev/sdz"}]}`), nil + } + if args[len(args)-1] == "/dev/sda" { + return []byte(strings.Replace(smartJSON, "%s", "true", 1)), nil + } + return []byte(`{"device":{"name":"/dev/sdz"},"serial_number":"ONLY","smart_status":{"passed":false}}`), nil + } + local, err := LocalSmart(context.Background(), fake) + if err != nil || len(local) != 2 { + t.Fatalf("%v %+v", err, local) + } + tn := []Disk{{Name: "sda", Serial: "Z1", Source: "truenas"}} + merged := mergeDisks(tn, local) + if len(merged) != 2 || merged[0].Health != "PASSED" || merged[0].TempC != 38 || merged[0].Source != "truenas" { + t.Fatalf("same disk should be merged, not duplicated: %+v", merged) + } + if merged[1].Serial != "ONLY" || merged[1].Health != "FAILED" { + t.Fatalf("smartctl-only disk missing: %+v", merged) + } +} + +func TestHostParsers(t *testing.T) { + a, err := parseCPU("cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 1 1 1 1 1 1 1 1 1\n") + if err != nil { + t.Fatal(err) + } + b, _ := parseCPU("cpu 200 0 200 750 150 0 0 0 0 0\n") + // between samples: busy +200, idle(+iowait) +100 + if got := cpuPct(a, b); got < 66 || got > 67 { + t.Fatalf("cpu %.1f", got) + } + if cpuPct(b, a) != 0 { + t.Fatal("a counter reset must not give a negative or huge value") + } + total, used, st, su := parseMeminfo("MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 200 kB\nSwapFree: 150 kB\n") + if total != 1000*1024 || used != 600*1024 || st != 200*1024 || su != 50*1024 { + t.Fatalf("%d %d %d %d", total, used, st, su) + } + if l := parseLoad("0.50 1.25 2.00 1/200 999"); l != [3]float64{0.5, 1.25, 2} { + t.Fatalf("%v", l) + } + if parseUptime("3600.55 7000.1") != 3600.55 { + t.Fatal("uptime") + } + mounts := "/dev/sda1 / ext4 rw 0 0\ntmpfs /run tmpfs rw 0 0\ntank/data /mnt/tank zfs rw 0 0\n/dev/sda1 /var/lib/docker/x ext4 rw 0 0\n/dev/sdb1 /var/lib/docker/y ext4 rw 0 0\n" + if got := realMounts(mounts); len(got) != 2 || got[0] != "/" || got[1] != "/mnt/tank" { + t.Fatalf("%v", got) + } + if h := CollectHost(); h.CPUs < 1 { + t.Fatal("CollectHost should report CPU count") + } +} + +func TestParseTunnels(t *testing.T) { + body := `{"sites":[{"id":"s1","name":"homelab","version":"v0.1.8"},{"id":"s2","name":"vps","local":true}], + "online":{"s1":true,"s2":false}, + "mappings":[{"name":"wiki","proto":"web","hostname":"wiki.example.com","port":443,"enabled":true,"statusKnown":true, + "targets":[{},{}],"status":{"targets":[{"healthy":true},{"healthy":false}]}}, + {"name":"ssh","proto":"tcp","port":22,"enabled":true,"statusKnown":true,"status":{"error":"bind: in use"}}, + {"name":"off","proto":"tcp","port":1,"enabled":false}]}` + tn, err := parseTunnels([]byte(body)) + if err != nil { + t.Fatal(err) + } + if len(tn.Sites) != 2 || tn.Sites[0].Name != "homelab" || !tn.Sites[0].Online || tn.Sites[1].Online { + t.Fatalf("sites: %+v", tn.Sites) + } + byName := map[string]TunnelPort{} + for _, p := range tn.Ports { + byName[p.Name] = p + } + if w := byName["wiki"]; !w.Up || w.Healthy != 1 || w.Targets != 2 { + t.Fatalf("wiki: %+v", w) + } + if byName["ssh"].Up || byName["off"].Up { + t.Fatal("errored or disabled ports must not be up") + } +} + +func TestFetchTunnels(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/orgs/o1/state" || r.Header.Get("Authorization") != "Bearer k" { + w.WriteHeader(401) + return + } + w.Write([]byte(`{"sites":[],"mappings":[],"online":{}}`)) + })) + defer srv.Close() + if tn := FetchTunnels(context.Background(), srv.URL+"/", "k", "o1"); tn.Error != "" { + t.Fatal(tn.Error) + } + if tn := FetchTunnels(context.Background(), srv.URL, "nope", "o1"); !strings.Contains(tn.Error, "401") { + t.Fatalf("expected 401 error, got %q", tn.Error) + } +} diff --git a/internal/beach/docker.go b/internal/beach/docker.go new file mode 100644 index 0000000..782eb8f --- /dev/null +++ b/internal/beach/docker.go @@ -0,0 +1,179 @@ +package beach + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +const defaultDockerHost = "/var/run/docker.sock" + +// Container is one row in the containers table. +type Container struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` // running, exited, ... + Status string `json:"status"` // "Up 3 hours (healthy)" + Ports []string `json:"ports"` + Created int64 `json:"created"` +} + +// Docker talks to the Engine API over a unix socket or plain TCP. +type Docker struct { + base string + client *http.Client +} + +// NewDocker accepts "/var/run/docker.sock", "unix:///path", "tcp://host:2375" or "host:2375". +func NewDocker(host string) *Docker { + host = strings.TrimSpace(host) + d := &Docker{base: "http://docker"} + tr := &http.Transport{} + switch { + case strings.HasPrefix(host, "tcp://"), strings.HasPrefix(host, "http://"): + d.base = "http://" + strings.TrimPrefix(strings.TrimPrefix(host, "tcp://"), "http://") + case strings.HasPrefix(host, "/"), strings.HasPrefix(host, "unix://"): + path := strings.TrimPrefix(host, "unix://") + tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, "unix", path) + } + default: + d.base = "http://" + host + } + d.client = &http.Client{Transport: tr, Timeout: 30 * time.Second} + return d +} + +func (d *Docker) do(ctx context.Context, method, path string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, method, d.base+path, nil) + if err != nil { + return nil, err + } + resp, err := d.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if resp.StatusCode >= 300 && resp.StatusCode != http.StatusNotModified { + var e struct{ Message string } + json.Unmarshal(body, &e) + if e.Message == "" { + e.Message = resp.Status + } + return nil, fmt.Errorf("docker: %s", e.Message) + } + return body, nil +} + +func (d *Docker) List(ctx context.Context) ([]Container, error) { + body, err := d.do(ctx, "GET", "/containers/json?all=1") + if err != nil { + return nil, err + } + return parseContainers(body) +} + +func parseContainers(body []byte) ([]Container, error) { + var raw []struct { + ID string + Names []string + Image string + State string + Status string + Created int64 + Ports []struct { + PrivatePort int + PublicPort int + Type string + } + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + out := make([]Container, 0, len(raw)) + for _, r := range raw { + c := Container{ID: r.ID, Image: r.Image, State: r.State, Status: r.Status, Created: r.Created, Ports: []string{}} + if len(r.Names) > 0 { + c.Name = strings.TrimPrefix(r.Names[0], "/") + } + seen := map[string]bool{} + for _, p := range r.Ports { + if p.PublicPort == 0 { + continue + } + s := fmt.Sprintf("%d:%d/%s", p.PublicPort, p.PrivatePort, p.Type) + if !seen[s] { // IPv4 and IPv6 bindings repeat + seen[s] = true + c.Ports = append(c.Ports, s) + } + } + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { + if (out[i].State == "running") != (out[j].State == "running") { + return out[i].State == "running" + } + return out[i].Name < out[j].Name + }) + return out, nil +} + +// Action is one of start, stop, restart. +func (d *Docker) Action(ctx context.Context, id, action string) error { + switch action { + case "start", "stop", "restart": + default: + return fmt.Errorf("unknown action %q", action) + } + _, err := d.do(ctx, "POST", "/containers/"+url.PathEscape(id)+"/"+action) + return err +} + +func (d *Docker) Logs(ctx context.Context, id string, tail int) (string, error) { + if tail < 1 || tail > 5000 { + tail = 200 + } + // the multiplexed format is only used for containers without a TTY + var info struct{ Config struct{ Tty bool } } + if b, err := d.do(ctx, "GET", "/containers/"+url.PathEscape(id)+"/json"); err == nil { + json.Unmarshal(b, &info) + } + body, err := d.do(ctx, "GET", fmt.Sprintf("/containers/%s/logs?stdout=1&stderr=1×tamps=1&tail=%d", url.PathEscape(id), tail)) + if err != nil { + return "", err + } + if info.Config.Tty { + return string(body), nil + } + return demux(body), nil +} + +// demux strips the 8-byte stream headers Docker puts in front of each log frame. +func demux(b []byte) string { + var out bytes.Buffer + for len(b) >= 8 { + if b[0] > 2 || b[1] != 0 || b[2] != 0 || b[3] != 0 { // not framed; treat as plain text + out.Write(b) + return out.String() + } + n := int(binary.BigEndian.Uint32(b[4:8])) + b = b[8:] + if n > len(b) { + n = len(b) + } + out.Write(b[:n]) + b = b[n:] + } + return out.String() +} diff --git a/internal/beach/hammerhead.go b/internal/beach/hammerhead.go new file mode 100644 index 0000000..4bb49b8 --- /dev/null +++ b/internal/beach/hammerhead.go @@ -0,0 +1,111 @@ +package beach + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +type TunnelSite struct { + Name string `json:"name"` + Online bool `json:"online"` + Local bool `json:"local"` + Version string `json:"version,omitempty"` + LastSeen time.Time `json:"lastSeen"` +} + +type TunnelPort struct { + Name string `json:"name"` + Proto string `json:"proto"` + Hostname string `json:"hostname,omitempty"` + Port int `json:"port"` + Enabled bool `json:"enabled"` + Up bool `json:"up"` // the edge reports it listening without error + Known bool `json:"known"` + Healthy int `json:"healthy"` + Targets int `json:"targets"` +} + +type Tunnels struct { + Sites []TunnelSite `json:"sites"` + Ports []TunnelPort `json:"ports"` + Error string `json:"error,omitempty"` +} + +// FetchTunnels reads GET /api/orgs/{org}/state from Hammerhead with an API key. +func FetchTunnels(ctx context.Context, base, key, org string) Tunnels { + u := strings.TrimRight(base, "/") + "/api/orgs/" + url.PathEscape(org) + "/state" + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + if err != nil { + return Tunnels{Error: err.Error()} + } + req.Header.Set("Authorization", "Bearer "+key) + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return Tunnels{Error: err.Error()} + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if resp.StatusCode != 200 { + return Tunnels{Error: fmt.Sprintf("hammerhead: %s", resp.Status)} + } + t, err := parseTunnels(b) + if err != nil { + return Tunnels{Error: err.Error()} + } + return t +} + +func parseTunnels(b []byte) (Tunnels, error) { + var st struct { + Sites []struct { + ID string `json:"id"` + Name string `json:"name"` + Local bool `json:"local"` + Version string `json:"version"` + LastSeen time.Time `json:"lastSeen"` + } `json:"sites"` + Online map[string]bool `json:"online"` + Mappings []struct { + Name string `json:"name"` + Proto string `json:"proto"` + Hostname string `json:"hostname"` + Port int `json:"port"` + Enabled bool `json:"enabled"` + Targets []any `json:"targets"` + Status struct { + Error string `json:"error"` + Targets []struct { + Healthy bool `json:"healthy"` + } `json:"targets"` + } `json:"status"` + Known bool `json:"statusKnown"` + } `json:"mappings"` + } + if err := json.Unmarshal(b, &st); err != nil { + return Tunnels{}, fmt.Errorf("hammerhead: unexpected response: %w", err) + } + t := Tunnels{Sites: []TunnelSite{}, Ports: []TunnelPort{}} + for _, s := range st.Sites { + t.Sites = append(t.Sites, TunnelSite{Name: s.Name, Online: st.Online[s.ID], Local: s.Local, Version: s.Version, LastSeen: s.LastSeen}) + } + for _, m := range st.Mappings { + p := TunnelPort{Name: m.Name, Proto: m.Proto, Hostname: m.Hostname, Port: m.Port, Enabled: m.Enabled, Known: m.Known, Targets: len(m.Targets)} + p.Up = m.Enabled && m.Known && m.Status.Error == "" + for _, th := range m.Status.Targets { + if th.Healthy { + p.Healthy++ + } + } + t.Ports = append(t.Ports, p) + } + sort.Slice(t.Sites, func(i, j int) bool { return t.Sites[i].Name < t.Sites[j].Name }) + sort.Slice(t.Ports, func(i, j int) bool { return t.Ports[i].Name < t.Ports[j].Name }) + return t, nil +} diff --git a/internal/beach/host.go b/internal/beach/host.go new file mode 100644 index 0000000..a82637a --- /dev/null +++ b/internal/beach/host.go @@ -0,0 +1,129 @@ +package beach + +import ( + "fmt" + "strconv" + "strings" +) + +// Host is the machine Beach runs on. +type Host struct { + Hostname string `json:"hostname"` + Uptime float64 `json:"uptimeSeconds"` + Load [3]float64 `json:"load"` + CPUPct float64 `json:"cpuPct"` + CPUs int `json:"cpus"` + MemTotal uint64 `json:"memTotal"` + MemUsed uint64 `json:"memUsed"` + SwapTotal uint64 `json:"swapTotal"` + SwapUsed uint64 `json:"swapUsed"` + Disks []Filesystem `json:"filesystems"` + Supported bool `json:"supported"` +} + +type Filesystem struct { + Mount string `json:"mount"` + Total uint64 `json:"total"` + Used uint64 `json:"used"` +} + +// cpuTimes is the aggregate line of /proc/stat. +type cpuTimes struct{ busy, total uint64 } + +func parseCPU(stat string) (cpuTimes, error) { + for _, line := range strings.Split(stat, "\n") { + f := strings.Fields(line) + if len(f) < 5 || f[0] != "cpu" { + continue + } + var v []uint64 + for _, s := range f[1:] { + n, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return cpuTimes{}, err + } + v = append(v, n) + } + var total uint64 + for i, n := range v { + if i < 8 { // guest times are already counted in user/nice + total += n + } + } + idle := v[3] + if len(v) > 4 { + idle += v[4] // iowait + } + return cpuTimes{busy: total - idle, total: total}, nil + } + return cpuTimes{}, fmt.Errorf("no cpu line") +} + +// cpuPct is the busy share between two samples. +func cpuPct(a, b cpuTimes) float64 { + if b.total <= a.total || b.busy < a.busy { + return 0 + } + return float64(b.busy-a.busy) / float64(b.total-a.total) * 100 +} + +func parseMeminfo(s string) (total, used, swapTotal, swapUsed uint64) { + kb := map[string]uint64{} + for _, line := range strings.Split(s, "\n") { + f := strings.Fields(line) + if len(f) >= 2 { + n, _ := strconv.ParseUint(f[1], 10, 64) + kb[strings.TrimSuffix(f[0], ":")] = n * 1024 + } + } + total = kb["MemTotal"] + avail, ok := kb["MemAvailable"] + if !ok { + avail = kb["MemFree"] + kb["Buffers"] + kb["Cached"] + } + if avail <= total { + used = total - avail + } + swapTotal = kb["SwapTotal"] + if kb["SwapFree"] <= swapTotal { + swapUsed = swapTotal - kb["SwapFree"] + } + return +} + +func parseLoad(s string) (l [3]float64) { + f := strings.Fields(s) + for i := 0; i < 3 && i < len(f); i++ { + l[i], _ = strconv.ParseFloat(f[i], 64) + } + return +} + +func parseUptime(s string) float64 { + f := strings.Fields(s) + if len(f) == 0 { + return 0 + } + v, _ := strconv.ParseFloat(f[0], 64) + return v +} + +// realMounts picks the mounts worth showing from /proc/mounts: real block or +// ZFS filesystems, once per device, without Docker's own overlay mounts. +func realMounts(mounts string) []string { + ok := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "ext3": true, "f2fs": true, "vfat": true, "ntfs": true, "ntfs3": true, "exfat": true} + seen := map[string]bool{} + var out []string + for _, line := range strings.Split(mounts, "\n") { + f := strings.Fields(line) + if len(f) < 3 || !ok[f[2]] || seen[f[0]] { + continue + } + if strings.HasPrefix(f[1], "/var/lib/docker") || strings.HasPrefix(f[1], "/proc") || strings.HasPrefix(f[1], "/sys") { + continue + } + seen[f[0]] = true + out = append(out, f[1]) + } + return out +} diff --git a/internal/beach/host_linux.go b/internal/beach/host_linux.go new file mode 100644 index 0000000..600a990 --- /dev/null +++ b/internal/beach/host_linux.go @@ -0,0 +1,53 @@ +//go:build linux + +package beach + +import ( + "os" + "runtime" + "sync" + "syscall" + "time" +) + +var ( + cpuMu sync.Mutex + lastCPU cpuTimes + lastAt time.Time + lastPct float64 +) + +func readFile(p string) string { b, _ := os.ReadFile(p); return string(b) } + +// CollectHost reads /proc. CPU use is the change since the previous call; the +// first call takes a short sample of its own. +func CollectHost() Host { + h := Host{Supported: true, CPUs: runtime.NumCPU(), Disks: []Filesystem{}} + h.Hostname, _ = os.Hostname() + h.Uptime = parseUptime(readFile("/proc/uptime")) + h.Load = parseLoad(readFile("/proc/loadavg")) + h.MemTotal, h.MemUsed, h.SwapTotal, h.SwapUsed = parseMeminfo(readFile("/proc/meminfo")) + cpuMu.Lock() + if now, err := parseCPU(readFile("/proc/stat")); err == nil { + if lastAt.IsZero() { + time.Sleep(250 * time.Millisecond) + if n2, err := parseCPU(readFile("/proc/stat")); err == nil { + lastPct, now = cpuPct(now, n2), n2 + } + lastCPU, lastAt = now, time.Now() + } else if time.Since(lastAt) > 500*time.Millisecond { + lastPct = cpuPct(lastCPU, now) + lastCPU, lastAt = now, time.Now() + } + } + h.CPUPct = lastPct + cpuMu.Unlock() + for _, m := range realMounts(readFile("/proc/mounts")) { + var st syscall.Statfs_t + if syscall.Statfs(m, &st) == nil && st.Blocks > 0 { + bs := uint64(st.Bsize) + h.Disks = append(h.Disks, Filesystem{Mount: m, Total: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs}) + } + } + return h +} diff --git a/internal/beach/host_other.go b/internal/beach/host_other.go new file mode 100644 index 0000000..739d327 --- /dev/null +++ b/internal/beach/host_other.go @@ -0,0 +1,15 @@ +//go:build !linux + +package beach + +import ( + "os" + "runtime" +) + +// CollectHost has no portable source off Linux; Beach is meant to run on the homelab box. +func CollectHost() Host { + h := Host{CPUs: runtime.NumCPU(), Disks: []Filesystem{}} + h.Hostname, _ = os.Hostname() + return h +} diff --git a/internal/beach/server.go b/internal/beach/server.go new file mode 100644 index 0000000..a1ad335 --- /dev/null +++ b/internal/beach/server.go @@ -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}) +} diff --git a/internal/beach/server_test.go b/internal/beach/server_test.go new file mode 100644 index 0000000..0096d44 --- /dev/null +++ b/internal/beach/server_test.go @@ -0,0 +1,137 @@ +package beach + +import ( + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +type client struct { + t *testing.T + c *http.Client + u string +} + +func newClient(t *testing.T) (*client, *Store) { + st, err := OpenStore(filepath.Join(t.TempDir(), "beach.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + ts := httptest.NewServer(NewServer(st)) + t.Cleanup(ts.Close) + jar, _ := cookiejar.New(nil) + return &client{t, &http.Client{Jar: jar}, ts.URL}, st +} + +func (c *client) do(method, path, body string, hdr bool) (int, map[string]any) { + req, _ := http.NewRequest(method, c.u+path, strings.NewReader(body)) + if hdr { + req.Header.Set("X-Beach", "1") + } + resp, err := c.c.Do(req) + if err != nil { + c.t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + var m map[string]any + json.Unmarshal(b, &m) + return resp.StatusCode, m +} + +func TestAuthFlow(t *testing.T) { + c, _ := newClient(t) + if code, _ := c.do("GET", "/api/overview", "", false); code != 401 { + t.Fatalf("overview without a session: %d", code) + } + if code, _ := c.do("POST", "/api/setup", `{"name":"a","password":"longenough"}`, false); code != 403 { + t.Fatalf("writes without X-Beach must be refused, got %d", code) + } + if code, _ := c.do("POST", "/api/setup", `{"name":"a","password":"short"}`, true); code != 400 { + t.Fatalf("short password: %d", code) + } + if _, m := c.do("GET", "/api/session", "", false); m["setup"] != true { + t.Fatalf("fresh install should ask for setup: %v", m) + } + if code, _ := c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true); code != 200 { + t.Fatalf("setup: %d", code) + } + if code, _ := c.do("POST", "/api/setup", `{"name":"eve","password":"longenough"}`, true); code != 409 { + t.Fatalf("second setup must be refused, got %d", code) + } + if code, m := c.do("GET", "/api/overview", "", false); code != 200 || m["host"] == nil { + t.Fatalf("overview after setup: %d %v", code, m) + } + c.do("POST", "/api/logout", "", true) + if code, _ := c.do("GET", "/api/overview", "", false); code != 401 { + t.Fatal("logout should end the session") + } + if code, _ := c.do("POST", "/api/login", `{"name":"alex","password":"wrongwrong"}`, true); code != 401 { + t.Fatalf("bad password: %d", code) + } + if code, _ := c.do("POST", "/api/login", `{"name":"alex","password":"longenough"}`, true); code != 200 { + t.Fatalf("login: %d", code) + } +} + +func TestLoginRateLimit(t *testing.T) { + c, _ := newClient(t) + c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true) + c.do("POST", "/api/logout", "", true) + var last int + for i := 0; i < 9; i++ { + last, _ = c.do("POST", "/api/login", `{"name":"alex","password":"nope-nope"}`, true) + } + if last != 429 { + t.Fatalf("expected lockout after repeated failures, got %d", last) + } + if code, _ := c.do("POST", "/api/login", `{"name":"alex","password":"longenough"}`, true); code != 429 { + t.Fatalf("lockout should hold even for the right password, got %d", code) + } +} + +func TestSettingsKeepSecrets(t *testing.T) { + c, st := newClient(t) + c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true) + body := `{"dockerHost":"","truenas":{"url":"https://nas","apiKey":"k1"},"hammerhead":{"url":"https://hh","org":"o","apiKey":"k2"},"refreshSeconds":5}` + code, m := c.do("PUT", "/api/settings", body, true) + if code != 200 || m["truenasKeySet"] != true || m["hammerheadKeySet"] != true { + t.Fatalf("%d %v", code, m) + } + if tn := m["truenas"].(map[string]any); tn["apiKey"] != "" { + t.Fatal("API key must never be sent back to the browser") + } + // saving again with blank key boxes keeps the stored keys + c.do("PUT", "/api/settings", `{"dockerHost":"","truenas":{"url":"https://nas","apiKey":""},"hammerhead":{"url":"https://hh","org":"o","apiKey":""},"refreshSeconds":5}`, true) + if s := st.Settings(); s.TrueNAS.APIKey != "k1" || s.Hammerhead.APIKey != "k2" { + t.Fatalf("keys lost: %+v", s) + } + if code, _ := c.do("PUT", "/api/settings", `{"truenas":{"url":"ftp://x"}}`, true); code != 400 { + t.Fatalf("bad URL scheme accepted: %d", code) + } +} + +func TestOverviewSectionsFollowSettings(t *testing.T) { + c, _ := newClient(t) + c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true) + c.do("PUT", "/api/settings", `{"dockerHost":"","refreshSeconds":10}`, true) + _, m := c.do("GET", "/api/overview", "", false) + if m["docker"] != nil || m["storage"] != nil || m["tunnels"] != nil { + t.Fatalf("unconfigured sections must be absent: %v", m) + } + c.do("PUT", "/api/settings", `{"dockerHost":"127.0.0.1:1","refreshSeconds":10}`, true) + _, m = c.do("GET", "/api/overview", "", false) + d, _ := m["docker"].(map[string]any) + if d == nil || d["error"] == nil { + t.Fatalf("unreachable Docker should show an error, not break the page: %v", m) + } + if code, _ := c.do("POST", "/api/containers/..%2Fx/start", "", true); code != 400 && code != 404 { + t.Fatalf("path tricks in container id: %d", code) + } +} diff --git a/internal/beach/smart.go b/internal/beach/smart.go new file mode 100644 index 0000000..d1a2f14 --- /dev/null +++ b/internal/beach/smart.go @@ -0,0 +1,85 @@ +package beach + +import ( + "context" + "encoding/json" + "os/exec" + "sort" + "time" +) + +// parseSmartctl reads `smartctl -j -H -i -A` output for one device. +func parseSmartctl(b []byte) (Disk, error) { + var r struct { + Device struct { + Name string `json:"name"` + } `json:"device"` + ModelName string `json:"model_name"` + SerialNumber string `json:"serial_number"` + UserCapacity struct { + Bytes int64 `json:"bytes"` + } `json:"user_capacity"` + SmartStatus struct { + Passed *bool `json:"passed"` + } `json:"smart_status"` + Temperature struct { + Current float64 `json:"current"` + } `json:"temperature"` + } + if err := json.Unmarshal(b, &r); err != nil { + return Disk{}, err + } + d := Disk{Name: r.Device.Name, Model: r.ModelName, Serial: r.SerialNumber, Size: r.UserCapacity.Bytes, TempC: r.Temperature.Current, Source: "smartctl"} + if r.SmartStatus.Passed != nil { + d.Health = "FAILED" + if *r.SmartStatus.Passed { + d.Health = "PASSED" + } + } + return d, nil +} + +// runner lets tests replace the smartctl binary. +type runner func(ctx context.Context, args ...string) ([]byte, error) + +func execSmartctl(ctx context.Context, args ...string) ([]byte, error) { + // smartctl uses non-zero exit bits to report problems; the JSON is still valid + out, err := exec.CommandContext(ctx, "smartctl", args...).Output() + if len(out) > 0 { + return out, nil + } + return out, err +} + +// LocalSmart scans for devices and reads each one. +func LocalSmart(ctx context.Context, run runner) ([]Disk, error) { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + b, err := run(ctx, "--scan", "-j") + if err != nil { + return nil, err + } + var scan struct { + Devices []struct { + Name string `json:"name"` + } `json:"devices"` + } + if err := json.Unmarshal(b, &scan); err != nil { + return nil, err + } + out := []Disk{} + for _, dev := range scan.Devices { + b, err := run(ctx, "-j", "-H", "-i", "-A", dev.Name) + if err != nil { + continue + } + if d, err := parseSmartctl(b); err == nil { + if d.Name == "" { + d.Name = dev.Name + } + out = append(out, d) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} diff --git a/internal/beach/store.go b/internal/beach/store.go new file mode 100644 index 0000000..47114a5 --- /dev/null +++ b/internal/beach/store.go @@ -0,0 +1,124 @@ +package beach + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "time" + + _ "modernc.org/sqlite" +) + +// Store is Beach's one SQLite file (pure Go, WAL mode). Settings are a single +// JSON document, so everything is edited in the web UI and there is no config file. +type Store struct{ db *sql.DB } + +const schema = ` +CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), body TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY, hash TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, user TEXT NOT NULL, expires INTEGER NOT NULL); +` + +// Settings is everything configurable. Empty sections are simply not collected. +type Settings struct { + DockerHost string `json:"dockerHost"` // unix socket path, or tcp://host:2375; empty disables Docker + TrueNAS struct { + URL string `json:"url"` + APIKey string `json:"apiKey"` + Insecure bool `json:"insecure"` // accept a self-signed certificate + } `json:"truenas"` + Smartctl bool `json:"smartctl"` // also read SMART from local smartctl + Hammerhead struct { + URL string `json:"url"` + APIKey string `json:"apiKey"` + Org string `json:"org"` + } `json:"hammerhead"` + RefreshSeconds int `json:"refreshSeconds"` +} + +func OpenStore(path string) (*Store, error) { + db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(schema); err != nil { + return nil, err + } + return &Store{db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) Settings() Settings { + var set Settings + var body string + if s.db.QueryRow(`SELECT body FROM settings WHERE id=1`).Scan(&body) == nil { + _ = json.Unmarshal([]byte(body), &set) + } else { + set.DockerHost = defaultDockerHost + } + if set.RefreshSeconds < 2 { + set.RefreshSeconds = 10 + } + return set +} + +func (s *Store) SaveSettings(set Settings) error { + b, _ := json.Marshal(set) + _, err := s.db.Exec(`INSERT INTO settings (id, body) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET body=excluded.body`, string(b)) + return err +} + +func (s *Store) HasUsers() bool { + var n int + s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + return n > 0 +} + +func (s *Store) AddUser(name, hash string) error { + _, err := s.db.Exec(`INSERT INTO users (name, hash) VALUES (?, ?)`, name, hash) + return err +} + +func (s *Store) UserHash(name string) (string, bool) { + var h string + err := s.db.QueryRow(`SELECT hash FROM users WHERE name=?`, name).Scan(&h) + return h, err == nil +} + +func hashToken(t string) string { + h := sha256.Sum256([]byte(t)) + return hex.EncodeToString(h[:]) +} + +// NewSession returns a random token; only its hash is stored. +func (s *Store) NewSession(user string, ttl time.Duration) (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + tok := hex.EncodeToString(b) + _, err := s.db.Exec(`INSERT INTO sessions (hash, user, expires) VALUES (?, ?, ?)`, hashToken(tok), user, time.Now().Add(ttl).Unix()) + return tok, err +} + +func (s *Store) SessionUser(tok string) (string, error) { + var u string + var exp int64 + if err := s.db.QueryRow(`SELECT user, expires FROM sessions WHERE hash=?`, hashToken(tok)).Scan(&u, &exp); err != nil { + return "", err + } + if time.Now().Unix() > exp { + s.DeleteSession(tok) + return "", errors.New("expired") + } + return u, nil +} + +func (s *Store) DeleteSession(tok string) { + s.db.Exec(`DELETE FROM sessions WHERE hash=?`, hashToken(tok)) +} diff --git a/internal/beach/truenas.go b/internal/beach/truenas.go new file mode 100644 index 0000000..66c8ac4 --- /dev/null +++ b/internal/beach/truenas.go @@ -0,0 +1,187 @@ +package beach + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// Pool is a ZFS pool as TrueNAS reports it. +type Pool struct { + Name string `json:"name"` + Status string `json:"status"` // ONLINE, DEGRADED, ... + Healthy bool `json:"healthy"` + Size int64 `json:"size"` + Allocated int64 `json:"allocated"` + UsedPct float64 `json:"usedPct"` + Scan string `json:"scan"` // "scrub finished, 0 errors" or "scrub in progress 41%" + Errors int `json:"errors"` +} + +// Disk is a physical disk with its SMART verdict. +type Disk struct { + Name string `json:"name"` + Model string `json:"model"` + Serial string `json:"serial"` + Size int64 `json:"size"` + Pool string `json:"pool,omitempty"` + TempC float64 `json:"tempC"` // 0 when unknown + Health string `json:"health"` // PASSED, FAILED or "" when unknown + Source string `json:"source"` // truenas or smartctl +} + +type Alert struct { + Level string `json:"level"` + Message string `json:"message"` +} + +type Storage struct { + Pools []Pool `json:"pools"` + Disks []Disk `json:"disks"` + Alerts []Alert `json:"alerts"` + Error string `json:"error,omitempty"` +} + +// TrueNAS reads the v2.0 REST API with an API key. +type TrueNAS struct { + base string + key string + client *http.Client +} + +func NewTrueNAS(base, key string, insecure bool) *TrueNAS { + tr := &http.Transport{} + if insecure { + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + return &TrueNAS{base: strings.TrimRight(base, "/"), key: key, client: &http.Client{Transport: tr, Timeout: 15 * time.Second}} +} + +func (t *TrueNAS) call(ctx context.Context, method, path string, body io.Reader, out any) error { + req, err := http.NewRequestWithContext(ctx, method, t.base+"/api/v2.0"+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+t.key) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := t.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return fmt.Errorf("truenas: API key rejected (%s)", resp.Status) + } + if resp.StatusCode >= 300 { + return fmt.Errorf("truenas %s: %s", path, resp.Status) + } + return json.Unmarshal(b, out) +} + +// Collect gathers pools, disks, temperatures and alerts. A failing secondary +// endpoint degrades the result instead of failing the whole panel. +func (t *TrueNAS) Collect(ctx context.Context) Storage { + st := Storage{Pools: []Pool{}, Disks: []Disk{}, Alerts: []Alert{}} + var pools []map[string]any + if err := t.call(ctx, "GET", "/pool", nil, &pools); err != nil { + st.Error = err.Error() + return st + } + st.Pools = parsePools(pools) + var disks []map[string]any + if err := t.call(ctx, "GET", "/disk", nil, &disks); err == nil { + st.Disks = parseDisks(disks) + } else { + st.Error = err.Error() + } + var temps map[string]*float64 + if err := t.call(ctx, "POST", "/disk/temperatures", strings.NewReader(`{}`), &temps); err == nil { + for i := range st.Disks { + if v := temps[st.Disks[i].Name]; v != nil { + st.Disks[i].TempC = *v + } + } + } + var alerts []map[string]any + if err := t.call(ctx, "GET", "/alert/list", nil, &alerts); err == nil { + st.Alerts = parseAlerts(alerts) + } + return st +} + +func str(m map[string]any, k string) string { + if v, ok := m[k].(string); ok { + return v + } + return "" +} + +func num(m map[string]any, k string) float64 { + switch v := m[k].(type) { + case float64: + return v + case map[string]any: // some fields come as {"parsed": n, "rawvalue": "n"} + if p, ok := v["parsed"].(float64); ok { + return p + } + } + return 0 +} + +func parsePools(in []map[string]any) []Pool { + out := []Pool{} + for _, m := range in { + p := Pool{Name: str(m, "name"), Status: str(m, "status"), Size: int64(num(m, "size")), Allocated: int64(num(m, "allocated"))} + p.Healthy, _ = m["healthy"].(bool) + if p.Size > 0 { + p.UsedPct = float64(p.Allocated) / float64(p.Size) * 100 + } + if scan, ok := m["scan"].(map[string]any); ok { + p.Errors = int(num(scan, "errors")) + fn := strings.ToLower(str(scan, "function")) + state := strings.ToLower(str(scan, "state")) + switch { + case state == "scanning": + p.Scan = fmt.Sprintf("%s in progress %.0f%%", fn, num(scan, "percentage")) + case state != "": + p.Scan = fmt.Sprintf("%s %s, %d errors", fn, state, p.Errors) + } + } + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func parseDisks(in []map[string]any) []Disk { + out := []Disk{} + for _, m := range in { + out = append(out, Disk{Name: str(m, "name"), Model: str(m, "model"), Serial: str(m, "serial"), Size: int64(num(m, "size")), Pool: str(m, "pool"), Source: "truenas"}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func parseAlerts(in []map[string]any) []Alert { + out := []Alert{} + for _, m := range in { + if dismissed, _ := m["dismissed"].(bool); dismissed { + continue + } + msg := str(m, "formatted") + if msg == "" { + msg = str(m, "klass") + } + out = append(out, Alert{Level: str(m, "level"), Message: msg}) + } + return out +} diff --git a/internal/beach/web/index.html b/internal/beach/web/index.html new file mode 100644 index 0000000..c12fb38 --- /dev/null +++ b/internal/beach/web/index.html @@ -0,0 +1,229 @@ + + + + + +Beach + + + + +
+

Beach

+

+ + +

+ +
+ +
+
+

🏖 Beach

+ + + +
+
+

Host

+

Containers

+
+

Pools

+

Tunnels & sites

+
+

Disks & SMART

+
+
+ +

 
+ + +

Settings

+ +
+
+

TrueNAS

+ + + + +
+
+

Hammerhead

+ + + +
+
+ +

+ +
+ + + +