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
This commit is contained in:
2026-07-10 06:10:40 +00:00
parent e78a1d548a
commit 68d6fde80d
9 changed files with 570 additions and 1072 deletions

View File

@@ -1,82 +1,17 @@
package secrets
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
"math/big"
"os/exec"
"path/filepath"
"strings"
"os"
"github.com/wild-cloud/wild-central/internal/storage"
"github.com/wild-cloud/wild-central/internal/tools"
"gopkg.in/yaml.v3"
)
// 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
@@ -84,19 +19,21 @@ const (
alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
)
// Manager handles secret generation and storage
// Manager handles secret storage backed by a single YAML file.
type Manager struct {
yq *YQ
secretsPath string
yq *tools.YQ
}
// NewManager creates a new secrets manager
func NewManager() *Manager {
// NewManager creates a new secrets manager bound to the given file path.
func NewManager(secretsPath string) *Manager {
return &Manager{
yq: NewYQ(),
secretsPath: secretsPath,
yq: tools.NewYQ(),
}
}
// GenerateSecret generates a cryptographically secure random alphanumeric string
// GenerateSecret generates a cryptographically secure random alphanumeric string.
func GenerateSecret(length int) (string, error) {
if length <= 0 {
length = DefaultSecretLength
@@ -114,54 +51,17 @@ func GenerateSecret(length int) (string, error) {
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
// 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)
}
// 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))
value, err := m.yq.Get(m.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)
}
@@ -169,69 +69,75 @@ func (m *Manager) GetSecret(secretsPath, key string) (string, error) {
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)
// 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)
}
// Acquire lock before modifying
lockPath := secretsPath + ".lock"
lockPath := m.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 {
if err := m.yq.Set(m.secretsPath, fmt.Sprintf(".%s", key), value); err != nil {
return err
}
// Ensure permissions remain secure after modification
return storage.EnsureFilePermissions(secretsPath, 0600)
return storage.EnsureFilePermissions(m.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)
// 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)
}
// 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
}
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)
})
}
// Generate new secret
secret, err := GenerateSecret(length)
// 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 {
return "", err
if errors.Is(err, os.ErrNotExist) {
return map[string]any{}, nil
}
return nil, fmt.Errorf("reading secrets: %w", err)
}
// Set the secret
if err := m.SetSecret(secretsPath, key, secret); err != nil {
return "", 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 secret, nil
return secretsMap, 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"
// 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 {
if err := m.yq.Delete(secretsPath, fmt.Sprintf(".%s", key)); err != nil {
existing, err := m.GetAll()
if err != nil {
return err
}
// Ensure permissions remain secure after modification
return storage.EnsureFilePermissions(secretsPath, 0600)
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)
})
}

File diff suppressed because it is too large Load Diff