Files
wild-central/internal/secrets/secrets.go
Paul Payne 68d6fde80d Show routes, TLS certs, and port-forwarding on services page
- Add Routes model to services UI (paths, headers, IP whitelisting per route)
- Show TLS cert info per service with inline provision/renew actions
- Remove TLS Certificates section from dashboard (now on services page)
- Make gateway router port list dynamic from config + VPN state
- Add TODO for header validation in HAProxy config generation
2026-07-10 06:10:40 +00:00

144 lines
3.6 KiB
Go

package secrets
import (
"crypto/rand"
"errors"
"fmt"
"math/big"
"os"
"github.com/wild-cloud/wild-central/internal/storage"
"github.com/wild-cloud/wild-central/internal/tools"
"gopkg.in/yaml.v3"
)
const (
// DefaultSecretLength is 32 characters
DefaultSecretLength = 32
// Alphanumeric characters for secret generation
alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
)
// Manager handles secret storage backed by a single YAML file.
type Manager struct {
secretsPath string
yq *tools.YQ
}
// NewManager creates a new secrets manager bound to the given file path.
func NewManager(secretsPath string) *Manager {
return &Manager{
secretsPath: secretsPath,
yq: tools.NewYQ(),
}
}
// GenerateSecret generates a cryptographically secure random alphanumeric string.
func GenerateSecret(length int) (string, error) {
if length <= 0 {
length = DefaultSecretLength
}
result := make([]byte, length)
for i := range result {
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphanumeric))))
if err != nil {
return "", fmt.Errorf("generating random number: %w", err)
}
result[i] = alphanumeric[num.Int64()]
}
return string(result), nil
}
// GetSecret retrieves a secret value by dot-notation key.
func (m *Manager) GetSecret(key string) (string, error) {
if !storage.FileExists(m.secretsPath) {
return "", fmt.Errorf("secrets file not found: %s", m.secretsPath)
}
value, err := m.yq.Get(m.secretsPath, fmt.Sprintf(".%s", key))
if err != nil {
return "", fmt.Errorf("getting secret %s: %w", key, err)
}
if value == "" || value == "null" {
return "", fmt.Errorf("secret not found: %s", key)
}
return value, nil
}
// SetSecret sets a secret value by dot-notation key.
func (m *Manager) SetSecret(key, value string) error {
if !storage.FileExists(m.secretsPath) {
return fmt.Errorf("secrets file not found: %s", m.secretsPath)
}
lockPath := m.secretsPath + ".lock"
return storage.WithLock(lockPath, func() error {
if err := m.yq.Set(m.secretsPath, fmt.Sprintf(".%s", key), value); err != nil {
return err
}
return storage.EnsureFilePermissions(m.secretsPath, 0600)
})
}
// DeleteSecret removes a secret by dot-notation key.
func (m *Manager) DeleteSecret(key string) error {
if !storage.FileExists(m.secretsPath) {
return fmt.Errorf("secrets file not found: %s", m.secretsPath)
}
lockPath := m.secretsPath + ".lock"
return storage.WithLock(lockPath, func() error {
if err := m.yq.Delete(m.secretsPath, fmt.Sprintf(".%s", key)); err != nil {
return err
}
return storage.EnsureFilePermissions(m.secretsPath, 0600)
})
}
// GetAll reads and returns the full secrets map.
func (m *Manager) GetAll() (map[string]any, error) {
data, err := os.ReadFile(m.secretsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return map[string]any{}, nil
}
return nil, fmt.Errorf("reading secrets: %w", err)
}
var secretsMap map[string]any
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
return nil, fmt.Errorf("parsing secrets: %w", err)
}
if secretsMap == nil {
secretsMap = map[string]any{}
}
return secretsMap, nil
}
// MergeUpdate merges the provided key-value pairs into the existing secrets file.
func (m *Manager) MergeUpdate(updates map[string]any) error {
lockPath := m.secretsPath + ".lock"
return storage.WithLock(lockPath, func() error {
existing, err := m.GetAll()
if err != nil {
return err
}
for key, value := range updates {
existing[key] = value
}
yamlContent, err := yaml.Marshal(existing)
if err != nil {
return fmt.Errorf("marshaling secrets: %w", err)
}
return storage.WriteFile(m.secretsPath, yamlContent, 0600)
})
}