CI / build (push) Successful in 40s
Tiles now show the public URL and, when set, the internal URL as a second link. Label names ignore case and - versus _. Containers HODA can't fully use are listed above the tiles with the reason: labels without enable, a missing url, or an unknown label name with a suggestion (category -> group). To find labels without enable, discovery reads all containers. A "Copy labels" button copies every label as a commented template, and falls back to the copy command where the clipboard API is missing (plain http on the LAN). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
5.5 KiB
Go
189 lines
5.5 KiB
Go
// Package server serves the dashboard page, the app list as JSON and the icons.
|
|
// An agent serves only the JSON and the icons, and only with the peer token.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"git.mariosem.es/msemes/hoda/internal/apps"
|
|
"git.mariosem.es/msemes/hoda/internal/config"
|
|
"git.mariosem.es/msemes/hoda/internal/icons"
|
|
)
|
|
|
|
//go:embed web
|
|
var webFS embed.FS
|
|
|
|
// IconSource is this host's icon cache.
|
|
type IconSource interface {
|
|
URL(id string) string
|
|
Lookup(id string) (icons.Icon, bool)
|
|
}
|
|
|
|
// PeerIcons fetches icons of apps that run on an agent's host.
|
|
type PeerIcons interface {
|
|
Icon(ctx context.Context, id string) (icons.Icon, bool)
|
|
}
|
|
|
|
// Deps are the parts the server reads from.
|
|
type Deps struct {
|
|
Store *apps.Store
|
|
Icons IconSource
|
|
Peers PeerIcons // nil when there are no agents
|
|
}
|
|
|
|
type server struct {
|
|
cfg config.Config
|
|
deps Deps
|
|
version string
|
|
log *slog.Logger
|
|
page *template.Template
|
|
}
|
|
|
|
// New returns the HTTP handler for cfg.Mode.
|
|
func New(cfg config.Config, deps Deps, version string, log *slog.Logger) http.Handler {
|
|
s := &server{
|
|
cfg: cfg,
|
|
deps: deps,
|
|
version: version,
|
|
log: log,
|
|
page: template.Must(template.ParseFS(webFS, "web/index.html")),
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /healthz", s.healthz)
|
|
if cfg.Mode == config.ModeAgent {
|
|
mux.HandleFunc("GET /{$}", s.agentIndex)
|
|
mux.Handle("GET /api/apps", s.requireToken(s.apiApps))
|
|
mux.Handle("GET /icons/{id}", s.requireToken(s.icon))
|
|
} else {
|
|
static, err := fs.Sub(webFS, "web/static")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
mux.HandleFunc("GET /{$}", s.index)
|
|
mux.HandleFunc("GET /api/apps", s.apiApps)
|
|
mux.HandleFunc("GET /icons/{id}", s.icon)
|
|
mux.Handle("GET /static/", s.cacheStatic(http.StripPrefix("/static/", http.FileServerFS(static))))
|
|
}
|
|
return securityHeaders(mux)
|
|
}
|
|
|
|
func (s *server) index(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
err := s.page.Execute(w, map[string]string{
|
|
"Title": s.cfg.Title,
|
|
"Version": s.version,
|
|
"LinkTarget": s.cfg.LinkTarget,
|
|
"LabelPrefix": s.cfg.LabelPrefix,
|
|
})
|
|
if err != nil {
|
|
s.log.Error("rendering the page failed", "err", err)
|
|
}
|
|
}
|
|
|
|
func (s *server) agentIndex(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = w.Write([]byte("HODA agent on " + s.cfg.NodeName + ". The dashboard is served by the primary.\n"))
|
|
}
|
|
|
|
func (s *server) apiApps(w http.ResponseWriter, r *http.Request) {
|
|
resp := s.deps.Store.Snapshot(s.deps.Icons.URL)
|
|
resp.Node = s.cfg.NodeName
|
|
body, err := json.Marshal(resp)
|
|
if err != nil {
|
|
http.Error(w, "encoding the app list failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sum := sha256.Sum256(body)
|
|
etag := `"` + hex.EncodeToString(sum[:8]) + `"`
|
|
w.Header().Set("ETag", etag)
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
if r.Header.Get("If-None-Match") == etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
func (s *server) icon(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if !apps.ValidID(id) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
ic, ok := s.deps.Icons.Lookup(id)
|
|
if !ok && s.deps.Peers != nil {
|
|
ic, ok = s.deps.Peers.Icon(r.Context(), id)
|
|
}
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
h := w.Header()
|
|
h.Set("Content-Type", ic.ContentType)
|
|
// Icons come from third parties: the sandbox stops an SVG from running script if opened directly.
|
|
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
|
etag := `"` + ic.Version + `"`
|
|
h.Set("ETag", etag)
|
|
if r.URL.Query().Get("v") == ic.Version {
|
|
h.Set("Cache-Control", "private, max-age=31536000, immutable")
|
|
} else {
|
|
h.Set("Cache-Control", "no-cache")
|
|
}
|
|
if r.Header.Get("If-None-Match") == etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
_, _ = w.Write(ic.Data)
|
|
}
|
|
|
|
func (s *server) healthz(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = w.Write([]byte("ok\n"))
|
|
}
|
|
|
|
func (s *server) requireToken(next http.HandlerFunc) http.Handler {
|
|
want := []byte("Bearer " + s.cfg.PeerToken)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), want) != 1 {
|
|
http.Error(w, "missing or wrong peer token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next(w, r)
|
|
})
|
|
}
|
|
|
|
// cacheStatic lets browsers keep assets for a year when the URL carries this
|
|
// build's version, which changes with every release.
|
|
func (s *server) cacheStatic(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if s.version != "dev" && r.URL.Query().Get("v") == s.version {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
} else {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|