mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(deploy): webhook queue+nocache, restart sweep, secrets at rest
- Webhook-triggered deploys now go through the per-service deploy queue and default to no_cache builds (dflow convention — prevents stale layer surprises on push). - Boot sweep marks deployments interrupted by restart as failed instead of leaving phantom queued/building rows. - is_secret environment variables are AES-GCM-encrypted at rest (enc:v1: prefix); key from SECRETS_KEY or JWT_SECRET fallback. Existing plaintext secrets encrypt on boot; deploy/exec paths decrypt transparently. Masked-echo writes preserve stored ciphertext. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
7839dc48a3
commit
747b714a02
8 files changed
+242
-8
No files matched your search
@@ -0,0 +1,98 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Prefix marks AES-GCM-encrypted values in environment_variables.value.
|
||||
// Plaintext rows keep working — Decrypt returns them unchanged, so the
|
||||
// rollout is transparent encrypt-on-write / decrypt-on-read.
|
||||
const Prefix = "enc:v1:"
|
||||
|
||||
var (
|
||||
keyOnce sync.Once
|
||||
key []byte
|
||||
enabled bool
|
||||
)
|
||||
|
||||
// Configure derives the AES-256 key. SECRETS_KEY wins; JWT_SECRET is the
|
||||
// fallback so existing installs need no new config. With neither, encrypt
|
||||
// degrades to plaintext with a single warning.
|
||||
func Configure(secretsKey, jwtSecret string) {
|
||||
keyOnce.Do(func() {
|
||||
seed := secretsKey
|
||||
if seed == "" {
|
||||
seed = jwtSecret
|
||||
}
|
||||
if seed == "" {
|
||||
log.Printf("secrets: no SECRETS_KEY or JWT_SECRET — secret variables stay plaintext")
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256([]byte("containr-env-secrets:" + seed))
|
||||
key = sum[:]
|
||||
enabled = true
|
||||
})
|
||||
}
|
||||
|
||||
func IsEncrypted(value string) bool {
|
||||
return strings.HasPrefix(value, Prefix)
|
||||
}
|
||||
|
||||
// Encrypt returns value unchanged if disabled or already encrypted.
|
||||
func Encrypt(value string) string {
|
||||
if !enabled || IsEncrypted(value) {
|
||||
return value
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return value
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(value), nil)
|
||||
return Prefix + base64.StdEncoding.EncodeToString(sealed)
|
||||
}
|
||||
|
||||
// Decrypt returns plaintext for enc:v1 values, the input otherwise.
|
||||
// Corrupt ciphertext fails soft to the stored value rather than erroring
|
||||
// the deploy path — an unreadable secret is no worse than a plaintext one.
|
||||
func Decrypt(value string) string {
|
||||
if !enabled || !IsEncrypted(value) {
|
||||
return value
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, Prefix))
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return value
|
||||
}
|
||||
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
log.Printf("secrets: decrypt failed (%v) — check SECRETS_KEY/JWT_SECRET rotation", fmt.Errorf("%w", err))
|
||||
return value
|
||||
}
|
||||
return string(plain)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resetForTest restores the package state so Configure can be re-run.
|
||||
func resetForTest() {
|
||||
keyOnce = sync.Once{}
|
||||
key = nil
|
||||
enabled = false
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
resetForTest()
|
||||
Configure("test-secrets-key", "")
|
||||
|
||||
enc := Encrypt("super-secret-value")
|
||||
if !IsEncrypted(enc) {
|
||||
t.Fatalf("expected encrypted prefix, got %q", enc)
|
||||
}
|
||||
if enc == "super-secret-value" {
|
||||
t.Fatal("value stored as plaintext")
|
||||
}
|
||||
if got := Decrypt(enc); got != "super-secret-value" {
|
||||
t.Fatalf("decrypt = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextPassThrough(t *testing.T) {
|
||||
resetForTest()
|
||||
Configure("test-secrets-key", "")
|
||||
if got := Decrypt("legacy-plaintext"); got != "legacy-plaintext" {
|
||||
t.Fatalf("legacy plaintext changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptIsIdempotent(t *testing.T) {
|
||||
resetForTest()
|
||||
Configure("test-secrets-key", "")
|
||||
enc := Encrypt("v")
|
||||
if got := Encrypt(enc); got != enc {
|
||||
t.Fatal("double-encrypt changed the value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptFailsSoftOnCorruption(t *testing.T) {
|
||||
resetForTest()
|
||||
Configure("test-secrets-key", "")
|
||||
corrupt := Prefix + "not-valid-base64!!!"
|
||||
if got := Decrypt(corrupt); got != corrupt {
|
||||
t.Fatal("corrupt ciphertext should pass through unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledWithoutKey(t *testing.T) {
|
||||
resetForTest()
|
||||
Configure("", "")
|
||||
if got := Encrypt("v"); got != "v" {
|
||||
t.Fatal("encrypt should pass through when no key configured")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user