hub v0.29.0: Day-0 artifact manifest — version dropdowns + auto-derived sha

Operator picks a version from a Gitea-populated dropdown; the hub reads that
version's sha256 from Gitea itself (files-metadata API, no artifact download) and
vouches it — no hand-copied checksums. New internal/gitea read-only client
(ListVersions + FileSHA256, unit-tested). Configuration UI: version <select>s +
read-only sha display; handleSetArtifacts derives the sha authoritatively and
refuses the save on a Gitea lookup failure. Degrades to manual text entry without
registry creds. go build/vet/test clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 09:02:13 +02:00
parent ce26c9d646
commit 079a2cdd08
7 changed files with 375 additions and 12 deletions
+24
View File
@@ -1,5 +1,29 @@
# Felhom Hub — Changelog
## v0.29.0 — Day-0 artifact manifest: version dropdowns + auto-derived sha (2026-07-01)
Removes the hand-copied sha256 from the Day-0 artifact manifest. The operator now **picks a version**
from a dropdown of what's actually in Gitea (olders get pruned), and the hub **reads that version's
sha256 from Gitea itself** — no transcription, no stale checksums. Keeps the human-in-the-loop trust
gate (the operator still deliberately chooses the version; "latest" is never auto-promoted) while the
hub stays the checksum trust root.
- **`internal/gitea`** (new): a minimal read-only Gitea packages client — `ListVersions` (generic
package versions, newest-semver first) + `FileSHA256` (a version's file sha256 via the files-metadata
API, **without downloading** the artifact — important for the ~GB golden). Basic-auth with the
registry creds the hub already holds. Unit-tested against an httptest server (filter+sort, preferred
file match + fallback, non-200 → error).
- **Configuration → Day-0 artifacts:** the two version text inputs are now `<select>` dropdowns
populated from Gitea; the sha256 fields are **read-only, displayed** (mirrored from the picked
version via a tiny inline script). Choosing "— none —" clears an artifact.
- **`handleSetArtifacts`:** derives each chosen version's sha256 from Gitea **authoritatively** (a
client-submitted sha is ignored); a Gitea lookup failure REFUSES the save (never stores a version
with a wrong/blank checksum) rather than corrupting the manifest.
- **Graceful degradation:** with no registry creds (`web.SetGiteaClient` not wired) the form falls back
to the previous manual text-entry path. `main.go` enables the Gitea browser when `REGISTRY_USERNAME`
/`REGISTRY_TOKEN` are set.
- `go build`/`vet`/`test ./...` clean.
## v0.28.0 — global settings → Configuration tab + online setup command (2026-07-01)
Three operator-requested improvements (companion: host-install script v1.2.0).
+8
View File
@@ -14,6 +14,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
@@ -254,6 +255,13 @@ func main() {
webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger)
webServer.SetTemplateFetcher(templateFetcher)
webServer.SetAssetManager(assetsMgr)
// Day-0 artifact version dropdowns: let the operator pick a version and have the hub derive the
// sha256 from Gitea (no hand-copied checksums). Reuses the registry creds; degrades to manual text
// entry when they're absent.
if cfg.Registry.Username != "" && cfg.Registry.Token != "" {
webServer.SetGiteaClient(gitea.New("https://gitea.dooplex.hu", "admin", cfg.Registry.Username, cfg.Registry.Token))
logger.Printf("[INFO] Gitea artifact browser enabled (Day-0 version dropdowns)")
}
// Build HTTP mux
mux := http.NewServeMux()
+134
View File
@@ -0,0 +1,134 @@
// Package gitea is a minimal read-only client for the Gitea generic-package API. It lets the hub
// discover which artifact versions currently exist (olders get pruned) and read each file's sha256
// WITHOUT downloading the artifact — so the operator picks a version in the UI and the hub derives and
// vouches the checksum itself, instead of the operator pasting a 64-hex sha by hand.
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// Client talks to the Gitea packages API with basic auth (the registry username + token the hub
// already holds for the controller image pull).
type Client struct {
baseURL string // e.g. https://gitea.dooplex.hu (no trailing slash)
owner string // package owner, e.g. admin
user string
token string
http *http.Client
}
// New builds a client. baseURL/owner/user/token come from the hub's registry config.
func New(baseURL, owner, user, token string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
owner: owner,
user: user,
token: token,
http: &http.Client{Timeout: 10 * time.Second},
}
}
type pkgEntry struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
}
// ListVersions returns the versions of a generic package, newest-semver first. Non-semver versions
// (if any) sort after the semver ones so nothing is silently dropped.
func (c *Client) ListVersions(ctx context.Context, pkgName string) ([]string, error) {
u := fmt.Sprintf("%s/api/v1/packages/%s?type=generic&q=%s&limit=100",
c.baseURL, c.owner, pkgName)
var pkgs []pkgEntry
if err := c.getJSON(ctx, u, &pkgs); err != nil {
return nil, err
}
var vers []string
for _, p := range pkgs {
if p.Name == pkgName && p.Version != "" {
vers = append(vers, p.Version)
}
}
sort.SliceStable(vers, func(i, j int) bool { return compareSemver(vers[i], vers[j]) > 0 })
return vers, nil
}
type pkgFile struct {
Name string `json:"name"`
SHA256 string `json:"sha256"`
}
// FileSHA256 returns the sha256 of a file inside a generic package version. When the version holds
// multiple files it prefers an exact match to preferredFile (e.g. "golden.tar.zst"); otherwise it uses
// the first file. This is a cheap metadata call — the artifact bytes are never downloaded.
func (c *Client) FileSHA256(ctx context.Context, pkgName, version, preferredFile string) (string, error) {
u := fmt.Sprintf("%s/api/v1/packages/%s/generic/%s/%s/files",
c.baseURL, c.owner, pkgName, version)
var files []pkgFile
if err := c.getJSON(ctx, u, &files); err != nil {
return "", err
}
if len(files) == 0 {
return "", fmt.Errorf("no files listed for %s/%s", pkgName, version)
}
if preferredFile != "" {
for _, f := range files {
if f.Name == preferredFile && f.SHA256 != "" {
return f.SHA256, nil
}
}
}
if files[0].SHA256 == "" {
return "", fmt.Errorf("no sha256 for %s/%s file %q", pkgName, version, files[0].Name)
}
return files[0].SHA256, nil
}
func (c *Client) getJSON(ctx context.Context, url string, out interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.SetBasicAuth(c.user, c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("gitea GET %s: HTTP %d", url, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// compareSemver returns >0 if a>b, 0 if equal, <0 if a<b. Falls back to a lexical compare on a
// non-"X.Y.Z" input (mirrors web.compareVersions; kept local to avoid an import cycle).
func compareSemver(a, b string) int {
a = strings.TrimPrefix(a, "v")
b = strings.TrimPrefix(b, "v")
ap := strings.SplitN(a, ".", 3)
bp := strings.SplitN(b, ".", 3)
if len(ap) != 3 || len(bp) != 3 {
return strings.Compare(a, b)
}
for i := 0; i < 3; i++ {
ai, e1 := strconv.Atoi(ap[i])
bi, e2 := strconv.Atoi(bp[i])
if e1 != nil || e2 != nil {
return strings.Compare(a, b)
}
if ai != bi {
return ai - bi
}
}
return 0
}
+88
View File
@@ -0,0 +1,88 @@
package gitea
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ListVersions filters to the requested package name and returns versions newest-semver first.
func TestListVersions_FilterAndSort(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/packages/admin") {
http.NotFound(w, r)
return
}
// Intentionally unsorted + a foreign package that must be filtered out.
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"name":"felhom-agent","version":"0.43.0","type":"generic"},
{"name":"felhom-agent","version":"0.52.0","type":"generic"},
{"name":"felhom-golden","version":"0.85.1","type":"generic"},
{"name":"felhom-agent","version":"0.9.0","type":"generic"}
]`))
}))
defer srv.Close()
c := New(srv.URL, "admin", "u", "t")
vers, err := c.ListVersions(context.Background(), "felhom-agent")
if err != nil {
t.Fatalf("ListVersions: %v", err)
}
// Only felhom-agent, newest semver first (0.52.0 > 0.43.0 > 0.9.0 — numeric, not lexical).
want := []string{"0.52.0", "0.43.0", "0.9.0"}
if len(vers) != len(want) {
t.Fatalf("got %v, want %v", vers, want)
}
for i := range want {
if vers[i] != want[i] {
t.Fatalf("got %v, want %v", vers, want)
}
}
}
// FileSHA256 prefers an exact filename match; otherwise it falls back to the first file.
func TestFileSHA256_PreferredAndFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/felhom-golden/0.85.1/files"):
w.Write([]byte(`[
{"name":"stray.txt","sha256":"aaaa"},
{"name":"golden.tar.zst","sha256":"f87031cc"}
]`))
case strings.Contains(r.URL.Path, "/felhom-agent/0.52.0/files"):
w.Write([]byte(`[{"name":"felhom-agent","sha256":"5bfc690c"}]`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c := New(srv.URL, "admin", "u", "t")
// preferred-file match among multiple files
sha, err := c.FileSHA256(context.Background(), "felhom-golden", "0.85.1", "golden.tar.zst")
if err != nil || sha != "f87031cc" {
t.Fatalf("golden sha: got %q err %v, want f87031cc", sha, err)
}
// single-file version resolves regardless of preferred name (fallback to first)
sha, err = c.FileSHA256(context.Background(), "felhom-agent", "0.52.0", "felhom-agent")
if err != nil || sha != "5bfc690c" {
t.Fatalf("agent sha: got %q err %v, want 5bfc690c", sha, err)
}
}
// A non-200 from Gitea surfaces as an error (so a save refuses rather than storing a blank sha).
func TestGetJSON_ErrorOnNon200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
}))
defer srv.Close()
c := New(srv.URL, "admin", "u", "t")
if _, err := c.ListVersions(context.Background(), "felhom-agent"); err == nil {
t.Fatal("expected an error on HTTP 500")
}
}
+28 -5
View File
@@ -1,6 +1,7 @@
package web
import (
"context"
"encoding/json"
"fmt"
"html/template"
@@ -635,22 +636,24 @@ func normalizeSHA256(raw string) (string, bool) {
}
// handleSetArtifacts records the operator-vouched current artifact set (agent binary + golden
// archive: version + sha256 each) into hub_settings. This is the checksum TRUST ROOT the
// host-bootstrap script verifies fetched artifacts against. Versions are validated as bare semver
// (reusing the floor validator); sha256s as 64-hex. Empty fields are allowed (clears that field).
// archive) into hub_settings the checksum TRUST ROOT the host-bootstrap script verifies fetched
// artifacts against. The operator picks a VERSION (from the Gitea-populated dropdown); the hub DERIVES
// that version's sha256 from Gitea itself (never trusting a client-supplied checksum), so there is no
// hand-copied sha to get wrong. When no Gitea client is configured (no registry creds) it falls back
// to the submitted sha256 (legacy manual path). Empty version clears that artifact.
func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version"))
agentSHA, okAS := normalizeSHA256(r.FormValue("agent_sha256"))
goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version"))
goldenSHA, okGS := normalizeSHA256(r.FormValue("golden_sha256"))
if !okAV || !okGV {
http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther)
return
}
agentSHA, okAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
goldenSHA, okGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256"))
if !okAS || !okGS {
http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
return
@@ -669,6 +672,26 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther)
}
// resolveArtifactSHA determines the sha256 to store for a chosen artifact version. An empty version
// clears the artifact (returns "",true). With a Gitea client it fetches the sha AUTHORITATIVELY from
// Gitea (the submitted value is ignored — nothing hand-typed to trust); a fetch failure returns
// (_,false) so the caller refuses the save rather than storing a version with a wrong/blank checksum.
// Without a Gitea client it validates + uses the submitted sha (legacy manual path).
func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, bool) {
if version == "" {
return "", true
}
if s.gitea != nil {
sha, err := s.gitea.FileSHA256(ctx, pkg, version, file)
if err != nil {
s.logger.Printf("[WARN] artifact sha resolve (%s/%s): %v", pkg, version, err)
return "", false
}
return sha, true
}
return normalizeSHA256(submittedSHA)
}
// handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor
// override. Empty clears the override (the customer then uses the global floor).
func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, customerID string) {
+57
View File
@@ -17,10 +17,27 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"golang.org/x/crypto/bcrypt"
)
// Generic-package names + the artifact filename inside each version (used to resolve version lists +
// sha256 from Gitea for the Day-0 artifact manifest UI).
const (
pkgAgent = "felhom-agent"
fileAgent = "felhom-agent"
pkgGolden = "felhom-golden"
fileGolden = "golden.tar.zst"
)
// artifactChoice is one selectable artifact version + its Gitea-resolved sha256 (for the dropdown +
// the read-only sha display).
type artifactChoice struct {
Version string
SHA256 string
}
// hubSession holds per-session auth and CSRF data.
type hubSession struct {
expiresAt time.Time
@@ -39,6 +56,7 @@ type Server struct {
versionChecker *VersionChecker
templateFetcher *TemplateFetcher
assetsMgr *assets.Manager
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
sessions map[string]*hubSession
sessionsMu sync.RWMutex
@@ -125,6 +143,42 @@ func (s *Server) SetAssetManager(am *assets.Manager) {
s.assetsMgr = am
}
// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
// degrades to manual text entry.
func (s *Server) SetGiteaClient(c *gitea.Client) {
s.gitea = c
}
// artifactChoices resolves the currently-available versions of a generic package + each one's sha256
// from Gitea, newest first, for the Day-0 artifact dropdown. Returns nil (→ manual text-entry
// fallback) when no Gitea client is configured or Gitea is unreachable. A failed sha lookup for a
// single version drops just that version, not the whole list.
func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifactChoice {
if s.gitea == nil {
return nil
}
vers, err := s.gitea.ListVersions(ctx, pkg)
if err != nil {
s.logger.Printf("[WARN] artifact versions (%s): %v", pkg, err)
return nil
}
const maxChoices = 20
if len(vers) > maxChoices {
s.logger.Printf("[INFO] artifact versions (%s): showing newest %d of %d", pkg, maxChoices, len(vers))
vers = vers[:maxChoices]
}
out := make([]artifactChoice, 0, len(vers))
for _, v := range vers {
sha, err := s.gitea.FileSHA256(ctx, pkg, v, file)
if err != nil {
s.logger.Printf("[WARN] artifact sha (%s/%s): %v", pkg, v, err)
continue
}
out = append(out, artifactChoice{Version: v, SHA256: sha})
}
return out
}
// ServeHTTP routes web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
@@ -586,6 +640,7 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
}
}
ctx := r.Context()
data := map[string]interface{}{
"CSRFToken": csrfToken,
"CSRFField": s.csrfField(r),
@@ -593,6 +648,8 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
"AssetLastSync": assetLastSync,
"GlobalFloor": s.store.GetGlobalMinControllerVersion(),
"Artifacts": s.store.GetArtifactManifest(),
"AgentChoices": s.artifactChoices(ctx, pkgAgent, fileAgent),
"GoldenChoices": s.artifactChoices(ctx, pkgGolden, fileGolden),
"Flash": r.URL.Query().Get("flash"),
}
if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil {
+36 -7
View File
@@ -43,7 +43,7 @@
<div class="flash flash-error">Invalid artifact version — use X.Y.Z (or blank to clear).</div>
{{end}}
{{if eq .Flash "artifact_sha_invalid"}}
<div class="flash flash-error">Invalid sha256 — use 64 hex chars (or blank to clear).</div>
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
{{end}}
<!-- Phase 2 managed updates: global controller-version floor (moved from the Customers page —
@@ -71,20 +71,49 @@
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
The current agent binary + golden archive the host-bootstrap script fetches from Gitea and
verifies (sha256) before installing. The hub vouches for these checksums (a different trust
root than Gitea). Paste the version + sha256 printed by <code>publish-agent.sh</code> /
<code>build-golden.sh</code>. Blank a field to clear it.
root than Gitea). Pick a version — the sha256 is read from Gitea automatically (no manual
copy). Choose <em>— none —</em> to clear an artifact.
</p>
<form method="POST" action="/configuration/artifacts" style="display: grid; grid-template-columns: auto 8em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
<form method="POST" action="/configuration/artifacts" style="display: grid; grid-template-columns: auto 12em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
{{.CSRFField}}
<label style="font-size: 0.9em; color: #cbd5e1;">Agent</label>
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.43.0" style="padding: 0.3em 0.5em;">
<input type="text" name="agent_sha256" value="{{.Artifacts.AgentSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;">
{{if .AgentChoices}}
<select name="agent_version" id="agent_version" onchange="syncArtifactSha('agent')" style="padding: 0.3em 0.5em;">
<option value="" data-sha="">— none —</option>
{{range .AgentChoices}}
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.AgentVersion}}selected{{end}}>{{.Version}}</option>
{{end}}
</select>
{{else}}
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.52.0" style="padding: 0.3em 0.5em;">
{{end}}
<input type="text" name="agent_sha256" id="agent_sha256" value="{{.Artifacts.AgentSHA256}}" {{if .AgentChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .AgentChoices}}opacity: 0.7;{{end}}">
<label style="font-size: 0.9em; color: #cbd5e1;">Golden</label>
{{if .GoldenChoices}}
<select name="golden_version" id="golden_version" onchange="syncArtifactSha('golden')" style="padding: 0.3em 0.5em;">
<option value="" data-sha="">— none —</option>
{{range .GoldenChoices}}
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.GoldenVersion}}selected{{end}}>{{.Version}}</option>
{{end}}
</select>
{{else}}
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
<input type="text" name="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;">
{{end}}
<input type="text" name="golden_sha256" id="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" {{if .GoldenChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .GoldenChoices}}opacity: 0.7;{{end}}">
<span></span><span></span>
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
</form>
<script>
// When a version is picked, mirror that option's Gitea-resolved sha256 into the read-only
// display field. The hub re-derives the sha authoritatively on save regardless of this value.
function syncArtifactSha(kind) {
var sel = document.getElementById(kind + '_version');
var sha = document.getElementById(kind + '_sha256');
if (!sel || !sha) return;
var opt = sel.options[sel.selectedIndex];
sha.value = (opt && opt.getAttribute('data-sha')) || '';
}
</script>
</section>
<!-- Assets section -->