From b25ca60ab7ba3893422458a5f168c475fd4d79bf Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 10 Jul 2026 17:52:03 +0200 Subject: [PATCH] =?UTF-8?q?v0.112.0:=20self-update=20without=20credentials?= =?UTF-8?q?=20=E2=80=94=20anonymous=20Docker=20v2=20token=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (live on Peti's box): the updater piggybacked on Git Sync creds and refused when absent, but the registry serves the public package anonymously (verified 2026-07-10). Credentials become what they were meant to be — optional, for private catalogs only. - queryRegistry: both creds empty → anonymous flow (plain GET → parse WWW-Authenticate realm/service from the header, never hardcoded → credential-free token → Bearer retry); creds present → BasicAuth path unchanged; half-configured pair → loud incomplete-credentials error - pullImage: no creds → skip docker login entirely (docker's native anonymous flow); denied anonymous access → clear 'registry denied anonymous access — a private registry requires Git Sync credentials' - settings page: 'Registry: nyilvános (hitelesítés nélkül) / hitelesített' mode line — credential-less is no longer an error state; DryRun.PullCapable counts anonymous as capable - tests: fake registry httptest token dance (zero creds, no auth on token request, correct scope), creds path unchanged (BasicAuth, no dance), both denial paths, WWW-Authenticate parser table (quoted/bare/order/comma-in-quotes/missing-realm), fake-runner pull tests (no login invoked anonymously; login/pull/logout order + stdin token with creds; partial creds refuse) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- .../internal/selfupdate/registry_anon_test.go | 322 ++++++++++++++++++ controller/internal/selfupdate/updater.go | 251 ++++++++++++-- controller/internal/web/handlers.go | 3 + .../web/templates/settings_system.html | 4 + 4 files changed, 554 insertions(+), 26 deletions(-) create mode 100644 controller/internal/selfupdate/registry_anon_test.go diff --git a/controller/internal/selfupdate/registry_anon_test.go b/controller/internal/selfupdate/registry_anon_test.go new file mode 100644 index 0000000..4bd61c3 --- /dev/null +++ b/controller/internal/selfupdate/registry_anon_test.go @@ -0,0 +1,322 @@ +package selfupdate + +import ( + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// newRegistryUpdater builds an updater pointed at a fake registry with the given creds. +func newRegistryUpdater(t *testing.T, baseURL, user, token string) *Updater { + t.Helper() + cfg := &config.SelfUpdateConfig{Enabled: true, Image: imageBase} + git := &config.GitConfig{Username: user, Token: token} + u := NewUpdater(cfg, git, "0.111.0", t.TempDir(), nil, log.New(io.Discard, "", 0), false) + u.registryBase = baseURL + return u +} + +// fakeRegistry is an httptest server speaking the Docker Registry v2 anonymous flow. +type fakeRegistry struct { + *httptest.Server + mu sync.Mutex + tokenHits int + tokenHadAuth bool + tokenQuery string + tagsBasicAuths []string // Authorization headers seen on tags/list + denyToken bool // token endpoint refuses (private registry) + denyBearerTags bool // tags/list refuses even WITH the bearer +} + +func newFakeRegistry(t *testing.T) *fakeRegistry { + t.Helper() + f := &fakeRegistry{} + mux := http.NewServeMux() + mux.HandleFunc("/v2/token", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.tokenHits++ + f.tokenHadAuth = r.Header.Get("Authorization") != "" + f.tokenQuery = r.URL.RawQuery + deny := f.denyToken + f.mu.Unlock() + if deny { + w.WriteHeader(401) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"token":"anon-token-123"}`) + }) + mux.HandleFunc("/v2/admin/felhom-controller/tags/list", func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + f.mu.Lock() + f.tagsBasicAuths = append(f.tagsBasicAuths, auth) + denyBearer := f.denyBearerTags + f.mu.Unlock() + switch { + case strings.HasPrefix(auth, "Basic "): + // authenticated path — serve directly + case auth == "Bearer anon-token-123" && !denyBearer: + // anonymous path with the issued token — serve + default: + w.Header().Set("WWW-Authenticate", + fmt.Sprintf(`Bearer realm="%s/v2/token",service="container_registry"`, f.Server.URL)) + w.WriteHeader(401) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"name":"admin/felhom-controller","tags":["latest","dev","0.111.0","0.112.0","0.99.0"]}`) + }) + f.Server = httptest.NewServer(mux) + t.Cleanup(f.Server.Close) + return f +} + +// Part 1 — the anonymous token dance end-to-end: 401 + WWW-Authenticate → token with NO +// auth → tags with the Bearer → highest semver, with ZERO credentials configured. +// Red-proof: restoring the old creds-required guard in queryRegistry fails this with +// "registry hitelesítő adatok hiányoznak". +func TestQueryRegistry_AnonymousTokenDance(t *testing.T) { + reg := newFakeRegistry(t) + u := newRegistryUpdater(t, reg.URL, "", "") + + got, err := u.queryRegistry() + if err != nil { + t.Fatalf("anonymous queryRegistry failed: %v", err) + } + if got != "0.112.0" { + t.Fatalf("latest = %q, want 0.112.0 (highest semver, non-semver tags skipped)", got) + } + reg.mu.Lock() + defer reg.mu.Unlock() + if reg.tokenHits != 1 { + t.Fatalf("token endpoint hit %d times, want 1", reg.tokenHits) + } + if reg.tokenHadAuth { + t.Fatalf("anonymous token request carried an Authorization header — must be credential-free") + } + if !strings.Contains(reg.tokenQuery, "service=container_registry") || + !strings.Contains(reg.tokenQuery, "scope=repository%3Aadmin%2Ffelhom-controller%3Apull") { + t.Fatalf("token query missing service/scope: %q", reg.tokenQuery) + } +} + +// Creds-present path: BasicAuth request shape unchanged, and NO token dance attempted. +func TestQueryRegistry_CredsPath_Unchanged(t *testing.T) { + reg := newFakeRegistry(t) + u := newRegistryUpdater(t, reg.URL, "user1", "tok1") + + got, err := u.queryRegistry() + if err != nil { + t.Fatalf("creds queryRegistry failed: %v", err) + } + if got != "0.112.0" { + t.Fatalf("latest = %q, want 0.112.0", got) + } + reg.mu.Lock() + defer reg.mu.Unlock() + if reg.tokenHits != 0 { + t.Fatalf("creds path attempted the anonymous token dance (%d token hits)", reg.tokenHits) + } + if len(reg.tagsBasicAuths) != 1 || !strings.HasPrefix(reg.tagsBasicAuths[0], "Basic ") { + t.Fatalf("tags/list not requested with BasicAuth: %v", reg.tagsBasicAuths) + } +} + +// Denied-anonymous (token endpoint refuses): the NEW clear error — not a crash, not the +// old "credentials missing" message. +func TestQueryRegistry_AnonymousDenied_TokenEndpoint(t *testing.T) { + reg := newFakeRegistry(t) + reg.denyToken = true + u := newRegistryUpdater(t, reg.URL, "", "") + + _, err := u.queryRegistry() + if err == nil { + t.Fatal("expected error from a denying registry") + } + if !strings.Contains(err.Error(), "denied anonymous access") || + !strings.Contains(err.Error(), "Git Sync credentials") { + t.Fatalf("wrong denial error: %v", err) + } + if strings.Contains(err.Error(), "hiányoznak") { + t.Fatalf("the OLD credentials-missing message leaked back: %v", err) + } +} + +// Denied-anonymous (tags 401 even WITH the Bearer): same clear error. +func TestQueryRegistry_AnonymousDenied_TagsWithBearer(t *testing.T) { + reg := newFakeRegistry(t) + reg.denyBearerTags = true + u := newRegistryUpdater(t, reg.URL, "", "") + + _, err := u.queryRegistry() + if err == nil || !strings.Contains(err.Error(), "denied anonymous access") { + t.Fatalf("want the anonymous-denial error, got: %v", err) + } +} + +// Half-configured credentials stay a loud misconfig, never silently anonymous. +func TestQueryRegistry_PartialCreds_LoudError(t *testing.T) { + reg := newFakeRegistry(t) + for _, pair := range [][2]string{{"user-only", ""}, {"", "token-only"}} { + u := newRegistryUpdater(t, reg.URL, pair[0], pair[1]) + _, err := u.queryRegistry() + if err == nil || !strings.Contains(err.Error(), "hiányos registry hitelesítő adatok") { + t.Fatalf("partial creds (%q,%q): want the incomplete-credentials error, got %v", pair[0], pair[1], err) + } + } +} + +// realm/service parsing: quoted + bare values, order independence, commas inside quotes, +// and clear errors on unusable headers. +func TestParseWWWAuthenticate(t *testing.T) { + cases := []struct { + in string + realm, svc string + wantErrSubstr string + }{ + {in: `Bearer realm="https://r.example/v2/token",service="container_registry"`, + realm: "https://r.example/v2/token", svc: "container_registry"}, + {in: `bearer service=svc,realm=https://r.example/tok`, // bare values, swapped order, lowercase scheme word + realm: "https://r.example/tok", svc: "svc"}, + {in: `Bearer realm="https://r.example/t",service="s",error="insufficient_scope, retry"`, + realm: "https://r.example/t", svc: "s"}, // comma INSIDE a quoted param must not split + {in: `Bearer service="s"`, wantErrSubstr: "no realm"}, + {in: `Basic realm="x"`, wantErrSubstr: "not a Bearer"}, + {in: ``, wantErrSubstr: "empty"}, + } + for _, c := range cases { + realm, svc, err := parseWWWAuthenticate(c.in) + if c.wantErrSubstr != "" { + if err == nil || !strings.Contains(err.Error(), c.wantErrSubstr) { + t.Errorf("parse(%q): want error containing %q, got %v", c.in, c.wantErrSubstr, err) + } + continue + } + if err != nil { + t.Errorf("parse(%q): unexpected error %v", c.in, err) + continue + } + if realm != c.realm || svc != c.svc { + t.Errorf("parse(%q) = (%q,%q), want (%q,%q)", c.in, realm, svc, c.realm, c.svc) + } + } +} + +// fakeRunner records docker CLI invocations for pullImage tests. +type fakeRunner struct { + mu sync.Mutex + commands []string // "login"/"pull"/"logout"/... + stdins []string +} + +func (f *fakeRunner) install(t *testing.T) { + t.Helper() + origRun, origRunStdin := runCommand, runCommandStdin + runCommand = func(name string, args ...string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.commands = append(f.commands, strings.Join(append([]string{name}, args...), " ")) + return "", nil + } + runCommandStdin = func(stdin, name string, args ...string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.commands = append(f.commands, strings.Join(append([]string{name}, args...), " ")) + f.stdins = append(f.stdins, stdin) + return "", nil + } + t.Cleanup(func() { runCommand, runCommandStdin = origRun, origRunStdin }) +} + +// Part 2 — no creds → NO docker login, the pull still runs (docker's native anonymous flow). +func TestPullImage_NoCreds_SkipsLogin(t *testing.T) { + fr := &fakeRunner{} + fr.install(t) + u := newRegistryUpdater(t, "http://unused", "", "") + + if err := u.pullImage(imageBase + ":0.112.0"); err != nil { + t.Fatalf("anonymous pullImage failed: %v", err) + } + fr.mu.Lock() + defer fr.mu.Unlock() + joined := strings.Join(fr.commands, " | ") + if strings.Contains(joined, "login") || strings.Contains(joined, "logout") { + t.Fatalf("anonymous pull invoked docker login/logout: %v", fr.commands) + } + if len(fr.commands) != 1 || fr.commands[0] != "docker pull "+imageBase+":0.112.0" { + t.Fatalf("pull not invoked exactly once: %v", fr.commands) + } +} + +// Creds present → login (token via stdin) → pull → logout, unchanged. +func TestPullImage_WithCreds_LoginPullLogout(t *testing.T) { + fr := &fakeRunner{} + fr.install(t) + u := newRegistryUpdater(t, "http://unused", "user1", "tok1") + + if err := u.pullImage(imageBase + ":0.112.0"); err != nil { + t.Fatalf("authenticated pullImage failed: %v", err) + } + fr.mu.Lock() + defer fr.mu.Unlock() + want := []string{ + "docker login gitea.dooplex.hu -u user1 --password-stdin", + "docker pull " + imageBase + ":0.112.0", + "docker logout gitea.dooplex.hu", + } + if len(fr.commands) != 3 { + t.Fatalf("commands = %v, want login/pull/logout", fr.commands) + } + for i := range want { + if fr.commands[i] != want[i] { + t.Fatalf("command[%d] = %q, want %q", i, fr.commands[i], want[i]) + } + } + if len(fr.stdins) != 1 || fr.stdins[0] != "tok1" { + t.Fatalf("token not passed via stdin: %v", fr.stdins) + } +} + +// Half-configured creds refuse the pull loudly (never a silent anonymous downgrade). +func TestPullImage_PartialCreds_LoudError(t *testing.T) { + fr := &fakeRunner{} + fr.install(t) + u := newRegistryUpdater(t, "http://unused", "user-only", "") + + err := u.pullImage(imageBase + ":0.112.0") + if err == nil || !strings.Contains(err.Error(), "hiányos registry hitelesítő adatok") { + t.Fatalf("want incomplete-credentials error, got %v", err) + } + fr.mu.Lock() + defer fr.mu.Unlock() + if len(fr.commands) != 0 { + t.Fatalf("no docker command may run on misconfig: %v", fr.commands) + } +} + +// RegistryAnonymous drives the settings-page mode line; DryRun.PullCapable counts +// anonymous as capable (only a half-configured pair is incapable). +func TestRegistryMode_And_PullCapable(t *testing.T) { + anon := newRegistryUpdater(t, "http://unused", "", "") + if !anon.RegistryAnonymous() { + t.Fatal("empty creds must report anonymous mode") + } + authed := newRegistryUpdater(t, "http://unused", "u", "t") + if authed.RegistryAnonymous() { + t.Fatal("full creds must report authenticated mode") + } + if capable := (anon.gitCfg.Username != "" && anon.gitCfg.Token != "") || anon.RegistryAnonymous(); !capable { + t.Fatal("anonymous must be pull-capable") + } + partial := newRegistryUpdater(t, "http://unused", "u", "") + if capable := (partial.gitCfg.Username != "" && partial.gitCfg.Token != "") || partial.RegistryAnonymous(); capable { + t.Fatal("partial creds must NOT be pull-capable") + } +} diff --git a/controller/internal/selfupdate/updater.go b/controller/internal/selfupdate/updater.go index c755589..bca99bb 100644 --- a/controller/internal/selfupdate/updater.go +++ b/controller/internal/selfupdate/updater.go @@ -5,8 +5,10 @@ import ( "context" "encoding/json" "fmt" + "io" "log" "net/http" + neturl "net/url" "os/exec" "strings" "sync" @@ -66,6 +68,8 @@ type Updater struct { // Seams (default to the real implementations; overridden in tests to avoid network/docker). queryFn func() (string, error) // resolve latest registry tag (default u.queryRegistry) pullFn func(targetImage) error // pull the image in-guest (default u.pullImage) + // registryBase overrides the registry API base ("https://") in tests (httptest is http://). + registryBase string } // targetImage is a tiny named type so the pullFn seam reads clearly. @@ -187,25 +191,53 @@ func (u *Updater) CheckForUpdate() CheckResult { return result } -// queryRegistry queries the Gitea Docker Registry V2 API for available tags. -// Returns the highest valid semver tag found. +// errAnonymousDenied is the LOUD, actionable error for a registry that refuses the +// anonymous flow — distinct from the old (wrong) "credentials missing" refusal. +var errAnonymousDenied = fmt.Errorf("registry denied anonymous access — a private registry requires Git Sync credentials") + +// RegistryAnonymous reports whether the updater talks to the registry without credentials. +// Credential-less is a SUPPORTED mode (public package — the registry's anonymous token +// flow covers it), not an error state; credentials exist for private catalogs only. +func (u *Updater) RegistryAnonymous() bool { + return u.gitCfg.Username == "" && u.gitCfg.Token == "" +} + +// registryBaseURL returns the scheme+host of the registry API, derived from the image +// reference ("gitea.dooplex.hu/admin/felhom-controller" → "https://gitea.dooplex.hu"). +// Overridable via registryBase in tests (httptest serves plain http). +func (u *Updater) registryBaseURL() string { + if u.registryBase != "" { + return u.registryBase + } + return "https://" + registryHost(u.cfg.Image) +} + +// queryRegistry queries the Docker Registry V2 API for available tags and returns the +// highest valid semver tag. With credentials it uses BasicAuth (private catalogs, +// unchanged); with NO credentials it performs the registry's anonymous token dance +// (public packages need no configured credentials at all). func (u *Updater) queryRegistry() (string, error) { - if u.gitCfg.Username == "" || u.gitCfg.Token == "" { - return "", fmt.Errorf("registry hitelesítő adatok hiányoznak") + anonymous := u.RegistryAnonymous() + if !anonymous && (u.gitCfg.Username == "" || u.gitCfg.Token == "") { + return "", fmt.Errorf("hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)") } - // Gitea registry V2: GET /v2///tags/list - url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image)) + // Registry V2: GET /v2///tags/list + tagsURL := fmt.Sprintf("%s/v2/%s/tags/list", u.registryBaseURL(), registryImagePath(u.cfg.Image)) + client := &http.Client{Timeout: 15 * time.Second} - u.dbg("queryRegistry: url=%s user=%s", url, u.gitCfg.Username) + if anonymous { + return u.queryRegistryAnonymous(client, tagsURL) + } - req, err := http.NewRequest("GET", url, nil) + u.dbg("queryRegistry: url=%s user=%s", tagsURL, u.gitCfg.Username) + + req, err := http.NewRequest("GET", tagsURL, nil) if err != nil { return "", fmt.Errorf("creating request: %w", err) } req.SetBasicAuth(u.gitCfg.Username, u.gitCfg.Token) - client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { u.dbg("queryRegistry: HTTP request failed: %v", err) @@ -221,11 +253,167 @@ func (u *Updater) queryRegistry() (string, error) { return "", fmt.Errorf("unexpected status: %d", resp.StatusCode) } + return u.decodeHighestTag(resp.Body) +} + +// queryRegistryAnonymous is the Docker Registry v2 anonymous flow: plain GET → 401 with a +// WWW-Authenticate Bearer challenge → fetch a token from the ADVERTISED realm with no +// credentials → retry tags/list with the Bearer. The realm/service come from the header — +// never hardcoded (registry-agnostic). A registry that denies the anonymous token (a +// truly private one) surfaces errAnonymousDenied, not "credentials missing". +func (u *Updater) queryRegistryAnonymous(client *http.Client, tagsURL string) (string, error) { + u.dbg("queryRegistry: anonymous mode, url=%s", tagsURL) + + resp, err := client.Get(tagsURL) + if err != nil { + return "", fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + u.dbg("queryRegistry: anonymous plain GET HTTP %d", resp.StatusCode) + + if resp.StatusCode == 200 { + // Registry serves tags with no token at all — done. + return u.decodeHighestTag(resp.Body) + } + if resp.StatusCode != 401 { + return "", fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + realm, service, err := parseWWWAuthenticate(resp.Header.Get("WWW-Authenticate")) + if err != nil { + return "", fmt.Errorf("registry 401 without a usable WWW-Authenticate challenge: %w", err) + } + token, err := u.fetchAnonymousToken(client, realm, service) + if err != nil { + return "", err + } + + req, err := http.NewRequest("GET", tagsURL, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + resp2, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("HTTP request failed: %w", err) + } + defer resp2.Body.Close() + u.dbg("queryRegistry: anonymous tags/list with Bearer HTTP %d", resp2.StatusCode) + + if resp2.StatusCode == 401 || resp2.StatusCode == 403 { + return "", errAnonymousDenied + } + if resp2.StatusCode != 200 { + return "", fmt.Errorf("unexpected status: %d", resp2.StatusCode) + } + return u.decodeHighestTag(resp2.Body) +} + +// fetchAnonymousToken GETs the challenge's realm with service+pull-scope and NO +// credentials, returning the Bearer token. A denial here means a private registry. +func (u *Updater) fetchAnonymousToken(client *http.Client, realm, service string) (string, error) { + q := neturl.Values{} + if service != "" { + q.Set("service", service) + } + q.Set("scope", "repository:"+registryImagePath(u.cfg.Image)+":pull") + sep := "?" + if strings.Contains(realm, "?") { + sep = "&" + } + tokenURL := realm + sep + q.Encode() + u.dbg("queryRegistry: anonymous token request: %s", tokenURL) + + resp, err := client.Get(tokenURL) + if err != nil { + return "", fmt.Errorf("token request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + u.dbg("queryRegistry: anonymous token denied: HTTP %d", resp.StatusCode) + return "", errAnonymousDenied + } + + var tr struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return "", fmt.Errorf("decoding token response: %w", err) + } + tok := tr.Token + if tok == "" { + tok = tr.AccessToken + } + if tok == "" { + return "", errAnonymousDenied + } + return tok, nil +} + +// parseWWWAuthenticate extracts realm and service from a Bearer challenge like +// `Bearer realm="https://host/v2/token",service="container_registry"`. Values may be +// quoted or bare; parameter order is not assumed; commas inside quotes are respected. +// The realm is REQUIRED (it IS the token endpoint — never hardcoded); a missing service +// is tolerated (omitted from the token query). +func parseWWWAuthenticate(h string) (realm, service string, err error) { + trimmed := strings.TrimSpace(h) + if trimmed == "" { + return "", "", fmt.Errorf("empty WWW-Authenticate header") + } + if len(trimmed) < 7 || !strings.EqualFold(trimmed[:6], "bearer") || trimmed[6] != ' ' { + return "", "", fmt.Errorf("not a Bearer challenge: %q", h) + } + for _, part := range splitAuthParams(trimmed[7:]) { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + key := strings.ToLower(strings.TrimSpace(kv[0])) + val := strings.Trim(strings.TrimSpace(kv[1]), `"`) + switch key { + case "realm": + realm = val + case "service": + service = val + } + } + if realm == "" { + return "", "", fmt.Errorf("no realm in Bearer challenge: %q", h) + } + return realm, service, nil +} + +// splitAuthParams splits `k="v",k2=v2` on commas, respecting quoted sections. +func splitAuthParams(s string) []string { + var parts []string + var cur strings.Builder + inQuote := false + for _, r := range s { + switch { + case r == '"': + inQuote = !inQuote + cur.WriteRune(r) + case r == ',' && !inQuote: + parts = append(parts, cur.String()) + cur.Reset() + default: + cur.WriteRune(r) + } + } + if cur.Len() > 0 { + parts = append(parts, cur.String()) + } + return parts +} + +// decodeHighestTag parses a tags/list response body and returns the highest semver tag. +func (u *Updater) decodeHighestTag(body io.Reader) (string, error) { var tagsResp struct { Name string `json:"name"` Tags []string `json:"tags"` } - if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil { + if err := json.NewDecoder(body).Decode(&tagsResp); err != nil { return "", fmt.Errorf("decoding response: %w", err) } @@ -267,7 +455,7 @@ type DryRunResult struct { LatestVersion string `json:"latest_version"` UpdateAvailable bool `json:"update_available"` AgentReachable bool `json:"agent_reachable"` // the host agent (which performs the swap) is wired - PullCapable bool `json:"pull_capable"` // registry creds present for the in-guest pull + PullCapable bool `json:"pull_capable"` // in-guest pull path available (full creds OR anonymous; false = half-configured creds) TargetImage string `json:"target_image"` // what we would pull + swap to BackupRunning bool `json:"backup_running"` Error string `json:"error,omitempty"` @@ -288,8 +476,10 @@ func (u *Updater) DryRun() *DryRunResult { } // The new flow: pull in-guest, then the agent swaps. Report those two capabilities. + // Pull is capable both with full credentials AND fully anonymous (public package); + // only a half-configured credential pair (misconfig) makes it incapable. result.AgentReachable = u.agent != nil - result.PullCapable = u.gitCfg.Username != "" && u.gitCfg.Token != "" + result.PullCapable = (u.gitCfg.Username != "" && u.gitCfg.Token != "") || u.RegistryAnonymous() if check.UpdateAvailable { result.TargetImage = fmt.Sprintf("%s:%s", u.cfg.Image, check.LatestVersion) } @@ -539,22 +729,29 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi // agent's verify is still polling), just wait — the new container replaces us shortly. } -// pullImage authenticates to the registry and pulls targetImage into the guest's docker storage. The -// token is passed via stdin (never argv) and the session is logged out afterwards. +// pullImage pulls targetImage into the guest's docker storage. With credentials it +// logs in first (token via stdin, never argv; logged out afterwards). Without +// credentials it skips login entirely — docker's native anonymous flow covers public +// packages; a genuinely-denied anonymous pull surfaces docker's own error. func (u *Updater) pullImage(targetImage string) error { - if u.gitCfg.Username == "" || u.gitCfg.Token == "" { - return fmt.Errorf("docker pull: registry hitelesítő adatok hiányoznak") + hasCreds := u.gitCfg.Username != "" && u.gitCfg.Token != "" + if !hasCreds && (u.gitCfg.Username != "" || u.gitCfg.Token != "") { + return fmt.Errorf("docker pull: hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)") } host := registryHost(u.cfg.Image) - u.dbg("pullImage: docker login %s as %s", host, u.gitCfg.Username) - if out, err := runCommandStdin(u.gitCfg.Token, "docker", "login", host, "-u", u.gitCfg.Username, "--password-stdin"); err != nil { - return fmt.Errorf("docker login failed: %v — %s", err, out) - } - defer func() { - if out, err := runCommand("docker", "logout", host); err != nil { - u.logger.Printf("[WARN] [selfupdate] docker logout failed: %v — %s", err, out) + if hasCreds { + u.dbg("pullImage: docker login %s as %s", host, u.gitCfg.Username) + if out, err := runCommandStdin(u.gitCfg.Token, "docker", "login", host, "-u", u.gitCfg.Username, "--password-stdin"); err != nil { + return fmt.Errorf("docker login failed: %v — %s", err, out) } - }() + defer func() { + if out, err := runCommand("docker", "logout", host); err != nil { + u.logger.Printf("[WARN] [selfupdate] docker logout failed: %v — %s", err, out) + } + }() + } else { + u.dbg("pullImage: no credentials configured — anonymous pull (public package)") + } u.logger.Printf("[INFO] [selfupdate] Pulling image: %s", targetImage) pullStart := time.Now() @@ -623,7 +820,8 @@ func (u *Updater) VerifyStartup() *UpdateState { } // runCommand executes a command and returns combined stdout+stderr and error. -func runCommand(name string, args ...string) (string, error) { +// Package var so tests can fake the docker CLI (no real exec in unit tests). +var runCommand = func(name string, args ...string) (string, error) { cmd := exec.Command(name, args...) var out bytes.Buffer cmd.Stdout = &out @@ -634,7 +832,8 @@ func runCommand(name string, args ...string) (string, error) { // runCommandStdin executes a command, feeding `stdin` on its standard input (used for // `docker login --password-stdin` so the token is never in argv/ps). Returns combined output. -func runCommandStdin(stdin, name string, args ...string) (string, error) { +// Package var so tests can fake the docker CLI. +var runCommandStdin = func(stdin, name string, args ...string) (string, error) { cmd := exec.Command(name, args...) cmd.Stdin = strings.NewReader(stdin) var out bytes.Buffer diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 37fb856..820be6a 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -968,6 +968,9 @@ func (s *Server) systemPageData() map[string]interface{} { // Self-update status data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled if s.updater != nil { + // Registry mode (v0.112.0): credential-less is a SUPPORTED mode (anonymous pull of + // the public package), not an error state — the panel says which mode is active. + data["RegistryAnonymous"] = s.updater.RegistryAnonymous() status := s.updater.GetStatus() data["UpdateRunning"] = status.Running if status.LastCheck != nil { diff --git a/controller/internal/web/templates/settings_system.html b/controller/internal/web/templates/settings_system.html index 13c1769..1424ff4 100644 --- a/controller/internal/web/templates/settings_system.html +++ b/controller/internal/web/templates/settings_system.html @@ -87,6 +87,10 @@ {{.LastCheckTime}} {{end}} +
+ Registry + {{if .RegistryAnonymous}}nyilvános (hitelesítés nélkül){{else}}hitelesített{{end}} +
{{if .LastCheckError}}
Hiba