mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 17:07:47 +02:00
223 lines
7.0 KiB
Go
223 lines
7.0 KiB
Go
package agent
|
|
|
|
import (
|
|
_ "crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/distribution/reference"
|
|
"github.com/opencontainers/go-digest"
|
|
)
|
|
|
|
const imageRegistryTimeout = 10 * time.Second
|
|
|
|
const imageManifestAccept = "application/vnd.docker.distribution.manifest.list.v2+json, " +
|
|
"application/vnd.docker.distribution.manifest.v2+json, " +
|
|
"application/vnd.oci.image.manifest.v1+json, " +
|
|
"application/vnd.oci.image.index.v1+json"
|
|
|
|
// checkImageUpdate compares the digest recorded by Docker for image with the
|
|
// digest currently advertised by its registry. A digest-pinned reference is
|
|
// immutable and therefore never has an update available.
|
|
func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
|
|
named, err := reference.ParseNormalizedNamed(image)
|
|
if err != nil {
|
|
return false, fmt.Errorf("parse image reference %q: %w", image, err)
|
|
}
|
|
if _, pinned := named.(reference.Digested); pinned {
|
|
return false, nil
|
|
}
|
|
named = reference.TagNameOnly(named)
|
|
|
|
registry := reference.Domain(named)
|
|
repository := reference.Path(named)
|
|
tag := named.(reference.Tagged).Tag()
|
|
|
|
localDigest, err := dm.inspectImageDigest(image, registry, repository)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
remoteDigest, err := dm.registryImageDigest(registry, repository, tag)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return remoteDigest != localDigest, nil
|
|
}
|
|
|
|
// inspectImageDigest reads Docker's image metadata without using dm.decode.
|
|
// The checker runs in the image-discovery goroutine, so it must not hold any
|
|
// of the container statistics locks while waiting on the Docker API.
|
|
func (dm *dockerManager) inspectImageDigest(image, registry, repository string) (string, error) {
|
|
if dm.client == nil {
|
|
return "", fmt.Errorf("inspect image %q: Docker client is unavailable", image)
|
|
}
|
|
|
|
endpoint := "http://localhost/images/" + url.PathEscape(image) + "/json"
|
|
resp, err := dm.client.Get(endpoint)
|
|
if err != nil {
|
|
return "", fmt.Errorf("inspect image %q: %w", image, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
|
|
}
|
|
|
|
var inspect struct {
|
|
RepoDigests []string `json:"RepoDigests"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil {
|
|
return "", fmt.Errorf("decode image inspect %q: %w", image, err)
|
|
}
|
|
if len(inspect.RepoDigests) == 0 {
|
|
return "", fmt.Errorf("inspect image %q returned no repository digests", image)
|
|
}
|
|
|
|
localDigest, ok := matchingRepositoryDigest(inspect.RepoDigests, registry, repository)
|
|
if !ok {
|
|
return "", fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
|
|
}
|
|
return localDigest, nil
|
|
}
|
|
|
|
// matchingRepositoryDigest returns a valid digest belonging to the requested
|
|
// repository. Docker can return multiple RepoDigests for one local image; an
|
|
// unrelated first entry must never be used for the comparison.
|
|
func matchingRepositoryDigest(repoDigests []string, registry, repository string) (string, bool) {
|
|
for _, repoDigest := range repoDigests {
|
|
repoDigest = strings.TrimSpace(repoDigest)
|
|
at := strings.LastIndexByte(repoDigest, '@')
|
|
if at <= 0 || at == len(repoDigest)-1 || strings.Contains(repoDigest[:at], "@") {
|
|
continue
|
|
}
|
|
|
|
repoRef, err := reference.ParseNormalizedNamed(repoDigest[:at])
|
|
if err != nil || reference.Path(repoRef) != repository || !sameRegistry(reference.Domain(repoRef), registry) {
|
|
continue
|
|
}
|
|
if _, hasTag := repoRef.(reference.Tagged); hasTag {
|
|
continue
|
|
}
|
|
|
|
d, err := digest.Parse(repoDigest[at+1:])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
return d.String(), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func sameRegistry(left, right string) bool {
|
|
left = canonicalRegistry(left)
|
|
right = canonicalRegistry(right)
|
|
return left == right ||
|
|
(left == "ghcr.io" && right == "lscr.io") ||
|
|
(left == "lscr.io" && right == "ghcr.io")
|
|
}
|
|
|
|
func canonicalRegistry(registry string) string {
|
|
if registry == "index.docker.io" {
|
|
return "docker.io"
|
|
}
|
|
return registry
|
|
}
|
|
|
|
func (dm *dockerManager) registryImageDigest(registry, repository, tag string) (string, error) {
|
|
client := dm.registryClient
|
|
if client == nil {
|
|
client = &http.Client{Timeout: imageRegistryTimeout}
|
|
}
|
|
|
|
token, err := dm.registryToken(client, registry, repository)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
host := registry
|
|
if registry == "docker.io" {
|
|
host = "registry-1.docker.io"
|
|
}
|
|
manifestURL := "https://" + host + "/v2/" + repository + "/manifests/" + url.PathEscape(tag)
|
|
req, err := http.NewRequest(http.MethodHead, manifestURL, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create manifest request: %w", err)
|
|
}
|
|
req.Header.Set("Accept", imageManifestAccept)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch manifest %s:%s: %w", registry, repository, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("manifest request for %s:%s failed: %s", repository, tag, responseStatus(resp))
|
|
}
|
|
|
|
remote := strings.TrimSpace(resp.Header.Get("Docker-Content-Digest"))
|
|
d, err := digest.Parse(remote)
|
|
if err != nil {
|
|
return "", fmt.Errorf("manifest request for %s:%s returned invalid digest: %w", repository, tag, err)
|
|
}
|
|
return d.String(), nil
|
|
}
|
|
|
|
func (dm *dockerManager) registryToken(client *http.Client, registry, repository string) (string, error) {
|
|
var authURL string
|
|
switch registry {
|
|
case "docker.io":
|
|
authURL = "https://auth.docker.io/token?service=registry.docker.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
|
|
case "ghcr.io", "lscr.io":
|
|
// lscr.io is the LinuxServer alias for its GHCR-backed images.
|
|
authURL = "https://ghcr.io/token?service=ghcr.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
|
|
default:
|
|
// Anonymous registries remain supported, as they were before the
|
|
// authenticated Docker Hub and GHCR paths were added.
|
|
return "", nil
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodGet, authURL, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create registry auth request: %w", err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch registry auth token for %s: %w", repository, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("registry auth request for %s failed: %s", repository, responseStatus(resp))
|
|
}
|
|
|
|
var tokenResponse struct {
|
|
Token string `json:"token"`
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
|
|
return "", fmt.Errorf("decode registry auth response for %s: %w", repository, err)
|
|
}
|
|
token := strings.TrimSpace(tokenResponse.Token)
|
|
if token == "" {
|
|
token = strings.TrimSpace(tokenResponse.AccessToken)
|
|
}
|
|
if token == "" {
|
|
return "", fmt.Errorf("registry auth response for %s contained no token", repository)
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func responseStatus(resp *http.Response) string {
|
|
if resp.Status != "" {
|
|
return resp.Status
|
|
}
|
|
return http.StatusText(resp.StatusCode)
|
|
}
|