feat(app): integrate authentication services and secrets backend
Wire authentication into application: - Add secrets backend configuration - Enhanced registry auth config (password/token types) - Create and inject auth service - Register auth CLI commands - Add bcrypt and JWT dependencies
- commit
- 4d70f404ecb7c97c1a05580f653325d1fa2b6944
- author
- brice · · 2026-01-12 18:53 UTC
- committer
- brice · · 2026-01-12 21:32 UTC
- parent
- 136d617
- signature
- unsigned
4 files · +177 -6
M go.mod +3 -0
| @@ -14,8 +14,11 @@ require ( | |||
| 14 | 14 | ||
| 15 | 15 | require ( | |
| 16 | 16 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| 17 | + | github.com/golang-jwt/jwt/v5 v5.3.0 // indirect | |
| 17 | 18 | github.com/rs/zerolog v1.34.0 // indirect | |
| 18 | 19 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 20 | + | golang.org/x/crypto v0.47.0 // indirect | |
| 21 | + | golang.org/x/term v0.39.0 // indirect | |
| 19 | 22 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | |
| 20 | 23 | ) | |
| 21 | 24 | ||
| @@ -14,8 +14,11 @@ require ( | |||||
| 14 | 14 | ||||
| 15 | require ( | 15 | require ( | ||
| 16 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | 16 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||
| 17 | + | github.com/golang-jwt/jwt/v5 v5.3.0 // indirect | |||
| 17 | github.com/rs/zerolog v1.34.0 // indirect | 18 | github.com/rs/zerolog v1.34.0 // indirect | ||
| 18 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 19 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 20 | + | golang.org/x/crypto v0.47.0 // indirect | |||
| 21 | + | golang.org/x/term v0.39.0 // indirect | |||
| 19 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | 22 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | ||
| 20 | ) | 23 | ) | ||
| 21 | 24 | ||||
M go.sum +6 -0
| @@ -42,6 +42,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd | |||
| 42 | 42 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | |
| 43 | 43 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= | |
| 44 | 44 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= | |
| 45 | + | github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= | |
| 46 | + | github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= | |
| 45 | 47 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | |
| 46 | 48 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | |
| 47 | 49 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |
| @@ -142,6 +144,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | |||
| 142 | 144 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | |
| 143 | 145 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | |
| 144 | 146 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | |
| 147 | + | golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= | |
| 148 | + | golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= | |
| 145 | 149 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | |
| 146 | 150 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | |
| 147 | 151 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | |
| @@ -163,6 +167,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |||
| 163 | 167 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 164 | 168 | golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= | |
| 165 | 169 | golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | |
| 170 | + | golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= | |
| 171 | + | golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= | |
| 166 | 172 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | |
| 167 | 173 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | |
| 168 | 174 | golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= | |
| @@ -42,6 +42,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd | |||||
| 42 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | 42 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | ||
| 43 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= | 43 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= | ||
| 44 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= | 44 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= | ||
| 45 | + | github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= | |||
| 46 | + | github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= | |||
| 45 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | 47 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | ||
| 46 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | 48 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | ||
| 47 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | 49 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||
| @@ -142,6 +144,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | |||||
| 142 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | 144 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||
| 143 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | 145 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||
| 144 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | 146 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | ||
| 147 | + | golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= | |||
| 148 | + | golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= | |||
| 145 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | 149 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||
| 146 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | 150 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||
| 147 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | 151 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||
| @@ -163,6 +167,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |||||
| 163 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 167 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 164 | golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= | 168 | golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= | ||
| 165 | golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | 169 | golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | ||
| 170 | + | golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= | |||
| 171 | + | golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= | |||
| 166 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | 172 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||
| 167 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | 173 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||
| 168 | golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= | 174 | golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= | ||
M internal/adapters/in/cli/root.go +1 -0
| @@ -33,6 +33,7 @@ configuration rules, making it ideal for single-server deployments.`, | |||
| 33 | 33 | rootCmd.AddCommand(newStartCmd()) | |
| 34 | 34 | rootCmd.AddCommand(newReloadCmd()) | |
| 35 | 35 | rootCmd.AddCommand(newVersionCmd()) | |
| 36 | + | rootCmd.AddCommand(newAuthCmd()) | |
| 36 | 37 | ||
| 37 | 38 | return rootCmd | |
| 38 | 39 | } | |
| @@ -33,6 +33,7 @@ configuration rules, making it ideal for single-server deployments.`, | |||||
| 33 | rootCmd.AddCommand(newStartCmd()) | 33 | rootCmd.AddCommand(newStartCmd()) | ||
| 34 | rootCmd.AddCommand(newReloadCmd()) | 34 | rootCmd.AddCommand(newReloadCmd()) | ||
| 35 | rootCmd.AddCommand(newVersionCmd()) | 35 | rootCmd.AddCommand(newVersionCmd()) | ||
| 36 | + | rootCmd.AddCommand(newAuthCmd()) | |||
| 36 | 37 | ||||
| 37 | return rootCmd | 38 | return rootCmd | ||
| 38 | } | 39 | } | ||
M internal/app/run.go +167 -6
| @@ -22,12 +22,20 @@ import ( | |||
| 22 | 22 | "gordon/internal/adapters/out/filesystem" | |
| 23 | 23 | "gordon/internal/adapters/out/logwriter" | |
| 24 | 24 | "gordon/internal/adapters/out/secrets" | |
| 25 | + | "gordon/internal/adapters/out/tokenstore" | |
| 25 | 26 | ||
| 26 | 27 | // Adapters - Input | |
| 27 | 28 | "gordon/internal/adapters/in/http/middleware" | |
| 28 | 29 | "gordon/internal/adapters/in/http/registry" | |
| 29 | 30 | ||
| 31 | + | // Boundaries | |
| 32 | + | "gordon/internal/boundaries/out" | |
| 33 | + | ||
| 34 | + | // Domain | |
| 35 | + | "gordon/internal/domain" | |
| 36 | + | ||
| 30 | 37 | // Use cases | |
| 38 | + | "gordon/internal/usecase/auth" | |
| 31 | 39 | "gordon/internal/usecase/config" | |
| 32 | 40 | "gordon/internal/usecase/container" | |
| 33 | 41 | "gordon/internal/usecase/proxy" | |
| @@ -66,10 +74,18 @@ type Config struct { | |||
| 66 | 74 | Dir string `mapstructure:"dir"` | |
| 67 | 75 | } `mapstructure:"env"` | |
| 68 | 76 | ||
| 77 | + | Secrets struct { | |
| 78 | + | Backend string `mapstructure:"backend"` // "pass", "sops", or "unsafe" | |
| 79 | + | } `mapstructure:"secrets"` | |
| 80 | + | ||
| 69 | 81 | RegistryAuth struct { | |
| 70 | - | Enabled bool `mapstructure:"enabled"` | |
| 71 | - | Username string `mapstructure:"username"` | |
| 72 | - | Password string `mapstructure:"password"` | |
| 82 | + | Enabled bool `mapstructure:"enabled"` | |
| 83 | + | Type string `mapstructure:"type"` // "password" or "token" | |
| 84 | + | Username string `mapstructure:"username"` | |
| 85 | + | Password string `mapstructure:"password"` // deprecated: use password_hash | |
| 86 | + | PasswordHash string `mapstructure:"password_hash"` // path in secrets backend | |
| 87 | + | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | |
| 88 | + | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h" | |
| 73 | 89 | } `mapstructure:"registry_auth"` | |
| 74 | 90 | } | |
| 75 | 91 | ||
| @@ -81,10 +97,13 @@ type services struct { | |||
| 81 | 97 | manifestStorage *filesystem.ManifestStorage | |
| 82 | 98 | envLoader *envloader.FileLoader | |
| 83 | 99 | logWriter *logwriter.LogWriter | |
| 100 | + | tokenStore out.TokenStore | |
| 84 | 101 | configSvc *config.Service | |
| 85 | 102 | containerSvc *container.Service | |
| 86 | 103 | registrySvc *registrySvc.Service | |
| 87 | 104 | proxySvc *proxy.Service | |
| 105 | + | authSvc *auth.Service | |
| 106 | + | tokenHandler *registry.TokenHandler | |
| 88 | 107 | } | |
| 89 | 108 | ||
| 90 | 109 | // Run initializes and starts the Gordon application. | |
| @@ -208,6 +227,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 208 | 227 | return nil, err | |
| 209 | 228 | } | |
| 210 | 229 | ||
| 230 | + | // Create auth service (if enabled) | |
| 231 | + | if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil { | |
| 232 | + | return nil, err | |
| 233 | + | } | |
| 234 | + | ||
| 211 | 235 | // Create use case services | |
| 212 | 236 | svc.configSvc = config.NewService(v, svc.eventBus) | |
| 213 | 237 | if err := svc.configSvc.Load(ctx); err != nil { | |
| @@ -221,6 +245,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 221 | 245 | RegistryPort: cfg.Server.RegistryPort, | |
| 222 | 246 | }) | |
| 223 | 247 | ||
| 248 | + | // Create token handler for registry token endpoint | |
| 249 | + | if svc.authSvc != nil { | |
| 250 | + | svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, log) | |
| 251 | + | } | |
| 252 | + | ||
| 224 | 253 | return svc, nil | |
| 225 | 254 | } | |
| 226 | 255 | ||
| @@ -329,6 +358,122 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err | |||
| 329 | 358 | return writer, nil | |
| 330 | 359 | } | |
| 331 | 360 | ||
| 361 | + | // createAuthService creates the authentication service and token store. | |
| 362 | + | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | |
| 363 | + | if !cfg.RegistryAuth.Enabled { | |
| 364 | + | return nil, nil, nil | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // Determine auth type | |
| 368 | + | authType := domain.AuthTypePassword | |
| 369 | + | if cfg.RegistryAuth.Type == "token" { | |
| 370 | + | authType = domain.AuthTypeToken | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // Determine secrets backend | |
| 374 | + | backend := domain.SecretsBackendUnsafe // default | |
| 375 | + | switch cfg.Secrets.Backend { | |
| 376 | + | case "pass": | |
| 377 | + | backend = domain.SecretsBackendPass | |
| 378 | + | case "sops": | |
| 379 | + | backend = domain.SecretsBackendSops | |
| 380 | + | case "unsafe", "": | |
| 381 | + | backend = domain.SecretsBackendUnsafe | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // Get data directory | |
| 385 | + | dataDir := cfg.Server.DataDir | |
| 386 | + | if dataDir == "" { | |
| 387 | + | dataDir = "/var/lib/gordon" | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | // Create token store (needed for token auth or to store revocations) | |
| 391 | + | var store out.TokenStore | |
| 392 | + | var err error | |
| 393 | + | if authType == domain.AuthTypeToken { | |
| 394 | + | store, err = tokenstore.NewStore(backend, dataDir, log) | |
| 395 | + | if err != nil { | |
| 396 | + | return nil, nil, log.WrapErr(err, "failed to create token store") | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // Build auth config | |
| 401 | + | authConfig := auth.Config{ | |
| 402 | + | Enabled: cfg.RegistryAuth.Enabled, | |
| 403 | + | AuthType: authType, | |
| 404 | + | Username: cfg.RegistryAuth.Username, | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // Load secrets based on auth type | |
| 408 | + | if authType == domain.AuthTypePassword { | |
| 409 | + | // For password auth, we need the bcrypt hash | |
| 410 | + | if cfg.RegistryAuth.PasswordHash != "" { | |
| 411 | + | // Load from secrets backend | |
| 412 | + | hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log) | |
| 413 | + | if err != nil { | |
| 414 | + | return nil, nil, log.WrapErr(err, "failed to load password hash") | |
| 415 | + | } | |
| 416 | + | authConfig.PasswordHash = hash | |
| 417 | + | } else if cfg.RegistryAuth.Password != "" { | |
| 418 | + | // Backward compatibility: use plain password (will be hashed on validation) | |
| 419 | + | // This is less secure but maintains backward compatibility | |
| 420 | + | log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend") | |
| 421 | + | authConfig.PasswordHash = cfg.RegistryAuth.Password | |
| 422 | + | } | |
| 423 | + | } else { | |
| 424 | + | // For token auth, we need the signing secret | |
| 425 | + | if cfg.RegistryAuth.TokenSecret != "" { | |
| 426 | + | secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log) | |
| 427 | + | if err != nil { | |
| 428 | + | return nil, nil, log.WrapErr(err, "failed to load token secret") | |
| 429 | + | } | |
| 430 | + | authConfig.TokenSecret = []byte(secret) | |
| 431 | + | } else { | |
| 432 | + | return nil, nil, fmt.Errorf("token_secret is required for token authentication") | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // Parse token expiry | |
| 436 | + | if cfg.RegistryAuth.TokenExpiry != "" { | |
| 437 | + | expiry, err := time.ParseDuration(cfg.RegistryAuth.TokenExpiry) | |
| 438 | + | if err != nil { | |
| 439 | + | return nil, nil, fmt.Errorf("invalid token_expiry: %w", err) | |
| 440 | + | } | |
| 441 | + | authConfig.TokenExpiry = expiry | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | authSvc := auth.NewService(authConfig, store, log) | |
| 446 | + | ||
| 447 | + | log.Info(). | |
| 448 | + | Str("type", string(authType)). | |
| 449 | + | Str("backend", string(backend)). | |
| 450 | + | Msg("registry authentication enabled") | |
| 451 | + | ||
| 452 | + | return store, authSvc, nil | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | // loadSecret loads a secret from the configured backend. | |
| 456 | + | func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) { | |
| 457 | + | switch backend { | |
| 458 | + | case domain.SecretsBackendPass: | |
| 459 | + | provider := secrets.NewPassProvider(log) | |
| 460 | + | return provider.GetSecret(ctx, path) | |
| 461 | + | case domain.SecretsBackendSops: | |
| 462 | + | provider := secrets.NewSopsProvider(log) | |
| 463 | + | return provider.GetSecret(ctx, path) | |
| 464 | + | case domain.SecretsBackendUnsafe: | |
| 465 | + | // For unsafe backend, path is relative to dataDir/secrets/ | |
| 466 | + | secretFile := filepath.Join(dataDir, "secrets", path) | |
| 467 | + | data, err := os.ReadFile(secretFile) | |
| 468 | + | if err != nil { | |
| 469 | + | return "", fmt.Errorf("failed to read secret file: %w", err) | |
| 470 | + | } | |
| 471 | + | return string(data), nil | |
| 472 | + | default: | |
| 473 | + | return "", fmt.Errorf("unknown secrets backend: %s", backend) | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | ||
| 332 | 477 | // createContainerService creates the container service with configuration. | |
| 333 | 478 | func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service { | |
| 334 | 479 | containerConfig := container.Config{ | |
| @@ -414,13 +559,26 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 414 | 559 | middleware.RequestLogger(log), | |
| 415 | 560 | } | |
| 416 | 561 | ||
| 417 | - | if cfg.RegistryAuth.Enabled { | |
| 562 | + | if cfg.RegistryAuth.Enabled && svc.authSvc != nil { | |
| 418 | 563 | registryMiddlewares = append(registryMiddlewares, | |
| 419 | - | middleware.RegistryAuth(cfg.RegistryAuth.Username, cfg.RegistryAuth.Password, log)) | |
| 564 | + | middleware.RegistryAuthV2(svc.authSvc, log)) | |
| 420 | 565 | } | |
| 421 | 566 | ||
| 422 | 567 | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | |
| 423 | 568 | ||
| 569 | + | // Create a mux that routes /v2/token to the token handler (no auth required) | |
| 570 | + | // and all other /v2/* routes to the registry handler (with auth) | |
| 571 | + | registryMux := http.NewServeMux() | |
| 572 | + | if svc.tokenHandler != nil { | |
| 573 | + | // Token endpoint is NOT protected by auth - it's where clients get tokens | |
| 574 | + | tokenWithLogging := middleware.Chain( | |
| 575 | + | middleware.PanicRecovery(log), | |
| 576 | + | middleware.RequestLogger(log), | |
| 577 | + | )(svc.tokenHandler) | |
| 578 | + | registryMux.Handle("/v2/token", tokenWithLogging) | |
| 579 | + | } | |
| 580 | + | registryMux.Handle("/v2/", registryWithMiddleware) | |
| 581 | + | ||
| 424 | 582 | proxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 425 | 583 | middleware.PanicRecovery(log), | |
| 426 | 584 | middleware.RequestLogger(log), | |
| @@ -429,7 +587,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 429 | 587 | ||
| 430 | 588 | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | |
| 431 | 589 | ||
| 432 | - | return registryWithMiddleware, proxyWithMiddleware | |
| 590 | + | return registryMux, proxyWithMiddleware | |
| 433 | 591 | } | |
| 434 | 592 | ||
| 435 | 593 | // runServers starts the HTTP servers and waits for shutdown. | |
| @@ -578,7 +736,10 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 578 | 736 | v.SetDefault("logging.container_logs.max_backups", 3) | |
| 579 | 737 | v.SetDefault("logging.container_logs.max_age", 28) | |
| 580 | 738 | v.SetDefault("env.dir", "/var/lib/gordon/env") | |
| 739 | + | v.SetDefault("secrets.backend", "unsafe") | |
| 581 | 740 | v.SetDefault("registry_auth.enabled", false) | |
| 741 | + | v.SetDefault("registry_auth.type", "password") | |
| 742 | + | v.SetDefault("registry_auth.token_expiry", "720h") | |
| 582 | 743 | ||
| 583 | 744 | if configPath != "" { | |
| 584 | 745 | v.SetConfigFile(configPath) | |
| @@ -22,12 +22,20 @@ import ( | |||||
| 22 | "gordon/internal/adapters/out/filesystem" | 22 | "gordon/internal/adapters/out/filesystem" | ||
| 23 | "gordon/internal/adapters/out/logwriter" | 23 | "gordon/internal/adapters/out/logwriter" | ||
| 24 | "gordon/internal/adapters/out/secrets" | 24 | "gordon/internal/adapters/out/secrets" | ||
| 25 | + | "gordon/internal/adapters/out/tokenstore" | |||
| 25 | 26 | ||||
| 26 | // Adapters - Input | 27 | // Adapters - Input | ||
| 27 | "gordon/internal/adapters/in/http/middleware" | 28 | "gordon/internal/adapters/in/http/middleware" | ||
| 28 | "gordon/internal/adapters/in/http/registry" | 29 | "gordon/internal/adapters/in/http/registry" | ||
| 29 | 30 | ||||
| 31 | + | // Boundaries | |||
| 32 | + | "gordon/internal/boundaries/out" | |||
| 33 | + | ||||
| 34 | + | // Domain | |||
| 35 | + | "gordon/internal/domain" | |||
| 36 | + | ||||
| 30 | // Use cases | 37 | // Use cases | ||
| 38 | + | "gordon/internal/usecase/auth" | |||
| 31 | "gordon/internal/usecase/config" | 39 | "gordon/internal/usecase/config" | ||
| 32 | "gordon/internal/usecase/container" | 40 | "gordon/internal/usecase/container" | ||
| 33 | "gordon/internal/usecase/proxy" | 41 | "gordon/internal/usecase/proxy" | ||
| @@ -66,10 +74,18 @@ type Config struct { | |||||
| 66 | Dir string `mapstructure:"dir"` | 74 | Dir string `mapstructure:"dir"` | ||
| 67 | } `mapstructure:"env"` | 75 | } `mapstructure:"env"` | ||
| 68 | 76 | ||||
| 77 | + | Secrets struct { | |||
| 78 | + | Backend string `mapstructure:"backend"` // "pass", "sops", or "unsafe" | |||
| 79 | + | } `mapstructure:"secrets"` | |||
| 80 | + | ||||
| 69 | RegistryAuth struct { | 81 | RegistryAuth struct { | ||
| 70 | - | Enabled bool `mapstructure:"enabled"` | 82 | + | Enabled bool `mapstructure:"enabled"` |
| 71 | - | Username string `mapstructure:"username"` | 83 | + | Type string `mapstructure:"type"` // "password" or "token" |
| 72 | - | Password string `mapstructure:"password"` | 84 | + | Username string `mapstructure:"username"` |
| 85 | + | Password string `mapstructure:"password"` // deprecated: use password_hash | |||
| 86 | + | PasswordHash string `mapstructure:"password_hash"` // path in secrets backend | |||
| 87 | + | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | |||
| 88 | + | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h" | |||
| 73 | } `mapstructure:"registry_auth"` | 89 | } `mapstructure:"registry_auth"` | ||
| 74 | } | 90 | } | ||
| 75 | 91 | ||||
| @@ -81,10 +97,13 @@ type services struct { | |||||
| 81 | manifestStorage *filesystem.ManifestStorage | 97 | manifestStorage *filesystem.ManifestStorage | ||
| 82 | envLoader *envloader.FileLoader | 98 | envLoader *envloader.FileLoader | ||
| 83 | logWriter *logwriter.LogWriter | 99 | logWriter *logwriter.LogWriter | ||
| 100 | + | tokenStore out.TokenStore | |||
| 84 | configSvc *config.Service | 101 | configSvc *config.Service | ||
| 85 | containerSvc *container.Service | 102 | containerSvc *container.Service | ||
| 86 | registrySvc *registrySvc.Service | 103 | registrySvc *registrySvc.Service | ||
| 87 | proxySvc *proxy.Service | 104 | proxySvc *proxy.Service | ||
| 105 | + | authSvc *auth.Service | |||
| 106 | + | tokenHandler *registry.TokenHandler | |||
| 88 | } | 107 | } | ||
| 89 | 108 | ||||
| 90 | // Run initializes and starts the Gordon application. | 109 | // Run initializes and starts the Gordon application. | ||
| @@ -208,6 +227,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 208 | return nil, err | 227 | return nil, err | ||
| 209 | } | 228 | } | ||
| 210 | 229 | ||||
| 230 | + | // Create auth service (if enabled) | |||
| 231 | + | if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil { | |||
| 232 | + | return nil, err | |||
| 233 | + | } | |||
| 234 | + | ||||
| 211 | // Create use case services | 235 | // Create use case services | ||
| 212 | svc.configSvc = config.NewService(v, svc.eventBus) | 236 | svc.configSvc = config.NewService(v, svc.eventBus) | ||
| 213 | if err := svc.configSvc.Load(ctx); err != nil { | 237 | if err := svc.configSvc.Load(ctx); err != nil { | ||
| @@ -221,6 +245,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 221 | RegistryPort: cfg.Server.RegistryPort, | 245 | RegistryPort: cfg.Server.RegistryPort, | ||
| 222 | }) | 246 | }) | ||
| 223 | 247 | ||||
| 248 | + | // Create token handler for registry token endpoint | |||
| 249 | + | if svc.authSvc != nil { | |||
| 250 | + | svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, log) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 224 | return svc, nil | 253 | return svc, nil | ||
| 225 | } | 254 | } | ||
| 226 | 255 | ||||
| @@ -329,6 +358,122 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err | |||||
| 329 | return writer, nil | 358 | return writer, nil | ||
| 330 | } | 359 | } | ||
| 331 | 360 | ||||
| 361 | + | // createAuthService creates the authentication service and token store. | |||
| 362 | + | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | |||
| 363 | + | if !cfg.RegistryAuth.Enabled { | |||
| 364 | + | return nil, nil, nil | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // Determine auth type | |||
| 368 | + | authType := domain.AuthTypePassword | |||
| 369 | + | if cfg.RegistryAuth.Type == "token" { | |||
| 370 | + | authType = domain.AuthTypeToken | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // Determine secrets backend | |||
| 374 | + | backend := domain.SecretsBackendUnsafe // default | |||
| 375 | + | switch cfg.Secrets.Backend { | |||
| 376 | + | case "pass": | |||
| 377 | + | backend = domain.SecretsBackendPass | |||
| 378 | + | case "sops": | |||
| 379 | + | backend = domain.SecretsBackendSops | |||
| 380 | + | case "unsafe", "": | |||
| 381 | + | backend = domain.SecretsBackendUnsafe | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | // Get data directory | |||
| 385 | + | dataDir := cfg.Server.DataDir | |||
| 386 | + | if dataDir == "" { | |||
| 387 | + | dataDir = "/var/lib/gordon" | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | // Create token store (needed for token auth or to store revocations) | |||
| 391 | + | var store out.TokenStore | |||
| 392 | + | var err error | |||
| 393 | + | if authType == domain.AuthTypeToken { | |||
| 394 | + | store, err = tokenstore.NewStore(backend, dataDir, log) | |||
| 395 | + | if err != nil { | |||
| 396 | + | return nil, nil, log.WrapErr(err, "failed to create token store") | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // Build auth config | |||
| 401 | + | authConfig := auth.Config{ | |||
| 402 | + | Enabled: cfg.RegistryAuth.Enabled, | |||
| 403 | + | AuthType: authType, | |||
| 404 | + | Username: cfg.RegistryAuth.Username, | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | // Load secrets based on auth type | |||
| 408 | + | if authType == domain.AuthTypePassword { | |||
| 409 | + | // For password auth, we need the bcrypt hash | |||
| 410 | + | if cfg.RegistryAuth.PasswordHash != "" { | |||
| 411 | + | // Load from secrets backend | |||
| 412 | + | hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log) | |||
| 413 | + | if err != nil { | |||
| 414 | + | return nil, nil, log.WrapErr(err, "failed to load password hash") | |||
| 415 | + | } | |||
| 416 | + | authConfig.PasswordHash = hash | |||
| 417 | + | } else if cfg.RegistryAuth.Password != "" { | |||
| 418 | + | // Backward compatibility: use plain password (will be hashed on validation) | |||
| 419 | + | // This is less secure but maintains backward compatibility | |||
| 420 | + | log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend") | |||
| 421 | + | authConfig.PasswordHash = cfg.RegistryAuth.Password | |||
| 422 | + | } | |||
| 423 | + | } else { | |||
| 424 | + | // For token auth, we need the signing secret | |||
| 425 | + | if cfg.RegistryAuth.TokenSecret != "" { | |||
| 426 | + | secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log) | |||
| 427 | + | if err != nil { | |||
| 428 | + | return nil, nil, log.WrapErr(err, "failed to load token secret") | |||
| 429 | + | } | |||
| 430 | + | authConfig.TokenSecret = []byte(secret) | |||
| 431 | + | } else { | |||
| 432 | + | return nil, nil, fmt.Errorf("token_secret is required for token authentication") | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | // Parse token expiry | |||
| 436 | + | if cfg.RegistryAuth.TokenExpiry != "" { | |||
| 437 | + | expiry, err := time.ParseDuration(cfg.RegistryAuth.TokenExpiry) | |||
| 438 | + | if err != nil { | |||
| 439 | + | return nil, nil, fmt.Errorf("invalid token_expiry: %w", err) | |||
| 440 | + | } | |||
| 441 | + | authConfig.TokenExpiry = expiry | |||
| 442 | + | } | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | authSvc := auth.NewService(authConfig, store, log) | |||
| 446 | + | ||||
| 447 | + | log.Info(). | |||
| 448 | + | Str("type", string(authType)). | |||
| 449 | + | Str("backend", string(backend)). | |||
| 450 | + | Msg("registry authentication enabled") | |||
| 451 | + | ||||
| 452 | + | return store, authSvc, nil | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | // loadSecret loads a secret from the configured backend. | |||
| 456 | + | func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) { | |||
| 457 | + | switch backend { | |||
| 458 | + | case domain.SecretsBackendPass: | |||
| 459 | + | provider := secrets.NewPassProvider(log) | |||
| 460 | + | return provider.GetSecret(ctx, path) | |||
| 461 | + | case domain.SecretsBackendSops: | |||
| 462 | + | provider := secrets.NewSopsProvider(log) | |||
| 463 | + | return provider.GetSecret(ctx, path) | |||
| 464 | + | case domain.SecretsBackendUnsafe: | |||
| 465 | + | // For unsafe backend, path is relative to dataDir/secrets/ | |||
| 466 | + | secretFile := filepath.Join(dataDir, "secrets", path) | |||
| 467 | + | data, err := os.ReadFile(secretFile) | |||
| 468 | + | if err != nil { | |||
| 469 | + | return "", fmt.Errorf("failed to read secret file: %w", err) | |||
| 470 | + | } | |||
| 471 | + | return string(data), nil | |||
| 472 | + | default: | |||
| 473 | + | return "", fmt.Errorf("unknown secrets backend: %s", backend) | |||
| 474 | + | } | |||
| 475 | + | } | |||
| 476 | + | ||||
| 332 | // createContainerService creates the container service with configuration. | 477 | // createContainerService creates the container service with configuration. | ||
| 333 | func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service { | 478 | func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service { | ||
| 334 | containerConfig := container.Config{ | 479 | containerConfig := container.Config{ | ||
| @@ -414,13 +559,26 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 414 | middleware.RequestLogger(log), | 559 | middleware.RequestLogger(log), | ||
| 415 | } | 560 | } | ||
| 416 | 561 | ||||
| 417 | - | if cfg.RegistryAuth.Enabled { | 562 | + | if cfg.RegistryAuth.Enabled && svc.authSvc != nil { |
| 418 | registryMiddlewares = append(registryMiddlewares, | 563 | registryMiddlewares = append(registryMiddlewares, | ||
| 419 | - | middleware.RegistryAuth(cfg.RegistryAuth.Username, cfg.RegistryAuth.Password, log)) | 564 | + | middleware.RegistryAuthV2(svc.authSvc, log)) |
| 420 | } | 565 | } | ||
| 421 | 566 | ||||
| 422 | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | 567 | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | ||
| 423 | 568 | ||||
| 569 | + | // Create a mux that routes /v2/token to the token handler (no auth required) | |||
| 570 | + | // and all other /v2/* routes to the registry handler (with auth) | |||
| 571 | + | registryMux := http.NewServeMux() | |||
| 572 | + | if svc.tokenHandler != nil { | |||
| 573 | + | // Token endpoint is NOT protected by auth - it's where clients get tokens | |||
| 574 | + | tokenWithLogging := middleware.Chain( | |||
| 575 | + | middleware.PanicRecovery(log), | |||
| 576 | + | middleware.RequestLogger(log), | |||
| 577 | + | )(svc.tokenHandler) | |||
| 578 | + | registryMux.Handle("/v2/token", tokenWithLogging) | |||
| 579 | + | } | |||
| 580 | + | registryMux.Handle("/v2/", registryWithMiddleware) | |||
| 581 | + | ||||
| 424 | proxyMiddlewares := []func(http.Handler) http.Handler{ | 582 | proxyMiddlewares := []func(http.Handler) http.Handler{ | ||
| 425 | middleware.PanicRecovery(log), | 583 | middleware.PanicRecovery(log), | ||
| 426 | middleware.RequestLogger(log), | 584 | middleware.RequestLogger(log), | ||
| @@ -429,7 +587,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 429 | 587 | ||||
| 430 | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | 588 | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | ||
| 431 | 589 | ||||
| 432 | - | return registryWithMiddleware, proxyWithMiddleware | 590 | + | return registryMux, proxyWithMiddleware |
| 433 | } | 591 | } | ||
| 434 | 592 | ||||
| 435 | // runServers starts the HTTP servers and waits for shutdown. | 593 | // runServers starts the HTTP servers and waits for shutdown. | ||
| @@ -578,7 +736,10 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 578 | v.SetDefault("logging.container_logs.max_backups", 3) | 736 | v.SetDefault("logging.container_logs.max_backups", 3) | ||
| 579 | v.SetDefault("logging.container_logs.max_age", 28) | 737 | v.SetDefault("logging.container_logs.max_age", 28) | ||
| 580 | v.SetDefault("env.dir", "/var/lib/gordon/env") | 738 | v.SetDefault("env.dir", "/var/lib/gordon/env") | ||
| 739 | + | v.SetDefault("secrets.backend", "unsafe") | |||
| 581 | v.SetDefault("registry_auth.enabled", false) | 740 | v.SetDefault("registry_auth.enabled", false) | ||
| 741 | + | v.SetDefault("registry_auth.type", "password") | |||
| 742 | + | v.SetDefault("registry_auth.token_expiry", "720h") | |||
| 582 | 743 | ||||
| 583 | if configPath != "" { | 744 | if configPath != "" { | ||
| 584 | v.SetConfigFile(configPath) | 745 | v.SetConfigFile(configPath) | ||