0.27.0 — Infisical hardening: runtime refresh, audit logging, CLI verify/audit, startup verification, SSH host key verification, interface consolidation
This commit is contained in:
@@ -424,7 +424,7 @@ func sshExecSimple(ctx context.Context, host, user, command string) (string, err
|
||||
clientCfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
HostKeyCallback: HostKeyCallback(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
129
internal/actuator/hostkeys.go
Normal file
129
internal/actuator/hostkeys.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var (
|
||||
hostKeyMu sync.RWMutex
|
||||
hostKeyCache map[string]ssh.PublicKey
|
||||
hostKeyOnce sync.Once
|
||||
hostKeySrc HostKeySource
|
||||
)
|
||||
|
||||
// HostKeySource provides storage for SSH host public keys.
|
||||
type HostKeySource interface {
|
||||
GetHostKey(ctx context.Context, hostname string) (string, error)
|
||||
SetHostKey(ctx context.Context, hostname string, key string) error
|
||||
}
|
||||
|
||||
// SetHostKeySource sets the host key source. Must be called before
|
||||
// any SSH connections. A nil source enables TOFU-only mode (keys
|
||||
// accepted in memory but not persisted).
|
||||
func SetHostKeySource(src HostKeySource) {
|
||||
hostKeyMu.Lock()
|
||||
defer hostKeyMu.Unlock()
|
||||
hostKeySrc = src
|
||||
}
|
||||
|
||||
// HostKeyCallback returns an ssh.HostKeyCallback that verifies host keys.
|
||||
// Known keys are verified (MITM detection). Unknown keys are accepted
|
||||
// via TOFU and optionally persisted to the source.
|
||||
func HostKeyCallback() ssh.HostKeyCallback {
|
||||
return hostKeyVerify
|
||||
}
|
||||
|
||||
func hostKeyVerify(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
hostKeyOnce.Do(func() {
|
||||
hostKeyCache = make(map[string]ssh.PublicKey)
|
||||
})
|
||||
|
||||
normalized := hostWithoutPort(hostname)
|
||||
|
||||
hostKeyMu.RLock()
|
||||
known, exists := hostKeyCache[normalized]
|
||||
hostKeyMu.RUnlock()
|
||||
|
||||
if exists {
|
||||
if bytes.Equal(key.Marshal(), known.Marshal()) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("SSH HOST KEY CHANGED for %s (possible MITM)", normalized)
|
||||
}
|
||||
|
||||
hostKeyMu.Lock()
|
||||
hostKeyCache[normalized] = key
|
||||
hostKeyMu.Unlock()
|
||||
|
||||
slog.Info("ssh: accepting new host key (TOFU)", "host", normalized)
|
||||
|
||||
if hostKeySrc != nil {
|
||||
go persistHostKey(normalized, key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func persistHostKey(hostname string, key ssh.PublicKey) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
keyBase64 := key.Type() + " " + string(key.Marshal())
|
||||
if err := hostKeySrc.SetHostKey(ctx, "ssh/host-keys/"+hostname, keyBase64); err != nil {
|
||||
slog.Warn("ssh: failed to persist host key", "host", hostname, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadHostKeys pre-loads known host keys from the source into the
|
||||
// in-memory cache. Call at startup to avoid TOFU on first connection.
|
||||
// The source should return key lines in the format "key-type base64-data".
|
||||
func LoadHostKeys(ctx context.Context, hostnames []string, src HostKeySource) {
|
||||
if src == nil {
|
||||
return
|
||||
}
|
||||
SetHostKeySource(src)
|
||||
|
||||
hostKeyMu.Lock()
|
||||
defer hostKeyMu.Unlock()
|
||||
|
||||
if hostKeyCache == nil {
|
||||
hostKeyCache = make(map[string]ssh.PublicKey)
|
||||
}
|
||||
|
||||
loaded := 0
|
||||
for _, hostname := range hostnames {
|
||||
keyData, err := src.GetHostKey(ctx, "ssh/host-keys/"+hostname)
|
||||
if err != nil {
|
||||
slog.Debug("ssh: no stored key for host", "host", hostname, "error", err)
|
||||
continue
|
||||
}
|
||||
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyData))
|
||||
if err != nil {
|
||||
slog.Warn("ssh: invalid stored key for host", "host", hostname, "error", err)
|
||||
continue
|
||||
}
|
||||
hostKeyCache[hostname] = pubKey
|
||||
loaded++
|
||||
}
|
||||
|
||||
if loaded > 0 {
|
||||
slog.Info("ssh: loaded host keys from Infisical", "count", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func hostWithoutPort(hostname string) string {
|
||||
for i := len(hostname) - 1; i >= 0; i-- {
|
||||
if hostname[i] == ':' {
|
||||
return hostname[:i]
|
||||
}
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
56
internal/actuator/hostkeys_infisical.go
Normal file
56
internal/actuator/hostkeys_infisical.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/secrets"
|
||||
)
|
||||
|
||||
// InfisicalHostKeySource implements HostKeySource backed by Infisical.
|
||||
type InfisicalHostKeySource struct {
|
||||
sec secrets.Backend
|
||||
}
|
||||
|
||||
// NewInfisicalHostKeySource creates a HostKeySource that reads/writes
|
||||
// SSH host public keys from Infisical under the `ssh/host-keys/` prefix.
|
||||
func NewInfisicalHostKeySource(sec secrets.Backend) *InfisicalHostKeySource {
|
||||
return &InfisicalHostKeySource{sec: sec}
|
||||
}
|
||||
|
||||
func (s *InfisicalHostKeySource) GetHostKey(ctx context.Context, path string) (string, error) {
|
||||
val, err := s.sec.Get(ctx, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (s *InfisicalHostKeySource) SetHostKey(ctx context.Context, path string, key string) error {
|
||||
return s.sec.Set(ctx, path, key)
|
||||
}
|
||||
|
||||
// ResolveSSHHosts queries the DB for active proxmox-host and standalone-server
|
||||
// entities, returning their slugs as SSH host identifiers.
|
||||
func ResolveSSHHosts(ctx context.Context, pool *db.Pool) []string {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT slug FROM entities
|
||||
WHERE type IN ('proxmox-host', 'standalone-server')
|
||||
AND state = 'active'
|
||||
ORDER BY slug`)
|
||||
if err != nil {
|
||||
slog.Warn("ssh: failed to list hosts", "error", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hosts []string
|
||||
for rows.Next() {
|
||||
var slug string
|
||||
if rows.Scan(&slug) == nil {
|
||||
hosts = append(hosts, slug)
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func ExecuteProcedure(
|
||||
clientCfg := &ssh.ClientConfig{
|
||||
User: cfg.User,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // restricted key; host trust via inventory
|
||||
HostKeyCallback: HostKeyCallback(),
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/config"
|
||||
"github.com/dtoro/oikos/internal/actuator"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
||||
@@ -53,22 +54,12 @@ type actor struct {
|
||||
type Server struct {
|
||||
pool *db.Pool
|
||||
cfg config.Config
|
||||
secretsManager secretsBackend
|
||||
secretsManager secrets.Backend
|
||||
sseBroker *sseBroker
|
||||
sseSubs map[*sseSubscriber]struct{}
|
||||
sseMu sync.Mutex
|
||||
}
|
||||
|
||||
// secretsBackend is a minimal interface for secrets operations used by the
|
||||
// HTTP API (enrollment key storage, listing, retrieval). Compatible with
|
||||
// internal/secrets.Backend. If Infisical is configured, a real backend is
|
||||
// wired in; otherwise the field stays nil and all guarded paths are no-ops.
|
||||
type secretsBackend interface {
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
Set(ctx context.Context, key string, value string) error
|
||||
List(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
|
||||
// SG18) + the OpenAPI surface under /api/v1 behind bearer auth.
|
||||
//
|
||||
@@ -84,7 +75,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
sseSubs: make(map[*sseSubscriber]struct{}),
|
||||
}
|
||||
|
||||
// Wire Infisical backend when configured.
|
||||
// Wire secrets backend: Infisical primary with SOPS DR fallback.
|
||||
if cfg.InfisicalSiteURL != "" {
|
||||
infCfg := secrets.InfisicalConfig{
|
||||
SiteURL: cfg.InfisicalSiteURL,
|
||||
@@ -97,8 +88,24 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
if infCfg.Env == "" {
|
||||
infCfg.Env = "dev"
|
||||
}
|
||||
s.secretsManager = secrets.NewInfisicalBackend(infCfg)
|
||||
slog.Info("secrets backend wired", "backend", "infisical", "site", cfg.InfisicalSiteURL)
|
||||
primary := secrets.NewInfisicalBackend(infCfg)
|
||||
var fallback secrets.Backend
|
||||
if cfg.SecretsDir != "" {
|
||||
fallback = secrets.NewSOPSBackend(cfg.SecretsDir)
|
||||
}
|
||||
s.secretsManager = secrets.NewManager(primary, fallback)
|
||||
slog.Info("secrets backend wired", "backend", "infisical+sops", "site", cfg.InfisicalSiteURL)
|
||||
|
||||
// Start background secret refresh loop.
|
||||
if mgr, ok := s.secretsManager.(*secrets.Manager); ok {
|
||||
mgr.StartRefreshLoop(ctx)
|
||||
}
|
||||
|
||||
// Pre-load SSH host keys from Infisical for host verification.
|
||||
if hosts := actuator.ResolveSSHHosts(ctx, pool); len(hosts) > 0 {
|
||||
hkSrc := actuator.NewInfisicalHostKeySource(s.secretsManager)
|
||||
actuator.LoadHostKeys(ctx, hosts, hkSrc)
|
||||
}
|
||||
}
|
||||
|
||||
// Start background SSE listener, tied to ctx for clean shutdown.
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/secrets"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// mockSecretBackend implements secretBackend for testing.
|
||||
type mockSecretBackend struct {
|
||||
data map[string]string
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type mockSecretBackend struct {
|
||||
func (m *mockSecretBackend) Get(ctx context.Context, key string) (string, error) {
|
||||
v, ok := m.data[key]
|
||||
if !ok {
|
||||
return "", &secretErr{msg: "secret not found: " + key}
|
||||
return "", secrets.ErrNotFound
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -35,12 +35,10 @@ func (m *mockSecretBackend) List(ctx context.Context) ([]string, error) {
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
type secretErr struct{ msg string }
|
||||
|
||||
func (e *secretErr) Error() string { return e.msg }
|
||||
func (m *mockSecretBackend) Name() string { return "mock" }
|
||||
|
||||
// findToolHandler locates a tool's handler from allTools by name.
|
||||
func findToolHandler(t *testing.T, pool interface{}, name string, sec secretBackend) func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
func findToolHandler(t *testing.T, pool interface{}, name string, sec secrets.Backend) func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t.Helper()
|
||||
for _, r := range allTools(nil, uuid.Nil, sec) {
|
||||
if r.tool.Name == name {
|
||||
@@ -51,7 +49,7 @@ func findToolHandler(t *testing.T, pool interface{}, name string, sec secretBack
|
||||
return nil
|
||||
}
|
||||
|
||||
func callToolJSON(t *testing.T, name string, sec secretBackend, args map[string]any) any {
|
||||
func callToolJSON(t *testing.T, name string, sec secrets.Backend, args map[string]any) any {
|
||||
t.Helper()
|
||||
handler := findToolHandler(t, nil, name, sec)
|
||||
argBytes, _ := json.Marshal(args)
|
||||
@@ -74,7 +72,7 @@ func callToolJSON(t *testing.T, name string, sec secretBackend, args map[string]
|
||||
return out
|
||||
}
|
||||
|
||||
func callToolText(t *testing.T, name string, sec secretBackend, args map[string]any) string {
|
||||
func callToolText(t *testing.T, name string, sec secrets.Backend, args map[string]any) string {
|
||||
t.Helper()
|
||||
handler := findToolHandler(t, nil, name, sec)
|
||||
argBytes, _ := json.Marshal(args)
|
||||
|
||||
@@ -19,12 +19,14 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/actuator"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/execlog"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/dtoro/oikos/internal/policy"
|
||||
"github.com/dtoro/oikos/internal/remote"
|
||||
"github.com/dtoro/oikos/internal/secrets"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
@@ -47,18 +49,9 @@ func objSchema(props ...prop) *jsonschema.Schema {
|
||||
return s
|
||||
}
|
||||
|
||||
// secretBackend is the interface MCP tools use to access the secrets store.
|
||||
// Defined here to avoid importing the full secrets package (which brings in
|
||||
// the Infisical SDK). Mirrors the subset of secrets.Backend used by tools.
|
||||
type secretBackend interface {
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
Set(ctx context.Context, key string, value string) error
|
||||
List(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
||||
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec secretBackend) http.Handler {
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec secrets.Backend) http.Handler {
|
||||
s := newServer(pool, agentID, sec)
|
||||
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||
if token != "" {
|
||||
@@ -74,7 +67,7 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec secretBacken
|
||||
// toolHandler is the function signature registered via AddTool.
|
||||
type toolHandler = mcp.ToolHandler
|
||||
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID, sec secretBackend) *mcp.Server {
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID, sec secrets.Backend) *mcp.Server {
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
@@ -499,7 +492,7 @@ func sshExecStream(ctx context.Context, host, user, command string, sink execlog
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
HostKeyCallback: actuator.HostKeyCallback(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/dtoro/oikos/internal/policy"
|
||||
"github.com/dtoro/oikos/internal/secrets"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
@@ -29,7 +30,7 @@ type toolReg struct {
|
||||
// allTools returns every MCP tool registration. Tool definitions, schemas,
|
||||
// descriptions, and handler bodies are kept verbatim from the former inline
|
||||
// newServer registrations.
|
||||
func allTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
|
||||
func allTools(pool *db.Pool, agentID uuid.UUID, sec secrets.Backend) []toolReg {
|
||||
return []toolReg{
|
||||
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
|
||||
InputSchema: objSchema(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
118
internal/secrets/config_overlay.go
Normal file
118
internal/secrets/config_overlay.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user