Skip to content

Commit 25fdb40

Browse files
Pwutspiksel
andauthored
fix(registry): image name parsing behavior (#1526)
Co-authored-by: nils måsén <[email protected]>
1 parent aa50d12 commit 25fdb40

File tree

12 files changed

+248
-293
lines changed

12 files changed

+248
-293
lines changed

docs/private-registries.md

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,29 @@ password `auth` string:
2323
```
2424

2525
`<REGISTRY_NAME>` needs to be replaced by the name of your private registry
26-
(e.g., `my-private-registry.example.org`)
27-
28-
!!! important "Using private images on docker hub"
29-
When using private images on docker hub, the containers beeing watched needs to use the full image name, including the repository prefix `index.docker.io`.
30-
So instead of
31-
```
32-
docker run -d myuser/myimage
33-
```
34-
you would run it as
35-
```
36-
docker run -d index.docker.io/myuser/myimage
37-
```
38-
26+
(e.g., `my-private-registry.example.org`).
27+
28+
!!! info "Using private images on Docker Hub"
29+
To access private repositories on Docker Hub,
30+
`<REGISTRY_NAME>` should be `https://index.docker.io/v1/`.
31+
In this special case, the registry domain does not have to be specified
32+
in `docker run` or `docker-compose`. Like Docker, Watchtower will use the
33+
Docker Hub registry and its credentials when no registry domain is specified.
34+
35+
<sub>Watchtower will recognize credentials with `<REGISTRY_NAME>` `index.docker.io`,
36+
but the Docker CLI will not.</sub>
37+
38+
!!! important "Using a private registry on a local host"
39+
To use a private registry hosted locally, make sure to correctly specify the registry host
40+
in both `config.json` and the `docker run` command or `docker-compose` file.
41+
Valid hosts are `localhost[:PORT]`, `HOST:PORT`,
42+
or any multi-part `domain.name` or IP-address with or without a port.
43+
44+
Examples:
45+
* `localhost` -> `localhost/myimage`
46+
* `127.0.0.1` -> `127.0.0.1/myimage:mytag`
47+
* `host.domain` -> `host.domain/myorganization/myimage`
48+
* `other-lan-host:80` -> `other-lan-host:80/imagename:latest`
3949

4050
The required `auth` string can be generated as follows:
4151

@@ -75,7 +85,7 @@ When creating the watchtower container via docker-compose, use the following lin
7585
version: "3.4"
7686
services:
7787
watchtower:
78-
image: index.docker.io/containrrr/watchtower:latest
88+
image: containrrr/watchtower:latest
7989
volumes:
8090
- /var/run/docker.sock:/var/run/docker.sock
8191
- <PATH_TO_HOME_DIR>/.docker/config.json:/config.json

docs/usage-overview.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,14 @@ docker run -d \
4848

4949
If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your
5050
watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container
51-
from a private repo at Docker Hub and monitors it with watchtower. Note the command argument changing the interval to
52-
30s rather than the default 24 hours.
51+
from a private repo on the GitHub Registry and monitors it with watchtower. Note the command argument changing the interval
52+
to 30s rather than the default 24 hours.
5353

5454
```yaml
5555
version: "3"
5656
services:
5757
cavo:
58-
image: index.docker.io/<org>/<image>:<tag>
58+
image: ghcr.io/<org>/<image>:<tag>
5959
ports:
6060
- "443:3443"
6161
- "80:3080"

pkg/registry/auth/auth.go

Lines changed: 19 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import (
44
"encoding/json"
55
"errors"
66
"fmt"
7-
"io/ioutil"
7+
"io"
88
"net/http"
99
"net/url"
1010
"strings"
1111

1212
"github.com/containrrr/watchtower/pkg/registry/helpers"
1313
"github.com/containrrr/watchtower/pkg/types"
14-
"github.com/docker/distribution/reference"
14+
ref "github.com/docker/distribution/reference"
1515
"github.com/sirupsen/logrus"
1616
)
1717

@@ -20,13 +20,13 @@ const ChallengeHeader = "WWW-Authenticate"
2020

2121
// GetToken fetches a token for the registry hosting the provided image
2222
func GetToken(container types.Container, registryAuth string) (string, error) {
23-
var err error
24-
var URL url.URL
25-
26-
if URL, err = GetChallengeURL(container.ImageName()); err != nil {
23+
normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName())
24+
if err != nil {
2725
return "", err
2826
}
29-
logrus.WithField("URL", URL.String()).Debug("Building challenge URL")
27+
28+
URL := GetChallengeURL(normalizedRef)
29+
logrus.WithField("URL", URL.String()).Debug("Built challenge URL")
3030

3131
var req *http.Request
3232
if req, err = GetChallengeRequest(URL); err != nil {
@@ -55,7 +55,7 @@ func GetToken(container types.Container, registryAuth string) (string, error) {
5555
return fmt.Sprintf("Basic %s", registryAuth), nil
5656
}
5757
if strings.HasPrefix(challenge, "bearer") {
58-
return GetBearerHeader(challenge, container.ImageName(), registryAuth)
58+
return GetBearerHeader(challenge, normalizedRef, registryAuth)
5959
}
6060

6161
return "", errors.New("unsupported challenge type from registry")
@@ -73,12 +73,9 @@ func GetChallengeRequest(URL url.URL) (*http.Request, error) {
7373
}
7474

7575
// GetBearerHeader tries to fetch a bearer token from the registry based on the challenge instructions
76-
func GetBearerHeader(challenge string, img string, registryAuth string) (string, error) {
76+
func GetBearerHeader(challenge string, imageRef ref.Named, registryAuth string) (string, error) {
7777
client := http.Client{}
78-
if strings.Contains(img, ":") {
79-
img = strings.Split(img, ":")[0]
80-
}
81-
authURL, err := GetAuthURL(challenge, img)
78+
authURL, err := GetAuthURL(challenge, imageRef)
8279

8380
if err != nil {
8481
return "", err
@@ -103,7 +100,7 @@ func GetBearerHeader(challenge string, img string, registryAuth string) (string,
103100
return "", err
104101
}
105102

106-
body, _ := ioutil.ReadAll(authResponse.Body)
103+
body, _ := io.ReadAll(authResponse.Body)
107104
tokenResponse := &types.TokenResponse{}
108105

109106
err = json.Unmarshal(body, tokenResponse)
@@ -115,7 +112,7 @@ func GetBearerHeader(challenge string, img string, registryAuth string) (string,
115112
}
116113

117114
// GetAuthURL from the instructions in the challenge
118-
func GetAuthURL(challenge string, img string) (*url.URL, error) {
115+
func GetAuthURL(challenge string, imageRef ref.Named) (*url.URL, error) {
119116
loweredChallenge := strings.ToLower(challenge)
120117
raw := strings.TrimPrefix(loweredChallenge, "bearer")
121118

@@ -141,53 +138,25 @@ func GetAuthURL(challenge string, img string) (*url.URL, error) {
141138
q := authURL.Query()
142139
q.Add("service", values["service"])
143140

144-
scopeImage := GetScopeFromImageName(img, values["service"])
141+
scopeImage := ref.Path(imageRef)
145142

146143
scope := fmt.Sprintf("repository:%s:pull", scopeImage)
147-
logrus.WithFields(logrus.Fields{"scope": scope, "image": img}).Debug("Setting scope for auth token")
144+
logrus.WithFields(logrus.Fields{"scope": scope, "image": imageRef.Name()}).Debug("Setting scope for auth token")
148145
q.Add("scope", scope)
149146

150147
authURL.RawQuery = q.Encode()
151148
return authURL, nil
152149
}
153150

154-
// GetScopeFromImageName normalizes an image name for use as scope during auth and head requests
155-
func GetScopeFromImageName(img, svc string) string {
156-
parts := strings.Split(img, "/")
157-
158-
if len(parts) > 2 {
159-
if strings.Contains(svc, "docker.io") {
160-
return fmt.Sprintf("%s/%s", parts[1], strings.Join(parts[2:], "/"))
161-
}
162-
return strings.Join(parts, "/")
163-
}
164-
165-
if len(parts) == 2 {
166-
if strings.Contains(parts[0], "docker.io") {
167-
return fmt.Sprintf("library/%s", parts[1])
168-
}
169-
return strings.Replace(img, svc+"/", "", 1)
170-
}
171-
172-
if strings.Contains(svc, "docker.io") {
173-
return fmt.Sprintf("library/%s", parts[0])
174-
}
175-
return img
176-
}
177-
178-
// GetChallengeURL creates a URL object based on the image info
179-
func GetChallengeURL(img string) (url.URL, error) {
180-
181-
normalizedNamed, _ := reference.ParseNormalizedNamed(img)
182-
host, err := helpers.NormalizeRegistry(normalizedNamed.String())
183-
if err != nil {
184-
return url.URL{}, err
185-
}
151+
// GetChallengeURL returns the URL to check auth requirements
152+
// for access to a given image
153+
func GetChallengeURL(imageRef ref.Named) url.URL {
154+
host, _ := helpers.GetRegistryAddress(imageRef.Name())
186155

187156
URL := url.URL{
188157
Scheme: "https",
189158
Host: host,
190159
Path: "/v2/",
191160
}
192-
return URL, nil
161+
return URL
193162
}

pkg/registry/auth/auth_test.go

Lines changed: 68 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import (
44
"fmt"
55
"net/url"
66
"os"
7+
"strings"
78
"testing"
89
"time"
910

1011
"github.com/containrrr/watchtower/internal/actions/mocks"
1112
"github.com/containrrr/watchtower/pkg/registry/auth"
1213

1314
wtTypes "github.com/containrrr/watchtower/pkg/types"
15+
ref "github.com/docker/distribution/reference"
1416
. "github.com/onsi/ginkgo"
1517
. "github.com/onsi/gomega"
1618
)
@@ -52,7 +54,7 @@ var _ = Describe("the auth module", func() {
5254
mockCreated,
5355
mockDigest)
5456

55-
When("getting an auth url", func() {
57+
Describe("GetToken", func() {
5658
It("should parse the token from the response",
5759
SkipIfCredentialsEmpty(GHCRCredentials, func() {
5860
creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
@@ -61,73 +63,100 @@ var _ = Describe("the auth module", func() {
6163
Expect(token).NotTo(Equal(""))
6264
}),
6365
)
66+
})
6467

68+
Describe("GetAuthURL", func() {
6569
It("should create a valid auth url object based on the challenge header supplied", func() {
66-
input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"`
70+
challenge := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"`
71+
imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
72+
Expect(err).NotTo(HaveOccurred())
6773
expected := &url.URL{
6874
Host: "ghcr.io",
6975
Scheme: "https",
7076
Path: "/token",
7177
RawQuery: "scope=repository%3Acontainrrr%2Fwatchtower%3Apull&service=ghcr.io",
7278
}
73-
res, err := auth.GetAuthURL(input, "containrrr/watchtower")
79+
80+
URL, err := auth.GetAuthURL(challenge, imageRef)
7481
Expect(err).NotTo(HaveOccurred())
75-
Expect(res).To(Equal(expected))
82+
Expect(URL).To(Equal(expected))
7683
})
77-
It("should create a valid auth url object based on the challenge header supplied", func() {
78-
input := `bearer realm="https://ghcr.io/token"`
79-
res, err := auth.GetAuthURL(input, "containrrr/watchtower")
80-
Expect(err).To(HaveOccurred())
81-
Expect(res).To(BeNil())
84+
85+
When("given an invalid challenge header", func() {
86+
It("should return an error", func() {
87+
challenge := `bearer realm="https://ghcr.io/token"`
88+
imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
89+
Expect(err).NotTo(HaveOccurred())
90+
URL, err := auth.GetAuthURL(challenge, imageRef)
91+
Expect(err).To(HaveOccurred())
92+
Expect(URL).To(BeNil())
93+
})
94+
})
95+
96+
When("deriving the auth scope from an image name", func() {
97+
It("should prepend official dockerhub images with \"library/\"", func() {
98+
Expect(getScopeFromImageAuthURL("registry")).To(Equal("library/registry"))
99+
Expect(getScopeFromImageAuthURL("docker.io/registry")).To(Equal("library/registry"))
100+
Expect(getScopeFromImageAuthURL("index.docker.io/registry")).To(Equal("library/registry"))
101+
})
102+
It("should not include vanity hosts\"", func() {
103+
Expect(getScopeFromImageAuthURL("docker.io/containrrr/watchtower")).To(Equal("containrrr/watchtower"))
104+
Expect(getScopeFromImageAuthURL("index.docker.io/containrrr/watchtower")).To(Equal("containrrr/watchtower"))
105+
})
106+
It("should not destroy three segment image names\"", func() {
107+
Expect(getScopeFromImageAuthURL("piksel/containrrr/watchtower")).To(Equal("piksel/containrrr/watchtower"))
108+
Expect(getScopeFromImageAuthURL("ghcr.io/piksel/containrrr/watchtower")).To(Equal("piksel/containrrr/watchtower"))
109+
})
110+
It("should not prepend library/ to image names if they're not on dockerhub", func() {
111+
Expect(getScopeFromImageAuthURL("ghcr.io/watchtower")).To(Equal("watchtower"))
112+
Expect(getScopeFromImageAuthURL("ghcr.io/containrrr/watchtower")).To(Equal("containrrr/watchtower"))
113+
})
82114
})
83115
It("should not crash when an empty field is recieved", func() {
84116
input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull",`
85-
res, err := auth.GetAuthURL(input, "containrrr/watchtower")
117+
imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
118+
Expect(err).NotTo(HaveOccurred())
119+
res, err := auth.GetAuthURL(input, imageRef)
86120
Expect(err).NotTo(HaveOccurred())
87121
Expect(res).NotTo(BeNil())
88122
})
89123
It("should not crash when a field without a value is recieved", func() {
90124
input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull",valuelesskey`
91-
res, err := auth.GetAuthURL(input, "containrrr/watchtower")
125+
imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
126+
Expect(err).NotTo(HaveOccurred())
127+
res, err := auth.GetAuthURL(input, imageRef)
92128
Expect(err).NotTo(HaveOccurred())
93129
Expect(res).NotTo(BeNil())
94130
})
95131
})
96-
When("getting a challenge url", func() {
132+
133+
Describe("GetChallengeURL", func() {
97134
It("should create a valid challenge url object based on the image ref supplied", func() {
98135
expected := url.URL{Host: "ghcr.io", Scheme: "https", Path: "/v2/"}
99-
Expect(auth.GetChallengeURL("ghcr.io/containrrr/watchtower:latest")).To(Equal(expected))
136+
imageRef, _ := ref.ParseNormalizedNamed("ghcr.io/containrrr/watchtower:latest")
137+
Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
100138
})
101-
It("should assume dockerhub if the image ref is not fully qualified", func() {
139+
It("should assume Docker Hub for image refs with no explicit registry", func() {
102140
expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
103-
Expect(auth.GetChallengeURL("containrrr/watchtower:latest")).To(Equal(expected))
141+
imageRef, _ := ref.ParseNormalizedNamed("containrrr/watchtower:latest")
142+
Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
104143
})
105-
It("should convert legacy dockerhub hostnames to index.docker.io", func() {
144+
It("should use index.docker.io if the image ref specifies docker.io", func() {
106145
expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
107-
Expect(auth.GetChallengeURL("docker.io/containrrr/watchtower:latest")).To(Equal(expected))
108-
Expect(auth.GetChallengeURL("registry-1.docker.io/containrrr/watchtower:latest")).To(Equal(expected))
146+
imageRef, _ := ref.ParseNormalizedNamed("docker.io/containrrr/watchtower:latest")
147+
Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
109148
})
110149
})
111-
When("getting the auth scope from an image name", func() {
112-
It("should prepend official dockerhub images with \"library/\"", func() {
113-
Expect(auth.GetScopeFromImageName("docker.io/registry", "index.docker.io")).To(Equal("library/registry"))
114-
Expect(auth.GetScopeFromImageName("docker.io/registry", "docker.io")).To(Equal("library/registry"))
150+
})
115151

116-
Expect(auth.GetScopeFromImageName("registry", "index.docker.io")).To(Equal("library/registry"))
117-
Expect(auth.GetScopeFromImageName("watchtower", "registry-1.docker.io")).To(Equal("library/watchtower"))
152+
var scopeImageRegexp = MatchRegexp("^repository:[a-z0-9]+(/[a-z0-9]+)*:pull$")
118153

119-
})
120-
It("should not include vanity hosts\"", func() {
121-
Expect(auth.GetScopeFromImageName("docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
122-
Expect(auth.GetScopeFromImageName("index.docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
123-
})
124-
It("should not destroy three segment image names\"", func() {
125-
Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
126-
Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "ghcr.io")).To(Equal("piksel/containrrr/watchtower"))
127-
})
128-
It("should not add \"library/\" for one segment image names if they're not on dockerhub", func() {
129-
Expect(auth.GetScopeFromImageName("ghcr.io/watchtower", "ghcr.io")).To(Equal("watchtower"))
130-
Expect(auth.GetScopeFromImageName("watchtower", "ghcr.io")).To(Equal("watchtower"))
131-
})
132-
})
133-
})
154+
func getScopeFromImageAuthURL(imageName string) string {
155+
normalizedRef, _ := ref.ParseNormalizedNamed(imageName)
156+
challenge := `bearer realm="https://dummy.host/token",service="dummy.host",scope="repository:user/image:pull"`
157+
URL, _ := auth.GetAuthURL(challenge, normalizedRef)
158+
159+
scope := URL.Query().Get("scope")
160+
Expect(scopeImageRegexp.Match(scope)).To(BeTrue())
161+
return strings.Replace(scope[11:], ":pull", "", 1)
162+
}

0 commit comments

Comments
 (0)