Merge pull request #69 from bnema/sec/auth-proxy-critical-fixes
refactor(cli): local control plane parity + security hardening
35 files · +1814 -258
M docs/cli/backup.md +13 -10
| @@ -2,10 +2,10 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Manage backups for database attachments. | |
| 4 | 4 | ||
| 5 | - | ## gordon backup | |
| 5 | + | ## gordon backups | |
| 6 | 6 | ||
| 7 | 7 | ```bash | |
| 8 | - | gordon backup <subcommand> | |
| 8 | + | gordon backups <subcommand> | |
| 9 | 9 | ``` | |
| 10 | 10 | ||
| 11 | 11 | Subcommands: | |
| @@ -15,9 +15,12 @@ Subcommands: | |||
| 15 | 15 | - `detect <domain>` - Detect supported databases for a domain | |
| 16 | 16 | - `status` - Show backup status across domains | |
| 17 | 17 | ||
| 18 | - | > **Note:** Backup commands currently require remote mode (`--remote` + `--token`, or configured remotes). | |
| 18 | + | > Backup commands work both locally and remotely. | |
| 19 | 19 | > | |
| 20 | - | > `gordon backup run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`. | |
| 20 | + | > - Local mode (no `--remote`) executes backups through in-process services. | |
| 21 | + | > - Remote mode uses the admin API on the target instance. | |
| 22 | + | > | |
| 23 | + | > `gordon backups run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`. | |
| 21 | 24 | ||
| 22 | 25 | In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags. | |
| 23 | 26 | ||
| @@ -25,22 +28,22 @@ In normal usage, configure remotes once (`gordon remotes ...`) and run backup co | |||
| 25 | 28 | ||
| 26 | 29 | ```bash | |
| 27 | 30 | # List all backups | |
| 28 | - | gordon backup list | |
| 31 | + | gordon backups list | |
| 29 | 32 | ||
| 30 | 33 | # List backups for one domain | |
| 31 | - | gordon backup list app.example.com | |
| 34 | + | gordon backups list app.example.com | |
| 32 | 35 | ||
| 33 | 36 | # Trigger backup (auto-detect when exactly one DB attachment exists) | |
| 34 | - | gordon backup run app.example.com | |
| 37 | + | gordon backups run app.example.com | |
| 35 | 38 | ||
| 36 | 39 | # Trigger backup for specific attachment name | |
| 37 | - | gordon backup run app.example.com --db postgres | |
| 40 | + | gordon backups run app.example.com --db postgres | |
| 38 | 41 | ||
| 39 | 42 | # Detect DB attachments | |
| 40 | - | gordon backup detect app.example.com | |
| 43 | + | gordon backups detect app.example.com | |
| 41 | 44 | ||
| 42 | 45 | # Status summary | |
| 43 | - | gordon backup status | |
| 46 | + | gordon backups status | |
| 44 | 47 | ``` | |
| 45 | 48 | ||
| 46 | 49 | ## Output Columns | |
| @@ -2,10 +2,10 @@ | |||||
| 2 | 2 | ||||
| 3 | Manage backups for database attachments. | 3 | Manage backups for database attachments. | ||
| 4 | 4 | ||||
| 5 | - | ## gordon backup | 5 | + | ## gordon backups |
| 6 | 6 | ||||
| 7 | ```bash | 7 | ```bash | ||
| 8 | - | gordon backup <subcommand> | 8 | + | gordon backups <subcommand> |
| 9 | ``` | 9 | ``` | ||
| 10 | 10 | ||||
| 11 | Subcommands: | 11 | Subcommands: | ||
| @@ -15,9 +15,12 @@ Subcommands: | |||||
| 15 | - `detect <domain>` - Detect supported databases for a domain | 15 | - `detect <domain>` - Detect supported databases for a domain | ||
| 16 | - `status` - Show backup status across domains | 16 | - `status` - Show backup status across domains | ||
| 17 | 17 | ||||
| 18 | - | > **Note:** Backup commands currently require remote mode (`--remote` + `--token`, or configured remotes). | 18 | + | > Backup commands work both locally and remotely. |
| 19 | > | 19 | > | ||
| 20 | - | > `gordon backup run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`. | 20 | + | > - Local mode (no `--remote`) executes backups through in-process services. |
| 21 | + | > - Remote mode uses the admin API on the target instance. | |||
| 22 | + | > | |||
| 23 | + | > `gordon backups run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`. | |||
| 21 | 24 | ||||
| 22 | In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags. | 25 | In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags. | ||
| 23 | 26 | ||||
| @@ -25,22 +28,22 @@ In normal usage, configure remotes once (`gordon remotes ...`) and run backup co | |||||
| 25 | 28 | ||||
| 26 | ```bash | 29 | ```bash | ||
| 27 | # List all backups | 30 | # List all backups | ||
| 28 | - | gordon backup list | 31 | + | gordon backups list |
| 29 | 32 | ||||
| 30 | # List backups for one domain | 33 | # List backups for one domain | ||
| 31 | - | gordon backup list app.example.com | 34 | + | gordon backups list app.example.com |
| 32 | 35 | ||||
| 33 | # Trigger backup (auto-detect when exactly one DB attachment exists) | 36 | # Trigger backup (auto-detect when exactly one DB attachment exists) | ||
| 34 | - | gordon backup run app.example.com | 37 | + | gordon backups run app.example.com |
| 35 | 38 | ||||
| 36 | # Trigger backup for specific attachment name | 39 | # Trigger backup for specific attachment name | ||
| 37 | - | gordon backup run app.example.com --db postgres | 40 | + | gordon backups run app.example.com --db postgres |
| 38 | 41 | ||||
| 39 | # Detect DB attachments | 42 | # Detect DB attachments | ||
| 40 | - | gordon backup detect app.example.com | 43 | + | gordon backups detect app.example.com |
| 41 | 44 | ||||
| 42 | # Status summary | 45 | # Status summary | ||
| 43 | - | gordon backup status | 46 | + | gordon backups status |
| 44 | ``` | 47 | ``` | ||
| 45 | 48 | ||||
| 46 | ## Output Columns | 49 | ## Output Columns | ||
M docs/cli/index.md +7 -5
| @@ -17,6 +17,8 @@ Commands are organized by where they run: | |||
| 17 | 17 | ||
| 18 | 18 | ## Management Commands (local or remote) | |
| 19 | 19 | ||
| 20 | + | Management commands run locally through in-process services by default. Add `--remote` to target another Gordon instance. | |
| 21 | + | ||
| 20 | 22 | | Command | Description | Documentation | | |
| 21 | 23 | |---------|-------------|---------------| | |
| 22 | 24 | | `gordon routes` | Manage routes | [routes](./routes.md) | | |
| @@ -29,7 +31,7 @@ Commands are organized by where they run: | |||
| 29 | 31 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |
| 30 | 32 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | |
| 31 | 33 | | `gordon status` | Show Gordon server status | [status](./status.md) | | |
| 32 | - | | `gordon backup` | Manage database backups | [backup](./backup.md) | | |
| 34 | + | | `gordon backups` | Manage database backups | [backup](./backup.md) | | |
| 33 | 35 | | `gordon images` | List and prune images | [images](./images.md) | | |
| 34 | 36 | ||
| 35 | 37 | ## Client Commands | |
| @@ -76,10 +78,10 @@ gordon logs myapp.local -f # Follow container logs | |||
| 76 | 78 | gordon version | |
| 77 | 79 | ||
| 78 | 80 | # Backups | |
| 79 | - | gordon backup list | |
| 80 | - | gordon backup run app.example.com | |
| 81 | - | gordon backup detect app.example.com | |
| 82 | - | gordon backup status | |
| 81 | + | gordon backups list | |
| 82 | + | gordon backups run app.example.com | |
| 83 | + | gordon backups detect app.example.com | |
| 84 | + | gordon backups status | |
| 83 | 85 | ||
| 84 | 86 | # Images | |
| 85 | 87 | gordon images list | |
| @@ -17,6 +17,8 @@ Commands are organized by where they run: | |||||
| 17 | 17 | ||||
| 18 | ## Management Commands (local or remote) | 18 | ## Management Commands (local or remote) | ||
| 19 | 19 | ||||
| 20 | + | Management commands run locally through in-process services by default. Add `--remote` to target another Gordon instance. | |||
| 21 | + | ||||
| 20 | | Command | Description | Documentation | | 22 | | Command | Description | Documentation | | ||
| 21 | |---------|-------------|---------------| | 23 | |---------|-------------|---------------| | ||
| 22 | | `gordon routes` | Manage routes | [routes](./routes.md) | | 24 | | `gordon routes` | Manage routes | [routes](./routes.md) | | ||
| @@ -29,7 +31,7 @@ Commands are organized by where they run: | |||||
| 29 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | 31 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | ||
| 30 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | 32 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | ||
| 31 | | `gordon status` | Show Gordon server status | [status](./status.md) | | 33 | | `gordon status` | Show Gordon server status | [status](./status.md) | | ||
| 32 | - | | `gordon backup` | Manage database backups | [backup](./backup.md) | | 34 | + | | `gordon backups` | Manage database backups | [backup](./backup.md) | |
| 33 | | `gordon images` | List and prune images | [images](./images.md) | | 35 | | `gordon images` | List and prune images | [images](./images.md) | | ||
| 34 | 36 | ||||
| 35 | ## Client Commands | 37 | ## Client Commands | ||
| @@ -76,10 +78,10 @@ gordon logs myapp.local -f # Follow container logs | |||||
| 76 | gordon version | 78 | gordon version | ||
| 77 | 79 | ||||
| 78 | # Backups | 80 | # Backups | ||
| 79 | - | gordon backup list | 81 | + | gordon backups list |
| 80 | - | gordon backup run app.example.com | 82 | + | gordon backups run app.example.com |
| 81 | - | gordon backup detect app.example.com | 83 | + | gordon backups detect app.example.com |
| 82 | - | gordon backup status | 84 | + | gordon backups status |
| 83 | 85 | ||||
| 84 | # Images | 86 | # Images | ||
| 85 | gordon images list | 87 | gordon images list | ||
M docs/cli/status.md +11 -4
| @@ -7,10 +7,14 @@ Show Gordon server status and container health. | |||
| 7 | 7 | Display server configuration and container status for all routes. | |
| 8 | 8 | ||
| 9 | 9 | ```bash | |
| 10 | + | gordon status | |
| 10 | 11 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | |
| 11 | 12 | ``` | |
| 12 | 13 | ||
| 13 | - | > **Note:** The status command requires remote mode. See [CLI Overview](./index.md) for targeting options. | |
| 14 | + | `gordon status` works in local mode and remote mode. | |
| 15 | + | ||
| 16 | + | - Local mode reads status from in-process services. | |
| 17 | + | - Remote mode reads status from the target admin API. | |
| 14 | 18 | ||
| 15 | 19 | ### Output | |
| 16 | 20 | ||
| @@ -70,9 +74,12 @@ The status command uses global flags for remote access: | |||
| 70 | 74 | ||
| 71 | 75 | ## Examples | |
| 72 | 76 | ||
| 73 | - | ### Check Remote Server Status | |
| 77 | + | ### Check Local or Remote Status | |
| 74 | 78 | ||
| 75 | 79 | ```bash | |
| 80 | + | # Local | |
| 81 | + | gordon status | |
| 82 | + | ||
| 76 | 83 | # Using flags | |
| 77 | 84 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | |
| 78 | 85 | ||
| @@ -89,9 +96,9 @@ gordon status | |||
| 89 | 96 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)" | |
| 90 | 97 | ``` | |
| 91 | 98 | ||
| 92 | - | ## Required Permissions | |
| 99 | + | ## Required Permissions (Remote Only) | |
| 93 | 100 | ||
| 94 | - | The status endpoint requires `admin:status:read` scope in the authentication token. | |
| 101 | + | Remote status calls require `admin:status:read` scope in the authentication token. | |
| 95 | 102 | ||
| 96 | 103 | ```bash | |
| 97 | 104 | # Generate token with required scope | |
| @@ -7,10 +7,14 @@ Show Gordon server status and container health. | |||||
| 7 | Display server configuration and container status for all routes. | 7 | Display server configuration and container status for all routes. | ||
| 8 | 8 | ||||
| 9 | ```bash | 9 | ```bash | ||
| 10 | + | gordon status | |||
| 10 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | 11 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | ||
| 11 | ``` | 12 | ``` | ||
| 12 | 13 | ||||
| 13 | - | > **Note:** The status command requires remote mode. See [CLI Overview](./index.md) for targeting options. | 14 | + | `gordon status` works in local mode and remote mode. |
| 15 | + | ||||
| 16 | + | - Local mode reads status from in-process services. | |||
| 17 | + | - Remote mode reads status from the target admin API. | |||
| 14 | 18 | ||||
| 15 | ### Output | 19 | ### Output | ||
| 16 | 20 | ||||
| @@ -70,9 +74,12 @@ The status command uses global flags for remote access: | |||||
| 70 | 74 | ||||
| 71 | ## Examples | 75 | ## Examples | ||
| 72 | 76 | ||||
| 73 | - | ### Check Remote Server Status | 77 | + | ### Check Local or Remote Status |
| 74 | 78 | ||||
| 75 | ```bash | 79 | ```bash | ||
| 80 | + | # Local | |||
| 81 | + | gordon status | |||
| 82 | + | ||||
| 76 | # Using flags | 83 | # Using flags | ||
| 77 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | 84 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | ||
| 78 | 85 | ||||
| @@ -89,9 +96,9 @@ gordon status | |||||
| 89 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)" | 96 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)" | ||
| 90 | ``` | 97 | ``` | ||
| 91 | 98 | ||||
| 92 | - | ## Required Permissions | 99 | + | ## Required Permissions (Remote Only) |
| 93 | 100 | ||||
| 94 | - | The status endpoint requires `admin:status:read` scope in the authentication token. | 101 | + | Remote status calls require `admin:status:read` scope in the authentication token. |
| 95 | 102 | ||||
| 96 | ```bash | 103 | ```bash | ||
| 97 | # Generate token with required scope | 104 | # Generate token with required scope | ||
M docs/config/auth.md +8 -9
| @@ -10,7 +10,6 @@ Authentication is enabled by default. Gordon will not start without a valid `tok | |||
| 10 | 10 | ||
| 11 | 11 | ```toml | |
| 12 | 12 | [auth] | |
| 13 | - | enabled = true | |
| 14 | 13 | secrets_backend = "pass" | |
| 15 | 14 | token_secret = "gordon/auth/token_secret" | |
| 16 | 15 | token_expiry = "30d" | |
| @@ -20,7 +19,6 @@ token_expiry = "30d" | |||
| 20 | 19 | ||
| 21 | 20 | ```toml | |
| 22 | 21 | [auth] | |
| 23 | - | enabled = true | |
| 24 | 22 | secrets_backend = "pass" | |
| 25 | 23 | username = "deploy" | |
| 26 | 24 | password_hash = "gordon/auth/password_hash" | |
| @@ -33,7 +31,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive | |||
| 33 | 31 | ||
| 34 | 32 | | Option | Type | Default | Description | | |
| 35 | 33 | |--------|------|---------|-------------| | |
| 36 | - | | `enabled` | bool | `true` | Enable authentication | | |
| 34 | + | | `enabled` | bool | `true` | Authentication toggle. `false` enables local-only mode (see below) | | |
| 37 | 35 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | |
| 38 | 36 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | |
| 39 | 37 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | |
| @@ -154,14 +152,15 @@ gordon auth token generate --subject temp --expiry 30d | |||
| 154 | 152 | - Tokens from one Gordon instance won't work on another | |
| 155 | 153 | - Changing `token_secret` invalidates all existing tokens | |
| 156 | 154 | ||
| 157 | - | ## Disable Auth (development only) | |
| 155 | + | ## Local-only Mode | |
| 158 | 156 | ||
| 159 | - | ```toml | |
| 160 | - | [auth] | |
| 161 | - | enabled = false | |
| 162 | - | ``` | |
| 157 | + | Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode: | |
| 158 | + | ||
| 159 | + | - `/admin/*` endpoints are not registered. | |
| 160 | + | - `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`). | |
| 161 | + | - Remote registry and remote admin access are disabled. | |
| 163 | 162 | ||
| 164 | - | > **Security Warning:** When authentication is disabled, the admin API is fully accessible without credentials. This includes endpoints to deploy containers, manage secrets, modify routes, and reload configuration. Only disable auth for local development where Gordon is not exposed to the internet. | |
| 163 | + | Use this only when Gordon is intended for local machine usage. | |
| 165 | 164 | ||
| 166 | 165 | ## Internal Registry Auth | |
| 167 | 166 | ||
| @@ -10,7 +10,6 @@ Authentication is enabled by default. Gordon will not start without a valid `tok | |||||
| 10 | 10 | ||||
| 11 | ```toml | 11 | ```toml | ||
| 12 | [auth] | 12 | [auth] | ||
| 13 | - | enabled = true | |||
| 14 | secrets_backend = "pass" | 13 | secrets_backend = "pass" | ||
| 15 | token_secret = "gordon/auth/token_secret" | 14 | token_secret = "gordon/auth/token_secret" | ||
| 16 | token_expiry = "30d" | 15 | token_expiry = "30d" | ||
| @@ -20,7 +19,6 @@ token_expiry = "30d" | |||||
| 20 | 19 | ||||
| 21 | ```toml | 20 | ```toml | ||
| 22 | [auth] | 21 | [auth] | ||
| 23 | - | enabled = true | |||
| 24 | secrets_backend = "pass" | 22 | secrets_backend = "pass" | ||
| 25 | username = "deploy" | 23 | username = "deploy" | ||
| 26 | password_hash = "gordon/auth/password_hash" | 24 | password_hash = "gordon/auth/password_hash" | ||
| @@ -33,7 +31,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive | |||||
| 33 | 31 | ||||
| 34 | | Option | Type | Default | Description | | 32 | | Option | Type | Default | Description | | ||
| 35 | |--------|------|---------|-------------| | 33 | |--------|------|---------|-------------| | ||
| 36 | - | | `enabled` | bool | `true` | Enable authentication | | 34 | + | | `enabled` | bool | `true` | Authentication toggle. `false` enables local-only mode (see below) | |
| 37 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | 35 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | ||
| 38 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | 36 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | ||
| 39 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | 37 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | ||
| @@ -154,14 +152,15 @@ gordon auth token generate --subject temp --expiry 30d | |||||
| 154 | - Tokens from one Gordon instance won't work on another | 152 | - Tokens from one Gordon instance won't work on another | ||
| 155 | - Changing `token_secret` invalidates all existing tokens | 153 | - Changing `token_secret` invalidates all existing tokens | ||
| 156 | 154 | ||||
| 157 | - | ## Disable Auth (development only) | 155 | + | ## Local-only Mode |
| 158 | 156 | ||||
| 159 | - | ```toml | 157 | + | Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode: |
| 160 | - | [auth] | 158 | + | |
| 161 | - | enabled = false | 159 | + | - `/admin/*` endpoints are not registered. |
| 162 | - | ``` | 160 | + | - `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`). |
| 161 | + | - Remote registry and remote admin access are disabled. | |||
| 163 | 162 | ||||
| 164 | - | > **Security Warning:** When authentication is disabled, the admin API is fully accessible without credentials. This includes endpoints to deploy containers, manage secrets, modify routes, and reload configuration. Only disable auth for local development where Gordon is not exposed to the internet. | 163 | + | Use this only when Gordon is intended for local machine usage. |
| 165 | 164 | ||||
| 166 | ## Internal Registry Auth | 165 | ## Internal Registry Auth | ||
| 167 | 166 | ||||
M docs/config/backups.md +1 -1
| @@ -6,7 +6,7 @@ Configure automatic PostgreSQL logical backups for attachment containers. | |||
| 6 | 6 | ||
| 7 | 7 | When backups are enabled, Gordon detects PostgreSQL attachments for each route and can: | |
| 8 | 8 | ||
| 9 | - | - Run on-demand backups via `gordon backup run` | |
| 9 | + | - Run on-demand backups via `gordon backups run` | |
| 10 | 10 | - Store backup artifacts on the Gordon host filesystem | |
| 11 | 11 | - Apply retention policies per schedule tier | |
| 12 | 12 | ||
| @@ -6,7 +6,7 @@ Configure automatic PostgreSQL logical backups for attachment containers. | |||||
| 6 | 6 | ||||
| 7 | When backups are enabled, Gordon detects PostgreSQL attachments for each route and can: | 7 | When backups are enabled, Gordon detects PostgreSQL attachments for each route and can: | ||
| 8 | 8 | ||||
| 9 | - | - Run on-demand backups via `gordon backup run` | 9 | + | - Run on-demand backups via `gordon backups run` |
| 10 | - Store backup artifacts on the Gordon host filesystem | 10 | - Store backup artifacts on the Gordon host filesystem | ||
| 11 | - Apply retention policies per schedule tier | 11 | - Apply retention policies per schedule tier | ||
| 12 | 12 | ||||
M docs/config/index.md +2 -0
| @@ -195,6 +195,8 @@ keep_last = 3 | |||
| 195 | 195 | | `images.prune.schedule` | `"daily"` | | |
| 196 | 196 | | `images.prune.keep_last` | `3` | | |
| 197 | 197 | ||
| 198 | + | When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only. | |
| 199 | + | ||
| 198 | 200 | ## Hot Reload | |
| 199 | 201 | ||
| 200 | 202 | Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload: | |
| @@ -195,6 +195,8 @@ keep_last = 3 | |||||
| 195 | | `images.prune.schedule` | `"daily"` | | 195 | | `images.prune.schedule` | `"daily"` | | ||
| 196 | | `images.prune.keep_last` | `3` | | 196 | | `images.prune.keep_last` | `3` | | ||
| 197 | 197 | ||||
| 198 | + | When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only. | |||
| 199 | + | ||||
| 198 | ## Hot Reload | 200 | ## Hot Reload | ||
| 199 | 201 | ||||
| 200 | Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload: | 202 | Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload: | ||
M docs/config/reference.md +1 -1
| @@ -168,7 +168,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 168 | 168 | | `server.data_dir` | `~/.gordon` | Data directory | | |
| 169 | 169 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | |
| 170 | 170 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 171 | - | | `auth.enabled` | `true` | Enable authentication | | |
| 171 | + | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | |
| 172 | 172 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 173 | 173 | | `auth.token_expiry` | `"720h"` | 30 days | | |
| 174 | 174 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | |
| @@ -168,7 +168,7 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 168 | | `server.data_dir` | `~/.gordon` | Data directory | | 168 | | `server.data_dir` | `~/.gordon` | Data directory | | ||
| 169 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | 169 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | ||
| 170 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | 170 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | ||
| 171 | - | | `auth.enabled` | `true` | Enable authentication | | 171 | + | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | |
| 172 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | 172 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | ||
| 173 | | `auth.token_expiry` | `"720h"` | 30 days | | 173 | | `auth.token_expiry` | `"720h"` | 30 days | | ||
| 174 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | 174 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | ||
M docs/config/server.md +4 -0
| @@ -127,6 +127,10 @@ This domain is used for: | |||
| 127 | 127 | ||
| 128 | 128 | When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API). | |
| 129 | 129 | ||
| 130 | + | Security note: | |
| 131 | + | - Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients. | |
| 132 | + | - Admin API traffic should go through the main proxy listener (`server.port`/`server.tls_port`). | |
| 133 | + | ||
| 130 | 134 | > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility. | |
| 131 | 135 | ||
| 132 | 136 | ## Data Directory | |
| @@ -127,6 +127,10 @@ This domain is used for: | |||||
| 127 | 127 | ||||
| 128 | When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API). | 128 | When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API). | ||
| 129 | 129 | ||||
| 130 | + | Security note: | |||
| 131 | + | - Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients. | |||
| 132 | + | - Admin API traffic should go through the main proxy listener (`server.port`/`server.tls_port`). | |||
| 133 | + | ||||
| 130 | > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility. | 134 | > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility. | ||
| 131 | 135 | ||||
| 132 | ## Data Directory | 136 | ## Data Directory | ||
M docs/upgrading.md +1 -1
| @@ -121,7 +121,7 @@ password_hash = "gordon/auth/password_hash" | |||
| 121 | 121 | ||
| 122 | 122 | ### Breaking: Auth Enabled by Default | |
| 123 | 123 | ||
| 124 | - | Gordon now requires explicit auth configuration. Set `auth.enabled = false` to disable. | |
| 124 | + | Gordon keeps auth enabled by default. If you set `auth.enabled = false`, Gordon runs in local-only mode (`/admin/*` disabled, `/v2/*` loopback-only). | |
| 125 | 125 | ||
| 126 | 126 | ### Breaking: Secret Paths Changed | |
| 127 | 127 | ||
| @@ -121,7 +121,7 @@ password_hash = "gordon/auth/password_hash" | |||||
| 121 | 121 | ||||
| 122 | ### Breaking: Auth Enabled by Default | 122 | ### Breaking: Auth Enabled by Default | ||
| 123 | 123 | ||||
| 124 | - | Gordon now requires explicit auth configuration. Set `auth.enabled = false` to disable. | 124 | + | Gordon keeps auth enabled by default. If you set `auth.enabled = false`, Gordon runs in local-only mode (`/admin/*` disabled, `/v2/*` loopback-only). |
| 125 | 125 | ||||
| 126 | ### Breaking: Secret Paths Changed | 126 | ### Breaking: Secret Paths Changed | ||
| 127 | 127 | ||||
M internal/adapters/in/cli/backup.go +26 -20
| @@ -12,11 +12,13 @@ import ( | |||
| 12 | 12 | // newBackupCmd creates the backup command group. | |
| 13 | 13 | func newBackupCmd() *cobra.Command { | |
| 14 | 14 | cmd := &cobra.Command{ | |
| 15 | - | Use: "backup", | |
| 16 | - | Short: "Manage database backups", | |
| 17 | - | Long: `Manage database backups through Gordon's admin API. | |
| 15 | + | Use: "backups", | |
| 16 | + | Aliases: []string{"backup"}, | |
| 17 | + | Short: "Manage database backups", | |
| 18 | + | Long: `Manage database backups. | |
| 18 | 19 | ||
| 19 | - | These commands currently require remote mode with a configured target.`, | |
| 20 | + | Runs locally via in-process services by default, or against a remote Gordon | |
| 21 | + | instance when --remote targeting is configured.`, | |
| 20 | 22 | } | |
| 21 | 23 | ||
| 22 | 24 | cmd.AddCommand(newBackupListCmd()) | |
| @@ -33,17 +35,18 @@ func newBackupListCmd() *cobra.Command { | |||
| 33 | 35 | Short: "List backups", | |
| 34 | 36 | Args: cobra.MaximumNArgs(1), | |
| 35 | 37 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 36 | - | client, isRemote := GetRemoteClient() | |
| 37 | - | if !isRemote { | |
| 38 | - | return fmt.Errorf("backup commands require a configured remote target") | |
| 38 | + | handle, err := resolveControlPlane(configPath) | |
| 39 | + | if err != nil { | |
| 40 | + | return err | |
| 39 | 41 | } | |
| 42 | + | defer handle.close() | |
| 40 | 43 | ||
| 41 | 44 | domainName := "" | |
| 42 | 45 | if len(args) == 1 { | |
| 43 | 46 | domainName = args[0] | |
| 44 | 47 | } | |
| 45 | 48 | ||
| 46 | - | jobs, err := client.ListBackups(cmd.Context(), domainName) | |
| 49 | + | jobs, err := handle.plane.ListBackups(cmd.Context(), domainName) | |
| 47 | 50 | if err != nil { | |
| 48 | 51 | return fmt.Errorf("failed to list backups: %w", err) | |
| 49 | 52 | } | |
| @@ -79,12 +82,13 @@ func newBackupRunCmd() *cobra.Command { | |||
| 79 | 82 | Short: "Run backup now", | |
| 80 | 83 | Args: cobra.ExactArgs(1), | |
| 81 | 84 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 82 | - | client, isRemote := GetRemoteClient() | |
| 83 | - | if !isRemote { | |
| 84 | - | return fmt.Errorf("backup commands require a configured remote target") | |
| 85 | + | handle, err := resolveControlPlane(configPath) | |
| 86 | + | if err != nil { | |
| 87 | + | return err | |
| 85 | 88 | } | |
| 89 | + | defer handle.close() | |
| 86 | 90 | ||
| 87 | - | result, err := client.RunBackup(cmd.Context(), args[0], dbName) | |
| 91 | + | result, err := handle.plane.RunBackup(cmd.Context(), args[0], dbName) | |
| 88 | 92 | if err != nil { | |
| 89 | 93 | return fmt.Errorf("failed to run backup: %w", err) | |
| 90 | 94 | } | |
| @@ -117,12 +121,13 @@ func newBackupDetectCmd() *cobra.Command { | |||
| 117 | 121 | Short: "Detect databases for domain", | |
| 118 | 122 | Args: cobra.ExactArgs(1), | |
| 119 | 123 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 120 | - | client, isRemote := GetRemoteClient() | |
| 121 | - | if !isRemote { | |
| 122 | - | return fmt.Errorf("backup commands require a configured remote target") | |
| 124 | + | handle, err := resolveControlPlane(configPath) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 123 | 127 | } | |
| 128 | + | defer handle.close() | |
| 124 | 129 | ||
| 125 | - | dbs, err := client.DetectDatabases(cmd.Context(), args[0]) | |
| 130 | + | dbs, err := handle.plane.DetectDatabases(cmd.Context(), args[0]) | |
| 126 | 131 | if err != nil { | |
| 127 | 132 | return fmt.Errorf("failed to detect databases: %w", err) | |
| 128 | 133 | } | |
| @@ -155,12 +160,13 @@ func newBackupStatusCmd() *cobra.Command { | |||
| 155 | 160 | Use: "status", | |
| 156 | 161 | Short: "Show backup status", | |
| 157 | 162 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 158 | - | client, isRemote := GetRemoteClient() | |
| 159 | - | if !isRemote { | |
| 160 | - | return fmt.Errorf("backup commands require a configured remote target") | |
| 163 | + | handle, err := resolveControlPlane(configPath) | |
| 164 | + | if err != nil { | |
| 165 | + | return err | |
| 161 | 166 | } | |
| 167 | + | defer handle.close() | |
| 162 | 168 | ||
| 163 | - | jobs, err := client.BackupStatus(cmd.Context()) | |
| 169 | + | jobs, err := handle.plane.BackupStatus(cmd.Context()) | |
| 164 | 170 | if err != nil { | |
| 165 | 171 | return fmt.Errorf("failed to get backup status: %w", err) | |
| 166 | 172 | } | |
| @@ -12,11 +12,13 @@ import ( | |||||
| 12 | // newBackupCmd creates the backup command group. | 12 | // newBackupCmd creates the backup command group. | ||
| 13 | func newBackupCmd() *cobra.Command { | 13 | func newBackupCmd() *cobra.Command { | ||
| 14 | cmd := &cobra.Command{ | 14 | cmd := &cobra.Command{ | ||
| 15 | - | Use: "backup", | 15 | + | Use: "backups", |
| 16 | - | Short: "Manage database backups", | 16 | + | Aliases: []string{"backup"}, |
| 17 | - | Long: `Manage database backups through Gordon's admin API. | 17 | + | Short: "Manage database backups", |
| 18 | + | Long: `Manage database backups. | |||
| 18 | 19 | ||||
| 19 | - | These commands currently require remote mode with a configured target.`, | 20 | + | Runs locally via in-process services by default, or against a remote Gordon |
| 21 | + | instance when --remote targeting is configured.`, | |||
| 20 | } | 22 | } | ||
| 21 | 23 | ||||
| 22 | cmd.AddCommand(newBackupListCmd()) | 24 | cmd.AddCommand(newBackupListCmd()) | ||
| @@ -33,17 +35,18 @@ func newBackupListCmd() *cobra.Command { | |||||
| 33 | Short: "List backups", | 35 | Short: "List backups", | ||
| 34 | Args: cobra.MaximumNArgs(1), | 36 | Args: cobra.MaximumNArgs(1), | ||
| 35 | RunE: func(cmd *cobra.Command, args []string) error { | 37 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 36 | - | client, isRemote := GetRemoteClient() | 38 | + | handle, err := resolveControlPlane(configPath) |
| 37 | - | if !isRemote { | 39 | + | if err != nil { |
| 38 | - | return fmt.Errorf("backup commands require a configured remote target") | 40 | + | return err |
| 39 | } | 41 | } | ||
| 42 | + | defer handle.close() | |||
| 40 | 43 | ||||
| 41 | domainName := "" | 44 | domainName := "" | ||
| 42 | if len(args) == 1 { | 45 | if len(args) == 1 { | ||
| 43 | domainName = args[0] | 46 | domainName = args[0] | ||
| 44 | } | 47 | } | ||
| 45 | 48 | ||||
| 46 | - | jobs, err := client.ListBackups(cmd.Context(), domainName) | 49 | + | jobs, err := handle.plane.ListBackups(cmd.Context(), domainName) |
| 47 | if err != nil { | 50 | if err != nil { | ||
| 48 | return fmt.Errorf("failed to list backups: %w", err) | 51 | return fmt.Errorf("failed to list backups: %w", err) | ||
| 49 | } | 52 | } | ||
| @@ -79,12 +82,13 @@ func newBackupRunCmd() *cobra.Command { | |||||
| 79 | Short: "Run backup now", | 82 | Short: "Run backup now", | ||
| 80 | Args: cobra.ExactArgs(1), | 83 | Args: cobra.ExactArgs(1), | ||
| 81 | RunE: func(cmd *cobra.Command, args []string) error { | 84 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 82 | - | client, isRemote := GetRemoteClient() | 85 | + | handle, err := resolveControlPlane(configPath) |
| 83 | - | if !isRemote { | 86 | + | if err != nil { |
| 84 | - | return fmt.Errorf("backup commands require a configured remote target") | 87 | + | return err |
| 85 | } | 88 | } | ||
| 89 | + | defer handle.close() | |||
| 86 | 90 | ||||
| 87 | - | result, err := client.RunBackup(cmd.Context(), args[0], dbName) | 91 | + | result, err := handle.plane.RunBackup(cmd.Context(), args[0], dbName) |
| 88 | if err != nil { | 92 | if err != nil { | ||
| 89 | return fmt.Errorf("failed to run backup: %w", err) | 93 | return fmt.Errorf("failed to run backup: %w", err) | ||
| 90 | } | 94 | } | ||
| @@ -117,12 +121,13 @@ func newBackupDetectCmd() *cobra.Command { | |||||
| 117 | Short: "Detect databases for domain", | 121 | Short: "Detect databases for domain", | ||
| 118 | Args: cobra.ExactArgs(1), | 122 | Args: cobra.ExactArgs(1), | ||
| 119 | RunE: func(cmd *cobra.Command, args []string) error { | 123 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 120 | - | client, isRemote := GetRemoteClient() | 124 | + | handle, err := resolveControlPlane(configPath) |
| 121 | - | if !isRemote { | 125 | + | if err != nil { |
| 122 | - | return fmt.Errorf("backup commands require a configured remote target") | 126 | + | return err |
| 123 | } | 127 | } | ||
| 128 | + | defer handle.close() | |||
| 124 | 129 | ||||
| 125 | - | dbs, err := client.DetectDatabases(cmd.Context(), args[0]) | 130 | + | dbs, err := handle.plane.DetectDatabases(cmd.Context(), args[0]) |
| 126 | if err != nil { | 131 | if err != nil { | ||
| 127 | return fmt.Errorf("failed to detect databases: %w", err) | 132 | return fmt.Errorf("failed to detect databases: %w", err) | ||
| 128 | } | 133 | } | ||
| @@ -155,12 +160,13 @@ func newBackupStatusCmd() *cobra.Command { | |||||
| 155 | Use: "status", | 160 | Use: "status", | ||
| 156 | Short: "Show backup status", | 161 | Short: "Show backup status", | ||
| 157 | RunE: func(cmd *cobra.Command, args []string) error { | 162 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 158 | - | client, isRemote := GetRemoteClient() | 163 | + | handle, err := resolveControlPlane(configPath) |
| 159 | - | if !isRemote { | 164 | + | if err != nil { |
| 160 | - | return fmt.Errorf("backup commands require a configured remote target") | 165 | + | return err |
| 161 | } | 166 | } | ||
| 167 | + | defer handle.close() | |||
| 162 | 168 | ||||
| 163 | - | jobs, err := client.BackupStatus(cmd.Context()) | 169 | + | jobs, err := handle.plane.BackupStatus(cmd.Context()) |
| 164 | if err != nil { | 170 | if err != nil { | ||
| 165 | return fmt.Errorf("failed to get backup status: %w", err) | 171 | return fmt.Errorf("failed to get backup status: %w", err) | ||
| 166 | } | 172 | } | ||
A internal/adapters/in/cli/controlplane.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 8 | + | "github.com/bnema/gordon/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ControlPlane defines command operations available to CLI execution paths. | |
| 12 | + | // | |
| 13 | + | // Remote implementations call admin HTTP APIs. | |
| 14 | + | // Local implementations call services directly in-process. | |
| 15 | + | type ControlPlane interface { | |
| 16 | + | ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) | |
| 17 | + | GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) | |
| 18 | + | GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) | |
| 19 | + | AddRoute(ctx context.Context, route domain.Route) error | |
| 20 | + | UpdateRoute(ctx context.Context, route domain.Route) error | |
| 21 | + | RemoveRoute(ctx context.Context, routeDomain string) error | |
| 22 | + | ||
| 23 | + | ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | |
| 24 | + | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | |
| 25 | + | DeleteSecret(ctx context.Context, secretDomain, key string) error | |
| 26 | + | SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error | |
| 27 | + | DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error | |
| 28 | + | ||
| 29 | + | GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) | |
| 30 | + | GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) | |
| 31 | + | AddAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 32 | + | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 33 | + | ||
| 34 | + | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 35 | + | Reload(ctx context.Context) error | |
| 36 | + | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | |
| 37 | + | Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) | |
| 38 | + | ListTags(ctx context.Context, repository string) ([]string, error) | |
| 39 | + | ||
| 40 | + | ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) | |
| 41 | + | BackupStatus(ctx context.Context) ([]dto.BackupJob, error) | |
| 42 | + | RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) | |
| 43 | + | DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) | |
| 44 | + | ||
| 45 | + | GetProcessLogs(ctx context.Context, lines int) ([]string, error) | |
| 46 | + | GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) | |
| 47 | + | StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) | |
| 48 | + | StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ControlPlane defines command operations available to CLI execution paths. | |||
| 12 | + | // | |||
| 13 | + | // Remote implementations call admin HTTP APIs. | |||
| 14 | + | // Local implementations call services directly in-process. | |||
| 15 | + | type ControlPlane interface { | |||
| 16 | + | ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) | |||
| 17 | + | GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) | |||
| 18 | + | GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) | |||
| 19 | + | AddRoute(ctx context.Context, route domain.Route) error | |||
| 20 | + | UpdateRoute(ctx context.Context, route domain.Route) error | |||
| 21 | + | RemoveRoute(ctx context.Context, routeDomain string) error | |||
| 22 | + | ||||
| 23 | + | ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | |||
| 24 | + | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | |||
| 25 | + | DeleteSecret(ctx context.Context, secretDomain, key string) error | |||
| 26 | + | SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error | |||
| 27 | + | DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error | |||
| 28 | + | ||||
| 29 | + | GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) | |||
| 30 | + | GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) | |||
| 31 | + | AddAttachment(ctx context.Context, domainOrGroup, image string) error | |||
| 32 | + | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | |||
| 33 | + | ||||
| 34 | + | GetStatus(ctx context.Context) (*remote.Status, error) | |||
| 35 | + | Reload(ctx context.Context) error | |||
| 36 | + | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | |||
| 37 | + | Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) | |||
| 38 | + | ListTags(ctx context.Context, repository string) ([]string, error) | |||
| 39 | + | ||||
| 40 | + | ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) | |||
| 41 | + | BackupStatus(ctx context.Context) ([]dto.BackupJob, error) | |||
| 42 | + | RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) | |||
| 43 | + | DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) | |||
| 44 | + | ||||
| 45 | + | GetProcessLogs(ctx context.Context, lines int) ([]string, error) | |||
| 46 | + | GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) | |||
| 47 | + | StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) | |||
| 48 | + | StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) | |||
| 49 | + | } | |||
A internal/adapters/in/cli/controlplane_local.go +418 -0
| @@ -0,0 +1,418 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 9 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 10 | + | "github.com/bnema/gordon/internal/app" | |
| 11 | + | "github.com/bnema/gordon/internal/boundaries/in" | |
| 12 | + | "github.com/bnema/gordon/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type localControlPlane struct { | |
| 16 | + | configSvc in.ConfigService | |
| 17 | + | secretSvc in.SecretService | |
| 18 | + | containerSvc in.ContainerService | |
| 19 | + | backupSvc in.BackupService | |
| 20 | + | registrySvc in.RegistryService | |
| 21 | + | healthSvc in.HealthService | |
| 22 | + | logSvc in.LogService | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |
| 26 | + | if kernel == nil { | |
| 27 | + | return &localControlPlane{} | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | return &localControlPlane{ | |
| 31 | + | configSvc: kernel.Config(), | |
| 32 | + | secretSvc: kernel.Secrets(), | |
| 33 | + | containerSvc: kernel.Container(), | |
| 34 | + | backupSvc: kernel.Backup(), | |
| 35 | + | registrySvc: kernel.Registry(), | |
| 36 | + | healthSvc: kernel.Health(), | |
| 37 | + | logSvc: kernel.Logs(), | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) { | |
| 42 | + | if l.containerSvc != nil { | |
| 43 | + | return toRemoteRouteInfos(l.containerSvc.ListRoutesWithDetails(ctx)), nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | if l.configSvc == nil { | |
| 47 | + | return nil, fmt.Errorf("local config service unavailable") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | routes := l.configSvc.GetRoutes(ctx) | |
| 51 | + | infos := make([]remote.RouteInfo, 0, len(routes)) | |
| 52 | + | for _, route := range routes { | |
| 53 | + | infos = append(infos, remote.RouteInfo{Domain: route.Domain, Image: route.Image}) | |
| 54 | + | } | |
| 55 | + | return infos, nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (l *localControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) { | |
| 59 | + | if l.healthSvc == nil { | |
| 60 | + | return map[string]*remote.RouteHealth{}, nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | health := l.healthSvc.CheckAllRoutes(ctx) | |
| 64 | + | result := make(map[string]*remote.RouteHealth, len(health)) | |
| 65 | + | for domainName, h := range health { | |
| 66 | + | if h == nil { | |
| 67 | + | continue | |
| 68 | + | } | |
| 69 | + | result[domainName] = &remote.RouteHealth{ | |
| 70 | + | ContainerStatus: h.ContainerStatus, | |
| 71 | + | HTTPStatus: h.HTTPStatus, | |
| 72 | + | ResponseTimeMs: h.ResponseTimeMs, | |
| 73 | + | Healthy: h.Healthy, | |
| 74 | + | Error: h.Error, | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | return result, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | |
| 81 | + | if l.configSvc == nil { | |
| 82 | + | return nil, fmt.Errorf("local config service unavailable") | |
| 83 | + | } | |
| 84 | + | return l.configSvc.GetRoute(ctx, routeDomain) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | |
| 88 | + | if l.configSvc == nil { | |
| 89 | + | return fmt.Errorf("local config service unavailable") | |
| 90 | + | } | |
| 91 | + | return l.configSvc.AddRoute(ctx, route) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error { | |
| 95 | + | if l.configSvc == nil { | |
| 96 | + | return fmt.Errorf("local config service unavailable") | |
| 97 | + | } | |
| 98 | + | return l.configSvc.UpdateRoute(ctx, route) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | |
| 102 | + | if l.configSvc == nil { | |
| 103 | + | return fmt.Errorf("local config service unavailable") | |
| 104 | + | } | |
| 105 | + | return l.configSvc.RemoveRoute(ctx, routeDomain) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 109 | + | if l.secretSvc == nil { | |
| 110 | + | return nil, fmt.Errorf("local secret service unavailable") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain) | |
| 114 | + | if err != nil { | |
| 115 | + | return nil, err | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | result := &remote.SecretsListResult{ | |
| 119 | + | Domain: secretDomain, | |
| 120 | + | Keys: keys, | |
| 121 | + | } | |
| 122 | + | for _, att := range attachments { | |
| 123 | + | result.Attachments = append(result.Attachments, remote.AttachmentSecrets{ | |
| 124 | + | Service: att.Service, | |
| 125 | + | Keys: att.Keys, | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | return result, nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (l *localControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |
| 133 | + | if l.secretSvc == nil { | |
| 134 | + | return fmt.Errorf("local secret service unavailable") | |
| 135 | + | } | |
| 136 | + | return l.secretSvc.Set(ctx, secretDomain, secrets) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (l *localControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |
| 140 | + | if l.secretSvc == nil { | |
| 141 | + | return fmt.Errorf("local secret service unavailable") | |
| 142 | + | } | |
| 143 | + | return l.secretSvc.Delete(ctx, secretDomain, key) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (l *localControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error { | |
| 147 | + | if l.secretSvc == nil { | |
| 148 | + | return fmt.Errorf("local secret service unavailable") | |
| 149 | + | } | |
| 150 | + | return l.secretSvc.SetAttachment(ctx, domainName, service, secrets) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error { | |
| 154 | + | if l.secretSvc == nil { | |
| 155 | + | return fmt.Errorf("local secret service unavailable") | |
| 156 | + | } | |
| 157 | + | return l.secretSvc.DeleteAttachment(ctx, domainName, service, key) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |
| 161 | + | if l.configSvc == nil { | |
| 162 | + | return nil, fmt.Errorf("local config service unavailable") | |
| 163 | + | } | |
| 164 | + | return l.configSvc.GetAllAttachments(ctx), nil | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) { | |
| 168 | + | if l.configSvc == nil { | |
| 169 | + | return nil, fmt.Errorf("local config service unavailable") | |
| 170 | + | } | |
| 171 | + | return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 175 | + | if l.configSvc == nil { | |
| 176 | + | return fmt.Errorf("local config service unavailable") | |
| 177 | + | } | |
| 178 | + | return l.configSvc.AddAttachment(ctx, domainOrGroup, image) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 182 | + | if l.configSvc == nil { | |
| 183 | + | return fmt.Errorf("local config service unavailable") | |
| 184 | + | } | |
| 185 | + | return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 189 | + | if l.configSvc == nil { | |
| 190 | + | return nil, fmt.Errorf("local config service unavailable") | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | status := &remote.Status{ | |
| 194 | + | Routes: len(l.configSvc.GetRoutes(ctx)), | |
| 195 | + | RegistryDomain: l.configSvc.GetRegistryDomain(), | |
| 196 | + | RegistryPort: l.configSvc.GetRegistryPort(), | |
| 197 | + | ServerPort: l.configSvc.GetServerPort(), | |
| 198 | + | AutoRoute: l.configSvc.IsAutoRouteEnabled(), | |
| 199 | + | NetworkIsolation: l.configSvc.IsNetworkIsolationEnabled(), | |
| 200 | + | ContainerStatus: map[string]string{}, | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | if l.containerSvc != nil { | |
| 204 | + | for domainName, container := range l.containerSvc.List(ctx) { | |
| 205 | + | if container == nil { | |
| 206 | + | continue | |
| 207 | + | } | |
| 208 | + | status.ContainerStatus[domainName] = container.Status | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | return status, nil | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (l *localControlPlane) Reload(_ context.Context) error { | |
| 216 | + | return app.SendReloadSignal() | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | |
| 220 | + | if l.containerSvc != nil && l.configSvc != nil { | |
| 221 | + | route, err := l.configSvc.GetRoute(ctx, deployDomain) | |
| 222 | + | if err != nil { | |
| 223 | + | return nil, err | |
| 224 | + | } | |
| 225 | + | container, err := l.containerSvc.Deploy(ctx, *route) | |
| 226 | + | if err != nil { | |
| 227 | + | return nil, err | |
| 228 | + | } | |
| 229 | + | result := &remote.DeployResult{Status: "deployed", Domain: deployDomain} | |
| 230 | + | if container != nil { | |
| 231 | + | result.ContainerID = container.ID | |
| 232 | + | } | |
| 233 | + | return result, nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | domainName, err := app.SendDeploySignal(deployDomain) | |
| 237 | + | if err != nil { | |
| 238 | + | return nil, err | |
| 239 | + | } | |
| 240 | + | return &remote.DeployResult{Status: "queued", Domain: domainName}, nil | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func (l *localControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) { | |
| 244 | + | if l.containerSvc == nil { | |
| 245 | + | if withAttachments { | |
| 246 | + | return nil, fmt.Errorf("local restart with attachments requires active local container service") | |
| 247 | + | } | |
| 248 | + | domainName, err := app.SendDeploySignal(restartDomain) | |
| 249 | + | if err != nil { | |
| 250 | + | return nil, err | |
| 251 | + | } | |
| 252 | + | return &remote.RestartResult{Status: "queued", Domain: domainName}, nil | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | if err := l.containerSvc.SyncContainers(ctx); err != nil { | |
| 256 | + | return nil, err | |
| 257 | + | } | |
| 258 | + | if err := l.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil { | |
| 259 | + | return nil, err | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | return &remote.RestartResult{Status: "restarted", Domain: restartDomain}, nil | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func (l *localControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) { | |
| 266 | + | if l.registrySvc == nil { | |
| 267 | + | return nil, fmt.Errorf("local registry service unavailable") | |
| 268 | + | } | |
| 269 | + | return l.registrySvc.ListTags(ctx, repository) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func (l *localControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 273 | + | if l.backupSvc == nil { | |
| 274 | + | return nil, fmt.Errorf("local backup service unavailable") | |
| 275 | + | } | |
| 276 | + | jobs, err := l.backupSvc.ListBackups(ctx, backupDomain) | |
| 277 | + | if err != nil { | |
| 278 | + | return nil, err | |
| 279 | + | } | |
| 280 | + | return toDTOBackupJobs(jobs), nil | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func (l *localControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |
| 284 | + | if l.backupSvc == nil { | |
| 285 | + | return nil, fmt.Errorf("local backup service unavailable") | |
| 286 | + | } | |
| 287 | + | jobs, err := l.backupSvc.Status(ctx) | |
| 288 | + | if err != nil { | |
| 289 | + | return nil, err | |
| 290 | + | } | |
| 291 | + | return toDTOBackupJobs(jobs), nil | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func (l *localControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) { | |
| 295 | + | if l.backupSvc == nil { | |
| 296 | + | return nil, fmt.Errorf("local backup service unavailable") | |
| 297 | + | } | |
| 298 | + | result, err := l.backupSvc.RunBackup(ctx, backupDomain, dbName) | |
| 299 | + | if err != nil { | |
| 300 | + | return nil, err | |
| 301 | + | } | |
| 302 | + | if result == nil { | |
| 303 | + | return &dto.BackupRunResponse{Status: "ok"}, nil | |
| 304 | + | } | |
| 305 | + | job := toDTOBackupJob(result.Job) | |
| 306 | + | return &dto.BackupRunResponse{Status: "ok", Backup: &job}, nil | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |
| 310 | + | if l.backupSvc == nil { | |
| 311 | + | return nil, fmt.Errorf("local backup service unavailable") | |
| 312 | + | } | |
| 313 | + | dbs, err := l.backupSvc.DetectDatabases(ctx, backupDomain) | |
| 314 | + | if err != nil { | |
| 315 | + | return nil, err | |
| 316 | + | } | |
| 317 | + | out := make([]dto.DatabaseInfo, 0, len(dbs)) | |
| 318 | + | for _, db := range dbs { | |
| 319 | + | out = append(out, dto.DatabaseInfo{ | |
| 320 | + | Type: string(db.Type), | |
| 321 | + | Name: db.Name, | |
| 322 | + | Version: db.Version, | |
| 323 | + | Host: db.Host, | |
| 324 | + | Port: db.Port, | |
| 325 | + | ContainerID: db.ContainerID, | |
| 326 | + | ImageName: db.ImageName, | |
| 327 | + | }) | |
| 328 | + | } | |
| 329 | + | return out, nil | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) { | |
| 333 | + | if l.logSvc == nil { | |
| 334 | + | return nil, fmt.Errorf("local log service unavailable") | |
| 335 | + | } | |
| 336 | + | return l.logSvc.GetProcessLogs(ctx, lines) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func (l *localControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) { | |
| 340 | + | if l.logSvc == nil { | |
| 341 | + | return nil, fmt.Errorf("local log service unavailable") | |
| 342 | + | } | |
| 343 | + | return l.logSvc.GetContainerLogs(ctx, logDomain, lines) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func (l *localControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) { | |
| 347 | + | if l.logSvc == nil { | |
| 348 | + | return nil, fmt.Errorf("local log service unavailable") | |
| 349 | + | } | |
| 350 | + | return l.logSvc.FollowProcessLogs(ctx, lines) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | |
| 354 | + | if l.logSvc == nil { | |
| 355 | + | return nil, fmt.Errorf("local log service unavailable") | |
| 356 | + | } | |
| 357 | + | return l.logSvc.FollowContainerLogs(ctx, logDomain, lines) | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo { | |
| 361 | + | out := make([]remote.RouteInfo, 0, len(routes)) | |
| 362 | + | for _, route := range routes { | |
| 363 | + | attachments := make([]dto.Attachment, 0, len(route.Attachments)) | |
| 364 | + | for _, attachment := range route.Attachments { | |
| 365 | + | attachments = append(attachments, dto.Attachment{ | |
| 366 | + | Name: attachment.Name, | |
| 367 | + | Image: attachment.Image, | |
| 368 | + | ContainerID: attachment.ContainerID, | |
| 369 | + | Status: attachment.Status, | |
| 370 | + | Network: attachment.Network, | |
| 371 | + | }) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | out = append(out, remote.RouteInfo{ | |
| 375 | + | Domain: route.Domain, | |
| 376 | + | Image: route.Image, | |
| 377 | + | ContainerID: route.ContainerID, | |
| 378 | + | ContainerStatus: route.ContainerStatus, | |
| 379 | + | Network: route.Network, | |
| 380 | + | Attachments: attachments, | |
| 381 | + | }) | |
| 382 | + | } | |
| 383 | + | return out | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob { | |
| 387 | + | out := make([]dto.BackupJob, 0, len(jobs)) | |
| 388 | + | for _, job := range jobs { | |
| 389 | + | out = append(out, toDTOBackupJob(job)) | |
| 390 | + | } | |
| 391 | + | return out | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func toDTOBackupJob(job domain.BackupJob) dto.BackupJob { | |
| 395 | + | var startedAt *time.Time | |
| 396 | + | if !job.StartedAt.IsZero() { | |
| 397 | + | t := job.StartedAt | |
| 398 | + | startedAt = &t | |
| 399 | + | } | |
| 400 | + | var completedAt *time.Time | |
| 401 | + | if !job.CompletedAt.IsZero() { | |
| 402 | + | t := job.CompletedAt | |
| 403 | + | completedAt = &t | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | return dto.BackupJob{ | |
| 407 | + | ID: job.ID, | |
| 408 | + | Domain: job.Domain, | |
| 409 | + | DBName: job.DBName, | |
| 410 | + | Schedule: string(job.Schedule), | |
| 411 | + | Type: string(job.Type), | |
| 412 | + | Status: string(job.Status), | |
| 413 | + | StartedAt: startedAt, | |
| 414 | + | CompletedAt: completedAt, | |
| 415 | + | SizeBytes: job.SizeBytes, | |
| 416 | + | Error: job.Error, | |
| 417 | + | } | |
| 418 | + | } | |
| @@ -0,0 +1,418 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 9 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 10 | + | "github.com/bnema/gordon/internal/app" | |||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/in" | |||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type localControlPlane struct { | |||
| 16 | + | configSvc in.ConfigService | |||
| 17 | + | secretSvc in.SecretService | |||
| 18 | + | containerSvc in.ContainerService | |||
| 19 | + | backupSvc in.BackupService | |||
| 20 | + | registrySvc in.RegistryService | |||
| 21 | + | healthSvc in.HealthService | |||
| 22 | + | logSvc in.LogService | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |||
| 26 | + | if kernel == nil { | |||
| 27 | + | return &localControlPlane{} | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | return &localControlPlane{ | |||
| 31 | + | configSvc: kernel.Config(), | |||
| 32 | + | secretSvc: kernel.Secrets(), | |||
| 33 | + | containerSvc: kernel.Container(), | |||
| 34 | + | backupSvc: kernel.Backup(), | |||
| 35 | + | registrySvc: kernel.Registry(), | |||
| 36 | + | healthSvc: kernel.Health(), | |||
| 37 | + | logSvc: kernel.Logs(), | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) { | |||
| 42 | + | if l.containerSvc != nil { | |||
| 43 | + | return toRemoteRouteInfos(l.containerSvc.ListRoutesWithDetails(ctx)), nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | if l.configSvc == nil { | |||
| 47 | + | return nil, fmt.Errorf("local config service unavailable") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | routes := l.configSvc.GetRoutes(ctx) | |||
| 51 | + | infos := make([]remote.RouteInfo, 0, len(routes)) | |||
| 52 | + | for _, route := range routes { | |||
| 53 | + | infos = append(infos, remote.RouteInfo{Domain: route.Domain, Image: route.Image}) | |||
| 54 | + | } | |||
| 55 | + | return infos, nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (l *localControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) { | |||
| 59 | + | if l.healthSvc == nil { | |||
| 60 | + | return map[string]*remote.RouteHealth{}, nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | health := l.healthSvc.CheckAllRoutes(ctx) | |||
| 64 | + | result := make(map[string]*remote.RouteHealth, len(health)) | |||
| 65 | + | for domainName, h := range health { | |||
| 66 | + | if h == nil { | |||
| 67 | + | continue | |||
| 68 | + | } | |||
| 69 | + | result[domainName] = &remote.RouteHealth{ | |||
| 70 | + | ContainerStatus: h.ContainerStatus, | |||
| 71 | + | HTTPStatus: h.HTTPStatus, | |||
| 72 | + | ResponseTimeMs: h.ResponseTimeMs, | |||
| 73 | + | Healthy: h.Healthy, | |||
| 74 | + | Error: h.Error, | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | return result, nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | |||
| 81 | + | if l.configSvc == nil { | |||
| 82 | + | return nil, fmt.Errorf("local config service unavailable") | |||
| 83 | + | } | |||
| 84 | + | return l.configSvc.GetRoute(ctx, routeDomain) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | |||
| 88 | + | if l.configSvc == nil { | |||
| 89 | + | return fmt.Errorf("local config service unavailable") | |||
| 90 | + | } | |||
| 91 | + | return l.configSvc.AddRoute(ctx, route) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error { | |||
| 95 | + | if l.configSvc == nil { | |||
| 96 | + | return fmt.Errorf("local config service unavailable") | |||
| 97 | + | } | |||
| 98 | + | return l.configSvc.UpdateRoute(ctx, route) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | |||
| 102 | + | if l.configSvc == nil { | |||
| 103 | + | return fmt.Errorf("local config service unavailable") | |||
| 104 | + | } | |||
| 105 | + | return l.configSvc.RemoveRoute(ctx, routeDomain) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |||
| 109 | + | if l.secretSvc == nil { | |||
| 110 | + | return nil, fmt.Errorf("local secret service unavailable") | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return nil, err | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | result := &remote.SecretsListResult{ | |||
| 119 | + | Domain: secretDomain, | |||
| 120 | + | Keys: keys, | |||
| 121 | + | } | |||
| 122 | + | for _, att := range attachments { | |||
| 123 | + | result.Attachments = append(result.Attachments, remote.AttachmentSecrets{ | |||
| 124 | + | Service: att.Service, | |||
| 125 | + | Keys: att.Keys, | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | return result, nil | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (l *localControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |||
| 133 | + | if l.secretSvc == nil { | |||
| 134 | + | return fmt.Errorf("local secret service unavailable") | |||
| 135 | + | } | |||
| 136 | + | return l.secretSvc.Set(ctx, secretDomain, secrets) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (l *localControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |||
| 140 | + | if l.secretSvc == nil { | |||
| 141 | + | return fmt.Errorf("local secret service unavailable") | |||
| 142 | + | } | |||
| 143 | + | return l.secretSvc.Delete(ctx, secretDomain, key) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func (l *localControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error { | |||
| 147 | + | if l.secretSvc == nil { | |||
| 148 | + | return fmt.Errorf("local secret service unavailable") | |||
| 149 | + | } | |||
| 150 | + | return l.secretSvc.SetAttachment(ctx, domainName, service, secrets) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error { | |||
| 154 | + | if l.secretSvc == nil { | |||
| 155 | + | return fmt.Errorf("local secret service unavailable") | |||
| 156 | + | } | |||
| 157 | + | return l.secretSvc.DeleteAttachment(ctx, domainName, service, key) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |||
| 161 | + | if l.configSvc == nil { | |||
| 162 | + | return nil, fmt.Errorf("local config service unavailable") | |||
| 163 | + | } | |||
| 164 | + | return l.configSvc.GetAllAttachments(ctx), nil | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) { | |||
| 168 | + | if l.configSvc == nil { | |||
| 169 | + | return nil, fmt.Errorf("local config service unavailable") | |||
| 170 | + | } | |||
| 171 | + | return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |||
| 175 | + | if l.configSvc == nil { | |||
| 176 | + | return fmt.Errorf("local config service unavailable") | |||
| 177 | + | } | |||
| 178 | + | return l.configSvc.AddAttachment(ctx, domainOrGroup, image) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error { | |||
| 182 | + | if l.configSvc == nil { | |||
| 183 | + | return fmt.Errorf("local config service unavailable") | |||
| 184 | + | } | |||
| 185 | + | return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |||
| 189 | + | if l.configSvc == nil { | |||
| 190 | + | return nil, fmt.Errorf("local config service unavailable") | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | status := &remote.Status{ | |||
| 194 | + | Routes: len(l.configSvc.GetRoutes(ctx)), | |||
| 195 | + | RegistryDomain: l.configSvc.GetRegistryDomain(), | |||
| 196 | + | RegistryPort: l.configSvc.GetRegistryPort(), | |||
| 197 | + | ServerPort: l.configSvc.GetServerPort(), | |||
| 198 | + | AutoRoute: l.configSvc.IsAutoRouteEnabled(), | |||
| 199 | + | NetworkIsolation: l.configSvc.IsNetworkIsolationEnabled(), | |||
| 200 | + | ContainerStatus: map[string]string{}, | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | if l.containerSvc != nil { | |||
| 204 | + | for domainName, container := range l.containerSvc.List(ctx) { | |||
| 205 | + | if container == nil { | |||
| 206 | + | continue | |||
| 207 | + | } | |||
| 208 | + | status.ContainerStatus[domainName] = container.Status | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | return status, nil | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func (l *localControlPlane) Reload(_ context.Context) error { | |||
| 216 | + | return app.SendReloadSignal() | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | |||
| 220 | + | if l.containerSvc != nil && l.configSvc != nil { | |||
| 221 | + | route, err := l.configSvc.GetRoute(ctx, deployDomain) | |||
| 222 | + | if err != nil { | |||
| 223 | + | return nil, err | |||
| 224 | + | } | |||
| 225 | + | container, err := l.containerSvc.Deploy(ctx, *route) | |||
| 226 | + | if err != nil { | |||
| 227 | + | return nil, err | |||
| 228 | + | } | |||
| 229 | + | result := &remote.DeployResult{Status: "deployed", Domain: deployDomain} | |||
| 230 | + | if container != nil { | |||
| 231 | + | result.ContainerID = container.ID | |||
| 232 | + | } | |||
| 233 | + | return result, nil | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | domainName, err := app.SendDeploySignal(deployDomain) | |||
| 237 | + | if err != nil { | |||
| 238 | + | return nil, err | |||
| 239 | + | } | |||
| 240 | + | return &remote.DeployResult{Status: "queued", Domain: domainName}, nil | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func (l *localControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) { | |||
| 244 | + | if l.containerSvc == nil { | |||
| 245 | + | if withAttachments { | |||
| 246 | + | return nil, fmt.Errorf("local restart with attachments requires active local container service") | |||
| 247 | + | } | |||
| 248 | + | domainName, err := app.SendDeploySignal(restartDomain) | |||
| 249 | + | if err != nil { | |||
| 250 | + | return nil, err | |||
| 251 | + | } | |||
| 252 | + | return &remote.RestartResult{Status: "queued", Domain: domainName}, nil | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | if err := l.containerSvc.SyncContainers(ctx); err != nil { | |||
| 256 | + | return nil, err | |||
| 257 | + | } | |||
| 258 | + | if err := l.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil { | |||
| 259 | + | return nil, err | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | return &remote.RestartResult{Status: "restarted", Domain: restartDomain}, nil | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func (l *localControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) { | |||
| 266 | + | if l.registrySvc == nil { | |||
| 267 | + | return nil, fmt.Errorf("local registry service unavailable") | |||
| 268 | + | } | |||
| 269 | + | return l.registrySvc.ListTags(ctx, repository) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func (l *localControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |||
| 273 | + | if l.backupSvc == nil { | |||
| 274 | + | return nil, fmt.Errorf("local backup service unavailable") | |||
| 275 | + | } | |||
| 276 | + | jobs, err := l.backupSvc.ListBackups(ctx, backupDomain) | |||
| 277 | + | if err != nil { | |||
| 278 | + | return nil, err | |||
| 279 | + | } | |||
| 280 | + | return toDTOBackupJobs(jobs), nil | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func (l *localControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |||
| 284 | + | if l.backupSvc == nil { | |||
| 285 | + | return nil, fmt.Errorf("local backup service unavailable") | |||
| 286 | + | } | |||
| 287 | + | jobs, err := l.backupSvc.Status(ctx) | |||
| 288 | + | if err != nil { | |||
| 289 | + | return nil, err | |||
| 290 | + | } | |||
| 291 | + | return toDTOBackupJobs(jobs), nil | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func (l *localControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) { | |||
| 295 | + | if l.backupSvc == nil { | |||
| 296 | + | return nil, fmt.Errorf("local backup service unavailable") | |||
| 297 | + | } | |||
| 298 | + | result, err := l.backupSvc.RunBackup(ctx, backupDomain, dbName) | |||
| 299 | + | if err != nil { | |||
| 300 | + | return nil, err | |||
| 301 | + | } | |||
| 302 | + | if result == nil { | |||
| 303 | + | return &dto.BackupRunResponse{Status: "ok"}, nil | |||
| 304 | + | } | |||
| 305 | + | job := toDTOBackupJob(result.Job) | |||
| 306 | + | return &dto.BackupRunResponse{Status: "ok", Backup: &job}, nil | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |||
| 310 | + | if l.backupSvc == nil { | |||
| 311 | + | return nil, fmt.Errorf("local backup service unavailable") | |||
| 312 | + | } | |||
| 313 | + | dbs, err := l.backupSvc.DetectDatabases(ctx, backupDomain) | |||
| 314 | + | if err != nil { | |||
| 315 | + | return nil, err | |||
| 316 | + | } | |||
| 317 | + | out := make([]dto.DatabaseInfo, 0, len(dbs)) | |||
| 318 | + | for _, db := range dbs { | |||
| 319 | + | out = append(out, dto.DatabaseInfo{ | |||
| 320 | + | Type: string(db.Type), | |||
| 321 | + | Name: db.Name, | |||
| 322 | + | Version: db.Version, | |||
| 323 | + | Host: db.Host, | |||
| 324 | + | Port: db.Port, | |||
| 325 | + | ContainerID: db.ContainerID, | |||
| 326 | + | ImageName: db.ImageName, | |||
| 327 | + | }) | |||
| 328 | + | } | |||
| 329 | + | return out, nil | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) { | |||
| 333 | + | if l.logSvc == nil { | |||
| 334 | + | return nil, fmt.Errorf("local log service unavailable") | |||
| 335 | + | } | |||
| 336 | + | return l.logSvc.GetProcessLogs(ctx, lines) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func (l *localControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) { | |||
| 340 | + | if l.logSvc == nil { | |||
| 341 | + | return nil, fmt.Errorf("local log service unavailable") | |||
| 342 | + | } | |||
| 343 | + | return l.logSvc.GetContainerLogs(ctx, logDomain, lines) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func (l *localControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) { | |||
| 347 | + | if l.logSvc == nil { | |||
| 348 | + | return nil, fmt.Errorf("local log service unavailable") | |||
| 349 | + | } | |||
| 350 | + | return l.logSvc.FollowProcessLogs(ctx, lines) | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | |||
| 354 | + | if l.logSvc == nil { | |||
| 355 | + | return nil, fmt.Errorf("local log service unavailable") | |||
| 356 | + | } | |||
| 357 | + | return l.logSvc.FollowContainerLogs(ctx, logDomain, lines) | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo { | |||
| 361 | + | out := make([]remote.RouteInfo, 0, len(routes)) | |||
| 362 | + | for _, route := range routes { | |||
| 363 | + | attachments := make([]dto.Attachment, 0, len(route.Attachments)) | |||
| 364 | + | for _, attachment := range route.Attachments { | |||
| 365 | + | attachments = append(attachments, dto.Attachment{ | |||
| 366 | + | Name: attachment.Name, | |||
| 367 | + | Image: attachment.Image, | |||
| 368 | + | ContainerID: attachment.ContainerID, | |||
| 369 | + | Status: attachment.Status, | |||
| 370 | + | Network: attachment.Network, | |||
| 371 | + | }) | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | out = append(out, remote.RouteInfo{ | |||
| 375 | + | Domain: route.Domain, | |||
| 376 | + | Image: route.Image, | |||
| 377 | + | ContainerID: route.ContainerID, | |||
| 378 | + | ContainerStatus: route.ContainerStatus, | |||
| 379 | + | Network: route.Network, | |||
| 380 | + | Attachments: attachments, | |||
| 381 | + | }) | |||
| 382 | + | } | |||
| 383 | + | return out | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob { | |||
| 387 | + | out := make([]dto.BackupJob, 0, len(jobs)) | |||
| 388 | + | for _, job := range jobs { | |||
| 389 | + | out = append(out, toDTOBackupJob(job)) | |||
| 390 | + | } | |||
| 391 | + | return out | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func toDTOBackupJob(job domain.BackupJob) dto.BackupJob { | |||
| 395 | + | var startedAt *time.Time | |||
| 396 | + | if !job.StartedAt.IsZero() { | |||
| 397 | + | t := job.StartedAt | |||
| 398 | + | startedAt = &t | |||
| 399 | + | } | |||
| 400 | + | var completedAt *time.Time | |||
| 401 | + | if !job.CompletedAt.IsZero() { | |||
| 402 | + | t := job.CompletedAt | |||
| 403 | + | completedAt = &t | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | return dto.BackupJob{ | |||
| 407 | + | ID: job.ID, | |||
| 408 | + | Domain: job.Domain, | |||
| 409 | + | DBName: job.DBName, | |||
| 410 | + | Schedule: string(job.Schedule), | |||
| 411 | + | Type: string(job.Type), | |||
| 412 | + | Status: string(job.Status), | |||
| 413 | + | StartedAt: startedAt, | |||
| 414 | + | CompletedAt: completedAt, | |||
| 415 | + | SizeBytes: job.SizeBytes, | |||
| 416 | + | Error: job.Error, | |||
| 417 | + | } | |||
| 418 | + | } | |||
A internal/adapters/in/cli/controlplane_local_test.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 9 | + | "github.com/bnema/gordon/internal/domain" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestLocalControlPlane_GetStatus(t *testing.T) { | |
| 15 | + | t.Parallel() | |
| 16 | + | ||
| 17 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 18 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 19 | + | ||
| 20 | + | ctx := context.Background() | |
| 21 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.local", Image: "repo/app:latest"}}) | |
| 22 | + | configSvc.EXPECT().GetRegistryDomain().Return("registry.local") | |
| 23 | + | configSvc.EXPECT().GetRegistryPort().Return(5000) | |
| 24 | + | configSvc.EXPECT().GetServerPort().Return(80) | |
| 25 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |
| 26 | + | configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true) | |
| 27 | + | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |
| 28 | + | "app.local": {ID: "abc123", Status: "running"}, | |
| 29 | + | }) | |
| 30 | + | ||
| 31 | + | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |
| 32 | + | status, err := cp.GetStatus(ctx) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.Equal(t, 1, status.Routes) | |
| 35 | + | require.Equal(t, "registry.local", status.RegistryDomain) | |
| 36 | + | require.Equal(t, "running", status.ContainerStatus["app.local"]) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestLocalControlPlane_Backups(t *testing.T) { | |
| 40 | + | t.Parallel() | |
| 41 | + | ||
| 42 | + | backupSvc := inmocks.NewMockBackupService(t) | |
| 43 | + | ctx := context.Background() | |
| 44 | + | ||
| 45 | + | now := time.Now().UTC() | |
| 46 | + | jobs := []domain.BackupJob{{ID: "b1", Domain: "app.local", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: now}} | |
| 47 | + | backupSvc.EXPECT().ListBackups(mock.Anything, "app.local").Return(jobs, nil) | |
| 48 | + | backupSvc.EXPECT().Status(mock.Anything).Return(jobs, nil) | |
| 49 | + | backupSvc.EXPECT().RunBackup(mock.Anything, "app.local", "postgres").Return(&domain.BackupResult{Job: jobs[0]}, nil) | |
| 50 | + | backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.local").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Host: "postgres", Port: 5432}}, nil) | |
| 51 | + | ||
| 52 | + | cp := &localControlPlane{backupSvc: backupSvc} | |
| 53 | + | ||
| 54 | + | list, err := cp.ListBackups(ctx, "app.local") | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | require.Len(t, list, 1) | |
| 57 | + | ||
| 58 | + | status, err := cp.BackupStatus(ctx) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | require.Len(t, status, 1) | |
| 61 | + | ||
| 62 | + | run, err := cp.RunBackup(ctx, "app.local", "postgres") | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | require.NotNil(t, run.Backup) | |
| 65 | + | require.Equal(t, "b1", run.Backup.ID) | |
| 66 | + | ||
| 67 | + | dbs, err := cp.DetectDatabases(ctx, "app.local") | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | require.Len(t, dbs, 1) | |
| 70 | + | require.Equal(t, "postgres", dbs[0].Name) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestLocalControlPlane_ListTags(t *testing.T) { | |
| 74 | + | t.Parallel() | |
| 75 | + | ||
| 76 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 77 | + | registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"v1.0.0", "latest"}, nil) | |
| 78 | + | ||
| 79 | + | cp := &localControlPlane{registrySvc: registrySvc} | |
| 80 | + | tags, err := cp.ListTags(context.Background(), "repo/app") | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | require.Equal(t, []string{"v1.0.0", "latest"}, tags) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestLocalControlPlane_RestartWithAttachments(t *testing.T) { | |
| 86 | + | t.Parallel() | |
| 87 | + | ||
| 88 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 89 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 90 | + | containerSvc.EXPECT().Restart(mock.Anything, "app.local", true).Return(nil) | |
| 91 | + | ||
| 92 | + | cp := &localControlPlane{containerSvc: containerSvc} | |
| 93 | + | result, err := cp.Restart(context.Background(), "app.local", true) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | require.Equal(t, "app.local", result.Domain) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestLocalControlPlane_GetContainerLogs(t *testing.T) { | |
| 99 | + | t.Parallel() | |
| 100 | + | ||
| 101 | + | logSvc := inmocks.NewMockLogService(t) | |
| 102 | + | logSvc.EXPECT().GetContainerLogs(mock.Anything, "app.local", 50).Return([]string{"line1", "line2"}, nil) | |
| 103 | + | ||
| 104 | + | cp := &localControlPlane{logSvc: logSvc} | |
| 105 | + | lines, err := cp.GetContainerLogs(context.Background(), "app.local", 50) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | require.Equal(t, []string{"line1", "line2"}, lines) | |
| 108 | + | } | |
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestLocalControlPlane_GetStatus(t *testing.T) { | |||
| 15 | + | t.Parallel() | |||
| 16 | + | ||||
| 17 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 18 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 19 | + | ||||
| 20 | + | ctx := context.Background() | |||
| 21 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.local", Image: "repo/app:latest"}}) | |||
| 22 | + | configSvc.EXPECT().GetRegistryDomain().Return("registry.local") | |||
| 23 | + | configSvc.EXPECT().GetRegistryPort().Return(5000) | |||
| 24 | + | configSvc.EXPECT().GetServerPort().Return(80) | |||
| 25 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |||
| 26 | + | configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true) | |||
| 27 | + | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |||
| 28 | + | "app.local": {ID: "abc123", Status: "running"}, | |||
| 29 | + | }) | |||
| 30 | + | ||||
| 31 | + | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |||
| 32 | + | status, err := cp.GetStatus(ctx) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | require.Equal(t, 1, status.Routes) | |||
| 35 | + | require.Equal(t, "registry.local", status.RegistryDomain) | |||
| 36 | + | require.Equal(t, "running", status.ContainerStatus["app.local"]) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestLocalControlPlane_Backups(t *testing.T) { | |||
| 40 | + | t.Parallel() | |||
| 41 | + | ||||
| 42 | + | backupSvc := inmocks.NewMockBackupService(t) | |||
| 43 | + | ctx := context.Background() | |||
| 44 | + | ||||
| 45 | + | now := time.Now().UTC() | |||
| 46 | + | jobs := []domain.BackupJob{{ID: "b1", Domain: "app.local", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: now}} | |||
| 47 | + | backupSvc.EXPECT().ListBackups(mock.Anything, "app.local").Return(jobs, nil) | |||
| 48 | + | backupSvc.EXPECT().Status(mock.Anything).Return(jobs, nil) | |||
| 49 | + | backupSvc.EXPECT().RunBackup(mock.Anything, "app.local", "postgres").Return(&domain.BackupResult{Job: jobs[0]}, nil) | |||
| 50 | + | backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.local").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Host: "postgres", Port: 5432}}, nil) | |||
| 51 | + | ||||
| 52 | + | cp := &localControlPlane{backupSvc: backupSvc} | |||
| 53 | + | ||||
| 54 | + | list, err := cp.ListBackups(ctx, "app.local") | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | require.Len(t, list, 1) | |||
| 57 | + | ||||
| 58 | + | status, err := cp.BackupStatus(ctx) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | require.Len(t, status, 1) | |||
| 61 | + | ||||
| 62 | + | run, err := cp.RunBackup(ctx, "app.local", "postgres") | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | require.NotNil(t, run.Backup) | |||
| 65 | + | require.Equal(t, "b1", run.Backup.ID) | |||
| 66 | + | ||||
| 67 | + | dbs, err := cp.DetectDatabases(ctx, "app.local") | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | require.Len(t, dbs, 1) | |||
| 70 | + | require.Equal(t, "postgres", dbs[0].Name) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestLocalControlPlane_ListTags(t *testing.T) { | |||
| 74 | + | t.Parallel() | |||
| 75 | + | ||||
| 76 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 77 | + | registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"v1.0.0", "latest"}, nil) | |||
| 78 | + | ||||
| 79 | + | cp := &localControlPlane{registrySvc: registrySvc} | |||
| 80 | + | tags, err := cp.ListTags(context.Background(), "repo/app") | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | require.Equal(t, []string{"v1.0.0", "latest"}, tags) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestLocalControlPlane_RestartWithAttachments(t *testing.T) { | |||
| 86 | + | t.Parallel() | |||
| 87 | + | ||||
| 88 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 89 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 90 | + | containerSvc.EXPECT().Restart(mock.Anything, "app.local", true).Return(nil) | |||
| 91 | + | ||||
| 92 | + | cp := &localControlPlane{containerSvc: containerSvc} | |||
| 93 | + | result, err := cp.Restart(context.Background(), "app.local", true) | |||
| 94 | + | require.NoError(t, err) | |||
| 95 | + | require.Equal(t, "app.local", result.Domain) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestLocalControlPlane_GetContainerLogs(t *testing.T) { | |||
| 99 | + | t.Parallel() | |||
| 100 | + | ||||
| 101 | + | logSvc := inmocks.NewMockLogService(t) | |||
| 102 | + | logSvc.EXPECT().GetContainerLogs(mock.Anything, "app.local", 50).Return([]string{"line1", "line2"}, nil) | |||
| 103 | + | ||||
| 104 | + | cp := &localControlPlane{logSvc: logSvc} | |||
| 105 | + | lines, err := cp.GetContainerLogs(context.Background(), "app.local", 50) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | require.Equal(t, []string{"line1", "line2"}, lines) | |||
| 108 | + | } | |||
A internal/adapters/in/cli/controlplane_remote.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 8 | + | "github.com/bnema/gordon/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type remoteControlPlane struct { | |
| 12 | + | client *remote.Client | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func NewRemoteControlPlane(client *remote.Client) ControlPlane { | |
| 16 | + | return &remoteControlPlane{client: client} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (r *remoteControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) { | |
| 20 | + | return r.client.ListRoutesWithDetails(ctx) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (r *remoteControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) { | |
| 24 | + | return r.client.GetHealth(ctx) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | |
| 28 | + | return r.client.GetRoute(ctx, routeDomain) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | |
| 32 | + | return r.client.AddRoute(ctx, route) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (r *remoteControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error { | |
| 36 | + | return r.client.UpdateRoute(ctx, route) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | |
| 40 | + | return r.client.RemoveRoute(ctx, routeDomain) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 44 | + | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (r *remoteControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |
| 48 | + | return r.client.SetSecrets(ctx, secretDomain, secrets) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |
| 52 | + | return r.client.DeleteSecret(ctx, secretDomain, key) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (r *remoteControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error { | |
| 56 | + | return r.client.SetAttachmentSecrets(ctx, domainName, service, secrets) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (r *remoteControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error { | |
| 60 | + | return r.client.DeleteAttachmentSecret(ctx, domainName, service, key) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (r *remoteControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |
| 64 | + | return r.client.GetAllAttachmentsConfig(ctx) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (r *remoteControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) { | |
| 68 | + | return r.client.GetAttachmentsConfig(ctx, domainOrGroup) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (r *remoteControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 72 | + | return r.client.AddAttachment(ctx, domainOrGroup, image) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (r *remoteControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 76 | + | return r.client.RemoveAttachment(ctx, domainOrGroup, image) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 80 | + | return r.client.GetStatus(ctx) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (r *remoteControlPlane) Reload(ctx context.Context) error { | |
| 84 | + | return r.client.Reload(ctx) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | |
| 88 | + | return r.client.Deploy(ctx, deployDomain) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (r *remoteControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) { | |
| 92 | + | return r.client.Restart(ctx, restartDomain, withAttachments) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (r *remoteControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) { | |
| 96 | + | return r.client.ListTags(ctx, repository) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (r *remoteControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 100 | + | return r.client.ListBackups(ctx, backupDomain) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (r *remoteControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |
| 104 | + | return r.client.BackupStatus(ctx) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (r *remoteControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) { | |
| 108 | + | return r.client.RunBackup(ctx, backupDomain, dbName) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |
| 112 | + | return r.client.DetectDatabases(ctx, backupDomain) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) { | |
| 116 | + | return r.client.GetProcessLogs(ctx, lines) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (r *remoteControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) { | |
| 120 | + | return r.client.GetContainerLogs(ctx, logDomain, lines) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (r *remoteControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) { | |
| 124 | + | return r.client.StreamProcessLogs(ctx, lines) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (r *remoteControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | |
| 128 | + | return r.client.StreamContainerLogs(ctx, logDomain, lines) | |
| 129 | + | } | |
| @@ -0,0 +1,129 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type remoteControlPlane struct { | |||
| 12 | + | client *remote.Client | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func NewRemoteControlPlane(client *remote.Client) ControlPlane { | |||
| 16 | + | return &remoteControlPlane{client: client} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (r *remoteControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) { | |||
| 20 | + | return r.client.ListRoutesWithDetails(ctx) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (r *remoteControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) { | |||
| 24 | + | return r.client.GetHealth(ctx) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | |||
| 28 | + | return r.client.GetRoute(ctx, routeDomain) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | |||
| 32 | + | return r.client.AddRoute(ctx, route) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (r *remoteControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error { | |||
| 36 | + | return r.client.UpdateRoute(ctx, route) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | |||
| 40 | + | return r.client.RemoveRoute(ctx, routeDomain) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |||
| 44 | + | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (r *remoteControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |||
| 48 | + | return r.client.SetSecrets(ctx, secretDomain, secrets) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |||
| 52 | + | return r.client.DeleteSecret(ctx, secretDomain, key) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (r *remoteControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error { | |||
| 56 | + | return r.client.SetAttachmentSecrets(ctx, domainName, service, secrets) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (r *remoteControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error { | |||
| 60 | + | return r.client.DeleteAttachmentSecret(ctx, domainName, service, key) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (r *remoteControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |||
| 64 | + | return r.client.GetAllAttachmentsConfig(ctx) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (r *remoteControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) { | |||
| 68 | + | return r.client.GetAttachmentsConfig(ctx, domainOrGroup) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (r *remoteControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |||
| 72 | + | return r.client.AddAttachment(ctx, domainOrGroup, image) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (r *remoteControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error { | |||
| 76 | + | return r.client.RemoveAttachment(ctx, domainOrGroup, image) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |||
| 80 | + | return r.client.GetStatus(ctx) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (r *remoteControlPlane) Reload(ctx context.Context) error { | |||
| 84 | + | return r.client.Reload(ctx) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | |||
| 88 | + | return r.client.Deploy(ctx, deployDomain) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (r *remoteControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) { | |||
| 92 | + | return r.client.Restart(ctx, restartDomain, withAttachments) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (r *remoteControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) { | |||
| 96 | + | return r.client.ListTags(ctx, repository) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (r *remoteControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |||
| 100 | + | return r.client.ListBackups(ctx, backupDomain) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (r *remoteControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |||
| 104 | + | return r.client.BackupStatus(ctx) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (r *remoteControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) { | |||
| 108 | + | return r.client.RunBackup(ctx, backupDomain, dbName) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |||
| 112 | + | return r.client.DetectDatabases(ctx, backupDomain) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) { | |||
| 116 | + | return r.client.GetProcessLogs(ctx, lines) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (r *remoteControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) { | |||
| 120 | + | return r.client.GetContainerLogs(ctx, logDomain, lines) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (r *remoteControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) { | |||
| 124 | + | return r.client.StreamProcessLogs(ctx, lines) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (r *remoteControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | |||
| 128 | + | return r.client.StreamContainerLogs(ctx, logDomain, lines) | |||
| 129 | + | } | |||
A internal/adapters/in/cli/controlplane_remote_test.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | var _ ControlPlane = (*remoteControlPlane)(nil) | |
| 10 | + | ||
| 11 | + | func TestRemoteControlPlane_ImplementsInterface(t *testing.T) { | |
| 12 | + | client := remote.NewClient("https://gordon.example.com") | |
| 13 | + | if NewRemoteControlPlane(client) == nil { | |
| 14 | + | t.Fatal("expected non-nil remote control-plane") | |
| 15 | + | } | |
| 16 | + | } | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | var _ ControlPlane = (*remoteControlPlane)(nil) | |||
| 10 | + | ||||
| 11 | + | func TestRemoteControlPlane_ImplementsInterface(t *testing.T) { | |||
| 12 | + | client := remote.NewClient("https://gordon.example.com") | |||
| 13 | + | if NewRemoteControlPlane(client) == nil { | |||
| 14 | + | t.Fatal("expected non-nil remote control-plane") | |||
| 15 | + | } | |||
| 16 | + | } | |||
A internal/adapters/in/cli/controlplane_resolver.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zerowrap" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/app" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type controlPlaneHandle struct { | |
| 12 | + | plane ControlPlane | |
| 13 | + | isRemote bool | |
| 14 | + | closeFn func() error | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (h *controlPlaneHandle) close() { | |
| 18 | + | if h == nil || h.closeFn == nil { | |
| 19 | + | return | |
| 20 | + | } | |
| 21 | + | if err := h.closeFn(); err != nil { | |
| 22 | + | log := zerowrap.Default() | |
| 23 | + | log.Warn().Err(err).Msg("failed to close control-plane resources") | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func resolveControlPlane(configPath string) (*controlPlaneHandle, error) { | |
| 28 | + | client, isRemote := GetRemoteClient() | |
| 29 | + | if isRemote { | |
| 30 | + | return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | kernel, err := app.NewKernel(configPath) | |
| 34 | + | if err != nil { | |
| 35 | + | return nil, fmt.Errorf("failed to initialize local control plane: %w", err) | |
| 36 | + | } | |
| 37 | + | if kernel.AuthEnabled() { | |
| 38 | + | kernel.Close() | |
| 39 | + | return nil, fmt.Errorf("local control plane is disabled when auth.enabled=true; use --remote with token") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | return &controlPlaneHandle{ | |
| 43 | + | plane: NewLocalControlPlane(kernel), | |
| 44 | + | closeFn: kernel.Close, | |
| 45 | + | }, nil | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zerowrap" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/app" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type controlPlaneHandle struct { | |||
| 12 | + | plane ControlPlane | |||
| 13 | + | isRemote bool | |||
| 14 | + | closeFn func() error | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func (h *controlPlaneHandle) close() { | |||
| 18 | + | if h == nil || h.closeFn == nil { | |||
| 19 | + | return | |||
| 20 | + | } | |||
| 21 | + | if err := h.closeFn(); err != nil { | |||
| 22 | + | log := zerowrap.Default() | |||
| 23 | + | log.Warn().Err(err).Msg("failed to close control-plane resources") | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func resolveControlPlane(configPath string) (*controlPlaneHandle, error) { | |||
| 28 | + | client, isRemote := GetRemoteClient() | |||
| 29 | + | if isRemote { | |||
| 30 | + | return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | kernel, err := app.NewKernel(configPath) | |||
| 34 | + | if err != nil { | |||
| 35 | + | return nil, fmt.Errorf("failed to initialize local control plane: %w", err) | |||
| 36 | + | } | |||
| 37 | + | if kernel.AuthEnabled() { | |||
| 38 | + | kernel.Close() | |||
| 39 | + | return nil, fmt.Errorf("local control plane is disabled when auth.enabled=true; use --remote with token") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | return &controlPlaneHandle{ | |||
| 43 | + | plane: NewLocalControlPlane(kernel), | |||
| 44 | + | closeFn: kernel.Close, | |||
| 45 | + | }, nil | |||
| 46 | + | } | |||
A internal/adapters/in/cli/controlplane_resolver_test.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResolveControlPlane_LocalDeniedWhenAuthEnabled(t *testing.T) { | |
| 11 | + | tmpDir := t.TempDir() | |
| 12 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | |
| 13 | + | t.Setenv("GORDON_REMOTE", "") | |
| 14 | + | t.Setenv("GORDON_TOKEN", "") | |
| 15 | + | originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag | |
| 16 | + | t.Cleanup(func() { | |
| 17 | + | remoteFlag = originalRemoteFlag | |
| 18 | + | tokenFlag = originalTokenFlag | |
| 19 | + | insecureTLSFlag = originalInsecureTLSFlag | |
| 20 | + | }) | |
| 21 | + | ||
| 22 | + | remoteFlag = "" | |
| 23 | + | tokenFlag = "" | |
| 24 | + | insecureTLSFlag = false | |
| 25 | + | ||
| 26 | + | configPath := filepath.Join(tmpDir, "gordon.toml") | |
| 27 | + | err := os.WriteFile(configPath, []byte(`[server] | |
| 28 | + | gordon_domain = "gordon.local" | |
| 29 | + | data_dir = "`+filepath.Join(tmpDir, "data")+`" | |
| 30 | + | ||
| 31 | + | [auth] | |
| 32 | + | enabled = true | |
| 33 | + | `), 0o600) | |
| 34 | + | if err != nil { | |
| 35 | + | t.Fatalf("failed to write config: %v", err) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | handle, err := resolveControlPlane(configPath) | |
| 39 | + | if err == nil { | |
| 40 | + | if handle != nil { | |
| 41 | + | handle.close() | |
| 42 | + | } | |
| 43 | + | t.Fatalf("expected error when auth.enabled=true and no remote target") | |
| 44 | + | } | |
| 45 | + | if !strings.Contains(err.Error(), "local control plane is disabled when auth.enabled=true") { | |
| 46 | + | t.Fatalf("unexpected error: %v", err) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) { | |
| 51 | + | tmpDir := t.TempDir() | |
| 52 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | |
| 53 | + | t.Setenv("GORDON_REMOTE", "") | |
| 54 | + | t.Setenv("GORDON_TOKEN", "") | |
| 55 | + | originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag | |
| 56 | + | t.Cleanup(func() { | |
| 57 | + | remoteFlag = originalRemoteFlag | |
| 58 | + | tokenFlag = originalTokenFlag | |
| 59 | + | insecureTLSFlag = originalInsecureTLSFlag | |
| 60 | + | }) | |
| 61 | + | ||
| 62 | + | remoteFlag = "" | |
| 63 | + | tokenFlag = "" | |
| 64 | + | insecureTLSFlag = false | |
| 65 | + | ||
| 66 | + | configPath := filepath.Join(tmpDir, "gordon.toml") | |
| 67 | + | err := os.WriteFile(configPath, []byte(`[server] | |
| 68 | + | gordon_domain = "gordon.local" | |
| 69 | + | data_dir = "`+filepath.Join(tmpDir, "data")+`" | |
| 70 | + | ||
| 71 | + | [auth] | |
| 72 | + | enabled = false | |
| 73 | + | `), 0o600) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatalf("failed to write config: %v", err) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | handle, err := resolveControlPlane(configPath) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatalf("expected local control-plane handle, got error: %v", err) | |
| 81 | + | } | |
| 82 | + | if handle == nil || handle.plane == nil { | |
| 83 | + | t.Fatalf("expected non-nil local control-plane handle") | |
| 84 | + | } | |
| 85 | + | handle.close() | |
| 86 | + | } | |
| @@ -0,0 +1,86 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResolveControlPlane_LocalDeniedWhenAuthEnabled(t *testing.T) { | |||
| 11 | + | tmpDir := t.TempDir() | |||
| 12 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | |||
| 13 | + | t.Setenv("GORDON_REMOTE", "") | |||
| 14 | + | t.Setenv("GORDON_TOKEN", "") | |||
| 15 | + | originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag | |||
| 16 | + | t.Cleanup(func() { | |||
| 17 | + | remoteFlag = originalRemoteFlag | |||
| 18 | + | tokenFlag = originalTokenFlag | |||
| 19 | + | insecureTLSFlag = originalInsecureTLSFlag | |||
| 20 | + | }) | |||
| 21 | + | ||||
| 22 | + | remoteFlag = "" | |||
| 23 | + | tokenFlag = "" | |||
| 24 | + | insecureTLSFlag = false | |||
| 25 | + | ||||
| 26 | + | configPath := filepath.Join(tmpDir, "gordon.toml") | |||
| 27 | + | err := os.WriteFile(configPath, []byte(`[server] | |||
| 28 | + | gordon_domain = "gordon.local" | |||
| 29 | + | data_dir = "`+filepath.Join(tmpDir, "data")+`" | |||
| 30 | + | ||||
| 31 | + | [auth] | |||
| 32 | + | enabled = true | |||
| 33 | + | `), 0o600) | |||
| 34 | + | if err != nil { | |||
| 35 | + | t.Fatalf("failed to write config: %v", err) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | handle, err := resolveControlPlane(configPath) | |||
| 39 | + | if err == nil { | |||
| 40 | + | if handle != nil { | |||
| 41 | + | handle.close() | |||
| 42 | + | } | |||
| 43 | + | t.Fatalf("expected error when auth.enabled=true and no remote target") | |||
| 44 | + | } | |||
| 45 | + | if !strings.Contains(err.Error(), "local control plane is disabled when auth.enabled=true") { | |||
| 46 | + | t.Fatalf("unexpected error: %v", err) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) { | |||
| 51 | + | tmpDir := t.TempDir() | |||
| 52 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | |||
| 53 | + | t.Setenv("GORDON_REMOTE", "") | |||
| 54 | + | t.Setenv("GORDON_TOKEN", "") | |||
| 55 | + | originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag | |||
| 56 | + | t.Cleanup(func() { | |||
| 57 | + | remoteFlag = originalRemoteFlag | |||
| 58 | + | tokenFlag = originalTokenFlag | |||
| 59 | + | insecureTLSFlag = originalInsecureTLSFlag | |||
| 60 | + | }) | |||
| 61 | + | ||||
| 62 | + | remoteFlag = "" | |||
| 63 | + | tokenFlag = "" | |||
| 64 | + | insecureTLSFlag = false | |||
| 65 | + | ||||
| 66 | + | configPath := filepath.Join(tmpDir, "gordon.toml") | |||
| 67 | + | err := os.WriteFile(configPath, []byte(`[server] | |||
| 68 | + | gordon_domain = "gordon.local" | |||
| 69 | + | data_dir = "`+filepath.Join(tmpDir, "data")+`" | |||
| 70 | + | ||||
| 71 | + | [auth] | |||
| 72 | + | enabled = false | |||
| 73 | + | `), 0o600) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatalf("failed to write config: %v", err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | handle, err := resolveControlPlane(configPath) | |||
| 79 | + | if err != nil { | |||
| 80 | + | t.Fatalf("expected local control-plane handle, got error: %v", err) | |||
| 81 | + | } | |||
| 82 | + | if handle == nil || handle.plane == nil { | |||
| 83 | + | t.Fatalf("expected non-nil local control-plane handle") | |||
| 84 | + | } | |||
| 85 | + | handle.close() | |||
| 86 | + | } | |||
A internal/adapters/in/cli/parity_matrix_test.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestLocalParityMatrix(t *testing.T) { | |
| 10 | + | checks := []struct { | |
| 11 | + | file string | |
| 12 | + | legacyText string | |
| 13 | + | command string | |
| 14 | + | }{ | |
| 15 | + | {file: "push.go", legacyText: "push requires remote mode", command: "gordon push"}, | |
| 16 | + | {file: "rollback.go", legacyText: "rollback requires remote mode", command: "gordon rollback"}, | |
| 17 | + | {file: "backup.go", legacyText: "backup commands require a configured remote target", command: "gordon backups"}, | |
| 18 | + | {file: "routes.go", legacyText: "status command requires --remote flag or GORDON_REMOTE env var", command: "gordon status"}, | |
| 19 | + | {file: "restart.go", legacyText: "local restart does not support --with-attachments; use --remote", command: "gordon restart --with-attachments"}, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | for _, check := range checks { | |
| 23 | + | content, err := os.ReadFile(check.file) | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatalf("failed to read %s: %v", check.file, err) | |
| 26 | + | } | |
| 27 | + | if strings.Contains(string(content), check.legacyText) { | |
| 28 | + | t.Fatalf("local parity gap: %s still blocks local mode (%q found in %s)", check.command, check.legacyText, check.file) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestLocalParityMatrix(t *testing.T) { | |||
| 10 | + | checks := []struct { | |||
| 11 | + | file string | |||
| 12 | + | legacyText string | |||
| 13 | + | command string | |||
| 14 | + | }{ | |||
| 15 | + | {file: "push.go", legacyText: "push requires remote mode", command: "gordon push"}, | |||
| 16 | + | {file: "rollback.go", legacyText: "rollback requires remote mode", command: "gordon rollback"}, | |||
| 17 | + | {file: "backup.go", legacyText: "backup commands require a configured remote target", command: "gordon backups"}, | |||
| 18 | + | {file: "routes.go", legacyText: "status command requires --remote flag or GORDON_REMOTE env var", command: "gordon status"}, | |||
| 19 | + | {file: "restart.go", legacyText: "local restart does not support --with-attachments; use --remote", command: "gordon restart --with-attachments"}, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | for _, check := range checks { | |||
| 23 | + | content, err := os.ReadFile(check.file) | |||
| 24 | + | if err != nil { | |||
| 25 | + | t.Fatalf("failed to read %s: %v", check.file, err) | |||
| 26 | + | } | |||
| 27 | + | if strings.Contains(string(content), check.legacyText) { | |||
| 28 | + | t.Fatalf("local parity gap: %s still blocks local mode (%q found in %s)", check.command, check.legacyText, check.file) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | } | |||
M internal/adapters/in/cli/push.go +17 -8
| @@ -75,17 +75,18 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe | |||
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | 77 | func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error { | |
| 78 | - | client, isRemote := GetRemoteClient() | |
| 79 | - | if !isRemote { | |
| 80 | - | return fmt.Errorf("push requires remote mode") | |
| 78 | + | handle, err := resolveControlPlane(configPath) | |
| 79 | + | if err != nil { | |
| 80 | + | return err | |
| 81 | 81 | } | |
| 82 | + | defer handle.close() | |
| 82 | 83 | ||
| 83 | - | dockerfile, err := resolveDockerfile(dockerfile, build) | |
| 84 | + | dockerfile, err = resolveDockerfile(dockerfile, build) | |
| 84 | 85 | if err != nil { | |
| 85 | 86 | return err | |
| 86 | 87 | } | |
| 87 | 88 | ||
| 88 | - | route, err := client.GetRoute(ctx, pushDomain) | |
| 89 | + | route, err := handle.plane.GetRoute(ctx, pushDomain) | |
| 89 | 90 | if err != nil { | |
| 90 | 91 | return fmt.Errorf("failed to get route: %w", err) | |
| 91 | 92 | } | |
| @@ -128,7 +129,7 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||
| 128 | 129 | fmt.Println(styles.RenderSuccess("Push complete")) | |
| 129 | 130 | ||
| 130 | 131 | if !noDeploy { | |
| 131 | - | return deployAfterPush(ctx, client, pushDomain, noConfirm) | |
| 132 | + | return deployAfterPush(ctx, handle.plane, pushDomain, noConfirm) | |
| 132 | 133 | } | |
| 133 | 134 | ||
| 134 | 135 | return nil | |
| @@ -226,7 +227,7 @@ func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, l | |||
| 226 | 227 | return nil | |
| 227 | 228 | } | |
| 228 | 229 | ||
| 229 | - | func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain string, noConfirm bool) error { | |
| 230 | + | func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error { | |
| 230 | 231 | if !noConfirm { | |
| 231 | 232 | confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes()) | |
| 232 | 233 | if err != nil { | |
| @@ -237,7 +238,15 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri | |||
| 237 | 238 | } | |
| 238 | 239 | } | |
| 239 | 240 | ||
| 240 | - | result, err := deployWithSpinner(ctx, client, pushDomain) | |
| 241 | + | var ( | |
| 242 | + | result *remote.DeployResult | |
| 243 | + | err error | |
| 244 | + | ) | |
| 245 | + | if remoteCP, ok := cp.(*remoteControlPlane); ok { | |
| 246 | + | result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain) | |
| 247 | + | } else { | |
| 248 | + | result, err = cp.Deploy(ctx, pushDomain) | |
| 249 | + | } | |
| 241 | 250 | if err != nil { | |
| 242 | 251 | return fmt.Errorf("failed to deploy: %w", err) | |
| 243 | 252 | } | |
| @@ -75,17 +75,18 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe | |||||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error { | 77 | func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error { | ||
| 78 | - | client, isRemote := GetRemoteClient() | 78 | + | handle, err := resolveControlPlane(configPath) |
| 79 | - | if !isRemote { | 79 | + | if err != nil { |
| 80 | - | return fmt.Errorf("push requires remote mode") | 80 | + | return err |
| 81 | } | 81 | } | ||
| 82 | + | defer handle.close() | |||
| 82 | 83 | ||||
| 83 | - | dockerfile, err := resolveDockerfile(dockerfile, build) | 84 | + | dockerfile, err = resolveDockerfile(dockerfile, build) |
| 84 | if err != nil { | 85 | if err != nil { | ||
| 85 | return err | 86 | return err | ||
| 86 | } | 87 | } | ||
| 87 | 88 | ||||
| 88 | - | route, err := client.GetRoute(ctx, pushDomain) | 89 | + | route, err := handle.plane.GetRoute(ctx, pushDomain) |
| 89 | if err != nil { | 90 | if err != nil { | ||
| 90 | return fmt.Errorf("failed to get route: %w", err) | 91 | return fmt.Errorf("failed to get route: %w", err) | ||
| 91 | } | 92 | } | ||
| @@ -128,7 +129,7 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||||
| 128 | fmt.Println(styles.RenderSuccess("Push complete")) | 129 | fmt.Println(styles.RenderSuccess("Push complete")) | ||
| 129 | 130 | ||||
| 130 | if !noDeploy { | 131 | if !noDeploy { | ||
| 131 | - | return deployAfterPush(ctx, client, pushDomain, noConfirm) | 132 | + | return deployAfterPush(ctx, handle.plane, pushDomain, noConfirm) |
| 132 | } | 133 | } | ||
| 133 | 134 | ||||
| 134 | return nil | 135 | return nil | ||
| @@ -226,7 +227,7 @@ func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, l | |||||
| 226 | return nil | 227 | return nil | ||
| 227 | } | 228 | } | ||
| 228 | 229 | ||||
| 229 | - | func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain string, noConfirm bool) error { | 230 | + | func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error { |
| 230 | if !noConfirm { | 231 | if !noConfirm { | ||
| 231 | confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes()) | 232 | confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes()) | ||
| 232 | if err != nil { | 233 | if err != nil { | ||
| @@ -237,7 +238,15 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri | |||||
| 237 | } | 238 | } | ||
| 238 | } | 239 | } | ||
| 239 | 240 | ||||
| 240 | - | result, err := deployWithSpinner(ctx, client, pushDomain) | 241 | + | var ( |
| 242 | + | result *remote.DeployResult | |||
| 243 | + | err error | |||
| 244 | + | ) | |||
| 245 | + | if remoteCP, ok := cp.(*remoteControlPlane); ok { | |||
| 246 | + | result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain) | |||
| 247 | + | } else { | |||
| 248 | + | result, err = cp.Deploy(ctx, pushDomain) | |||
| 249 | + | } | |||
| 241 | if err != nil { | 250 | if err != nil { | ||
| 242 | return fmt.Errorf("failed to deploy: %w", err) | 251 | return fmt.Errorf("failed to deploy: %w", err) | ||
| 243 | } | 252 | } | ||
M internal/adapters/in/cli/restart.go +6 -13
| @@ -28,22 +28,15 @@ Examples: | |||
| 28 | 28 | ctx := cmd.Context() | |
| 29 | 29 | restartDomain := args[0] | |
| 30 | 30 | ||
| 31 | - | client, isRemote := GetRemoteClient() | |
| 32 | - | if !isRemote { | |
| 33 | - | if withAttachments { | |
| 34 | - | return fmt.Errorf("local restart does not support --with-attachments; use --remote") | |
| 35 | - | } | |
| 36 | - | domain, err := app.SendDeploySignal(restartDomain) | |
| 37 | - | if err != nil { | |
| 38 | - | return fmt.Errorf("failed to trigger local restart via deploy signal: %w", err) | |
| 39 | - | } | |
| 40 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain))) | |
| 41 | - | return nil | |
| 31 | + | handle, err := resolveControlPlane(configPath) | |
| 32 | + | if err != nil { | |
| 33 | + | return err | |
| 42 | 34 | } | |
| 35 | + | defer handle.close() | |
| 43 | 36 | ||
| 44 | - | result, err := client.Restart(ctx, restartDomain, withAttachments) | |
| 37 | + | result, err := handle.plane.Restart(ctx, restartDomain, withAttachments) | |
| 45 | 38 | if err != nil { | |
| 46 | - | if !withAttachments && shouldFallbackToLocal(err) { | |
| 39 | + | if handle.isRemote && !withAttachments && shouldFallbackToLocal(err) { | |
| 47 | 40 | domain, localErr := app.SendDeploySignal(restartDomain) | |
| 48 | 41 | if localErr == nil { | |
| 49 | 42 | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err))) | |
| @@ -28,22 +28,15 @@ Examples: | |||||
| 28 | ctx := cmd.Context() | 28 | ctx := cmd.Context() | ||
| 29 | restartDomain := args[0] | 29 | restartDomain := args[0] | ||
| 30 | 30 | ||||
| 31 | - | client, isRemote := GetRemoteClient() | 31 | + | handle, err := resolveControlPlane(configPath) |
| 32 | - | if !isRemote { | 32 | + | if err != nil { |
| 33 | - | if withAttachments { | 33 | + | return err |
| 34 | - | return fmt.Errorf("local restart does not support --with-attachments; use --remote") | |||
| 35 | - | } | |||
| 36 | - | domain, err := app.SendDeploySignal(restartDomain) | |||
| 37 | - | if err != nil { | |||
| 38 | - | return fmt.Errorf("failed to trigger local restart via deploy signal: %w", err) | |||
| 39 | - | } | |||
| 40 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain))) | |||
| 41 | - | return nil | |||
| 42 | } | 34 | } | ||
| 35 | + | defer handle.close() | |||
| 43 | 36 | ||||
| 44 | - | result, err := client.Restart(ctx, restartDomain, withAttachments) | 37 | + | result, err := handle.plane.Restart(ctx, restartDomain, withAttachments) |
| 45 | if err != nil { | 38 | if err != nil { | ||
| 46 | - | if !withAttachments && shouldFallbackToLocal(err) { | 39 | + | if handle.isRemote && !withAttachments && shouldFallbackToLocal(err) { |
| 47 | domain, localErr := app.SendDeploySignal(restartDomain) | 40 | domain, localErr := app.SendDeploySignal(restartDomain) | ||
| 48 | if localErr == nil { | 41 | if localErr == nil { | ||
| 49 | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err))) | 42 | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err))) | ||
M internal/adapters/in/cli/rollback.go +13 -13
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | 10 | "github.com/spf13/cobra" | |
| 11 | 11 | "golang.org/x/mod/semver" | |
| 12 | 12 | ||
| 13 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 14 | 13 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 15 | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 16 | 15 | "github.com/bnema/gordon/internal/domain" | |
| @@ -40,12 +39,13 @@ Examples: | |||
| 40 | 39 | } | |
| 41 | 40 | ||
| 42 | 41 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |
| 43 | - | client, isRemote := GetRemoteClient() | |
| 44 | - | if !isRemote { | |
| 45 | - | return fmt.Errorf("rollback requires remote mode") | |
| 42 | + | handle, err := resolveControlPlane(configPath) | |
| 43 | + | if err != nil { | |
| 44 | + | return err | |
| 46 | 45 | } | |
| 46 | + | defer handle.close() | |
| 47 | 47 | ||
| 48 | - | route, err := client.GetRoute(ctx, rollbackDomain) | |
| 48 | + | route, err := handle.plane.GetRoute(ctx, rollbackDomain) | |
| 49 | 49 | if err != nil { | |
| 50 | 50 | return fmt.Errorf("failed to get route: %w", err) | |
| 51 | 51 | } | |
| @@ -55,7 +55,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||
| 55 | 55 | return fmt.Errorf("cannot parse image name from route: %s", route.Image) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | tags, err := fetchAndSortTags(ctx, client, imageName) | |
| 58 | + | tags, err := fetchAndSortTags(ctx, handle.plane, imageName) | |
| 59 | 59 | if err != nil { | |
| 60 | 60 | return err | |
| 61 | 61 | } | |
| @@ -70,11 +70,11 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||
| 70 | 70 | return nil | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | - | return deploySelectedTag(ctx, client, route, rollbackDomain, imageName, selectedTag) | |
| 73 | + | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | - | func fetchAndSortTags(ctx context.Context, client *remote.Client, imageName string) ([]string, error) { | |
| 77 | - | tags, err := client.ListTags(ctx, imageName) | |
| 76 | + | func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) { | |
| 77 | + | tags, err := cp.ListTags(ctx, imageName) | |
| 78 | 78 | if err != nil { | |
| 79 | 79 | return nil, fmt.Errorf("failed to list tags: %w", err) | |
| 80 | 80 | } | |
| @@ -154,22 +154,22 @@ func validateTagExists(targetTag string, tags []string) bool { | |||
| 154 | 154 | return false | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | - | func deploySelectedTag(ctx context.Context, client *remote.Client, route *domain.Route, rollbackDomain, imageName, selectedTag string) error { | |
| 157 | + | func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, rollbackDomain, imageName, selectedTag string) error { | |
| 158 | 158 | registry, _, _ := parseImageRef(route.Image) | |
| 159 | 159 | oldImage := route.Image | |
| 160 | 160 | route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag) | |
| 161 | 161 | ||
| 162 | 162 | fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag)) | |
| 163 | 163 | ||
| 164 | - | if err := client.UpdateRoute(ctx, *route); err != nil { | |
| 164 | + | if err := cp.UpdateRoute(ctx, *route); err != nil { | |
| 165 | 165 | return fmt.Errorf("failed to update route: %w", err) | |
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | - | result, err := client.Deploy(ctx, rollbackDomain) | |
| 168 | + | result, err := cp.Deploy(ctx, rollbackDomain) | |
| 169 | 169 | if err != nil { | |
| 170 | 170 | // Attempt to revert route to previous image | |
| 171 | 171 | route.Image = oldImage | |
| 172 | - | if revertErr := client.UpdateRoute(ctx, *route); revertErr != nil { | |
| 172 | + | if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil { | |
| 173 | 173 | fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr) | |
| 174 | 174 | return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err) | |
| 175 | 175 | } | |
| @@ -10,7 +10,6 @@ import ( | |||||
| 10 | "github.com/spf13/cobra" | 10 | "github.com/spf13/cobra" | ||
| 11 | "golang.org/x/mod/semver" | 11 | "golang.org/x/mod/semver" | ||
| 12 | 12 | ||||
| 13 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | 13 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | ||
| 15 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| 16 | "github.com/bnema/gordon/internal/domain" | 15 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -40,12 +39,13 @@ Examples: | |||||
| 40 | } | 39 | } | ||
| 41 | 40 | ||||
| 42 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | 41 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | ||
| 43 | - | client, isRemote := GetRemoteClient() | 42 | + | handle, err := resolveControlPlane(configPath) |
| 44 | - | if !isRemote { | 43 | + | if err != nil { |
| 45 | - | return fmt.Errorf("rollback requires remote mode") | 44 | + | return err |
| 46 | } | 45 | } | ||
| 46 | + | defer handle.close() | |||
| 47 | 47 | ||||
| 48 | - | route, err := client.GetRoute(ctx, rollbackDomain) | 48 | + | route, err := handle.plane.GetRoute(ctx, rollbackDomain) |
| 49 | if err != nil { | 49 | if err != nil { | ||
| 50 | return fmt.Errorf("failed to get route: %w", err) | 50 | return fmt.Errorf("failed to get route: %w", err) | ||
| 51 | } | 51 | } | ||
| @@ -55,7 +55,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||||
| 55 | return fmt.Errorf("cannot parse image name from route: %s", route.Image) | 55 | return fmt.Errorf("cannot parse image name from route: %s", route.Image) | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | - | tags, err := fetchAndSortTags(ctx, client, imageName) | 58 | + | tags, err := fetchAndSortTags(ctx, handle.plane, imageName) |
| 59 | if err != nil { | 59 | if err != nil { | ||
| 60 | return err | 60 | return err | ||
| 61 | } | 61 | } | ||
| @@ -70,11 +70,11 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||||
| 70 | return nil | 70 | return nil | ||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
| 73 | - | return deploySelectedTag(ctx, client, route, rollbackDomain, imageName, selectedTag) | 73 | + | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) |
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | - | func fetchAndSortTags(ctx context.Context, client *remote.Client, imageName string) ([]string, error) { | 76 | + | func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) { |
| 77 | - | tags, err := client.ListTags(ctx, imageName) | 77 | + | tags, err := cp.ListTags(ctx, imageName) |
| 78 | if err != nil { | 78 | if err != nil { | ||
| 79 | return nil, fmt.Errorf("failed to list tags: %w", err) | 79 | return nil, fmt.Errorf("failed to list tags: %w", err) | ||
| 80 | } | 80 | } | ||
| @@ -154,22 +154,22 @@ func validateTagExists(targetTag string, tags []string) bool { | |||||
| 154 | return false | 154 | return false | ||
| 155 | } | 155 | } | ||
| 156 | 156 | ||||
| 157 | - | func deploySelectedTag(ctx context.Context, client *remote.Client, route *domain.Route, rollbackDomain, imageName, selectedTag string) error { | 157 | + | func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, rollbackDomain, imageName, selectedTag string) error { |
| 158 | registry, _, _ := parseImageRef(route.Image) | 158 | registry, _, _ := parseImageRef(route.Image) | ||
| 159 | oldImage := route.Image | 159 | oldImage := route.Image | ||
| 160 | route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag) | 160 | route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag) | ||
| 161 | 161 | ||||
| 162 | fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag)) | 162 | fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag)) | ||
| 163 | 163 | ||||
| 164 | - | if err := client.UpdateRoute(ctx, *route); err != nil { | 164 | + | if err := cp.UpdateRoute(ctx, *route); err != nil { |
| 165 | return fmt.Errorf("failed to update route: %w", err) | 165 | return fmt.Errorf("failed to update route: %w", err) | ||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | - | result, err := client.Deploy(ctx, rollbackDomain) | 168 | + | result, err := cp.Deploy(ctx, rollbackDomain) |
| 169 | if err != nil { | 169 | if err != nil { | ||
| 170 | // Attempt to revert route to previous image | 170 | // Attempt to revert route to previous image | ||
| 171 | route.Image = oldImage | 171 | route.Image = oldImage | ||
| 172 | - | if revertErr := client.UpdateRoute(ctx, *route); revertErr != nil { | 172 | + | if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil { |
| 173 | fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr) | 173 | fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr) | ||
| 174 | return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err) | 174 | return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err) | ||
| 175 | } | 175 | } | ||
M internal/adapters/in/cli/routes.go +5 -6
| @@ -3,7 +3,6 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "os" | |
| 7 | 6 | "strings" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -375,13 +374,13 @@ func newStatusCmd() *cobra.Command { | |||
| 375 | 374 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 376 | 375 | ctx := context.Background() | |
| 377 | 376 | ||
| 378 | - | client, isRemote := GetRemoteClient() | |
| 379 | - | if !isRemote { | |
| 380 | - | fmt.Println(styles.RenderError("status command requires --remote flag or GORDON_REMOTE env var")) | |
| 381 | - | os.Exit(1) | |
| 377 | + | handle, err := resolveControlPlane(configPath) | |
| 378 | + | if err != nil { | |
| 379 | + | return err | |
| 382 | 380 | } | |
| 381 | + | defer handle.close() | |
| 383 | 382 | ||
| 384 | - | status, err := client.GetStatus(ctx) | |
| 383 | + | status, err := handle.plane.GetStatus(ctx) | |
| 385 | 384 | if err != nil { | |
| 386 | 385 | return fmt.Errorf("failed to get status: %w", err) | |
| 387 | 386 | } | |
| @@ -3,7 +3,6 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "os" | |||
| 7 | "strings" | 6 | "strings" | ||
| 8 | 7 | ||||
| 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 8 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| @@ -375,13 +374,13 @@ func newStatusCmd() *cobra.Command { | |||||
| 375 | RunE: func(cmd *cobra.Command, args []string) error { | 374 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 376 | ctx := context.Background() | 375 | ctx := context.Background() | ||
| 377 | 376 | ||||
| 378 | - | client, isRemote := GetRemoteClient() | 377 | + | handle, err := resolveControlPlane(configPath) |
| 379 | - | if !isRemote { | 378 | + | if err != nil { |
| 380 | - | fmt.Println(styles.RenderError("status command requires --remote flag or GORDON_REMOTE env var")) | 379 | + | return err |
| 381 | - | os.Exit(1) | |||
| 382 | } | 380 | } | ||
| 381 | + | defer handle.close() | |||
| 383 | 382 | ||||
| 384 | - | status, err := client.GetStatus(ctx) | 383 | + | status, err := handle.plane.GetStatus(ctx) |
| 385 | if err != nil { | 384 | if err != nil { | ||
| 386 | return fmt.Errorf("failed to get status: %w", err) | 385 | return fmt.Errorf("failed to get status: %w", err) | ||
| 387 | } | 386 | } | ||
M internal/adapters/in/http/admin/middleware.go +5 -9
| @@ -51,19 +51,15 @@ func AuthMiddleware( | |||
| 51 | 51 | } | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | // Check if auth is enabled. | |
| 55 | - | // DESIGN NOTE: When auth is disabled, the admin API is intentionally | |
| 56 | - | // accessible without credentials. This is the expected behavior for | |
| 57 | - | // local/development setups where Gordon is not exposed to the internet. | |
| 58 | - | // Users who disable auth accept this tradeoff explicitly in their config. | |
| 59 | - | // For production deployments, auth should always be enabled. | |
| 54 | + | // Authentication is always required for admin APIs. | |
| 55 | + | // Fail closed if auth is disabled or misconfigured. | |
| 60 | 56 | if !authSvc.IsEnabled() { | |
| 61 | - | log.Warn(). | |
| 57 | + | log.Error(). | |
| 62 | 58 | Str("path", r.URL.Path). | |
| 63 | 59 | Str("method", r.Method). | |
| 64 | 60 | Str("remote_addr", r.RemoteAddr). | |
| 65 | - | Msg("auth disabled - allowing unauthenticated admin API access (intended for local use only)") | |
| 66 | - | next.ServeHTTP(w, r) | |
| 61 | + | Msg("authentication disabled/misconfigured - denying admin API request") | |
| 62 | + | sendUnauthorized(w, "authentication is required") | |
| 67 | 63 | return | |
| 68 | 64 | } | |
| 69 | 65 | ||
| @@ -51,19 +51,15 @@ func AuthMiddleware( | |||||
| 51 | } | 51 | } | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | - | // Check if auth is enabled. | 54 | + | // Authentication is always required for admin APIs. |
| 55 | - | // DESIGN NOTE: When auth is disabled, the admin API is intentionally | 55 | + | // Fail closed if auth is disabled or misconfigured. |
| 56 | - | // accessible without credentials. This is the expected behavior for | |||
| 57 | - | // local/development setups where Gordon is not exposed to the internet. | |||
| 58 | - | // Users who disable auth accept this tradeoff explicitly in their config. | |||
| 59 | - | // For production deployments, auth should always be enabled. | |||
| 60 | if !authSvc.IsEnabled() { | 56 | if !authSvc.IsEnabled() { | ||
| 61 | - | log.Warn(). | 57 | + | log.Error(). |
| 62 | Str("path", r.URL.Path). | 58 | Str("path", r.URL.Path). | ||
| 63 | Str("method", r.Method). | 59 | Str("method", r.Method). | ||
| 64 | Str("remote_addr", r.RemoteAddr). | 60 | Str("remote_addr", r.RemoteAddr). | ||
| 65 | - | Msg("auth disabled - allowing unauthenticated admin API access (intended for local use only)") | 61 | + | Msg("authentication disabled/misconfigured - denying admin API request") |
| 66 | - | next.ServeHTTP(w, r) | 62 | + | sendUnauthorized(w, "authentication is required") |
| 67 | return | 63 | return | ||
| 68 | } | 64 | } | ||
| 69 | 65 | ||||
M internal/adapters/in/http/admin/middleware_test.go +15 -5
| @@ -78,7 +78,11 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 78 | 78 | ||
| 79 | 79 | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 80 | 80 | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | |
| 81 | - | authSvc.EXPECT().IsEnabled().Return(false) | |
| 81 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 82 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |
| 83 | + | Subject: "admin", | |
| 84 | + | Scopes: []string{"admin:*:*"}, | |
| 85 | + | }, nil) | |
| 82 | 86 | ||
| 83 | 87 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 84 | 88 | w.WriteHeader(http.StatusOK) | |
| @@ -90,6 +94,7 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 90 | 94 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 91 | 95 | req.RemoteAddr = "127.0.0.1:12345" | |
| 92 | 96 | req.Header.Set("X-Forwarded-For", "203.0.113.50") | |
| 97 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |
| 93 | 98 | rec := httptest.NewRecorder() | |
| 94 | 99 | wrappedHandler.ServeHTTP(rec, req) | |
| 95 | 100 | ||
| @@ -99,8 +104,12 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 99 | 104 | func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |
| 100 | 105 | authSvc := inmocks.NewMockAuthService(t) | |
| 101 | 106 | ||
| 102 | - | // Auth disabled, no rate limiters | |
| 103 | - | authSvc.EXPECT().IsEnabled().Return(false) | |
| 107 | + | // No rate limiters, auth still required | |
| 108 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 109 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |
| 110 | + | Subject: "admin", | |
| 111 | + | Scopes: []string{"admin:*:*"}, | |
| 112 | + | }, nil) | |
| 104 | 113 | ||
| 105 | 114 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 106 | 115 | w.WriteHeader(http.StatusOK) | |
| @@ -110,6 +119,7 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||
| 110 | 119 | wrappedHandler := mw(handler) | |
| 111 | 120 | ||
| 112 | 121 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 122 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |
| 113 | 123 | rec := httptest.NewRecorder() | |
| 114 | 124 | wrappedHandler.ServeHTTP(rec, req) | |
| 115 | 125 | ||
| @@ -139,8 +149,8 @@ func TestAuthMiddleware_AuthDisabled(t *testing.T) { | |||
| 139 | 149 | rec := httptest.NewRecorder() | |
| 140 | 150 | wrappedHandler.ServeHTTP(rec, req) | |
| 141 | 151 | ||
| 142 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 143 | - | assert.True(t, handlerCalled) | |
| 152 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 153 | + | assert.False(t, handlerCalled) | |
| 144 | 154 | } | |
| 145 | 155 | ||
| 146 | 156 | func TestAuthMiddleware_MissingAuthHeader(t *testing.T) { | |
| @@ -78,7 +78,11 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||||
| 78 | 78 | ||||
| 79 | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | 79 | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | ||
| 80 | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | 80 | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | ||
| 81 | - | authSvc.EXPECT().IsEnabled().Return(false) | 81 | + | authSvc.EXPECT().IsEnabled().Return(true) |
| 82 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |||
| 83 | + | Subject: "admin", | |||
| 84 | + | Scopes: []string{"admin:*:*"}, | |||
| 85 | + | }, nil) | |||
| 82 | 86 | ||||
| 83 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 87 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 84 | w.WriteHeader(http.StatusOK) | 88 | w.WriteHeader(http.StatusOK) | ||
| @@ -90,6 +94,7 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||||
| 90 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | 94 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | ||
| 91 | req.RemoteAddr = "127.0.0.1:12345" | 95 | req.RemoteAddr = "127.0.0.1:12345" | ||
| 92 | req.Header.Set("X-Forwarded-For", "203.0.113.50") | 96 | req.Header.Set("X-Forwarded-For", "203.0.113.50") | ||
| 97 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |||
| 93 | rec := httptest.NewRecorder() | 98 | rec := httptest.NewRecorder() | ||
| 94 | wrappedHandler.ServeHTTP(rec, req) | 99 | wrappedHandler.ServeHTTP(rec, req) | ||
| 95 | 100 | ||||
| @@ -99,8 +104,12 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||||
| 99 | func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | 104 | func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | ||
| 100 | authSvc := inmocks.NewMockAuthService(t) | 105 | authSvc := inmocks.NewMockAuthService(t) | ||
| 101 | 106 | ||||
| 102 | - | // Auth disabled, no rate limiters | 107 | + | // No rate limiters, auth still required |
| 103 | - | authSvc.EXPECT().IsEnabled().Return(false) | 108 | + | authSvc.EXPECT().IsEnabled().Return(true) |
| 109 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |||
| 110 | + | Subject: "admin", | |||
| 111 | + | Scopes: []string{"admin:*:*"}, | |||
| 112 | + | }, nil) | |||
| 104 | 113 | ||||
| 105 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 114 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 106 | w.WriteHeader(http.StatusOK) | 115 | w.WriteHeader(http.StatusOK) | ||
| @@ -110,6 +119,7 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||||
| 110 | wrappedHandler := mw(handler) | 119 | wrappedHandler := mw(handler) | ||
| 111 | 120 | ||||
| 112 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | 121 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | ||
| 122 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |||
| 113 | rec := httptest.NewRecorder() | 123 | rec := httptest.NewRecorder() | ||
| 114 | wrappedHandler.ServeHTTP(rec, req) | 124 | wrappedHandler.ServeHTTP(rec, req) | ||
| 115 | 125 | ||||
| @@ -139,8 +149,8 @@ func TestAuthMiddleware_AuthDisabled(t *testing.T) { | |||||
| 139 | rec := httptest.NewRecorder() | 149 | rec := httptest.NewRecorder() | ||
| 140 | wrappedHandler.ServeHTTP(rec, req) | 150 | wrappedHandler.ServeHTTP(rec, req) | ||
| 141 | 151 | ||||
| 142 | - | assert.Equal(t, http.StatusOK, rec.Code) | 152 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) |
| 143 | - | assert.True(t, handlerCalled) | 153 | + | assert.False(t, handlerCalled) |
| 144 | } | 154 | } | ||
| 145 | 155 | ||||
| 146 | func TestAuthMiddleware_MissingAuthHeader(t *testing.T) { | 156 | func TestAuthMiddleware_MissingAuthHeader(t *testing.T) { | ||
M internal/adapters/in/http/auth/handler.go +110 -40
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | package auth | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "context" | |
| 5 | 6 | "crypto/subtle" | |
| 6 | 7 | "encoding/json" | |
| 7 | 8 | "net/http" | |
| @@ -50,6 +51,13 @@ type PasswordResponse struct { | |||
| 50 | 51 | IssuedAt string `json:"issued_at"` | |
| 51 | 52 | } | |
| 52 | 53 | ||
| 54 | + | const passwordSessionTTL = 24 * time.Hour | |
| 55 | + | ||
| 56 | + | func setNoStoreHeaders(w http.ResponseWriter) { | |
| 57 | + | w.Header().Set("Cache-Control", "no-store") | |
| 58 | + | w.Header().Set("Pragma", "no-cache") | |
| 59 | + | } | |
| 60 | + | ||
| 53 | 61 | // ServeHTTP routes requests to the appropriate handler. | |
| 54 | 62 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 55 | 63 | path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/") | |
| @@ -67,9 +75,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 67 | 75 | } | |
| 68 | 76 | ||
| 69 | 77 | // handlePassword handles POST /auth/password requests. | |
| 70 | - | // Validates username/password and returns a long-lived JWT (7 days). | |
| 78 | + | // Validates username/password and returns a daily session JWT. | |
| 71 | 79 | // Only works when auth type is "password" - returns error for "token" auth type. | |
| 72 | 80 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |
| 81 | + | setNoStoreHeaders(w) | |
| 82 | + | ||
| 73 | 83 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |
| 74 | 84 | zerowrap.FieldLayer: "adapter", | |
| 75 | 85 | zerowrap.FieldAdapter: "http", | |
| @@ -89,8 +99,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||
| 89 | 99 | // Check if auth is enabled | |
| 90 | 100 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |
| 91 | 101 | w.Header().Set("Content-Type", "application/json") | |
| 92 | - | w.WriteHeader(http.StatusBadRequest) | |
| 93 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"}) | |
| 102 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 103 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) | |
| 94 | 104 | return | |
| 95 | 105 | } | |
| 96 | 106 | ||
| @@ -132,8 +142,9 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||
| 132 | 142 | return | |
| 133 | 143 | } | |
| 134 | 144 | ||
| 135 | - | // Generate 7-day token with full access (push, pull, admin) | |
| 136 | - | expiry := 7 * 24 * time.Hour | |
| 145 | + | // Generate daily session token with full access (push, pull, admin). | |
| 146 | + | // This enforces regular re-authentication similar to short-session UX. | |
| 147 | + | expiry := passwordSessionTTL | |
| 137 | 148 | scopes := []string{"push", "pull", "admin:*:*"} | |
| 138 | 149 | ||
| 139 | 150 | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | |
| @@ -168,6 +179,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||
| 168 | 179 | // This is the Docker Registry v2 token server endpoint. | |
| 169 | 180 | // Issues short-lived access tokens for registry access. | |
| 170 | 181 | func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |
| 182 | + | setNoStoreHeaders(w) | |
| 183 | + | ||
| 171 | 184 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |
| 172 | 185 | zerowrap.FieldLayer: "adapter", | |
| 173 | 186 | zerowrap.FieldAdapter: "http", | |
| @@ -186,38 +199,23 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 186 | 199 | ||
| 187 | 200 | // Check if auth is enabled | |
| 188 | 201 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |
| 189 | - | // If auth is disabled, return an anonymous token | |
| 190 | - | h.sendAnonymousToken(w, log) | |
| 202 | + | w.Header().Set("Content-Type", "application/json") | |
| 203 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 204 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) | |
| 191 | 205 | return | |
| 192 | 206 | } | |
| 193 | 207 | ||
| 194 | 208 | // Get credentials from Basic Auth | |
| 195 | 209 | username, password, ok := r.BasicAuth() | |
| 196 | 210 | if !ok { | |
| 197 | - | // No credentials provided - return anonymous token with limited scope | |
| 198 | - | h.sendAnonymousToken(w, log) | |
| 211 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 212 | + | w.Header().Set("Content-Type", "application/json") | |
| 213 | + | w.WriteHeader(http.StatusUnauthorized) | |
| 214 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"}) | |
| 199 | 215 | return | |
| 200 | 216 | } | |
| 201 | 217 | ||
| 202 | - | // Check for internal registry auth (localhost requests with internal creds) | |
| 203 | - | var authenticated bool | |
| 204 | - | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { | |
| 205 | - | authenticated = true | |
| 206 | - | log.Debug().Str("username", username).Msg("internal registry auth accepted") | |
| 207 | - | } else { | |
| 208 | - | // Validate credentials based on auth type | |
| 209 | - | switch h.authSvc.GetAuthType() { | |
| 210 | - | case domain.AuthTypePassword: | |
| 211 | - | authenticated = h.authSvc.ValidatePassword(ctx, username, password) | |
| 212 | - | case domain.AuthTypeToken: | |
| 213 | - | // For token auth, the password might be an existing JWT | |
| 214 | - | claims, err := h.authSvc.ValidateToken(ctx, password) | |
| 215 | - | if err == nil && claims.Subject == username { | |
| 216 | - | authenticated = true | |
| 217 | - | } | |
| 218 | - | } | |
| 219 | - | } | |
| 220 | - | ||
| 218 | + | authenticated, parentClaims := h.authenticateTokenCredentials(ctx, r, username, password, log) | |
| 221 | 219 | if !authenticated { | |
| 222 | 220 | log.Debug(). | |
| 223 | 221 | Str("username", username). | |
| @@ -231,6 +229,15 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 231 | 229 | ||
| 232 | 230 | // Parse requested scopes from query parameter | |
| 233 | 231 | requestedScopes := h.parseRequestedScopes(r, log) | |
| 232 | + | if parentClaims != nil { | |
| 233 | + | requestedScopes = h.intersectRequestedScopes(requestedScopes, parentClaims.Scopes, log) | |
| 234 | + | if len(requestedScopes) == 0 { | |
| 235 | + | w.Header().Set("Content-Type", "application/json") | |
| 236 | + | w.WriteHeader(http.StatusForbidden) | |
| 237 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "forbidden: insufficient scope"}) | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | } | |
| 234 | 241 | ||
| 235 | 242 | // Generate a short-lived access token (5 minutes) - not stored | |
| 236 | 243 | accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute) | |
| @@ -261,23 +268,86 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 261 | 268 | Msg("access token issued") | |
| 262 | 269 | } | |
| 263 | 270 | ||
| 264 | - | // sendAnonymousToken sends a token for anonymous/unauthenticated access. | |
| 265 | - | func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { | |
| 266 | - | // For anonymous access, we issue a very short-lived token with limited scope | |
| 267 | - | response := dto.TokenResponse{ | |
| 268 | - | Token: "", // Empty token indicates limited access | |
| 269 | - | ExpiresIn: 60, // 1 minute | |
| 270 | - | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |
| 271 | + | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | |
| 272 | + | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { | |
| 273 | + | log.Debug().Str("username", username).Msg("internal registry auth accepted") | |
| 274 | + | return true, nil | |
| 271 | 275 | } | |
| 272 | 276 | ||
| 273 | - | w.Header().Set("Content-Type", "application/json") | |
| 274 | - | w.WriteHeader(http.StatusOK) | |
| 277 | + | switch h.authSvc.GetAuthType() { | |
| 278 | + | case domain.AuthTypePassword: | |
| 279 | + | return h.authSvc.ValidatePassword(ctx, username, password), nil | |
| 280 | + | case domain.AuthTypeToken: | |
| 281 | + | claims, err := h.authSvc.ValidateToken(ctx, password) | |
| 282 | + | if err == nil && claims.Subject == username { | |
| 283 | + | return true, claims | |
| 284 | + | } | |
| 285 | + | } | |
| 275 | 286 | ||
| 276 | - | if err := json.NewEncoder(w).Encode(response); err != nil { | |
| 277 | - | log.Error().Err(err).Msg("failed to encode anonymous token response") | |
| 287 | + | return false, nil | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string { | |
| 291 | + | var effective []string | |
| 292 | + | ||
| 293 | + | for _, reqScopeStr := range requestedScopes { | |
| 294 | + | reqScope, err := domain.ParseScope(reqScopeStr) | |
| 295 | + | if err != nil || reqScope.Type != domain.ScopeTypeRepository { | |
| 296 | + | continue | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | allowedActions := make([]string, 0, len(reqScope.Actions)) | |
| 300 | + | for _, action := range reqScope.Actions { | |
| 301 | + | if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) { | |
| 302 | + | allowedActions = append(allowedActions, action) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | if len(allowedActions) == 0 { | |
| 307 | + | continue | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | effective = append(effective, (&domain.Scope{ | |
| 311 | + | Type: reqScope.Type, | |
| 312 | + | Name: reqScope.Name, | |
| 313 | + | Actions: allowedActions, | |
| 314 | + | }).String()) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | log.Debug(). | |
| 318 | + | Strs("requested_scopes", requestedScopes). | |
| 319 | + | Strs("granted_scopes", grantedScopes). | |
| 320 | + | Strs("effective_scopes", effective). | |
| 321 | + | Msg("calculated effective scopes from parent token") | |
| 322 | + | ||
| 323 | + | return effective | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool { | |
| 327 | + | for _, grantedScopeStr := range grantedScopes { | |
| 328 | + | scopeStr := strings.TrimSpace(grantedScopeStr) | |
| 329 | + | switch scopeStr { | |
| 330 | + | case domain.ScopeActionAll: | |
| 331 | + | return true | |
| 332 | + | case domain.ScopeActionPull, domain.ScopeActionPush: | |
| 333 | + | if scopeStr == action { | |
| 334 | + | return true | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | scope, err := domain.ParseScope(scopeStr) | |
| 339 | + | if err != nil { | |
| 340 | + | continue | |
| 341 | + | } | |
| 342 | + | if scope.Type != domain.ScopeTypeRepository { | |
| 343 | + | continue | |
| 344 | + | } | |
| 345 | + | if scope.CanAccess(repoName, action) { | |
| 346 | + | return true | |
| 347 | + | } | |
| 278 | 348 | } | |
| 279 | 349 | ||
| 280 | - | log.Debug().Msg("anonymous token issued") | |
| 350 | + | return false | |
| 281 | 351 | } | |
| 282 | 352 | ||
| 283 | 353 | // isLocalhostRequest checks if the request originates from localhost. | |
| @@ -2,6 +2,7 @@ | |||||
| 2 | package auth | 2 | package auth | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "context" | |||
| 5 | "crypto/subtle" | 6 | "crypto/subtle" | ||
| 6 | "encoding/json" | 7 | "encoding/json" | ||
| 7 | "net/http" | 8 | "net/http" | ||
| @@ -50,6 +51,13 @@ type PasswordResponse struct { | |||||
| 50 | IssuedAt string `json:"issued_at"` | 51 | IssuedAt string `json:"issued_at"` | ||
| 51 | } | 52 | } | ||
| 52 | 53 | ||||
| 54 | + | const passwordSessionTTL = 24 * time.Hour | |||
| 55 | + | ||||
| 56 | + | func setNoStoreHeaders(w http.ResponseWriter) { | |||
| 57 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 58 | + | w.Header().Set("Pragma", "no-cache") | |||
| 59 | + | } | |||
| 60 | + | ||||
| 53 | // ServeHTTP routes requests to the appropriate handler. | 61 | // ServeHTTP routes requests to the appropriate handler. | ||
| 54 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 62 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 55 | path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/") | 63 | path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/") | ||
| @@ -67,9 +75,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 67 | } | 75 | } | ||
| 68 | 76 | ||||
| 69 | // handlePassword handles POST /auth/password requests. | 77 | // handlePassword handles POST /auth/password requests. | ||
| 70 | - | // Validates username/password and returns a long-lived JWT (7 days). | 78 | + | // Validates username/password and returns a daily session JWT. |
| 71 | // Only works when auth type is "password" - returns error for "token" auth type. | 79 | // Only works when auth type is "password" - returns error for "token" auth type. | ||
| 72 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | 80 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | ||
| 81 | + | setNoStoreHeaders(w) | |||
| 82 | + | ||||
| 73 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | 83 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | ||
| 74 | zerowrap.FieldLayer: "adapter", | 84 | zerowrap.FieldLayer: "adapter", | ||
| 75 | zerowrap.FieldAdapter: "http", | 85 | zerowrap.FieldAdapter: "http", | ||
| @@ -89,8 +99,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||||
| 89 | // Check if auth is enabled | 99 | // Check if auth is enabled | ||
| 90 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | 100 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | ||
| 91 | w.Header().Set("Content-Type", "application/json") | 101 | w.Header().Set("Content-Type", "application/json") | ||
| 92 | - | w.WriteHeader(http.StatusBadRequest) | 102 | + | w.WriteHeader(http.StatusServiceUnavailable) |
| 93 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"}) | 103 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) |
| 94 | return | 104 | return | ||
| 95 | } | 105 | } | ||
| 96 | 106 | ||||
| @@ -132,8 +142,9 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||||
| 132 | return | 142 | return | ||
| 133 | } | 143 | } | ||
| 134 | 144 | ||||
| 135 | - | // Generate 7-day token with full access (push, pull, admin) | 145 | + | // Generate daily session token with full access (push, pull, admin). |
| 136 | - | expiry := 7 * 24 * time.Hour | 146 | + | // This enforces regular re-authentication similar to short-session UX. |
| 147 | + | expiry := passwordSessionTTL | |||
| 137 | scopes := []string{"push", "pull", "admin:*:*"} | 148 | scopes := []string{"push", "pull", "admin:*:*"} | ||
| 138 | 149 | ||||
| 139 | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | 150 | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | ||
| @@ -168,6 +179,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||||
| 168 | // This is the Docker Registry v2 token server endpoint. | 179 | // This is the Docker Registry v2 token server endpoint. | ||
| 169 | // Issues short-lived access tokens for registry access. | 180 | // Issues short-lived access tokens for registry access. | ||
| 170 | func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | 181 | func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | ||
| 182 | + | setNoStoreHeaders(w) | |||
| 183 | + | ||||
| 171 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | 184 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | ||
| 172 | zerowrap.FieldLayer: "adapter", | 185 | zerowrap.FieldLayer: "adapter", | ||
| 173 | zerowrap.FieldAdapter: "http", | 186 | zerowrap.FieldAdapter: "http", | ||
| @@ -186,38 +199,23 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 186 | 199 | ||||
| 187 | // Check if auth is enabled | 200 | // Check if auth is enabled | ||
| 188 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | 201 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | ||
| 189 | - | // If auth is disabled, return an anonymous token | 202 | + | w.Header().Set("Content-Type", "application/json") |
| 190 | - | h.sendAnonymousToken(w, log) | 203 | + | w.WriteHeader(http.StatusServiceUnavailable) |
| 204 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) | |||
| 191 | return | 205 | return | ||
| 192 | } | 206 | } | ||
| 193 | 207 | ||||
| 194 | // Get credentials from Basic Auth | 208 | // Get credentials from Basic Auth | ||
| 195 | username, password, ok := r.BasicAuth() | 209 | username, password, ok := r.BasicAuth() | ||
| 196 | if !ok { | 210 | if !ok { | ||
| 197 | - | // No credentials provided - return anonymous token with limited scope | 211 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) |
| 198 | - | h.sendAnonymousToken(w, log) | 212 | + | w.Header().Set("Content-Type", "application/json") |
| 213 | + | w.WriteHeader(http.StatusUnauthorized) | |||
| 214 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"}) | |||
| 199 | return | 215 | return | ||
| 200 | } | 216 | } | ||
| 201 | 217 | ||||
| 202 | - | // Check for internal registry auth (localhost requests with internal creds) | 218 | + | authenticated, parentClaims := h.authenticateTokenCredentials(ctx, r, username, password, log) |
| 203 | - | var authenticated bool | |||
| 204 | - | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { | |||
| 205 | - | authenticated = true | |||
| 206 | - | log.Debug().Str("username", username).Msg("internal registry auth accepted") | |||
| 207 | - | } else { | |||
| 208 | - | // Validate credentials based on auth type | |||
| 209 | - | switch h.authSvc.GetAuthType() { | |||
| 210 | - | case domain.AuthTypePassword: | |||
| 211 | - | authenticated = h.authSvc.ValidatePassword(ctx, username, password) | |||
| 212 | - | case domain.AuthTypeToken: | |||
| 213 | - | // For token auth, the password might be an existing JWT | |||
| 214 | - | claims, err := h.authSvc.ValidateToken(ctx, password) | |||
| 215 | - | if err == nil && claims.Subject == username { | |||
| 216 | - | authenticated = true | |||
| 217 | - | } | |||
| 218 | - | } | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | if !authenticated { | 219 | if !authenticated { | ||
| 222 | log.Debug(). | 220 | log.Debug(). | ||
| 223 | Str("username", username). | 221 | Str("username", username). | ||
| @@ -231,6 +229,15 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 231 | 229 | ||||
| 232 | // Parse requested scopes from query parameter | 230 | // Parse requested scopes from query parameter | ||
| 233 | requestedScopes := h.parseRequestedScopes(r, log) | 231 | requestedScopes := h.parseRequestedScopes(r, log) | ||
| 232 | + | if parentClaims != nil { | |||
| 233 | + | requestedScopes = h.intersectRequestedScopes(requestedScopes, parentClaims.Scopes, log) | |||
| 234 | + | if len(requestedScopes) == 0 { | |||
| 235 | + | w.Header().Set("Content-Type", "application/json") | |||
| 236 | + | w.WriteHeader(http.StatusForbidden) | |||
| 237 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "forbidden: insufficient scope"}) | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 234 | 241 | ||||
| 235 | // Generate a short-lived access token (5 minutes) - not stored | 242 | // Generate a short-lived access token (5 minutes) - not stored | ||
| 236 | accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute) | 243 | accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute) | ||
| @@ -261,23 +268,86 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 261 | Msg("access token issued") | 268 | Msg("access token issued") | ||
| 262 | } | 269 | } | ||
| 263 | 270 | ||||
| 264 | - | // sendAnonymousToken sends a token for anonymous/unauthenticated access. | 271 | + | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { |
| 265 | - | func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { | 272 | + | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { |
| 266 | - | // For anonymous access, we issue a very short-lived token with limited scope | 273 | + | log.Debug().Str("username", username).Msg("internal registry auth accepted") |
| 267 | - | response := dto.TokenResponse{ | 274 | + | return true, nil |
| 268 | - | Token: "", // Empty token indicates limited access | |||
| 269 | - | ExpiresIn: 60, // 1 minute | |||
| 270 | - | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |||
| 271 | } | 275 | } | ||
| 272 | 276 | ||||
| 273 | - | w.Header().Set("Content-Type", "application/json") | 277 | + | switch h.authSvc.GetAuthType() { |
| 274 | - | w.WriteHeader(http.StatusOK) | 278 | + | case domain.AuthTypePassword: |
| 279 | + | return h.authSvc.ValidatePassword(ctx, username, password), nil | |||
| 280 | + | case domain.AuthTypeToken: | |||
| 281 | + | claims, err := h.authSvc.ValidateToken(ctx, password) | |||
| 282 | + | if err == nil && claims.Subject == username { | |||
| 283 | + | return true, claims | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 275 | 286 | ||||
| 276 | - | if err := json.NewEncoder(w).Encode(response); err != nil { | 287 | + | return false, nil |
| 277 | - | log.Error().Err(err).Msg("failed to encode anonymous token response") | 288 | + | } |
| 289 | + | ||||
| 290 | + | func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string { | |||
| 291 | + | var effective []string | |||
| 292 | + | ||||
| 293 | + | for _, reqScopeStr := range requestedScopes { | |||
| 294 | + | reqScope, err := domain.ParseScope(reqScopeStr) | |||
| 295 | + | if err != nil || reqScope.Type != domain.ScopeTypeRepository { | |||
| 296 | + | continue | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | allowedActions := make([]string, 0, len(reqScope.Actions)) | |||
| 300 | + | for _, action := range reqScope.Actions { | |||
| 301 | + | if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) { | |||
| 302 | + | allowedActions = append(allowedActions, action) | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | if len(allowedActions) == 0 { | |||
| 307 | + | continue | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | effective = append(effective, (&domain.Scope{ | |||
| 311 | + | Type: reqScope.Type, | |||
| 312 | + | Name: reqScope.Name, | |||
| 313 | + | Actions: allowedActions, | |||
| 314 | + | }).String()) | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | log.Debug(). | |||
| 318 | + | Strs("requested_scopes", requestedScopes). | |||
| 319 | + | Strs("granted_scopes", grantedScopes). | |||
| 320 | + | Strs("effective_scopes", effective). | |||
| 321 | + | Msg("calculated effective scopes from parent token") | |||
| 322 | + | ||||
| 323 | + | return effective | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool { | |||
| 327 | + | for _, grantedScopeStr := range grantedScopes { | |||
| 328 | + | scopeStr := strings.TrimSpace(grantedScopeStr) | |||
| 329 | + | switch scopeStr { | |||
| 330 | + | case domain.ScopeActionAll: | |||
| 331 | + | return true | |||
| 332 | + | case domain.ScopeActionPull, domain.ScopeActionPush: | |||
| 333 | + | if scopeStr == action { | |||
| 334 | + | return true | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | scope, err := domain.ParseScope(scopeStr) | |||
| 339 | + | if err != nil { | |||
| 340 | + | continue | |||
| 341 | + | } | |||
| 342 | + | if scope.Type != domain.ScopeTypeRepository { | |||
| 343 | + | continue | |||
| 344 | + | } | |||
| 345 | + | if scope.CanAccess(repoName, action) { | |||
| 346 | + | return true | |||
| 347 | + | } | |||
| 278 | } | 348 | } | ||
| 279 | 349 | ||||
| 280 | - | log.Debug().Msg("anonymous token issued") | 350 | + | return false |
| 281 | } | 351 | } | ||
| 282 | 352 | ||||
| 283 | // isLocalhostRequest checks if the request originates from localhost. | 353 | // isLocalhostRequest checks if the request originates from localhost. | ||
M internal/adapters/in/http/auth/handler_test.go +58 -19
| @@ -26,7 +26,7 @@ func TestHandler_Password_Success(t *testing.T) { | |||
| 26 | 26 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 27 | 27 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 28 | 28 | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |
| 29 | - | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour). | |
| 29 | + | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour). | |
| 30 | 30 | Return("jwt-token-here", nil) | |
| 31 | 31 | ||
| 32 | 32 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| @@ -44,7 +44,7 @@ func TestHandler_Password_Success(t *testing.T) { | |||
| 44 | 44 | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 45 | 45 | assert.NoError(t, err) | |
| 46 | 46 | assert.Equal(t, "jwt-token-here", resp.Token) | |
| 47 | - | assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds | |
| 47 | + | assert.Equal(t, 24*60*60, resp.ExpiresIn) | |
| 48 | 48 | assert.NotEmpty(t, resp.IssuedAt) | |
| 49 | 49 | } | |
| 50 | 50 | ||
| @@ -101,8 +101,8 @@ func TestHandler_Password_AuthDisabled(t *testing.T) { | |||
| 101 | 101 | ||
| 102 | 102 | handler.ServeHTTP(rec, req) | |
| 103 | 103 | ||
| 104 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 105 | - | assert.Contains(t, rec.Body.String(), "authentication is disabled") | |
| 104 | + | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) | |
| 105 | + | assert.Contains(t, rec.Body.String(), "authentication is required") | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | 108 | func TestHandler_Password_MissingFields(t *testing.T) { | |
| @@ -179,7 +179,7 @@ func TestHandler_Token_Success(t *testing.T) { | |||
| 179 | 179 | assert.Equal(t, float64(300), resp["expires_in"]) | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | - | func TestHandler_Token_Anonymous(t *testing.T) { | |
| 182 | + | func TestHandler_Token_MissingCredentials(t *testing.T) { | |
| 183 | 183 | authSvc := mocks.NewMockAuthService(t) | |
| 184 | 184 | ||
| 185 | 185 | authSvc.EXPECT().IsEnabled().Return(true) | |
| @@ -191,13 +191,7 @@ func TestHandler_Token_Anonymous(t *testing.T) { | |||
| 191 | 191 | ||
| 192 | 192 | handler.ServeHTTP(rec, req) | |
| 193 | 193 | ||
| 194 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 195 | - | ||
| 196 | - | var resp map[string]any | |
| 197 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 198 | - | assert.NoError(t, err) | |
| 199 | - | assert.Equal(t, "", resp["token"]) | |
| 200 | - | assert.Equal(t, float64(60), resp["expires_in"]) | |
| 194 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 201 | 195 | } | |
| 202 | 196 | ||
| 203 | 197 | func TestHandler_Token_AuthDisabled(t *testing.T) { | |
| @@ -212,13 +206,8 @@ func TestHandler_Token_AuthDisabled(t *testing.T) { | |||
| 212 | 206 | ||
| 213 | 207 | handler.ServeHTTP(rec, req) | |
| 214 | 208 | ||
| 215 | - | // Should return anonymous token when auth is disabled | |
| 216 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 217 | - | ||
| 218 | - | var resp map[string]any | |
| 219 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 220 | - | assert.NoError(t, err) | |
| 221 | - | assert.Equal(t, "", resp["token"]) | |
| 209 | + | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) | |
| 210 | + | assert.Contains(t, rec.Body.String(), "authentication is required") | |
| 222 | 211 | } | |
| 223 | 212 | ||
| 224 | 213 | func TestHandler_Token_InvalidCredentials(t *testing.T) { | |
| @@ -274,6 +263,7 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||
| 274 | 263 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |
| 275 | 264 | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |
| 276 | 265 | Subject: "myuser", | |
| 266 | + | Scopes: []string{"repository:*:pull"}, | |
| 277 | 267 | }, nil) | |
| 278 | 268 | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute). | |
| 279 | 269 | Return("short-lived-token", nil) | |
| @@ -294,6 +284,55 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||
| 294 | 284 | assert.Equal(t, "short-lived-token", resp["token"]) | |
| 295 | 285 | } | |
| 296 | 286 | ||
| 287 | + | func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) { | |
| 288 | + | authSvc := mocks.NewMockAuthService(t) | |
| 289 | + | ||
| 290 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 291 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |
| 292 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |
| 293 | + | Subject: "myuser", | |
| 294 | + | Scopes: []string{"repository:myrepo:pull"}, | |
| 295 | + | }, nil) | |
| 296 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute). | |
| 297 | + | Return("scoped-token", nil) | |
| 298 | + | ||
| 299 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 300 | + | ||
| 301 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull,push", nil) | |
| 302 | + | req.SetBasicAuth("myuser", "existing-jwt-token") | |
| 303 | + | rec := httptest.NewRecorder() | |
| 304 | + | ||
| 305 | + | handler.ServeHTTP(rec, req) | |
| 306 | + | ||
| 307 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 308 | + | var resp map[string]any | |
| 309 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 310 | + | assert.NoError(t, err) | |
| 311 | + | assert.Equal(t, "scoped-token", resp["token"]) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestHandler_Token_TokenAuthScopeDenied(t *testing.T) { | |
| 315 | + | authSvc := mocks.NewMockAuthService(t) | |
| 316 | + | ||
| 317 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 318 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |
| 319 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |
| 320 | + | Subject: "myuser", | |
| 321 | + | Scopes: []string{"repository:myrepo:pull"}, | |
| 322 | + | }, nil) | |
| 323 | + | ||
| 324 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 325 | + | ||
| 326 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:push", nil) | |
| 327 | + | req.SetBasicAuth("myuser", "existing-jwt-token") | |
| 328 | + | rec := httptest.NewRecorder() | |
| 329 | + | ||
| 330 | + | handler.ServeHTTP(rec, req) | |
| 331 | + | ||
| 332 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 333 | + | assert.Contains(t, rec.Body.String(), "insufficient scope") | |
| 334 | + | } | |
| 335 | + | ||
| 297 | 336 | func TestHandler_Token_MethodNotAllowed(t *testing.T) { | |
| 298 | 337 | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |
| 299 | 338 | ||
| @@ -26,7 +26,7 @@ func TestHandler_Password_Success(t *testing.T) { | |||||
| 26 | authSvc.EXPECT().IsEnabled().Return(true) | 26 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 27 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | 27 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | ||
| 28 | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | 28 | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | ||
| 29 | - | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour). | 29 | + | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour). |
| 30 | Return("jwt-token-here", nil) | 30 | Return("jwt-token-here", nil) | ||
| 31 | 31 | ||||
| 32 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | 32 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | ||
| @@ -44,7 +44,7 @@ func TestHandler_Password_Success(t *testing.T) { | |||||
| 44 | err := json.NewDecoder(rec.Body).Decode(&resp) | 44 | err := json.NewDecoder(rec.Body).Decode(&resp) | ||
| 45 | assert.NoError(t, err) | 45 | assert.NoError(t, err) | ||
| 46 | assert.Equal(t, "jwt-token-here", resp.Token) | 46 | assert.Equal(t, "jwt-token-here", resp.Token) | ||
| 47 | - | assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds | 47 | + | assert.Equal(t, 24*60*60, resp.ExpiresIn) |
| 48 | assert.NotEmpty(t, resp.IssuedAt) | 48 | assert.NotEmpty(t, resp.IssuedAt) | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| @@ -101,8 +101,8 @@ func TestHandler_Password_AuthDisabled(t *testing.T) { | |||||
| 101 | 101 | ||||
| 102 | handler.ServeHTTP(rec, req) | 102 | handler.ServeHTTP(rec, req) | ||
| 103 | 103 | ||||
| 104 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | 104 | + | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) |
| 105 | - | assert.Contains(t, rec.Body.String(), "authentication is disabled") | 105 | + | assert.Contains(t, rec.Body.String(), "authentication is required") |
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | func TestHandler_Password_MissingFields(t *testing.T) { | 108 | func TestHandler_Password_MissingFields(t *testing.T) { | ||
| @@ -179,7 +179,7 @@ func TestHandler_Token_Success(t *testing.T) { | |||||
| 179 | assert.Equal(t, float64(300), resp["expires_in"]) | 179 | assert.Equal(t, float64(300), resp["expires_in"]) | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | - | func TestHandler_Token_Anonymous(t *testing.T) { | 182 | + | func TestHandler_Token_MissingCredentials(t *testing.T) { |
| 183 | authSvc := mocks.NewMockAuthService(t) | 183 | authSvc := mocks.NewMockAuthService(t) | ||
| 184 | 184 | ||||
| 185 | authSvc.EXPECT().IsEnabled().Return(true) | 185 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| @@ -191,13 +191,7 @@ func TestHandler_Token_Anonymous(t *testing.T) { | |||||
| 191 | 191 | ||||
| 192 | handler.ServeHTTP(rec, req) | 192 | handler.ServeHTTP(rec, req) | ||
| 193 | 193 | ||||
| 194 | - | assert.Equal(t, http.StatusOK, rec.Code) | 194 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) |
| 195 | - | ||||
| 196 | - | var resp map[string]any | |||
| 197 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 198 | - | assert.NoError(t, err) | |||
| 199 | - | assert.Equal(t, "", resp["token"]) | |||
| 200 | - | assert.Equal(t, float64(60), resp["expires_in"]) | |||
| 201 | } | 195 | } | ||
| 202 | 196 | ||||
| 203 | func TestHandler_Token_AuthDisabled(t *testing.T) { | 197 | func TestHandler_Token_AuthDisabled(t *testing.T) { | ||
| @@ -212,13 +206,8 @@ func TestHandler_Token_AuthDisabled(t *testing.T) { | |||||
| 212 | 206 | ||||
| 213 | handler.ServeHTTP(rec, req) | 207 | handler.ServeHTTP(rec, req) | ||
| 214 | 208 | ||||
| 215 | - | // Should return anonymous token when auth is disabled | 209 | + | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) |
| 216 | - | assert.Equal(t, http.StatusOK, rec.Code) | 210 | + | assert.Contains(t, rec.Body.String(), "authentication is required") |
| 217 | - | ||||
| 218 | - | var resp map[string]any | |||
| 219 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 220 | - | assert.NoError(t, err) | |||
| 221 | - | assert.Equal(t, "", resp["token"]) | |||
| 222 | } | 211 | } | ||
| 223 | 212 | ||||
| 224 | func TestHandler_Token_InvalidCredentials(t *testing.T) { | 213 | func TestHandler_Token_InvalidCredentials(t *testing.T) { | ||
| @@ -274,6 +263,7 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||||
| 274 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | 263 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | ||
| 275 | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | 264 | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | ||
| 276 | Subject: "myuser", | 265 | Subject: "myuser", | ||
| 266 | + | Scopes: []string{"repository:*:pull"}, | |||
| 277 | }, nil) | 267 | }, nil) | ||
| 278 | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute). | 268 | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute). | ||
| 279 | Return("short-lived-token", nil) | 269 | Return("short-lived-token", nil) | ||
| @@ -294,6 +284,55 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||||
| 294 | assert.Equal(t, "short-lived-token", resp["token"]) | 284 | assert.Equal(t, "short-lived-token", resp["token"]) | ||
| 295 | } | 285 | } | ||
| 296 | 286 | ||||
| 287 | + | func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) { | |||
| 288 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 289 | + | ||||
| 290 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 291 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |||
| 292 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |||
| 293 | + | Subject: "myuser", | |||
| 294 | + | Scopes: []string{"repository:myrepo:pull"}, | |||
| 295 | + | }, nil) | |||
| 296 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute). | |||
| 297 | + | Return("scoped-token", nil) | |||
| 298 | + | ||||
| 299 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 300 | + | ||||
| 301 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull,push", nil) | |||
| 302 | + | req.SetBasicAuth("myuser", "existing-jwt-token") | |||
| 303 | + | rec := httptest.NewRecorder() | |||
| 304 | + | ||||
| 305 | + | handler.ServeHTTP(rec, req) | |||
| 306 | + | ||||
| 307 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 308 | + | var resp map[string]any | |||
| 309 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 310 | + | assert.NoError(t, err) | |||
| 311 | + | assert.Equal(t, "scoped-token", resp["token"]) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func TestHandler_Token_TokenAuthScopeDenied(t *testing.T) { | |||
| 315 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 316 | + | ||||
| 317 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 318 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |||
| 319 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |||
| 320 | + | Subject: "myuser", | |||
| 321 | + | Scopes: []string{"repository:myrepo:pull"}, | |||
| 322 | + | }, nil) | |||
| 323 | + | ||||
| 324 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 325 | + | ||||
| 326 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:push", nil) | |||
| 327 | + | req.SetBasicAuth("myuser", "existing-jwt-token") | |||
| 328 | + | rec := httptest.NewRecorder() | |||
| 329 | + | ||||
| 330 | + | handler.ServeHTTP(rec, req) | |||
| 331 | + | ||||
| 332 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 333 | + | assert.Contains(t, rec.Body.String(), "insufficient scope") | |||
| 334 | + | } | |||
| 335 | + | ||||
| 297 | func TestHandler_Token_MethodNotAllowed(t *testing.T) { | 336 | func TestHandler_Token_MethodNotAllowed(t *testing.T) { | ||
| 298 | handler := NewHandler(nil, InternalAuth{}, testLogger()) | 337 | handler := NewHandler(nil, InternalAuth{}, testLogger()) | ||
| 299 | 338 | ||||
M internal/adapters/in/http/middleware/clientip.go +44 -6
| @@ -6,6 +6,24 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | + | func normalizeIP(raw string) string { | |
| 10 | + | ip := strings.TrimSpace(raw) | |
| 11 | + | if ip == "" { | |
| 12 | + | return "" | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | if host, _, err := net.SplitHostPort(ip); err == nil { | |
| 16 | + | ip = host | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | parsed := net.ParseIP(strings.TrimSpace(ip)) | |
| 20 | + | if parsed == nil { | |
| 21 | + | return "" | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | return parsed.String() | |
| 25 | + | } | |
| 26 | + | ||
| 9 | 27 | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | |
| 10 | 28 | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | |
| 11 | 29 | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | |
| @@ -60,21 +78,41 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||
| 60 | 78 | if err != nil { | |
| 61 | 79 | remoteIP = r.RemoteAddr | |
| 62 | 80 | } | |
| 81 | + | if normalized := normalizeIP(remoteIP); normalized != "" { | |
| 82 | + | remoteIP = normalized | |
| 83 | + | } | |
| 63 | 84 | ||
| 64 | 85 | // Only honor proxy headers if the request comes from a trusted proxy | |
| 65 | 86 | if IsTrustedProxy(remoteIP, trustedNets) { | |
| 66 | - | // Check X-Forwarded-For for proxied requests | |
| 87 | + | // Check X-Forwarded-For for proxied requests. | |
| 88 | + | // SECURITY: Parse from right to left and return the first untrusted IP. | |
| 89 | + | // This prevents spoofing when a trusted proxy appends to an attacker- | |
| 90 | + | // supplied XFF chain. | |
| 67 | 91 | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |
| 68 | - | // Take first IP in chain (original client) | |
| 69 | - | if first, _, found := strings.Cut(xff, ","); found { | |
| 70 | - | return strings.TrimSpace(first) | |
| 92 | + | parts := strings.Split(xff, ",") | |
| 93 | + | validChain := make([]string, 0, len(parts)) | |
| 94 | + | for _, part := range parts { | |
| 95 | + | if ip := normalizeIP(part); ip != "" { | |
| 96 | + | validChain = append(validChain, ip) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | for i := len(validChain) - 1; i >= 0; i-- { | |
| 101 | + | if !IsTrustedProxy(validChain[i], trustedNets) { | |
| 102 | + | return validChain[i] | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | if len(validChain) > 0 { | |
| 107 | + | return validChain[0] | |
| 71 | 108 | } | |
| 72 | - | return strings.TrimSpace(xff) | |
| 73 | 109 | } | |
| 74 | 110 | ||
| 75 | 111 | // Check X-Real-IP | |
| 76 | 112 | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |
| 77 | - | return xri | |
| 113 | + | if ip := normalizeIP(xri); ip != "" { | |
| 114 | + | return ip | |
| 115 | + | } | |
| 78 | 116 | } | |
| 79 | 117 | } | |
| 80 | 118 | ||
| @@ -6,6 +6,24 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | + | func normalizeIP(raw string) string { | |||
| 10 | + | ip := strings.TrimSpace(raw) | |||
| 11 | + | if ip == "" { | |||
| 12 | + | return "" | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | if host, _, err := net.SplitHostPort(ip); err == nil { | |||
| 16 | + | ip = host | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | parsed := net.ParseIP(strings.TrimSpace(ip)) | |||
| 20 | + | if parsed == nil { | |||
| 21 | + | return "" | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | return parsed.String() | |||
| 25 | + | } | |||
| 26 | + | ||||
| 9 | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | 27 | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | ||
| 10 | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | 28 | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | ||
| 11 | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | 29 | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | ||
| @@ -60,21 +78,41 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||||
| 60 | if err != nil { | 78 | if err != nil { | ||
| 61 | remoteIP = r.RemoteAddr | 79 | remoteIP = r.RemoteAddr | ||
| 62 | } | 80 | } | ||
| 81 | + | if normalized := normalizeIP(remoteIP); normalized != "" { | |||
| 82 | + | remoteIP = normalized | |||
| 83 | + | } | |||
| 63 | 84 | ||||
| 64 | // Only honor proxy headers if the request comes from a trusted proxy | 85 | // Only honor proxy headers if the request comes from a trusted proxy | ||
| 65 | if IsTrustedProxy(remoteIP, trustedNets) { | 86 | if IsTrustedProxy(remoteIP, trustedNets) { | ||
| 66 | - | // Check X-Forwarded-For for proxied requests | 87 | + | // Check X-Forwarded-For for proxied requests. |
| 88 | + | // SECURITY: Parse from right to left and return the first untrusted IP. | |||
| 89 | + | // This prevents spoofing when a trusted proxy appends to an attacker- | |||
| 90 | + | // supplied XFF chain. | |||
| 67 | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | 91 | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | ||
| 68 | - | // Take first IP in chain (original client) | 92 | + | parts := strings.Split(xff, ",") |
| 69 | - | if first, _, found := strings.Cut(xff, ","); found { | 93 | + | validChain := make([]string, 0, len(parts)) |
| 70 | - | return strings.TrimSpace(first) | 94 | + | for _, part := range parts { |
| 95 | + | if ip := normalizeIP(part); ip != "" { | |||
| 96 | + | validChain = append(validChain, ip) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | for i := len(validChain) - 1; i >= 0; i-- { | |||
| 101 | + | if !IsTrustedProxy(validChain[i], trustedNets) { | |||
| 102 | + | return validChain[i] | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | if len(validChain) > 0 { | |||
| 107 | + | return validChain[0] | |||
| 71 | } | 108 | } | ||
| 72 | - | return strings.TrimSpace(xff) | |||
| 73 | } | 109 | } | ||
| 74 | 110 | ||||
| 75 | // Check X-Real-IP | 111 | // Check X-Real-IP | ||
| 76 | if xri := r.Header.Get("X-Real-IP"); xri != "" { | 112 | if xri := r.Header.Get("X-Real-IP"); xri != "" { | ||
| 77 | - | return xri | 113 | + | if ip := normalizeIP(xri); ip != "" { |
| 114 | + | return ip | |||
| 115 | + | } | |||
| 78 | } | 116 | } | ||
| 79 | } | 117 | } | ||
| 80 | 118 | ||||
M internal/adapters/in/http/middleware/clientip_test.go +8 -1
| @@ -58,7 +58,14 @@ func TestGetClientIP(t *testing.T) { | |||
| 58 | 58 | { | |
| 59 | 59 | name: "XFF honored from trusted proxy (multiple IPs)", | |
| 60 | 60 | remoteAddr: "127.0.0.1:12345", | |
| 61 | - | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | |
| 61 | + | xff: "203.0.113.50, 10.0.0.1", | |
| 62 | + | trustedNets: trustedNets, | |
| 63 | + | wantIP: "203.0.113.50", | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "XFF spoof chain returns first untrusted from right", | |
| 67 | + | remoteAddr: "127.0.0.1:12345", | |
| 68 | + | xff: "198.51.100.123, 203.0.113.50, 10.0.0.9", | |
| 62 | 69 | trustedNets: trustedNets, | |
| 63 | 70 | wantIP: "203.0.113.50", | |
| 64 | 71 | }, | |
| @@ -58,7 +58,14 @@ func TestGetClientIP(t *testing.T) { | |||||
| 58 | { | 58 | { | ||
| 59 | name: "XFF honored from trusted proxy (multiple IPs)", | 59 | name: "XFF honored from trusted proxy (multiple IPs)", | ||
| 60 | remoteAddr: "127.0.0.1:12345", | 60 | remoteAddr: "127.0.0.1:12345", | ||
| 61 | - | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | 61 | + | xff: "203.0.113.50, 10.0.0.1", |
| 62 | + | trustedNets: trustedNets, | |||
| 63 | + | wantIP: "203.0.113.50", | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "XFF spoof chain returns first untrusted from right", | |||
| 67 | + | remoteAddr: "127.0.0.1:12345", | |||
| 68 | + | xff: "198.51.100.123, 203.0.113.50, 10.0.0.9", | |||
| 62 | trustedNets: trustedNets, | 69 | trustedNets: trustedNets, | ||
| 63 | wantIP: "203.0.113.50", | 70 | wantIP: "203.0.113.50", | ||
| 64 | }, | 71 | }, | ||
M internal/adapters/in/http/registry/ratelimit_test.go +2 -2
| @@ -266,11 +266,11 @@ func TestGetClientIP(t *testing.T) { | |||
| 266 | 266 | wantIP: "203.0.113.50", | |
| 267 | 267 | }, | |
| 268 | 268 | { | |
| 269 | - | name: "XFF honored from trusted proxy (multiple IPs)", | |
| 269 | + | name: "XFF returns first untrusted IP from right", | |
| 270 | 270 | remoteAddr: "127.0.0.1:12345", | |
| 271 | 271 | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | |
| 272 | 272 | trustedNets: trustedNets, | |
| 273 | - | wantIP: "203.0.113.50", | |
| 273 | + | wantIP: "172.16.0.1", | |
| 274 | 274 | }, | |
| 275 | 275 | { | |
| 276 | 276 | name: "XFF honored from trusted CIDR", | |
| @@ -266,11 +266,11 @@ func TestGetClientIP(t *testing.T) { | |||||
| 266 | wantIP: "203.0.113.50", | 266 | wantIP: "203.0.113.50", | ||
| 267 | }, | 267 | }, | ||
| 268 | { | 268 | { | ||
| 269 | - | name: "XFF honored from trusted proxy (multiple IPs)", | 269 | + | name: "XFF returns first untrusted IP from right", |
| 270 | remoteAddr: "127.0.0.1:12345", | 270 | remoteAddr: "127.0.0.1:12345", | ||
| 271 | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | 271 | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | ||
| 272 | trustedNets: trustedNets, | 272 | trustedNets: trustedNets, | ||
| 273 | - | wantIP: "203.0.113.50", | 273 | + | wantIP: "172.16.0.1", |
| 274 | }, | 274 | }, | ||
| 275 | { | 275 | { | ||
| 276 | name: "XFF honored from trusted CIDR", | 276 | name: "XFF honored from trusted CIDR", | ||
A internal/app/kernel.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zerowrap" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/boundaries/in" | |
| 10 | + | configusecase "github.com/bnema/gordon/internal/usecase/config" | |
| 11 | + | secretsusecase "github.com/bnema/gordon/internal/usecase/secrets" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Kernel provides in-process service access for local CLI execution. | |
| 15 | + | // | |
| 16 | + | // It intentionally does not start HTTP servers or register signal handlers. | |
| 17 | + | type Kernel struct { | |
| 18 | + | authEnabled bool | |
| 19 | + | configSvc in.ConfigService | |
| 20 | + | secretSvc in.SecretService | |
| 21 | + | containerSvc in.ContainerService | |
| 22 | + | backupSvc in.BackupService | |
| 23 | + | registrySvc in.RegistryService | |
| 24 | + | healthSvc in.HealthService | |
| 25 | + | logSvc in.LogService | |
| 26 | + | cleanup func() | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // NewKernel initializes local services without starting server listeners. | |
| 30 | + | func NewKernel(configPath string) (*Kernel, error) { | |
| 31 | + | ctx := context.Background() | |
| 32 | + | v, cfg, err := initConfig(configPath) | |
| 33 | + | if err != nil { | |
| 34 | + | return nil, err | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | log, cleanup, err := initLogger(cfg) | |
| 38 | + | if err != nil { | |
| 39 | + | return nil, err | |
| 40 | + | } | |
| 41 | + | if cleanup == nil { | |
| 42 | + | cleanup = func() {} | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | ctx = zerowrap.WithCtx(ctx, log) | |
| 46 | + | ||
| 47 | + | // Prefer full service wiring so local CLI can execute the same operations | |
| 48 | + | // as remote mode without going through HTTP admin endpoints. | |
| 49 | + | if svc, fullErr := createServices(ctx, v, cfg, log); fullErr == nil { | |
| 50 | + | return &Kernel{ | |
| 51 | + | authEnabled: cfg.Auth.Enabled, | |
| 52 | + | configSvc: svc.configSvc, | |
| 53 | + | secretSvc: svc.secretSvc, | |
| 54 | + | containerSvc: svc.containerSvc, | |
| 55 | + | backupSvc: svc.backupSvc, | |
| 56 | + | registrySvc: svc.registrySvc, | |
| 57 | + | healthSvc: svc.healthSvc, | |
| 58 | + | logSvc: svc.logSvc, | |
| 59 | + | cleanup: cleanup, | |
| 60 | + | }, nil | |
| 61 | + | } else { | |
| 62 | + | log.Warn().Err(fullErr).Msg("local kernel running in minimal mode") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | configSvc := configusecase.NewService(v, nil) | |
| 66 | + | if err := configSvc.Load(ctx); err != nil { | |
| 67 | + | cleanup() | |
| 68 | + | return nil, fmt.Errorf("failed to load configuration: %w", err) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | _, _, _, domainSecretStore, err := createDomainSecretStore(cfg, log) | |
| 72 | + | if err != nil { | |
| 73 | + | cleanup() | |
| 74 | + | return nil, fmt.Errorf("failed to create local secret store: %w", err) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | secretSvc := secretsusecase.NewService(domainSecretStore, log) | |
| 78 | + | ||
| 79 | + | return &Kernel{ | |
| 80 | + | authEnabled: cfg.Auth.Enabled, | |
| 81 | + | configSvc: configSvc, | |
| 82 | + | secretSvc: secretSvc, | |
| 83 | + | cleanup: cleanup, | |
| 84 | + | }, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (k *Kernel) Close() error { | |
| 88 | + | if k == nil || k.cleanup == nil { | |
| 89 | + | return nil | |
| 90 | + | } | |
| 91 | + | k.cleanup() | |
| 92 | + | return nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (k *Kernel) Config() in.ConfigService { return k.configSvc } | |
| 96 | + | ||
| 97 | + | func (k *Kernel) Secrets() in.SecretService { return k.secretSvc } | |
| 98 | + | ||
| 99 | + | func (k *Kernel) Container() in.ContainerService { return k.containerSvc } | |
| 100 | + | ||
| 101 | + | func (k *Kernel) Backup() in.BackupService { return k.backupSvc } | |
| 102 | + | ||
| 103 | + | func (k *Kernel) Registry() in.RegistryService { return k.registrySvc } | |
| 104 | + | ||
| 105 | + | func (k *Kernel) Health() in.HealthService { return k.healthSvc } | |
| 106 | + | ||
| 107 | + | func (k *Kernel) Logs() in.LogService { return k.logSvc } | |
| 108 | + | ||
| 109 | + | func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zerowrap" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/boundaries/in" | |||
| 10 | + | configusecase "github.com/bnema/gordon/internal/usecase/config" | |||
| 11 | + | secretsusecase "github.com/bnema/gordon/internal/usecase/secrets" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Kernel provides in-process service access for local CLI execution. | |||
| 15 | + | // | |||
| 16 | + | // It intentionally does not start HTTP servers or register signal handlers. | |||
| 17 | + | type Kernel struct { | |||
| 18 | + | authEnabled bool | |||
| 19 | + | configSvc in.ConfigService | |||
| 20 | + | secretSvc in.SecretService | |||
| 21 | + | containerSvc in.ContainerService | |||
| 22 | + | backupSvc in.BackupService | |||
| 23 | + | registrySvc in.RegistryService | |||
| 24 | + | healthSvc in.HealthService | |||
| 25 | + | logSvc in.LogService | |||
| 26 | + | cleanup func() | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // NewKernel initializes local services without starting server listeners. | |||
| 30 | + | func NewKernel(configPath string) (*Kernel, error) { | |||
| 31 | + | ctx := context.Background() | |||
| 32 | + | v, cfg, err := initConfig(configPath) | |||
| 33 | + | if err != nil { | |||
| 34 | + | return nil, err | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | log, cleanup, err := initLogger(cfg) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return nil, err | |||
| 40 | + | } | |||
| 41 | + | if cleanup == nil { | |||
| 42 | + | cleanup = func() {} | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | ctx = zerowrap.WithCtx(ctx, log) | |||
| 46 | + | ||||
| 47 | + | // Prefer full service wiring so local CLI can execute the same operations | |||
| 48 | + | // as remote mode without going through HTTP admin endpoints. | |||
| 49 | + | if svc, fullErr := createServices(ctx, v, cfg, log); fullErr == nil { | |||
| 50 | + | return &Kernel{ | |||
| 51 | + | authEnabled: cfg.Auth.Enabled, | |||
| 52 | + | configSvc: svc.configSvc, | |||
| 53 | + | secretSvc: svc.secretSvc, | |||
| 54 | + | containerSvc: svc.containerSvc, | |||
| 55 | + | backupSvc: svc.backupSvc, | |||
| 56 | + | registrySvc: svc.registrySvc, | |||
| 57 | + | healthSvc: svc.healthSvc, | |||
| 58 | + | logSvc: svc.logSvc, | |||
| 59 | + | cleanup: cleanup, | |||
| 60 | + | }, nil | |||
| 61 | + | } else { | |||
| 62 | + | log.Warn().Err(fullErr).Msg("local kernel running in minimal mode") | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | configSvc := configusecase.NewService(v, nil) | |||
| 66 | + | if err := configSvc.Load(ctx); err != nil { | |||
| 67 | + | cleanup() | |||
| 68 | + | return nil, fmt.Errorf("failed to load configuration: %w", err) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | _, _, _, domainSecretStore, err := createDomainSecretStore(cfg, log) | |||
| 72 | + | if err != nil { | |||
| 73 | + | cleanup() | |||
| 74 | + | return nil, fmt.Errorf("failed to create local secret store: %w", err) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | secretSvc := secretsusecase.NewService(domainSecretStore, log) | |||
| 78 | + | ||||
| 79 | + | return &Kernel{ | |||
| 80 | + | authEnabled: cfg.Auth.Enabled, | |||
| 81 | + | configSvc: configSvc, | |||
| 82 | + | secretSvc: secretSvc, | |||
| 83 | + | cleanup: cleanup, | |||
| 84 | + | }, nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (k *Kernel) Close() error { | |||
| 88 | + | if k == nil || k.cleanup == nil { | |||
| 89 | + | return nil | |||
| 90 | + | } | |||
| 91 | + | k.cleanup() | |||
| 92 | + | return nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (k *Kernel) Config() in.ConfigService { return k.configSvc } | |||
| 96 | + | ||||
| 97 | + | func (k *Kernel) Secrets() in.SecretService { return k.secretSvc } | |||
| 98 | + | ||||
| 99 | + | func (k *Kernel) Container() in.ContainerService { return k.containerSvc } | |||
| 100 | + | ||||
| 101 | + | func (k *Kernel) Backup() in.BackupService { return k.backupSvc } | |||
| 102 | + | ||||
| 103 | + | func (k *Kernel) Registry() in.RegistryService { return k.registrySvc } | |||
| 104 | + | ||||
| 105 | + | func (k *Kernel) Health() in.HealthService { return k.healthSvc } | |||
| 106 | + | ||||
| 107 | + | func (k *Kernel) Logs() in.LogService { return k.logSvc } | |||
| 108 | + | ||||
| 109 | + | func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled } | |||
A internal/app/kernel_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestNewKernel(t *testing.T) { | |
| 13 | + | t.Parallel() | |
| 14 | + | ||
| 15 | + | tmpDir := t.TempDir() | |
| 16 | + | cfgPath := filepath.Join(tmpDir, "gordon.toml") | |
| 17 | + | dataDir := filepath.Join(tmpDir, "data") | |
| 18 | + | ||
| 19 | + | cfg := fmt.Sprintf(`[server] | |
| 20 | + | gordon_domain = "gordon.local" | |
| 21 | + | data_dir = %q | |
| 22 | + | ||
| 23 | + | [auth] | |
| 24 | + | enabled = false | |
| 25 | + | `, dataDir) | |
| 26 | + | ||
| 27 | + | err := os.WriteFile(cfgPath, []byte(cfg), 0o600) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | ||
| 30 | + | kernel, err := NewKernel(cfgPath) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.NotNil(t, kernel) | |
| 33 | + | t.Cleanup(func() { require.NoError(t, kernel.Close()) }) | |
| 34 | + | ||
| 35 | + | require.NotNil(t, kernel.Config()) | |
| 36 | + | require.NotNil(t, kernel.Secrets()) | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestNewKernel(t *testing.T) { | |||
| 13 | + | t.Parallel() | |||
| 14 | + | ||||
| 15 | + | tmpDir := t.TempDir() | |||
| 16 | + | cfgPath := filepath.Join(tmpDir, "gordon.toml") | |||
| 17 | + | dataDir := filepath.Join(tmpDir, "data") | |||
| 18 | + | ||||
| 19 | + | cfg := fmt.Sprintf(`[server] | |||
| 20 | + | gordon_domain = "gordon.local" | |||
| 21 | + | data_dir = %q | |||
| 22 | + | ||||
| 23 | + | [auth] | |||
| 24 | + | enabled = false | |||
| 25 | + | `, dataDir) | |||
| 26 | + | ||||
| 27 | + | err := os.WriteFile(cfgPath, []byte(cfg), 0o600) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | ||||
| 30 | + | kernel, err := NewKernel(cfgPath) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | require.NotNil(t, kernel) | |||
| 33 | + | t.Cleanup(func() { require.NoError(t, kernel.Close()) }) | |||
| 34 | + | ||||
| 35 | + | require.NotNil(t, kernel.Config()) | |||
| 36 | + | require.NotNil(t, kernel.Secrets()) | |||
| 37 | + | } | |||
M internal/app/run.go +260 -85
| @@ -41,6 +41,7 @@ import ( | |||
| 41 | 41 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | |
| 42 | 42 | ||
| 43 | 43 | // Adapters - Input | |
| 44 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 44 | 45 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 45 | 46 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | |
| 46 | 47 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| @@ -165,9 +166,12 @@ type services struct { | |||
| 165 | 166 | logWriter *logwriter.LogWriter | |
| 166 | 167 | tokenStore out.TokenStore | |
| 167 | 168 | configSvc *config.Service | |
| 169 | + | secretSvc *secretsSvc.Service | |
| 168 | 170 | containerSvc *container.Service | |
| 169 | 171 | backupSvc *backup.Service | |
| 170 | 172 | registrySvc *registrySvc.Service | |
| 173 | + | healthSvc *health.Service | |
| 174 | + | logSvc *logs.Service | |
| 171 | 175 | imageSvc *images.Service | |
| 172 | 176 | proxySvc *proxy.Service | |
| 173 | 177 | authSvc *auth.Service | |
| @@ -430,7 +434,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 430 | 434 | return nil, err | |
| 431 | 435 | } | |
| 432 | 436 | ||
| 433 | - | if err := setupInternalRegistryAuth(cfg, svc, log); err != nil { | |
| 437 | + | if err := setupInternalRegistryAuth(svc, log); err != nil { | |
| 434 | 438 | return nil, err | |
| 435 | 439 | } | |
| 436 | 440 | ||
| @@ -453,7 +457,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 453 | 457 | return nil, err | |
| 454 | 458 | } | |
| 455 | 459 | ||
| 456 | - | secretSvc := secretsSvc.NewService(domainSecretStore, log) | |
| 460 | + | svc.secretSvc = secretsSvc.NewService(domainSecretStore, log) | |
| 457 | 461 | ||
| 458 | 462 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | |
| 459 | 463 | return nil, err | |
| @@ -489,22 +493,30 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 489 | 493 | ||
| 490 | 494 | // Create health service for route health checking | |
| 491 | 495 | prober := httpprober.New() | |
| 492 | - | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |
| 496 | + | svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |
| 493 | 497 | ||
| 494 | 498 | // Create log service for accessing logs via admin API | |
| 495 | - | logSvc := logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | |
| 499 | + | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | |
| 496 | 500 | ||
| 497 | 501 | // Create admin handler for admin API | |
| 498 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc) | |
| 502 | + | svc.adminHandler = admin.NewHandler( | |
| 503 | + | svc.configSvc, | |
| 504 | + | svc.authSvc, | |
| 505 | + | svc.containerSvc, | |
| 506 | + | svc.healthSvc, | |
| 507 | + | svc.secretSvc, | |
| 508 | + | svc.logSvc, | |
| 509 | + | svc.registrySvc, | |
| 510 | + | svc.eventBus, | |
| 511 | + | log, | |
| 512 | + | svc.backupSvc, | |
| 513 | + | svc.imageSvc, | |
| 514 | + | ) | |
| 499 | 515 | ||
| 500 | 516 | return svc, nil | |
| 501 | 517 | } | |
| 502 | 518 | ||
| 503 | - | func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error { | |
| 504 | - | if !cfg.Auth.Enabled { | |
| 505 | - | return nil | |
| 506 | - | } | |
| 507 | - | ||
| 519 | + | func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error { | |
| 508 | 520 | var err error | |
| 509 | 521 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | |
| 510 | 522 | if err != nil { | |
| @@ -774,6 +786,7 @@ func GetInternalCredentials() (*InternalCredentials, error) { | |||
| 774 | 786 | // createAuthService creates the authentication service and token store. | |
| 775 | 787 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | |
| 776 | 788 | if !cfg.Auth.Enabled { | |
| 789 | + | log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)") | |
| 777 | 790 | return nil, nil, nil | |
| 778 | 791 | } | |
| 779 | 792 | ||
| @@ -1084,7 +1097,10 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1084 | 1097 | containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay") | |
| 1085 | 1098 | } | |
| 1086 | 1099 | ||
| 1087 | - | if cfg.Auth.Enabled && svc.authSvc != nil { | |
| 1100 | + | if containerConfig.RegistryAuthEnabled { | |
| 1101 | + | if svc.authSvc == nil { | |
| 1102 | + | return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token") | |
| 1103 | + | } | |
| 1088 | 1104 | expiry, err := resolveServiceTokenExpiry(cfg) | |
| 1089 | 1105 | if err != nil { | |
| 1090 | 1106 | return nil, log.WrapErr(err, "failed to resolve service token expiry") | |
| @@ -1101,6 +1117,8 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1101 | 1117 | Msg("generated service token for container registry access") | |
| 1102 | 1118 | containerConfig.ServiceTokenUsername = serviceTokenSubject | |
| 1103 | 1119 | containerConfig.ServiceToken = serviceToken | |
| 1120 | + | } else { | |
| 1121 | + | log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode") | |
| 1104 | 1122 | } | |
| 1105 | 1123 | ||
| 1106 | 1124 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | |
| @@ -1258,6 +1276,28 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||
| 1258 | 1276 | // Start background monitor to restart crashed containers. | |
| 1259 | 1277 | svc.containerSvc.StartMonitor(ctx) | |
| 1260 | 1278 | } | |
| 1279 | + | func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |
| 1280 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1281 | + | host, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 1282 | + | if err != nil { | |
| 1283 | + | host = r.RemoteAddr | |
| 1284 | + | } | |
| 1285 | + | ||
| 1286 | + | ip := net.ParseIP(host) | |
| 1287 | + | if ip == nil || !ip.IsLoopback() { | |
| 1288 | + | log.Warn(). | |
| 1289 | + | Str("path", r.URL.Path). | |
| 1290 | + | Str("remote_addr", r.RemoteAddr). | |
| 1291 | + | Msg("blocked non-loopback access on internal admin route") | |
| 1292 | + | w.Header().Set("Content-Type", "application/json") | |
| 1293 | + | w.WriteHeader(http.StatusForbidden) | |
| 1294 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |
| 1295 | + | return | |
| 1296 | + | } | |
| 1297 | + | ||
| 1298 | + | next.ServeHTTP(w, r) | |
| 1299 | + | }) | |
| 1300 | + | } | |
| 1261 | 1301 | ||
| 1262 | 1302 | // createHTTPHandlers creates HTTP handlers with middleware. | |
| 1263 | 1303 | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) { | |
| @@ -1266,117 +1306,252 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1266 | 1306 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | |
| 1267 | 1307 | ||
| 1268 | 1308 | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize) | |
| 1309 | + | registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware( | |
| 1310 | + | svc, | |
| 1311 | + | cfg, | |
| 1312 | + | trustedNets, | |
| 1313 | + | registryHandler, | |
| 1314 | + | log, | |
| 1315 | + | ) | |
| 1316 | + | ||
| 1317 | + | registryMux := http.NewServeMux() | |
| 1318 | + | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log) | |
| 1319 | + | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | |
| 1320 | + | ||
| 1321 | + | // SECURITY: No CORS middleware on the proxy chain. Backend applications | |
| 1322 | + | // should control their own CORS policies. A blanket Access-Control-Allow-Origin: * | |
| 1323 | + | // would override backend CORS settings and allow any website to make | |
| 1324 | + | // cross-origin authenticated requests to proxied applications. | |
| 1325 | + | proxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 1326 | + | middleware.PanicRecovery(log), | |
| 1327 | + | middleware.RequestLogger(log, trustedNets), | |
| 1328 | + | middleware.SecurityHeaders, | |
| 1329 | + | } | |
| 1269 | 1330 | ||
| 1331 | + | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | |
| 1332 | + | proxyMux := http.NewServeMux() | |
| 1333 | + | proxyMux.Handle("/", proxyWithMiddleware) | |
| 1334 | + | ||
| 1335 | + | registerAdminRoutes(registryMux, proxyMux, svc, cfg, trustedNets, log) | |
| 1336 | + | ||
| 1337 | + | return registryMux, proxyMux | |
| 1338 | + | } | |
| 1339 | + | ||
| 1340 | + | func buildRegistryHandlerWithMiddleware( | |
| 1341 | + | svc *services, | |
| 1342 | + | cfg Config, | |
| 1343 | + | trustedNets []*net.IPNet, | |
| 1344 | + | registryHandler http.Handler, | |
| 1345 | + | log zerowrap.Logger, | |
| 1346 | + | ) (http.Handler, func(http.Handler) http.Handler, func(http.Handler) http.Handler) { | |
| 1270 | 1347 | registryMiddlewares := []func(http.Handler) http.Handler{ | |
| 1271 | 1348 | middleware.PanicRecovery(log), | |
| 1272 | 1349 | middleware.RequestLogger(log, trustedNets), | |
| 1273 | 1350 | middleware.SecurityHeaders, | |
| 1274 | 1351 | } | |
| 1275 | 1352 | ||
| 1276 | - | // CIDR allowlist for registry access (e.g., restrict to Tailscale IPs). | |
| 1277 | - | // Inserted before rate limiting so denied IPs never consume limiter tokens. | |
| 1278 | - | var cidrAllowlistMiddleware func(http.Handler) http.Handler | |
| 1279 | - | if len(cfg.Server.RegistryAllowedIPs) > 0 { | |
| 1280 | - | allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs) | |
| 1281 | - | if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) { | |
| 1282 | - | for _, entry := range cfg.Server.RegistryAllowedIPs { | |
| 1283 | - | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | |
| 1284 | - | log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry") | |
| 1285 | - | } | |
| 1353 | + | cidrAllowlistMiddleware := buildRegistryCIDRAllowlistMiddleware(cfg, trustedNets, log) | |
| 1354 | + | if cidrAllowlistMiddleware != nil { | |
| 1355 | + | registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware) | |
| 1356 | + | } | |
| 1357 | + | ||
| 1358 | + | rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log) | |
| 1359 | + | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | |
| 1360 | + | ||
| 1361 | + | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, log) | |
| 1362 | + | ||
| 1363 | + | return middleware.Chain(registryMiddlewares...)(registryHandler), cidrAllowlistMiddleware, rateLimitMiddleware | |
| 1364 | + | } | |
| 1365 | + | ||
| 1366 | + | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1367 | + | if len(cfg.Server.RegistryAllowedIPs) == 0 { | |
| 1368 | + | return nil | |
| 1369 | + | } | |
| 1370 | + | ||
| 1371 | + | allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs) | |
| 1372 | + | if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) { | |
| 1373 | + | for _, entry := range cfg.Server.RegistryAllowedIPs { | |
| 1374 | + | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | |
| 1375 | + | log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry") | |
| 1286 | 1376 | } | |
| 1287 | 1377 | } | |
| 1288 | - | if len(allowedNets) == 0 { | |
| 1289 | - | log.Error(). | |
| 1290 | - | Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs). | |
| 1291 | - | Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic") | |
| 1378 | + | } | |
| 1379 | + | ||
| 1380 | + | if len(allowedNets) == 0 { | |
| 1381 | + | log.Error(). | |
| 1382 | + | Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs). | |
| 1383 | + | Msg("registry_allowed_ips is set but no valid entries were parsed; registry will deny all traffic (fail-closed)") | |
| 1384 | + | return func(next http.Handler) http.Handler { | |
| 1385 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1386 | + | log.Warn(). | |
| 1387 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 1388 | + | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |
| 1389 | + | Msg("registry access denied due to invalid registry_allowed_ips configuration") | |
| 1390 | + | w.Header().Set("Content-Type", "application/json") | |
| 1391 | + | w.WriteHeader(http.StatusForbidden) | |
| 1392 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |
| 1393 | + | }) | |
| 1292 | 1394 | } | |
| 1293 | - | cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |
| 1294 | - | registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware) | |
| 1295 | 1395 | } | |
| 1296 | 1396 | ||
| 1297 | - | // Create rate limiters using the factory | |
| 1298 | - | var rateLimitMiddleware func(http.Handler) http.Handler | |
| 1397 | + | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |
| 1398 | + | } | |
| 1399 | + | ||
| 1400 | + | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1299 | 1401 | if cfg.API.RateLimit.Enabled { | |
| 1300 | 1402 | globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | |
| 1301 | 1403 | ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |
| 1302 | - | rateLimitMiddleware = registry.RateLimitMiddleware( | |
| 1404 | + | return registry.RateLimitMiddleware( | |
| 1303 | 1405 | globalLimiter, | |
| 1304 | 1406 | ipLimiter, | |
| 1305 | 1407 | cfg.API.RateLimit.TrustedProxies, | |
| 1306 | 1408 | log, | |
| 1307 | 1409 | ) | |
| 1308 | - | } else { | |
| 1309 | - | rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log) | |
| 1310 | 1410 | } | |
| 1311 | - | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | |
| 1312 | 1411 | ||
| 1313 | - | if cfg.Auth.Enabled && svc.authSvc != nil { | |
| 1412 | + | return registry.RateLimitMiddleware(nil, nil, nil, log) | |
| 1413 | + | } | |
| 1414 | + | ||
| 1415 | + | func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, log zerowrap.Logger) { | |
| 1416 | + | if svc.authSvc != nil { | |
| 1314 | 1417 | internalAuth := middleware.InternalRegistryAuth{ | |
| 1315 | 1418 | Username: svc.internalRegUser, | |
| 1316 | 1419 | Password: svc.internalRegPass, | |
| 1317 | 1420 | } | |
| 1318 | - | registryMiddlewares = append(registryMiddlewares, | |
| 1319 | - | middleware.RegistryAuthV2(svc.authSvc, internalAuth, log)) | |
| 1421 | + | *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, log)) | |
| 1422 | + | return | |
| 1320 | 1423 | } | |
| 1321 | 1424 | ||
| 1322 | - | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | |
| 1425 | + | if cfg.Auth.Enabled { | |
| 1426 | + | log.Error().Msg("authentication service unavailable; registry requests will be denied") | |
| 1427 | + | *registryMiddlewares = append(*registryMiddlewares, func(next http.Handler) http.Handler { | |
| 1428 | + | return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 1429 | + | w.Header().Set("Content-Type", "application/json") | |
| 1430 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 1431 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"}) | |
| 1432 | + | }) | |
| 1433 | + | }) | |
| 1434 | + | } | |
| 1435 | + | } | |
| 1323 | 1436 | ||
| 1324 | - | // Create a mux that routes: | |
| 1325 | - | // - /auth/* to auth handler (no auth required, for password login and token exchange) | |
| 1326 | - | // - /v2/* to registry handler (with auth) | |
| 1327 | - | // - /admin/* to admin handler (with auth) | |
| 1328 | - | registryMux := http.NewServeMux() | |
| 1329 | - | if svc.authHandler != nil { | |
| 1330 | - | // Auth endpoints are NOT protected by auth - they're where clients authenticate | |
| 1331 | - | // but still need rate limiting to prevent brute force attacks | |
| 1332 | - | authMiddlewares := []func(http.Handler) http.Handler{ | |
| 1333 | - | middleware.PanicRecovery(log), | |
| 1334 | - | middleware.RequestLogger(log, trustedNets), | |
| 1335 | - | middleware.SecurityHeaders, | |
| 1336 | - | } | |
| 1337 | - | if cidrAllowlistMiddleware != nil { | |
| 1338 | - | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | |
| 1339 | - | } | |
| 1340 | - | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | |
| 1341 | - | authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler) | |
| 1342 | - | registryMux.Handle("/auth/", authWithMiddleware) | |
| 1343 | - | } | |
| 1344 | - | registryMux.Handle("/v2/", registryWithMiddleware) | |
| 1345 | - | ||
| 1346 | - | // Add admin API handler with auth middleware | |
| 1347 | - | if svc.adminHandler != nil { | |
| 1348 | - | adminMiddlewares := []func(http.Handler) http.Handler{ | |
| 1349 | - | middleware.PanicRecovery(log), | |
| 1350 | - | middleware.RequestLogger(log, trustedNets), | |
| 1351 | - | middleware.SecurityHeaders, | |
| 1352 | - | } | |
| 1353 | - | // Add admin auth middleware if auth is enabled | |
| 1354 | - | if svc.authSvc != nil { | |
| 1355 | - | // Create rate limiters for admin API - uses same config as registry | |
| 1356 | - | var globalLimiter, ipLimiter out.RateLimiter | |
| 1357 | - | if cfg.API.RateLimit.Enabled { | |
| 1358 | - | globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | |
| 1359 | - | ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |
| 1360 | - | } | |
| 1361 | - | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log)) | |
| 1362 | - | } | |
| 1363 | - | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |
| 1364 | - | registryMux.Handle("/admin/", adminWithMiddleware) | |
| 1437 | + | func registerAuthRoutes( | |
| 1438 | + | registryMux *http.ServeMux, | |
| 1439 | + | svc *services, | |
| 1440 | + | trustedNets []*net.IPNet, | |
| 1441 | + | cidrAllowlistMiddleware func(http.Handler) http.Handler, | |
| 1442 | + | rateLimitMiddleware func(http.Handler) http.Handler, | |
| 1443 | + | log zerowrap.Logger, | |
| 1444 | + | ) { | |
| 1445 | + | if svc.authHandler == nil { | |
| 1446 | + | return | |
| 1365 | 1447 | } | |
| 1366 | 1448 | ||
| 1367 | - | // SECURITY: No CORS middleware on the proxy chain. Backend applications | |
| 1368 | - | // should control their own CORS policies. A blanket Access-Control-Allow-Origin: * | |
| 1369 | - | // would override backend CORS settings and allow any website to make | |
| 1370 | - | // cross-origin authenticated requests to proxied applications. | |
| 1371 | - | proxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 1449 | + | // Auth endpoints are NOT protected by auth - they're where clients authenticate | |
| 1450 | + | // but still need rate limiting to prevent brute force attacks. | |
| 1451 | + | authMiddlewares := []func(http.Handler) http.Handler{ | |
| 1372 | 1452 | middleware.PanicRecovery(log), | |
| 1373 | 1453 | middleware.RequestLogger(log, trustedNets), | |
| 1374 | 1454 | middleware.SecurityHeaders, | |
| 1375 | 1455 | } | |
| 1456 | + | if cidrAllowlistMiddleware != nil { | |
| 1457 | + | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | |
| 1458 | + | } | |
| 1459 | + | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | |
| 1460 | + | authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler) | |
| 1461 | + | registryMux.Handle("/auth/", authWithMiddleware) | |
| 1462 | + | } | |
| 1376 | 1463 | ||
| 1377 | - | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | |
| 1464 | + | func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, log zerowrap.Logger) http.Handler { | |
| 1465 | + | if !cfg.Auth.Enabled { | |
| 1466 | + | return loopbackOnly(registryWithMiddleware, log) | |
| 1467 | + | } | |
| 1468 | + | return registryWithMiddleware | |
| 1469 | + | } | |
| 1470 | + | ||
| 1471 | + | func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) { | |
| 1472 | + | if svc.adminHandler == nil { | |
| 1473 | + | return | |
| 1474 | + | } | |
| 1475 | + | ||
| 1476 | + | if !cfg.Auth.Enabled { | |
| 1477 | + | log.Warn().Msg("auth disabled: admin API endpoints are not registered") | |
| 1478 | + | return | |
| 1479 | + | } | |
| 1480 | + | ||
| 1481 | + | adminMiddlewares := []func(http.Handler) http.Handler{ | |
| 1482 | + | middleware.PanicRecovery(log), | |
| 1483 | + | middleware.RequestLogger(log, trustedNets), | |
| 1484 | + | middleware.SecurityHeaders, | |
| 1485 | + | } | |
| 1486 | + | ||
| 1487 | + | if svc.authSvc != nil { | |
| 1488 | + | // Create rate limiters for admin API - uses same config as registry. | |
| 1489 | + | var globalLimiter, ipLimiter out.RateLimiter | |
| 1490 | + | if cfg.API.RateLimit.Enabled { | |
| 1491 | + | globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | |
| 1492 | + | ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |
| 1493 | + | } | |
| 1494 | + | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log)) | |
| 1495 | + | } else { | |
| 1496 | + | adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler { | |
| 1497 | + | return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 1498 | + | w.Header().Set("Content-Type", "application/json") | |
| 1499 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 1500 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"}) | |
| 1501 | + | }) | |
| 1502 | + | }) | |
| 1503 | + | } | |
| 1504 | + | ||
| 1505 | + | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |
| 1506 | + | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) | |
| 1507 | + | proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log)) | |
| 1508 | + | } | |
| 1509 | + | ||
| 1510 | + | func adminHostOnly(next http.Handler, allowedHost string, log zerowrap.Logger) http.Handler { | |
| 1511 | + | canonicalAllowedHost := canonicalHost(allowedHost) | |
| 1512 | + | ||
| 1513 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1514 | + | host := canonicalHost(r.Host) | |
| 1515 | + | if host == "" { | |
| 1516 | + | host = canonicalHost(r.URL.Host) | |
| 1517 | + | } | |
| 1518 | + | ||
| 1519 | + | if !isLoopbackHost(host) && (canonicalAllowedHost == "" || !strings.EqualFold(host, canonicalAllowedHost)) { | |
| 1520 | + | log.Warn(). | |
| 1521 | + | Str("path", r.URL.Path). | |
| 1522 | + | Str("host", r.Host). | |
| 1523 | + | Str("allowed_host", canonicalAllowedHost). | |
| 1524 | + | Msg("blocked admin request on unauthorized host") | |
| 1525 | + | http.NotFound(w, r) | |
| 1526 | + | return | |
| 1527 | + | } | |
| 1528 | + | ||
| 1529 | + | next.ServeHTTP(w, r) | |
| 1530 | + | }) | |
| 1531 | + | } | |
| 1532 | + | ||
| 1533 | + | func canonicalHost(rawHost string) string { | |
| 1534 | + | host := strings.TrimSpace(rawHost) | |
| 1535 | + | if host == "" { | |
| 1536 | + | return "" | |
| 1537 | + | } | |
| 1538 | + | ||
| 1539 | + | if splitHost, _, err := net.SplitHostPort(host); err == nil { | |
| 1540 | + | host = splitHost | |
| 1541 | + | } | |
| 1542 | + | ||
| 1543 | + | host = strings.TrimPrefix(host, "[") | |
| 1544 | + | host = strings.TrimSuffix(host, "]") | |
| 1545 | + | return strings.ToLower(host) | |
| 1546 | + | } | |
| 1547 | + | ||
| 1548 | + | func isLoopbackHost(host string) bool { | |
| 1549 | + | if host == "localhost" { | |
| 1550 | + | return true | |
| 1551 | + | } | |
| 1378 | 1552 | ||
| 1379 | - | return registryMux, proxyWithMiddleware | |
| 1553 | + | ip := net.ParseIP(host) | |
| 1554 | + | return ip != nil && ip.IsLoopback() | |
| 1380 | 1555 | } | |
| 1381 | 1556 | ||
| 1382 | 1557 | // runServers starts the HTTP servers and waits for shutdown. | |
| @@ -41,6 +41,7 @@ import ( | |||||
| 41 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | 41 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | ||
| 42 | 42 | ||||
| 43 | // Adapters - Input | 43 | // Adapters - Input | ||
| 44 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 44 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | 45 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | ||
| 45 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | 46 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | ||
| 46 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | 47 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | ||
| @@ -165,9 +166,12 @@ type services struct { | |||||
| 165 | logWriter *logwriter.LogWriter | 166 | logWriter *logwriter.LogWriter | ||
| 166 | tokenStore out.TokenStore | 167 | tokenStore out.TokenStore | ||
| 167 | configSvc *config.Service | 168 | configSvc *config.Service | ||
| 169 | + | secretSvc *secretsSvc.Service | |||
| 168 | containerSvc *container.Service | 170 | containerSvc *container.Service | ||
| 169 | backupSvc *backup.Service | 171 | backupSvc *backup.Service | ||
| 170 | registrySvc *registrySvc.Service | 172 | registrySvc *registrySvc.Service | ||
| 173 | + | healthSvc *health.Service | |||
| 174 | + | logSvc *logs.Service | |||
| 171 | imageSvc *images.Service | 175 | imageSvc *images.Service | ||
| 172 | proxySvc *proxy.Service | 176 | proxySvc *proxy.Service | ||
| 173 | authSvc *auth.Service | 177 | authSvc *auth.Service | ||
| @@ -430,7 +434,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 430 | return nil, err | 434 | return nil, err | ||
| 431 | } | 435 | } | ||
| 432 | 436 | ||||
| 433 | - | if err := setupInternalRegistryAuth(cfg, svc, log); err != nil { | 437 | + | if err := setupInternalRegistryAuth(svc, log); err != nil { |
| 434 | return nil, err | 438 | return nil, err | ||
| 435 | } | 439 | } | ||
| 436 | 440 | ||||
| @@ -453,7 +457,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 453 | return nil, err | 457 | return nil, err | ||
| 454 | } | 458 | } | ||
| 455 | 459 | ||||
| 456 | - | secretSvc := secretsSvc.NewService(domainSecretStore, log) | 460 | + | svc.secretSvc = secretsSvc.NewService(domainSecretStore, log) |
| 457 | 461 | ||||
| 458 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | 462 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | ||
| 459 | return nil, err | 463 | return nil, err | ||
| @@ -489,22 +493,30 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 489 | 493 | ||||
| 490 | // Create health service for route health checking | 494 | // Create health service for route health checking | ||
| 491 | prober := httpprober.New() | 495 | prober := httpprober.New() | ||
| 492 | - | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | 496 | + | svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log) |
| 493 | 497 | ||||
| 494 | // Create log service for accessing logs via admin API | 498 | // Create log service for accessing logs via admin API | ||
| 495 | - | logSvc := logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | 499 | + | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) |
| 496 | 500 | ||||
| 497 | // Create admin handler for admin API | 501 | // Create admin handler for admin API | ||
| 498 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc) | 502 | + | svc.adminHandler = admin.NewHandler( |
| 503 | + | svc.configSvc, | |||
| 504 | + | svc.authSvc, | |||
| 505 | + | svc.containerSvc, | |||
| 506 | + | svc.healthSvc, | |||
| 507 | + | svc.secretSvc, | |||
| 508 | + | svc.logSvc, | |||
| 509 | + | svc.registrySvc, | |||
| 510 | + | svc.eventBus, | |||
| 511 | + | log, | |||
| 512 | + | svc.backupSvc, | |||
| 513 | + | svc.imageSvc, | |||
| 514 | + | ) | |||
| 499 | 515 | ||||
| 500 | return svc, nil | 516 | return svc, nil | ||
| 501 | } | 517 | } | ||
| 502 | 518 | ||||
| 503 | - | func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error { | 519 | + | func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error { |
| 504 | - | if !cfg.Auth.Enabled { | |||
| 505 | - | return nil | |||
| 506 | - | } | |||
| 507 | - | ||||
| 508 | var err error | 520 | var err error | ||
| 509 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | 521 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | ||
| 510 | if err != nil { | 522 | if err != nil { | ||
| @@ -774,6 +786,7 @@ func GetInternalCredentials() (*InternalCredentials, error) { | |||||
| 774 | // createAuthService creates the authentication service and token store. | 786 | // createAuthService creates the authentication service and token store. | ||
| 775 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | 787 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | ||
| 776 | if !cfg.Auth.Enabled { | 788 | if !cfg.Auth.Enabled { | ||
| 789 | + | log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)") | |||
| 777 | return nil, nil, nil | 790 | return nil, nil, nil | ||
| 778 | } | 791 | } | ||
| 779 | 792 | ||||
| @@ -1084,7 +1097,10 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 1084 | containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay") | 1097 | containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay") | ||
| 1085 | } | 1098 | } | ||
| 1086 | 1099 | ||||
| 1087 | - | if cfg.Auth.Enabled && svc.authSvc != nil { | 1100 | + | if containerConfig.RegistryAuthEnabled { |
| 1101 | + | if svc.authSvc == nil { | |||
| 1102 | + | return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token") | |||
| 1103 | + | } | |||
| 1088 | expiry, err := resolveServiceTokenExpiry(cfg) | 1104 | expiry, err := resolveServiceTokenExpiry(cfg) | ||
| 1089 | if err != nil { | 1105 | if err != nil { | ||
| 1090 | return nil, log.WrapErr(err, "failed to resolve service token expiry") | 1106 | return nil, log.WrapErr(err, "failed to resolve service token expiry") | ||
| @@ -1101,6 +1117,8 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 1101 | Msg("generated service token for container registry access") | 1117 | Msg("generated service token for container registry access") | ||
| 1102 | containerConfig.ServiceTokenUsername = serviceTokenSubject | 1118 | containerConfig.ServiceTokenUsername = serviceTokenSubject | ||
| 1103 | containerConfig.ServiceToken = serviceToken | 1119 | containerConfig.ServiceToken = serviceToken | ||
| 1120 | + | } else { | |||
| 1121 | + | log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode") | |||
| 1104 | } | 1122 | } | ||
| 1105 | 1123 | ||||
| 1106 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | 1124 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | ||
| @@ -1258,6 +1276,28 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||||
| 1258 | // Start background monitor to restart crashed containers. | 1276 | // Start background monitor to restart crashed containers. | ||
| 1259 | svc.containerSvc.StartMonitor(ctx) | 1277 | svc.containerSvc.StartMonitor(ctx) | ||
| 1260 | } | 1278 | } | ||
| 1279 | + | func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |||
| 1280 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1281 | + | host, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 1282 | + | if err != nil { | |||
| 1283 | + | host = r.RemoteAddr | |||
| 1284 | + | } | |||
| 1285 | + | ||||
| 1286 | + | ip := net.ParseIP(host) | |||
| 1287 | + | if ip == nil || !ip.IsLoopback() { | |||
| 1288 | + | log.Warn(). | |||
| 1289 | + | Str("path", r.URL.Path). | |||
| 1290 | + | Str("remote_addr", r.RemoteAddr). | |||
| 1291 | + | Msg("blocked non-loopback access on internal admin route") | |||
| 1292 | + | w.Header().Set("Content-Type", "application/json") | |||
| 1293 | + | w.WriteHeader(http.StatusForbidden) | |||
| 1294 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |||
| 1295 | + | return | |||
| 1296 | + | } | |||
| 1297 | + | ||||
| 1298 | + | next.ServeHTTP(w, r) | |||
| 1299 | + | }) | |||
| 1300 | + | } | |||
| 1261 | 1301 | ||||
| 1262 | // createHTTPHandlers creates HTTP handlers with middleware. | 1302 | // createHTTPHandlers creates HTTP handlers with middleware. | ||
| 1263 | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) { | 1303 | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) { | ||
| @@ -1266,117 +1306,252 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1266 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | 1306 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | ||
| 1267 | 1307 | ||||
| 1268 | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize) | 1308 | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize) | ||
| 1309 | + | registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware( | |||
| 1310 | + | svc, | |||
| 1311 | + | cfg, | |||
| 1312 | + | trustedNets, | |||
| 1313 | + | registryHandler, | |||
| 1314 | + | log, | |||
| 1315 | + | ) | |||
| 1316 | + | ||||
| 1317 | + | registryMux := http.NewServeMux() | |||
| 1318 | + | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log) | |||
| 1319 | + | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | |||
| 1320 | + | ||||
| 1321 | + | // SECURITY: No CORS middleware on the proxy chain. Backend applications | |||
| 1322 | + | // should control their own CORS policies. A blanket Access-Control-Allow-Origin: * | |||
| 1323 | + | // would override backend CORS settings and allow any website to make | |||
| 1324 | + | // cross-origin authenticated requests to proxied applications. | |||
| 1325 | + | proxyMiddlewares := []func(http.Handler) http.Handler{ | |||
| 1326 | + | middleware.PanicRecovery(log), | |||
| 1327 | + | middleware.RequestLogger(log, trustedNets), | |||
| 1328 | + | middleware.SecurityHeaders, | |||
| 1329 | + | } | |||
| 1269 | 1330 | ||||
| 1331 | + | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | |||
| 1332 | + | proxyMux := http.NewServeMux() | |||
| 1333 | + | proxyMux.Handle("/", proxyWithMiddleware) | |||
| 1334 | + | ||||
| 1335 | + | registerAdminRoutes(registryMux, proxyMux, svc, cfg, trustedNets, log) | |||
| 1336 | + | ||||
| 1337 | + | return registryMux, proxyMux | |||
| 1338 | + | } | |||
| 1339 | + | ||||
| 1340 | + | func buildRegistryHandlerWithMiddleware( | |||
| 1341 | + | svc *services, | |||
| 1342 | + | cfg Config, | |||
| 1343 | + | trustedNets []*net.IPNet, | |||
| 1344 | + | registryHandler http.Handler, | |||
| 1345 | + | log zerowrap.Logger, | |||
| 1346 | + | ) (http.Handler, func(http.Handler) http.Handler, func(http.Handler) http.Handler) { | |||
| 1270 | registryMiddlewares := []func(http.Handler) http.Handler{ | 1347 | registryMiddlewares := []func(http.Handler) http.Handler{ | ||
| 1271 | middleware.PanicRecovery(log), | 1348 | middleware.PanicRecovery(log), | ||
| 1272 | middleware.RequestLogger(log, trustedNets), | 1349 | middleware.RequestLogger(log, trustedNets), | ||
| 1273 | middleware.SecurityHeaders, | 1350 | middleware.SecurityHeaders, | ||
| 1274 | } | 1351 | } | ||
| 1275 | 1352 | ||||
| 1276 | - | // CIDR allowlist for registry access (e.g., restrict to Tailscale IPs). | 1353 | + | cidrAllowlistMiddleware := buildRegistryCIDRAllowlistMiddleware(cfg, trustedNets, log) |
| 1277 | - | // Inserted before rate limiting so denied IPs never consume limiter tokens. | 1354 | + | if cidrAllowlistMiddleware != nil { |
| 1278 | - | var cidrAllowlistMiddleware func(http.Handler) http.Handler | 1355 | + | registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware) |
| 1279 | - | if len(cfg.Server.RegistryAllowedIPs) > 0 { | 1356 | + | } |
| 1280 | - | allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs) | 1357 | + | |
| 1281 | - | if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) { | 1358 | + | rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log) |
| 1282 | - | for _, entry := range cfg.Server.RegistryAllowedIPs { | 1359 | + | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) |
| 1283 | - | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | 1360 | + | |
| 1284 | - | log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry") | 1361 | + | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, log) |
| 1285 | - | } | 1362 | + | |
| 1363 | + | return middleware.Chain(registryMiddlewares...)(registryHandler), cidrAllowlistMiddleware, rateLimitMiddleware | |||
| 1364 | + | } | |||
| 1365 | + | ||||
| 1366 | + | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 1367 | + | if len(cfg.Server.RegistryAllowedIPs) == 0 { | |||
| 1368 | + | return nil | |||
| 1369 | + | } | |||
| 1370 | + | ||||
| 1371 | + | allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs) | |||
| 1372 | + | if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) { | |||
| 1373 | + | for _, entry := range cfg.Server.RegistryAllowedIPs { | |||
| 1374 | + | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | |||
| 1375 | + | log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry") | |||
| 1286 | } | 1376 | } | ||
| 1287 | } | 1377 | } | ||
| 1288 | - | if len(allowedNets) == 0 { | 1378 | + | } |
| 1289 | - | log.Error(). | 1379 | + | |
| 1290 | - | Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs). | 1380 | + | if len(allowedNets) == 0 { |
| 1291 | - | Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic") | 1381 | + | log.Error(). |
| 1382 | + | Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs). | |||
| 1383 | + | Msg("registry_allowed_ips is set but no valid entries were parsed; registry will deny all traffic (fail-closed)") | |||
| 1384 | + | return func(next http.Handler) http.Handler { | |||
| 1385 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1386 | + | log.Warn(). | |||
| 1387 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 1388 | + | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |||
| 1389 | + | Msg("registry access denied due to invalid registry_allowed_ips configuration") | |||
| 1390 | + | w.Header().Set("Content-Type", "application/json") | |||
| 1391 | + | w.WriteHeader(http.StatusForbidden) | |||
| 1392 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |||
| 1393 | + | }) | |||
| 1292 | } | 1394 | } | ||
| 1293 | - | cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |||
| 1294 | - | registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware) | |||
| 1295 | } | 1395 | } | ||
| 1296 | 1396 | ||||
| 1297 | - | // Create rate limiters using the factory | 1397 | + | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) |
| 1298 | - | var rateLimitMiddleware func(http.Handler) http.Handler | 1398 | + | } |
| 1399 | + | ||||
| 1400 | + | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 1299 | if cfg.API.RateLimit.Enabled { | 1401 | if cfg.API.RateLimit.Enabled { | ||
| 1300 | globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | 1402 | globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | ||
| 1301 | ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | 1403 | ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | ||
| 1302 | - | rateLimitMiddleware = registry.RateLimitMiddleware( | 1404 | + | return registry.RateLimitMiddleware( |
| 1303 | globalLimiter, | 1405 | globalLimiter, | ||
| 1304 | ipLimiter, | 1406 | ipLimiter, | ||
| 1305 | cfg.API.RateLimit.TrustedProxies, | 1407 | cfg.API.RateLimit.TrustedProxies, | ||
| 1306 | log, | 1408 | log, | ||
| 1307 | ) | 1409 | ) | ||
| 1308 | - | } else { | |||
| 1309 | - | rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log) | |||
| 1310 | } | 1410 | } | ||
| 1311 | - | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | |||
| 1312 | 1411 | ||||
| 1313 | - | if cfg.Auth.Enabled && svc.authSvc != nil { | 1412 | + | return registry.RateLimitMiddleware(nil, nil, nil, log) |
| 1413 | + | } | |||
| 1414 | + | ||||
| 1415 | + | func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, log zerowrap.Logger) { | |||
| 1416 | + | if svc.authSvc != nil { | |||
| 1314 | internalAuth := middleware.InternalRegistryAuth{ | 1417 | internalAuth := middleware.InternalRegistryAuth{ | ||
| 1315 | Username: svc.internalRegUser, | 1418 | Username: svc.internalRegUser, | ||
| 1316 | Password: svc.internalRegPass, | 1419 | Password: svc.internalRegPass, | ||
| 1317 | } | 1420 | } | ||
| 1318 | - | registryMiddlewares = append(registryMiddlewares, | 1421 | + | *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, log)) |
| 1319 | - | middleware.RegistryAuthV2(svc.authSvc, internalAuth, log)) | 1422 | + | return |
| 1320 | } | 1423 | } | ||
| 1321 | 1424 | ||||
| 1322 | - | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | 1425 | + | if cfg.Auth.Enabled { |
| 1426 | + | log.Error().Msg("authentication service unavailable; registry requests will be denied") | |||
| 1427 | + | *registryMiddlewares = append(*registryMiddlewares, func(next http.Handler) http.Handler { | |||
| 1428 | + | return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 1429 | + | w.Header().Set("Content-Type", "application/json") | |||
| 1430 | + | w.WriteHeader(http.StatusServiceUnavailable) | |||
| 1431 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"}) | |||
| 1432 | + | }) | |||
| 1433 | + | }) | |||
| 1434 | + | } | |||
| 1435 | + | } | |||
| 1323 | 1436 | ||||
| 1324 | - | // Create a mux that routes: | 1437 | + | func registerAuthRoutes( |
| 1325 | - | // - /auth/* to auth handler (no auth required, for password login and token exchange) | 1438 | + | registryMux *http.ServeMux, |
| 1326 | - | // - /v2/* to registry handler (with auth) | 1439 | + | svc *services, |
| 1327 | - | // - /admin/* to admin handler (with auth) | 1440 | + | trustedNets []*net.IPNet, |
| 1328 | - | registryMux := http.NewServeMux() | 1441 | + | cidrAllowlistMiddleware func(http.Handler) http.Handler, |
| 1329 | - | if svc.authHandler != nil { | 1442 | + | rateLimitMiddleware func(http.Handler) http.Handler, |
| 1330 | - | // Auth endpoints are NOT protected by auth - they're where clients authenticate | 1443 | + | log zerowrap.Logger, |
| 1331 | - | // but still need rate limiting to prevent brute force attacks | 1444 | + | ) { |
| 1332 | - | authMiddlewares := []func(http.Handler) http.Handler{ | 1445 | + | if svc.authHandler == nil { |
| 1333 | - | middleware.PanicRecovery(log), | 1446 | + | return |
| 1334 | - | middleware.RequestLogger(log, trustedNets), | |||
| 1335 | - | middleware.SecurityHeaders, | |||
| 1336 | - | } | |||
| 1337 | - | if cidrAllowlistMiddleware != nil { | |||
| 1338 | - | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | |||
| 1339 | - | } | |||
| 1340 | - | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | |||
| 1341 | - | authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler) | |||
| 1342 | - | registryMux.Handle("/auth/", authWithMiddleware) | |||
| 1343 | - | } | |||
| 1344 | - | registryMux.Handle("/v2/", registryWithMiddleware) | |||
| 1345 | - | ||||
| 1346 | - | // Add admin API handler with auth middleware | |||
| 1347 | - | if svc.adminHandler != nil { | |||
| 1348 | - | adminMiddlewares := []func(http.Handler) http.Handler{ | |||
| 1349 | - | middleware.PanicRecovery(log), | |||
| 1350 | - | middleware.RequestLogger(log, trustedNets), | |||
| 1351 | - | middleware.SecurityHeaders, | |||
| 1352 | - | } | |||
| 1353 | - | // Add admin auth middleware if auth is enabled | |||
| 1354 | - | if svc.authSvc != nil { | |||
| 1355 | - | // Create rate limiters for admin API - uses same config as registry | |||
| 1356 | - | var globalLimiter, ipLimiter out.RateLimiter | |||
| 1357 | - | if cfg.API.RateLimit.Enabled { | |||
| 1358 | - | globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | |||
| 1359 | - | ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |||
| 1360 | - | } | |||
| 1361 | - | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log)) | |||
| 1362 | - | } | |||
| 1363 | - | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |||
| 1364 | - | registryMux.Handle("/admin/", adminWithMiddleware) | |||
| 1365 | } | 1447 | } | ||
| 1366 | 1448 | ||||
| 1367 | - | // SECURITY: No CORS middleware on the proxy chain. Backend applications | 1449 | + | // Auth endpoints are NOT protected by auth - they're where clients authenticate |
| 1368 | - | // should control their own CORS policies. A blanket Access-Control-Allow-Origin: * | 1450 | + | // but still need rate limiting to prevent brute force attacks. |
| 1369 | - | // would override backend CORS settings and allow any website to make | 1451 | + | authMiddlewares := []func(http.Handler) http.Handler{ |
| 1370 | - | // cross-origin authenticated requests to proxied applications. | |||
| 1371 | - | proxyMiddlewares := []func(http.Handler) http.Handler{ | |||
| 1372 | middleware.PanicRecovery(log), | 1452 | middleware.PanicRecovery(log), | ||
| 1373 | middleware.RequestLogger(log, trustedNets), | 1453 | middleware.RequestLogger(log, trustedNets), | ||
| 1374 | middleware.SecurityHeaders, | 1454 | middleware.SecurityHeaders, | ||
| 1375 | } | 1455 | } | ||
| 1456 | + | if cidrAllowlistMiddleware != nil { | |||
| 1457 | + | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | |||
| 1458 | + | } | |||
| 1459 | + | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | |||
| 1460 | + | authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler) | |||
| 1461 | + | registryMux.Handle("/auth/", authWithMiddleware) | |||
| 1462 | + | } | |||
| 1376 | 1463 | ||||
| 1377 | - | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | 1464 | + | func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, log zerowrap.Logger) http.Handler { |
| 1465 | + | if !cfg.Auth.Enabled { | |||
| 1466 | + | return loopbackOnly(registryWithMiddleware, log) | |||
| 1467 | + | } | |||
| 1468 | + | return registryWithMiddleware | |||
| 1469 | + | } | |||
| 1470 | + | ||||
| 1471 | + | func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) { | |||
| 1472 | + | if svc.adminHandler == nil { | |||
| 1473 | + | return | |||
| 1474 | + | } | |||
| 1475 | + | ||||
| 1476 | + | if !cfg.Auth.Enabled { | |||
| 1477 | + | log.Warn().Msg("auth disabled: admin API endpoints are not registered") | |||
| 1478 | + | return | |||
| 1479 | + | } | |||
| 1480 | + | ||||
| 1481 | + | adminMiddlewares := []func(http.Handler) http.Handler{ | |||
| 1482 | + | middleware.PanicRecovery(log), | |||
| 1483 | + | middleware.RequestLogger(log, trustedNets), | |||
| 1484 | + | middleware.SecurityHeaders, | |||
| 1485 | + | } | |||
| 1486 | + | ||||
| 1487 | + | if svc.authSvc != nil { | |||
| 1488 | + | // Create rate limiters for admin API - uses same config as registry. | |||
| 1489 | + | var globalLimiter, ipLimiter out.RateLimiter | |||
| 1490 | + | if cfg.API.RateLimit.Enabled { | |||
| 1491 | + | globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | |||
| 1492 | + | ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |||
| 1493 | + | } | |||
| 1494 | + | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log)) | |||
| 1495 | + | } else { | |||
| 1496 | + | adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler { | |||
| 1497 | + | return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 1498 | + | w.Header().Set("Content-Type", "application/json") | |||
| 1499 | + | w.WriteHeader(http.StatusServiceUnavailable) | |||
| 1500 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"}) | |||
| 1501 | + | }) | |||
| 1502 | + | }) | |||
| 1503 | + | } | |||
| 1504 | + | ||||
| 1505 | + | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |||
| 1506 | + | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) | |||
| 1507 | + | proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log)) | |||
| 1508 | + | } | |||
| 1509 | + | ||||
| 1510 | + | func adminHostOnly(next http.Handler, allowedHost string, log zerowrap.Logger) http.Handler { | |||
| 1511 | + | canonicalAllowedHost := canonicalHost(allowedHost) | |||
| 1512 | + | ||||
| 1513 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1514 | + | host := canonicalHost(r.Host) | |||
| 1515 | + | if host == "" { | |||
| 1516 | + | host = canonicalHost(r.URL.Host) | |||
| 1517 | + | } | |||
| 1518 | + | ||||
| 1519 | + | if !isLoopbackHost(host) && (canonicalAllowedHost == "" || !strings.EqualFold(host, canonicalAllowedHost)) { | |||
| 1520 | + | log.Warn(). | |||
| 1521 | + | Str("path", r.URL.Path). | |||
| 1522 | + | Str("host", r.Host). | |||
| 1523 | + | Str("allowed_host", canonicalAllowedHost). | |||
| 1524 | + | Msg("blocked admin request on unauthorized host") | |||
| 1525 | + | http.NotFound(w, r) | |||
| 1526 | + | return | |||
| 1527 | + | } | |||
| 1528 | + | ||||
| 1529 | + | next.ServeHTTP(w, r) | |||
| 1530 | + | }) | |||
| 1531 | + | } | |||
| 1532 | + | ||||
| 1533 | + | func canonicalHost(rawHost string) string { | |||
| 1534 | + | host := strings.TrimSpace(rawHost) | |||
| 1535 | + | if host == "" { | |||
| 1536 | + | return "" | |||
| 1537 | + | } | |||
| 1538 | + | ||||
| 1539 | + | if splitHost, _, err := net.SplitHostPort(host); err == nil { | |||
| 1540 | + | host = splitHost | |||
| 1541 | + | } | |||
| 1542 | + | ||||
| 1543 | + | host = strings.TrimPrefix(host, "[") | |||
| 1544 | + | host = strings.TrimSuffix(host, "]") | |||
| 1545 | + | return strings.ToLower(host) | |||
| 1546 | + | } | |||
| 1547 | + | ||||
| 1548 | + | func isLoopbackHost(host string) bool { | |||
| 1549 | + | if host == "localhost" { | |||
| 1550 | + | return true | |||
| 1551 | + | } | |||
| 1378 | 1552 | ||||
| 1379 | - | return registryMux, proxyWithMiddleware | 1553 | + | ip := net.ParseIP(host) |
| 1554 | + | return ip != nil && ip.IsLoopback() | |||
| 1380 | } | 1555 | } | ||
| 1381 | 1556 | ||||
| 1382 | // runServers starts the HTTP servers and waits for shutdown. | 1557 | // runServers starts the HTTP servers and waits for shutdown. | ||
A internal/app/run_auth_config_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | |
| 12 | + | t.Setenv(TokenSecretEnvVar, "test-token-secret") | |
| 13 | + | ||
| 14 | + | cfg := Config{} | |
| 15 | + | cfg.Auth.Enabled = false | |
| 16 | + | cfg.Auth.Type = "token" | |
| 17 | + | ||
| 18 | + | authCfg, err := buildAuthConfig(context.Background(), cfg, domain.AuthTypeToken, domain.SecretsBackendUnsafe, t.TempDir(), zerowrap.Default()) | |
| 19 | + | if err != nil { | |
| 20 | + | t.Fatalf("expected no error, got %v", err) | |
| 21 | + | } | |
| 22 | + | if authCfg.Enabled { | |
| 23 | + | t.Fatalf("expected auth config enabled=false, got true") | |
| 24 | + | } | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | |||
| 12 | + | t.Setenv(TokenSecretEnvVar, "test-token-secret") | |||
| 13 | + | ||||
| 14 | + | cfg := Config{} | |||
| 15 | + | cfg.Auth.Enabled = false | |||
| 16 | + | cfg.Auth.Type = "token" | |||
| 17 | + | ||||
| 18 | + | authCfg, err := buildAuthConfig(context.Background(), cfg, domain.AuthTypeToken, domain.SecretsBackendUnsafe, t.TempDir(), zerowrap.Default()) | |||
| 19 | + | if err != nil { | |||
| 20 | + | t.Fatalf("expected no error, got %v", err) | |||
| 21 | + | } | |||
| 22 | + | if authCfg.Enabled { | |||
| 23 | + | t.Fatalf("expected auth config enabled=false, got true") | |||
| 24 | + | } | |||
| 25 | + | } | |||
A internal/app/run_http_handlers_test.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | |
| 14 | + | t.Parallel() | |
| 15 | + | ||
| 16 | + | cfg := Config{} | |
| 17 | + | cfg.Auth.Enabled = false | |
| 18 | + | ||
| 19 | + | store, authSvc, err := createAuthService(context.Background(), cfg, zerowrap.Default()) | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatalf("expected no error when auth is disabled, got %v", err) | |
| 22 | + | } | |
| 23 | + | if store != nil { | |
| 24 | + | t.Fatalf("expected nil token store when auth is disabled") | |
| 25 | + | } | |
| 26 | + | if authSvc != nil { | |
| 27 | + | t.Fatalf("expected nil auth service when auth is disabled") | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) { | |
| 32 | + | t.Parallel() | |
| 33 | + | ||
| 34 | + | cfg := Config{} | |
| 35 | + | cfg.Auth.Enabled = false | |
| 36 | + | ||
| 37 | + | svc := &services{adminHandler: &adminhttp.Handler{}} | |
| 38 | + | registryHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 39 | + | ||
| 40 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 41 | + | req.RemoteAddr = "192.0.2.10:12345" | |
| 42 | + | ||
| 43 | + | rec := httptest.NewRecorder() | |
| 44 | + | registryHandler.ServeHTTP(rec, req) | |
| 45 | + | ||
| 46 | + | if rec.Code != http.StatusNotFound { | |
| 47 | + | t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) { | |
| 52 | + | t.Parallel() | |
| 53 | + | ||
| 54 | + | cfg := Config{} | |
| 55 | + | cfg.Auth.Enabled = false | |
| 56 | + | ||
| 57 | + | registryHandler, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default()) | |
| 58 | + | ||
| 59 | + | req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) | |
| 60 | + | req.RemoteAddr = "192.0.2.20:12345" | |
| 61 | + | ||
| 62 | + | rec := httptest.NewRecorder() | |
| 63 | + | registryHandler.ServeHTTP(rec, req) | |
| 64 | + | ||
| 65 | + | if rec.Code != http.StatusForbidden { | |
| 66 | + | t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestCreateHTTPHandlers_AuthEnabled_ExposesAdminOnProxyForRegistryHost(t *testing.T) { | |
| 71 | + | t.Parallel() | |
| 72 | + | ||
| 73 | + | cfg := Config{} | |
| 74 | + | cfg.Auth.Enabled = true | |
| 75 | + | cfg.Server.RegistryDomain = "gordon.local" | |
| 76 | + | ||
| 77 | + | svc := &services{adminHandler: &adminhttp.Handler{}} | |
| 78 | + | _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 79 | + | // services uses an adminHandler stub with authSvc left nil. In createHTTPHandlers, | |
| 80 | + | // registerAdminRoutes installs fail-closed middleware for that case, so admin | |
| 81 | + | // requests on the allowed host return 503 ServiceUnavailable. | |
| 82 | + | ||
| 83 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 84 | + | req.Host = "gordon.local" | |
| 85 | + | req.RemoteAddr = "192.0.2.30:12345" | |
| 86 | + | ||
| 87 | + | rec := httptest.NewRecorder() | |
| 88 | + | proxyHandler.ServeHTTP(rec, req) | |
| 89 | + | ||
| 90 | + | if rec.Code != http.StatusServiceUnavailable { | |
| 91 | + | t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestCreateHTTPHandlers_AuthEnabled_BlocksAdminOnNonRegistryHost(t *testing.T) { | |
| 96 | + | t.Parallel() | |
| 97 | + | ||
| 98 | + | cfg := Config{} | |
| 99 | + | cfg.Auth.Enabled = true | |
| 100 | + | cfg.Server.RegistryDomain = "gordon.local" | |
| 101 | + | ||
| 102 | + | svc := &services{adminHandler: &adminhttp.Handler{}} | |
| 103 | + | _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 104 | + | ||
| 105 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 106 | + | req.Host = "app.example.com" | |
| 107 | + | req.RemoteAddr = "192.0.2.30:12345" | |
| 108 | + | ||
| 109 | + | rec := httptest.NewRecorder() | |
| 110 | + | proxyHandler.ServeHTTP(rec, req) | |
| 111 | + | ||
| 112 | + | if rec.Code != http.StatusNotFound { | |
| 113 | + | t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) { | |
| 118 | + | t.Parallel() | |
| 119 | + | ||
| 120 | + | cfg := Config{} | |
| 121 | + | cfg.Server.RegistryAllowedIPs = []string{"not-a-cidr"} | |
| 122 | + | ||
| 123 | + | mw := buildRegistryCIDRAllowlistMiddleware(cfg, nil, zerowrap.Default()) | |
| 124 | + | if mw == nil { | |
| 125 | + | t.Fatal("expected non-nil allowlist middleware") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 129 | + | w.WriteHeader(http.StatusOK) | |
| 130 | + | })) | |
| 131 | + | ||
| 132 | + | req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) | |
| 133 | + | req.RemoteAddr = "127.0.0.1:12345" | |
| 134 | + | ||
| 135 | + | rec := httptest.NewRecorder() | |
| 136 | + | handler.ServeHTTP(rec, req) | |
| 137 | + | ||
| 138 | + | if rec.Code != http.StatusForbidden { | |
| 139 | + | t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) | |
| 140 | + | } | |
| 141 | + | } | |
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | |||
| 14 | + | t.Parallel() | |||
| 15 | + | ||||
| 16 | + | cfg := Config{} | |||
| 17 | + | cfg.Auth.Enabled = false | |||
| 18 | + | ||||
| 19 | + | store, authSvc, err := createAuthService(context.Background(), cfg, zerowrap.Default()) | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatalf("expected no error when auth is disabled, got %v", err) | |||
| 22 | + | } | |||
| 23 | + | if store != nil { | |||
| 24 | + | t.Fatalf("expected nil token store when auth is disabled") | |||
| 25 | + | } | |||
| 26 | + | if authSvc != nil { | |||
| 27 | + | t.Fatalf("expected nil auth service when auth is disabled") | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) { | |||
| 32 | + | t.Parallel() | |||
| 33 | + | ||||
| 34 | + | cfg := Config{} | |||
| 35 | + | cfg.Auth.Enabled = false | |||
| 36 | + | ||||
| 37 | + | svc := &services{adminHandler: &adminhttp.Handler{}} | |||
| 38 | + | registryHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 39 | + | ||||
| 40 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 41 | + | req.RemoteAddr = "192.0.2.10:12345" | |||
| 42 | + | ||||
| 43 | + | rec := httptest.NewRecorder() | |||
| 44 | + | registryHandler.ServeHTTP(rec, req) | |||
| 45 | + | ||||
| 46 | + | if rec.Code != http.StatusNotFound { | |||
| 47 | + | t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) { | |||
| 52 | + | t.Parallel() | |||
| 53 | + | ||||
| 54 | + | cfg := Config{} | |||
| 55 | + | cfg.Auth.Enabled = false | |||
| 56 | + | ||||
| 57 | + | registryHandler, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default()) | |||
| 58 | + | ||||
| 59 | + | req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) | |||
| 60 | + | req.RemoteAddr = "192.0.2.20:12345" | |||
| 61 | + | ||||
| 62 | + | rec := httptest.NewRecorder() | |||
| 63 | + | registryHandler.ServeHTTP(rec, req) | |||
| 64 | + | ||||
| 65 | + | if rec.Code != http.StatusForbidden { | |||
| 66 | + | t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestCreateHTTPHandlers_AuthEnabled_ExposesAdminOnProxyForRegistryHost(t *testing.T) { | |||
| 71 | + | t.Parallel() | |||
| 72 | + | ||||
| 73 | + | cfg := Config{} | |||
| 74 | + | cfg.Auth.Enabled = true | |||
| 75 | + | cfg.Server.RegistryDomain = "gordon.local" | |||
| 76 | + | ||||
| 77 | + | svc := &services{adminHandler: &adminhttp.Handler{}} | |||
| 78 | + | _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 79 | + | // services uses an adminHandler stub with authSvc left nil. In createHTTPHandlers, | |||
| 80 | + | // registerAdminRoutes installs fail-closed middleware for that case, so admin | |||
| 81 | + | // requests on the allowed host return 503 ServiceUnavailable. | |||
| 82 | + | ||||
| 83 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 84 | + | req.Host = "gordon.local" | |||
| 85 | + | req.RemoteAddr = "192.0.2.30:12345" | |||
| 86 | + | ||||
| 87 | + | rec := httptest.NewRecorder() | |||
| 88 | + | proxyHandler.ServeHTTP(rec, req) | |||
| 89 | + | ||||
| 90 | + | if rec.Code != http.StatusServiceUnavailable { | |||
| 91 | + | t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestCreateHTTPHandlers_AuthEnabled_BlocksAdminOnNonRegistryHost(t *testing.T) { | |||
| 96 | + | t.Parallel() | |||
| 97 | + | ||||
| 98 | + | cfg := Config{} | |||
| 99 | + | cfg.Auth.Enabled = true | |||
| 100 | + | cfg.Server.RegistryDomain = "gordon.local" | |||
| 101 | + | ||||
| 102 | + | svc := &services{adminHandler: &adminhttp.Handler{}} | |||
| 103 | + | _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 104 | + | ||||
| 105 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 106 | + | req.Host = "app.example.com" | |||
| 107 | + | req.RemoteAddr = "192.0.2.30:12345" | |||
| 108 | + | ||||
| 109 | + | rec := httptest.NewRecorder() | |||
| 110 | + | proxyHandler.ServeHTTP(rec, req) | |||
| 111 | + | ||||
| 112 | + | if rec.Code != http.StatusNotFound { | |||
| 113 | + | t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) { | |||
| 118 | + | t.Parallel() | |||
| 119 | + | ||||
| 120 | + | cfg := Config{} | |||
| 121 | + | cfg.Server.RegistryAllowedIPs = []string{"not-a-cidr"} | |||
| 122 | + | ||||
| 123 | + | mw := buildRegistryCIDRAllowlistMiddleware(cfg, nil, zerowrap.Default()) | |||
| 124 | + | if mw == nil { | |||
| 125 | + | t.Fatal("expected non-nil allowlist middleware") | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 129 | + | w.WriteHeader(http.StatusOK) | |||
| 130 | + | })) | |||
| 131 | + | ||||
| 132 | + | req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) | |||
| 133 | + | req.RemoteAddr = "127.0.0.1:12345" | |||
| 134 | + | ||||
| 135 | + | rec := httptest.NewRecorder() | |||
| 136 | + | handler.ServeHTTP(rec, req) | |||
| 137 | + | ||||
| 138 | + | if rec.Code != http.StatusForbidden { | |||
| 139 | + | t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) | |||
| 140 | + | } | |||
| 141 | + | } | |||
M wiki/guides/remote-cli.md +2 -0
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Manage Gordon instances remotely using the CLI with the `--remote` flag or saved remotes. | |
| 4 | 4 | ||
| 5 | + | By default (without `--remote`), management commands execute locally through in-process services. | |
| 6 | + | ||
| 5 | 7 | ## What You'll Learn | |
| 6 | 8 | ||
| 7 | 9 | - Using global `--remote` and `--token` flags | |
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | Manage Gordon instances remotely using the CLI with the `--remote` flag or saved remotes. | 3 | Manage Gordon instances remotely using the CLI with the `--remote` flag or saved remotes. | ||
| 4 | 4 | ||||
| 5 | + | By default (without `--remote`), management commands execute locally through in-process services. | |||
| 6 | + | ||||
| 5 | ## What You'll Learn | 7 | ## What You'll Learn | ||
| 6 | 8 | ||||
| 7 | - Using global `--remote` and `--token` flags | 9 | - Using global `--remote` and `--token` flags | ||