Files
wild-central/internal/certbot/manager.go
Paul Payne 60f3ca4a3a Security hardening: input validation, secrets protection, NATS auth
Config injection prevention:
- Add FQDN validation for domain names (RFC 1123) in Register/Update —
  rejects newlines, spaces, shell metacharacters that could inject into
  HAProxy/dnsmasq configs
- Add backend address validation (valid host:port format, valid IP or
  hostname, port 1-65535). DNS-only backends allow bare IPs.
- Add header key/value validation — keys must be HTTP token chars only,
  values must not contain newlines or NULs
- Add WireGuard peer name validation (alphanumeric + hyphens + underscores)
- Add defense-in-depth domain validation in certbot Provision()

Secrets protection:
- Remove ?raw=true bypass on GET /api/v1/secrets — secrets are now always
  redacted in API responses regardless of query parameters
- Update test to verify redaction cannot be bypassed

NATS authentication:
- Generate random auth token on first startup, store in secrets.yaml
- Pass token to embedded NATS server via Authorization option
- Internal client connects with the same token
- External NATS clients (Wild Cloud) must now authenticate

Security headers:
- Add X-Content-Type-Options: nosniff
- Add X-Frame-Options: DENY
- Add Cache-Control: no-store
2026-07-14 12:38:17 +00:00

204 lines
6.6 KiB
Go

