Skip to content

Commit d99cb95

Browse files
committed
feat(accessories): resolve secret:KEY env references from the encrypted store
Accessory env values can now reference age-encrypted secrets (`POSTGRES_PASSWORD: secret:DB_PASSWORD`), resolved at container start via the same store that `teploy secret set` writes and app deploys inject — one secret feeds both the accessory and the app, with nothing persisted in plaintext (unlike `auto`, references are never written to the credentials file). Unset references fail the deploy with the exact command to run; empty references are rejected at config validation. Unblocks running database accessories with real auth (e.g. a Nucleus accessory with NUCLEUS_PASSWORD instead of NUCLEUS_ALLOW_NO_AUTH).
1 parent 823259f commit d99cb95

5 files changed

Lines changed: 154 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,10 @@ accessories:
162162
image: postgres:16
163163
port: 5432
164164
env:
165-
POSTGRES_PASSWORD: secret
165+
POSTGRES_PASSWORD: auto # generated once, persisted server-side
166+
# or reference an encrypted secret (set with `teploy secret set DB_PASSWORD=...`);
167+
# the app container receives the same secret, so both sides agree:
168+
# POSTGRES_PASSWORD: secret:DB_PASSWORD
166169

167170
assets:
168171
path: /app/public/assets

‎TODO.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# TODO
2+
3+
## harden: bake trusted-network `ignoreip` into the fail2ban config
4+
5+
`internal/harden/harden.go` writes `[sshd]\nenabled = true\nmode = aggressive` and enables
6+
fail2ban, but it does **not** set `ignoreip`. Combined with aggressive mode + a low
7+
`maxretry`, this causes the server to ban trusted IPs (e.g. an operator's VPN / tailnet
8+
CGNAT range such as `100.64.0.0/10`) after only a couple of failed auth attempts —
9+
locking the operator out of their own SSH for the full `bantime`.
10+
11+
Fix: when writing the jail config, include an `ignoreip` line covering at minimum
12+
`127.0.0.1/8 ::1` plus the deployer's trusted network. Parameterize the trusted CIDR
13+
(a `--trusted-cidr` flag, or read from the teploy config file) so the operator's mesh
14+
traffic is never banned by their own hardening step.
15+
16+
Observed in the wild: a Tailscale tailnet IP got banned for 24h after two failed pubkey
17+
attempts, manifesting as "Connection refused" on port 22 (banaction = ufw sends RST).

‎internal/accessories/accessories.go‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/useteploy/teploy/internal/config"
1313
"github.com/useteploy/teploy/internal/docker"
14+
"github.com/useteploy/teploy/internal/secret"
1415
"github.com/useteploy/teploy/internal/ssh"
1516
)
1617

@@ -106,8 +107,16 @@ func (m *Manager) EnsureRunning(ctx context.Context, app, name string, cfg confi
106107
return connectionEnvVars(app, name, cfg.Image, cfg.Port, env), nil
107108
}
108109

