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:
2026-07-08 23:31:16 +00:00
commit beb643f76f
52 changed files with 12814 additions and 0 deletions

174
internal/certbot/manager.go Normal file
View File

@@ -0,0 +1,174 @@
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"`
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")
}
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))
}
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 expiry from the certificate
cmd := exec.Command("openssl", "x509", "-in", pemPath, "-noout", "-enddate", "-issuer")
out, err := cmd.Output()
if err != nil {
return status
}
for line := range strings.SplitSeq(string(out), "\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=")
}
}
return status
}
// 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...)
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)
}