detached d7ba6d9

refactor(auth): remove dead RegistryAuth middleware and deprecated GetRegistryAuthConfig

commit
d7ba6d99778499fedb27664a208e14f8f8bb6d1d
author
bnema · · 2026-02-24 21:49 UTC
parent
56a8a7b
signature
unsigned
4 files · +115 -416
M internal/adapters/in/http/middleware/auth.go +0 -69
@@ -19,75 +19,6 @@ import (
1919 // Using domain key for consistency across all auth flows.
2020 const TokenClaimsKey = domain.TokenClaimsKey
2121
22-// RegistryAuth middleware provides Docker Registry authentication.
23-func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler {
24- return func(next http.Handler) http.Handler {
25- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26- // Require auth for all registry operations
27- if !isAuthenticated(r, username, password, log) {
28- w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
29- w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
30- w.Header().Set("Content-Type", "application/json")
31- w.WriteHeader(http.StatusUnauthorized)
32- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
33-
34- log.Warn().
35- Str(zerowrap.FieldLayer, "adapter").
36- Str(zerowrap.FieldAdapter, "http").
37- Str(zerowrap.FieldMethod, r.Method).
38- Str(zerowrap.FieldPath, r.URL.Path).
39- Str(zerowrap.FieldClientIP, r.RemoteAddr).
40- Msg("unauthorized registry access attempt")
41- return
42- }
43-
44- next.ServeHTTP(w, r)
45- })
46- }
47-}
48-
49-// isAuthenticated checks basic auth credentials.
50-func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, log zerowrap.Logger) bool {
51- authHeader := r.Header.Get("Authorization")
52- log.Debug().
53- Str(zerowrap.FieldLayer, "adapter").
54- Str(zerowrap.FieldAdapter, "http").
55- Str(zerowrap.FieldMethod, r.Method).
56- Str(zerowrap.FieldPath, r.URL.Path).
57- Bool("has_auth_header", authHeader != "").
58- Msg("processing authentication request")
59-
60- username, password, ok := r.BasicAuth()
61- if !ok {
62- log.Debug().
63- Str(zerowrap.FieldMethod, r.Method).
64- Str(zerowrap.FieldPath, r.URL.Path).
65- Msg("no basic auth provided")
66- return false
67- }
68-
69- // Use constant-time comparison to prevent timing attacks
70- usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1
71- passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1
72-
73- authenticated := usernameMatch && passwordMatch
74- if !authenticated {
75- log.Debug().
76- Str("provided_username", redactUsername(username)).
77- Str(zerowrap.FieldMethod, r.Method).
78- Str(zerowrap.FieldPath, r.URL.Path).
79- Msg("authentication failed")
80- } else {
81- log.Debug().
82- Str("username", redactUsername(username)).
83- Str(zerowrap.FieldMethod, r.Method).
84- Str(zerowrap.FieldPath, r.URL.Path).
85- Msg("authentication successful")
86- }
87-
88- return authenticated
89-}
90-
9122 // sanitizeHeaderValue removes characters that could enable header injection.
9223 // Only allows alphanumeric, dots, hyphens, colons, and square brackets
9324 // (sufficient for host:port and IPv6 addresses).
@@ -19,75 +19,6 @@ import (
19 // Using domain key for consistency across all auth flows.19 // Using domain key for consistency across all auth flows.
20 const TokenClaimsKey = domain.TokenClaimsKey20 const TokenClaimsKey = domain.TokenClaimsKey
21 21
22-// RegistryAuth middleware provides Docker Registry authentication.
23-func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler {
24- return func(next http.Handler) http.Handler {
25- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26- // Require auth for all registry operations
27- if !isAuthenticated(r, username, password, log) {
28- w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
29- w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
30- w.Header().Set("Content-Type", "application/json")
31- w.WriteHeader(http.StatusUnauthorized)
32- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
33-
34- log.Warn().
35- Str(zerowrap.FieldLayer, "adapter").
36- Str(zerowrap.FieldAdapter, "http").
37- Str(zerowrap.FieldMethod, r.Method).
38- Str(zerowrap.FieldPath, r.URL.Path).
39- Str(zerowrap.FieldClientIP, r.RemoteAddr).
40- Msg("unauthorized registry access attempt")
41- return
42- }
43-
44- next.ServeHTTP(w, r)
45- })
46- }
47-}
48-
49-// isAuthenticated checks basic auth credentials.
50-func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, log zerowrap.Logger) bool {
51- authHeader := r.Header.Get("Authorization")
52- log.Debug().
53- Str(zerowrap.FieldLayer, "adapter").
54- Str(zerowrap.FieldAdapter, "http").
55- Str(zerowrap.FieldMethod, r.Method).
56- Str(zerowrap.FieldPath, r.URL.Path).
57- Bool("has_auth_header", authHeader != "").
58- Msg("processing authentication request")
59-
60- username, password, ok := r.BasicAuth()
61- if !ok {
62- log.Debug().
63- Str(zerowrap.FieldMethod, r.Method).
64- Str(zerowrap.FieldPath, r.URL.Path).
65- Msg("no basic auth provided")
66- return false
67- }
68-
69- // Use constant-time comparison to prevent timing attacks
70- usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1
71- passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1
72-
73- authenticated := usernameMatch && passwordMatch
74- if !authenticated {
75- log.Debug().
76- Str("provided_username", redactUsername(username)).
77- Str(zerowrap.FieldMethod, r.Method).
78- Str(zerowrap.FieldPath, r.URL.Path).
79- Msg("authentication failed")
80- } else {
81- log.Debug().
82- Str("username", redactUsername(username)).
83- Str(zerowrap.FieldMethod, r.Method).
84- Str(zerowrap.FieldPath, r.URL.Path).
85- Msg("authentication successful")
86- }
87-
88- return authenticated
89-}
90-
91 // sanitizeHeaderValue removes characters that could enable header injection.22 // sanitizeHeaderValue removes characters that could enable header injection.
92 // Only allows alphanumeric, dots, hyphens, colons, and square brackets23 // Only allows alphanumeric, dots, hyphens, colons, and square brackets
93 // (sufficient for host:port and IPv6 addresses).24 // (sufficient for host:port and IPv6 addresses).
M internal/adapters/in/http/middleware/auth_test.go +0 -335
@@ -2,7 +2,6 @@ package middleware
22
33 import (
44 "context"
5- "encoding/base64"
65 "errors"
76 "net/http"
87 "net/http/httptest"
@@ -19,340 +18,6 @@ func testLogger() zerowrap.Logger {
1918 return zerowrap.Default()
2019 }
2120
22-func TestRegistryAuth_ValidCredentials(t *testing.T) {
23- log := testLogger()
24- middleware := RegistryAuth("admin", "secret", log)
25-
26- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27- w.WriteHeader(http.StatusOK)
28- w.Write([]byte("authenticated"))
29- }))
30-
31- req := httptest.NewRequest("GET", "/v2/", nil)
32- req.SetBasicAuth("admin", "secret")
33- rec := httptest.NewRecorder()
34-
35- handler.ServeHTTP(rec, req)
36-
37- assert.Equal(t, http.StatusOK, rec.Code)
38- assert.Equal(t, "authenticated", rec.Body.String())
39-}
40-
41-func TestRegistryAuth_InvalidUsername(t *testing.T) {
42- log := testLogger()
43- middleware := RegistryAuth("admin", "secret", log)
44-
45- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46- w.WriteHeader(http.StatusOK)
47- }))
48-
49- req := httptest.NewRequest("GET", "/v2/", nil)
50- req.SetBasicAuth("wronguser", "secret")
51- rec := httptest.NewRecorder()
52-
53- handler.ServeHTTP(rec, req)
54-
55- assert.Equal(t, http.StatusUnauthorized, rec.Code)
56- assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
57- assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
58-}
59-
60-func TestRegistryAuth_InvalidPassword(t *testing.T) {
61- log := testLogger()
62- middleware := RegistryAuth("admin", "secret", log)
63-
64- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65- w.WriteHeader(http.StatusOK)
66- }))
67-
68- req := httptest.NewRequest("GET", "/v2/", nil)
69- req.SetBasicAuth("admin", "wrongpassword")
70- rec := httptest.NewRecorder()
71-
72- handler.ServeHTTP(rec, req)
73-
74- assert.Equal(t, http.StatusUnauthorized, rec.Code)
75-}
76-
77-func TestRegistryAuth_NoCredentials(t *testing.T) {
78- log := testLogger()
79- middleware := RegistryAuth("admin", "secret", log)
80-
81- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82- w.WriteHeader(http.StatusOK)
83- }))
84-
85- req := httptest.NewRequest("GET", "/v2/", nil)
86- // No auth header
87- rec := httptest.NewRecorder()
88-
89- handler.ServeHTTP(rec, req)
90-
91- assert.Equal(t, http.StatusUnauthorized, rec.Code)
92- assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
93-}
94-
95-func TestRegistryAuth_MalformedAuthHeader(t *testing.T) {
96- log := testLogger()
97- middleware := RegistryAuth("admin", "secret", log)
98-
99- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100- w.WriteHeader(http.StatusOK)
101- }))
102-
103- req := httptest.NewRequest("GET", "/v2/", nil)
104- req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64
105- rec := httptest.NewRecorder()
106-
107- handler.ServeHTTP(rec, req)
108-
109- assert.Equal(t, http.StatusUnauthorized, rec.Code)
110-}
111-
112-func TestRegistryAuth_WrongAuthScheme(t *testing.T) {
113- log := testLogger()
114- middleware := RegistryAuth("admin", "secret", log)
115-
116- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117- w.WriteHeader(http.StatusOK)
118- }))
119-
120- req := httptest.NewRequest("GET", "/v2/", nil)
121- req.Header.Set("Authorization", "Bearer some-token")
122- rec := httptest.NewRecorder()
123-
124- handler.ServeHTTP(rec, req)
125-
126- assert.Equal(t, http.StatusUnauthorized, rec.Code)
127-}
128-
129-func TestRegistryAuth_EmptyCredentials(t *testing.T) {
130- log := testLogger()
131- middleware := RegistryAuth("admin", "secret", log)
132-
133- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134- w.WriteHeader(http.StatusOK)
135- }))
136-
137- req := httptest.NewRequest("GET", "/v2/", nil)
138- req.SetBasicAuth("", "")
139- rec := httptest.NewRecorder()
140-
141- handler.ServeHTTP(rec, req)
142-
143- assert.Equal(t, http.StatusUnauthorized, rec.Code)
144-}
145-
146-func TestRegistryAuth_PartialCredentials(t *testing.T) {
147- log := testLogger()
148- middleware := RegistryAuth("admin", "secret", log)
149-
150- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151- w.WriteHeader(http.StatusOK)
152- }))
153-
154- // Only username, empty password
155- req := httptest.NewRequest("GET", "/v2/", nil)
156- req.SetBasicAuth("admin", "")
157- rec := httptest.NewRecorder()
158-
159- handler.ServeHTTP(rec, req)
160-
161- assert.Equal(t, http.StatusUnauthorized, rec.Code)
162-}
163-
164-func TestIsAuthenticated_ValidCredentials(t *testing.T) {
165- log := testLogger()
166-
167- req := httptest.NewRequest("GET", "/v2/", nil)
168- req.SetBasicAuth("admin", "secret")
169-
170- result := isAuthenticated(req, "admin", "secret", log)
171-
172- assert.True(t, result)
173-}
174-
175-func TestIsAuthenticated_InvalidCredentials(t *testing.T) {
176- log := testLogger()
177-
178- req := httptest.NewRequest("GET", "/v2/", nil)
179- req.SetBasicAuth("admin", "wrong")
180-
181- result := isAuthenticated(req, "admin", "secret", log)
182-
183- assert.False(t, result)
184-}
185-
186-func TestIsAuthenticated_NoAuthHeader(t *testing.T) {
187- log := testLogger()
188-
189- req := httptest.NewRequest("GET", "/v2/", nil)
190-
191- result := isAuthenticated(req, "admin", "secret", log)
192-
193- assert.False(t, result)
194-}
195-
196-func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) {
197- // This test verifies that constant-time comparison is used
198- // by checking both username and password are validated
199- log := testLogger()
200-
201- tests := []struct {
202- name string
203- username string
204- password string
205- expectedUser string
206- expectedPass string
207- shouldAuth bool
208- }{
209- {
210- name: "both correct",
211- username: "admin",
212- password: "secret",
213- expectedUser: "admin",
214- expectedPass: "secret",
215- shouldAuth: true,
216- },
217- {
218- name: "username wrong first char",
219- username: "bdmin",
220- password: "secret",
221- expectedUser: "admin",
222- expectedPass: "secret",
223- shouldAuth: false,
224- },
225- {
226- name: "username wrong last char",
227- username: "admio",
228- password: "secret",
229- expectedUser: "admin",
230- expectedPass: "secret",
231- shouldAuth: false,
232- },
233- {
234- name: "password wrong first char",
235- username: "admin",
236- password: "aecret",
237- expectedUser: "admin",
238- expectedPass: "secret",
239- shouldAuth: false,
240- },
241- {
242- name: "password wrong last char",
243- username: "admin",
244- password: "secreo",
245- expectedUser: "admin",
246- expectedPass: "secret",
247- shouldAuth: false,
248- },
249- {
250- name: "different length username",
251- username: "adm",
252- password: "secret",
253- expectedUser: "admin",
254- expectedPass: "secret",
255- shouldAuth: false,
256- },
257- {
258- name: "different length password",
259- username: "admin",
260- password: "sec",
261- expectedUser: "admin",
262- expectedPass: "secret",
263- shouldAuth: false,
264- },
265- }
266-
267- for _, tt := range tests {
268- t.Run(tt.name, func(t *testing.T) {
269- req := httptest.NewRequest("GET", "/v2/", nil)
270- req.SetBasicAuth(tt.username, tt.password)
271-
272- result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log)
273-
274- assert.Equal(t, tt.shouldAuth, result)
275- })
276- }
277-}
278-
279-func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) {
280- log := testLogger()
281-
282- tests := []struct {
283- name string
284- username string
285- password string
286- }{
287- {"unicode username", "用户", "password"},
288- {"unicode password", "admin", "密码"},
289- {"special chars", "admin@example.com", "p@ss:w0rd!"},
290- {"spaces", "admin user", "pass word"},
291- {"colon in password", "admin", "pass:word"},
292- }
293-
294- for _, tt := range tests {
295- t.Run(tt.name, func(t *testing.T) {
296- middleware := RegistryAuth(tt.username, tt.password, log)
297-
298- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
299- w.WriteHeader(http.StatusOK)
300- }))
301-
302- req := httptest.NewRequest("GET", "/v2/", nil)
303- req.SetBasicAuth(tt.username, tt.password)
304- rec := httptest.NewRecorder()
305-
306- handler.ServeHTTP(rec, req)
307-
308- assert.Equal(t, http.StatusOK, rec.Code)
309- })
310- }
311-}
312-
313-func TestRegistryAuth_Base64EdgeCases(t *testing.T) {
314- log := testLogger()
315- middleware := RegistryAuth("admin", "secret", log)
316-
317- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
318- w.WriteHeader(http.StatusOK)
319- }))
320-
321- tests := []struct {
322- name string
323- authHeader string
324- wantStatus int
325- }{
326- {
327- name: "empty basic auth",
328- authHeader: "Basic ",
329- wantStatus: http.StatusUnauthorized,
330- },
331- {
332- name: "basic auth with only colon",
333- authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")),
334- wantStatus: http.StatusUnauthorized,
335- },
336- {
337- name: "missing colon in decoded value",
338- authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")),
339- wantStatus: http.StatusUnauthorized,
340- },
341- }
342-
343- for _, tt := range tests {
344- t.Run(tt.name, func(t *testing.T) {
345- req := httptest.NewRequest("GET", "/v2/", nil)
346- req.Header.Set("Authorization", tt.authHeader)
347- rec := httptest.NewRecorder()
348-
349- handler.ServeHTTP(rec, req)
350-
351- assert.Equal(t, tt.wantStatus, rec.Code)
352- })
353- }
354-}
355-
35621 func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
35722 log := testLogger()
35823 called := false
@@ -2,7 +2,6 @@ package middleware
2 2
3 import (3 import (
4 "context"4 "context"
5- "encoding/base64"
6 "errors"5 "errors"
7 "net/http"6 "net/http"
8 "net/http/httptest"7 "net/http/httptest"
@@ -19,340 +18,6 @@ func testLogger() zerowrap.Logger {
19 return zerowrap.Default()18 return zerowrap.Default()
20 }19 }
21 20
22-func TestRegistryAuth_ValidCredentials(t *testing.T) {
23- log := testLogger()
24- middleware := RegistryAuth("admin", "secret", log)
25-
26- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27- w.WriteHeader(http.StatusOK)
28- w.Write([]byte("authenticated"))
29- }))
30-
31- req := httptest.NewRequest("GET", "/v2/", nil)
32- req.SetBasicAuth("admin", "secret")
33- rec := httptest.NewRecorder()
34-
35- handler.ServeHTTP(rec, req)
36-
37- assert.Equal(t, http.StatusOK, rec.Code)
38- assert.Equal(t, "authenticated", rec.Body.String())
39-}
40-
41-func TestRegistryAuth_InvalidUsername(t *testing.T) {
42- log := testLogger()
43- middleware := RegistryAuth("admin", "secret", log)
44-
45- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46- w.WriteHeader(http.StatusOK)
47- }))
48-
49- req := httptest.NewRequest("GET", "/v2/", nil)
50- req.SetBasicAuth("wronguser", "secret")
51- rec := httptest.NewRecorder()
52-
53- handler.ServeHTTP(rec, req)
54-
55- assert.Equal(t, http.StatusUnauthorized, rec.Code)
56- assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
57- assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
58-}
59-
60-func TestRegistryAuth_InvalidPassword(t *testing.T) {
61- log := testLogger()
62- middleware := RegistryAuth("admin", "secret", log)
63-
64- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65- w.WriteHeader(http.StatusOK)
66- }))
67-
68- req := httptest.NewRequest("GET", "/v2/", nil)
69- req.SetBasicAuth("admin", "wrongpassword")
70- rec := httptest.NewRecorder()
71-
72- handler.ServeHTTP(rec, req)
73-
74- assert.Equal(t, http.StatusUnauthorized, rec.Code)
75-}
76-
77-func TestRegistryAuth_NoCredentials(t *testing.T) {
78- log := testLogger()
79- middleware := RegistryAuth("admin", "secret", log)
80-
81- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82- w.WriteHeader(http.StatusOK)
83- }))
84-
85- req := httptest.NewRequest("GET", "/v2/", nil)
86- // No auth header
87- rec := httptest.NewRecorder()
88-
89- handler.ServeHTTP(rec, req)
90-
91- assert.Equal(t, http.StatusUnauthorized, rec.Code)
92- assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
93-}
94-
95-func TestRegistryAuth_MalformedAuthHeader(t *testing.T) {
96- log := testLogger()
97- middleware := RegistryAuth("admin", "secret", log)
98-
99- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100- w.WriteHeader(http.StatusOK)
101- }))
102-
103- req := httptest.NewRequest("GET", "/v2/", nil)
104- req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64
105- rec := httptest.NewRecorder()
106-
107- handler.ServeHTTP(rec, req)
108-
109- assert.Equal(t, http.StatusUnauthorized, rec.Code)
110-}
111-
112-func TestRegistryAuth_WrongAuthScheme(t *testing.T) {
113- log := testLogger()
114- middleware := RegistryAuth("admin", "secret", log)
115-
116- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117- w.WriteHeader(http.StatusOK)
118- }))
119-
120- req := httptest.NewRequest("GET", "/v2/", nil)
121- req.Header.Set("Authorization", "Bearer some-token")
122- rec := httptest.NewRecorder()
123-
124- handler.ServeHTTP(rec, req)
125-
126- assert.Equal(t, http.StatusUnauthorized, rec.Code)
127-}
128-
129-func TestRegistryAuth_EmptyCredentials(t *testing.T) {
130- log := testLogger()
131- middleware := RegistryAuth("admin", "secret", log)
132-
133- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134- w.WriteHeader(http.StatusOK)
135- }))
136-
137- req := httptest.NewRequest("GET", "/v2/", nil)
138- req.SetBasicAuth("", "")
139- rec := httptest.NewRecorder()
140-
141- handler.ServeHTTP(rec, req)
142-
143- assert.Equal(t, http.StatusUnauthorized, rec.Code)
144-}
145-
146-func TestRegistryAuth_PartialCredentials(t *testing.T) {
147- log := testLogger()
148- middleware := RegistryAuth("admin", "secret", log)
149-
150- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151- w.WriteHeader(http.StatusOK)
152- }))
153-
154- // Only username, empty password
155- req := httptest.NewRequest("GET", "/v2/", nil)
156- req.SetBasicAuth("admin", "")
157- rec := httptest.NewRecorder()
158-
159- handler.ServeHTTP(rec, req)
160-
161- assert.Equal(t, http.StatusUnauthorized, rec.Code)
162-}
163-
164-func TestIsAuthenticated_ValidCredentials(t *testing.T) {
165- log := testLogger()
166-
167- req := httptest.NewRequest("GET", "/v2/", nil)
168- req.SetBasicAuth("admin", "secret")
169-
170- result := isAuthenticated(req, "admin", "secret", log)
171-
172- assert.True(t, result)
173-}
174-
175-func TestIsAuthenticated_InvalidCredentials(t *testing.T) {
176- log := testLogger()
177-
178- req := httptest.NewRequest("GET", "/v2/", nil)
179- req.SetBasicAuth("admin", "wrong")
180-
181- result := isAuthenticated(req, "admin", "secret", log)
182-
183- assert.False(t, result)
184-}
185-
186-func TestIsAuthenticated_NoAuthHeader(t *testing.T) {
187- log := testLogger()
188-
189- req := httptest.NewRequest("GET", "/v2/", nil)
190-
191- result := isAuthenticated(req, "admin", "secret", log)
192-
193- assert.False(t, result)
194-}
195-
196-func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) {
197- // This test verifies that constant-time comparison is used
198- // by checking both username and password are validated
199- log := testLogger()
200-
201- tests := []struct {
202- name string
203- username string
204- password string
205- expectedUser string
206- expectedPass string
207- shouldAuth bool
208- }{
209- {
210- name: "both correct",
211- username: "admin",
212- password: "secret",
213- expectedUser: "admin",
214- expectedPass: "secret",
215- shouldAuth: true,
216- },
217- {
218- name: "username wrong first char",
219- username: "bdmin",
220- password: "secret",
221- expectedUser: "admin",
222- expectedPass: "secret",
223- shouldAuth: false,
224- },
225- {
226- name: "username wrong last char",
227- username: "admio",
228- password: "secret",
229- expectedUser: "admin",
230- expectedPass: "secret",
231- shouldAuth: false,
232- },
233- {
234- name: "password wrong first char",
235- username: "admin",
236- password: "aecret",
237- expectedUser: "admin",
238- expectedPass: "secret",
239- shouldAuth: false,
240- },
241- {
242- name: "password wrong last char",
243- username: "admin",
244- password: "secreo",
245- expectedUser: "admin",
246- expectedPass: "secret",
247- shouldAuth: false,
248- },
249- {
250- name: "different length username",
251- username: "adm",
252- password: "secret",
253- expectedUser: "admin",
254- expectedPass: "secret",
255- shouldAuth: false,
256- },
257- {
258- name: "different length password",
259- username: "admin",
260- password: "sec",
261- expectedUser: "admin",
262- expectedPass: "secret",
263- shouldAuth: false,
264- },
265- }
266-
267- for _, tt := range tests {
268- t.Run(tt.name, func(t *testing.T) {
269- req := httptest.NewRequest("GET", "/v2/", nil)
270- req.SetBasicAuth(tt.username, tt.password)
271-
272- result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log)
273-
274- assert.Equal(t, tt.shouldAuth, result)
275- })
276- }
277-}
278-
279-func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) {
280- log := testLogger()
281-
282- tests := []struct {
283- name string
284- username string
285- password string
286- }{
287- {"unicode username", "用户", "password"},
288- {"unicode password", "admin", "密码"},
289- {"special chars", "admin@example.com", "p@ss:w0rd!"},
290- {"spaces", "admin user", "pass word"},
291- {"colon in password", "admin", "pass:word"},
292- }
293-
294- for _, tt := range tests {
295- t.Run(tt.name, func(t *testing.T) {
296- middleware := RegistryAuth(tt.username, tt.password, log)
297-
298- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
299- w.WriteHeader(http.StatusOK)
300- }))
301-
302- req := httptest.NewRequest("GET", "/v2/", nil)
303- req.SetBasicAuth(tt.username, tt.password)
304- rec := httptest.NewRecorder()
305-
306- handler.ServeHTTP(rec, req)
307-
308- assert.Equal(t, http.StatusOK, rec.Code)
309- })
310- }
311-}
312-
313-func TestRegistryAuth_Base64EdgeCases(t *testing.T) {
314- log := testLogger()
315- middleware := RegistryAuth("admin", "secret", log)
316-
317- handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
318- w.WriteHeader(http.StatusOK)
319- }))
320-
321- tests := []struct {
322- name string
323- authHeader string
324- wantStatus int
325- }{
326- {
327- name: "empty basic auth",
328- authHeader: "Basic ",
329- wantStatus: http.StatusUnauthorized,
330- },
331- {
332- name: "basic auth with only colon",
333- authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")),
334- wantStatus: http.StatusUnauthorized,
335- },
336- {
337- name: "missing colon in decoded value",
338- authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")),
339- wantStatus: http.StatusUnauthorized,
340- },
341- }
342-
343- for _, tt := range tests {
344- t.Run(tt.name, func(t *testing.T) {
345- req := httptest.NewRequest("GET", "/v2/", nil)
346- req.Header.Set("Authorization", tt.authHeader)
347- rec := httptest.NewRecorder()
348-
349- handler.ServeHTTP(rec, req)
350-
351- assert.Equal(t, tt.wantStatus, rec.Code)
352- })
353- }
354-}
355-
356 func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {21 func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
357 log := testLogger()22 log := testLogger()
358 called := false23 called := false
M internal/usecase/config/service.go +0 -7
@@ -534,13 +534,6 @@ func (s *Service) GetConfig() Config {
534534 return s.config
535535 }
536536
537-// GetRegistryAuthConfig returns registry authentication configuration.
538-func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) {
539- s.mu.RLock()
540- defer s.mu.RUnlock()
541- return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword
542-}
543-
544537 // GetVolumeConfig returns volume configuration.
545538 func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
546539 s.mu.RLock()
@@ -534,13 +534,6 @@ func (s *Service) GetConfig() Config {
534 return s.config534 return s.config
535 }535 }
536 536
537-// GetRegistryAuthConfig returns registry authentication configuration.
538-func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) {
539- s.mu.RLock()
540- defer s.mu.RUnlock()
541- return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword
542-}
543-
544 // GetVolumeConfig returns volume configuration.537 // GetVolumeConfig returns volume configuration.
545 func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {538 func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
546 s.mu.RLock()539 s.mu.RLock()
M internal/usecase/config/service_test.go +115 -5
@@ -53,11 +53,6 @@ func TestService_Load(t *testing.T) {
5353 assert.True(t, svc.IsNetworkIsolationEnabled())
5454 assert.Equal(t, "gordon", svc.GetNetworkPrefix())
5555
56- enabled, username, password := svc.GetRegistryAuthConfig()
57- assert.True(t, enabled)
58- assert.Equal(t, "admin", username)
59- assert.Equal(t, "secret", password)
60-
6156 autoCreate, prefix, preserve := svc.GetVolumeConfig()
6257 assert.True(t, autoCreate)
6358 assert.Equal(t, "gordon", prefix)
@@ -1091,6 +1086,121 @@ func TestService_FindRoutesByImage(t *testing.T) {
10911086 })
10921087 }
10931088
1089+func TestSavePreservesAllConfigFields(t *testing.T) {
1090+ t.Run("network_groups are persisted to disk", func(t *testing.T) {
1091+ // Setup: config file with auth, server, and network_groups
1092+ tmpDir := t.TempDir()
1093+ configFile := filepath.Join(tmpDir, "gordon.toml")
1094+ initialConfig := `
1095+[server]
1096+port = 9999
1097+
1098+[auth]
1099+username = "admin"
1100+token_secret = "supersecretvalue"
1101+
1102+[routes]
1103+"app.example.com" = "myapp:latest"
1104+
1105+[network_groups]
1106+frontend = ["app1.example.com", "app2.example.com"]
1107+`
1108+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1109+ require.NoError(t, err)
1110+
1111+ v := viper.New()
1112+ v.SetConfigFile(configFile)
1113+ err = v.ReadInConfig()
1114+ require.NoError(t, err)
1115+
1116+ eventBus := mocks.NewMockEventPublisher(t)
1117+ svc := NewService(v, eventBus)
1118+ ctx := testContext()
1119+
1120+ err = svc.Load(ctx)
1121+ require.NoError(t, err)
1122+
1123+ // Verify network_groups loaded correctly in memory
1124+ groups := svc.GetNetworkGroups()
1125+ require.Len(t, groups, 1)
1126+ require.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"])
1127+
1128+ // Trigger a Save via AddRoute (which calls Save internally)
1129+ err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1130+ require.NoError(t, err)
1131+
1132+ // Re-read the file to verify network_groups was persisted
1133+ v2 := viper.New()
1134+ v2.SetConfigFile(configFile)
1135+ err = v2.ReadInConfig()
1136+ require.NoError(t, err)
1137+
1138+ // Verify network_groups is still in the file after save
1139+ savedGroups := loadStringArrayMap(v2.Get("network_groups"))
1140+ assert.Len(t, savedGroups, 1, "network_groups should still be present in saved config")
1141+ assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, savedGroups["frontend"])
1142+
1143+ // Verify auth fields are preserved
1144+ assert.Equal(t, "admin", v2.GetString("auth.username"))
1145+ assert.Equal(t, "supersecretvalue", v2.GetString("auth.token_secret"))
1146+
1147+ // Verify server fields are preserved
1148+ assert.Equal(t, 9999, v2.GetInt("server.port"))
1149+ })
1150+
1151+ t.Run("network_groups added in memory are written to disk on Save", func(t *testing.T) {
1152+ // Setup: config file WITHOUT network_groups
1153+ tmpDir := t.TempDir()
1154+ configFile := filepath.Join(tmpDir, "gordon.toml")
1155+ initialConfig := `
1156+[server]
1157+port = 8080
1158+
1159+[routes]
1160+"app.example.com" = "myapp:latest"
1161+`
1162+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1163+ require.NoError(t, err)
1164+
1165+ v := viper.New()
1166+ v.SetConfigFile(configFile)
1167+ err = v.ReadInConfig()
1168+ require.NoError(t, err)
1169+
1170+ // Inject network_groups into viper as if they were loaded from some other source
1171+ // This simulates a case where network_groups exist in memory but not on disk
1172+ v.Set("network_groups", map[string]interface{}{
1173+ "backend": []interface{}{"api.example.com"},
1174+ })
1175+
1176+ eventBus := mocks.NewMockEventPublisher(t)
1177+ svc := NewService(v, eventBus)
1178+ ctx := testContext()
1179+
1180+ err = svc.Load(ctx)
1181+ require.NoError(t, err)
1182+
1183+ // Verify network_groups is loaded in memory
1184+ groups := svc.GetNetworkGroups()
1185+ require.Len(t, groups, 1)
1186+
1187+ // Trigger Save
1188+ err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1189+ require.NoError(t, err)
1190+
1191+ // Re-read the file
1192+ v2 := viper.New()
1193+ v2.SetConfigFile(configFile)
1194+ err = v2.ReadInConfig()
1195+ require.NoError(t, err)
1196+
1197+ // network_groups must be present in the saved file
1198+ savedGroups := loadStringArrayMap(v2.Get("network_groups"))
1199+ assert.Len(t, savedGroups, 1, "network_groups added in memory must be written on Save")
1200+ assert.ElementsMatch(t, []string{"api.example.com"}, savedGroups["backend"])
1201+ })
1202+}
1203+
10941204 func TestExtractDomainFromImageName(t *testing.T) {
10951205 tests := []struct {
10961206 name string
@@ -53,11 +53,6 @@ func TestService_Load(t *testing.T) {
53 assert.True(t, svc.IsNetworkIsolationEnabled())53 assert.True(t, svc.IsNetworkIsolationEnabled())
54 assert.Equal(t, "gordon", svc.GetNetworkPrefix())54 assert.Equal(t, "gordon", svc.GetNetworkPrefix())
55 55
56- enabled, username, password := svc.GetRegistryAuthConfig()
57- assert.True(t, enabled)
58- assert.Equal(t, "admin", username)
59- assert.Equal(t, "secret", password)
60-
61 autoCreate, prefix, preserve := svc.GetVolumeConfig()56 autoCreate, prefix, preserve := svc.GetVolumeConfig()
62 assert.True(t, autoCreate)57 assert.True(t, autoCreate)
63 assert.Equal(t, "gordon", prefix)58 assert.Equal(t, "gordon", prefix)
@@ -1091,6 +1086,121 @@ func TestService_FindRoutesByImage(t *testing.T) {
1091 })1086 })
1092 }1087 }
1093 1088
1089+func TestSavePreservesAllConfigFields(t *testing.T) {
1090+ t.Run("network_groups are persisted to disk", func(t *testing.T) {
1091+ // Setup: config file with auth, server, and network_groups
1092+ tmpDir := t.TempDir()
1093+ configFile := filepath.Join(tmpDir, "gordon.toml")
1094+ initialConfig := `
1095+[server]
1096+port = 9999
1097+
1098+[auth]
1099+username = "admin"
1100+token_secret = "supersecretvalue"
1101+
1102+[routes]
1103+"app.example.com" = "myapp:latest"
1104+
1105+[network_groups]
1106+frontend = ["app1.example.com", "app2.example.com"]
1107+`
1108+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1109+ require.NoError(t, err)
1110+
1111+ v := viper.New()
1112+ v.SetConfigFile(configFile)
1113+ err = v.ReadInConfig()
1114+ require.NoError(t, err)
1115+
1116+ eventBus := mocks.NewMockEventPublisher(t)
1117+ svc := NewService(v, eventBus)
1118+ ctx := testContext()
1119+
1120+ err = svc.Load(ctx)
1121+ require.NoError(t, err)
1122+
1123+ // Verify network_groups loaded correctly in memory
1124+ groups := svc.GetNetworkGroups()
1125+ require.Len(t, groups, 1)
1126+ require.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"])
1127+
1128+ // Trigger a Save via AddRoute (which calls Save internally)
1129+ err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1130+ require.NoError(t, err)
1131+
1132+ // Re-read the file to verify network_groups was persisted
1133+ v2 := viper.New()
1134+ v2.SetConfigFile(configFile)
1135+ err = v2.ReadInConfig()
1136+ require.NoError(t, err)
1137+
1138+ // Verify network_groups is still in the file after save
1139+ savedGroups := loadStringArrayMap(v2.Get("network_groups"))
1140+ assert.Len(t, savedGroups, 1, "network_groups should still be present in saved config")
1141+ assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, savedGroups["frontend"])
1142+
1143+ // Verify auth fields are preserved
1144+ assert.Equal(t, "admin", v2.GetString("auth.username"))
1145+ assert.Equal(t, "supersecretvalue", v2.GetString("auth.token_secret"))
1146+
1147+ // Verify server fields are preserved
1148+ assert.Equal(t, 9999, v2.GetInt("server.port"))
1149+ })
1150+
1151+ t.Run("network_groups added in memory are written to disk on Save", func(t *testing.T) {
1152+ // Setup: config file WITHOUT network_groups
1153+ tmpDir := t.TempDir()
1154+ configFile := filepath.Join(tmpDir, "gordon.toml")
1155+ initialConfig := `
1156+[server]
1157+port = 8080
1158+
1159+[routes]
1160+"app.example.com" = "myapp:latest"
1161+`
1162+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1163+ require.NoError(t, err)
1164+
1165+ v := viper.New()
1166+ v.SetConfigFile(configFile)
1167+ err = v.ReadInConfig()
1168+ require.NoError(t, err)
1169+
1170+ // Inject network_groups into viper as if they were loaded from some other source
1171+ // This simulates a case where network_groups exist in memory but not on disk
1172+ v.Set("network_groups", map[string]interface{}{
1173+ "backend": []interface{}{"api.example.com"},
1174+ })
1175+
1176+ eventBus := mocks.NewMockEventPublisher(t)
1177+ svc := NewService(v, eventBus)
1178+ ctx := testContext()
1179+
1180+ err = svc.Load(ctx)
1181+ require.NoError(t, err)
1182+
1183+ // Verify network_groups is loaded in memory
1184+ groups := svc.GetNetworkGroups()
1185+ require.Len(t, groups, 1)
1186+
1187+ // Trigger Save
1188+ err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1189+ require.NoError(t, err)
1190+
1191+ // Re-read the file
1192+ v2 := viper.New()
1193+ v2.SetConfigFile(configFile)
1194+ err = v2.ReadInConfig()
1195+ require.NoError(t, err)
1196+
1197+ // network_groups must be present in the saved file
1198+ savedGroups := loadStringArrayMap(v2.Get("network_groups"))
1199+ assert.Len(t, savedGroups, 1, "network_groups added in memory must be written on Save")
1200+ assert.ElementsMatch(t, []string{"api.example.com"}, savedGroups["backend"])
1201+ })
1202+}
1203+
1094 func TestExtractDomainFromImageName(t *testing.T) {1204 func TestExtractDomainFromImageName(t *testing.T) {
1095 tests := []struct {1205 tests := []struct {
1096 name string1206 name string

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close