detached faacc3c

feat!: merge secrets/registry_auth into unified auth config, add attachments CRUD CLI

BREAKING CHANGE: Config restructure merges [secrets] and [registry_auth] into [auth]

Auth config changes:
- [secrets].backend -> [auth].secrets_backend
- [registry_auth].* -> [auth].*
- Secret paths: gordon/registry/* -> gordon/auth/*
- auth.enabled defaults to true for security

New attachments CLI:
- gordon attachments list [domain-or-group]
- gordon attachments add <target> <image>
- gordon attachments remove <target> <image>
- gordon attach alias for gordon attachments
- Works locally and remotely via --remote flag

New files:
- docs/config/auth.md (replaces registry-auth.md)
- internal/adapters/in/cli/attachments.go
- internal/adapters/dto/admin_attachments.go

Updated documentation across docs/ and wiki/ to reflect new config structure.
commit
faacc3cf8b0d8e89d21797fba4ccf7d5bc7c4937
author
brice · · 2026-01-17 15:25 UTC
parent
b9b6391
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +1262 -192
M docs/cli/commands.md +67 -6
@@ -13,6 +13,7 @@ Gordon provides a command-line interface for server management, deployment, and
1313 | `gordon version` | Print version information |
1414 | `gordon auth` | Manage registry authentication |
1515 | `gordon routes` | Manage routes (local or remote) |
16+| `gordon attachments` | Manage attachments (local or remote) |
1617 | `gordon secrets` | Manage secrets (local or remote) |
1718 | `gordon remotes` | Manage saved remote Gordon instances |
1819
@@ -368,7 +369,7 @@ Manage registry authentication tokens and passwords.
368369 |------------|-------------|
369370 | `token generate` | Generate a new JWT token |
370371 | `token list` | List all stored tokens |
371-| `token revoke` | Revoke a token by ID |
372+| `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) |
372373 | `password hash` | Generate bcrypt password hash |
373374 | `internal` | Show internal registry credentials |
374375
@@ -494,9 +495,9 @@ Bcrypt hash (store in your secrets backend):
494495 $2a$10$N9qo8uLOickgx2ZMRZoMye...
495496
496497 Then reference the path in your config:
497- [registry_auth]
498+ [auth]
498499 type = "password"
499- password_hash = "gordon/registry/password_hash"
500+ password_hash = "gordon/auth/password_hash"
500501 ```
501502
502503 ### gordon auth internal
@@ -595,15 +596,16 @@ gordon auth password hash
595596 # Enter: mypassword
596597
597598 # Store in pass
598-pass insert gordon/registry/password_hash
599+pass insert gordon/auth/password_hash
599600 # Paste: $2a$10$...
600601
601602 # Configure gordon.toml
602-[registry_auth]
603+[auth]
603604 enabled = true
604605 type = "password"
606+secrets_backend = "pass"
605607 username = "deploy"
606-password_hash = "gordon/registry/password_hash"
608+password_hash = "gordon/auth/password_hash"
607609 ```
608610
609611 ---
@@ -659,6 +661,65 @@ gordon routes deploy myapp.example.com
659661
660662 ---
661663
664+## gordon attachments
665+
666+Manage attachments on local or remote Gordon instances.
667+
668+### Subcommands
669+
670+| Subcommand | Description |
671+|------------|-------------|
672+| `list` | List all attachments or attachments for a specific target |
673+| `add` | Add an attachment to a domain or network group |
674+| `remove` | Remove an attachment from a domain or network group |
675+
676+### gordon attachments list
677+
678+List all configured attachments.
679+
680+```bash
681+# List all attachments
682+gordon attachments list
683+
684+# List attachments for a specific domain or group
685+gordon attachments list app.example.com
686+gordon attachments list backend
687+
688+# Remote mode
689+gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN
690+```
691+
692+### gordon attachments add
693+
694+Add an attachment to a domain or network group.
695+
696+```bash
697+gordon attachments add <domain-or-group> <image>
698+gordon attachments add app.example.com postgres:15
699+gordon attachments add backend redis:7-alpine
700+```
701+
702+### gordon attachments remove
703+
704+Remove an attachment.
705+
706+```bash
707+gordon attachments remove <domain-or-group> <image>
708+gordon attachments remove app.example.com postgres:15
709+```
710+
711+### Alias
712+
713+`gordon attach` is an alias for `gordon attachments`:
714+
715+```bash
716+gordon attach list
717+gordon attach add app.example.com postgres:15
718+gordon attach remove app.example.com postgres:15
719+```
720+
721+---
722+
662723 ## gordon secrets
663724
664725 Manage secrets on local or remote Gordon instances.
@@ -13,6 +13,7 @@ Gordon provides a command-line interface for server management, deployment, and
13 | `gordon version` | Print version information |13 | `gordon version` | Print version information |
14 | `gordon auth` | Manage registry authentication |14 | `gordon auth` | Manage registry authentication |
15 | `gordon routes` | Manage routes (local or remote) |15 | `gordon routes` | Manage routes (local or remote) |
16+| `gordon attachments` | Manage attachments (local or remote) |
16 | `gordon secrets` | Manage secrets (local or remote) |17 | `gordon secrets` | Manage secrets (local or remote) |
17 | `gordon remotes` | Manage saved remote Gordon instances |18 | `gordon remotes` | Manage saved remote Gordon instances |
18 19
@@ -368,7 +369,7 @@ Manage registry authentication tokens and passwords.
368 |------------|-------------|369 |------------|-------------|
369 | `token generate` | Generate a new JWT token |370 | `token generate` | Generate a new JWT token |
370 | `token list` | List all stored tokens |371 | `token list` | List all stored tokens |
371-| `token revoke` | Revoke a token by ID |372+| `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) |
372 | `password hash` | Generate bcrypt password hash |373 | `password hash` | Generate bcrypt password hash |
373 | `internal` | Show internal registry credentials |374 | `internal` | Show internal registry credentials |
374 375
@@ -494,9 +495,9 @@ Bcrypt hash (store in your secrets backend):
494 $2a$10$N9qo8uLOickgx2ZMRZoMye...495 $2a$10$N9qo8uLOickgx2ZMRZoMye...
495 496
496 Then reference the path in your config:497 Then reference the path in your config:
497- [registry_auth]498+ [auth]
498 type = "password"499 type = "password"
499- password_hash = "gordon/registry/password_hash"500+ password_hash = "gordon/auth/password_hash"
500 ```501 ```
501 502
502 ### gordon auth internal503 ### gordon auth internal
@@ -595,15 +596,16 @@ gordon auth password hash
595 # Enter: mypassword596 # Enter: mypassword
596 597
597 # Store in pass598 # Store in pass
598-pass insert gordon/registry/password_hash599+pass insert gordon/auth/password_hash
599 # Paste: $2a$10$...600 # Paste: $2a$10$...
600 601
601 # Configure gordon.toml602 # Configure gordon.toml
602-[registry_auth]603+[auth]
603 enabled = true604 enabled = true
604 type = "password"605 type = "password"
606+secrets_backend = "pass"
605 username = "deploy"607 username = "deploy"
606-password_hash = "gordon/registry/password_hash"608+password_hash = "gordon/auth/password_hash"
607 ```609 ```
608 610
609 ---611 ---
@@ -659,6 +661,65 @@ gordon routes deploy myapp.example.com
659 661
660 ---662 ---
661 663
664+## gordon attachments
665+
666+Manage attachments on local or remote Gordon instances.
667+
668+### Subcommands
669+
670+| Subcommand | Description |
671+|------------|-------------|
672+| `list` | List all attachments or attachments for a specific target |
673+| `add` | Add an attachment to a domain or network group |
674+| `remove` | Remove an attachment from a domain or network group |
675+
676+### gordon attachments list
677+
678+List all configured attachments.
679+
680+```bash
681+# List all attachments
682+gordon attachments list
683+
684+# List attachments for a specific domain or group
685+gordon attachments list app.example.com
686+gordon attachments list backend
687+
688+# Remote mode
689+gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN
690+```
691+
692+### gordon attachments add
693+
694+Add an attachment to a domain or network group.
695+
696+```bash
697+gordon attachments add <domain-or-group> <image>
698+gordon attachments add app.example.com postgres:15
699+gordon attachments add backend redis:7-alpine
700+```
701+
702+### gordon attachments remove
703+
704+Remove an attachment.
705+
706+```bash
707+gordon attachments remove <domain-or-group> <image>
708+gordon attachments remove app.example.com postgres:15
709+```
710+
711+### Alias
712+
713+`gordon attach` is an alias for `gordon attachments`:
714+
715+```bash
716+gordon attach list
717+gordon attach add app.example.com postgres:15
718+gordon attach remove app.example.com postgres:15
719+```
720+
721+---
722+
662 ## gordon secrets723 ## gordon secrets
663 724
664 Manage secrets on local or remote Gordon instances.725 Manage secrets on local or remote Gordon instances.
M docs/config/attachments.md +55 -0
@@ -204,8 +204,63 @@ Gordon adds labels to attachment containers:
204204 | `gordon.attachment` | `true` |
205205 | `gordon.attached-to` | Domain or group name |
206206
207+## CLI Management
208+
209+Manage attachments via the CLI without editing configuration files.
210+
211+### List Attachments
212+
213+```bash
214+# List all attachments
215+gordon attachments list
216+
217+# List attachments for a specific domain or network group
218+gordon attachments list app.mydomain.com
219+gordon attachments list backend
220+
221+# Remote mode
222+gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN
223+```
224+
225+### Add Attachments
226+
227+```bash
228+# Add attachment to a domain
229+gordon attachments add app.mydomain.com postgres:15
230+
231+# Add attachment to a network group
232+gordon attachments add backend redis:7-alpine
233+
234+# Remote mode
235+gordon attachments add app.mydomain.com postgres:15 --remote https://gordon.mydomain.com --token $TOKEN
236+```
237+
238+### Remove Attachments
239+
240+```bash
241+# Remove attachment from a domain
242+gordon attachments remove app.mydomain.com postgres:15
243+
244+# Remove from network group
245+gordon attachments remove backend redis:7-alpine
246+
247+# Remote mode
248+gordon attachments remove app.mydomain.com postgres:15 --remote https://gordon.mydomain.com --token $TOKEN
249+```
250+
251+### Alias
252+
253+The `gordon attach` command is an alias for `gordon attachments`:
254+
255+```bash
256+gordon attach list
257+gordon attach add app.mydomain.com postgres:15
258+gordon attach remove app.mydomain.com postgres:15
259+```
260+
207261 ## Related
208262
209263 - [Network Isolation](./network-isolation.md)
210264 - [Network Groups](./network-groups.md)
211265 - [Routes](./routes.md)
266+- [CLI Commands](/docs/cli/commands.md)
@@ -204,8 +204,63 @@ Gordon adds labels to attachment containers:
204 | `gordon.attachment` | `true` |204 | `gordon.attachment` | `true` |
205 | `gordon.attached-to` | Domain or group name |205 | `gordon.attached-to` | Domain or group name |
206 206
207+## CLI Management
208+
209+Manage attachments via the CLI without editing configuration files.
210+
211+### List Attachments
212+
213+```bash
214+# List all attachments
215+gordon attachments list
216+
217+# List attachments for a specific domain or network group
218+gordon attachments list app.mydomain.com
219+gordon attachments list backend
220+
221+# Remote mode
222+gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN
223+```
224+
225+### Add Attachments
226+
227+```bash
228+# Add attachment to a domain
229+gordon attachments add app.mydomain.com postgres:15
230+
231+# Add attachment to a network group
232+gordon attachments add backend redis:7-alpine
233+
234+# Remote mode
235+gordon attachments add app.mydomain.com postgres:15 --remote https://gordon.mydomain.com --token $TOKEN
236+```
237+
238+### Remove Attachments
239+
240+```bash
241+# Remove attachment from a domain
242+gordon attachments remove app.mydomain.com postgres:15
243+
244+# Remove from network group
245+gordon attachments remove backend redis:7-alpine
246+
247+# Remote mode
248+gordon attachments remove app.mydomain.com postgres:15 --remote https://gordon.mydomain.com --token $TOKEN
249+```
250+
251+### Alias
252+
253+The `gordon attach` command is an alias for `gordon attachments`:
254+
255+```bash
256+gordon attach list
257+gordon attach add app.mydomain.com postgres:15
258+gordon attach remove app.mydomain.com postgres:15
259+```
260+
207 ## Related261 ## Related
208 262
209 - [Network Isolation](./network-isolation.md)263 - [Network Isolation](./network-isolation.md)
210 - [Network Groups](./network-groups.md)264 - [Network Groups](./network-groups.md)
211 - [Routes](./routes.md)265 - [Routes](./routes.md)
266+- [CLI Commands](/docs/cli/commands.md)
R docs/config/registry-auth.md → docs/config/auth.md +96 -56 renamed, 56% similar
@@ -1,33 +1,43 @@
1-# Registry Authentication
1+# Authentication
22
3-Secure your container registry with password or token-based authentication.
3+Configure authentication for the Gordon registry and Admin API.
44
55 ## Configuration
66
77 ```toml
8-[registry_auth]
8+[auth]
99 enabled = true
10-type = "token" # "password" or "token"
11-
12-# For token auth:
13-token_secret = "gordon/registry/token_secret" # Path in secrets backend
14-token_expiry = "720h" # Duration or 0 for never
15-
16-# For password auth:
17-# username = "deploy"
18-# password_hash = "gordon/registry/password_hash" # Path in secrets backend
10+type = "token"
11+secrets_backend = "pass"
12+token_secret = "gordon/auth/token_secret"
13+token_expiry = "30d"
1914 ```
2015
16+> **Note:** Authentication is enabled by default for security. For local development without auth, explicitly set `enabled = false`.
17+
2118 ## Options
2219
2320 | Option | Type | Default | Description |
2421 |--------|------|---------|-------------|
25-| `enabled` | bool | `false` | Enable registry authentication |
22+| `enabled` | bool | `true` | Enable authentication |
2623 | `type` | string | `"password"` | Auth type: `"password"` or `"token"` |
24+| `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |
2725 | `username` | string | - | Username for password auth |
2826 | `password_hash` | string | - | Path to bcrypt hash in secrets backend |
2927 | `token_secret` | string | - | Path to JWT signing secret in secrets backend |
30-| `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires). Supports: d (days), w (weeks), M (months), y (years) |
28+| `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |
29+
30+## Secrets Backend
31+
32+The `secrets_backend` option determines how Gordon retrieves sensitive values like `token_secret` and `password_hash`:
33+
34+| Backend | Description |
35+|---------|-------------|
36+| `pass` | Unix password manager (GPG-encrypted) |
37+| `sops` | Mozilla SOPS encrypted files |
38+| `unsafe` | Plain text files (development only) |
39+
40+See [Secret Providers](./secrets.md) for detailed configuration of each backend.
3141
3242 ## Authentication Types
3343
@@ -36,20 +46,18 @@ token_expiry = "720h" # Duration or 0 for never
3646 JWT-based authentication, ideal for CI/CD pipelines:
3747
3848 ```toml
39-[secrets]
40-backend = "pass"
41-
42-[registry_auth]
49+[auth]
4350 enabled = true
4451 type = "token"
45-token_secret = "gordon/registry/token_secret"
46-token_expiry = "30d" # 30 days, or "0" for never. Also: 1y, 2w, 6M
52+secrets_backend = "pass"
53+token_secret = "gordon/auth/token_secret"
54+token_expiry = "30d"
4755 ```
4856
4957 **Setup:**
5058 ```bash
5159 # Create token secret (random 32+ characters)
52-pass insert gordon/registry/token_secret
60+pass insert gordon/auth/token_secret
5361
5462 # Generate a token
5563 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
@@ -66,8 +74,11 @@ docker login -u ci-bot -p <token> registry.mydomain.com
6674 # List all tokens
6775 gordon auth token list
6876
69-# Revoke a token
77+# Revoke a specific token
7078 gordon auth token revoke <token-id>
79+
80+# Revoke all tokens
81+gordon auth token revoke --all
7182 ```
7283
7384 ### Password Authentication
@@ -75,14 +86,12 @@ gordon auth token revoke <token-id>
7586 Simple username/password authentication:
7687
7788 ```toml
78-[secrets]
79-backend = "pass"
80-
81-[registry_auth]
89+[auth]
8290 enabled = true
8391 type = "password"
92+secrets_backend = "pass"
8493 username = "deploy"
85-password_hash = "gordon/registry/password_hash"
94+password_hash = "gordon/auth/password_hash"
8695 ```
8796
8897 **Setup:**
@@ -92,7 +101,7 @@ gordon auth password hash
92101 # Enter password when prompted
93102
94103 # Store hash in secrets backend
95-pass insert gordon/registry/password_hash
104+pass insert gordon/auth/password_hash
96105 # Paste the bcrypt hash
97106 ```
98107
@@ -105,27 +114,51 @@ docker login -u deploy -p <password> registry.mydomain.com
105114
106115 Tokens can have different permission levels:
107116
117+### Registry Scopes
118+
108119 | Scope | Permission |
109120 |-------|------------|
110121 | `push` | Push images to registry |
111122 | `pull` | Pull images from registry |
112123 | `push,pull` | Both push and pull (default) |
113124
125+### Admin Scopes
126+
127+For remote CLI access via the Admin API:
128+
129+| Scope | Permission |
130+|-------|------------|
131+| `admin:*:*` | Full admin access |
132+| `admin:routes:read` | Read-only routes access |
133+| `admin:routes:write` | Routes write access |
134+| `admin:config:read` | Read-only config access |
135+| `admin:config:write` | Config write access |
136+| `admin:status:read` | Read-only status/health |
137+| `admin:secrets:read` | List secret keys |
138+| `admin:secrets:write` | Set/delete secrets |
139+
140+**Examples:**
114141 ```bash
115142 # Pull-only token (for read-only access)
116143 gordon auth token generate --subject reader --scopes pull --expiry 30d
117144
118145 # Push-only token (for CI builds)
119146 gordon auth token generate --subject builder --scopes push --expiry 0
147+
148+# Full admin access (for remote CLI)
149+gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
120150 ```
121151
122152 ## Token Expiry
123153
124154 Control how long tokens remain valid. Supports human-friendly units:
125-- `d` - days (24 hours)
126-- `w` - weeks (7 days)
127-- `M` - months (30 days)
128-- `y` - years (365 days)
155+
156+| Unit | Description |
157+|------|-------------|
158+| `d` | days (24 hours) |
159+| `w` | weeks (7 days) |
160+| `M` | months (30 days) |
161+| `y` | years (365 days) |
129162
130163 Compound durations work too: `1y6M`, `2w3d`
131164
@@ -138,39 +171,40 @@ gordon auth token generate --subject deploy --expiry 1y
138171
139172 # Expires in 30 days
140173 gordon auth token generate --subject temp --expiry 30d
141-
142-# Expires in 2 weeks
143-gordon auth token generate --subject short --expiry 2w
144174 ```
145175
146176 ## Instance-Specific Tokens
147177
148-Tokens are signed with the `token_secret`, which is unique to each Gordon instance. This means:
178+Tokens are signed with the `token_secret`, which is unique to each Gordon instance:
149179
150180 - Tokens from one Gordon instance won't work on another
151181 - Changing the `token_secret` invalidates all existing tokens
152182 - Each environment (dev, staging, prod) should have different secrets
153183
184+## Development Setup
185+
186+For local development without authentication:
187+
188+```toml
189+[auth]
190+enabled = false
191+```
192+
154193 ## Examples
155194
156195 ### CI/CD Setup
157196
158197 ```toml
159-[secrets]
160-backend = "pass"
161-
162-[registry_auth]
198+[auth]
163199 enabled = true
164200 type = "token"
165-token_secret = "gordon/registry/token_secret"
201+secrets_backend = "pass"
202+token_secret = "gordon/auth/token_secret"
166203 ```
167204
168205 ```bash
169206 # Generate CI token
170207 gordon auth token generate --subject github-actions --scopes push,pull --expiry 0
171-
172-# Token output:
173-# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
174208 ```
175209
176210 In GitHub Actions:
@@ -179,32 +213,38 @@ In GitHub Actions:
179213 run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }}
180214 ```
181215
182-### Development Setup
216+### Production with Password
183217
184218 ```toml
185-[registry_auth]
186-enabled = false # Disable for local development
219+[auth]
220+enabled = true
221+type = "password"
222+secrets_backend = "pass"
223+username = "deploy"
224+password_hash = "gordon/auth/password_hash"
187225 ```
188226
189-### Production with Password
227+### Enterprise with SOPS
190228
191229 ```toml
192-[secrets]
193-backend = "pass"
194-
195-[registry_auth]
230+[auth]
196231 enabled = true
197-type = "password"
198-username = "deploy"
199-password_hash = "gordon/registry/password_hash"
232+type = "token"
233+secrets_backend = "sops"
234+token_secret = "gordon/auth/token_secret"
200235 ```
201236
202237 ## Internal Registry Auth
203238
204239 Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart.
205240
241+To view internal credentials (for troubleshooting):
242+```bash
243+gordon auth internal
244+```
245+
206246 ## Related
207247
208-- [Secrets Configuration](./secrets.md)
248+- [Secret Providers](./secrets.md)
209249 - [CLI Commands](../cli/commands.md)
210250 - [GitHub Actions Deployment](../deployment/github-actions.md)
@@ -1,33 +1,43 @@
1-# Registry Authentication1+# Authentication
2 2
3-Secure your container registry with password or token-based authentication.3+Configure authentication for the Gordon registry and Admin API.
4 4
5 ## Configuration5 ## Configuration
6 6
7 ```toml7 ```toml
8-[registry_auth]8+[auth]
9 enabled = true9 enabled = true
10-type = "token" # "password" or "token"10+type = "token"
11-11+secrets_backend = "pass"
12-# For token auth:12+token_secret = "gordon/auth/token_secret"
13-token_secret = "gordon/registry/token_secret" # Path in secrets backend13+token_expiry = "30d"
14-token_expiry = "720h" # Duration or 0 for never
15-
16-# For password auth:
17-# username = "deploy"
18-# password_hash = "gordon/registry/password_hash" # Path in secrets backend
19 ```14 ```
20 15
16+> **Note:** Authentication is enabled by default for security. For local development without auth, explicitly set `enabled = false`.
17+
21 ## Options18 ## Options
22 19
23 | Option | Type | Default | Description |20 | Option | Type | Default | Description |
24 |--------|------|---------|-------------|21 |--------|------|---------|-------------|
25-| `enabled` | bool | `false` | Enable registry authentication |22+| `enabled` | bool | `true` | Enable authentication |
26 | `type` | string | `"password"` | Auth type: `"password"` or `"token"` |23 | `type` | string | `"password"` | Auth type: `"password"` or `"token"` |
24+| `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |
27 | `username` | string | - | Username for password auth |25 | `username` | string | - | Username for password auth |
28 | `password_hash` | string | - | Path to bcrypt hash in secrets backend |26 | `password_hash` | string | - | Path to bcrypt hash in secrets backend |
29 | `token_secret` | string | - | Path to JWT signing secret in secrets backend |27 | `token_secret` | string | - | Path to JWT signing secret in secrets backend |
30-| `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires). Supports: d (days), w (weeks), M (months), y (years) |28+| `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |
29+
30+## Secrets Backend
31+
32+The `secrets_backend` option determines how Gordon retrieves sensitive values like `token_secret` and `password_hash`:
33+
34+| Backend | Description |
35+|---------|-------------|
36+| `pass` | Unix password manager (GPG-encrypted) |
37+| `sops` | Mozilla SOPS encrypted files |
38+| `unsafe` | Plain text files (development only) |
39+
40+See [Secret Providers](./secrets.md) for detailed configuration of each backend.
31 41
32 ## Authentication Types42 ## Authentication Types
33 43
@@ -36,20 +46,18 @@ token_expiry = "720h" # Duration or 0 for never
36 JWT-based authentication, ideal for CI/CD pipelines:46 JWT-based authentication, ideal for CI/CD pipelines:
37 47
38 ```toml48 ```toml
39-[secrets]49+[auth]
40-backend = "pass"
41-
42-[registry_auth]
43 enabled = true50 enabled = true
44 type = "token"51 type = "token"
45-token_secret = "gordon/registry/token_secret"52+secrets_backend = "pass"
46-token_expiry = "30d" # 30 days, or "0" for never. Also: 1y, 2w, 6M53+token_secret = "gordon/auth/token_secret"
54+token_expiry = "30d"
47 ```55 ```
48 56
49 **Setup:**57 **Setup:**
50 ```bash58 ```bash
51 # Create token secret (random 32+ characters)59 # Create token secret (random 32+ characters)
52-pass insert gordon/registry/token_secret60+pass insert gordon/auth/token_secret
53 61
54 # Generate a token62 # Generate a token
55 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 063 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
@@ -66,8 +74,11 @@ docker login -u ci-bot -p <token> registry.mydomain.com
66 # List all tokens74 # List all tokens
67 gordon auth token list75 gordon auth token list
68 76
69-# Revoke a token77+# Revoke a specific token
70 gordon auth token revoke <token-id>78 gordon auth token revoke <token-id>
79+
80+# Revoke all tokens
81+gordon auth token revoke --all
71 ```82 ```
72 83
73 ### Password Authentication84 ### Password Authentication
@@ -75,14 +86,12 @@ gordon auth token revoke <token-id>
75 Simple username/password authentication:86 Simple username/password authentication:
76 87
77 ```toml88 ```toml
78-[secrets]89+[auth]
79-backend = "pass"
80-
81-[registry_auth]
82 enabled = true90 enabled = true
83 type = "password"91 type = "password"
92+secrets_backend = "pass"
84 username = "deploy"93 username = "deploy"
85-password_hash = "gordon/registry/password_hash"94+password_hash = "gordon/auth/password_hash"
86 ```95 ```
87 96
88 **Setup:**97 **Setup:**
@@ -92,7 +101,7 @@ gordon auth password hash
92 # Enter password when prompted101 # Enter password when prompted
93 102
94 # Store hash in secrets backend103 # Store hash in secrets backend
95-pass insert gordon/registry/password_hash104+pass insert gordon/auth/password_hash
96 # Paste the bcrypt hash105 # Paste the bcrypt hash
97 ```106 ```
98 107
@@ -105,27 +114,51 @@ docker login -u deploy -p <password> registry.mydomain.com
105 114
106 Tokens can have different permission levels:115 Tokens can have different permission levels:
107 116
117+### Registry Scopes
118+
108 | Scope | Permission |119 | Scope | Permission |
109 |-------|------------|120 |-------|------------|
110 | `push` | Push images to registry |121 | `push` | Push images to registry |
111 | `pull` | Pull images from registry |122 | `pull` | Pull images from registry |
112 | `push,pull` | Both push and pull (default) |123 | `push,pull` | Both push and pull (default) |
113 124
125+### Admin Scopes
126+
127+For remote CLI access via the Admin API:
128+
129+| Scope | Permission |
130+|-------|------------|
131+| `admin:*:*` | Full admin access |
132+| `admin:routes:read` | Read-only routes access |
133+| `admin:routes:write` | Routes write access |
134+| `admin:config:read` | Read-only config access |
135+| `admin:config:write` | Config write access |
136+| `admin:status:read` | Read-only status/health |
137+| `admin:secrets:read` | List secret keys |
138+| `admin:secrets:write` | Set/delete secrets |
139+
140+**Examples:**
114 ```bash141 ```bash
115 # Pull-only token (for read-only access)142 # Pull-only token (for read-only access)
116 gordon auth token generate --subject reader --scopes pull --expiry 30d143 gordon auth token generate --subject reader --scopes pull --expiry 30d
117 144
118 # Push-only token (for CI builds)145 # Push-only token (for CI builds)
119 gordon auth token generate --subject builder --scopes push --expiry 0146 gordon auth token generate --subject builder --scopes push --expiry 0
147+
148+# Full admin access (for remote CLI)
149+gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
120 ```150 ```
121 151
122 ## Token Expiry152 ## Token Expiry
123 153
124 Control how long tokens remain valid. Supports human-friendly units:154 Control how long tokens remain valid. Supports human-friendly units:
125-- `d` - days (24 hours)155+
126-- `w` - weeks (7 days)156+| Unit | Description |
127-- `M` - months (30 days)157+|------|-------------|
128-- `y` - years (365 days)158+| `d` | days (24 hours) |
159+| `w` | weeks (7 days) |
160+| `M` | months (30 days) |
161+| `y` | years (365 days) |
129 162
130 Compound durations work too: `1y6M`, `2w3d`163 Compound durations work too: `1y6M`, `2w3d`
131 164
@@ -138,39 +171,40 @@ gordon auth token generate --subject deploy --expiry 1y
138 171
139 # Expires in 30 days172 # Expires in 30 days
140 gordon auth token generate --subject temp --expiry 30d173 gordon auth token generate --subject temp --expiry 30d
141-
142-# Expires in 2 weeks
143-gordon auth token generate --subject short --expiry 2w
144 ```174 ```
145 175
146 ## Instance-Specific Tokens176 ## Instance-Specific Tokens
147 177
148-Tokens are signed with the `token_secret`, which is unique to each Gordon instance. This means:178+Tokens are signed with the `token_secret`, which is unique to each Gordon instance:
149 179
150 - Tokens from one Gordon instance won't work on another180 - Tokens from one Gordon instance won't work on another
151 - Changing the `token_secret` invalidates all existing tokens181 - Changing the `token_secret` invalidates all existing tokens
152 - Each environment (dev, staging, prod) should have different secrets182 - Each environment (dev, staging, prod) should have different secrets
153 183
184+## Development Setup
185+
186+For local development without authentication:
187+
188+```toml
189+[auth]
190+enabled = false
191+```
192+
154 ## Examples193 ## Examples
155 194
156 ### CI/CD Setup195 ### CI/CD Setup
157 196
158 ```toml197 ```toml
159-[secrets]198+[auth]
160-backend = "pass"
161-
162-[registry_auth]
163 enabled = true199 enabled = true
164 type = "token"200 type = "token"
165-token_secret = "gordon/registry/token_secret"201+secrets_backend = "pass"
202+token_secret = "gordon/auth/token_secret"
166 ```203 ```
167 204
168 ```bash205 ```bash
169 # Generate CI token206 # Generate CI token
170 gordon auth token generate --subject github-actions --scopes push,pull --expiry 0207 gordon auth token generate --subject github-actions --scopes push,pull --expiry 0
171-
172-# Token output:
173-# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
174 ```208 ```
175 209
176 In GitHub Actions:210 In GitHub Actions:
@@ -179,32 +213,38 @@ In GitHub Actions:
179 run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }}213 run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }}
180 ```214 ```
181 215
182-### Development Setup216+### Production with Password
183 217
184 ```toml218 ```toml
185-[registry_auth]219+[auth]
186-enabled = false # Disable for local development220+enabled = true
221+type = "password"
222+secrets_backend = "pass"
223+username = "deploy"
224+password_hash = "gordon/auth/password_hash"
187 ```225 ```
188 226
189-### Production with Password227+### Enterprise with SOPS
190 228
191 ```toml229 ```toml
192-[secrets]230+[auth]
193-backend = "pass"
194-
195-[registry_auth]
196 enabled = true231 enabled = true
197-type = "password"232+type = "token"
198-username = "deploy"233+secrets_backend = "sops"
199-password_hash = "gordon/registry/password_hash"234+token_secret = "gordon/auth/token_secret"
200 ```235 ```
201 236
202 ## Internal Registry Auth237 ## Internal Registry Auth
203 238
204 Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart.239 Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart.
205 240
241+To view internal credentials (for troubleshooting):
242+```bash
243+gordon auth internal
244+```
245+
206 ## Related246 ## Related
207 247
208-- [Secrets Configuration](./secrets.md)248+- [Secret Providers](./secrets.md)
209 - [CLI Commands](../cli/commands.md)249 - [CLI Commands](../cli/commands.md)
210 - [GitHub Actions Deployment](../deployment/github-actions.md)250 - [GitHub Actions Deployment](../deployment/github-actions.md)
M docs/config/index.md +13 -18
@@ -33,19 +33,16 @@ registry_port = 5000 # Registry port (default: 5000)
3333 gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API)
3434 # data_dir = "~/.gordon" # Default for user installations
3535
36-# Secrets backend
37-[secrets]
38-backend = "pass" # "pass", "sops", or "unsafe"
39-
40-# Registry authentication
41-[registry_auth]
42-enabled = true
36+# Authentication (includes secrets backend)
37+[auth]
38+enabled = true # Enable registry authentication (default: true)
4339 type = "token" # "password" or "token"
40+secrets_backend = "pass" # "pass", "sops", or "unsafe"
4441 # Password auth:
4542 # username = "deploy"
46-# password_hash = "gordon/registry/password_hash"
43+# password_hash = "gordon/auth/password_hash"
4744 # Token auth:
48-token_secret = "gordon/registry/token_secret"
45+token_secret = "gordon/auth/token_secret"
4946 token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never
5047
5148 # Deploy behavior
@@ -111,8 +108,7 @@ enabled = false # Auto-create routes from image names
111108 | Section | Description | Documentation |
112109 |---------|-------------|---------------|
113110 | `[server]` | Core server settings | [Server](./server.md) |
114-| `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) |
115-| `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) |
111+| `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |
116112 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
117113 | `[logging]` | Logging configuration | [Logging](./logging.md) |
118114 | `[env]` | Environment variable settings | [Environment](./env.md) |
@@ -131,10 +127,10 @@ enabled = false # Auto-create routes from image names
131127 | `server.port` | `80` |
132128 | `server.registry_port` | `5000` |
133129 | `server.data_dir` | `~/.gordon` |
134-| `secrets.backend` | `"unsafe"` |
135-| `registry_auth.enabled` | `false` |
136-| `registry_auth.type` | `"password"` |
137-| `registry_auth.token_expiry` | `"30d"` |
130+| `auth.enabled` | `true` |
131+| `auth.type` | `"password"` |
132+| `auth.secrets_backend` | `"unsafe"` |
133+| `auth.token_expiry` | `"30d"` |
138134 | `deploy.pull_policy` | `"if-tag-changed"` |
139135 | `logging.level` | `"info"` |
140136 | `logging.format` | `"console"` |
@@ -162,8 +158,7 @@ The following settings require a restart to take effect:
162158 - `server.port`
163159 - `server.registry_port`
164160 - `server.data_dir`
165-- `secrets.backend`
166-- `registry_auth` settings
161+- `auth` settings
167162 - `deploy.pull_policy`
168163
169164 ## Environment Variable Override
@@ -182,4 +177,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
182177 - [Server Configuration](./server.md)
183178 - [Routes Configuration](./routes.md)
184179 - [External Routes](./external-routes.md)
185-- [Registry Authentication](./registry-auth.md)
180+- [Authentication](./auth.md)
@@ -33,19 +33,16 @@ registry_port = 5000 # Registry port (default: 5000)
33 gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API)33 gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API)
34 # data_dir = "~/.gordon" # Default for user installations34 # data_dir = "~/.gordon" # Default for user installations
35 35
36-# Secrets backend36+# Authentication (includes secrets backend)
37-[secrets]37+[auth]
38-backend = "pass" # "pass", "sops", or "unsafe"38+enabled = true # Enable registry authentication (default: true)
39-
40-# Registry authentication
41-[registry_auth]
42-enabled = true
43 type = "token" # "password" or "token"39 type = "token" # "password" or "token"
40+secrets_backend = "pass" # "pass", "sops", or "unsafe"
44 # Password auth:41 # Password auth:
45 # username = "deploy"42 # username = "deploy"
46-# password_hash = "gordon/registry/password_hash"43+# password_hash = "gordon/auth/password_hash"
47 # Token auth:44 # Token auth:
48-token_secret = "gordon/registry/token_secret"45+token_secret = "gordon/auth/token_secret"
49 token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never46 token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never
50 47
51 # Deploy behavior48 # Deploy behavior
@@ -111,8 +108,7 @@ enabled = false # Auto-create routes from image names
111 | Section | Description | Documentation |108 | Section | Description | Documentation |
112 |---------|-------------|---------------|109 |---------|-------------|---------------|
113 | `[server]` | Core server settings | [Server](./server.md) |110 | `[server]` | Core server settings | [Server](./server.md) |
114-| `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) |111+| `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |
115-| `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) |
116 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |112 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
117 | `[logging]` | Logging configuration | [Logging](./logging.md) |113 | `[logging]` | Logging configuration | [Logging](./logging.md) |
118 | `[env]` | Environment variable settings | [Environment](./env.md) |114 | `[env]` | Environment variable settings | [Environment](./env.md) |
@@ -131,10 +127,10 @@ enabled = false # Auto-create routes from image names
131 | `server.port` | `80` |127 | `server.port` | `80` |
132 | `server.registry_port` | `5000` |128 | `server.registry_port` | `5000` |
133 | `server.data_dir` | `~/.gordon` |129 | `server.data_dir` | `~/.gordon` |
134-| `secrets.backend` | `"unsafe"` |130+| `auth.enabled` | `true` |
135-| `registry_auth.enabled` | `false` |131+| `auth.type` | `"password"` |
136-| `registry_auth.type` | `"password"` |132+| `auth.secrets_backend` | `"unsafe"` |
137-| `registry_auth.token_expiry` | `"30d"` |133+| `auth.token_expiry` | `"30d"` |
138 | `deploy.pull_policy` | `"if-tag-changed"` |134 | `deploy.pull_policy` | `"if-tag-changed"` |
139 | `logging.level` | `"info"` |135 | `logging.level` | `"info"` |
140 | `logging.format` | `"console"` |136 | `logging.format` | `"console"` |
@@ -162,8 +158,7 @@ The following settings require a restart to take effect:
162 - `server.port`158 - `server.port`
163 - `server.registry_port`159 - `server.registry_port`
164 - `server.data_dir`160 - `server.data_dir`
165-- `secrets.backend`161+- `auth` settings
166-- `registry_auth` settings
167 - `deploy.pull_policy`162 - `deploy.pull_policy`
168 163
169 ## Environment Variable Override164 ## Environment Variable Override
@@ -182,4 +177,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
182 - [Server Configuration](./server.md)177 - [Server Configuration](./server.md)
183 - [Routes Configuration](./routes.md)178 - [Routes Configuration](./routes.md)
184 - [External Routes](./external-routes.md)179 - [External Routes](./external-routes.md)
185-- [Registry Authentication](./registry-auth.md)180+- [Authentication](./auth.md)
M docs/config/secrets.md +29 -33
@@ -4,16 +4,18 @@ Configure how Gordon stores and retrieves sensitive data.
44
55 ## Configuration
66
7+The secrets backend is configured within the `[auth]` section:
8+
79 ```toml
8-[secrets]
9-backend = "pass" # "pass", "sops", or "unsafe"
10+[auth]
11+secrets_backend = "pass" # "pass", "sops", or "unsafe"
1012 ```
1113
1214 ## Options
1315
1416 | Option | Type | Default | Description |
1517 |--------|------|---------|-------------|
16-| `backend` | string | `"unsafe"` | Secrets storage backend |
18+| `secrets_backend` | string | `"unsafe"` | Secrets storage backend |
1719
1820 ## Backends
1921
@@ -22,8 +24,8 @@ backend = "pass" # "pass", "sops", or "unsafe"
2224 Uses the Unix password manager (`pass`) for secure secret storage:
2325
2426 ```toml
25-[secrets]
26-backend = "pass"
27+[auth]
28+secrets_backend = "pass"
2729 ```
2830
2931 **Setup:**
@@ -35,15 +37,15 @@ sudo apt install pass
3537 pass init your-gpg-key-id
3638
3739 # Store secrets
38-pass insert gordon/registry/password_hash
39-pass insert gordon/registry/token_secret
40+pass insert gordon/auth/password_hash
41+pass insert gordon/auth/token_secret
4042 ```
4143
4244 **Usage in config:**
4345 ```toml
44-[registry_auth]
45-password_hash = "gordon/registry/password_hash" # Path in pass store
46-token_secret = "gordon/registry/token_secret"
46+[auth]
47+password_hash = "gordon/auth/password_hash" # Path in pass store
48+token_secret = "gordon/auth/token_secret"
4749 ```
4850
4951 **Benefits:**
@@ -57,8 +59,8 @@ token_secret = "gordon/registry/token_secret"
5759 Uses Mozilla SOPS for encrypted file-based secrets:
5860
5961 ```toml
60-[secrets]
61-backend = "sops"
62+[auth]
63+secrets_backend = "sops"
6264 ```
6365
6466 **Setup:**
@@ -88,15 +90,15 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
8890 Stores secrets as plain text files:
8991
9092 ```toml
91-[secrets]
92-backend = "unsafe"
93+[auth]
94+secrets_backend = "unsafe"
9395 ```
9496
9597 **Storage location:**
9698 ```
9799 {data_dir}/secrets/
98100 ├── gordon/
99-│ └── registry/
101+│ └── auth/
100102 │ ├── password_hash
101103 │ └── token_secret
102104 ```
@@ -104,8 +106,8 @@ backend = "unsafe"
104106 **Usage:**
105107 ```bash
106108 # Create secret
107-mkdir -p ~/.gordon/secrets/gordon/registry
108-echo "your-bcrypt-hash" > ~/.gordon/secrets/gordon/registry/password_hash
109+mkdir -p ~/.gordon/secrets/gordon/auth
110+echo "your-bcrypt-hash" > ~/.gordon/secrets/gordon/auth/password_hash
109111 ```
110112
111113 > **Warning:** Only use for local development. Secrets are stored in plain text.
@@ -135,18 +137,16 @@ API_SECRET=${sops:production.yaml:api.secret.key}
135137 ### Production with Pass
136138
137139 ```toml
138-[secrets]
139-backend = "pass"
140-
141-[registry_auth]
140+[auth]
142141 enabled = true
143142 type = "token"
144-token_secret = "gordon/registry/token_secret"
143+secrets_backend = "pass"
144+token_secret = "gordon/auth/token_secret"
145145 ```
146146
147147 ```bash
148148 # Setup
149-pass insert gordon/registry/token_secret
149+pass insert gordon/auth/token_secret
150150 # Enter a random 32+ character string
151151
152152 # Generate tokens
@@ -156,23 +156,19 @@ gordon auth token generate --subject deploy --expiry 0
156156 ### Development with Unsafe
157157
158158 ```toml
159-[secrets]
160-backend = "unsafe"
161-
162-[registry_auth]
159+[auth]
163160 enabled = false
161+secrets_backend = "unsafe"
164162 ```
165163
166164 ### Enterprise with SOPS
167165
168166 ```toml
169-[secrets]
170-backend = "sops"
171-
172-[registry_auth]
167+[auth]
173168 enabled = true
174169 type = "token"
175-token_secret = "gordon/registry/token_secret"
170+secrets_backend = "sops"
171+token_secret = "gordon/auth/token_secret"
176172 ```
177173
178174 Environment file:
@@ -194,6 +190,6 @@ JWT_SECRET=${sops:secrets.yaml:jwt.secret}
194190
195191 ## Related
196192
197-- [Registry Authentication](./registry-auth.md)
193+- [Authentication](./auth.md)
198194 - [Environment Variables](./env.md)
199195 - [Configuration Overview](./index.md)
@@ -4,16 +4,18 @@ Configure how Gordon stores and retrieves sensitive data.
4 4
5 ## Configuration5 ## Configuration
6 6
7+The secrets backend is configured within the `[auth]` section:
8+
7 ```toml9 ```toml
8-[secrets]10+[auth]
9-backend = "pass" # "pass", "sops", or "unsafe"11+secrets_backend = "pass" # "pass", "sops", or "unsafe"
10 ```12 ```
11 13
12 ## Options14 ## Options
13 15
14 | Option | Type | Default | Description |16 | Option | Type | Default | Description |
15 |--------|------|---------|-------------|17 |--------|------|---------|-------------|
16-| `backend` | string | `"unsafe"` | Secrets storage backend |18+| `secrets_backend` | string | `"unsafe"` | Secrets storage backend |
17 19
18 ## Backends20 ## Backends
19 21
@@ -22,8 +24,8 @@ backend = "pass" # "pass", "sops", or "unsafe"
22 Uses the Unix password manager (`pass`) for secure secret storage:24 Uses the Unix password manager (`pass`) for secure secret storage:
23 25
24 ```toml26 ```toml
25-[secrets]27+[auth]
26-backend = "pass"28+secrets_backend = "pass"
27 ```29 ```
28 30
29 **Setup:**31 **Setup:**
@@ -35,15 +37,15 @@ sudo apt install pass
35 pass init your-gpg-key-id37 pass init your-gpg-key-id
36 38
37 # Store secrets39 # Store secrets
38-pass insert gordon/registry/password_hash40+pass insert gordon/auth/password_hash
39-pass insert gordon/registry/token_secret41+pass insert gordon/auth/token_secret
40 ```42 ```
41 43
42 **Usage in config:**44 **Usage in config:**
43 ```toml45 ```toml
44-[registry_auth]46+[auth]
45-password_hash = "gordon/registry/password_hash" # Path in pass store47+password_hash = "gordon/auth/password_hash" # Path in pass store
46-token_secret = "gordon/registry/token_secret"48+token_secret = "gordon/auth/token_secret"
47 ```49 ```
48 50
49 **Benefits:**51 **Benefits:**
@@ -57,8 +59,8 @@ token_secret = "gordon/registry/token_secret"
57 Uses Mozilla SOPS for encrypted file-based secrets:59 Uses Mozilla SOPS for encrypted file-based secrets:
58 60
59 ```toml61 ```toml
60-[secrets]62+[auth]
61-backend = "sops"63+secrets_backend = "sops"
62 ```64 ```
63 65
64 **Setup:**66 **Setup:**
@@ -88,15 +90,15 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
88 Stores secrets as plain text files:90 Stores secrets as plain text files:
89 91
90 ```toml92 ```toml
91-[secrets]93+[auth]
92-backend = "unsafe"94+secrets_backend = "unsafe"
93 ```95 ```
94 96
95 **Storage location:**97 **Storage location:**
96 ```98 ```
97 {data_dir}/secrets/99 {data_dir}/secrets/
98 ├── gordon/100 ├── gordon/
99-│ └── registry/101+│ └── auth/
100 │ ├── password_hash102 │ ├── password_hash
101 │ └── token_secret103 │ └── token_secret
102 ```104 ```
@@ -104,8 +106,8 @@ backend = "unsafe"
104 **Usage:**106 **Usage:**
105 ```bash107 ```bash
106 # Create secret108 # Create secret
107-mkdir -p ~/.gordon/secrets/gordon/registry109+mkdir -p ~/.gordon/secrets/gordon/auth
108-echo "your-bcrypt-hash" > ~/.gordon/secrets/gordon/registry/password_hash110+echo "your-bcrypt-hash" > ~/.gordon/secrets/gordon/auth/password_hash
109 ```111 ```
110 112
111 > **Warning:** Only use for local development. Secrets are stored in plain text.113 > **Warning:** Only use for local development. Secrets are stored in plain text.
@@ -135,18 +137,16 @@ API_SECRET=${sops:production.yaml:api.secret.key}
135 ### Production with Pass137 ### Production with Pass
136 138
137 ```toml139 ```toml
138-[secrets]140+[auth]
139-backend = "pass"
140-
141-[registry_auth]
142 enabled = true141 enabled = true
143 type = "token"142 type = "token"
144-token_secret = "gordon/registry/token_secret"143+secrets_backend = "pass"
144+token_secret = "gordon/auth/token_secret"
145 ```145 ```
146 146
147 ```bash147 ```bash
148 # Setup148 # Setup
149-pass insert gordon/registry/token_secret149+pass insert gordon/auth/token_secret
150 # Enter a random 32+ character string150 # Enter a random 32+ character string
151 151
152 # Generate tokens152 # Generate tokens
@@ -156,23 +156,19 @@ gordon auth token generate --subject deploy --expiry 0
156 ### Development with Unsafe156 ### Development with Unsafe
157 157
158 ```toml158 ```toml
159-[secrets]159+[auth]
160-backend = "unsafe"
161-
162-[registry_auth]
163 enabled = false160 enabled = false
161+secrets_backend = "unsafe"
164 ```162 ```
165 163
166 ### Enterprise with SOPS164 ### Enterprise with SOPS
167 165
168 ```toml166 ```toml
169-[secrets]167+[auth]
170-backend = "sops"
171-
172-[registry_auth]
173 enabled = true168 enabled = true
174 type = "token"169 type = "token"
175-token_secret = "gordon/registry/token_secret"170+secrets_backend = "sops"
171+token_secret = "gordon/auth/token_secret"
176 ```172 ```
177 173
178 Environment file:174 Environment file:
@@ -194,6 +190,6 @@ JWT_SECRET=${sops:secrets.yaml:jwt.secret}
194 190
195 ## Related191 ## Related
196 192
197-- [Registry Authentication](./registry-auth.md)193+- [Authentication](./auth.md)
198 - [Environment Variables](./env.md)194 - [Environment Variables](./env.md)
199 - [Configuration Overview](./index.md)195 - [Configuration Overview](./index.md)
A internal/adapters/dto/admin_attachments.go +23 -0
@@ -0,0 +1,23 @@
1+// Package dto provides shared data transfer objects for API responses.
2+package dto
3+
4+// AttachmentsConfigResponse represents all configured attachments.
5+type AttachmentsConfigResponse struct {
6+ Attachments map[string][]string `json:"attachments"`
7+}
8+
9+// AttachmentConfigResponse represents attachments for a specific target.
10+type AttachmentConfigResponse struct {
11+ Target string `json:"target"`
12+ Images []string `json:"images"`
13+}
14+
15+// AttachmentAddRequest represents a request to add an attachment.
16+type AttachmentAddRequest struct {
17+ Image string `json:"image"`
18+}
19+
20+// AttachmentStatusResponse represents attachment operation result.
21+type AttachmentStatusResponse struct {
22+ Status string `json:"status"`
23+}
@@ -0,0 +1,23 @@
1+// Package dto provides shared data transfer objects for API responses.
2+package dto
3+
4+// AttachmentsConfigResponse represents all configured attachments.
5+type AttachmentsConfigResponse struct {
6+ Attachments map[string][]string `json:"attachments"`
7+}
8+
9+// AttachmentConfigResponse represents attachments for a specific target.
10+type AttachmentConfigResponse struct {
11+ Target string `json:"target"`
12+ Images []string `json:"images"`
13+}
14+
15+// AttachmentAddRequest represents a request to add an attachment.
16+type AttachmentAddRequest struct {
17+ Image string `json:"image"`
18+}
19+
20+// AttachmentStatusResponse represents attachment operation result.
21+type AttachmentStatusResponse struct {
22+ Status string `json:"status"`
23+}
A internal/adapters/in/cli/attachments.go +272 -0
@@ -0,0 +1,272 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+
8+ "gordon/internal/adapters/in/cli/remote"
9+ "gordon/internal/adapters/in/cli/ui/components"
10+ "gordon/internal/adapters/in/cli/ui/styles"
11+
12+ "github.com/spf13/cobra"
13+)
14+
15+// newAttachmentsCmd creates the attachments command group.
16+func newAttachmentsCmd() *cobra.Command {
17+ cmd := &cobra.Command{
18+ Use: "attachments",
19+ Aliases: []string{"attach"},
20+ Short: "Manage container attachments",
21+ Long: `Manage container attachments (databases, caches, queues) in the configuration.
22+
23+Attachments are service dependencies that run alongside your application containers.
24+They are defined per-domain or per-network-group in the configuration.
25+
26+When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
27+these commands operate on the remote server. Otherwise, they require access to
28+the local Gordon configuration.`,
29+ }
30+
31+ cmd.AddCommand(newAttachmentsListCmd())
32+ cmd.AddCommand(newAttachmentsAddCmd())
33+ cmd.AddCommand(newAttachmentsRemoveCmd())
34+
35+ return cmd
36+}
37+
38+// newAttachmentsListCmd creates the attachments list command.
39+func newAttachmentsListCmd() *cobra.Command {
40+ return &cobra.Command{
41+ Use: "list [domain-or-group]",
42+ Short: "List configured attachments",
43+ Long: `List all configured attachments, or attachments for a specific domain/group.
44+
45+Examples:
46+ gordon attachments list # List all attachments
47+ gordon attachments list app.example.com # List attachments for domain
48+ gordon attachments list backend # List attachments for network group`,
49+ Args: cobra.MaximumNArgs(1),
50+ RunE: func(cmd *cobra.Command, args []string) error {
51+ ctx := context.Background()
52+ target := ""
53+ if len(args) > 0 {
54+ target = args[0]
55+ }
56+
57+ client, isRemote := GetRemoteClient()
58+ if isRemote {
59+ return runAttachmentsListRemote(ctx, client, target)
60+ }
61+ return runAttachmentsListLocal(ctx, configPath, target)
62+ },
63+ }
64+}
65+
66+// runAttachmentsListRemote lists attachments from a remote Gordon instance.
67+func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error {
68+ if target != "" {
69+ // List for specific target
70+ images, err := client.GetAttachmentsConfig(ctx, target)
71+ if err != nil {
72+ return fmt.Errorf("failed to get attachments: %w", err)
73+ }
74+
75+ if len(images) == 0 {
76+ fmt.Printf("No attachments configured for '%s'\n", target)
77+ return nil
78+ }
79+
80+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
81+ fmt.Println()
82+ for _, img := range images {
83+ fmt.Printf(" %s\n", img)
84+ }
85+ return nil
86+ }
87+
88+ // List all attachments
89+ attachments, err := client.GetAllAttachmentsConfig(ctx)
90+ if err != nil {
91+ return fmt.Errorf("failed to list attachments: %w", err)
92+ }
93+
94+ if len(attachments) == 0 {
95+ fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
96+ return nil
97+ }
98+
99+ // Sort targets for consistent output
100+ targets := make([]string, 0, len(attachments))
101+ for t := range attachments {
102+ targets = append(targets, t)
103+ }
104+ sort.Strings(targets)
105+
106+ fmt.Println(styles.Theme.Title.Render("Attachments"))
107+ fmt.Println()
108+
109+ for _, t := range targets {
110+ images := attachments[t]
111+ fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
112+ for _, img := range images {
113+ fmt.Printf(" %s\n", img)
114+ }
115+ }
116+
117+ return nil
118+}
119+
120+// runAttachmentsListLocal lists attachments from local configuration.
121+func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error {
122+ local, err := GetLocalServices(cfgPath)
123+ if err != nil {
124+ return fmt.Errorf("failed to initialize local services: %w", err)
125+ }
126+
127+ if target != "" {
128+ // List for specific target
129+ images, err := local.GetConfigService().GetAttachmentsFor(ctx, target)
130+ if err != nil {
131+ return fmt.Errorf("failed to get attachments: %w", err)
132+ }
133+
134+ if len(images) == 0 {
135+ fmt.Printf("No attachments configured for '%s'\n", target)
136+ return nil
137+ }
138+
139+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
140+ fmt.Println()
141+ for _, img := range images {
142+ fmt.Printf(" %s\n", img)
143+ }
144+ return nil
145+ }
146+
147+ // List all attachments
148+ attachments := local.GetConfigService().GetAllAttachments(ctx)
149+
150+ if len(attachments) == 0 {
151+ fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
152+ return nil
153+ }
154+
155+ // Sort targets for consistent output
156+ targets := make([]string, 0, len(attachments))
157+ for t := range attachments {
158+ targets = append(targets, t)
159+ }
160+ sort.Strings(targets)
161+
162+ fmt.Println(styles.Theme.Title.Render("Attachments (local)"))
163+ fmt.Println()
164+
165+ for _, t := range targets {
166+ images := attachments[t]
167+ fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
168+ for _, img := range images {
169+ fmt.Printf(" %s\n", img)
170+ }
171+ }
172+
173+ return nil
174+}
175+
176+// newAttachmentsAddCmd creates the attachments add command.
177+func newAttachmentsAddCmd() *cobra.Command {
178+ cmd := &cobra.Command{
179+ Use: "add <domain-or-group> <image>",
180+ Short: "Add an attachment",
181+ Long: `Add a container attachment to a domain or network group.
182+
183+Examples:
184+ gordon attachments add app.example.com postgres:15
185+ gordon attachments add backend redis:7-alpine
186+ gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
187+ Args: cobra.ExactArgs(2),
188+ RunE: func(cmd *cobra.Command, args []string) error {
189+ ctx := context.Background()
190+ target := args[0]
191+ image := args[1]
192+
193+ client, isRemote := GetRemoteClient()
194+ if isRemote {
195+ if err := client.AddAttachment(ctx, target, image); err != nil {
196+ return fmt.Errorf("failed to add attachment: %w", err)
197+ }
198+ } else {
199+ local, err := GetLocalServices(configPath)
200+ if err != nil {
201+ return fmt.Errorf("failed to initialize local services: %w", err)
202+ }
203+ if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil {
204+ return fmt.Errorf("failed to add attachment: %w", err)
205+ }
206+ }
207+
208+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
209+ return nil
210+ },
211+ }
212+
213+ return cmd
214+}
215+
216+// newAttachmentsRemoveCmd creates the attachments remove command.
217+func newAttachmentsRemoveCmd() *cobra.Command {
218+ var force bool
219+
220+ cmd := &cobra.Command{
221+ Use: "remove <domain-or-group> <image>",
222+ Short: "Remove an attachment",
223+ Long: `Remove a container attachment from a domain or network group.
224+
225+Examples:
226+ gordon attachments remove app.example.com postgres:15
227+ gordon attachments remove backend redis:7-alpine --force`,
228+ Args: cobra.ExactArgs(2),
229+ RunE: func(cmd *cobra.Command, args []string) error {
230+ ctx := context.Background()
231+ target := args[0]
232+ image := args[1]
233+
234+ // Confirm unless --force
235+ if !force {
236+ confirmed, err := components.RunConfirm(
237+ fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target),
238+ components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."),
239+ )
240+ if err != nil {
241+ return err
242+ }
243+ if !confirmed {
244+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
245+ return nil
246+ }
247+ }
248+
249+ client, isRemote := GetRemoteClient()
250+ if isRemote {
251+ if err := client.RemoveAttachment(ctx, target, image); err != nil {
252+ return fmt.Errorf("failed to remove attachment: %w", err)
253+ }
254+ } else {
255+ local, err := GetLocalServices(configPath)
256+ if err != nil {
257+ return fmt.Errorf("failed to initialize local services: %w", err)
258+ }
259+ if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil {
260+ return fmt.Errorf("failed to remove attachment: %w", err)
261+ }
262+ }
263+
264+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
265+ return nil
266+ },
267+ }
268+
269+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
270+
271+ return cmd
272+}
@@ -0,0 +1,272 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+
8+ "gordon/internal/adapters/in/cli/remote"
9+ "gordon/internal/adapters/in/cli/ui/components"
10+ "gordon/internal/adapters/in/cli/ui/styles"
11+
12+ "github.com/spf13/cobra"
13+)
14+
15+// newAttachmentsCmd creates the attachments command group.
16+func newAttachmentsCmd() *cobra.Command {
17+ cmd := &cobra.Command{
18+ Use: "attachments",
19+ Aliases: []string{"attach"},
20+ Short: "Manage container attachments",
21+ Long: `Manage container attachments (databases, caches, queues) in the configuration.
22+
23+Attachments are service dependencies that run alongside your application containers.
24+They are defined per-domain or per-network-group in the configuration.
25+
26+When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
27+these commands operate on the remote server. Otherwise, they require access to
28+the local Gordon configuration.`,
29+ }
30+
31+ cmd.AddCommand(newAttachmentsListCmd())
32+ cmd.AddCommand(newAttachmentsAddCmd())
33+ cmd.AddCommand(newAttachmentsRemoveCmd())
34+
35+ return cmd
36+}
37+
38+// newAttachmentsListCmd creates the attachments list command.
39+func newAttachmentsListCmd() *cobra.Command {
40+ return &cobra.Command{
41+ Use: "list [domain-or-group]",
42+ Short: "List configured attachments",
43+ Long: `List all configured attachments, or attachments for a specific domain/group.
44+
45+Examples:
46+ gordon attachments list # List all attachments
47+ gordon attachments list app.example.com # List attachments for domain
48+ gordon attachments list backend # List attachments for network group`,
49+ Args: cobra.MaximumNArgs(1),
50+ RunE: func(cmd *cobra.Command, args []string) error {
51+ ctx := context.Background()
52+ target := ""
53+ if len(args) > 0 {
54+ target = args[0]
55+ }
56+
57+ client, isRemote := GetRemoteClient()
58+ if isRemote {
59+ return runAttachmentsListRemote(ctx, client, target)
60+ }
61+ return runAttachmentsListLocal(ctx, configPath, target)
62+ },
63+ }
64+}
65+
66+// runAttachmentsListRemote lists attachments from a remote Gordon instance.
67+func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error {
68+ if target != "" {
69+ // List for specific target
70+ images, err := client.GetAttachmentsConfig(ctx, target)
71+ if err != nil {
72+ return fmt.Errorf("failed to get attachments: %w", err)
73+ }
74+
75+ if len(images) == 0 {
76+ fmt.Printf("No attachments configured for '%s'\n", target)
77+ return nil
78+ }
79+
80+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
81+ fmt.Println()
82+ for _, img := range images {
83+ fmt.Printf(" %s\n", img)
84+ }
85+ return nil
86+ }
87+
88+ // List all attachments
89+ attachments, err := client.GetAllAttachmentsConfig(ctx)
90+ if err != nil {
91+ return fmt.Errorf("failed to list attachments: %w", err)
92+ }
93+
94+ if len(attachments) == 0 {
95+ fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
96+ return nil
97+ }
98+
99+ // Sort targets for consistent output
100+ targets := make([]string, 0, len(attachments))
101+ for t := range attachments {
102+ targets = append(targets, t)
103+ }
104+ sort.Strings(targets)
105+
106+ fmt.Println(styles.Theme.Title.Render("Attachments"))
107+ fmt.Println()
108+
109+ for _, t := range targets {
110+ images := attachments[t]
111+ fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
112+ for _, img := range images {
113+ fmt.Printf(" %s\n", img)
114+ }
115+ }
116+
117+ return nil
118+}
119+
120+// runAttachmentsListLocal lists attachments from local configuration.
121+func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error {
122+ local, err := GetLocalServices(cfgPath)
123+ if err != nil {
124+ return fmt.Errorf("failed to initialize local services: %w", err)
125+ }
126+
127+ if target != "" {
128+ // List for specific target
129+ images, err := local.GetConfigService().GetAttachmentsFor(ctx, target)
130+ if err != nil {
131+ return fmt.Errorf("failed to get attachments: %w", err)
132+ }
133+
134+ if len(images) == 0 {
135+ fmt.Printf("No attachments configured for '%s'\n", target)
136+ return nil
137+ }
138+
139+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
140+ fmt.Println()
141+ for _, img := range images {
142+ fmt.Printf(" %s\n", img)
143+ }
144+ return nil
145+ }
146+
147+ // List all attachments
148+ attachments := local.GetConfigService().GetAllAttachments(ctx)
149+
150+ if len(attachments) == 0 {
151+ fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
152+ return nil
153+ }
154+
155+ // Sort targets for consistent output
156+ targets := make([]string, 0, len(attachments))
157+ for t := range attachments {
158+ targets = append(targets, t)
159+ }
160+ sort.Strings(targets)
161+
162+ fmt.Println(styles.Theme.Title.Render("Attachments (local)"))
163+ fmt.Println()
164+
165+ for _, t := range targets {
166+ images := attachments[t]
167+ fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
168+ for _, img := range images {
169+ fmt.Printf(" %s\n", img)
170+ }
171+ }
172+
173+ return nil
174+}
175+
176+// newAttachmentsAddCmd creates the attachments add command.
177+func newAttachmentsAddCmd() *cobra.Command {
178+ cmd := &cobra.Command{
179+ Use: "add <domain-or-group> <image>",
180+ Short: "Add an attachment",
181+ Long: `Add a container attachment to a domain or network group.
182+
183+Examples:
184+ gordon attachments add app.example.com postgres:15
185+ gordon attachments add backend redis:7-alpine
186+ gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
187+ Args: cobra.ExactArgs(2),
188+ RunE: func(cmd *cobra.Command, args []string) error {
189+ ctx := context.Background()
190+ target := args[0]
191+ image := args[1]
192+
193+ client, isRemote := GetRemoteClient()
194+ if isRemote {
195+ if err := client.AddAttachment(ctx, target, image); err != nil {
196+ return fmt.Errorf("failed to add attachment: %w", err)
197+ }
198+ } else {
199+ local, err := GetLocalServices(configPath)
200+ if err != nil {
201+ return fmt.Errorf("failed to initialize local services: %w", err)
202+ }
203+ if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil {
204+ return fmt.Errorf("failed to add attachment: %w", err)
205+ }
206+ }
207+
208+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
209+ return nil
210+ },
211+ }
212+
213+ return cmd
214+}
215+
216+// newAttachmentsRemoveCmd creates the attachments remove command.
217+func newAttachmentsRemoveCmd() *cobra.Command {
218+ var force bool
219+
220+ cmd := &cobra.Command{
221+ Use: "remove <domain-or-group> <image>",
222+ Short: "Remove an attachment",
223+ Long: `Remove a container attachment from a domain or network group.
224+
225+Examples:
226+ gordon attachments remove app.example.com postgres:15
227+ gordon attachments remove backend redis:7-alpine --force`,
228+ Args: cobra.ExactArgs(2),
229+ RunE: func(cmd *cobra.Command, args []string) error {
230+ ctx := context.Background()
231+ target := args[0]
232+ image := args[1]
233+
234+ // Confirm unless --force
235+ if !force {
236+ confirmed, err := components.RunConfirm(
237+ fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target),
238+ components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."),
239+ )
240+ if err != nil {
241+ return err
242+ }
243+ if !confirmed {
244+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
245+ return nil
246+ }
247+ }
248+
249+ client, isRemote := GetRemoteClient()
250+ if isRemote {
251+ if err := client.RemoveAttachment(ctx, target, image); err != nil {
252+ return fmt.Errorf("failed to remove attachment: %w", err)
253+ }
254+ } else {
255+ local, err := GetLocalServices(configPath)
256+ if err != nil {
257+ return fmt.Errorf("failed to initialize local services: %w", err)
258+ }
259+ if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil {
260+ return fmt.Errorf("failed to remove attachment: %w", err)
261+ }
262+ }
263+
264+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
265+ return nil
266+ },
267+ }
268+
269+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
270+
271+ return cmd
272+}
M internal/adapters/in/cli/auth.go +7 -7
@@ -224,9 +224,9 @@ func newPasswordHashCmd() *cobra.Command {
224224
225225 The hash can be stored in your secrets backend and referenced in the config:
226226
227- [registry_auth]
227+ [auth]
228228 type = "password"
229- password_hash = "gordon/registry/password_hash"`,
229+ password_hash = "gordon/auth/password_hash"`,
230230 RunE: func(cmd *cobra.Command, args []string) error {
231231 return runPasswordHash()
232232 },
@@ -410,9 +410,9 @@ func runPasswordHash() error {
410410 fmt.Println(hash)
411411 fmt.Println()
412412 fmt.Println("Then reference the path in your config:")
413- fmt.Println(" [registry_auth]")
413+ fmt.Println(" [auth]")
414414 fmt.Println(" type = \"password\"")
415- fmt.Println(" password_hash = \"gordon/registry/password_hash\"")
415+ fmt.Println(" password_hash = \"gordon/auth/password_hash\"")
416416
417417 return nil
418418 }
@@ -430,7 +430,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
430430
431431 // Set defaults
432432 v.SetDefault("server.data_dir", app.DefaultDataDir())
433- v.SetDefault("secrets.backend", "unsafe")
433+ v.SetDefault("auth.secrets_backend", "unsafe")
434434
435435 app.ConfigureViper(v, configPath)
436436
@@ -446,7 +446,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
446446 }
447447
448448 // Determine backend
449- switch v.GetString("secrets.backend") {
449+ switch v.GetString("auth.secrets_backend") {
450450 case "pass":
451451 cfg.Backend = domain.SecretsBackendPass
452452 case "sops":
@@ -456,7 +456,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
456456 }
457457
458458 // Load token secret from secrets backend
459- tokenSecretPath := v.GetString("registry_auth.token_secret")
459+ tokenSecretPath := v.GetString("auth.token_secret")
460460 if tokenSecretPath != "" {
461461 // Load actual secret from the configured backend
462462 ctx := context.Background()
@@ -224,9 +224,9 @@ func newPasswordHashCmd() *cobra.Command {
224 224
225 The hash can be stored in your secrets backend and referenced in the config:225 The hash can be stored in your secrets backend and referenced in the config:
226 226
227- [registry_auth]227+ [auth]
228 type = "password"228 type = "password"
229- password_hash = "gordon/registry/password_hash"`,229+ password_hash = "gordon/auth/password_hash"`,
230 RunE: func(cmd *cobra.Command, args []string) error {230 RunE: func(cmd *cobra.Command, args []string) error {
231 return runPasswordHash()231 return runPasswordHash()
232 },232 },
@@ -410,9 +410,9 @@ func runPasswordHash() error {
410 fmt.Println(hash)410 fmt.Println(hash)
411 fmt.Println()411 fmt.Println()
412 fmt.Println("Then reference the path in your config:")412 fmt.Println("Then reference the path in your config:")
413- fmt.Println(" [registry_auth]")413+ fmt.Println(" [auth]")
414 fmt.Println(" type = \"password\"")414 fmt.Println(" type = \"password\"")
415- fmt.Println(" password_hash = \"gordon/registry/password_hash\"")415+ fmt.Println(" password_hash = \"gordon/auth/password_hash\"")
416 416
417 return nil417 return nil
418 }418 }
@@ -430,7 +430,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
430 430
431 // Set defaults431 // Set defaults
432 v.SetDefault("server.data_dir", app.DefaultDataDir())432 v.SetDefault("server.data_dir", app.DefaultDataDir())
433- v.SetDefault("secrets.backend", "unsafe")433+ v.SetDefault("auth.secrets_backend", "unsafe")
434 434
435 app.ConfigureViper(v, configPath)435 app.ConfigureViper(v, configPath)
436 436
@@ -446,7 +446,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
446 }446 }
447 447
448 // Determine backend448 // Determine backend
449- switch v.GetString("secrets.backend") {449+ switch v.GetString("auth.secrets_backend") {
450 case "pass":450 case "pass":
451 cfg.Backend = domain.SecretsBackendPass451 cfg.Backend = domain.SecretsBackendPass
452 case "sops":452 case "sops":
@@ -456,7 +456,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
456 }456 }
457 457
458 // Load token secret from secrets backend458 // Load token secret from secrets backend
459- tokenSecretPath := v.GetString("registry_auth.token_secret")459+ tokenSecretPath := v.GetString("auth.token_secret")
460 if tokenSecretPath != "" {460 if tokenSecretPath != "" {
461 // Load actual secret from the configured backend461 // Load actual secret from the configured backend
462 ctx := context.Background()462 ctx := context.Background()
M internal/adapters/in/cli/remote/client.go +75 -0
@@ -458,6 +458,81 @@ func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, line
458458 return c.streamLogs(ctx, path)
459459 }
460460
461+// Attachments Config API
462+
463+// GetAllAttachmentsConfig returns all configured attachments.
464+func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
465+ resp, err := c.request(ctx, http.MethodGet, "/attachments", nil)
466+ if err != nil {
467+ return nil, err
468+ }
469+
470+ var result struct {
471+ Attachments map[string][]string `json:"attachments"`
472+ }
473+ if err := parseResponse(resp, &result); err != nil {
474+ return nil, err
475+ }
476+
477+ return result.Attachments, nil
478+}
479+
480+// GetAttachmentsConfig returns attachments for a specific domain/group from config.
481+func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
482+ if domainOrGroup == "" {
483+ return nil, fmt.Errorf("domain or group is required")
484+ }
485+ path := "/attachments/" + url.PathEscape(domainOrGroup)
486+ resp, err := c.request(ctx, http.MethodGet, path, nil)
487+ if err != nil {
488+ return nil, err
489+ }
490+
491+ var result struct {
492+ Target string `json:"target"`
493+ Images []string `json:"images"`
494+ }
495+ if err := parseResponse(resp, &result); err != nil {
496+ return nil, err
497+ }
498+
499+ return result.Images, nil
500+}
501+
502+// AddAttachment adds an attachment to a domain/group.
503+func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
504+ if domainOrGroup == "" {
505+ return fmt.Errorf("domain or group is required")
506+ }
507+ if image == "" {
508+ return fmt.Errorf("image is required")
509+ }
510+ path := "/attachments/" + url.PathEscape(domainOrGroup)
511+ resp, err := c.request(ctx, http.MethodPost, path, struct {
512+ Image string `json:"image"`
513+ }{Image: image})
514+ if err != nil {
515+ return err
516+ }
517+ return parseResponse(resp, nil)
518+}
519+
520+// RemoveAttachment removes an attachment from a domain/group.
521+func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
522+ if domainOrGroup == "" {
523+ return fmt.Errorf("domain or group is required")
524+ }
525+ if image == "" {
526+ return fmt.Errorf("image is required")
527+ }
528+ path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image)
529+ resp, err := c.request(ctx, http.MethodDelete, path, nil)
530+ if err != nil {
531+ return err
532+ }
533+ return parseResponse(resp, nil)
534+}
535+
461536 // streamLogs handles SSE streaming for log endpoints.
462537 func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
463538 url := c.baseURL + "/admin" + path
@@ -458,6 +458,81 @@ func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, line
458 return c.streamLogs(ctx, path)458 return c.streamLogs(ctx, path)
459 }459 }
460 460
461+// Attachments Config API
462+
463+// GetAllAttachmentsConfig returns all configured attachments.
464+func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
465+ resp, err := c.request(ctx, http.MethodGet, "/attachments", nil)
466+ if err != nil {
467+ return nil, err
468+ }
469+
470+ var result struct {
471+ Attachments map[string][]string `json:"attachments"`
472+ }
473+ if err := parseResponse(resp, &result); err != nil {
474+ return nil, err
475+ }
476+
477+ return result.Attachments, nil
478+}
479+
480+// GetAttachmentsConfig returns attachments for a specific domain/group from config.
481+func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
482+ if domainOrGroup == "" {
483+ return nil, fmt.Errorf("domain or group is required")
484+ }
485+ path := "/attachments/" + url.PathEscape(domainOrGroup)
486+ resp, err := c.request(ctx, http.MethodGet, path, nil)
487+ if err != nil {
488+ return nil, err
489+ }
490+
491+ var result struct {
492+ Target string `json:"target"`
493+ Images []string `json:"images"`
494+ }
495+ if err := parseResponse(resp, &result); err != nil {
496+ return nil, err
497+ }
498+
499+ return result.Images, nil
500+}
501+
502+// AddAttachment adds an attachment to a domain/group.
503+func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
504+ if domainOrGroup == "" {
505+ return fmt.Errorf("domain or group is required")
506+ }
507+ if image == "" {
508+ return fmt.Errorf("image is required")
509+ }
510+ path := "/attachments/" + url.PathEscape(domainOrGroup)
511+ resp, err := c.request(ctx, http.MethodPost, path, struct {
512+ Image string `json:"image"`
513+ }{Image: image})
514+ if err != nil {
515+ return err
516+ }
517+ return parseResponse(resp, nil)
518+}
519+
520+// RemoveAttachment removes an attachment from a domain/group.
521+func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
522+ if domainOrGroup == "" {
523+ return fmt.Errorf("domain or group is required")
524+ }
525+ if image == "" {
526+ return fmt.Errorf("image is required")
527+ }
528+ path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image)
529+ resp, err := c.request(ctx, http.MethodDelete, path, nil)
530+ if err != nil {
531+ return err
532+ }
533+ return parseResponse(resp, nil)
534+}
535+
461 // streamLogs handles SSE streaming for log endpoints.536 // streamLogs handles SSE streaming for log endpoints.
462 func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {537 func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
463 url := c.baseURL + "/admin" + path538 url := c.baseURL + "/admin" + path
M internal/adapters/in/cli/root.go +1 -0
@@ -53,6 +53,7 @@ GORDON_REMOTE environment variable.`,
5353
5454 // Remote management commands
5555 rootCmd.AddCommand(newRoutesCmd())
56+ rootCmd.AddCommand(newAttachmentsCmd())
5657 rootCmd.AddCommand(newSecretsCmd())
5758 rootCmd.AddCommand(newRemotesCmd())
5859 rootCmd.AddCommand(newStatusCmd())
@@ -53,6 +53,7 @@ GORDON_REMOTE environment variable.`,
53 53
54 // Remote management commands54 // Remote management commands
55 rootCmd.AddCommand(newRoutesCmd())55 rootCmd.AddCommand(newRoutesCmd())
56+ rootCmd.AddCommand(newAttachmentsCmd())
56 rootCmd.AddCommand(newSecretsCmd())57 rootCmd.AddCommand(newSecretsCmd())
57 rootCmd.AddCommand(newRemotesCmd())58 rootCmd.AddCommand(newRemotesCmd())
58 rootCmd.AddCommand(newStatusCmd())59 rootCmd.AddCommand(newStatusCmd())
M internal/adapters/in/http/admin/handler.go +141 -0
@@ -123,6 +123,8 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
123123
124124 // Route to appropriate handler
125125 switch {
126+ case path == "/attachments" || strings.HasPrefix(path, "/attachments/"):
127+ h.handleAttachmentsConfig(w, r, path)
126128 case path == "/routes" || strings.HasPrefix(path, "/routes/"):
127129 h.handleRoutes(w, r, path)
128130 case path == "/networks":
@@ -857,3 +859,142 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo
857859 }
858860 }
859861 }
862+
863+// handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments.
864+func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) {
865+ // Parse target (domain or group) from path
866+ target := strings.TrimPrefix(path, "/attachments/")
867+ if target == "/attachments" {
868+ target = ""
869+ }
870+
871+ switch r.Method {
872+ case http.MethodGet:
873+ h.handleAttachmentsConfigGet(w, r, target)
874+ case http.MethodPost:
875+ h.handleAttachmentsConfigPost(w, r, target)
876+ case http.MethodDelete:
877+ h.handleAttachmentsConfigDelete(w, r, target)
878+ default:
879+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
880+ }
881+}
882+
883+func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) {
884+ ctx := r.Context()
885+
886+ // Check read permission
887+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
888+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
889+ return
890+ }
891+
892+ if target == "" {
893+ // List all attachments
894+ attachments := h.configSvc.GetAllAttachments(ctx)
895+ h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments})
896+ return
897+ }
898+
899+ // List attachments for specific target
900+ images, err := h.configSvc.GetAttachmentsFor(ctx, target)
901+ if err != nil {
902+ if errors.Is(err, domain.ErrAttachmentNotFound) {
903+ h.sendError(w, http.StatusNotFound, "no attachments found for target")
904+ return
905+ }
906+ h.sendError(w, http.StatusInternalServerError, "failed to get attachments")
907+ return
908+ }
909+
910+ h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images})
911+}
912+
913+func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) {
914+ ctx := r.Context()
915+ log := zerowrap.FromCtx(ctx)
916+
917+ // Check write permission
918+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
919+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
920+ return
921+ }
922+
923+ if target == "" {
924+ h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
925+ return
926+ }
927+
928+ // Limit request body size
929+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
930+
931+ var req dto.AttachmentAddRequest
932+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
933+ log.Warn().Err(err).Msg("invalid attachment JSON")
934+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
935+ return
936+ }
937+
938+ if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil {
939+ log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment")
940+ switch {
941+ case errors.Is(err, domain.ErrAttachmentExists):
942+ h.sendError(w, http.StatusConflict, "attachment already exists")
943+ case errors.Is(err, domain.ErrAttachmentImageEmpty):
944+ h.sendError(w, http.StatusBadRequest, err.Error())
945+ case errors.Is(err, domain.ErrAttachmentTargetEmpty):
946+ h.sendError(w, http.StatusBadRequest, err.Error())
947+ default:
948+ h.sendError(w, http.StatusInternalServerError, "failed to add attachment")
949+ }
950+ return
951+ }
952+
953+ log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added")
954+ h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"})
955+}
956+
957+func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) {
958+ ctx := r.Context()
959+ log := zerowrap.FromCtx(ctx)
960+
961+ // Check write permission
962+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
963+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
964+ return
965+ }
966+
967+ if target == "" {
968+ h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
969+ return
970+ }
971+
972+ // Parse image from path: /attachments/{target}/{image}
973+ // target at this point contains "{domain}/{image}" or just "{domain}"
974+ parts := strings.SplitN(target, "/", 2)
975+ if len(parts) != 2 {
976+ h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}")
977+ return
978+ }
979+
980+ domainOrGroup := parts[0]
981+ image := parts[1]
982+
983+ if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil {
984+ log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment")
985+ switch {
986+ case errors.Is(err, domain.ErrAttachmentNotFound):
987+ h.sendError(w, http.StatusNotFound, "attachment not found")
988+ case errors.Is(err, domain.ErrAttachmentImageEmpty):
989+ h.sendError(w, http.StatusBadRequest, err.Error())
990+ case errors.Is(err, domain.ErrAttachmentTargetEmpty):
991+ h.sendError(w, http.StatusBadRequest, err.Error())
992+ default:
993+ h.sendError(w, http.StatusInternalServerError, "failed to remove attachment")
994+ }
995+ return
996+ }
997+
998+ log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
999+ h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1000+}
@@ -123,6 +123,8 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
123 123
124 // Route to appropriate handler124 // Route to appropriate handler
125 switch {125 switch {
126+ case path == "/attachments" || strings.HasPrefix(path, "/attachments/"):
127+ h.handleAttachmentsConfig(w, r, path)
126 case path == "/routes" || strings.HasPrefix(path, "/routes/"):128 case path == "/routes" || strings.HasPrefix(path, "/routes/"):
127 h.handleRoutes(w, r, path)129 h.handleRoutes(w, r, path)
128 case path == "/networks":130 case path == "/networks":
@@ -857,3 +859,142 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo
857 }859 }
858 }860 }
859 }861 }
862+
863+// handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments.
864+func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) {
865+ // Parse target (domain or group) from path
866+ target := strings.TrimPrefix(path, "/attachments/")
867+ if target == "/attachments" {
868+ target = ""
869+ }
870+
871+ switch r.Method {
872+ case http.MethodGet:
873+ h.handleAttachmentsConfigGet(w, r, target)
874+ case http.MethodPost:
875+ h.handleAttachmentsConfigPost(w, r, target)
876+ case http.MethodDelete:
877+ h.handleAttachmentsConfigDelete(w, r, target)
878+ default:
879+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
880+ }
881+}
882+
883+func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) {
884+ ctx := r.Context()
885+
886+ // Check read permission
887+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
888+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
889+ return
890+ }
891+
892+ if target == "" {
893+ // List all attachments
894+ attachments := h.configSvc.GetAllAttachments(ctx)
895+ h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments})
896+ return
897+ }
898+
899+ // List attachments for specific target
900+ images, err := h.configSvc.GetAttachmentsFor(ctx, target)
901+ if err != nil {
902+ if errors.Is(err, domain.ErrAttachmentNotFound) {
903+ h.sendError(w, http.StatusNotFound, "no attachments found for target")
904+ return
905+ }
906+ h.sendError(w, http.StatusInternalServerError, "failed to get attachments")
907+ return
908+ }
909+
910+ h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images})
911+}
912+
913+func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) {
914+ ctx := r.Context()
915+ log := zerowrap.FromCtx(ctx)
916+
917+ // Check write permission
918+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
919+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
920+ return
921+ }
922+
923+ if target == "" {
924+ h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
925+ return
926+ }
927+
928+ // Limit request body size
929+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
930+
931+ var req dto.AttachmentAddRequest
932+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
933+ log.Warn().Err(err).Msg("invalid attachment JSON")
934+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
935+ return
936+ }
937+
938+ if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil {
939+ log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment")
940+ switch {
941+ case errors.Is(err, domain.ErrAttachmentExists):
942+ h.sendError(w, http.StatusConflict, "attachment already exists")
943+ case errors.Is(err, domain.ErrAttachmentImageEmpty):
944+ h.sendError(w, http.StatusBadRequest, err.Error())
945+ case errors.Is(err, domain.ErrAttachmentTargetEmpty):
946+ h.sendError(w, http.StatusBadRequest, err.Error())
947+ default:
948+ h.sendError(w, http.StatusInternalServerError, "failed to add attachment")
949+ }
950+ return
951+ }
952+
953+ log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added")
954+ h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"})
955+}
956+
957+func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) {
958+ ctx := r.Context()
959+ log := zerowrap.FromCtx(ctx)
960+
961+ // Check write permission
962+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
963+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
964+ return
965+ }
966+
967+ if target == "" {
968+ h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
969+ return
970+ }
971+
972+ // Parse image from path: /attachments/{target}/{image}
973+ // target at this point contains "{domain}/{image}" or just "{domain}"
974+ parts := strings.SplitN(target, "/", 2)
975+ if len(parts) != 2 {
976+ h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}")
977+ return
978+ }
979+
980+ domainOrGroup := parts[0]
981+ image := parts[1]
982+
983+ if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil {
984+ log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment")
985+ switch {
986+ case errors.Is(err, domain.ErrAttachmentNotFound):
987+ h.sendError(w, http.StatusNotFound, "attachment not found")
988+ case errors.Is(err, domain.ErrAttachmentImageEmpty):
989+ h.sendError(w, http.StatusBadRequest, err.Error())
990+ case errors.Is(err, domain.ErrAttachmentTargetEmpty):
991+ h.sendError(w, http.StatusBadRequest, err.Error())
992+ default:
993+ h.sendError(w, http.StatusInternalServerError, "failed to remove attachment")
994+ }
995+ return
996+ }
997+
998+ log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
999+ h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1000+}
M internal/app/run.go +31 -34
@@ -90,19 +90,16 @@ type Config struct {
9090 Dir string `mapstructure:"dir"`
9191 } `mapstructure:"env"`
9292
93- Secrets struct {
94- Backend string `mapstructure:"backend"` // "pass", "sops", or "unsafe"
95- } `mapstructure:"secrets"`
96-
97- RegistryAuth struct {
98- Enabled bool `mapstructure:"enabled"`
99- Type string `mapstructure:"type"` // "password" or "token"
100- Username string `mapstructure:"username"`
101- Password string `mapstructure:"password"` // deprecated: use password_hash
102- PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
103- TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
104- TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h"
105- } `mapstructure:"registry_auth"`
93+ Auth struct {
94+ Enabled bool `mapstructure:"enabled"`
95+ Type string `mapstructure:"type"` // "password" or "token"
96+ SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
97+ Username string `mapstructure:"username"`
98+ Password string `mapstructure:"password"` // deprecated: use password_hash
99+ PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
100+ TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
101+ TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
102+ } `mapstructure:"auth"`
106103 }
107104
108105 // services holds all the services used by the application.
@@ -264,7 +261,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
264261 }
265262
266263 // Generate internal registry credentials for loopback-only pulls
267- if cfg.RegistryAuth.Enabled {
264+ if cfg.Auth.Enabled {
268265 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
269266 if err != nil {
270267 return nil, log.WrapErr(err, "failed to generate internal registry credentials")
@@ -517,12 +514,12 @@ func GetInternalCredentials() (*InternalCredentials, error) {
517514
518515 // createAuthService creates the authentication service and token store.
519516 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
520- if !cfg.RegistryAuth.Enabled {
517+ if !cfg.Auth.Enabled {
521518 return nil, nil, nil
522519 }
523520
524- authType := resolveAuthType(cfg.RegistryAuth.Type)
525- backend := resolveSecretsBackend(cfg.Secrets.Backend)
521+ authType := resolveAuthType(cfg.Auth.Type)
522+ backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
526523 dataDir := resolveDataDir(cfg.Server.DataDir)
527524
528525 store, err := createTokenStore(authType, backend, dataDir, log)
@@ -586,9 +583,9 @@ func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, d
586583
587584 func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
588585 authConfig := auth.Config{
589- Enabled: cfg.RegistryAuth.Enabled,
586+ Enabled: cfg.Auth.Enabled,
590587 AuthType: authType,
591- Username: cfg.RegistryAuth.Username,
588+ Username: cfg.Auth.Username,
592589 }
593590
594591 switch authType {
@@ -611,17 +608,17 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType,
611608 }
612609
613610 func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
614- if cfg.RegistryAuth.PasswordHash != "" {
615- hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log)
611+ if cfg.Auth.PasswordHash != "" {
612+ hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
616613 if err != nil {
617614 return "", log.WrapErr(err, "failed to load password hash")
618615 }
619616 return hash, nil
620617 }
621618
622- if cfg.RegistryAuth.Password != "" {
619+ if cfg.Auth.Password != "" {
623620 log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
624- return cfg.RegistryAuth.Password, nil
621+ return cfg.Auth.Password, nil
625622 }
626623
627624 return "", nil
@@ -633,7 +630,7 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack
633630 return nil, 0, err
634631 }
635632
636- expiry, err := parseTokenExpiry(cfg.RegistryAuth.TokenExpiry)
633+ expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
637634 if err != nil {
638635 return nil, 0, err
639636 }
@@ -642,11 +639,11 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack
642639 }
643640
644641 func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
645- if cfg.RegistryAuth.TokenSecret == "" {
642+ if cfg.Auth.TokenSecret == "" {
646643 return nil, fmt.Errorf("token_secret is required for token authentication")
647644 }
648645
649- secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log)
646+ secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
650647 if err != nil {
651648 return nil, log.WrapErr(err, "failed to load token secret")
652649 }
@@ -692,11 +689,11 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
692689 // createContainerService creates the container service with configuration.
693690 func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
694691 containerConfig := container.Config{
695- RegistryAuthEnabled: cfg.RegistryAuth.Enabled,
692+ RegistryAuthEnabled: cfg.Auth.Enabled,
696693 RegistryDomain: cfg.Server.RegistryDomain,
697694 RegistryPort: cfg.Server.RegistryPort,
698- RegistryUsername: cfg.RegistryAuth.Username,
699- RegistryPassword: cfg.RegistryAuth.Password,
695+ RegistryUsername: cfg.Auth.Username,
696+ RegistryPassword: cfg.Auth.Password,
700697 InternalRegistryUsername: svc.internalRegUser,
701698 InternalRegistryPassword: svc.internalRegPass,
702699 PullPolicy: v.GetString("deploy.pull_policy"),
@@ -803,7 +800,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
803800 middleware.RequestLogger(log),
804801 }
805802
806- if cfg.RegistryAuth.Enabled && svc.authSvc != nil {
803+ if cfg.Auth.Enabled && svc.authSvc != nil {
807804 internalAuth := middleware.InternalRegistryAuth{
808805 Username: svc.internalRegUser,
809806 Password: svc.internalRegPass,
@@ -1143,10 +1140,10 @@ func loadConfig(v *viper.Viper, configPath string) error {
11431140 v.SetDefault("logging.container_logs.max_backups", 3)
11441141 v.SetDefault("logging.container_logs.max_age", 28)
11451142 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
1146- v.SetDefault("secrets.backend", "unsafe")
1147- v.SetDefault("registry_auth.enabled", false)
1148- v.SetDefault("registry_auth.type", "password")
1149- v.SetDefault("registry_auth.token_expiry", "720h")
1143+ v.SetDefault("auth.enabled", true)
1144+ v.SetDefault("auth.type", "password")
1145+ v.SetDefault("auth.secrets_backend", "unsafe")
1146+ v.SetDefault("auth.token_expiry", "720h")
11501147 v.SetDefault("auto_route.enabled", false)
11511148 v.SetDefault("network_isolation.enabled", false)
11521149 v.SetDefault("network_isolation.network_prefix", "gordon")
@@ -90,19 +90,16 @@ type Config struct {
90 Dir string `mapstructure:"dir"`90 Dir string `mapstructure:"dir"`
91 } `mapstructure:"env"`91 } `mapstructure:"env"`
92 92
93- Secrets struct {93+ Auth struct {
94- Backend string `mapstructure:"backend"` // "pass", "sops", or "unsafe"94+ Enabled bool `mapstructure:"enabled"`
95- } `mapstructure:"secrets"`95+ Type string `mapstructure:"type"` // "password" or "token"
96-96+ SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
97- RegistryAuth struct {97+ Username string `mapstructure:"username"`
98- Enabled bool `mapstructure:"enabled"`98+ Password string `mapstructure:"password"` // deprecated: use password_hash
99- Type string `mapstructure:"type"` // "password" or "token"99+ PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
100- Username string `mapstructure:"username"`100+ TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
101- Password string `mapstructure:"password"` // deprecated: use password_hash101+ TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
102- PasswordHash string `mapstructure:"password_hash"` // path in secrets backend102+ } `mapstructure:"auth"`
103- TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
104- TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h"
105- } `mapstructure:"registry_auth"`
106 }103 }
107 104
108 // services holds all the services used by the application.105 // services holds all the services used by the application.
@@ -264,7 +261,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
264 }261 }
265 262
266 // Generate internal registry credentials for loopback-only pulls263 // Generate internal registry credentials for loopback-only pulls
267- if cfg.RegistryAuth.Enabled {264+ if cfg.Auth.Enabled {
268 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()265 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
269 if err != nil {266 if err != nil {
270 return nil, log.WrapErr(err, "failed to generate internal registry credentials")267 return nil, log.WrapErr(err, "failed to generate internal registry credentials")
@@ -517,12 +514,12 @@ func GetInternalCredentials() (*InternalCredentials, error) {
517 514
518 // createAuthService creates the authentication service and token store.515 // createAuthService creates the authentication service and token store.
519 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {516 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
520- if !cfg.RegistryAuth.Enabled {517+ if !cfg.Auth.Enabled {
521 return nil, nil, nil518 return nil, nil, nil
522 }519 }
523 520
524- authType := resolveAuthType(cfg.RegistryAuth.Type)521+ authType := resolveAuthType(cfg.Auth.Type)
525- backend := resolveSecretsBackend(cfg.Secrets.Backend)522+ backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
526 dataDir := resolveDataDir(cfg.Server.DataDir)523 dataDir := resolveDataDir(cfg.Server.DataDir)
527 524
528 store, err := createTokenStore(authType, backend, dataDir, log)525 store, err := createTokenStore(authType, backend, dataDir, log)
@@ -586,9 +583,9 @@ func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, d
586 583
587 func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {584 func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
588 authConfig := auth.Config{585 authConfig := auth.Config{
589- Enabled: cfg.RegistryAuth.Enabled,586+ Enabled: cfg.Auth.Enabled,
590 AuthType: authType,587 AuthType: authType,
591- Username: cfg.RegistryAuth.Username,588+ Username: cfg.Auth.Username,
592 }589 }
593 590
594 switch authType {591 switch authType {
@@ -611,17 +608,17 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType,
611 }608 }
612 609
613 func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {610 func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
614- if cfg.RegistryAuth.PasswordHash != "" {611+ if cfg.Auth.PasswordHash != "" {
615- hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log)612+ hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
616 if err != nil {613 if err != nil {
617 return "", log.WrapErr(err, "failed to load password hash")614 return "", log.WrapErr(err, "failed to load password hash")
618 }615 }
619 return hash, nil616 return hash, nil
620 }617 }
621 618
622- if cfg.RegistryAuth.Password != "" {619+ if cfg.Auth.Password != "" {
623 log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")620 log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
624- return cfg.RegistryAuth.Password, nil621+ return cfg.Auth.Password, nil
625 }622 }
626 623
627 return "", nil624 return "", nil
@@ -633,7 +630,7 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack
633 return nil, 0, err630 return nil, 0, err
634 }631 }
635 632
636- expiry, err := parseTokenExpiry(cfg.RegistryAuth.TokenExpiry)633+ expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
637 if err != nil {634 if err != nil {
638 return nil, 0, err635 return nil, 0, err
639 }636 }
@@ -642,11 +639,11 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack
642 }639 }
643 640
644 func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {641 func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
645- if cfg.RegistryAuth.TokenSecret == "" {642+ if cfg.Auth.TokenSecret == "" {
646 return nil, fmt.Errorf("token_secret is required for token authentication")643 return nil, fmt.Errorf("token_secret is required for token authentication")
647 }644 }
648 645
649- secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log)646+ secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
650 if err != nil {647 if err != nil {
651 return nil, log.WrapErr(err, "failed to load token secret")648 return nil, log.WrapErr(err, "failed to load token secret")
652 }649 }
@@ -692,11 +689,11 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
692 // createContainerService creates the container service with configuration.689 // createContainerService creates the container service with configuration.
693 func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {690 func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
694 containerConfig := container.Config{691 containerConfig := container.Config{
695- RegistryAuthEnabled: cfg.RegistryAuth.Enabled,692+ RegistryAuthEnabled: cfg.Auth.Enabled,
696 RegistryDomain: cfg.Server.RegistryDomain,693 RegistryDomain: cfg.Server.RegistryDomain,
697 RegistryPort: cfg.Server.RegistryPort,694 RegistryPort: cfg.Server.RegistryPort,
698- RegistryUsername: cfg.RegistryAuth.Username,695+ RegistryUsername: cfg.Auth.Username,
699- RegistryPassword: cfg.RegistryAuth.Password,696+ RegistryPassword: cfg.Auth.Password,
700 InternalRegistryUsername: svc.internalRegUser,697 InternalRegistryUsername: svc.internalRegUser,
701 InternalRegistryPassword: svc.internalRegPass,698 InternalRegistryPassword: svc.internalRegPass,
702 PullPolicy: v.GetString("deploy.pull_policy"),699 PullPolicy: v.GetString("deploy.pull_policy"),
@@ -803,7 +800,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
803 middleware.RequestLogger(log),800 middleware.RequestLogger(log),
804 }801 }
805 802
806- if cfg.RegistryAuth.Enabled && svc.authSvc != nil {803+ if cfg.Auth.Enabled && svc.authSvc != nil {
807 internalAuth := middleware.InternalRegistryAuth{804 internalAuth := middleware.InternalRegistryAuth{
808 Username: svc.internalRegUser,805 Username: svc.internalRegUser,
809 Password: svc.internalRegPass,806 Password: svc.internalRegPass,
@@ -1143,10 +1140,10 @@ func loadConfig(v *viper.Viper, configPath string) error {
1143 v.SetDefault("logging.container_logs.max_backups", 3)1140 v.SetDefault("logging.container_logs.max_backups", 3)
1144 v.SetDefault("logging.container_logs.max_age", 28)1141 v.SetDefault("logging.container_logs.max_age", 28)
1145 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty1142 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
1146- v.SetDefault("secrets.backend", "unsafe")1143+ v.SetDefault("auth.enabled", true)
1147- v.SetDefault("registry_auth.enabled", false)1144+ v.SetDefault("auth.type", "password")
1148- v.SetDefault("registry_auth.type", "password")1145+ v.SetDefault("auth.secrets_backend", "unsafe")
1149- v.SetDefault("registry_auth.token_expiry", "720h")1146+ v.SetDefault("auth.token_expiry", "720h")
1150 v.SetDefault("auto_route.enabled", false)1147 v.SetDefault("auto_route.enabled", false)
1151 v.SetDefault("network_isolation.enabled", false)1148 v.SetDefault("network_isolation.enabled", false)
1152 v.SetDefault("network_isolation.network_prefix", "gordon")1149 v.SetDefault("network_isolation.network_prefix", "gordon")
M internal/boundaries/in/config.go +12 -0
@@ -56,4 +56,16 @@ type ConfigService interface {
5656
5757 // GetExternalRoutes returns all configured external routes.
5858 GetExternalRoutes() map[string]string
59+
60+ // GetAllAttachments returns all configured attachments.
61+ GetAllAttachments(ctx context.Context) map[string][]string
62+
63+ // GetAttachmentsFor returns attachments for a specific domain or network group.
64+ GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error)
65+
66+ // AddAttachment adds an image to a domain/group's attachments.
67+ AddAttachment(ctx context.Context, domainOrGroup, image string) error
68+
69+ // RemoveAttachment removes an image from a domain/group's attachments.
70+ RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
5971 }
@@ -56,4 +56,16 @@ type ConfigService interface {
56 56
57 // GetExternalRoutes returns all configured external routes.57 // GetExternalRoutes returns all configured external routes.
58 GetExternalRoutes() map[string]string58 GetExternalRoutes() map[string]string
59+
60+ // GetAllAttachments returns all configured attachments.
61+ GetAllAttachments(ctx context.Context) map[string][]string
62+
63+ // GetAttachmentsFor returns attachments for a specific domain or network group.
64+ GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error)
65+
66+ // AddAttachment adds an image to a domain/group's attachments.
67+ AddAttachment(ctx context.Context, domainOrGroup, image string) error
68+
69+ // RemoveAttachment removes an image from a domain/group's attachments.
70+ RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
59 }71 }
M internal/boundaries/in/mocks/mock_config_service.go +247 -0
@@ -38,6 +38,69 @@ func (_m *MockConfigService) EXPECT() *MockConfigService_Expecter {
3838 return &MockConfigService_Expecter{mock: &_m.Mock}
3939 }
4040
41+// AddAttachment provides a mock function for the type MockConfigService
42+func (_mock *MockConfigService) AddAttachment(ctx context.Context, domainOrGroup string, image string) error {
43+ ret := _mock.Called(ctx, domainOrGroup, image)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for AddAttachment")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
51+ r0 = returnFunc(ctx, domainOrGroup, image)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockConfigService_AddAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAttachment'
59+type MockConfigService_AddAttachment_Call struct {
60+ *mock.Call
61+}
62+
63+// AddAttachment is a helper method to define mock.On call
64+// - ctx context.Context
65+// - domainOrGroup string
66+// - image string
67+func (_e *MockConfigService_Expecter) AddAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockConfigService_AddAttachment_Call {
68+ return &MockConfigService_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)}
69+}
70+
71+func (_c *MockConfigService_AddAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockConfigService_AddAttachment_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ var arg0 context.Context
74+ if args[0] != nil {
75+ arg0 = args[0].(context.Context)
76+ }
77+ var arg1 string
78+ if args[1] != nil {
79+ arg1 = args[1].(string)
80+ }
81+ var arg2 string
82+ if args[2] != nil {
83+ arg2 = args[2].(string)
84+ }
85+ run(
86+ arg0,
87+ arg1,
88+ arg2,
89+ )
90+ })
91+ return _c
92+}
93+
94+func (_c *MockConfigService_AddAttachment_Call) Return(err error) *MockConfigService_AddAttachment_Call {
95+ _c.Call.Return(err)
96+ return _c
97+}
98+
99+func (_c *MockConfigService_AddAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockConfigService_AddAttachment_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
103+
41104 // AddRoute provides a mock function for the type MockConfigService
42105 func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error {
43106 ret := _mock.Called(ctx, route)
@@ -95,6 +158,127 @@ func (_c *MockConfigService_AddRoute_Call) RunAndReturn(run func(ctx context.Con
95158 return _c
96159 }
97160
161+// GetAllAttachments provides a mock function for the type MockConfigService
162+func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string {
163+ ret := _mock.Called(ctx)
164+
165+ if len(ret) == 0 {
166+ panic("no return value specified for GetAllAttachments")
167+ }
168+
169+ var r0 map[string][]string
170+ if returnFunc, ok := ret.Get(0).(func(context.Context) map[string][]string); ok {
171+ r0 = returnFunc(ctx)
172+ } else {
173+ if ret.Get(0) != nil {
174+ r0 = ret.Get(0).(map[string][]string)
175+ }
176+ }
177+ return r0
178+}
179+
180+// MockConfigService_GetAllAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachments'
181+type MockConfigService_GetAllAttachments_Call struct {
182+ *mock.Call
183+}
184+
185+// GetAllAttachments is a helper method to define mock.On call
186+// - ctx context.Context
187+func (_e *MockConfigService_Expecter) GetAllAttachments(ctx interface{}) *MockConfigService_GetAllAttachments_Call {
188+ return &MockConfigService_GetAllAttachments_Call{Call: _e.mock.On("GetAllAttachments", ctx)}
189+}
190+
191+func (_c *MockConfigService_GetAllAttachments_Call) Run(run func(ctx context.Context)) *MockConfigService_GetAllAttachments_Call {
192+ _c.Call.Run(func(args mock.Arguments) {
193+ var arg0 context.Context
194+ if args[0] != nil {
195+ arg0 = args[0].(context.Context)
196+ }
197+ run(
198+ arg0,
199+ )
200+ })
201+ return _c
202+}
203+
204+func (_c *MockConfigService_GetAllAttachments_Call) Return(stringToStrings map[string][]string) *MockConfigService_GetAllAttachments_Call {
205+ _c.Call.Return(stringToStrings)
206+ return _c
207+}
208+
209+func (_c *MockConfigService_GetAllAttachments_Call) RunAndReturn(run func(ctx context.Context) map[string][]string) *MockConfigService_GetAllAttachments_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// GetAttachmentsFor provides a mock function for the type MockConfigService
215+func (_mock *MockConfigService) GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) {
216+ ret := _mock.Called(ctx, domainOrGroup)
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for GetAttachmentsFor")
220+ }
221+
222+ var r0 []string
223+ var r1 error
224+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok {
225+ return returnFunc(ctx, domainOrGroup)
226+ }
227+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
228+ r0 = returnFunc(ctx, domainOrGroup)
229+ } else {
230+ if ret.Get(0) != nil {
231+ r0 = ret.Get(0).([]string)
232+ }
233+ }
234+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
235+ r1 = returnFunc(ctx, domainOrGroup)
236+ } else {
237+ r1 = ret.Error(1)
238+ }
239+ return r0, r1
240+}
241+
242+// MockConfigService_GetAttachmentsFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachmentsFor'
243+type MockConfigService_GetAttachmentsFor_Call struct {
244+ *mock.Call
245+}
246+
247+// GetAttachmentsFor is a helper method to define mock.On call
248+// - ctx context.Context
249+// - domainOrGroup string
250+func (_e *MockConfigService_Expecter) GetAttachmentsFor(ctx interface{}, domainOrGroup interface{}) *MockConfigService_GetAttachmentsFor_Call {
251+ return &MockConfigService_GetAttachmentsFor_Call{Call: _e.mock.On("GetAttachmentsFor", ctx, domainOrGroup)}
252+}
253+
254+func (_c *MockConfigService_GetAttachmentsFor_Call) Run(run func(ctx context.Context, domainOrGroup string)) *MockConfigService_GetAttachmentsFor_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 string
261+ if args[1] != nil {
262+ arg1 = args[1].(string)
263+ }
264+ run(
265+ arg0,
266+ arg1,
267+ )
268+ })
269+ return _c
270+}
271+
272+func (_c *MockConfigService_GetAttachmentsFor_Call) Return(strings []string, err error) *MockConfigService_GetAttachmentsFor_Call {
273+ _c.Call.Return(strings, err)
274+ return _c
275+}
276+
277+func (_c *MockConfigService_GetAttachmentsFor_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string) ([]string, error)) *MockConfigService_GetAttachmentsFor_Call {
278+ _c.Call.Return(run)
279+ return _c
280+}
281+
98282 // GetDataDir provides a mock function for the type MockConfigService
99283 func (_mock *MockConfigService) GetDataDir() string {
100284 ret := _mock.Called()
@@ -621,6 +805,69 @@ func (_c *MockConfigService_Load_Call) RunAndReturn(run func(ctx context.Context
621805 return _c
622806 }
623807
808+// RemoveAttachment provides a mock function for the type MockConfigService
809+func (_mock *MockConfigService) RemoveAttachment(ctx context.Context, domainOrGroup string, image string) error {
810+ ret := _mock.Called(ctx, domainOrGroup, image)
811+
812+ if len(ret) == 0 {
813+ panic("no return value specified for RemoveAttachment")
814+ }
815+
816+ var r0 error
817+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
818+ r0 = returnFunc(ctx, domainOrGroup, image)
819+ } else {
820+ r0 = ret.Error(0)
821+ }
822+ return r0
823+}
824+
825+// MockConfigService_RemoveAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAttachment'
826+type MockConfigService_RemoveAttachment_Call struct {
827+ *mock.Call
828+}
829+
830+// RemoveAttachment is a helper method to define mock.On call
831+// - ctx context.Context
832+// - domainOrGroup string
833+// - image string
834+func (_e *MockConfigService_Expecter) RemoveAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockConfigService_RemoveAttachment_Call {
835+ return &MockConfigService_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)}
836+}
837+
838+func (_c *MockConfigService_RemoveAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockConfigService_RemoveAttachment_Call {
839+ _c.Call.Run(func(args mock.Arguments) {
840+ var arg0 context.Context
841+ if args[0] != nil {
842+ arg0 = args[0].(context.Context)
843+ }
844+ var arg1 string
845+ if args[1] != nil {
846+ arg1 = args[1].(string)
847+ }
848+ var arg2 string
849+ if args[2] != nil {
850+ arg2 = args[2].(string)
851+ }
852+ run(
853+ arg0,
854+ arg1,
855+ arg2,
856+ )
857+ })
858+ return _c
859+}
860+
861+func (_c *MockConfigService_RemoveAttachment_Call) Return(err error) *MockConfigService_RemoveAttachment_Call {
862+ _c.Call.Return(err)
863+ return _c
864+}
865+
866+func (_c *MockConfigService_RemoveAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockConfigService_RemoveAttachment_Call {
867+ _c.Call.Return(run)
868+ return _c
869+}
870+
624871 // RemoveRoute provides a mock function for the type MockConfigService
625872 func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error {
626873 ret := _mock.Called(ctx, domain1)
@@ -38,6 +38,69 @@ func (_m *MockConfigService) EXPECT() *MockConfigService_Expecter {
38 return &MockConfigService_Expecter{mock: &_m.Mock}38 return &MockConfigService_Expecter{mock: &_m.Mock}
39 }39 }
40 40
41+// AddAttachment provides a mock function for the type MockConfigService
42+func (_mock *MockConfigService) AddAttachment(ctx context.Context, domainOrGroup string, image string) error {
43+ ret := _mock.Called(ctx, domainOrGroup, image)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for AddAttachment")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
51+ r0 = returnFunc(ctx, domainOrGroup, image)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockConfigService_AddAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAttachment'
59+type MockConfigService_AddAttachment_Call struct {
60+ *mock.Call
61+}
62+
63+// AddAttachment is a helper method to define mock.On call
64+// - ctx context.Context
65+// - domainOrGroup string
66+// - image string
67+func (_e *MockConfigService_Expecter) AddAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockConfigService_AddAttachment_Call {
68+ return &MockConfigService_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)}
69+}
70+
71+func (_c *MockConfigService_AddAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockConfigService_AddAttachment_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ var arg0 context.Context
74+ if args[0] != nil {
75+ arg0 = args[0].(context.Context)
76+ }
77+ var arg1 string
78+ if args[1] != nil {
79+ arg1 = args[1].(string)
80+ }
81+ var arg2 string
82+ if args[2] != nil {
83+ arg2 = args[2].(string)
84+ }
85+ run(
86+ arg0,
87+ arg1,
88+ arg2,
89+ )
90+ })
91+ return _c
92+}
93+
94+func (_c *MockConfigService_AddAttachment_Call) Return(err error) *MockConfigService_AddAttachment_Call {
95+ _c.Call.Return(err)
96+ return _c
97+}
98+
99+func (_c *MockConfigService_AddAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockConfigService_AddAttachment_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
103+
41 // AddRoute provides a mock function for the type MockConfigService104 // AddRoute provides a mock function for the type MockConfigService
42 func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error {105 func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error {
43 ret := _mock.Called(ctx, route)106 ret := _mock.Called(ctx, route)
@@ -95,6 +158,127 @@ func (_c *MockConfigService_AddRoute_Call) RunAndReturn(run func(ctx context.Con
95 return _c158 return _c
96 }159 }
97 160
161+// GetAllAttachments provides a mock function for the type MockConfigService
162+func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string {
163+ ret := _mock.Called(ctx)
164+
165+ if len(ret) == 0 {
166+ panic("no return value specified for GetAllAttachments")
167+ }
168+
169+ var r0 map[string][]string
170+ if returnFunc, ok := ret.Get(0).(func(context.Context) map[string][]string); ok {
171+ r0 = returnFunc(ctx)
172+ } else {
173+ if ret.Get(0) != nil {
174+ r0 = ret.Get(0).(map[string][]string)
175+ }
176+ }
177+ return r0
178+}
179+
180+// MockConfigService_GetAllAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachments'
181+type MockConfigService_GetAllAttachments_Call struct {
182+ *mock.Call
183+}
184+
185+// GetAllAttachments is a helper method to define mock.On call
186+// - ctx context.Context
187+func (_e *MockConfigService_Expecter) GetAllAttachments(ctx interface{}) *MockConfigService_GetAllAttachments_Call {
188+ return &MockConfigService_GetAllAttachments_Call{Call: _e.mock.On("GetAllAttachments", ctx)}
189+}
190+
191+func (_c *MockConfigService_GetAllAttachments_Call) Run(run func(ctx context.Context)) *MockConfigService_GetAllAttachments_Call {
192+ _c.Call.Run(func(args mock.Arguments) {
193+ var arg0 context.Context
194+ if args[0] != nil {
195+ arg0 = args[0].(context.Context)
196+ }
197+ run(
198+ arg0,
199+ )
200+ })
201+ return _c
202+}
203+
204+func (_c *MockConfigService_GetAllAttachments_Call) Return(stringToStrings map[string][]string) *MockConfigService_GetAllAttachments_Call {
205+ _c.Call.Return(stringToStrings)
206+ return _c
207+}
208+
209+func (_c *MockConfigService_GetAllAttachments_Call) RunAndReturn(run func(ctx context.Context) map[string][]string) *MockConfigService_GetAllAttachments_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// GetAttachmentsFor provides a mock function for the type MockConfigService
215+func (_mock *MockConfigService) GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) {
216+ ret := _mock.Called(ctx, domainOrGroup)
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for GetAttachmentsFor")
220+ }
221+
222+ var r0 []string
223+ var r1 error
224+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok {
225+ return returnFunc(ctx, domainOrGroup)
226+ }
227+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
228+ r0 = returnFunc(ctx, domainOrGroup)
229+ } else {
230+ if ret.Get(0) != nil {
231+ r0 = ret.Get(0).([]string)
232+ }
233+ }
234+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
235+ r1 = returnFunc(ctx, domainOrGroup)
236+ } else {
237+ r1 = ret.Error(1)
238+ }
239+ return r0, r1
240+}
241+
242+// MockConfigService_GetAttachmentsFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachmentsFor'
243+type MockConfigService_GetAttachmentsFor_Call struct {
244+ *mock.Call
245+}
246+
247+// GetAttachmentsFor is a helper method to define mock.On call
248+// - ctx context.Context
249+// - domainOrGroup string
250+func (_e *MockConfigService_Expecter) GetAttachmentsFor(ctx interface{}, domainOrGroup interface{}) *MockConfigService_GetAttachmentsFor_Call {
251+ return &MockConfigService_GetAttachmentsFor_Call{Call: _e.mock.On("GetAttachmentsFor", ctx, domainOrGroup)}
252+}
253+
254+func (_c *MockConfigService_GetAttachmentsFor_Call) Run(run func(ctx context.Context, domainOrGroup string)) *MockConfigService_GetAttachmentsFor_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 string
261+ if args[1] != nil {
262+ arg1 = args[1].(string)
263+ }
264+ run(
265+ arg0,
266+ arg1,
267+ )
268+ })
269+ return _c
270+}
271+
272+func (_c *MockConfigService_GetAttachmentsFor_Call) Return(strings []string, err error) *MockConfigService_GetAttachmentsFor_Call {
273+ _c.Call.Return(strings, err)
274+ return _c
275+}
276+
277+func (_c *MockConfigService_GetAttachmentsFor_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string) ([]string, error)) *MockConfigService_GetAttachmentsFor_Call {
278+ _c.Call.Return(run)
279+ return _c
280+}
281+
98 // GetDataDir provides a mock function for the type MockConfigService282 // GetDataDir provides a mock function for the type MockConfigService
99 func (_mock *MockConfigService) GetDataDir() string {283 func (_mock *MockConfigService) GetDataDir() string {
100 ret := _mock.Called()284 ret := _mock.Called()
@@ -621,6 +805,69 @@ func (_c *MockConfigService_Load_Call) RunAndReturn(run func(ctx context.Context
621 return _c805 return _c
622 }806 }
623 807
808+// RemoveAttachment provides a mock function for the type MockConfigService
809+func (_mock *MockConfigService) RemoveAttachment(ctx context.Context, domainOrGroup string, image string) error {
810+ ret := _mock.Called(ctx, domainOrGroup, image)
811+
812+ if len(ret) == 0 {
813+ panic("no return value specified for RemoveAttachment")
814+ }
815+
816+ var r0 error
817+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
818+ r0 = returnFunc(ctx, domainOrGroup, image)
819+ } else {
820+ r0 = ret.Error(0)
821+ }
822+ return r0
823+}
824+
825+// MockConfigService_RemoveAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAttachment'
826+type MockConfigService_RemoveAttachment_Call struct {
827+ *mock.Call
828+}
829+
830+// RemoveAttachment is a helper method to define mock.On call
831+// - ctx context.Context
832+// - domainOrGroup string
833+// - image string
834+func (_e *MockConfigService_Expecter) RemoveAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockConfigService_RemoveAttachment_Call {
835+ return &MockConfigService_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)}
836+}
837+
838+func (_c *MockConfigService_RemoveAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockConfigService_RemoveAttachment_Call {
839+ _c.Call.Run(func(args mock.Arguments) {
840+ var arg0 context.Context
841+ if args[0] != nil {
842+ arg0 = args[0].(context.Context)
843+ }
844+ var arg1 string
845+ if args[1] != nil {
846+ arg1 = args[1].(string)
847+ }
848+ var arg2 string
849+ if args[2] != nil {
850+ arg2 = args[2].(string)
851+ }
852+ run(
853+ arg0,
854+ arg1,
855+ arg2,
856+ )
857+ })
858+ return _c
859+}
860+
861+func (_c *MockConfigService_RemoveAttachment_Call) Return(err error) *MockConfigService_RemoveAttachment_Call {
862+ _c.Call.Return(err)
863+ return _c
864+}
865+
866+func (_c *MockConfigService_RemoveAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockConfigService_RemoveAttachment_Call {
867+ _c.Call.Return(run)
868+ return _c
869+}
870+
624 // RemoveRoute provides a mock function for the type MockConfigService871 // RemoveRoute provides a mock function for the type MockConfigService
625 func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error {872 func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error {
626 ret := _mock.Called(ctx, domain1)873 ret := _mock.Called(ctx, domain1)
M internal/domain/errors.go +6 -0
@@ -62,4 +62,10 @@ var (
6262 ErrInvalidPath = errors.New("invalid path format")
6363 ErrInsufficientScope = errors.New("insufficient scope for operation")
6464 ErrInvalidScope = errors.New("invalid scope format")
65+
66+ // Attachment errors
67+ ErrAttachmentNotFound = errors.New("attachment not found")
68+ ErrAttachmentExists = errors.New("attachment already exists")
69+ ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty")
70+ ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty")
6571 )
@@ -62,4 +62,10 @@ var (
62 ErrInvalidPath = errors.New("invalid path format")62 ErrInvalidPath = errors.New("invalid path format")
63 ErrInsufficientScope = errors.New("insufficient scope for operation")63 ErrInsufficientScope = errors.New("insufficient scope for operation")
64 ErrInvalidScope = errors.New("invalid scope format")64 ErrInvalidScope = errors.New("invalid scope format")
65+
66+ // Attachment errors
67+ ErrAttachmentNotFound = errors.New("attachment not found")
68+ ErrAttachmentExists = errors.New("attachment already exists")
69+ ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty")
70+ ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty")
65 )71 )
M internal/testutils/helpers.go +3 -2
@@ -44,8 +44,9 @@ registry_port = 5000
4444 registry_domain = "registry.example.com"
4545 runtime = "docker"
4646
47-[registry_auth]
47+[auth]
4848 enabled = true
49+secrets_backend = "unsafe"
4950 username = "admin"
5051 password = "password123"
5152
@@ -72,7 +73,7 @@ level = "info"`
7273 registry_domain = "registry.example.com"
7374 runtime = "docker"
7475
75-[registry_auth]
76+[auth]
7677 enabled = false`
7778 case "invalid.toml":
7879 return `[server
@@ -44,8 +44,9 @@ registry_port = 5000
44 registry_domain = "registry.example.com"44 registry_domain = "registry.example.com"
45 runtime = "docker"45 runtime = "docker"
46 46
47-[registry_auth]47+[auth]
48 enabled = true48 enabled = true
49+secrets_backend = "unsafe"
49 username = "admin"50 username = "admin"
50 password = "password123"51 password = "password123"
51 52
@@ -72,7 +73,7 @@ level = "info"`
72 registry_domain = "registry.example.com"73 registry_domain = "registry.example.com"
73 runtime = "docker"74 runtime = "docker"
74 75
75-[registry_auth]76+[auth]
76 enabled = false`77 enabled = false`
77 case "invalid.toml":78 case "invalid.toml":
78 return `[server79 return `[server
M internal/usecase/config/service.go +156 -4
@@ -101,9 +101,9 @@ func (s *Service) loadConfigValues() Config {
101101 AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
102102 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
103103 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
104- RegistryAuthEnabled: s.viper.GetBool("registry_auth.enabled"),
105- RegistryAuthUsername: s.viper.GetString("registry_auth.username"),
106- RegistryAuthPassword: s.viper.GetString("registry_auth.password"),
104+ RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
105+ RegistryAuthUsername: s.viper.GetString("auth.username"),
106+ RegistryAuthPassword: s.viper.GetString("auth.password"),
107107 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
108108 VolumePrefix: s.viper.GetString("volumes.prefix"),
109109 VolumePreserve: s.viper.GetBool("volumes.preserve"),
@@ -333,8 +333,9 @@ func (s *Service) Save(ctx context.Context) error {
333333 s.mu.Lock()
334334 defer s.mu.Unlock()
335335
336- // Update viper with current routes
336+ // Update viper with current config
337337 s.viper.Set("routes", s.config.Routes)
338+ s.viper.Set("attachments", s.config.Attachments)
338339
339340 // Record save time to debounce file watcher events
340341 atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
@@ -475,6 +476,7 @@ func (s *Service) GetNetworkGroups() map[string][]string {
475476 }
476477
477478 // GetAttachments returns attachment configuration.
479+// Deprecated: Use GetAllAttachments instead.
478480 func (s *Service) GetAttachments() map[string][]string {
479481 s.mu.RLock()
480482 defer s.mu.RUnlock()
@@ -486,6 +488,156 @@ func (s *Service) GetAttachments() map[string][]string {
486488 return result
487489 }
488490
491+// GetAllAttachments returns all configured attachments.
492+func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
493+ s.mu.RLock()
494+ defer s.mu.RUnlock()
495+
496+ result := make(map[string][]string)
497+ for k, v := range s.config.Attachments {
498+ result[k] = append([]string{}, v...)
499+ }
500+ return result
501+}
502+
503+// GetAttachmentsFor returns attachments for a specific domain or network group.
504+func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
505+ s.mu.RLock()
506+ defer s.mu.RUnlock()
507+
508+ images, exists := s.config.Attachments[domainOrGroup]
509+ if !exists {
510+ return nil, domain.ErrAttachmentNotFound
511+ }
512+
513+ return append([]string{}, images...), nil
514+}
515+
516+// AddAttachment adds an image to a domain/group's attachments.
517+func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
518+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
519+ zerowrap.FieldLayer: "usecase",
520+ zerowrap.FieldUseCase: "AddAttachment",
521+ "target": domainOrGroup,
522+ "image": image,
523+ })
524+ log := zerowrap.FromCtx(ctx)
525+
526+ // Validate input
527+ if domainOrGroup == "" {
528+ return domain.ErrAttachmentTargetEmpty
529+ }
530+ if image == "" {
531+ return domain.ErrAttachmentImageEmpty
532+ }
533+
534+ s.mu.Lock()
535+
536+ // Initialize map if needed
537+ if s.config.Attachments == nil {
538+ s.config.Attachments = make(map[string][]string)
539+ }
540+
541+ // Check if already exists
542+ existing := s.config.Attachments[domainOrGroup]
543+ for _, img := range existing {
544+ if img == image {
545+ s.mu.Unlock()
546+ return domain.ErrAttachmentExists
547+ }
548+ }
549+
550+ // Store previous value for rollback
551+ previousImages := append([]string{}, existing...)
552+ hadKey := len(existing) > 0
553+
554+ // Add the image
555+ s.config.Attachments[domainOrGroup] = append(existing, image)
556+ s.mu.Unlock()
557+
558+ // Persist to disk - rollback on failure
559+ if err := s.Save(ctx); err != nil {
560+ log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
561+ s.mu.Lock()
562+ if hadKey {
563+ s.config.Attachments[domainOrGroup] = previousImages
564+ } else {
565+ delete(s.config.Attachments, domainOrGroup)
566+ }
567+ s.mu.Unlock()
568+ return err
569+ }
570+
571+ log.Info().Msg("attachment added to configuration")
572+ return nil
573+}
574+
575+// RemoveAttachment removes an image from a domain/group's attachments.
576+func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
577+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
578+ zerowrap.FieldLayer: "usecase",
579+ zerowrap.FieldUseCase: "RemoveAttachment",
580+ "target": domainOrGroup,
581+ "image": image,
582+ })
583+ log := zerowrap.FromCtx(ctx)
584+
585+ // Validate input
586+ if domainOrGroup == "" {
587+ return domain.ErrAttachmentTargetEmpty
588+ }
589+ if image == "" {
590+ return domain.ErrAttachmentImageEmpty
591+ }
592+
593+ s.mu.Lock()
594+
595+ existing, exists := s.config.Attachments[domainOrGroup]
596+ if !exists {
597+ s.mu.Unlock()
598+ return domain.ErrAttachmentNotFound
599+ }
600+
601+ // Find and remove the image
602+ found := false
603+ newImages := make([]string, 0, len(existing))
604+ for _, img := range existing {
605+ if img == image {
606+ found = true
607+ } else {
608+ newImages = append(newImages, img)
609+ }
610+ }
611+
612+ if !found {
613+ s.mu.Unlock()
614+ return domain.ErrAttachmentNotFound
615+ }
616+
617+ // Store previous value for rollback
618+ previousImages := existing
619+
620+ // Update or remove the key
621+ if len(newImages) == 0 {
622+ delete(s.config.Attachments, domainOrGroup)
623+ } else {
624+ s.config.Attachments[domainOrGroup] = newImages
625+ }
626+ s.mu.Unlock()
627+
628+ // Persist to disk - rollback on failure
629+ if err := s.Save(ctx); err != nil {
630+ log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
631+ s.mu.Lock()
632+ s.config.Attachments[domainOrGroup] = previousImages
633+ s.mu.Unlock()
634+ return err
635+ }
636+
637+ log.Info().Msg("attachment removed from configuration")
638+ return nil
639+}
640+
489641 // GetExternalRoutes returns all configured external routes.
490642 func (s *Service) GetExternalRoutes() map[string]string {
491643 s.mu.RLock()
@@ -101,9 +101,9 @@ func (s *Service) loadConfigValues() Config {
101 AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),101 AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
102 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),102 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
103 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),103 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
104- RegistryAuthEnabled: s.viper.GetBool("registry_auth.enabled"),104+ RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
105- RegistryAuthUsername: s.viper.GetString("registry_auth.username"),105+ RegistryAuthUsername: s.viper.GetString("auth.username"),
106- RegistryAuthPassword: s.viper.GetString("registry_auth.password"),106+ RegistryAuthPassword: s.viper.GetString("auth.password"),
107 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),107 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
108 VolumePrefix: s.viper.GetString("volumes.prefix"),108 VolumePrefix: s.viper.GetString("volumes.prefix"),
109 VolumePreserve: s.viper.GetBool("volumes.preserve"),109 VolumePreserve: s.viper.GetBool("volumes.preserve"),
@@ -333,8 +333,9 @@ func (s *Service) Save(ctx context.Context) error {
333 s.mu.Lock()333 s.mu.Lock()
334 defer s.mu.Unlock()334 defer s.mu.Unlock()
335 335
336- // Update viper with current routes336+ // Update viper with current config
337 s.viper.Set("routes", s.config.Routes)337 s.viper.Set("routes", s.config.Routes)
338+ s.viper.Set("attachments", s.config.Attachments)
338 339
339 // Record save time to debounce file watcher events340 // Record save time to debounce file watcher events
340 atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())341 atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
@@ -475,6 +476,7 @@ func (s *Service) GetNetworkGroups() map[string][]string {
475 }476 }
476 477
477 // GetAttachments returns attachment configuration.478 // GetAttachments returns attachment configuration.
479+// Deprecated: Use GetAllAttachments instead.
478 func (s *Service) GetAttachments() map[string][]string {480 func (s *Service) GetAttachments() map[string][]string {
479 s.mu.RLock()481 s.mu.RLock()
480 defer s.mu.RUnlock()482 defer s.mu.RUnlock()
@@ -486,6 +488,156 @@ func (s *Service) GetAttachments() map[string][]string {
486 return result488 return result
487 }489 }
488 490
491+// GetAllAttachments returns all configured attachments.
492+func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
493+ s.mu.RLock()
494+ defer s.mu.RUnlock()
495+
496+ result := make(map[string][]string)
497+ for k, v := range s.config.Attachments {
498+ result[k] = append([]string{}, v...)
499+ }
500+ return result
501+}
502+
503+// GetAttachmentsFor returns attachments for a specific domain or network group.
504+func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
505+ s.mu.RLock()
506+ defer s.mu.RUnlock()
507+
508+ images, exists := s.config.Attachments[domainOrGroup]
509+ if !exists {
510+ return nil, domain.ErrAttachmentNotFound
511+ }
512+
513+ return append([]string{}, images...), nil
514+}
515+
516+// AddAttachment adds an image to a domain/group's attachments.
517+func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
518+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
519+ zerowrap.FieldLayer: "usecase",
520+ zerowrap.FieldUseCase: "AddAttachment",
521+ "target": domainOrGroup,
522+ "image": image,
523+ })
524+ log := zerowrap.FromCtx(ctx)
525+
526+ // Validate input
527+ if domainOrGroup == "" {
528+ return domain.ErrAttachmentTargetEmpty
529+ }
530+ if image == "" {
531+ return domain.ErrAttachmentImageEmpty
532+ }
533+
534+ s.mu.Lock()
535+
536+ // Initialize map if needed
537+ if s.config.Attachments == nil {
538+ s.config.Attachments = make(map[string][]string)
539+ }
540+
541+ // Check if already exists
542+ existing := s.config.Attachments[domainOrGroup]
543+ for _, img := range existing {
544+ if img == image {
545+ s.mu.Unlock()
546+ return domain.ErrAttachmentExists
547+ }
548+ }
549+
550+ // Store previous value for rollback
551+ previousImages := append([]string{}, existing...)
552+ hadKey := len(existing) > 0
553+
554+ // Add the image
555+ s.config.Attachments[domainOrGroup] = append(existing, image)
556+ s.mu.Unlock()
557+
558+ // Persist to disk - rollback on failure
559+ if err := s.Save(ctx); err != nil {
560+ log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
561+ s.mu.Lock()
562+ if hadKey {
563+ s.config.Attachments[domainOrGroup] = previousImages
564+ } else {
565+ delete(s.config.Attachments, domainOrGroup)
566+ }
567+ s.mu.Unlock()
568+ return err
569+ }
570+
571+ log.Info().Msg("attachment added to configuration")
572+ return nil
573+}
574+
575+// RemoveAttachment removes an image from a domain/group's attachments.
576+func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
577+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
578+ zerowrap.FieldLayer: "usecase",
579+ zerowrap.FieldUseCase: "RemoveAttachment",
580+ "target": domainOrGroup,
581+ "image": image,
582+ })
583+ log := zerowrap.FromCtx(ctx)
584+
585+ // Validate input
586+ if domainOrGroup == "" {
587+ return domain.ErrAttachmentTargetEmpty
588+ }
589+ if image == "" {
590+ return domain.ErrAttachmentImageEmpty
591+ }
592+
593+ s.mu.Lock()
594+
595+ existing, exists := s.config.Attachments[domainOrGroup]
596+ if !exists {
597+ s.mu.Unlock()
598+ return domain.ErrAttachmentNotFound
599+ }
600+
601+ // Find and remove the image
602+ found := false
603+ newImages := make([]string, 0, len(existing))
604+ for _, img := range existing {
605+ if img == image {
606+ found = true
607+ } else {
608+ newImages = append(newImages, img)
609+ }
610+ }
611+
612+ if !found {
613+ s.mu.Unlock()
614+ return domain.ErrAttachmentNotFound
615+ }
616+
617+ // Store previous value for rollback
618+ previousImages := existing
619+
620+ // Update or remove the key
621+ if len(newImages) == 0 {
622+ delete(s.config.Attachments, domainOrGroup)
623+ } else {
624+ s.config.Attachments[domainOrGroup] = newImages
625+ }
626+ s.mu.Unlock()
627+
628+ // Persist to disk - rollback on failure
629+ if err := s.Save(ctx); err != nil {
630+ log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
631+ s.mu.Lock()
632+ s.config.Attachments[domainOrGroup] = previousImages
633+ s.mu.Unlock()
634+ return err
635+ }
636+
637+ log.Info().Msg("attachment removed from configuration")
638+ return nil
639+}
640+
489 // GetExternalRoutes returns all configured external routes.641 // GetExternalRoutes returns all configured external routes.
490 func (s *Service) GetExternalRoutes() map[string]string {642 func (s *Service) GetExternalRoutes() map[string]string {
491 s.mu.RLock()643 s.mu.RLock()
M internal/usecase/config/service_test.go +3 -3
@@ -28,9 +28,9 @@ func TestService_Load(t *testing.T) {
2828 v.Set("auto_route.enabled", true)
2929 v.Set("network_isolation.enabled", true)
3030 v.Set("network_isolation.network_prefix", "gordon")
31- v.Set("registry_auth.enabled", true)
32- v.Set("registry_auth.username", "admin")
33- v.Set("registry_auth.password", "secret")
31+ v.Set("auth.enabled", true)
32+ v.Set("auth.username", "admin")
33+ v.Set("auth.password", "secret")
3434 v.Set("volumes.auto_create", true)
3535 v.Set("volumes.prefix", "gordon")
3636 v.Set("volumes.preserve", false)
@@ -28,9 +28,9 @@ func TestService_Load(t *testing.T) {
28 v.Set("auto_route.enabled", true)28 v.Set("auto_route.enabled", true)
29 v.Set("network_isolation.enabled", true)29 v.Set("network_isolation.enabled", true)
30 v.Set("network_isolation.network_prefix", "gordon")30 v.Set("network_isolation.network_prefix", "gordon")
31- v.Set("registry_auth.enabled", true)31+ v.Set("auth.enabled", true)
32- v.Set("registry_auth.username", "admin")32+ v.Set("auth.username", "admin")
33- v.Set("registry_auth.password", "secret")33+ v.Set("auth.password", "secret")
34 v.Set("volumes.auto_create", true)34 v.Set("volumes.auto_create", true)
35 v.Set("volumes.prefix", "gordon")35 v.Set("volumes.prefix", "gordon")
36 v.Set("volumes.preserve", false)36 v.Set("volumes.preserve", false)
M wiki/examples/production.md +5 -8
@@ -19,15 +19,12 @@ port = 8080 # Cloudflare forwards 443 → 8080
1919 registry_port = 5000
2020 gordon_domain = "gordon.company.com"
2121
22-# Use pass for secrets (recommended)
23-[secrets]
24-backend = "pass"
25-
26-# Token authentication for CI/CD
27-[registry_auth]
22+# Authentication with pass backend (recommended)
23+[auth]
2824 enabled = true
2925 type = "token"
30-token_secret = "gordon/registry/token_secret"
26+secrets_backend = "pass"
27+token_secret = "gordon/auth/token_secret"
3128
3229 # File-based logging with rotation
3330 [logging]
@@ -96,7 +93,7 @@ pass init your-gpg-key-id
9693
9794 ```bash
9895 # Generate random secret
99-openssl rand -base64 32 | pass insert -m gordon/registry/token_secret
96+openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
10097 ```
10198
10299 ### 3. Generate CI Token
@@ -19,15 +19,12 @@ port = 8080 # Cloudflare forwards 443 → 8080
19 registry_port = 500019 registry_port = 5000
20 gordon_domain = "gordon.company.com"20 gordon_domain = "gordon.company.com"
21 21
22-# Use pass for secrets (recommended)22+# Authentication with pass backend (recommended)
23-[secrets]23+[auth]
24-backend = "pass"
25-
26-# Token authentication for CI/CD
27-[registry_auth]
28 enabled = true24 enabled = true
29 type = "token"25 type = "token"
30-token_secret = "gordon/registry/token_secret"26+secrets_backend = "pass"
27+token_secret = "gordon/auth/token_secret"
31 28
32 # File-based logging with rotation29 # File-based logging with rotation
33 [logging]30 [logging]
@@ -96,7 +93,7 @@ pass init your-gpg-key-id
96 93
97 ```bash94 ```bash
98 # Generate random secret95 # Generate random secret
99-openssl rand -base64 32 | pass insert -m gordon/registry/token_secret96+openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
100 ```97 ```
101 98
102 ### 3. Generate CI Token99 ### 3. Generate CI Token
M wiki/guides/remote-cli.md +5 -2
@@ -206,6 +206,9 @@ These commands work with remote targeting:
206206 | `gordon routes add <domain> <image>` | Add a route |
207207 | `gordon routes remove <domain>` | Remove a route |
208208 | `gordon routes deploy <domain>` | Deploy/redeploy a route |
209+| `gordon attachments list [target]` | List all or targeted attachments |
210+| `gordon attachments add <target> <image>` | Add an attachment |
211+| `gordon attachments remove <target> <image>` | Remove an attachment |
209212 | `gordon secrets list <domain>` | List secrets for a domain |
210213 | `gordon secrets set <domain> KEY=value` | Set secrets |
211214 | `gordon secrets remove <domain> <key>` | Remove a secret |
@@ -433,5 +436,5 @@ See [Remote Logs Requirement](#remote-logs-requirement) for details.
433436 ## Related
434437
435438 - [CLI Commands](/docs/cli/commands.md)
436-- [Authentication](/docs/config/registry-auth.md)
437-- [Admin API](/docs/config/admin.md)
439+- [Authentication](/docs/config/auth.md)
440+- [Attachments](/docs/config/attachments.md)
@@ -206,6 +206,9 @@ These commands work with remote targeting:
206 | `gordon routes add <domain> <image>` | Add a route |206 | `gordon routes add <domain> <image>` | Add a route |
207 | `gordon routes remove <domain>` | Remove a route |207 | `gordon routes remove <domain>` | Remove a route |
208 | `gordon routes deploy <domain>` | Deploy/redeploy a route |208 | `gordon routes deploy <domain>` | Deploy/redeploy a route |
209+| `gordon attachments list [target]` | List all or targeted attachments |
210+| `gordon attachments add <target> <image>` | Add an attachment |
211+| `gordon attachments remove <target> <image>` | Remove an attachment |
209 | `gordon secrets list <domain>` | List secrets for a domain |212 | `gordon secrets list <domain>` | List secrets for a domain |
210 | `gordon secrets set <domain> KEY=value` | Set secrets |213 | `gordon secrets set <domain> KEY=value` | Set secrets |
211 | `gordon secrets remove <domain> <key>` | Remove a secret |214 | `gordon secrets remove <domain> <key>` | Remove a secret |
@@ -433,5 +436,5 @@ See [Remote Logs Requirement](#remote-logs-requirement) for details.
433 ## Related436 ## Related
434 437
435 - [CLI Commands](/docs/cli/commands.md)438 - [CLI Commands](/docs/cli/commands.md)
436-- [Authentication](/docs/config/registry-auth.md)439+- [Authentication](/docs/config/auth.md)
437-- [Admin API](/docs/config/admin.md)440+- [Attachments](/docs/config/attachments.md)
M wiki/guides/secrets-pass.md +8 -10
@@ -86,10 +86,10 @@ This creates `~/.password-store/`.
8686 ```bash
8787 # Registry password hash (bcrypt)
8888 # Generate with: htpasswd -nbB admin yourpassword | cut -d: -f2
89-pass insert gordon/registry/password_hash
89+pass insert gordon/auth/password_hash
9090
9191 # Token secret (random 32+ chars for JWT signing)
92-pass generate gordon/registry/token_secret 32
92+pass generate gordon/auth/token_secret 32
9393 ```
9494
9595 ## Gordon Configuration
@@ -97,15 +97,13 @@ pass generate gordon/registry/token_secret 32
9797 ### Enable Pass Backend
9898
9999 ```toml
100-[secrets]
101-backend = "pass"
102-
103-[registry_auth]
100+[auth]
104101 enabled = true
105102 type = "token"
103+secrets_backend = "pass"
106104 username = "admin"
107-password_hash = "gordon/registry/password_hash"
108-token_secret = "gordon/registry/token_secret"
105+password_hash = "gordon/auth/password_hash"
106+token_secret = "gordon/auth/token_secret"
109107 ```
110108
111109 ### Using Secrets in Environment Files
@@ -126,7 +124,7 @@ Recommended directory structure in pass:
126124 ```
127125 ~/.password-store/
128126 ├── gordon/
129-│ └── registry/
127+│ └── auth/
130128 │ ├── password_hash.gpg
131129 │ └── token_secret.gpg
132130 ├── myapp/
@@ -286,6 +284,6 @@ chmod 700 ~/.password-store
286284 ## Related
287285
288286 - [Secrets Configuration](/docs/config/secrets.md)
289-- [Registry Authentication](/docs/config/registry-auth.md)
287+- [Authentication](/docs/config/auth.md)
290288 - [Running in Containers](/wiki/guides/running-in-container.md)
291289 - [Using SOPS for Secrets](./secrets-sops.md)
@@ -86,10 +86,10 @@ This creates `~/.password-store/`.
86 ```bash86 ```bash
87 # Registry password hash (bcrypt)87 # Registry password hash (bcrypt)
88 # Generate with: htpasswd -nbB admin yourpassword | cut -d: -f288 # Generate with: htpasswd -nbB admin yourpassword | cut -d: -f2
89-pass insert gordon/registry/password_hash89+pass insert gordon/auth/password_hash
90 90
91 # Token secret (random 32+ chars for JWT signing)91 # Token secret (random 32+ chars for JWT signing)
92-pass generate gordon/registry/token_secret 3292+pass generate gordon/auth/token_secret 32
93 ```93 ```
94 94
95 ## Gordon Configuration95 ## Gordon Configuration
@@ -97,15 +97,13 @@ pass generate gordon/registry/token_secret 32
97 ### Enable Pass Backend97 ### Enable Pass Backend
98 98
99 ```toml99 ```toml
100-[secrets]100+[auth]
101-backend = "pass"
102-
103-[registry_auth]
104 enabled = true101 enabled = true
105 type = "token"102 type = "token"
103+secrets_backend = "pass"
106 username = "admin"104 username = "admin"
107-password_hash = "gordon/registry/password_hash"105+password_hash = "gordon/auth/password_hash"
108-token_secret = "gordon/registry/token_secret"106+token_secret = "gordon/auth/token_secret"
109 ```107 ```
110 108
111 ### Using Secrets in Environment Files109 ### Using Secrets in Environment Files
@@ -126,7 +124,7 @@ Recommended directory structure in pass:
126 ```124 ```
127 ~/.password-store/125 ~/.password-store/
128 ├── gordon/126 ├── gordon/
129-│ └── registry/127+│ └── auth/
130 │ ├── password_hash.gpg128 │ ├── password_hash.gpg
131 │ └── token_secret.gpg129 │ └── token_secret.gpg
132 ├── myapp/130 ├── myapp/
@@ -286,6 +284,6 @@ chmod 700 ~/.password-store
286 ## Related284 ## Related
287 285
288 - [Secrets Configuration](/docs/config/secrets.md)286 - [Secrets Configuration](/docs/config/secrets.md)
289-- [Registry Authentication](/docs/config/registry-auth.md)287+- [Authentication](/docs/config/auth.md)
290 - [Running in Containers](/wiki/guides/running-in-container.md)288 - [Running in Containers](/wiki/guides/running-in-container.md)
291 - [Using SOPS for Secrets](./secrets-sops.md)289 - [Using SOPS for Secrets](./secrets-sops.md)
M wiki/guides/secrets-sops.md +7 -9
@@ -136,7 +136,7 @@ sops edit secrets.yaml
136136 This opens your editor. Add secrets in YAML format:
137137
138138 ```yaml
139-registry:
139+auth:
140140 password_hash: "$2y$10$..."
141141 token_secret: "your-random-32-char-string-here"
142142 database:
@@ -156,7 +156,7 @@ cat secrets.yaml
156156 Output shows encrypted values:
157157
158158 ```yaml
159-registry:
159+auth:
160160 password_hash: ENC[AES256_GCM,data:...,iv:...,tag:...]
161161 token_secret: ENC[AES256_GCM,data:...,iv:...,tag:...]
162162 database:
@@ -195,15 +195,13 @@ sops edit secrets.yaml
195195 ### Enable SOPS Backend
196196
197197 ```toml
198-[secrets]
199-backend = "sops"
200-
201-[registry_auth]
198+[auth]
202199 enabled = true
203200 type = "token"
201+secrets_backend = "sops"
204202 username = "admin"
205-password_hash = "secrets.yaml:registry.password_hash"
206-token_secret = "secrets.yaml:registry.token_secret"
203+password_hash = "secrets.yaml:auth.password_hash"
204+token_secret = "secrets.yaml:auth.token_secret"
207205 ```
208206
209207 The path format is `file:key.path` where:
@@ -424,6 +422,6 @@ ls -la ~/.config/sops/age/keys.txt
424422 ## Related
425423
426424 - [Secrets Configuration](/docs/config/secrets.md)
427-- [Registry Authentication](/docs/config/registry-auth.md)
425+- [Authentication](/docs/config/auth.md)
428426 - [Running in Containers](/wiki/guides/running-in-container.md)
429427 - [Using Pass for Secrets](./secrets-pass.md)
@@ -136,7 +136,7 @@ sops edit secrets.yaml
136 This opens your editor. Add secrets in YAML format:136 This opens your editor. Add secrets in YAML format:
137 137
138 ```yaml138 ```yaml
139-registry:139+auth:
140 password_hash: "$2y$10$..."140 password_hash: "$2y$10$..."
141 token_secret: "your-random-32-char-string-here"141 token_secret: "your-random-32-char-string-here"
142 database:142 database:
@@ -156,7 +156,7 @@ cat secrets.yaml
156 Output shows encrypted values:156 Output shows encrypted values:
157 157
158 ```yaml158 ```yaml
159-registry:159+auth:
160 password_hash: ENC[AES256_GCM,data:...,iv:...,tag:...]160 password_hash: ENC[AES256_GCM,data:...,iv:...,tag:...]
161 token_secret: ENC[AES256_GCM,data:...,iv:...,tag:...]161 token_secret: ENC[AES256_GCM,data:...,iv:...,tag:...]
162 database:162 database:
@@ -195,15 +195,13 @@ sops edit secrets.yaml
195 ### Enable SOPS Backend195 ### Enable SOPS Backend
196 196
197 ```toml197 ```toml
198-[secrets]198+[auth]
199-backend = "sops"
200-
201-[registry_auth]
202 enabled = true199 enabled = true
203 type = "token"200 type = "token"
201+secrets_backend = "sops"
204 username = "admin"202 username = "admin"
205-password_hash = "secrets.yaml:registry.password_hash"203+password_hash = "secrets.yaml:auth.password_hash"
206-token_secret = "secrets.yaml:registry.token_secret"204+token_secret = "secrets.yaml:auth.token_secret"
207 ```205 ```
208 206
209 The path format is `file:key.path` where:207 The path format is `file:key.path` where:
@@ -424,6 +422,6 @@ ls -la ~/.config/sops/age/keys.txt
424 ## Related422 ## Related
425 423
426 - [Secrets Configuration](/docs/config/secrets.md)424 - [Secrets Configuration](/docs/config/secrets.md)
427-- [Registry Authentication](/docs/config/registry-auth.md)425+- [Authentication](/docs/config/auth.md)
428 - [Running in Containers](/wiki/guides/running-in-container.md)426 - [Running in Containers](/wiki/guides/running-in-container.md)
429 - [Using Pass for Secrets](./secrets-pass.md)427 - [Using Pass for Secrets](./secrets-pass.md)

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