feat: Extract Wild Central as standalone Go service
Extract the Central networking functionality from wild-cloud/api into a standalone service. Wild Central manages DNS (dnsmasq), gateway (HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot), security (CrowdSec), and DDNS — all the network appliance concerns. All Cloud-specific code (instances, clusters, nodes, apps, backups, operations, kubectl/talosctl tooling) has been removed. The API struct and route registration contain only Central endpoints. Tests updated to match the new API signature. Builds and all tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
237
internal/secrets/secrets.go
Normal file
237
internal/secrets/secrets.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
)
|
||||
|
||||
// YQ provides a wrapper around the yq command-line tool
|
||||
type YQ struct {
|
||||
yqPath string
|
||||
}
|
||||
|
||||
// NewYQ creates a new YQ wrapper
|
||||
func NewYQ() *YQ {
|
||||
path, err := exec.LookPath("yq")
|
||||
if err != nil {
|
||||
path = "yq"
|
||||
}
|
||||
return &YQ{yqPath: path}
|
||||
}
|
||||
|
||||
// Get retrieves a value from a YAML file using a yq expression
|
||||
func (y *YQ) Get(filePath, expression string) (string, error) {
|
||||
cmd := exec.Command(y.yqPath, expression, filePath)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("yq get failed: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
return strings.TrimSpace(stdout.String()), nil
|
||||
}
|
||||
|
||||
// Set sets a value in a YAML file using a yq expression
|
||||
func (y *YQ) Set(filePath, expression, value string) error {
|
||||
if !strings.HasPrefix(expression, ".") {
|
||||
expression = "." + expression
|
||||
}
|
||||
quotedValue := fmt.Sprintf(`"%s"`, strings.ReplaceAll(value, `"`, `\"`))
|
||||
setExpr := fmt.Sprintf("%s = %s", expression, quotedValue)
|
||||
cmd := exec.Command(y.yqPath, "-i", setExpr, filePath)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("yq set failed: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a key from a YAML file
|
||||
func (y *YQ) Delete(filePath, expression string) error {
|
||||
delExpr := fmt.Sprintf("del(%s)", expression)
|
||||
cmd := exec.Command(y.yqPath, "-i", delExpr, filePath)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("yq delete failed: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if a YAML file is valid
|
||||
func (y *YQ) Validate(filePath string) error {
|
||||
cmd := exec.Command(y.yqPath, "eval", ".", filePath)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("yaml validation failed: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
// DefaultSecretLength is 32 characters
|
||||
DefaultSecretLength = 32
|
||||
// Alphanumeric characters for secret generation
|
||||
alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
)
|
||||
|
||||
// Manager handles secret generation and storage
|
||||
type Manager struct {
|
||||
yq *YQ
|
||||
}
|
||||
|
||||
// NewManager creates a new secrets manager
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
yq: 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(dataDir, instanceName string) error {
|
||||
secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "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: ""
|
||||
cloudflare:
|
||||
token: ""
|
||||
`
|
||||
|
||||
// Ensure config directory exists
|
||||
if err := storage.EnsureDir(filepath.Join(dataDir, "instances", instanceName, "config"), 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)
|
||||
}
|
||||
|
||||
// yq returns "null" for non-existent keys
|
||||
if value == "" || value == "null" {
|
||||
return "", fmt.Errorf("secret not found: %s", key)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
973
internal/secrets/secrets_test.go
Normal file
973
internal/secrets/secrets_test.go
Normal file
@@ -0,0 +1,973 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
)
|
||||
|
||||
// Test: GenerateSecret generates valid secrets
|
||||
func TestGenerateSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
length int
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "default length",
|
||||
length: DefaultSecretLength,
|
||||
want: DefaultSecretLength,
|
||||
},
|
||||
{
|
||||
name: "custom length 64",
|
||||
length: 64,
|
||||
want: 64,
|
||||
},
|
||||
{
|
||||
name: "custom length 128",
|
||||
length: 128,
|
||||
want: 128,
|
||||
},
|
||||
{
|
||||
name: "zero length defaults to DefaultSecretLength",
|
||||
length: 0,
|
||||
want: DefaultSecretLength,
|
||||
},
|
||||
{
|
||||
name: "negative length defaults to DefaultSecretLength",
|
||||
length: -1,
|
||||
want: DefaultSecretLength,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
secret, err := GenerateSecret(tt.length)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSecret failed: %v", err)
|
||||
}
|
||||
|
||||
if len(secret) != tt.want {
|
||||
t.Errorf("got length %d, want %d", len(secret), tt.want)
|
||||
}
|
||||
|
||||
// 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: GenerateSecret produces unique values
|
||||
func TestGenerateSecret_Uniqueness(t *testing.T) {
|
||||
const numSecrets = 100
|
||||
secrets := make(map[string]bool, numSecrets)
|
||||
|
||||
for i := 0; i < numSecrets; i++ {
|
||||
secret, err := GenerateSecret(32)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSecret failed: %v", err)
|
||||
}
|
||||
|
||||
if secrets[secret] {
|
||||
t.Errorf("duplicate secret generated: %s", secret)
|
||||
}
|
||||
secrets[secret] = true
|
||||
}
|
||||
|
||||
if len(secrets) != numSecrets {
|
||||
t.Errorf("expected %d unique secrets, got %d", numSecrets, len(secrets))
|
||||
}
|
||||
}
|
||||
|
||||
// Test: NewManager creates manager successfully
|
||||
func TestNewManager(t *testing.T) {
|
||||
m := NewManager()
|
||||
if m == nil || m.yq == nil {
|
||||
t.Fatal("NewManager returned nil or Manager.yq is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: EnsureSecretsFile creates secrets file with proper structure and permissions
|
||||
func TestEnsureSecretsFile(t *testing.T) {
|
||||
const instanceName = "test-instance"
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, dataDir string)
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "creates secrets file when not exists",
|
||||
setupFunc: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "returns nil when secrets file exists",
|
||||
setupFunc: func(t *testing.T, dataDir string) {
|
||||
secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(secretsPath), 0755); err != nil {
|
||||
t.Fatalf("setup mkdir failed: %v", err)
|
||||
}
|
||||
content := `# Wild Cloud Instance Secrets
|
||||
cluster:
|
||||
talosSecrets: ""
|
||||
kubeconfig: ""
|
||||
certManager:
|
||||
cloudflare:
|
||||
apiToken: ""
|
||||
`
|
||||
if err := storage.WriteFile(secretsPath, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "corrects permissions on existing file",
|
||||
setupFunc: func(t *testing.T, dataDir string) {
|
||||
secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(secretsPath), 0755); err != nil {
|
||||
t.Fatalf("setup mkdir failed: %v", err)
|
||||
}
|
||||
content := `# Wild Cloud Instance Secrets
|
||||
cluster:
|
||||
talosSecrets: "existing-secret"
|
||||
kubeconfig: ""
|
||||
certManager:
|
||||
cloudflare:
|
||||
apiToken: ""
|
||||
`
|
||||
// Create with wrong permissions
|
||||
if err := storage.WriteFile(secretsPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
m := NewManager()
|
||||
|
||||
if tt.setupFunc != nil {
|
||||
tt.setupFunc(t, dataDir)
|
||||
}
|
||||
|
||||
err := m.EnsureSecretsFile(dataDir, instanceName)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify secrets file exists
|
||||
secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml")
|
||||
if !storage.FileExists(secretsPath) {
|
||||
t.Error("secrets file not created")
|
||||
}
|
||||
|
||||
// Verify permissions are 0600 (secure)
|
||||
info, err := os.Stat(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat secrets file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("expected permissions 0600, got %o", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// Verify file has expected structure
|
||||
content, err := storage.ReadFile(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read secrets: %v", err)
|
||||
}
|
||||
contentStr := string(content)
|
||||
requiredFields := []string{"cluster:", "cloudflare:"}
|
||||
for _, field := range requiredFields {
|
||||
if !strings.Contains(contentStr, field) {
|
||||
t.Errorf("secrets missing required field: %s", field)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GetSecret retrieves secrets correctly
|
||||
func TestGetSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secretsYAML string
|
||||
key string
|
||||
want string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "get simple string value",
|
||||
secretsYAML: `cluster:
|
||||
talosSecrets: "my-secret-value"
|
||||
`,
|
||||
key: "cluster.talosSecrets",
|
||||
want: "my-secret-value",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "get nested value with dot notation",
|
||||
secretsYAML: `certManager:
|
||||
cloudflare:
|
||||
apiToken: "cf-token-12345"
|
||||
`,
|
||||
key: "certManager.cloudflare.apiToken",
|
||||
want: "cf-token-12345",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "get non-existent key returns error",
|
||||
secretsYAML: `cluster:
|
||||
talosSecrets: "value"
|
||||
`,
|
||||
key: "nonexistent",
|
||||
wantErr: true,
|
||||
errContains: "secret not found",
|
||||
},
|
||||
{
|
||||
name: "get empty string value returns error",
|
||||
secretsYAML: `cluster:
|
||||
talosSecrets: ""
|
||||
`,
|
||||
key: "cluster.talosSecrets",
|
||||
wantErr: true,
|
||||
errContains: "secret not found",
|
||||
},
|
||||
{
|
||||
name: "get null value returns error",
|
||||
secretsYAML: `cluster:
|
||||
talosSecrets: null
|
||||
`,
|
||||
key: "cluster.talosSecrets",
|
||||
wantErr: true,
|
||||
errContains: "secret not found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
secretsPath := filepath.Join(tempDir, "secrets.yaml")
|
||||
|
||||
if err := storage.WriteFile(secretsPath, []byte(tt.secretsYAML), 0600); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager()
|
||||
got, err := m.GetSecret(secretsPath, tt.key)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GetSecret error cases
|
||||
func TestGetSecret_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) string
|
||||
key string
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "non-existent file",
|
||||
setupFunc: func(t *testing.T) string {
|
||||
return filepath.Join(t.TempDir(), "nonexistent.yaml")
|
||||
},
|
||||
key: "cluster.talosSecrets",
|
||||
errContains: "secrets file not found",
|
||||
},
|
||||
{
|
||||
name: "malformed yaml",
|
||||
setupFunc: func(t *testing.T) string {
|
||||
tempDir := t.TempDir()
|
||||
secretsPath := filepath.Join(tempDir, "secrets.yaml")
|
||||
content := `invalid: yaml: [[[`
|
||||
if err := storage.WriteFile(secretsPath, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
return secretsPath
|
||||
},
|
||||
key: "cluster.talosSecrets",
|
||||
errContains: "getting secret",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
secretsPath := tt.setupFunc(t)
|
||||
m := NewManager()
|
||||
|
||||
_, err := m.GetSecret(secretsPath, tt.key)
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
} else if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GetSecret does not leak secrets in error messages
|
||||
func TestGetSecret_NoSecretLeakage(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
secretsPath := filepath.Join(tempDir, "secrets.yaml")
|
||||
|
||||
secretValue := "super-secret-password-12345"
|
||||
secretsYAML := `cluster:
|
||||
talosSecrets: "` + secretValue + `"
|
||||
`
|
||||
if err := storage.WriteFile(secretsPath, []byte(secretsYAML), 0600); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager()
|
||||
|
||||
// Try to get a non-existent key - error should not contain actual secret values
|
||||
_, err := m.GetSecret(secretsPath, "nonexistent.key")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
// Error message should not contain the secret value
|
||||
if strings.Contains(err.Error(), secretValue) {
|
||||
t.Errorf("error message leaked secret value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: SetSecret sets secrets correctly
|
||||
func TestSetSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initialYAML string
|
||||
key string
|
||||
value string
|
||||
verifyFunc func(t *testing.T, secretsPath string)
|
||||
}{
|
||||
{
|
||||
name: "set simple value",
|
||||
initialYAML: `cluster:
|
||||
talosSecrets: ""
|
||||
`,
|
||||
key: "cluster.talosSecrets",
|
||||
value: "new-secret-value",
|
||||
verifyFunc: func(t *testing.T, secretsPath string) {
|
||||
m := NewManager()
|
||||
got, err := m.GetSecret(secretsPath, "cluster.talosSecrets")
|
||||
if err != nil {
|
||||
t.Fatalf("verify failed: %v", err)
|
||||
}
|
||||
if got != "new-secret-value" {
|
||||
t.Errorf("got %q, want %q", got, "new-secret-value")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set nested value",
|
||||
initialYAML: `certManager:
|
||||
cloudflare:
|
||||
apiToken: ""
|
||||
`,
|
||||
key: "certManager.cloudflare.apiToken",
|
||||
value: "cf-token-xyz",
|
||||
verifyFunc: func(t *testing.T, secretsPath string) {
|
||||
m := NewManager()
|
||||
got, err := m.GetSecret(secretsPath, "certManager.cloudflare.apiToken")
|
||||
if err != nil {
|
||||
t.Fatalf("verify failed: %v", err)
|
||||
}
|
||||
if got != "cf-token-xyz" {
|
||||
t.Errorf("got %q, want %q", got, "cf-token-xyz")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update existing value",
|
||||
initialYAML: `cluster:
|
||||
talosSecrets: "old-secret"
|
||||
`,
|
||||
key: "cluster.talosSecrets",
|
||||
value: "new-secret",
|
||||
verifyFunc: func(t *testing.T, secretsPath string) {
|
||||
m := NewManager()
|
||||
got, err := m.GetSecret(secretsPath, "cluster.talosSecrets")
|
||||
if err != nil {
|
||||
t.Fatalf("verify failed: %v", err)
|
||||
}
|
||||
if got != "new-secret" {
|
||||
t.Errorf("got %q, want %q", got, "new-secret")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create new nested path",
|
||||
initialYAML: `cluster: {}
|
||||
`,
|
||||
key: "cluster.newSecret",
|
||||
value: "newValue",
|
||||
verifyFunc: func(t *testing.T, secretsPath string) {
|
||||
m := NewManager()
|
||||
got, err := m.GetSecret(secretsPath, "cluster.newSecret")
|
||||
if err != nil {
|
||||
t.Fatalf("verify failed: %v", err)
|
||||
}
|
||||
if got != "newValue" {
|
||||
t.Errorf("got %q, want %q", got, "newValue")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set value with special characters",
|
||||
initialYAML: `cluster:
|
||||
talosSecrets: ""
|
||||
`,
|
||||
key: "cluster.talosSecrets",
|
||||
value: `special"quotes'and\backslashes`,
|
||||
verifyFunc: func(t *testing.T, secretsPath string) {
|
||||
m := NewManager()
|
||||
got, err := m.GetSecret(secretsPath, "cluster.talosSecrets")
|
||||
if err != nil {
|
||||
t.Fatalf("verify failed: %v", err)
|
||||
}
|
||||
if got != `special"quotes'and\backslashes` {
|
||||
t.Errorf("got %q, want %q", got, `special"quotes'and\backslashes`)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
secretsPath := filepath.Join(tempDir, "secrets.yaml")
|
||||
|
||||
if err := storage.WriteFile(secretsPath, []byte(tt.initialYAML), 0600); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager()
|
||||
if err := m.SetSecret(secretsPath, tt.key, tt.value); err != nil {
|
||||
t.Errorf("SetSecret failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the value was set correctly
|
||||
tt.verifyFunc(t, secretsPath)
|
||||
|
||||
// Verify permissions remain secure (0600)
|
||||
info, err := os.Stat(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat secrets file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("permissions changed after SetSecret: got %o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test: SetSecret error cases
|
||||
func TestSetSecret_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) string
|
||||
key string
|
||||
value string
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "non-existent file",
|
||||
setupFunc: func(t *testing.T) string {
|
||||
return filepath.Join(t.TempDir(), "nonexistent.yaml")
|
||||
},
|
||||
key: "cluster.talosSecrets",
|
||||
value: "secret",
|
||||
errContains: "secrets file not found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
secretsPath := tt.setupFunc(t)
|
||||
m := NewManager()
|
||||
|
||||
err := m.SetSecret(secretsPath, tt.key, tt.value)
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
} else if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test: SetSecret with concurrent access
|
||||
func TestSetSecret_ConcurrentAccess(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
secretsPath := filepath.Join(tempDir, "secrets.yaml")
|
||||
|
||||
initialYAML := `counter: "0"
|
||||
`
|
||||
if err := storage.WriteFile(secretsPath, []byte(initialYAML), 0600); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager()
|
||||
const numGoroutines = 10
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, numGoroutines)
|
||||
|
||||
// Launch multiple goroutines trying to write different values
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(val int) {
|
||||
defer wg.Done()
|
||||
key := "counter"
|
||||
value := string(rune('0' + val))
|
||||
if err := m.SetSecret(secretsPath, key, value); err != nil {
|
||||
errors <- err
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
// Check if any errors occurred
|
||||
for err := range errors {
|
||||
t.Errorf("concurrent write error: %v", err)
|
||||
}
|
||||
|
||||
// Verify permissions remain secure after concurrent access
|
||||
info, err := os.Stat(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat secrets file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("permissions changed after concurrent writes: got %o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// Verify we can read a value (should be one of the written values)
|
||||
value, err := m.GetSecret(secretsPath, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("failed to read value after concurrent writes: %v", err)
|
||||
}
|
||||
if value == "" || value == "null" {
|
||||
t.Error("counter value is empty after concurrent writes")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: EnsureSecret generates and sets secret only when not set
|
||||
func TestEnsureSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initialYAML string
|
||||
key string
|
||||
length int
|
||||
expectNew bool
|
||||
}{
|
||||
{
|
||||
name: "generates secret when empty string",
|
||||
initialYAML: `cluster:
|
||||
talosSecrets: ""
|
||||
`,
|
||||
key: "cluster.talosSecrets",
|
||||
length: 32,
|
||||
expectNew: true,
|
||||
},
|
||||
{
|
||||
name: "generates secret when null",
|
||||
initialYAML: `cluster:
|
||||
talosSecrets: null
|
||||
`,
|
||||
key: "cluster.talosSecrets",
|
||||
length: 32,
|
||||
expectNew: true,
|
||||
},
|
||||
{
|
||||
name: "does not generate when secret exists",
|
||||
initialYAML: `cluster:
|
||||
talosSecrets: "existing-secret"
|
||||
`,
|
||||
key: "cluster.talosSecrets",
|
||||
length: 32,
|
||||
expectNew: false,
|
||||
},
|
||||
{
|
||||
name: "generates secret for non-existent key",
|
||||
initialYAML: `cluster: {}
|
||||
`,
|
||||
key: "cluster.newSecret",
|
||||
length: 64,
|
||||
expectNew: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
secretsPath := filepath.Join(tempDir, "secrets.yaml")
|
||||
|
||||
if err := storage.WriteFile(secretsPath, []byte(tt.initialYAML), 0600); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager()
|
||||
|
||||
// Get initial value if exists
|
||||
initialVal, _ := m.GetSecret(secretsPath, tt.key)
|
||||
|
||||
// Call EnsureSecret
|
||||
secret, err := m.EnsureSecret(secretsPath, tt.key, tt.length)
|
||||
if err != nil {
|
||||
t.Errorf("EnsureSecret failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify secret is returned
|
||||
if secret == "" {
|
||||
t.Error("EnsureSecret returned empty secret")
|
||||
}
|
||||
|
||||
// Verify length
|
||||
if tt.expectNew && len(secret) != tt.length {
|
||||
t.Errorf("expected secret length %d, got %d", tt.length, len(secret))
|
||||
}
|
||||
|
||||
// Get final value
|
||||
finalVal, err := m.GetSecret(secretsPath, tt.key)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret failed: %v", err)
|
||||
}
|
||||
|
||||
if tt.expectNew {
|
||||
// Should have generated new secret
|
||||
if initialVal != "" && finalVal == initialVal {
|
||||
t.Errorf("expected new secret, got same value: %q", finalVal)
|
||||
}
|
||||
} else {
|
||||
// Should have kept existing secret
|
||||
if finalVal != initialVal {
|
||||
t.Errorf("expected to keep existing secret %q, got %q", initialVal, finalVal)
|
||||
}
|
||||
}
|
||||
|
||||
// Call EnsureSecret again - should be idempotent
|
||||
secret2, err := m.EnsureSecret(secretsPath, tt.key, tt.length)
|
||||
if err != nil {
|
||||
t.Errorf("second EnsureSecret failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Secret should not change on second call
|
||||
if secret2 != secret {
|
||||
t.Errorf("secret changed on second ensure: %q -> %q", secret, secret2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GenerateAndStoreSecret convenience function
|
||||
func TestGenerateAndStoreSecret(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
secretsPath := filepath.Join(tempDir, "secrets.yaml")
|
||||
|
||||
initialYAML := `cluster:
|
||||
talosSecrets: ""
|
||||
`
|
||||
if err := storage.WriteFile(secretsPath, []byte(initialYAML), 0600); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager()
|
||||
|
||||
// Generate and store secret
|
||||
secret, err := m.GenerateAndStoreSecret(secretsPath, "cluster.talosSecrets")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAndStoreSecret failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify secret length matches default
|
||||
if len(secret) != DefaultSecretLength {
|
||||
t.Errorf("expected length %d, got %d", DefaultSecretLength, len(secret))
|
||||
}
|
||||
|
||||
// Verify secret is stored
|
||||
stored, err := m.GetSecret(secretsPath, "cluster.talosSecrets")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret failed: %v", err)
|
||||
}
|
||||
|
||||
if stored != secret {
|
||||
t.Errorf("stored secret %q does not match returned secret %q", stored, secret)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: DeleteSecret removes secrets correctly
|
||||
func TestDeleteSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initialYAML string
|
||||
key string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "delete existing secret",
|
||||
initialYAML: `cluster:
|
||||
talosSecrets: "secret-to-delete"
|
||||
kubeconfig: "other-secret"
|
||||
`,
|
||||
key: "cluster.talosSecrets",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "delete nested secret",
|
||||
initialYAML: `certManager:
|
||||
cloudflare:
|
||||
apiToken: "token-to-delete"
|
||||
`,
|
||||
key: "certManager.cloudflare.apiToken",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
secretsPath := filepath.Join(tempDir, "secrets.yaml")
|
||||
|
||||
if err := storage.WriteFile(secretsPath, []byte(tt.initialYAML), 0600); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager()
|
||||
|
||||
// Verify secret exists before deletion
|
||||
_, err := m.GetSecret(secretsPath, tt.key)
|
||||
if err != nil {
|
||||
t.Fatalf("secret should exist before deletion: %v", err)
|
||||
}
|
||||
|
||||
// Delete secret
|
||||
err = m.DeleteSecret(secretsPath, tt.key)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify secret no longer exists
|
||||
_, err = m.GetSecret(secretsPath, tt.key)
|
||||
if err == nil {
|
||||
t.Error("secret should not exist after deletion")
|
||||
}
|
||||
|
||||
// Verify permissions remain secure (0600)
|
||||
info, err := os.Stat(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat secrets file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("permissions changed after DeleteSecret: got %o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test: DeleteSecret error cases
|
||||
func TestDeleteSecret_Errors(t *testing.T) {
|
||||
t.Run("non-existent file", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
secretsPath := filepath.Join(tempDir, "nonexistent.yaml")
|
||||
|
||||
m := NewManager()
|
||||
err := m.DeleteSecret(secretsPath, "cluster.talosSecrets")
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
} else if !strings.Contains(err.Error(), "secrets file not found") {
|
||||
t.Errorf("error %q does not contain 'secrets file not found'", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test: File permissions are always 0600
|
||||
func TestEnsureSecretsFile_FilePermissions(t *testing.T) {
|
||||
const instanceName = "test-instance"
|
||||
tempDir := t.TempDir()
|
||||
m := NewManager()
|
||||
|
||||
if err := m.EnsureSecretsFile(tempDir, instanceName); err != nil {
|
||||
t.Fatalf("EnsureSecretsFile failed: %v", err)
|
||||
}
|
||||
|
||||
secretsPath := filepath.Join(tempDir, "instances", instanceName, "config", "secrets.yaml")
|
||||
info, err := os.Stat(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat secrets file: %v", err)
|
||||
}
|
||||
|
||||
// Verify file has 0600 permissions (read/write for owner only)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("expected permissions 0600, got %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Idempotent secrets creation
|
||||
func TestEnsureSecretsFile_Idempotent(t *testing.T) {
|
||||
const instanceName = "test-instance"
|
||||
tempDir := t.TempDir()
|
||||
m := NewManager()
|
||||
|
||||
// First call creates secrets
|
||||
if err := m.EnsureSecretsFile(tempDir, instanceName); err != nil {
|
||||
t.Fatalf("first EnsureSecretsFile failed: %v", err)
|
||||
}
|
||||
|
||||
secretsPath := filepath.Join(tempDir, "instances", instanceName, "config", "secrets.yaml")
|
||||
firstContent, err := storage.ReadFile(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read secrets: %v", err)
|
||||
}
|
||||
|
||||
// Second call should not modify secrets
|
||||
if err := m.EnsureSecretsFile(tempDir, instanceName); err != nil {
|
||||
t.Fatalf("second EnsureSecretsFile failed: %v", err)
|
||||
}
|
||||
|
||||
secondContent, err := storage.ReadFile(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read secrets: %v", err)
|
||||
}
|
||||
|
||||
if string(firstContent) != string(secondContent) {
|
||||
t.Error("secrets content changed on second call")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Secrets structure contains required fields
|
||||
func TestEnsureSecretsFile_RequiredFields(t *testing.T) {
|
||||
const instanceName = "test-instance"
|
||||
tempDir := t.TempDir()
|
||||
m := NewManager()
|
||||
|
||||
if err := m.EnsureSecretsFile(tempDir, instanceName); err != nil {
|
||||
t.Fatalf("EnsureSecretsFile failed: %v", err)
|
||||
}
|
||||
|
||||
secretsPath := filepath.Join(tempDir, "instances", instanceName, "config", "secrets.yaml")
|
||||
content, err := storage.ReadFile(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read secrets: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
requiredFields := []string{
|
||||
"cluster:",
|
||||
"talosSecrets:",
|
||||
"kubeconfig:",
|
||||
"cloudflare:",
|
||||
"token:",
|
||||
}
|
||||
|
||||
for _, field := range requiredFields {
|
||||
if !strings.Contains(contentStr, field) {
|
||||
t.Errorf("secrets missing required field: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify warning comment exists
|
||||
if !strings.Contains(contentStr, "WARNING") || !strings.Contains(contentStr, "sensitive") {
|
||||
t.Error("secrets file missing security warning comment")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Secrets are more restrictive than config
|
||||
func TestSecretsPermissions_MoreRestrictiveThanConfig(t *testing.T) {
|
||||
const instanceName = "test-instance"
|
||||
tempDir := t.TempDir()
|
||||
secretsPath := filepath.Join(tempDir, "instances", instanceName, "config", "secrets.yaml")
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
|
||||
// Create secrets file
|
||||
m := NewManager()
|
||||
if err := m.EnsureSecretsFile(tempDir, instanceName); err != nil {
|
||||
t.Fatalf("EnsureSecretsFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Create config file (typically 0644)
|
||||
configContent := `baseDomain: "example.com"`
|
||||
if err := storage.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
secretsInfo, err := os.Stat(secretsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat secrets: %v", err)
|
||||
}
|
||||
|
||||
configInfo, err := os.Stat(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat config: %v", err)
|
||||
}
|
||||
|
||||
secretsPerm := secretsInfo.Mode().Perm()
|
||||
configPerm := configInfo.Mode().Perm()
|
||||
|
||||
// Secrets (0600) should be more restrictive than config (0644)
|
||||
if secretsPerm >= configPerm {
|
||||
t.Errorf("secrets permissions %o should be more restrictive than config %o", secretsPerm, configPerm)
|
||||
}
|
||||
|
||||
// Secrets should not be group or world readable
|
||||
if secretsPerm&0077 != 0 {
|
||||
t.Errorf("secrets file should not have group/world permissions, got %o", secretsPerm)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user