detached 3038498

Merge pull request #69 from bnema/sec/auth-proxy-critical-fixes

refactor(cli): local control plane parity + security hardening
commit
303849893b2936038aca0c192d65608e5132199b
author
bnema · · 2026-02-09 06:56 UTC
committer
GitHub · · 2026-02-09 06:56 UTC
merge of
c808592 + 7123d45 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
35 files · +1814 -258
M docs/cli/backup.md +13 -10
@@ -2,10 +2,10 @@
22
33 Manage backups for database attachments.
44
5-## gordon backup
5+## gordon backups
66
77 ```bash
8-gordon backup <subcommand>
8+gordon backups <subcommand>
99 ```
1010
1111 Subcommands:
@@ -15,9 +15,12 @@ Subcommands:
1515 - `detect <domain>` - Detect supported databases for a domain
1616 - `status` - Show backup status across domains
1717
18-> **Note:** Backup commands currently require remote mode (`--remote` + `--token`, or configured remotes).
18+> Backup commands work both locally and remotely.
1919 >
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>`.
2124
2225 In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags.
2326
@@ -25,22 +28,22 @@ In normal usage, configure remotes once (`gordon remotes ...`) and run backup co
2528
2629 ```bash
2730 # List all backups
28-gordon backup list
31+gordon backups list
2932
3033 # List backups for one domain
31-gordon backup list app.example.com
34+gordon backups list app.example.com
3235
3336 # Trigger backup (auto-detect when exactly one DB attachment exists)
34-gordon backup run app.example.com
37+gordon backups run app.example.com
3538
3639 # Trigger backup for specific attachment name
37-gordon backup run app.example.com --db postgres
40+gordon backups run app.example.com --db postgres
3841
3942 # Detect DB attachments
40-gordon backup detect app.example.com
43+gordon backups detect app.example.com
4144
4245 # Status summary
43-gordon backup status
46+gordon backups status
4447 ```
4548
4649 ## Output Columns
@@ -2,10 +2,10 @@
2 2
3 Manage backups for database attachments.3 Manage backups for database attachments.
4 4
5-## gordon backup5+## gordon backups
6 6
7 ```bash7 ```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 domain15 - `detect <domain>` - Detect supported databases for a domain
16 - `status` - Show backup status across domains16 - `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 ```bash29 ```bash
27 # List all backups30 # List all backups
28-gordon backup list31+gordon backups list
29 32
30 # List backups for one domain33 # List backups for one domain
31-gordon backup list app.example.com34+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.com37+gordon backups run app.example.com
35 38
36 # Trigger backup for specific attachment name39 # Trigger backup for specific attachment name
37-gordon backup run app.example.com --db postgres40+gordon backups run app.example.com --db postgres
38 41
39 # Detect DB attachments42 # Detect DB attachments
40-gordon backup detect app.example.com43+gordon backups detect app.example.com
41 44
42 # Status summary45 # Status summary
43-gordon backup status46+gordon backups status
44 ```47 ```
45 48
46 ## Output Columns49 ## Output Columns
M docs/cli/index.md +7 -5
@@ -17,6 +17,8 @@ Commands are organized by where they run:
1717
1818 ## Management Commands (local or remote)
1919
20+Management commands run locally through in-process services by default. Add `--remote` to target another Gordon instance.
21+
2022 | Command | Description | Documentation |
2123 |---------|-------------|---------------|
2224 | `gordon routes` | Manage routes | [routes](./routes.md) |
@@ -29,7 +31,7 @@ Commands are organized by where they run:
2931 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
3032 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
3133 | `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) |
3335 | `gordon images` | List and prune images | [images](./images.md) |
3436
3537 ## Client Commands
@@ -76,10 +78,10 @@ gordon logs myapp.local -f # Follow container logs
7678 gordon version
7779
7880 # 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
8385
8486 # Images
8587 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 Commands37 ## Client Commands
@@ -76,10 +78,10 @@ gordon logs myapp.local -f # Follow container logs
76 gordon version78 gordon version
77 79
78 # Backups80 # Backups
79-gordon backup list81+gordon backups list
80-gordon backup run app.example.com82+gordon backups run app.example.com
81-gordon backup detect app.example.com83+gordon backups detect app.example.com
82-gordon backup status84+gordon backups status
83 85
84 # Images86 # Images
85 gordon images list87 gordon images list
M docs/cli/status.md +11 -4
@@ -7,10 +7,14 @@ Show Gordon server status and container health.
77 Display server configuration and container status for all routes.
88
99 ```bash
10+gordon status
1011 gordon status --remote https://gordon.mydomain.com --token $TOKEN
1112 ```
1213
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.
1418
1519 ### Output
1620
@@ -70,9 +74,12 @@ The status command uses global flags for remote access:
7074
7175 ## Examples
7276
73-### Check Remote Server Status
77+### Check Local or Remote Status
7478
7579 ```bash
80+# Local
81+gordon status
82+
7683 # Using flags
7784 gordon status --remote https://gordon.mydomain.com --token $TOKEN
7885
@@ -89,9 +96,9 @@ gordon status
8996 gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)"
9097 ```
9198
92-## Required Permissions
99+## Required Permissions (Remote Only)
93100
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.
95102
96103 ```bash
97104 # 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 ```bash9 ```bash
10+gordon status
10 gordon status --remote https://gordon.mydomain.com --token $TOKEN11 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 ### Output19 ### Output
16 20
@@ -70,9 +74,12 @@ The status command uses global flags for remote access:
70 74
71 ## Examples75 ## Examples
72 76
73-### Check Remote Server Status77+### Check Local or Remote Status
74 78
75 ```bash79 ```bash
80+# Local
81+gordon status
82+
76 # Using flags83 # Using flags
77 gordon status --remote https://gordon.mydomain.com --token $TOKEN84 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 Permissions99+## 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 ```bash103 ```bash
97 # Generate token with required scope104 # 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
1010
1111 ```toml
1212 [auth]
13-enabled = true
1413 secrets_backend = "pass"
1514 token_secret = "gordon/auth/token_secret"
1615 token_expiry = "30d"
@@ -20,7 +19,6 @@ token_expiry = "30d"
2019
2120 ```toml
2221 [auth]
23-enabled = true
2422 secrets_backend = "pass"
2523 username = "deploy"
2624 password_hash = "gordon/auth/password_hash"
@@ -33,7 +31,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive
3331
3432 | Option | Type | Default | Description |
3533 |--------|------|---------|-------------|
36-| `enabled` | bool | `true` | Enable authentication |
34+| `enabled` | bool | `true` | Authentication toggle. `false` enables local-only mode (see below) |
3735 | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |
3836 | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend |
3937 | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |
@@ -154,14 +152,15 @@ gordon auth token generate --subject temp --expiry 30d
154152 - Tokens from one Gordon instance won't work on another
155153 - Changing `token_secret` invalidates all existing tokens
156154
157-## Disable Auth (development only)
155+## Local-only Mode
158156
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.
163162
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.
165164
166165 ## Internal Registry Auth
167166
@@ -10,7 +10,6 @@ Authentication is enabled by default. Gordon will not start without a valid `tok
10 10
11 ```toml11 ```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 ```toml20 ```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 another152 - Tokens from one Gordon instance won't work on another
155 - Changing `token_secret` invalidates all existing tokens153 - Changing `token_secret` invalidates all existing tokens
156 154
157-## Disable Auth (development only)155+## Local-only Mode
158 156
159-```toml157+Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode:
160-[auth]158+
161-enabled = false159+- `/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 Auth165 ## Internal Registry Auth
167 166
M docs/config/backups.md +1 -1
@@ -6,7 +6,7 @@ Configure automatic PostgreSQL logical backups for attachment containers.
66
77 When backups are enabled, Gordon detects PostgreSQL attachments for each route and can:
88
9-- Run on-demand backups via `gordon backup run`
9+- Run on-demand backups via `gordon backups run`
1010 - Store backup artifacts on the Gordon host filesystem
1111 - Apply retention policies per schedule tier
1212
@@ -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 filesystem10 - Store backup artifacts on the Gordon host filesystem
11 - Apply retention policies per schedule tier11 - Apply retention policies per schedule tier
12 12
M docs/config/index.md +2 -0
@@ -195,6 +195,8 @@ keep_last = 3
195195 | `images.prune.schedule` | `"daily"` |
196196 | `images.prune.keep_last` | `3` |
197197
198+When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.
199+
198200 ## Hot Reload
199201
200202 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 Reload200 ## 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
168168 | `server.data_dir` | `~/.gordon` | Data directory |
169169 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
170170 | `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) |
172172 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
173173 | `auth.token_expiry` | `"720h"` | 30 days |
174174 | `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:
127127
128128 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).
129129
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+
130134 > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility.
131135
132136 ## 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 Directory136 ## Data Directory
M docs/upgrading.md +1 -1
@@ -121,7 +121,7 @@ password_hash = "gordon/auth/password_hash"
121121
122122 ### Breaking: Auth Enabled by Default
123123
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).
125125
126126 ### Breaking: Secret Paths Changed
127127
@@ -121,7 +121,7 @@ password_hash = "gordon/auth/password_hash"
121 121
122 ### Breaking: Auth Enabled by Default122 ### 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 Changed126 ### Breaking: Secret Paths Changed
127 127
M internal/adapters/in/cli/backup.go +26 -20
@@ -12,11 +12,13 @@ import (
1212 // newBackupCmd creates the backup command group.
1313 func newBackupCmd() *cobra.Command {
1414 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.
1819
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.`,
2022 }
2123
2224 cmd.AddCommand(newBackupListCmd())
@@ -33,17 +35,18 @@ func newBackupListCmd() *cobra.Command {
3335 Short: "List backups",
3436 Args: cobra.MaximumNArgs(1),
3537 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
3941 }
42+ defer handle.close()
4043
4144 domainName := ""
4245 if len(args) == 1 {
4346 domainName = args[0]
4447 }
4548
46- jobs, err := client.ListBackups(cmd.Context(), domainName)
49+ jobs, err := handle.plane.ListBackups(cmd.Context(), domainName)
4750 if err != nil {
4851 return fmt.Errorf("failed to list backups: %w", err)
4952 }
@@ -79,12 +82,13 @@ func newBackupRunCmd() *cobra.Command {
7982 Short: "Run backup now",
8083 Args: cobra.ExactArgs(1),
8184 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
8588 }
89+ defer handle.close()
8690
87- result, err := client.RunBackup(cmd.Context(), args[0], dbName)
91+ result, err := handle.plane.RunBackup(cmd.Context(), args[0], dbName)
8892 if err != nil {
8993 return fmt.Errorf("failed to run backup: %w", err)
9094 }
@@ -117,12 +121,13 @@ func newBackupDetectCmd() *cobra.Command {
117121 Short: "Detect databases for domain",
118122 Args: cobra.ExactArgs(1),
119123 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
123127 }
128+ defer handle.close()
124129
125- dbs, err := client.DetectDatabases(cmd.Context(), args[0])
130+ dbs, err := handle.plane.DetectDatabases(cmd.Context(), args[0])
126131 if err != nil {
127132 return fmt.Errorf("failed to detect databases: %w", err)
128133 }
@@ -155,12 +160,13 @@ func newBackupStatusCmd() *cobra.Command {
155160 Use: "status",
156161 Short: "Show backup status",
157162 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
161166 }
167+ defer handle.close()
162168
163- jobs, err := client.BackupStatus(cmd.Context())
169+ jobs, err := handle.plane.BackupStatus(cmd.Context())
164170 if err != nil {
165171 return fmt.Errorf("failed to get backup status: %w", err)
166172 }
@@ -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
7575 }
7676
7777 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
8181 }
82+ defer handle.close()
8283
83- dockerfile, err := resolveDockerfile(dockerfile, build)
84+ dockerfile, err = resolveDockerfile(dockerfile, build)
8485 if err != nil {
8586 return err
8687 }
8788
88- route, err := client.GetRoute(ctx, pushDomain)
89+ route, err := handle.plane.GetRoute(ctx, pushDomain)
8990 if err != nil {
9091 return fmt.Errorf("failed to get route: %w", err)
9192 }
@@ -128,7 +129,7 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s
128129 fmt.Println(styles.RenderSuccess("Push complete"))
129130
130131 if !noDeploy {
131- return deployAfterPush(ctx, client, pushDomain, noConfirm)
132+ return deployAfterPush(ctx, handle.plane, pushDomain, noConfirm)
132133 }
133134
134135 return nil
@@ -226,7 +227,7 @@ func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, l
226227 return nil
227228 }
228229
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 {
230231 if !noConfirm {
231232 confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
232233 if err != nil {
@@ -237,7 +238,15 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri
237238 }
238239 }
239240
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+ }
241250 if err != nil {
242251 return fmt.Errorf("failed to deploy: %w", err)
243252 }
@@ -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 err86 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 nil135 return nil
@@ -226,7 +227,7 @@ func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, l
226 return nil227 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:
2828 ctx := cmd.Context()
2929 restartDomain := args[0]
3030
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
4234 }
35+ defer handle.close()
4336
44- result, err := client.Restart(ctx, restartDomain, withAttachments)
37+ result, err := handle.plane.Restart(ctx, restartDomain, withAttachments)
4538 if err != nil {
46- if !withAttachments && shouldFallbackToLocal(err) {
39+ if handle.isRemote && !withAttachments && shouldFallbackToLocal(err) {
4740 domain, localErr := app.SendDeploySignal(restartDomain)
4841 if localErr == nil {
4942 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 (
1010 "github.com/spf13/cobra"
1111 "golang.org/x/mod/semver"
1212
13- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1413 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1514 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
1615 "github.com/bnema/gordon/internal/domain"
@@ -40,12 +39,13 @@ Examples:
4039 }
4140
4241 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
4645 }
46+ defer handle.close()
4747
48- route, err := client.GetRoute(ctx, rollbackDomain)
48+ route, err := handle.plane.GetRoute(ctx, rollbackDomain)
4949 if err != nil {
5050 return fmt.Errorf("failed to get route: %w", err)
5151 }
@@ -55,7 +55,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
5555 return fmt.Errorf("cannot parse image name from route: %s", route.Image)
5656 }
5757
58- tags, err := fetchAndSortTags(ctx, client, imageName)
58+ tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
5959 if err != nil {
6060 return err
6161 }
@@ -70,11 +70,11 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
7070 return nil
7171 }
7272
73- return deploySelectedTag(ctx, client, route, rollbackDomain, imageName, selectedTag)
73+ return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
7474 }
7575
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)
7878 if err != nil {
7979 return nil, fmt.Errorf("failed to list tags: %w", err)
8080 }
@@ -154,22 +154,22 @@ func validateTagExists(targetTag string, tags []string) bool {
154154 return false
155155 }
156156
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 {
158158 registry, _, _ := parseImageRef(route.Image)
159159 oldImage := route.Image
160160 route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
161161
162162 fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag))
163163
164- if err := client.UpdateRoute(ctx, *route); err != nil {
164+ if err := cp.UpdateRoute(ctx, *route); err != nil {
165165 return fmt.Errorf("failed to update route: %w", err)
166166 }
167167
168- result, err := client.Deploy(ctx, rollbackDomain)
168+ result, err := cp.Deploy(ctx, rollbackDomain)
169169 if err != nil {
170170 // Attempt to revert route to previous image
171171 route.Image = oldImage
172- if revertErr := client.UpdateRoute(ctx, *route); revertErr != nil {
172+ if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
173173 fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
174174 return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)
175175 }
@@ -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 err60 return err
61 }61 }
@@ -70,11 +70,11 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
70 return nil70 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 false154 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.Image159 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 image170 // Attempt to revert route to previous image
171 route.Image = oldImage171 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
33 import (
44 "context"
55 "fmt"
6- "os"
76 "strings"
87
98 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -375,13 +374,13 @@ func newStatusCmd() *cobra.Command {
375374 RunE: func(cmd *cobra.Command, args []string) error {
376375 ctx := context.Background()
377376
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
382380 }
381+ defer handle.close()
383382
384- status, err := client.GetStatus(ctx)
383+ status, err := handle.plane.GetStatus(ctx)
385384 if err != nil {
386385 return fmt.Errorf("failed to get status: %w", err)
387386 }
@@ -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(
5151 }
5252 }
5353
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.
6056 if !authSvc.IsEnabled() {
61- log.Warn().
57+ log.Error().
6258 Str("path", r.URL.Path).
6359 Str("method", r.Method).
6460 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")
6763 return
6864 }
6965
@@ -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 intentionally55+ // 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 return63 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) {
7878
7979 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
8080 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)
8286
8387 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
8488 w.WriteHeader(http.StatusOK)
@@ -90,6 +94,7 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
9094 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
9195 req.RemoteAddr = "127.0.0.1:12345"
9296 req.Header.Set("X-Forwarded-For", "203.0.113.50")
97+ req.Header.Set("Authorization", "Bearer valid-admin-token")
9398 rec := httptest.NewRecorder()
9499 wrappedHandler.ServeHTTP(rec, req)
95100
@@ -99,8 +104,12 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
99104 func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
100105 authSvc := inmocks.NewMockAuthService(t)
101106
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)
104113
105114 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
106115 w.WriteHeader(http.StatusOK)
@@ -110,6 +119,7 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
110119 wrappedHandler := mw(handler)
111120
112121 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
122+ req.Header.Set("Authorization", "Bearer valid-admin-token")
113123 rec := httptest.NewRecorder()
114124 wrappedHandler.ServeHTTP(rec, req)
115125
@@ -139,8 +149,8 @@ func TestAuthMiddleware_AuthDisabled(t *testing.T) {
139149 rec := httptest.NewRecorder()
140150 wrappedHandler.ServeHTTP(rec, req)
141151
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)
144154 }
145155
146156 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 limiters107+ // 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 @@
22 package auth
33
44 import (
5+ "context"
56 "crypto/subtle"
67 "encoding/json"
78 "net/http"
@@ -50,6 +51,13 @@ type PasswordResponse struct {
5051 IssuedAt string `json:"issued_at"`
5152 }
5253
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+
5361 // ServeHTTP routes requests to the appropriate handler.
5462 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
5563 path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
@@ -67,9 +75,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6775 }
6876
6977 // 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.
7179 // Only works when auth type is "password" - returns error for "token" auth type.
7280 func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
81+ setNoStoreHeaders(w)
82+
7383 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
7484 zerowrap.FieldLayer: "adapter",
7585 zerowrap.FieldAdapter: "http",
@@ -89,8 +99,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
8999 // Check if auth is enabled
90100 if h.authSvc == nil || !h.authSvc.IsEnabled() {
91101 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"})
94104 return
95105 }
96106
@@ -132,8 +142,9 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
132142 return
133143 }
134144
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
137148 scopes := []string{"push", "pull", "admin:*:*"}
138149
139150 token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
@@ -168,6 +179,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
168179 // This is the Docker Registry v2 token server endpoint.
169180 // Issues short-lived access tokens for registry access.
170181 func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
182+ setNoStoreHeaders(w)
183+
171184 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
172185 zerowrap.FieldLayer: "adapter",
173186 zerowrap.FieldAdapter: "http",
@@ -186,38 +199,23 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
186199
187200 // Check if auth is enabled
188201 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"})
191205 return
192206 }
193207
194208 // Get credentials from Basic Auth
195209 username, password, ok := r.BasicAuth()
196210 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"})
199215 return
200216 }
201217
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)
221219 if !authenticated {
222220 log.Debug().
223221 Str("username", username).
@@ -231,6 +229,15 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
231229
232230 // Parse requested scopes from query parameter
233231 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+ }
234241
235242 // Generate a short-lived access token (5 minutes) - not stored
236243 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) {
261268 Msg("access token issued")
262269 }
263270
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
271275 }
272276
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+ }
275286
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+ }
278348 }
279349
280- log.Debug().Msg("anonymous token issued")
350+ return false
281351 }
282352
283353 // isLocalhostRequest checks if the request originates from localhost.
@@ -2,6 +2,7 @@
2 package auth2 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 enabled99 // 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 return104 return
95 }105 }
96 106
@@ -132,8 +142,9 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
132 return142 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.Hour146+ // 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 enabled200 // 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 token202+ 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 return205 return
192 }206 }
193 207
194 // Get credentials from Basic Auth208 // 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 scope211+ 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 return215 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 parameter230 // 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 stored242 // 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 scope273+ 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) {
2626 authSvc.EXPECT().IsEnabled().Return(true)
2727 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
2828 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).
3030 Return("jwt-token-here", nil)
3131
3232 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -44,7 +44,7 @@ func TestHandler_Password_Success(t *testing.T) {
4444 err := json.NewDecoder(rec.Body).Decode(&resp)
4545 assert.NoError(t, err)
4646 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)
4848 assert.NotEmpty(t, resp.IssuedAt)
4949 }
5050
@@ -101,8 +101,8 @@ func TestHandler_Password_AuthDisabled(t *testing.T) {
101101
102102 handler.ServeHTTP(rec, req)
103103
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")
106106 }
107107
108108 func TestHandler_Password_MissingFields(t *testing.T) {
@@ -179,7 +179,7 @@ func TestHandler_Token_Success(t *testing.T) {
179179 assert.Equal(t, float64(300), resp["expires_in"])
180180 }
181181
182-func TestHandler_Token_Anonymous(t *testing.T) {
182+func TestHandler_Token_MissingCredentials(t *testing.T) {
183183 authSvc := mocks.NewMockAuthService(t)
184184
185185 authSvc.EXPECT().IsEnabled().Return(true)
@@ -191,13 +191,7 @@ func TestHandler_Token_Anonymous(t *testing.T) {
191191
192192 handler.ServeHTTP(rec, req)
193193
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)
201195 }
202196
203197 func TestHandler_Token_AuthDisabled(t *testing.T) {
@@ -212,13 +206,8 @@ func TestHandler_Token_AuthDisabled(t *testing.T) {
212206
213207 handler.ServeHTTP(rec, req)
214208
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")
222211 }
223212
224213 func TestHandler_Token_InvalidCredentials(t *testing.T) {
@@ -274,6 +263,7 @@ func TestHandler_Token_TokenAuth(t *testing.T) {
274263 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
275264 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
276265 Subject: "myuser",
266+ Scopes: []string{"repository:*:pull"},
277267 }, nil)
278268 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).
279269 Return("short-lived-token", nil)
@@ -294,6 +284,55 @@ func TestHandler_Token_TokenAuth(t *testing.T) {
294284 assert.Equal(t, "short-lived-token", resp["token"])
295285 }
296286
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+
297336 func TestHandler_Token_MethodNotAllowed(t *testing.T) {
298337 handler := NewHandler(nil, InternalAuth{}, testLogger())
299338
@@ -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 seconds47+ 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 disabled209+ 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 (
66 "strings"
77 )
88
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+
927 // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet.
1028 // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8").
1129 // 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 {
6078 if err != nil {
6179 remoteIP = r.RemoteAddr
6280 }
81+ if normalized := normalizeIP(remoteIP); normalized != "" {
82+ remoteIP = normalized
83+ }
6384
6485 // Only honor proxy headers if the request comes from a trusted proxy
6586 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.
6791 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]
71108 }
72- return strings.TrimSpace(xff)
73109 }
74110
75111 // Check X-Real-IP
76112 if xri := r.Header.Get("X-Real-IP"); xri != "" {
77- return xri
113+ if ip := normalizeIP(xri); ip != "" {
114+ return ip
115+ }
78116 }
79117 }
80118
@@ -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.RemoteAddr79 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 proxy85 // 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 requests87+ // 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-IP111 // 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 xri113+ 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) {
5858 {
5959 name: "XFF honored from trusted proxy (multiple IPs)",
6060 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",
6269 trustedNets: trustedNets,
6370 wantIP: "203.0.113.50",
6471 },
@@ -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) {
266266 wantIP: "203.0.113.50",
267267 },
268268 {
269- name: "XFF honored from trusted proxy (multiple IPs)",
269+ name: "XFF returns first untrusted IP from right",
270270 remoteAddr: "127.0.0.1:12345",
271271 xff: "203.0.113.50, 10.0.0.1, 172.16.0.1",
272272 trustedNets: trustedNets,
273- wantIP: "203.0.113.50",
273+ wantIP: "172.16.0.1",
274274 },
275275 {
276276 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 (
4141 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
4242
4343 // Adapters - Input
44+ "github.com/bnema/gordon/internal/adapters/dto"
4445 "github.com/bnema/gordon/internal/adapters/in/http/admin"
4546 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
4647 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
@@ -165,9 +166,12 @@ type services struct {
165166 logWriter *logwriter.LogWriter
166167 tokenStore out.TokenStore
167168 configSvc *config.Service
169+ secretSvc *secretsSvc.Service
168170 containerSvc *container.Service
169171 backupSvc *backup.Service
170172 registrySvc *registrySvc.Service
173+ healthSvc *health.Service
174+ logSvc *logs.Service
171175 imageSvc *images.Service
172176 proxySvc *proxy.Service
173177 authSvc *auth.Service
@@ -430,7 +434,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
430434 return nil, err
431435 }
432436
433- if err := setupInternalRegistryAuth(cfg, svc, log); err != nil {
437+ if err := setupInternalRegistryAuth(svc, log); err != nil {
434438 return nil, err
435439 }
436440
@@ -453,7 +457,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
453457 return nil, err
454458 }
455459
456- secretSvc := secretsSvc.NewService(domainSecretStore, log)
460+ svc.secretSvc = secretsSvc.NewService(domainSecretStore, log)
457461
458462 if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
459463 return nil, err
@@ -489,22 +493,30 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
489493
490494 // Create health service for route health checking
491495 prober := httpprober.New()
492- healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
496+ svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log)
493497
494498 // 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)
496500
497501 // 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+ )
499515
500516 return svc, nil
501517 }
502518
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 {
508520 var err error
509521 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
510522 if err != nil {
@@ -774,6 +786,7 @@ func GetInternalCredentials() (*InternalCredentials, error) {
774786 // createAuthService creates the authentication service and token store.
775787 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
776788 if !cfg.Auth.Enabled {
789+ log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)")
777790 return nil, nil, nil
778791 }
779792
@@ -1084,7 +1097,10 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
10841097 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
10851098 }
10861099
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+ }
10881104 expiry, err := resolveServiceTokenExpiry(cfg)
10891105 if err != nil {
10901106 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
11011117 Msg("generated service token for container registry access")
11021118 containerConfig.ServiceTokenUsername = serviceTokenSubject
11031119 containerConfig.ServiceToken = serviceToken
1120+ } else {
1121+ log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")
11041122 }
11051123
11061124 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) {
12581276 // Start background monitor to restart crashed containers.
12591277 svc.containerSvc.StartMonitor(ctx)
12601278 }
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+}
12611301
12621302 // createHTTPHandlers creates HTTP handlers with middleware.
12631303 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
12661306 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
12671307
12681308 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+ }
12691330
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) {
12701347 registryMiddlewares := []func(http.Handler) http.Handler{
12711348 middleware.PanicRecovery(log),
12721349 middleware.RequestLogger(log, trustedNets),
12731350 middleware.SecurityHeaders,
12741351 }
12751352
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(&registryMiddlewares, 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")
12861376 }
12871377 }
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+ })
12921394 }
1293- cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1294- registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
12951395 }
12961396
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 {
12991401 if cfg.API.RateLimit.Enabled {
13001402 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
13011403 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1302- rateLimitMiddleware = registry.RateLimitMiddleware(
1404+ return registry.RateLimitMiddleware(
13031405 globalLimiter,
13041406 ipLimiter,
13051407 cfg.API.RateLimit.TrustedProxies,
13061408 log,
13071409 )
1308- } else {
1309- rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
13101410 }
1311- registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
13121411
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 {
13141417 internalAuth := middleware.InternalRegistryAuth{
13151418 Username: svc.internalRegUser,
13161419 Password: svc.internalRegPass,
13171420 }
1318- registryMiddlewares = append(registryMiddlewares,
1319- middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1421+ *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1422+ return
13201423 }
13211424
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+}
13231436
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
13651447 }
13661448
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{
13721452 middleware.PanicRecovery(log),
13731453 middleware.RequestLogger(log, trustedNets),
13741454 middleware.SecurityHeaders,
13751455 }
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+}
13761463
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+ }
13781552
1379- return registryMux, proxyWithMiddleware
1553+ ip := net.ParseIP(host)
1554+ return ip != nil && ip.IsLoopback()
13801555 }
13811556
13821557 // 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 - Input43 // 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.LogWriter166 logWriter *logwriter.LogWriter
166 tokenStore out.TokenStore167 tokenStore out.TokenStore
167 configSvc *config.Service168 configSvc *config.Service
169+ secretSvc *secretsSvc.Service
168 containerSvc *container.Service170 containerSvc *container.Service
169 backupSvc *backup.Service171 backupSvc *backup.Service
170 registrySvc *registrySvc.Service172 registrySvc *registrySvc.Service
173+ healthSvc *health.Service
174+ logSvc *logs.Service
171 imageSvc *images.Service175 imageSvc *images.Service
172 proxySvc *proxy.Service176 proxySvc *proxy.Service
173 authSvc *auth.Service177 authSvc *auth.Service
@@ -430,7 +434,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
430 return nil, err434 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, err438 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, err457 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, err463 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 checking494 // 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 API498 // 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 API501 // 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, nil516 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 error520 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, nil790 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 = serviceTokenSubject1118 containerConfig.ServiceTokenUsername = serviceTokenSubject
1103 containerConfig.ServiceToken = serviceToken1119 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), nil1124 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.Handler1355+ 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(&registryMiddlewares, 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 factory1397+ return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1298- var rateLimitMiddleware func(http.Handler) http.Handler1398+}
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 authenticate1443+ log zerowrap.Logger,
1331- // but still need rate limiting to prevent brute force attacks1444+) {
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 applications1449+ // 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 make1451+ 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, proxyWithMiddleware1553+ 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 @@
22
33 Manage Gordon instances remotely using the CLI with the `--remote` flag or saved remotes.
44
5+By default (without `--remote`), management commands execute locally through in-process services.
6+
57 ## What You'll Learn
68
79 - 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 Learn7 ## What You'll Learn
6 8
7 - Using global `--remote` and `--token` flags9 - Using global `--remote` and `--token` flags

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close