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] | |||
| 36 | 36 | ||
| 37 | 37 | ### 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 | 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 | 63 | ### 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 | 80 | Username: admin | |
| 67 | 81 | Password: ******** | |
| 68 | 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 | 85 | ✓ Authentication successful! | |
| 71 | 86 | Token stored for remote 'prod' | |
| 72 | 87 | Expires in: 604800 seconds | |
| @@ -36,7 +36,7 @@ gordon auth login [options] | |||||
| 36 | 36 | ||||
| 37 | ### Description | 37 | ### 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 | ### Output | 63 | ### 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: admin | 80 | 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 seconds | 87 | 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 | |||
| 43 | 43 | "data_dir": "/var/lib/gordon" | |
| 44 | 44 | }, | |
| 45 | 45 | "auto_route": { | |
| 46 | - | "enabled": true | |
| 46 | + | "enabled": true, | |
| 47 | + | "allowed_domains": ["example.com", "*.staging.example.com"] | |
| 47 | 48 | }, | |
| 48 | 49 | "network_isolation": { | |
| 49 | 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 | 66 | ## Related | |
| 62 | 67 | ||
| 63 | 68 | - [CLI Overview](./index.md) | |
| 69 | + | - [Auto-Route Commands](./autoroute.md) | |
| 64 | 70 | - [Status Command](./status.md) | |
| 65 | 71 | - [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": true | 46 | + | "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 | ## Related | 66 | ## 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 | |||
| 39 | 39 | | `gordon images` | List and prune images | [images](./images.md) | | |
| 40 | 40 | | `gordon config show` | Show server configuration | [config](./config.md) | | |
| 41 | 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 | 44 | ## Client Commands | |
| 44 | 45 | ||
| @@ -127,6 +128,12 @@ gordon secrets remove myapp.local DATABASE_URL | |||
| 127 | 128 | gordon remotes add prod https://gordon.mydomain.com --token $TOKEN | |
| 128 | 129 | gordon remotes list | |
| 129 | 130 | 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 | 139 | ## 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 Commands | 44 | ## 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 $TOKEN | 128 | gordon remotes add prod https://gordon.mydomain.com --token $TOKEN | ||
| 128 | gordon remotes list | 129 | gordon remotes list | ||
| 129 | gordon remotes use prod | 130 | 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 Options | 139 | ## Global Options | ||
M docs/cli/remotes.md +11 -3
| @@ -79,7 +79,13 @@ gordon remotes add dev https://dev.internal --insecure | |||
| 79 | 79 | ||
| 80 | 80 | ### 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 | 90 | ```bash | |
| 85 | 91 | gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN | |
| @@ -95,7 +101,7 @@ token_env = "PROD_TOKEN" | |||
| 95 | 101 | ||
| 96 | 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 | 106 | ```bash | |
| 101 | 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 | 251 | **Token:** | |
| 246 | 252 | 1. `--token` flag | |
| 247 | 253 | 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 | 258 | **Insecure TLS:** | |
| 251 | 259 | 1. `--insecure` flag | |
| @@ -79,7 +79,13 @@ gordon remotes add dev https://dev.internal --insecure | |||||
| 79 | 79 | ||||
| 80 | ### Token Security | 80 | ### 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 | ```bash | 90 | ```bash | ||
| 85 | gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN | 91 | 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 | ```bash | 106 | ```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` flag | 252 | 1. `--token` flag | ||
| 247 | 2. `GORDON_TOKEN` environment variable | 253 | 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` flag | 259 | 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 | |||
| 96 | 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 | 111 | func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 100 | 112 | if c.getStatus != nil { | |
| 101 | 113 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "net/http" | |
| 5 | 5 | "net/http/httptest" | |
| 6 | + | "path/filepath" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/stretchr/testify/assert" | |
| @@ -12,6 +13,7 @@ import ( | |||
| 12 | 13 | ||
| 13 | 14 | func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) { | |
| 14 | 15 | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 16 | + | t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled")) | |
| 15 | 17 | ||
| 16 | 18 | var receivedAuth string | |
| 17 | 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 | 48 | func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) { | |
| 47 | 49 | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 50 | + | t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled")) | |
| 48 | 51 | ||
| 49 | 52 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 50 | 53 | 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 string | 18 | 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 { | |||
| 33 | 33 | FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) | |
| 34 | 34 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 35 | 35 | 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 | 40 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 38 | 41 | 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) error | 34 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | ||
| 35 | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | 35 | 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) error | 41 | 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, | |||
| 307 | 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 | 331 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 311 | 332 | if l.configSvc == nil { | |
| 312 | 333 | 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 | |||
| 88 | 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 | 103 | func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 92 | 104 | return r.client.GetStatus(ctx) | |
| 93 | 105 | } | |
| @@ -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 | |||
| 96 | 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 | 111 | func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) { | |
| 100 | 112 | panic("unexpected call") | |
| 101 | 113 | } | |
| @@ -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 ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | 9 | "net/http" | |
| 10 | + | "os" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 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 | 74 | // UpdateRemoteToken updates the token for a named remote. | |
| 74 | 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 | 100 | config, err := LoadRemotes("") | |
| 76 | 101 | if err != nil { | |
| 77 | 102 | 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 err | 102 | 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 | |||
| 946 | 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 | 985 | // streamLogs handles SSE streaming for log endpoints. | |
| 950 | 986 | func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) { | |
| 951 | 987 | 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" + path | 987 | 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 | |||
| 191 | 191 | ||
| 192 | 192 | // resolveToken resolves the authentication token from various sources. | |
| 193 | 193 | func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) string { | |
| 194 | + | activeRemote := activeRemoteEntry(remotes) | |
| 195 | + | ||
| 194 | 196 | // 1. Flag token takes precedence | |
| 195 | 197 | if flagToken != "" { | |
| 196 | 198 | return flagToken | |
| @@ -207,22 +209,50 @@ func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) | |||
| 207 | 209 | return config.Client.Token | |
| 208 | 210 | } | |
| 209 | 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 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 | |
| 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 | 256 | return "" | |
| 227 | 257 | } | |
| 228 | 258 | ||
| @@ -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 precedence | 196 | // 1. Flag token takes precedence | ||
| 195 | if flagToken != "" { | 197 | if flagToken != "" { | ||
| 196 | return flagToken | 198 | return flagToken | ||
| @@ -207,22 +209,50 @@ func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) | |||||
| 207 | return config.Client.Token | 209 | 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 token | 218 | + | // 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: | |||
| 137 | 137 | networksCmd.GroupID = groupManage | |
| 138 | 138 | rootCmd.AddCommand(networksCmd) | |
| 139 | 139 | ||
| 140 | + | autorouteCmd := newAutorouteCmd() | |
| 141 | + | autorouteCmd.GroupID = groupManage | |
| 142 | + | rootCmd.AddCommand(autorouteCmd) | |
| 143 | + | ||
| 140 | 144 | // Client-only commands (no server needed) | |
| 141 | 145 | remotesCmd := newRemotesCmd() | |
| 142 | 146 | remotesCmd.GroupID = groupClient | |
| @@ -137,6 +137,10 @@ Commands are organized by where they run: | |||||
| 137 | networksCmd.GroupID = groupManage | 137 | 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 = groupClient | 146 | 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) { | |||
| 229 | 229 | {"/tags", h.handleTags}, | |
| 230 | 230 | {"/images", h.handleImages}, | |
| 231 | 231 | {"/logs", h.handleLogs}, | |
| 232 | + | {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains}, | |
| 232 | 233 | } | |
| 233 | 234 | for _, route := range prefixRoutes { | |
| 234 | 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 | 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 | 1799 | // handleAuthVerify handles /admin/auth/verify endpoint. | |
| 1713 | 1800 | // Validates authentication session and returns token status. | |
| 1714 | 1801 | 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 ( | |||
| 11 | 11 | "os" | |
| 12 | 12 | "path/filepath" | |
| 13 | 13 | "strings" | |
| 14 | + | "sync" | |
| 14 | 15 | ||
| 15 | 16 | "github.com/bnema/zerowrap" | |
| 16 | 17 | "github.com/google/uuid" | |
| @@ -20,8 +21,9 @@ import ( | |||
| 20 | 21 | ||
| 21 | 22 | // BlobStorage implements the BlobStorage interface using the local filesystem. | |
| 22 | 23 | type BlobStorage struct { | |
| 23 | - | rootDir string | |
| 24 | - | log zerowrap.Logger | |
| 24 | + | rootDir string | |
| 25 | + | log zerowrap.Logger | |
| 26 | + | uploadLocks sync.Map | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 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 | 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 | 258 | file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600) | |
| 253 | 259 | if err != nil { | |
| 254 | 260 | if os.IsNotExist(err) { | |
| 261 | + | s.cleanupUploadLock(uuid) | |
| 255 | 262 | return 0, fmt.Errorf("upload not found: %s", uuid) | |
| 256 | 263 | } | |
| 257 | 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 | 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 | 292 | func (s *BlobStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) { | |
| 285 | 293 | uploadPath, err := s.getUploadPath(uuid) | |
| 286 | 294 | if err != nil { | |
| 287 | 295 | return nil, fmt.Errorf("invalid upload path: %w", err) | |
| 288 | 296 | } | |
| 289 | 297 | ||
| 298 | + | mu := s.getUploadLock(uuid) | |
| 299 | + | mu.Lock() | |
| 300 | + | ||
| 290 | 301 | file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600) | |
| 291 | 302 | if err != nil { | |
| 303 | + | mu.Unlock() | |
| 292 | 304 | if os.IsNotExist(err) { | |
| 305 | + | s.cleanupUploadLock(uuid) | |
| 293 | 306 | return nil, fmt.Errorf("upload not found: %s", uuid) | |
| 294 | 307 | } | |
| 295 | 308 | return nil, fmt.Errorf("failed to open upload file: %w", err) | |
| 296 | 309 | } | |
| 297 | 310 | ||
| 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 | |
| 299 | 332 | } | |
| 300 | 333 | ||
| 301 | 334 | // FinishBlobUpload completes an upload and moves it to blob storage. | |
| @@ -304,6 +337,17 @@ func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error { | |||
| 304 | 337 | if err != nil { | |
| 305 | 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 | 351 | blobPath, err := s.getBlobPath(digest) | |
| 308 | 352 | if err != nil { | |
| 309 | 353 | return fmt.Errorf("invalid blob path: %w", err) | |
| @@ -323,6 +367,7 @@ func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error { | |||
| 323 | 367 | if err := os.Rename(uploadPath, blobPath); err != nil { | |
| 324 | 368 | return fmt.Errorf("failed to move upload to blob location: %w", err) | |
| 325 | 369 | } | |
| 370 | + | finalized = true | |
| 326 | 371 | ||
| 327 | 372 | s.log.Info(). | |
| 328 | 373 | Str(zerowrap.FieldLayer, "adapter"). | |
| @@ -341,12 +386,24 @@ func (s *BlobStorage) CancelBlobUpload(uuid string) error { | |||
| 341 | 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 | 399 | if err := os.Remove(uploadPath); err != nil { | |
| 345 | 400 | if os.IsNotExist(err) { | |
| 401 | + | s.cleanupUploadLock(uuid) | |
| 346 | 402 | return fmt.Errorf("upload not found: %s", uuid) | |
| 347 | 403 | } | |
| 348 | 404 | return fmt.Errorf("failed to cancel upload: %w", err) | |
| 349 | 405 | } | |
| 406 | + | finalized = true | |
| 350 | 407 | ||
| 351 | 408 | s.log.Info(). | |
| 352 | 409 | Str(zerowrap.FieldLayer, "adapter"). | |
| @@ -406,6 +463,15 @@ func (s *BlobStorage) getUploadPath(uuid string) (string, error) { | |||
| 406 | 463 | 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 | 475 | // verifyUploadDigest computes and verifies the digest of an uploaded file. | |
| 410 | 476 | func (s *BlobStorage) verifyUploadDigest(uploadPath, expectedDigest string) error { | |
| 411 | 477 | 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 string | 24 | + | rootDir string |
| 24 | - | log zerowrap.Logger | 25 | + | 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, 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 | |||
| 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, nil | 463 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "encoding/hex" | |
| 5 | 7 | "io" | |
| 6 | 8 | "os" | |
| 7 | 9 | "path/filepath" | |
| 10 | + | "sync" | |
| 8 | 11 | "testing" | |
| 9 | 12 | ||
| 10 | 13 | "github.com/bnema/zerowrap" | |
| @@ -484,3 +487,181 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) { | |||
| 484 | 487 | err = storage.CancelBlobUpload(uuid) | |
| 485 | 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 | + | } | |
| @@ -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 { | |||
| 79 | 79 | ||
| 80 | 80 | // RemoveAttachment removes an image from a domain/group's attachments. | |
| 81 | 81 | 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 | } | |
| @@ -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) error | 81 | 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 | |||
| 101 | 101 | 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 | 161 | // AddRoute provides a mock function for the type MockConfigService | |
| 105 | 162 | func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error { | |
| 106 | 163 | ret := _mock.Called(ctx, route) | |
| @@ -397,6 +454,68 @@ func (_c *MockConfigService_GetAttachmentsFor_Call) RunAndReturn(run func(ctx co | |||
| 397 | 454 | 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 | 519 | // GetDataDir provides a mock function for the type MockConfigService | |
| 401 | 520 | func (_mock *MockConfigService) GetDataDir() string { | |
| 402 | 521 | ret := _mock.Called() | |
| @@ -1037,6 +1156,63 @@ func (_c *MockConfigService_RemoveAttachment_Call) RunAndReturn(run func(ctx con | |||
| 1037 | 1156 | 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 | 1216 | // RemoveRoute provides a mock function for the type MockConfigService | |
| 1041 | 1217 | func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error { | |
| 1042 | 1218 | ret := _mock.Called(ctx, domain1) | |
| @@ -101,6 +101,63 @@ func (_c *MockConfigService_AddAttachment_Call) RunAndReturn(run func(ctx contex | |||||
| 101 | return _c | 101 | 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 MockConfigService | 161 | // 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 _c | 454 | 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 MockConfigService | 519 | // 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 _c | 1156 | 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 MockConfigService | 1216 | // 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 ( | |||
| 41 | 41 | ErrVolumeExists = errors.New("volume already exists") | |
| 42 | 42 | ||
| 43 | 43 | // 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") | |
| 47 | 48 | ||
| 48 | 49 | // Environment errors | |
| 49 | 50 | ErrEnvFileNotFound = errors.New("environment file not found") | |
| @@ -66,6 +67,12 @@ var ( | |||
| 66 | 67 | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | |
| 67 | 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 | 76 | // Attachment errors | |
| 70 | 77 | ErrAttachmentNotFound = errors.New("attachment not found") | |
| 71 | 78 | 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 errors | 43 | // 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 errors | 49 | // 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 errors | 76 | // 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 ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "reflect" | |
| 11 | + | "regexp" | |
| 11 | 12 | "slices" | |
| 12 | 13 | "strconv" | |
| 13 | 14 | "strings" | |
| @@ -26,23 +27,24 @@ import ( | |||
| 26 | 27 | ||
| 27 | 28 | // Config holds the loaded configuration. | |
| 28 | 29 | 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 | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 48 | 50 | // Service implements the ConfigService interface. | |
| @@ -121,23 +123,24 @@ func (s *Service) loadConfigValues() Config { | |||
| 121 | 123 | } | |
| 122 | 124 | ||
| 123 | 125 | 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), | |
| 141 | 144 | } | |
| 142 | 145 | } | |
| 143 | 146 | ||
| @@ -516,7 +519,7 @@ type configSnapshot struct { | |||
| 516 | 519 | } | |
| 517 | 520 | ||
| 518 | 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 | 524 | func isMutableKey(key string) bool { | |
| 522 | 525 | for _, prefix := range mutableSections { | |
| @@ -526,7 +529,7 @@ func isMutableKey(key string) bool { | |||
| 526 | 529 | } | |
| 527 | 530 | ||
| 528 | 531 | switch key { | |
| 529 | - | case "routes", "external_routes", "attachments", "network_groups": | |
| 532 | + | case "routes", "external_routes", "attachments", "network_groups", "auto_route_allowed_domains": | |
| 530 | 533 | return true | |
| 531 | 534 | } | |
| 532 | 535 | ||
| @@ -566,6 +569,7 @@ func (s *Service) writeConfigSurgical(configFile string) error { | |||
| 566 | 569 | s.viper.Set("external_routes", s.config.ExternalRoutes) | |
| 567 | 570 | s.viper.Set("attachments", s.config.Attachments) | |
| 568 | 571 | s.viper.Set("network_groups", s.config.NetworkGroups) | |
| 572 | + | s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains) | |
| 569 | 573 | return s.viper.WriteConfigAs(configFile) | |
| 570 | 574 | } | |
| 571 | 575 | return fmt.Errorf("failed to read config file: %w", err) | |
| @@ -585,6 +589,7 @@ func (s *Service) writeConfigSurgical(configFile string) error { | |||
| 585 | 589 | config["external_routes"] = s.config.ExternalRoutes | |
| 586 | 590 | config["attachments"] = s.config.Attachments | |
| 587 | 591 | config["network_groups"] = s.config.NetworkGroups | |
| 592 | + | config["auto_route_allowed_domains"] = s.config.AutoRouteAllowedDomains | |
| 588 | 593 | ||
| 589 | 594 | out, err := toml.Marshal(config) | |
| 590 | 595 | if err != nil { | |
| @@ -1045,6 +1050,131 @@ func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image str | |||
| 1045 | 1050 | 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 | 1178 | // GetExternalRoutes returns all configured external routes. | |
| 1049 | 1179 | func (s *Service) GetExternalRoutes() map[string]string { | |
| 1050 | 1180 | 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 int | 30 | + | ServerPort int |
| 30 | - | RegistryPort int | 31 | + | RegistryPort int |
| 31 | - | RegistryDomain string | 32 | + | RegistryDomain string |
| 32 | - | DataDir string | 33 | + | DataDir string |
| 33 | - | AutoRouteEnabled bool | 34 | + | AutoRouteEnabled bool |
| 34 | - | NetworkIsolation bool | 35 | + | AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"` |
| 35 | - | NetworkPrefix string | 36 | + | NetworkIsolation bool |
| 36 | - | Routes map[string]string | 37 | + | NetworkPrefix string |
| 37 | - | ExternalRoutes map[string]string // domain -> "host:port" | 38 | + | Routes map[string]string |
| 38 | - | RegistryAuthEnabled bool | 39 | + | ExternalRoutes map[string]string // domain -> "host:port" |
| 39 | - | RegistryAuthUsername string | 40 | + | RegistryAuthEnabled bool |
| 40 | - | RegistryAuthPassword string | 41 | + | RegistryAuthUsername string |
| 41 | - | VolumeAutoCreate bool | 42 | + | RegistryAuthPassword string |
| 42 | - | VolumePrefix string | 43 | + | VolumeAutoCreate bool |
| 43 | - | VolumePreserve bool | 44 | + | VolumePrefix string |
| 44 | - | NetworkGroups map[string][]string | 45 | + | VolumePreserve bool |
| 45 | - | Attachments map[string][]string | 46 | + | 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 true | 533 | 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.ExternalRoutes | 589 | config["external_routes"] = s.config.ExternalRoutes | ||
| 586 | config["attachments"] = s.config.Attachments | 590 | config["attachments"] = s.config.Attachments | ||
| 587 | config["network_groups"] = s.config.NetworkGroups | 591 | 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 nil | 1050 | 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) { | |||
| 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 | 958 | func TestService_GetExternalRoutes(t *testing.T) { | |
| 895 | 959 | v := viper.New() | |
| 896 | 960 | 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 { | |||
| 160 | 160 | func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, imageName string, labels *domain.ImageLabels) { | |
| 161 | 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 | 170 | // Build full image reference for env extraction (Docker needs fully qualified name) | |
| 164 | 171 | fullImageRef := h.buildFullImageRef(imageName) | |
| 165 | 172 | ||
| @@ -169,9 +176,9 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, | |||
| 169 | 176 | 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 | 182 | // Use full image ref for extraction (Docker knows image by this name) | |
| 176 | 183 | if err := h.extractAndMergeEnvFile(ctx, fullImageRef, routeDomain, labels.EnvFile); err != nil { | |
| 177 | 184 | log.Warn(). | |
| @@ -187,7 +194,7 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, | |||
| 187 | 194 | // createOrUpdateRoute creates a new route or updates an existing one. | |
| 188 | 195 | // It also triggers deployment for new routes since the ImagePushedHandler | |
| 189 | 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 | 198 | log := zerowrap.FromCtx(ctx) | |
| 192 | 199 | ||
| 193 | 200 | route := domain.Route{ | |
| @@ -198,6 +205,10 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, | |||
| 198 | 205 | existingRoutes := h.configSvc.GetRoutes(ctx) | |
| 199 | 206 | for _, existing := range existingRoutes { | |
| 200 | 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 | 212 | if existing.Image != imageName { | |
| 202 | 213 | if err := h.configSvc.UpdateRoute(ctx, route); err != nil { | |
| 203 | 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 | 218 | h.triggerDeploy(ctx, route) | |
| 208 | 219 | } | |
| 209 | 220 | } | |
| 210 | - | return | |
| 221 | + | 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 | 230 | if err := h.configSvc.AddRoute(ctx, route); err != nil { | |
| 215 | 231 | log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route") | |
| 216 | 232 | } else { | |
| 217 | 233 | log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels") | |
| 218 | 234 | // Trigger deploy for new route since ImagePushedHandler may have already finished | |
| 219 | 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 | 291 | // 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 | continue | 176 | 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 ImagePushedHandler | 195 | // 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 | - | return | 221 | + | 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 finished | 234 | // 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) { | |||
| 334 | 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 | 384 | // AutoRouteHandler tests | |
| 338 | 385 | ||
| 339 | 386 | func TestAutoRouteHandler_CanHandle(t *testing.T) { | |
| @@ -450,6 +497,7 @@ func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) { | |||
| 450 | 497 | ||
| 451 | 498 | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |
| 452 | 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 | 502 | // No existing routes | |
| 455 | 503 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | |
| @@ -510,6 +558,7 @@ func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) { | |||
| 510 | 558 | ||
| 511 | 559 | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |
| 512 | 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 | 563 | // Existing route with different image | |
| 515 | 564 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| @@ -572,6 +621,7 @@ func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(t *testing.T) { | |||
| 572 | 621 | ||
| 573 | 622 | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |
| 574 | 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 | 626 | // Existing route with same image - no update needed | |
| 577 | 627 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| @@ -672,6 +722,7 @@ func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) { | |||
| 672 | 722 | ||
| 673 | 723 | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |
| 674 | 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 | 727 | // No existing routes | |
| 677 | 728 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3) | |
| @@ -725,6 +776,98 @@ func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) { | |||
| 725 | 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 | 871 | // collectDomains tests | |
| 729 | 872 | ||
| 730 | 873 | 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 tests | 384 | // 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 routes | 502 | // 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 image | 563 | // 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 needed | 626 | // 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 routes | 727 | // 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 tests | 871 | // collectDomains tests | ||
| 729 | 872 | ||||
| 730 | func TestAutoRouteHandler_CollectDomains(t *testing.T) { | 873 | func TestAutoRouteHandler_CollectDomains(t *testing.T) { | ||