Initial commit.
This commit is contained in:
166
internal/secrets/secrets.go
Normal file
166
internal/secrets/secrets.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/storage"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultSecretLength is 32 characters
|
||||
DefaultSecretLength = 32
|
||||
// Alphanumeric characters for secret generation
|
||||
alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
)
|
||||
|
||||
// Manager handles secret generation and storage
|
||||
type Manager struct {
|
||||
yq *tools.YQ
|
||||
}
|
||||
|
||||
// NewManager creates a new secrets manager
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
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
|
||||
}
|
||||
|
||||
// EnsureSecretsFile ensures a secrets file exists with proper structure and permissions
|
||||
func (m *Manager) EnsureSecretsFile(instancePath string) error {
|
||||
secretsPath := filepath.Join(instancePath, "secrets.yaml")
|
||||
|
||||
// Check if secrets file already exists
|
||||
if storage.FileExists(secretsPath) {
|
||||
// Ensure proper permissions
|
||||
if err := storage.EnsureFilePermissions(secretsPath, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create minimal secrets structure
|
||||
initialSecrets := `# Wild Cloud Instance Secrets
|
||||
# WARNING: This file contains sensitive data. Keep secure!
|
||||
cluster:
|
||||
talosSecrets: ""
|
||||
kubeconfig: ""
|
||||
certManager:
|
||||
cloudflare:
|
||||
apiToken: ""
|
||||
`
|
||||
|
||||
// Ensure instance directory exists
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write secrets file with restrictive permissions (0600)
|
||||
if err := storage.WriteFile(secretsPath, []byte(initialSecrets), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSecret retrieves a secret value from a secrets file
|
||||
func (m *Manager) GetSecret(secretsPath, key string) (string, error) {
|
||||
if !storage.FileExists(secretsPath) {
|
||||
return "", fmt.Errorf("secrets file not found: %s", secretsPath)
|
||||
}
|
||||
|
||||
value, err := m.yq.Get(secretsPath, fmt.Sprintf(".%s", key))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting secret %s: %w", key, err)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// SetSecret sets a secret value in a secrets file
|
||||
func (m *Manager) SetSecret(secretsPath, key, value string) error {
|
||||
if !storage.FileExists(secretsPath) {
|
||||
return fmt.Errorf("secrets file not found: %s", secretsPath)
|
||||
}
|
||||
|
||||
// Acquire lock before modifying
|
||||
lockPath := secretsPath + ".lock"
|
||||
return storage.WithLock(lockPath, func() error {
|
||||
// Don't wrap value in quotes - yq handles YAML quoting automatically
|
||||
if err := m.yq.Set(secretsPath, fmt.Sprintf(".%s", key), value); err != nil {
|
||||
return err
|
||||
}
|
||||
// Ensure permissions remain secure after modification
|
||||
return storage.EnsureFilePermissions(secretsPath, 0600)
|
||||
})
|
||||
}
|
||||
|
||||
// EnsureSecret generates and sets a secret only if it doesn't exist (idempotent)
|
||||
func (m *Manager) EnsureSecret(secretsPath, key string, length int) (string, error) {
|
||||
if !storage.FileExists(secretsPath) {
|
||||
return "", fmt.Errorf("secrets file not found: %s", secretsPath)
|
||||
}
|
||||
|
||||
// Check if secret already exists
|
||||
existingSecret, err := m.GetSecret(secretsPath, key)
|
||||
if err == nil && existingSecret != "" && existingSecret != "null" {
|
||||
// Secret already exists, return it
|
||||
return existingSecret, nil
|
||||
}
|
||||
|
||||
// Generate new secret
|
||||
secret, err := GenerateSecret(length)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Set the secret
|
||||
if err := m.SetSecret(secretsPath, key, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// GenerateAndStoreSecret is a convenience function that generates a secret and stores it
|
||||
func (m *Manager) GenerateAndStoreSecret(secretsPath, key string) (string, error) {
|
||||
return m.EnsureSecret(secretsPath, key, DefaultSecretLength)
|
||||
}
|
||||
|
||||
// DeleteSecret removes a secret from a secrets file
|
||||
func (m *Manager) DeleteSecret(secretsPath, key string) error {
|
||||
if !storage.FileExists(secretsPath) {
|
||||
return fmt.Errorf("secrets file not found: %s", secretsPath)
|
||||
}
|
||||
|
||||
// Acquire lock before modifying
|
||||
lockPath := secretsPath + ".lock"
|
||||
return storage.WithLock(lockPath, func() error {
|
||||
if err := m.yq.Delete(secretsPath, fmt.Sprintf(".%s", key)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Ensure permissions remain secure after modification
|
||||
return storage.EnsureFilePermissions(secretsPath, 0600)
|
||||
})
|
||||
}
|
||||
121
internal/secrets/secrets_test.go
Normal file
121
internal/secrets/secrets_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateSecret(t *testing.T) {
|
||||
// Test various lengths
|
||||
lengths := []int{32, 64, 128}
|
||||
for _, length := range lengths {
|
||||
secret, err := GenerateSecret(length)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSecret(%d) failed: %v", length, err)
|
||||
}
|
||||
|
||||
if len(secret) != length {
|
||||
t.Errorf("Expected length %d, got %d", length, len(secret))
|
||||
}
|
||||
|
||||
// Verify only alphanumeric characters
|
||||
for _, c := range secret {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
|
||||
t.Errorf("Non-alphanumeric character found: %c", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test that secrets are different (not deterministic)
|
||||
secret1, _ := GenerateSecret(32)
|
||||
secret2, _ := GenerateSecret(32)
|
||||
if secret1 == secret2 {
|
||||
t.Errorf("Generated secrets should be different")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_EnsureSecretsFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
m := NewManager()
|
||||
|
||||
instancePath := filepath.Join(tmpDir, "test-cloud")
|
||||
err := os.MkdirAll(instancePath, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create instance dir: %v", err)
|
||||
}
|
||||
|
||||
// Ensure secrets
|
||||
err = m.EnsureSecretsFile(instancePath)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureSecretsFile failed: %v", err)
|
||||
}
|
||||
|
||||
secretsPath := filepath.Join(instancePath, "secrets.yaml")
|
||||
|
||||
// Verify file exists
|
||||
info, err := os.Stat(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Secrets file not created: %v", err)
|
||||
}
|
||||
|
||||
// Verify permissions are 0600
|
||||
mode := info.Mode().Perm()
|
||||
if mode != 0600 {
|
||||
t.Errorf("Wrong permissions: got %o, want 0600", mode)
|
||||
}
|
||||
|
||||
// Test idempotency - calling again should not error
|
||||
err = m.EnsureSecretsFile(instancePath)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureSecretsFile not idempotent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_SetAndGetSecret(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
m := NewManager()
|
||||
|
||||
instancePath := filepath.Join(tmpDir, "test-cloud")
|
||||
err := os.MkdirAll(instancePath, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create instance dir: %v", err)
|
||||
}
|
||||
|
||||
secretsPath := filepath.Join(instancePath, "secrets.yaml")
|
||||
|
||||
// Initialize secrets
|
||||
err = m.EnsureSecretsFile(instancePath)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureSecretsFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Set a custom secret (requires yq)
|
||||
err = m.SetSecret(secretsPath, "customSecret", "myvalue123")
|
||||
if err != nil {
|
||||
t.Skipf("SetSecret requires yq: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the secret back
|
||||
value, err := m.GetSecret(secretsPath, "customSecret")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret failed: %v", err)
|
||||
}
|
||||
|
||||
if value != "myvalue123" {
|
||||
t.Errorf("Secret not retrieved correctly: got %q, want %q", value, "myvalue123")
|
||||
}
|
||||
|
||||
// Verify permissions still 0600
|
||||
info, _ := os.Stat(secretsPath)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("Permissions changed after SetSecret")
|
||||
}
|
||||
|
||||
// Get non-existent secret should error
|
||||
_, err = m.GetSecret(secretsPath, "nonExistent")
|
||||
if err == nil {
|
||||
t.Fatalf("GetSecret should fail for non-existent secret")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user