0.27.0 — Infisical hardening: runtime refresh, audit logging, CLI verify/audit, startup verification, SSH host key verification, interface consolidation
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

This commit is contained in:
2026-08-05 23:51:01 +02:00
parent e3449b24c1
commit c9d506b0f8
19 changed files with 1081 additions and 75 deletions

View File

@@ -1,10 +1,9 @@
// Package secrets abstracts secret retrieval across backends (SOPS, Infisical).
// Phase 5: SOPS → Infisical migration with SOPS DR fallback.
package secrets
import (
"context"
"errors"
"log/slog"
"sync"
"time"
)
@@ -14,24 +13,23 @@ var ErrBackendUnavailable = errors.New("secret backend unavailable")
// Backend is the interface for retrieving and storing secrets.
type Backend interface {
// Get retrieves a secret value by path/key.
Get(ctx context.Context, key string) (string, error)
// List returns all secret keys available in this backend.
List(ctx context.Context) ([]string, error)
// Set stores a secret value. Used during migration.
Set(ctx context.Context, key string, value string) error
// Name returns a human-readable backend identifier.
Name() string
}
// Manager holds a primary and fallback backend. If the primary fails,
// it falls back to the secondary.
// it falls back to the secondary. Supports periodic background refresh
// of cached secrets from the primary backend.
type Manager struct {
primary Backend
fallback Backend
cache map[string]cachedSecret
mu sync.RWMutex
cacheTTL time.Duration
primary Backend
fallback Backend
cache map[string]cachedSecret
mu sync.RWMutex
cacheTTL time.Duration
refreshMu sync.Mutex
lastRefresh time.Time
}
type cachedSecret struct {
@@ -39,21 +37,97 @@ type cachedSecret struct {
expiresAt time.Time
}
// ManagerRefreshInterval controls how often cached secrets are re-fetched
// from the primary backend in the background. Zero disables background refresh.
var ManagerRefreshInterval = 5 * time.Minute
// NewManager creates a secret manager with primary and fallback backends.
func NewManager(primary, fallback Backend) *Manager {
return &Manager{
primary: primary,
fallback: fallback,
cache: make(map[string]cachedSecret),
cacheTTL: 5 * time.Minute,
primary: primary,
fallback: fallback,
cache: make(map[string]cachedSecret),
cacheTTL: 5 * time.Minute,
lastRefresh: time.Now(),
}
}
// StartRefreshLoop starts a background goroutine that periodically refreshes
// cached secrets from the primary backend. Call from the server's main
// goroutine. The loop runs until ctx is cancelled.
func (m *Manager) StartRefreshLoop(ctx context.Context) {
if ManagerRefreshInterval <= 0 {
return
}
slog.Info("secrets: background refresh loop started",
"interval", ManagerRefreshInterval)
ticker := time.NewTicker(ManagerRefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("secrets: refresh loop stopped")
return
case <-ticker.C:
m.refreshAll(ctx)
}
}
}
// refreshAll re-fetches all cached secrets from the primary backend.
// Keys not found in the primary are left cached (they may be in fallback).
// Logs a summary line.
func (m *Manager) refreshAll(ctx context.Context) {
m.refreshMu.Lock()
defer m.refreshMu.Unlock()
if m.primary == nil {
return
}
m.mu.RLock()
keys := make([]string, 0, len(m.cache))
for k := range m.cache {
keys = append(keys, k)
}
m.mu.RUnlock()
if len(keys) == 0 {
return
}
refreshed, stale, failed := 0, 0, 0
for _, key := range keys {
val, err := m.primary.Get(ctx, key)
if err != nil {
failed++
continue
}
m.mu.RLock()
cached, ok := m.cache[key]
m.mu.RUnlock()
if !ok || val != cached.value {
m.mu.Lock()
m.cache[key] = cachedSecret{value: val, expiresAt: time.Now().Add(m.cacheTTL)}
m.mu.Unlock()
refreshed++
} else {
stale++
}
}
m.lastRefresh = time.Now()
slog.Info("secrets: background refresh complete",
"refreshed", refreshed, "stale", stale, "failed", failed,
"cached", len(keys))
}
// Get retrieves a secret from primary, falling back to secondary on error.
func (m *Manager) Get(ctx context.Context, key string) (string, error) {
m.mu.RLock()
if cached, ok := m.cache[key]; ok && time.Now().Before(cached.expiresAt) {
m.mu.RUnlock()
slog.Debug("secret: cache hit", "key", key)
return cached.value, nil
}
m.mu.RUnlock()
@@ -63,16 +137,20 @@ func (m *Manager) Get(ctx context.Context, key string) (string, error) {
m.mu.Lock()
m.cache[key] = cachedSecret{value: val, expiresAt: time.Now().Add(m.cacheTTL)}
m.mu.Unlock()
slog.Debug("secret: fetched from primary", "key", key)
return val, nil
}
if m.fallback != nil {
val, fallbackErr := m.fallback.Get(ctx, key)
if fallbackErr == nil {
slog.Warn("secret: primary failed, using fallback",
"key", key, "primary_error", err)
return val, nil
}
}
slog.Warn("secret: not found in any backend", "key", key, "error", err)
return "", err
}
@@ -85,11 +163,17 @@ func (m *Manager) List(ctx context.Context) ([]string, error) {
return keys, err
}
// Set stores a secret in the primary backend (used during migration).
// Set stores a secret in the primary backend and invalidates the cache.
func (m *Manager) Set(ctx context.Context, key string, value string) error {
m.InvalidateCache()
return m.primary.Set(ctx, key, value)
}
// Name returns the primary backend name.
func (m *Manager) Name() string {
return m.primary.Name()
}
// PrimaryName returns the name of the primary backend.
func (m *Manager) PrimaryName() string {
return m.primary.Name()
@@ -101,3 +185,14 @@ func (m *Manager) InvalidateCache() {
m.cache = make(map[string]cachedSecret)
m.mu.Unlock()
}
// LastRefresh returns the timestamp of the last background refresh.
func (m *Manager) LastRefresh() time.Time {
return m.lastRefresh
}
// secretOverlay maps an Infisical key to a config setter function.
type secretOverlay struct {
infisicalKey string
apply func(value string)
}

View File

@@ -0,0 +1,118 @@
package secrets
import (
"context"
"log/slog"
)
// NewManagerFromConfig creates a secrets Manager from the Infisical connection
// parameters in cfg, with an optional SOPS fallback from cfg.SecretsDir.
// Returns nil if Infisical is not configured.
func NewManagerFromConfig(siteURL, clientID, clientSecret, projectID, env, secretsDir string) *Manager {
if siteURL == "" {
return nil
}
infCfg := InfisicalConfig{
SiteURL: siteURL,
ClientID: clientID,
ClientSecret: clientSecret,
ProjectID: projectID,
SecretPath: "/",
Env: env,
}
if infCfg.Env == "" {
infCfg.Env = "dev"
}
primary := NewInfisicalBackend(infCfg)
var fallback Backend
if secretsDir != "" {
fallback = NewSOPSBackend(secretsDir)
}
return NewManager(primary, fallback)
}
// VerifyExpectedSecrets checks that a list of expected keys are present
// in the backend. Logs a summary and returns the count of missing keys.
// Use at startup to detect incomplete Infisical migration.
func VerifyExpectedSecrets(ctx context.Context, sec Backend, expected []string) int {
keys, err := sec.List(ctx)
if err != nil {
slog.Warn("secrets: cannot verify expected secrets, list failed", "error", err)
return len(expected)
}
keySet := make(map[string]struct{}, len(keys))
for _, k := range keys {
keySet[k] = struct{}{}
}
missing := 0
for _, exp := range expected {
if _, ok := keySet[exp]; !ok {
missing++
slog.Warn("secrets: expected key missing from Infisical", "key", exp)
}
}
if missing == 0 {
slog.Info("secrets: all expected keys present", "count", len(expected))
} else {
slog.Warn("secrets: some expected keys missing from Infisical",
"missing", missing, "total", len(expected))
}
return missing
}
// OverlayConfig fetches secrets from the backend and returns a function that
// applies them to config fields. Each entry maps an Infisical key to a setter;
// if the key is found and non-empty, the setter is called; if not found or
// empty, the env-derived value is left unchanged and a warning is logged.
// Returns the number of secrets resolved from Infisical (useful for logging).
func OverlayConfig(ctx context.Context, sec Backend, overlays []secretOverlay) int {
resolved := 0
for _, o := range overlays {
val, err := sec.Get(ctx, o.infisicalKey)
if err != nil {
slog.Warn("secret not resolved from Infisical, using env fallback",
"key", o.infisicalKey, "error", err)
continue
}
if val == "" {
slog.Warn("Infisical returned empty value, keeping env-derived value",
"key", o.infisicalKey)
continue
}
o.apply(val)
resolved++
}
return resolved
}
// ConfigOverlays returns the standard set of Infisical → config overlays for
// the oikos binary. Each overlay is attempted at startup; if the key exists
// in Infisical, it overrides the env-derived value.
func ConfigOverlays(cfg map[string]func(string)) []secretOverlay {
overlays := make([]secretOverlay, 0, len(cfg))
for key, setter := range cfg {
overlays = append(overlays, secretOverlay{infisicalKey: key, apply: setter})
}
return overlays
}
// ResolveSecret attempts to fetch a single secret from the backend. Returns
// the Infisical value if found and non-empty, otherwise falls back to the
// env-derived value. Warnings are logged for failures.
func ResolveSecret(ctx context.Context, sec Backend, infisicalKey, fallback string) string {
if sec == nil {
return fallback
}
val, err := sec.Get(ctx, infisicalKey)
if err != nil {
slog.Warn("secret not resolved from Infisical, using env fallback",
"key", infisicalKey, "error", err)
return fallback
}
if val == "" {
slog.Warn("Infisical returned empty value, keeping env-derived value",
"key", infisicalKey)
return fallback
}
return val
}

View File

@@ -3,7 +3,6 @@ package secrets
import (
"context"
"fmt"
"os"
"strings"
"sync"
@@ -54,14 +53,8 @@ func (b *InfisicalBackend) connect() error {
clientID := b.cfg.ClientID
clientSecret := b.cfg.ClientSecret
if clientID == "" {
clientID = os.Getenv("INFISICAL_CLIENT_ID")
}
if clientSecret == "" {
clientSecret = os.Getenv("INFISICAL_CLIENT_SECRET")
}
if clientID == "" || clientSecret == "" {
return fmt.Errorf("%w: INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET not set", ErrBackendUnavailable)
return fmt.Errorf("%w: OIKOS_INFISICAL_CLIENT_ID and OIKOS_INFISICAL_CLIENT_SECRET not set", ErrBackendUnavailable)
}
_, err := b.client.Auth().UniversalAuthLogin(clientID, clientSecret)

View File

@@ -3,6 +3,7 @@ package secrets
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
@@ -45,11 +46,13 @@ func (s *SOPSBackend) load() error {
cmd := exec.Command("sops", "-d", path)
out, err := cmd.Output()
if err != nil {
slog.Warn("sops: decryption failed", "file", entry.Name(), "error", err)
continue // skip unreadable files
}
var data map[string]any
if err := yaml.Unmarshal(out, &data); err != nil {
slog.Warn("sops: invalid yaml after decryption", "file", entry.Name(), "error", err)
continue
}