109-
// resolveEnv processes env vars, replacing "auto" values with generated passwords.
110-
// Persists generated credentials to /deployments/{app}/accessories/{name}/credentials.
110+
// resolveEnv processes env vars, replacing "auto" values with generated
111+
// passwords and "secret:KEY" references with values decrypted from the app's
112+
// encrypted secret store (`teploy secret set KEY=value`).
113+
//
114+
// Generated ("auto") credentials are persisted to
115+
// /deployments/{app}/accessories/{name}/credentials. Secret references are
116+
// NOT persisted anywhere in plaintext — the age-encrypted store stays the
117+
// single source of truth, and because app deploys inject the same store into
118+
// the app container, one `teploy secret set` feeds both sides (e.g. a
119+
// database password the accessory sets and the app connects with).
111120
func (m *Manager) resolveEnv(ctx context.Context, app, name string, env map[string]string) (map[string]string, error) {
112121
if len(env) == 0 {
113122
return nil, nil
@@ -119,8 +128,10 @@ func (m *Manager) resolveEnv(ctx context.Context, app, name string, env map[stri
119128
result := make(map[string]string)
120129
needsWrite := false
121130

131+
var secrets *secret.Manager
122132
for k, v := range env {
123-
if v == "auto" {
133+
switch {
134+
case v == "auto":
124135
if existing, ok := stored[k]; ok {
125136
result[k] = existing
126137
} else {
@@ -132,7 +143,20 @@ func (m *Manager) resolveEnv(ctx context.Context, app, name string, env map[stri
132143
stored[k] = password
133144
needsWrite = true
134145
}
135-
} else {
146+
case strings.HasPrefix(v, "secret:"):
147+
key := strings.TrimSpace(strings.TrimPrefix(v, "secret:"))
148+
if key == "" {
149+
return nil, fmt.Errorf("accessory %s env %s: empty secret reference (expected secret:KEY)", name, k)
150+
}
151+
if secrets == nil {
152+
secrets = secret.NewManager(m.exec)
153+
}
154+
val, err := secrets.Get(ctx, app, key)
155+
if err != nil {
156+
return nil, fmt.Errorf("accessory %s env %s: %w — set it with: teploy secret set %s=<value>", name, k, err, key)
157+
}
158+
result[k] = val
159+
default:
136160
result[k] = v
137161
}
138162
}

‎internal/accessories/accessories_test.go‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,3 +572,102 @@ func TestIsImageType(t *testing.T) {
572572
}
573573
}
574574
}
575+
576+
func TestEnsureRunning_SecretReference(t *testing.T) {
577+
mock := ssh.NewMockExecutor("1.2.3.4",
578+
// No stored credentials.
579+
ssh.MockCommand{Match: "cat /deployments/myapp/accessories/nucleus/credentials", Err: fmt.Errorf("not found")},
580+
// Secret exists and decrypts.
581+
ssh.MockCommand{Match: "test -f /deployments/myapp/secrets/NUCLEUS_PASSWORD.age", Output: ""},
582+
ssh.MockCommand{Match: "age -d -i /deployments/.age-key /deployments/myapp/secrets/NUCLEUS_PASSWORD.age", Output: "s3cr3t-pa$$word\n"},
583+
// Not running.
584+
ssh.MockCommand{Match: "docker inspect", Err: fmt.Errorf("not found")},
585+
ssh.MockCommand{Match: "mkdir -p /deployments/myapp/accessories/nucleus", Output: ""},
586+
ssh.MockCommand{Match: "docker run", Output: "abc123"},
587+
)
588+
589+
var buf bytes.Buffer
590+
mgr := NewManager(mock, &buf)
591+
592+
_, err := mgr.EnsureRunning(context.Background(), "myapp", "nucleus", config.AccessoryConfig{
593+
Image: "ghcr.io/neutron-build/nucleus:latest",
594+
Port: 5432,
595+
Env: map[string]string{
596+
"NUCLEUS_PASSWORD": "secret:NUCLEUS_PASSWORD",
597+
"NUCLEUS_PLAIN": "literal",
598+
},
599+
})
600+
if err != nil {
601+
t.Fatalf("EnsureRunning: %v", err)
602+
}
603+
604+
var runCmd string
605+
for _, call := range mock.Calls {
606+
if strings.HasPrefix(call, "docker run") {
607+
runCmd = call
608+
}
609+
}
610+
if runCmd == "" {
611+
t.Fatal("expected docker run command")
612+
}
613+
// The decrypted value is injected (shell-quoted), not the reference.
614+
if !strings.Contains(runCmd, `'NUCLEUS_PASSWORD=s3cr3t-pa$$word'`) {
615+
t.Errorf("expected decrypted secret in docker run env, got: %s", runCmd)
616+
}
617+
if strings.Contains(runCmd, "secret:NUCLEUS_PASSWORD") {
618+
t.Errorf("secret reference leaked into docker run: %s", runCmd)
619+
}
620+
if !strings.Contains(runCmd, `'NUCLEUS_PLAIN=literal'`) {
621+
t.Errorf("literal env should pass through, got: %s", runCmd)
622+
}
623+
624+
// The plaintext must never be persisted to the credentials file.
625+
for path, content := range mock.Files {
626+
if strings.Contains(path, "credentials") && strings.Contains(string(content), "s3cr3t") {
627+
t.Errorf("secret value persisted in plaintext to %s", path)
628+
}
629+
}
630+
}
631+
632+
func TestEnsureRunning_SecretReferenceMissing(t *testing.T) {
633+
mock := ssh.NewMockExecutor("1.2.3.4",
634+
ssh.MockCommand{Match: "cat /deployments/myapp/accessories/nucleus/credentials", Err: fmt.Errorf("not found")},
635+
// Secret file does not exist.
636+
ssh.MockCommand{Match: "test -f /deployments/myapp/secrets/NUCLEUS_PASSWORD.age", Err: fmt.Errorf("exit 1")},
637+
)
638+
639+
var buf bytes.Buffer
640+
mgr := NewManager(mock, &buf)
641+
642+
_, err := mgr.EnsureRunning(context.Background(), "myapp", "nucleus", config.AccessoryConfig{
643+
Image: "ghcr.io/neutron-build/nucleus:latest",
644+
Env: map[string]string{"NUCLEUS_PASSWORD": "secret:NUCLEUS_PASSWORD"},
645+
})
646+
if err == nil {
647+
t.Fatal("expected error for unset secret")
648+
}
649+
if !strings.Contains(err.Error(), "teploy secret set NUCLEUS_PASSWORD=") {
650+
t.Errorf("error should tell the user how to set the secret, got: %v", err)
651+
}
652+
// Must fail before any container is started.
653+
for _, call := range mock.Calls {
654+
if strings.HasPrefix(call, "docker run") {
655+
t.Error("container must not start when a secret reference is unresolvable")
656+
}
657+
}
658+
}
659+
660+
func TestEnsureRunning_SecretReferenceEmptyKey(t *testing.T) {
661+
mock := ssh.NewMockExecutor("1.2.3.4",
662+
ssh.MockCommand{Match: "cat /deployments/myapp/accessories/nucleus/credentials", Err: fmt.Errorf("not found")},
663+
)
664+
var buf bytes.Buffer
665+
mgr := NewManager(mock, &buf)
666+
_, err := mgr.EnsureRunning(context.Background(), "myapp", "nucleus", config.AccessoryConfig{
667+
Image: "nucleus:latest",
668+
Env: map[string]string{"NUCLEUS_PASSWORD": "secret:"},
669+
})
670+
if err == nil || !strings.Contains(err.Error(), "empty secret reference") {
671+
t.Fatalf("expected empty-reference error, got: %v", err)
672+
}
673+
}

‎internal/config/app.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,10 +427,15 @@ func (c *AppConfig) validate() error {
427427
return fmt.Errorf("volume name %q must be lowercase alphanumeric with hyphens", name)
428428
}
429429
}
430-
for name := range c.Accessories {
430+
for name, acc := range c.Accessories {
431431
if !validName.MatchString(name) {
432432
return fmt.Errorf("accessory name %q must be lowercase alphanumeric with hyphens", name)
433433
}
434+
for k, v := range acc.Env {
435+
if strings.HasPrefix(v, "secret:") && strings.TrimSpace(strings.TrimPrefix(v, "secret:")) == "" {
436+
return fmt.Errorf("accessory %s env %s: empty secret reference (expected secret:KEY)", name, k)
437+
}
438+
}
434439
}
435440
for name := range c.Processes {
436441
if !validName.MatchString(name) {

0 commit comments

Comments
 (0)