detached 8ebdfe0

Merge pull request #42 from bnema/fix/plan-secrets-registry-auth-token

Add pass-backed secrets, registry token auth, and CLI token login
commit
8ebdfe055e50e8035774ff8cfee1353d4ec2669a
author
bnema · · 2026-02-01 06:50 UTC
committer
GitHub · · 2026-02-01 06:50 UTC
merge of
6aced72 + 1f18a18 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.9.0
30 files · +2992 -415
M .goreleaser.yaml +4 -0
@@ -3,8 +3,12 @@ version: 2
33 before:
44 hooks:
55
6+git:
7+ prerelease_suffix: "-"
8+
69 release:
710 draft: true
11+ prerelease: auto
812
913 builds:
1014 - 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: true10 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.
1919
2020 ## gordon auth login
2121
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.
2323
2424 ```bash
2525 gordon auth login [options]
@@ -31,14 +31,18 @@ gordon auth login [options]
3131 |--------|-------------|
3232 | `-r, --remote` | Remote to authenticate with (defaults to active remote) |
3333 | `-u, --username` | Username for authentication |
34+| `-p, --password` | Password for authentication |
35+| `-t, --token` | Authentication token to store for the remote |
3436
3537 ### Description
3638
3739 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.
3840
41+If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis.
42+
3943 The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote.
4044
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`.
4246
4347 ### Examples
4448
@@ -51,6 +55,9 @@ gordon auth login --remote prod
5155
5256 # Pre-fill username
5357 gordon auth login --username admin
58+
59+# Store a pre-generated token
60+gordon auth login --token <token>
5461 ```
5562
5663 ### Output
@@ -212,7 +219,9 @@ Display the auto-generated internal registry credentials.
212219 gordon auth internal
213220 ```
214221
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.
216225
217226 ### Output
218227
@@ -231,6 +240,7 @@ Usage:
231240 - Credentials are regenerated each time Gordon starts
232241 - Only available while Gordon is running
233242 - 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
234244
235245 ### Use Cases
236246
@@ -19,7 +19,7 @@ Manage Gordon server authentication tokens and passwords.
19 19
20 ## gordon auth login20 ## 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 ```bash24 ```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 ### Description37 ### 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 ### Examples47 ### Examples
44 48
@@ -51,6 +55,9 @@ gordon auth login --remote prod
51 55
52 # Pre-fill username56 # Pre-fill username
53 gordon auth login --username admin57 gordon auth login --username admin
58+
59+# Store a pre-generated token
60+gordon auth login --token <token>
54 ```61 ```
55 62
56 ### Output63 ### Output
@@ -212,7 +219,9 @@ Display the auto-generated internal registry credentials.
212 gordon auth internal219 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 ### Output226 ### Output
218 227
@@ -231,6 +240,7 @@ Usage:
231 - Credentials are regenerated each time Gordon starts240 - Credentials are regenerated each time Gordon starts
232 - Only available while Gordon is running241 - Only available while Gordon is running
233 - Used for manual `docker pull` from the local registry during debugging242 - 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 Cases245 ### Use Cases
236 246
M docs/cli/secrets.md +4 -0
@@ -2,6 +2,10 @@
22
33 Manage secrets on local or remote Gordon instances.
44
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+
59 ## gordon secrets
610
711 ### 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 secrets9 ## gordon secrets
6 10
7 ### Subcommands11 ### Subcommands
M docs/config/auth.md +45 -190
@@ -2,47 +2,33 @@
22
33 Configure authentication for the Gordon registry and Admin API.
44
5-## Quick Start
5+Authentication is enabled by default. Gordon will not start without a valid `token_secret` (or `GORDON_AUTH_TOKEN_SECRET`).
66
7-Authentication is **enabled by default** for security. Gordon will not start without authentication configured.
7+## Pick a Mode
88
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)
1410
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)**
2711 ```toml
2812 [auth]
29-enabled = false
13+enabled = true
14+secrets_backend = "pass"
15+token_secret = "gordon/auth/token_secret"
16+token_expiry = "30d"
3017 ```
3118
32-## Configuration
19+### Password + Token (interactive + CI/CD)
3320
3421 ```toml
3522 [auth]
3623 enabled = true
3724 secrets_backend = "pass"
38-token_secret = "gordon/auth/token_secret"
39-token_expiry = "30d"
40-
41-# Optional: enable password authentication
4225 username = "deploy"
4326 password_hash = "gordon/auth/password_hash"
27+token_secret = "gordon/auth/token_secret"
4428 ```
4529
30+Token auth always works. Adding `username` + `password_hash` enables interactive login.
31+
4632 ## Options
4733
4834 | Option | Type | Default | Description |
@@ -54,59 +40,31 @@ password_hash = "gordon/auth/password_hash"
5440 | `username` | string | - | Username for password auth (optional) |
5541 | `password_hash` | string | - | Path to bcrypt hash in secrets backend (optional) |
5642
57-## Authentication Modes
58-
59-Gordon infers the authentication mode from your configuration:
43+## Secrets Backends
6044
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:
6546
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) |
6952
70-Gordon supports environment variables for sensitive configuration:
53+See [Secret Providers](./secrets.md) for setup details.
7154
72-| Variable | Description |
73-|----------|-------------|
74-| `GORDON_AUTH_TOKEN_SECRET` | JWT signing secret (takes priority over config file) |
55+## Environment Variable
7556
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`).
7758
7859 ```bash
7960 export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here"
8061 gordon serve
8162 ```
8263
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
9665
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
10867
109-**Setup:**
11068 ```bash
11169 # Create token secret (random 32+ characters)
11270 pass insert gordon/auth/token_secret
@@ -115,93 +73,51 @@ pass insert gordon/auth/token_secret
11573 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
11674 ```
11775
118-**Usage:**
11976 ```bash
12077 # Docker login with token
12178 docker login -u ci-bot -p <token> registry.mydomain.com
12279 ```
12380
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
13582
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:**
15283 ```bash
15384 # Generate bcrypt hash
15485 gordon auth password hash
155-# Enter password when prompted
15686
157-# Store hash in secrets backend
87+# Store hash and token secret
15888 pass insert gordon/auth/password_hash
159-# Paste the bcrypt hash
160-
161-# Create token secret (for CI/CD token generation)
16289 pass insert gordon/auth/token_secret
163-# Enter a random 32+ character string
16490 ```
16591
166-**Usage:**
16792 ```bash
16893 # Docker registry access
16994 docker login -u deploy -p <password> registry.mydomain.com
17095
171-# Remote CLI authentication
96+# Remote CLI login (stores a token for you)
17297 gordon auth login --remote prod
173-# Enter username and password when prompted
17498 ```
17599
176-**Remote CLI Authentication:**
100+## Remote CLI Tokens
177101
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:
179103
180104 ```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>
186108 ```
187109
188-The returned token is stored automatically and used for subsequent CLI commands.
189-
190110 ## Token Scopes
191111
192-Tokens can have different permission levels:
193-
194-### Registry Scopes
112+Registry scopes:
195113
196114 | Scope | Permission |
197-|-------|------------|
115+| ------- | ------------ |
198116 | `push` | Push images to registry |
199117 | `pull` | Pull images from registry |
200118 | `push,pull` | Both push and pull (default) |
201119
202-### Admin Scopes
203-
204-For remote CLI access via the Admin API:
120+Admin scopes (for remote CLI):
205121
206122 | Scope | Permission |
207123 |-------|------------|
@@ -214,106 +130,45 @@ For remote CLI access via the Admin API:
214130 | `admin:secrets:read` | List secret keys |
215131 | `admin:secrets:write` | Set/delete secrets |
216132
217-**Examples:**
133+Examples:
134+
218135 ```bash
219-# Pull-only token (for read-only access)
220136 gordon auth token generate --subject reader --scopes pull --expiry 30d
221-
222-# Push-only token (for CI builds)
223137 gordon auth token generate --subject builder --scopes push --expiry 0
224-
225-# Full admin access (for remote CLI)
226138 gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
227139 ```
228140
229141 ## Token Expiry
230142
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`.
241144
242145 ```bash
243-# Never expires (for CI/CD)
244146 gordon auth token generate --subject ci --expiry 0
245-
246-# Expires in 1 year
247147 gordon auth token generate --subject deploy --expiry 1y
248-
249-# Expires in 30 days
250148 gordon auth token generate --subject temp --expiry 30d
251149 ```
252150
253-## Instance-Specific Tokens
254-
255-Tokens are signed with the `token_secret`, which is unique to each Gordon instance:
151+## Instance-specific Tokens
256152
153+- Tokens are signed with `token_secret`
257154 - 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
260156
261-## Development Setup
262-
263-For local development without authentication:
157+## Disable Auth (development only)
264158
265159 ```toml
266160 [auth]
267161 enabled = false
268162 ```
269163
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-
312164 ## Internal Registry Auth
313165
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.
315169
316170 To view internal credentials (for troubleshooting):
171+
317172 ```bash
318173 gordon auth internal
319174 ```
@@ -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 Start5+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 ```toml11 ```toml
28 [auth]12 [auth]
29-enabled = false13+enabled = true
14+secrets_backend = "pass"
15+token_secret = "gordon/auth/token_secret"
16+token_expiry = "30d"
30 ```17 ```
31 18
32-## Configuration19+### Password + Token (interactive + CI/CD)
33 20
34 ```toml21 ```toml
35 [auth]22 [auth]
36 enabled = true23 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 ## Options32 ## 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 Modes43+## 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 Variables49+| `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 ```bash59 ```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 serve61 gordon serve
81 ```62 ```
82 63
83-## Secrets Backend64+## 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 Mode66+### 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 ```bash68 ```bash
111 # Create token secret (random 32+ characters)69 # Create token secret (random 32+ characters)
112 pass insert gordon/auth/token_secret70 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 073 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
116 ```74 ```
117 75
118-**Usage:**
119 ```bash76 ```bash
120 # Docker login with token77 # Docker login with token
121 docker login -u ci-bot -p <token> registry.mydomain.com78 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 ```bash83 ```bash
153 # Generate bcrypt hash84 # Generate bcrypt hash
154 gordon auth password hash85 gordon auth password hash
155-# Enter password when prompted
156 86
157-# Store hash in secrets backend87+# Store hash and token secret
158 pass insert gordon/auth/password_hash88 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_secret89 pass insert gordon/auth/token_secret
163-# Enter a random 32+ character string
164 ```90 ```
165 91
166-**Usage:**
167 ```bash92 ```bash
168 # Docker registry access93 # Docker registry access
169 docker login -u deploy -p <password> registry.mydomain.com94 docker login -u deploy -p <password> registry.mydomain.com
170 95
171-# Remote CLI authentication96+# Remote CLI login (stores a token for you)
172 gordon auth login --remote prod97 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 ```bash104 ```bash
181-gordon auth login --remote prod105+gordon auth login --token <token>
182-# Username: deploy106+# 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 Scopes110 ## 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 Scopes120+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 ```bash135 ```bash
219-# Pull-only token (for read-only access)
220 gordon auth token generate --subject reader --scopes pull --expiry 30d136 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 0137 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 0138 gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
227 ```139 ```
228 140
229 ## Token Expiry141 ## 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 ```bash145 ```bash
243-# Never expires (for CI/CD)
244 gordon auth token generate --subject ci --expiry 0146 gordon auth token generate --subject ci --expiry 0
245-
246-# Expires in 1 year
247 gordon auth token generate --subject deploy --expiry 1y147 gordon auth token generate --subject deploy --expiry 1y
248-
249-# Expires in 30 days
250 gordon auth token generate --subject temp --expiry 30d148 gordon auth token generate --subject temp --expiry 30d
251 ```149 ```
252 150
253-## Instance-Specific Tokens151+## 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 another154 - Tokens from one Gordon instance won't work on another
258-- Changing the `token_secret` invalidates all existing tokens155+- Changing `token_secret` invalidates all existing tokens
259-- Each environment (dev, staging, prod) should have different secrets
260 156
261-## Development Setup157+## Disable Auth (development only)
262-
263-For local development without authentication:
264 158
265 ```toml159 ```toml
266 [auth]160 [auth]
267 enabled = false161 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 Auth164 ## 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 ```bash172 ```bash
318 gordon auth internal173 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:
3434 | `api.company.io` | `api_company_io.env` |
3535 | `staging.app.dev` | `staging_app_dev.env` |
3636
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+
3752 ## File Format
3853
3954 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 Format52 ## 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"
5454 - Standard Unix tooling
5555 - Works with team GPG keys
5656
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+
5761 ### SOPS
5862
5963 Uses Mozilla SOPS for encrypted file-based secrets:
@@ -85,6 +89,10 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
8589 - YAML/JSON file encryption
8690 - Git-friendly (encrypted files in repo)
8791
92+**Route secrets storage:**
93+- Domain secrets stay in `.env` files
94+- Use `${sops:...}` syntax to resolve encrypted values
95+
8896 ### Unsafe (Development Only)
8997
9098 Stores secrets as plain text files:
@@ -54,6 +54,10 @@ token_secret = "gordon/auth/token_secret"
54 - Standard Unix tooling54 - Standard Unix tooling
55 - Works with team GPG keys55 - 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 ### SOPS61 ### 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 encryption89 - 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`.
2929
3030 ## 3. Set Up Authentication
3131
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.
3333
3434 > **Local development?** If you just want to try Gordon locally, you can disable auth temporarily:
3535 > ```toml
@@ -53,6 +53,12 @@ pass init <your-gpg-key-id>
5353 openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
5454 ```
5555
56+**Or set it via environment variable:**
57+
58+```bash
59+export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here"
60+```
61+
5662 **Update your config** (`~/.config/gordon/gordon.toml`):
5763 ```toml
5864 [auth]
@@ -29,7 +29,7 @@ Config is created at `~/.config/gordon/gordon.toml`.
29 29
30 ## 3. Set Up Authentication30 ## 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 > ```toml35 > ```toml
@@ -53,6 +53,12 @@ pass init <your-gpg-key-id>
53 openssl rand -base64 32 | pass insert -m gordon/auth/token_secret53 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 ```toml63 ```toml
58 [auth]64 [auth]
M install.sh +27 -2
@@ -3,9 +3,11 @@ set -e
33
44 # Gordon installer script
55 # 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
67
78 REPO="bnema/gordon"
89 INSTALL_DIR="/usr/local/bin"
10+VERSION="${GORDON_VERSION:-latest}"
911
1012 echo "Installing Gordon..."
1113
@@ -43,14 +45,37 @@ echo "Detected: ${OS}/${ARCH}"
4345
4446 # Construct download URL
4547 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}"
4772
4873 # Create temporary directory
4974 TMP_DIR=$(mktemp -d)
5075 trap 'rm -rf "$TMP_DIR"' EXIT
5176
5277 # 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"
5479 echo "Downloading checksums..."
5580 if ! curl -fsSL "$CHECKSUMS_URL" -o "$TMP_DIR/checksums.txt"; then
5681 echo "Error: Failed to download checksums file"
@@ -3,9 +3,11 @@ set -e
3 3
4 # Gordon installer script4 # Gordon installer script
5 # Usage: curl -fsSL https://gordon.bnema.dev/install | bash5 # 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 URL46 # 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 directory73 # Create temporary directory
49 TMP_DIR=$(mktemp -d)74 TMP_DIR=$(mktemp -d)
50 trap 'rm -rf "$TMP_DIR"' EXIT75 trap 'rm -rf "$TMP_DIR"' EXIT
51 76
52 # Download checksums file77 # 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"; then80 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 {
207207 var (
208208 remoteName string
209209 username string
210+ token string
211+ password string
210212 )
211213
212214 cmd := &cobra.Command{
213215 Use: "login",
214216 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.
216218
217219 This command prompts for your username and password, authenticates with the
218220 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.
220222 The token is stored in your remote configuration and used for subsequent
221223 CLI operations.
222224
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.
225227
226228 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`,
230233 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)
232238 },
233239 }
234240
235241 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
236242 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")
237248
238249 return cmd
239250 }
240251
241252 // 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)
245255 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
260257 }
261258
262259 // Prompt for username if not provided
@@ -274,21 +271,28 @@ func runAuthLogin(remoteName, username string) error {
274271 return fmt.Errorf("username cannot be empty")
275272 }
276273
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"))
286287 }
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")
288293 }
289- fmt.Println() // newline after hidden password input
290294
291- if len(passwordBytes) == 0 {
295+ if password == "" {
292296 return fmt.Errorf("password cannot be empty")
293297 }
294298
@@ -297,26 +301,79 @@ func runAuthLogin(remoteName, username string) error {
297301
298302 // Authenticate
299303 ctx := context.Background()
300- fmt.Printf("Authenticating with %s...\n", remoteName)
304+ fmt.Printf("Authenticating with %s...\n", resolvedName)
301305
302- resp, err := client.Authenticate(ctx, username, string(passwordBytes))
306+ resp, err := client.Authenticate(ctx, username, password)
303307 if err != nil {
304308 return fmt.Errorf("authentication failed: %w", err)
305309 }
306310
307311 // 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 {
309313 return fmt.Errorf("failed to save token: %w", err)
310314 }
311315
312316 fmt.Println()
313317 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)
315319 fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
316320
317321 return nil
318322 }
319323
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+
320377 // runShowInternalAuth displays the internal registry credentials.
321378 func runShowInternalAuth() error {
322379 creds, err := app.GetInternalCredentials()
@@ -207,12 +207,14 @@ func newAuthLoginCmd() *cobra.Command {
207 var (207 var (
208 remoteName string208 remoteName string
209 username string209 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 the219 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 subsequent222 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 remote229+ gordon auth login Login using active remote
228- gordon auth login --remote prod Login to specific remote230+ 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 cmd249 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 config254+ 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 provided259 // 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 input278+ 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 // Authenticate302 // 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 token311 // 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 nil321 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 (
66 "fmt"
77 "os"
88 "path/filepath"
9- "regexp"
9+ "sort"
1010 "strings"
1111
1212 "github.com/bnema/zerowrap"
@@ -86,33 +86,17 @@ func (s *FileStore) GetAll(domainName string) (map[string]string, error) {
8686 return nil, err
8787 }
8888
89- file, err := os.Open(envFile)
89+ data, err := os.ReadFile(envFile)
9090 if err != nil {
9191 if os.IsNotExist(err) {
9292 return map[string]string{}, nil
9393 }
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)
11295 }
11396
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)
116100 }
117101
118102 return secrets, nil
@@ -165,6 +149,56 @@ func (s *FileStore) Delete(domainName, key string) error {
165149 return s.writeSecretsAtomic(domainName, existing)
166150 }
167151
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+
168202 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
169203 // It writes to a temporary file first, syncs it, then renames to the final path.
170204 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
191225 return fmt.Errorf("failed to write header: %w", err)
192226 }
193227
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 {
197237 file.Close()
198238 os.Remove(tmpFile)
199239 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
221261 return nil
222262 }
223263
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-
228264 // getEnvFilePath converts a domain to its env file path.
229265 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
230266 //
231267 // SECURITY: Validates domain and ensures the resulting path stays within envDir.
232268 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 {
245271 s.log.Warn().
246272 Str(zerowrap.FieldLayer, "adapter").
247273 Str(zerowrap.FieldAdapter, "domainsecrets").
248274 Str("domain", domainName).
249- Msg("rejected invalid domain format")
275+ Err(err).
276+ Msg("rejected invalid domain")
250277 return ""
251278 }
252279
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-
258280 fullPath := filepath.Join(s.envDir, safeDomain+".env")
259281
260282 // SECURITY: Final validation - ensure path stays within envDir
@@ -283,12 +305,118 @@ func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
283305 return path, nil
284306 }
285307
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+
286409 // 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.
287411 // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env
288412 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+ }
292420
293421 // List all files in env directory
294422 entries, err := os.ReadDir(s.envDir)
@@ -299,57 +427,66 @@ func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre
299427 return nil, fmt.Errorf("failed to read env directory: %w", err)
300428 }
301429
430+ seen := make(map[string]bool)
302431 var results []out.AttachmentSecrets
303432 for _, entry := range entries {
304433 if entry.IsDir() {
305434 continue
306435 }
307436 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- }
317437
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
331444 }
445+ }
446+ if matchedPrefix == "" {
447+ continue
448+ }
332449
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+ })
339484 }
340485 }
341486
342487 return results, nil
343488 }
344489
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-
353490 // listKeysFromFile reads secret keys from a specific env file path.
354491 func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) {
355492 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, err86 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{}, nil92 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, nil102 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 secret228+ // 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 nil261 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 attempts269+ 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 envDir282 // SECURITY: Final validation - ensure path stays within envDir
@@ -283,12 +305,118 @@ func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
283 return path, nil305 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}.env411 // 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 does413+ // 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 directory421 // 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.AttachmentSecrets431 var results []out.AttachmentSecrets
303 for _, entry := range entries {432 for _, entry := range entries {
304 if entry.IsDir() {433 if entry.IsDir() {
305 continue434 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 .env438+ // 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 method441+ if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
322- // Note: We use the container name directly since it matches the env file naming442+ 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, nil487 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 (
66 "path/filepath"
77 "testing"
88
9- "github.com/bnema/zerowrap"
109 "github.com/stretchr/testify/assert"
1110 "github.com/stretchr/testify/require"
1211
1312 "github.com/bnema/gordon/internal/domain"
1413 )
1514
16-func testLogger() zerowrap.Logger {
17- return zerowrap.Default()
18-}
19-
2015 func TestFileStore_PathTraversal(t *testing.T) {
2116 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
2217 require.NoError(t, err)
@@ -121,6 +116,183 @@ func TestFileStore_PathContainment(t *testing.T) {
121116 }
122117 }
123118
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+
124296 func TestFileStore_ValidDomainOperations(t *testing.T) {
125297 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
126298 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 {
119119 eventBus *eventbus.InMemory
120120 blobStorage *filesystem.BlobStorage
121121 manifestStorage *filesystem.ManifestStorage
122- envLoader *envloader.FileLoader
122+ envLoader out.EnvLoader
123123 logWriter *logwriter.LogWriter
124124 tokenStore out.TokenStore
125125 configSvc *config.Service
@@ -256,11 +256,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
256256 return nil, err
257257 }
258258
259- // Create env loader
260- if svc.envLoader, err = createEnvLoader(cfg, log); err != nil {
261- return nil, err
262- }
263-
264259 // Create log writer
265260 if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
266261 return nil, err
@@ -271,19 +266,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
271266 return nil, err
272267 }
273268
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
287271 }
288272
289273 // Create use case services
@@ -292,7 +276,24 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
292276 return nil, log.WrapErr(err, "failed to load configuration")
293277 }
294278
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+ }
296297 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
297298 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
298299 RegistryDomain: cfg.Server.RegistryDomain,
@@ -308,23 +309,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
308309 svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
309310 }
310311
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-
328312 // Create health service for route health checking
329313 prober := httpprober.New()
330314 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
338322 return svc, nil
339323 }
340324
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+
341368 // resolveLogFilePath returns the configured log file path or a default.
342369 func resolveLogFilePath(cfg Config) string {
343370 if cfg.Logging.File.Path != "" {
@@ -391,36 +418,35 @@ func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *f
391418 }
392419
393420 // 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+ }
404434
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+ }
409441
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+ }
416447
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
421449 }
422-
423- return envLoader, nil
424450 }
425451
426452 // createLogWriter creates the container log writer.
@@ -454,7 +480,11 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err
454480 return writer, nil
455481 }
456482
457-const internalRegistryUsername = "gordon-internal"
483+const (
484+ internalRegistryUsername = "gordon-internal"
485+ serviceTokenSubject = "gordon-service"
486+ serviceTokenDefaultTTL = 30 * 24 * time.Hour
487+)
458488
459489 func generateInternalRegistryAuth() (string, string, error) {
460490 password, err := randomTokenHex(32)
@@ -634,6 +664,15 @@ func resolveDataDir(dataDir string) string {
634664 return dataDir
635665 }
636666
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+
637676 func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
638677 // Token store is always created since tokens work in both auth modes
639678 store, err := tokenstore.NewStore(backend, dataDir, log)
@@ -751,6 +790,17 @@ func parseTokenExpiry(expiry string) (time.Duration, error) {
751790 return parsed, nil
752791 }
753792
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+
754804 // loadSecret loads a secret from the configured backend.
755805 func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
756806 switch backend {
@@ -774,13 +824,11 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
774824 }
775825
776826 // 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) {
778828 containerConfig := container.Config{
779829 RegistryAuthEnabled: cfg.Auth.Enabled,
780830 RegistryDomain: cfg.Server.RegistryDomain,
781831 RegistryPort: cfg.Server.RegistryPort,
782- RegistryUsername: cfg.Auth.Username,
783- RegistryPassword: cfg.Auth.Password,
784832 InternalRegistryUsername: svc.internalRegUser,
785833 InternalRegistryPassword: svc.internalRegPass,
786834 PullPolicy: v.GetString("deploy.pull_policy"),
@@ -793,7 +841,27 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
793841 NetworkGroups: svc.configSvc.GetNetworkGroups(),
794842 Attachments: svc.configSvc.GetAttachments(),
795843 }
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
797865 }
798866
799867 // registerEventHandlers registers all event handlers.
@@ -119,7 +119,7 @@ type services struct {
119 eventBus *eventbus.InMemory119 eventBus *eventbus.InMemory
120 blobStorage *filesystem.BlobStorage120 blobStorage *filesystem.BlobStorage
121 manifestStorage *filesystem.ManifestStorage121 manifestStorage *filesystem.ManifestStorage
122- envLoader *envloader.FileLoader122+ envLoader out.EnvLoader
123 logWriter *logwriter.LogWriter123 logWriter *logwriter.LogWriter
124 tokenStore out.TokenStore124 tokenStore out.TokenStore
125 configSvc *config.Service125 configSvc *config.Service
@@ -256,11 +256,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
256 return nil, err256 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 writer259 // 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, err261 return nil, err
@@ -271,19 +266,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
271 return nil, err266 return nil, err
272 }267 }
273 268
274- // Generate internal registry credentials for loopback-only pulls269+ 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 services273 // 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 checking312 // 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, nil322 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.DataDir422+ 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.Dir427+ }
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 providers442+ 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, nil480 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 dataDir664 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 modes677 // 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, nil790 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 {
2323 // Delete removes a specific secret key from a domain.
2424 Delete(domain, key string) error
2525
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+
2632 // ListAttachmentKeys finds and returns secret keys for attachment containers
2733 // associated with the given domain. Returns a list of AttachmentSecrets, one
2834 // 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) error24 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 containers32 // ListAttachmentKeys finds and returns secret keys for attachment containers
27 // associated with the given domain. Returns a list of AttachmentSecrets, one33 // 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
155155 return _c
156156 }
157157
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+
158220 // ListAttachmentKeys provides a mock function for the type MockDomainSecretStore
159221 func (_mock *MockDomainSecretStore) ListAttachmentKeys(domain string) ([]out.AttachmentSecrets, error) {
160222 ret := _mock.Called(domain)
@@ -335,3 +397,60 @@ func (_c *MockDomainSecretStore_Set_Call) RunAndReturn(run func(domain string, s
335397 _c.Call.Return(run)
336398 return _c
337399 }
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 _c155 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 MockDomainSecretStore220 // 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 _c398 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 (
4646 ErrConfigLoadFailed = errors.New("failed to load configuration")
4747
4848 // 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")
5253
5354 // Authentication errors
5455 ErrInvalidToken = errors.New("invalid token")
@@ -63,6 +64,7 @@ var (
6364 ErrInsufficientScope = errors.New("insufficient scope for operation")
6465 ErrInvalidScope = errors.New("invalid scope format")
6566 ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
67+ ErrInvalidEnvKey = errors.New("invalid environment variable key")
6668
6769 // Attachment errors
6870 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 errors48 // 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 errors54 // 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 errors69 // 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 {
2121 RegistryAuthEnabled bool
2222 RegistryDomain string
2323 RegistryPort int
24- RegistryUsername string
25- RegistryPassword string
24+ ServiceTokenUsername string
25+ ServiceToken string
2626 InternalRegistryUsername string
2727 InternalRegistryPassword string
2828 PullPolicy string
@@ -754,7 +754,10 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool
754754 return log.WrapErr(err, "failed to pull image")
755755 }
756756 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 {
758761 return log.WrapErr(err, "failed to pull image with auth")
759762 }
760763 default:
@@ -1092,7 +1095,25 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
10921095 })
10931096
10941097 // Check if already running (idempotent)
1098+ // Try new name first, then fall back to legacy name for backwards compatibility
10951099 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+
10961117 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
10971118 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
10981119 return nil
@@ -1102,7 +1123,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
11021123 if existingContainer != nil {
11031124 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
11041125 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")
11061127 }
11071128 }
11081129
@@ -1303,13 +1324,14 @@ func extractServiceName(image string) string {
13031324 return name
13041325 }
13051326
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)
13131335 }
13141336
13151337 // findContainerByName finds a container by its name.
@@ -21,8 +21,8 @@ type Config struct {
21 RegistryAuthEnabled bool21 RegistryAuthEnabled bool
22 RegistryDomain string22 RegistryDomain string
23 RegistryPort int23 RegistryPort int
24- RegistryUsername string24+ ServiceTokenUsername string
25- RegistryPassword string25+ ServiceToken string
26 InternalRegistryUsername string26 InternalRegistryUsername string
27 InternalRegistryPassword string27 InternalRegistryPassword string
28 PullPolicy string28 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 nil1119 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 name1324 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 dashes1329+ 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 result1333+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) {
119119 assert.Contains(t, err.Error(), "failed to pull image")
120120 }
121121
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+
122157 func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
123158 runtime := mocks.NewMockContainerRuntime(t)
124159 envLoader := mocks.NewMockEnvLoader(t)
@@ -1118,3 +1153,63 @@ func TestService_StripRegistryPrefix(t *testing.T) {
11181153 })
11191154 }
11201155 }
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"
106106 # password_hash = "gordon/auth/password_hash"
107107 ```
108108
109-### Using Secrets in Environment Files
109+### Using Route Secrets
110110
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.
112112
113113 ```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"
118117 ```
119118
119+Gordon migrates existing `.env` files on startup and renames them to `.env.migrated`.
120+
120121 ## Organizing Secrets
121122
122123 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 Files109+### 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 ```bash113 ```bash
114-# ~/.gordon/env/app_mydomain_com.env114+# Store secrets for a domain
115-DATABASE_URL=postgresql://user:${pass:myapp/db/password}@postgres:5432/app115+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 Secrets121 ## 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:
208208 - `file` is the SOPS-encrypted file path
209209 - `key.path` is dot-notation to the value
210210
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+
211213 ### Using Secrets in Environment Files
212214
213215 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 path208 - `file` is the SOPS-encrypted file path
209 - `key.path` is dot-notation to the value209 - `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 Files213 ### 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:

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close