Initial Beach: homelab control panel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6938da4d7a
18 files changed
+2071
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 & sites</h2><div id="tunnelBody"></div></section>
|
||||
</div>
|
||||
<section id="diskSec"><h2>Disks & 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
Reference in new issue
Block a user