package certbot
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// CertStatus represents the current state of a TLS certificate.
type CertStatus struct {
Exists bool `json:"exists"`
Domain string `json:"domain"`
Wildcard bool `json:"wildcard,omitempty"`
Expiry time.Time `json:"expiry,omitempty"`
CertPath string `json:"certPath,omitempty"`
KeyPath string `json:"keyPath,omitempty"`
DaysLeft int `json:"daysLeft,omitempty"`
IssuerCN string `json:"issuerCN,omitempty"`
}
// Manager handles TLS certificate provisioning via certbot.
type Manager struct {
credsPath string // path to cloudflare credentials ini file
}
// NewManager creates a new certbot manager.
// credsPath is the path to the Cloudflare API credentials file used for DNS-01 challenges.
func NewManager(credsPath string) *Manager {
if credsPath == "" {
credsPath = "/etc/letsencrypt/cloudflare.ini"
}
return &Manager{credsPath: credsPath}
}
// EnsureCredentials writes the Cloudflare API token to the credentials file
// for certbot's DNS-01 challenge. Safe to call repeatedly.
func (m *Manager) EnsureCredentials(apiToken string) error {
if apiToken == "" {
return fmt.Errorf("cloudflare API token is empty")
}
dir := filepath.Dir(m.credsPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("create credentials dir: %w", err)
}
content := fmt.Sprintf("dns_cloudflare_api_token = %s\n", apiToken)
if err := os.WriteFile(m.credsPath, []byte(content), 0600); err != nil {
return fmt.Errorf("write credentials: %w", err)
}
return nil
}
// Provision requests a new TLS certificate for the given domain using DNS-01 via Cloudflare.
// A deploy hook builds the combined PEM for HAProxy and reloads HAProxy automatically.
func (m *Manager) Provision(domain, email string) error {
if domain == "" {
return fmt.Errorf("domain is required")
}
if email == "" {
return fmt.Errorf("email is required")
}
// Defense-in-depth: reject domains with shell metacharacters.
// Domain validation at the API boundary should already prevent this.
for _, c := range domain {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '*') {
return fmt.Errorf("invalid domain for cert provisioning: %q", domain)
}
}
if _, err := os.Stat(m.credsPath); err != nil {
return fmt.Errorf("cloudflare credentials not found at %s; configure the Cloudflare API token first", m.credsPath)
}
// Deploy hook runs as root (certbot runs via sudo), so it can read the cert files.
// It builds the combined PEM for HAProxy and reloads the service.
haproxyPEM := HAProxyCertPath(domain)
deployHook := fmt.Sprintf(
"mkdir -p %s && cat %s %s > %s && systemctl reload haproxy 2>/dev/null || true",
filepath.Dir(haproxyPEM), CertPath(domain), KeyPath(domain), haproxyPEM,
)
cmd := exec.Command("sudo", "certbot", "certonly",
"--dns-cloudflare",
"--dns-cloudflare-credentials", m.credsPath,
"-d", domain,
"--non-interactive",
"--agree-tos",
"-m", email,
"--deploy-hook", deployHook,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot: %w\n%s", err, string(out))
}
// Verify the deploy hook created a valid PEM file
info, statErr := os.Stat(haproxyPEM)
if statErr != nil || info.Size() == 0 {
return fmt.Errorf("cert provisioned but HAProxy PEM is empty or missing: %s", haproxyPEM)
}
return nil
}
// Renew renews all certificates managed by certbot.
func (m *Manager) Renew() error {
cmd := exec.Command("sudo", "certbot", "renew", "--non-interactive")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot renew: %w\n%s", err, string(out))
}
return nil
}
// GetStatus returns the TLS certificate status for a domain.
// Reads the HAProxy combined PEM (which we own) rather than certbot's root-owned files.
func (m *Manager) GetStatus(domain string) *CertStatus {
status := &CertStatus{Domain: domain}
pemPath := HAProxyCertPath(domain)
if _, err := os.Stat(pemPath); err != nil {
return status
}
status.Exists = true
status.CertPath = pemPath
status.KeyPath = pemPath
// Parse certificate metadata
cmd := exec.Command("openssl", "x509", "-in", pemPath, "-noout", "-enddate", "-issuer", "-subject")
out, err := cmd.Output()
if err != nil {
return status
}
parseCertOutput(string(out), status)
return status
}
// parseCertOutput parses the output of `openssl x509 -noout -enddate -issuer -subject`
// and populates the given CertStatus.
func parseCertOutput(output string, status *CertStatus) {
for line := range strings.SplitSeq(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "notAfter=") {
dateStr := strings.TrimPrefix(line, "notAfter=")
if t, err := time.Parse("Jan 2 15:04:05 2006 GMT", dateStr); err == nil {
status.Expiry = t
status.DaysLeft = int(time.Until(t).Hours() / 24)
} else if t, err := time.Parse("Jan 2 15:04:05 2006 GMT", dateStr); err == nil {
status.Expiry = t
status.DaysLeft = int(time.Until(t).Hours() / 24)
}
}
if strings.HasPrefix(line, "issuer=") {
status.IssuerCN = strings.TrimPrefix(line, "issuer=")
}
if strings.HasPrefix(line, "subject=") {
if strings.Contains(line, "CN=*.") || strings.Contains(line, "CN = *.") {
status.Wildcard = true
}
}
}
}
// CertPath returns the fullchain.pem path for a domain.
func CertPath(domain string) string {
return fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", domain)
}
// KeyPath returns the privkey.pem path for a domain.
func KeyPath(domain string) string {
return fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", domain)
}
// HAProxyCertPath returns the combined PEM path for HAProxy TLS termination.
func HAProxyCertPath(domain string) string {
return fmt.Sprintf("/etc/haproxy/certs/%s.pem", domain)
}
// BuildHAProxyCert concatenates fullchain.pem + privkey.pem into a single PEM
// file that HAProxy can use for TLS termination.
// Uses sudo to read certbot's root-owned files.
func (m *Manager) BuildHAProxyCert(domain string) error {
certData, err := exec.Command("sudo", "cat", CertPath(domain)).Output()
if err != nil {
return fmt.Errorf("read fullchain: %w", err)
}
keyData, err := exec.Command("sudo", "cat", KeyPath(domain)).Output()
if err != nil {
return fmt.Errorf("read privkey: %w", err)
}
combined := append(certData, keyData...)
if len(combined) == 0 {
return fmt.Errorf("combined cert+key is empty for %s", domain)
}
outPath := HAProxyCertPath(domain)
if err := os.MkdirAll(filepath.Dir(outPath), 0700); err != nil {
return fmt.Errorf("create haproxy certs dir: %w", err)
}
return os.WriteFile(outPath, combined, 0600)
}