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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user