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

+4
View File
@@ -0,0 +1,4 @@
bin/
*.db
*.db-wal
*.db-shm
+21
View File
@@ -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.
+61
View File
@@ -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).
+26
View File
@@ -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())
}
+20
View File
@@ -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
)
+52
View File
@@ -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=
+253
View File
@@ -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)
}
}
+179
View File
@@ -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&timestamps=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()
}
+111
View File
@@ -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
}
+129
View File
@@ -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
}
+53
View File
@@ -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
}
+15
View File
@@ -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
}
+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})
}
+137
View File
@@ -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)
}
}
+85
View File
@@ -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
}
+124
View File
@@ -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))
}
+187
View File
@@ -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
}
+229
View File
@@ -0,0 +1,229 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Beach</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='%23e9b44c'/%3E%3C/svg%3E">
<style>
:root{--bg:#f6f4ef;--card:#fff;--fg:#1d2327;--mut:#6b7378;--line:#e3dfd5;--ok:#1f8a4c;--warn:#b7791f;--bad:#c53030;--acc:#0e6e8c}
@media (prefers-color-scheme:dark){:root{--bg:#14181b;--card:#1d2226;--fg:#e8eaeb;--mut:#8d979c;--line:#2d3439;--ok:#4ac17c;--warn:#e0a84a;--bad:#f06a6a;--acc:#4fb3d1}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,sans-serif}
header{display:flex;align-items:center;gap:12px;padding:12px 20px;border-bottom:1px solid var(--line);background:var(--card);position:sticky;top:0;z-index:2}
header h1{font-size:18px;margin:0;flex:1}
main{max-width:1200px;margin:0 auto;padding:16px 20px 60px;display:grid;gap:16px}
section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow-x:auto}
section h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 10px;display:flex;gap:8px;align-items:center}
table{border-collapse:collapse;width:100%}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);white-space:nowrap}
th{color:var(--mut);font-weight:500;font-size:12px}
tr:last-child td{border-bottom:0}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px;background:var(--mut)}
.ok{background:var(--ok)}.warn{background:var(--warn)}.bad{background:var(--bad)}
.t-ok{color:var(--ok)}.t-warn{color:var(--warn)}.t-bad{color:var(--bad)}.mut{color:var(--mut)}
button{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--fg);border-radius:6px;padding:3px 10px;cursor:pointer}
button:hover{border-color:var(--acc)}button.primary{background:var(--acc);color:#fff;border-color:var(--acc)}
input[type=text],input[type=password],input[type=url],input[type=number]{font:inherit;width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
.stat .v{font-size:22px;font-weight:600}.stat .l{color:var(--mut);font-size:12px}
.bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;margin-top:4px}.bar i{display:block;height:100%;background:var(--acc)}
.bar i.warn{background:var(--warn)}.bar i.bad{background:var(--bad)}
.err{color:var(--bad);font-size:13px;margin:4px 0}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media(max-width:800px){.cols{grid-template-columns:1fr}}
dialog{border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--fg);width:min(900px,94vw);padding:16px}
dialog::backdrop{background:#0008}
pre{background:var(--bg);padding:10px;border-radius:6px;max-height:60vh;overflow:auto;font-size:12px;white-space:pre-wrap;word-break:break-all}
label{display:block;margin:10px 0 3px;color:var(--mut);font-size:12px}
.login{max-width:340px;margin:12vh auto;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:20px}
.hide{display:none!important}
</style>
</head>
<body>
<div id="auth" class="login hide">
<h1 id="authTitle" style="margin-top:0">Beach</h1>
<p class="mut" id="authHint"></p>
<label>Name</label><input type="text" id="aName" autocomplete="username">
<label>Password</label><input type="password" id="aPass" autocomplete="current-password">
<p class="err" id="aErr"></p>
<button class="primary" id="aGo"></button>
</div>
<div id="app" class="hide">
<header>
<h1>🏖 Beach <span class="mut" id="hostName" style="font-weight:400;font-size:13px"></span></h1>
<span class="mut" id="updated"></span>
<button id="btnSettings">Settings</button>
<button id="btnLogout">Sign out</button>
</header>
<main>
<section id="hostSec"><h2>Host</h2><div class="grid" id="hostStats"></div></section>
<section id="dockerSec"><h2>Containers <span id="dockerCount" class="mut"></span></h2><div id="dockerBody"></div></section>
<div class="cols">
<section id="poolSec"><h2>Pools</h2><div id="poolBody"></div></section>
<section id="tunnelSec"><h2>Tunnels &amp; sites</h2><div id="tunnelBody"></div></section>
</div>
<section id="diskSec"><h2>Disks &amp; SMART</h2><div id="diskBody"></div></section>
</main>
</div>
<dialog id="logDlg"><h2 id="logTitle" style="margin-top:0"></h2><pre id="logBody"></pre><button id="logClose">Close</button> <button id="logReload">Reload</button></dialog>
<dialog id="setDlg">
<h2 style="margin-top:0">Settings</h2>
<label>Docker (socket path, or tcp://host:2375 — empty turns Docker off)</label><input type="text" id="sDocker">
<div class="cols">
<div>
<h3>TrueNAS</h3>
<label>URL</label><input type="url" id="sTnUrl" placeholder="https://truenas.local">
<label>API key <span id="sTnSet" class="mut"></span></label><input type="password" id="sTnKey" autocomplete="off" placeholder="leave blank to keep">
<label><input type="checkbox" id="sTnInsecure"> Accept a self-signed certificate</label>
<label><input type="checkbox" id="sSmart"> Also read SMART from local <code>smartctl</code></label>
</div>
<div>
<h3>Hammerhead</h3>
<label>URL</label><input type="url" id="sHhUrl" placeholder="https://hammerhead.example.com">
<label>Organization ID</label><input type="text" id="sHhOrg">
<label>API key <span id="sHhSet" class="mut"></span></label><input type="password" id="sHhKey" autocomplete="off" placeholder="leave blank to keep">
</div>
</div>
<label>Refresh every (seconds)</label><input type="number" id="sRefresh" min="2" max="3600" style="width:100px">
<p class="err" id="sErr"></p>
<button class="primary" id="sSave">Save</button> <button id="sCancel">Cancel</button>
</dialog>
<script>
const $ = id => document.getElementById(id);
let timer = null, logId = null;
async function api(method, path, body) {
const r = await fetch(path, {method, headers: {'X-Beach': '1', 'Content-Type': 'application/json'}, body: body ? JSON.stringify(body) : undefined});
const j = await r.json().catch(() => ({}));
if (r.status === 401 && path !== '/api/login') { showAuth(false); throw new Error(j.error || 'signed out'); }
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
}
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function bytes(n) { if (!n) return '0 B'; const u = ['B','KB','MB','GB','TB','PB']; let i = Math.min(5, Math.floor(Math.log(n) / Math.log(1024))); return (n / 1024 ** i).toFixed(i > 1 ? 1 : 0) + ' ' + u[i]; }
function dur(s) { s = Math.floor(s); const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
const dot = c => `<span class="dot ${c}"></span>`;
function bar(p) { p = Math.max(0, Math.min(100, p)); return `<div class="bar"><i class="${p > 90 ? 'bad' : p > 75 ? 'warn' : ''}" style="width:${p}%"></i></div>`; }
function stat(l, v, p) { return `<div class="stat"><div class="l">${esc(l)}</div><div class="v">${v}</div>${p == null ? '' : bar(p)}</div>`; }
function showAuth(setup) {
$('app').classList.add('hide'); $('auth').classList.remove('hide');
clearTimeout(timer);
$('authTitle').textContent = setup ? 'Welcome to Beach' : 'Sign in';
$('authHint').textContent = setup ? 'Create the administrator account.' : '';
$('aGo').textContent = setup ? 'Create account' : 'Sign in';
$('aGo').onclick = async () => {
$('aErr').textContent = '';
try { await api('POST', setup ? '/api/setup' : '/api/login', {name: $('aName').value, password: $('aPass').value}); $('aPass').value = ''; start(); }
catch (e) { $('aErr').textContent = e.message; }
};
}
async function start() {
const s = await api('GET', '/api/session');
if (!s.user) return showAuth(s.setup);
$('auth').classList.add('hide'); $('app').classList.remove('hide');
refresh();
}
async function refresh() {
clearTimeout(timer);
let o;
try { o = await api('GET', '/api/overview'); } catch (e) { $('updated').textContent = e.message; timer = setTimeout(refresh, 15000); return; }
render(o);
$('updated').textContent = 'Updated ' + new Date(o.time).toLocaleTimeString();
timer = setTimeout(refresh, o.refreshSeconds * 1000);
}
function render(o) {
const h = o.host;
$('hostName').textContent = h.hostname || '';
if (!h.supported) $('hostStats').innerHTML = '<span class="mut">Host metrics are only collected on Linux.</span>';
else {
const memP = h.memTotal ? h.memUsed / h.memTotal * 100 : 0;
let s = stat('CPU', h.cpuPct.toFixed(0) + '%', h.cpuPct) + stat('Memory', `${bytes(h.memUsed)} / ${bytes(h.memTotal)}`, memP)
+ stat('Load', h.load.map(x => x.toFixed(2)).join(' · '), Math.min(100, h.load[0] / h.cpus * 100)) + stat('Uptime', dur(h.uptimeSeconds));
if (h.swapTotal) s += stat('Swap', `${bytes(h.swapUsed)} / ${bytes(h.swapTotal)}`, h.swapUsed / h.swapTotal * 100);
for (const f of h.filesystems) s += stat(f.mount, `${bytes(f.used)} / ${bytes(f.total)}`, f.used / f.total * 100);
$('hostStats').innerHTML = s;
}
const d = o.docker;
$('dockerSec').classList.toggle('hide', !d);
if (d) {
const run = d.containers.filter(c => c.state === 'running').length;
$('dockerCount').textContent = d.error ? '' : `${run} running / ${d.containers.length}`;
$('dockerBody').innerHTML = (d.error ? `<div class="err">${esc(d.error)}</div>` : '') + (d.containers.length ? `<table><tr><th>Name</th><th>Image</th><th>Status</th><th>Ports</th><th></th></tr>${d.containers.map(c => {
const up = c.state === 'running', bad = /unhealthy/.test(c.status) || c.state === 'dead';
return `<tr><td>${dot(bad ? 'bad' : up ? 'ok' : '')}${esc(c.name)}</td><td class="mut">${esc(c.image)}</td><td>${esc(c.status)}</td><td class="mut">${esc(c.ports.join(', '))}</td>
<td>${up ? `<button data-a="stop" data-id="${esc(c.id)}">Stop</button> <button data-a="restart" data-id="${esc(c.id)}">Restart</button>` : `<button data-a="start" data-id="${esc(c.id)}">Start</button>`} <button data-a="logs" data-id="${esc(c.id)}" data-n="${esc(c.name)}">Logs</button></td></tr>`;
}).join('')}</table>` : d.error ? '' : '<span class="mut">No containers.</span>');
}
const s = o.storage;
$('poolSec').classList.toggle('hide', !s); $('diskSec').classList.toggle('hide', !s);
if (s) {
$('poolBody').innerHTML = (s.error ? `<div class="err">${esc(s.error)}</div>` : '')
+ s.pools.map(p => `<div style="margin-bottom:10px">${dot(p.healthy ? 'ok' : p.status === 'ONLINE' ? 'warn' : 'bad')}<b>${esc(p.name)}</b> <span class="${p.healthy ? 't-ok' : 't-bad'}">${esc(p.status)}</span> <span class="mut">${bytes(p.allocated)} / ${bytes(p.size)}</span>${bar(p.usedPct)}<div class="mut">${esc(p.scan || 'never scrubbed')}</div></div>`).join('')
+ s.alerts.map(a => `<div class="t-warn">⚠ ${esc(a.level)}: ${esc(a.message)}</div>`).join('');
$('diskBody').innerHTML = s.disks.length ? `<table><tr><th>Disk</th><th>Model</th><th>Serial</th><th>Size</th><th>Pool</th><th>Temp</th><th>SMART</th></tr>${s.disks.map(k => {
const hc = k.health === 'PASSED' ? 'ok' : k.health ? 'bad' : '', tc = k.tempC >= 55 ? 'bad' : k.tempC >= 45 ? 'warn' : '';
return `<tr><td>${esc(k.name)}</td><td>${esc(k.model)}</td><td class="mut">${esc(k.serial)}</td><td>${bytes(k.size)}</td><td>${esc(k.pool || '')}</td><td class="${tc && 't-' + tc}">${k.tempC ? k.tempC + ' °C' : '–'}</td><td>${dot(hc)}${esc(k.health || 'unknown')}</td></tr>`;
}).join('')}</table>` : '<span class="mut">No disks reported.</span>';
}
const t = o.tunnels;
$('tunnelSec').classList.toggle('hide', !t);
if (t) {
$('tunnelBody').innerHTML = (t.error ? `<div class="err">${esc(t.error)}</div>` : '')
+ `<table>${t.sites.map(x => `<tr><td>${dot(x.online ? 'ok' : 'bad')}${esc(x.name)}${x.local ? ' <span class="mut">(edge-local)</span>' : ''}</td><td class="mut">${esc(x.version || '')}</td><td>${x.online ? 'online' : 'offline'}</td></tr>`).join('')}
${t.ports.map(p => `<tr><td>${dot(!p.enabled ? '' : p.up ? 'ok' : 'bad')}${esc(p.name)}</td><td class="mut">${esc(p.hostname || p.proto + ':' + p.port)}</td><td>${!p.enabled ? 'disabled' : p.up ? `up${p.targets ? ` · ${p.healthy}/${p.targets} healthy` : ''}` : 'down'}</td></tr>`).join('')}</table>`;
}
}
$('dockerBody').addEventListener('click', async e => {
const b = e.target.closest('button'); if (!b) return;
const {a, id} = b.dataset;
if (a === 'logs') return openLogs(id, b.dataset.n);
b.disabled = true;
try { await api('POST', `/api/containers/${encodeURIComponent(id)}/${a}`); } catch (err) { alert(err.message); }
refresh();
});
async function openLogs(id, name) {
logId = id; $('logTitle').textContent = name + ' — last 200 lines';
if (!$('logDlg').open) $('logDlg').showModal();
$('logBody').textContent = 'Loading…';
try { $('logBody').textContent = (await api('GET', `/api/containers/${encodeURIComponent(id)}/logs?tail=200`)).logs || '(empty)'; } catch (e) { $('logBody').textContent = e.message; }
$('logBody').scrollTop = 1e9;
}
$('logClose').onclick = () => $('logDlg').close();
$('logReload').onclick = () => openLogs(logId, $('logTitle').textContent.split(' — ')[0]);
$('btnLogout').onclick = async () => { await api('POST', '/api/logout').catch(() => {}); start(); };
$('btnSettings').onclick = async () => {
const s = await api('GET', '/api/settings');
$('sDocker').value = s.dockerHost; $('sTnUrl').value = s.truenas.url; $('sTnInsecure').checked = s.truenas.insecure; $('sSmart').checked = s.smartctl;
$('sHhUrl').value = s.hammerhead.url; $('sHhOrg').value = s.hammerhead.org; $('sRefresh').value = s.refreshSeconds;
$('sTnKey').value = $('sHhKey').value = ''; $('sTnSet').textContent = s.truenasKeySet ? '(saved)' : ''; $('sHhSet').textContent = s.hammerheadKeySet ? '(saved)' : '';
$('sErr').textContent = ''; $('setDlg').showModal();
};
$('sCancel').onclick = () => $('setDlg').close();
$('sSave').onclick = async () => {
try {
await api('PUT', '/api/settings', {dockerHost: $('sDocker').value.trim(), smartctl: $('sSmart').checked, refreshSeconds: +$('sRefresh').value,
truenas: {url: $('sTnUrl').value.trim(), apiKey: $('sTnKey').value, insecure: $('sTnInsecure').checked},
hammerhead: {url: $('sHhUrl').value.trim(), org: $('sHhOrg').value.trim(), apiKey: $('sHhKey').value}});
$('setDlg').close(); refresh();
} catch (e) { $('sErr').textContent = e.message; }
};
['aName', 'aPass'].forEach(i => $(i).addEventListener('keydown', e => { if (e.key === 'Enter') $('aGo').click(); }));
start();
</script>
</body>
</html>