Merge pull request #42 from bnema/fix/plan-secrets-registry-auth-token
Add pass-backed secrets, registry token auth, and CLI token login
30 files · +2992 -415
M .goreleaser.yaml +4 -0
| @@ -3,8 +3,12 @@ version: 2 | |||
| 3 | 3 | before: | |
| 4 | 4 | hooks: | |
| 5 | 5 | ||
| 6 | + | git: | |
| 7 | + | prerelease_suffix: "-" | |
| 8 | + | ||
| 6 | 9 | release: | |
| 7 | 10 | draft: true | |
| 11 | + | prerelease: auto | |
| 8 | 12 | ||
| 9 | 13 | builds: | |
| 10 | 14 | - id: "gordon" | |
| @@ -3,8 +3,12 @@ version: 2 | |||||
| 3 | before: | 3 | before: | ||
| 4 | hooks: | 4 | hooks: | ||
| 5 | 5 | ||||
| 6 | + | git: | |||
| 7 | + | prerelease_suffix: "-" | |||
| 8 | + | ||||
| 6 | release: | 9 | release: | ||
| 7 | draft: true | 10 | draft: true | ||
| 11 | + | prerelease: auto | |||
| 8 | 12 | ||||
| 9 | builds: | 13 | builds: | ||
| 10 | - id: "gordon" | 14 | - id: "gordon" | ||
M docs/cli/auth.md +13 -3
| @@ -19,7 +19,7 @@ Manage Gordon server authentication tokens and passwords. | |||
| 19 | 19 | ||
| 20 | 20 | ## gordon auth login | |
| 21 | 21 | ||
| 22 | - | Authenticate with a remote Gordon server using password authentication. | |
| 22 | + | Authenticate with a remote Gordon server using password authentication or a pre-generated token. | |
| 23 | 23 | ||
| 24 | 24 | ```bash | |
| 25 | 25 | gordon auth login [options] | |
| @@ -31,14 +31,18 @@ gordon auth login [options] | |||
| 31 | 31 | |--------|-------------| | |
| 32 | 32 | | `-r, --remote` | Remote to authenticate with (defaults to active remote) | | |
| 33 | 33 | | `-u, --username` | Username for authentication | | |
| 34 | + | | `-p, --password` | Password for authentication | | |
| 35 | + | | `-t, --token` | Authentication token to store for the remote | | |
| 34 | 36 | ||
| 35 | 37 | ### Description | |
| 36 | 38 | ||
| 37 | 39 | This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token in your remotes configuration. | |
| 38 | 40 | ||
| 41 | + | If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis. | |
| 42 | + | ||
| 39 | 43 | The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote. | |
| 40 | 44 | ||
| 41 | - | > **Note:** This command only works with servers that have password authentication configured (`username` + `password_hash`). For token-only servers, use `gordon remotes set-token` instead. | |
| 45 | + | > **Note:** For token-only servers, you can pass `--token` here or use `gordon remotes set-token`. | |
| 42 | 46 | ||
| 43 | 47 | ### Examples | |
| 44 | 48 | ||
| @@ -51,6 +55,9 @@ gordon auth login --remote prod | |||
| 51 | 55 | ||
| 52 | 56 | # Pre-fill username | |
| 53 | 57 | gordon auth login --username admin | |
| 58 | + | ||
| 59 | + | # Store a pre-generated token | |
| 60 | + | gordon auth login --token <token> | |
| 54 | 61 | ``` | |
| 55 | 62 | ||
| 56 | 63 | ### Output | |
| @@ -212,7 +219,9 @@ Display the auto-generated internal registry credentials. | |||
| 212 | 219 | gordon auth internal | |
| 213 | 220 | ``` | |
| 214 | 221 | ||
| 215 | - | Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues. | |
| 222 | + | Gordon generates temporary credentials for loopback-only communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues. | |
| 223 | + | ||
| 224 | + | When registry auth is enabled, Gordon also generates a separate service token for its own registry-domain pulls. That token is not exposed via the CLI. | |
| 216 | 225 | ||
| 217 | 226 | ### Output | |
| 218 | 227 | ||
| @@ -231,6 +240,7 @@ Usage: | |||
| 231 | 240 | - Credentials are regenerated each time Gordon starts | |
| 232 | 241 | - Only available while Gordon is running | |
| 233 | 242 | - Used for manual `docker pull` from the local registry during debugging | |
| 243 | + | - Separate from the service token used by Gordon core for registry-domain pulls | |
| 234 | 244 | ||
| 235 | 245 | ### Use Cases | |
| 236 | 246 | ||
| @@ -19,7 +19,7 @@ Manage Gordon server authentication tokens and passwords. | |||||
| 19 | 19 | ||||
| 20 | ## gordon auth login | 20 | ## gordon auth login | ||
| 21 | 21 | ||||
| 22 | - | Authenticate with a remote Gordon server using password authentication. | 22 | + | Authenticate with a remote Gordon server using password authentication or a pre-generated token. |
| 23 | 23 | ||||
| 24 | ```bash | 24 | ```bash | ||
| 25 | gordon auth login [options] | 25 | gordon auth login [options] | ||
| @@ -31,14 +31,18 @@ gordon auth login [options] | |||||
| 31 | |--------|-------------| | 31 | |--------|-------------| | ||
| 32 | | `-r, --remote` | Remote to authenticate with (defaults to active remote) | | 32 | | `-r, --remote` | Remote to authenticate with (defaults to active remote) | | ||
| 33 | | `-u, --username` | Username for authentication | | 33 | | `-u, --username` | Username for authentication | | ||
| 34 | + | | `-p, --password` | Password for authentication | | |||
| 35 | + | | `-t, --token` | Authentication token to store for the remote | | |||
| 34 | 36 | ||||
| 35 | ### Description | 37 | ### Description | ||
| 36 | 38 | ||||
| 37 | This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token in your remotes configuration. | 39 | This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token in your remotes configuration. | ||
| 38 | 40 | ||||
| 41 | + | If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis. | |||
| 42 | + | ||||
| 39 | The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote. | 43 | The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote. | ||
| 40 | 44 | ||||
| 41 | - | > **Note:** This command only works with servers that have password authentication configured (`username` + `password_hash`). For token-only servers, use `gordon remotes set-token` instead. | 45 | + | > **Note:** For token-only servers, you can pass `--token` here or use `gordon remotes set-token`. |
| 42 | 46 | ||||
| 43 | ### Examples | 47 | ### Examples | ||
| 44 | 48 | ||||
| @@ -51,6 +55,9 @@ gordon auth login --remote prod | |||||
| 51 | 55 | ||||
| 52 | # Pre-fill username | 56 | # Pre-fill username | ||
| 53 | gordon auth login --username admin | 57 | gordon auth login --username admin | ||
| 58 | + | ||||
| 59 | + | # Store a pre-generated token | |||
| 60 | + | gordon auth login --token <token> | |||
| 54 | ``` | 61 | ``` | ||
| 55 | 62 | ||||
| 56 | ### Output | 63 | ### Output | ||
| @@ -212,7 +219,9 @@ Display the auto-generated internal registry credentials. | |||||
| 212 | gordon auth internal | 219 | gordon auth internal | ||
| 213 | ``` | 220 | ``` | ||
| 214 | 221 | ||||
| 215 | - | Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues. | 222 | + | Gordon generates temporary credentials for loopback-only communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues. |
| 223 | + | ||||
| 224 | + | When registry auth is enabled, Gordon also generates a separate service token for its own registry-domain pulls. That token is not exposed via the CLI. | |||
| 216 | 225 | ||||
| 217 | ### Output | 226 | ### Output | ||
| 218 | 227 | ||||
| @@ -231,6 +240,7 @@ Usage: | |||||
| 231 | - Credentials are regenerated each time Gordon starts | 240 | - Credentials are regenerated each time Gordon starts | ||
| 232 | - Only available while Gordon is running | 241 | - Only available while Gordon is running | ||
| 233 | - Used for manual `docker pull` from the local registry during debugging | 242 | - Used for manual `docker pull` from the local registry during debugging | ||
| 243 | + | - Separate from the service token used by Gordon core for registry-domain pulls | |||
| 234 | 244 | ||||
| 235 | ### Use Cases | 245 | ### Use Cases | ||
| 236 | 246 | ||||
M docs/cli/secrets.md +4 -0
| @@ -2,6 +2,10 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Manage secrets on local or remote Gordon instances. | |
| 4 | 4 | ||
| 5 | + | Storage depends on the secrets backend: | |
| 6 | + | - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>` | |
| 7 | + | - `sops` or `unsafe`: secrets are stored in domain `.env` files | |
| 8 | + | ||
| 5 | 9 | ## gordon secrets | |
| 6 | 10 | ||
| 7 | 11 | ### Subcommands | |
| @@ -2,6 +2,10 @@ | |||||
| 2 | 2 | ||||
| 3 | Manage secrets on local or remote Gordon instances. | 3 | Manage secrets on local or remote Gordon instances. | ||
| 4 | 4 | ||||
| 5 | + | Storage depends on the secrets backend: | |||
| 6 | + | - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>` | |||
| 7 | + | - `sops` or `unsafe`: secrets are stored in domain `.env` files | |||
| 8 | + | ||||
| 5 | ## gordon secrets | 9 | ## gordon secrets | ||
| 6 | 10 | ||||
| 7 | ### Subcommands | 11 | ### Subcommands | ||
M docs/config/auth.md +45 -190
| @@ -2,47 +2,33 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Configure authentication for the Gordon registry and Admin API. | |
| 4 | 4 | ||
| 5 | - | ## Quick Start | |
| 5 | + | Authentication is enabled by default. Gordon will not start without a valid `token_secret` (or `GORDON_AUTH_TOKEN_SECRET`). | |
| 6 | 6 | ||
| 7 | - | Authentication is **enabled by default** for security. Gordon will not start without authentication configured. | |
| 7 | + | ## Pick a Mode | |
| 8 | 8 | ||
| 9 | - | **Option 1: Password Authentication (simplest)** | |
| 10 | - | ```bash | |
| 11 | - | # 1. Generate password hash | |
| 12 | - | gordon auth password hash | |
| 13 | - | # Enter your password, copy the bcrypt hash | |
| 9 | + | ### Token-only (recommended for CI/CD) | |
| 14 | 10 | ||
| 15 | - | # 2. Save hash to secrets file | |
| 16 | - | mkdir -p ~/.gordon/secrets | |
| 17 | - | echo '$2a$10$...' > ~/.gordon/secrets/password_hash | |
| 18 | - | ||
| 19 | - | # 3. Add to config.toml | |
| 20 | - | cat >> ~/.gordon/config.toml << 'EOF' | |
| 21 | - | [auth] | |
| 22 | - | password_hash = "password_hash" | |
| 23 | - | EOF | |
| 24 | - | ``` | |
| 25 | - | ||
| 26 | - | **Option 2: Disable Authentication (development only)** | |
| 27 | 11 | ```toml | |
| 28 | 12 | [auth] | |
| 29 | - | enabled = false | |
| 13 | + | enabled = true | |
| 14 | + | secrets_backend = "pass" | |
| 15 | + | token_secret = "gordon/auth/token_secret" | |
| 16 | + | token_expiry = "30d" | |
| 30 | 17 | ``` | |
| 31 | 18 | ||
| 32 | - | ## Configuration | |
| 19 | + | ### Password + Token (interactive + CI/CD) | |
| 33 | 20 | ||
| 34 | 21 | ```toml | |
| 35 | 22 | [auth] | |
| 36 | 23 | enabled = true | |
| 37 | 24 | secrets_backend = "pass" | |
| 38 | - | token_secret = "gordon/auth/token_secret" | |
| 39 | - | token_expiry = "30d" | |
| 40 | - | ||
| 41 | - | # Optional: enable password authentication | |
| 42 | 25 | username = "deploy" | |
| 43 | 26 | password_hash = "gordon/auth/password_hash" | |
| 27 | + | token_secret = "gordon/auth/token_secret" | |
| 44 | 28 | ``` | |
| 45 | 29 | ||
| 30 | + | Token auth always works. Adding `username` + `password_hash` enables interactive login. | |
| 31 | + | ||
| 46 | 32 | ## Options | |
| 47 | 33 | ||
| 48 | 34 | | Option | Type | Default | Description | | |
| @@ -54,59 +40,31 @@ password_hash = "gordon/auth/password_hash" | |||
| 54 | 40 | | `username` | string | - | Username for password auth (optional) | | |
| 55 | 41 | | `password_hash` | string | - | Path to bcrypt hash in secrets backend (optional) | | |
| 56 | 42 | ||
| 57 | - | ## Authentication Modes | |
| 58 | - | ||
| 59 | - | Gordon infers the authentication mode from your configuration: | |
| 43 | + | ## Secrets Backends | |
| 60 | 44 | ||
| 61 | - | | Config | Mode | Description | | |
| 62 | - | |--------|------|-------------| | |
| 63 | - | | Only `token_secret` | Token-only | JWT tokens for all access | | |
| 64 | - | | `token_secret` + `username` + `password_hash` | Password + Token | Password for interactive login, tokens for CI/CD | | |
| 45 | + | `token_secret` and `password_hash` are read through the configured backend: | |
| 65 | 46 | ||
| 66 | - | Tokens always work regardless of mode. The difference is whether password authentication is available. | |
| 67 | - | ||
| 68 | - | ## Environment Variables | |
| 47 | + | | Backend | Description | | |
| 48 | + | | --------- | ------------- | | |
| 49 | + | | `pass` | Unix password manager (GPG-encrypted) | | |
| 50 | + | | `sops` | Mozilla SOPS encrypted files | | |
| 51 | + | | `unsafe` | Plain text files (development only) | | |
| 69 | 52 | ||
| 70 | - | Gordon supports environment variables for sensitive configuration: | |
| 53 | + | See [Secret Providers](./secrets.md) for setup details. | |
| 71 | 54 | ||
| 72 | - | | Variable | Description | | |
| 73 | - | |----------|-------------| | |
| 74 | - | | `GORDON_AUTH_TOKEN_SECRET` | JWT signing secret (takes priority over config file) | | |
| 55 | + | ## Environment Variable | |
| 75 | 56 | ||
| 76 | - | Using environment variables is recommended for production as it avoids storing secrets on disk: | |
| 57 | + | `GORDON_AUTH_TOKEN_SECRET` overrides `token_secret` in the config. If you don’t set the env var, `token_secret` is loaded from the configured secrets backend (including `pass`). | |
| 77 | 58 | ||
| 78 | 59 | ```bash | |
| 79 | 60 | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | |
| 80 | 61 | gordon serve | |
| 81 | 62 | ``` | |
| 82 | 63 | ||
| 83 | - | ## Secrets Backend | |
| 84 | - | ||
| 85 | - | The `secrets_backend` option determines how Gordon retrieves sensitive values like `token_secret` and `password_hash`: | |
| 86 | - | ||
| 87 | - | | Backend | Description | | |
| 88 | - | |---------|-------------| | |
| 89 | - | | `pass` | Unix password manager (GPG-encrypted) | | |
| 90 | - | | `sops` | Mozilla SOPS encrypted files | | |
| 91 | - | | `unsafe` | Plain text files (development only) | | |
| 92 | - | ||
| 93 | - | See [Secret Providers](./secrets.md) for detailed configuration of each backend. | |
| 94 | - | ||
| 95 | - | ## Authentication Types | |
| 64 | + | ## Setup | |
| 96 | 65 | ||
| 97 | - | ### Token-Only Mode | |
| 98 | - | ||
| 99 | - | JWT-based authentication, ideal for CI/CD pipelines when you don't need interactive login: | |
| 100 | - | ||
| 101 | - | ```toml | |
| 102 | - | [auth] | |
| 103 | - | enabled = true | |
| 104 | - | secrets_backend = "pass" | |
| 105 | - | token_secret = "gordon/auth/token_secret" | |
| 106 | - | token_expiry = "30d" | |
| 107 | - | ``` | |
| 66 | + | ### Token-only setup | |
| 108 | 67 | ||
| 109 | - | **Setup:** | |
| 110 | 68 | ```bash | |
| 111 | 69 | # Create token secret (random 32+ characters) | |
| 112 | 70 | pass insert gordon/auth/token_secret | |
| @@ -115,93 +73,51 @@ pass insert gordon/auth/token_secret | |||
| 115 | 73 | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |
| 116 | 74 | ``` | |
| 117 | 75 | ||
| 118 | - | **Usage:** | |
| 119 | 76 | ```bash | |
| 120 | 77 | # Docker login with token | |
| 121 | 78 | docker login -u ci-bot -p <token> registry.mydomain.com | |
| 122 | 79 | ``` | |
| 123 | 80 | ||
| 124 | - | **Token Management:** | |
| 125 | - | ```bash | |
| 126 | - | # List all tokens | |
| 127 | - | gordon auth token list | |
| 128 | - | ||
| 129 | - | # Revoke a specific token | |
| 130 | - | gordon auth token revoke <token-id> | |
| 131 | - | ||
| 132 | - | # Revoke all tokens | |
| 133 | - | gordon auth token revoke --all | |
| 134 | - | ``` | |
| 81 | + | ### Password + token setup | |
| 135 | 82 | ||
| 136 | - | ### Password + Token Mode | |
| 137 | - | ||
| 138 | - | Password authentication for interactive login, plus tokens for CI/CD: | |
| 139 | - | ||
| 140 | - | ```toml | |
| 141 | - | [auth] | |
| 142 | - | enabled = true | |
| 143 | - | secrets_backend = "pass" | |
| 144 | - | username = "deploy" | |
| 145 | - | password_hash = "gordon/auth/password_hash" | |
| 146 | - | token_secret = "gordon/auth/token_secret" | |
| 147 | - | ``` | |
| 148 | - | ||
| 149 | - | When `username` and `password_hash` are configured, both password and token authentication work. | |
| 150 | - | ||
| 151 | - | **Setup:** | |
| 152 | 83 | ```bash | |
| 153 | 84 | # Generate bcrypt hash | |
| 154 | 85 | gordon auth password hash | |
| 155 | - | # Enter password when prompted | |
| 156 | 86 | ||
| 157 | - | # Store hash in secrets backend | |
| 87 | + | # Store hash and token secret | |
| 158 | 88 | pass insert gordon/auth/password_hash | |
| 159 | - | # Paste the bcrypt hash | |
| 160 | - | ||
| 161 | - | # Create token secret (for CI/CD token generation) | |
| 162 | 89 | pass insert gordon/auth/token_secret | |
| 163 | - | # Enter a random 32+ character string | |
| 164 | 90 | ``` | |
| 165 | 91 | ||
| 166 | - | **Usage:** | |
| 167 | 92 | ```bash | |
| 168 | 93 | # Docker registry access | |
| 169 | 94 | docker login -u deploy -p <password> registry.mydomain.com | |
| 170 | 95 | ||
| 171 | - | # Remote CLI authentication | |
| 96 | + | # Remote CLI login (stores a token for you) | |
| 172 | 97 | gordon auth login --remote prod | |
| 173 | - | # Enter username and password when prompted | |
| 174 | 98 | ``` | |
| 175 | 99 | ||
| 176 | - | **Remote CLI Authentication:** | |
| 100 | + | ## Remote CLI Tokens | |
| 177 | 101 | ||
| 178 | - | With password authentication enabled, you can use `gordon auth login` to obtain a token for remote CLI operations: | |
| 102 | + | If you already have a token (token-only servers), store it directly: | |
| 179 | 103 | ||
| 180 | 104 | ```bash | |
| 181 | - | gordon auth login --remote prod | |
| 182 | - | # Username: deploy | |
| 183 | - | # Password: ******** | |
| 184 | - | # ✓ Authentication successful! | |
| 185 | - | # Token stored for remote 'prod' | |
| 105 | + | gordon auth login --token <token> | |
| 106 | + | # or | |
| 107 | + | gordon remotes set-token prod <token> | |
| 186 | 108 | ``` | |
| 187 | 109 | ||
| 188 | - | The returned token is stored automatically and used for subsequent CLI commands. | |
| 189 | - | ||
| 190 | 110 | ## Token Scopes | |
| 191 | 111 | ||
| 192 | - | Tokens can have different permission levels: | |
| 193 | - | ||
| 194 | - | ### Registry Scopes | |
| 112 | + | Registry scopes: | |
| 195 | 113 | ||
| 196 | 114 | | Scope | Permission | | |
| 197 | - | |-------|------------| | |
| 115 | + | | ------- | ------------ | | |
| 198 | 116 | | `push` | Push images to registry | | |
| 199 | 117 | | `pull` | Pull images from registry | | |
| 200 | 118 | | `push,pull` | Both push and pull (default) | | |
| 201 | 119 | ||
| 202 | - | ### Admin Scopes | |
| 203 | - | ||
| 204 | - | For remote CLI access via the Admin API: | |
| 120 | + | Admin scopes (for remote CLI): | |
| 205 | 121 | ||
| 206 | 122 | | Scope | Permission | | |
| 207 | 123 | |-------|------------| | |
| @@ -214,106 +130,45 @@ For remote CLI access via the Admin API: | |||
| 214 | 130 | | `admin:secrets:read` | List secret keys | | |
| 215 | 131 | | `admin:secrets:write` | Set/delete secrets | | |
| 216 | 132 | ||
| 217 | - | **Examples:** | |
| 133 | + | Examples: | |
| 134 | + | ||
| 218 | 135 | ```bash | |
| 219 | - | # Pull-only token (for read-only access) | |
| 220 | 136 | gordon auth token generate --subject reader --scopes pull --expiry 30d | |
| 221 | - | ||
| 222 | - | # Push-only token (for CI builds) | |
| 223 | 137 | gordon auth token generate --subject builder --scopes push --expiry 0 | |
| 224 | - | ||
| 225 | - | # Full admin access (for remote CLI) | |
| 226 | 138 | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | |
| 227 | 139 | ``` | |
| 228 | 140 | ||
| 229 | 141 | ## Token Expiry | |
| 230 | 142 | ||
| 231 | - | Control how long tokens remain valid. Supports human-friendly units: | |
| 232 | - | ||
| 233 | - | | Unit | Description | | |
| 234 | - | |------|-------------| | |
| 235 | - | | `d` | days (24 hours) | | |
| 236 | - | | `w` | weeks (7 days) | | |
| 237 | - | | `M` | months (30 days) | | |
| 238 | - | | `y` | years (365 days) | | |
| 239 | - | ||
| 240 | - | Compound durations work too: `1y6M`, `2w3d` | |
| 143 | + | Durations support `d`, `w`, `M`, `y` and combinations like `1y6M` or `2w3d`. | |
| 241 | 144 | ||
| 242 | 145 | ```bash | |
| 243 | - | # Never expires (for CI/CD) | |
| 244 | 146 | gordon auth token generate --subject ci --expiry 0 | |
| 245 | - | ||
| 246 | - | # Expires in 1 year | |
| 247 | 147 | gordon auth token generate --subject deploy --expiry 1y | |
| 248 | - | ||
| 249 | - | # Expires in 30 days | |
| 250 | 148 | gordon auth token generate --subject temp --expiry 30d | |
| 251 | 149 | ``` | |
| 252 | 150 | ||
| 253 | - | ## Instance-Specific Tokens | |
| 254 | - | ||
| 255 | - | Tokens are signed with the `token_secret`, which is unique to each Gordon instance: | |
| 151 | + | ## Instance-specific Tokens | |
| 256 | 152 | ||
| 153 | + | - Tokens are signed with `token_secret` | |
| 257 | 154 | - Tokens from one Gordon instance won't work on another | |
| 258 | - | - Changing the `token_secret` invalidates all existing tokens | |
| 259 | - | - Each environment (dev, staging, prod) should have different secrets | |
| 155 | + | - Changing `token_secret` invalidates all existing tokens | |
| 260 | 156 | ||
| 261 | - | ## Development Setup | |
| 262 | - | ||
| 263 | - | For local development without authentication: | |
| 157 | + | ## Disable Auth (development only) | |
| 264 | 158 | ||
| 265 | 159 | ```toml | |
| 266 | 160 | [auth] | |
| 267 | 161 | enabled = false | |
| 268 | 162 | ``` | |
| 269 | 163 | ||
| 270 | - | ## Examples | |
| 271 | - | ||
| 272 | - | ### CI/CD Setup (Token-Only) | |
| 273 | - | ||
| 274 | - | ```toml | |
| 275 | - | [auth] | |
| 276 | - | enabled = true | |
| 277 | - | secrets_backend = "pass" | |
| 278 | - | token_secret = "gordon/auth/token_secret" | |
| 279 | - | ``` | |
| 280 | - | ||
| 281 | - | ```bash | |
| 282 | - | # Generate CI token | |
| 283 | - | gordon auth token generate --subject github-actions --scopes push,pull --expiry 0 | |
| 284 | - | ``` | |
| 285 | - | ||
| 286 | - | In GitHub Actions: | |
| 287 | - | ```yaml | |
| 288 | - | - name: Login to Gordon Registry | |
| 289 | - | run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }} | |
| 290 | - | ``` | |
| 291 | - | ||
| 292 | - | ### Production with Password + Token | |
| 293 | - | ||
| 294 | - | ```toml | |
| 295 | - | [auth] | |
| 296 | - | enabled = true | |
| 297 | - | secrets_backend = "pass" | |
| 298 | - | username = "deploy" | |
| 299 | - | password_hash = "gordon/auth/password_hash" | |
| 300 | - | token_secret = "gordon/auth/token_secret" | |
| 301 | - | ``` | |
| 302 | - | ||
| 303 | - | ### Enterprise with SOPS | |
| 304 | - | ||
| 305 | - | ```toml | |
| 306 | - | [auth] | |
| 307 | - | enabled = true | |
| 308 | - | secrets_backend = "sops" | |
| 309 | - | token_secret = "gordon/auth/token_secret" | |
| 310 | - | ``` | |
| 311 | - | ||
| 312 | 164 | ## Internal Registry Auth | |
| 313 | 165 | ||
| 314 | - | Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart. | |
| 166 | + | Gordon generates internal credentials when auth is enabled. These are used for loopback pulls during deploys and are regenerated on each restart. | |
| 167 | + | ||
| 168 | + | Gordon also generates a separate service token for its own registry-domain pulls. This token is managed internally and is not exposed via configuration or CLI. | |
| 315 | 169 | ||
| 316 | 170 | To view internal credentials (for troubleshooting): | |
| 171 | + | ||
| 317 | 172 | ```bash | |
| 318 | 173 | gordon auth internal | |
| 319 | 174 | ``` | |
| @@ -2,47 +2,33 @@ | |||||
| 2 | 2 | ||||
| 3 | Configure authentication for the Gordon registry and Admin API. | 3 | Configure authentication for the Gordon registry and Admin API. | ||
| 4 | 4 | ||||
| 5 | - | ## Quick Start | 5 | + | Authentication is enabled by default. Gordon will not start without a valid `token_secret` (or `GORDON_AUTH_TOKEN_SECRET`). |
| 6 | 6 | ||||
| 7 | - | Authentication is **enabled by default** for security. Gordon will not start without authentication configured. | 7 | + | ## Pick a Mode |
| 8 | 8 | ||||
| 9 | - | **Option 1: Password Authentication (simplest)** | 9 | + | ### Token-only (recommended for CI/CD) |
| 10 | - | ```bash | |||
| 11 | - | # 1. Generate password hash | |||
| 12 | - | gordon auth password hash | |||
| 13 | - | # Enter your password, copy the bcrypt hash | |||
| 14 | 10 | ||||
| 15 | - | # 2. Save hash to secrets file | |||
| 16 | - | mkdir -p ~/.gordon/secrets | |||
| 17 | - | echo '$2a$10$...' > ~/.gordon/secrets/password_hash | |||
| 18 | - | ||||
| 19 | - | # 3. Add to config.toml | |||
| 20 | - | cat >> ~/.gordon/config.toml << 'EOF' | |||
| 21 | - | [auth] | |||
| 22 | - | password_hash = "password_hash" | |||
| 23 | - | EOF | |||
| 24 | - | ``` | |||
| 25 | - | ||||
| 26 | - | **Option 2: Disable Authentication (development only)** | |||
| 27 | ```toml | 11 | ```toml | ||
| 28 | [auth] | 12 | [auth] | ||
| 29 | - | enabled = false | 13 | + | enabled = true |
| 14 | + | secrets_backend = "pass" | |||
| 15 | + | token_secret = "gordon/auth/token_secret" | |||
| 16 | + | token_expiry = "30d" | |||
| 30 | ``` | 17 | ``` | ||
| 31 | 18 | ||||
| 32 | - | ## Configuration | 19 | + | ### Password + Token (interactive + CI/CD) |
| 33 | 20 | ||||
| 34 | ```toml | 21 | ```toml | ||
| 35 | [auth] | 22 | [auth] | ||
| 36 | enabled = true | 23 | enabled = true | ||
| 37 | secrets_backend = "pass" | 24 | secrets_backend = "pass" | ||
| 38 | - | token_secret = "gordon/auth/token_secret" | |||
| 39 | - | token_expiry = "30d" | |||
| 40 | - | ||||
| 41 | - | # Optional: enable password authentication | |||
| 42 | username = "deploy" | 25 | username = "deploy" | ||
| 43 | password_hash = "gordon/auth/password_hash" | 26 | password_hash = "gordon/auth/password_hash" | ||
| 27 | + | token_secret = "gordon/auth/token_secret" | |||
| 44 | ``` | 28 | ``` | ||
| 45 | 29 | ||||
| 30 | + | Token auth always works. Adding `username` + `password_hash` enables interactive login. | |||
| 31 | + | ||||
| 46 | ## Options | 32 | ## Options | ||
| 47 | 33 | ||||
| 48 | | Option | Type | Default | Description | | 34 | | Option | Type | Default | Description | | ||
| @@ -54,59 +40,31 @@ password_hash = "gordon/auth/password_hash" | |||||
| 54 | | `username` | string | - | Username for password auth (optional) | | 40 | | `username` | string | - | Username for password auth (optional) | | ||
| 55 | | `password_hash` | string | - | Path to bcrypt hash in secrets backend (optional) | | 41 | | `password_hash` | string | - | Path to bcrypt hash in secrets backend (optional) | | ||
| 56 | 42 | ||||
| 57 | - | ## Authentication Modes | 43 | + | ## Secrets Backends |
| 58 | - | ||||
| 59 | - | Gordon infers the authentication mode from your configuration: | |||
| 60 | 44 | ||||
| 61 | - | | Config | Mode | Description | | 45 | + | `token_secret` and `password_hash` are read through the configured backend: |
| 62 | - | |--------|------|-------------| | |||
| 63 | - | | Only `token_secret` | Token-only | JWT tokens for all access | | |||
| 64 | - | | `token_secret` + `username` + `password_hash` | Password + Token | Password for interactive login, tokens for CI/CD | | |||
| 65 | 46 | ||||
| 66 | - | Tokens always work regardless of mode. The difference is whether password authentication is available. | 47 | + | | Backend | Description | |
| 67 | - | 48 | + | | --------- | ------------- | | |
| 68 | - | ## Environment Variables | 49 | + | | `pass` | Unix password manager (GPG-encrypted) | |
| 50 | + | | `sops` | Mozilla SOPS encrypted files | | |||
| 51 | + | | `unsafe` | Plain text files (development only) | | |||
| 69 | 52 | ||||
| 70 | - | Gordon supports environment variables for sensitive configuration: | 53 | + | See [Secret Providers](./secrets.md) for setup details. |
| 71 | 54 | ||||
| 72 | - | | Variable | Description | | 55 | + | ## Environment Variable |
| 73 | - | |----------|-------------| | |||
| 74 | - | | `GORDON_AUTH_TOKEN_SECRET` | JWT signing secret (takes priority over config file) | | |||
| 75 | 56 | ||||
| 76 | - | Using environment variables is recommended for production as it avoids storing secrets on disk: | 57 | + | `GORDON_AUTH_TOKEN_SECRET` overrides `token_secret` in the config. If you don’t set the env var, `token_secret` is loaded from the configured secrets backend (including `pass`). |
| 77 | 58 | ||||
| 78 | ```bash | 59 | ```bash | ||
| 79 | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | 60 | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | ||
| 80 | gordon serve | 61 | gordon serve | ||
| 81 | ``` | 62 | ``` | ||
| 82 | 63 | ||||
| 83 | - | ## Secrets Backend | 64 | + | ## Setup |
| 84 | - | ||||
| 85 | - | The `secrets_backend` option determines how Gordon retrieves sensitive values like `token_secret` and `password_hash`: | |||
| 86 | - | ||||
| 87 | - | | Backend | Description | | |||
| 88 | - | |---------|-------------| | |||
| 89 | - | | `pass` | Unix password manager (GPG-encrypted) | | |||
| 90 | - | | `sops` | Mozilla SOPS encrypted files | | |||
| 91 | - | | `unsafe` | Plain text files (development only) | | |||
| 92 | - | ||||
| 93 | - | See [Secret Providers](./secrets.md) for detailed configuration of each backend. | |||
| 94 | - | ||||
| 95 | - | ## Authentication Types | |||
| 96 | 65 | ||||
| 97 | - | ### Token-Only Mode | 66 | + | ### Token-only setup |
| 98 | - | ||||
| 99 | - | JWT-based authentication, ideal for CI/CD pipelines when you don't need interactive login: | |||
| 100 | - | ||||
| 101 | - | ```toml | |||
| 102 | - | [auth] | |||
| 103 | - | enabled = true | |||
| 104 | - | secrets_backend = "pass" | |||
| 105 | - | token_secret = "gordon/auth/token_secret" | |||
| 106 | - | token_expiry = "30d" | |||
| 107 | - | ``` | |||
| 108 | 67 | ||||
| 109 | - | **Setup:** | |||
| 110 | ```bash | 68 | ```bash | ||
| 111 | # Create token secret (random 32+ characters) | 69 | # Create token secret (random 32+ characters) | ||
| 112 | pass insert gordon/auth/token_secret | 70 | pass insert gordon/auth/token_secret | ||
| @@ -115,93 +73,51 @@ pass insert gordon/auth/token_secret | |||||
| 115 | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | 73 | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | ||
| 116 | ``` | 74 | ``` | ||
| 117 | 75 | ||||
| 118 | - | **Usage:** | |||
| 119 | ```bash | 76 | ```bash | ||
| 120 | # Docker login with token | 77 | # Docker login with token | ||
| 121 | docker login -u ci-bot -p <token> registry.mydomain.com | 78 | docker login -u ci-bot -p <token> registry.mydomain.com | ||
| 122 | ``` | 79 | ``` | ||
| 123 | 80 | ||||
| 124 | - | **Token Management:** | 81 | + | ### Password + token setup |
| 125 | - | ```bash | |||
| 126 | - | # List all tokens | |||
| 127 | - | gordon auth token list | |||
| 128 | - | ||||
| 129 | - | # Revoke a specific token | |||
| 130 | - | gordon auth token revoke <token-id> | |||
| 131 | - | ||||
| 132 | - | # Revoke all tokens | |||
| 133 | - | gordon auth token revoke --all | |||
| 134 | - | ``` | |||
| 135 | 82 | ||||
| 136 | - | ### Password + Token Mode | |||
| 137 | - | ||||
| 138 | - | Password authentication for interactive login, plus tokens for CI/CD: | |||
| 139 | - | ||||
| 140 | - | ```toml | |||
| 141 | - | [auth] | |||
| 142 | - | enabled = true | |||
| 143 | - | secrets_backend = "pass" | |||
| 144 | - | username = "deploy" | |||
| 145 | - | password_hash = "gordon/auth/password_hash" | |||
| 146 | - | token_secret = "gordon/auth/token_secret" | |||
| 147 | - | ``` | |||
| 148 | - | ||||
| 149 | - | When `username` and `password_hash` are configured, both password and token authentication work. | |||
| 150 | - | ||||
| 151 | - | **Setup:** | |||
| 152 | ```bash | 83 | ```bash | ||
| 153 | # Generate bcrypt hash | 84 | # Generate bcrypt hash | ||
| 154 | gordon auth password hash | 85 | gordon auth password hash | ||
| 155 | - | # Enter password when prompted | |||
| 156 | 86 | ||||
| 157 | - | # Store hash in secrets backend | 87 | + | # Store hash and token secret |
| 158 | pass insert gordon/auth/password_hash | 88 | pass insert gordon/auth/password_hash | ||
| 159 | - | # Paste the bcrypt hash | |||
| 160 | - | ||||
| 161 | - | # Create token secret (for CI/CD token generation) | |||
| 162 | pass insert gordon/auth/token_secret | 89 | pass insert gordon/auth/token_secret | ||
| 163 | - | # Enter a random 32+ character string | |||
| 164 | ``` | 90 | ``` | ||
| 165 | 91 | ||||
| 166 | - | **Usage:** | |||
| 167 | ```bash | 92 | ```bash | ||
| 168 | # Docker registry access | 93 | # Docker registry access | ||
| 169 | docker login -u deploy -p <password> registry.mydomain.com | 94 | docker login -u deploy -p <password> registry.mydomain.com | ||
| 170 | 95 | ||||
| 171 | - | # Remote CLI authentication | 96 | + | # Remote CLI login (stores a token for you) |
| 172 | gordon auth login --remote prod | 97 | gordon auth login --remote prod | ||
| 173 | - | # Enter username and password when prompted | |||
| 174 | ``` | 98 | ``` | ||
| 175 | 99 | ||||
| 176 | - | **Remote CLI Authentication:** | 100 | + | ## Remote CLI Tokens |
| 177 | 101 | ||||
| 178 | - | With password authentication enabled, you can use `gordon auth login` to obtain a token for remote CLI operations: | 102 | + | If you already have a token (token-only servers), store it directly: |
| 179 | 103 | ||||
| 180 | ```bash | 104 | ```bash | ||
| 181 | - | gordon auth login --remote prod | 105 | + | gordon auth login --token <token> |
| 182 | - | # Username: deploy | 106 | + | # or |
| 183 | - | # Password: ******** | 107 | + | gordon remotes set-token prod <token> |
| 184 | - | # ✓ Authentication successful! | |||
| 185 | - | # Token stored for remote 'prod' | |||
| 186 | ``` | 108 | ``` | ||
| 187 | 109 | ||||
| 188 | - | The returned token is stored automatically and used for subsequent CLI commands. | |||
| 189 | - | ||||
| 190 | ## Token Scopes | 110 | ## Token Scopes | ||
| 191 | 111 | ||||
| 192 | - | Tokens can have different permission levels: | 112 | + | Registry scopes: |
| 193 | - | ||||
| 194 | - | ### Registry Scopes | |||
| 195 | 113 | ||||
| 196 | | Scope | Permission | | 114 | | Scope | Permission | | ||
| 197 | - | |-------|------------| | 115 | + | | ------- | ------------ | |
| 198 | | `push` | Push images to registry | | 116 | | `push` | Push images to registry | | ||
| 199 | | `pull` | Pull images from registry | | 117 | | `pull` | Pull images from registry | | ||
| 200 | | `push,pull` | Both push and pull (default) | | 118 | | `push,pull` | Both push and pull (default) | | ||
| 201 | 119 | ||||
| 202 | - | ### Admin Scopes | 120 | + | Admin scopes (for remote CLI): |
| 203 | - | ||||
| 204 | - | For remote CLI access via the Admin API: | |||
| 205 | 121 | ||||
| 206 | | Scope | Permission | | 122 | | Scope | Permission | | ||
| 207 | |-------|------------| | 123 | |-------|------------| | ||
| @@ -214,106 +130,45 @@ For remote CLI access via the Admin API: | |||||
| 214 | | `admin:secrets:read` | List secret keys | | 130 | | `admin:secrets:read` | List secret keys | | ||
| 215 | | `admin:secrets:write` | Set/delete secrets | | 131 | | `admin:secrets:write` | Set/delete secrets | | ||
| 216 | 132 | ||||
| 217 | - | **Examples:** | 133 | + | Examples: |
| 134 | + | ||||
| 218 | ```bash | 135 | ```bash | ||
| 219 | - | # Pull-only token (for read-only access) | |||
| 220 | gordon auth token generate --subject reader --scopes pull --expiry 30d | 136 | gordon auth token generate --subject reader --scopes pull --expiry 30d | ||
| 221 | - | ||||
| 222 | - | # Push-only token (for CI builds) | |||
| 223 | gordon auth token generate --subject builder --scopes push --expiry 0 | 137 | gordon auth token generate --subject builder --scopes push --expiry 0 | ||
| 224 | - | ||||
| 225 | - | # Full admin access (for remote CLI) | |||
| 226 | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | 138 | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | ||
| 227 | ``` | 139 | ``` | ||
| 228 | 140 | ||||
| 229 | ## Token Expiry | 141 | ## Token Expiry | ||
| 230 | 142 | ||||
| 231 | - | Control how long tokens remain valid. Supports human-friendly units: | 143 | + | Durations support `d`, `w`, `M`, `y` and combinations like `1y6M` or `2w3d`. |
| 232 | - | ||||
| 233 | - | | Unit | Description | | |||
| 234 | - | |------|-------------| | |||
| 235 | - | | `d` | days (24 hours) | | |||
| 236 | - | | `w` | weeks (7 days) | | |||
| 237 | - | | `M` | months (30 days) | | |||
| 238 | - | | `y` | years (365 days) | | |||
| 239 | - | ||||
| 240 | - | Compound durations work too: `1y6M`, `2w3d` | |||
| 241 | 144 | ||||
| 242 | ```bash | 145 | ```bash | ||
| 243 | - | # Never expires (for CI/CD) | |||
| 244 | gordon auth token generate --subject ci --expiry 0 | 146 | gordon auth token generate --subject ci --expiry 0 | ||
| 245 | - | ||||
| 246 | - | # Expires in 1 year | |||
| 247 | gordon auth token generate --subject deploy --expiry 1y | 147 | gordon auth token generate --subject deploy --expiry 1y | ||
| 248 | - | ||||
| 249 | - | # Expires in 30 days | |||
| 250 | gordon auth token generate --subject temp --expiry 30d | 148 | gordon auth token generate --subject temp --expiry 30d | ||
| 251 | ``` | 149 | ``` | ||
| 252 | 150 | ||||
| 253 | - | ## Instance-Specific Tokens | 151 | + | ## Instance-specific Tokens |
| 254 | - | ||||
| 255 | - | Tokens are signed with the `token_secret`, which is unique to each Gordon instance: | |||
| 256 | 152 | ||||
| 153 | + | - Tokens are signed with `token_secret` | |||
| 257 | - Tokens from one Gordon instance won't work on another | 154 | - Tokens from one Gordon instance won't work on another | ||
| 258 | - | - Changing the `token_secret` invalidates all existing tokens | 155 | + | - Changing `token_secret` invalidates all existing tokens |
| 259 | - | - Each environment (dev, staging, prod) should have different secrets | |||
| 260 | 156 | ||||
| 261 | - | ## Development Setup | 157 | + | ## Disable Auth (development only) |
| 262 | - | ||||
| 263 | - | For local development without authentication: | |||
| 264 | 158 | ||||
| 265 | ```toml | 159 | ```toml | ||
| 266 | [auth] | 160 | [auth] | ||
| 267 | enabled = false | 161 | enabled = false | ||
| 268 | ``` | 162 | ``` | ||
| 269 | 163 | ||||
| 270 | - | ## Examples | |||
| 271 | - | ||||
| 272 | - | ### CI/CD Setup (Token-Only) | |||
| 273 | - | ||||
| 274 | - | ```toml | |||
| 275 | - | [auth] | |||
| 276 | - | enabled = true | |||
| 277 | - | secrets_backend = "pass" | |||
| 278 | - | token_secret = "gordon/auth/token_secret" | |||
| 279 | - | ``` | |||
| 280 | - | ||||
| 281 | - | ```bash | |||
| 282 | - | # Generate CI token | |||
| 283 | - | gordon auth token generate --subject github-actions --scopes push,pull --expiry 0 | |||
| 284 | - | ``` | |||
| 285 | - | ||||
| 286 | - | In GitHub Actions: | |||
| 287 | - | ```yaml | |||
| 288 | - | - name: Login to Gordon Registry | |||
| 289 | - | run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }} | |||
| 290 | - | ``` | |||
| 291 | - | ||||
| 292 | - | ### Production with Password + Token | |||
| 293 | - | ||||
| 294 | - | ```toml | |||
| 295 | - | [auth] | |||
| 296 | - | enabled = true | |||
| 297 | - | secrets_backend = "pass" | |||
| 298 | - | username = "deploy" | |||
| 299 | - | password_hash = "gordon/auth/password_hash" | |||
| 300 | - | token_secret = "gordon/auth/token_secret" | |||
| 301 | - | ``` | |||
| 302 | - | ||||
| 303 | - | ### Enterprise with SOPS | |||
| 304 | - | ||||
| 305 | - | ```toml | |||
| 306 | - | [auth] | |||
| 307 | - | enabled = true | |||
| 308 | - | secrets_backend = "sops" | |||
| 309 | - | token_secret = "gordon/auth/token_secret" | |||
| 310 | - | ``` | |||
| 311 | - | ||||
| 312 | ## Internal Registry Auth | 164 | ## Internal Registry Auth | ||
| 313 | 165 | ||||
| 314 | - | Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart. | 166 | + | Gordon generates internal credentials when auth is enabled. These are used for loopback pulls during deploys and are regenerated on each restart. |
| 167 | + | ||||
| 168 | + | Gordon also generates a separate service token for its own registry-domain pulls. This token is managed internally and is not exposed via configuration or CLI. | |||
| 315 | 169 | ||||
| 316 | To view internal credentials (for troubleshooting): | 170 | To view internal credentials (for troubleshooting): | ||
| 171 | + | ||||
| 317 | ```bash | 172 | ```bash | ||
| 318 | gordon auth internal | 173 | gordon auth internal | ||
| 319 | ``` | 174 | ``` | ||
M docs/config/env.md +15 -0
| @@ -34,6 +34,21 @@ Gordon loads environment variables from files named after the domain: | |||
| 34 | 34 | | `api.company.io` | `api_company_io.env` | | |
| 35 | 35 | | `staging.app.dev` | `staging_app_dev.env` | | |
| 36 | 36 | ||
| 37 | + | ## Backend Behavior | |
| 38 | + | ||
| 39 | + | The `auth.secrets_backend` setting changes where route secrets live: | |
| 40 | + | ||
| 41 | + | ### Pass Backend | |
| 42 | + | ||
| 43 | + | - Env files are not used for route secrets. | |
| 44 | + | - Use `gordon secrets set` to store per-domain secrets in pass. | |
| 45 | + | - Existing `.env` files are migrated on startup and renamed to `.env.migrated`. | |
| 46 | + | ||
| 47 | + | ### SOPS or Unsafe Backend | |
| 48 | + | ||
| 49 | + | - Env files remain the source of truth. | |
| 50 | + | - Use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`. | |
| 51 | + | ||
| 37 | 52 | ## File Format | |
| 38 | 53 | ||
| 39 | 54 | Standard `.env` file format: | |
| @@ -34,6 +34,21 @@ Gordon loads environment variables from files named after the domain: | |||||
| 34 | | `api.company.io` | `api_company_io.env` | | 34 | | `api.company.io` | `api_company_io.env` | | ||
| 35 | | `staging.app.dev` | `staging_app_dev.env` | | 35 | | `staging.app.dev` | `staging_app_dev.env` | | ||
| 36 | 36 | ||||
| 37 | + | ## Backend Behavior | |||
| 38 | + | ||||
| 39 | + | The `auth.secrets_backend` setting changes where route secrets live: | |||
| 40 | + | ||||
| 41 | + | ### Pass Backend | |||
| 42 | + | ||||
| 43 | + | - Env files are not used for route secrets. | |||
| 44 | + | - Use `gordon secrets set` to store per-domain secrets in pass. | |||
| 45 | + | - Existing `.env` files are migrated on startup and renamed to `.env.migrated`. | |||
| 46 | + | ||||
| 47 | + | ### SOPS or Unsafe Backend | |||
| 48 | + | ||||
| 49 | + | - Env files remain the source of truth. | |||
| 50 | + | - Use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`. | |||
| 51 | + | ||||
| 37 | ## File Format | 52 | ## File Format | ||
| 38 | 53 | ||||
| 39 | Standard `.env` file format: | 54 | Standard `.env` file format: | ||
M docs/config/secrets.md +8 -0
| @@ -54,6 +54,10 @@ token_secret = "gordon/auth/token_secret" | |||
| 54 | 54 | - Standard Unix tooling | |
| 55 | 55 | - Works with team GPG keys | |
| 56 | 56 | ||
| 57 | + | **Route secrets storage:** | |
| 58 | + | - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores) | |
| 59 | + | - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated` | |
| 60 | + | ||
| 57 | 61 | ### SOPS | |
| 58 | 62 | ||
| 59 | 63 | Uses Mozilla SOPS for encrypted file-based secrets: | |
| @@ -85,6 +89,10 @@ DB_PASSWORD=${sops:secrets.yaml:database.password} | |||
| 85 | 89 | - YAML/JSON file encryption | |
| 86 | 90 | - Git-friendly (encrypted files in repo) | |
| 87 | 91 | ||
| 92 | + | **Route secrets storage:** | |
| 93 | + | - Domain secrets stay in `.env` files | |
| 94 | + | - Use `${sops:...}` syntax to resolve encrypted values | |
| 95 | + | ||
| 88 | 96 | ### Unsafe (Development Only) | |
| 89 | 97 | ||
| 90 | 98 | Stores secrets as plain text files: | |
| @@ -54,6 +54,10 @@ token_secret = "gordon/auth/token_secret" | |||||
| 54 | - Standard Unix tooling | 54 | - Standard Unix tooling | ||
| 55 | - Works with team GPG keys | 55 | - Works with team GPG keys | ||
| 56 | 56 | ||||
| 57 | + | **Route secrets storage:** | |||
| 58 | + | - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores) | |||
| 59 | + | - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated` | |||
| 60 | + | ||||
| 57 | ### SOPS | 61 | ### SOPS | ||
| 58 | 62 | ||||
| 59 | Uses Mozilla SOPS for encrypted file-based secrets: | 63 | Uses Mozilla SOPS for encrypted file-based secrets: | ||
| @@ -85,6 +89,10 @@ DB_PASSWORD=${sops:secrets.yaml:database.password} | |||||
| 85 | - YAML/JSON file encryption | 89 | - YAML/JSON file encryption | ||
| 86 | - Git-friendly (encrypted files in repo) | 90 | - Git-friendly (encrypted files in repo) | ||
| 87 | 91 | ||||
| 92 | + | **Route secrets storage:** | |||
| 93 | + | - Domain secrets stay in `.env` files | |||
| 94 | + | - Use `${sops:...}` syntax to resolve encrypted values | |||
| 95 | + | ||||
| 88 | ### Unsafe (Development Only) | 96 | ### Unsafe (Development Only) | ||
| 89 | 97 | ||||
| 90 | Stores secrets as plain text files: | 98 | Stores secrets as plain text files: | ||
M docs/getting-started.md +7 -1
| @@ -29,7 +29,7 @@ Config is created at `~/.config/gordon/gordon.toml`. | |||
| 29 | 29 | ||
| 30 | 30 | ## 3. Set Up Authentication | |
| 31 | 31 | ||
| 32 | - | Gordon requires a `token_secret` for JWT authentication. We recommend using `pass` to store secrets securely. | |
| 32 | + | Gordon requires a `token_secret` for JWT authentication. You can store it in `pass` or provide it via the `GORDON_AUTH_TOKEN_SECRET` environment variable. We recommend using `pass` to store secrets securely. | |
| 33 | 33 | ||
| 34 | 34 | > **Local development?** If you just want to try Gordon locally, you can disable auth temporarily: | |
| 35 | 35 | > ```toml | |
| @@ -53,6 +53,12 @@ pass init <your-gpg-key-id> | |||
| 53 | 53 | openssl rand -base64 32 | pass insert -m gordon/auth/token_secret | |
| 54 | 54 | ``` | |
| 55 | 55 | ||
| 56 | + | **Or set it via environment variable:** | |
| 57 | + | ||
| 58 | + | ```bash | |
| 59 | + | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | |
| 60 | + | ``` | |
| 61 | + | ||
| 56 | 62 | **Update your config** (`~/.config/gordon/gordon.toml`): | |
| 57 | 63 | ```toml | |
| 58 | 64 | [auth] | |
| @@ -29,7 +29,7 @@ Config is created at `~/.config/gordon/gordon.toml`. | |||||
| 29 | 29 | ||||
| 30 | ## 3. Set Up Authentication | 30 | ## 3. Set Up Authentication | ||
| 31 | 31 | ||||
| 32 | - | Gordon requires a `token_secret` for JWT authentication. We recommend using `pass` to store secrets securely. | 32 | + | Gordon requires a `token_secret` for JWT authentication. You can store it in `pass` or provide it via the `GORDON_AUTH_TOKEN_SECRET` environment variable. We recommend using `pass` to store secrets securely. |
| 33 | 33 | ||||
| 34 | > **Local development?** If you just want to try Gordon locally, you can disable auth temporarily: | 34 | > **Local development?** If you just want to try Gordon locally, you can disable auth temporarily: | ||
| 35 | > ```toml | 35 | > ```toml | ||
| @@ -53,6 +53,12 @@ pass init <your-gpg-key-id> | |||||
| 53 | openssl rand -base64 32 | pass insert -m gordon/auth/token_secret | 53 | openssl rand -base64 32 | pass insert -m gordon/auth/token_secret | ||
| 54 | ``` | 54 | ``` | ||
| 55 | 55 | ||||
| 56 | + | **Or set it via environment variable:** | |||
| 57 | + | ||||
| 58 | + | ```bash | |||
| 59 | + | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | |||
| 60 | + | ``` | |||
| 61 | + | ||||
| 56 | **Update your config** (`~/.config/gordon/gordon.toml`): | 62 | **Update your config** (`~/.config/gordon/gordon.toml`): | ||
| 57 | ```toml | 63 | ```toml | ||
| 58 | [auth] | 64 | [auth] | ||
M install.sh +27 -2
| @@ -3,9 +3,11 @@ set -e | |||
| 3 | 3 | ||
| 4 | 4 | # Gordon installer script | |
| 5 | 5 | # Usage: curl -fsSL https://gordon.bnema.dev/install | bash | |
| 6 | + | # Usage with pre-release: curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash | |
| 6 | 7 | ||
| 7 | 8 | REPO="bnema/gordon" | |
| 8 | 9 | INSTALL_DIR="/usr/local/bin" | |
| 10 | + | VERSION="${GORDON_VERSION:-latest}" | |
| 9 | 11 | ||
| 10 | 12 | echo "Installing Gordon..." | |
| 11 | 13 | ||
| @@ -43,14 +45,37 @@ echo "Detected: ${OS}/${ARCH}" | |||
| 43 | 45 | ||
| 44 | 46 | # Construct download URL | |
| 45 | 47 | TARBALL="gordon_${OS}_${ARCH}.tar.gz" | |
| 46 | - | DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${TARBALL}" | |
| 48 | + | ||
| 49 | + | # Determine version to download | |
| 50 | + | if [ "$VERSION" = "latest" ] && [ -n "$GORDON_PRERELEASE" ]; then | |
| 51 | + | echo "Finding latest pre-release..." | |
| 52 | + | # Get latest release (including pre-releases) from GitHub API | |
| 53 | + | RELEASE_DATA=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" 2>/dev/null || echo "") | |
| 54 | + | if [ -z "$RELEASE_DATA" ]; then | |
| 55 | + | echo "Error: Failed to fetch release information from GitHub API" | |
| 56 | + | exit 1 | |
| 57 | + | fi | |
| 58 | + | VERSION=$(echo "$RELEASE_DATA" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name":\s*"([^"]+)".*/\1/') | |
| 59 | + | if [ -z "$VERSION" ]; then | |
| 60 | + | echo "Error: Could not determine latest pre-release version" | |
| 61 | + | exit 1 | |
| 62 | + | fi | |
| 63 | + | echo "Using pre-release version: ${VERSION}" | |
| 64 | + | elif [ "$VERSION" = "latest" ]; then | |
| 65 | + | VERSION="latest" | |
| 66 | + | echo "Using latest stable release" | |
| 67 | + | else | |
| 68 | + | echo "Using version: ${VERSION}" | |
| 69 | + | fi | |
| 70 | + | ||
| 71 | + | DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${TARBALL}" | |
| 47 | 72 | ||
| 48 | 73 | # Create temporary directory | |
| 49 | 74 | TMP_DIR=$(mktemp -d) | |
| 50 | 75 | trap 'rm -rf "$TMP_DIR"' EXIT | |
| 51 | 76 | ||
| 52 | 77 | # Download checksums file | |
| 53 | - | CHECKSUMS_URL="https://github.com/${REPO}/releases/latest/download/checksums.txt" | |
| 78 | + | CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt" | |
| 54 | 79 | echo "Downloading checksums..." | |
| 55 | 80 | if ! curl -fsSL "$CHECKSUMS_URL" -o "$TMP_DIR/checksums.txt"; then | |
| 56 | 81 | echo "Error: Failed to download checksums file" | |
| @@ -3,9 +3,11 @@ set -e | |||||
| 3 | 3 | ||||
| 4 | # Gordon installer script | 4 | # Gordon installer script | ||
| 5 | # Usage: curl -fsSL https://gordon.bnema.dev/install | bash | 5 | # Usage: curl -fsSL https://gordon.bnema.dev/install | bash | ||
| 6 | + | # Usage with pre-release: curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash | |||
| 6 | 7 | ||||
| 7 | REPO="bnema/gordon" | 8 | REPO="bnema/gordon" | ||
| 8 | INSTALL_DIR="/usr/local/bin" | 9 | INSTALL_DIR="/usr/local/bin" | ||
| 10 | + | VERSION="${GORDON_VERSION:-latest}" | |||
| 9 | 11 | ||||
| 10 | echo "Installing Gordon..." | 12 | echo "Installing Gordon..." | ||
| 11 | 13 | ||||
| @@ -43,14 +45,37 @@ echo "Detected: ${OS}/${ARCH}" | |||||
| 43 | 45 | ||||
| 44 | # Construct download URL | 46 | # Construct download URL | ||
| 45 | TARBALL="gordon_${OS}_${ARCH}.tar.gz" | 47 | TARBALL="gordon_${OS}_${ARCH}.tar.gz" | ||
| 46 | - | DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${TARBALL}" | 48 | + | |
| 49 | + | # Determine version to download | |||
| 50 | + | if [ "$VERSION" = "latest" ] && [ -n "$GORDON_PRERELEASE" ]; then | |||
| 51 | + | echo "Finding latest pre-release..." | |||
| 52 | + | # Get latest release (including pre-releases) from GitHub API | |||
| 53 | + | RELEASE_DATA=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" 2>/dev/null || echo "") | |||
| 54 | + | if [ -z "$RELEASE_DATA" ]; then | |||
| 55 | + | echo "Error: Failed to fetch release information from GitHub API" | |||
| 56 | + | exit 1 | |||
| 57 | + | fi | |||
| 58 | + | VERSION=$(echo "$RELEASE_DATA" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name":\s*"([^"]+)".*/\1/') | |||
| 59 | + | if [ -z "$VERSION" ]; then | |||
| 60 | + | echo "Error: Could not determine latest pre-release version" | |||
| 61 | + | exit 1 | |||
| 62 | + | fi | |||
| 63 | + | echo "Using pre-release version: ${VERSION}" | |||
| 64 | + | elif [ "$VERSION" = "latest" ]; then | |||
| 65 | + | VERSION="latest" | |||
| 66 | + | echo "Using latest stable release" | |||
| 67 | + | else | |||
| 68 | + | echo "Using version: ${VERSION}" | |||
| 69 | + | fi | |||
| 70 | + | ||||
| 71 | + | DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${TARBALL}" | |||
| 47 | 72 | ||||
| 48 | # Create temporary directory | 73 | # Create temporary directory | ||
| 49 | TMP_DIR=$(mktemp -d) | 74 | TMP_DIR=$(mktemp -d) | ||
| 50 | trap 'rm -rf "$TMP_DIR"' EXIT | 75 | trap 'rm -rf "$TMP_DIR"' EXIT | ||
| 51 | 76 | ||||
| 52 | # Download checksums file | 77 | # Download checksums file | ||
| 53 | - | CHECKSUMS_URL="https://github.com/${REPO}/releases/latest/download/checksums.txt" | 78 | + | CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt" |
| 54 | echo "Downloading checksums..." | 79 | echo "Downloading checksums..." | ||
| 55 | if ! curl -fsSL "$CHECKSUMS_URL" -o "$TMP_DIR/checksums.txt"; then | 80 | if ! curl -fsSL "$CHECKSUMS_URL" -o "$TMP_DIR/checksums.txt"; then | ||
| 56 | echo "Error: Failed to download checksums file" | 81 | echo "Error: Failed to download checksums file" | ||
M internal/adapters/in/cli/auth.go +97 -40
| @@ -207,12 +207,14 @@ func newAuthLoginCmd() *cobra.Command { | |||
| 207 | 207 | var ( | |
| 208 | 208 | remoteName string | |
| 209 | 209 | username string | |
| 210 | + | token string | |
| 211 | + | password string | |
| 210 | 212 | ) | |
| 211 | 213 | ||
| 212 | 214 | cmd := &cobra.Command{ | |
| 213 | 215 | Use: "login", | |
| 214 | 216 | Short: "Authenticate with a Gordon server", | |
| 215 | - | Long: `Authenticate with a Gordon server using password authentication. | |
| 217 | + | Long: `Authenticate with a Gordon server using password authentication or a pre-generated token. | |
| 216 | 218 | ||
| 217 | 219 | This command prompts for your username and password, authenticates with the | |
| 218 | 220 | remote server's /auth/password endpoint, and stores the returned token. | |
| @@ -220,43 +222,38 @@ remote server's /auth/password endpoint, and stores the returned token. | |||
| 220 | 222 | The token is stored in your remote configuration and used for subsequent | |
| 221 | 223 | CLI operations. | |
| 222 | 224 | ||
| 223 | - | Note: This command only works with servers configured for password authentication. | |
| 224 | - | For servers using token-based auth, use 'gordon remote set-token' instead. | |
| 225 | + | If --token is provided, the token is stored for the remote and verified | |
| 226 | + | against /admin/status on a best-effort basis. | |
| 225 | 227 | ||
| 226 | 228 | Examples: | |
| 227 | - | gordon auth login Login using active remote | |
| 228 | - | gordon auth login --remote prod Login to specific remote | |
| 229 | - | gordon auth login --username admin Pre-fill username`, | |
| 229 | + | gordon auth login Login using active remote | |
| 230 | + | gordon auth login --remote prod Login to specific remote | |
| 231 | + | gordon auth login --username admin Pre-fill username | |
| 232 | + | gordon auth login --token <token> Store a token for token-only servers`, | |
| 230 | 233 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 231 | - | return runAuthLogin(remoteName, username) | |
| 234 | + | if token != "" { | |
| 235 | + | return runAuthLoginWithToken(remoteName, token) | |
| 236 | + | } | |
| 237 | + | return runAuthLogin(remoteName, username, password) | |
| 232 | 238 | }, | |
| 233 | 239 | } | |
| 234 | 240 | ||
| 235 | 241 | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)") | |
| 236 | 242 | cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication") | |
| 243 | + | cmd.Flags().StringVarP(&password, "password", "p", "", "Password (visible in process listings; prefer interactive prompt)") | |
| 244 | + | cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote") | |
| 245 | + | ||
| 246 | + | cmd.MarkFlagsMutuallyExclusive("token", "password") | |
| 247 | + | cmd.MarkFlagsMutuallyExclusive("token", "username") | |
| 237 | 248 | ||
| 238 | 249 | return cmd | |
| 239 | 250 | } | |
| 240 | 251 | ||
| 241 | 252 | // runAuthLogin authenticates with a remote Gordon server. | |
| 242 | - | func runAuthLogin(remoteName, username string) error { | |
| 243 | - | // Load remotes config | |
| 244 | - | config, err := remote.LoadRemotes("") | |
| 253 | + | func runAuthLogin(remoteName, username, password string) error { | |
| 254 | + | resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName) | |
| 245 | 255 | if err != nil { | |
| 246 | - | return fmt.Errorf("failed to load remotes: %w", err) | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | // Determine which remote to use | |
| 250 | - | if remoteName == "" { | |
| 251 | - | remoteName = config.Active | |
| 252 | - | } | |
| 253 | - | if remoteName == "" { | |
| 254 | - | return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'") | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | remoteConfig, ok := config.Remotes[remoteName] | |
| 258 | - | if !ok { | |
| 259 | - | return fmt.Errorf("remote '%s' not found", remoteName) | |
| 256 | + | return err | |
| 260 | 257 | } | |
| 261 | 258 | ||
| 262 | 259 | // Prompt for username if not provided | |
| @@ -274,21 +271,28 @@ func runAuthLogin(remoteName, username string) error { | |||
| 274 | 271 | return fmt.Errorf("username cannot be empty") | |
| 275 | 272 | } | |
| 276 | 273 | ||
| 277 | - | // Prompt for password (hidden input) | |
| 278 | - | fmt.Print("Password: ") | |
| 279 | - | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | |
| 280 | - | if err != nil { | |
| 281 | - | // Fallback for non-terminal input | |
| 282 | - | reader := bufio.NewReader(os.Stdin) | |
| 283 | - | password, readErr := reader.ReadString('\n') | |
| 284 | - | if readErr != nil { | |
| 285 | - | return fmt.Errorf("failed to read password: %w", readErr) | |
| 274 | + | if password == "" { | |
| 275 | + | // Prompt for password (hidden input) | |
| 276 | + | fmt.Print("Password: ") | |
| 277 | + | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | |
| 278 | + | if err != nil { | |
| 279 | + | // Fallback for non-terminal input | |
| 280 | + | reader := bufio.NewReader(os.Stdin) | |
| 281 | + | passwordInput, readErr := reader.ReadString('\n') | |
| 282 | + | if readErr != nil { | |
| 283 | + | return fmt.Errorf("failed to read password: %w", readErr) | |
| 284 | + | } | |
| 285 | + | // Only trim trailing newline from fallback input | |
| 286 | + | passwordBytes = []byte(strings.TrimRight(passwordInput, "\r\n")) | |
| 286 | 287 | } | |
| 287 | - | passwordBytes = []byte(strings.TrimSpace(password)) | |
| 288 | + | fmt.Println() | |
| 289 | + | password = string(passwordBytes) | |
| 290 | + | } else { | |
| 291 | + | // Only trim trailing newline from flag-provided password | |
| 292 | + | password = strings.TrimRight(password, "\r\n") | |
| 288 | 293 | } | |
| 289 | - | fmt.Println() // newline after hidden password input | |
| 290 | 294 | ||
| 291 | - | if len(passwordBytes) == 0 { | |
| 295 | + | if password == "" { | |
| 292 | 296 | return fmt.Errorf("password cannot be empty") | |
| 293 | 297 | } | |
| 294 | 298 | ||
| @@ -297,26 +301,79 @@ func runAuthLogin(remoteName, username string) error { | |||
| 297 | 301 | ||
| 298 | 302 | // Authenticate | |
| 299 | 303 | ctx := context.Background() | |
| 300 | - | fmt.Printf("Authenticating with %s...\n", remoteName) | |
| 304 | + | fmt.Printf("Authenticating with %s...\n", resolvedName) | |
| 301 | 305 | ||
| 302 | - | resp, err := client.Authenticate(ctx, username, string(passwordBytes)) | |
| 306 | + | resp, err := client.Authenticate(ctx, username, password) | |
| 303 | 307 | if err != nil { | |
| 304 | 308 | return fmt.Errorf("authentication failed: %w", err) | |
| 305 | 309 | } | |
| 306 | 310 | ||
| 307 | 311 | // Update remote config with new token | |
| 308 | - | if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil { | |
| 312 | + | if err := remote.UpdateRemoteToken(resolvedName, resp.Token); err != nil { | |
| 309 | 313 | return fmt.Errorf("failed to save token: %w", err) | |
| 310 | 314 | } | |
| 311 | 315 | ||
| 312 | 316 | fmt.Println() | |
| 313 | 317 | fmt.Println(styles.RenderSuccess("Authentication successful!")) | |
| 314 | - | fmt.Printf("Token stored for remote '%s'\n", remoteName) | |
| 318 | + | fmt.Printf("Token stored for remote '%s'\n", resolvedName) | |
| 315 | 319 | fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn) | |
| 316 | 320 | ||
| 317 | 321 | return nil | |
| 318 | 322 | } | |
| 319 | 323 | ||
| 324 | + | func runAuthLoginWithToken(remoteName, token string) error { | |
| 325 | + | token = strings.TrimSpace(token) | |
| 326 | + | if token == "" { | |
| 327 | + | return fmt.Errorf("token cannot be empty") | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName) | |
| 331 | + | if err != nil { | |
| 332 | + | return err | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | client := remote.NewClient(remoteConfig.URL, remote.WithToken(token)) | |
| 336 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 337 | + | defer cancel() | |
| 338 | + | verified := true | |
| 339 | + | if _, err := client.GetStatus(ctx); err != nil { | |
| 340 | + | fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |
| 341 | + | verified = false | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | if err := remote.UpdateRemoteToken(resolvedName, token); err != nil { | |
| 345 | + | return fmt.Errorf("failed to save token: %w", err) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | fmt.Println() | |
| 349 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName))) | |
| 350 | + | if verified { | |
| 351 | + | fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint")) | |
| 352 | + | } | |
| 353 | + | return nil | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) { | |
| 357 | + | config, err := remote.LoadRemotes("") | |
| 358 | + | if err != nil { | |
| 359 | + | return "", remote.RemoteEntry{}, fmt.Errorf("failed to load remotes: %w", err) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | if remoteName == "" { | |
| 363 | + | remoteName = config.Active | |
| 364 | + | } | |
| 365 | + | if remoteName == "" { | |
| 366 | + | return "", remote.RemoteEntry{}, fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'") | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | remoteConfig, ok := config.Remotes[remoteName] | |
| 370 | + | if !ok { | |
| 371 | + | return "", remote.RemoteEntry{}, fmt.Errorf("remote '%s' not found", remoteName) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | return remoteName, remoteConfig, nil | |
| 375 | + | } | |
| 376 | + | ||
| 320 | 377 | // runShowInternalAuth displays the internal registry credentials. | |
| 321 | 378 | func runShowInternalAuth() error { | |
| 322 | 379 | creds, err := app.GetInternalCredentials() | |
| @@ -207,12 +207,14 @@ func newAuthLoginCmd() *cobra.Command { | |||||
| 207 | var ( | 207 | var ( | ||
| 208 | remoteName string | 208 | remoteName string | ||
| 209 | username string | 209 | username string | ||
| 210 | + | token string | |||
| 211 | + | password string | |||
| 210 | ) | 212 | ) | ||
| 211 | 213 | ||||
| 212 | cmd := &cobra.Command{ | 214 | cmd := &cobra.Command{ | ||
| 213 | Use: "login", | 215 | Use: "login", | ||
| 214 | Short: "Authenticate with a Gordon server", | 216 | Short: "Authenticate with a Gordon server", | ||
| 215 | - | Long: `Authenticate with a Gordon server using password authentication. | 217 | + | Long: `Authenticate with a Gordon server using password authentication or a pre-generated token. |
| 216 | 218 | ||||
| 217 | This command prompts for your username and password, authenticates with the | 219 | This command prompts for your username and password, authenticates with the | ||
| 218 | remote server's /auth/password endpoint, and stores the returned token. | 220 | remote server's /auth/password endpoint, and stores the returned token. | ||
| @@ -220,43 +222,38 @@ remote server's /auth/password endpoint, and stores the returned token. | |||||
| 220 | The token is stored in your remote configuration and used for subsequent | 222 | The token is stored in your remote configuration and used for subsequent | ||
| 221 | CLI operations. | 223 | CLI operations. | ||
| 222 | 224 | ||||
| 223 | - | Note: This command only works with servers configured for password authentication. | 225 | + | If --token is provided, the token is stored for the remote and verified |
| 224 | - | For servers using token-based auth, use 'gordon remote set-token' instead. | 226 | + | against /admin/status on a best-effort basis. |
| 225 | 227 | ||||
| 226 | Examples: | 228 | Examples: | ||
| 227 | - | gordon auth login Login using active remote | 229 | + | gordon auth login Login using active remote |
| 228 | - | gordon auth login --remote prod Login to specific remote | 230 | + | gordon auth login --remote prod Login to specific remote |
| 229 | - | gordon auth login --username admin Pre-fill username`, | 231 | + | gordon auth login --username admin Pre-fill username |
| 232 | + | gordon auth login --token <token> Store a token for token-only servers`, | |||
| 230 | RunE: func(cmd *cobra.Command, args []string) error { | 233 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 231 | - | return runAuthLogin(remoteName, username) | 234 | + | if token != "" { |
| 235 | + | return runAuthLoginWithToken(remoteName, token) | |||
| 236 | + | } | |||
| 237 | + | return runAuthLogin(remoteName, username, password) | |||
| 232 | }, | 238 | }, | ||
| 233 | } | 239 | } | ||
| 234 | 240 | ||||
| 235 | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)") | 241 | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)") | ||
| 236 | cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication") | 242 | cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication") | ||
| 243 | + | cmd.Flags().StringVarP(&password, "password", "p", "", "Password (visible in process listings; prefer interactive prompt)") | |||
| 244 | + | cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote") | |||
| 245 | + | ||||
| 246 | + | cmd.MarkFlagsMutuallyExclusive("token", "password") | |||
| 247 | + | cmd.MarkFlagsMutuallyExclusive("token", "username") | |||
| 237 | 248 | ||||
| 238 | return cmd | 249 | return cmd | ||
| 239 | } | 250 | } | ||
| 240 | 251 | ||||
| 241 | // runAuthLogin authenticates with a remote Gordon server. | 252 | // runAuthLogin authenticates with a remote Gordon server. | ||
| 242 | - | func runAuthLogin(remoteName, username string) error { | 253 | + | func runAuthLogin(remoteName, username, password string) error { |
| 243 | - | // Load remotes config | 254 | + | resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName) |
| 244 | - | config, err := remote.LoadRemotes("") | |||
| 245 | if err != nil { | 255 | if err != nil { | ||
| 246 | - | return fmt.Errorf("failed to load remotes: %w", err) | 256 | + | return err |
| 247 | - | } | |||
| 248 | - | ||||
| 249 | - | // Determine which remote to use | |||
| 250 | - | if remoteName == "" { | |||
| 251 | - | remoteName = config.Active | |||
| 252 | - | } | |||
| 253 | - | if remoteName == "" { | |||
| 254 | - | return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'") | |||
| 255 | - | } | |||
| 256 | - | ||||
| 257 | - | remoteConfig, ok := config.Remotes[remoteName] | |||
| 258 | - | if !ok { | |||
| 259 | - | return fmt.Errorf("remote '%s' not found", remoteName) | |||
| 260 | } | 257 | } | ||
| 261 | 258 | ||||
| 262 | // Prompt for username if not provided | 259 | // Prompt for username if not provided | ||
| @@ -274,21 +271,28 @@ func runAuthLogin(remoteName, username string) error { | |||||
| 274 | return fmt.Errorf("username cannot be empty") | 271 | return fmt.Errorf("username cannot be empty") | ||
| 275 | } | 272 | } | ||
| 276 | 273 | ||||
| 277 | - | // Prompt for password (hidden input) | 274 | + | if password == "" { |
| 278 | - | fmt.Print("Password: ") | 275 | + | // Prompt for password (hidden input) |
| 279 | - | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | 276 | + | fmt.Print("Password: ") |
| 280 | - | if err != nil { | 277 | + | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) |
| 281 | - | // Fallback for non-terminal input | 278 | + | if err != nil { |
| 282 | - | reader := bufio.NewReader(os.Stdin) | 279 | + | // Fallback for non-terminal input |
| 283 | - | password, readErr := reader.ReadString('\n') | 280 | + | reader := bufio.NewReader(os.Stdin) |
| 284 | - | if readErr != nil { | 281 | + | passwordInput, readErr := reader.ReadString('\n') |
| 285 | - | return fmt.Errorf("failed to read password: %w", readErr) | 282 | + | if readErr != nil { |
| 283 | + | return fmt.Errorf("failed to read password: %w", readErr) | |||
| 284 | + | } | |||
| 285 | + | // Only trim trailing newline from fallback input | |||
| 286 | + | passwordBytes = []byte(strings.TrimRight(passwordInput, "\r\n")) | |||
| 286 | } | 287 | } | ||
| 287 | - | passwordBytes = []byte(strings.TrimSpace(password)) | 288 | + | fmt.Println() |
| 289 | + | password = string(passwordBytes) | |||
| 290 | + | } else { | |||
| 291 | + | // Only trim trailing newline from flag-provided password | |||
| 292 | + | password = strings.TrimRight(password, "\r\n") | |||
| 288 | } | 293 | } | ||
| 289 | - | fmt.Println() // newline after hidden password input | |||
| 290 | 294 | ||||
| 291 | - | if len(passwordBytes) == 0 { | 295 | + | if password == "" { |
| 292 | return fmt.Errorf("password cannot be empty") | 296 | return fmt.Errorf("password cannot be empty") | ||
| 293 | } | 297 | } | ||
| 294 | 298 | ||||
| @@ -297,26 +301,79 @@ func runAuthLogin(remoteName, username string) error { | |||||
| 297 | 301 | ||||
| 298 | // Authenticate | 302 | // Authenticate | ||
| 299 | ctx := context.Background() | 303 | ctx := context.Background() | ||
| 300 | - | fmt.Printf("Authenticating with %s...\n", remoteName) | 304 | + | fmt.Printf("Authenticating with %s...\n", resolvedName) |
| 301 | 305 | ||||
| 302 | - | resp, err := client.Authenticate(ctx, username, string(passwordBytes)) | 306 | + | resp, err := client.Authenticate(ctx, username, password) |
| 303 | if err != nil { | 307 | if err != nil { | ||
| 304 | return fmt.Errorf("authentication failed: %w", err) | 308 | return fmt.Errorf("authentication failed: %w", err) | ||
| 305 | } | 309 | } | ||
| 306 | 310 | ||||
| 307 | // Update remote config with new token | 311 | // Update remote config with new token | ||
| 308 | - | if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil { | 312 | + | if err := remote.UpdateRemoteToken(resolvedName, resp.Token); err != nil { |
| 309 | return fmt.Errorf("failed to save token: %w", err) | 313 | return fmt.Errorf("failed to save token: %w", err) | ||
| 310 | } | 314 | } | ||
| 311 | 315 | ||||
| 312 | fmt.Println() | 316 | fmt.Println() | ||
| 313 | fmt.Println(styles.RenderSuccess("Authentication successful!")) | 317 | fmt.Println(styles.RenderSuccess("Authentication successful!")) | ||
| 314 | - | fmt.Printf("Token stored for remote '%s'\n", remoteName) | 318 | + | fmt.Printf("Token stored for remote '%s'\n", resolvedName) |
| 315 | fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn) | 319 | fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn) | ||
| 316 | 320 | ||||
| 317 | return nil | 321 | return nil | ||
| 318 | } | 322 | } | ||
| 319 | 323 | ||||
| 324 | + | func runAuthLoginWithToken(remoteName, token string) error { | |||
| 325 | + | token = strings.TrimSpace(token) | |||
| 326 | + | if token == "" { | |||
| 327 | + | return fmt.Errorf("token cannot be empty") | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName) | |||
| 331 | + | if err != nil { | |||
| 332 | + | return err | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | client := remote.NewClient(remoteConfig.URL, remote.WithToken(token)) | |||
| 336 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |||
| 337 | + | defer cancel() | |||
| 338 | + | verified := true | |||
| 339 | + | if _, err := client.GetStatus(ctx); err != nil { | |||
| 340 | + | fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |||
| 341 | + | verified = false | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | if err := remote.UpdateRemoteToken(resolvedName, token); err != nil { | |||
| 345 | + | return fmt.Errorf("failed to save token: %w", err) | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | fmt.Println() | |||
| 349 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName))) | |||
| 350 | + | if verified { | |||
| 351 | + | fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint")) | |||
| 352 | + | } | |||
| 353 | + | return nil | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) { | |||
| 357 | + | config, err := remote.LoadRemotes("") | |||
| 358 | + | if err != nil { | |||
| 359 | + | return "", remote.RemoteEntry{}, fmt.Errorf("failed to load remotes: %w", err) | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | if remoteName == "" { | |||
| 363 | + | remoteName = config.Active | |||
| 364 | + | } | |||
| 365 | + | if remoteName == "" { | |||
| 366 | + | return "", remote.RemoteEntry{}, fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'") | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | remoteConfig, ok := config.Remotes[remoteName] | |||
| 370 | + | if !ok { | |||
| 371 | + | return "", remote.RemoteEntry{}, fmt.Errorf("remote '%s' not found", remoteName) | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | return remoteName, remoteConfig, nil | |||
| 375 | + | } | |||
| 376 | + | ||||
| 320 | // runShowInternalAuth displays the internal registry credentials. | 377 | // runShowInternalAuth displays the internal registry credentials. | ||
| 321 | func runShowInternalAuth() error { | 378 | func runShowInternalAuth() error { | ||
| 322 | creds, err := app.GetInternalCredentials() | 379 | creds, err := app.GetInternalCredentials() | ||
A internal/adapters/in/cli/auth_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | "net/http/httptest" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) { | |
| 14 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 15 | + | ||
| 16 | + | var receivedAuth string | |
| 17 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 18 | + | if r.URL.Path != "/admin/status" { | |
| 19 | + | w.WriteHeader(http.StatusNotFound) | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | receivedAuth = r.Header.Get("Authorization") | |
| 23 | + | w.Header().Set("Content-Type", "application/json") | |
| 24 | + | w.WriteHeader(http.StatusOK) | |
| 25 | + | _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`)) | |
| 26 | + | })) | |
| 27 | + | defer server.Close() | |
| 28 | + | ||
| 29 | + | config := &remote.ClientConfig{ | |
| 30 | + | Active: "prod", | |
| 31 | + | Remotes: map[string]remote.RemoteEntry{ | |
| 32 | + | "prod": {URL: server.URL}, | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config)) | |
| 36 | + | ||
| 37 | + | err := runAuthLoginWithToken("", "token123") | |
| 38 | + | assert.NoError(t, err) | |
| 39 | + | ||
| 40 | + | loaded, err := remote.LoadRemotes("") | |
| 41 | + | assert.NoError(t, err) | |
| 42 | + | assert.Equal(t, "token123", loaded.Remotes["prod"].Token) | |
| 43 | + | assert.Equal(t, "Bearer token123", receivedAuth) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) { | |
| 47 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 48 | + | ||
| 49 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 50 | + | if r.URL.Path != "/admin/status" { | |
| 51 | + | w.WriteHeader(http.StatusNotFound) | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | w.WriteHeader(http.StatusUnauthorized) | |
| 55 | + | _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) | |
| 56 | + | })) | |
| 57 | + | defer server.Close() | |
| 58 | + | ||
| 59 | + | config := &remote.ClientConfig{ | |
| 60 | + | Active: "prod", | |
| 61 | + | Remotes: map[string]remote.RemoteEntry{ | |
| 62 | + | "prod": {URL: server.URL}, | |
| 63 | + | }, | |
| 64 | + | } | |
| 65 | + | assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config)) | |
| 66 | + | ||
| 67 | + | err := runAuthLoginWithToken("", "token123") | |
| 68 | + | assert.NoError(t, err) | |
| 69 | + | ||
| 70 | + | loaded, err := remote.LoadRemotes("") | |
| 71 | + | assert.NoError(t, err) | |
| 72 | + | assert.Equal(t, "token123", loaded.Remotes["prod"].Token) | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | "net/http/httptest" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) { | |||
| 14 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |||
| 15 | + | ||||
| 16 | + | var receivedAuth string | |||
| 17 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 18 | + | if r.URL.Path != "/admin/status" { | |||
| 19 | + | w.WriteHeader(http.StatusNotFound) | |||
| 20 | + | return | |||
| 21 | + | } | |||
| 22 | + | receivedAuth = r.Header.Get("Authorization") | |||
| 23 | + | w.Header().Set("Content-Type", "application/json") | |||
| 24 | + | w.WriteHeader(http.StatusOK) | |||
| 25 | + | _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`)) | |||
| 26 | + | })) | |||
| 27 | + | defer server.Close() | |||
| 28 | + | ||||
| 29 | + | config := &remote.ClientConfig{ | |||
| 30 | + | Active: "prod", | |||
| 31 | + | Remotes: map[string]remote.RemoteEntry{ | |||
| 32 | + | "prod": {URL: server.URL}, | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config)) | |||
| 36 | + | ||||
| 37 | + | err := runAuthLoginWithToken("", "token123") | |||
| 38 | + | assert.NoError(t, err) | |||
| 39 | + | ||||
| 40 | + | loaded, err := remote.LoadRemotes("") | |||
| 41 | + | assert.NoError(t, err) | |||
| 42 | + | assert.Equal(t, "token123", loaded.Remotes["prod"].Token) | |||
| 43 | + | assert.Equal(t, "Bearer token123", receivedAuth) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) { | |||
| 47 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |||
| 48 | + | ||||
| 49 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 50 | + | if r.URL.Path != "/admin/status" { | |||
| 51 | + | w.WriteHeader(http.StatusNotFound) | |||
| 52 | + | return | |||
| 53 | + | } | |||
| 54 | + | w.WriteHeader(http.StatusUnauthorized) | |||
| 55 | + | _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) | |||
| 56 | + | })) | |||
| 57 | + | defer server.Close() | |||
| 58 | + | ||||
| 59 | + | config := &remote.ClientConfig{ | |||
| 60 | + | Active: "prod", | |||
| 61 | + | Remotes: map[string]remote.RemoteEntry{ | |||
| 62 | + | "prod": {URL: server.URL}, | |||
| 63 | + | }, | |||
| 64 | + | } | |||
| 65 | + | assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config)) | |||
| 66 | + | ||||
| 67 | + | err := runAuthLoginWithToken("", "token123") | |||
| 68 | + | assert.NoError(t, err) | |||
| 69 | + | ||||
| 70 | + | loaded, err := remote.LoadRemotes("") | |||
| 71 | + | assert.NoError(t, err) | |||
| 72 | + | assert.Equal(t, "token123", loaded.Remotes["prod"].Token) | |||
| 73 | + | } | |||
A internal/adapters/out/domainsecrets/helpers_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package domainsecrets | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zerowrap" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func testLogger() zerowrap.Logger { | |
| 11 | + | return zerowrap.Default() | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // CleanupPassAttachment removes attachment secrets from pass for testing. | |
| 15 | + | // It's a test helper that should be called with defer to ensure cleanup. | |
| 16 | + | // This function is exported to be shared across test packages. | |
| 17 | + | func CleanupPassAttachment(_ *testing.T, containerName string, keys []string) { | |
| 18 | + | for _, key := range keys { | |
| 19 | + | path := fmt.Sprintf("%s/%s/%s", PassAttachmentPath, containerName, key) | |
| 20 | + | _ = passCmd("rm", "-f", path) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | |
| 24 | + | _ = passCmd("rm", "-f", manifestPath) | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package domainsecrets | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zerowrap" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func testLogger() zerowrap.Logger { | |||
| 11 | + | return zerowrap.Default() | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // CleanupPassAttachment removes attachment secrets from pass for testing. | |||
| 15 | + | // It's a test helper that should be called with defer to ensure cleanup. | |||
| 16 | + | // This function is exported to be shared across test packages. | |||
| 17 | + | func CleanupPassAttachment(_ *testing.T, containerName string, keys []string) { | |||
| 18 | + | for _, key := range keys { | |||
| 19 | + | path := fmt.Sprintf("%s/%s/%s", PassAttachmentPath, containerName, key) | |||
| 20 | + | _ = passCmd("rm", "-f", path) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | |||
| 24 | + | _ = passCmd("rm", "-f", manifestPath) | |||
| 25 | + | } | |||
A internal/adapters/out/domainsecrets/pass_store.go +693 -0
| @@ -0,0 +1,693 @@ | |||
| 1 | + | // Package domainsecrets implements the DomainSecretStore adapter using pass. | |
| 2 | + | package domainsecrets | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "os/exec" | |
| 8 | + | "regexp" | |
| 9 | + | "sort" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zerowrap" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/out/secrets" | |
| 16 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 17 | + | "github.com/bnema/gordon/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // ansiRegex matches ANSI escape sequences for stripping from pass output. | |
| 21 | + | var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | PassDomainSecretsPath = "gordon/env" //nolint:gosec // Not a credential, this is a pass store path. | |
| 25 | + | PassAttachmentPath = "gordon/env/attachments" //nolint:gosec // Not a credential, this is a pass store path. | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // PassStore implements the DomainSecretStore interface using the pass password manager. | |
| 29 | + | type PassStore struct { | |
| 30 | + | timeout time.Duration | |
| 31 | + | log zerowrap.Logger | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // NewPassStore creates a new pass-based domain secret store. | |
| 35 | + | func NewPassStore(log zerowrap.Logger) (*PassStore, error) { | |
| 36 | + | if err := exec.Command("pass", "version").Run(); err != nil { | |
| 37 | + | return nil, fmt.Errorf("pass is not available: %w", err) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | log.Debug(). | |
| 41 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 42 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 43 | + | Str("provider", "pass"). | |
| 44 | + | Msg("domain secret store initialized") | |
| 45 | + | ||
| 46 | + | return &PassStore{ | |
| 47 | + | timeout: 10 * time.Second, | |
| 48 | + | log: log, | |
| 49 | + | }, nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // ListKeys returns the list of secret keys for a domain (not values). | |
| 53 | + | func (s *PassStore) ListKeys(domainName string) ([]string, error) { | |
| 54 | + | manifestPath, err := s.manifestPath(domainName) | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, err | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 60 | + | defer cancel() | |
| 61 | + | ||
| 62 | + | content, exists, err := s.passShow(ctx, manifestPath) | |
| 63 | + | if err != nil { | |
| 64 | + | return nil, err | |
| 65 | + | } | |
| 66 | + | if !exists { | |
| 67 | + | return []string{}, nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | keys := []string{} | |
| 71 | + | for _, line := range strings.Split(content, "\n") { | |
| 72 | + | key := strings.TrimSpace(line) | |
| 73 | + | if key == "" { | |
| 74 | + | continue | |
| 75 | + | } | |
| 76 | + | keys = append(keys, key) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | return keys, nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // GetAll returns all secrets for a domain as a key-value map. | |
| 83 | + | func (s *PassStore) GetAll(domainName string) (map[string]string, error) { | |
| 84 | + | keys, err := s.ListKeys(domainName) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, err | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | secretsMap := make(map[string]string) | |
| 90 | + | for _, key := range keys { | |
| 91 | + | keyPath, err := s.keyPath(domainName, key) | |
| 92 | + | if err != nil { | |
| 93 | + | return nil, err | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 97 | + | value, exists, err := s.passShow(ctx, keyPath) | |
| 98 | + | cancel() | |
| 99 | + | if err != nil { | |
| 100 | + | return nil, err | |
| 101 | + | } | |
| 102 | + | if !exists { | |
| 103 | + | s.log.Warn(). | |
| 104 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 105 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 106 | + | Str("domain", domainName). | |
| 107 | + | Str("key", key). | |
| 108 | + | Msg("secret listed in manifest but missing in pass") | |
| 109 | + | continue | |
| 110 | + | } | |
| 111 | + | secretsMap[key] = value | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | return secretsMap, nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Set sets or updates multiple secrets for a domain. | |
| 118 | + | func (s *PassStore) Set(domainName string, secretsMap map[string]string) error { | |
| 119 | + | if _, err := s.domainPath(domainName); err != nil { | |
| 120 | + | return err | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | existingKeys, err := s.ListKeys(domainName) | |
| 124 | + | if err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | existingSet := make(map[string]struct{}, len(existingKeys)) | |
| 129 | + | for _, key := range existingKeys { | |
| 130 | + | existingSet[key] = struct{}{} | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | insertedNewPaths := make([]string, 0, len(secretsMap)) | |
| 134 | + | for key, value := range secretsMap { | |
| 135 | + | if err := domain.ValidateEnvKey(key); err != nil { | |
| 136 | + | return err | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | keyPath, err := s.keyPath(domainName, key) | |
| 140 | + | if err != nil { | |
| 141 | + | return err | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 145 | + | err = s.passInsert(ctx, keyPath, value) | |
| 146 | + | cancel() | |
| 147 | + | if err != nil { | |
| 148 | + | s.cleanupInsertedPaths(insertedNewPaths) | |
| 149 | + | return fmt.Errorf("failed to store secret %s: %w", key, err) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | if _, exists := existingSet[key]; !exists { | |
| 153 | + | insertedNewPaths = append(insertedNewPaths, keyPath) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |
| 158 | + | for _, key := range existingKeys { | |
| 159 | + | keySet[key] = struct{}{} | |
| 160 | + | } | |
| 161 | + | for key := range secretsMap { | |
| 162 | + | keySet[key] = struct{}{} | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | keys := make([]string, 0, len(keySet)) | |
| 166 | + | for key := range keySet { | |
| 167 | + | keys = append(keys, key) | |
| 168 | + | } | |
| 169 | + | sort.Strings(keys) | |
| 170 | + | ||
| 171 | + | manifestPath, err := s.manifestPath(domainName) | |
| 172 | + | if err != nil { | |
| 173 | + | return err | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 177 | + | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |
| 178 | + | cancel() | |
| 179 | + | s.cleanupInsertedPaths(insertedNewPaths) | |
| 180 | + | return fmt.Errorf("failed to update manifest: %w", err) | |
| 181 | + | } | |
| 182 | + | cancel() | |
| 183 | + | ||
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // Delete removes a specific secret key from a domain. | |
| 188 | + | func (s *PassStore) Delete(domainName, key string) error { | |
| 189 | + | if _, err := s.domainPath(domainName); err != nil { | |
| 190 | + | return err | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | keyPath, err := s.keyPath(domainName, key) | |
| 194 | + | if err != nil { | |
| 195 | + | return err | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 199 | + | defer rmCancel() | |
| 200 | + | if err := s.passRemove(rmCtx, keyPath); err != nil { | |
| 201 | + | return err | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | keys, err := s.ListKeys(domainName) | |
| 205 | + | if err != nil { | |
| 206 | + | return err | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | updated := make([]string, 0, len(keys)) | |
| 210 | + | for _, existingKey := range keys { | |
| 211 | + | if existingKey == key { | |
| 212 | + | continue | |
| 213 | + | } | |
| 214 | + | updated = append(updated, existingKey) | |
| 215 | + | } | |
| 216 | + | sort.Strings(updated) | |
| 217 | + | ||
| 218 | + | manifestPath, err := s.manifestPath(domainName) | |
| 219 | + | if err != nil { | |
| 220 | + | return err | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 224 | + | defer insertCancel() | |
| 225 | + | if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil { | |
| 226 | + | return fmt.Errorf("failed to update manifest: %w", err) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | return nil | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |
| 233 | + | func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error { | |
| 234 | + | if _, err := s.attachmentPath(containerName); err != nil { | |
| 235 | + | return err | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | existingKeys, err := s.listAttachmentKeys(containerName) | |
| 239 | + | if err != nil { | |
| 240 | + | return err | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | existingSet := make(map[string]struct{}, len(existingKeys)) | |
| 244 | + | for _, key := range existingKeys { | |
| 245 | + | existingSet[key] = struct{}{} | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | insertedNewPaths := make([]string, 0, len(secretsMap)) | |
| 249 | + | for key, value := range secretsMap { | |
| 250 | + | if err := domain.ValidateEnvKey(key); err != nil { | |
| 251 | + | return err | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | keyPath, err := s.attachmentKeyPath(containerName, key) | |
| 255 | + | if err != nil { | |
| 256 | + | return err | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 260 | + | err = s.passInsert(ctx, keyPath, value) | |
| 261 | + | cancel() | |
| 262 | + | if err != nil { | |
| 263 | + | s.cleanupInsertedPaths(insertedNewPaths) | |
| 264 | + | return fmt.Errorf("failed to store attachment secret %s: %w", key, err) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | if _, exists := existingSet[key]; !exists { | |
| 268 | + | insertedNewPaths = append(insertedNewPaths, keyPath) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |
| 273 | + | for _, key := range existingKeys { | |
| 274 | + | keySet[key] = struct{}{} | |
| 275 | + | } | |
| 276 | + | for key := range secretsMap { | |
| 277 | + | keySet[key] = struct{}{} | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | keys := make([]string, 0, len(keySet)) | |
| 281 | + | for key := range keySet { | |
| 282 | + | keys = append(keys, key) | |
| 283 | + | } | |
| 284 | + | sort.Strings(keys) | |
| 285 | + | ||
| 286 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |
| 287 | + | if err != nil { | |
| 288 | + | return err | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 292 | + | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |
| 293 | + | cancel() | |
| 294 | + | s.cleanupInsertedPaths(insertedNewPaths) | |
| 295 | + | return fmt.Errorf("failed to update attachment manifest: %w", err) | |
| 296 | + | } | |
| 297 | + | cancel() | |
| 298 | + | ||
| 299 | + | return nil | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 303 | + | func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) { | |
| 304 | + | keys, err := s.listAttachmentKeys(containerName) | |
| 305 | + | if err != nil { | |
| 306 | + | return nil, err | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | secretsMap := make(map[string]string) | |
| 310 | + | for _, key := range keys { | |
| 311 | + | keyPath, err := s.attachmentKeyPath(containerName, key) | |
| 312 | + | if err != nil { | |
| 313 | + | return nil, err | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 317 | + | value, exists, err := s.passShow(ctx, keyPath) | |
| 318 | + | cancel() | |
| 319 | + | if err != nil { | |
| 320 | + | return nil, err | |
| 321 | + | } | |
| 322 | + | if !exists { | |
| 323 | + | s.log.Warn(). | |
| 324 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 325 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 326 | + | Str("container", containerName). | |
| 327 | + | Str("key", key). | |
| 328 | + | Msg("attachment secret listed in manifest but missing in pass") | |
| 329 | + | continue | |
| 330 | + | } | |
| 331 | + | secretsMap[key] = value | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | return secretsMap, nil | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // ListAttachmentKeys finds attachment secrets for a domain from pass. | |
| 338 | + | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |
| 339 | + | func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | |
| 340 | + | if _, err := s.domainPath(domainName); err != nil { | |
| 341 | + | return nil, err | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 345 | + | defer cancel() | |
| 346 | + | ||
| 347 | + | containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath) | |
| 348 | + | if err != nil { | |
| 349 | + | return nil, err | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // Try both new and legacy sanitization for backwards compatibility | |
| 353 | + | sanitizedDomain := domain.SanitizeDomainForContainer(domainName) | |
| 354 | + | sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName) | |
| 355 | + | prefixes := []string{ | |
| 356 | + | "gordon-" + sanitizedDomain + "-", // New format (collision-resistant) | |
| 357 | + | "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible) | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | seen := make(map[string]bool) | |
| 361 | + | var results []out.AttachmentSecrets | |
| 362 | + | for _, containerName := range containers { | |
| 363 | + | matches := false | |
| 364 | + | for _, prefix := range prefixes { | |
| 365 | + | if strings.HasPrefix(containerName, prefix) { | |
| 366 | + | matches = true | |
| 367 | + | break | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | if !matches { | |
| 371 | + | continue | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // Deduplicate results | |
| 375 | + | if seen[containerName] { | |
| 376 | + | continue | |
| 377 | + | } | |
| 378 | + | seen[containerName] = true | |
| 379 | + | ||
| 380 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | |
| 381 | + | if err := secrets.ValidatePath(manifestPath); err != nil { | |
| 382 | + | return nil, err | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 386 | + | content, exists, err := s.passShow(showCtx, manifestPath) | |
| 387 | + | showCancel() | |
| 388 | + | if err != nil { | |
| 389 | + | return nil, err | |
| 390 | + | } | |
| 391 | + | if !exists { | |
| 392 | + | continue | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | keys := []string{} | |
| 396 | + | for _, line := range strings.Split(content, "\n") { | |
| 397 | + | key := strings.TrimSpace(line) | |
| 398 | + | if key == "" { | |
| 399 | + | continue | |
| 400 | + | } | |
| 401 | + | keys = append(keys, key) | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | if len(keys) > 0 { | |
| 405 | + | results = append(results, out.AttachmentSecrets{ | |
| 406 | + | Service: containerName, | |
| 407 | + | Keys: keys, | |
| 408 | + | }) | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | return results, nil | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func (s *PassStore) domainPath(domainName string) (string, error) { | |
| 416 | + | safeDomain, err := s.sanitizeDomain(domainName) | |
| 417 | + | if err != nil { | |
| 418 | + | return "", err | |
| 419 | + | } | |
| 420 | + | path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain) | |
| 421 | + | if err := secrets.ValidatePath(path); err != nil { | |
| 422 | + | return "", err | |
| 423 | + | } | |
| 424 | + | return path, nil | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func (s *PassStore) keyPath(domainName, key string) (string, error) { | |
| 428 | + | domainPath, err := s.domainPath(domainName) | |
| 429 | + | if err != nil { | |
| 430 | + | return "", err | |
| 431 | + | } | |
| 432 | + | if err := domain.ValidateEnvKey(key); err != nil { | |
| 433 | + | return "", err | |
| 434 | + | } | |
| 435 | + | path := fmt.Sprintf("%s/%s", domainPath, key) | |
| 436 | + | if err := secrets.ValidatePath(path); err != nil { | |
| 437 | + | return "", err | |
| 438 | + | } | |
| 439 | + | return path, nil | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | func (s *PassStore) manifestPath(domainName string) (string, error) { | |
| 443 | + | domainPath, err := s.domainPath(domainName) | |
| 444 | + | if err != nil { | |
| 445 | + | return "", err | |
| 446 | + | } | |
| 447 | + | path := fmt.Sprintf("%s/.keys", domainPath) | |
| 448 | + | if err := secrets.ValidatePath(path); err != nil { | |
| 449 | + | return "", err | |
| 450 | + | } | |
| 451 | + | return path, nil | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | func (s *PassStore) attachmentPath(containerName string) (string, error) { | |
| 455 | + | if err := domain.ValidateContainerName(containerName); err != nil { | |
| 456 | + | return "", err | |
| 457 | + | } | |
| 458 | + | path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName) | |
| 459 | + | if err := secrets.ValidatePath(path); err != nil { | |
| 460 | + | return "", err | |
| 461 | + | } | |
| 462 | + | return path, nil | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) { | |
| 466 | + | attachmentPath, err := s.attachmentPath(containerName) | |
| 467 | + | if err != nil { | |
| 468 | + | return "", err | |
| 469 | + | } | |
| 470 | + | if err := domain.ValidateEnvKey(key); err != nil { | |
| 471 | + | return "", err | |
| 472 | + | } | |
| 473 | + | path := fmt.Sprintf("%s/%s", attachmentPath, key) | |
| 474 | + | if err := secrets.ValidatePath(path); err != nil { | |
| 475 | + | return "", err | |
| 476 | + | } | |
| 477 | + | return path, nil | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | func (s *PassStore) attachmentManifestPath(containerName string) (string, error) { | |
| 481 | + | attachmentPath, err := s.attachmentPath(containerName) | |
| 482 | + | if err != nil { | |
| 483 | + | return "", err | |
| 484 | + | } | |
| 485 | + | path := fmt.Sprintf("%s/.keys", attachmentPath) | |
| 486 | + | if err := secrets.ValidatePath(path); err != nil { | |
| 487 | + | return "", err | |
| 488 | + | } | |
| 489 | + | return path, nil | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) { | |
| 493 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |
| 494 | + | if err != nil { | |
| 495 | + | return nil, err | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 499 | + | defer cancel() | |
| 500 | + | ||
| 501 | + | content, exists, err := s.passShow(ctx, manifestPath) | |
| 502 | + | if err != nil { | |
| 503 | + | return nil, err | |
| 504 | + | } | |
| 505 | + | if !exists { | |
| 506 | + | return []string{}, nil | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | keys := []string{} | |
| 510 | + | for _, line := range strings.Split(content, "\n") { | |
| 511 | + | key := strings.TrimSpace(line) | |
| 512 | + | if key == "" { | |
| 513 | + | continue | |
| 514 | + | } | |
| 515 | + | keys = append(keys, key) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | return keys, nil | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | func (s *PassStore) sanitizeDomain(domainName string) (string, error) { | |
| 522 | + | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 523 | + | if err != nil { | |
| 524 | + | s.log.Warn(). | |
| 525 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 526 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 527 | + | Str("domain", domainName). | |
| 528 | + | Err(err). | |
| 529 | + | Msg("rejected invalid domain") | |
| 530 | + | return "", domain.ErrPathTraversal | |
| 531 | + | } | |
| 532 | + | return safeDomain, nil | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | // ManifestExists checks if the manifest exists for a domain. | |
| 536 | + | func (s *PassStore) ManifestExists(domainName string) (bool, error) { | |
| 537 | + | manifestPath, err := s.manifestPath(domainName) | |
| 538 | + | if err != nil { | |
| 539 | + | return false, err | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 543 | + | defer cancel() | |
| 544 | + | ||
| 545 | + | _, exists, err := s.passShow(ctx, manifestPath) | |
| 546 | + | if err != nil { | |
| 547 | + | return false, err | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | return exists, nil | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |
| 554 | + | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | |
| 555 | + | cmd.Stdin = strings.NewReader(value) | |
| 556 | + | output, err := cmd.CombinedOutput() | |
| 557 | + | if err != nil { | |
| 558 | + | return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err) | |
| 559 | + | } | |
| 560 | + | return nil | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | func (s *PassStore) passRemove(ctx context.Context, path string) error { | |
| 564 | + | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path) | |
| 565 | + | output, err := cmd.CombinedOutput() | |
| 566 | + | if err != nil { | |
| 567 | + | if passEntryMissing(string(output)) { | |
| 568 | + | return nil | |
| 569 | + | } | |
| 570 | + | return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err) | |
| 571 | + | } | |
| 572 | + | return nil | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) { | |
| 576 | + | cmd := exec.CommandContext(ctx, "pass", "show", path) | |
| 577 | + | output, err := cmd.CombinedOutput() | |
| 578 | + | if err != nil { | |
| 579 | + | if passEntryMissing(string(output)) { | |
| 580 | + | return "", false, nil | |
| 581 | + | } | |
| 582 | + | return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | clean := ansiRegex.ReplaceAllString(string(output), "") | |
| 586 | + | clean = strings.TrimRight(clean, "\r\n") | |
| 587 | + | return clean, true, nil | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) { | |
| 591 | + | if err := secrets.ValidatePath(basePath); err != nil { | |
| 592 | + | return nil, err | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | cmd := exec.CommandContext(ctx, "pass", "ls", basePath) | |
| 596 | + | output, err := cmd.CombinedOutput() | |
| 597 | + | if err != nil { | |
| 598 | + | if passEntryMissing(string(output)) { | |
| 599 | + | return []string{}, nil | |
| 600 | + | } | |
| 601 | + | return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err) | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | entries := []string{} | |
| 605 | + | for _, entry := range parsePassListOutput(basePath, string(output)) { | |
| 606 | + | if entry.depth == 1 { | |
| 607 | + | entries = append(entries, entry.name) | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | return entries, nil | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | type passListEntry struct { | |
| 615 | + | name string | |
| 616 | + | depth int | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | func parsePassListOutput(basePath, output string) []passListEntry { | |
| 620 | + | lines := strings.Split(output, "\n") | |
| 621 | + | entries := []passListEntry{} | |
| 622 | + | ||
| 623 | + | for _, line := range lines { | |
| 624 | + | line = ansiRegex.ReplaceAllString(line, "") | |
| 625 | + | if strings.TrimSpace(line) == "" { | |
| 626 | + | continue | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | depth := 0 | |
| 630 | + | prefixLoop: | |
| 631 | + | for { | |
| 632 | + | switch { | |
| 633 | + | case strings.HasPrefix(line, "│ "): | |
| 634 | + | line = strings.TrimPrefix(line, "│ ") | |
| 635 | + | depth++ | |
| 636 | + | case strings.HasPrefix(line, "| "): | |
| 637 | + | line = strings.TrimPrefix(line, "| ") | |
| 638 | + | depth++ | |
| 639 | + | case strings.HasPrefix(line, " "): | |
| 640 | + | line = strings.TrimPrefix(line, " ") | |
| 641 | + | depth++ | |
| 642 | + | default: | |
| 643 | + | break prefixLoop | |
| 644 | + | } | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | switch { | |
| 648 | + | case strings.HasPrefix(line, "├── "): | |
| 649 | + | line = strings.TrimPrefix(line, "├── ") | |
| 650 | + | depth++ | |
| 651 | + | case strings.HasPrefix(line, "└── "): | |
| 652 | + | line = strings.TrimPrefix(line, "└── ") | |
| 653 | + | depth++ | |
| 654 | + | case strings.HasPrefix(line, "|-- "): | |
| 655 | + | line = strings.TrimPrefix(line, "|-- ") | |
| 656 | + | depth++ | |
| 657 | + | case strings.HasPrefix(line, "`-- "): | |
| 658 | + | line = strings.TrimPrefix(line, "`-- ") | |
| 659 | + | depth++ | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | name := strings.TrimSpace(line) | |
| 663 | + | if name == "" || name == basePath { | |
| 664 | + | continue | |
| 665 | + | } | |
| 666 | + | if depth == 0 { | |
| 667 | + | continue | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | entries = append(entries, passListEntry{name: name, depth: depth}) | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | return entries | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | func passEntryMissing(output string) bool { | |
| 677 | + | clean := ansiRegex.ReplaceAllString(output, "") | |
| 678 | + | lower := strings.ToLower(clean) | |
| 679 | + | return strings.Contains(lower, "not in the password store") || | |
| 680 | + | strings.Contains(lower, "password store is empty") | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | func (s *PassStore) cleanupInsertedPaths(paths []string) { | |
| 684 | + | if len(paths) == 0 { | |
| 685 | + | return | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | for _, path := range paths { | |
| 689 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 690 | + | _ = s.passRemove(ctx, path) | |
| 691 | + | cancel() | |
| 692 | + | } | |
| 693 | + | } | |
| @@ -0,0 +1,693 @@ | |||||
| 1 | + | // Package domainsecrets implements the DomainSecretStore adapter using pass. | |||
| 2 | + | package domainsecrets | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "regexp" | |||
| 9 | + | "sort" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/gordon/internal/adapters/out/secrets" | |||
| 16 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 17 | + | "github.com/bnema/gordon/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // ansiRegex matches ANSI escape sequences for stripping from pass output. | |||
| 21 | + | var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | PassDomainSecretsPath = "gordon/env" //nolint:gosec // Not a credential, this is a pass store path. | |||
| 25 | + | PassAttachmentPath = "gordon/env/attachments" //nolint:gosec // Not a credential, this is a pass store path. | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // PassStore implements the DomainSecretStore interface using the pass password manager. | |||
| 29 | + | type PassStore struct { | |||
| 30 | + | timeout time.Duration | |||
| 31 | + | log zerowrap.Logger | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // NewPassStore creates a new pass-based domain secret store. | |||
| 35 | + | func NewPassStore(log zerowrap.Logger) (*PassStore, error) { | |||
| 36 | + | if err := exec.Command("pass", "version").Run(); err != nil { | |||
| 37 | + | return nil, fmt.Errorf("pass is not available: %w", err) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | log.Debug(). | |||
| 41 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 42 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 43 | + | Str("provider", "pass"). | |||
| 44 | + | Msg("domain secret store initialized") | |||
| 45 | + | ||||
| 46 | + | return &PassStore{ | |||
| 47 | + | timeout: 10 * time.Second, | |||
| 48 | + | log: log, | |||
| 49 | + | }, nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 53 | + | func (s *PassStore) ListKeys(domainName string) ([]string, error) { | |||
| 54 | + | manifestPath, err := s.manifestPath(domainName) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return nil, err | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 60 | + | defer cancel() | |||
| 61 | + | ||||
| 62 | + | content, exists, err := s.passShow(ctx, manifestPath) | |||
| 63 | + | if err != nil { | |||
| 64 | + | return nil, err | |||
| 65 | + | } | |||
| 66 | + | if !exists { | |||
| 67 | + | return []string{}, nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | keys := []string{} | |||
| 71 | + | for _, line := range strings.Split(content, "\n") { | |||
| 72 | + | key := strings.TrimSpace(line) | |||
| 73 | + | if key == "" { | |||
| 74 | + | continue | |||
| 75 | + | } | |||
| 76 | + | keys = append(keys, key) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | return keys, nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // GetAll returns all secrets for a domain as a key-value map. | |||
| 83 | + | func (s *PassStore) GetAll(domainName string) (map[string]string, error) { | |||
| 84 | + | keys, err := s.ListKeys(domainName) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return nil, err | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | secretsMap := make(map[string]string) | |||
| 90 | + | for _, key := range keys { | |||
| 91 | + | keyPath, err := s.keyPath(domainName, key) | |||
| 92 | + | if err != nil { | |||
| 93 | + | return nil, err | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 97 | + | value, exists, err := s.passShow(ctx, keyPath) | |||
| 98 | + | cancel() | |||
| 99 | + | if err != nil { | |||
| 100 | + | return nil, err | |||
| 101 | + | } | |||
| 102 | + | if !exists { | |||
| 103 | + | s.log.Warn(). | |||
| 104 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 105 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 106 | + | Str("domain", domainName). | |||
| 107 | + | Str("key", key). | |||
| 108 | + | Msg("secret listed in manifest but missing in pass") | |||
| 109 | + | continue | |||
| 110 | + | } | |||
| 111 | + | secretsMap[key] = value | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | return secretsMap, nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // Set sets or updates multiple secrets for a domain. | |||
| 118 | + | func (s *PassStore) Set(domainName string, secretsMap map[string]string) error { | |||
| 119 | + | if _, err := s.domainPath(domainName); err != nil { | |||
| 120 | + | return err | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | existingKeys, err := s.ListKeys(domainName) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | existingSet := make(map[string]struct{}, len(existingKeys)) | |||
| 129 | + | for _, key := range existingKeys { | |||
| 130 | + | existingSet[key] = struct{}{} | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | insertedNewPaths := make([]string, 0, len(secretsMap)) | |||
| 134 | + | for key, value := range secretsMap { | |||
| 135 | + | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 136 | + | return err | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | keyPath, err := s.keyPath(domainName, key) | |||
| 140 | + | if err != nil { | |||
| 141 | + | return err | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 145 | + | err = s.passInsert(ctx, keyPath, value) | |||
| 146 | + | cancel() | |||
| 147 | + | if err != nil { | |||
| 148 | + | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 149 | + | return fmt.Errorf("failed to store secret %s: %w", key, err) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | if _, exists := existingSet[key]; !exists { | |||
| 153 | + | insertedNewPaths = append(insertedNewPaths, keyPath) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |||
| 158 | + | for _, key := range existingKeys { | |||
| 159 | + | keySet[key] = struct{}{} | |||
| 160 | + | } | |||
| 161 | + | for key := range secretsMap { | |||
| 162 | + | keySet[key] = struct{}{} | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | keys := make([]string, 0, len(keySet)) | |||
| 166 | + | for key := range keySet { | |||
| 167 | + | keys = append(keys, key) | |||
| 168 | + | } | |||
| 169 | + | sort.Strings(keys) | |||
| 170 | + | ||||
| 171 | + | manifestPath, err := s.manifestPath(domainName) | |||
| 172 | + | if err != nil { | |||
| 173 | + | return err | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 177 | + | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |||
| 178 | + | cancel() | |||
| 179 | + | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 180 | + | return fmt.Errorf("failed to update manifest: %w", err) | |||
| 181 | + | } | |||
| 182 | + | cancel() | |||
| 183 | + | ||||
| 184 | + | return nil | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // Delete removes a specific secret key from a domain. | |||
| 188 | + | func (s *PassStore) Delete(domainName, key string) error { | |||
| 189 | + | if _, err := s.domainPath(domainName); err != nil { | |||
| 190 | + | return err | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | keyPath, err := s.keyPath(domainName, key) | |||
| 194 | + | if err != nil { | |||
| 195 | + | return err | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 199 | + | defer rmCancel() | |||
| 200 | + | if err := s.passRemove(rmCtx, keyPath); err != nil { | |||
| 201 | + | return err | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | keys, err := s.ListKeys(domainName) | |||
| 205 | + | if err != nil { | |||
| 206 | + | return err | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | updated := make([]string, 0, len(keys)) | |||
| 210 | + | for _, existingKey := range keys { | |||
| 211 | + | if existingKey == key { | |||
| 212 | + | continue | |||
| 213 | + | } | |||
| 214 | + | updated = append(updated, existingKey) | |||
| 215 | + | } | |||
| 216 | + | sort.Strings(updated) | |||
| 217 | + | ||||
| 218 | + | manifestPath, err := s.manifestPath(domainName) | |||
| 219 | + | if err != nil { | |||
| 220 | + | return err | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 224 | + | defer insertCancel() | |||
| 225 | + | if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil { | |||
| 226 | + | return fmt.Errorf("failed to update manifest: %w", err) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | return nil | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |||
| 233 | + | func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error { | |||
| 234 | + | if _, err := s.attachmentPath(containerName); err != nil { | |||
| 235 | + | return err | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | existingKeys, err := s.listAttachmentKeys(containerName) | |||
| 239 | + | if err != nil { | |||
| 240 | + | return err | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | existingSet := make(map[string]struct{}, len(existingKeys)) | |||
| 244 | + | for _, key := range existingKeys { | |||
| 245 | + | existingSet[key] = struct{}{} | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | insertedNewPaths := make([]string, 0, len(secretsMap)) | |||
| 249 | + | for key, value := range secretsMap { | |||
| 250 | + | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 251 | + | return err | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | keyPath, err := s.attachmentKeyPath(containerName, key) | |||
| 255 | + | if err != nil { | |||
| 256 | + | return err | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 260 | + | err = s.passInsert(ctx, keyPath, value) | |||
| 261 | + | cancel() | |||
| 262 | + | if err != nil { | |||
| 263 | + | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 264 | + | return fmt.Errorf("failed to store attachment secret %s: %w", key, err) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | if _, exists := existingSet[key]; !exists { | |||
| 268 | + | insertedNewPaths = append(insertedNewPaths, keyPath) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |||
| 273 | + | for _, key := range existingKeys { | |||
| 274 | + | keySet[key] = struct{}{} | |||
| 275 | + | } | |||
| 276 | + | for key := range secretsMap { | |||
| 277 | + | keySet[key] = struct{}{} | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | keys := make([]string, 0, len(keySet)) | |||
| 281 | + | for key := range keySet { | |||
| 282 | + | keys = append(keys, key) | |||
| 283 | + | } | |||
| 284 | + | sort.Strings(keys) | |||
| 285 | + | ||||
| 286 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |||
| 287 | + | if err != nil { | |||
| 288 | + | return err | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 292 | + | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |||
| 293 | + | cancel() | |||
| 294 | + | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 295 | + | return fmt.Errorf("failed to update attachment manifest: %w", err) | |||
| 296 | + | } | |||
| 297 | + | cancel() | |||
| 298 | + | ||||
| 299 | + | return nil | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |||
| 303 | + | func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) { | |||
| 304 | + | keys, err := s.listAttachmentKeys(containerName) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return nil, err | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | secretsMap := make(map[string]string) | |||
| 310 | + | for _, key := range keys { | |||
| 311 | + | keyPath, err := s.attachmentKeyPath(containerName, key) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return nil, err | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 317 | + | value, exists, err := s.passShow(ctx, keyPath) | |||
| 318 | + | cancel() | |||
| 319 | + | if err != nil { | |||
| 320 | + | return nil, err | |||
| 321 | + | } | |||
| 322 | + | if !exists { | |||
| 323 | + | s.log.Warn(). | |||
| 324 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 325 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 326 | + | Str("container", containerName). | |||
| 327 | + | Str("key", key). | |||
| 328 | + | Msg("attachment secret listed in manifest but missing in pass") | |||
| 329 | + | continue | |||
| 330 | + | } | |||
| 331 | + | secretsMap[key] = value | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | return secretsMap, nil | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | // ListAttachmentKeys finds attachment secrets for a domain from pass. | |||
| 338 | + | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |||
| 339 | + | func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | |||
| 340 | + | if _, err := s.domainPath(domainName); err != nil { | |||
| 341 | + | return nil, err | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 345 | + | defer cancel() | |||
| 346 | + | ||||
| 347 | + | containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath) | |||
| 348 | + | if err != nil { | |||
| 349 | + | return nil, err | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | // Try both new and legacy sanitization for backwards compatibility | |||
| 353 | + | sanitizedDomain := domain.SanitizeDomainForContainer(domainName) | |||
| 354 | + | sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName) | |||
| 355 | + | prefixes := []string{ | |||
| 356 | + | "gordon-" + sanitizedDomain + "-", // New format (collision-resistant) | |||
| 357 | + | "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible) | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | seen := make(map[string]bool) | |||
| 361 | + | var results []out.AttachmentSecrets | |||
| 362 | + | for _, containerName := range containers { | |||
| 363 | + | matches := false | |||
| 364 | + | for _, prefix := range prefixes { | |||
| 365 | + | if strings.HasPrefix(containerName, prefix) { | |||
| 366 | + | matches = true | |||
| 367 | + | break | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | if !matches { | |||
| 371 | + | continue | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // Deduplicate results | |||
| 375 | + | if seen[containerName] { | |||
| 376 | + | continue | |||
| 377 | + | } | |||
| 378 | + | seen[containerName] = true | |||
| 379 | + | ||||
| 380 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | |||
| 381 | + | if err := secrets.ValidatePath(manifestPath); err != nil { | |||
| 382 | + | return nil, err | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 386 | + | content, exists, err := s.passShow(showCtx, manifestPath) | |||
| 387 | + | showCancel() | |||
| 388 | + | if err != nil { | |||
| 389 | + | return nil, err | |||
| 390 | + | } | |||
| 391 | + | if !exists { | |||
| 392 | + | continue | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | keys := []string{} | |||
| 396 | + | for _, line := range strings.Split(content, "\n") { | |||
| 397 | + | key := strings.TrimSpace(line) | |||
| 398 | + | if key == "" { | |||
| 399 | + | continue | |||
| 400 | + | } | |||
| 401 | + | keys = append(keys, key) | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | if len(keys) > 0 { | |||
| 405 | + | results = append(results, out.AttachmentSecrets{ | |||
| 406 | + | Service: containerName, | |||
| 407 | + | Keys: keys, | |||
| 408 | + | }) | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | return results, nil | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func (s *PassStore) domainPath(domainName string) (string, error) { | |||
| 416 | + | safeDomain, err := s.sanitizeDomain(domainName) | |||
| 417 | + | if err != nil { | |||
| 418 | + | return "", err | |||
| 419 | + | } | |||
| 420 | + | path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain) | |||
| 421 | + | if err := secrets.ValidatePath(path); err != nil { | |||
| 422 | + | return "", err | |||
| 423 | + | } | |||
| 424 | + | return path, nil | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | func (s *PassStore) keyPath(domainName, key string) (string, error) { | |||
| 428 | + | domainPath, err := s.domainPath(domainName) | |||
| 429 | + | if err != nil { | |||
| 430 | + | return "", err | |||
| 431 | + | } | |||
| 432 | + | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 433 | + | return "", err | |||
| 434 | + | } | |||
| 435 | + | path := fmt.Sprintf("%s/%s", domainPath, key) | |||
| 436 | + | if err := secrets.ValidatePath(path); err != nil { | |||
| 437 | + | return "", err | |||
| 438 | + | } | |||
| 439 | + | return path, nil | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | func (s *PassStore) manifestPath(domainName string) (string, error) { | |||
| 443 | + | domainPath, err := s.domainPath(domainName) | |||
| 444 | + | if err != nil { | |||
| 445 | + | return "", err | |||
| 446 | + | } | |||
| 447 | + | path := fmt.Sprintf("%s/.keys", domainPath) | |||
| 448 | + | if err := secrets.ValidatePath(path); err != nil { | |||
| 449 | + | return "", err | |||
| 450 | + | } | |||
| 451 | + | return path, nil | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func (s *PassStore) attachmentPath(containerName string) (string, error) { | |||
| 455 | + | if err := domain.ValidateContainerName(containerName); err != nil { | |||
| 456 | + | return "", err | |||
| 457 | + | } | |||
| 458 | + | path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName) | |||
| 459 | + | if err := secrets.ValidatePath(path); err != nil { | |||
| 460 | + | return "", err | |||
| 461 | + | } | |||
| 462 | + | return path, nil | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) { | |||
| 466 | + | attachmentPath, err := s.attachmentPath(containerName) | |||
| 467 | + | if err != nil { | |||
| 468 | + | return "", err | |||
| 469 | + | } | |||
| 470 | + | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 471 | + | return "", err | |||
| 472 | + | } | |||
| 473 | + | path := fmt.Sprintf("%s/%s", attachmentPath, key) | |||
| 474 | + | if err := secrets.ValidatePath(path); err != nil { | |||
| 475 | + | return "", err | |||
| 476 | + | } | |||
| 477 | + | return path, nil | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | func (s *PassStore) attachmentManifestPath(containerName string) (string, error) { | |||
| 481 | + | attachmentPath, err := s.attachmentPath(containerName) | |||
| 482 | + | if err != nil { | |||
| 483 | + | return "", err | |||
| 484 | + | } | |||
| 485 | + | path := fmt.Sprintf("%s/.keys", attachmentPath) | |||
| 486 | + | if err := secrets.ValidatePath(path); err != nil { | |||
| 487 | + | return "", err | |||
| 488 | + | } | |||
| 489 | + | return path, nil | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) { | |||
| 493 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |||
| 494 | + | if err != nil { | |||
| 495 | + | return nil, err | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 499 | + | defer cancel() | |||
| 500 | + | ||||
| 501 | + | content, exists, err := s.passShow(ctx, manifestPath) | |||
| 502 | + | if err != nil { | |||
| 503 | + | return nil, err | |||
| 504 | + | } | |||
| 505 | + | if !exists { | |||
| 506 | + | return []string{}, nil | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | keys := []string{} | |||
| 510 | + | for _, line := range strings.Split(content, "\n") { | |||
| 511 | + | key := strings.TrimSpace(line) | |||
| 512 | + | if key == "" { | |||
| 513 | + | continue | |||
| 514 | + | } | |||
| 515 | + | keys = append(keys, key) | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | return keys, nil | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | func (s *PassStore) sanitizeDomain(domainName string) (string, error) { | |||
| 522 | + | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |||
| 523 | + | if err != nil { | |||
| 524 | + | s.log.Warn(). | |||
| 525 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 526 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 527 | + | Str("domain", domainName). | |||
| 528 | + | Err(err). | |||
| 529 | + | Msg("rejected invalid domain") | |||
| 530 | + | return "", domain.ErrPathTraversal | |||
| 531 | + | } | |||
| 532 | + | return safeDomain, nil | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | // ManifestExists checks if the manifest exists for a domain. | |||
| 536 | + | func (s *PassStore) ManifestExists(domainName string) (bool, error) { | |||
| 537 | + | manifestPath, err := s.manifestPath(domainName) | |||
| 538 | + | if err != nil { | |||
| 539 | + | return false, err | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 543 | + | defer cancel() | |||
| 544 | + | ||||
| 545 | + | _, exists, err := s.passShow(ctx, manifestPath) | |||
| 546 | + | if err != nil { | |||
| 547 | + | return false, err | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | return exists, nil | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |||
| 554 | + | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | |||
| 555 | + | cmd.Stdin = strings.NewReader(value) | |||
| 556 | + | output, err := cmd.CombinedOutput() | |||
| 557 | + | if err != nil { | |||
| 558 | + | return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err) | |||
| 559 | + | } | |||
| 560 | + | return nil | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | func (s *PassStore) passRemove(ctx context.Context, path string) error { | |||
| 564 | + | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path) | |||
| 565 | + | output, err := cmd.CombinedOutput() | |||
| 566 | + | if err != nil { | |||
| 567 | + | if passEntryMissing(string(output)) { | |||
| 568 | + | return nil | |||
| 569 | + | } | |||
| 570 | + | return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err) | |||
| 571 | + | } | |||
| 572 | + | return nil | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) { | |||
| 576 | + | cmd := exec.CommandContext(ctx, "pass", "show", path) | |||
| 577 | + | output, err := cmd.CombinedOutput() | |||
| 578 | + | if err != nil { | |||
| 579 | + | if passEntryMissing(string(output)) { | |||
| 580 | + | return "", false, nil | |||
| 581 | + | } | |||
| 582 | + | return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err) | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | clean := ansiRegex.ReplaceAllString(string(output), "") | |||
| 586 | + | clean = strings.TrimRight(clean, "\r\n") | |||
| 587 | + | return clean, true, nil | |||
| 588 | + | } | |||
| 589 | + | ||||
| 590 | + | func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) { | |||
| 591 | + | if err := secrets.ValidatePath(basePath); err != nil { | |||
| 592 | + | return nil, err | |||
| 593 | + | } | |||
| 594 | + | ||||
| 595 | + | cmd := exec.CommandContext(ctx, "pass", "ls", basePath) | |||
| 596 | + | output, err := cmd.CombinedOutput() | |||
| 597 | + | if err != nil { | |||
| 598 | + | if passEntryMissing(string(output)) { | |||
| 599 | + | return []string{}, nil | |||
| 600 | + | } | |||
| 601 | + | return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err) | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | entries := []string{} | |||
| 605 | + | for _, entry := range parsePassListOutput(basePath, string(output)) { | |||
| 606 | + | if entry.depth == 1 { | |||
| 607 | + | entries = append(entries, entry.name) | |||
| 608 | + | } | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | return entries, nil | |||
| 612 | + | } | |||
| 613 | + | ||||
| 614 | + | type passListEntry struct { | |||
| 615 | + | name string | |||
| 616 | + | depth int | |||
| 617 | + | } | |||
| 618 | + | ||||
| 619 | + | func parsePassListOutput(basePath, output string) []passListEntry { | |||
| 620 | + | lines := strings.Split(output, "\n") | |||
| 621 | + | entries := []passListEntry{} | |||
| 622 | + | ||||
| 623 | + | for _, line := range lines { | |||
| 624 | + | line = ansiRegex.ReplaceAllString(line, "") | |||
| 625 | + | if strings.TrimSpace(line) == "" { | |||
| 626 | + | continue | |||
| 627 | + | } | |||
| 628 | + | ||||
| 629 | + | depth := 0 | |||
| 630 | + | prefixLoop: | |||
| 631 | + | for { | |||
| 632 | + | switch { | |||
| 633 | + | case strings.HasPrefix(line, "│ "): | |||
| 634 | + | line = strings.TrimPrefix(line, "│ ") | |||
| 635 | + | depth++ | |||
| 636 | + | case strings.HasPrefix(line, "| "): | |||
| 637 | + | line = strings.TrimPrefix(line, "| ") | |||
| 638 | + | depth++ | |||
| 639 | + | case strings.HasPrefix(line, " "): | |||
| 640 | + | line = strings.TrimPrefix(line, " ") | |||
| 641 | + | depth++ | |||
| 642 | + | default: | |||
| 643 | + | break prefixLoop | |||
| 644 | + | } | |||
| 645 | + | } | |||
| 646 | + | ||||
| 647 | + | switch { | |||
| 648 | + | case strings.HasPrefix(line, "├── "): | |||
| 649 | + | line = strings.TrimPrefix(line, "├── ") | |||
| 650 | + | depth++ | |||
| 651 | + | case strings.HasPrefix(line, "└── "): | |||
| 652 | + | line = strings.TrimPrefix(line, "└── ") | |||
| 653 | + | depth++ | |||
| 654 | + | case strings.HasPrefix(line, "|-- "): | |||
| 655 | + | line = strings.TrimPrefix(line, "|-- ") | |||
| 656 | + | depth++ | |||
| 657 | + | case strings.HasPrefix(line, "`-- "): | |||
| 658 | + | line = strings.TrimPrefix(line, "`-- ") | |||
| 659 | + | depth++ | |||
| 660 | + | } | |||
| 661 | + | ||||
| 662 | + | name := strings.TrimSpace(line) | |||
| 663 | + | if name == "" || name == basePath { | |||
| 664 | + | continue | |||
| 665 | + | } | |||
| 666 | + | if depth == 0 { | |||
| 667 | + | continue | |||
| 668 | + | } | |||
| 669 | + | ||||
| 670 | + | entries = append(entries, passListEntry{name: name, depth: depth}) | |||
| 671 | + | } | |||
| 672 | + | ||||
| 673 | + | return entries | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | func passEntryMissing(output string) bool { | |||
| 677 | + | clean := ansiRegex.ReplaceAllString(output, "") | |||
| 678 | + | lower := strings.ToLower(clean) | |||
| 679 | + | return strings.Contains(lower, "not in the password store") || | |||
| 680 | + | strings.Contains(lower, "password store is empty") | |||
| 681 | + | } | |||
| 682 | + | ||||
| 683 | + | func (s *PassStore) cleanupInsertedPaths(paths []string) { | |||
| 684 | + | if len(paths) == 0 { | |||
| 685 | + | return | |||
| 686 | + | } | |||
| 687 | + | ||||
| 688 | + | for _, path := range paths { | |||
| 689 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 690 | + | _ = s.passRemove(ctx, path) | |||
| 691 | + | cancel() | |||
| 692 | + | } | |||
| 693 | + | } | |||
A internal/adapters/out/domainsecrets/pass_store_test.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | package domainsecrets | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os/exec" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func passCmd(args ...string) error { | |
| 17 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 18 | + | defer cancel() | |
| 19 | + | return exec.CommandContext(ctx, "pass", args...).Run() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func requirePass(t *testing.T) { | |
| 23 | + | if err := passCmd("version"); err != nil { | |
| 24 | + | t.Skip("pass not available") | |
| 25 | + | } | |
| 26 | + | if err := passCmd("ls"); err != nil { | |
| 27 | + | t.Skip("pass store not initialized") | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func cleanupPassDomain(_ *testing.T, domainName string, keys []string) { | |
| 32 | + | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 33 | + | if err != nil { | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | for _, key := range keys { | |
| 38 | + | path := fmt.Sprintf("%s/%s/%s", PassDomainSecretsPath, safeDomain, key) | |
| 39 | + | _ = passCmd("rm", "-f", path) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain) | |
| 43 | + | _ = passCmd("rm", "-f", manifestPath) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestPassStore_SetGetDelete(t *testing.T) { | |
| 47 | + | requirePass(t) | |
| 48 | + | ||
| 49 | + | store, err := NewPassStore(testLogger()) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | ||
| 52 | + | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |
| 53 | + | keys := []string{"API_KEY", "DB_PASSWORD"} | |
| 54 | + | defer cleanupPassDomain(t, domainName, keys) | |
| 55 | + | ||
| 56 | + | secretsMap := map[string]string{ | |
| 57 | + | "API_KEY": "alpha", | |
| 58 | + | "DB_PASSWORD": "bravo", | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | err = store.Set(domainName, secretsMap) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | ||
| 64 | + | keysList, err := store.ListKeys(domainName) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | assert.Len(t, keysList, 2) | |
| 67 | + | assert.ElementsMatch(t, keys, keysList) | |
| 68 | + | ||
| 69 | + | values, err := store.GetAll(domainName) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | assert.Equal(t, "alpha", values["API_KEY"]) | |
| 72 | + | assert.Equal(t, "bravo", values["DB_PASSWORD"]) | |
| 73 | + | ||
| 74 | + | err = store.Delete(domainName, "DB_PASSWORD") | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | ||
| 77 | + | keysList, err = store.ListKeys(domainName) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | assert.ElementsMatch(t, []string{"API_KEY"}, keysList) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestPassStore_SetGetAttachment(t *testing.T) { | |
| 83 | + | requirePass(t) | |
| 84 | + | ||
| 85 | + | store, err := NewPassStore(testLogger()) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | ||
| 88 | + | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |
| 89 | + | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |
| 90 | + | defer CleanupPassAttachment(t, containerName, keys) | |
| 91 | + | ||
| 92 | + | secretsMap := map[string]string{ | |
| 93 | + | "POSTGRES_USER": "gitea", | |
| 94 | + | "POSTGRES_PASSWORD": "secret123", | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | err = store.SetAttachment(containerName, secretsMap) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | ||
| 100 | + | values, err := store.GetAllAttachment(containerName) | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |
| 103 | + | assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"]) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) { | |
| 107 | + | requirePass(t) | |
| 108 | + | ||
| 109 | + | store, err := NewPassStore(testLogger()) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | ||
| 112 | + | containerName := fmt.Sprintf("redis-cache-%d", time.Now().UnixNano()) | |
| 113 | + | keys := []string{"REDIS_PASSWORD"} | |
| 114 | + | defer CleanupPassAttachment(t, containerName, keys) | |
| 115 | + | ||
| 116 | + | secretsMap := map[string]string{ | |
| 117 | + | "REDIS_PASSWORD": "redis123", | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | err = store.SetAttachment(containerName, secretsMap) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | ||
| 123 | + | values, err := store.GetAllAttachment(containerName) | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | assert.Len(t, values, 1) | |
| 126 | + | assert.Equal(t, "redis123", values["REDIS_PASSWORD"]) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestParsePassListOutput(t *testing.T) { | |
| 130 | + | tests := []struct { | |
| 131 | + | name string | |
| 132 | + | basePath string | |
| 133 | + | output string | |
| 134 | + | want []passListEntry | |
| 135 | + | }{ | |
| 136 | + | { | |
| 137 | + | name: "simple tree", | |
| 138 | + | basePath: "domain", | |
| 139 | + | output: "domain\n├── key1\n└── key2", | |
| 140 | + | want: []passListEntry{{name: "key1", depth: 1}, {name: "key2", depth: 1}}, | |
| 141 | + | }, | |
| 142 | + | { | |
| 143 | + | name: "nested tree", | |
| 144 | + | basePath: "domain", | |
| 145 | + | output: "domain\n│ ├── subkey1\n│ └── subkey2\n└── key1", | |
| 146 | + | want: []passListEntry{{name: "subkey1", depth: 2}, {name: "subkey2", depth: 2}, {name: "key1", depth: 1}}, | |
| 147 | + | }, | |
| 148 | + | { | |
| 149 | + | name: "empty output", | |
| 150 | + | basePath: "domain", | |
| 151 | + | output: "", | |
| 152 | + | want: []passListEntry{}, | |
| 153 | + | }, | |
| 154 | + | { | |
| 155 | + | name: "ASCII fallback chars", | |
| 156 | + | basePath: "domain", | |
| 157 | + | output: "domain\n| |-- subkey1\n`-- key1", | |
| 158 | + | want: []passListEntry{{name: "subkey1", depth: 2}, {name: "key1", depth: 1}}, | |
| 159 | + | }, | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | for _, tt := range tests { | |
| 163 | + | t.Run(tt.name, func(t *testing.T) { | |
| 164 | + | got := parsePassListOutput(tt.basePath, tt.output) | |
| 165 | + | assert.Equal(t, tt.want, got) | |
| 166 | + | }) | |
| 167 | + | } | |
| 168 | + | } | |
| @@ -0,0 +1,168 @@ | |||||
| 1 | + | package domainsecrets | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func passCmd(args ...string) error { | |||
| 17 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 18 | + | defer cancel() | |||
| 19 | + | return exec.CommandContext(ctx, "pass", args...).Run() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func requirePass(t *testing.T) { | |||
| 23 | + | if err := passCmd("version"); err != nil { | |||
| 24 | + | t.Skip("pass not available") | |||
| 25 | + | } | |||
| 26 | + | if err := passCmd("ls"); err != nil { | |||
| 27 | + | t.Skip("pass store not initialized") | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func cleanupPassDomain(_ *testing.T, domainName string, keys []string) { | |||
| 32 | + | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |||
| 33 | + | if err != nil { | |||
| 34 | + | return | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | for _, key := range keys { | |||
| 38 | + | path := fmt.Sprintf("%s/%s/%s", PassDomainSecretsPath, safeDomain, key) | |||
| 39 | + | _ = passCmd("rm", "-f", path) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain) | |||
| 43 | + | _ = passCmd("rm", "-f", manifestPath) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestPassStore_SetGetDelete(t *testing.T) { | |||
| 47 | + | requirePass(t) | |||
| 48 | + | ||||
| 49 | + | store, err := NewPassStore(testLogger()) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | ||||
| 52 | + | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |||
| 53 | + | keys := []string{"API_KEY", "DB_PASSWORD"} | |||
| 54 | + | defer cleanupPassDomain(t, domainName, keys) | |||
| 55 | + | ||||
| 56 | + | secretsMap := map[string]string{ | |||
| 57 | + | "API_KEY": "alpha", | |||
| 58 | + | "DB_PASSWORD": "bravo", | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | err = store.Set(domainName, secretsMap) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | ||||
| 64 | + | keysList, err := store.ListKeys(domainName) | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | assert.Len(t, keysList, 2) | |||
| 67 | + | assert.ElementsMatch(t, keys, keysList) | |||
| 68 | + | ||||
| 69 | + | values, err := store.GetAll(domainName) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | assert.Equal(t, "alpha", values["API_KEY"]) | |||
| 72 | + | assert.Equal(t, "bravo", values["DB_PASSWORD"]) | |||
| 73 | + | ||||
| 74 | + | err = store.Delete(domainName, "DB_PASSWORD") | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | ||||
| 77 | + | keysList, err = store.ListKeys(domainName) | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | assert.ElementsMatch(t, []string{"API_KEY"}, keysList) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestPassStore_SetGetAttachment(t *testing.T) { | |||
| 83 | + | requirePass(t) | |||
| 84 | + | ||||
| 85 | + | store, err := NewPassStore(testLogger()) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | ||||
| 88 | + | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |||
| 89 | + | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |||
| 90 | + | defer CleanupPassAttachment(t, containerName, keys) | |||
| 91 | + | ||||
| 92 | + | secretsMap := map[string]string{ | |||
| 93 | + | "POSTGRES_USER": "gitea", | |||
| 94 | + | "POSTGRES_PASSWORD": "secret123", | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | err = store.SetAttachment(containerName, secretsMap) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | ||||
| 100 | + | values, err := store.GetAllAttachment(containerName) | |||
| 101 | + | require.NoError(t, err) | |||
| 102 | + | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |||
| 103 | + | assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"]) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) { | |||
| 107 | + | requirePass(t) | |||
| 108 | + | ||||
| 109 | + | store, err := NewPassStore(testLogger()) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | ||||
| 112 | + | containerName := fmt.Sprintf("redis-cache-%d", time.Now().UnixNano()) | |||
| 113 | + | keys := []string{"REDIS_PASSWORD"} | |||
| 114 | + | defer CleanupPassAttachment(t, containerName, keys) | |||
| 115 | + | ||||
| 116 | + | secretsMap := map[string]string{ | |||
| 117 | + | "REDIS_PASSWORD": "redis123", | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | err = store.SetAttachment(containerName, secretsMap) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | ||||
| 123 | + | values, err := store.GetAllAttachment(containerName) | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | assert.Len(t, values, 1) | |||
| 126 | + | assert.Equal(t, "redis123", values["REDIS_PASSWORD"]) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestParsePassListOutput(t *testing.T) { | |||
| 130 | + | tests := []struct { | |||
| 131 | + | name string | |||
| 132 | + | basePath string | |||
| 133 | + | output string | |||
| 134 | + | want []passListEntry | |||
| 135 | + | }{ | |||
| 136 | + | { | |||
| 137 | + | name: "simple tree", | |||
| 138 | + | basePath: "domain", | |||
| 139 | + | output: "domain\n├── key1\n└── key2", | |||
| 140 | + | want: []passListEntry{{name: "key1", depth: 1}, {name: "key2", depth: 1}}, | |||
| 141 | + | }, | |||
| 142 | + | { | |||
| 143 | + | name: "nested tree", | |||
| 144 | + | basePath: "domain", | |||
| 145 | + | output: "domain\n│ ├── subkey1\n│ └── subkey2\n└── key1", | |||
| 146 | + | want: []passListEntry{{name: "subkey1", depth: 2}, {name: "subkey2", depth: 2}, {name: "key1", depth: 1}}, | |||
| 147 | + | }, | |||
| 148 | + | { | |||
| 149 | + | name: "empty output", | |||
| 150 | + | basePath: "domain", | |||
| 151 | + | output: "", | |||
| 152 | + | want: []passListEntry{}, | |||
| 153 | + | }, | |||
| 154 | + | { | |||
| 155 | + | name: "ASCII fallback chars", | |||
| 156 | + | basePath: "domain", | |||
| 157 | + | output: "domain\n| |-- subkey1\n`-- key1", | |||
| 158 | + | want: []passListEntry{{name: "subkey1", depth: 2}, {name: "key1", depth: 1}}, | |||
| 159 | + | }, | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | for _, tt := range tests { | |||
| 163 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 164 | + | got := parsePassListOutput(tt.basePath, tt.output) | |||
| 165 | + | assert.Equal(t, tt.want, got) | |||
| 166 | + | }) | |||
| 167 | + | } | |||
| 168 | + | } | |||
M internal/adapters/out/domainsecrets/store.go +223 -86
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | - | "regexp" | |
| 9 | + | "sort" | |
| 10 | 10 | "strings" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| @@ -86,33 +86,17 @@ func (s *FileStore) GetAll(domainName string) (map[string]string, error) { | |||
| 86 | 86 | return nil, err | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | - | file, err := os.Open(envFile) | |
| 89 | + | data, err := os.ReadFile(envFile) | |
| 90 | 90 | if err != nil { | |
| 91 | 91 | if os.IsNotExist(err) { | |
| 92 | 92 | return map[string]string{}, nil | |
| 93 | 93 | } | |
| 94 | - | return nil, fmt.Errorf("failed to open env file: %w", err) | |
| 95 | - | } | |
| 96 | - | defer file.Close() | |
| 97 | - | ||
| 98 | - | secrets := make(map[string]string) | |
| 99 | - | scanner := bufio.NewScanner(file) | |
| 100 | - | for scanner.Scan() { | |
| 101 | - | line := strings.TrimSpace(scanner.Text()) | |
| 102 | - | // Skip empty lines and comments | |
| 103 | - | if line == "" || strings.HasPrefix(line, "#") { | |
| 104 | - | continue | |
| 105 | - | } | |
| 106 | - | // Parse KEY=value | |
| 107 | - | if idx := strings.Index(line, "="); idx > 0 { | |
| 108 | - | key := line[:idx] | |
| 109 | - | value := line[idx+1:] | |
| 110 | - | secrets[key] = value | |
| 111 | - | } | |
| 94 | + | return nil, fmt.Errorf("failed to read env file: %w", err) | |
| 112 | 95 | } | |
| 113 | 96 | ||
| 114 | - | if err := scanner.Err(); err != nil { | |
| 115 | - | return nil, fmt.Errorf("failed to read env file: %w", err) | |
| 97 | + | secrets, err := domain.ParseEnvData(data) | |
| 98 | + | if err != nil { | |
| 99 | + | return nil, fmt.Errorf("failed to parse env file: %w", err) | |
| 116 | 100 | } | |
| 117 | 101 | ||
| 118 | 102 | return secrets, nil | |
| @@ -165,6 +149,56 @@ func (s *FileStore) Delete(domainName, key string) error { | |||
| 165 | 149 | return s.writeSecretsAtomic(domainName, existing) | |
| 166 | 150 | } | |
| 167 | 151 | ||
| 152 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |
| 153 | + | func (s *FileStore) SetAttachment(containerName string, secrets map[string]string) error { | |
| 154 | + | // Validate container name first | |
| 155 | + | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |
| 156 | + | return err | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Ensure env directory exists | |
| 160 | + | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |
| 161 | + | return fmt.Errorf("failed to create env directory: %w", err) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Read existing secrets | |
| 165 | + | existing, err := s.GetAllAttachment(containerName) | |
| 166 | + | if err != nil { | |
| 167 | + | return err | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Merge new secrets with existing | |
| 171 | + | for key, value := range secrets { | |
| 172 | + | existing[key] = value | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Write back atomically | |
| 176 | + | return s.writeAttachmentSecretsAtomic(containerName, existing) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 180 | + | func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) { | |
| 181 | + | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |
| 182 | + | if err != nil { | |
| 183 | + | return nil, err | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | data, err := os.ReadFile(envFile) | |
| 187 | + | if err != nil { | |
| 188 | + | if os.IsNotExist(err) { | |
| 189 | + | return map[string]string{}, nil | |
| 190 | + | } | |
| 191 | + | return nil, fmt.Errorf("failed to read attachment env file: %w", err) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | secrets, err := domain.ParseEnvData(data) | |
| 195 | + | if err != nil { | |
| 196 | + | return nil, fmt.Errorf("failed to parse attachment env file: %w", err) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | return secrets, nil | |
| 200 | + | } | |
| 201 | + | ||
| 168 | 202 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | |
| 169 | 203 | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 170 | 204 | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | |
| @@ -191,9 +225,15 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str | |||
| 191 | 225 | return fmt.Errorf("failed to write header: %w", err) | |
| 192 | 226 | } | |
| 193 | 227 | ||
| 194 | - | // Write each secret | |
| 195 | - | for key, value := range secrets { | |
| 196 | - | if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil { | |
| 228 | + | // Write each secret in sorted order for deterministic output | |
| 229 | + | keys := make([]string, 0, len(secrets)) | |
| 230 | + | for key := range secrets { | |
| 231 | + | keys = append(keys, key) | |
| 232 | + | } | |
| 233 | + | sort.Strings(keys) | |
| 234 | + | ||
| 235 | + | for _, key := range keys { | |
| 236 | + | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |
| 197 | 237 | file.Close() | |
| 198 | 238 | os.Remove(tmpFile) | |
| 199 | 239 | return fmt.Errorf("failed to write secret %s: %w", key, err) | |
| @@ -221,40 +261,22 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str | |||
| 221 | 261 | return nil | |
| 222 | 262 | } | |
| 223 | 263 | ||
| 224 | - | // domainRegex validates domain names to prevent path injection. | |
| 225 | - | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |
| 226 | - | var domainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | |
| 227 | - | ||
| 228 | 264 | // getEnvFilePath converts a domain to its env file path. | |
| 229 | 265 | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | |
| 230 | 266 | // | |
| 231 | 267 | // SECURITY: Validates domain and ensures the resulting path stays within envDir. | |
| 232 | 268 | func (s *FileStore) getEnvFilePath(domainName string) string { | |
| 233 | - | // SECURITY: Reject domains that look like path traversal attempts | |
| 234 | - | if strings.Contains(domainName, "..") { | |
| 235 | - | s.log.Warn(). | |
| 236 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 237 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 238 | - | Str("domain", domainName). | |
| 239 | - | Msg("rejected domain with path traversal sequence") | |
| 240 | - | return "" | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | // SECURITY: Validate domain format | |
| 244 | - | if !domainRegex.MatchString(domainName) { | |
| 269 | + | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 270 | + | if err != nil { | |
| 245 | 271 | s.log.Warn(). | |
| 246 | 272 | Str(zerowrap.FieldLayer, "adapter"). | |
| 247 | 273 | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 248 | 274 | Str("domain", domainName). | |
| 249 | - | Msg("rejected invalid domain format") | |
| 275 | + | Err(err). | |
| 276 | + | Msg("rejected invalid domain") | |
| 250 | 277 | return "" | |
| 251 | 278 | } | |
| 252 | 279 | ||
| 253 | - | // Create domain-safe filename (replace dots and other chars with underscores) | |
| 254 | - | safeDomain := strings.ReplaceAll(domainName, ".", "_") | |
| 255 | - | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |
| 256 | - | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |
| 257 | - | ||
| 258 | 280 | fullPath := filepath.Join(s.envDir, safeDomain+".env") | |
| 259 | 281 | ||
| 260 | 282 | // SECURITY: Final validation - ensure path stays within envDir | |
| @@ -283,12 +305,118 @@ func (s *FileStore) validateEnvFilePath(domainName string) (string, error) { | |||
| 283 | 305 | return path, nil | |
| 284 | 306 | } | |
| 285 | 307 | ||
| 308 | + | // getAttachmentEnvFilePath converts a container name to its attachment env file path. | |
| 309 | + | // Attachment env files follow the pattern: gordon-<containerName>.env | |
| 310 | + | // | |
| 311 | + | // SECURITY: Validates container name and ensures the resulting path stays within envDir. | |
| 312 | + | func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) { | |
| 313 | + | if err := domain.ValidateContainerName(containerName); err != nil { | |
| 314 | + | s.log.Warn(). | |
| 315 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 316 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 317 | + | Str("container", containerName). | |
| 318 | + | Err(err). | |
| 319 | + | Msg("rejected invalid container name") | |
| 320 | + | return "", err | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env") | |
| 324 | + | ||
| 325 | + | // SECURITY: Final validation - ensure path stays within envDir | |
| 326 | + | cleanPath := filepath.Clean(fullPath) | |
| 327 | + | cleanEnvDir := filepath.Clean(s.envDir) | |
| 328 | + | if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) { | |
| 329 | + | s.log.Error(). | |
| 330 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 331 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 332 | + | Str("container", containerName). | |
| 333 | + | Str("attempted_path", fullPath). | |
| 334 | + | Msg("path traversal attempt blocked - path escapes env directory") | |
| 335 | + | return "", domain.ErrPathTraversal | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | return fullPath, nil | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path. | |
| 342 | + | // Returns an error if the container name is invalid or would result in path traversal. | |
| 343 | + | func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) { | |
| 344 | + | return s.getAttachmentEnvFilePath(containerName) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically. | |
| 348 | + | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 349 | + | func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error { | |
| 350 | + | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |
| 351 | + | if err != nil { | |
| 352 | + | return err | |
| 353 | + | } | |
| 354 | + | tmpFile := envFile + ".tmp" | |
| 355 | + | ||
| 356 | + | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |
| 357 | + | if err != nil { | |
| 358 | + | return fmt.Errorf("failed to create temp attachment env file: %w", err) | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // Write header comment | |
| 362 | + | if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil { | |
| 363 | + | file.Close() | |
| 364 | + | os.Remove(tmpFile) | |
| 365 | + | return fmt.Errorf("failed to write header: %w", err) | |
| 366 | + | } | |
| 367 | + | if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil { | |
| 368 | + | file.Close() | |
| 369 | + | os.Remove(tmpFile) | |
| 370 | + | return fmt.Errorf("failed to write header: %w", err) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // Write each secret in sorted order for deterministic output | |
| 374 | + | keys := make([]string, 0, len(secrets)) | |
| 375 | + | for key := range secrets { | |
| 376 | + | keys = append(keys, key) | |
| 377 | + | } | |
| 378 | + | sort.Strings(keys) | |
| 379 | + | ||
| 380 | + | for _, key := range keys { | |
| 381 | + | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |
| 382 | + | file.Close() | |
| 383 | + | os.Remove(tmpFile) | |
| 384 | + | return fmt.Errorf("failed to write secret %s: %w", key, err) | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // Sync to ensure data is on disk before rename | |
| 389 | + | if err := file.Sync(); err != nil { | |
| 390 | + | file.Close() | |
| 391 | + | os.Remove(tmpFile) | |
| 392 | + | return fmt.Errorf("failed to sync attachment env file: %w", err) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | if err := file.Close(); err != nil { | |
| 396 | + | os.Remove(tmpFile) | |
| 397 | + | return fmt.Errorf("failed to close attachment env file: %w", err) | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // Atomic rename | |
| 401 | + | if err := os.Rename(tmpFile, envFile); err != nil { | |
| 402 | + | os.Remove(tmpFile) | |
| 403 | + | return fmt.Errorf("failed to rename attachment env file: %w", err) | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | return nil | |
| 407 | + | } | |
| 408 | + | ||
| 286 | 409 | // ListAttachmentKeys finds attachment env files for a domain and returns their keys. | |
| 410 | + | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |
| 287 | 411 | // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env | |
| 288 | 412 | func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | |
| 289 | - | // Sanitize domain the same way container service does | |
| 290 | - | sanitized := sanitizeDomainForContainer(domainName) | |
| 291 | - | prefix := "gordon-" + sanitized + "-" | |
| 413 | + | // Try both new and legacy sanitization for backwards compatibility | |
| 414 | + | sanitized := domain.SanitizeDomainForContainer(domainName) | |
| 415 | + | sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName) | |
| 416 | + | prefixes := []string{ | |
| 417 | + | "gordon-" + sanitized + "-", // New format (collision-resistant) | |
| 418 | + | "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible) | |
| 419 | + | } | |
| 292 | 420 | ||
| 293 | 421 | // List all files in env directory | |
| 294 | 422 | entries, err := os.ReadDir(s.envDir) | |
| @@ -299,57 +427,66 @@ func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre | |||
| 299 | 427 | return nil, fmt.Errorf("failed to read env directory: %w", err) | |
| 300 | 428 | } | |
| 301 | 429 | ||
| 430 | + | seen := make(map[string]bool) | |
| 302 | 431 | var results []out.AttachmentSecrets | |
| 303 | 432 | for _, entry := range entries { | |
| 304 | 433 | if entry.IsDir() { | |
| 305 | 434 | continue | |
| 306 | 435 | } | |
| 307 | 436 | name := entry.Name() | |
| 308 | - | // Check if this is an attachment file for the domain | |
| 309 | - | if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") { | |
| 310 | - | // Extract service name from filename | |
| 311 | - | // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres" | |
| 312 | - | serviceName := strings.TrimPrefix(name, prefix) | |
| 313 | - | serviceName = strings.TrimSuffix(serviceName, ".env") | |
| 314 | - | if serviceName == "" { | |
| 315 | - | continue | |
| 316 | - | } | |
| 317 | 437 | ||
| 318 | - | // The full container name is the filename without .env | |
| 319 | - | containerName := strings.TrimSuffix(name, ".env") | |
| 320 | - | ||
| 321 | - | // Read keys from this file using existing method | |
| 322 | - | // Note: We use the container name directly since it matches the env file naming | |
| 323 | - | keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name)) | |
| 324 | - | if err != nil { | |
| 325 | - | s.log.Warn(). | |
| 326 | - | Err(err). | |
| 327 | - | Str("file", name). | |
| 328 | - | Str("domain", domainName). | |
| 329 | - | Msg("failed to read attachment secrets file") | |
| 330 | - | continue | |
| 438 | + | // Check if this matches any of our prefixes | |
| 439 | + | var matchedPrefix string | |
| 440 | + | for _, prefix := range prefixes { | |
| 441 | + | if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") { | |
| 442 | + | matchedPrefix = prefix | |
| 443 | + | break | |
| 331 | 444 | } | |
| 445 | + | } | |
| 446 | + | if matchedPrefix == "" { | |
| 447 | + | continue | |
| 448 | + | } | |
| 332 | 449 | ||
| 333 | - | if len(keys) > 0 { | |
| 334 | - | results = append(results, out.AttachmentSecrets{ | |
| 335 | - | Service: containerName, | |
| 336 | - | Keys: keys, | |
| 337 | - | }) | |
| 338 | - | } | |
| 450 | + | // Extract container name from filename | |
| 451 | + | containerName := strings.TrimSuffix(name, ".env") | |
| 452 | + | ||
| 453 | + | // Deduplicate results | |
| 454 | + | if seen[containerName] { | |
| 455 | + | continue | |
| 456 | + | } | |
| 457 | + | seen[containerName] = true | |
| 458 | + | ||
| 459 | + | // Extract service name from filename | |
| 460 | + | // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres" | |
| 461 | + | serviceName := strings.TrimPrefix(name, matchedPrefix) | |
| 462 | + | serviceName = strings.TrimSuffix(serviceName, ".env") | |
| 463 | + | if serviceName == "" { | |
| 464 | + | continue | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // Read keys from this file using existing method | |
| 468 | + | // Note: We use the container name directly since it matches the env file naming | |
| 469 | + | keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name)) | |
| 470 | + | if err != nil { | |
| 471 | + | s.log.Warn(). | |
| 472 | + | Err(err). | |
| 473 | + | Str("file", name). | |
| 474 | + | Str("domain", domainName). | |
| 475 | + | Msg("failed to read attachment secrets file") | |
| 476 | + | continue | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | if len(keys) > 0 { | |
| 480 | + | results = append(results, out.AttachmentSecrets{ | |
| 481 | + | Service: containerName, | |
| 482 | + | Keys: keys, | |
| 483 | + | }) | |
| 339 | 484 | } | |
| 340 | 485 | } | |
| 341 | 486 | ||
| 342 | 487 | return results, nil | |
| 343 | 488 | } | |
| 344 | 489 | ||
| 345 | - | // sanitizeDomainForContainer matches the sanitizeName function in container service. | |
| 346 | - | func sanitizeDomainForContainer(domain string) string { | |
| 347 | - | result := strings.ReplaceAll(domain, ".", "-") | |
| 348 | - | result = strings.ReplaceAll(result, ":", "-") | |
| 349 | - | result = strings.ReplaceAll(result, "/", "-") | |
| 350 | - | return result | |
| 351 | - | } | |
| 352 | - | ||
| 353 | 490 | // listKeysFromFile reads secret keys from a specific env file path. | |
| 354 | 491 | func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) { | |
| 355 | 492 | file, err := os.Open(filePath) | |
| @@ -6,7 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | - | "regexp" | 9 | + | "sort" |
| 10 | "strings" | 10 | "strings" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| @@ -86,33 +86,17 @@ func (s *FileStore) GetAll(domainName string) (map[string]string, error) { | |||||
| 86 | return nil, err | 86 | return nil, err | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | - | file, err := os.Open(envFile) | 89 | + | data, err := os.ReadFile(envFile) |
| 90 | if err != nil { | 90 | if err != nil { | ||
| 91 | if os.IsNotExist(err) { | 91 | if os.IsNotExist(err) { | ||
| 92 | return map[string]string{}, nil | 92 | return map[string]string{}, nil | ||
| 93 | } | 93 | } | ||
| 94 | - | return nil, fmt.Errorf("failed to open env file: %w", err) | 94 | + | return nil, fmt.Errorf("failed to read env file: %w", err) |
| 95 | - | } | |||
| 96 | - | defer file.Close() | |||
| 97 | - | ||||
| 98 | - | secrets := make(map[string]string) | |||
| 99 | - | scanner := bufio.NewScanner(file) | |||
| 100 | - | for scanner.Scan() { | |||
| 101 | - | line := strings.TrimSpace(scanner.Text()) | |||
| 102 | - | // Skip empty lines and comments | |||
| 103 | - | if line == "" || strings.HasPrefix(line, "#") { | |||
| 104 | - | continue | |||
| 105 | - | } | |||
| 106 | - | // Parse KEY=value | |||
| 107 | - | if idx := strings.Index(line, "="); idx > 0 { | |||
| 108 | - | key := line[:idx] | |||
| 109 | - | value := line[idx+1:] | |||
| 110 | - | secrets[key] = value | |||
| 111 | - | } | |||
| 112 | } | 95 | } | ||
| 113 | 96 | ||||
| 114 | - | if err := scanner.Err(); err != nil { | 97 | + | secrets, err := domain.ParseEnvData(data) |
| 115 | - | return nil, fmt.Errorf("failed to read env file: %w", err) | 98 | + | if err != nil { |
| 99 | + | return nil, fmt.Errorf("failed to parse env file: %w", err) | |||
| 116 | } | 100 | } | ||
| 117 | 101 | ||||
| 118 | return secrets, nil | 102 | return secrets, nil | ||
| @@ -165,6 +149,56 @@ func (s *FileStore) Delete(domainName, key string) error { | |||||
| 165 | return s.writeSecretsAtomic(domainName, existing) | 149 | return s.writeSecretsAtomic(domainName, existing) | ||
| 166 | } | 150 | } | ||
| 167 | 151 | ||||
| 152 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |||
| 153 | + | func (s *FileStore) SetAttachment(containerName string, secrets map[string]string) error { | |||
| 154 | + | // Validate container name first | |||
| 155 | + | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Ensure env directory exists | |||
| 160 | + | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |||
| 161 | + | return fmt.Errorf("failed to create env directory: %w", err) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // Read existing secrets | |||
| 165 | + | existing, err := s.GetAllAttachment(containerName) | |||
| 166 | + | if err != nil { | |||
| 167 | + | return err | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // Merge new secrets with existing | |||
| 171 | + | for key, value := range secrets { | |||
| 172 | + | existing[key] = value | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // Write back atomically | |||
| 176 | + | return s.writeAttachmentSecretsAtomic(containerName, existing) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |||
| 180 | + | func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) { | |||
| 181 | + | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |||
| 182 | + | if err != nil { | |||
| 183 | + | return nil, err | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | data, err := os.ReadFile(envFile) | |||
| 187 | + | if err != nil { | |||
| 188 | + | if os.IsNotExist(err) { | |||
| 189 | + | return map[string]string{}, nil | |||
| 190 | + | } | |||
| 191 | + | return nil, fmt.Errorf("failed to read attachment env file: %w", err) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | secrets, err := domain.ParseEnvData(data) | |||
| 195 | + | if err != nil { | |||
| 196 | + | return nil, fmt.Errorf("failed to parse attachment env file: %w", err) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | return secrets, nil | |||
| 200 | + | } | |||
| 201 | + | ||||
| 168 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | 202 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | ||
| 169 | // It writes to a temporary file first, syncs it, then renames to the final path. | 203 | // It writes to a temporary file first, syncs it, then renames to the final path. | ||
| 170 | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | 204 | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | ||
| @@ -191,9 +225,15 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str | |||||
| 191 | return fmt.Errorf("failed to write header: %w", err) | 225 | return fmt.Errorf("failed to write header: %w", err) | ||
| 192 | } | 226 | } | ||
| 193 | 227 | ||||
| 194 | - | // Write each secret | 228 | + | // Write each secret in sorted order for deterministic output |
| 195 | - | for key, value := range secrets { | 229 | + | keys := make([]string, 0, len(secrets)) |
| 196 | - | if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil { | 230 | + | for key := range secrets { |
| 231 | + | keys = append(keys, key) | |||
| 232 | + | } | |||
| 233 | + | sort.Strings(keys) | |||
| 234 | + | ||||
| 235 | + | for _, key := range keys { | |||
| 236 | + | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |||
| 197 | file.Close() | 237 | file.Close() | ||
| 198 | os.Remove(tmpFile) | 238 | os.Remove(tmpFile) | ||
| 199 | return fmt.Errorf("failed to write secret %s: %w", key, err) | 239 | return fmt.Errorf("failed to write secret %s: %w", key, err) | ||
| @@ -221,40 +261,22 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str | |||||
| 221 | return nil | 261 | return nil | ||
| 222 | } | 262 | } | ||
| 223 | 263 | ||||
| 224 | - | // domainRegex validates domain names to prevent path injection. | |||
| 225 | - | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |||
| 226 | - | var domainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | |||
| 227 | - | ||||
| 228 | // getEnvFilePath converts a domain to its env file path. | 264 | // getEnvFilePath converts a domain to its env file path. | ||
| 229 | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | 265 | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | ||
| 230 | // | 266 | // | ||
| 231 | // SECURITY: Validates domain and ensures the resulting path stays within envDir. | 267 | // SECURITY: Validates domain and ensures the resulting path stays within envDir. | ||
| 232 | func (s *FileStore) getEnvFilePath(domainName string) string { | 268 | func (s *FileStore) getEnvFilePath(domainName string) string { | ||
| 233 | - | // SECURITY: Reject domains that look like path traversal attempts | 269 | + | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) |
| 234 | - | if strings.Contains(domainName, "..") { | 270 | + | if err != nil { |
| 235 | - | s.log.Warn(). | |||
| 236 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 237 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 238 | - | Str("domain", domainName). | |||
| 239 | - | Msg("rejected domain with path traversal sequence") | |||
| 240 | - | return "" | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | // SECURITY: Validate domain format | |||
| 244 | - | if !domainRegex.MatchString(domainName) { | |||
| 245 | s.log.Warn(). | 271 | s.log.Warn(). | ||
| 246 | Str(zerowrap.FieldLayer, "adapter"). | 272 | Str(zerowrap.FieldLayer, "adapter"). | ||
| 247 | Str(zerowrap.FieldAdapter, "domainsecrets"). | 273 | Str(zerowrap.FieldAdapter, "domainsecrets"). | ||
| 248 | Str("domain", domainName). | 274 | Str("domain", domainName). | ||
| 249 | - | Msg("rejected invalid domain format") | 275 | + | Err(err). |
| 276 | + | Msg("rejected invalid domain") | |||
| 250 | return "" | 277 | return "" | ||
| 251 | } | 278 | } | ||
| 252 | 279 | ||||
| 253 | - | // Create domain-safe filename (replace dots and other chars with underscores) | |||
| 254 | - | safeDomain := strings.ReplaceAll(domainName, ".", "_") | |||
| 255 | - | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |||
| 256 | - | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |||
| 257 | - | ||||
| 258 | fullPath := filepath.Join(s.envDir, safeDomain+".env") | 280 | fullPath := filepath.Join(s.envDir, safeDomain+".env") | ||
| 259 | 281 | ||||
| 260 | // SECURITY: Final validation - ensure path stays within envDir | 282 | // SECURITY: Final validation - ensure path stays within envDir | ||
| @@ -283,12 +305,118 @@ func (s *FileStore) validateEnvFilePath(domainName string) (string, error) { | |||||
| 283 | return path, nil | 305 | return path, nil | ||
| 284 | } | 306 | } | ||
| 285 | 307 | ||||
| 308 | + | // getAttachmentEnvFilePath converts a container name to its attachment env file path. | |||
| 309 | + | // Attachment env files follow the pattern: gordon-<containerName>.env | |||
| 310 | + | // | |||
| 311 | + | // SECURITY: Validates container name and ensures the resulting path stays within envDir. | |||
| 312 | + | func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) { | |||
| 313 | + | if err := domain.ValidateContainerName(containerName); err != nil { | |||
| 314 | + | s.log.Warn(). | |||
| 315 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 316 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 317 | + | Str("container", containerName). | |||
| 318 | + | Err(err). | |||
| 319 | + | Msg("rejected invalid container name") | |||
| 320 | + | return "", err | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env") | |||
| 324 | + | ||||
| 325 | + | // SECURITY: Final validation - ensure path stays within envDir | |||
| 326 | + | cleanPath := filepath.Clean(fullPath) | |||
| 327 | + | cleanEnvDir := filepath.Clean(s.envDir) | |||
| 328 | + | if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) { | |||
| 329 | + | s.log.Error(). | |||
| 330 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 331 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 332 | + | Str("container", containerName). | |||
| 333 | + | Str("attempted_path", fullPath). | |||
| 334 | + | Msg("path traversal attempt blocked - path escapes env directory") | |||
| 335 | + | return "", domain.ErrPathTraversal | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | return fullPath, nil | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path. | |||
| 342 | + | // Returns an error if the container name is invalid or would result in path traversal. | |||
| 343 | + | func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) { | |||
| 344 | + | return s.getAttachmentEnvFilePath(containerName) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically. | |||
| 348 | + | // It writes to a temporary file first, syncs it, then renames to the final path. | |||
| 349 | + | func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error { | |||
| 350 | + | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |||
| 351 | + | if err != nil { | |||
| 352 | + | return err | |||
| 353 | + | } | |||
| 354 | + | tmpFile := envFile + ".tmp" | |||
| 355 | + | ||||
| 356 | + | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |||
| 357 | + | if err != nil { | |||
| 358 | + | return fmt.Errorf("failed to create temp attachment env file: %w", err) | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | // Write header comment | |||
| 362 | + | if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil { | |||
| 363 | + | file.Close() | |||
| 364 | + | os.Remove(tmpFile) | |||
| 365 | + | return fmt.Errorf("failed to write header: %w", err) | |||
| 366 | + | } | |||
| 367 | + | if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil { | |||
| 368 | + | file.Close() | |||
| 369 | + | os.Remove(tmpFile) | |||
| 370 | + | return fmt.Errorf("failed to write header: %w", err) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // Write each secret in sorted order for deterministic output | |||
| 374 | + | keys := make([]string, 0, len(secrets)) | |||
| 375 | + | for key := range secrets { | |||
| 376 | + | keys = append(keys, key) | |||
| 377 | + | } | |||
| 378 | + | sort.Strings(keys) | |||
| 379 | + | ||||
| 380 | + | for _, key := range keys { | |||
| 381 | + | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |||
| 382 | + | file.Close() | |||
| 383 | + | os.Remove(tmpFile) | |||
| 384 | + | return fmt.Errorf("failed to write secret %s: %w", key, err) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | // Sync to ensure data is on disk before rename | |||
| 389 | + | if err := file.Sync(); err != nil { | |||
| 390 | + | file.Close() | |||
| 391 | + | os.Remove(tmpFile) | |||
| 392 | + | return fmt.Errorf("failed to sync attachment env file: %w", err) | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | if err := file.Close(); err != nil { | |||
| 396 | + | os.Remove(tmpFile) | |||
| 397 | + | return fmt.Errorf("failed to close attachment env file: %w", err) | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // Atomic rename | |||
| 401 | + | if err := os.Rename(tmpFile, envFile); err != nil { | |||
| 402 | + | os.Remove(tmpFile) | |||
| 403 | + | return fmt.Errorf("failed to rename attachment env file: %w", err) | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | return nil | |||
| 407 | + | } | |||
| 408 | + | ||||
| 286 | // ListAttachmentKeys finds attachment env files for a domain and returns their keys. | 409 | // ListAttachmentKeys finds attachment env files for a domain and returns their keys. | ||
| 410 | + | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |||
| 287 | // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env | 411 | // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env | ||
| 288 | func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | 412 | func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | ||
| 289 | - | // Sanitize domain the same way container service does | 413 | + | // Try both new and legacy sanitization for backwards compatibility |
| 290 | - | sanitized := sanitizeDomainForContainer(domainName) | 414 | + | sanitized := domain.SanitizeDomainForContainer(domainName) |
| 291 | - | prefix := "gordon-" + sanitized + "-" | 415 | + | sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName) |
| 416 | + | prefixes := []string{ | |||
| 417 | + | "gordon-" + sanitized + "-", // New format (collision-resistant) | |||
| 418 | + | "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible) | |||
| 419 | + | } | |||
| 292 | 420 | ||||
| 293 | // List all files in env directory | 421 | // List all files in env directory | ||
| 294 | entries, err := os.ReadDir(s.envDir) | 422 | entries, err := os.ReadDir(s.envDir) | ||
| @@ -299,57 +427,66 @@ func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre | |||||
| 299 | return nil, fmt.Errorf("failed to read env directory: %w", err) | 427 | return nil, fmt.Errorf("failed to read env directory: %w", err) | ||
| 300 | } | 428 | } | ||
| 301 | 429 | ||||
| 430 | + | seen := make(map[string]bool) | |||
| 302 | var results []out.AttachmentSecrets | 431 | var results []out.AttachmentSecrets | ||
| 303 | for _, entry := range entries { | 432 | for _, entry := range entries { | ||
| 304 | if entry.IsDir() { | 433 | if entry.IsDir() { | ||
| 305 | continue | 434 | continue | ||
| 306 | } | 435 | } | ||
| 307 | name := entry.Name() | 436 | name := entry.Name() | ||
| 308 | - | // Check if this is an attachment file for the domain | |||
| 309 | - | if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") { | |||
| 310 | - | // Extract service name from filename | |||
| 311 | - | // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres" | |||
| 312 | - | serviceName := strings.TrimPrefix(name, prefix) | |||
| 313 | - | serviceName = strings.TrimSuffix(serviceName, ".env") | |||
| 314 | - | if serviceName == "" { | |||
| 315 | - | continue | |||
| 316 | - | } | |||
| 317 | 437 | ||||
| 318 | - | // The full container name is the filename without .env | 438 | + | // Check if this matches any of our prefixes |
| 319 | - | containerName := strings.TrimSuffix(name, ".env") | 439 | + | var matchedPrefix string |
| 320 | - | 440 | + | for _, prefix := range prefixes { | |
| 321 | - | // Read keys from this file using existing method | 441 | + | if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") { |
| 322 | - | // Note: We use the container name directly since it matches the env file naming | 442 | + | matchedPrefix = prefix |
| 323 | - | keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name)) | 443 | + | break |
| 324 | - | if err != nil { | |||
| 325 | - | s.log.Warn(). | |||
| 326 | - | Err(err). | |||
| 327 | - | Str("file", name). | |||
| 328 | - | Str("domain", domainName). | |||
| 329 | - | Msg("failed to read attachment secrets file") | |||
| 330 | - | continue | |||
| 331 | } | 444 | } | ||
| 445 | + | } | |||
| 446 | + | if matchedPrefix == "" { | |||
| 447 | + | continue | |||
| 448 | + | } | |||
| 332 | 449 | ||||
| 333 | - | if len(keys) > 0 { | 450 | + | // Extract container name from filename |
| 334 | - | results = append(results, out.AttachmentSecrets{ | 451 | + | containerName := strings.TrimSuffix(name, ".env") |
| 335 | - | Service: containerName, | 452 | + | |
| 336 | - | Keys: keys, | 453 | + | // Deduplicate results |
| 337 | - | }) | 454 | + | if seen[containerName] { |
| 338 | - | } | 455 | + | continue |
| 456 | + | } | |||
| 457 | + | seen[containerName] = true | |||
| 458 | + | ||||
| 459 | + | // Extract service name from filename | |||
| 460 | + | // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres" | |||
| 461 | + | serviceName := strings.TrimPrefix(name, matchedPrefix) | |||
| 462 | + | serviceName = strings.TrimSuffix(serviceName, ".env") | |||
| 463 | + | if serviceName == "" { | |||
| 464 | + | continue | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | // Read keys from this file using existing method | |||
| 468 | + | // Note: We use the container name directly since it matches the env file naming | |||
| 469 | + | keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name)) | |||
| 470 | + | if err != nil { | |||
| 471 | + | s.log.Warn(). | |||
| 472 | + | Err(err). | |||
| 473 | + | Str("file", name). | |||
| 474 | + | Str("domain", domainName). | |||
| 475 | + | Msg("failed to read attachment secrets file") | |||
| 476 | + | continue | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | if len(keys) > 0 { | |||
| 480 | + | results = append(results, out.AttachmentSecrets{ | |||
| 481 | + | Service: containerName, | |||
| 482 | + | Keys: keys, | |||
| 483 | + | }) | |||
| 339 | } | 484 | } | ||
| 340 | } | 485 | } | ||
| 341 | 486 | ||||
| 342 | return results, nil | 487 | return results, nil | ||
| 343 | } | 488 | } | ||
| 344 | 489 | ||||
| 345 | - | // sanitizeDomainForContainer matches the sanitizeName function in container service. | |||
| 346 | - | func sanitizeDomainForContainer(domain string) string { | |||
| 347 | - | result := strings.ReplaceAll(domain, ".", "-") | |||
| 348 | - | result = strings.ReplaceAll(result, ":", "-") | |||
| 349 | - | result = strings.ReplaceAll(result, "/", "-") | |||
| 350 | - | return result | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | // listKeysFromFile reads secret keys from a specific env file path. | 490 | // listKeysFromFile reads secret keys from a specific env file path. | ||
| 354 | func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) { | 491 | func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) { | ||
| 355 | file, err := os.Open(filePath) | 492 | file, err := os.Open(filePath) | ||
M internal/adapters/out/domainsecrets/store_test.go +177 -5
| @@ -6,17 +6,12 @@ import ( | |||
| 6 | 6 | "path/filepath" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/zerowrap" | |
| 10 | 9 | "github.com/stretchr/testify/assert" | |
| 11 | 10 | "github.com/stretchr/testify/require" | |
| 12 | 11 | ||
| 13 | 12 | "github.com/bnema/gordon/internal/domain" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | - | func testLogger() zerowrap.Logger { | |
| 17 | - | return zerowrap.Default() | |
| 18 | - | } | |
| 19 | - | ||
| 20 | 15 | func TestFileStore_PathTraversal(t *testing.T) { | |
| 21 | 16 | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 22 | 17 | require.NoError(t, err) | |
| @@ -121,6 +116,183 @@ func TestFileStore_PathContainment(t *testing.T) { | |||
| 121 | 116 | } | |
| 122 | 117 | } | |
| 123 | 118 | ||
| 119 | + | func TestSanitizeDomainForContainer(t *testing.T) { | |
| 120 | + | tests := []struct { | |
| 121 | + | domain string | |
| 122 | + | expected string | |
| 123 | + | description string | |
| 124 | + | }{ | |
| 125 | + | { | |
| 126 | + | domain: "git.example.com", | |
| 127 | + | expected: "git__example__com", | |
| 128 | + | description: "Dots become double underscores", | |
| 129 | + | }, | |
| 130 | + | { | |
| 131 | + | domain: "git-example.com", | |
| 132 | + | expected: "git-example__com", | |
| 133 | + | description: "Hyphens preserved, dots become underscores", | |
| 134 | + | }, | |
| 135 | + | { | |
| 136 | + | domain: "app:8080.example.com", | |
| 137 | + | expected: "app-_8080__example__com", | |
| 138 | + | description: "Colons become hyphen-underscore", | |
| 139 | + | }, | |
| 140 | + | { | |
| 141 | + | domain: "git.example.com:3000", | |
| 142 | + | expected: "git__example__com-_3000", | |
| 143 | + | description: "Multiple separators handled distinctly", | |
| 144 | + | }, | |
| 145 | + | { | |
| 146 | + | domain: "simple.com", | |
| 147 | + | expected: "simple__com", | |
| 148 | + | description: "Simple domain", | |
| 149 | + | }, | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | for _, tt := range tests { | |
| 153 | + | t.Run(tt.domain, func(t *testing.T) { | |
| 154 | + | result := domain.SanitizeDomainForContainer(tt.domain) | |
| 155 | + | assert.Equal(t, tt.expected, result, "sanitization should match expected") | |
| 156 | + | }) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Verify no collisions between potentially conflicting domains | |
| 160 | + | t.Run("NoCollisions", func(t *testing.T) { | |
| 161 | + | domains := []string{ | |
| 162 | + | "git.example.com", | |
| 163 | + | "git-example.com", | |
| 164 | + | "app:8080.example.com", | |
| 165 | + | "app-8080-example.com", | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | results := make(map[string]string) | |
| 169 | + | for _, d := range domains { | |
| 170 | + | result := domain.SanitizeDomainForContainer(d) | |
| 171 | + | if original, exists := results[result]; exists { | |
| 172 | + | t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result) | |
| 173 | + | } | |
| 174 | + | results[result] = d | |
| 175 | + | } | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestFileStore_AttachmentOperations(t *testing.T) { | |
| 180 | + | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 181 | + | require.NoError(t, err) | |
| 182 | + | defer os.RemoveAll(tmpDir) | |
| 183 | + | ||
| 184 | + | store, err := NewFileStore(tmpDir, testLogger()) | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | ||
| 187 | + | containerName := "gitea-postgres" | |
| 188 | + | ||
| 189 | + | t.Run("SetAttachment creates new file", func(t *testing.T) { | |
| 190 | + | err := store.SetAttachment(containerName, map[string]string{ | |
| 191 | + | "POSTGRES_DB": "gitea", | |
| 192 | + | "POSTGRES_PASSWORD": "secret123", | |
| 193 | + | }) | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | ||
| 196 | + | // Verify file exists | |
| 197 | + | expectedFile := filepath.Join(tmpDir, "gordon-"+containerName+".env") | |
| 198 | + | _, err = os.Stat(expectedFile) | |
| 199 | + | assert.NoError(t, err, "expected attachment env file to exist at %s", expectedFile) | |
| 200 | + | }) | |
| 201 | + | ||
| 202 | + | t.Run("GetAllAttachment retrieves secrets", func(t *testing.T) { | |
| 203 | + | secrets, err := store.GetAllAttachment(containerName) | |
| 204 | + | require.NoError(t, err) | |
| 205 | + | assert.Equal(t, "gitea", secrets["POSTGRES_DB"]) | |
| 206 | + | assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"]) | |
| 207 | + | }) | |
| 208 | + | ||
| 209 | + | t.Run("SetAttachment merges with existing", func(t *testing.T) { | |
| 210 | + | err := store.SetAttachment(containerName, map[string]string{ | |
| 211 | + | "NEW_VAR": "new_value", | |
| 212 | + | }) | |
| 213 | + | require.NoError(t, err) | |
| 214 | + | ||
| 215 | + | // Should have both old and new secrets | |
| 216 | + | secrets, err := store.GetAllAttachment(containerName) | |
| 217 | + | require.NoError(t, err) | |
| 218 | + | assert.Equal(t, "gitea", secrets["POSTGRES_DB"]) | |
| 219 | + | assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"]) | |
| 220 | + | assert.Equal(t, "new_value", secrets["NEW_VAR"]) | |
| 221 | + | }) | |
| 222 | + | ||
| 223 | + | t.Run("GetAllAttachment returns empty map for non-existent container", func(t *testing.T) { | |
| 224 | + | secrets, err := store.GetAllAttachment("non-existent") | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | assert.Empty(t, secrets) | |
| 227 | + | }) | |
| 228 | + | ||
| 229 | + | t.Run("SetAttachment with hyphenated container name", func(t *testing.T) { | |
| 230 | + | hyphenatedContainer := "gordon-git-example-com-gitea-postgres" | |
| 231 | + | err := store.SetAttachment(hyphenatedContainer, map[string]string{ | |
| 232 | + | "KEY": "value", | |
| 233 | + | }) | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | ||
| 236 | + | secrets, err := store.GetAllAttachment(hyphenatedContainer) | |
| 237 | + | require.NoError(t, err) | |
| 238 | + | assert.Equal(t, "value", secrets["KEY"]) | |
| 239 | + | }) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func TestFileStore_AttachmentPathTraversal(t *testing.T) { | |
| 243 | + | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | defer os.RemoveAll(tmpDir) | |
| 246 | + | ||
| 247 | + | store, err := NewFileStore(tmpDir, testLogger()) | |
| 248 | + | require.NoError(t, err) | |
| 249 | + | ||
| 250 | + | tests := []struct { | |
| 251 | + | name string | |
| 252 | + | container string | |
| 253 | + | wantSetErr bool | |
| 254 | + | wantGetErr bool | |
| 255 | + | }{ | |
| 256 | + | // Valid container names | |
| 257 | + | {"simple", "postgres", false, false}, | |
| 258 | + | {"with hyphen", "gitea-postgres", false, false}, | |
| 259 | + | {"with underscore", "gitea_postgres", false, false}, | |
| 260 | + | {"complex real container", "gordon-git-example-com-gitea-postgres", false, false}, | |
| 261 | + | ||
| 262 | + | // Invalid container names - should fail | |
| 263 | + | {"empty", "", true, true}, | |
| 264 | + | {"starts with number", "1container", true, true}, | |
| 265 | + | {"starts with hyphen", "-container", true, true}, | |
| 266 | + | {"starts with underscore", "_container", true, true}, | |
| 267 | + | {"path traversal", "../etc", true, true}, | |
| 268 | + | {"contains slash", "container/name", true, true}, | |
| 269 | + | {"contains backslash", "container\\name", true, true}, | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | for _, tt := range tests { | |
| 273 | + | t.Run(tt.name, func(t *testing.T) { | |
| 274 | + | // Test SetAttachment | |
| 275 | + | err := store.SetAttachment(tt.container, map[string]string{"KEY": "value"}) | |
| 276 | + | if tt.wantSetErr { | |
| 277 | + | require.Error(t, err) | |
| 278 | + | } else { | |
| 279 | + | assert.NoError(t, err) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // Test GetAllAttachment | |
| 283 | + | _, err = store.GetAllAttachment(tt.container) | |
| 284 | + | if tt.wantGetErr { | |
| 285 | + | require.Error(t, err) | |
| 286 | + | } else { | |
| 287 | + | if !tt.wantSetErr { | |
| 288 | + | // Only check no error if we successfully set it | |
| 289 | + | assert.NoError(t, err) | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | }) | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 124 | 296 | func TestFileStore_ValidDomainOperations(t *testing.T) { | |
| 125 | 297 | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 126 | 298 | require.NoError(t, err) | |
| @@ -6,17 +6,12 @@ import ( | |||||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/zerowrap" | |||
| 10 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 12 | 11 | ||||
| 13 | "github.com/bnema/gordon/internal/domain" | 12 | "github.com/bnema/gordon/internal/domain" | ||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
| 16 | - | func testLogger() zerowrap.Logger { | |||
| 17 | - | return zerowrap.Default() | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | func TestFileStore_PathTraversal(t *testing.T) { | 15 | func TestFileStore_PathTraversal(t *testing.T) { | ||
| 21 | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | 16 | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | ||
| 22 | require.NoError(t, err) | 17 | require.NoError(t, err) | ||
| @@ -121,6 +116,183 @@ func TestFileStore_PathContainment(t *testing.T) { | |||||
| 121 | } | 116 | } | ||
| 122 | } | 117 | } | ||
| 123 | 118 | ||||
| 119 | + | func TestSanitizeDomainForContainer(t *testing.T) { | |||
| 120 | + | tests := []struct { | |||
| 121 | + | domain string | |||
| 122 | + | expected string | |||
| 123 | + | description string | |||
| 124 | + | }{ | |||
| 125 | + | { | |||
| 126 | + | domain: "git.example.com", | |||
| 127 | + | expected: "git__example__com", | |||
| 128 | + | description: "Dots become double underscores", | |||
| 129 | + | }, | |||
| 130 | + | { | |||
| 131 | + | domain: "git-example.com", | |||
| 132 | + | expected: "git-example__com", | |||
| 133 | + | description: "Hyphens preserved, dots become underscores", | |||
| 134 | + | }, | |||
| 135 | + | { | |||
| 136 | + | domain: "app:8080.example.com", | |||
| 137 | + | expected: "app-_8080__example__com", | |||
| 138 | + | description: "Colons become hyphen-underscore", | |||
| 139 | + | }, | |||
| 140 | + | { | |||
| 141 | + | domain: "git.example.com:3000", | |||
| 142 | + | expected: "git__example__com-_3000", | |||
| 143 | + | description: "Multiple separators handled distinctly", | |||
| 144 | + | }, | |||
| 145 | + | { | |||
| 146 | + | domain: "simple.com", | |||
| 147 | + | expected: "simple__com", | |||
| 148 | + | description: "Simple domain", | |||
| 149 | + | }, | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | for _, tt := range tests { | |||
| 153 | + | t.Run(tt.domain, func(t *testing.T) { | |||
| 154 | + | result := domain.SanitizeDomainForContainer(tt.domain) | |||
| 155 | + | assert.Equal(t, tt.expected, result, "sanitization should match expected") | |||
| 156 | + | }) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Verify no collisions between potentially conflicting domains | |||
| 160 | + | t.Run("NoCollisions", func(t *testing.T) { | |||
| 161 | + | domains := []string{ | |||
| 162 | + | "git.example.com", | |||
| 163 | + | "git-example.com", | |||
| 164 | + | "app:8080.example.com", | |||
| 165 | + | "app-8080-example.com", | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | results := make(map[string]string) | |||
| 169 | + | for _, d := range domains { | |||
| 170 | + | result := domain.SanitizeDomainForContainer(d) | |||
| 171 | + | if original, exists := results[result]; exists { | |||
| 172 | + | t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result) | |||
| 173 | + | } | |||
| 174 | + | results[result] = d | |||
| 175 | + | } | |||
| 176 | + | }) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func TestFileStore_AttachmentOperations(t *testing.T) { | |||
| 180 | + | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |||
| 181 | + | require.NoError(t, err) | |||
| 182 | + | defer os.RemoveAll(tmpDir) | |||
| 183 | + | ||||
| 184 | + | store, err := NewFileStore(tmpDir, testLogger()) | |||
| 185 | + | require.NoError(t, err) | |||
| 186 | + | ||||
| 187 | + | containerName := "gitea-postgres" | |||
| 188 | + | ||||
| 189 | + | t.Run("SetAttachment creates new file", func(t *testing.T) { | |||
| 190 | + | err := store.SetAttachment(containerName, map[string]string{ | |||
| 191 | + | "POSTGRES_DB": "gitea", | |||
| 192 | + | "POSTGRES_PASSWORD": "secret123", | |||
| 193 | + | }) | |||
| 194 | + | require.NoError(t, err) | |||
| 195 | + | ||||
| 196 | + | // Verify file exists | |||
| 197 | + | expectedFile := filepath.Join(tmpDir, "gordon-"+containerName+".env") | |||
| 198 | + | _, err = os.Stat(expectedFile) | |||
| 199 | + | assert.NoError(t, err, "expected attachment env file to exist at %s", expectedFile) | |||
| 200 | + | }) | |||
| 201 | + | ||||
| 202 | + | t.Run("GetAllAttachment retrieves secrets", func(t *testing.T) { | |||
| 203 | + | secrets, err := store.GetAllAttachment(containerName) | |||
| 204 | + | require.NoError(t, err) | |||
| 205 | + | assert.Equal(t, "gitea", secrets["POSTGRES_DB"]) | |||
| 206 | + | assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"]) | |||
| 207 | + | }) | |||
| 208 | + | ||||
| 209 | + | t.Run("SetAttachment merges with existing", func(t *testing.T) { | |||
| 210 | + | err := store.SetAttachment(containerName, map[string]string{ | |||
| 211 | + | "NEW_VAR": "new_value", | |||
| 212 | + | }) | |||
| 213 | + | require.NoError(t, err) | |||
| 214 | + | ||||
| 215 | + | // Should have both old and new secrets | |||
| 216 | + | secrets, err := store.GetAllAttachment(containerName) | |||
| 217 | + | require.NoError(t, err) | |||
| 218 | + | assert.Equal(t, "gitea", secrets["POSTGRES_DB"]) | |||
| 219 | + | assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"]) | |||
| 220 | + | assert.Equal(t, "new_value", secrets["NEW_VAR"]) | |||
| 221 | + | }) | |||
| 222 | + | ||||
| 223 | + | t.Run("GetAllAttachment returns empty map for non-existent container", func(t *testing.T) { | |||
| 224 | + | secrets, err := store.GetAllAttachment("non-existent") | |||
| 225 | + | require.NoError(t, err) | |||
| 226 | + | assert.Empty(t, secrets) | |||
| 227 | + | }) | |||
| 228 | + | ||||
| 229 | + | t.Run("SetAttachment with hyphenated container name", func(t *testing.T) { | |||
| 230 | + | hyphenatedContainer := "gordon-git-example-com-gitea-postgres" | |||
| 231 | + | err := store.SetAttachment(hyphenatedContainer, map[string]string{ | |||
| 232 | + | "KEY": "value", | |||
| 233 | + | }) | |||
| 234 | + | require.NoError(t, err) | |||
| 235 | + | ||||
| 236 | + | secrets, err := store.GetAllAttachment(hyphenatedContainer) | |||
| 237 | + | require.NoError(t, err) | |||
| 238 | + | assert.Equal(t, "value", secrets["KEY"]) | |||
| 239 | + | }) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func TestFileStore_AttachmentPathTraversal(t *testing.T) { | |||
| 243 | + | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |||
| 244 | + | require.NoError(t, err) | |||
| 245 | + | defer os.RemoveAll(tmpDir) | |||
| 246 | + | ||||
| 247 | + | store, err := NewFileStore(tmpDir, testLogger()) | |||
| 248 | + | require.NoError(t, err) | |||
| 249 | + | ||||
| 250 | + | tests := []struct { | |||
| 251 | + | name string | |||
| 252 | + | container string | |||
| 253 | + | wantSetErr bool | |||
| 254 | + | wantGetErr bool | |||
| 255 | + | }{ | |||
| 256 | + | // Valid container names | |||
| 257 | + | {"simple", "postgres", false, false}, | |||
| 258 | + | {"with hyphen", "gitea-postgres", false, false}, | |||
| 259 | + | {"with underscore", "gitea_postgres", false, false}, | |||
| 260 | + | {"complex real container", "gordon-git-example-com-gitea-postgres", false, false}, | |||
| 261 | + | ||||
| 262 | + | // Invalid container names - should fail | |||
| 263 | + | {"empty", "", true, true}, | |||
| 264 | + | {"starts with number", "1container", true, true}, | |||
| 265 | + | {"starts with hyphen", "-container", true, true}, | |||
| 266 | + | {"starts with underscore", "_container", true, true}, | |||
| 267 | + | {"path traversal", "../etc", true, true}, | |||
| 268 | + | {"contains slash", "container/name", true, true}, | |||
| 269 | + | {"contains backslash", "container\\name", true, true}, | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | for _, tt := range tests { | |||
| 273 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 274 | + | // Test SetAttachment | |||
| 275 | + | err := store.SetAttachment(tt.container, map[string]string{"KEY": "value"}) | |||
| 276 | + | if tt.wantSetErr { | |||
| 277 | + | require.Error(t, err) | |||
| 278 | + | } else { | |||
| 279 | + | assert.NoError(t, err) | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // Test GetAllAttachment | |||
| 283 | + | _, err = store.GetAllAttachment(tt.container) | |||
| 284 | + | if tt.wantGetErr { | |||
| 285 | + | require.Error(t, err) | |||
| 286 | + | } else { | |||
| 287 | + | if !tt.wantSetErr { | |||
| 288 | + | // Only check no error if we successfully set it | |||
| 289 | + | assert.NoError(t, err) | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | }) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 124 | func TestFileStore_ValidDomainOperations(t *testing.T) { | 296 | func TestFileStore_ValidDomainOperations(t *testing.T) { | ||
| 125 | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | 297 | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | ||
| 126 | require.NoError(t, err) | 298 | require.NoError(t, err) | ||
A internal/adapters/out/envloader/pass_loader.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | // Package envloader implements the environment variable loader adapter. | |
| 2 | + | package envloader | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "sort" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // PassLoader implements the EnvLoader interface using pass-backed secrets. | |
| 16 | + | type PassLoader struct { | |
| 17 | + | store *domainsecrets.PassStore | |
| 18 | + | log zerowrap.Logger | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // NewPassLoader creates a new pass-based environment loader. | |
| 22 | + | func NewPassLoader(store *domainsecrets.PassStore, log zerowrap.Logger) (*PassLoader, error) { | |
| 23 | + | if store == nil { | |
| 24 | + | return nil, fmt.Errorf("pass store is required") | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | log.Debug(). | |
| 28 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 29 | + | Str(zerowrap.FieldAdapter, "envloader"). | |
| 30 | + | Str("provider", "pass"). | |
| 31 | + | Msg("env loader initialized") | |
| 32 | + | ||
| 33 | + | return &PassLoader{ | |
| 34 | + | store: store, | |
| 35 | + | log: log, | |
| 36 | + | }, nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // LoadEnv loads environment variables for a given domain. | |
| 40 | + | func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) { | |
| 41 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 42 | + | zerowrap.FieldLayer: "adapter", | |
| 43 | + | zerowrap.FieldAdapter: "envloader", | |
| 44 | + | zerowrap.FieldAction: "LoadEnv", | |
| 45 | + | "domain": domain, | |
| 46 | + | }) | |
| 47 | + | log := zerowrap.FromCtx(ctx) | |
| 48 | + | ||
| 49 | + | var secretsMap map[string]string | |
| 50 | + | var err error | |
| 51 | + | ||
| 52 | + | if isAttachmentContainer(domain) { | |
| 53 | + | secretsMap, err = l.store.GetAllAttachment(domain) | |
| 54 | + | if err != nil { | |
| 55 | + | return nil, log.WrapErr(err, "failed to load attachment env from pass") | |
| 56 | + | } | |
| 57 | + | } else { | |
| 58 | + | secretsMap, err = l.store.GetAll(domain) | |
| 59 | + | if err != nil { | |
| 60 | + | return nil, log.WrapErr(err, "failed to load env from pass") | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | keys := make([]string, 0, len(secretsMap)) | |
| 65 | + | for key := range secretsMap { | |
| 66 | + | keys = append(keys, key) | |
| 67 | + | } | |
| 68 | + | sort.Strings(keys) | |
| 69 | + | ||
| 70 | + | envVars := make([]string, 0, len(keys)) | |
| 71 | + | for _, key := range keys { | |
| 72 | + | envVars = append(envVars, fmt.Sprintf("%s=%s", key, secretsMap[key])) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route") | |
| 76 | + | ||
| 77 | + | return envVars, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // isAttachmentContainer determines whether a given name refers to an attachment container | |
| 81 | + | // or a domain. It uses a simple heuristic: attachment container names always start with | |
| 82 | + | // the "gordon-" prefix (e.g., "gordon-git-example-com-gitea-postgres"). | |
| 83 | + | // | |
| 84 | + | // LIMITATION: This heuristic assumes domains never start with "gordon-". A domain like | |
| 85 | + | // "gordon-app.example.com" would be incorrectly classified as an attachment container. | |
| 86 | + | // This is an acceptable trade-off for simplicity, but if such domains become common, | |
| 87 | + | // this should be replaced with a more robust detection mechanism (e.g., checking both | |
| 88 | + | // GetAll() and GetAllAttachment() and seeing which returns results). | |
| 89 | + | func isAttachmentContainer(domain string) bool { | |
| 90 | + | return strings.HasPrefix(domain, "gordon-") | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // CreateEnvFile is a no-op for pass-backed loader. | |
| 94 | + | func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error { | |
| 95 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 96 | + | zerowrap.FieldLayer: "adapter", | |
| 97 | + | zerowrap.FieldAdapter: "envloader", | |
| 98 | + | zerowrap.FieldAction: "CreateEnvFile", | |
| 99 | + | "domain": domain, | |
| 100 | + | }) | |
| 101 | + | log := zerowrap.FromCtx(ctx) | |
| 102 | + | log.Debug().Msg("pass loader does not create env files") | |
| 103 | + | return nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // EnvFileExists checks if a manifest exists for a domain in pass. | |
| 107 | + | func (l *PassLoader) EnvFileExists(domain string) bool { | |
| 108 | + | exists, err := l.store.ManifestExists(domain) | |
| 109 | + | if err != nil { | |
| 110 | + | l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest") | |
| 111 | + | return false | |
| 112 | + | } | |
| 113 | + | return exists | |
| 114 | + | } | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | // Package envloader implements the environment variable loader adapter. | |||
| 2 | + | package envloader | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "sort" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // PassLoader implements the EnvLoader interface using pass-backed secrets. | |||
| 16 | + | type PassLoader struct { | |||
| 17 | + | store *domainsecrets.PassStore | |||
| 18 | + | log zerowrap.Logger | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // NewPassLoader creates a new pass-based environment loader. | |||
| 22 | + | func NewPassLoader(store *domainsecrets.PassStore, log zerowrap.Logger) (*PassLoader, error) { | |||
| 23 | + | if store == nil { | |||
| 24 | + | return nil, fmt.Errorf("pass store is required") | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | log.Debug(). | |||
| 28 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 29 | + | Str(zerowrap.FieldAdapter, "envloader"). | |||
| 30 | + | Str("provider", "pass"). | |||
| 31 | + | Msg("env loader initialized") | |||
| 32 | + | ||||
| 33 | + | return &PassLoader{ | |||
| 34 | + | store: store, | |||
| 35 | + | log: log, | |||
| 36 | + | }, nil | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // LoadEnv loads environment variables for a given domain. | |||
| 40 | + | func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) { | |||
| 41 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 42 | + | zerowrap.FieldLayer: "adapter", | |||
| 43 | + | zerowrap.FieldAdapter: "envloader", | |||
| 44 | + | zerowrap.FieldAction: "LoadEnv", | |||
| 45 | + | "domain": domain, | |||
| 46 | + | }) | |||
| 47 | + | log := zerowrap.FromCtx(ctx) | |||
| 48 | + | ||||
| 49 | + | var secretsMap map[string]string | |||
| 50 | + | var err error | |||
| 51 | + | ||||
| 52 | + | if isAttachmentContainer(domain) { | |||
| 53 | + | secretsMap, err = l.store.GetAllAttachment(domain) | |||
| 54 | + | if err != nil { | |||
| 55 | + | return nil, log.WrapErr(err, "failed to load attachment env from pass") | |||
| 56 | + | } | |||
| 57 | + | } else { | |||
| 58 | + | secretsMap, err = l.store.GetAll(domain) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return nil, log.WrapErr(err, "failed to load env from pass") | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | keys := make([]string, 0, len(secretsMap)) | |||
| 65 | + | for key := range secretsMap { | |||
| 66 | + | keys = append(keys, key) | |||
| 67 | + | } | |||
| 68 | + | sort.Strings(keys) | |||
| 69 | + | ||||
| 70 | + | envVars := make([]string, 0, len(keys)) | |||
| 71 | + | for _, key := range keys { | |||
| 72 | + | envVars = append(envVars, fmt.Sprintf("%s=%s", key, secretsMap[key])) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route") | |||
| 76 | + | ||||
| 77 | + | return envVars, nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // isAttachmentContainer determines whether a given name refers to an attachment container | |||
| 81 | + | // or a domain. It uses a simple heuristic: attachment container names always start with | |||
| 82 | + | // the "gordon-" prefix (e.g., "gordon-git-example-com-gitea-postgres"). | |||
| 83 | + | // | |||
| 84 | + | // LIMITATION: This heuristic assumes domains never start with "gordon-". A domain like | |||
| 85 | + | // "gordon-app.example.com" would be incorrectly classified as an attachment container. | |||
| 86 | + | // This is an acceptable trade-off for simplicity, but if such domains become common, | |||
| 87 | + | // this should be replaced with a more robust detection mechanism (e.g., checking both | |||
| 88 | + | // GetAll() and GetAllAttachment() and seeing which returns results). | |||
| 89 | + | func isAttachmentContainer(domain string) bool { | |||
| 90 | + | return strings.HasPrefix(domain, "gordon-") | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // CreateEnvFile is a no-op for pass-backed loader. | |||
| 94 | + | func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error { | |||
| 95 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 96 | + | zerowrap.FieldLayer: "adapter", | |||
| 97 | + | zerowrap.FieldAdapter: "envloader", | |||
| 98 | + | zerowrap.FieldAction: "CreateEnvFile", | |||
| 99 | + | "domain": domain, | |||
| 100 | + | }) | |||
| 101 | + | log := zerowrap.FromCtx(ctx) | |||
| 102 | + | log.Debug().Msg("pass loader does not create env files") | |||
| 103 | + | return nil | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // EnvFileExists checks if a manifest exists for a domain in pass. | |||
| 107 | + | func (l *PassLoader) EnvFileExists(domain string) bool { | |||
| 108 | + | exists, err := l.store.ManifestExists(domain) | |||
| 109 | + | if err != nil { | |||
| 110 | + | l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest") | |||
| 111 | + | return false | |||
| 112 | + | } | |||
| 113 | + | return exists | |||
| 114 | + | } | |||
A internal/adapters/out/envloader/pass_loader_test.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | package envloader | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os/exec" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 14 | + | "github.com/bnema/zerowrap" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func passCmd(args ...string) error { | |
| 18 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 19 | + | defer cancel() | |
| 20 | + | return exec.CommandContext(ctx, "pass", args...).Run() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func requirePass(t *testing.T) { | |
| 24 | + | if err := passCmd("version"); err != nil { | |
| 25 | + | t.Skip("pass not available") | |
| 26 | + | } | |
| 27 | + | if err := passCmd("ls"); err != nil { | |
| 28 | + | t.Skip("pass store not initialized") | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func cleanupPassEnv(t *testing.T, path string) { | |
| 33 | + | _ = passCmd("rm", "-rf", path) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestPassLoader_LoadEnv_Domain(t *testing.T) { | |
| 37 | + | requirePass(t) | |
| 38 | + | ||
| 39 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 40 | + | store, err := domainsecrets.NewPassStore(log) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | ||
| 43 | + | loader, err := NewPassLoader(store, log) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | ||
| 46 | + | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |
| 47 | + | defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassDomainSecretsPath, domainName)) | |
| 48 | + | ||
| 49 | + | secretsMap := map[string]string{ | |
| 50 | + | "API_KEY": "test123", | |
| 51 | + | "DB_HOST": "localhost", | |
| 52 | + | } | |
| 53 | + | err = store.Set(domainName, secretsMap) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | ||
| 56 | + | ctx := context.Background() | |
| 57 | + | envVars, err := loader.LoadEnv(ctx, domainName) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | assert.Len(t, envVars, 2) | |
| 60 | + | assert.Contains(t, envVars, "API_KEY=test123") | |
| 61 | + | assert.Contains(t, envVars, "DB_HOST=localhost") | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestPassLoader_LoadEnv_Attachment(t *testing.T) { | |
| 65 | + | requirePass(t) | |
| 66 | + | ||
| 67 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 68 | + | store, err := domainsecrets.NewPassStore(log) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | ||
| 71 | + | loader, err := NewPassLoader(store, log) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | ||
| 74 | + | containerName := fmt.Sprintf("gordon-gitea-postgres-%d", time.Now().UnixNano()) | |
| 75 | + | defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassAttachmentPath, containerName)) | |
| 76 | + | ||
| 77 | + | secretsMap := map[string]string{ | |
| 78 | + | "POSTGRES_USER": "gitea", | |
| 79 | + | "POSTGRES_PASSWORD": "secret123", | |
| 80 | + | } | |
| 81 | + | err = store.SetAttachment(containerName, secretsMap) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | ||
| 84 | + | ctx := context.Background() | |
| 85 | + | envVars, err := loader.LoadEnv(ctx, containerName) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | assert.Len(t, envVars, 2) | |
| 88 | + | assert.Contains(t, envVars, "POSTGRES_USER=gitea") | |
| 89 | + | assert.Contains(t, envVars, "POSTGRES_PASSWORD=secret123") | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestPassLoader_LoadEnv_EmptyDomain(t *testing.T) { | |
| 93 | + | requirePass(t) | |
| 94 | + | ||
| 95 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 96 | + | store, err := domainsecrets.NewPassStore(log) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | ||
| 99 | + | loader, err := NewPassLoader(store, log) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | ||
| 102 | + | domainName := fmt.Sprintf("empty.%d.example.com", time.Now().UnixNano()) | |
| 103 | + | ||
| 104 | + | ctx := context.Background() | |
| 105 | + | envVars, err := loader.LoadEnv(ctx, domainName) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | assert.Len(t, envVars, 0) | |
| 108 | + | } | |
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | package envloader | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 14 | + | "github.com/bnema/zerowrap" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func passCmd(args ...string) error { | |||
| 18 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 19 | + | defer cancel() | |||
| 20 | + | return exec.CommandContext(ctx, "pass", args...).Run() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func requirePass(t *testing.T) { | |||
| 24 | + | if err := passCmd("version"); err != nil { | |||
| 25 | + | t.Skip("pass not available") | |||
| 26 | + | } | |||
| 27 | + | if err := passCmd("ls"); err != nil { | |||
| 28 | + | t.Skip("pass store not initialized") | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func cleanupPassEnv(t *testing.T, path string) { | |||
| 33 | + | _ = passCmd("rm", "-rf", path) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestPassLoader_LoadEnv_Domain(t *testing.T) { | |||
| 37 | + | requirePass(t) | |||
| 38 | + | ||||
| 39 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 40 | + | store, err := domainsecrets.NewPassStore(log) | |||
| 41 | + | require.NoError(t, err) | |||
| 42 | + | ||||
| 43 | + | loader, err := NewPassLoader(store, log) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | ||||
| 46 | + | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |||
| 47 | + | defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassDomainSecretsPath, domainName)) | |||
| 48 | + | ||||
| 49 | + | secretsMap := map[string]string{ | |||
| 50 | + | "API_KEY": "test123", | |||
| 51 | + | "DB_HOST": "localhost", | |||
| 52 | + | } | |||
| 53 | + | err = store.Set(domainName, secretsMap) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | ||||
| 56 | + | ctx := context.Background() | |||
| 57 | + | envVars, err := loader.LoadEnv(ctx, domainName) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | assert.Len(t, envVars, 2) | |||
| 60 | + | assert.Contains(t, envVars, "API_KEY=test123") | |||
| 61 | + | assert.Contains(t, envVars, "DB_HOST=localhost") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestPassLoader_LoadEnv_Attachment(t *testing.T) { | |||
| 65 | + | requirePass(t) | |||
| 66 | + | ||||
| 67 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 68 | + | store, err := domainsecrets.NewPassStore(log) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | ||||
| 71 | + | loader, err := NewPassLoader(store, log) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | ||||
| 74 | + | containerName := fmt.Sprintf("gordon-gitea-postgres-%d", time.Now().UnixNano()) | |||
| 75 | + | defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassAttachmentPath, containerName)) | |||
| 76 | + | ||||
| 77 | + | secretsMap := map[string]string{ | |||
| 78 | + | "POSTGRES_USER": "gitea", | |||
| 79 | + | "POSTGRES_PASSWORD": "secret123", | |||
| 80 | + | } | |||
| 81 | + | err = store.SetAttachment(containerName, secretsMap) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | ||||
| 84 | + | ctx := context.Background() | |||
| 85 | + | envVars, err := loader.LoadEnv(ctx, containerName) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | assert.Len(t, envVars, 2) | |||
| 88 | + | assert.Contains(t, envVars, "POSTGRES_USER=gitea") | |||
| 89 | + | assert.Contains(t, envVars, "POSTGRES_PASSWORD=secret123") | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestPassLoader_LoadEnv_EmptyDomain(t *testing.T) { | |||
| 93 | + | requirePass(t) | |||
| 94 | + | ||||
| 95 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 96 | + | store, err := domainsecrets.NewPassStore(log) | |||
| 97 | + | require.NoError(t, err) | |||
| 98 | + | ||||
| 99 | + | loader, err := NewPassLoader(store, log) | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | ||||
| 102 | + | domainName := fmt.Sprintf("empty.%d.example.com", time.Now().UnixNano()) | |||
| 103 | + | ||||
| 104 | + | ctx := context.Background() | |||
| 105 | + | envVars, err := loader.LoadEnv(ctx, domainName) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | assert.Len(t, envVars, 0) | |||
| 108 | + | } | |||
A internal/app/migrate_env.go +223 -0
| @@ -0,0 +1,223 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 12 | + | "github.com/bnema/gordon/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func migrateEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |
| 16 | + | entries, err := readEnvDir(envDir) | |
| 17 | + | if err != nil { | |
| 18 | + | return err | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | for _, entry := range entries { | |
| 22 | + | if entry.IsDir() { | |
| 23 | + | continue | |
| 24 | + | } | |
| 25 | + | name := entry.Name() | |
| 26 | + | if !isPlainEnvFile(name) { | |
| 27 | + | continue | |
| 28 | + | } | |
| 29 | + | if err := migrateEnvFile(envDir, name, passStore, log); err != nil { | |
| 30 | + | return err | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | if err := migrateAttachmentEnvFilesToPass(envDir, passStore, log); err != nil { | |
| 35 | + | return err | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | return nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func readEnvDir(envDir string) ([]os.DirEntry, error) { | |
| 42 | + | entries, err := os.ReadDir(envDir) | |
| 43 | + | if err != nil { | |
| 44 | + | if os.IsNotExist(err) { | |
| 45 | + | return nil, nil | |
| 46 | + | } | |
| 47 | + | return nil, fmt.Errorf("failed to read env directory: %w", err) | |
| 48 | + | } | |
| 49 | + | return entries, nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func isPlainEnvFile(name string) bool { | |
| 53 | + | if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | // Skip attachment env files: gordon-<sanitized-domain>-<service>.env | |
| 57 | + | if strings.HasPrefix(name, "gordon-") { | |
| 58 | + | return false | |
| 59 | + | } | |
| 60 | + | return true | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |
| 64 | + | filePath := filepath.Join(envDir, name) | |
| 65 | + | data, err := os.ReadFile(filePath) | |
| 66 | + | if err != nil { | |
| 67 | + | return fmt.Errorf("failed to read env file %s: %w", filePath, err) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | domainName := strings.TrimSuffix(name, ".env") | |
| 71 | + | secrets, err := domain.ParseEnvData(data) | |
| 72 | + | if err != nil { | |
| 73 | + | return fmt.Errorf("failed to parse env file %s: %w", filePath, err) | |
| 74 | + | } | |
| 75 | + | if len(secrets) == 0 { | |
| 76 | + | log.Info(). | |
| 77 | + | Str("file", filePath). | |
| 78 | + | Msg("no secrets found in env file; skipping migration") | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | existingKeys, err := passStore.ListKeys(domainName) | |
| 83 | + | if err != nil { | |
| 84 | + | return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | missing := missingSecrets(existingKeys, secrets) | |
| 88 | + | if len(missing) > 0 { | |
| 89 | + | if err := passStore.Set(domainName, missing); err != nil { | |
| 90 | + | return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | migratedPath := filePath + ".migrated" | |
| 95 | + | if err := os.Rename(filePath, migratedPath); err != nil { | |
| 96 | + | return fmt.Errorf("failed to rename env file %s: %w", filePath, err) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | log.Info(). | |
| 100 | + | Int(zerowrap.FieldCount, len(missing)). | |
| 101 | + | Str("domain", domainName). | |
| 102 | + | Msg("migrated secrets for domain from plain text to pass") | |
| 103 | + | log.Info(). | |
| 104 | + | Str("file", migratedPath). | |
| 105 | + | Msg("original file renamed to .env.migrated - you can safely remove it") | |
| 106 | + | ||
| 107 | + | return nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func missingSecrets(existingKeys []string, secrets map[string]string) map[string]string { | |
| 111 | + | if len(secrets) == 0 { | |
| 112 | + | return nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | existingSet := make(map[string]struct{}, len(existingKeys)) | |
| 116 | + | for _, key := range existingKeys { | |
| 117 | + | existingSet[key] = struct{}{} | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | missing := make(map[string]string) | |
| 121 | + | for key, value := range secrets { | |
| 122 | + | if _, exists := existingSet[key]; exists { | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | missing[key] = value | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | if len(missing) == 0 { | |
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | return missing | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |
| 136 | + | entries, err := readEnvDir(envDir) | |
| 137 | + | if err != nil { | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | for _, entry := range entries { | |
| 142 | + | if entry.IsDir() { | |
| 143 | + | continue | |
| 144 | + | } | |
| 145 | + | name := entry.Name() | |
| 146 | + | if !isAttachmentEnvFile(name) { | |
| 147 | + | continue | |
| 148 | + | } | |
| 149 | + | if err := migrateAttachmentEnvFile(envDir, name, passStore, log); err != nil { | |
| 150 | + | return err | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | return nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func isAttachmentEnvFile(name string) bool { | |
| 158 | + | if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") { | |
| 159 | + | return false | |
| 160 | + | } | |
| 161 | + | return strings.HasPrefix(name, "gordon-") | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |
| 165 | + | filePath := filepath.Join(envDir, name) | |
| 166 | + | data, err := os.ReadFile(filePath) | |
| 167 | + | if err != nil { | |
| 168 | + | return fmt.Errorf("failed to read attachment env file %s: %w", filePath, err) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | containerName := extractContainerNameFromAttachmentFile(name) | |
| 172 | + | secrets, err := domain.ParseEnvData(data) | |
| 173 | + | if err != nil { | |
| 174 | + | return fmt.Errorf("failed to parse attachment env file %s: %w", filePath, err) | |
| 175 | + | } | |
| 176 | + | if len(secrets) == 0 { | |
| 177 | + | log.Info(). | |
| 178 | + | Str("file", filePath). | |
| 179 | + | Msg("no secrets found in attachment env file; skipping migration") | |
| 180 | + | return nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | existingSecrets, err := passStore.GetAllAttachment(containerName) | |
| 184 | + | if err != nil { | |
| 185 | + | return fmt.Errorf("failed to read pass secrets for attachment %s: %w", containerName, err) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | existingKeys := make([]string, 0, len(existingSecrets)) | |
| 189 | + | for key := range existingSecrets { | |
| 190 | + | existingKeys = append(existingKeys, key) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | missing := missingSecrets(existingKeys, secrets) | |
| 194 | + | if len(missing) > 0 { | |
| 195 | + | if err := passStore.SetAttachment(containerName, missing); err != nil { | |
| 196 | + | return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err) | |
| 197 | + | } | |
| 198 | + | log.Info(). | |
| 199 | + | Int(zerowrap.FieldCount, len(missing)). | |
| 200 | + | Str("container", containerName). | |
| 201 | + | Msg("migrated secrets for attachment container from plain text to pass") | |
| 202 | + | } else { | |
| 203 | + | log.Info(). | |
| 204 | + | Str("container", containerName). | |
| 205 | + | Msg("all attachment secrets already exist in pass; no migration needed") | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | migratedPath := filePath + ".migrated" | |
| 209 | + | if err := os.Rename(filePath, migratedPath); err != nil { | |
| 210 | + | return fmt.Errorf("failed to rename attachment env file %s: %w", filePath, err) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | log.Info(). | |
| 214 | + | Str("file", migratedPath). | |
| 215 | + | Msg("original attachment file renamed to .env.migrated - you can safely remove it") | |
| 216 | + | ||
| 217 | + | return nil | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func extractContainerNameFromAttachmentFile(filename string) string { | |
| 221 | + | name := strings.TrimSuffix(filename, ".env") | |
| 222 | + | return name | |
| 223 | + | } | |
| @@ -0,0 +1,223 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func migrateEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |||
| 16 | + | entries, err := readEnvDir(envDir) | |||
| 17 | + | if err != nil { | |||
| 18 | + | return err | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | for _, entry := range entries { | |||
| 22 | + | if entry.IsDir() { | |||
| 23 | + | continue | |||
| 24 | + | } | |||
| 25 | + | name := entry.Name() | |||
| 26 | + | if !isPlainEnvFile(name) { | |||
| 27 | + | continue | |||
| 28 | + | } | |||
| 29 | + | if err := migrateEnvFile(envDir, name, passStore, log); err != nil { | |||
| 30 | + | return err | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | if err := migrateAttachmentEnvFilesToPass(envDir, passStore, log); err != nil { | |||
| 35 | + | return err | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | return nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func readEnvDir(envDir string) ([]os.DirEntry, error) { | |||
| 42 | + | entries, err := os.ReadDir(envDir) | |||
| 43 | + | if err != nil { | |||
| 44 | + | if os.IsNotExist(err) { | |||
| 45 | + | return nil, nil | |||
| 46 | + | } | |||
| 47 | + | return nil, fmt.Errorf("failed to read env directory: %w", err) | |||
| 48 | + | } | |||
| 49 | + | return entries, nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func isPlainEnvFile(name string) bool { | |||
| 53 | + | if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") { | |||
| 54 | + | return false | |||
| 55 | + | } | |||
| 56 | + | // Skip attachment env files: gordon-<sanitized-domain>-<service>.env | |||
| 57 | + | if strings.HasPrefix(name, "gordon-") { | |||
| 58 | + | return false | |||
| 59 | + | } | |||
| 60 | + | return true | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |||
| 64 | + | filePath := filepath.Join(envDir, name) | |||
| 65 | + | data, err := os.ReadFile(filePath) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return fmt.Errorf("failed to read env file %s: %w", filePath, err) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | domainName := strings.TrimSuffix(name, ".env") | |||
| 71 | + | secrets, err := domain.ParseEnvData(data) | |||
| 72 | + | if err != nil { | |||
| 73 | + | return fmt.Errorf("failed to parse env file %s: %w", filePath, err) | |||
| 74 | + | } | |||
| 75 | + | if len(secrets) == 0 { | |||
| 76 | + | log.Info(). | |||
| 77 | + | Str("file", filePath). | |||
| 78 | + | Msg("no secrets found in env file; skipping migration") | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | existingKeys, err := passStore.ListKeys(domainName) | |||
| 83 | + | if err != nil { | |||
| 84 | + | return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | missing := missingSecrets(existingKeys, secrets) | |||
| 88 | + | if len(missing) > 0 { | |||
| 89 | + | if err := passStore.Set(domainName, missing); err != nil { | |||
| 90 | + | return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | migratedPath := filePath + ".migrated" | |||
| 95 | + | if err := os.Rename(filePath, migratedPath); err != nil { | |||
| 96 | + | return fmt.Errorf("failed to rename env file %s: %w", filePath, err) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | log.Info(). | |||
| 100 | + | Int(zerowrap.FieldCount, len(missing)). | |||
| 101 | + | Str("domain", domainName). | |||
| 102 | + | Msg("migrated secrets for domain from plain text to pass") | |||
| 103 | + | log.Info(). | |||
| 104 | + | Str("file", migratedPath). | |||
| 105 | + | Msg("original file renamed to .env.migrated - you can safely remove it") | |||
| 106 | + | ||||
| 107 | + | return nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func missingSecrets(existingKeys []string, secrets map[string]string) map[string]string { | |||
| 111 | + | if len(secrets) == 0 { | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | existingSet := make(map[string]struct{}, len(existingKeys)) | |||
| 116 | + | for _, key := range existingKeys { | |||
| 117 | + | existingSet[key] = struct{}{} | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | missing := make(map[string]string) | |||
| 121 | + | for key, value := range secrets { | |||
| 122 | + | if _, exists := existingSet[key]; exists { | |||
| 123 | + | continue | |||
| 124 | + | } | |||
| 125 | + | missing[key] = value | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | if len(missing) == 0 { | |||
| 129 | + | return nil | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | return missing | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |||
| 136 | + | entries, err := readEnvDir(envDir) | |||
| 137 | + | if err != nil { | |||
| 138 | + | return err | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | for _, entry := range entries { | |||
| 142 | + | if entry.IsDir() { | |||
| 143 | + | continue | |||
| 144 | + | } | |||
| 145 | + | name := entry.Name() | |||
| 146 | + | if !isAttachmentEnvFile(name) { | |||
| 147 | + | continue | |||
| 148 | + | } | |||
| 149 | + | if err := migrateAttachmentEnvFile(envDir, name, passStore, log); err != nil { | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | return nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func isAttachmentEnvFile(name string) bool { | |||
| 158 | + | if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") { | |||
| 159 | + | return false | |||
| 160 | + | } | |||
| 161 | + | return strings.HasPrefix(name, "gordon-") | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |||
| 165 | + | filePath := filepath.Join(envDir, name) | |||
| 166 | + | data, err := os.ReadFile(filePath) | |||
| 167 | + | if err != nil { | |||
| 168 | + | return fmt.Errorf("failed to read attachment env file %s: %w", filePath, err) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | containerName := extractContainerNameFromAttachmentFile(name) | |||
| 172 | + | secrets, err := domain.ParseEnvData(data) | |||
| 173 | + | if err != nil { | |||
| 174 | + | return fmt.Errorf("failed to parse attachment env file %s: %w", filePath, err) | |||
| 175 | + | } | |||
| 176 | + | if len(secrets) == 0 { | |||
| 177 | + | log.Info(). | |||
| 178 | + | Str("file", filePath). | |||
| 179 | + | Msg("no secrets found in attachment env file; skipping migration") | |||
| 180 | + | return nil | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | existingSecrets, err := passStore.GetAllAttachment(containerName) | |||
| 184 | + | if err != nil { | |||
| 185 | + | return fmt.Errorf("failed to read pass secrets for attachment %s: %w", containerName, err) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | existingKeys := make([]string, 0, len(existingSecrets)) | |||
| 189 | + | for key := range existingSecrets { | |||
| 190 | + | existingKeys = append(existingKeys, key) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | missing := missingSecrets(existingKeys, secrets) | |||
| 194 | + | if len(missing) > 0 { | |||
| 195 | + | if err := passStore.SetAttachment(containerName, missing); err != nil { | |||
| 196 | + | return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err) | |||
| 197 | + | } | |||
| 198 | + | log.Info(). | |||
| 199 | + | Int(zerowrap.FieldCount, len(missing)). | |||
| 200 | + | Str("container", containerName). | |||
| 201 | + | Msg("migrated secrets for attachment container from plain text to pass") | |||
| 202 | + | } else { | |||
| 203 | + | log.Info(). | |||
| 204 | + | Str("container", containerName). | |||
| 205 | + | Msg("all attachment secrets already exist in pass; no migration needed") | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | migratedPath := filePath + ".migrated" | |||
| 209 | + | if err := os.Rename(filePath, migratedPath); err != nil { | |||
| 210 | + | return fmt.Errorf("failed to rename attachment env file %s: %w", filePath, err) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | log.Info(). | |||
| 214 | + | Str("file", migratedPath). | |||
| 215 | + | Msg("original attachment file renamed to .env.migrated - you can safely remove it") | |||
| 216 | + | ||||
| 217 | + | return nil | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func extractContainerNameFromAttachmentFile(filename string) string { | |||
| 221 | + | name := strings.TrimSuffix(filename, ".env") | |||
| 222 | + | return name | |||
| 223 | + | } | |||
A internal/app/migrate_env_test.go +213 -0
| @@ -0,0 +1,213 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 17 | + | "github.com/bnema/gordon/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func passCmd(args ...string) error { | |
| 21 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 22 | + | defer cancel() | |
| 23 | + | return exec.CommandContext(ctx, "pass", args...).Run() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func requirePass(t *testing.T) { | |
| 27 | + | if err := passCmd("version"); err != nil { | |
| 28 | + | t.Skip("pass not available") | |
| 29 | + | } | |
| 30 | + | if err := passCmd("ls"); err != nil { | |
| 31 | + | t.Skip("pass store not initialized") | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func cleanupPassDomain(t *testing.T, domainName string, keys []string) { | |
| 36 | + | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 37 | + | if err != nil { | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | for _, key := range keys { | |
| 42 | + | path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassDomainSecretsPath, safeDomain, key) | |
| 43 | + | _ = passCmd("rm", "-f", path) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", domainsecrets.PassDomainSecretsPath, safeDomain) | |
| 47 | + | _ = passCmd("rm", "-f", manifestPath) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestMigrateEnvFile(t *testing.T) { | |
| 51 | + | requirePass(t) | |
| 52 | + | ||
| 53 | + | tmpDir, err := os.MkdirTemp("", "migrate-env-test-*") | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | defer os.RemoveAll(tmpDir) | |
| 56 | + | ||
| 57 | + | logger := zerowrap.Default() | |
| 58 | + | store, err := domainsecrets.NewPassStore(logger) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | ||
| 61 | + | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |
| 62 | + | keys := []string{"API_KEY", "DB_PASSWORD"} | |
| 63 | + | defer cleanupPassDomain(t, domainName, keys) | |
| 64 | + | ||
| 65 | + | envContent := "API_KEY=secret123\nDB_PASSWORD=pass456\n" | |
| 66 | + | envFileName := domainName + ".env" | |
| 67 | + | envFilePath := filepath.Join(tmpDir, envFileName) | |
| 68 | + | err = os.WriteFile(envFilePath, []byte(envContent), 0600) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | ||
| 71 | + | err = migrateEnvFile(tmpDir, envFileName, store, logger) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | ||
| 74 | + | migratedPath := envFilePath + ".migrated" | |
| 75 | + | _, err = os.Stat(envFilePath) | |
| 76 | + | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | |
| 77 | + | ||
| 78 | + | _, err = os.Stat(migratedPath) | |
| 79 | + | assert.NoError(t, err, ".env.migrated file should exist") | |
| 80 | + | ||
| 81 | + | passKeys, err := store.ListKeys(domainName) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | assert.ElementsMatch(t, keys, passKeys) | |
| 84 | + | ||
| 85 | + | values, err := store.GetAll(domainName) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | assert.Equal(t, "secret123", values["API_KEY"]) | |
| 88 | + | assert.Equal(t, "pass456", values["DB_PASSWORD"]) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func cleanupPassAttachment(t *testing.T, containerName string, keys []string) { | |
| 92 | + | for _, key := range keys { | |
| 93 | + | path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key) | |
| 94 | + | _ = passCmd("rm", "-f", path) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", domainsecrets.PassAttachmentPath, containerName) | |
| 98 | + | _ = passCmd("rm", "-f", manifestPath) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestMigrateAttachmentEnvFile(t *testing.T) { | |
| 102 | + | requirePass(t) | |
| 103 | + | ||
| 104 | + | tmpDir, err := os.MkdirTemp("", "migrate-attachment-env-test-*") | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | defer os.RemoveAll(tmpDir) | |
| 107 | + | ||
| 108 | + | logger := zerowrap.Default() | |
| 109 | + | store, err := domainsecrets.NewPassStore(logger) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | ||
| 112 | + | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |
| 113 | + | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |
| 114 | + | defer cleanupPassAttachment(t, containerName, keys) | |
| 115 | + | ||
| 116 | + | envContent := "POSTGRES_USER=gitea\nPOSTGRES_PASSWORD=secret123\n" | |
| 117 | + | envFileName := "gordon-" + containerName + ".env" | |
| 118 | + | envFilePath := filepath.Join(tmpDir, envFileName) | |
| 119 | + | err = os.WriteFile(envFilePath, []byte(envContent), 0600) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | ||
| 122 | + | err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | ||
| 125 | + | migratedPath := envFilePath + ".migrated" | |
| 126 | + | _, err = os.Stat(envFilePath) | |
| 127 | + | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | |
| 128 | + | ||
| 129 | + | _, err = os.Stat(migratedPath) | |
| 130 | + | assert.NoError(t, err, ".env.migrated file should exist") | |
| 131 | + | ||
| 132 | + | values, err := store.GetAllAttachment(containerName) | |
| 133 | + | require.NoError(t, err) | |
| 134 | + | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |
| 135 | + | assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"]) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestIsAttachmentEnvFile(t *testing.T) { | |
| 139 | + | tests := []struct { | |
| 140 | + | name string | |
| 141 | + | filename string | |
| 142 | + | want bool | |
| 143 | + | }{ | |
| 144 | + | { | |
| 145 | + | name: "attachment env file", | |
| 146 | + | filename: "gordon-gitea-postgres.env", | |
| 147 | + | want: true, | |
| 148 | + | }, | |
| 149 | + | { | |
| 150 | + | name: "attachment env file with complex name", | |
| 151 | + | filename: "gordon-git-example-com-gitea-postgres.env", | |
| 152 | + | want: true, | |
| 153 | + | }, | |
| 154 | + | { | |
| 155 | + | name: "plain domain env file", | |
| 156 | + | filename: "example.com.env", | |
| 157 | + | want: false, | |
| 158 | + | }, | |
| 159 | + | { | |
| 160 | + | name: "migrated attachment file", | |
| 161 | + | filename: "gordon-gitea-postgres.env.migrated", | |
| 162 | + | want: false, | |
| 163 | + | }, | |
| 164 | + | { | |
| 165 | + | name: "non-env file", | |
| 166 | + | filename: "config.yaml", | |
| 167 | + | want: false, | |
| 168 | + | }, | |
| 169 | + | { | |
| 170 | + | name: "gordon prefix but no env suffix", | |
| 171 | + | filename: "gordon-something", | |
| 172 | + | want: false, | |
| 173 | + | }, | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | for _, tt := range tests { | |
| 177 | + | t.Run(tt.name, func(t *testing.T) { | |
| 178 | + | got := isAttachmentEnvFile(tt.filename) | |
| 179 | + | assert.Equal(t, tt.want, got) | |
| 180 | + | }) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestExtractContainerNameFromAttachmentFile(t *testing.T) { | |
| 185 | + | tests := []struct { | |
| 186 | + | name string | |
| 187 | + | filename string | |
| 188 | + | want string | |
| 189 | + | }{ | |
| 190 | + | { | |
| 191 | + | name: "simple attachment file", | |
| 192 | + | filename: "gordon-gitea-postgres.env", | |
| 193 | + | want: "gordon-gitea-postgres", | |
| 194 | + | }, | |
| 195 | + | { | |
| 196 | + | name: "complex attachment file", | |
| 197 | + | filename: "gordon-git-example-com-gitea-redis.env", | |
| 198 | + | want: "gordon-git-example-com-gitea-redis", | |
| 199 | + | }, | |
| 200 | + | { | |
| 201 | + | name: "single word container", | |
| 202 | + | filename: "gordon-redis.env", | |
| 203 | + | want: "gordon-redis", | |
| 204 | + | }, | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | for _, tt := range tests { | |
| 208 | + | t.Run(tt.name, func(t *testing.T) { | |
| 209 | + | got := extractContainerNameFromAttachmentFile(tt.filename) | |
| 210 | + | assert.Equal(t, tt.want, got) | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | } | |
| @@ -0,0 +1,213 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 17 | + | "github.com/bnema/gordon/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func passCmd(args ...string) error { | |||
| 21 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 22 | + | defer cancel() | |||
| 23 | + | return exec.CommandContext(ctx, "pass", args...).Run() | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func requirePass(t *testing.T) { | |||
| 27 | + | if err := passCmd("version"); err != nil { | |||
| 28 | + | t.Skip("pass not available") | |||
| 29 | + | } | |||
| 30 | + | if err := passCmd("ls"); err != nil { | |||
| 31 | + | t.Skip("pass store not initialized") | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func cleanupPassDomain(t *testing.T, domainName string, keys []string) { | |||
| 36 | + | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | for _, key := range keys { | |||
| 42 | + | path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassDomainSecretsPath, safeDomain, key) | |||
| 43 | + | _ = passCmd("rm", "-f", path) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", domainsecrets.PassDomainSecretsPath, safeDomain) | |||
| 47 | + | _ = passCmd("rm", "-f", manifestPath) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestMigrateEnvFile(t *testing.T) { | |||
| 51 | + | requirePass(t) | |||
| 52 | + | ||||
| 53 | + | tmpDir, err := os.MkdirTemp("", "migrate-env-test-*") | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | defer os.RemoveAll(tmpDir) | |||
| 56 | + | ||||
| 57 | + | logger := zerowrap.Default() | |||
| 58 | + | store, err := domainsecrets.NewPassStore(logger) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | ||||
| 61 | + | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |||
| 62 | + | keys := []string{"API_KEY", "DB_PASSWORD"} | |||
| 63 | + | defer cleanupPassDomain(t, domainName, keys) | |||
| 64 | + | ||||
| 65 | + | envContent := "API_KEY=secret123\nDB_PASSWORD=pass456\n" | |||
| 66 | + | envFileName := domainName + ".env" | |||
| 67 | + | envFilePath := filepath.Join(tmpDir, envFileName) | |||
| 68 | + | err = os.WriteFile(envFilePath, []byte(envContent), 0600) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | ||||
| 71 | + | err = migrateEnvFile(tmpDir, envFileName, store, logger) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | ||||
| 74 | + | migratedPath := envFilePath + ".migrated" | |||
| 75 | + | _, err = os.Stat(envFilePath) | |||
| 76 | + | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | |||
| 77 | + | ||||
| 78 | + | _, err = os.Stat(migratedPath) | |||
| 79 | + | assert.NoError(t, err, ".env.migrated file should exist") | |||
| 80 | + | ||||
| 81 | + | passKeys, err := store.ListKeys(domainName) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | assert.ElementsMatch(t, keys, passKeys) | |||
| 84 | + | ||||
| 85 | + | values, err := store.GetAll(domainName) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | assert.Equal(t, "secret123", values["API_KEY"]) | |||
| 88 | + | assert.Equal(t, "pass456", values["DB_PASSWORD"]) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func cleanupPassAttachment(t *testing.T, containerName string, keys []string) { | |||
| 92 | + | for _, key := range keys { | |||
| 93 | + | path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key) | |||
| 94 | + | _ = passCmd("rm", "-f", path) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", domainsecrets.PassAttachmentPath, containerName) | |||
| 98 | + | _ = passCmd("rm", "-f", manifestPath) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestMigrateAttachmentEnvFile(t *testing.T) { | |||
| 102 | + | requirePass(t) | |||
| 103 | + | ||||
| 104 | + | tmpDir, err := os.MkdirTemp("", "migrate-attachment-env-test-*") | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | defer os.RemoveAll(tmpDir) | |||
| 107 | + | ||||
| 108 | + | logger := zerowrap.Default() | |||
| 109 | + | store, err := domainsecrets.NewPassStore(logger) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | ||||
| 112 | + | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |||
| 113 | + | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |||
| 114 | + | defer cleanupPassAttachment(t, containerName, keys) | |||
| 115 | + | ||||
| 116 | + | envContent := "POSTGRES_USER=gitea\nPOSTGRES_PASSWORD=secret123\n" | |||
| 117 | + | envFileName := "gordon-" + containerName + ".env" | |||
| 118 | + | envFilePath := filepath.Join(tmpDir, envFileName) | |||
| 119 | + | err = os.WriteFile(envFilePath, []byte(envContent), 0600) | |||
| 120 | + | require.NoError(t, err) | |||
| 121 | + | ||||
| 122 | + | err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | ||||
| 125 | + | migratedPath := envFilePath + ".migrated" | |||
| 126 | + | _, err = os.Stat(envFilePath) | |||
| 127 | + | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | |||
| 128 | + | ||||
| 129 | + | _, err = os.Stat(migratedPath) | |||
| 130 | + | assert.NoError(t, err, ".env.migrated file should exist") | |||
| 131 | + | ||||
| 132 | + | values, err := store.GetAllAttachment(containerName) | |||
| 133 | + | require.NoError(t, err) | |||
| 134 | + | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |||
| 135 | + | assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"]) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestIsAttachmentEnvFile(t *testing.T) { | |||
| 139 | + | tests := []struct { | |||
| 140 | + | name string | |||
| 141 | + | filename string | |||
| 142 | + | want bool | |||
| 143 | + | }{ | |||
| 144 | + | { | |||
| 145 | + | name: "attachment env file", | |||
| 146 | + | filename: "gordon-gitea-postgres.env", | |||
| 147 | + | want: true, | |||
| 148 | + | }, | |||
| 149 | + | { | |||
| 150 | + | name: "attachment env file with complex name", | |||
| 151 | + | filename: "gordon-git-example-com-gitea-postgres.env", | |||
| 152 | + | want: true, | |||
| 153 | + | }, | |||
| 154 | + | { | |||
| 155 | + | name: "plain domain env file", | |||
| 156 | + | filename: "example.com.env", | |||
| 157 | + | want: false, | |||
| 158 | + | }, | |||
| 159 | + | { | |||
| 160 | + | name: "migrated attachment file", | |||
| 161 | + | filename: "gordon-gitea-postgres.env.migrated", | |||
| 162 | + | want: false, | |||
| 163 | + | }, | |||
| 164 | + | { | |||
| 165 | + | name: "non-env file", | |||
| 166 | + | filename: "config.yaml", | |||
| 167 | + | want: false, | |||
| 168 | + | }, | |||
| 169 | + | { | |||
| 170 | + | name: "gordon prefix but no env suffix", | |||
| 171 | + | filename: "gordon-something", | |||
| 172 | + | want: false, | |||
| 173 | + | }, | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | for _, tt := range tests { | |||
| 177 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 178 | + | got := isAttachmentEnvFile(tt.filename) | |||
| 179 | + | assert.Equal(t, tt.want, got) | |||
| 180 | + | }) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestExtractContainerNameFromAttachmentFile(t *testing.T) { | |||
| 185 | + | tests := []struct { | |||
| 186 | + | name string | |||
| 187 | + | filename string | |||
| 188 | + | want string | |||
| 189 | + | }{ | |||
| 190 | + | { | |||
| 191 | + | name: "simple attachment file", | |||
| 192 | + | filename: "gordon-gitea-postgres.env", | |||
| 193 | + | want: "gordon-gitea-postgres", | |||
| 194 | + | }, | |||
| 195 | + | { | |||
| 196 | + | name: "complex attachment file", | |||
| 197 | + | filename: "gordon-git-example-com-gitea-redis.env", | |||
| 198 | + | want: "gordon-git-example-com-gitea-redis", | |||
| 199 | + | }, | |||
| 200 | + | { | |||
| 201 | + | name: "single word container", | |||
| 202 | + | filename: "gordon-redis.env", | |||
| 203 | + | want: "gordon-redis", | |||
| 204 | + | }, | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | for _, tt := range tests { | |||
| 208 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 209 | + | got := extractContainerNameFromAttachmentFile(tt.filename) | |||
| 210 | + | assert.Equal(t, tt.want, got) | |||
| 211 | + | }) | |||
| 212 | + | } | |||
| 213 | + | } | |||
M internal/app/run.go +136 -68
| @@ -119,7 +119,7 @@ type services struct { | |||
| 119 | 119 | eventBus *eventbus.InMemory | |
| 120 | 120 | blobStorage *filesystem.BlobStorage | |
| 121 | 121 | manifestStorage *filesystem.ManifestStorage | |
| 122 | - | envLoader *envloader.FileLoader | |
| 122 | + | envLoader out.EnvLoader | |
| 123 | 123 | logWriter *logwriter.LogWriter | |
| 124 | 124 | tokenStore out.TokenStore | |
| 125 | 125 | configSvc *config.Service | |
| @@ -256,11 +256,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 256 | 256 | return nil, err | |
| 257 | 257 | } | |
| 258 | 258 | ||
| 259 | - | // Create env loader | |
| 260 | - | if svc.envLoader, err = createEnvLoader(cfg, log); err != nil { | |
| 261 | - | return nil, err | |
| 262 | - | } | |
| 263 | - | ||
| 264 | 259 | // Create log writer | |
| 265 | 260 | if svc.logWriter, err = createLogWriter(cfg, log); err != nil { | |
| 266 | 261 | return nil, err | |
| @@ -271,19 +266,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 271 | 266 | return nil, err | |
| 272 | 267 | } | |
| 273 | 268 | ||
| 274 | - | // Generate internal registry credentials for loopback-only pulls | |
| 275 | - | if cfg.Auth.Enabled { | |
| 276 | - | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | |
| 277 | - | if err != nil { | |
| 278 | - | return nil, log.WrapErr(err, "failed to generate internal registry credentials") | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | // Persist credentials to file for CLI access (gordon auth internal) | |
| 282 | - | if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil { | |
| 283 | - | log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access") | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | log.Debug().Msg("internal registry auth generated for loopback pulls") | |
| 269 | + | if err := setupInternalRegistryAuth(cfg, svc, log); err != nil { | |
| 270 | + | return nil, err | |
| 287 | 271 | } | |
| 288 | 272 | ||
| 289 | 273 | // Create use case services | |
| @@ -292,7 +276,24 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 292 | 276 | return nil, log.WrapErr(err, "failed to load configuration") | |
| 293 | 277 | } | |
| 294 | 278 | ||
| 295 | - | svc.containerSvc = createContainerService(v, cfg, svc) | |
| 279 | + | var backend domain.SecretsBackend | |
| 280 | + | var passStore *domainsecrets.PassStore | |
| 281 | + | var domainSecretStore out.DomainSecretStore | |
| 282 | + | ||
| 283 | + | svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log) | |
| 284 | + | if err != nil { | |
| 285 | + | return nil, err | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil { | |
| 289 | + | return nil, err | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | secretSvc := secretsSvc.NewService(domainSecretStore, log) | |
| 293 | + | ||
| 294 | + | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | |
| 295 | + | return nil, err | |
| 296 | + | } | |
| 296 | 297 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | |
| 297 | 298 | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{ | |
| 298 | 299 | RegistryDomain: cfg.Server.RegistryDomain, | |
| @@ -308,23 +309,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 308 | 309 | svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log) | |
| 309 | 310 | } | |
| 310 | 311 | ||
| 311 | - | // Determine env directory for admin API | |
| 312 | - | dataDir := cfg.Server.DataDir | |
| 313 | - | if dataDir == "" { | |
| 314 | - | dataDir = DefaultDataDir() | |
| 315 | - | } | |
| 316 | - | svc.envDir = cfg.Env.Dir | |
| 317 | - | if svc.envDir == "" { | |
| 318 | - | svc.envDir = filepath.Join(dataDir, "env") | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | // Create domain secret store and service | |
| 322 | - | domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log) | |
| 323 | - | if err != nil { | |
| 324 | - | return nil, log.WrapErr(err, "failed to create domain secret store") | |
| 325 | - | } | |
| 326 | - | secretSvc := secretsSvc.NewService(domainSecretStore, log) | |
| 327 | - | ||
| 328 | 312 | // Create health service for route health checking | |
| 329 | 313 | prober := httpprober.New() | |
| 330 | 314 | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |
| @@ -338,6 +322,49 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 338 | 322 | return svc, nil | |
| 339 | 323 | } | |
| 340 | 324 | ||
| 325 | + | func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error { | |
| 326 | + | if !cfg.Auth.Enabled { | |
| 327 | + | return nil | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | var err error | |
| 331 | + | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | |
| 332 | + | if err != nil { | |
| 333 | + | return log.WrapErr(err, "failed to generate internal registry credentials") | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // Persist credentials to file for CLI access (gordon auth internal) | |
| 337 | + | if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil { | |
| 338 | + | log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access") | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | log.Debug().Msg("internal registry auth generated for loopback pulls") | |
| 342 | + | return nil | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) { | |
| 346 | + | envDir := resolveEnvDir(cfg) | |
| 347 | + | backend := resolveSecretsBackend(cfg.Auth.SecretsBackend) | |
| 348 | + | ||
| 349 | + | switch backend { | |
| 350 | + | case domain.SecretsBackendPass: | |
| 351 | + | passStore, err := domainsecrets.NewPassStore(log) | |
| 352 | + | if err != nil { | |
| 353 | + | return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store") | |
| 354 | + | } | |
| 355 | + | if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil { | |
| 356 | + | return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass") | |
| 357 | + | } | |
| 358 | + | return envDir, backend, passStore, passStore, nil | |
| 359 | + | default: | |
| 360 | + | store, err := domainsecrets.NewFileStore(envDir, log) | |
| 361 | + | if err != nil { | |
| 362 | + | return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store") | |
| 363 | + | } | |
| 364 | + | return envDir, backend, nil, store, nil | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | ||
| 341 | 368 | // resolveLogFilePath returns the configured log file path or a default. | |
| 342 | 369 | func resolveLogFilePath(cfg Config) string { | |
| 343 | 370 | if cfg.Logging.File.Path != "" { | |
| @@ -391,36 +418,35 @@ func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *f | |||
| 391 | 418 | } | |
| 392 | 419 | ||
| 393 | 420 | // createEnvLoader creates the environment loader with secret providers. | |
| 394 | - | func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, error) { | |
| 395 | - | dataDir := cfg.Server.DataDir | |
| 396 | - | if dataDir == "" { | |
| 397 | - | dataDir = DefaultDataDir() | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | envDir := cfg.Env.Dir | |
| 401 | - | if envDir == "" { | |
| 402 | - | envDir = filepath.Join(dataDir, "env") | |
| 403 | - | } | |
| 421 | + | func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) { | |
| 422 | + | switch backend { | |
| 423 | + | case domain.SecretsBackendPass: | |
| 424 | + | loader, err := envloader.NewPassLoader(passStore, log) | |
| 425 | + | if err != nil { | |
| 426 | + | return nil, log.WrapErr(err, "failed to create pass env loader") | |
| 427 | + | } | |
| 428 | + | return loader, nil | |
| 429 | + | default: | |
| 430 | + | loader, err := envloader.NewFileLoader(envDir, log) | |
| 431 | + | if err != nil { | |
| 432 | + | return nil, log.WrapErr(err, "failed to create env loader") | |
| 433 | + | } | |
| 404 | 434 | ||
| 405 | - | envLoader, err := envloader.NewFileLoader(envDir, log) | |
| 406 | - | if err != nil { | |
| 407 | - | return nil, log.WrapErr(err, "failed to create env loader") | |
| 408 | - | } | |
| 435 | + | // Register secret providers | |
| 436 | + | passProvider := secrets.NewPassProvider(log) | |
| 437 | + | if passProvider.IsAvailable() { | |
| 438 | + | loader.RegisterSecretProvider(passProvider) | |
| 439 | + | log.Debug().Msg("pass secret provider registered") | |
| 440 | + | } | |
| 409 | 441 | ||
| 410 | - | // Register secret providers | |
| 411 | - | passProvider := secrets.NewPassProvider(log) | |
| 412 | - | if passProvider.IsAvailable() { | |
| 413 | - | envLoader.RegisterSecretProvider(passProvider) | |
| 414 | - | log.Debug().Msg("pass secret provider registered") | |
| 415 | - | } | |
| 442 | + | sopsProvider := secrets.NewSopsProvider(log) | |
| 443 | + | if sopsProvider.IsAvailable() { | |
| 444 | + | loader.RegisterSecretProvider(sopsProvider) | |
| 445 | + | log.Debug().Msg("sops secret provider registered") | |
| 446 | + | } | |
| 416 | 447 | ||
| 417 | - | sopsProvider := secrets.NewSopsProvider(log) | |
| 418 | - | if sopsProvider.IsAvailable() { | |
| 419 | - | envLoader.RegisterSecretProvider(sopsProvider) | |
| 420 | - | log.Debug().Msg("sops secret provider registered") | |
| 448 | + | return loader, nil | |
| 421 | 449 | } | |
| 422 | - | ||
| 423 | - | return envLoader, nil | |
| 424 | 450 | } | |
| 425 | 451 | ||
| 426 | 452 | // createLogWriter creates the container log writer. | |
| @@ -454,7 +480,11 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err | |||
| 454 | 480 | return writer, nil | |
| 455 | 481 | } | |
| 456 | 482 | ||
| 457 | - | const internalRegistryUsername = "gordon-internal" | |
| 483 | + | const ( | |
| 484 | + | internalRegistryUsername = "gordon-internal" | |
| 485 | + | serviceTokenSubject = "gordon-service" | |
| 486 | + | serviceTokenDefaultTTL = 30 * 24 * time.Hour | |
| 487 | + | ) | |
| 458 | 488 | ||
| 459 | 489 | func generateInternalRegistryAuth() (string, string, error) { | |
| 460 | 490 | password, err := randomTokenHex(32) | |
| @@ -634,6 +664,15 @@ func resolveDataDir(dataDir string) string { | |||
| 634 | 664 | return dataDir | |
| 635 | 665 | } | |
| 636 | 666 | ||
| 667 | + | func resolveEnvDir(cfg Config) string { | |
| 668 | + | dataDir := resolveDataDir(cfg.Server.DataDir) | |
| 669 | + | envDir := cfg.Env.Dir | |
| 670 | + | if envDir == "" { | |
| 671 | + | envDir = filepath.Join(dataDir, "env") | |
| 672 | + | } | |
| 673 | + | return envDir | |
| 674 | + | } | |
| 675 | + | ||
| 637 | 676 | func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { | |
| 638 | 677 | // Token store is always created since tokens work in both auth modes | |
| 639 | 678 | store, err := tokenstore.NewStore(backend, dataDir, log) | |
| @@ -751,6 +790,17 @@ func parseTokenExpiry(expiry string) (time.Duration, error) { | |||
| 751 | 790 | return parsed, nil | |
| 752 | 791 | } | |
| 753 | 792 | ||
| 793 | + | func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) { | |
| 794 | + | expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry) | |
| 795 | + | if err != nil { | |
| 796 | + | return 0, err | |
| 797 | + | } | |
| 798 | + | if expiry <= 0 { | |
| 799 | + | return serviceTokenDefaultTTL, nil | |
| 800 | + | } | |
| 801 | + | return expiry, nil | |
| 802 | + | } | |
| 803 | + | ||
| 754 | 804 | // loadSecret loads a secret from the configured backend. | |
| 755 | 805 | func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) { | |
| 756 | 806 | switch backend { | |
| @@ -774,13 +824,11 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi | |||
| 774 | 824 | } | |
| 775 | 825 | ||
| 776 | 826 | // createContainerService creates the container service with configuration. | |
| 777 | - | func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service { | |
| 827 | + | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | |
| 778 | 828 | containerConfig := container.Config{ | |
| 779 | 829 | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 780 | 830 | RegistryDomain: cfg.Server.RegistryDomain, | |
| 781 | 831 | RegistryPort: cfg.Server.RegistryPort, | |
| 782 | - | RegistryUsername: cfg.Auth.Username, | |
| 783 | - | RegistryPassword: cfg.Auth.Password, | |
| 784 | 832 | InternalRegistryUsername: svc.internalRegUser, | |
| 785 | 833 | InternalRegistryPassword: svc.internalRegPass, | |
| 786 | 834 | PullPolicy: v.GetString("deploy.pull_policy"), | |
| @@ -793,7 +841,27 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe | |||
| 793 | 841 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | |
| 794 | 842 | Attachments: svc.configSvc.GetAttachments(), | |
| 795 | 843 | } | |
| 796 | - | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig) | |
| 844 | + | ||
| 845 | + | if cfg.Auth.Enabled && svc.authSvc != nil { | |
| 846 | + | expiry, err := resolveServiceTokenExpiry(cfg) | |
| 847 | + | if err != nil { | |
| 848 | + | return nil, log.WrapErr(err, "failed to resolve service token expiry") | |
| 849 | + | } | |
| 850 | + | // Note: Service tokens are not auto-refreshed. If the token expires during | |
| 851 | + | // container runtime, the container will need to be recreated to get a new token. | |
| 852 | + | serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry) | |
| 853 | + | if err != nil { | |
| 854 | + | return nil, log.WrapErr(err, "failed to generate registry service token") | |
| 855 | + | } | |
| 856 | + | log.Info(). | |
| 857 | + | Str("subject", serviceTokenSubject). | |
| 858 | + | Str("expiry", expiry.String()). | |
| 859 | + | Msg("generated service token for container registry access") | |
| 860 | + | containerConfig.ServiceTokenUsername = serviceTokenSubject | |
| 861 | + | containerConfig.ServiceToken = serviceToken | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | |
| 797 | 865 | } | |
| 798 | 866 | ||
| 799 | 867 | // registerEventHandlers registers all event handlers. | |
| @@ -119,7 +119,7 @@ type services struct { | |||||
| 119 | eventBus *eventbus.InMemory | 119 | eventBus *eventbus.InMemory | ||
| 120 | blobStorage *filesystem.BlobStorage | 120 | blobStorage *filesystem.BlobStorage | ||
| 121 | manifestStorage *filesystem.ManifestStorage | 121 | manifestStorage *filesystem.ManifestStorage | ||
| 122 | - | envLoader *envloader.FileLoader | 122 | + | envLoader out.EnvLoader |
| 123 | logWriter *logwriter.LogWriter | 123 | logWriter *logwriter.LogWriter | ||
| 124 | tokenStore out.TokenStore | 124 | tokenStore out.TokenStore | ||
| 125 | configSvc *config.Service | 125 | configSvc *config.Service | ||
| @@ -256,11 +256,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 256 | return nil, err | 256 | return nil, err | ||
| 257 | } | 257 | } | ||
| 258 | 258 | ||||
| 259 | - | // Create env loader | |||
| 260 | - | if svc.envLoader, err = createEnvLoader(cfg, log); err != nil { | |||
| 261 | - | return nil, err | |||
| 262 | - | } | |||
| 263 | - | ||||
| 264 | // Create log writer | 259 | // Create log writer | ||
| 265 | if svc.logWriter, err = createLogWriter(cfg, log); err != nil { | 260 | if svc.logWriter, err = createLogWriter(cfg, log); err != nil { | ||
| 266 | return nil, err | 261 | return nil, err | ||
| @@ -271,19 +266,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 271 | return nil, err | 266 | return nil, err | ||
| 272 | } | 267 | } | ||
| 273 | 268 | ||||
| 274 | - | // Generate internal registry credentials for loopback-only pulls | 269 | + | if err := setupInternalRegistryAuth(cfg, svc, log); err != nil { |
| 275 | - | if cfg.Auth.Enabled { | 270 | + | return nil, err |
| 276 | - | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | |||
| 277 | - | if err != nil { | |||
| 278 | - | return nil, log.WrapErr(err, "failed to generate internal registry credentials") | |||
| 279 | - | } | |||
| 280 | - | ||||
| 281 | - | // Persist credentials to file for CLI access (gordon auth internal) | |||
| 282 | - | if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil { | |||
| 283 | - | log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access") | |||
| 284 | - | } | |||
| 285 | - | ||||
| 286 | - | log.Debug().Msg("internal registry auth generated for loopback pulls") | |||
| 287 | } | 271 | } | ||
| 288 | 272 | ||||
| 289 | // Create use case services | 273 | // Create use case services | ||
| @@ -292,7 +276,24 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 292 | return nil, log.WrapErr(err, "failed to load configuration") | 276 | return nil, log.WrapErr(err, "failed to load configuration") | ||
| 293 | } | 277 | } | ||
| 294 | 278 | ||||
| 295 | - | svc.containerSvc = createContainerService(v, cfg, svc) | 279 | + | var backend domain.SecretsBackend |
| 280 | + | var passStore *domainsecrets.PassStore | |||
| 281 | + | var domainSecretStore out.DomainSecretStore | |||
| 282 | + | ||||
| 283 | + | svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log) | |||
| 284 | + | if err != nil { | |||
| 285 | + | return nil, err | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil { | |||
| 289 | + | return nil, err | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | secretSvc := secretsSvc.NewService(domainSecretStore, log) | |||
| 293 | + | ||||
| 294 | + | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | |||
| 295 | + | return nil, err | |||
| 296 | + | } | |||
| 296 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | 297 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | ||
| 297 | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{ | 298 | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{ | ||
| 298 | RegistryDomain: cfg.Server.RegistryDomain, | 299 | RegistryDomain: cfg.Server.RegistryDomain, | ||
| @@ -308,23 +309,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 308 | svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log) | 309 | svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log) | ||
| 309 | } | 310 | } | ||
| 310 | 311 | ||||
| 311 | - | // Determine env directory for admin API | |||
| 312 | - | dataDir := cfg.Server.DataDir | |||
| 313 | - | if dataDir == "" { | |||
| 314 | - | dataDir = DefaultDataDir() | |||
| 315 | - | } | |||
| 316 | - | svc.envDir = cfg.Env.Dir | |||
| 317 | - | if svc.envDir == "" { | |||
| 318 | - | svc.envDir = filepath.Join(dataDir, "env") | |||
| 319 | - | } | |||
| 320 | - | ||||
| 321 | - | // Create domain secret store and service | |||
| 322 | - | domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log) | |||
| 323 | - | if err != nil { | |||
| 324 | - | return nil, log.WrapErr(err, "failed to create domain secret store") | |||
| 325 | - | } | |||
| 326 | - | secretSvc := secretsSvc.NewService(domainSecretStore, log) | |||
| 327 | - | ||||
| 328 | // Create health service for route health checking | 312 | // Create health service for route health checking | ||
| 329 | prober := httpprober.New() | 313 | prober := httpprober.New() | ||
| 330 | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | 314 | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | ||
| @@ -338,6 +322,49 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 338 | return svc, nil | 322 | return svc, nil | ||
| 339 | } | 323 | } | ||
| 340 | 324 | ||||
| 325 | + | func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error { | |||
| 326 | + | if !cfg.Auth.Enabled { | |||
| 327 | + | return nil | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | var err error | |||
| 331 | + | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | |||
| 332 | + | if err != nil { | |||
| 333 | + | return log.WrapErr(err, "failed to generate internal registry credentials") | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // Persist credentials to file for CLI access (gordon auth internal) | |||
| 337 | + | if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil { | |||
| 338 | + | log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access") | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | log.Debug().Msg("internal registry auth generated for loopback pulls") | |||
| 342 | + | return nil | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) { | |||
| 346 | + | envDir := resolveEnvDir(cfg) | |||
| 347 | + | backend := resolveSecretsBackend(cfg.Auth.SecretsBackend) | |||
| 348 | + | ||||
| 349 | + | switch backend { | |||
| 350 | + | case domain.SecretsBackendPass: | |||
| 351 | + | passStore, err := domainsecrets.NewPassStore(log) | |||
| 352 | + | if err != nil { | |||
| 353 | + | return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store") | |||
| 354 | + | } | |||
| 355 | + | if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil { | |||
| 356 | + | return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass") | |||
| 357 | + | } | |||
| 358 | + | return envDir, backend, passStore, passStore, nil | |||
| 359 | + | default: | |||
| 360 | + | store, err := domainsecrets.NewFileStore(envDir, log) | |||
| 361 | + | if err != nil { | |||
| 362 | + | return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store") | |||
| 363 | + | } | |||
| 364 | + | return envDir, backend, nil, store, nil | |||
| 365 | + | } | |||
| 366 | + | } | |||
| 367 | + | ||||
| 341 | // resolveLogFilePath returns the configured log file path or a default. | 368 | // resolveLogFilePath returns the configured log file path or a default. | ||
| 342 | func resolveLogFilePath(cfg Config) string { | 369 | func resolveLogFilePath(cfg Config) string { | ||
| 343 | if cfg.Logging.File.Path != "" { | 370 | if cfg.Logging.File.Path != "" { | ||
| @@ -391,36 +418,35 @@ func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *f | |||||
| 391 | } | 418 | } | ||
| 392 | 419 | ||||
| 393 | // createEnvLoader creates the environment loader with secret providers. | 420 | // createEnvLoader creates the environment loader with secret providers. | ||
| 394 | - | func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, error) { | 421 | + | func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) { |
| 395 | - | dataDir := cfg.Server.DataDir | 422 | + | switch backend { |
| 396 | - | if dataDir == "" { | 423 | + | case domain.SecretsBackendPass: |
| 397 | - | dataDir = DefaultDataDir() | 424 | + | loader, err := envloader.NewPassLoader(passStore, log) |
| 398 | - | } | 425 | + | if err != nil { |
| 399 | - | 426 | + | return nil, log.WrapErr(err, "failed to create pass env loader") | |
| 400 | - | envDir := cfg.Env.Dir | 427 | + | } |
| 401 | - | if envDir == "" { | 428 | + | return loader, nil |
| 402 | - | envDir = filepath.Join(dataDir, "env") | 429 | + | default: |
| 403 | - | } | 430 | + | loader, err := envloader.NewFileLoader(envDir, log) |
| 431 | + | if err != nil { | |||
| 432 | + | return nil, log.WrapErr(err, "failed to create env loader") | |||
| 433 | + | } | |||
| 404 | 434 | ||||
| 405 | - | envLoader, err := envloader.NewFileLoader(envDir, log) | 435 | + | // Register secret providers |
| 406 | - | if err != nil { | 436 | + | passProvider := secrets.NewPassProvider(log) |
| 407 | - | return nil, log.WrapErr(err, "failed to create env loader") | 437 | + | if passProvider.IsAvailable() { |
| 408 | - | } | 438 | + | loader.RegisterSecretProvider(passProvider) |
| 439 | + | log.Debug().Msg("pass secret provider registered") | |||
| 440 | + | } | |||
| 409 | 441 | ||||
| 410 | - | // Register secret providers | 442 | + | sopsProvider := secrets.NewSopsProvider(log) |
| 411 | - | passProvider := secrets.NewPassProvider(log) | 443 | + | if sopsProvider.IsAvailable() { |
| 412 | - | if passProvider.IsAvailable() { | 444 | + | loader.RegisterSecretProvider(sopsProvider) |
| 413 | - | envLoader.RegisterSecretProvider(passProvider) | 445 | + | log.Debug().Msg("sops secret provider registered") |
| 414 | - | log.Debug().Msg("pass secret provider registered") | 446 | + | } |
| 415 | - | } | |||
| 416 | 447 | ||||
| 417 | - | sopsProvider := secrets.NewSopsProvider(log) | 448 | + | return loader, nil |
| 418 | - | if sopsProvider.IsAvailable() { | |||
| 419 | - | envLoader.RegisterSecretProvider(sopsProvider) | |||
| 420 | - | log.Debug().Msg("sops secret provider registered") | |||
| 421 | } | 449 | } | ||
| 422 | - | ||||
| 423 | - | return envLoader, nil | |||
| 424 | } | 450 | } | ||
| 425 | 451 | ||||
| 426 | // createLogWriter creates the container log writer. | 452 | // createLogWriter creates the container log writer. | ||
| @@ -454,7 +480,11 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err | |||||
| 454 | return writer, nil | 480 | return writer, nil | ||
| 455 | } | 481 | } | ||
| 456 | 482 | ||||
| 457 | - | const internalRegistryUsername = "gordon-internal" | 483 | + | const ( |
| 484 | + | internalRegistryUsername = "gordon-internal" | |||
| 485 | + | serviceTokenSubject = "gordon-service" | |||
| 486 | + | serviceTokenDefaultTTL = 30 * 24 * time.Hour | |||
| 487 | + | ) | |||
| 458 | 488 | ||||
| 459 | func generateInternalRegistryAuth() (string, string, error) { | 489 | func generateInternalRegistryAuth() (string, string, error) { | ||
| 460 | password, err := randomTokenHex(32) | 490 | password, err := randomTokenHex(32) | ||
| @@ -634,6 +664,15 @@ func resolveDataDir(dataDir string) string { | |||||
| 634 | return dataDir | 664 | return dataDir | ||
| 635 | } | 665 | } | ||
| 636 | 666 | ||||
| 667 | + | func resolveEnvDir(cfg Config) string { | |||
| 668 | + | dataDir := resolveDataDir(cfg.Server.DataDir) | |||
| 669 | + | envDir := cfg.Env.Dir | |||
| 670 | + | if envDir == "" { | |||
| 671 | + | envDir = filepath.Join(dataDir, "env") | |||
| 672 | + | } | |||
| 673 | + | return envDir | |||
| 674 | + | } | |||
| 675 | + | ||||
| 637 | func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { | 676 | func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { | ||
| 638 | // Token store is always created since tokens work in both auth modes | 677 | // Token store is always created since tokens work in both auth modes | ||
| 639 | store, err := tokenstore.NewStore(backend, dataDir, log) | 678 | store, err := tokenstore.NewStore(backend, dataDir, log) | ||
| @@ -751,6 +790,17 @@ func parseTokenExpiry(expiry string) (time.Duration, error) { | |||||
| 751 | return parsed, nil | 790 | return parsed, nil | ||
| 752 | } | 791 | } | ||
| 753 | 792 | ||||
| 793 | + | func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) { | |||
| 794 | + | expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry) | |||
| 795 | + | if err != nil { | |||
| 796 | + | return 0, err | |||
| 797 | + | } | |||
| 798 | + | if expiry <= 0 { | |||
| 799 | + | return serviceTokenDefaultTTL, nil | |||
| 800 | + | } | |||
| 801 | + | return expiry, nil | |||
| 802 | + | } | |||
| 803 | + | ||||
| 754 | // loadSecret loads a secret from the configured backend. | 804 | // loadSecret loads a secret from the configured backend. | ||
| 755 | func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) { | 805 | func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) { | ||
| 756 | switch backend { | 806 | switch backend { | ||
| @@ -774,13 +824,11 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi | |||||
| 774 | } | 824 | } | ||
| 775 | 825 | ||||
| 776 | // createContainerService creates the container service with configuration. | 826 | // createContainerService creates the container service with configuration. | ||
| 777 | - | func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service { | 827 | + | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { |
| 778 | containerConfig := container.Config{ | 828 | containerConfig := container.Config{ | ||
| 779 | RegistryAuthEnabled: cfg.Auth.Enabled, | 829 | RegistryAuthEnabled: cfg.Auth.Enabled, | ||
| 780 | RegistryDomain: cfg.Server.RegistryDomain, | 830 | RegistryDomain: cfg.Server.RegistryDomain, | ||
| 781 | RegistryPort: cfg.Server.RegistryPort, | 831 | RegistryPort: cfg.Server.RegistryPort, | ||
| 782 | - | RegistryUsername: cfg.Auth.Username, | |||
| 783 | - | RegistryPassword: cfg.Auth.Password, | |||
| 784 | InternalRegistryUsername: svc.internalRegUser, | 832 | InternalRegistryUsername: svc.internalRegUser, | ||
| 785 | InternalRegistryPassword: svc.internalRegPass, | 833 | InternalRegistryPassword: svc.internalRegPass, | ||
| 786 | PullPolicy: v.GetString("deploy.pull_policy"), | 834 | PullPolicy: v.GetString("deploy.pull_policy"), | ||
| @@ -793,7 +841,27 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe | |||||
| 793 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | 841 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | ||
| 794 | Attachments: svc.configSvc.GetAttachments(), | 842 | Attachments: svc.configSvc.GetAttachments(), | ||
| 795 | } | 843 | } | ||
| 796 | - | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig) | 844 | + | |
| 845 | + | if cfg.Auth.Enabled && svc.authSvc != nil { | |||
| 846 | + | expiry, err := resolveServiceTokenExpiry(cfg) | |||
| 847 | + | if err != nil { | |||
| 848 | + | return nil, log.WrapErr(err, "failed to resolve service token expiry") | |||
| 849 | + | } | |||
| 850 | + | // Note: Service tokens are not auto-refreshed. If the token expires during | |||
| 851 | + | // container runtime, the container will need to be recreated to get a new token. | |||
| 852 | + | serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry) | |||
| 853 | + | if err != nil { | |||
| 854 | + | return nil, log.WrapErr(err, "failed to generate registry service token") | |||
| 855 | + | } | |||
| 856 | + | log.Info(). | |||
| 857 | + | Str("subject", serviceTokenSubject). | |||
| 858 | + | Str("expiry", expiry.String()). | |||
| 859 | + | Msg("generated service token for container registry access") | |||
| 860 | + | containerConfig.ServiceTokenUsername = serviceTokenSubject | |||
| 861 | + | containerConfig.ServiceToken = serviceToken | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | |||
| 797 | } | 865 | } | ||
| 798 | 866 | ||||
| 799 | // registerEventHandlers registers all event handlers. | 867 | // registerEventHandlers registers all event handlers. | ||
M internal/boundaries/out/domain_secrets.go +6 -0
| @@ -23,6 +23,12 @@ type DomainSecretStore interface { | |||
| 23 | 23 | // Delete removes a specific secret key from a domain. | |
| 24 | 24 | Delete(domain, key string) error | |
| 25 | 25 | ||
| 26 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |
| 27 | + | SetAttachment(containerName string, secrets map[string]string) error | |
| 28 | + | ||
| 29 | + | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 30 | + | GetAllAttachment(containerName string) (map[string]string, error) | |
| 31 | + | ||
| 26 | 32 | // ListAttachmentKeys finds and returns secret keys for attachment containers | |
| 27 | 33 | // associated with the given domain. Returns a list of AttachmentSecrets, one | |
| 28 | 34 | // for each attachment that has secrets configured. | |
| @@ -23,6 +23,12 @@ type DomainSecretStore interface { | |||||
| 23 | // Delete removes a specific secret key from a domain. | 23 | // Delete removes a specific secret key from a domain. | ||
| 24 | Delete(domain, key string) error | 24 | Delete(domain, key string) error | ||
| 25 | 25 | ||||
| 26 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |||
| 27 | + | SetAttachment(containerName string, secrets map[string]string) error | |||
| 28 | + | ||||
| 29 | + | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |||
| 30 | + | GetAllAttachment(containerName string) (map[string]string, error) | |||
| 31 | + | ||||
| 26 | // ListAttachmentKeys finds and returns secret keys for attachment containers | 32 | // ListAttachmentKeys finds and returns secret keys for attachment containers | ||
| 27 | // associated with the given domain. Returns a list of AttachmentSecrets, one | 33 | // associated with the given domain. Returns a list of AttachmentSecrets, one | ||
| 28 | // for each attachment that has secrets configured. | 34 | // for each attachment that has secrets configured. | ||
M internal/boundaries/out/mocks/mock_domain_secret_store.go +119 -0
| @@ -155,6 +155,68 @@ func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string | |||
| 155 | 155 | return _c | |
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | + | // GetAllAttachment provides a mock function for the type MockDomainSecretStore | |
| 159 | + | func (_mock *MockDomainSecretStore) GetAllAttachment(containerName string) (map[string]string, error) { | |
| 160 | + | ret := _mock.Called(containerName) | |
| 161 | + | ||
| 162 | + | if len(ret) == 0 { | |
| 163 | + | panic("no return value specified for GetAllAttachment") | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | var r0 map[string]string | |
| 167 | + | var r1 error | |
| 168 | + | if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok { | |
| 169 | + | return returnFunc(containerName) | |
| 170 | + | } | |
| 171 | + | if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok { | |
| 172 | + | r0 = returnFunc(containerName) | |
| 173 | + | } else { | |
| 174 | + | if ret.Get(0) != nil { | |
| 175 | + | r0 = ret.Get(0).(map[string]string) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 179 | + | r1 = returnFunc(containerName) | |
| 180 | + | } else { | |
| 181 | + | r1 = ret.Error(1) | |
| 182 | + | } | |
| 183 | + | return r0, r1 | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // MockDomainSecretStore_GetAllAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachment' | |
| 187 | + | type MockDomainSecretStore_GetAllAttachment_Call struct { | |
| 188 | + | *mock.Call | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // GetAllAttachment is a helper method to define mock.On call | |
| 192 | + | // - containerName string | |
| 193 | + | func (_e *MockDomainSecretStore_Expecter) GetAllAttachment(containerName interface{}) *MockDomainSecretStore_GetAllAttachment_Call { | |
| 194 | + | return &MockDomainSecretStore_GetAllAttachment_Call{Call: _e.mock.On("GetAllAttachment", containerName)} | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (_c *MockDomainSecretStore_GetAllAttachment_Call) Run(run func(containerName string)) *MockDomainSecretStore_GetAllAttachment_Call { | |
| 198 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 199 | + | var arg0 string | |
| 200 | + | if args[0] != nil { | |
| 201 | + | arg0 = args[0].(string) | |
| 202 | + | } | |
| 203 | + | run( | |
| 204 | + | arg0, | |
| 205 | + | ) | |
| 206 | + | }) | |
| 207 | + | return _c | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (_c *MockDomainSecretStore_GetAllAttachment_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAllAttachment_Call { | |
| 211 | + | _c.Call.Return(stringToString, err) | |
| 212 | + | return _c | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (_c *MockDomainSecretStore_GetAllAttachment_Call) RunAndReturn(run func(containerName string) (map[string]string, error)) *MockDomainSecretStore_GetAllAttachment_Call { | |
| 216 | + | _c.Call.Return(run) | |
| 217 | + | return _c | |
| 218 | + | } | |
| 219 | + | ||
| 158 | 220 | // ListAttachmentKeys provides a mock function for the type MockDomainSecretStore | |
| 159 | 221 | func (_mock *MockDomainSecretStore) ListAttachmentKeys(domain string) ([]out.AttachmentSecrets, error) { | |
| 160 | 222 | ret := _mock.Called(domain) | |
| @@ -335,3 +397,60 @@ func (_c *MockDomainSecretStore_Set_Call) RunAndReturn(run func(domain string, s | |||
| 335 | 397 | _c.Call.Return(run) | |
| 336 | 398 | return _c | |
| 337 | 399 | } | |
| 400 | + | ||
| 401 | + | // SetAttachment provides a mock function for the type MockDomainSecretStore | |
| 402 | + | func (_mock *MockDomainSecretStore) SetAttachment(containerName string, secrets map[string]string) error { | |
| 403 | + | ret := _mock.Called(containerName, secrets) | |
| 404 | + | ||
| 405 | + | if len(ret) == 0 { | |
| 406 | + | panic("no return value specified for SetAttachment") | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | var r0 error | |
| 410 | + | if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok { | |
| 411 | + | r0 = returnFunc(containerName, secrets) | |
| 412 | + | } else { | |
| 413 | + | r0 = ret.Error(0) | |
| 414 | + | } | |
| 415 | + | return r0 | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // MockDomainSecretStore_SetAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachment' | |
| 419 | + | type MockDomainSecretStore_SetAttachment_Call struct { | |
| 420 | + | *mock.Call | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | // SetAttachment is a helper method to define mock.On call | |
| 424 | + | // - containerName string | |
| 425 | + | // - secrets map[string]string | |
| 426 | + | func (_e *MockDomainSecretStore_Expecter) SetAttachment(containerName interface{}, secrets interface{}) *MockDomainSecretStore_SetAttachment_Call { | |
| 427 | + | return &MockDomainSecretStore_SetAttachment_Call{Call: _e.mock.On("SetAttachment", containerName, secrets)} | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func (_c *MockDomainSecretStore_SetAttachment_Call) Run(run func(containerName string, secrets map[string]string)) *MockDomainSecretStore_SetAttachment_Call { | |
| 431 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 432 | + | var arg0 string | |
| 433 | + | if args[0] != nil { | |
| 434 | + | arg0 = args[0].(string) | |
| 435 | + | } | |
| 436 | + | var arg1 map[string]string | |
| 437 | + | if args[1] != nil { | |
| 438 | + | arg1 = args[1].(map[string]string) | |
| 439 | + | } | |
| 440 | + | run( | |
| 441 | + | arg0, | |
| 442 | + | arg1, | |
| 443 | + | ) | |
| 444 | + | }) | |
| 445 | + | return _c | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func (_c *MockDomainSecretStore_SetAttachment_Call) Return(err error) *MockDomainSecretStore_SetAttachment_Call { | |
| 449 | + | _c.Call.Return(err) | |
| 450 | + | return _c | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func (_c *MockDomainSecretStore_SetAttachment_Call) RunAndReturn(run func(containerName string, secrets map[string]string) error) *MockDomainSecretStore_SetAttachment_Call { | |
| 454 | + | _c.Call.Return(run) | |
| 455 | + | return _c | |
| 456 | + | } | |
| @@ -155,6 +155,68 @@ func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string | |||||
| 155 | return _c | 155 | return _c | ||
| 156 | } | 156 | } | ||
| 157 | 157 | ||||
| 158 | + | // GetAllAttachment provides a mock function for the type MockDomainSecretStore | |||
| 159 | + | func (_mock *MockDomainSecretStore) GetAllAttachment(containerName string) (map[string]string, error) { | |||
| 160 | + | ret := _mock.Called(containerName) | |||
| 161 | + | ||||
| 162 | + | if len(ret) == 0 { | |||
| 163 | + | panic("no return value specified for GetAllAttachment") | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | var r0 map[string]string | |||
| 167 | + | var r1 error | |||
| 168 | + | if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok { | |||
| 169 | + | return returnFunc(containerName) | |||
| 170 | + | } | |||
| 171 | + | if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok { | |||
| 172 | + | r0 = returnFunc(containerName) | |||
| 173 | + | } else { | |||
| 174 | + | if ret.Get(0) != nil { | |||
| 175 | + | r0 = ret.Get(0).(map[string]string) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 179 | + | r1 = returnFunc(containerName) | |||
| 180 | + | } else { | |||
| 181 | + | r1 = ret.Error(1) | |||
| 182 | + | } | |||
| 183 | + | return r0, r1 | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // MockDomainSecretStore_GetAllAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachment' | |||
| 187 | + | type MockDomainSecretStore_GetAllAttachment_Call struct { | |||
| 188 | + | *mock.Call | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // GetAllAttachment is a helper method to define mock.On call | |||
| 192 | + | // - containerName string | |||
| 193 | + | func (_e *MockDomainSecretStore_Expecter) GetAllAttachment(containerName interface{}) *MockDomainSecretStore_GetAllAttachment_Call { | |||
| 194 | + | return &MockDomainSecretStore_GetAllAttachment_Call{Call: _e.mock.On("GetAllAttachment", containerName)} | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (_c *MockDomainSecretStore_GetAllAttachment_Call) Run(run func(containerName string)) *MockDomainSecretStore_GetAllAttachment_Call { | |||
| 198 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 199 | + | var arg0 string | |||
| 200 | + | if args[0] != nil { | |||
| 201 | + | arg0 = args[0].(string) | |||
| 202 | + | } | |||
| 203 | + | run( | |||
| 204 | + | arg0, | |||
| 205 | + | ) | |||
| 206 | + | }) | |||
| 207 | + | return _c | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (_c *MockDomainSecretStore_GetAllAttachment_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAllAttachment_Call { | |||
| 211 | + | _c.Call.Return(stringToString, err) | |||
| 212 | + | return _c | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func (_c *MockDomainSecretStore_GetAllAttachment_Call) RunAndReturn(run func(containerName string) (map[string]string, error)) *MockDomainSecretStore_GetAllAttachment_Call { | |||
| 216 | + | _c.Call.Return(run) | |||
| 217 | + | return _c | |||
| 218 | + | } | |||
| 219 | + | ||||
| 158 | // ListAttachmentKeys provides a mock function for the type MockDomainSecretStore | 220 | // ListAttachmentKeys provides a mock function for the type MockDomainSecretStore | ||
| 159 | func (_mock *MockDomainSecretStore) ListAttachmentKeys(domain string) ([]out.AttachmentSecrets, error) { | 221 | func (_mock *MockDomainSecretStore) ListAttachmentKeys(domain string) ([]out.AttachmentSecrets, error) { | ||
| 160 | ret := _mock.Called(domain) | 222 | ret := _mock.Called(domain) | ||
| @@ -335,3 +397,60 @@ func (_c *MockDomainSecretStore_Set_Call) RunAndReturn(run func(domain string, s | |||||
| 335 | _c.Call.Return(run) | 397 | _c.Call.Return(run) | ||
| 336 | return _c | 398 | return _c | ||
| 337 | } | 399 | } | ||
| 400 | + | ||||
| 401 | + | // SetAttachment provides a mock function for the type MockDomainSecretStore | |||
| 402 | + | func (_mock *MockDomainSecretStore) SetAttachment(containerName string, secrets map[string]string) error { | |||
| 403 | + | ret := _mock.Called(containerName, secrets) | |||
| 404 | + | ||||
| 405 | + | if len(ret) == 0 { | |||
| 406 | + | panic("no return value specified for SetAttachment") | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | var r0 error | |||
| 410 | + | if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok { | |||
| 411 | + | r0 = returnFunc(containerName, secrets) | |||
| 412 | + | } else { | |||
| 413 | + | r0 = ret.Error(0) | |||
| 414 | + | } | |||
| 415 | + | return r0 | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | // MockDomainSecretStore_SetAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachment' | |||
| 419 | + | type MockDomainSecretStore_SetAttachment_Call struct { | |||
| 420 | + | *mock.Call | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | // SetAttachment is a helper method to define mock.On call | |||
| 424 | + | // - containerName string | |||
| 425 | + | // - secrets map[string]string | |||
| 426 | + | func (_e *MockDomainSecretStore_Expecter) SetAttachment(containerName interface{}, secrets interface{}) *MockDomainSecretStore_SetAttachment_Call { | |||
| 427 | + | return &MockDomainSecretStore_SetAttachment_Call{Call: _e.mock.On("SetAttachment", containerName, secrets)} | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func (_c *MockDomainSecretStore_SetAttachment_Call) Run(run func(containerName string, secrets map[string]string)) *MockDomainSecretStore_SetAttachment_Call { | |||
| 431 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 432 | + | var arg0 string | |||
| 433 | + | if args[0] != nil { | |||
| 434 | + | arg0 = args[0].(string) | |||
| 435 | + | } | |||
| 436 | + | var arg1 map[string]string | |||
| 437 | + | if args[1] != nil { | |||
| 438 | + | arg1 = args[1].(map[string]string) | |||
| 439 | + | } | |||
| 440 | + | run( | |||
| 441 | + | arg0, | |||
| 442 | + | arg1, | |||
| 443 | + | ) | |||
| 444 | + | }) | |||
| 445 | + | return _c | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | func (_c *MockDomainSecretStore_SetAttachment_Call) Return(err error) *MockDomainSecretStore_SetAttachment_Call { | |||
| 449 | + | _c.Call.Return(err) | |||
| 450 | + | return _c | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func (_c *MockDomainSecretStore_SetAttachment_Call) RunAndReturn(run func(containerName string, secrets map[string]string) error) *MockDomainSecretStore_SetAttachment_Call { | |||
| 454 | + | _c.Call.Return(run) | |||
| 455 | + | return _c | |||
| 456 | + | } | |||
A internal/domain/env.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "regexp" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // envDomainRegex validates domain names used for env secrets. | |
| 11 | + | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |
| 12 | + | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | |
| 13 | + | var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | |
| 14 | + | var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) | |
| 15 | + | ||
| 16 | + | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | |
| 17 | + | // Returns a safe domain string suitable for filenames. | |
| 18 | + | // This function is idempotent: calling it on already-sanitized input returns the same value. | |
| 19 | + | func SanitizeDomainForEnvFile(domainName string) (string, error) { | |
| 20 | + | if domainName == "" { | |
| 21 | + | return "", ErrPathTraversal | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | if strings.Contains(domainName, "..") { | |
| 25 | + | return "", ErrPathTraversal | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | if !envDomainRegex.MatchString(domainName) { | |
| 29 | + | return "", ErrPathTraversal | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | safeDomain := strings.ReplaceAll(domainName, ".", "_") | |
| 33 | + | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |
| 34 | + | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |
| 35 | + | return safeDomain, nil | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // ParseEnvData parses env data into a key-value map. | |
| 39 | + | func ParseEnvData(data []byte) (map[string]string, error) { | |
| 40 | + | secrets := make(map[string]string) | |
| 41 | + | scanner := bufio.NewScanner(bytes.NewReader(data)) | |
| 42 | + | // Allow large env values (certs/keys) without hitting the default ~64KB limit. | |
| 43 | + | scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) | |
| 44 | + | for scanner.Scan() { | |
| 45 | + | line := strings.TrimSpace(scanner.Text()) | |
| 46 | + | if line == "" || strings.HasPrefix(line, "#") { | |
| 47 | + | continue | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | parts := strings.SplitN(line, "=", 2) | |
| 51 | + | if len(parts) != 2 { | |
| 52 | + | continue | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | key := strings.TrimSpace(parts[0]) | |
| 56 | + | rawValue := parts[1] | |
| 57 | + | ||
| 58 | + | trimmedValue := strings.TrimSpace(rawValue) | |
| 59 | + | value := trimmedValue | |
| 60 | + | ||
| 61 | + | if len(trimmedValue) >= 2 { | |
| 62 | + | if (strings.HasPrefix(trimmedValue, "\"") && strings.HasSuffix(trimmedValue, "\"")) || | |
| 63 | + | (strings.HasPrefix(trimmedValue, "'") && strings.HasSuffix(trimmedValue, "'")) { | |
| 64 | + | value = trimmedValue[1 : len(trimmedValue)-1] | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | if key != "" { | |
| 69 | + | secrets[key] = value | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | if err := scanner.Err(); err != nil { | |
| 74 | + | return nil, err | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | return secrets, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // ValidateEnvKey validates an env key for storage. | |
| 81 | + | func ValidateEnvKey(key string) error { | |
| 82 | + | if key == "" { | |
| 83 | + | return ErrInvalidEnvKey | |
| 84 | + | } | |
| 85 | + | if strings.Contains(key, "..") || strings.ContainsAny(key, "/\\") { | |
| 86 | + | return ErrPathTraversal | |
| 87 | + | } | |
| 88 | + | if !envKeyRegex.MatchString(key) { | |
| 89 | + | return ErrInvalidEnvKey | |
| 90 | + | } | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // ValidateContainerName validates a container name for attachment storage. | |
| 95 | + | // Container names can contain alphanumeric characters, hyphens, and underscores, | |
| 96 | + | // but must start with a letter. | |
| 97 | + | func ValidateContainerName(name string) error { | |
| 98 | + | if name == "" { | |
| 99 | + | return ErrInvalidContainerName | |
| 100 | + | } | |
| 101 | + | if strings.Contains(name, "..") || strings.ContainsAny(name, "/\\") { | |
| 102 | + | return ErrPathTraversal | |
| 103 | + | } | |
| 104 | + | if !containerNameRegex.MatchString(name) { | |
| 105 | + | return ErrInvalidContainerName | |
| 106 | + | } | |
| 107 | + | return nil | |
| 108 | + | } | |
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "bytes" | |||
| 6 | + | "regexp" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // envDomainRegex validates domain names used for env secrets. | |||
| 11 | + | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |||
| 12 | + | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | |||
| 13 | + | var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | |||
| 14 | + | var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) | |||
| 15 | + | ||||
| 16 | + | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | |||
| 17 | + | // Returns a safe domain string suitable for filenames. | |||
| 18 | + | // This function is idempotent: calling it on already-sanitized input returns the same value. | |||
| 19 | + | func SanitizeDomainForEnvFile(domainName string) (string, error) { | |||
| 20 | + | if domainName == "" { | |||
| 21 | + | return "", ErrPathTraversal | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | if strings.Contains(domainName, "..") { | |||
| 25 | + | return "", ErrPathTraversal | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | if !envDomainRegex.MatchString(domainName) { | |||
| 29 | + | return "", ErrPathTraversal | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | safeDomain := strings.ReplaceAll(domainName, ".", "_") | |||
| 33 | + | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |||
| 34 | + | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |||
| 35 | + | return safeDomain, nil | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // ParseEnvData parses env data into a key-value map. | |||
| 39 | + | func ParseEnvData(data []byte) (map[string]string, error) { | |||
| 40 | + | secrets := make(map[string]string) | |||
| 41 | + | scanner := bufio.NewScanner(bytes.NewReader(data)) | |||
| 42 | + | // Allow large env values (certs/keys) without hitting the default ~64KB limit. | |||
| 43 | + | scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) | |||
| 44 | + | for scanner.Scan() { | |||
| 45 | + | line := strings.TrimSpace(scanner.Text()) | |||
| 46 | + | if line == "" || strings.HasPrefix(line, "#") { | |||
| 47 | + | continue | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | parts := strings.SplitN(line, "=", 2) | |||
| 51 | + | if len(parts) != 2 { | |||
| 52 | + | continue | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | key := strings.TrimSpace(parts[0]) | |||
| 56 | + | rawValue := parts[1] | |||
| 57 | + | ||||
| 58 | + | trimmedValue := strings.TrimSpace(rawValue) | |||
| 59 | + | value := trimmedValue | |||
| 60 | + | ||||
| 61 | + | if len(trimmedValue) >= 2 { | |||
| 62 | + | if (strings.HasPrefix(trimmedValue, "\"") && strings.HasSuffix(trimmedValue, "\"")) || | |||
| 63 | + | (strings.HasPrefix(trimmedValue, "'") && strings.HasSuffix(trimmedValue, "'")) { | |||
| 64 | + | value = trimmedValue[1 : len(trimmedValue)-1] | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | if key != "" { | |||
| 69 | + | secrets[key] = value | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | if err := scanner.Err(); err != nil { | |||
| 74 | + | return nil, err | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | return secrets, nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // ValidateEnvKey validates an env key for storage. | |||
| 81 | + | func ValidateEnvKey(key string) error { | |||
| 82 | + | if key == "" { | |||
| 83 | + | return ErrInvalidEnvKey | |||
| 84 | + | } | |||
| 85 | + | if strings.Contains(key, "..") || strings.ContainsAny(key, "/\\") { | |||
| 86 | + | return ErrPathTraversal | |||
| 87 | + | } | |||
| 88 | + | if !envKeyRegex.MatchString(key) { | |||
| 89 | + | return ErrInvalidEnvKey | |||
| 90 | + | } | |||
| 91 | + | return nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // ValidateContainerName validates a container name for attachment storage. | |||
| 95 | + | // Container names can contain alphanumeric characters, hyphens, and underscores, | |||
| 96 | + | // but must start with a letter. | |||
| 97 | + | func ValidateContainerName(name string) error { | |||
| 98 | + | if name == "" { | |||
| 99 | + | return ErrInvalidContainerName | |||
| 100 | + | } | |||
| 101 | + | if strings.Contains(name, "..") || strings.ContainsAny(name, "/\\") { | |||
| 102 | + | return ErrPathTraversal | |||
| 103 | + | } | |||
| 104 | + | if !containerNameRegex.MatchString(name) { | |||
| 105 | + | return ErrInvalidContainerName | |||
| 106 | + | } | |||
| 107 | + | return nil | |||
| 108 | + | } | |||
A internal/domain/env_test.go +206 -0
| @@ -0,0 +1,206 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSanitizeDomainForEnvFile(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | domain string | |
| 15 | + | want string | |
| 16 | + | wantErr bool | |
| 17 | + | }{ | |
| 18 | + | {name: "simple domain", domain: "example.com", want: "example_com"}, | |
| 19 | + | {name: "subdomain", domain: "app.example.com", want: "app_example_com"}, | |
| 20 | + | {name: "domain with port", domain: "example.com:8080", want: "example_com_8080"}, | |
| 21 | + | {name: "domain with path", domain: "example.com/path", want: "example_com_path"}, | |
| 22 | + | {name: "single char", domain: "a", want: "a"}, | |
| 23 | + | {name: "hyphenated", domain: "my-app.example.com", want: "my-app_example_com"}, | |
| 24 | + | {name: "empty", domain: "", wantErr: true}, | |
| 25 | + | {name: "path traversal", domain: "../etc/passwd", wantErr: true}, | |
| 26 | + | {name: "double dots", domain: "foo..bar", wantErr: true}, | |
| 27 | + | {name: "starts with dot", domain: ".hidden", wantErr: true}, | |
| 28 | + | {name: "ends with dot", domain: "trailing.", wantErr: true}, | |
| 29 | + | {name: "space", domain: "has space", wantErr: true}, | |
| 30 | + | {name: "special chars", domain: "bad$domain", wantErr: true}, | |
| 31 | + | {name: "idempotent simple", domain: "example_com", want: "example_com"}, | |
| 32 | + | {name: "idempotent complex", domain: "my-app_example_com_8080", want: "my-app_example_com_8080"}, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | for _, tt := range tests { | |
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |
| 37 | + | got, err := SanitizeDomainForEnvFile(tt.domain) | |
| 38 | + | if tt.wantErr { | |
| 39 | + | assert.Error(t, err) | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | assert.Equal(t, tt.want, got) | |
| 44 | + | }) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestParseEnvData(t *testing.T) { | |
| 49 | + | tests := []struct { | |
| 50 | + | name string | |
| 51 | + | data string | |
| 52 | + | want map[string]string | |
| 53 | + | wantErr bool | |
| 54 | + | }{ | |
| 55 | + | { | |
| 56 | + | name: "simple key-value", | |
| 57 | + | data: "FOO=bar\nBAZ=qux", | |
| 58 | + | want: map[string]string{"FOO": "bar", "BAZ": "qux"}, | |
| 59 | + | }, | |
| 60 | + | { | |
| 61 | + | name: "double-quoted value", | |
| 62 | + | data: `KEY="hello world"`, | |
| 63 | + | want: map[string]string{"KEY": "hello world"}, | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "single-quoted value", | |
| 67 | + | data: `KEY='hello world'`, | |
| 68 | + | want: map[string]string{"KEY": "hello world"}, | |
| 69 | + | }, | |
| 70 | + | { | |
| 71 | + | name: "comment lines", | |
| 72 | + | data: "# this is a comment\nKEY=val", | |
| 73 | + | want: map[string]string{"KEY": "val"}, | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "empty lines", | |
| 77 | + | data: "\n\nKEY=val\n\n", | |
| 78 | + | want: map[string]string{"KEY": "val"}, | |
| 79 | + | }, | |
| 80 | + | { | |
| 81 | + | name: "value with equals", | |
| 82 | + | data: "URL=postgres://host:5432/db?opt=1", | |
| 83 | + | want: map[string]string{"URL": "postgres://host:5432/db?opt=1"}, | |
| 84 | + | }, | |
| 85 | + | { | |
| 86 | + | name: "no value", | |
| 87 | + | data: "NOVALUE", | |
| 88 | + | want: map[string]string{}, | |
| 89 | + | }, | |
| 90 | + | { | |
| 91 | + | name: "empty value", | |
| 92 | + | data: "KEY=", | |
| 93 | + | want: map[string]string{"KEY": ""}, | |
| 94 | + | }, | |
| 95 | + | { | |
| 96 | + | name: "empty input", | |
| 97 | + | data: "", | |
| 98 | + | want: map[string]string{}, | |
| 99 | + | }, | |
| 100 | + | { | |
| 101 | + | name: "large value within buffer", | |
| 102 | + | data: "BIG=" + strings.Repeat("x", 100000), | |
| 103 | + | want: map[string]string{"BIG": strings.Repeat("x", 100000)}, | |
| 104 | + | }, | |
| 105 | + | { | |
| 106 | + | name: "quoted value with inner spaces", | |
| 107 | + | data: `KEY=" hello "`, | |
| 108 | + | want: map[string]string{"KEY": " hello "}, | |
| 109 | + | }, | |
| 110 | + | { | |
| 111 | + | name: "unquoted value with outer spaces", | |
| 112 | + | data: "KEY= hello ", | |
| 113 | + | want: map[string]string{"KEY": "hello"}, | |
| 114 | + | }, | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | for _, tt := range tests { | |
| 118 | + | t.Run(tt.name, func(t *testing.T) { | |
| 119 | + | got, err := ParseEnvData([]byte(tt.data)) | |
| 120 | + | if tt.wantErr { | |
| 121 | + | assert.Error(t, err) | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | assert.Equal(t, tt.want, got) | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestValidateEnvKey(t *testing.T) { | |
| 131 | + | tests := []struct { | |
| 132 | + | name string | |
| 133 | + | key string | |
| 134 | + | wantErr bool | |
| 135 | + | wantErrType error | |
| 136 | + | }{ | |
| 137 | + | {name: "simple", key: "FOO", wantErr: false}, | |
| 138 | + | {name: "with underscore", key: "FOO_BAR", wantErr: false}, | |
| 139 | + | {name: "starts with underscore", key: "_PRIVATE", wantErr: false}, | |
| 140 | + | {name: "lowercase", key: "foo", wantErr: false}, | |
| 141 | + | {name: "mixed", key: "myApp_v2", wantErr: false}, | |
| 142 | + | {name: "empty", key: "", wantErr: true, wantErrType: ErrInvalidEnvKey}, | |
| 143 | + | {name: "starts with number", key: "1BAD", wantErr: true, wantErrType: ErrInvalidEnvKey}, | |
| 144 | + | {name: "contains dot", key: "FOO.BAR", wantErr: true, wantErrType: ErrInvalidEnvKey}, | |
| 145 | + | {name: "contains slash", key: "FOO/BAR", wantErr: true, wantErrType: ErrPathTraversal}, | |
| 146 | + | {name: "contains backslash", key: `FOO\BAR`, wantErr: true, wantErrType: ErrPathTraversal}, | |
| 147 | + | {name: "path traversal", key: "..", wantErr: true, wantErrType: ErrPathTraversal}, | |
| 148 | + | {name: "contains hyphen", key: "FOO-BAR", wantErr: true, wantErrType: ErrInvalidEnvKey}, | |
| 149 | + | {name: "contains space", key: "FOO BAR", wantErr: true, wantErrType: ErrInvalidEnvKey}, | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | for _, tt := range tests { | |
| 153 | + | t.Run(tt.name, func(t *testing.T) { | |
| 154 | + | err := ValidateEnvKey(tt.key) | |
| 155 | + | if tt.wantErr { | |
| 156 | + | require.Error(t, err) | |
| 157 | + | if tt.wantErrType != nil { | |
| 158 | + | assert.ErrorIs(t, err, tt.wantErrType) | |
| 159 | + | } | |
| 160 | + | } else { | |
| 161 | + | assert.NoError(t, err) | |
| 162 | + | } | |
| 163 | + | }) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestValidateContainerName(t *testing.T) { | |
| 168 | + | tests := []struct { | |
| 169 | + | name string | |
| 170 | + | container string | |
| 171 | + | wantErr bool | |
| 172 | + | wantErrType error | |
| 173 | + | }{ | |
| 174 | + | {name: "simple", container: "postgres", wantErr: false}, | |
| 175 | + | {name: "with hyphen", container: "gitea-postgres", wantErr: false}, | |
| 176 | + | {name: "with underscore", container: "gitea_postgres", wantErr: false}, | |
| 177 | + | {name: "complex real container", container: "gordon-git-example-com-gitea-postgres", wantErr: false}, | |
| 178 | + | {name: "starts with letter", container: "a-b", wantErr: false}, | |
| 179 | + | {name: "lowercase", container: "my-container", wantErr: false}, | |
| 180 | + | {name: "mixed case", container: "MyContainer-123", wantErr: false}, | |
| 181 | + | {name: "empty", container: "", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 182 | + | {name: "starts with number", container: "1container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 183 | + | {name: "starts with hyphen", container: "-container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 184 | + | {name: "starts with underscore", container: "_container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 185 | + | {name: "contains slash", container: "container/name", wantErr: true, wantErrType: ErrPathTraversal}, | |
| 186 | + | {name: "contains backslash", container: `container\name`, wantErr: true, wantErrType: ErrPathTraversal}, | |
| 187 | + | {name: "path traversal", container: "..", wantErr: true, wantErrType: ErrPathTraversal}, | |
| 188 | + | {name: "contains dot", container: "container.name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 189 | + | {name: "contains space", container: "container name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 190 | + | {name: "contains special char", container: "container$name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | for _, tt := range tests { | |
| 194 | + | t.Run(tt.name, func(t *testing.T) { | |
| 195 | + | err := ValidateContainerName(tt.container) | |
| 196 | + | if tt.wantErr { | |
| 197 | + | require.Error(t, err) | |
| 198 | + | if tt.wantErrType != nil { | |
| 199 | + | assert.ErrorIs(t, err, tt.wantErrType) | |
| 200 | + | } | |
| 201 | + | } else { | |
| 202 | + | assert.NoError(t, err) | |
| 203 | + | } | |
| 204 | + | }) | |
| 205 | + | } | |
| 206 | + | } | |
| @@ -0,0 +1,206 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSanitizeDomainForEnvFile(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | domain string | |||
| 15 | + | want string | |||
| 16 | + | wantErr bool | |||
| 17 | + | }{ | |||
| 18 | + | {name: "simple domain", domain: "example.com", want: "example_com"}, | |||
| 19 | + | {name: "subdomain", domain: "app.example.com", want: "app_example_com"}, | |||
| 20 | + | {name: "domain with port", domain: "example.com:8080", want: "example_com_8080"}, | |||
| 21 | + | {name: "domain with path", domain: "example.com/path", want: "example_com_path"}, | |||
| 22 | + | {name: "single char", domain: "a", want: "a"}, | |||
| 23 | + | {name: "hyphenated", domain: "my-app.example.com", want: "my-app_example_com"}, | |||
| 24 | + | {name: "empty", domain: "", wantErr: true}, | |||
| 25 | + | {name: "path traversal", domain: "../etc/passwd", wantErr: true}, | |||
| 26 | + | {name: "double dots", domain: "foo..bar", wantErr: true}, | |||
| 27 | + | {name: "starts with dot", domain: ".hidden", wantErr: true}, | |||
| 28 | + | {name: "ends with dot", domain: "trailing.", wantErr: true}, | |||
| 29 | + | {name: "space", domain: "has space", wantErr: true}, | |||
| 30 | + | {name: "special chars", domain: "bad$domain", wantErr: true}, | |||
| 31 | + | {name: "idempotent simple", domain: "example_com", want: "example_com"}, | |||
| 32 | + | {name: "idempotent complex", domain: "my-app_example_com_8080", want: "my-app_example_com_8080"}, | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | for _, tt := range tests { | |||
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | + | got, err := SanitizeDomainForEnvFile(tt.domain) | |||
| 38 | + | if tt.wantErr { | |||
| 39 | + | assert.Error(t, err) | |||
| 40 | + | return | |||
| 41 | + | } | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | assert.Equal(t, tt.want, got) | |||
| 44 | + | }) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestParseEnvData(t *testing.T) { | |||
| 49 | + | tests := []struct { | |||
| 50 | + | name string | |||
| 51 | + | data string | |||
| 52 | + | want map[string]string | |||
| 53 | + | wantErr bool | |||
| 54 | + | }{ | |||
| 55 | + | { | |||
| 56 | + | name: "simple key-value", | |||
| 57 | + | data: "FOO=bar\nBAZ=qux", | |||
| 58 | + | want: map[string]string{"FOO": "bar", "BAZ": "qux"}, | |||
| 59 | + | }, | |||
| 60 | + | { | |||
| 61 | + | name: "double-quoted value", | |||
| 62 | + | data: `KEY="hello world"`, | |||
| 63 | + | want: map[string]string{"KEY": "hello world"}, | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "single-quoted value", | |||
| 67 | + | data: `KEY='hello world'`, | |||
| 68 | + | want: map[string]string{"KEY": "hello world"}, | |||
| 69 | + | }, | |||
| 70 | + | { | |||
| 71 | + | name: "comment lines", | |||
| 72 | + | data: "# this is a comment\nKEY=val", | |||
| 73 | + | want: map[string]string{"KEY": "val"}, | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | name: "empty lines", | |||
| 77 | + | data: "\n\nKEY=val\n\n", | |||
| 78 | + | want: map[string]string{"KEY": "val"}, | |||
| 79 | + | }, | |||
| 80 | + | { | |||
| 81 | + | name: "value with equals", | |||
| 82 | + | data: "URL=postgres://host:5432/db?opt=1", | |||
| 83 | + | want: map[string]string{"URL": "postgres://host:5432/db?opt=1"}, | |||
| 84 | + | }, | |||
| 85 | + | { | |||
| 86 | + | name: "no value", | |||
| 87 | + | data: "NOVALUE", | |||
| 88 | + | want: map[string]string{}, | |||
| 89 | + | }, | |||
| 90 | + | { | |||
| 91 | + | name: "empty value", | |||
| 92 | + | data: "KEY=", | |||
| 93 | + | want: map[string]string{"KEY": ""}, | |||
| 94 | + | }, | |||
| 95 | + | { | |||
| 96 | + | name: "empty input", | |||
| 97 | + | data: "", | |||
| 98 | + | want: map[string]string{}, | |||
| 99 | + | }, | |||
| 100 | + | { | |||
| 101 | + | name: "large value within buffer", | |||
| 102 | + | data: "BIG=" + strings.Repeat("x", 100000), | |||
| 103 | + | want: map[string]string{"BIG": strings.Repeat("x", 100000)}, | |||
| 104 | + | }, | |||
| 105 | + | { | |||
| 106 | + | name: "quoted value with inner spaces", | |||
| 107 | + | data: `KEY=" hello "`, | |||
| 108 | + | want: map[string]string{"KEY": " hello "}, | |||
| 109 | + | }, | |||
| 110 | + | { | |||
| 111 | + | name: "unquoted value with outer spaces", | |||
| 112 | + | data: "KEY= hello ", | |||
| 113 | + | want: map[string]string{"KEY": "hello"}, | |||
| 114 | + | }, | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | for _, tt := range tests { | |||
| 118 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 119 | + | got, err := ParseEnvData([]byte(tt.data)) | |||
| 120 | + | if tt.wantErr { | |||
| 121 | + | assert.Error(t, err) | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | assert.Equal(t, tt.want, got) | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestValidateEnvKey(t *testing.T) { | |||
| 131 | + | tests := []struct { | |||
| 132 | + | name string | |||
| 133 | + | key string | |||
| 134 | + | wantErr bool | |||
| 135 | + | wantErrType error | |||
| 136 | + | }{ | |||
| 137 | + | {name: "simple", key: "FOO", wantErr: false}, | |||
| 138 | + | {name: "with underscore", key: "FOO_BAR", wantErr: false}, | |||
| 139 | + | {name: "starts with underscore", key: "_PRIVATE", wantErr: false}, | |||
| 140 | + | {name: "lowercase", key: "foo", wantErr: false}, | |||
| 141 | + | {name: "mixed", key: "myApp_v2", wantErr: false}, | |||
| 142 | + | {name: "empty", key: "", wantErr: true, wantErrType: ErrInvalidEnvKey}, | |||
| 143 | + | {name: "starts with number", key: "1BAD", wantErr: true, wantErrType: ErrInvalidEnvKey}, | |||
| 144 | + | {name: "contains dot", key: "FOO.BAR", wantErr: true, wantErrType: ErrInvalidEnvKey}, | |||
| 145 | + | {name: "contains slash", key: "FOO/BAR", wantErr: true, wantErrType: ErrPathTraversal}, | |||
| 146 | + | {name: "contains backslash", key: `FOO\BAR`, wantErr: true, wantErrType: ErrPathTraversal}, | |||
| 147 | + | {name: "path traversal", key: "..", wantErr: true, wantErrType: ErrPathTraversal}, | |||
| 148 | + | {name: "contains hyphen", key: "FOO-BAR", wantErr: true, wantErrType: ErrInvalidEnvKey}, | |||
| 149 | + | {name: "contains space", key: "FOO BAR", wantErr: true, wantErrType: ErrInvalidEnvKey}, | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | for _, tt := range tests { | |||
| 153 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 154 | + | err := ValidateEnvKey(tt.key) | |||
| 155 | + | if tt.wantErr { | |||
| 156 | + | require.Error(t, err) | |||
| 157 | + | if tt.wantErrType != nil { | |||
| 158 | + | assert.ErrorIs(t, err, tt.wantErrType) | |||
| 159 | + | } | |||
| 160 | + | } else { | |||
| 161 | + | assert.NoError(t, err) | |||
| 162 | + | } | |||
| 163 | + | }) | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestValidateContainerName(t *testing.T) { | |||
| 168 | + | tests := []struct { | |||
| 169 | + | name string | |||
| 170 | + | container string | |||
| 171 | + | wantErr bool | |||
| 172 | + | wantErrType error | |||
| 173 | + | }{ | |||
| 174 | + | {name: "simple", container: "postgres", wantErr: false}, | |||
| 175 | + | {name: "with hyphen", container: "gitea-postgres", wantErr: false}, | |||
| 176 | + | {name: "with underscore", container: "gitea_postgres", wantErr: false}, | |||
| 177 | + | {name: "complex real container", container: "gordon-git-example-com-gitea-postgres", wantErr: false}, | |||
| 178 | + | {name: "starts with letter", container: "a-b", wantErr: false}, | |||
| 179 | + | {name: "lowercase", container: "my-container", wantErr: false}, | |||
| 180 | + | {name: "mixed case", container: "MyContainer-123", wantErr: false}, | |||
| 181 | + | {name: "empty", container: "", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 182 | + | {name: "starts with number", container: "1container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 183 | + | {name: "starts with hyphen", container: "-container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 184 | + | {name: "starts with underscore", container: "_container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 185 | + | {name: "contains slash", container: "container/name", wantErr: true, wantErrType: ErrPathTraversal}, | |||
| 186 | + | {name: "contains backslash", container: `container\name`, wantErr: true, wantErrType: ErrPathTraversal}, | |||
| 187 | + | {name: "path traversal", container: "..", wantErr: true, wantErrType: ErrPathTraversal}, | |||
| 188 | + | {name: "contains dot", container: "container.name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 189 | + | {name: "contains space", container: "container name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 190 | + | {name: "contains special char", container: "container$name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | for _, tt := range tests { | |||
| 194 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 195 | + | err := ValidateContainerName(tt.container) | |||
| 196 | + | if tt.wantErr { | |||
| 197 | + | require.Error(t, err) | |||
| 198 | + | if tt.wantErrType != nil { | |||
| 199 | + | assert.ErrorIs(t, err, tt.wantErrType) | |||
| 200 | + | } | |||
| 201 | + | } else { | |||
| 202 | + | assert.NoError(t, err) | |||
| 203 | + | } | |||
| 204 | + | }) | |||
| 205 | + | } | |||
| 206 | + | } | |||
M internal/domain/errors.go +5 -3
| @@ -46,9 +46,10 @@ var ( | |||
| 46 | 46 | ErrConfigLoadFailed = errors.New("failed to load configuration") | |
| 47 | 47 | ||
| 48 | 48 | // Environment errors | |
| 49 | - | ErrEnvFileNotFound = errors.New("environment file not found") | |
| 50 | - | ErrSecretNotFound = errors.New("secret not found") | |
| 51 | - | ErrProviderNotFound = errors.New("secret provider not found") | |
| 49 | + | ErrEnvFileNotFound = errors.New("environment file not found") | |
| 50 | + | ErrSecretNotFound = errors.New("secret not found") | |
| 51 | + | ErrProviderNotFound = errors.New("secret provider not found") | |
| 52 | + | ErrInvalidContainerName = errors.New("invalid container name") | |
| 52 | 53 | ||
| 53 | 54 | // Authentication errors | |
| 54 | 55 | ErrInvalidToken = errors.New("invalid token") | |
| @@ -63,6 +64,7 @@ var ( | |||
| 63 | 64 | ErrInsufficientScope = errors.New("insufficient scope for operation") | |
| 64 | 65 | ErrInvalidScope = errors.New("invalid scope format") | |
| 65 | 66 | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | |
| 67 | + | ErrInvalidEnvKey = errors.New("invalid environment variable key") | |
| 66 | 68 | ||
| 67 | 69 | // Attachment errors | |
| 68 | 70 | ErrAttachmentNotFound = errors.New("attachment not found") | |
| @@ -46,9 +46,10 @@ var ( | |||||
| 46 | ErrConfigLoadFailed = errors.New("failed to load configuration") | 46 | ErrConfigLoadFailed = errors.New("failed to load configuration") | ||
| 47 | 47 | ||||
| 48 | // Environment errors | 48 | // Environment errors | ||
| 49 | - | ErrEnvFileNotFound = errors.New("environment file not found") | 49 | + | ErrEnvFileNotFound = errors.New("environment file not found") |
| 50 | - | ErrSecretNotFound = errors.New("secret not found") | 50 | + | ErrSecretNotFound = errors.New("secret not found") |
| 51 | - | ErrProviderNotFound = errors.New("secret provider not found") | 51 | + | ErrProviderNotFound = errors.New("secret provider not found") |
| 52 | + | ErrInvalidContainerName = errors.New("invalid container name") | |||
| 52 | 53 | ||||
| 53 | // Authentication errors | 54 | // Authentication errors | ||
| 54 | ErrInvalidToken = errors.New("invalid token") | 55 | ErrInvalidToken = errors.New("invalid token") | ||
| @@ -63,6 +64,7 @@ var ( | |||||
| 63 | ErrInsufficientScope = errors.New("insufficient scope for operation") | 64 | ErrInsufficientScope = errors.New("insufficient scope for operation") | ||
| 64 | ErrInvalidScope = errors.New("invalid scope format") | 65 | ErrInvalidScope = errors.New("invalid scope format") | ||
| 65 | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | 66 | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | ||
| 67 | + | ErrInvalidEnvKey = errors.New("invalid environment variable key") | |||
| 66 | 68 | ||||
| 67 | // Attachment errors | 69 | // Attachment errors | ||
| 68 | ErrAttachmentNotFound = errors.New("attachment not found") | 70 | ErrAttachmentNotFound = errors.New("attachment not found") | ||
A internal/domain/sanitize.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // SanitizeDomainForContainer makes a domain safe for container naming. | |
| 6 | + | // It uses distinct replacements to avoid collisions between domains like | |
| 7 | + | // "git.example.com" and "git-example.com" (which would both become "git-example-com" | |
| 8 | + | // if we naively replaced all separators with hyphens). | |
| 9 | + | // | |
| 10 | + | // Replacements: | |
| 11 | + | // | |
| 12 | + | // . → __ (double underscore, distinct from single _ in some conventions) | |
| 13 | + | // : → -_ (hyphen-underscore combo, used for ports) | |
| 14 | + | // / → -- (double hyphen, distinct from colon to avoid :/ collisions) | |
| 15 | + | func SanitizeDomainForContainer(domain string) string { | |
| 16 | + | result := strings.ReplaceAll(domain, ".", "__") | |
| 17 | + | result = strings.ReplaceAll(result, ":", "-_") | |
| 18 | + | result = strings.ReplaceAll(result, "/", "--") | |
| 19 | + | return result | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces | |
| 23 | + | // all separators with hyphens, for backwards compatibility. | |
| 24 | + | // | |
| 25 | + | // IMPORTANT: This is maintained ONLY for finding old containers during upgrades. | |
| 26 | + | // New containers always use the collision-resistant SanitizeDomainForContainer(). | |
| 27 | + | // | |
| 28 | + | // Migration strategy: | |
| 29 | + | // - During binary upgrades, legacy containers are found using this function | |
| 30 | + | // - Old containers are stopped and removed, then replaced with new containers | |
| 31 | + | // - New containers use the collision-resistant SanitizeDomainForContainer() naming | |
| 32 | + | // - After all deployments are upgraded, this function can be removed in a major version | |
| 33 | + | func SanitizeDomainForContainerLegacy(domain string) string { | |
| 34 | + | result := strings.ReplaceAll(domain, ".", "-") | |
| 35 | + | result = strings.ReplaceAll(result, ":", "-") | |
| 36 | + | result = strings.ReplaceAll(result, "/", "-") | |
| 37 | + | return result | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // SanitizeDomainForContainer makes a domain safe for container naming. | |||
| 6 | + | // It uses distinct replacements to avoid collisions between domains like | |||
| 7 | + | // "git.example.com" and "git-example.com" (which would both become "git-example-com" | |||
| 8 | + | // if we naively replaced all separators with hyphens). | |||
| 9 | + | // | |||
| 10 | + | // Replacements: | |||
| 11 | + | // | |||
| 12 | + | // . → __ (double underscore, distinct from single _ in some conventions) | |||
| 13 | + | // : → -_ (hyphen-underscore combo, used for ports) | |||
| 14 | + | // / → -- (double hyphen, distinct from colon to avoid :/ collisions) | |||
| 15 | + | func SanitizeDomainForContainer(domain string) string { | |||
| 16 | + | result := strings.ReplaceAll(domain, ".", "__") | |||
| 17 | + | result = strings.ReplaceAll(result, ":", "-_") | |||
| 18 | + | result = strings.ReplaceAll(result, "/", "--") | |||
| 19 | + | return result | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces | |||
| 23 | + | // all separators with hyphens, for backwards compatibility. | |||
| 24 | + | // | |||
| 25 | + | // IMPORTANT: This is maintained ONLY for finding old containers during upgrades. | |||
| 26 | + | // New containers always use the collision-resistant SanitizeDomainForContainer(). | |||
| 27 | + | // | |||
| 28 | + | // Migration strategy: | |||
| 29 | + | // - During binary upgrades, legacy containers are found using this function | |||
| 30 | + | // - Old containers are stopped and removed, then replaced with new containers | |||
| 31 | + | // - New containers use the collision-resistant SanitizeDomainForContainer() naming | |||
| 32 | + | // - After all deployments are upgraded, this function can be removed in a major version | |||
| 33 | + | func SanitizeDomainForContainerLegacy(domain string) string { | |||
| 34 | + | result := strings.ReplaceAll(domain, ".", "-") | |||
| 35 | + | result = strings.ReplaceAll(result, ":", "-") | |||
| 36 | + | result = strings.ReplaceAll(result, "/", "-") | |||
| 37 | + | return result | |||
| 38 | + | } | |||
M internal/usecase/container/service.go +33 -11
| @@ -21,8 +21,8 @@ type Config struct { | |||
| 21 | 21 | RegistryAuthEnabled bool | |
| 22 | 22 | RegistryDomain string | |
| 23 | 23 | RegistryPort int | |
| 24 | - | RegistryUsername string | |
| 25 | - | RegistryPassword string | |
| 24 | + | ServiceTokenUsername string | |
| 25 | + | ServiceToken string | |
| 26 | 26 | InternalRegistryUsername string | |
| 27 | 27 | InternalRegistryPassword string | |
| 28 | 28 | PullPolicy string | |
| @@ -754,7 +754,10 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||
| 754 | 754 | return log.WrapErr(err, "failed to pull image") | |
| 755 | 755 | } | |
| 756 | 756 | case s.config.RegistryAuthEnabled: | |
| 757 | - | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil { | |
| 757 | + | if s.config.ServiceTokenUsername == "" || s.config.ServiceToken == "" { | |
| 758 | + | return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth") | |
| 759 | + | } | |
| 760 | + | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.ServiceTokenUsername, s.config.ServiceToken); err != nil { | |
| 758 | 761 | return log.WrapErr(err, "failed to pull image with auth") | |
| 759 | 762 | } | |
| 760 | 763 | default: | |
| @@ -1092,7 +1095,25 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 1092 | 1095 | }) | |
| 1093 | 1096 | ||
| 1094 | 1097 | // Check if already running (idempotent) | |
| 1098 | + | // Try new name first, then fall back to legacy name for backwards compatibility | |
| 1095 | 1099 | existingContainer := s.findContainerByName(ctx, containerName) | |
| 1100 | + | if existingContainer == nil { | |
| 1101 | + | // Try legacy naming scheme for backwards compatibility during upgrades | |
| 1102 | + | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |
| 1103 | + | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |
| 1104 | + | if existingContainer != nil { | |
| 1105 | + | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |
| 1106 | + | // Remove the old container and recreate with new name | |
| 1107 | + | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |
| 1108 | + | return log.WrapErr(err, "failed to stop legacy attachment container") | |
| 1109 | + | } | |
| 1110 | + | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |
| 1111 | + | return log.WrapErr(err, "failed to remove legacy attachment container") | |
| 1112 | + | } | |
| 1113 | + | existingContainer = nil // Clear so we create new one below | |
| 1114 | + | } | |
| 1115 | + | } | |
| 1116 | + | ||
| 1096 | 1117 | if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) { | |
| 1097 | 1118 | log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping") | |
| 1098 | 1119 | return nil | |
| @@ -1102,7 +1123,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 1102 | 1123 | if existingContainer != nil { | |
| 1103 | 1124 | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | |
| 1104 | 1125 | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |
| 1105 | - | log.WrapErr(err, "failed to remove existing attachment container") | |
| 1126 | + | return log.WrapErr(err, "failed to remove existing attachment container") | |
| 1106 | 1127 | } | |
| 1107 | 1128 | } | |
| 1108 | 1129 | ||
| @@ -1303,13 +1324,14 @@ func extractServiceName(image string) string { | |||
| 1303 | 1324 | return name | |
| 1304 | 1325 | } | |
| 1305 | 1326 | ||
| 1306 | - | // sanitizeName makes a domain safe for container naming. | |
| 1307 | - | func sanitizeName(domain string) string { | |
| 1308 | - | // Replace dots and other special chars with dashes | |
| 1309 | - | result := strings.ReplaceAll(domain, ".", "-") | |
| 1310 | - | result = strings.ReplaceAll(result, ":", "-") | |
| 1311 | - | result = strings.ReplaceAll(result, "/", "-") | |
| 1312 | - | return result | |
| 1327 | + | // sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer. | |
| 1328 | + | func sanitizeName(d string) string { | |
| 1329 | + | return domain.SanitizeDomainForContainer(d) | |
| 1330 | + | } | |
| 1331 | + | ||
| 1332 | + | // sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy. | |
| 1333 | + | func sanitizeNameLegacy(d string) string { | |
| 1334 | + | return domain.SanitizeDomainForContainerLegacy(d) | |
| 1313 | 1335 | } | |
| 1314 | 1336 | ||
| 1315 | 1337 | // findContainerByName finds a container by its name. | |
| @@ -21,8 +21,8 @@ type Config struct { | |||||
| 21 | RegistryAuthEnabled bool | 21 | RegistryAuthEnabled bool | ||
| 22 | RegistryDomain string | 22 | RegistryDomain string | ||
| 23 | RegistryPort int | 23 | RegistryPort int | ||
| 24 | - | RegistryUsername string | 24 | + | ServiceTokenUsername string |
| 25 | - | RegistryPassword string | 25 | + | ServiceToken string |
| 26 | InternalRegistryUsername string | 26 | InternalRegistryUsername string | ||
| 27 | InternalRegistryPassword string | 27 | InternalRegistryPassword string | ||
| 28 | PullPolicy string | 28 | PullPolicy string | ||
| @@ -754,7 +754,10 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||||
| 754 | return log.WrapErr(err, "failed to pull image") | 754 | return log.WrapErr(err, "failed to pull image") | ||
| 755 | } | 755 | } | ||
| 756 | case s.config.RegistryAuthEnabled: | 756 | case s.config.RegistryAuthEnabled: | ||
| 757 | - | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil { | 757 | + | if s.config.ServiceTokenUsername == "" || s.config.ServiceToken == "" { |
| 758 | + | return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth") | |||
| 759 | + | } | |||
| 760 | + | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.ServiceTokenUsername, s.config.ServiceToken); err != nil { | |||
| 758 | return log.WrapErr(err, "failed to pull image with auth") | 761 | return log.WrapErr(err, "failed to pull image with auth") | ||
| 759 | } | 762 | } | ||
| 760 | default: | 763 | default: | ||
| @@ -1092,7 +1095,25 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 1092 | }) | 1095 | }) | ||
| 1093 | 1096 | ||||
| 1094 | // Check if already running (idempotent) | 1097 | // Check if already running (idempotent) | ||
| 1098 | + | // Try new name first, then fall back to legacy name for backwards compatibility | |||
| 1095 | existingContainer := s.findContainerByName(ctx, containerName) | 1099 | existingContainer := s.findContainerByName(ctx, containerName) | ||
| 1100 | + | if existingContainer == nil { | |||
| 1101 | + | // Try legacy naming scheme for backwards compatibility during upgrades | |||
| 1102 | + | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |||
| 1103 | + | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |||
| 1104 | + | if existingContainer != nil { | |||
| 1105 | + | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |||
| 1106 | + | // Remove the old container and recreate with new name | |||
| 1107 | + | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |||
| 1108 | + | return log.WrapErr(err, "failed to stop legacy attachment container") | |||
| 1109 | + | } | |||
| 1110 | + | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |||
| 1111 | + | return log.WrapErr(err, "failed to remove legacy attachment container") | |||
| 1112 | + | } | |||
| 1113 | + | existingContainer = nil // Clear so we create new one below | |||
| 1114 | + | } | |||
| 1115 | + | } | |||
| 1116 | + | ||||
| 1096 | if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) { | 1117 | if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) { | ||
| 1097 | log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping") | 1118 | log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping") | ||
| 1098 | return nil | 1119 | return nil | ||
| @@ -1102,7 +1123,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 1102 | if existingContainer != nil { | 1123 | if existingContainer != nil { | ||
| 1103 | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | 1124 | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | ||
| 1104 | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | 1125 | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | ||
| 1105 | - | log.WrapErr(err, "failed to remove existing attachment container") | 1126 | + | return log.WrapErr(err, "failed to remove existing attachment container") |
| 1106 | } | 1127 | } | ||
| 1107 | } | 1128 | } | ||
| 1108 | 1129 | ||||
| @@ -1303,13 +1324,14 @@ func extractServiceName(image string) string { | |||||
| 1303 | return name | 1324 | return name | ||
| 1304 | } | 1325 | } | ||
| 1305 | 1326 | ||||
| 1306 | - | // sanitizeName makes a domain safe for container naming. | 1327 | + | // sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer. |
| 1307 | - | func sanitizeName(domain string) string { | 1328 | + | func sanitizeName(d string) string { |
| 1308 | - | // Replace dots and other special chars with dashes | 1329 | + | return domain.SanitizeDomainForContainer(d) |
| 1309 | - | result := strings.ReplaceAll(domain, ".", "-") | 1330 | + | } |
| 1310 | - | result = strings.ReplaceAll(result, ":", "-") | 1331 | + | |
| 1311 | - | result = strings.ReplaceAll(result, "/", "-") | 1332 | + | // sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy. |
| 1312 | - | return result | 1333 | + | func sanitizeNameLegacy(d string) string { |
| 1334 | + | return domain.SanitizeDomainForContainerLegacy(d) | |||
| 1313 | } | 1335 | } | ||
| 1314 | 1336 | ||||
| 1315 | // findContainerByName finds a container by its name. | 1337 | // findContainerByName finds a container by its name. | ||
M internal/usecase/container/service_test.go +95 -0
| @@ -119,6 +119,41 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) { | |||
| 119 | 119 | assert.Contains(t, err.Error(), "failed to pull image") | |
| 120 | 120 | } | |
| 121 | 121 | ||
| 122 | + | func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) { | |
| 123 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 124 | + | ||
| 125 | + | config := Config{ | |
| 126 | + | RegistryAuthEnabled: true, | |
| 127 | + | ServiceTokenUsername: "gordon-service", | |
| 128 | + | ServiceToken: "service-token", | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | svc := NewService(runtime, nil, nil, nil, config) | |
| 132 | + | ctx := testContext() | |
| 133 | + | ||
| 134 | + | runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil) | |
| 135 | + | ||
| 136 | + | err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false) | |
| 137 | + | ||
| 138 | + | assert.NoError(t, err) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) { | |
| 142 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 143 | + | ||
| 144 | + | config := Config{ | |
| 145 | + | RegistryAuthEnabled: true, | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | svc := NewService(runtime, nil, nil, nil, config) | |
| 149 | + | ctx := testContext() | |
| 150 | + | ||
| 151 | + | err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false) | |
| 152 | + | ||
| 153 | + | assert.Error(t, err) | |
| 154 | + | assert.Contains(t, err.Error(), "registry service token not configured") | |
| 155 | + | } | |
| 156 | + | ||
| 122 | 157 | func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |
| 123 | 158 | runtime := mocks.NewMockContainerRuntime(t) | |
| 124 | 159 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -1118,3 +1153,63 @@ func TestService_StripRegistryPrefix(t *testing.T) { | |||
| 1118 | 1153 | }) | |
| 1119 | 1154 | } | |
| 1120 | 1155 | } | |
| 1156 | + | ||
| 1157 | + | func TestSanitizeName(t *testing.T) { | |
| 1158 | + | tests := []struct { | |
| 1159 | + | domain string | |
| 1160 | + | expected string | |
| 1161 | + | description string | |
| 1162 | + | }{ | |
| 1163 | + | { | |
| 1164 | + | domain: "git.example.com", | |
| 1165 | + | expected: "git__example__com", | |
| 1166 | + | description: "Dots become double underscores", | |
| 1167 | + | }, | |
| 1168 | + | { | |
| 1169 | + | domain: "git-example.com", | |
| 1170 | + | expected: "git-example__com", | |
| 1171 | + | description: "Hyphens preserved, dots become underscores", | |
| 1172 | + | }, | |
| 1173 | + | { | |
| 1174 | + | domain: "app:8080.example.com", | |
| 1175 | + | expected: "app-_8080__example__com", | |
| 1176 | + | description: "Colons become hyphen-underscore", | |
| 1177 | + | }, | |
| 1178 | + | { | |
| 1179 | + | domain: "git.example.com:3000", | |
| 1180 | + | expected: "git__example__com-_3000", | |
| 1181 | + | description: "Multiple separators handled distinctly", | |
| 1182 | + | }, | |
| 1183 | + | { | |
| 1184 | + | domain: "simple.com", | |
| 1185 | + | expected: "simple__com", | |
| 1186 | + | description: "Simple domain", | |
| 1187 | + | }, | |
| 1188 | + | } | |
| 1189 | + | ||
| 1190 | + | for _, tt := range tests { | |
| 1191 | + | t.Run(tt.domain, func(t *testing.T) { | |
| 1192 | + | result := sanitizeName(tt.domain) | |
| 1193 | + | assert.Equal(t, tt.expected, result, "sanitization should match expected") | |
| 1194 | + | }) | |
| 1195 | + | } | |
| 1196 | + | ||
| 1197 | + | // Verify no collisions between potentially conflicting domains | |
| 1198 | + | t.Run("NoCollisions", func(t *testing.T) { | |
| 1199 | + | domains := []string{ | |
| 1200 | + | "git.example.com", | |
| 1201 | + | "git-example.com", | |
| 1202 | + | "app:8080.example.com", | |
| 1203 | + | "app-8080-example.com", | |
| 1204 | + | } | |
| 1205 | + | ||
| 1206 | + | results := make(map[string]string) | |
| 1207 | + | for _, d := range domains { | |
| 1208 | + | result := sanitizeName(d) | |
| 1209 | + | if original, exists := results[result]; exists { | |
| 1210 | + | t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result) | |
| 1211 | + | } | |
| 1212 | + | results[result] = d | |
| 1213 | + | } | |
| 1214 | + | }) | |
| 1215 | + | } | |
| @@ -119,6 +119,41 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) { | |||||
| 119 | assert.Contains(t, err.Error(), "failed to pull image") | 119 | assert.Contains(t, err.Error(), "failed to pull image") | ||
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
| 122 | + | func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) { | |||
| 123 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 124 | + | ||||
| 125 | + | config := Config{ | |||
| 126 | + | RegistryAuthEnabled: true, | |||
| 127 | + | ServiceTokenUsername: "gordon-service", | |||
| 128 | + | ServiceToken: "service-token", | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | svc := NewService(runtime, nil, nil, nil, config) | |||
| 132 | + | ctx := testContext() | |||
| 133 | + | ||||
| 134 | + | runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil) | |||
| 135 | + | ||||
| 136 | + | err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false) | |||
| 137 | + | ||||
| 138 | + | assert.NoError(t, err) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) { | |||
| 142 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 143 | + | ||||
| 144 | + | config := Config{ | |||
| 145 | + | RegistryAuthEnabled: true, | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | svc := NewService(runtime, nil, nil, nil, config) | |||
| 149 | + | ctx := testContext() | |||
| 150 | + | ||||
| 151 | + | err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false) | |||
| 152 | + | ||||
| 153 | + | assert.Error(t, err) | |||
| 154 | + | assert.Contains(t, err.Error(), "registry service token not configured") | |||
| 155 | + | } | |||
| 156 | + | ||||
| 122 | func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | 157 | func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | ||
| 123 | runtime := mocks.NewMockContainerRuntime(t) | 158 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 124 | envLoader := mocks.NewMockEnvLoader(t) | 159 | envLoader := mocks.NewMockEnvLoader(t) | ||
| @@ -1118,3 +1153,63 @@ func TestService_StripRegistryPrefix(t *testing.T) { | |||||
| 1118 | }) | 1153 | }) | ||
| 1119 | } | 1154 | } | ||
| 1120 | } | 1155 | } | ||
| 1156 | + | ||||
| 1157 | + | func TestSanitizeName(t *testing.T) { | |||
| 1158 | + | tests := []struct { | |||
| 1159 | + | domain string | |||
| 1160 | + | expected string | |||
| 1161 | + | description string | |||
| 1162 | + | }{ | |||
| 1163 | + | { | |||
| 1164 | + | domain: "git.example.com", | |||
| 1165 | + | expected: "git__example__com", | |||
| 1166 | + | description: "Dots become double underscores", | |||
| 1167 | + | }, | |||
| 1168 | + | { | |||
| 1169 | + | domain: "git-example.com", | |||
| 1170 | + | expected: "git-example__com", | |||
| 1171 | + | description: "Hyphens preserved, dots become underscores", | |||
| 1172 | + | }, | |||
| 1173 | + | { | |||
| 1174 | + | domain: "app:8080.example.com", | |||
| 1175 | + | expected: "app-_8080__example__com", | |||
| 1176 | + | description: "Colons become hyphen-underscore", | |||
| 1177 | + | }, | |||
| 1178 | + | { | |||
| 1179 | + | domain: "git.example.com:3000", | |||
| 1180 | + | expected: "git__example__com-_3000", | |||
| 1181 | + | description: "Multiple separators handled distinctly", | |||
| 1182 | + | }, | |||
| 1183 | + | { | |||
| 1184 | + | domain: "simple.com", | |||
| 1185 | + | expected: "simple__com", | |||
| 1186 | + | description: "Simple domain", | |||
| 1187 | + | }, | |||
| 1188 | + | } | |||
| 1189 | + | ||||
| 1190 | + | for _, tt := range tests { | |||
| 1191 | + | t.Run(tt.domain, func(t *testing.T) { | |||
| 1192 | + | result := sanitizeName(tt.domain) | |||
| 1193 | + | assert.Equal(t, tt.expected, result, "sanitization should match expected") | |||
| 1194 | + | }) | |||
| 1195 | + | } | |||
| 1196 | + | ||||
| 1197 | + | // Verify no collisions between potentially conflicting domains | |||
| 1198 | + | t.Run("NoCollisions", func(t *testing.T) { | |||
| 1199 | + | domains := []string{ | |||
| 1200 | + | "git.example.com", | |||
| 1201 | + | "git-example.com", | |||
| 1202 | + | "app:8080.example.com", | |||
| 1203 | + | "app-8080-example.com", | |||
| 1204 | + | } | |||
| 1205 | + | ||||
| 1206 | + | results := make(map[string]string) | |||
| 1207 | + | for _, d := range domains { | |||
| 1208 | + | result := sanitizeName(d) | |||
| 1209 | + | if original, exists := results[result]; exists { | |||
| 1210 | + | t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result) | |||
| 1211 | + | } | |||
| 1212 | + | results[result] = d | |||
| 1213 | + | } | |||
| 1214 | + | }) | |||
| 1215 | + | } | |||
M wiki/guides/secrets-pass.md +7 -6
| @@ -106,17 +106,18 @@ token_secret = "gordon/auth/token_secret" | |||
| 106 | 106 | # password_hash = "gordon/auth/password_hash" | |
| 107 | 107 | ``` | |
| 108 | 108 | ||
| 109 | - | ### Using Secrets in Environment Files | |
| 109 | + | ### Using Route Secrets | |
| 110 | 110 | ||
| 111 | - | Reference pass secrets in your app's environment files: | |
| 111 | + | With the pass backend, per-domain secrets are stored in pass, not `.env` files. | |
| 112 | 112 | ||
| 113 | 113 | ```bash | |
| 114 | - | # ~/.gordon/env/app_mydomain_com.env | |
| 115 | - | DATABASE_URL=postgresql://user:${pass:myapp/db/password}@postgres:5432/app | |
| 116 | - | API_KEY=${pass:myapp/api/key} | |
| 117 | - | JWT_SECRET=${pass:myapp/jwt/secret} | |
| 114 | + | # Store secrets for a domain | |
| 115 | + | gordon secrets set app.mydomain.com DATABASE_URL "postgresql://user:pass@postgres:5432/app" | |
| 116 | + | gordon secrets set app.mydomain.com API_KEY "your-api-key" | |
| 118 | 117 | ``` | |
| 119 | 118 | ||
| 119 | + | Gordon migrates existing `.env` files on startup and renames them to `.env.migrated`. | |
| 120 | + | ||
| 120 | 121 | ## Organizing Secrets | |
| 121 | 122 | ||
| 122 | 123 | Recommended directory structure in pass: | |
| @@ -106,17 +106,18 @@ token_secret = "gordon/auth/token_secret" | |||||
| 106 | # password_hash = "gordon/auth/password_hash" | 106 | # password_hash = "gordon/auth/password_hash" | ||
| 107 | ``` | 107 | ``` | ||
| 108 | 108 | ||||
| 109 | - | ### Using Secrets in Environment Files | 109 | + | ### Using Route Secrets |
| 110 | 110 | ||||
| 111 | - | Reference pass secrets in your app's environment files: | 111 | + | With the pass backend, per-domain secrets are stored in pass, not `.env` files. |
| 112 | 112 | ||||
| 113 | ```bash | 113 | ```bash | ||
| 114 | - | # ~/.gordon/env/app_mydomain_com.env | 114 | + | # Store secrets for a domain |
| 115 | - | DATABASE_URL=postgresql://user:${pass:myapp/db/password}@postgres:5432/app | 115 | + | gordon secrets set app.mydomain.com DATABASE_URL "postgresql://user:pass@postgres:5432/app" |
| 116 | - | API_KEY=${pass:myapp/api/key} | 116 | + | gordon secrets set app.mydomain.com API_KEY "your-api-key" |
| 117 | - | JWT_SECRET=${pass:myapp/jwt/secret} | |||
| 118 | ``` | 117 | ``` | ||
| 119 | 118 | ||||
| 119 | + | Gordon migrates existing `.env` files on startup and renames them to `.env.migrated`. | |||
| 120 | + | ||||
| 120 | ## Organizing Secrets | 121 | ## Organizing Secrets | ||
| 121 | 122 | ||||
| 122 | Recommended directory structure in pass: | 123 | Recommended directory structure in pass: | ||
M wiki/guides/secrets-sops.md +2 -0
| @@ -208,6 +208,8 @@ The path format is `file:key.path` where: | |||
| 208 | 208 | - `file` is the SOPS-encrypted file path | |
| 209 | 209 | - `key.path` is dot-notation to the value | |
| 210 | 210 | ||
| 211 | + | Route secrets remain in `.env` files with `${sops:...}` references. The SOPS backend is used for auth secrets (like `token_secret`) and provider lookups. | |
| 212 | + | ||
| 211 | 213 | ### Using Secrets in Environment Files | |
| 212 | 214 | ||
| 213 | 215 | Reference SOPS secrets in your app's environment files: | |
| @@ -208,6 +208,8 @@ The path format is `file:key.path` where: | |||||
| 208 | - `file` is the SOPS-encrypted file path | 208 | - `file` is the SOPS-encrypted file path | ||
| 209 | - `key.path` is dot-notation to the value | 209 | - `key.path` is dot-notation to the value | ||
| 210 | 210 | ||||
| 211 | + | Route secrets remain in `.env` files with `${sops:...}` references. The SOPS backend is used for auth secrets (like `token_secret`) and provider lookups. | |||
| 212 | + | ||||
| 211 | ### Using Secrets in Environment Files | 213 | ### Using Secrets in Environment Files | ||
| 212 | 214 | ||||
| 213 | Reference SOPS secrets in your app's environment files: | 215 | Reference SOPS secrets in your app's environment files: | ||