From 079a2cdd0865ba3138b7ef522f00b9fd96efee40 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 1 Jul 2026 09:02:13 +0200 Subject: [PATCH] =?UTF-8?q?hub=20v0.29.0:=20Day-0=20artifact=20manifest=20?= =?UTF-8?q?=E2=80=94=20version=20dropdowns=20+=20auto-derived=20sha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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). diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 9d4e29d..e828730 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -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() diff --git a/hub/internal/gitea/gitea.go b/hub/internal/gitea/gitea.go new file mode 100644 index 0000000..671f75b --- /dev/null +++ b/hub/internal/gitea/gitea.go @@ -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 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") + } +} diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 00e4c15..6d76c6f 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -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) { diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 4b56cfa..c3904a2 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -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 { diff --git a/hub/internal/web/templates/configuration.html b/hub/internal/web/templates/configuration.html index cac44ad..b46649b 100644 --- a/hub/internal/web/templates/configuration.html +++ b/hub/internal/web/templates/configuration.html @@ -43,7 +43,7 @@
Invalid artifact version — use X.Y.Z (or blank to clear).
{{end}} {{if eq .Flash "artifact_sha_invalid"}} -
Invalid sha256 — use 64 hex chars (or blank to clear).
+
Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.
{{end}}