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

+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
}