From 9d6a921c34fb8821284e1742dff9e44297d9bbcf Mon Sep 17 00:00:00 2001 From: TMC Date: Mon, 2 Nov 2026 21:18:20 +0100 Subject: [PATCH] Start HODA: a homelab dashboard built from Docker labels 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 --- .dockerignore | 7 + .gitattributes | 1 + .gitea/workflows/ci.yml | 118 +++++++++ .gitignore | 11 + Dockerfile | 41 +++ README.md | 94 +++++++ cmd/hoda/main.go | 116 +++++++++ deploy/.env.example | 5 + deploy/agent/compose.yml | 34 +++ deploy/primary/compose.yml | 45 ++++ go.mod | 3 + internal/apps/app.go | 127 ++++++++++ internal/apps/app_test.go | 146 +++++++++++ internal/apps/store.go | 136 ++++++++++ internal/apps/store_test.go | 77 ++++++ internal/config/config.go | 112 +++++++++ internal/config/config_test.go | 65 +++++ internal/discovery/discovery.go | 179 ++++++++++++++ internal/discovery/discovery_test.go | 77 ++++++ internal/docker/client.go | 152 ++++++++++++ internal/docker/client_test.go | 82 ++++++ internal/icons/fetch.go | 271 ++++++++++++++++++++ internal/icons/icons.go | 329 +++++++++++++++++++++++++ internal/icons/icons_test.go | 199 +++++++++++++++ internal/peer/peer.go | 218 ++++++++++++++++ internal/peer/peer_test.go | 94 +++++++ internal/server/server.go | 187 ++++++++++++++ internal/server/server_test.go | 141 +++++++++++ internal/server/web/index.html | 24 ++ internal/server/web/static/app.css | 254 +++++++++++++++++++ internal/server/web/static/app.js | 225 +++++++++++++++++ internal/server/web/static/favicon.svg | 1 + 32 files changed, 3571 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/hoda/main.go create mode 100644 deploy/.env.example create mode 100644 deploy/agent/compose.yml create mode 100644 deploy/primary/compose.yml create mode 100644 go.mod create mode 100644 internal/apps/app.go create mode 100644 internal/apps/app_test.go create mode 100644 internal/apps/store.go create mode 100644 internal/apps/store_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/discovery/discovery.go create mode 100644 internal/discovery/discovery_test.go create mode 100644 internal/docker/client.go create mode 100644 internal/docker/client_test.go create mode 100644 internal/icons/fetch.go create mode 100644 internal/icons/icons.go create mode 100644 internal/icons/icons_test.go create mode 100644 internal/peer/peer.go create mode 100644 internal/peer/peer_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/web/index.html create mode 100644 internal/server/web/static/app.css create mode 100644 internal/server/web/static/app.js create mode 100644 internal/server/web/static/favicon.svg diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6caa674 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitea +deploy +data +*.md +.env +.env.* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d48e34e --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5385bc7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa16519 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fa2a00 --- /dev/null +++ b/README.md @@ -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 ` | +| `/icons/` | 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-`; a `v1.2.3` tag +publishes `:1.2.3`. Images are scanned with Trivy, and a CRITICAL finding blocks the push. diff --git a/cmd/hoda/main.go b/cmd/hoda/main.go new file mode 100644 index 0000000..0a1cd2d --- /dev/null +++ b/cmd/hoda/main.go @@ -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 +} diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..44c728a --- /dev/null +++ b/deploy/.env.example @@ -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 diff --git a/deploy/agent/compose.yml b/deploy/agent/compose.yml new file mode 100644 index 0000000..917a478 --- /dev/null +++ b/deploy/agent/compose.yml @@ -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 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: + - ":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: diff --git a/deploy/primary/compose.yml b/deploy/primary/compose.yml new file mode 100644 index 0000000..559aad1 --- /dev/null +++ b/deploy/primary/compose.yml @@ -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 . 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://: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: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cad4b76 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.mariosem.es/msemes/hoda + +go 1.24 diff --git a/internal/apps/app.go b/internal/apps/app.go new file mode 100644 index 0000000..de11018 --- /dev/null +++ b/internal/apps/app.go @@ -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 "" +} diff --git a/internal/apps/app_test.go b/internal/apps/app_test.go new file mode 100644 index 0000000..f73f1c9 --- /dev/null +++ b/internal/apps/app_test.go @@ -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) + } + } +} diff --git a/internal/apps/store.go b/internal/apps/store.go new file mode 100644 index 0000000..9a32e74 --- /dev/null +++ b/internal/apps/store.go @@ -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), + ) +} diff --git a/internal/apps/store_test.go b/internal/apps/store_test.go new file mode 100644 index 0000000..6394ea6 --- /dev/null +++ b/internal/apps/store_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..438e873 --- /dev/null +++ b/internal/config/config.go @@ -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...) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..97f720e --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } + } +} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..1116934 --- /dev/null +++ b/internal/discovery/discovery.go @@ -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 +} diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go new file mode 100644 index 0000000..27c3647 --- /dev/null +++ b/internal/discovery/discovery_test.go @@ -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) + } + } +} diff --git a/internal/docker/client.go b/internal/docker/client.go new file mode 100644 index 0000000..16a5f29 --- /dev/null +++ b/internal/docker/client.go @@ -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) +} diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go new file mode 100644 index 0000000..ad4a2c9 --- /dev/null +++ b/internal/docker/client_test.go @@ -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) + } + } +} diff --git a/internal/icons/fetch.go b/internal/icons/fetch.go new file mode 100644 index 0000000..9adc6f9 --- /dev/null +++ b/internal/icons/fetch.go @@ -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("]*>`) + 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(), "-") +} diff --git a/internal/icons/icons.go b/internal/icons/icons.go new file mode 100644 index 0000000..8b2c80a --- /dev/null +++ b/internal/icons/icons.go @@ -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 +} diff --git a/internal/icons/icons_test.go b/internal/icons/icons_test.go new file mode 100644 index 0000000..2bb050b --- /dev/null +++ b/internal/icons/icons_test.go @@ -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(``) + 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(` + + + + + `) + 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(``)}) + 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("Sign in")}) + 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(``) + 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) + } + } +} diff --git a/internal/peer/peer.go b/internal/peer/peer.go new file mode 100644 index 0000000..52d6169 --- /dev/null +++ b/internal/peer/peer.go @@ -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() + } + } +} diff --git a/internal/peer/peer_test.go b/internal/peer/peer_test.go new file mode 100644 index 0000000..73fc054 --- /dev/null +++ b/internal/peer/peer_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..7e669a5 --- /dev/null +++ b/internal/server/server.go @@ -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) + }) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..db3d67a --- /dev/null +++ b/internal/server/server_test.go @@ -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(""), 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, "HODA") || !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) + } +} diff --git a/internal/server/web/index.html b/internal/server/web/index.html new file mode 100644 index 0000000..fea25c1 --- /dev/null +++ b/internal/server/web/index.html @@ -0,0 +1,24 @@ + + + + + + + {{.Title}} + + + + + +
+

{{.Title}}

+ +
+
+
+ + + diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css new file mode 100644 index 0000000..b3fc60d --- /dev/null +++ b/internal/server/web/static/app.css @@ -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; } +} diff --git a/internal/server/web/static/app.js b/internal/server/web/static/app.js new file mode 100644 index 0000000..e8b064b --- /dev/null +++ b/internal/server/web/static/app.js @@ -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(); +})(); diff --git a/internal/server/web/static/favicon.svg b/internal/server/web/static/favicon.svg new file mode 100644 index 0000000..4d2135a --- /dev/null +++ b/internal/server/web/static/favicon.svg @@ -0,0 +1 @@ +