Start HODA: a homelab dashboard built from Docker labels
CI / build (push) Successful in 30s

Apps opt in with sema.dashboard.* labels in their own compose file. The
primary reads its host's containers through a read-only socket proxy,
follows Docker events, and merges in what the agent on the second host
reports. Icons come from the icon label, the dashboard-icons set or the
app's favicon, with a letter as the fallback.

Go with only the standard library, a distroless image, and a Gitea
Actions workflow copied from FiMa's: checks in the Dockerfile's check
stage, a Trivy scan, and a push to git.mariosem.es/msemes/hoda.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TMCandClaude Opus 5.5 committed 2026-11-02 21:18:20 +01:00
commit 9d6a921c34
32 files changed
+3571

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.git
.gitea
deploy
data
*.md
.env
.env.*
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+118
View File
@@ -0,0 +1,118 @@
# HODA CI/CD on Gitea Actions, following the conventions of the other msemes/* repos (FiMa's ci.yml):
# - the `self-hosted` runner label, which drives the host Docker daemon through the mounted socket
# - actions mirrored into git.mariosem.es, so the runner never needs public GitHub
# - checks run inside Docker, so the runner needs no Go toolchain
#
# Every push and pull request: gofmt, go vet and tests (the Dockerfile's check stage), then the image
# build. Pushes to main and v* tags also scan the image with Trivy and push it to the Gitea registry.
#
# Needs the REGISTRY_TOKEN secret (personal access token with write:package), set at user level.
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
workflow_dispatch:
# Serialize main builds so two quick pushes cannot race on :latest; PR runs cancel superseded runs.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
packages: write
env:
REGISTRY: git.mariosem.es
IMAGE: msemes/hoda
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout
uses: https://git.mariosem.es/actions/checkout@v4
- name: Metadata
id: meta
run: |
sha="$(git rev-parse --short HEAD)"
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
case "${GITHUB_REF}" in
refs/tags/v*) echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" ;;
*) echo "version=${GITHUB_REF_NAME:-local}-${sha}" >> "$GITHUB_OUTPUT" ;;
esac
# gofmt, go vet and go test run in the Dockerfile's check stage; a failure fails the build.
- name: Format, vet and test
env:
DOCKER_BUILDKIT: "1"
run: docker build --target check .
- name: Build image
env:
DOCKER_BUILDKIT: "1"
run: |
tags="-t ${{ env.REGISTRY }}/${{ env.IMAGE }}:sha-${{ steps.meta.outputs.sha }}"
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
tags="${tags} -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest"
fi
case "${GITHUB_REF}" in
refs/tags/v*) tags="${tags} -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${GITHUB_REF#refs/tags/v}" ;;
esac
docker build --platform linux/amd64 \
--build-arg HODA_VERSION=${{ steps.meta.outputs.version }} \
--label org.opencontainers.image.revision=${{ github.sha }} \
${tags} .
# Same policy as the other repos: print HIGH and CRITICAL, block the push only on CRITICAL.
- name: Scan image for known CVEs
if: github.event_name == 'push'
run: |
img=${{ env.REGISTRY }}/${{ env.IMAGE }}:sha-${{ steps.meta.outputs.sha }}
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$HOME/.cache/trivy":/root/.cache/ \
aquasec/trivy:latest image \
--severity HIGH,CRITICAL --ignore-unfixed --no-progress "$img" || true
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$HOME/.cache/trivy":/root/.cache/ \
aquasec/trivy:latest image \
--exit-code 1 --severity CRITICAL --ignore-unfixed --no-progress "$img"
- name: Log in to Gitea container registry
if: github.event_name == 'push'
uses: https://git.mariosem.es/docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
# Personal access token with write:package; the injected GITEA_TOKEN has no package rights.
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Push image
if: github.event_name == 'push'
run: |
set -e
retry_push() {
for attempt in 1 2 3; do
docker push "$1" && return 0
[ "$attempt" -lt 3 ] && echo "Push of $1 failed (attempt $attempt/3), retrying in 5s" && sleep 5
done
echo "Push of $1 failed after 3 attempts"
return 1
}
base=${{ env.REGISTRY }}/${{ env.IMAGE }}
retry_push "${base}:sha-${{ steps.meta.outputs.sha }}"
if [ "${{ github.ref }}" = "refs/heads/main" ]; then retry_push "${base}:latest"; fi
case "${GITHUB_REF}" in refs/tags/v*) retry_push "${base}:${GITHUB_REF#refs/tags/v}" ;; esac
# Keep the host tidy: drop the per-commit tag; :latest stays as the layer cache anchor.
docker rmi "${base}:sha-${{ steps.meta.outputs.sha }}" >/dev/null 2>&1 || true
- name: Remove pull request image
if: github.event_name != 'push'
run: docker rmi "${{ env.REGISTRY }}/${{ env.IMAGE }}:sha-${{ steps.meta.outputs.sha }}" >/dev/null 2>&1 || true
+11
View File
@@ -0,0 +1,11 @@
# runtime data (the icon cache when running locally)
/data/
# env and secrets
.env
.env.*
!.env.example
# local builds
/hoda
/hoda.exe
+41
View File
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1.7
# HODA image, built from the repository root.
#
# docker build -t hoda . # production image (default target)
# docker build --target check . # CI: gofmt, go vet, go test
ARG GO_VERSION=1
FROM golang:${GO_VERSION}-alpine AS source
WORKDIR /src
# HODA uses only the standard library; go.sum appears once a dependency does.
COPY go.mod go.sum* ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
# CI checks: the build fails when formatting, vet or a test fails.
FROM source AS check
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
test -z "$(gofmt -l .)" || { echo "Run gofmt on:"; gofmt -l .; exit 1; } \
&& go vet ./... \
&& go test ./...
FROM source AS build
ARG HODA_VERSION=dev
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w -X main.version=${HODA_VERSION}" -o /out/hoda ./cmd/hoda \
&& mkdir /out/data
FROM gcr.io/distroless/static:nonroot AS runner
ARG HODA_VERSION=dev
COPY --from=build /out/hoda /hoda
COPY --from=build --chown=65532:65532 /out/data /data
ENV DATA_DIR=/data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/hoda", "healthcheck"]
LABEL org.opencontainers.image.title="HODA" \
org.opencontainers.image.description="Homelab dashboard built from Docker labels" \
org.opencontainers.image.source="https://git.mariosem.es/msemes/HODA" \
org.opencontainers.image.version="${HODA_VERSION}"
ENTRYPOINT ["/hoda"]
+94
View File
@@ -0,0 +1,94 @@
# HODA
HODA (HOmelab DAshboard) shows every self-hosted app as a tile with its icon, name and link. Apps opt in
with Docker labels in their own compose file, so adding an app never means editing the dashboard. It runs
at apps.mariosem.es behind Pangolin, which handles login; HODA itself has none.
One image, two roles: the **primary** serves the page, and an **agent** on each other Docker host reports
that host's apps to it. Each instance reads only its own Docker, read-only, through a socket proxy.
## Add an app
Put labels on the service in its compose file, then `docker compose up -d`:
```yaml
services:
gitea:
image: gitea/gitea
labels:
sema.dashboard.enable: "true"
sema.dashboard.name: Gitea
sema.dashboard.url: https://git.mariosem.es
sema.dashboard.group: Dev
sema.dashboard.description: Git hosting and CI
```
| Label | Required | Effect |
| --- | --- | --- |
| `sema.dashboard.enable` | Yes | `true` opts the container in |
| `sema.dashboard.url` | Yes | Link the tile opens (http or https) |
| `sema.dashboard.name` | No | Tile title; defaults to the compose service name |
| `sema.dashboard.icon` | No | A [dashboard-icons](https://github.com/homarr-labs/dashboard-icons) name (`jellyfin`) or an image URL |
| `sema.dashboard.internal_url` | No | Where HODA fetches the favicon when the public URL sits behind Pangolin's login |
| `sema.dashboard.description` | No | One line under the name |
| `sema.dashboard.group` | No | Section heading; apps without one go under "Apps", last |
| `sema.dashboard.order` | No | Position inside the group; ties sort by name |
A container with `enable` but a missing or invalid `url` stays off the dashboard and is logged once.
**Icons**, first match wins: the icon label, the dashboard-icons set by app name, then by image name
(generic images such as `nginx` or `node` are skipped), the app's own favicon, and finally the app's
first letter. Icons are cached in `/data/icons` and refreshed daily.
## Deploy
- Host 1, the primary: [deploy/primary/compose.yml](deploy/primary/compose.yml)
- Host 2, the agent: [deploy/agent/compose.yml](deploy/agent/compose.yml)
Both need the same `HODA_PEER_TOKEN` in a `.env` file next to them ([deploy/.env.example](deploy/.env.example)).
Publish the primary through Pangolin with SSO on; give it no public port of its own.
## Configuration
All environment variables are optional.
| Variable | Default | Purpose |
| --- | --- | --- |
| `MODE` | `primary` | `agent` serves only `/api/apps` and `/icons` to the primary, with no page |
| `PEERS` | | Comma-separated agent URLs the primary pulls from |
| `PEER_TOKEN` | | Shared token between primary and agents; required with `PEERS` or `MODE=agent` |
| `PEER_INTERVAL` | `5s` | How often the primary pulls each agent |
| `NODE_NAME` | container hostname | Host name in tile tooltips and search |
| `DOCKER_HOST` | `unix:///var/run/docker.sock` | Docker API; point it at the socket proxy |
| `LABEL_PREFIX` | `sema.dashboard` | Label namespace |
| `DASHBOARD_TITLE` | `HODA` | Page header and browser tab title |
| `PORT` | `8080` | HTTP port |
| `LINK_TARGET` | `_blank` | `_self` opens apps in the same tab |
| `RESYNC_INTERVAL` | `60s` | Full container re-list, on top of Docker events |
| `ICON_REFRESH` | `24h` | How often cached icons are fetched again |
| `ICON_CDN` | jsDelivr's dashboard-icons | Icon set base URL; empty turns the icon set off |
| `DATA_DIR` | `/data` | Where the icon cache lives |
| `LOG_LEVEL` | `info` | `debug` shows every icon lookup |
## Endpoints
| Path | Primary | Agent |
| --- | --- | --- |
| `/` | The dashboard | A one-line notice |
| `/api/apps` | All hosts' apps as JSON, with an ETag | This host's apps; needs `Authorization: Bearer <PEER_TOKEN>` |
| `/icons/<id>` | Cached icon, or fetched from the agent that owns it | This host's icons; needs the token |
| `/healthz` | `ok` | `ok`, no token |
## Development
HODA is Go with only the standard library; the page is plain HTML, CSS and JavaScript embedded in the
binary. Go doesn't need to be installed: everything runs in Docker.
```sh
docker build --target check . # gofmt, go vet, go test
docker build -t hoda . # the production image
```
Gitea Actions ([.gitea/workflows/ci.yml](.gitea/workflows/ci.yml)) runs the checks on every push and pull
request. Pushes to `main` publish `git.mariosem.es/msemes/hoda:latest` and `:sha-<short>`; a `v1.2.3` tag
publishes `:1.2.3`. Images are scanned with Trivy, and a CRITICAL finding blocks the push.
+116
View File
@@ -0,0 +1,116 @@
// Command hoda runs the HODA homelab dashboard. MODE=primary serves the page
// and merges apps from agents; MODE=agent only reports its own host's apps.
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
"git.mariosem.es/msemes/hoda/internal/config"
"git.mariosem.es/msemes/hoda/internal/discovery"
"git.mariosem.es/msemes/hoda/internal/docker"
"git.mariosem.es/msemes/hoda/internal/icons"
"git.mariosem.es/msemes/hoda/internal/peer"
"git.mariosem.es/msemes/hoda/internal/server"
)
// version is set at build time with -ldflags "-X main.version=...".
var version = "dev"
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "healthcheck":
os.Exit(healthcheck())
case "version":
fmt.Println(version)
return
default:
fmt.Fprintf(os.Stderr, "unknown command %q; the commands are healthcheck and version\n", os.Args[1])
os.Exit(2)
}
}
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "hoda:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
dc, err := docker.New(cfg.DockerHost)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
store := apps.NewStore()
resolver := icons.New(filepath.Join(cfg.DataDir, "icons"), cfg.IconCDN, cfg.IconRefresh, log)
go resolver.Run(ctx)
go discovery.New(dc, store, resolver, cfg.LabelPrefix, cfg.NodeName, cfg.ResyncInterval, log).Run(ctx)
deps := server.Deps{Store: store, Icons: resolver}
if len(cfg.Peers) > 0 {
puller := peer.New(cfg.Peers, cfg.PeerToken, cfg.PeerInterval, store, log)
go puller.Run(ctx)
deps.Peers = puller
}
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: server.New(cfg, deps, version, log),
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 2 * time.Minute,
}
errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }()
log.Info("HODA started", "version", version, "mode", cfg.Mode, "node", cfg.NodeName, "port", cfg.Port, "agents", len(cfg.Peers))
select {
case err := <-errc:
return err
case <-ctx.Done():
}
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// healthcheck backs the image's HEALTHCHECK: distroless has no curl or wget.
func healthcheck() int {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://127.0.0.1:" + port + "/healthz")
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintln(os.Stderr, "healthz:", resp.Status)
return 1
}
return 0
}
+5
View File
@@ -0,0 +1,5 @@
# Copy to .env next to each compose.yml. Both hosts need the same token;
# generate one with: openssl rand -hex 32
HODA_PEER_TOKEN=
# Pin a version instead of latest, e.g. 1.0.0 or sha-abc1234.
HODA_TAG=latest
+34
View File
@@ -0,0 +1,34 @@
# HODA agent, on Docker host 2. Reports this host's labeled apps and their icons
# to the primary on host 1; it serves no dashboard of its own.
#
# Set HODA_PEER_TOKEN in a .env file next to this one (the same value as on host 1),
# and replace <host-2-lan-ip> so the port is only reachable on the LAN.
services:
hoda-agent:
image: git.mariosem.es/msemes/hoda:${HODA_TAG:-latest}
restart: unless-stopped
environment:
MODE: agent
DOCKER_HOST: tcp://socket-proxy:2375
NODE_NAME: host-2
PEER_TOKEN: ${HODA_PEER_TOKEN:?set HODA_PEER_TOKEN in .env}
volumes:
- hoda-data:/data
ports:
- "<host-2-lan-ip>:8081:8080"
depends_on:
- socket-proxy
# Read-only Docker API: container lists and events, nothing else.
socket-proxy:
image: tecnativa/docker-socket-proxy
restart: unless-stopped
environment:
CONTAINERS: 1
EVENTS: 1
POST: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes:
hoda-data:
+45
View File
@@ -0,0 +1,45 @@
# HODA primary, on Docker host 1. Serves apps.mariosem.es through Pangolin and
# merges in the apps the agent on host 2 reports.
#
# Set HODA_PEER_TOKEN in a .env file next to this one (the same value as on host 2),
# and replace <host-2-lan-ip>. There is no published port: Pangolin reaches hoda:8080
# over a Docker network it shares with this stack. Create the apps.mariosem.es
# resource in the Pangolin UI with SSO on, or keep the pangolin.* labels below if
# Pangolin's site connector reads this host's Docker labels.
services:
hoda:
image: git.mariosem.es/msemes/hoda:${HODA_TAG:-latest}
restart: unless-stopped
environment:
MODE: primary
DOCKER_HOST: tcp://socket-proxy:2375
DASHBOARD_TITLE: apps.mariosem.es
NODE_NAME: host-1
PEERS: http://<host-2-lan-ip>:8081
PEER_TOKEN: ${HODA_PEER_TOKEN:?set HODA_PEER_TOKEN in .env}
volumes:
- hoda-data:/data
depends_on:
- socket-proxy
labels:
pangolin.public-resources.hoda.name: HODA
pangolin.public-resources.hoda.mode: http
pangolin.public-resources.hoda.full-domain: apps.mariosem.es
pangolin.public-resources.hoda.auth.sso-enabled: "true"
pangolin.public-resources.hoda.targets[0].method: http
pangolin.public-resources.hoda.targets[0].hostname: hoda
pangolin.public-resources.hoda.targets[0].port: "8080"
# Read-only Docker API: container lists and events, nothing else.
socket-proxy:
image: tecnativa/docker-socket-proxy
restart: unless-stopped
environment:
CONTAINERS: 1
EVENTS: 1
POST: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes:
hoda-data:
+3
View File
@@ -0,0 +1,3 @@
module git.mariosem.es/msemes/hoda
go 1.24
+127
View File
@@ -0,0 +1,127 @@
// Package apps turns labeled containers into dashboard apps and keeps the
// merged list from this host and any agents.
package apps
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"strconv"
"strings"
"git.mariosem.es/msemes/hoda/internal/docker"
)
// State is what a tile's dot shows.
type State string
const (
StateRunning State = "running"
StateUnhealthy State = "unhealthy"
StateStopped State = "stopped"
)
// DefaultGroup holds apps that have no group label.
const DefaultGroup = "Apps"
// App is one tile on the dashboard. Only the fields with JSON names leave the
// process; the rest feed icon resolution.
type App struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Description string `json:"description,omitempty"`
Group string `json:"group"`
Order int `json:"order"`
Icon string `json:"icon,omitempty"`
State State `json:"state"`
Host string `json:"host"`
IconHint string `json:"-"`
InternalURL string `json:"-"`
Image string `json:"-"`
}
// FromContainer reads an app from a container's labels. ok is false when the
// container has not opted in; err says why an opted-in container can't be shown.
func FromContainer(c docker.Container, prefix, host string) (app App, ok bool, err error) {
label := func(key string) string { return strings.TrimSpace(c.Labels[prefix+"."+key]) }
if enabled, _ := strconv.ParseBool(label("enable")); !enabled {
return App{}, false, nil
}
app = App{
ID: ID(host, c.Name()),
Name: firstNonEmpty(label("name"), c.Labels["com.docker.compose.service"], c.Name()),
Description: label("description"),
Group: firstNonEmpty(label("group"), DefaultGroup),
State: stateOf(c),
Host: host,
IconHint: label("icon"),
Image: c.Image,
}
if app.URL = label("url"); app.URL == "" {
return App{}, true, fmt.Errorf("missing %s.url label", prefix)
}
if !IsWebURL(app.URL) {
return App{}, true, fmt.Errorf("%s.url %q is not an http or https URL", prefix, app.URL)
}
if app.InternalURL = label("internal_url"); app.InternalURL != "" && !IsWebURL(app.InternalURL) {
return App{}, true, fmt.Errorf("%s.internal_url %q is not an http or https URL", prefix, app.InternalURL)
}
if raw := label("order"); raw != "" {
if app.Order, err = strconv.Atoi(raw); err != nil {
return App{}, true, fmt.Errorf("%s.order %q is not a whole number", prefix, raw)
}
}
return app, true, nil
}
// ID is a short identifier for a container on a host. It survives the
// container being re-created, because compose keeps the name.
func ID(host, container string) string {
sum := sha256.Sum256([]byte(host + "/" + container))
return hex.EncodeToString(sum[:6])
}
// ValidID reports whether id has the shape ID produces.
func ValidID(id string) bool {
if len(id) != 12 {
return false
}
for _, r := range id {
if !strings.ContainsRune("0123456789abcdef", r) {
return false
}
}
return true
}
// IsWebURL reports whether raw is an absolute http or https URL.
func IsWebURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
func stateOf(c docker.Container) State {
switch c.State {
case "running":
if strings.Contains(c.Status, "(unhealthy)") {
return StateUnhealthy
}
return StateRunning
case "restarting":
return StateUnhealthy
default:
return StateStopped
}
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
+146
View File
@@ -0,0 +1,146 @@
package apps
import (
"encoding/json"
"strings"
"testing"
"git.mariosem.es/msemes/hoda/internal/docker"
)
const prefix = "sema.dashboard"
func container(state, status string, labels map[string]string) docker.Container {
return docker.Container{
ID: "4f1c2d3e4f5a6b7c",
Names: []string{"/gitea"},
Image: "gitea/gitea:1.22",
State: state,
Status: status,
Labels: labels,
}
}
func TestFromContainer(t *testing.T) {
full := map[string]string{
"sema.dashboard.enable": "true",
"sema.dashboard.name": "Gitea",
"sema.dashboard.url": "https://git.mariosem.es",
"sema.dashboard.group": "Dev",
"sema.dashboard.description": "Git hosting and CI",
"sema.dashboard.order": "10",
"sema.dashboard.icon": "gitea",
"sema.dashboard.internal_url": "http://gitea:3000",
}
app, ok, err := FromContainer(container("running", "Up 2 hours (healthy)", full), prefix, "host-1")
if !ok || err != nil {
t.Fatalf("FromContainer = ok %v, err %v; want an app", ok, err)
}
want := App{
ID: ID("host-1", "gitea"), Name: "Gitea", URL: "https://git.mariosem.es", Group: "Dev",
Description: "Git hosting and CI", Order: 10, State: StateRunning, Host: "host-1",
IconHint: "gitea", InternalURL: "http://gitea:3000", Image: "gitea/gitea:1.22",
}
if app != want {
t.Errorf("FromContainer =\n%+v\nwant\n%+v", app, want)
}
}
func TestFromContainerDefaults(t *testing.T) {
labels := map[string]string{
"sema.dashboard.enable": "TRUE",
"sema.dashboard.url": "http://192.168.1.10:8096",
"com.docker.compose.service": "jellyfin",
}
app, ok, err := FromContainer(container("running", "Up 1 minute", labels), prefix, "host-1")
if !ok || err != nil {
t.Fatalf("FromContainer = ok %v, err %v; want an app", ok, err)
}
if app.Name != "jellyfin" || app.Group != DefaultGroup || app.Order != 0 {
t.Errorf("defaults = name %q, group %q, order %d; want jellyfin, %s, 0", app.Name, app.Group, app.Order, DefaultGroup)
}
delete(labels, "com.docker.compose.service")
app, _, _ = FromContainer(container("running", "Up 1 minute", labels), prefix, "host-1")
if app.Name != "gitea" {
t.Errorf("name without a compose service = %q, want the container name gitea", app.Name)
}
}
func TestFromContainerRejects(t *testing.T) {
tests := []struct {
name string
labels map[string]string
wantOK bool
wantErr string
}{
{"not opted in", map[string]string{"sema.dashboard.url": "https://x.example"}, false, ""},
{"enable false", map[string]string{"sema.dashboard.enable": "false", "sema.dashboard.url": "https://x.example"}, false, ""},
{"missing url", map[string]string{"sema.dashboard.enable": "true"}, true, "missing sema.dashboard.url"},
{"script url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "javascript:alert(1)"}, true, "not an http or https URL"},
{"relative url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "/gitea"}, true, "not an http or https URL"},
{"bad internal url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.internal_url": "gitea:3000"}, true, "internal_url"},
{"bad order", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.order": "first"}, true, "not a whole number"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, ok, err := FromContainer(container("running", "Up", tt.labels), prefix, "host-1")
if ok != tt.wantOK {
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
}
if tt.wantErr == "" && err != nil {
t.Errorf("err = %v, want none", err)
}
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
t.Errorf("err = %v, want one containing %q", err, tt.wantErr)
}
})
}
}
func TestState(t *testing.T) {
tests := []struct {
state, status string
want State
}{
{"running", "Up 3 hours", StateRunning},
{"running", "Up 3 hours (healthy)", StateRunning},
{"running", "Up 2 minutes (health: starting)", StateRunning},
{"running", "Up 3 hours (unhealthy)", StateUnhealthy},
{"restarting", "Restarting (1) 5 seconds ago", StateUnhealthy},
{"exited", "Exited (0) 2 days ago", StateStopped},
{"paused", "Up 3 hours (Paused)", StateStopped},
{"created", "Created", StateStopped},
}
for _, tt := range tests {
if got := stateOf(container(tt.state, tt.status, nil)); got != tt.want {
t.Errorf("state of %s / %q = %s, want %s", tt.state, tt.status, got, tt.want)
}
}
}
func TestID(t *testing.T) {
a, b := ID("host-1", "gitea"), ID("host-2", "gitea")
if a == b {
t.Error("the same container name on two hosts got the same ID")
}
if a != ID("host-1", "gitea") {
t.Error("ID is not stable")
}
if !ValidID(a) || ValidID("../etc/passwd") || ValidID("ABCDEF123456") {
t.Error("ValidID accepts or rejects the wrong IDs")
}
}
func TestAppJSONHidesInternalFields(t *testing.T) {
app := App{ID: "0123456789ab", Name: "Gitea", URL: "https://git.mariosem.es", InternalURL: "http://gitea:3000", Image: "gitea/gitea", IconHint: "gitea"}
raw, err := json.Marshal(app)
if err != nil {
t.Fatal(err)
}
for _, leak := range []string{"gitea:3000", "gitea/gitea", "Hint", "Internal", "Image"} {
if strings.Contains(string(raw), leak) {
t.Errorf("JSON %s contains %q", raw, leak)
}
}
}
+136
View File
@@ -0,0 +1,136 @@
package apps
import (
"cmp"
"slices"
"strings"
"sync"
"time"
)
// LocalSource is the store key for the host this instance runs on. Agents are
// stored under their URL.
const LocalSource = "local"
// HostStatus says whether a Docker host answered on the last attempt. LastOK
// is only sent while the host is unreachable, so the response stays the same
// (and its ETag stable) while nothing changes.
type HostStatus struct {
Name string `json:"name"`
OK bool `json:"ok"`
LastOK *time.Time `json:"lastOk,omitempty"`
}
// Response is the body of GET /api/apps, on the primary and on agents.
type Response struct {
Node string `json:"node"`
Hosts []HostStatus `json:"hosts"`
Apps []App `json:"apps"`
}
type source struct {
status HostStatus
apps []App
}
// Store holds the latest apps of each source: this host, then one per agent.
type Store struct {
mu sync.RWMutex
sources map[string]*source
now func() time.Time
}
// NewStore returns an empty store.
func NewStore() *Store {
return &Store{sources: map[string]*source{}, now: time.Now}
}
// SetApps records a successful read of a source.
func (s *Store) SetApps(key, host string, list []App) {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
s.sources[key] = &source{status: HostStatus{Name: host, OK: true, LastOK: &now}, apps: list}
}
// SetReported records an agent's own view of its host. An agent that can't
// reach its Docker still sends its last known apps and when it last could.
func (s *Store) SetReported(key string, status HostStatus, list []App) {
if status.OK {
now := s.now()
status.LastOK = &now
}
s.mu.Lock()
defer s.mu.Unlock()
s.sources[key] = &source{status: status, apps: list}
}
// SetError marks a source unreachable and keeps its last known apps.
func (s *Store) SetError(key, host string) {
s.mu.Lock()
defer s.mu.Unlock()
src, ok := s.sources[key]
if !ok {
src = &source{status: HostStatus{Name: host}}
s.sources[key] = src
}
src.status.OK = false
}
// Lookup finds an app by ID and the key of the source that reported it.
func (s *Store) Lookup(id string) (key string, app App, ok bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for k, src := range s.sources {
for _, a := range src.apps {
if a.ID == id {
return k, a, true
}
}
}
return "", App{}, false
}
// Snapshot returns every host's status and all apps in display order. localIcon
// gives the icon URL of this host's apps; agents send their own.
func (s *Store) Snapshot(localIcon func(id string) string) ([]HostStatus, []App) {
s.mu.RLock()
defer s.mu.RUnlock()
hosts := make([]HostStatus, 0, len(s.sources))
list := []App{}
for key, src := range s.sources {
status := src.status
if status.OK {
status.LastOK = nil
}
hosts = append(hosts, status)
for _, a := range src.apps {
if key == LocalSource && localIcon != nil {
a.Icon = localIcon(a.ID)
}
list = append(list, a)
}
}
slices.SortFunc(hosts, func(a, b HostStatus) int { return strings.Compare(a.Name, b.Name) })
slices.SortFunc(list, compareApps)
return hosts, list
}
// compareApps orders by group, with apps that have none last, then the order
// label, then name.
func compareApps(a, b App) int {
ungrouped := func(app App) int {
if app.Group == DefaultGroup {
return 1
}
return 0
}
return cmp.Or(
cmp.Compare(ungrouped(a), ungrouped(b)),
cmp.Compare(strings.ToLower(a.Group), strings.ToLower(b.Group)),
cmp.Compare(a.Order, b.Order),
cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)),
cmp.Compare(a.Host, b.Host),
cmp.Compare(a.ID, b.ID),
)
}
+77
View File
@@ -0,0 +1,77 @@
package apps
import (
"testing"
"time"
)
func TestSnapshotOrder(t *testing.T) {
s := NewStore()
s.SetApps(LocalSource, "host-1", []App{
{ID: "000000000001", Name: "Vaultwarden", Group: "Dev"},
{ID: "000000000002", Name: "gitea", Group: "Dev"},
{ID: "000000000003", Name: "Jellyfin", Group: "Media"},
{ID: "000000000004", Name: "Zebra", Group: "dev", Order: -1},
{ID: "000000000006", Name: "Adminer", Group: DefaultGroup},
})
s.SetReported("http://host-2:8081", HostStatus{Name: "host-2", OK: true}, []App{
{ID: "000000000005", Name: "Immich", Group: "Media", Host: "host-2"},
})
hosts, list := s.Snapshot(nil)
var names []string
for _, a := range list {
names = append(names, a.Name)
}
want := []string{"Zebra", "gitea", "Vaultwarden", "Immich", "Jellyfin", "Adminer"}
if len(names) != len(want) {
t.Fatalf("apps = %v, want %v", names, want)
}
for i := range want {
if names[i] != want[i] {
t.Fatalf("apps = %v, want %v", names, want)
}
}
if len(hosts) != 2 || hosts[0].Name != "host-1" || hosts[1].Name != "host-2" {
t.Errorf("hosts = %+v, want host-1 then host-2", hosts)
}
for _, h := range hosts {
if !h.OK || h.LastOK != nil {
t.Errorf("host %+v: a reachable host is sent without lastOk", h)
}
}
}
func TestSetErrorKeepsApps(t *testing.T) {
s := NewStore()
at := time.Date(2026, 11, 2, 14, 2, 0, 0, time.UTC)
s.now = func() time.Time { return at }
s.SetApps(LocalSource, "host-1", []App{{ID: "000000000001", Name: "Gitea"}})
s.SetError(LocalSource, "host-1")
hosts, list := s.Snapshot(nil)
if len(list) != 1 {
t.Fatalf("apps = %d, want the last known app kept", len(list))
}
if hosts[0].OK || hosts[0].LastOK == nil || !hosts[0].LastOK.Equal(at) {
t.Errorf("host = %+v, want unreachable, last OK at %s", hosts[0], at)
}
}
func TestSnapshotLocalIcons(t *testing.T) {
s := NewStore()
s.SetApps(LocalSource, "host-1", []App{{ID: "000000000001", Name: "Gitea"}})
s.SetReported("http://host-2:8081", HostStatus{Name: "host-2", OK: true}, []App{
{ID: "000000000002", Name: "Immich", Icon: "/icons/000000000002?v=abc"},
})
_, list := s.Snapshot(func(id string) string { return "/icons/" + id + "?v=local" })
for _, a := range list {
want := map[string]string{"Gitea": "/icons/000000000001?v=local", "Immich": "/icons/000000000002?v=abc"}[a.Name]
if a.Icon != want {
t.Errorf("%s icon = %q, want %q", a.Name, a.Icon, want)
}
}
if key, _, ok := s.Lookup("000000000002"); !ok || key != "http://host-2:8081" {
t.Errorf("Lookup = %q, %v; want the agent's key", key, ok)
}
}
+112
View File
@@ -0,0 +1,112 @@
// Package config reads HODA's settings from environment variables.
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
)
// Mode selects what an instance does: the primary serves the dashboard, an
// agent only reports its own host's apps to the primary.
type Mode string
const (
ModePrimary Mode = "primary"
ModeAgent Mode = "agent"
)
// Config holds every setting; the README lists what each one does.
type Config struct {
Mode Mode
Port string
DockerHost string
LabelPrefix string
Title string
NodeName string
Peers []string
PeerToken string
PeerInterval time.Duration
LinkTarget string
ResyncInterval time.Duration
IconRefresh time.Duration
IconCDN string
DataDir string
LogLevel slog.Level
}
// Load reads the configuration from the process environment.
func Load() (Config, error) {
return parse(os.Getenv)
}
func parse(getenv func(string) string) (Config, error) {
get := func(key, fallback string) string {
if v := strings.TrimSpace(getenv(key)); v != "" {
return v
}
return fallback
}
c := Config{
Mode: Mode(strings.ToLower(get("MODE", string(ModePrimary)))),
Port: get("PORT", "8080"),
DockerHost: get("DOCKER_HOST", "unix:///var/run/docker.sock"),
LabelPrefix: strings.TrimSuffix(get("LABEL_PREFIX", "sema.dashboard"), "."),
Title: get("DASHBOARD_TITLE", "HODA"),
NodeName: get("NODE_NAME", ""),
PeerToken: get("PEER_TOKEN", ""),
LinkTarget: get("LINK_TARGET", "_blank"),
IconCDN: strings.TrimSuffix(get("ICON_CDN", "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons"), "/"),
DataDir: get("DATA_DIR", "/data"),
}
if c.NodeName == "" {
c.NodeName, _ = os.Hostname()
}
for _, p := range strings.Split(getenv("PEERS"), ",") {
if p = strings.TrimSuffix(strings.TrimSpace(p), "/"); p != "" {
c.Peers = append(c.Peers, p)
}
}
var errs []error
duration := func(key, fallback string) time.Duration {
raw := get(key, fallback)
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
errs = append(errs, fmt.Errorf("%s=%q is not a positive duration such as 5s or 24h", key, raw))
}
return d
}
c.PeerInterval = duration("PEER_INTERVAL", "5s")
c.ResyncInterval = duration("RESYNC_INTERVAL", "60s")
c.IconRefresh = duration("ICON_REFRESH", "24h")
if err := c.LogLevel.UnmarshalText([]byte(get("LOG_LEVEL", "info"))); err != nil {
errs = append(errs, errors.New("LOG_LEVEL must be debug, info, warn or error"))
}
if c.Mode != ModePrimary && c.Mode != ModeAgent {
errs = append(errs, fmt.Errorf("MODE=%q must be primary or agent", c.Mode))
}
if n, err := strconv.Atoi(c.Port); err != nil || n < 1 || n > 65535 {
errs = append(errs, fmt.Errorf("PORT=%q is not a valid port", c.Port))
}
if c.LinkTarget != "_blank" && c.LinkTarget != "_self" {
errs = append(errs, fmt.Errorf("LINK_TARGET=%q must be _blank or _self", c.LinkTarget))
}
if c.Mode == ModeAgent && len(c.Peers) > 0 {
errs = append(errs, errors.New("PEERS only applies to MODE=primary"))
}
if (c.Mode == ModeAgent || len(c.Peers) > 0) && c.PeerToken == "" {
errs = append(errs, errors.New("PEER_TOKEN is required when MODE=agent or PEERS is set"))
}
for _, p := range c.Peers {
if !strings.HasPrefix(p, "http://") && !strings.HasPrefix(p, "https://") {
errs = append(errs, fmt.Errorf("PEERS entry %q must start with http:// or https://", p))
}
}
return c, errors.Join(errs...)
}
+65
View File
@@ -0,0 +1,65 @@
package config
import (
"strings"
"testing"
"time"
)
func env(vars map[string]string) func(string) string {
return func(key string) string { return vars[key] }
}
func TestDefaults(t *testing.T) {
c, err := parse(env(map[string]string{"NODE_NAME": "host-1"}))
if err != nil {
t.Fatal(err)
}
if c.Mode != ModePrimary || c.Port != "8080" || c.LabelPrefix != "sema.dashboard" || c.Title != "HODA" ||
c.LinkTarget != "_blank" || c.DataDir != "/data" || c.DockerHost != "unix:///var/run/docker.sock" {
t.Errorf("defaults = %+v", c)
}
if c.PeerInterval != 5*time.Second || c.ResyncInterval != time.Minute || c.IconRefresh != 24*time.Hour {
t.Errorf("default intervals = %s, %s, %s", c.PeerInterval, c.ResyncInterval, c.IconRefresh)
}
}
func TestPeers(t *testing.T) {
c, err := parse(env(map[string]string{
"PEERS": " http://10.0.0.12:8081/ , ,https://host-3.lan ",
"PEER_TOKEN": "secret",
"LABEL_PREFIX": "sema.dashboard.",
}))
if err != nil {
t.Fatal(err)
}
if len(c.Peers) != 2 || c.Peers[0] != "http://10.0.0.12:8081" || c.Peers[1] != "https://host-3.lan" {
t.Errorf("peers = %q", c.Peers)
}
if c.LabelPrefix != "sema.dashboard" {
t.Errorf("label prefix = %q, want the trailing dot dropped", c.LabelPrefix)
}
}
func TestInvalid(t *testing.T) {
tests := []struct {
vars map[string]string
want string
}{
{map[string]string{"MODE": "agent"}, "PEER_TOKEN is required"},
{map[string]string{"PEERS": "http://10.0.0.12:8081"}, "PEER_TOKEN is required"},
{map[string]string{"MODE": "agent", "PEER_TOKEN": "x", "PEERS": "http://a"}, "PEERS only applies"},
{map[string]string{"PEERS": "10.0.0.12:8081", "PEER_TOKEN": "x"}, "must start with http"},
{map[string]string{"MODE": "server"}, "must be primary or agent"},
{map[string]string{"PORT": "80a"}, "not a valid port"},
{map[string]string{"PEER_INTERVAL": "5"}, "not a positive duration"},
{map[string]string{"LINK_TARGET": "_top"}, "must be _blank or _self"},
{map[string]string{"LOG_LEVEL": "loud"}, "LOG_LEVEL"},
}
for _, tt := range tests {
_, err := parse(env(tt.vars))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("parse(%v) error = %v, want one containing %q", tt.vars, err, tt.want)
}
}
}
+179
View File
@@ -0,0 +1,179 @@
// Package discovery keeps the store in step with this host's labeled containers.
package discovery
import (
"context"
"log/slog"
"strings"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
"git.mariosem.es/msemes/hoda/internal/docker"
)
const (
settle = 300 * time.Millisecond // lets a burst of events become one re-list
minBackoff = time.Second
maxBackoff = 30 * time.Second
)
// IconQueue is told about the current apps so their icons get resolved.
type IconQueue interface {
Ensure([]apps.App)
}
// Discovery lists this host's labeled containers into the store.
type Discovery struct {
docker *docker.Client
store *apps.Store
icons IconQueue
prefix string
host string
resync time.Duration
log *slog.Logger
reachable bool
count int
warned map[string]string // container name -> the label problem already logged
}
// New returns a Discovery for the Docker host behind dc, named host on the dashboard.
func New(dc *docker.Client, store *apps.Store, icons IconQueue, prefix, host string, resync time.Duration, log *slog.Logger) *Discovery {
return &Discovery{
docker: dc,
store: store,
icons: icons,
prefix: prefix,
host: host,
resync: resync,
log: log,
reachable: true,
count: -1,
warned: map[string]string{},
}
}
// Run lists containers now, again after every relevant Docker event and every
// resync interval, until ctx ends.
func (d *Discovery) Run(ctx context.Context) {
changed := make(chan struct{}, 1)
go d.watch(ctx, func() {
select {
case changed <- struct{}{}:
default:
}
})
ticker := time.NewTicker(d.resync)
defer ticker.Stop()
d.sync(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
case <-changed:
select {
case <-ctx.Done():
return
case <-time.After(settle):
}
select {
case <-changed:
default:
}
}
d.sync(ctx)
}
}
func (d *Discovery) sync(ctx context.Context) {
listCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
containers, err := d.docker.ListContainers(listCtx, d.prefix+".enable")
if err != nil {
if ctx.Err() != nil {
return
}
d.store.SetError(apps.LocalSource, d.host)
if d.reachable {
d.log.Warn("can't reach Docker, keeping the last known apps", "err", err)
}
d.reachable = false
return
}
if !d.reachable {
d.log.Info("Docker is reachable again")
}
d.reachable = true
list := make([]apps.App, 0, len(containers))
seen := make(map[string]bool, len(containers))
for _, c := range containers {
app, ok, err := apps.FromContainer(c, d.prefix, d.host)
if !ok {
continue
}
name := c.Name()
seen[name] = true
if err != nil {
if d.warned[name] != err.Error() {
d.log.Warn("container left off the dashboard", "container", name, "reason", err)
d.warned[name] = err.Error()
}
continue
}
delete(d.warned, name)
list = append(list, app)
}
for name := range d.warned {
if !seen[name] {
delete(d.warned, name)
}
}
if len(list) != d.count {
d.log.Info("apps on this host", "count", len(list))
d.count = len(list)
}
d.store.SetApps(apps.LocalSource, d.host, list)
d.icons.Ensure(list)
}
// watch follows Docker events and reconnects with backoff when the stream breaks.
func (d *Discovery) watch(ctx context.Context, changed func()) {
backoff := minBackoff
for {
started := time.Now()
err := d.docker.Events(ctx, d.prefix+".enable", func(ev docker.Event) {
if relevant(ev.Action) {
changed()
}
})
if ctx.Err() != nil {
return
}
if time.Since(started) > time.Minute {
backoff = minBackoff
}
d.log.Debug("Docker event stream ended, reconnecting", "err", err, "in", backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff = min(backoff*2, maxBackoff)
changed() // events may have been missed while disconnected
}
}
// relevant drops the event chatter of healthchecks and `docker exec`, which
// never changes what the dashboard shows.
func relevant(action string) bool {
switch {
case strings.HasPrefix(action, "exec_"), strings.HasPrefix(action, "archive-path"):
return false
case action == "attach", action == "detach", action == "resize", action == "top",
action == "commit", action == "copy", action == "export", action == "extract-to-dir":
return false
}
return true
}
+77
View File
@@ -0,0 +1,77 @@
package discovery
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
"git.mariosem.es/msemes/hoda/internal/docker"
)
type recorder struct{ got []apps.App }
func (r *recorder) Ensure(list []apps.App) { r.got = list }
func TestSync(t *testing.T) {
up := true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !up {
http.Error(w, "down", http.StatusBadGateway)
return
}
fmt.Fprint(w, `[
{"Id":"a","Names":["/gitea"],"State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"true","sema.dashboard.url":"https://git.mariosem.es"}},
{"Id":"b","Names":["/broken"],"State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"true"}},
{"Id":"c","Names":["/off"],"State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"false"}}
]`)
}))
defer srv.Close()
dc, err := docker.New(srv.URL)
if err != nil {
t.Fatal(err)
}
store := apps.NewStore()
icons := &recorder{}
d := New(dc, store, icons, "sema.dashboard", "host-1", time.Minute, slog.New(slog.NewTextHandler(io.Discard, nil)))
d.sync(context.Background())
hosts, list := store.Snapshot(nil)
if len(list) != 1 || list[0].Name != "gitea" || list[0].Host != "host-1" {
t.Fatalf("apps = %+v, want only gitea", list)
}
if len(icons.got) != 1 {
t.Error("icons were not queued for the apps")
}
if _, warned := d.warned["broken"]; !warned {
t.Error("the container without a url label was not reported")
}
if !hosts[0].OK {
t.Error("host not marked reachable")
}
up = false
d.sync(context.Background())
hosts, list = store.Snapshot(nil)
if hosts[0].OK || len(list) != 1 {
t.Errorf("Docker down: hosts %+v, %d apps; want unreachable with apps kept", hosts, len(list))
}
}
func TestRelevant(t *testing.T) {
for _, action := range []string{"start", "stop", "die", "destroy", "create", "health_status: unhealthy", "pause", "rename"} {
if !relevant(action) {
t.Errorf("%q is ignored", action)
}
}
for _, action := range []string{"exec_create: sh -c curl", "exec_start: sh", "exec_die", "attach", "resize", "top", "archive-path"} {
if relevant(action) {
t.Errorf("%q triggers a re-list", action)
}
}
}
+152
View File
@@ -0,0 +1,152 @@
// Package docker is a small read-only client for the two Docker Engine API
// calls HODA makes: listing containers and following container events. It
// stands in for the full SDK so the image carries no third-party code.
package docker
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// Container is the part of a GET /containers/json entry that HODA reads.
type Container struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Status string `json:"Status"`
Labels map[string]string `json:"Labels"`
}
// Name is the container name without Docker's leading slash.
func (c Container) Name() string {
if len(c.Names) > 0 {
return strings.TrimPrefix(c.Names[0], "/")
}
if len(c.ID) > 12 {
return c.ID[:12]
}
return c.ID
}
// Event is the part of a GET /events message that HODA reads.
type Event struct {
Type string `json:"Type"`
Action string `json:"Action"`
}
// Client talks to one Docker Engine API endpoint.
type Client struct {
base string
api *http.Client // bounded requests
stream *http.Client // the long-lived events stream
}
// New returns a client for host, which takes the forms DOCKER_HOST does:
// unix:///var/run/docker.sock, tcp://socket-proxy:2375 or http(s)://host:port.
func New(host string) (*Client, error) {
u, err := url.Parse(host)
if err != nil {
return nil, fmt.Errorf("DOCKER_HOST %q: %w", host, err)
}
transport := &http.Transport{
MaxIdleConns: 4,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
}
var base string
switch u.Scheme {
case "unix":
socket := u.Path
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", socket)
}
base = "http://docker"
case "tcp", "http":
base = "http://" + u.Host
case "https":
base = "https://" + u.Host
default:
return nil, fmt.Errorf("DOCKER_HOST %q: scheme must be unix, tcp, http or https", host)
}
if u.Scheme != "unix" && u.Host == "" {
return nil, fmt.Errorf("DOCKER_HOST %q: missing host", host)
}
return &Client{
base: base,
api: &http.Client{Transport: transport, Timeout: 10 * time.Second},
stream: &http.Client{Transport: transport},
}, nil
}
// ListContainers returns every container, running or not, that has the label key.
func (c *Client) ListContainers(ctx context.Context, labelKey string) ([]Container, error) {
q := url.Values{
"all": {"1"},
"filters": {filters(map[string][]string{"label": {labelKey}})},
}
resp, err := c.get(ctx, c.api, "/containers/json?"+q.Encode())
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
defer resp.Body.Close()
var out []Container
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
return out, nil
}
// Events follows events of containers that have the label key and calls fn
// for each one. It returns when ctx ends or the stream breaks.
func (c *Client) Events(ctx context.Context, labelKey string, fn func(Event)) error {
q := url.Values{
"filters": {filters(map[string][]string{"type": {"container"}, "label": {labelKey}})},
}
resp, err := c.get(ctx, c.stream, "/events?"+q.Encode())
if err != nil {
return fmt.Errorf("events: %w", err)
}
defer resp.Body.Close()
dec := json.NewDecoder(resp.Body)
for {
var ev Event
if err := dec.Decode(&ev); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("events: %w", err)
}
fn(ev)
}
}
func (c *Client) get(ctx context.Context, client *http.Client, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(msg)))
}
return resp, nil
}
func filters(f map[string][]string) string {
b, _ := json.Marshal(f)
return string(b)
}
+82
View File
@@ -0,0 +1,82 @@
package docker
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestListContainers(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/containers/json" {
http.NotFound(w, r)
return
}
var f map[string][]string
if err := json.Unmarshal([]byte(r.URL.Query().Get("filters")), &f); err != nil || f["label"][0] != "sema.dashboard.enable" {
t.Errorf("filters = %q, want the label key", r.URL.Query().Get("filters"))
}
if r.URL.Query().Get("all") != "1" {
t.Error("stopped containers are not requested")
}
fmt.Fprint(w, `[{"Id":"abc","Names":["/gitea"],"Image":"gitea/gitea","State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"true"},"Ports":[]}]`)
}))
defer srv.Close()
c, err := New("tcp://" + strings.TrimPrefix(srv.URL, "http://"))
if err != nil {
t.Fatal(err)
}
list, err := c.ListContainers(context.Background(), "sema.dashboard.enable")
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Name() != "gitea" || list[0].Labels["sema.dashboard.enable"] != "true" {
t.Errorf("containers = %+v", list)
}
}
func TestListContainersError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Forbidden", http.StatusForbidden)
}))
defer srv.Close()
c, _ := New(srv.URL)
if _, err := c.ListContainers(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "403") {
t.Errorf("err = %v, want the 403 reported", err)
}
}
func TestEvents(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `{"Type":"container","Action":"start","Actor":{"ID":"abc"}}`)
fmt.Fprintln(w, `{"Type":"container","Action":"exec_start: sh -c true","Actor":{"ID":"abc"}}`)
}))
defer srv.Close()
c, _ := New(srv.URL)
var actions []string
err := c.Events(context.Background(), "sema.dashboard.enable", func(ev Event) { actions = append(actions, ev.Action) })
if err == nil {
t.Error("Events returned no error when the stream ended")
}
if len(actions) != 2 || actions[0] != "start" {
t.Errorf("actions = %q", actions)
}
}
func TestNew(t *testing.T) {
for _, host := range []string{"unix:///var/run/docker.sock", "tcp://socket-proxy:2375", "https://docker.lan:2376"} {
if _, err := New(host); err != nil {
t.Errorf("New(%q) = %v", host, err)
}
}
for _, host := range []string{"ssh://user@host", "tcp://", "socket-proxy:2375"} {
if _, err := New(host); err == nil {
t.Errorf("New(%q) accepted an unusable host", host)
}
}
}
+271
View File
@@ -0,0 +1,271 @@
package icons
import (
"bytes"
"context"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"git.mariosem.es/msemes/hoda/internal/apps"
)
type candidate struct {
desc string
fetch func(context.Context) ([]byte, string, error)
}
// candidates lists where to look for an app's icon, best first: the icon
// label, the icon set by app name, then by image name, then the favicon.
func (r *Resolver) candidates(app apps.App) []candidate {
var out []candidate
tried := map[string]bool{}
hint := app.IconHint
if apps.IsWebURL(hint) {
out = append(out, candidate{"icon label " + hint, func(ctx context.Context) ([]byte, string, error) {
return r.image(ctx, r.webHTTP, hint)
}})
} else if name := iconName(hint); name != "" {
out = append(out, r.fromSet(name)...)
tried[name] = true
}
// The name was chosen for the dashboard; the image may be a generic base.
for _, name := range append([]string{slug(app.Name)}, imageNames(app.Image)...) {
if name != "" && !tried[name] {
out = append(out, r.fromSet(name)...)
tried[name] = true
}
}
page := app.InternalURL
if page == "" {
page = app.URL
}
out = append(out, candidate{"favicon of " + page, func(ctx context.Context) ([]byte, string, error) {
return r.favicon(ctx, page)
}})
return out
}
// fromSet looks a name up in the dashboard-icons set, SVG first.
func (r *Resolver) fromSet(name string) []candidate {
if r.cdn == "" {
return nil
}
var out []candidate
for _, format := range []string{"svg", "png"} {
link := r.cdn + "/" + format + "/" + name + "." + format
out = append(out, candidate{"dashboard-icons " + name + "." + format, func(ctx context.Context) ([]byte, string, error) {
return r.image(ctx, r.cdnHTTP, link)
}})
}
return out
}
// favicon reads the icon links of the app's page, largest first, then falls
// back to /favicon.ico.
func (r *Resolver) favicon(ctx context.Context, page string) ([]byte, string, error) {
base, err := url.Parse(page)
if err != nil {
return nil, "", err
}
var links []string
if resp, err := get(ctx, r.webHTTP, page, "text/html,*/*;q=0.8"); err == nil {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxPageBytes))
resp.Body.Close()
links = iconLinks(body, resp.Request.URL)
}
if ico := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String(); !slices.Contains(links, ico) {
links = append(links, ico)
}
var lastErr error
for _, link := range links {
data, ctype, err := r.image(ctx, r.webHTTP, link)
if err == nil {
return data, ctype, nil
}
lastErr = err
}
return nil, "", lastErr
}
// image downloads link and checks that it really is an image: a login page
// served with status 200 must not end up as an app's icon.
func (r *Resolver) image(ctx context.Context, client *http.Client, link string) ([]byte, string, error) {
resp, err := get(ctx, client, link, "image/*")
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxIconBytes+1))
if err != nil {
return nil, "", err
}
if len(data) > maxIconBytes {
return nil, "", fmt.Errorf("%s: larger than %d KB", link, maxIconBytes>>10)
}
ctype := sniff(data)
if ctype == "" {
return nil, "", fmt.Errorf("%s: not an image", link)
}
return data, ctype, nil
}
func get(ctx context.Context, client *http.Client, link, accept string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", accept)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("%s: %s", link, resp.Status)
}
return resp, nil
}
// sniff returns the image type of data, or "" if it isn't an image.
func sniff(data []byte) string {
if len(data) == 0 {
return ""
}
if t := http.DetectContentType(data); strings.HasPrefix(t, "image/") {
return t
}
// SVG is XML, which DetectContentType reports as text.
head := bytes.ToLower(data[:min(len(data), 1024)])
if bytes.Contains(head, []byte("<svg")) && !bytes.Contains(head, []byte("<html")) {
return "image/svg+xml"
}
return ""
}
var (
linkTag = regexp.MustCompile(`(?is)<link\b[^>]*>`)
tagAttr = regexp.MustCompile(`(?is)([a-z][a-z0-9:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))`)
)
// iconLinks returns the icon links of an HTML page, largest first.
func iconLinks(page []byte, base *url.URL) []string {
type link struct {
href string
size int
}
var found []link
for _, tag := range linkTag.FindAll(page, -1) {
attrs := map[string]string{}
for _, m := range tagAttr.FindAllSubmatch(tag, -1) {
attrs[strings.ToLower(string(m[1]))] = html.UnescapeString(string(m[2]) + string(m[3]) + string(m[4]))
}
rel := strings.Fields(strings.ToLower(attrs["rel"]))
touch := slices.Contains(rel, "apple-touch-icon") || slices.Contains(rel, "apple-touch-icon-precomposed")
if !touch && !slices.Contains(rel, "icon") {
continue
}
href := strings.TrimSpace(attrs["href"])
if href == "" || strings.HasPrefix(href, "data:") {
continue
}
u, err := base.Parse(href)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
continue
}
found = append(found, link{u.String(), iconSize(touch, attrs["sizes"], attrs["type"], u.Path)})
}
slices.SortStableFunc(found, func(a, b link) int { return b.size - a.size })
var out []string
for _, l := range found {
if !slices.Contains(out, l.href) {
out = append(out, l.href)
}
}
return out
}
// iconSize ranks a link by its declared size. SVG scales, so it ranks first.
func iconSize(touch bool, sizes, typ, path string) int {
if strings.Contains(typ, "svg") || strings.HasSuffix(strings.ToLower(path), ".svg") || strings.EqualFold(strings.TrimSpace(sizes), "any") {
return 1 << 16
}
best := 0
for _, s := range strings.Fields(strings.ToLower(sizes)) {
if w, _, ok := strings.Cut(s, "x"); ok {
if n, err := strconv.Atoi(w); err == nil && n > best {
best = n
}
}
}
switch {
case best > 0:
return best
case touch:
return 180
default:
return 32
}
}
// genericImages run many different apps, so their logo says nothing about the app.
var genericImages = map[string]bool{
"alpine": true, "busybox": true, "caddy": true, "debian": true, "eclipse-temurin": true,
"golang": true, "httpd": true, "nginx": true, "nginx-unprivileged": true, "node": true,
"openjdk": true, "php": true, "python": true, "ruby": true, "ubuntu": true,
}
// imageNames turns an image reference into names to look up in the icon set:
// "lscr.io/linuxserver/jellyfin:latest" gives "jellyfin", and
// "ghcr.io/immich-app/immich-server:release" gives "immich-server", then "immich".
func imageNames(image string) []string {
if image == "" || strings.HasPrefix(image, "sha256:") {
return nil
}
ref, _, _ := strings.Cut(image, "@")
name := ref[strings.LastIndex(ref, "/")+1:]
name, _, _ = strings.Cut(name, ":")
if name = slug(name); name == "" || genericImages[name] {
return nil
}
out := []string{name}
for _, suffix := range []string{"-server", "-app", "-web", "-frontend", "-backend"} {
if base, ok := strings.CutSuffix(name, suffix); ok && base != "" {
out = append(out, base)
break
}
}
return out
}
// iconName turns an icon label into an icon set name: "Jellyfin.svg" gives "jellyfin".
func iconName(hint string) string {
lower := strings.ToLower(strings.TrimSpace(hint))
for _, ext := range []string{".svg", ".png", ".webp"} {
lower = strings.TrimSuffix(lower, ext)
}
return slug(lower)
}
// slug lowercases s and joins its letters and digits with dashes: "Uptime Kuma" gives "uptime-kuma".
func slug(s string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(s) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
dash = false
} else if b.Len() > 0 && !dash {
b.WriteByte('-')
dash = true
}
}
return strings.TrimSuffix(b.String(), "-")
}
+329
View File
@@ -0,0 +1,329 @@
// Package icons finds an icon for each app and keeps them cached on disk. It
// tries, in order: the icon label, the dashboard-icons set by image name and
// app name, then the app's own favicon. Apps with no icon get a letter in the UI.
package icons
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
)
const (
maxIconBytes = 512 << 10
maxPageBytes = 1 << 20
fetchTimeout = 3 * time.Second
missRetry = time.Hour // an app that had no icon may simply have been down
workers = 4
indexFile = "index.json"
userAgent = "Mozilla/5.0 (compatible; HODA; +https://git.mariosem.es/msemes/HODA)"
)
// Icon is a cached image, ready to serve.
type Icon struct {
Data []byte
ContentType string
Version string
}
type entry struct {
Key string `json:"key"` // hash of the inputs; a change triggers a new lookup
Source string `json:"source,omitempty"`
ContentType string `json:"contentType,omitempty"`
Version string `json:"version,omitempty"` // hash of the image; empty when none was found
Checked time.Time `json:"checked"`
data []byte
}
// Resolver looks icons up in the background. URL and Lookup never wait on the network.
type Resolver struct {
dir string
cdn string
refresh time.Duration
log *slog.Logger
cdnHTTP *http.Client
webHTTP *http.Client
mu sync.RWMutex
entries map[string]*entry
queued map[string]bool
queue chan apps.App
saveMu sync.Mutex
}
// New returns a resolver that caches under dir; an empty dir keeps icons in
// memory only. cdn is the dashboard-icons base URL; empty skips the icon set.
func New(dir, cdn string, refresh time.Duration, log *slog.Logger) *Resolver {
r := &Resolver{
dir: dir,
cdn: strings.TrimSuffix(cdn, "/"),
refresh: refresh,
log: log,
cdnHTTP: &http.Client{Timeout: fetchTimeout},
webHTTP: &http.Client{
Timeout: fetchTimeout,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
// Homelab apps often run on self-signed certificates, and only images are read.
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
// A redirect to another host is a login page (Pangolin's SSO, for one), not the app.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
if req.URL.Host != via[0].URL.Host {
return fmt.Errorf("redirected to %s, likely a login page", req.URL.Host)
}
return nil
},
},
entries: map[string]*entry{},
queued: map[string]bool{},
queue: make(chan apps.App, 256),
}
r.load()
return r
}
// Run resolves queued apps until ctx ends.
func (r *Resolver) Run(ctx context.Context) {
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case app := <-r.queue:
r.resolve(ctx, app)
}
}
}()
}
wg.Wait()
}
// Ensure queues a lookup for every app whose icon is missing, stale or based
// on labels that changed, and forgets the icons of apps that are gone.
func (r *Resolver) Ensure(list []apps.App) {
now := time.Now()
present := make(map[string]bool, len(list))
var todo []apps.App
var gone []string
r.mu.Lock()
for _, app := range list {
present[app.ID] = true
e := r.entries[app.ID]
if r.queued[app.ID] || (e != nil && e.Key == inputKey(app) && !r.stale(e, now)) {
continue
}
r.queued[app.ID] = true
todo = append(todo, app)
}
for id := range r.entries {
if !present[id] {
delete(r.entries, id)
gone = append(gone, id)
}
}
r.mu.Unlock()
if len(gone) > 0 {
r.forget(gone)
}
for _, app := range todo {
select {
case r.queue <- app:
default: // queue full: the next sync queues it again
r.mu.Lock()
delete(r.queued, app.ID)
r.mu.Unlock()
}
}
}
// URL is the path the browser loads an app's icon from, or "" for a letter.
// The version in it lets browsers cache the icon until it changes.
func (r *Resolver) URL(id string) string {
r.mu.RLock()
defer r.mu.RUnlock()
if e := r.entries[id]; e != nil && e.Version != "" {
return "/icons/" + id + "?v=" + e.Version
}
return ""
}
// Lookup returns an app's cached icon.
func (r *Resolver) Lookup(id string) (Icon, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e := r.entries[id]
if e == nil || e.Version == "" {
return Icon{}, false
}
return Icon{Data: e.data, ContentType: e.ContentType, Version: e.Version}, true
}
func (r *Resolver) stale(e *entry, now time.Time) bool {
ttl := r.refresh
if e.Version == "" {
ttl = min(ttl, missRetry)
}
return now.Sub(e.Checked) > ttl
}
func (r *Resolver) resolve(ctx context.Context, app apps.App) {
e := &entry{Key: inputKey(app), Checked: time.Now()}
for _, c := range r.candidates(app) {
data, ctype, err := c.fetch(ctx)
if err != nil {
r.log.Debug("icon candidate failed", "app", app.Name, "candidate", c.desc, "err", err)
continue
}
sum := sha256.Sum256(data)
e.Source, e.ContentType, e.Version, e.data = c.desc, ctype, hex.EncodeToString(sum[:6]), data
break
}
r.mu.Lock()
prev := r.entries[app.ID]
delete(r.queued, app.ID)
r.entries[app.ID] = e
r.mu.Unlock()
level := slog.LevelDebug
if prev == nil || prev.Key != e.Key || prev.Version != e.Version {
level = slog.LevelInfo
}
if e.Version != "" {
r.log.Log(ctx, level, "icon found", "app", app.Name, "source", e.Source)
} else {
r.log.Log(ctx, level, "no icon found, showing a letter", "app", app.Name)
}
r.persist(app.ID, e)
}
// inputKey hashes everything that decides which icon an app gets.
func inputKey(app apps.App) string {
sum := sha256.Sum256([]byte(strings.Join([]string{app.IconHint, app.Image, app.Name, app.URL, app.InternalURL}, "\x00")))
return hex.EncodeToString(sum[:8])
}
func (r *Resolver) load() {
if r.dir == "" {
return
}
if err := os.MkdirAll(r.dir, 0o755); err != nil {
r.log.Warn("icon cache disabled, keeping icons in memory", "dir", r.dir, "err", err)
r.dir = ""
return
}
raw, err := os.ReadFile(filepath.Join(r.dir, indexFile))
if err != nil {
return // first start
}
var index map[string]*entry
if err := json.Unmarshal(raw, &index); err != nil {
r.log.Warn("ignoring an unreadable icon index", "err", err)
return
}
for id, e := range index {
if !apps.ValidID(id) || e == nil {
continue
}
if e.Version != "" {
if e.data, err = os.ReadFile(filepath.Join(r.dir, id)); err != nil {
continue
}
}
r.entries[id] = e
}
}
func (r *Resolver) persist(id string, e *entry) {
if r.dir == "" {
return
}
r.saveMu.Lock()
defer r.saveMu.Unlock()
path := filepath.Join(r.dir, id)
var err error
if e.Version != "" {
err = writeAtomic(path, e.data)
} else {
err = removeIfExists(path)
}
if err == nil {
err = r.saveIndex()
}
if err != nil {
r.log.Warn("writing the icon cache failed", "err", err)
}
}
func (r *Resolver) forget(ids []string) {
if r.dir == "" {
return
}
r.saveMu.Lock()
defer r.saveMu.Unlock()
for _, id := range ids {
if err := removeIfExists(filepath.Join(r.dir, id)); err != nil {
r.log.Warn("removing a cached icon failed", "err", err)
}
}
if err := r.saveIndex(); err != nil {
r.log.Warn("writing the icon cache failed", "err", err)
}
}
// saveIndex writes the index; the caller holds saveMu.
func (r *Resolver) saveIndex() error {
r.mu.RLock()
raw, err := json.MarshalIndent(r.entries, "", " ")
r.mu.RUnlock()
if err != nil {
return err
}
return writeAtomic(filepath.Join(r.dir, indexFile), raw)
}
func writeAtomic(path string, data []byte) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
func removeIfExists(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
+199
View File
@@ -0,0 +1,199 @@
package icons
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"testing"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
)
var (
svgIcon = []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>`)
pngIcon = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
quiet = slog.New(slog.NewTextHandler(io.Discard, nil))
)
func serve(t *testing.T, routes map[string][]byte) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, ok := routes[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
return srv
}
func resolveOne(t *testing.T, r *Resolver, app apps.App) Icon {
t.Helper()
r.resolve(context.Background(), app)
ic, _ := r.Lookup(app.ID)
return ic
}
func TestIconSetBeforeFavicon(t *testing.T) {
cdn := serve(t, map[string][]byte{"/svg/gitea.svg": svgIcon})
site := serve(t, map[string][]byte{"/favicon.ico": pngIcon})
r := New("", cdn.URL, time.Hour, quiet)
app := apps.App{ID: "000000000001", Name: "Git", Image: "gitea/gitea:1.22", URL: site.URL}
ic := resolveOne(t, r, app)
if ic.ContentType != "image/svg+xml" {
t.Errorf("content type = %q, want the SVG from the icon set", ic.ContentType)
}
if r.URL(app.ID) != "/icons/000000000001?v="+ic.Version {
t.Errorf("URL = %q", r.URL(app.ID))
}
}
func TestNameBeforeImage(t *testing.T) {
cdn := serve(t, map[string][]byte{"/svg/nginx.svg": svgIcon, "/png/nginx.png": pngIcon, "/png/gitea.png": pngIcon, "/svg/immich.svg": svgIcon})
r := New("", cdn.URL, time.Hour, quiet)
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "Gitea", Image: "nginx:alpine", URL: "http://127.0.0.1:1"})
if ic.ContentType != "image/png" {
t.Errorf("Gitea on nginx: content type = %q, want the gitea PNG, not nginx's logo", ic.ContentType)
}
ic = resolveOne(t, r, apps.App{ID: "000000000002", Name: "My Blog", Image: "nginx:alpine", URL: "http://127.0.0.1:1"})
if ic.Version != "" {
t.Error("an app on a generic image got that image's logo")
}
ic = resolveOne(t, r, apps.App{ID: "000000000003", Name: "Photos", Image: "ghcr.io/immich-app/immich-server:release", URL: "http://127.0.0.1:1"})
if ic.ContentType != "image/svg+xml" {
t.Errorf("Photos on immich-server: content type = %q, want the immich SVG", ic.ContentType)
}
}
func TestIconLabelWins(t *testing.T) {
cdn := serve(t, map[string][]byte{"/svg/gitea.svg": svgIcon, "/png/forgejo.png": pngIcon})
r := New("", cdn.URL, time.Hour, quiet)
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "Gitea", Image: "gitea/gitea", IconHint: "Forgejo.png", URL: "http://127.0.0.1:1"})
if ic.ContentType != "image/png" {
t.Errorf("content type = %q, want the PNG named by the icon label", ic.ContentType)
}
}
func TestFaviconLargestLink(t *testing.T) {
page := []byte(`<html><head>
<link rel="icon" href="/small.png" sizes="16x16">
<link rel="mask-icon" href="/mask.svg">
<link rel=apple-touch-icon href="/touch.png">
<link rel="shortcut icon" href="/big.png" sizes="192x192">
</head></html>`)
site := serve(t, map[string][]byte{"/": page, "/big.png": append(slices.Clone(pngIcon), 'b'), "/small.png": pngIcon})
r := New("", "", time.Hour, quiet)
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "No Match", URL: site.URL + "/"})
if string(ic.Data) != string(pngIcon)+"b" {
t.Errorf("got %d bytes, want the 192 px icon", len(ic.Data))
}
}
func TestInternalURLPreferred(t *testing.T) {
internal := serve(t, map[string][]byte{"/favicon.ico": pngIcon})
r := New("", "", time.Hour, quiet)
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "x", URL: "http://127.0.0.1:1", InternalURL: internal.URL})
if ic.Version == "" {
t.Error("no icon from internal_url")
}
}
func TestLoginRedirectIsNotAnIcon(t *testing.T) {
login := serve(t, map[string][]byte{"/favicon.ico": pngIcon, "/": []byte(`<link rel="icon" href="/favicon.ico">`)})
app := httptest.NewServer(http.RedirectHandler(login.URL+"/", http.StatusFound))
defer app.Close()
r := New("", "", time.Hour, quiet)
if ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "x", URL: app.URL}); ic.Version != "" {
t.Error("took the login page's icon after a redirect to another host")
}
}
func TestHTMLIsNotAnIcon(t *testing.T) {
site := serve(t, map[string][]byte{"/favicon.ico": []byte("<!doctype html><html><body>Sign in</body></html>")})
r := New("", "", time.Hour, quiet)
if ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "x", URL: site.URL}); ic.Version != "" {
t.Error("took an HTML page as an icon")
}
if r.URL("000000000001") != "" {
t.Error("an app without an icon got an icon URL")
}
}
func TestCacheSurvivesRestart(t *testing.T) {
dir := t.TempDir()
cdn := serve(t, map[string][]byte{"/svg/jellyfin.svg": svgIcon})
app := apps.App{ID: "000000000001", Name: "Jellyfin", URL: "http://127.0.0.1:1"}
first := resolveOne(t, New(dir, cdn.URL, time.Hour, quiet), app)
again := New(dir, cdn.URL, time.Hour, quiet)
ic, ok := again.Lookup(app.ID)
if !ok || ic.Version != first.Version || string(ic.Data) != string(svgIcon) {
t.Fatalf("after restart: ok %v, version %q; want %q", ok, ic.Version, first.Version)
}
again.Ensure([]apps.App{app})
if len(again.queue) != 0 {
t.Error("a fresh cached icon was looked up again")
}
again.Ensure(nil)
if _, ok := again.Lookup(app.ID); ok {
t.Error("the icon of a removed app was kept")
}
}
func TestEnsureRequeuesChangedLabels(t *testing.T) {
r := New("", "", time.Hour, quiet)
app := apps.App{ID: "000000000001", Name: "x", URL: "http://127.0.0.1:1"}
r.entries[app.ID] = &entry{Key: inputKey(app), Checked: time.Now()}
r.Ensure([]apps.App{app})
if len(r.queue) != 0 {
t.Fatal("an unchanged app was queued")
}
app.IconHint = "jellyfin"
r.Ensure([]apps.App{app})
if len(r.queue) != 1 {
t.Error("an app with a new icon label was not queued")
}
}
func TestIconLinks(t *testing.T) {
base, _ := url.Parse("https://app.example/sub/page")
page := []byte(`<LINK REL="icon" HREF="icon.svg"><link rel='icon' href='data:image/png;base64,AA'><link rel="stylesheet" href="x.css"><link rel="icon" href="/a.png?x=1&amp;y=2" sizes="32x32 64x64">`)
got := iconLinks(page, base)
want := []string{"https://app.example/sub/icon.svg", "https://app.example/a.png?x=1&y=2"}
if !slices.Equal(got, want) {
t.Errorf("iconLinks = %q, want %q", got, want)
}
}
func TestImageNames(t *testing.T) {
tests := map[string][]string{
"lscr.io/linuxserver/jellyfin:latest": {"jellyfin"},
"ghcr.io/immich-app/immich-server:release": {"immich-server", "immich"},
"gitea/gitea": {"gitea"},
"localhost:5000/team/vaultwarden@sha256:0": {"vaultwarden"},
"sha256:4f1c2d3e": nil,
"": nil,
}
for image, want := range tests {
if got := imageNames(image); !slices.Equal(got, want) {
t.Errorf("imageNames(%q) = %q, want %q", image, got, want)
}
}
}
func TestSlug(t *testing.T) {
tests := map[string]string{"Uptime Kuma": "uptime-kuma", " Home--Assistant! ": "home-assistant", "ÄBC": "bc", "": ""}
for in, want := range tests {
if got := slug(in); got != want {
t.Errorf("slug(%q) = %q, want %q", in, got, want)
}
}
}
+218
View File
@@ -0,0 +1,218 @@
// Package peer lets the primary pull app lists and icons from agents on other
// Docker hosts, authenticating with the shared PEER_TOKEN.
package peer
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
"git.mariosem.es/msemes/hoda/internal/icons"
)
const (
requestTimeout = 3 * time.Second
maxBodyBytes = 4 << 20
maxIconBytes = 1 << 20
)
// Puller fetches each agent's apps on an interval and its icons on demand.
type Puller struct {
peers []string
token string
interval time.Duration
store *apps.Store
log *slog.Logger
client *http.Client
mu sync.Mutex
names map[string]string // agent URL -> host name it last reported
up map[string]bool // agent URL -> reachable on the last pull
icons map[string]icons.Icon // app ID -> icon fetched from its agent
}
// New returns a Puller for the agents at peers.
func New(peers []string, token string, interval time.Duration, store *apps.Store, log *slog.Logger) *Puller {
return &Puller{
peers: peers,
token: token,
interval: interval,
store: store,
log: log,
client: &http.Client{Timeout: requestTimeout},
names: map[string]string{},
up: map[string]bool{},
icons: map[string]icons.Icon{},
}
}
// Run pulls every agent now and then once per interval until ctx ends.
func (p *Puller) Run(ctx context.Context) {
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
var wg sync.WaitGroup
for _, peer := range p.peers {
wg.Add(1)
go func() {
defer wg.Done()
p.pull(ctx, peer)
}()
}
wg.Wait()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (p *Puller) pull(ctx context.Context, peer string) {
var body apps.Response
err := p.get(ctx, peer+"/api/apps", func(resp *http.Response) error {
return json.NewDecoder(io.LimitReader(resp.Body, maxBodyBytes)).Decode(&body)
})
if err == nil && len(body.Hosts) == 0 {
err = errors.New("the agent reported no host")
}
if err != nil {
if ctx.Err() != nil {
return
}
p.store.SetError(peer, p.name(peer))
p.mark(peer, false, err)
return
}
status := body.Hosts[0]
list := make([]apps.App, 0, len(body.Apps))
for _, a := range body.Apps {
// The agent holds the token, but a bad link from it would still end up in an href.
if !apps.ValidID(a.ID) || !apps.IsWebURL(a.URL) {
continue
}
if !strings.HasPrefix(a.Icon, "/icons/"+a.ID+"?") {
a.Icon = ""
}
if a.Host == "" {
a.Host = status.Name
}
list = append(list, a)
}
p.mu.Lock()
p.names[peer] = status.Name
p.mu.Unlock()
p.store.SetReported(peer, status, list)
p.mark(peer, true, nil)
p.pruneIcons()
}
// Icon returns an agent's icon for one of its apps, fetched once per icon version.
func (p *Puller) Icon(ctx context.Context, id string) (icons.Icon, bool) {
key, app, ok := p.store.Lookup(id)
if !ok || key == apps.LocalSource || app.Icon == "" {
return icons.Icon{}, false
}
version := ""
if u, err := url.Parse(app.Icon); err == nil {
version = u.Query().Get("v")
}
p.mu.Lock()
cached, hit := p.icons[id]
p.mu.Unlock()
if hit && cached.Version == version {
return cached, true
}
var ic icons.Icon
err := p.get(ctx, key+"/icons/"+id, func(resp *http.Response) error {
data, err := io.ReadAll(io.LimitReader(resp.Body, maxIconBytes))
if err != nil {
return err
}
ctype := resp.Header.Get("Content-Type")
if !strings.HasPrefix(ctype, "image/") {
return fmt.Errorf("agent sent %q, not an image", ctype)
}
ic = icons.Icon{Data: data, ContentType: ctype, Version: version}
return nil
})
if err != nil {
p.log.Debug("fetching an icon from an agent failed", "agent", key, "app", app.Name, "err", err)
return icons.Icon{}, false
}
p.mu.Lock()
p.icons[id] = ic
p.mu.Unlock()
return ic, true
}
func (p *Puller) get(ctx context.Context, link string, read func(*http.Response) error) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+p.token)
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", link, resp.Status)
}
return read(resp)
}
// name is the host name an agent last reported, or its URL's host before that.
func (p *Puller) name(peer string) string {
p.mu.Lock()
defer p.mu.Unlock()
if n := p.names[peer]; n != "" {
return n
}
if u, err := url.Parse(peer); err == nil && u.Hostname() != "" {
return u.Hostname()
}
return peer
}
// mark logs only when an agent goes up or down, not on every pull.
func (p *Puller) mark(peer string, up bool, err error) {
p.mu.Lock()
was, seen := p.up[peer]
p.up[peer] = up
p.mu.Unlock()
switch {
case up && (!seen || !was):
p.log.Info("agent reachable", "agent", peer)
case !up && (!seen || was):
p.log.Warn("can't reach agent, keeping its last known apps", "agent", peer, "err", err)
}
}
func (p *Puller) pruneIcons() {
p.mu.Lock()
ids := make([]string, 0, len(p.icons))
for id := range p.icons {
ids = append(ids, id)
}
p.mu.Unlock()
for _, id := range ids {
if _, _, ok := p.store.Lookup(id); !ok {
p.mu.Lock()
delete(p.icons, id)
p.mu.Unlock()
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package peer
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
)
var (
immichID = apps.ID("host-2", "immich")
quiet = slog.New(slog.NewTextHandler(io.Discard, nil))
)
func agent(t *testing.T, iconHits *atomic.Int32) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer s3cret" {
http.Error(w, "no", http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/api/apps":
_ = json.NewEncoder(w).Encode(apps.Response{
Node: "host-2",
Hosts: []apps.HostStatus{{Name: "host-2", OK: true}},
Apps: []apps.App{
{ID: immichID, Name: "Immich", URL: "https://photos.mariosem.es", Group: "Media", Host: "host-2", Icon: "/icons/" + immichID + "?v=v1"},
{ID: apps.ID("host-2", "evil"), Name: "Evil", URL: "javascript:alert(1)", Host: "host-2"},
{ID: "../../etc", Name: "Bad ID", URL: "https://x.example", Host: "host-2"},
},
})
case "/icons/" + immichID:
iconHits.Add(1)
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("png"))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestPull(t *testing.T) {
var hits atomic.Int32
srv := agent(t, &hits)
store := apps.NewStore()
p := New([]string{srv.URL}, "s3cret", time.Second, store, quiet)
p.pull(context.Background(), srv.URL)
hosts, list := store.Snapshot(nil)
if len(list) != 1 || list[0].Name != "Immich" {
t.Fatalf("apps = %+v, want only the valid app", list)
}
if len(hosts) != 1 || !hosts[0].OK || hosts[0].Name != "host-2" {
t.Errorf("hosts = %+v", hosts)
}
ic, ok := p.Icon(context.Background(), immichID)
if !ok || string(ic.Data) != "png" || ic.Version != "v1" {
t.Fatalf("Icon = %+v, %v", ic, ok)
}
p.Icon(context.Background(), immichID)
if hits.Load() != 1 {
t.Errorf("icon fetched %d times, want once per version", hits.Load())
}
srv.Close()
p.pull(context.Background(), srv.URL)
hosts, list = store.Snapshot(nil)
if len(list) != 1 || hosts[0].OK || hosts[0].Name != "host-2" || hosts[0].LastOK == nil {
t.Errorf("after the agent went away: hosts %+v, %d apps; want it unreachable with its apps kept", hosts, len(list))
}
}
func TestWrongToken(t *testing.T) {
var hits atomic.Int32
srv := agent(t, &hits)
store := apps.NewStore()
p := New([]string{srv.URL}, "wrong", time.Second, store, quiet)
p.pull(context.Background(), srv.URL)
hosts, list := store.Snapshot(nil)
if len(list) != 0 || len(hosts) != 1 || hosts[0].OK || hosts[0].Name != "127.0.0.1" {
t.Errorf("hosts %+v, apps %+v; want the agent unreachable under its address", hosts, list)
}
}
+187
View File
@@ -0,0 +1,187 @@
// 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) {
hosts, list := s.deps.Store.Snapshot(s.deps.Icons.URL)
body, err := json.Marshal(apps.Response{Node: s.cfg.NodeName, Hosts: hosts, Apps: list})
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)
})
}
+141
View File
@@ -0,0 +1,141 @@
package server
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.mariosem.es/msemes/hoda/internal/apps"
"git.mariosem.es/msemes/hoda/internal/config"
"git.mariosem.es/msemes/hoda/internal/icons"
)
type fakeIcons map[string]icons.Icon
func (f fakeIcons) URL(id string) string {
if ic, ok := f[id]; ok {
return "/icons/" + id + "?v=" + ic.Version
}
return ""
}
func (f fakeIcons) Lookup(id string) (icons.Icon, bool) {
ic, ok := f[id]
return ic, ok
}
var giteaID = apps.ID("host-1", "gitea")
func newHandler(mode config.Mode) http.Handler {
store := apps.NewStore()
store.SetApps(apps.LocalSource, "host-1", []apps.App{{
ID: giteaID, Name: "Gitea", URL: "https://git.mariosem.es", Group: "Dev", State: apps.StateRunning,
Host: "host-1", InternalURL: "http://gitea:3000", Image: "gitea/gitea",
}})
cfg := config.Config{
Mode: mode, NodeName: "host-1", Title: "HODA", LinkTarget: "_blank",
LabelPrefix: "sema.dashboard", PeerToken: "s3cret",
}
ic := fakeIcons{giteaID: {Data: []byte("<svg></svg>"), ContentType: "image/svg+xml", Version: "abc123"}}
return New(cfg, Deps{Store: store, Icons: ic}, "1.0.0", slog.New(slog.NewTextHandler(io.Discard, nil)))
}
func do(h http.Handler, path string, header map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
for k, v := range header {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestAPIApps(t *testing.T) {
h := newHandler(config.ModePrimary)
rec := do(h, "/api/apps", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var body apps.Response
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body.Apps) != 1 || body.Apps[0].Icon != "/icons/"+giteaID+"?v=abc123" || body.Node != "host-1" {
t.Errorf("body = %+v", body)
}
if strings.Contains(rec.Body.String(), "gitea:3000") || strings.Contains(rec.Body.String(), "gitea/gitea") {
t.Errorf("the response leaks internal fields: %s", rec.Body)
}
etag := rec.Header().Get("ETag")
if again := do(h, "/api/apps", map[string]string{"If-None-Match": etag}); again.Code != http.StatusNotModified {
t.Errorf("unchanged list: status = %d, want 304", again.Code)
}
}
func TestAgentNeedsToken(t *testing.T) {
h := newHandler(config.ModeAgent)
for _, path := range []string{"/api/apps", "/icons/" + giteaID} {
if rec := do(h, path, nil); rec.Code != http.StatusUnauthorized {
t.Errorf("%s without a token: status = %d, want 401", path, rec.Code)
}
if rec := do(h, path, map[string]string{"Authorization": "Bearer wrong"}); rec.Code != http.StatusUnauthorized {
t.Errorf("%s with a wrong token: status = %d, want 401", path, rec.Code)
}
if rec := do(h, path, map[string]string{"Authorization": "Bearer s3cret"}); rec.Code != http.StatusOK {
t.Errorf("%s with the token: status = %d, want 200", path, rec.Code)
}
}
if rec := do(h, "/healthz", nil); rec.Code != http.StatusOK {
t.Errorf("agent healthz: status = %d, want 200 without a token", rec.Code)
}
if rec := do(h, "/static/app.js", nil); rec.Code != http.StatusNotFound {
t.Errorf("agent static: status = %d, want 404", rec.Code)
}
}
func TestIconHeaders(t *testing.T) {
h := newHandler(config.ModePrimary)
rec := do(h, "/icons/"+giteaID+"?v=abc123", nil)
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/svg+xml" {
t.Fatalf("status %d, type %q", rec.Code, rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Header().Get("Content-Security-Policy"), "sandbox") {
t.Error("an icon is served without the sandbox policy")
}
if !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
t.Error("a versioned icon URL is not cached")
}
for _, path := range []string{"/icons/unknown00000", "/icons/..%2f..%2fetc", "/icons/" + strings.Repeat("a", 12)} {
if rec := do(h, path, nil); rec.Code == http.StatusOK {
t.Errorf("%s: status = %d, want no icon", path, rec.Code)
}
}
}
func TestPage(t *testing.T) {
h := newHandler(config.ModePrimary)
rec := do(h, "/", nil)
body := rec.Body.String()
if rec.Code != http.StatusOK || !strings.Contains(body, "<title>HODA</title>") || !strings.Contains(body, `/static/app.js?v=1.0.0`) {
t.Errorf("page: status %d\n%s", rec.Code, body)
}
if !strings.Contains(rec.Header().Get("Content-Security-Policy"), "script-src 'self'") {
t.Error("the page is served without a content security policy")
}
for _, path := range []string{"/static/app.js", "/static/app.css", "/static/favicon.svg"} {
if rec := do(h, path, nil); rec.Code != http.StatusOK {
t.Errorf("%s: status = %d", path, rec.Code)
}
}
if rec := do(h, "/static/app.js?v=1.0.0", nil); !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
t.Error("versioned static assets are not cached")
}
if rec := do(h, "/nope", nil); rec.Code != http.StatusNotFound {
t.Errorf("/nope: status = %d, want 404", rec.Code)
}
}
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}}</title>
<link rel="icon" href="/static/favicon.svg?v={{.Version}}" type="image/svg+xml">
<link rel="stylesheet" href="/static/app.css?v={{.Version}}">
<script src="/static/app.js?v={{.Version}}" defer></script>
</head>
<body data-link-target="{{.LinkTarget}}" data-label-prefix="{{.LabelPrefix}}">
<header class="top">
<h1 class="title">{{.Title}}</h1>
<div class="search">
<input id="search" type="search" placeholder="Search apps" autocomplete="off" spellcheck="false" aria-label="Search apps">
<kbd aria-hidden="true">/</kbd>
</div>
</header>
<div id="banners" class="banners" role="status" aria-live="polite"></div>
<main id="groups" class="groups" aria-busy="true"></main>
<noscript><p class="note">HODA needs JavaScript to list your apps.</p></noscript>
</body>
</html>
+254
View File
@@ -0,0 +1,254 @@
:root {
color-scheme: light dark;
--bg: #f6f6f4;
--surface: #ffffff;
--border: #dcdbd5;
--border-strong: #a9a79e;
--text: #1d1d1b;
--muted: #64635d;
--focus: #2f6fdb;
--good: #1a9e3a;
--warn: #c98000;
--off: #a9a79e;
--banner-bg: #fff3d6;
--banner-text: #573a00;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161615;
--surface: #1f1f1d;
--border: #34332f;
--border-strong: #5c5b54;
--text: #ededea;
--muted: #a5a49d;
--focus: #6ea2ff;
--good: #3cc35e;
--warn: #f0a92a;
--off: #6b6a63;
--banner-bg: #3a2e12;
--banner-text: #f6dca0;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-size: 15px;
line-height: 1.4;
}
.top {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.title {
margin: 0;
font-size: 18px;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search { position: relative; flex: 0 1 280px; }
.search input {
width: 100%;
padding: 8px 36px 8px 12px;
font: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 8px;
}
.search input:focus { outline: 2px solid var(--focus); outline-offset: 1px; }
.search kbd {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
padding: 1px 6px;
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 4px;
pointer-events: none;
}
.search input:focus + kbd { display: none; }
@media (hover: none) {
.search kbd { display: none; }
}
.banners:empty { display: none; }
.banner {
max-width: 1440px;
margin: 16px auto 0;
padding: 10px 14px;
color: var(--banner-text);
background: var(--banner-bg);
border-radius: 8px;
}
.banners { padding: 0 24px; }
.groups { max-width: 1440px; margin: 0 auto; padding: 8px 24px 40px; }
.group-name {
margin: 24px 0 10px;
font-size: 13px;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.tile {
position: relative;
display: flex;
align-items: center;
gap: 12px;
min-height: 64px;
padding: 12px 28px 12px 12px;
color: inherit;
text-decoration: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
transition: border-color 0.12s, transform 0.12s;
}
.tile:hover { border-color: var(--border-strong); transform: translateY(-1px); }
.tile:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.tile.state-stopped { opacity: 0.5; }
.icon {
flex: none;
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: contain;
}
.letter {
display: grid;
place-items: center;
font-size: 17px;
font-weight: 650;
color: hsl(var(--hue) 45% 25%);
background: hsl(var(--hue) 70% 88%);
}
@media (prefers-color-scheme: dark) {
.letter { color: hsl(var(--hue) 70% 85%); background: hsl(var(--hue) 35% 25%); }
}
.text { display: flex; flex-direction: column; min-width: 0; }
.name,
.desc {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name { font-weight: 600; }
.desc { font-size: 13px; color: var(--muted); }
.dot {
position: absolute;
top: 10px;
right: 10px;
width: 8px;
height: 8px;
background: var(--good);
border-radius: 50%;
}
.state-unhealthy .dot { background: var(--warn); }
.state-stopped .dot { background: var(--off); }
.note { margin: 32px 0; color: var(--muted); }
.empty {
max-width: 560px;
margin: 48px auto;
padding: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
}
.empty h2 { margin: 0 0 8px; font-size: 18px; }
.empty p { margin: 0 0 16px; color: var(--muted); }
.snippet {
margin: 0 0 12px;
padding: 12px;
overflow-x: auto;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}
.copy {
padding: 6px 14px;
font: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 8px;
cursor: pointer;
}
.copy:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 480px) {
.top { flex-wrap: wrap; padding: 12px 16px; }
.search { flex-basis: 100%; }
.banners { padding: 0 16px; }
.groups { padding: 4px 16px 32px; }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.tile { gap: 10px; padding: 10px 22px 10px 10px; }
.desc { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.tile { transition: none; }
.tile:hover { transform: none; }
}
+225
View File
@@ -0,0 +1,225 @@
// HODA dashboard: loads /api/apps, shows the apps as tiles grouped by their
// group label, and keeps them current by polling with ETags. Search filters as
// you type; "/" focuses it and Enter opens the first match.
(() => {
"use strict";
const POLL_MS = 10_000;
const STATE_LABELS = { running: "Running", unhealthy: "Unhealthy", stopped: "Stopped" };
const groupsEl = document.getElementById("groups");
const bannersEl = document.getElementById("banners");
const searchEl = document.getElementById("search");
const linkTarget = document.body.dataset.linkTarget === "_self" ? "_self" : "_blank";
const labelPrefix = document.body.dataset.labelPrefix || "sema.dashboard";
let data = null;
let etag = "";
let inFlight = false;
let offline = false;
async function load() {
if (inFlight) return;
inFlight = true;
try {
const res = await fetch("/api/apps", {
cache: "no-store",
headers: etag ? { "If-None-Match": etag } : {},
});
if (res.status === 304) {
setOffline(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.json();
etag = res.headers.get("ETag") || "";
offline = false;
render();
} catch {
setOffline(true);
} finally {
inFlight = false;
groupsEl.removeAttribute("aria-busy");
}
}
function setOffline(value) {
if (offline === value) return;
offline = value;
renderBanners();
}
function render() {
renderBanners();
if (!data) return;
const focusedId = document.activeElement?.dataset?.id;
const terms = searchEl.value.toLowerCase().split(/\s+/).filter(Boolean);
const visible = data.apps.filter((app) => matches(app, terms));
const sections = [];
if (data.apps.length === 0) {
sections.push(emptyState());
} else if (visible.length === 0) {
sections.push(el("p", "note", `No apps match “${searchEl.value.trim()}”.`));
} else {
for (const [group, list] of groupBy(visible)) {
const section = el("section", "group");
const grid = el("div", "grid");
grid.append(...list.map(tile));
section.append(el("h2", "group-name", group), grid);
sections.push(section);
}
}
groupsEl.replaceChildren(...sections);
if (focusedId) groupsEl.querySelector(`[data-id="${CSS.escape(focusedId)}"]`)?.focus();
}
function renderBanners() {
const banners = [];
if (offline) banners.push(el("p", "banner", "Can't reach HODA right now. Retrying every 10 seconds."));
for (const host of data?.hosts ?? []) {
if (host.ok) continue;
const since = host.lastOk ? `, showing its apps from ${clock(host.lastOk)}` : "";
banners.push(el("p", "banner", `Can't reach ${host.name}${since}.`));
}
bannersEl.replaceChildren(...banners);
}
function matches(app, terms) {
if (terms.length === 0) return true;
const text = [app.name, app.description, app.group, app.host].join(" ").toLowerCase();
return terms.every((term) => text.includes(term));
}
// The server sends apps in display order, so grouping keeps that order.
function groupBy(apps) {
const groups = new Map();
for (const app of apps) {
if (!groups.has(app.group)) groups.set(app.group, []);
groups.get(app.group).push(app);
}
return groups;
}
function tile(app) {
const state = STATE_LABELS[app.state] ? app.state : "stopped";
const a = el("a", `tile state-${state}`);
a.href = app.url;
a.dataset.id = app.id;
a.title = `${app.name} on ${app.host}`;
if (linkTarget === "_blank") {
a.target = "_blank";
a.rel = "noopener noreferrer";
}
const text = el("span", "text");
text.append(el("span", "name", app.name));
if (app.description) text.append(el("span", "desc", app.description));
const dot = el("span", "dot");
dot.title = STATE_LABELS[state];
a.append(icon(app), text, dot, el("span", "visually-hidden", `, ${STATE_LABELS[state]}`));
return a;
}
function icon(app) {
if (!app.icon) return letter(app);
const img = el("img", "icon");
img.src = app.icon;
img.alt = "";
img.width = 40;
img.height = 40;
img.decoding = "async";
img.addEventListener("error", () => img.replaceWith(letter(app)), { once: true });
return img;
}
function letter(app) {
const first = (app.name.match(/[\p{L}\p{N}]/u) || ["?"])[0].toUpperCase();
const span = el("span", "icon letter", first);
span.style.setProperty("--hue", String(hue(app.name)));
span.setAttribute("aria-hidden", "true");
return span;
}
// A hue derived from the name, so a letter tile keeps its colour across reloads.
function hue(text) {
let h = 0;
for (const ch of text) h = (h * 31 + ch.codePointAt(0)) >>> 0;
return h % 360;
}
function emptyState() {
const snippet = [
"labels:",
` ${labelPrefix}.enable: "true"`,
` ${labelPrefix}.name: My app`,
` ${labelPrefix}.url: https://myapp.example.com`,
].join("\n");
const copy = el("button", "copy", "Copy");
copy.type = "button";
copy.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(snippet);
copy.textContent = "Copied";
} catch {
copy.textContent = "Copy failed";
}
});
const box = el("div", "empty");
box.append(
el("h2", "", "No apps yet"),
el("p", "", "Add these labels to a service in its docker-compose file, then run docker compose up -d."),
el("pre", "snippet", snippet),
copy,
);
return box;
}
function clock(iso) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function openTile(a) {
if (a.target === "_blank") window.open(a.href, "_blank", "noopener,noreferrer");
else window.location.assign(a.href);
}
function isTyping(target) {
return target instanceof HTMLElement && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
}
searchEl.addEventListener("input", render);
searchEl.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
searchEl.value = "";
render();
searchEl.blur();
} else if (event.key === "Enter") {
const first = groupsEl.querySelector("a.tile");
if (first) {
event.preventDefault();
openTile(first);
}
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "/" && !event.ctrlKey && !event.metaKey && !event.altKey && !isTyping(event.target)) {
event.preventDefault();
searchEl.focus();
searchEl.select();
}
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden) load();
});
setInterval(() => {
if (!document.hidden) load();
}, POLL_MS);
load();
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#1d1d1b"/><rect x="7" y="7" width="8" height="8" rx="2" fill="#6ea2ff"/><rect x="17" y="7" width="8" height="8" rx="2" fill="#ededea"/><rect x="7" y="17" width="8" height="8" rx="2" fill="#ededea"/><rect x="17" y="17" width="8" height="8" rx="2" fill="#3cc35e"/></svg>

After

Width:  |  Height:  |  Size: 371 B