Skip to content

Commit 4f01b71

Browse files
authored
fix(registry): Answer 400 when the endpoint URL cannot be reached (#923)
Signed-off-by: Prasanth Baskar <prasanth@8gears.com>
1 parent 2c5821f commit 4f01b71

4 files changed

Lines changed: 135 additions & 21 deletions

File tree

src/controller/registry/controller.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ package registry
1616

1717
import (
1818
"context"
19+
stderrors "errors"
1920
"math/rand"
21+
neturl "net/url"
2022
"strings"
2123
"time"
2224

@@ -92,7 +94,9 @@ func (c *controller) validate(ctx context.Context, registry *model.Registry) err
9294
if len(registry.Name) > 64 {
9395
return errors.New(nil).WithCode(errors.BadRequestCode).WithMessage("the max length of name is 64")
9496
}
95-
url, err := lib.ValidateURL(registry.URL)
97+
// a registry endpoint is only ever spoken to over HTTP; the default scheme
98+
// set of ValidateURL also admits ftp/s3/sftp, which net/http cannot dial
99+
url, err := lib.ValidateURL(registry.URL, "http", "https")
96100
if err != nil {
97101
return err
98102
}
@@ -170,17 +174,44 @@ func (c *controller) Delete(ctx context.Context, id int64) error {
170174
}
171175

172176
func (c *controller) IsHealthy(ctx context.Context, registry *model.Registry) (bool, error) {
177+
// the Harbor adapter probes /api/version while the adapter is built, so an
178+
// unreachable endpoint surfaces from CreateAdapter rather than HealthCheck
173179
adapter, err := c.regMgr.CreateAdapter(ctx, registry)
174180
if err != nil {
175-
return false, err
181+
return false, unreachableEndpointError(registry.URL, err)
176182
}
177183
status, err := adapter.HealthCheck()
178184
if err != nil {
179-
return false, err
185+
return false, unreachableEndpointError(registry.URL, err)
180186
}
181187
return status == model.Healthy, nil
182188
}
183189

190+
// unreachableEndpointError maps a transport failure onto a bad request.
191+
//
192+
// net/http returns *url.Error for every failure that stops an HTTP exchange
193+
// from completing, and each one describes the endpoint the caller supplied
194+
// rather than a fault inside Harbor:
195+
//
196+
// - the host does not resolve, or the connection is refused or times out
197+
// - the scheme is one net/http cannot dial
198+
// - the TLS handshake fails, including an untrusted or mismatched
199+
// certificate, which the caller fixes with insecure or ca_certificate
200+
//
201+
// All of them are a 400 deliberately: the caller has to change what it sent.
202+
// The message keeps the cause verbatim so the specific reason is visible even
203+
// though the prefix is shared. Every other error is passed through untouched,
204+
// so a genuine internal failure keeps its own code, as does a registry that
205+
// answers over HTTP with a status Harbor did not expect.
206+
func unreachableEndpointError(url string, err error) error {
207+
var urlErr *neturl.Error
208+
if !stderrors.As(err, &urlErr) {
209+
return err
210+
}
211+
return errors.New(nil).WithCode(errors.BadRequestCode).
212+
WithMessagef("failed to reach the registry endpoint %s: %v", url, err)
213+
}
214+
184215
func (c *controller) GetInfo(ctx context.Context, id int64) (*model.RegistryInfo, error) {
185216
var (
186217
registry *model.Registry

src/controller/registry/controller_test.go

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@ package registry
1616

1717
import (
1818
"context"
19+
stderrors "errors"
20+
"net"
21+
neturl "net/url"
1922
"testing"
2023

2124
"github.com/stretchr/testify/suite"
2225

2326
"github.com/goharbor/harbor/src/common"
2427
"github.com/goharbor/harbor/src/lib/config"
28+
"github.com/goharbor/harbor/src/lib/errors"
2529
_ "github.com/goharbor/harbor/src/pkg/config/inmemory"
30+
"github.com/goharbor/harbor/src/pkg/reg"
2631
"github.com/goharbor/harbor/src/pkg/reg/model"
2732
"github.com/goharbor/harbor/src/testing/mock"
2833
testingproject "github.com/goharbor/harbor/src/testing/pkg/project"
@@ -68,15 +73,18 @@ func (r *registryTestSuite) TestValidate() {
6873
err = r.ctl.validate(nil, registry)
6974
r.NotNil(err)
7075

71-
// URL with FTP scheme
76+
// URL with FTP scheme: rejected before an adapter is ever built, because
77+
// net/http cannot dial it and the transport error used to surface as a 500
7278
registry = &model.Registry{
7379
Name: "endpoint01",
7480
URL: "ftp://example.com",
7581
}
76-
mock.OnAnything(r.regMgr, "CreateAdapter").Return(r.adapter, nil)
77-
mock.OnAnything(r.adapter, "HealthCheck").Return(model.Healthy, nil)
7882
err = r.ctl.validate(nil, registry)
79-
r.Nil(err)
83+
r.NotNil(err)
84+
r.True(errors.IsErr(err, errors.BadRequestCode), "want a bad request error, got %v", err)
85+
r.regMgr.AssertNotCalled(r.T(), "CreateAdapter", mock.Anything, mock.Anything)
86+
87+
r.SetupTest()
8088

8189
// URL without scheme
8290
registry = &model.Registry{
@@ -151,6 +159,84 @@ func (r *registryTestSuite) TestValidate() {
151159
r.adapter.AssertExpectations(r.T())
152160
}
153161

162+
// An endpoint that cannot be reached is a bad request, not a 500. This drives
163+
// the real registry manager and the real Harbor adapter factory rather than
164+
// mocks: the probe that fails is the GET /api/version issued while the adapter
165+
// is being built, so it never reaches the HealthCheck call a mocked manager
166+
// would exercise.
167+
func (r *registryTestSuite) TestValidateUnreachableEndpoint() {
168+
config.InitWithSettings(map[string]any{
169+
common.CoreURL: "http://core:8080",
170+
})
171+
172+
// Hold a loopback listener open and hang up on every connection. Keeping
173+
// the port bound is what makes this deterministic: releasing it first
174+
// would let another process take it between the bind and the request.
175+
listener, err := net.Listen("tcp", "127.0.0.1:0")
176+
r.Require().NoError(err)
177+
defer listener.Close()
178+
address := listener.Addr().String()
179+
go func() {
180+
for {
181+
conn, err := listener.Accept()
182+
if err != nil {
183+
return
184+
}
185+
conn.Close()
186+
}
187+
}()
188+
189+
ctl := &controller{
190+
regMgr: reg.Mgr,
191+
repMgr: r.repMgr,
192+
proMgr: r.proMgr,
193+
}
194+
195+
err = ctl.validate(context.Background(), &model.Registry{
196+
Name: "endpoint01",
197+
Type: model.RegistryTypeHarbor,
198+
URL: "http://" + address,
199+
})
200+
201+
r.Require().NotNil(err)
202+
r.True(errors.IsErr(err, errors.BadRequestCode), "want a bad request error, got %v", err)
203+
r.Contains(err.Error(), "failed to reach the registry endpoint")
204+
}
205+
206+
// The other branch: an adapter that builds but whose health check cannot reach
207+
// the endpoint. Only reachable with a mocked adapter, since the adapters that
208+
// probe lazily are the ones that need credentials.
209+
func (r *registryTestSuite) TestValidateHealthCheckTransportError() {
210+
transportErr := &neturl.Error{
211+
Op: "Get",
212+
URL: "http://example.com/v2/",
213+
Err: stderrors.New("dial tcp: connection refused"),
214+
}
215+
mock.OnAnything(r.regMgr, "CreateAdapter").Return(r.adapter, nil)
216+
mock.OnAnything(r.adapter, "HealthCheck").Return("", transportErr)
217+
218+
err := r.ctl.validate(context.Background(), &model.Registry{
219+
Name: "endpoint01",
220+
URL: "http://example.com",
221+
})
222+
223+
r.Require().NotNil(err)
224+
r.True(errors.IsErr(err, errors.BadRequestCode), "want a bad request error, got %v", err)
225+
r.Contains(err.Error(), "failed to reach the registry endpoint")
226+
r.regMgr.AssertExpectations(r.T())
227+
r.adapter.AssertExpectations(r.T())
228+
}
229+
230+
// Errors that are not transport failures keep their own code, so a genuine
231+
// internal fault is not reported to the caller as a bad request.
232+
func (r *registryTestSuite) TestUnreachableEndpointErrorPassesOtherErrorsThrough() {
233+
internal := stderrors.New("boom")
234+
r.Equal(internal, unreachableEndpointError("http://example.com", internal))
235+
236+
notFound := errors.New(nil).WithCode(errors.NotFoundCode).WithMessage("gone")
237+
r.Equal(notFound, unreachableEndpointError("http://example.com", notFound))
238+
}
239+
154240
func (r *registryTestSuite) TestDelete() {
155241
// referenced by replication policy
156242
mock.OnAnything(r.repMgr, "Count").Return(int64(1), nil)

src/server/v2.0/handler/registry.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ func (r *registryAPI) UpdateRegistry(ctx context.Context, params operation.Updat
174174
registry.Credential.AccessSecret = *params.Registry.AccessSecret
175175
}
176176
if registry.URL != storedURL && params.Registry.AccessSecret == nil {
177-
normalizedURL, err := lib.ValidateURL(registry.URL)
177+
normalizedURL, err := lib.ValidateURL(registry.URL, "http", "https")
178178
if err != nil {
179179
return r.SendError(ctx, err)
180180
}
@@ -256,7 +256,7 @@ func (r *registryAPI) PingRegistry(ctx context.Context, params operation.PingReg
256256
// authoritative; ignore url/insecure/ca overrides so the ping (and the saved
257257
// credentials it sends) can't be redirected to or MITM'd via an untrusted endpoint
258258
if params.Registry.URL != nil && params.Registry.ID == nil {
259-
url, err := lib.ValidateURL(*params.Registry.URL)
259+
url, err := lib.ValidateURL(*params.Registry.URL, "http", "https")
260260
if err != nil {
261261
return r.SendError(ctx, err)
262262
}

src/server/v2.0/handler/registry_test.go

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,13 @@ func (suite *RegistryTestSuite) TestUpdateRegistryInvalidURLReturnsError() {
190190
}
191191
}
192192

193-
// TestUpdateRegistryStorageSchemeURLAccepted documents that schema-aware validation
194-
// accepts storage-backed registry URLs (sftp/s3) on update, and that changing to one
195-
// still clears the stored AccessSecret like any other URL change.
196-
func (suite *RegistryTestSuite) TestUpdateRegistryStorageSchemeURLAccepted() {
193+
// TestUpdateRegistryStorageSchemeURLRejected pins the update path to http/https,
194+
// the only schemes a replication adapter can speak. #742 originally documented
195+
// sftp:// and s3:// as accepted here, but no adapter under src/pkg/reg/ handles
196+
// either, so such an endpoint was accepted and then failed its health check with
197+
// a 500 -- the bug this lane fixes. The controller refuses them too; this keeps
198+
// the handler and the controller saying the same thing.
199+
func (suite *RegistryTestSuite) TestUpdateRegistryStorageSchemeURLRejected() {
197200
for _, newURL := range []string{
198201
"sftp://storage.example.com",
199202
"s3://bucket.example.com",
@@ -210,18 +213,12 @@ func (suite *RegistryTestSuite) TestUpdateRegistryStorageSchemeURLAccepted() {
210213
}
211214
mock.OnAnything(suite.regCtl, "Get").Return(saved, nil).Once()
212215

213-
var updated *model.Registry
214-
suite.regCtl.On("Update", mock.Anything, mock.Anything).Return(nil).Once().
215-
Run(func(args testifymock.Arguments) { updated = args.Get(1).(*model.Registry) })
216-
217216
res, err := suite.PutJSON("/registries/1", &models.RegistryUpdate{
218217
URL: suite.ptrStr(newURL),
219218
})
220219
suite.NoError(err)
221-
suite.Equal(200, res.StatusCode, "URL %q must be accepted", newURL)
222-
suite.Require().NotNil(updated)
223-
suite.Equal(newURL, updated.URL)
224-
suite.Empty(updated.Credential.AccessSecret)
220+
suite.Equal(400, res.StatusCode, "URL %q must be rejected", newURL)
221+
suite.regCtl.AssertNotCalled(suite.T(), "Update", testifymock.Anything, testifymock.Anything)
225222
}
226223
}
227224

0 commit comments

Comments
 (0)