detached a28bceb

feat: security hardening — auto-route domain allowlist, pass token storage, blob upload locking (#125)

* fix: add per-upload mutex locking to BlobStorage to prevent finalization race

* feat: add pass helper functions for remote token storage

* feat: add auto-route allowed domains to ConfigService boundary and implementation

* feat: add domain allowlist matching and repo name extraction helpers

* feat: add admin API endpoints and CLI commands for auto-route domain allowlist

* fix: integrate pass token helpers into auth flow and fix staticcheck lint

* fix: address code review findings (TOCTOU race, error logging, DELETE path params, lock cleanup)

* fix: correct path parsing, slice mutation, and upload locking bugs

- Fix handleAutoRouteAllowedDomainsDelete trimming only leading slash instead
  of the full /autoroute/allowed-domains/ prefix, passing wrong value downstream
- Fix RemoveAutoRouteAllowedDomain corrupting rollback snapshot by taking the
  copy before in-place filtering and using a new backing array
- Fix GetBlobUpload race condition by acquiring the per-upload mutex and
  returning a lockedWriteCloser that releases it on Close

* docs: align CLI docs with security-hardening features

Add autoroute allow command docs, document pass token storage in
remotes and auth, and add allowed_domains to config output example.

* fix: make lockedWriteCloser.Close idempotent and add zerowrap logging to allowlist methods

Wrap Close in sync.Once to prevent panic on double-close of the
upload mutex. Add structured logging to AddAutoRouteAllowedDomain
and RemoveAutoRouteAllowedDomain consistent with peer methods.

* fix: idempotent lockedWriteCloser.Close, zerowrap logging for allowlist, and doc wording

Make lockedWriteCloser.Close safe for repeated calls via sync.Once.
Add structured zerowrap logging to Add/RemoveAutoRouteAllowedDomain.
Clarify autoroute.md intro to cover both image-name auto-deploy and
label-based route creation, and explain why allowlist applies only to
the latter.

* fix: address code review findings across CLI, handler, and usecase layers

Extract autoroute_allow runner functions per project convention. Add
AutoRouteStatusResponse DTO and URL-decode DELETE path in admin handler.
Validate empty patterns in remote client. Make UpdateRemoteToken error
consistently on missing remote. Add pass_test edge cases and clarify
error message. Remove unreachable wildcard check in validateDomainPattern.
Support bare * as universal allowlist match. Call Load before validation
test. Show both pass and fallback outputs in auth docs.

* fix: harden handler error responses, blob lock cleanup, and domain pattern validation

Add ErrInvalidDomainPattern sentinel and wrap validation errors so the
admin handler returns 400 for bad patterns and 500 for internal failures.
Fix DELETE path parsing to reject collection-level requests. Clean up
per-upload lock entries on 'upload not found' paths to prevent map growth.
Persist lockedWriteCloser.Close error on struct for consistent repeated
returns. Allow bare '*' as universal wildcard in validateDomainPattern.
Fix remaining AttachmentStatusResponse misuse in POST handler. Add text
language identifiers to auth.md output fences and document bare '*'
wildcard in autoroute.md.

* refactor: use sentinel errors for remote name validation and pass availability

Add ErrEmptyRemoteName, ErrInvalidRemoteNameChar, ErrConsecutiveDots,
and ErrPassUnavailable to domain/errors.go. Update validateRemoteName
and pass helpers to return sentinels instead of ad-hoc strings, enabling
upstream errors.Is checks.

* fix: address final CodeRabbit review findings

Fix leaked AutoRouteStatusResponse in attachments handler. Return nil
from UpdateRemoteToken when remote missing after successful pass storage.
Add --force to pass insert to prevent hanging on existing entries.
Clean up upload lock in CancelBlobUpload not-found path for consistency.
Use errors.Is in allowlist validation test. Add wildcard matching comment.

* refactor: hoist GetAutoRouteAllowedDomains to processRoutes

Load the domain allowlist once per event instead of per-domain,
avoiding redundant calls when multiple domains are in the labels.

* fix: fall through on empty TokenEnv value and add text fences to autoroute docs

Prevent empty GORDON_TOKEN_ENV env var from blocking pass/remote
fallbacks in resolveToken. Add text language tags to autoroute.md
output fences for MD040 compliance.
commit
a28bceb8fc58a40dcf7e134198262f9360d790fd
author
bnema · · 2026-03-16 19:24 UTC
committer
GitHub · · 2026-03-16 19:24 UTC
parent
a226908
signature
unsigned
29 files · +1658 -61
M docs/cli/auth.md +16 -1
@@ -36,7 +36,7 @@ gordon auth login [options]
3636
3737 ### Description
3838
39-This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token in your remotes configuration.
39+This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning.
4040
4141 If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis.
4242
@@ -62,11 +62,26 @@ gordon auth login --token <token>
6262
6363 ### Output
6464
65+When `pass` is available:
66+
67+```text
68+Username: admin
69+Password: ********
70+Authenticating with prod...
71+
72+✓ Authentication successful!
73+Token stored in pass for remote 'prod'
74+Expires in: 604800 seconds
6575 ```
76+
77+When `pass` is not available:
78+
79+```text
6680 Username: admin
6781 Password: ********
6882 Authenticating with prod...
6983
84+Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.
7085 ✓ Authentication successful!
7186 Token stored for remote 'prod'
7287 Expires in: 604800 seconds
@@ -36,7 +36,7 @@ gordon auth login [options]
36 36
37 ### Description37 ### Description
38 38
39-This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token in your remotes configuration.39+This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning.
40 40
41 If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis.41 If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis.
42 42
@@ -62,11 +62,26 @@ gordon auth login --token <token>
62 62
63 ### Output63 ### Output
64 64
65+When `pass` is available:
66+
67+```text
68+Username: admin
69+Password: ********
70+Authenticating with prod...
71+
72+✓ Authentication successful!
73+Token stored in pass for remote 'prod'
74+Expires in: 604800 seconds
65 ```75 ```
76+
77+When `pass` is not available:
78+
79+```text
66 Username: admin80 Username: admin
67 Password: ********81 Password: ********
68 Authenticating with prod...82 Authenticating with prod...
69 83
84+Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.
70 ✓ Authentication successful!85 ✓ Authentication successful!
71 Token stored for remote 'prod'86 Token stored for remote 'prod'
72 Expires in: 604800 seconds87 Expires in: 604800 seconds
A docs/cli/autoroute.md +205 -0
@@ -0,0 +1,205 @@
1+# Auto-Route Commands
2+
3+Manage the auto-route domain allowlist on local or remote Gordon instances.
4+
5+When auto-route is enabled, Gordon handles image pushes in two ways:
6+
7+1. **Image-name matching** — when a pushed image matches an existing route's configured image, Gordon auto-deploys that route. Since the route already exists, no allowlist check is needed.
8+2. **Label-based creation** — when a pushed image carries `gordon.domain` labels, Gordon creates new routes for those domains. The domain allowlist restricts which domains may be created this way, preventing untrusted images from registering arbitrary domains.
9+
10+The allowlist gates only the creation of new routes from labels. Routes created manually with `gordon routes add` or already present in configuration are not subject to the allowlist.
11+
12+Remote targeting uses client config or an active remote by default.
13+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
14+
15+## gordon autoroute
16+
17+Manage auto-route settings.
18+
19+### Subcommands
20+
21+| Subcommand | Description |
22+|------------|-------------|
23+| `allow` | Manage auto-route allowed domains |
24+
25+---
26+
27+## gordon autoroute allow
28+
29+Manage auto-route allowed domains.
30+
31+### Subcommands
32+
33+| Subcommand | Description |
34+|------------|-------------|
35+| `add` | Add a domain pattern to the allowlist |
36+| `list` | List allowed domains |
37+| `remove` | Remove a domain pattern |
38+
39+---
40+
41+## gordon autoroute allow add
42+
43+Add a domain pattern to the auto-route allowlist.
44+
45+```bash
46+gordon autoroute allow add <pattern>
47+gordon autoroute allow add example.com
48+gordon autoroute allow add "*.staging.example.com"
49+gordon autoroute allow add example.com --remote https://gordon.mydomain.com --token $TOKEN
50+```
51+
52+### Arguments
53+
54+| Argument | Description |
55+|----------|-------------|
56+| `<pattern>` | Domain pattern to allow for auto-route |
57+
58+### Options
59+
60+| Option | Description |
61+|--------|-------------|
62+| `--remote` | Remote Gordon URL |
63+| `--token` | Authentication token for remote |
64+
65+### Description
66+
67+Patterns must be lowercase and must not have trailing dots.
68+A bare `*` matches all domains. Wildcards in `*.domain.tld` form match exactly one subdomain level, so `*.example.com` matches `app.example.com` but not `api.app.example.com`.
69+
70+### Examples
71+
72+```bash
73+# Allow an exact domain
74+gordon autoroute allow add example.com
75+
76+# Allow one subdomain level under staging.example.com
77+gordon autoroute allow add "*.staging.example.com"
78+
79+# Remote (override)
80+gordon autoroute allow add example.com --remote https://gordon.mydomain.com --token $TOKEN
81+```
82+
83+### Output
84+
85+```text
86+✓ Allowed domain added
87+```
88+
89+---
90+
91+## gordon autoroute allow list
92+
93+List auto-route allowed domains.
94+
95+```bash
96+gordon autoroute allow list
97+gordon autoroute allow list --json
98+gordon autoroute allow list --remote https://gordon.mydomain.com --token $TOKEN
99+```
100+
101+### Options
102+
103+| Option | Description |
104+|--------|-------------|
105+| `--json` | Output allowed domains as JSON |
106+| `--remote` | Remote Gordon URL |
107+| `--token` | Authentication token for remote |
108+
109+### Description
110+
111+Shows the current allowlist used to restrict which domains auto-route may claim.
112+When no patterns are configured, the command prints a friendly empty-state message instead of a table.
113+
114+### Examples
115+
116+```bash
117+# Local
118+gordon autoroute allow list
119+
120+# JSON
121+gordon autoroute allow list --json
122+
123+# Remote (override)
124+gordon autoroute allow list --remote https://gordon.mydomain.com --token $TOKEN
125+```
126+
127+### Output
128+
129+```text
130+example.com
131+*.staging.example.com
132+```
133+
134+When the allowlist is empty:
135+
136+```text
137+No allowed domains configured
138+```
139+
140+### JSON Output
141+
142+```bash
143+gordon autoroute allow list --json
144+```
145+
146+```json
147+{
148+ "domains": ["example.com", "*.staging.example.com"]
149+}
150+```
151+
152+---
153+
154+## gordon autoroute allow remove
155+
156+Remove a domain pattern from the auto-route allowlist.
157+
158+```bash
159+gordon autoroute allow remove <pattern>
160+gordon autoroute allow remove example.com
161+gordon autoroute allow remove example.com --remote https://gordon.mydomain.com --token $TOKEN
162+```
163+
164+### Arguments
165+
166+| Argument | Description |
167+|----------|-------------|
168+| `<pattern>` | Domain pattern to remove from the allowlist |
169+
170+### Options
171+
172+| Option | Description |
173+|--------|-------------|
174+| `--remote` | Remote Gordon URL |
175+| `--token` | Authentication token for remote |
176+
177+### Description
178+
179+Removes an exact allowlist entry.
180+Use the same pattern string you added, including the `*.` prefix for wildcard entries.
181+
182+### Examples
183+
184+```bash
185+# Remove an exact domain
186+gordon autoroute allow remove example.com
187+
188+# Remove a wildcard domain
189+gordon autoroute allow remove "*.staging.example.com"
190+
191+# Remote (override)
192+gordon autoroute allow remove example.com --remote https://gordon.mydomain.com --token $TOKEN
193+```
194+
195+### Output
196+
197+```text
198+✓ Allowed domain removed
199+```
200+
201+## Related
202+
203+- [CLI Overview](./index.md)
204+- [Routes Command](./routes.md)
205+- [Config Command](./config.md)
@@ -0,0 +1,205 @@
1+# Auto-Route Commands
2+
3+Manage the auto-route domain allowlist on local or remote Gordon instances.
4+
5+When auto-route is enabled, Gordon handles image pushes in two ways:
6+
7+1. **Image-name matching** — when a pushed image matches an existing route's configured image, Gordon auto-deploys that route. Since the route already exists, no allowlist check is needed.
8+2. **Label-based creation** — when a pushed image carries `gordon.domain` labels, Gordon creates new routes for those domains. The domain allowlist restricts which domains may be created this way, preventing untrusted images from registering arbitrary domains.
9+
10+The allowlist gates only the creation of new routes from labels. Routes created manually with `gordon routes add` or already present in configuration are not subject to the allowlist.
11+
12+Remote targeting uses client config or an active remote by default.
13+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
14+
15+## gordon autoroute
16+
17+Manage auto-route settings.
18+
19+### Subcommands
20+
21+| Subcommand | Description |
22+|------------|-------------|
23+| `allow` | Manage auto-route allowed domains |
24+
25+---
26+
27+## gordon autoroute allow
28+
29+Manage auto-route allowed domains.
30+
31+### Subcommands
32+
33+| Subcommand | Description |
34+|------------|-------------|
35+| `add` | Add a domain pattern to the allowlist |
36+| `list` | List allowed domains |
37+| `remove` | Remove a domain pattern |
38+
39+---
40+
41+## gordon autoroute allow add
42+
43+Add a domain pattern to the auto-route allowlist.
44+
45+```bash
46+gordon autoroute allow add <pattern>
47+gordon autoroute allow add example.com
48+gordon autoroute allow add "*.staging.example.com"
49+gordon autoroute allow add example.com --remote https://gordon.mydomain.com --token $TOKEN
50+```
51+
52+### Arguments
53+
54+| Argument | Description |
55+|----------|-------------|
56+| `<pattern>` | Domain pattern to allow for auto-route |
57+
58+### Options
59+
60+| Option | Description |
61+|--------|-------------|
62+| `--remote` | Remote Gordon URL |
63+| `--token` | Authentication token for remote |
64+
65+### Description
66+
67+Patterns must be lowercase and must not have trailing dots.
68+A bare `*` matches all domains. Wildcards in `*.domain.tld` form match exactly one subdomain level, so `*.example.com` matches `app.example.com` but not `api.app.example.com`.
69+
70+### Examples
71+
72+```bash
73+# Allow an exact domain
74+gordon autoroute allow add example.com
75+
76+# Allow one subdomain level under staging.example.com
77+gordon autoroute allow add "*.staging.example.com"
78+
79+# Remote (override)
80+gordon autoroute allow add example.com --remote https://gordon.mydomain.com --token $TOKEN
81+```
82+
83+### Output
84+
85+```text
86+✓ Allowed domain added
87+```
88+
89+---
90+
91+## gordon autoroute allow list
92+
93+List auto-route allowed domains.
94+
95+```bash
96+gordon autoroute allow list
97+gordon autoroute allow list --json
98+gordon autoroute allow list --remote https://gordon.mydomain.com --token $TOKEN
99+```
100+
101+### Options
102+
103+| Option | Description |
104+|--------|-------------|
105+| `--json` | Output allowed domains as JSON |
106+| `--remote` | Remote Gordon URL |
107+| `--token` | Authentication token for remote |
108+
109+### Description
110+
111+Shows the current allowlist used to restrict which domains auto-route may claim.
112+When no patterns are configured, the command prints a friendly empty-state message instead of a table.
113+
114+### Examples
115+
116+```bash
117+# Local
118+gordon autoroute allow list
119+
120+# JSON
121+gordon autoroute allow list --json
122+
123+# Remote (override)
124+gordon autoroute allow list --remote https://gordon.mydomain.com --token $TOKEN
125+```
126+
127+### Output
128+
129+```text
130+example.com
131+*.staging.example.com
132+```
133+
134+When the allowlist is empty:
135+
136+```text
137+No allowed domains configured
138+```
139+
140+### JSON Output
141+
142+```bash
143+gordon autoroute allow list --json
144+```
145+
146+```json
147+{
148+ "domains": ["example.com", "*.staging.example.com"]
149+}
150+```
151+
152+---
153+
154+## gordon autoroute allow remove
155+
156+Remove a domain pattern from the auto-route allowlist.
157+
158+```bash
159+gordon autoroute allow remove <pattern>
160+gordon autoroute allow remove example.com
161+gordon autoroute allow remove example.com --remote https://gordon.mydomain.com --token $TOKEN
162+```
163+
164+### Arguments
165+
166+| Argument | Description |
167+|----------|-------------|
168+| `<pattern>` | Domain pattern to remove from the allowlist |
169+
170+### Options
171+
172+| Option | Description |
173+|--------|-------------|
174+| `--remote` | Remote Gordon URL |
175+| `--token` | Authentication token for remote |
176+
177+### Description
178+
179+Removes an exact allowlist entry.
180+Use the same pattern string you added, including the `*.` prefix for wildcard entries.
181+
182+### Examples
183+
184+```bash
185+# Remove an exact domain
186+gordon autoroute allow remove example.com
187+
188+# Remove a wildcard domain
189+gordon autoroute allow remove "*.staging.example.com"
190+
191+# Remote (override)
192+gordon autoroute allow remove example.com --remote https://gordon.mydomain.com --token $TOKEN
193+```
194+
195+### Output
196+
197+```text
198+✓ Allowed domain removed
199+```
200+
201+## Related
202+
203+- [CLI Overview](./index.md)
204+- [Routes Command](./routes.md)
205+- [Config Command](./config.md)
M docs/cli/config.md +7 -1
@@ -43,7 +43,8 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
4343 "data_dir": "/var/lib/gordon"
4444 },
4545 "auto_route": {
46- "enabled": true
46+ "enabled": true,
47+ "allowed_domains": ["example.com", "*.staging.example.com"]
4748 },
4849 "network_isolation": {
4950 "enabled": true,
@@ -58,8 +59,13 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
5859 }
5960 ```
6061
62+### Auto-Route Allowed Domains
63+
64+The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md).
65+
6166 ## Related
6267
6368 - [CLI Overview](./index.md)
69+- [Auto-Route Commands](./autoroute.md)
6470 - [Status Command](./status.md)
6571 - [Routes Command](./routes.md)
@@ -43,7 +43,8 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
43 "data_dir": "/var/lib/gordon"43 "data_dir": "/var/lib/gordon"
44 },44 },
45 "auto_route": {45 "auto_route": {
46- "enabled": true46+ "enabled": true,
47+ "allowed_domains": ["example.com", "*.staging.example.com"]
47 },48 },
48 "network_isolation": {49 "network_isolation": {
49 "enabled": true,50 "enabled": true,
@@ -58,8 +59,13 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
58 }59 }
59 ```60 ```
60 61
62+### Auto-Route Allowed Domains
63+
64+The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md).
65+
61 ## Related66 ## Related
62 67
63 - [CLI Overview](./index.md)68 - [CLI Overview](./index.md)
69+- [Auto-Route Commands](./autoroute.md)
64 - [Status Command](./status.md)70 - [Status Command](./status.md)
65 - [Routes Command](./routes.md)71 - [Routes Command](./routes.md)
M docs/cli/index.md +7 -0
@@ -39,6 +39,7 @@ Management commands run locally through in-process services by default. Add `--r
3939 | `gordon images` | List and prune images | [images](./images.md) |
4040 | `gordon config show` | Show server configuration | [config](./config.md) |
4141 | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |
42+| `gordon autoroute` | Manage auto-route domain allowlist | [autoroute](./autoroute.md) |
4243
4344 ## Client Commands
4445
@@ -127,6 +128,12 @@ gordon secrets remove myapp.local DATABASE_URL
127128 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
128129 gordon remotes list
129130 gordon remotes use prod
131+
132+# Auto-route allowlist
133+gordon autoroute allow list
134+gordon autoroute allow add example.com
135+gordon autoroute allow add "*.staging.example.com"
136+gordon autoroute allow remove example.com
130137 ```
131138
132139 ## Global Options
@@ -39,6 +39,7 @@ Management commands run locally through in-process services by default. Add `--r
39 | `gordon images` | List and prune images | [images](./images.md) |39 | `gordon images` | List and prune images | [images](./images.md) |
40 | `gordon config show` | Show server configuration | [config](./config.md) |40 | `gordon config show` | Show server configuration | [config](./config.md) |
41 | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |41 | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |
42+| `gordon autoroute` | Manage auto-route domain allowlist | [autoroute](./autoroute.md) |
42 43
43 ## Client Commands44 ## Client Commands
44 45
@@ -127,6 +128,12 @@ gordon secrets remove myapp.local DATABASE_URL
127 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN128 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
128 gordon remotes list129 gordon remotes list
129 gordon remotes use prod130 gordon remotes use prod
131+
132+# Auto-route allowlist
133+gordon autoroute allow list
134+gordon autoroute allow add example.com
135+gordon autoroute allow add "*.staging.example.com"
136+gordon autoroute allow remove example.com
130 ```137 ```
131 138
132 ## Global Options139 ## Global Options
M docs/cli/remotes.md +11 -3
@@ -79,7 +79,13 @@ gordon remotes add dev https://dev.internal --insecure
7979
8080 ### Token Security
8181
82-**Option 1: Environment Variable Reference (Recommended)**
82+**Option 1: pass Store (Recommended)**
83+
84+When [pass](https://www.passwordstore.org/) is installed and initialized, Gordon stores tokens encrypted via GPG automatically. No extra flags are needed -- `gordon auth login` and `gordon remotes set-token` use `pass` when available.
85+
86+Tokens are stored at the path `gordon/remotes/<name>/token` inside the pass store. If `pass` is unavailable, Gordon falls back to plaintext config with a warning.
87+
88+**Option 2: Environment Variable Reference**
8389
8490 ```bash
8591 gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN
@@ -95,7 +101,7 @@ token_env = "PROD_TOKEN"
95101
96102 At runtime, Gordon reads `$PROD_TOKEN` from the environment.
97103
98-**Option 2: Direct Token Storage**
104+**Option 3: Direct Token Storage**
99105
100106 ```bash
101107 gordon remotes add prod https://gordon.mydomain.com --token eyJ...
@@ -245,7 +251,9 @@ When multiple sources specify remote or token, the CLI uses this priority:
245251 **Token:**
246252 1. `--token` flag
247253 2. `GORDON_TOKEN` environment variable
248-3. Token from active remote in `remotes.toml`
254+3. Token from client config (`gordon.toml`)
255+4. `pass` store (`gordon/remotes/<name>/token`)
256+5. Token from active remote in `remotes.toml`
249257
250258 **Insecure TLS:**
251259 1. `--insecure` flag
@@ -79,7 +79,13 @@ gordon remotes add dev https://dev.internal --insecure
79 79
80 ### Token Security80 ### Token Security
81 81
82-**Option 1: Environment Variable Reference (Recommended)**82+**Option 1: pass Store (Recommended)**
83+
84+When [pass](https://www.passwordstore.org/) is installed and initialized, Gordon stores tokens encrypted via GPG automatically. No extra flags are needed -- `gordon auth login` and `gordon remotes set-token` use `pass` when available.
85+
86+Tokens are stored at the path `gordon/remotes/<name>/token` inside the pass store. If `pass` is unavailable, Gordon falls back to plaintext config with a warning.
87+
88+**Option 2: Environment Variable Reference**
83 89
84 ```bash90 ```bash
85 gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN91 gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN
@@ -95,7 +101,7 @@ token_env = "PROD_TOKEN"
95 101
96 At runtime, Gordon reads `$PROD_TOKEN` from the environment.102 At runtime, Gordon reads `$PROD_TOKEN` from the environment.
97 103
98-**Option 2: Direct Token Storage**104+**Option 3: Direct Token Storage**
99 105
100 ```bash106 ```bash
101 gordon remotes add prod https://gordon.mydomain.com --token eyJ...107 gordon remotes add prod https://gordon.mydomain.com --token eyJ...
@@ -245,7 +251,9 @@ When multiple sources specify remote or token, the CLI uses this priority:
245 **Token:**251 **Token:**
246 1. `--token` flag252 1. `--token` flag
247 2. `GORDON_TOKEN` environment variable253 2. `GORDON_TOKEN` environment variable
248-3. Token from active remote in `remotes.toml`254+3. Token from client config (`gordon.toml`)
255+4. `pass` store (`gordon/remotes/<name>/token`)
256+5. Token from active remote in `remotes.toml`
249 257
250 **Insecure TLS:**258 **Insecure TLS:**
251 1. `--insecure` flag259 1. `--insecure` flag
A internal/adapters/dto/admin_autoroute.go +16 -0
@@ -0,0 +1,16 @@
1+package dto
2+
3+// AutoRouteAllowedDomainsResponse represents the list of allowed domains.
4+type AutoRouteAllowedDomainsResponse struct {
5+ Domains []string `json:"domains"`
6+}
7+
8+// AutoRouteAllowedDomainRequest represents a request to add/remove a domain pattern.
9+type AutoRouteAllowedDomainRequest struct {
10+ Pattern string `json:"pattern"`
11+}
12+
13+// AutoRouteStatusResponse represents a status response for auto-route operations.
14+type AutoRouteStatusResponse struct {
15+ Status string `json:"status"`
16+}
@@ -0,0 +1,16 @@
1+package dto
2+
3+// AutoRouteAllowedDomainsResponse represents the list of allowed domains.
4+type AutoRouteAllowedDomainsResponse struct {
5+ Domains []string `json:"domains"`
6+}
7+
8+// AutoRouteAllowedDomainRequest represents a request to add/remove a domain pattern.
9+type AutoRouteAllowedDomainRequest struct {
10+ Pattern string `json:"pattern"`
11+}
12+
13+// AutoRouteStatusResponse represents a status response for auto-route operations.
14+type AutoRouteStatusResponse struct {
15+ Status string `json:"status"`
16+}
M internal/adapters/in/cli/attachments_push_test.go +12 -0
@@ -96,6 +96,18 @@ func (c *attachmentPushTestControlPlane) RemoveAttachment(context.Context, strin
9696 panic("unexpected call")
9797 }
9898
99+func (c *attachmentPushTestControlPlane) GetAutoRouteAllowedDomains(context.Context) ([]string, error) {
100+ panic("unexpected call")
101+}
102+
103+func (c *attachmentPushTestControlPlane) AddAutoRouteAllowedDomain(context.Context, string) error {
104+ panic("unexpected call")
105+}
106+
107+func (c *attachmentPushTestControlPlane) RemoveAutoRouteAllowedDomain(context.Context, string) error {
108+ panic("unexpected call")
109+}
110+
99111 func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
100112 if c.getStatus != nil {
101113 return c.getStatus(ctx)
@@ -96,6 +96,18 @@ func (c *attachmentPushTestControlPlane) RemoveAttachment(context.Context, strin
96 panic("unexpected call")96 panic("unexpected call")
97 }97 }
98 98
99+func (c *attachmentPushTestControlPlane) GetAutoRouteAllowedDomains(context.Context) ([]string, error) {
100+ panic("unexpected call")
101+}
102+
103+func (c *attachmentPushTestControlPlane) AddAutoRouteAllowedDomain(context.Context, string) error {
104+ panic("unexpected call")
105+}
106+
107+func (c *attachmentPushTestControlPlane) RemoveAutoRouteAllowedDomain(context.Context, string) error {
108+ panic("unexpected call")
109+}
110+
99 func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {111 func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
100 if c.getStatus != nil {112 if c.getStatus != nil {
101 return c.getStatus(ctx)113 return c.getStatus(ctx)
M internal/adapters/in/cli/auth_test.go +3 -0
@@ -3,6 +3,7 @@ package cli
33 import (
44 "net/http"
55 "net/http/httptest"
6+ "path/filepath"
67 "testing"
78
89 "github.com/stretchr/testify/assert"
@@ -12,6 +13,7 @@ import (
1213
1314 func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {
1415 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
16+ t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
1517
1618 var receivedAuth string
1719 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -45,6 +47,7 @@ func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {
4547
4648 func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
4749 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
50+ t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
4851
4952 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5053 if r.URL.Path != "/admin/status" {
@@ -3,6 +3,7 @@ package cli
3 import (3 import (
4 "net/http"4 "net/http"
5 "net/http/httptest"5 "net/http/httptest"
6+ "path/filepath"
6 "testing"7 "testing"
7 8
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
@@ -12,6 +13,7 @@ import (
12 13
13 func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {14 func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {
14 t.Setenv("XDG_CONFIG_HOME", t.TempDir())15 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
16+ t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
15 17
16 var receivedAuth string18 var receivedAuth string
17 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {19 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -45,6 +47,7 @@ func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {
45 47
46 func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {48 func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
47 t.Setenv("XDG_CONFIG_HOME", t.TempDir())49 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
50+ t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
48 51
49 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {52 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50 if r.URL.Path != "/admin/status" {53 if r.URL.Path != "/admin/status" {
A internal/adapters/in/cli/autoroute_allow.go +112 -0
@@ -0,0 +1,112 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+
8+ "github.com/spf13/cobra"
9+)
10+
11+func newAutorouteCmd() *cobra.Command {
12+ cmd := &cobra.Command{
13+ Use: "autoroute",
14+ Short: "Manage auto-route settings",
15+ }
16+ cmd.AddCommand(newAutorouteAllowCmd())
17+ return cmd
18+}
19+
20+func newAutorouteAllowCmd() *cobra.Command {
21+ cmd := &cobra.Command{
22+ Use: "allow",
23+ Short: "Manage auto-route allowed domains",
24+ }
25+ cmd.AddCommand(newAutorouteAllowAddCmd())
26+ cmd.AddCommand(newAutorouteAllowListCmd())
27+ cmd.AddCommand(newAutorouteAllowRemoveCmd())
28+ return cmd
29+}
30+
31+func newAutorouteAllowAddCmd() *cobra.Command {
32+ return &cobra.Command{
33+ Use: "add <pattern>",
34+ Short: "Allow an auto-route domain pattern",
35+ Args: cobra.ExactArgs(1),
36+ RunE: func(cmd *cobra.Command, args []string) error {
37+ return runAutorouteAllowAdd(cmd.Context(), args, cmd.OutOrStdout())
38+ },
39+ }
40+}
41+
42+func newAutorouteAllowListCmd() *cobra.Command {
43+ var jsonOut bool
44+ cmd := &cobra.Command{
45+ Use: "list",
46+ Short: "List auto-route allowed domains",
47+ RunE: func(cmd *cobra.Command, args []string) error {
48+ return runAutorouteAllowList(cmd.Context(), cmd.OutOrStdout(), jsonOut)
49+ },
50+ }
51+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
52+ return cmd
53+}
54+
55+func newAutorouteAllowRemoveCmd() *cobra.Command {
56+ return &cobra.Command{
57+ Use: "remove <pattern>",
58+ Short: "Remove an auto-route domain pattern",
59+ Args: cobra.ExactArgs(1),
60+ RunE: func(cmd *cobra.Command, args []string) error {
61+ return runAutorouteAllowRemove(cmd.Context(), args, cmd.OutOrStdout())
62+ },
63+ }
64+}
65+
66+func runAutorouteAllowAdd(ctx context.Context, args []string, out io.Writer) error {
67+ handle, err := resolveControlPlane(configPath)
68+ if err != nil {
69+ return err
70+ }
71+ defer handle.close()
72+ if err := handle.plane.AddAutoRouteAllowedDomain(ctx, args[0]); err != nil {
73+ return fmt.Errorf("failed to add auto-route allowed domain: %w", err)
74+ }
75+ return cliWriteLine(out, cliRenderSuccess("Allowed domain added"))
76+}
77+
78+func runAutorouteAllowList(ctx context.Context, out io.Writer, jsonOut bool) error {
79+ handle, err := resolveControlPlane(configPath)
80+ if err != nil {
81+ return err
82+ }
83+ defer handle.close()
84+ domains, err := handle.plane.GetAutoRouteAllowedDomains(ctx)
85+ if err != nil {
86+ return fmt.Errorf("failed to list auto-route allowed domains: %w", err)
87+ }
88+ if jsonOut {
89+ return writeJSON(out, map[string]any{"domains": domains})
90+ }
91+ if len(domains) == 0 {
92+ return cliWriteLine(out, cliRenderMuted("No allowed domains configured"))
93+ }
94+ for _, domain := range domains {
95+ if err := cliWriteLine(out, domain); err != nil {
96+ return err
97+ }
98+ }
99+ return nil
100+}
101+
102+func runAutorouteAllowRemove(ctx context.Context, args []string, out io.Writer) error {
103+ handle, err := resolveControlPlane(configPath)
104+ if err != nil {
105+ return err
106+ }
107+ defer handle.close()
108+ if err := handle.plane.RemoveAutoRouteAllowedDomain(ctx, args[0]); err != nil {
109+ return fmt.Errorf("failed to remove auto-route allowed domain: %w", err)
110+ }
111+ return cliWriteLine(out, cliRenderSuccess("Allowed domain removed"))
112+}
@@ -0,0 +1,112 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+
8+ "github.com/spf13/cobra"
9+)
10+
11+func newAutorouteCmd() *cobra.Command {
12+ cmd := &cobra.Command{
13+ Use: "autoroute",
14+ Short: "Manage auto-route settings",
15+ }
16+ cmd.AddCommand(newAutorouteAllowCmd())
17+ return cmd
18+}
19+
20+func newAutorouteAllowCmd() *cobra.Command {
21+ cmd := &cobra.Command{
22+ Use: "allow",
23+ Short: "Manage auto-route allowed domains",
24+ }
25+ cmd.AddCommand(newAutorouteAllowAddCmd())
26+ cmd.AddCommand(newAutorouteAllowListCmd())
27+ cmd.AddCommand(newAutorouteAllowRemoveCmd())
28+ return cmd
29+}
30+
31+func newAutorouteAllowAddCmd() *cobra.Command {
32+ return &cobra.Command{
33+ Use: "add <pattern>",
34+ Short: "Allow an auto-route domain pattern",
35+ Args: cobra.ExactArgs(1),
36+ RunE: func(cmd *cobra.Command, args []string) error {
37+ return runAutorouteAllowAdd(cmd.Context(), args, cmd.OutOrStdout())
38+ },
39+ }
40+}
41+
42+func newAutorouteAllowListCmd() *cobra.Command {
43+ var jsonOut bool
44+ cmd := &cobra.Command{
45+ Use: "list",
46+ Short: "List auto-route allowed domains",
47+ RunE: func(cmd *cobra.Command, args []string) error {
48+ return runAutorouteAllowList(cmd.Context(), cmd.OutOrStdout(), jsonOut)
49+ },
50+ }
51+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
52+ return cmd
53+}
54+
55+func newAutorouteAllowRemoveCmd() *cobra.Command {
56+ return &cobra.Command{
57+ Use: "remove <pattern>",
58+ Short: "Remove an auto-route domain pattern",
59+ Args: cobra.ExactArgs(1),
60+ RunE: func(cmd *cobra.Command, args []string) error {
61+ return runAutorouteAllowRemove(cmd.Context(), args, cmd.OutOrStdout())
62+ },
63+ }
64+}
65+
66+func runAutorouteAllowAdd(ctx context.Context, args []string, out io.Writer) error {
67+ handle, err := resolveControlPlane(configPath)
68+ if err != nil {
69+ return err
70+ }
71+ defer handle.close()
72+ if err := handle.plane.AddAutoRouteAllowedDomain(ctx, args[0]); err != nil {
73+ return fmt.Errorf("failed to add auto-route allowed domain: %w", err)
74+ }
75+ return cliWriteLine(out, cliRenderSuccess("Allowed domain added"))
76+}
77+
78+func runAutorouteAllowList(ctx context.Context, out io.Writer, jsonOut bool) error {
79+ handle, err := resolveControlPlane(configPath)
80+ if err != nil {
81+ return err
82+ }
83+ defer handle.close()
84+ domains, err := handle.plane.GetAutoRouteAllowedDomains(ctx)
85+ if err != nil {
86+ return fmt.Errorf("failed to list auto-route allowed domains: %w", err)
87+ }
88+ if jsonOut {
89+ return writeJSON(out, map[string]any{"domains": domains})
90+ }
91+ if len(domains) == 0 {
92+ return cliWriteLine(out, cliRenderMuted("No allowed domains configured"))
93+ }
94+ for _, domain := range domains {
95+ if err := cliWriteLine(out, domain); err != nil {
96+ return err
97+ }
98+ }
99+ return nil
100+}
101+
102+func runAutorouteAllowRemove(ctx context.Context, args []string, out io.Writer) error {
103+ handle, err := resolveControlPlane(configPath)
104+ if err != nil {
105+ return err
106+ }
107+ defer handle.close()
108+ if err := handle.plane.RemoveAutoRouteAllowedDomain(ctx, args[0]); err != nil {
109+ return fmt.Errorf("failed to remove auto-route allowed domain: %w", err)
110+ }
111+ return cliWriteLine(out, cliRenderSuccess("Allowed domain removed"))
112+}
M internal/adapters/in/cli/controlplane.go +3 -0
@@ -33,6 +33,9 @@ type ControlPlane interface {
3333 FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error)
3434 AddAttachment(ctx context.Context, domainOrGroup, image string) error
3535 RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
36+ GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error)
37+ AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error
38+ RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error
3639
3740 GetStatus(ctx context.Context) (*remote.Status, error)
3841 Reload(ctx context.Context) error
@@ -33,6 +33,9 @@ type ControlPlane interface {
33 FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error)33 FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error)
34 AddAttachment(ctx context.Context, domainOrGroup, image string) error34 AddAttachment(ctx context.Context, domainOrGroup, image string) error
35 RemoveAttachment(ctx context.Context, domainOrGroup, image string) error35 RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
36+ GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error)
37+ AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error
38+ RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error
36 39
37 GetStatus(ctx context.Context) (*remote.Status, error)40 GetStatus(ctx context.Context) (*remote.Status, error)
38 Reload(ctx context.Context) error41 Reload(ctx context.Context) error
M internal/adapters/in/cli/controlplane_local.go +21 -0
@@ -307,6 +307,27 @@ func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup,
307307 return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image)
308308 }
309309
310+func (l *localControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
311+ if l.configSvc == nil {
312+ return nil, fmt.Errorf("local config service unavailable")
313+ }
314+ return l.configSvc.GetAutoRouteAllowedDomains(ctx)
315+}
316+
317+func (l *localControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
318+ if l.configSvc == nil {
319+ return fmt.Errorf("local config service unavailable")
320+ }
321+ return l.configSvc.AddAutoRouteAllowedDomain(ctx, pattern)
322+}
323+
324+func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
325+ if l.configSvc == nil {
326+ return fmt.Errorf("local config service unavailable")
327+ }
328+ return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern)
329+}
330+
310331 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
311332 if l.configSvc == nil {
312333 return nil, fmt.Errorf("local config service unavailable")
@@ -307,6 +307,27 @@ func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup,
307 return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image)307 return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image)
308 }308 }
309 309
310+func (l *localControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
311+ if l.configSvc == nil {
312+ return nil, fmt.Errorf("local config service unavailable")
313+ }
314+ return l.configSvc.GetAutoRouteAllowedDomains(ctx)
315+}
316+
317+func (l *localControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
318+ if l.configSvc == nil {
319+ return fmt.Errorf("local config service unavailable")
320+ }
321+ return l.configSvc.AddAutoRouteAllowedDomain(ctx, pattern)
322+}
323+
324+func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
325+ if l.configSvc == nil {
326+ return fmt.Errorf("local config service unavailable")
327+ }
328+ return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern)
329+}
330+
310 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {331 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
311 if l.configSvc == nil {332 if l.configSvc == nil {
312 return nil, fmt.Errorf("local config service unavailable")333 return nil, fmt.Errorf("local config service unavailable")
M internal/adapters/in/cli/controlplane_remote.go +12 -0
@@ -88,6 +88,18 @@ func (r *remoteControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup
8888 return r.client.RemoveAttachment(ctx, domainOrGroup, image)
8989 }
9090
91+func (r *remoteControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
92+ return r.client.GetAutoRouteAllowedDomains(ctx)
93+}
94+
95+func (r *remoteControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
96+ return r.client.AddAutoRouteAllowedDomain(ctx, pattern)
97+}
98+
99+func (r *remoteControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
100+ return r.client.RemoveAutoRouteAllowedDomain(ctx, pattern)
101+}
102+
91103 func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
92104 return r.client.GetStatus(ctx)
93105 }
@@ -88,6 +88,18 @@ func (r *remoteControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup
88 return r.client.RemoveAttachment(ctx, domainOrGroup, image)88 return r.client.RemoveAttachment(ctx, domainOrGroup, image)
89 }89 }
90 90
91+func (r *remoteControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
92+ return r.client.GetAutoRouteAllowedDomains(ctx)
93+}
94+
95+func (r *remoteControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
96+ return r.client.AddAutoRouteAllowedDomain(ctx, pattern)
97+}
98+
99+func (r *remoteControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
100+ return r.client.RemoveAutoRouteAllowedDomain(ctx, pattern)
101+}
102+
91 func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {103 func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
92 return r.client.GetStatus(ctx)104 return r.client.GetStatus(ctx)
93 }105 }
M internal/adapters/in/cli/push_test.go +12 -0
@@ -96,6 +96,18 @@ func (c *resolveFromImageTestControlPlane) RemoveAttachment(context.Context, str
9696 panic("unexpected call")
9797 }
9898
99+func (c *resolveFromImageTestControlPlane) GetAutoRouteAllowedDomains(context.Context) ([]string, error) {
100+ panic("unexpected call")
101+}
102+
103+func (c *resolveFromImageTestControlPlane) AddAutoRouteAllowedDomain(context.Context, string) error {
104+ panic("unexpected call")
105+}
106+
107+func (c *resolveFromImageTestControlPlane) RemoveAutoRouteAllowedDomain(context.Context, string) error {
108+ panic("unexpected call")
109+}
110+
99111 func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) {
100112 panic("unexpected call")
101113 }
@@ -96,6 +96,18 @@ func (c *resolveFromImageTestControlPlane) RemoveAttachment(context.Context, str
96 panic("unexpected call")96 panic("unexpected call")
97 }97 }
98 98
99+func (c *resolveFromImageTestControlPlane) GetAutoRouteAllowedDomains(context.Context) ([]string, error) {
100+ panic("unexpected call")
101+}
102+
103+func (c *resolveFromImageTestControlPlane) AddAutoRouteAllowedDomain(context.Context, string) error {
104+ panic("unexpected call")
105+}
106+
107+func (c *resolveFromImageTestControlPlane) RemoveAutoRouteAllowedDomain(context.Context, string) error {
108+ panic("unexpected call")
109+}
110+
99 func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) {111 func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) {
100 panic("unexpected call")112 panic("unexpected call")
101 }113 }
M internal/adapters/in/cli/remote/auth.go +25 -0
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "io"
99 "net/http"
10+ "os"
1011 )
1112
1213 // PasswordRequest represents the request body for POST /auth/password.
@@ -72,6 +73,30 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
7273
7374 // UpdateRemoteToken updates the token for a named remote.
7475 func UpdateRemoteToken(name, token string) error {
76+ // Try pass first
77+ if passAvailable() {
78+ if err := passWriteToken(name, token); err == nil {
79+ // Successfully stored in pass. Remove plaintext token from TOML (best-effort).
80+ config, err := LoadRemotes("")
81+ if err != nil {
82+ return nil
83+ }
84+ remote, ok := config.Remotes[name]
85+ if !ok {
86+ return nil
87+ }
88+ remote.Token = ""
89+ remote.TokenEnv = ""
90+ config.Remotes[name] = remote
91+ _ = SaveRemotes("", config)
92+ return nil
93+ } else {
94+ fmt.Fprintf(os.Stderr, "Warning: failed to store token in pass: %v. Falling back to plaintext config.\n", err)
95+ }
96+ } else {
97+ warnPassUnavailable()
98+ }
99+
75100 config, err := LoadRemotes("")
76101 if err != nil {
77102 return err
@@ -7,6 +7,7 @@ import (
7 "fmt"7 "fmt"
8 "io"8 "io"
9 "net/http"9 "net/http"
10+ "os"
10 )11 )
11 12
12 // PasswordRequest represents the request body for POST /auth/password.13 // PasswordRequest represents the request body for POST /auth/password.
@@ -72,6 +73,30 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
72 73
73 // UpdateRemoteToken updates the token for a named remote.74 // UpdateRemoteToken updates the token for a named remote.
74 func UpdateRemoteToken(name, token string) error {75 func UpdateRemoteToken(name, token string) error {
76+ // Try pass first
77+ if passAvailable() {
78+ if err := passWriteToken(name, token); err == nil {
79+ // Successfully stored in pass. Remove plaintext token from TOML (best-effort).
80+ config, err := LoadRemotes("")
81+ if err != nil {
82+ return nil
83+ }
84+ remote, ok := config.Remotes[name]
85+ if !ok {
86+ return nil
87+ }
88+ remote.Token = ""
89+ remote.TokenEnv = ""
90+ config.Remotes[name] = remote
91+ _ = SaveRemotes("", config)
92+ return nil
93+ } else {
94+ fmt.Fprintf(os.Stderr, "Warning: failed to store token in pass: %v. Falling back to plaintext config.\n", err)
95+ }
96+ } else {
97+ warnPassUnavailable()
98+ }
99+
75 config, err := LoadRemotes("")100 config, err := LoadRemotes("")
76 if err != nil {101 if err != nil {
77 return err102 return err
M internal/adapters/in/cli/remote/client.go +36 -0
@@ -946,6 +946,42 @@ func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image stri
946946 return parseResponse(resp, nil)
947947 }
948948
949+func (c *Client) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
950+ resp, err := c.request(ctx, http.MethodGet, "/autoroute/allowed-domains", nil)
951+ if err != nil {
952+ return nil, err
953+ }
954+
955+ var result dto.AutoRouteAllowedDomainsResponse
956+ if err := parseResponse(resp, &result); err != nil {
957+ return nil, err
958+ }
959+
960+ return result.Domains, nil
961+}
962+
963+func (c *Client) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
964+ if strings.TrimSpace(pattern) == "" {
965+ return fmt.Errorf("pattern must not be empty")
966+ }
967+ resp, err := c.request(ctx, http.MethodPost, "/autoroute/allowed-domains", dto.AutoRouteAllowedDomainRequest{Pattern: pattern})
968+ if err != nil {
969+ return err
970+ }
971+ return parseResponse(resp, nil)
972+}
973+
974+func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
975+ if strings.TrimSpace(pattern) == "" {
976+ return fmt.Errorf("pattern must not be empty")
977+ }
978+ resp, err := c.request(ctx, http.MethodDelete, "/autoroute/allowed-domains/"+url.PathEscape(pattern), nil)
979+ if err != nil {
980+ return err
981+ }
982+ return parseResponse(resp, nil)
983+}
984+
949985 // streamLogs handles SSE streaming for log endpoints.
950986 func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
951987 url := c.baseURL + "/admin" + path
@@ -946,6 +946,42 @@ func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image stri
946 return parseResponse(resp, nil)946 return parseResponse(resp, nil)
947 }947 }
948 948
949+func (c *Client) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
950+ resp, err := c.request(ctx, http.MethodGet, "/autoroute/allowed-domains", nil)
951+ if err != nil {
952+ return nil, err
953+ }
954+
955+ var result dto.AutoRouteAllowedDomainsResponse
956+ if err := parseResponse(resp, &result); err != nil {
957+ return nil, err
958+ }
959+
960+ return result.Domains, nil
961+}
962+
963+func (c *Client) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
964+ if strings.TrimSpace(pattern) == "" {
965+ return fmt.Errorf("pattern must not be empty")
966+ }
967+ resp, err := c.request(ctx, http.MethodPost, "/autoroute/allowed-domains", dto.AutoRouteAllowedDomainRequest{Pattern: pattern})
968+ if err != nil {
969+ return err
970+ }
971+ return parseResponse(resp, nil)
972+}
973+
974+func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
975+ if strings.TrimSpace(pattern) == "" {
976+ return fmt.Errorf("pattern must not be empty")
977+ }
978+ resp, err := c.request(ctx, http.MethodDelete, "/autoroute/allowed-domains/"+url.PathEscape(pattern), nil)
979+ if err != nil {
980+ return err
981+ }
982+ return parseResponse(resp, nil)
983+}
984+
949 // streamLogs handles SSE streaming for log endpoints.985 // streamLogs handles SSE streaming for log endpoints.
950 func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {986 func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
951 url := c.baseURL + "/admin" + path987 url := c.baseURL + "/admin" + path
M internal/adapters/in/cli/remote/config.go +40 -10
@@ -191,6 +191,8 @@ func resolveRemoteURL(flagRemote string, config *ClientConfig, remotes *ClientCo
191191
192192 // resolveToken resolves the authentication token from various sources.
193193 func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) string {
194+ activeRemote := activeRemoteEntry(remotes)
195+
194196 // 1. Flag token takes precedence
195197 if flagToken != "" {
196198 return flagToken
@@ -207,22 +209,50 @@ func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig)
207209 return config.Client.Token
208210 }
209211 if config.Client.TokenEnv != "" {
210- return os.Getenv(config.Client.TokenEnv)
212+ if envToken := os.Getenv(config.Client.TokenEnv); envToken != "" {
213+ return envToken
214+ }
211215 }
212216 }
213217
214- // 4. Active remote token
215- if remotes != nil && remotes.Active != "" {
216- if remote, ok := remotes.Remotes[remotes.Active]; ok {
217- if remote.Token != "" {
218- return remote.Token
219- }
220- if remote.TokenEnv != "" {
221- return os.Getenv(remote.TokenEnv)
222- }
218+ // 4. Try pass store for active remote
219+ if remotes != nil && remotes.Active != "" && passAvailable() {
220+ if token, err := passReadToken(remotes.Active); err == nil && token != "" {
221+ return token
223222 }
224223 }
225224
225+ // 5. Active remote token
226+ if token := resolveRemoteEntryToken(activeRemote); token != "" {
227+ return token
228+ }
229+
230+ return ""
231+}
232+
233+func activeRemoteEntry(remotes *ClientConfig) *RemoteEntry {
234+ if remotes == nil || remotes.Active == "" {
235+ return nil
236+ }
237+
238+ remote, ok := remotes.Remotes[remotes.Active]
239+ if !ok {
240+ return nil
241+ }
242+
243+ return &remote
244+}
245+
246+func resolveRemoteEntryToken(remote *RemoteEntry) string {
247+ if remote == nil {
248+ return ""
249+ }
250+ if remote.Token != "" {
251+ return remote.Token
252+ }
253+ if remote.TokenEnv != "" {
254+ return os.Getenv(remote.TokenEnv)
255+ }
226256 return ""
227257 }
228258
@@ -191,6 +191,8 @@ func resolveRemoteURL(flagRemote string, config *ClientConfig, remotes *ClientCo
191 191
192 // resolveToken resolves the authentication token from various sources.192 // resolveToken resolves the authentication token from various sources.
193 func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) string {193 func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) string {
194+ activeRemote := activeRemoteEntry(remotes)
195+
194 // 1. Flag token takes precedence196 // 1. Flag token takes precedence
195 if flagToken != "" {197 if flagToken != "" {
196 return flagToken198 return flagToken
@@ -207,22 +209,50 @@ func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig)
207 return config.Client.Token209 return config.Client.Token
208 }210 }
209 if config.Client.TokenEnv != "" {211 if config.Client.TokenEnv != "" {
210- return os.Getenv(config.Client.TokenEnv)212+ if envToken := os.Getenv(config.Client.TokenEnv); envToken != "" {
213+ return envToken
214+ }
211 }215 }
212 }216 }
213 217
214- // 4. Active remote token218+ // 4. Try pass store for active remote
215- if remotes != nil && remotes.Active != "" {219+ if remotes != nil && remotes.Active != "" && passAvailable() {
216- if remote, ok := remotes.Remotes[remotes.Active]; ok {220+ if token, err := passReadToken(remotes.Active); err == nil && token != "" {
217- if remote.Token != "" {221+ return token
218- return remote.Token
219- }
220- if remote.TokenEnv != "" {
221- return os.Getenv(remote.TokenEnv)
222- }
223 }222 }
224 }223 }
225 224
225+ // 5. Active remote token
226+ if token := resolveRemoteEntryToken(activeRemote); token != "" {
227+ return token
228+ }
229+
230+ return ""
231+}
232+
233+func activeRemoteEntry(remotes *ClientConfig) *RemoteEntry {
234+ if remotes == nil || remotes.Active == "" {
235+ return nil
236+ }
237+
238+ remote, ok := remotes.Remotes[remotes.Active]
239+ if !ok {
240+ return nil
241+ }
242+
243+ return &remote
244+}
245+
246+func resolveRemoteEntryToken(remote *RemoteEntry) string {
247+ if remote == nil {
248+ return ""
249+ }
250+ if remote.Token != "" {
251+ return remote.Token
252+ }
253+ if remote.TokenEnv != "" {
254+ return os.Getenv(remote.TokenEnv)
255+ }
226 return ""256 return ""
227 }257 }
228 258
A internal/adapters/in/cli/remote/pass.go +102 -0
@@ -0,0 +1,102 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "regexp"
9+ "strings"
10+ "sync"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+var (
16+ passAvailableOnce sync.Once
17+ passAvailableResult bool
18+ passWarnOnce sync.Once
19+ validRemoteName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
20+)
21+
22+func validateRemoteName(name string) error {
23+ if name == "" {
24+ return domain.ErrEmptyRemoteName
25+ }
26+ if !validRemoteName.MatchString(name) {
27+ return fmt.Errorf("%w (allowed: a-z, A-Z, 0-9, '.', '_', '-')", domain.ErrInvalidRemoteNameChar)
28+ }
29+ if strings.Contains(name, "..") {
30+ return domain.ErrConsecutiveDots
31+ }
32+ return nil
33+}
34+
35+func passAvailable() bool {
36+ passAvailableOnce.Do(func() {
37+ path, err := exec.LookPath("pass")
38+ if err != nil || path == "" {
39+ passAvailableResult = false
40+ return
41+ }
42+
43+ cmd := exec.Command("pass", "ls") //nolint:gosec // pass binary name is constant
44+ if err := cmd.Run(); err != nil {
45+ passAvailableResult = false
46+ return
47+ }
48+
49+ passAvailableResult = true
50+ })
51+
52+ return passAvailableResult
53+}
54+
55+func passTokenPath(name string) string {
56+ return "gordon/remotes/" + name + "/token"
57+}
58+
59+func passReadToken(name string) (string, error) {
60+ if err := validateRemoteName(name); err != nil {
61+ return "", err
62+ }
63+ if !passAvailable() {
64+ return "", domain.ErrPassUnavailable
65+ }
66+
67+ cmd := exec.Command("pass", "show", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
68+ var stdout bytes.Buffer
69+ var stderr bytes.Buffer
70+ cmd.Stdout = &stdout
71+ cmd.Stderr = &stderr
72+ if err := cmd.Run(); err != nil {
73+ return "", fmt.Errorf("pass show failed: %w: %s", err, stderr.String())
74+ }
75+
76+ return strings.TrimSpace(stdout.String()), nil
77+}
78+
79+func passWriteToken(name, token string) error {
80+ if err := validateRemoteName(name); err != nil {
81+ return err
82+ }
83+ if !passAvailable() {
84+ return domain.ErrPassUnavailable
85+ }
86+
87+ cmd := exec.Command("pass", "insert", "--multiline", "--force", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
88+ cmd.Stdin = strings.NewReader(token + "\n")
89+ var stderr bytes.Buffer
90+ cmd.Stderr = &stderr
91+ if err := cmd.Run(); err != nil {
92+ return fmt.Errorf("pass insert failed: %w: %s", err, stderr.String())
93+ }
94+
95+ return nil
96+}
97+
98+func warnPassUnavailable() {
99+ passWarnOnce.Do(func() {
100+ fmt.Fprintf(os.Stderr, "Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.\n")
101+ })
102+}
@@ -0,0 +1,102 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "regexp"
9+ "strings"
10+ "sync"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+var (
16+ passAvailableOnce sync.Once
17+ passAvailableResult bool
18+ passWarnOnce sync.Once
19+ validRemoteName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
20+)
21+
22+func validateRemoteName(name string) error {
23+ if name == "" {
24+ return domain.ErrEmptyRemoteName
25+ }
26+ if !validRemoteName.MatchString(name) {
27+ return fmt.Errorf("%w (allowed: a-z, A-Z, 0-9, '.', '_', '-')", domain.ErrInvalidRemoteNameChar)
28+ }
29+ if strings.Contains(name, "..") {
30+ return domain.ErrConsecutiveDots
31+ }
32+ return nil
33+}
34+
35+func passAvailable() bool {
36+ passAvailableOnce.Do(func() {
37+ path, err := exec.LookPath("pass")
38+ if err != nil || path == "" {
39+ passAvailableResult = false
40+ return
41+ }
42+
43+ cmd := exec.Command("pass", "ls") //nolint:gosec // pass binary name is constant
44+ if err := cmd.Run(); err != nil {
45+ passAvailableResult = false
46+ return
47+ }
48+
49+ passAvailableResult = true
50+ })
51+
52+ return passAvailableResult
53+}
54+
55+func passTokenPath(name string) string {
56+ return "gordon/remotes/" + name + "/token"
57+}
58+
59+func passReadToken(name string) (string, error) {
60+ if err := validateRemoteName(name); err != nil {
61+ return "", err
62+ }
63+ if !passAvailable() {
64+ return "", domain.ErrPassUnavailable
65+ }
66+
67+ cmd := exec.Command("pass", "show", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
68+ var stdout bytes.Buffer
69+ var stderr bytes.Buffer
70+ cmd.Stdout = &stdout
71+ cmd.Stderr = &stderr
72+ if err := cmd.Run(); err != nil {
73+ return "", fmt.Errorf("pass show failed: %w: %s", err, stderr.String())
74+ }
75+
76+ return strings.TrimSpace(stdout.String()), nil
77+}
78+
79+func passWriteToken(name, token string) error {
80+ if err := validateRemoteName(name); err != nil {
81+ return err
82+ }
83+ if !passAvailable() {
84+ return domain.ErrPassUnavailable
85+ }
86+
87+ cmd := exec.Command("pass", "insert", "--multiline", "--force", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
88+ cmd.Stdin = strings.NewReader(token + "\n")
89+ var stderr bytes.Buffer
90+ cmd.Stderr = &stderr
91+ if err := cmd.Run(); err != nil {
92+ return fmt.Errorf("pass insert failed: %w: %s", err, stderr.String())
93+ }
94+
95+ return nil
96+}
97+
98+func warnPassUnavailable() {
99+ passWarnOnce.Do(func() {
100+ fmt.Fprintf(os.Stderr, "Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.\n")
101+ })
102+}
A internal/adapters/in/cli/remote/pass_test.go +41 -0
@@ -0,0 +1,41 @@
1+package remote
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestValidateRemoteName(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ input string
14+ wantErr bool
15+ }{
16+ {"valid simple", "myserver", false},
17+ {"valid with dots", "my.server", false},
18+ {"valid with dashes", "my-server", false},
19+ {"valid with underscore", "my_server", false},
20+ {"invalid slash", "my/server", true},
21+ {"invalid dotdot", "my..server", true},
22+ {"invalid space", "my server", true},
23+ {"empty", "", true},
24+ {"leading dot", ".server", false},
25+ {"trailing dot", "server.", false},
26+ {"only dots", "...", true},
27+ {"only dashes", "---", false},
28+ {"very long name", strings.Repeat("a", 256), false},
29+ }
30+
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ err := validateRemoteName(tt.input)
34+ if tt.wantErr {
35+ assert.Error(t, err)
36+ } else {
37+ assert.NoError(t, err)
38+ }
39+ })
40+ }
41+}
@@ -0,0 +1,41 @@
1+package remote
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestValidateRemoteName(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ input string
14+ wantErr bool
15+ }{
16+ {"valid simple", "myserver", false},
17+ {"valid with dots", "my.server", false},
18+ {"valid with dashes", "my-server", false},
19+ {"valid with underscore", "my_server", false},
20+ {"invalid slash", "my/server", true},
21+ {"invalid dotdot", "my..server", true},
22+ {"invalid space", "my server", true},
23+ {"empty", "", true},
24+ {"leading dot", ".server", false},
25+ {"trailing dot", "server.", false},
26+ {"only dots", "...", true},
27+ {"only dashes", "---", false},
28+ {"very long name", strings.Repeat("a", 256), false},
29+ }
30+
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ err := validateRemoteName(tt.input)
34+ if tt.wantErr {
35+ assert.Error(t, err)
36+ } else {
37+ assert.NoError(t, err)
38+ }
39+ })
40+ }
41+}
M internal/adapters/in/cli/root.go +4 -0
@@ -137,6 +137,10 @@ Commands are organized by where they run:
137137 networksCmd.GroupID = groupManage
138138 rootCmd.AddCommand(networksCmd)
139139
140+ autorouteCmd := newAutorouteCmd()
141+ autorouteCmd.GroupID = groupManage
142+ rootCmd.AddCommand(autorouteCmd)
143+
140144 // Client-only commands (no server needed)
141145 remotesCmd := newRemotesCmd()
142146 remotesCmd.GroupID = groupClient
@@ -137,6 +137,10 @@ Commands are organized by where they run:
137 networksCmd.GroupID = groupManage137 networksCmd.GroupID = groupManage
138 rootCmd.AddCommand(networksCmd)138 rootCmd.AddCommand(networksCmd)
139 139
140+ autorouteCmd := newAutorouteCmd()
141+ autorouteCmd.GroupID = groupManage
142+ rootCmd.AddCommand(autorouteCmd)
143+
140 // Client-only commands (no server needed)144 // Client-only commands (no server needed)
141 remotesCmd := newRemotesCmd()145 remotesCmd := newRemotesCmd()
142 remotesCmd.GroupID = groupClient146 remotesCmd.GroupID = groupClient
M internal/adapters/in/http/admin/handler.go +87 -0
@@ -229,6 +229,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
229229 {"/tags", h.handleTags},
230230 {"/images", h.handleImages},
231231 {"/logs", h.handleLogs},
232+ {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains},
232233 }
233234 for _, route := range prefixRoutes {
234235 if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
@@ -1709,6 +1710,92 @@ func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.R
17091710 h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
17101711 }
17111712
1713+func (h *Handler) handleAutoRouteAllowedDomains(w http.ResponseWriter, r *http.Request, path string) {
1714+ switch r.Method {
1715+ case http.MethodGet:
1716+ h.handleAutoRouteAllowedDomainsGet(w, r)
1717+ case http.MethodPost:
1718+ h.handleAutoRouteAllowedDomainsPost(w, r)
1719+ case http.MethodDelete:
1720+ h.handleAutoRouteAllowedDomainsDelete(w, r, path)
1721+ default:
1722+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1723+ }
1724+}
1725+
1726+func (h *Handler) handleAutoRouteAllowedDomainsGet(w http.ResponseWriter, r *http.Request) {
1727+ ctx := r.Context()
1728+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1729+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1730+ return
1731+ }
1732+
1733+ domains, err := h.configSvc.GetAutoRouteAllowedDomains(ctx)
1734+ if err != nil {
1735+ h.sendError(w, http.StatusInternalServerError, "failed to get auto-route allowed domains")
1736+ return
1737+ }
1738+
1739+ h.sendJSON(w, http.StatusOK, dto.AutoRouteAllowedDomainsResponse{Domains: domains})
1740+}
1741+
1742+func (h *Handler) handleAutoRouteAllowedDomainsPost(w http.ResponseWriter, r *http.Request) {
1743+ ctx := r.Context()
1744+ log := zerowrap.FromCtx(ctx)
1745+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1746+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1747+ return
1748+ }
1749+
1750+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1751+ var req dto.AutoRouteAllowedDomainRequest
1752+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1753+ log.Warn().Err(err).Msg("invalid auto-route allowlist JSON")
1754+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
1755+ return
1756+ }
1757+
1758+ if err := h.configSvc.AddAutoRouteAllowedDomain(ctx, req.Pattern); err != nil {
1759+ if errors.Is(err, domain.ErrInvalidDomainPattern) {
1760+ h.sendError(w, http.StatusBadRequest, err.Error())
1761+ } else {
1762+ log.Error().Err(err).Str("pattern", req.Pattern).Msg("failed to add auto-route allowed domain")
1763+ h.sendError(w, http.StatusInternalServerError, "failed to add auto-route allowed domain")
1764+ }
1765+ return
1766+ }
1767+
1768+ h.sendJSON(w, http.StatusCreated, dto.AutoRouteStatusResponse{Status: "added"})
1769+}
1770+
1771+func (h *Handler) handleAutoRouteAllowedDomainsDelete(w http.ResponseWriter, r *http.Request, path string) {
1772+ ctx := r.Context()
1773+ log := zerowrap.FromCtx(ctx)
1774+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1775+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1776+ return
1777+ }
1778+
1779+ raw := strings.TrimPrefix(path, "/autoroute/allowed-domains/")
1780+ if raw == path || raw == "" || raw == "/" {
1781+ h.sendError(w, http.StatusBadRequest, "missing domain pattern")
1782+ return
1783+ }
1784+ pattern, err := url.PathUnescape(raw)
1785+ if err != nil || strings.TrimSpace(pattern) == "" {
1786+ h.sendError(w, http.StatusBadRequest, "invalid domain pattern")
1787+ return
1788+ }
1789+
1790+ if err := h.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern); err != nil {
1791+ log.Error().Err(err).Str("pattern", pattern).Msg("failed to remove auto-route allowed domain")
1792+ h.sendError(w, http.StatusInternalServerError, "failed to remove auto-route allowed domain")
1793+ return
1794+ }
1795+
1796+ h.sendJSON(w, http.StatusOK, dto.AutoRouteStatusResponse{Status: "removed"})
1797+}
1798+
17121799 // handleAuthVerify handles /admin/auth/verify endpoint.
17131800 // Validates authentication session and returns token status.
17141801 func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
@@ -229,6 +229,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
229 {"/tags", h.handleTags},229 {"/tags", h.handleTags},
230 {"/images", h.handleImages},230 {"/images", h.handleImages},
231 {"/logs", h.handleLogs},231 {"/logs", h.handleLogs},
232+ {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains},
232 }233 }
233 for _, route := range prefixRoutes {234 for _, route := range prefixRoutes {
234 if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {235 if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
@@ -1709,6 +1710,92 @@ func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.R
1709 h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})1710 h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1710 }1711 }
1711 1712
1713+func (h *Handler) handleAutoRouteAllowedDomains(w http.ResponseWriter, r *http.Request, path string) {
1714+ switch r.Method {
1715+ case http.MethodGet:
1716+ h.handleAutoRouteAllowedDomainsGet(w, r)
1717+ case http.MethodPost:
1718+ h.handleAutoRouteAllowedDomainsPost(w, r)
1719+ case http.MethodDelete:
1720+ h.handleAutoRouteAllowedDomainsDelete(w, r, path)
1721+ default:
1722+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1723+ }
1724+}
1725+
1726+func (h *Handler) handleAutoRouteAllowedDomainsGet(w http.ResponseWriter, r *http.Request) {
1727+ ctx := r.Context()
1728+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1729+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1730+ return
1731+ }
1732+
1733+ domains, err := h.configSvc.GetAutoRouteAllowedDomains(ctx)
1734+ if err != nil {
1735+ h.sendError(w, http.StatusInternalServerError, "failed to get auto-route allowed domains")
1736+ return
1737+ }
1738+
1739+ h.sendJSON(w, http.StatusOK, dto.AutoRouteAllowedDomainsResponse{Domains: domains})
1740+}
1741+
1742+func (h *Handler) handleAutoRouteAllowedDomainsPost(w http.ResponseWriter, r *http.Request) {
1743+ ctx := r.Context()
1744+ log := zerowrap.FromCtx(ctx)
1745+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1746+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1747+ return
1748+ }
1749+
1750+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1751+ var req dto.AutoRouteAllowedDomainRequest
1752+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1753+ log.Warn().Err(err).Msg("invalid auto-route allowlist JSON")
1754+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
1755+ return
1756+ }
1757+
1758+ if err := h.configSvc.AddAutoRouteAllowedDomain(ctx, req.Pattern); err != nil {
1759+ if errors.Is(err, domain.ErrInvalidDomainPattern) {
1760+ h.sendError(w, http.StatusBadRequest, err.Error())
1761+ } else {
1762+ log.Error().Err(err).Str("pattern", req.Pattern).Msg("failed to add auto-route allowed domain")
1763+ h.sendError(w, http.StatusInternalServerError, "failed to add auto-route allowed domain")
1764+ }
1765+ return
1766+ }
1767+
1768+ h.sendJSON(w, http.StatusCreated, dto.AutoRouteStatusResponse{Status: "added"})
1769+}
1770+
1771+func (h *Handler) handleAutoRouteAllowedDomainsDelete(w http.ResponseWriter, r *http.Request, path string) {
1772+ ctx := r.Context()
1773+ log := zerowrap.FromCtx(ctx)
1774+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1775+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1776+ return
1777+ }
1778+
1779+ raw := strings.TrimPrefix(path, "/autoroute/allowed-domains/")
1780+ if raw == path || raw == "" || raw == "/" {
1781+ h.sendError(w, http.StatusBadRequest, "missing domain pattern")
1782+ return
1783+ }
1784+ pattern, err := url.PathUnescape(raw)
1785+ if err != nil || strings.TrimSpace(pattern) == "" {
1786+ h.sendError(w, http.StatusBadRequest, "invalid domain pattern")
1787+ return
1788+ }
1789+
1790+ if err := h.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern); err != nil {
1791+ log.Error().Err(err).Str("pattern", pattern).Msg("failed to remove auto-route allowed domain")
1792+ h.sendError(w, http.StatusInternalServerError, "failed to remove auto-route allowed domain")
1793+ return
1794+ }
1795+
1796+ h.sendJSON(w, http.StatusOK, dto.AutoRouteStatusResponse{Status: "removed"})
1797+}
1798+
1712 // handleAuthVerify handles /admin/auth/verify endpoint.1799 // handleAuthVerify handles /admin/auth/verify endpoint.
1713 // Validates authentication session and returns token status.1800 // Validates authentication session and returns token status.
1714 func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {1801 func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
M internal/adapters/out/filesystem/blob.go +69 -3
@@ -11,6 +11,7 @@ import (
1111 "os"
1212 "path/filepath"
1313 "strings"
14+ "sync"
1415
1516 "github.com/bnema/zerowrap"
1617 "github.com/google/uuid"
@@ -20,8 +21,9 @@ import (
2021
2122 // BlobStorage implements the BlobStorage interface using the local filesystem.
2223 type BlobStorage struct {
23- rootDir string
24- log zerowrap.Logger
24+ rootDir string
25+ log zerowrap.Logger
26+ uploadLocks sync.Map
2527 }
2628
2729 // NewBlobStorage creates a new filesystem blob storage instance.
@@ -249,9 +251,14 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64,
249251 return 0, fmt.Errorf("invalid upload path: %w", err)
250252 }
251253
254+ mu := s.getUploadLock(uuid)
255+ mu.Lock()
256+ defer mu.Unlock()
257+
252258 file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
253259 if err != nil {
254260 if os.IsNotExist(err) {
261+ s.cleanupUploadLock(uuid)
255262 return 0, fmt.Errorf("upload not found: %s", uuid)
256263 }
257264 return 0, fmt.Errorf("failed to open upload file: %w", err)
@@ -281,21 +288,47 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64,
281288 }
282289
283290 // GetBlobUpload returns a writer for the upload.
291+// The returned WriteCloser holds the per-upload lock; callers must Close it to release the lock.
284292 func (s *BlobStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
285293 uploadPath, err := s.getUploadPath(uuid)
286294 if err != nil {
287295 return nil, fmt.Errorf("invalid upload path: %w", err)
288296 }
289297
298+ mu := s.getUploadLock(uuid)
299+ mu.Lock()
300+
290301 file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
291302 if err != nil {
303+ mu.Unlock()
292304 if os.IsNotExist(err) {
305+ s.cleanupUploadLock(uuid)
293306 return nil, fmt.Errorf("upload not found: %s", uuid)
294307 }
295308 return nil, fmt.Errorf("failed to open upload file: %w", err)
296309 }
297310
298- return file, nil
311+ return &lockedWriteCloser{file: file, mu: mu}, nil
312+}
313+
314+// lockedWriteCloser wraps an io.WriteCloser and releases a mutex on Close.
315+type lockedWriteCloser struct {
316+ file io.WriteCloser
317+ mu *sync.Mutex
318+ closeOnce sync.Once
319+ closeErr error
320+}
321+
322+func (lw *lockedWriteCloser) Write(p []byte) (int, error) {
323+ return lw.file.Write(p)
324+}
325+
326+func (lw *lockedWriteCloser) Close() error {
327+ lw.closeOnce.Do(func() {
328+ defer lw.mu.Unlock()
329+ lw.closeErr = lw.file.Close()
330+ })
331+ return lw.closeErr
299332 }
300333
301334 // FinishBlobUpload completes an upload and moves it to blob storage.
@@ -304,6 +337,17 @@ func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error {
304337 if err != nil {
305338 return fmt.Errorf("invalid upload path: %w", err)
306339 }
340+
341+ mu := s.getUploadLock(uuid)
342+ mu.Lock()
343+ var finalized bool
344+ defer func() {
345+ mu.Unlock()
346+ if finalized {
347+ s.cleanupUploadLock(uuid)
348+ }
349+ }()
350+
307351 blobPath, err := s.getBlobPath(digest)
308352 if err != nil {
309353 return fmt.Errorf("invalid blob path: %w", err)
@@ -323,6 +367,7 @@ func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error {
323367 if err := os.Rename(uploadPath, blobPath); err != nil {
324368 return fmt.Errorf("failed to move upload to blob location: %w", err)
325369 }
370+ finalized = true
326371
327372 s.log.Info().
328373 Str(zerowrap.FieldLayer, "adapter").
@@ -341,12 +386,24 @@ func (s *BlobStorage) CancelBlobUpload(uuid string) error {
341386 return fmt.Errorf("invalid upload path: %w", err)
342387 }
343388
389+ mu := s.getUploadLock(uuid)
390+ mu.Lock()
391+ var finalized bool
392+ defer func() {
393+ mu.Unlock()
394+ if finalized {
395+ s.cleanupUploadLock(uuid)
396+ }
397+ }()
398+
344399 if err := os.Remove(uploadPath); err != nil {
345400 if os.IsNotExist(err) {
401+ s.cleanupUploadLock(uuid)
346402 return fmt.Errorf("upload not found: %s", uuid)
347403 }
348404 return fmt.Errorf("failed to cancel upload: %w", err)
349405 }
406+ finalized = true
350407
351408 s.log.Info().
352409 Str(zerowrap.FieldLayer, "adapter").
@@ -406,6 +463,15 @@ func (s *BlobStorage) getUploadPath(uuid string) (string, error) {
406463 return path, nil
407464 }
408465
466+func (s *BlobStorage) getUploadLock(uuid string) *sync.Mutex {
467+ v, _ := s.uploadLocks.LoadOrStore(uuid, &sync.Mutex{})
468+ return v.(*sync.Mutex)
469+}
470+
471+func (s *BlobStorage) cleanupUploadLock(uuid string) {
472+ s.uploadLocks.Delete(uuid)
473+}
474+
409475 // verifyUploadDigest computes and verifies the digest of an uploaded file.
410476 func (s *BlobStorage) verifyUploadDigest(uploadPath, expectedDigest string) error {
411477 parts := strings.SplitN(expectedDigest, ":", 2)
@@ -11,6 +11,7 @@ import (
11 "os"11 "os"
12 "path/filepath"12 "path/filepath"
13 "strings"13 "strings"
14+ "sync"
14 15
15 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
16 "github.com/google/uuid"17 "github.com/google/uuid"
@@ -20,8 +21,9 @@ import (
20 21
21 // BlobStorage implements the BlobStorage interface using the local filesystem.22 // BlobStorage implements the BlobStorage interface using the local filesystem.
22 type BlobStorage struct {23 type BlobStorage struct {
23- rootDir string24+ rootDir string
24- log zerowrap.Logger25+ log zerowrap.Logger
26+ uploadLocks sync.Map
25 }27 }
26 28
27 // NewBlobStorage creates a new filesystem blob storage instance.29 // NewBlobStorage creates a new filesystem blob storage instance.
@@ -249,9 +251,14 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64,
249 return 0, fmt.Errorf("invalid upload path: %w", err)251 return 0, fmt.Errorf("invalid upload path: %w", err)
250 }252 }
251 253
254+ mu := s.getUploadLock(uuid)
255+ mu.Lock()
256+ defer mu.Unlock()
257+
252 file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)258 file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
253 if err != nil {259 if err != nil {
254 if os.IsNotExist(err) {260 if os.IsNotExist(err) {
261+ s.cleanupUploadLock(uuid)
255 return 0, fmt.Errorf("upload not found: %s", uuid)262 return 0, fmt.Errorf("upload not found: %s", uuid)
256 }263 }
257 return 0, fmt.Errorf("failed to open upload file: %w", err)264 return 0, fmt.Errorf("failed to open upload file: %w", err)
@@ -281,21 +288,47 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64,
281 }288 }
282 289
283 // GetBlobUpload returns a writer for the upload.290 // GetBlobUpload returns a writer for the upload.
291+// The returned WriteCloser holds the per-upload lock; callers must Close it to release the lock.
284 func (s *BlobStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {292 func (s *BlobStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
285 uploadPath, err := s.getUploadPath(uuid)293 uploadPath, err := s.getUploadPath(uuid)
286 if err != nil {294 if err != nil {
287 return nil, fmt.Errorf("invalid upload path: %w", err)295 return nil, fmt.Errorf("invalid upload path: %w", err)
288 }296 }
289 297
298+ mu := s.getUploadLock(uuid)
299+ mu.Lock()
300+
290 file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)301 file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
291 if err != nil {302 if err != nil {
303+ mu.Unlock()
292 if os.IsNotExist(err) {304 if os.IsNotExist(err) {
305+ s.cleanupUploadLock(uuid)
293 return nil, fmt.Errorf("upload not found: %s", uuid)306 return nil, fmt.Errorf("upload not found: %s", uuid)
294 }307 }
295 return nil, fmt.Errorf("failed to open upload file: %w", err)308 return nil, fmt.Errorf("failed to open upload file: %w", err)
296 }309 }
297 310
298- return file, nil311+ return &lockedWriteCloser{file: file, mu: mu}, nil
312+}
313+
314+// lockedWriteCloser wraps an io.WriteCloser and releases a mutex on Close.
315+type lockedWriteCloser struct {
316+ file io.WriteCloser
317+ mu *sync.Mutex
318+ closeOnce sync.Once
319+ closeErr error
320+}
321+
322+func (lw *lockedWriteCloser) Write(p []byte) (int, error) {
323+ return lw.file.Write(p)
324+}
325+
326+func (lw *lockedWriteCloser) Close() error {
327+ lw.closeOnce.Do(func() {
328+ defer lw.mu.Unlock()
329+ lw.closeErr = lw.file.Close()
330+ })
331+ return lw.closeErr
299 }332 }
300 333
301 // FinishBlobUpload completes an upload and moves it to blob storage.334 // FinishBlobUpload completes an upload and moves it to blob storage.
@@ -304,6 +337,17 @@ func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error {
304 if err != nil {337 if err != nil {
305 return fmt.Errorf("invalid upload path: %w", err)338 return fmt.Errorf("invalid upload path: %w", err)
306 }339 }
340+
341+ mu := s.getUploadLock(uuid)
342+ mu.Lock()
343+ var finalized bool
344+ defer func() {
345+ mu.Unlock()
346+ if finalized {
347+ s.cleanupUploadLock(uuid)
348+ }
349+ }()
350+
307 blobPath, err := s.getBlobPath(digest)351 blobPath, err := s.getBlobPath(digest)
308 if err != nil {352 if err != nil {
309 return fmt.Errorf("invalid blob path: %w", err)353 return fmt.Errorf("invalid blob path: %w", err)
@@ -323,6 +367,7 @@ func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error {
323 if err := os.Rename(uploadPath, blobPath); err != nil {367 if err := os.Rename(uploadPath, blobPath); err != nil {
324 return fmt.Errorf("failed to move upload to blob location: %w", err)368 return fmt.Errorf("failed to move upload to blob location: %w", err)
325 }369 }
370+ finalized = true
326 371
327 s.log.Info().372 s.log.Info().
328 Str(zerowrap.FieldLayer, "adapter").373 Str(zerowrap.FieldLayer, "adapter").
@@ -341,12 +386,24 @@ func (s *BlobStorage) CancelBlobUpload(uuid string) error {
341 return fmt.Errorf("invalid upload path: %w", err)386 return fmt.Errorf("invalid upload path: %w", err)
342 }387 }
343 388
389+ mu := s.getUploadLock(uuid)
390+ mu.Lock()
391+ var finalized bool
392+ defer func() {
393+ mu.Unlock()
394+ if finalized {
395+ s.cleanupUploadLock(uuid)
396+ }
397+ }()
398+
344 if err := os.Remove(uploadPath); err != nil {399 if err := os.Remove(uploadPath); err != nil {
345 if os.IsNotExist(err) {400 if os.IsNotExist(err) {
401+ s.cleanupUploadLock(uuid)
346 return fmt.Errorf("upload not found: %s", uuid)402 return fmt.Errorf("upload not found: %s", uuid)
347 }403 }
348 return fmt.Errorf("failed to cancel upload: %w", err)404 return fmt.Errorf("failed to cancel upload: %w", err)
349 }405 }
406+ finalized = true
350 407
351 s.log.Info().408 s.log.Info().
352 Str(zerowrap.FieldLayer, "adapter").409 Str(zerowrap.FieldLayer, "adapter").
@@ -406,6 +463,15 @@ func (s *BlobStorage) getUploadPath(uuid string) (string, error) {
406 return path, nil463 return path, nil
407 }464 }
408 465
466+func (s *BlobStorage) getUploadLock(uuid string) *sync.Mutex {
467+ v, _ := s.uploadLocks.LoadOrStore(uuid, &sync.Mutex{})
468+ return v.(*sync.Mutex)
469+}
470+
471+func (s *BlobStorage) cleanupUploadLock(uuid string) {
472+ s.uploadLocks.Delete(uuid)
473+}
474+
409 // verifyUploadDigest computes and verifies the digest of an uploaded file.475 // verifyUploadDigest computes and verifies the digest of an uploaded file.
410 func (s *BlobStorage) verifyUploadDigest(uploadPath, expectedDigest string) error {476 func (s *BlobStorage) verifyUploadDigest(uploadPath, expectedDigest string) error {
411 parts := strings.SplitN(expectedDigest, ":", 2)477 parts := strings.SplitN(expectedDigest, ":", 2)
M internal/adapters/out/filesystem/blob_test.go +181 -0
@@ -2,9 +2,12 @@ package filesystem
22
33 import (
44 "bytes"
5+ "crypto/sha256"
6+ "encoding/hex"
57 "io"
68 "os"
79 "path/filepath"
10+ "sync"
811 "testing"
912
1013 "github.com/bnema/zerowrap"
@@ -484,3 +487,181 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) {
484487 err = storage.CancelBlobUpload(uuid)
485488 require.NoError(t, err)
486489 }
490+
491+func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
492+ tmpDir := t.TempDir()
493+ log := testLogger()
494+
495+ storage, err := NewBlobStorage(tmpDir, log)
496+ require.NoError(t, err)
497+
498+ uuid, err := storage.StartBlobUpload("myapp")
499+ require.NoError(t, err)
500+
501+ initialData := []byte("initial-")
502+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData))
503+ require.NoError(t, err)
504+
505+ appendChunk := []byte("chunk")
506+ appenders := 8
507+ var wg sync.WaitGroup
508+ appendReady := make(chan struct{})
509+ finalizeErrs := make(chan error, 1)
510+ appendErrs := make(chan error, appenders)
511+
512+ for range appenders {
513+ wg.Add(1)
514+ go func() {
515+ defer wg.Done()
516+ <-appendReady
517+ _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk))
518+ appendErrs <- appendErr
519+ }()
520+ }
521+
522+ close(appendReady)
523+ fullyAppended := appendRepeated(initialData, appendChunk, appenders)
524+
525+ wg.Add(1)
526+ go func() {
527+ defer wg.Done()
528+ finalizeErrs <- storage.FinishBlobUpload(uuid, digestForData(fullyAppended))
529+ }()
530+
531+ wg.Wait()
532+ close(appendErrs)
533+
534+ finalizeErr := <-finalizeErrs
535+ if finalizeErr == nil {
536+ for appendErr := range appendErrs {
537+ require.NoError(t, appendErr)
538+ }
539+
540+ reader, err := storage.GetBlob(digestForData(fullyAppended))
541+ require.NoError(t, err)
542+ defer reader.Close()
543+
544+ blobData, err := io.ReadAll(reader)
545+ require.NoError(t, err)
546+ assert.Equal(t, fullyAppended, blobData)
547+ } else {
548+ assert.Contains(t, finalizeErr.Error(), "digest verification failed")
549+
550+ appendCount := 0
551+ for appendErr := range appendErrs {
552+ require.NoError(t, appendErr)
553+ appendCount++
554+ }
555+
556+ require.Equal(t, appenders, appendCount)
557+
558+ retryDigest := digestForData(fullyAppended)
559+ err = storage.FinishBlobUpload(uuid, retryDigest)
560+ require.NoError(t, err)
561+
562+ reader, err := storage.GetBlob(retryDigest)
563+ require.NoError(t, err)
564+ defer reader.Close()
565+
566+ blobData, err := io.ReadAll(reader)
567+ require.NoError(t, err)
568+ assert.Equal(t, fullyAppended, blobData)
569+ }
570+
571+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")))
572+ assert.Error(t, err)
573+ assert.Contains(t, err.Error(), "upload not found")
574+}
575+
576+func TestBlobStorage_AppendAfterFinalize(t *testing.T) {
577+ tmpDir := t.TempDir()
578+ log := testLogger()
579+
580+ storage, err := NewBlobStorage(tmpDir, log)
581+ require.NoError(t, err)
582+
583+ uuid, err := storage.StartBlobUpload("myapp")
584+ require.NoError(t, err)
585+
586+ blobData := []byte("finalized content")
587+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))
588+ require.NoError(t, err)
589+
590+ err = storage.FinishBlobUpload(uuid, digestForData(blobData))
591+ require.NoError(t, err)
592+
593+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")))
594+ assert.Error(t, err)
595+ assert.Contains(t, err.Error(), "upload not found")
596+}
597+
598+func TestBlobStorage_FailedFinalizeRetryable(t *testing.T) {
599+ tmpDir := t.TempDir()
600+ log := testLogger()
601+
602+ storage, err := NewBlobStorage(tmpDir, log)
603+ require.NoError(t, err)
604+
605+ uuid, err := storage.StartBlobUpload("myapp")
606+ require.NoError(t, err)
607+
608+ blobData := []byte("retryable finalize")
609+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))
610+ require.NoError(t, err)
611+
612+ err = storage.FinishBlobUpload(uuid, testDigest1)
613+ assert.Error(t, err)
614+ assert.Contains(t, err.Error(), "digest verification failed")
615+
616+ err = storage.FinishBlobUpload(uuid, digestForData(blobData))
617+ require.NoError(t, err)
618+}
619+
620+func TestBlobStorage_SequentialFlow(t *testing.T) {
621+ tmpDir := t.TempDir()
622+ log := testLogger()
623+
624+ storage, err := NewBlobStorage(tmpDir, log)
625+ require.NoError(t, err)
626+
627+ uuid, err := storage.StartBlobUpload("myapp")
628+ require.NoError(t, err)
629+
630+ chunks := [][]byte{
631+ []byte("one-"),
632+ []byte("two-"),
633+ []byte("three"),
634+ }
635+
636+ var blobData []byte
637+ for _, chunk := range chunks {
638+ blobData = append(blobData, chunk...)
639+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk))
640+ require.NoError(t, err)
641+ }
642+
643+ digest := digestForData(blobData)
644+ err = storage.FinishBlobUpload(uuid, digest)
645+ require.NoError(t, err)
646+
647+ reader, err := storage.GetBlob(digest)
648+ require.NoError(t, err)
649+ defer reader.Close()
650+
651+ storedData, err := io.ReadAll(reader)
652+ require.NoError(t, err)
653+ assert.Equal(t, blobData, storedData)
654+}
655+
656+func digestForData(data []byte) string {
657+ sum := sha256.Sum256(data)
658+ return "sha256:" + hex.EncodeToString(sum[:])
659+}
660+
661+func appendRepeated(initialData, chunk []byte, repetitions int) []byte {
662+ data := append([]byte{}, initialData...)
663+ for range repetitions {
664+ data = append(data, chunk...)
665+ }
666+ return data
667+}
@@ -2,9 +2,12 @@ package filesystem
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "crypto/sha256"
6+ "encoding/hex"
5 "io"7 "io"
6 "os"8 "os"
7 "path/filepath"9 "path/filepath"
10+ "sync"
8 "testing"11 "testing"
9 12
10 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
@@ -484,3 +487,181 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) {
484 err = storage.CancelBlobUpload(uuid)487 err = storage.CancelBlobUpload(uuid)
485 require.NoError(t, err)488 require.NoError(t, err)
486 }489 }
490+
491+func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
492+ tmpDir := t.TempDir()
493+ log := testLogger()
494+
495+ storage, err := NewBlobStorage(tmpDir, log)
496+ require.NoError(t, err)
497+
498+ uuid, err := storage.StartBlobUpload("myapp")
499+ require.NoError(t, err)
500+
501+ initialData := []byte("initial-")
502+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData))
503+ require.NoError(t, err)
504+
505+ appendChunk := []byte("chunk")
506+ appenders := 8
507+ var wg sync.WaitGroup
508+ appendReady := make(chan struct{})
509+ finalizeErrs := make(chan error, 1)
510+ appendErrs := make(chan error, appenders)
511+
512+ for range appenders {
513+ wg.Add(1)
514+ go func() {
515+ defer wg.Done()
516+ <-appendReady
517+ _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk))
518+ appendErrs <- appendErr
519+ }()
520+ }
521+
522+ close(appendReady)
523+ fullyAppended := appendRepeated(initialData, appendChunk, appenders)
524+
525+ wg.Add(1)
526+ go func() {
527+ defer wg.Done()
528+ finalizeErrs <- storage.FinishBlobUpload(uuid, digestForData(fullyAppended))
529+ }()
530+
531+ wg.Wait()
532+ close(appendErrs)
533+
534+ finalizeErr := <-finalizeErrs
535+ if finalizeErr == nil {
536+ for appendErr := range appendErrs {
537+ require.NoError(t, appendErr)
538+ }
539+
540+ reader, err := storage.GetBlob(digestForData(fullyAppended))
541+ require.NoError(t, err)
542+ defer reader.Close()
543+
544+ blobData, err := io.ReadAll(reader)
545+ require.NoError(t, err)
546+ assert.Equal(t, fullyAppended, blobData)
547+ } else {
548+ assert.Contains(t, finalizeErr.Error(), "digest verification failed")
549+
550+ appendCount := 0
551+ for appendErr := range appendErrs {
552+ require.NoError(t, appendErr)
553+ appendCount++
554+ }
555+
556+ require.Equal(t, appenders, appendCount)
557+
558+ retryDigest := digestForData(fullyAppended)
559+ err = storage.FinishBlobUpload(uuid, retryDigest)
560+ require.NoError(t, err)
561+
562+ reader, err := storage.GetBlob(retryDigest)
563+ require.NoError(t, err)
564+ defer reader.Close()
565+
566+ blobData, err := io.ReadAll(reader)
567+ require.NoError(t, err)
568+ assert.Equal(t, fullyAppended, blobData)
569+ }
570+
571+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")))
572+ assert.Error(t, err)
573+ assert.Contains(t, err.Error(), "upload not found")
574+}
575+
576+func TestBlobStorage_AppendAfterFinalize(t *testing.T) {
577+ tmpDir := t.TempDir()
578+ log := testLogger()
579+
580+ storage, err := NewBlobStorage(tmpDir, log)
581+ require.NoError(t, err)
582+
583+ uuid, err := storage.StartBlobUpload("myapp")
584+ require.NoError(t, err)
585+
586+ blobData := []byte("finalized content")
587+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))
588+ require.NoError(t, err)
589+
590+ err = storage.FinishBlobUpload(uuid, digestForData(blobData))
591+ require.NoError(t, err)
592+
593+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")))
594+ assert.Error(t, err)
595+ assert.Contains(t, err.Error(), "upload not found")
596+}
597+
598+func TestBlobStorage_FailedFinalizeRetryable(t *testing.T) {
599+ tmpDir := t.TempDir()
600+ log := testLogger()
601+
602+ storage, err := NewBlobStorage(tmpDir, log)
603+ require.NoError(t, err)
604+
605+ uuid, err := storage.StartBlobUpload("myapp")
606+ require.NoError(t, err)
607+
608+ blobData := []byte("retryable finalize")
609+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))
610+ require.NoError(t, err)
611+
612+ err = storage.FinishBlobUpload(uuid, testDigest1)
613+ assert.Error(t, err)
614+ assert.Contains(t, err.Error(), "digest verification failed")
615+
616+ err = storage.FinishBlobUpload(uuid, digestForData(blobData))
617+ require.NoError(t, err)
618+}
619+
620+func TestBlobStorage_SequentialFlow(t *testing.T) {
621+ tmpDir := t.TempDir()
622+ log := testLogger()
623+
624+ storage, err := NewBlobStorage(tmpDir, log)
625+ require.NoError(t, err)
626+
627+ uuid, err := storage.StartBlobUpload("myapp")
628+ require.NoError(t, err)
629+
630+ chunks := [][]byte{
631+ []byte("one-"),
632+ []byte("two-"),
633+ []byte("three"),
634+ }
635+
636+ var blobData []byte
637+ for _, chunk := range chunks {
638+ blobData = append(blobData, chunk...)
639+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk))
640+ require.NoError(t, err)
641+ }
642+
643+ digest := digestForData(blobData)
644+ err = storage.FinishBlobUpload(uuid, digest)
645+ require.NoError(t, err)
646+
647+ reader, err := storage.GetBlob(digest)
648+ require.NoError(t, err)
649+ defer reader.Close()
650+
651+ storedData, err := io.ReadAll(reader)
652+ require.NoError(t, err)
653+ assert.Equal(t, blobData, storedData)
654+}
655+
656+func digestForData(data []byte) string {
657+ sum := sha256.Sum256(data)
658+ return "sha256:" + hex.EncodeToString(sum[:])
659+}
660+
661+func appendRepeated(initialData, chunk []byte, repetitions int) []byte {
662+ data := append([]byte{}, initialData...)
663+ for range repetitions {
664+ data = append(data, chunk...)
665+ }
666+ return data
667+}
M internal/boundaries/in/config.go +5 -0
@@ -79,4 +79,9 @@ type ConfigService interface {
7979
8080 // RemoveAttachment removes an image from a domain/group's attachments.
8181 RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
82+
83+ // Auto-route domain allowlist
84+ GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error)
85+ AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error
86+ RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error
8287 }
@@ -79,4 +79,9 @@ type ConfigService interface {
79 79
80 // RemoveAttachment removes an image from a domain/group's attachments.80 // RemoveAttachment removes an image from a domain/group's attachments.
81 RemoveAttachment(ctx context.Context, domainOrGroup, image string) error81 RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
82+
83+ // Auto-route domain allowlist
84+ GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error)
85+ AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error
86+ RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error
82 }87 }
M internal/boundaries/in/mocks/mock_config_service.go +176 -0
@@ -101,6 +101,63 @@ func (_c *MockConfigService_AddAttachment_Call) RunAndReturn(run func(ctx contex
101101 return _c
102102 }
103103
104+// AddAutoRouteAllowedDomain provides a mock function for the type MockConfigService
105+func (_mock *MockConfigService) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
106+ ret := _mock.Called(ctx, pattern)
107+
108+ if len(ret) == 0 {
109+ panic("no return value specified for AddAutoRouteAllowedDomain")
110+ }
111+
112+ var r0 error
113+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
114+ r0 = returnFunc(ctx, pattern)
115+ } else {
116+ r0 = ret.Error(0)
117+ }
118+ return r0
119+}
120+
121+// MockConfigService_AddAutoRouteAllowedDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAutoRouteAllowedDomain'
122+type MockConfigService_AddAutoRouteAllowedDomain_Call struct {
123+ *mock.Call
124+}
125+
126+// AddAutoRouteAllowedDomain is a helper method to define mock.On call
127+// - ctx context.Context
128+// - pattern string
129+func (_e *MockConfigService_Expecter) AddAutoRouteAllowedDomain(ctx interface{}, pattern interface{}) *MockConfigService_AddAutoRouteAllowedDomain_Call {
130+ return &MockConfigService_AddAutoRouteAllowedDomain_Call{Call: _e.mock.On("AddAutoRouteAllowedDomain", ctx, pattern)}
131+}
132+
133+func (_c *MockConfigService_AddAutoRouteAllowedDomain_Call) Run(run func(ctx context.Context, pattern string)) *MockConfigService_AddAutoRouteAllowedDomain_Call {
134+ _c.Call.Run(func(args mock.Arguments) {
135+ var arg0 context.Context
136+ if args[0] != nil {
137+ arg0 = args[0].(context.Context)
138+ }
139+ var arg1 string
140+ if args[1] != nil {
141+ arg1 = args[1].(string)
142+ }
143+ run(
144+ arg0,
145+ arg1,
146+ )
147+ })
148+ return _c
149+}
150+
151+func (_c *MockConfigService_AddAutoRouteAllowedDomain_Call) Return(err error) *MockConfigService_AddAutoRouteAllowedDomain_Call {
152+ _c.Call.Return(err)
153+ return _c
154+}
155+
156+func (_c *MockConfigService_AddAutoRouteAllowedDomain_Call) RunAndReturn(run func(ctx context.Context, pattern string) error) *MockConfigService_AddAutoRouteAllowedDomain_Call {
157+ _c.Call.Return(run)
158+ return _c
159+}
160+
104161 // AddRoute provides a mock function for the type MockConfigService
105162 func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error {
106163 ret := _mock.Called(ctx, route)
@@ -397,6 +454,68 @@ func (_c *MockConfigService_GetAttachmentsFor_Call) RunAndReturn(run func(ctx co
397454 return _c
398455 }
399456
457+// GetAutoRouteAllowedDomains provides a mock function for the type MockConfigService
458+func (_mock *MockConfigService) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
459+ ret := _mock.Called(ctx)
460+
461+ if len(ret) == 0 {
462+ panic("no return value specified for GetAutoRouteAllowedDomains")
463+ }
464+
465+ var r0 []string
466+ var r1 error
467+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
468+ return returnFunc(ctx)
469+ }
470+ if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
471+ r0 = returnFunc(ctx)
472+ } else {
473+ if ret.Get(0) != nil {
474+ r0 = ret.Get(0).([]string)
475+ }
476+ }
477+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
478+ r1 = returnFunc(ctx)
479+ } else {
480+ r1 = ret.Error(1)
481+ }
482+ return r0, r1
483+}
484+
485+// MockConfigService_GetAutoRouteAllowedDomains_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAutoRouteAllowedDomains'
486+type MockConfigService_GetAutoRouteAllowedDomains_Call struct {
487+ *mock.Call
488+}
489+
490+// GetAutoRouteAllowedDomains is a helper method to define mock.On call
491+// - ctx context.Context
492+func (_e *MockConfigService_Expecter) GetAutoRouteAllowedDomains(ctx interface{}) *MockConfigService_GetAutoRouteAllowedDomains_Call {
493+ return &MockConfigService_GetAutoRouteAllowedDomains_Call{Call: _e.mock.On("GetAutoRouteAllowedDomains", ctx)}
494+}
495+
496+func (_c *MockConfigService_GetAutoRouteAllowedDomains_Call) Run(run func(ctx context.Context)) *MockConfigService_GetAutoRouteAllowedDomains_Call {
497+ _c.Call.Run(func(args mock.Arguments) {
498+ var arg0 context.Context
499+ if args[0] != nil {
500+ arg0 = args[0].(context.Context)
501+ }
502+ run(
503+ arg0,
504+ )
505+ })
506+ return _c
507+}
508+
509+func (_c *MockConfigService_GetAutoRouteAllowedDomains_Call) Return(strings []string, err error) *MockConfigService_GetAutoRouteAllowedDomains_Call {
510+ _c.Call.Return(strings, err)
511+ return _c
512+}
513+
514+func (_c *MockConfigService_GetAutoRouteAllowedDomains_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockConfigService_GetAutoRouteAllowedDomains_Call {
515+ _c.Call.Return(run)
516+ return _c
517+}
518+
400519 // GetDataDir provides a mock function for the type MockConfigService
401520 func (_mock *MockConfigService) GetDataDir() string {
402521 ret := _mock.Called()
@@ -1037,6 +1156,63 @@ func (_c *MockConfigService_RemoveAttachment_Call) RunAndReturn(run func(ctx con
10371156 return _c
10381157 }
10391158
1159+// RemoveAutoRouteAllowedDomain provides a mock function for the type MockConfigService
1160+func (_mock *MockConfigService) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1161+ ret := _mock.Called(ctx, pattern)
1162+
1163+ if len(ret) == 0 {
1164+ panic("no return value specified for RemoveAutoRouteAllowedDomain")
1165+ }
1166+
1167+ var r0 error
1168+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
1169+ r0 = returnFunc(ctx, pattern)
1170+ } else {
1171+ r0 = ret.Error(0)
1172+ }
1173+ return r0
1174+}
1175+
1176+// MockConfigService_RemoveAutoRouteAllowedDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAutoRouteAllowedDomain'
1177+type MockConfigService_RemoveAutoRouteAllowedDomain_Call struct {
1178+ *mock.Call
1179+}
1180+
1181+// RemoveAutoRouteAllowedDomain is a helper method to define mock.On call
1182+// - ctx context.Context
1183+// - pattern string
1184+func (_e *MockConfigService_Expecter) RemoveAutoRouteAllowedDomain(ctx interface{}, pattern interface{}) *MockConfigService_RemoveAutoRouteAllowedDomain_Call {
1185+ return &MockConfigService_RemoveAutoRouteAllowedDomain_Call{Call: _e.mock.On("RemoveAutoRouteAllowedDomain", ctx, pattern)}
1186+}
1187+
1188+func (_c *MockConfigService_RemoveAutoRouteAllowedDomain_Call) Run(run func(ctx context.Context, pattern string)) *MockConfigService_RemoveAutoRouteAllowedDomain_Call {
1189+ _c.Call.Run(func(args mock.Arguments) {
1190+ var arg0 context.Context
1191+ if args[0] != nil {
1192+ arg0 = args[0].(context.Context)
1193+ }
1194+ var arg1 string
1195+ if args[1] != nil {
1196+ arg1 = args[1].(string)
1197+ }
1198+ run(
1199+ arg0,
1200+ arg1,
1201+ )
1202+ })
1203+ return _c
1204+}
1205+
1206+func (_c *MockConfigService_RemoveAutoRouteAllowedDomain_Call) Return(err error) *MockConfigService_RemoveAutoRouteAllowedDomain_Call {
1207+ _c.Call.Return(err)
1208+ return _c
1209+}
1210+
1211+func (_c *MockConfigService_RemoveAutoRouteAllowedDomain_Call) RunAndReturn(run func(ctx context.Context, pattern string) error) *MockConfigService_RemoveAutoRouteAllowedDomain_Call {
1212+ _c.Call.Return(run)
1213+ return _c
1214+}
1215+
10401216 // RemoveRoute provides a mock function for the type MockConfigService
10411217 func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error {
10421218 ret := _mock.Called(ctx, domain1)
@@ -101,6 +101,63 @@ func (_c *MockConfigService_AddAttachment_Call) RunAndReturn(run func(ctx contex
101 return _c101 return _c
102 }102 }
103 103
104+// AddAutoRouteAllowedDomain provides a mock function for the type MockConfigService
105+func (_mock *MockConfigService) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
106+ ret := _mock.Called(ctx, pattern)
107+
108+ if len(ret) == 0 {
109+ panic("no return value specified for AddAutoRouteAllowedDomain")
110+ }
111+
112+ var r0 error
113+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
114+ r0 = returnFunc(ctx, pattern)
115+ } else {
116+ r0 = ret.Error(0)
117+ }
118+ return r0
119+}
120+
121+// MockConfigService_AddAutoRouteAllowedDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAutoRouteAllowedDomain'
122+type MockConfigService_AddAutoRouteAllowedDomain_Call struct {
123+ *mock.Call
124+}
125+
126+// AddAutoRouteAllowedDomain is a helper method to define mock.On call
127+// - ctx context.Context
128+// - pattern string
129+func (_e *MockConfigService_Expecter) AddAutoRouteAllowedDomain(ctx interface{}, pattern interface{}) *MockConfigService_AddAutoRouteAllowedDomain_Call {
130+ return &MockConfigService_AddAutoRouteAllowedDomain_Call{Call: _e.mock.On("AddAutoRouteAllowedDomain", ctx, pattern)}
131+}
132+
133+func (_c *MockConfigService_AddAutoRouteAllowedDomain_Call) Run(run func(ctx context.Context, pattern string)) *MockConfigService_AddAutoRouteAllowedDomain_Call {
134+ _c.Call.Run(func(args mock.Arguments) {
135+ var arg0 context.Context
136+ if args[0] != nil {
137+ arg0 = args[0].(context.Context)
138+ }
139+ var arg1 string
140+ if args[1] != nil {
141+ arg1 = args[1].(string)
142+ }
143+ run(
144+ arg0,
145+ arg1,
146+ )
147+ })
148+ return _c
149+}
150+
151+func (_c *MockConfigService_AddAutoRouteAllowedDomain_Call) Return(err error) *MockConfigService_AddAutoRouteAllowedDomain_Call {
152+ _c.Call.Return(err)
153+ return _c
154+}
155+
156+func (_c *MockConfigService_AddAutoRouteAllowedDomain_Call) RunAndReturn(run func(ctx context.Context, pattern string) error) *MockConfigService_AddAutoRouteAllowedDomain_Call {
157+ _c.Call.Return(run)
158+ return _c
159+}
160+
104 // AddRoute provides a mock function for the type MockConfigService161 // AddRoute provides a mock function for the type MockConfigService
105 func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error {162 func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error {
106 ret := _mock.Called(ctx, route)163 ret := _mock.Called(ctx, route)
@@ -397,6 +454,68 @@ func (_c *MockConfigService_GetAttachmentsFor_Call) RunAndReturn(run func(ctx co
397 return _c454 return _c
398 }455 }
399 456
457+// GetAutoRouteAllowedDomains provides a mock function for the type MockConfigService
458+func (_mock *MockConfigService) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
459+ ret := _mock.Called(ctx)
460+
461+ if len(ret) == 0 {
462+ panic("no return value specified for GetAutoRouteAllowedDomains")
463+ }
464+
465+ var r0 []string
466+ var r1 error
467+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
468+ return returnFunc(ctx)
469+ }
470+ if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
471+ r0 = returnFunc(ctx)
472+ } else {
473+ if ret.Get(0) != nil {
474+ r0 = ret.Get(0).([]string)
475+ }
476+ }
477+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
478+ r1 = returnFunc(ctx)
479+ } else {
480+ r1 = ret.Error(1)
481+ }
482+ return r0, r1
483+}
484+
485+// MockConfigService_GetAutoRouteAllowedDomains_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAutoRouteAllowedDomains'
486+type MockConfigService_GetAutoRouteAllowedDomains_Call struct {
487+ *mock.Call
488+}
489+
490+// GetAutoRouteAllowedDomains is a helper method to define mock.On call
491+// - ctx context.Context
492+func (_e *MockConfigService_Expecter) GetAutoRouteAllowedDomains(ctx interface{}) *MockConfigService_GetAutoRouteAllowedDomains_Call {
493+ return &MockConfigService_GetAutoRouteAllowedDomains_Call{Call: _e.mock.On("GetAutoRouteAllowedDomains", ctx)}
494+}
495+
496+func (_c *MockConfigService_GetAutoRouteAllowedDomains_Call) Run(run func(ctx context.Context)) *MockConfigService_GetAutoRouteAllowedDomains_Call {
497+ _c.Call.Run(func(args mock.Arguments) {
498+ var arg0 context.Context
499+ if args[0] != nil {
500+ arg0 = args[0].(context.Context)
501+ }
502+ run(
503+ arg0,
504+ )
505+ })
506+ return _c
507+}
508+
509+func (_c *MockConfigService_GetAutoRouteAllowedDomains_Call) Return(strings []string, err error) *MockConfigService_GetAutoRouteAllowedDomains_Call {
510+ _c.Call.Return(strings, err)
511+ return _c
512+}
513+
514+func (_c *MockConfigService_GetAutoRouteAllowedDomains_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockConfigService_GetAutoRouteAllowedDomains_Call {
515+ _c.Call.Return(run)
516+ return _c
517+}
518+
400 // GetDataDir provides a mock function for the type MockConfigService519 // GetDataDir provides a mock function for the type MockConfigService
401 func (_mock *MockConfigService) GetDataDir() string {520 func (_mock *MockConfigService) GetDataDir() string {
402 ret := _mock.Called()521 ret := _mock.Called()
@@ -1037,6 +1156,63 @@ func (_c *MockConfigService_RemoveAttachment_Call) RunAndReturn(run func(ctx con
1037 return _c1156 return _c
1038 }1157 }
1039 1158
1159+// RemoveAutoRouteAllowedDomain provides a mock function for the type MockConfigService
1160+func (_mock *MockConfigService) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1161+ ret := _mock.Called(ctx, pattern)
1162+
1163+ if len(ret) == 0 {
1164+ panic("no return value specified for RemoveAutoRouteAllowedDomain")
1165+ }
1166+
1167+ var r0 error
1168+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
1169+ r0 = returnFunc(ctx, pattern)
1170+ } else {
1171+ r0 = ret.Error(0)
1172+ }
1173+ return r0
1174+}
1175+
1176+// MockConfigService_RemoveAutoRouteAllowedDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAutoRouteAllowedDomain'
1177+type MockConfigService_RemoveAutoRouteAllowedDomain_Call struct {
1178+ *mock.Call
1179+}
1180+
1181+// RemoveAutoRouteAllowedDomain is a helper method to define mock.On call
1182+// - ctx context.Context
1183+// - pattern string
1184+func (_e *MockConfigService_Expecter) RemoveAutoRouteAllowedDomain(ctx interface{}, pattern interface{}) *MockConfigService_RemoveAutoRouteAllowedDomain_Call {
1185+ return &MockConfigService_RemoveAutoRouteAllowedDomain_Call{Call: _e.mock.On("RemoveAutoRouteAllowedDomain", ctx, pattern)}
1186+}
1187+
1188+func (_c *MockConfigService_RemoveAutoRouteAllowedDomain_Call) Run(run func(ctx context.Context, pattern string)) *MockConfigService_RemoveAutoRouteAllowedDomain_Call {
1189+ _c.Call.Run(func(args mock.Arguments) {
1190+ var arg0 context.Context
1191+ if args[0] != nil {
1192+ arg0 = args[0].(context.Context)
1193+ }
1194+ var arg1 string
1195+ if args[1] != nil {
1196+ arg1 = args[1].(string)
1197+ }
1198+ run(
1199+ arg0,
1200+ arg1,
1201+ )
1202+ })
1203+ return _c
1204+}
1205+
1206+func (_c *MockConfigService_RemoveAutoRouteAllowedDomain_Call) Return(err error) *MockConfigService_RemoveAutoRouteAllowedDomain_Call {
1207+ _c.Call.Return(err)
1208+ return _c
1209+}
1210+
1211+func (_c *MockConfigService_RemoveAutoRouteAllowedDomain_Call) RunAndReturn(run func(ctx context.Context, pattern string) error) *MockConfigService_RemoveAutoRouteAllowedDomain_Call {
1212+ _c.Call.Return(run)
1213+ return _c
1214+}
1215+
1040 // RemoveRoute provides a mock function for the type MockConfigService1216 // RemoveRoute provides a mock function for the type MockConfigService
1041 func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error {1217 func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error {
1042 ret := _mock.Called(ctx, domain1)1218 ret := _mock.Called(ctx, domain1)
M internal/domain/errors.go +10 -3
@@ -41,9 +41,10 @@ var (
4141 ErrVolumeExists = errors.New("volume already exists")
4242
4343 // Config errors
44- ErrConfigNotFound = errors.New("configuration not found")
45- ErrInvalidConfig = errors.New("invalid configuration")
46- ErrConfigLoadFailed = errors.New("failed to load configuration")
44+ ErrConfigNotFound = errors.New("configuration not found")
45+ ErrInvalidConfig = errors.New("invalid configuration")
46+ ErrConfigLoadFailed = errors.New("failed to load configuration")
47+ ErrInvalidDomainPattern = errors.New("invalid domain pattern")
4748
4849 // Environment errors
4950 ErrEnvFileNotFound = errors.New("environment file not found")
@@ -66,6 +67,12 @@ var (
6667 ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
6768 ErrInvalidEnvKey = errors.New("invalid environment variable key")
6869
70+ // Remote errors
71+ ErrEmptyRemoteName = errors.New("remote name must not be empty")
72+ ErrInvalidRemoteNameChar = errors.New("remote name contains invalid characters")
73+ ErrConsecutiveDots = errors.New("remote name must not contain consecutive dots")
74+ ErrPassUnavailable = errors.New("pass is not available")
75+
6976 // Attachment errors
7077 ErrAttachmentNotFound = errors.New("attachment not found")
7178 ErrAttachmentExists = errors.New("attachment already exists")
@@ -41,9 +41,10 @@ var (
41 ErrVolumeExists = errors.New("volume already exists")41 ErrVolumeExists = errors.New("volume already exists")
42 42
43 // Config errors43 // Config errors
44- ErrConfigNotFound = errors.New("configuration not found")44+ ErrConfigNotFound = errors.New("configuration not found")
45- ErrInvalidConfig = errors.New("invalid configuration")45+ ErrInvalidConfig = errors.New("invalid configuration")
46- ErrConfigLoadFailed = errors.New("failed to load configuration")46+ ErrConfigLoadFailed = errors.New("failed to load configuration")
47+ ErrInvalidDomainPattern = errors.New("invalid domain pattern")
47 48
48 // Environment errors49 // Environment errors
49 ErrEnvFileNotFound = errors.New("environment file not found")50 ErrEnvFileNotFound = errors.New("environment file not found")
@@ -66,6 +67,12 @@ var (
66 ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")67 ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
67 ErrInvalidEnvKey = errors.New("invalid environment variable key")68 ErrInvalidEnvKey = errors.New("invalid environment variable key")
68 69
70+ // Remote errors
71+ ErrEmptyRemoteName = errors.New("remote name must not be empty")
72+ ErrInvalidRemoteNameChar = errors.New("remote name contains invalid characters")
73+ ErrConsecutiveDots = errors.New("remote name must not contain consecutive dots")
74+ ErrPassUnavailable = errors.New("pass is not available")
75+
69 // Attachment errors76 // Attachment errors
70 ErrAttachmentNotFound = errors.New("attachment not found")77 ErrAttachmentNotFound = errors.New("attachment not found")
71 ErrAttachmentExists = errors.New("attachment already exists")78 ErrAttachmentExists = errors.New("attachment already exists")
M internal/usecase/config/service.go +166 -36
@@ -8,6 +8,7 @@ import (
88 "os"
99 "path/filepath"
1010 "reflect"
11+ "regexp"
1112 "slices"
1213 "strconv"
1314 "strings"
@@ -26,23 +27,24 @@ import (
2627
2728 // Config holds the loaded configuration.
2829 type Config struct {
29- ServerPort int
30- RegistryPort int
31- RegistryDomain string
32- DataDir string
33- AutoRouteEnabled bool
34- NetworkIsolation bool
35- NetworkPrefix string
36- Routes map[string]string
37- ExternalRoutes map[string]string // domain -> "host:port"
38- RegistryAuthEnabled bool
39- RegistryAuthUsername string
40- RegistryAuthPassword string
41- VolumeAutoCreate bool
42- VolumePrefix string
43- VolumePreserve bool
44- NetworkGroups map[string][]string
45- Attachments map[string][]string
30+ ServerPort int
31+ RegistryPort int
32+ RegistryDomain string
33+ DataDir string
34+ AutoRouteEnabled bool
35+ AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"`
36+ NetworkIsolation bool
37+ NetworkPrefix string
38+ Routes map[string]string
39+ ExternalRoutes map[string]string // domain -> "host:port"
40+ RegistryAuthEnabled bool
41+ RegistryAuthUsername string
42+ RegistryAuthPassword string
43+ VolumeAutoCreate bool
44+ VolumePrefix string
45+ VolumePreserve bool
46+ NetworkGroups map[string][]string
47+ Attachments map[string][]string
4648 }
4749
4850 // Service implements the ConfigService interface.
@@ -121,23 +123,24 @@ func (s *Service) loadConfigValues() Config {
121123 }
122124
123125 return Config{
124- ServerPort: s.viper.GetInt("server.port"),
125- RegistryPort: s.viper.GetInt("server.registry_port"),
126- RegistryDomain: registryDomain,
127- DataDir: s.viper.GetString("server.data_dir"),
128- AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
129- NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
130- NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
131- RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
132- RegistryAuthUsername: s.viper.GetString("auth.username"),
133- RegistryAuthPassword: s.viper.GetString("auth.password"),
134- VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
135- VolumePrefix: s.viper.GetString("volumes.prefix"),
136- VolumePreserve: s.viper.GetBool("volumes.preserve"),
137- Routes: make(map[string]string),
138- ExternalRoutes: make(map[string]string),
139- NetworkGroups: make(map[string][]string),
140- Attachments: make(map[string][]string),
126+ ServerPort: s.viper.GetInt("server.port"),
127+ RegistryPort: s.viper.GetInt("server.registry_port"),
128+ RegistryDomain: registryDomain,
129+ DataDir: s.viper.GetString("server.data_dir"),
130+ AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
131+ AutoRouteAllowedDomains: append([]string{}, s.viper.GetStringSlice("auto_route_allowed_domains")...),
132+ NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
133+ NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
134+ RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
135+ RegistryAuthUsername: s.viper.GetString("auth.username"),
136+ RegistryAuthPassword: s.viper.GetString("auth.password"),
137+ VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
138+ VolumePrefix: s.viper.GetString("volumes.prefix"),
139+ VolumePreserve: s.viper.GetBool("volumes.preserve"),
140+ Routes: make(map[string]string),
141+ ExternalRoutes: make(map[string]string),
142+ NetworkGroups: make(map[string][]string),
143+ Attachments: make(map[string][]string),
141144 }
142145 }
143146
@@ -516,7 +519,7 @@ type configSnapshot struct {
516519 }
517520
518521 // mutableSections lists config sections that are intentionally modified by service operations.
519-var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups."}
522+var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups.", "auto_route_allowed_domains"}
520523
521524 func isMutableKey(key string) bool {
522525 for _, prefix := range mutableSections {
@@ -526,7 +529,7 @@ func isMutableKey(key string) bool {
526529 }
527530
528531 switch key {
529- case "routes", "external_routes", "attachments", "network_groups":
532+ case "routes", "external_routes", "attachments", "network_groups", "auto_route_allowed_domains":
530533 return true
531534 }
532535
@@ -566,6 +569,7 @@ func (s *Service) writeConfigSurgical(configFile string) error {
566569 s.viper.Set("external_routes", s.config.ExternalRoutes)
567570 s.viper.Set("attachments", s.config.Attachments)
568571 s.viper.Set("network_groups", s.config.NetworkGroups)
572+ s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains)
569573 return s.viper.WriteConfigAs(configFile)
570574 }
571575 return fmt.Errorf("failed to read config file: %w", err)
@@ -585,6 +589,7 @@ func (s *Service) writeConfigSurgical(configFile string) error {
585589 config["external_routes"] = s.config.ExternalRoutes
586590 config["attachments"] = s.config.Attachments
587591 config["network_groups"] = s.config.NetworkGroups
592+ config["auto_route_allowed_domains"] = s.config.AutoRouteAllowedDomains
588593
589594 out, err := toml.Marshal(config)
590595 if err != nil {
@@ -1045,6 +1050,131 @@ func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image str
10451050 return nil
10461051 }
10471052
1053+// GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist.
1054+func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) {
1055+ s.mu.RLock()
1056+ defer s.mu.RUnlock()
1057+
1058+ return append([]string{}, s.config.AutoRouteAllowedDomains...), nil
1059+}
1060+
1061+// AddAutoRouteAllowedDomain adds a domain pattern to the auto-route allowlist.
1062+func (s *Service) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1063+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1064+ zerowrap.FieldLayer: "usecase",
1065+ zerowrap.FieldUseCase: "AddAutoRouteAllowedDomain",
1066+ "pattern": pattern,
1067+ })
1068+ log := zerowrap.FromCtx(ctx)
1069+
1070+ if err := validateDomainPattern(pattern); err != nil {
1071+ return err
1072+ }
1073+
1074+ s.mu.Lock()
1075+ for _, existing := range s.config.AutoRouteAllowedDomains {
1076+ if existing == pattern {
1077+ s.mu.Unlock()
1078+ return nil
1079+ }
1080+ }
1081+ snapshot := make([]string, len(s.config.AutoRouteAllowedDomains))
1082+ copy(snapshot, s.config.AutoRouteAllowedDomains)
1083+ s.config.AutoRouteAllowedDomains = append(s.config.AutoRouteAllowedDomains, pattern)
1084+ s.mu.Unlock()
1085+
1086+ if err := s.Save(ctx); err != nil {
1087+ log.Warn().Err(err).Msg("failed to persist allowed domain, rolling back")
1088+ s.mu.Lock()
1089+ s.config.AutoRouteAllowedDomains = snapshot
1090+ s.mu.Unlock()
1091+ return err
1092+ }
1093+
1094+ log.Info().Msg("auto-route allowed domain added")
1095+ return nil
1096+}
1097+
1098+// RemoveAutoRouteAllowedDomain removes a domain pattern from the auto-route allowlist.
1099+func (s *Service) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1100+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1101+ zerowrap.FieldLayer: "usecase",
1102+ zerowrap.FieldUseCase: "RemoveAutoRouteAllowedDomain",
1103+ "pattern": pattern,
1104+ })
1105+ log := zerowrap.FromCtx(ctx)
1106+
1107+ s.mu.Lock()
1108+ previous := append([]string{}, s.config.AutoRouteAllowedDomains...)
1109+ filtered := make([]string, 0, len(s.config.AutoRouteAllowedDomains))
1110+ removed := false
1111+ for _, existing := range s.config.AutoRouteAllowedDomains {
1112+ if existing == pattern {
1113+ removed = true
1114+ continue
1115+ }
1116+ filtered = append(filtered, existing)
1117+ }
1118+ s.config.AutoRouteAllowedDomains = filtered
1119+ s.mu.Unlock()
1120+
1121+ if !removed {
1122+ log.Debug().Msg("pattern not found in allowlist")
1123+ return nil
1124+ }
1125+
1126+ if err := s.Save(ctx); err != nil {
1127+ log.Warn().Err(err).Msg("failed to persist allowed domain removal, rolling back")
1128+ s.mu.Lock()
1129+ s.config.AutoRouteAllowedDomains = previous
1130+ s.mu.Unlock()
1131+ return err
1132+ }
1133+
1134+ log.Info().Msg("auto-route allowed domain removed")
1135+ return nil
1136+}
1137+
1138+func validateDomainPattern(pattern string) error {
1139+ if pattern == "" {
1140+ return fmt.Errorf("%w: pattern is required", domain.ErrInvalidDomainPattern)
1141+ }
1142+ if pattern == "*" {
1143+ return nil
1144+ }
1145+ if pattern != strings.ToLower(pattern) {
1146+ return fmt.Errorf("%w: must be lowercase", domain.ErrInvalidDomainPattern)
1147+ }
1148+ if strings.HasSuffix(pattern, ".") {
1149+ return fmt.Errorf("%w: must not have trailing dots", domain.ErrInvalidDomainPattern)
1150+ }
1151+ if strings.Contains(pattern, "**") {
1152+ return fmt.Errorf("%w: must not contain double wildcards", domain.ErrInvalidDomainPattern)
1153+ }
1154+ if strings.Count(pattern, "*") > 1 {
1155+ return fmt.Errorf("%w: must not contain multiple wildcards", domain.ErrInvalidDomainPattern)
1156+ }
1157+ if strings.Contains(pattern, "*") {
1158+ if !strings.HasPrefix(pattern, "*.") {
1159+ return fmt.Errorf("%w: wildcard must be in *.domain form", domain.ErrInvalidDomainPattern)
1160+ }
1161+ if !isValidExactDomain(strings.TrimPrefix(pattern, "*.")) {
1162+ return fmt.Errorf("%w: invalid domain after wildcard", domain.ErrInvalidDomainPattern)
1163+ }
1164+ return nil
1165+ }
1166+ if !isValidExactDomain(pattern) {
1167+ return fmt.Errorf("%w: invalid domain format", domain.ErrInvalidDomainPattern)
1168+ }
1169+ return nil
1170+}
1171+
1172+var exactDomainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`)
1173+
1174+func isValidExactDomain(pattern string) bool {
1175+ return exactDomainPattern.MatchString(pattern)
1176+}
1177+
10481178 // GetExternalRoutes returns all configured external routes.
10491179 func (s *Service) GetExternalRoutes() map[string]string {
10501180 s.mu.RLock()
@@ -8,6 +8,7 @@ import (
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10 "reflect"10 "reflect"
11+ "regexp"
11 "slices"12 "slices"
12 "strconv"13 "strconv"
13 "strings"14 "strings"
@@ -26,23 +27,24 @@ import (
26 27
27 // Config holds the loaded configuration.28 // Config holds the loaded configuration.
28 type Config struct {29 type Config struct {
29- ServerPort int30+ ServerPort int
30- RegistryPort int31+ RegistryPort int
31- RegistryDomain string32+ RegistryDomain string
32- DataDir string33+ DataDir string
33- AutoRouteEnabled bool34+ AutoRouteEnabled bool
34- NetworkIsolation bool35+ AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"`
35- NetworkPrefix string36+ NetworkIsolation bool
36- Routes map[string]string37+ NetworkPrefix string
37- ExternalRoutes map[string]string // domain -> "host:port"38+ Routes map[string]string
38- RegistryAuthEnabled bool39+ ExternalRoutes map[string]string // domain -> "host:port"
39- RegistryAuthUsername string40+ RegistryAuthEnabled bool
40- RegistryAuthPassword string41+ RegistryAuthUsername string
41- VolumeAutoCreate bool42+ RegistryAuthPassword string
42- VolumePrefix string43+ VolumeAutoCreate bool
43- VolumePreserve bool44+ VolumePrefix string
44- NetworkGroups map[string][]string45+ VolumePreserve bool
45- Attachments map[string][]string46+ NetworkGroups map[string][]string
47+ Attachments map[string][]string
46 }48 }
47 49
48 // Service implements the ConfigService interface.50 // Service implements the ConfigService interface.
@@ -121,23 +123,24 @@ func (s *Service) loadConfigValues() Config {
121 }123 }
122 124
123 return Config{125 return Config{
124- ServerPort: s.viper.GetInt("server.port"),126+ ServerPort: s.viper.GetInt("server.port"),
125- RegistryPort: s.viper.GetInt("server.registry_port"),127+ RegistryPort: s.viper.GetInt("server.registry_port"),
126- RegistryDomain: registryDomain,128+ RegistryDomain: registryDomain,
127- DataDir: s.viper.GetString("server.data_dir"),129+ DataDir: s.viper.GetString("server.data_dir"),
128- AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),130+ AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
129- NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),131+ AutoRouteAllowedDomains: append([]string{}, s.viper.GetStringSlice("auto_route_allowed_domains")...),
130- NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),132+ NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
131- RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),133+ NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
132- RegistryAuthUsername: s.viper.GetString("auth.username"),134+ RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
133- RegistryAuthPassword: s.viper.GetString("auth.password"),135+ RegistryAuthUsername: s.viper.GetString("auth.username"),
134- VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),136+ RegistryAuthPassword: s.viper.GetString("auth.password"),
135- VolumePrefix: s.viper.GetString("volumes.prefix"),137+ VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
136- VolumePreserve: s.viper.GetBool("volumes.preserve"),138+ VolumePrefix: s.viper.GetString("volumes.prefix"),
137- Routes: make(map[string]string),139+ VolumePreserve: s.viper.GetBool("volumes.preserve"),
138- ExternalRoutes: make(map[string]string),140+ Routes: make(map[string]string),
139- NetworkGroups: make(map[string][]string),141+ ExternalRoutes: make(map[string]string),
140- Attachments: make(map[string][]string),142+ NetworkGroups: make(map[string][]string),
143+ Attachments: make(map[string][]string),
141 }144 }
142 }145 }
143 146
@@ -516,7 +519,7 @@ type configSnapshot struct {
516 }519 }
517 520
518 // mutableSections lists config sections that are intentionally modified by service operations.521 // mutableSections lists config sections that are intentionally modified by service operations.
519-var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups."}522+var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups.", "auto_route_allowed_domains"}
520 523
521 func isMutableKey(key string) bool {524 func isMutableKey(key string) bool {
522 for _, prefix := range mutableSections {525 for _, prefix := range mutableSections {
@@ -526,7 +529,7 @@ func isMutableKey(key string) bool {
526 }529 }
527 530
528 switch key {531 switch key {
529- case "routes", "external_routes", "attachments", "network_groups":532+ case "routes", "external_routes", "attachments", "network_groups", "auto_route_allowed_domains":
530 return true533 return true
531 }534 }
532 535
@@ -566,6 +569,7 @@ func (s *Service) writeConfigSurgical(configFile string) error {
566 s.viper.Set("external_routes", s.config.ExternalRoutes)569 s.viper.Set("external_routes", s.config.ExternalRoutes)
567 s.viper.Set("attachments", s.config.Attachments)570 s.viper.Set("attachments", s.config.Attachments)
568 s.viper.Set("network_groups", s.config.NetworkGroups)571 s.viper.Set("network_groups", s.config.NetworkGroups)
572+ s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains)
569 return s.viper.WriteConfigAs(configFile)573 return s.viper.WriteConfigAs(configFile)
570 }574 }
571 return fmt.Errorf("failed to read config file: %w", err)575 return fmt.Errorf("failed to read config file: %w", err)
@@ -585,6 +589,7 @@ func (s *Service) writeConfigSurgical(configFile string) error {
585 config["external_routes"] = s.config.ExternalRoutes589 config["external_routes"] = s.config.ExternalRoutes
586 config["attachments"] = s.config.Attachments590 config["attachments"] = s.config.Attachments
587 config["network_groups"] = s.config.NetworkGroups591 config["network_groups"] = s.config.NetworkGroups
592+ config["auto_route_allowed_domains"] = s.config.AutoRouteAllowedDomains
588 593
589 out, err := toml.Marshal(config)594 out, err := toml.Marshal(config)
590 if err != nil {595 if err != nil {
@@ -1045,6 +1050,131 @@ func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image str
1045 return nil1050 return nil
1046 }1051 }
1047 1052
1053+// GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist.
1054+func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) {
1055+ s.mu.RLock()
1056+ defer s.mu.RUnlock()
1057+
1058+ return append([]string{}, s.config.AutoRouteAllowedDomains...), nil
1059+}
1060+
1061+// AddAutoRouteAllowedDomain adds a domain pattern to the auto-route allowlist.
1062+func (s *Service) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1063+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1064+ zerowrap.FieldLayer: "usecase",
1065+ zerowrap.FieldUseCase: "AddAutoRouteAllowedDomain",
1066+ "pattern": pattern,
1067+ })
1068+ log := zerowrap.FromCtx(ctx)
1069+
1070+ if err := validateDomainPattern(pattern); err != nil {
1071+ return err
1072+ }
1073+
1074+ s.mu.Lock()
1075+ for _, existing := range s.config.AutoRouteAllowedDomains {
1076+ if existing == pattern {
1077+ s.mu.Unlock()
1078+ return nil
1079+ }
1080+ }
1081+ snapshot := make([]string, len(s.config.AutoRouteAllowedDomains))
1082+ copy(snapshot, s.config.AutoRouteAllowedDomains)
1083+ s.config.AutoRouteAllowedDomains = append(s.config.AutoRouteAllowedDomains, pattern)
1084+ s.mu.Unlock()
1085+
1086+ if err := s.Save(ctx); err != nil {
1087+ log.Warn().Err(err).Msg("failed to persist allowed domain, rolling back")
1088+ s.mu.Lock()
1089+ s.config.AutoRouteAllowedDomains = snapshot
1090+ s.mu.Unlock()
1091+ return err
1092+ }
1093+
1094+ log.Info().Msg("auto-route allowed domain added")
1095+ return nil
1096+}
1097+
1098+// RemoveAutoRouteAllowedDomain removes a domain pattern from the auto-route allowlist.
1099+func (s *Service) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1100+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1101+ zerowrap.FieldLayer: "usecase",
1102+ zerowrap.FieldUseCase: "RemoveAutoRouteAllowedDomain",
1103+ "pattern": pattern,
1104+ })
1105+ log := zerowrap.FromCtx(ctx)
1106+
1107+ s.mu.Lock()
1108+ previous := append([]string{}, s.config.AutoRouteAllowedDomains...)
1109+ filtered := make([]string, 0, len(s.config.AutoRouteAllowedDomains))
1110+ removed := false
1111+ for _, existing := range s.config.AutoRouteAllowedDomains {
1112+ if existing == pattern {
1113+ removed = true
1114+ continue
1115+ }
1116+ filtered = append(filtered, existing)
1117+ }
1118+ s.config.AutoRouteAllowedDomains = filtered
1119+ s.mu.Unlock()
1120+
1121+ if !removed {
1122+ log.Debug().Msg("pattern not found in allowlist")
1123+ return nil
1124+ }
1125+
1126+ if err := s.Save(ctx); err != nil {
1127+ log.Warn().Err(err).Msg("failed to persist allowed domain removal, rolling back")
1128+ s.mu.Lock()
1129+ s.config.AutoRouteAllowedDomains = previous
1130+ s.mu.Unlock()
1131+ return err
1132+ }
1133+
1134+ log.Info().Msg("auto-route allowed domain removed")
1135+ return nil
1136+}
1137+
1138+func validateDomainPattern(pattern string) error {
1139+ if pattern == "" {
1140+ return fmt.Errorf("%w: pattern is required", domain.ErrInvalidDomainPattern)
1141+ }
1142+ if pattern == "*" {
1143+ return nil
1144+ }
1145+ if pattern != strings.ToLower(pattern) {
1146+ return fmt.Errorf("%w: must be lowercase", domain.ErrInvalidDomainPattern)
1147+ }
1148+ if strings.HasSuffix(pattern, ".") {
1149+ return fmt.Errorf("%w: must not have trailing dots", domain.ErrInvalidDomainPattern)
1150+ }
1151+ if strings.Contains(pattern, "**") {
1152+ return fmt.Errorf("%w: must not contain double wildcards", domain.ErrInvalidDomainPattern)
1153+ }
1154+ if strings.Count(pattern, "*") > 1 {
1155+ return fmt.Errorf("%w: must not contain multiple wildcards", domain.ErrInvalidDomainPattern)
1156+ }
1157+ if strings.Contains(pattern, "*") {
1158+ if !strings.HasPrefix(pattern, "*.") {
1159+ return fmt.Errorf("%w: wildcard must be in *.domain form", domain.ErrInvalidDomainPattern)
1160+ }
1161+ if !isValidExactDomain(strings.TrimPrefix(pattern, "*.")) {
1162+ return fmt.Errorf("%w: invalid domain after wildcard", domain.ErrInvalidDomainPattern)
1163+ }
1164+ return nil
1165+ }
1166+ if !isValidExactDomain(pattern) {
1167+ return fmt.Errorf("%w: invalid domain format", domain.ErrInvalidDomainPattern)
1168+ }
1169+ return nil
1170+}
1171+
1172+var exactDomainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`)
1173+
1174+func isValidExactDomain(pattern string) bool {
1175+ return exactDomainPattern.MatchString(pattern)
1176+}
1177+
1048 // GetExternalRoutes returns all configured external routes.1178 // GetExternalRoutes returns all configured external routes.
1049 func (s *Service) GetExternalRoutes() map[string]string {1179 func (s *Service) GetExternalRoutes() map[string]string {
1050 s.mu.RLock()1180 s.mu.RLock()
M internal/usecase/config/service_test.go +64 -0
@@ -891,6 +891,70 @@ func TestService_RemoveAttachment(t *testing.T) {
891891 })
892892 }
893893
894+func TestService_AutoRouteAllowedDomains(t *testing.T) {
895+ t.Run("get returns copy", func(t *testing.T) {
896+ v := viper.New()
897+ v.Set("auto_route_allowed_domains", []string{"example.com", "*.example.com"})
898+
899+ eventBus := mocks.NewMockEventPublisher(t)
900+ svc := NewService(v, eventBus)
901+ ctx := testContext()
902+
903+ require.NoError(t, svc.Load(ctx))
904+
905+ domains, err := svc.GetAutoRouteAllowedDomains(ctx)
906+ require.NoError(t, err)
907+ domains[0] = "changed.com"
908+
909+ again, err := svc.GetAutoRouteAllowedDomains(ctx)
910+ require.NoError(t, err)
911+ assert.Equal(t, []string{"example.com", "*.example.com"}, again)
912+ })
913+
914+ t.Run("add and remove persist idempotently", func(t *testing.T) {
915+ tmpDir := t.TempDir()
916+ configFile := filepath.Join(tmpDir, "gordon.toml")
917+ require.NoError(t, os.WriteFile(configFile, []byte("auto_route_allowed_domains = [\"example.com\"]\n"), 0600))
918+
919+ v := viper.New()
920+ v.SetConfigFile(configFile)
921+ v.Set("auto_route_allowed_domains", []interface{}{"example.com"})
922+
923+ eventBus := mocks.NewMockEventPublisher(t)
924+ svc := NewService(v, eventBus)
925+ ctx := testContext()
926+
927+ require.NoError(t, svc.Load(ctx))
928+ require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com"))
929+ require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com"))
930+
931+ domains, err := svc.GetAutoRouteAllowedDomains(ctx)
932+ require.NoError(t, err)
933+ assert.Equal(t, []string{"example.com", "*.example.com"}, domains)
934+
935+ require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "example.com"))
936+ require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "missing.com"))
937+
938+ domains, err = svc.GetAutoRouteAllowedDomains(ctx)
939+ require.NoError(t, err)
940+ assert.Equal(t, []string{"*.example.com"}, domains)
941+ })
942+
943+ t.Run("add validates patterns", func(t *testing.T) {
944+ v := viper.New()
945+ eventBus := mocks.NewMockEventPublisher(t)
946+ svc := NewService(v, eventBus)
947+ ctx := testContext()
948+ require.NoError(t, svc.Load(ctx))
949+
950+ invalid := []string{"", "EXAMPLE.com", "example.com.", "**.example.com", "foo.*.example.com", "*.example.com."}
951+ for _, pattern := range invalid {
952+ err := svc.AddAutoRouteAllowedDomain(ctx, pattern)
953+ assert.ErrorIs(t, err, domain.ErrInvalidDomainPattern, "pattern: %s", pattern)
954+ }
955+ })
956+}
957+
894958 func TestService_GetExternalRoutes(t *testing.T) {
895959 v := viper.New()
896960 v.Set("external_routes", map[string]interface{}{
@@ -891,6 +891,70 @@ func TestService_RemoveAttachment(t *testing.T) {
891 })891 })
892 }892 }
893 893
894+func TestService_AutoRouteAllowedDomains(t *testing.T) {
895+ t.Run("get returns copy", func(t *testing.T) {
896+ v := viper.New()
897+ v.Set("auto_route_allowed_domains", []string{"example.com", "*.example.com"})
898+
899+ eventBus := mocks.NewMockEventPublisher(t)
900+ svc := NewService(v, eventBus)
901+ ctx := testContext()
902+
903+ require.NoError(t, svc.Load(ctx))
904+
905+ domains, err := svc.GetAutoRouteAllowedDomains(ctx)
906+ require.NoError(t, err)
907+ domains[0] = "changed.com"
908+
909+ again, err := svc.GetAutoRouteAllowedDomains(ctx)
910+ require.NoError(t, err)
911+ assert.Equal(t, []string{"example.com", "*.example.com"}, again)
912+ })
913+
914+ t.Run("add and remove persist idempotently", func(t *testing.T) {
915+ tmpDir := t.TempDir()
916+ configFile := filepath.Join(tmpDir, "gordon.toml")
917+ require.NoError(t, os.WriteFile(configFile, []byte("auto_route_allowed_domains = [\"example.com\"]\n"), 0600))
918+
919+ v := viper.New()
920+ v.SetConfigFile(configFile)
921+ v.Set("auto_route_allowed_domains", []interface{}{"example.com"})
922+
923+ eventBus := mocks.NewMockEventPublisher(t)
924+ svc := NewService(v, eventBus)
925+ ctx := testContext()
926+
927+ require.NoError(t, svc.Load(ctx))
928+ require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com"))
929+ require.NoError(t, svc.AddAutoRouteAllowedDomain(ctx, "*.example.com"))
930+
931+ domains, err := svc.GetAutoRouteAllowedDomains(ctx)
932+ require.NoError(t, err)
933+ assert.Equal(t, []string{"example.com", "*.example.com"}, domains)
934+
935+ require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "example.com"))
936+ require.NoError(t, svc.RemoveAutoRouteAllowedDomain(ctx, "missing.com"))
937+
938+ domains, err = svc.GetAutoRouteAllowedDomains(ctx)
939+ require.NoError(t, err)
940+ assert.Equal(t, []string{"*.example.com"}, domains)
941+ })
942+
943+ t.Run("add validates patterns", func(t *testing.T) {
944+ v := viper.New()
945+ eventBus := mocks.NewMockEventPublisher(t)
946+ svc := NewService(v, eventBus)
947+ ctx := testContext()
948+ require.NoError(t, svc.Load(ctx))
949+
950+ invalid := []string{"", "EXAMPLE.com", "example.com.", "**.example.com", "foo.*.example.com", "*.example.com."}
951+ for _, pattern := range invalid {
952+ err := svc.AddAutoRouteAllowedDomain(ctx, pattern)
953+ assert.ErrorIs(t, err, domain.ErrInvalidDomainPattern, "pattern: %s", pattern)
954+ }
955+ })
956+}
957+
894 func TestService_GetExternalRoutes(t *testing.T) {958 func TestService_GetExternalRoutes(t *testing.T) {
895 v := viper.New()959 v := viper.New()
896 v.Set("external_routes", map[string]interface{}{960 v.Set("external_routes", map[string]interface{}{
M internal/usecase/container/autoroute.go +72 -4
@@ -160,6 +160,13 @@ func (h *AutoRouteHandler) buildFullImageRef(imageName string) string {
160160 func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, imageName string, labels *domain.ImageLabels) {
161161 log := zerowrap.FromCtx(ctx)
162162
163+ // Load allowed domains once for all routes in this event.
164+ allowedDomains, err := h.configSvc.GetAutoRouteAllowedDomains(ctx)
165+ if err != nil {
166+ log.Warn().Err(err).Msg("failed to load auto-route allowed domains, skipping route processing")
167+ return
168+ }
169+
163170 // Build full image reference for env extraction (Docker needs fully qualified name)
164171 fullImageRef := h.buildFullImageRef(imageName)
165172
@@ -169,9 +176,9 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string,
169176 continue
170177 }
171178
172- h.createOrUpdateRoute(ctx, routeDomain, imageName)
179+ created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)
173180
174- if labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
181+ if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
175182 // Use full image ref for extraction (Docker knows image by this name)
176183 if err := h.extractAndMergeEnvFile(ctx, fullImageRef, routeDomain, labels.EnvFile); err != nil {
177184 log.Warn().
@@ -187,7 +194,7 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string,
187194 // createOrUpdateRoute creates a new route or updates an existing one.
188195 // It also triggers deployment for new routes since the ImagePushedHandler
189196 // may have already finished processing (handlers run concurrently).
190-func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string) {
197+func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string, allowedDomains []string) bool {
191198 log := zerowrap.FromCtx(ctx)
192199
193200 route := domain.Route{
@@ -198,6 +205,10 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
198205 existingRoutes := h.configSvc.GetRoutes(ctx)
199206 for _, existing := range existingRoutes {
200207 if existing.Domain == routeDomain {
208+ if extractRepoName(existing.Image, h.registryDomain) != extractRepoName(imageName, h.registryDomain) {
209+ log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
210+ return false
211+ }
201212 if existing.Image != imageName {
202213 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
203214 log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to update route")
@@ -207,17 +218,74 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
207218 h.triggerDeploy(ctx, route)
208219 }
209220 }
210- return
221+ return false
211222 }
212223 }
213224
225+ if !matchesDomainAllowlist(routeDomain, allowedDomains) {
226+ log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")
227+ return false
228+ }
229+
214230 if err := h.configSvc.AddRoute(ctx, route); err != nil {
215231 log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route")
216232 } else {
217233 log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels")
218234 // Trigger deploy for new route since ImagePushedHandler may have already finished
219235 h.triggerDeploy(ctx, route)
236+ return true
237+ }
238+
239+ return false
240+}
241+
242+func matchesDomainAllowlist(domain string, patterns []string) bool {
243+ domain = strings.ToLower(domain)
244+ for _, pattern := range patterns {
245+ pattern = strings.ToLower(strings.TrimSpace(pattern))
246+ if pattern == "" {
247+ continue
248+ }
249+ if pattern == "*" {
250+ return true
251+ }
252+ if pattern == domain {
253+ return true
254+ }
255+ if !strings.HasPrefix(pattern, "*.") {
256+ continue
257+ }
258+ // Wildcard patterns match single-level subdomains only (per DNS/TLS conventions).
259+ // e.g., "*.example.com" matches "foo.example.com" but not "bar.foo.example.com"
260+ suffix := strings.TrimPrefix(pattern, "*.")
261+ if !strings.HasSuffix(domain, "."+suffix) {
262+ continue
263+ }
264+ prefix := strings.TrimSuffix(domain, "."+suffix)
265+ if prefix != "" && !strings.Contains(prefix, ".") {
266+ return true
267+ }
268+ }
269+ return false
270+}
271+
272+func extractRepoName(imageRef, registryDomain string) string {
273+ imageRef = strings.ToLower(imageRef)
274+ if idx := strings.Index(imageRef, "@"); idx != -1 {
275+ imageRef = imageRef[:idx]
276+ }
277+ if idx := strings.LastIndex(imageRef, ":"); idx != -1 {
278+ slashIdx := strings.LastIndex(imageRef, "/")
279+ if idx > slashIdx {
280+ imageRef = imageRef[:idx]
281+ }
282+ }
283+ registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/"))
284+ if registryPrefix != "" {
285+ prefix := registryPrefix + "/"
286+ imageRef = strings.TrimPrefix(imageRef, prefix)
220287 }
288+ return imageRef
221289 }
222290
223291 // triggerDeploy initiates deployment for a route.
@@ -160,6 +160,13 @@ func (h *AutoRouteHandler) buildFullImageRef(imageName string) string {
160 func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, imageName string, labels *domain.ImageLabels) {160 func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, imageName string, labels *domain.ImageLabels) {
161 log := zerowrap.FromCtx(ctx)161 log := zerowrap.FromCtx(ctx)
162 162
163+ // Load allowed domains once for all routes in this event.
164+ allowedDomains, err := h.configSvc.GetAutoRouteAllowedDomains(ctx)
165+ if err != nil {
166+ log.Warn().Err(err).Msg("failed to load auto-route allowed domains, skipping route processing")
167+ return
168+ }
169+
163 // Build full image reference for env extraction (Docker needs fully qualified name)170 // Build full image reference for env extraction (Docker needs fully qualified name)
164 fullImageRef := h.buildFullImageRef(imageName)171 fullImageRef := h.buildFullImageRef(imageName)
165 172
@@ -169,9 +176,9 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string,
169 continue176 continue
170 }177 }
171 178
172- h.createOrUpdateRoute(ctx, routeDomain, imageName)179+ created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)
173 180
174- if labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {181+ if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
175 // Use full image ref for extraction (Docker knows image by this name)182 // Use full image ref for extraction (Docker knows image by this name)
176 if err := h.extractAndMergeEnvFile(ctx, fullImageRef, routeDomain, labels.EnvFile); err != nil {183 if err := h.extractAndMergeEnvFile(ctx, fullImageRef, routeDomain, labels.EnvFile); err != nil {
177 log.Warn().184 log.Warn().
@@ -187,7 +194,7 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string,
187 // createOrUpdateRoute creates a new route or updates an existing one.194 // createOrUpdateRoute creates a new route or updates an existing one.
188 // It also triggers deployment for new routes since the ImagePushedHandler195 // It also triggers deployment for new routes since the ImagePushedHandler
189 // may have already finished processing (handlers run concurrently).196 // may have already finished processing (handlers run concurrently).
190-func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string) {197+func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string, allowedDomains []string) bool {
191 log := zerowrap.FromCtx(ctx)198 log := zerowrap.FromCtx(ctx)
192 199
193 route := domain.Route{200 route := domain.Route{
@@ -198,6 +205,10 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
198 existingRoutes := h.configSvc.GetRoutes(ctx)205 existingRoutes := h.configSvc.GetRoutes(ctx)
199 for _, existing := range existingRoutes {206 for _, existing := range existingRoutes {
200 if existing.Domain == routeDomain {207 if existing.Domain == routeDomain {
208+ if extractRepoName(existing.Image, h.registryDomain) != extractRepoName(imageName, h.registryDomain) {
209+ log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
210+ return false
211+ }
201 if existing.Image != imageName {212 if existing.Image != imageName {
202 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {213 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
203 log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to update route")214 log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to update route")
@@ -207,17 +218,74 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
207 h.triggerDeploy(ctx, route)218 h.triggerDeploy(ctx, route)
208 }219 }
209 }220 }
210- return221+ return false
211 }222 }
212 }223 }
213 224
225+ if !matchesDomainAllowlist(routeDomain, allowedDomains) {
226+ log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")
227+ return false
228+ }
229+
214 if err := h.configSvc.AddRoute(ctx, route); err != nil {230 if err := h.configSvc.AddRoute(ctx, route); err != nil {
215 log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route")231 log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route")
216 } else {232 } else {
217 log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels")233 log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels")
218 // Trigger deploy for new route since ImagePushedHandler may have already finished234 // Trigger deploy for new route since ImagePushedHandler may have already finished
219 h.triggerDeploy(ctx, route)235 h.triggerDeploy(ctx, route)
236+ return true
237+ }
238+
239+ return false
240+}
241+
242+func matchesDomainAllowlist(domain string, patterns []string) bool {
243+ domain = strings.ToLower(domain)
244+ for _, pattern := range patterns {
245+ pattern = strings.ToLower(strings.TrimSpace(pattern))
246+ if pattern == "" {
247+ continue
248+ }
249+ if pattern == "*" {
250+ return true
251+ }
252+ if pattern == domain {
253+ return true
254+ }
255+ if !strings.HasPrefix(pattern, "*.") {
256+ continue
257+ }
258+ // Wildcard patterns match single-level subdomains only (per DNS/TLS conventions).
259+ // e.g., "*.example.com" matches "foo.example.com" but not "bar.foo.example.com"
260+ suffix := strings.TrimPrefix(pattern, "*.")
261+ if !strings.HasSuffix(domain, "."+suffix) {
262+ continue
263+ }
264+ prefix := strings.TrimSuffix(domain, "."+suffix)
265+ if prefix != "" && !strings.Contains(prefix, ".") {
266+ return true
267+ }
268+ }
269+ return false
270+}
271+
272+func extractRepoName(imageRef, registryDomain string) string {
273+ imageRef = strings.ToLower(imageRef)
274+ if idx := strings.Index(imageRef, "@"); idx != -1 {
275+ imageRef = imageRef[:idx]
276+ }
277+ if idx := strings.LastIndex(imageRef, ":"); idx != -1 {
278+ slashIdx := strings.LastIndex(imageRef, "/")
279+ if idx > slashIdx {
280+ imageRef = imageRef[:idx]
281+ }
282+ }
283+ registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/"))
284+ if registryPrefix != "" {
285+ prefix := registryPrefix + "/"
286+ imageRef = strings.TrimPrefix(imageRef, prefix)
220 }287 }
288+ return imageRef
221 }289 }
222 290
223 // triggerDeploy initiates deployment for a route.291 // triggerDeploy initiates deployment for a route.
M internal/usecase/container/autoroute_test.go +143 -0
@@ -334,6 +334,53 @@ func TestParseImageLabels_InvalidJSON(t *testing.T) {
334334 assert.Error(t, err)
335335 }
336336
337+func TestMatchesDomainAllowlist(t *testing.T) {
338+ tests := []struct {
339+ name string
340+ domain string
341+ patterns []string
342+ want bool
343+ }{
344+ {name: "exact match", domain: "app.example.com", patterns: []string{"app.example.com"}, want: true},
345+ {name: "no match", domain: "app.example.com", patterns: []string{"api.example.com"}, want: false},
346+ {name: "wildcard match", domain: "app.example.com", patterns: []string{"*.example.com"}, want: true},
347+ {name: "wildcard no match on root", domain: "example.com", patterns: []string{"*.example.com"}, want: false},
348+ {name: "wildcard matches one level only", domain: "api.app.example.com", patterns: []string{"*.example.com"}, want: false},
349+ {name: "empty allowlist", domain: "app.example.com", patterns: nil, want: false},
350+ {name: "case insensitive", domain: "App.Example.Com", patterns: []string{"APP.EXAMPLE.COM"}, want: true},
351+ {name: "multiple patterns", domain: "api.example.com", patterns: []string{"foo.com", "*.example.com"}, want: true},
352+ }
353+
354+ for _, tt := range tests {
355+ t.Run(tt.name, func(t *testing.T) {
356+ assert.Equal(t, tt.want, matchesDomainAllowlist(tt.domain, tt.patterns))
357+ })
358+ }
359+}
360+
361+func TestExtractRepoName(t *testing.T) {
362+ tests := []struct {
363+ name string
364+ imageRef string
365+ registryDomain string
366+ want string
367+ }{
368+ {name: "simple tag", imageRef: "myapp:latest", want: "myapp"},
369+ {name: "version tag", imageRef: "myapp:v2.0.0", want: "myapp"},
370+ {name: "digest", imageRef: "myapp@sha256:abc123", want: "myapp"},
371+ {name: "strip registry", imageRef: "registry.example.com/myapp:latest", registryDomain: "registry.example.com", want: "myapp"},
372+ {name: "org image", imageRef: "org/myapp:latest", want: "org/myapp"},
373+ {name: "registry org image", imageRef: "registry.example.com/org/myapp:latest", registryDomain: "registry.example.com", want: "org/myapp"},
374+ {name: "lowercase", imageRef: "MyApp:Latest", want: "myapp"},
375+ }
376+
377+ for _, tt := range tests {
378+ t.Run(tt.name, func(t *testing.T) {
379+ assert.Equal(t, tt.want, extractRepoName(tt.imageRef, tt.registryDomain))
380+ })
381+ }
382+}
383+
337384 // AutoRouteHandler tests
338385
339386 func TestAutoRouteHandler_CanHandle(t *testing.T) {
@@ -450,6 +497,7 @@ func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
450497
451498 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
452499 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
500+ configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"app.example.com"}, nil)
453501
454502 // No existing routes
455503 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
@@ -510,6 +558,7 @@ func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
510558
511559 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
512560 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
561+ configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*"}, nil)
513562
514563 // Existing route with different image
515564 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
@@ -572,6 +621,7 @@ func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(t *testing.T) {
572621
573622 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
574623 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
624+ configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*"}, nil)
575625
576626 // Existing route with same image - no update needed
577627 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
@@ -672,6 +722,7 @@ func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
672722
673723 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
674724 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
725+ configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"app.example.com", "api.example.com", "www.example.com"}, nil).Once()
675726
676727 // No existing routes
677728 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3)
@@ -725,6 +776,98 @@ func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) {
725776 handler.triggerDeploy(ctx, route)
726777 }
727778
779+func TestAutoRouteHandler_NewDomain_Allowed(t *testing.T) {
780+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
781+ configSvc := inmocks.NewMockConfigService(t)
782+ containerSvc := inmocks.NewMockContainerService(t)
783+ blobStorage := mocks.NewMockBlobStorage(t)
784+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
785+
786+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
787+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil)
788+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)
789+
790+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.example.com"})
791+ assert.True(t, created)
792+}
793+
794+func TestAutoRouteHandler_NewDomain_NotAllowed(t *testing.T) {
795+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
796+ configSvc := inmocks.NewMockConfigService(t)
797+ containerSvc := inmocks.NewMockContainerService(t)
798+ blobStorage := mocks.NewMockBlobStorage(t)
799+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
800+
801+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
802+
803+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.allowed.com"})
804+ assert.False(t, created)
805+}
806+
807+func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
808+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
809+ configSvc := inmocks.NewMockConfigService(t)
810+ containerSvc := inmocks.NewMockContainerService(t)
811+ blobStorage := mocks.NewMockBlobStorage(t)
812+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
813+
814+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:v1"}})
815+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(nil)
816+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(&domain.Container{ID: "c1"}, nil)
817+
818+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:v2", []string{"*.example.com"})
819+ assert.False(t, created)
820+}
821+
822+func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
823+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
824+ configSvc := inmocks.NewMockConfigService(t)
825+ containerSvc := inmocks.NewMockContainerService(t)
826+ blobStorage := mocks.NewMockBlobStorage(t)
827+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
828+
829+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "oldapp:v1"}})
830+
831+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "newapp:v2", []string{"*.example.com"})
832+ assert.False(t, created)
833+}
834+
835+func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
836+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
837+ configSvc := inmocks.NewMockConfigService(t)
838+ containerSvc := inmocks.NewMockContainerService(t)
839+ blobStorage := mocks.NewMockBlobStorage(t)
840+ extractor := &testEnvExtractor{}
841+
842+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, t.TempDir())
843+
844+ configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*.example.com"}, nil).Twice()
845+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once()
846+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil)
847+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)
848+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:latest"}}).Once()
849+
850+ handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
851+ handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
852+
853+ assert.Equal(t, 1, extractor.calls)
854+ assert.Equal(t, "registry.example.com/myapp:latest", extractor.lastImage)
855+ assert.Equal(t, ".env", extractor.lastPath)
856+}
857+
858+type testEnvExtractor struct {
859+ calls int
860+ lastImage string
861+ lastPath string
862+}
863+
864+func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef, envFilePath string) ([]byte, error) {
865+ e.calls++
866+ e.lastImage = imageRef
867+ e.lastPath = envFilePath
868+ return []byte("FOO=bar\n"), nil
869+}
870+
728871 // collectDomains tests
729872
730873 func TestAutoRouteHandler_CollectDomains(t *testing.T) {
@@ -334,6 +334,53 @@ func TestParseImageLabels_InvalidJSON(t *testing.T) {
334 assert.Error(t, err)334 assert.Error(t, err)
335 }335 }
336 336
337+func TestMatchesDomainAllowlist(t *testing.T) {
338+ tests := []struct {
339+ name string
340+ domain string
341+ patterns []string
342+ want bool
343+ }{
344+ {name: "exact match", domain: "app.example.com", patterns: []string{"app.example.com"}, want: true},
345+ {name: "no match", domain: "app.example.com", patterns: []string{"api.example.com"}, want: false},
346+ {name: "wildcard match", domain: "app.example.com", patterns: []string{"*.example.com"}, want: true},
347+ {name: "wildcard no match on root", domain: "example.com", patterns: []string{"*.example.com"}, want: false},
348+ {name: "wildcard matches one level only", domain: "api.app.example.com", patterns: []string{"*.example.com"}, want: false},
349+ {name: "empty allowlist", domain: "app.example.com", patterns: nil, want: false},
350+ {name: "case insensitive", domain: "App.Example.Com", patterns: []string{"APP.EXAMPLE.COM"}, want: true},
351+ {name: "multiple patterns", domain: "api.example.com", patterns: []string{"foo.com", "*.example.com"}, want: true},
352+ }
353+
354+ for _, tt := range tests {
355+ t.Run(tt.name, func(t *testing.T) {
356+ assert.Equal(t, tt.want, matchesDomainAllowlist(tt.domain, tt.patterns))
357+ })
358+ }
359+}
360+
361+func TestExtractRepoName(t *testing.T) {
362+ tests := []struct {
363+ name string
364+ imageRef string
365+ registryDomain string
366+ want string
367+ }{
368+ {name: "simple tag", imageRef: "myapp:latest", want: "myapp"},
369+ {name: "version tag", imageRef: "myapp:v2.0.0", want: "myapp"},
370+ {name: "digest", imageRef: "myapp@sha256:abc123", want: "myapp"},
371+ {name: "strip registry", imageRef: "registry.example.com/myapp:latest", registryDomain: "registry.example.com", want: "myapp"},
372+ {name: "org image", imageRef: "org/myapp:latest", want: "org/myapp"},
373+ {name: "registry org image", imageRef: "registry.example.com/org/myapp:latest", registryDomain: "registry.example.com", want: "org/myapp"},
374+ {name: "lowercase", imageRef: "MyApp:Latest", want: "myapp"},
375+ }
376+
377+ for _, tt := range tests {
378+ t.Run(tt.name, func(t *testing.T) {
379+ assert.Equal(t, tt.want, extractRepoName(tt.imageRef, tt.registryDomain))
380+ })
381+ }
382+}
383+
337 // AutoRouteHandler tests384 // AutoRouteHandler tests
338 385
339 func TestAutoRouteHandler_CanHandle(t *testing.T) {386 func TestAutoRouteHandler_CanHandle(t *testing.T) {
@@ -450,6 +497,7 @@ func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
450 497
451 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)498 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
452 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)499 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
500+ configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"app.example.com"}, nil)
453 501
454 // No existing routes502 // No existing routes
455 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})503 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
@@ -510,6 +558,7 @@ func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
510 558
511 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)559 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
512 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)560 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
561+ configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*"}, nil)
513 562
514 // Existing route with different image563 // Existing route with different image
515 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{564 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
@@ -572,6 +621,7 @@ func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(t *testing.T) {
572 621
573 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)622 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
574 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)623 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
624+ configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*"}, nil)
575 625
576 // Existing route with same image - no update needed626 // Existing route with same image - no update needed
577 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{627 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
@@ -672,6 +722,7 @@ func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
672 722
673 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)723 configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
674 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)724 blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
725+ configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"app.example.com", "api.example.com", "www.example.com"}, nil).Once()
675 726
676 // No existing routes727 // No existing routes
677 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3)728 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3)
@@ -725,6 +776,98 @@ func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) {
725 handler.triggerDeploy(ctx, route)776 handler.triggerDeploy(ctx, route)
726 }777 }
727 778
779+func TestAutoRouteHandler_NewDomain_Allowed(t *testing.T) {
780+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
781+ configSvc := inmocks.NewMockConfigService(t)
782+ containerSvc := inmocks.NewMockContainerService(t)
783+ blobStorage := mocks.NewMockBlobStorage(t)
784+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
785+
786+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
787+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil)
788+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)
789+
790+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.example.com"})
791+ assert.True(t, created)
792+}
793+
794+func TestAutoRouteHandler_NewDomain_NotAllowed(t *testing.T) {
795+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
796+ configSvc := inmocks.NewMockConfigService(t)
797+ containerSvc := inmocks.NewMockContainerService(t)
798+ blobStorage := mocks.NewMockBlobStorage(t)
799+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
800+
801+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
802+
803+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.allowed.com"})
804+ assert.False(t, created)
805+}
806+
807+func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
808+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
809+ configSvc := inmocks.NewMockConfigService(t)
810+ containerSvc := inmocks.NewMockContainerService(t)
811+ blobStorage := mocks.NewMockBlobStorage(t)
812+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
813+
814+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:v1"}})
815+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(nil)
816+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(&domain.Container{ID: "c1"}, nil)
817+
818+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:v2", []string{"*.example.com"})
819+ assert.False(t, created)
820+}
821+
822+func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
823+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
824+ configSvc := inmocks.NewMockConfigService(t)
825+ containerSvc := inmocks.NewMockContainerService(t)
826+ blobStorage := mocks.NewMockBlobStorage(t)
827+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
828+
829+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "oldapp:v1"}})
830+
831+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "newapp:v2", []string{"*.example.com"})
832+ assert.False(t, created)
833+}
834+
835+func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
836+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
837+ configSvc := inmocks.NewMockConfigService(t)
838+ containerSvc := inmocks.NewMockContainerService(t)
839+ blobStorage := mocks.NewMockBlobStorage(t)
840+ extractor := &testEnvExtractor{}
841+
842+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, t.TempDir())
843+
844+ configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*.example.com"}, nil).Twice()
845+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once()
846+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil)
847+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)
848+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:latest"}}).Once()
849+
850+ handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
851+ handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
852+
853+ assert.Equal(t, 1, extractor.calls)
854+ assert.Equal(t, "registry.example.com/myapp:latest", extractor.lastImage)
855+ assert.Equal(t, ".env", extractor.lastPath)
856+}
857+
858+type testEnvExtractor struct {
859+ calls int
860+ lastImage string
861+ lastPath string
862+}
863+
864+func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef, envFilePath string) ([]byte, error) {
865+ e.calls++
866+ e.lastImage = imageRef
867+ e.lastPath = envFilePath
868+ return []byte("FOO=bar\n"), nil
869+}
870+
728 // collectDomains tests871 // collectDomains tests
729 872
730 func TestAutoRouteHandler_CollectDomains(t *testing.T) {873 func TestAutoRouteHandler_CollectDomains(t *testing.T) {

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