v0.112.0: self-update without credentials — anonymous Docker v2 token flow

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-10 17:52:03 +02:00
parent b1250ad5a0
commit b25ca60ab7
4 changed files with 554 additions and 26 deletions
+225 -26
View File
@@ -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://<host>") 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/<owner>/<repo>/tags/list
url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image))
// Registry V2: GET /v2/<owner>/<repo>/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