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

View File

@@ -0,0 +1,191 @@
package nftables
import (
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
)
const defaultRulesPath = "/etc/nftables.d/wild-cloud.nft"
// Manager handles nftables rule generation and application
type Manager struct {
rulesPath string
}
// NewManager creates a new nftables manager
func NewManager(rulesPath string) *Manager {
if rulesPath == "" {
rulesPath = defaultRulesPath
}
return &Manager{rulesPath: rulesPath}
}
// GetRulesPath returns the nftables rules file path
func (m *Manager) GetRulesPath() string {
return m.rulesPath
}
// Generate creates nftables rules that track HAProxy listening ports plus any extra ports.
// Policy is always "accept" — the allowed_tcp_ports set documents what is in use
// and enables strict WAN filtering when wanInterface is configured.
// extraTCPPorts and extraUDPPorts are user-specified ports to allow beyond the HAProxy set.
func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPorts []int, wanInterface string) string {
// Merge and deduplicate TCP ports (HAProxy + extra TCP)
seen := make(map[int]bool)
var tcpPorts []int
for _, p := range haproxyPorts {
if !seen[p] {
seen[p] = true
tcpPorts = append(tcpPorts, p)
}
}
for _, p := range extraTCPPorts {
if !seen[p] {
seen[p] = true
tcpPorts = append(tcpPorts, p)
}
}
// Build UDP exception list: DNS + DHCP hardcoded, plus user extras
udpBase := []int{53, 67, 68}
seenUDP := make(map[int]bool)
for _, p := range udpBase {
seenUDP[p] = true
}
udpPorts := append([]int{}, udpBase...)
for _, p := range extraUDPPorts {
if !seenUDP[p] {
seenUDP[p] = true
udpPorts = append(udpPorts, p)
}
}
var sb strings.Builder
sb.WriteString("# Wild Cloud nftables rules\n")
sb.WriteString("# Managed by Wild Cloud Central API — do not edit manually\n\n")
// Delete and recreate the table so re-applying never accumulates stale set elements.
// The first line ensures the table exists (so delete never errors on first run).
sb.WriteString("table inet wild-cloud {}\n")
sb.WriteString("delete table inet wild-cloud\n\n")
sb.WriteString("table inet wild-cloud {\n")
sb.WriteString(" # TCP ports allowed through the firewall (HAProxy + extra TCP)\n")
sb.WriteString(" set allowed_tcp_ports {\n")
sb.WriteString(" type inet_service\n")
sb.WriteString(" flags interval\n")
if len(tcpPorts) > 0 {
fmt.Fprintf(&sb, " elements = { %s }\n", portsToStr(tcpPorts))
}
sb.WriteString(" }\n\n")
sb.WriteString(" # UDP ports allowed through the firewall (DNS/DHCP + extra UDP)\n")
sb.WriteString(" set allowed_udp_ports {\n")
sb.WriteString(" type inet_service\n")
sb.WriteString(" flags interval\n")
if len(udpPorts) > 0 {
fmt.Fprintf(&sb, " elements = { %s }\n", portsToStr(udpPorts))
}
sb.WriteString(" }\n\n")
sb.WriteString(" chain input {\n")
sb.WriteString(" type filter hook input priority filter; policy accept;\n\n")
sb.WriteString(" iif \"lo\" accept\n")
sb.WriteString(" ct state { established, related } accept\n")
if wanInterface != "" {
sb.WriteString("\n # Strict WAN filtering: only allow listed TCP and UDP ports\n")
fmt.Fprintf(&sb, " iif \"%s\" tcp dport != @allowed_tcp_ports drop\n", wanInterface)
fmt.Fprintf(&sb, " iif \"%s\" udp dport != @allowed_udp_ports drop\n", wanInterface)
}
sb.WriteString(" }\n")
sb.WriteString("}\n")
return sb.String()
}
// portsToStr converts a slice of ports to a comma-separated string
func portsToStr(ports []int) string {
strs := make([]string, len(ports))
for i, p := range ports {
strs[i] = fmt.Sprintf("%d", p)
}
return strings.Join(strs, ", ")
}
// ValidateRules tests an nftables rules file for syntax errors.
// Uses sudo to allow the wildcloud user to run the read-only check.
func (m *Manager) ValidateRules(rulesPath string) error {
cmd := exec.Command("sudo", "nft", "-c", "-f", rulesPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("nftables validation failed: %w (output: %s)", err, string(output))
}
return nil
}
// WriteRules validates the content then writes the nftables rules file.
// Validation uses a temp file in /tmp (world-writable) to avoid needing
// write permission on the /etc/nftables.d/ directory itself.
func (m *Manager) WriteRules(content string) error {
tempFile := "/tmp/wild-cloud-nft-validate.tmp"
if err := os.WriteFile(tempFile, []byte(content), 0644); err != nil {
return fmt.Errorf("writing temp rules: %w", err)
}
defer os.Remove(tempFile)
if err := m.ValidateRules(tempFile); err != nil {
return err
}
if err := os.WriteFile(m.rulesPath, []byte(content), 0644); err != nil {
return fmt.Errorf("writing rules file: %w", err)
}
slog.Info("nftables rules written", "component", "nftables", "path", m.rulesPath)
return nil
}
// ApplyRules loads the rules file into the kernel via a systemd oneshot service.
// The wildcloud user has polkit permission to start wild-cloud-nftables-reload.service.
func (m *Manager) ApplyRules() error {
cmd := exec.Command("systemctl", "start", "wild-cloud-nftables-reload.service")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("applying nftables rules: %w (output: %s)", err, string(output))
}
slog.Info("nftables rules applied", "component", "nftables")
return nil
}
// WriteDisabledRules writes a rules file that flushes the wild-cloud table,
// removing all Wild Cloud firewall rules from the kernel when applied.
func (m *Manager) WriteDisabledRules() error {
content := "# Wild Cloud nftables rules — firewall disabled\n" +
"# Managed by Wild Cloud Central API — do not edit manually\n\n" +
"table inet wild-cloud {}\n" +
"delete table inet wild-cloud\n"
if err := os.WriteFile(m.rulesPath, []byte(content), 0644); err != nil {
return fmt.Errorf("writing disabled rules file: %w", err)
}
slog.Info("nftables rules disabled", "component", "nftables", "path", m.rulesPath)
return nil
}
// GetStatus returns the current wild-cloud nftables table as a string.
// Uses sudo to allow the wildcloud user to read kernel state.
func (m *Manager) GetStatus() (string, error) {
cmd := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud")
output, err := cmd.CombinedOutput()
if err != nil {
// Table may not exist yet — not an error
return "", nil
}
return string(output), nil
}

View File

@@ -0,0 +1,158 @@
package nftables
import (
"strings"
"testing"
)
func TestNewManager_DefaultPath(t *testing.T) {
m := NewManager("")
if m.GetRulesPath() != defaultRulesPath {
t.Errorf("got %q, want %q", m.GetRulesPath(), defaultRulesPath)
}
}
func TestNewManager_CustomPath(t *testing.T) {
m := NewManager("/tmp/wild-cloud.nft")
if m.GetRulesPath() != "/tmp/wild-cloud.nft" {
t.Errorf("got %q, want /tmp/wild-cloud.nft", m.GetRulesPath())
}
}
func TestPortsToStr(t *testing.T) {
cases := []struct {
ports []int
want string
}{
{[]int{80, 443, 8404}, "80, 443, 8404"},
{[]int{443}, "443"},
{[]int{}, ""},
}
for _, c := range cases {
if got := portsToStr(c.ports); got != c.want {
t.Errorf("portsToStr(%v) = %q, want %q", c.ports, got, c.want)
}
}
}
func TestGenerate_AlwaysIncludesStructure(t *testing.T) {
m := NewManager("")
out := m.Generate([]int{80, 443}, nil, nil, "")
for _, want := range []string{
"table inet wild-cloud",
"set allowed_tcp_ports",
"chain input",
"type filter hook input priority filter; policy accept;",
"iif \"lo\" accept",
"ct state { established, related } accept",
} {
if !strings.Contains(out, want) {
t.Errorf("expected %q in output, got:\n%s", want, out)
}
}
}
func TestGenerate_WithPorts_IncludesElements(t *testing.T) {
m := NewManager("")
out := m.Generate([]int{80, 443, 8404}, nil, nil, "")
if !strings.Contains(out, "elements = { 80, 443, 8404 }") {
t.Errorf("expected elements line with ports, got:\n%s", out)
}
}
func TestGenerate_NoPorts_TCPSetHasNoElements(t *testing.T) {
m := NewManager("")
out := m.Generate([]int{}, nil, nil, "")
// TCP set should have no elements when no ports given
if strings.Contains(out, "allowed_tcp_ports") && strings.Contains(out, "elements") {
// Check that the elements line is not inside the TCP block
tcpBlock := out[strings.Index(out, "set allowed_tcp_ports"):]
tcpBlock = tcpBlock[:strings.Index(tcpBlock, "}")+1]
if strings.Contains(tcpBlock, "elements") {
t.Errorf("expected no elements in allowed_tcp_ports block when no ports, got:\n%s", tcpBlock)
}
}
// UDP set always has DNS/DHCP elements
if !strings.Contains(out, "53, 67, 68") {
t.Errorf("expected DNS/DHCP elements in allowed_udp_ports even with no TCP ports, got:\n%s", out)
}
}
func TestGenerate_ExtraPortsMerged(t *testing.T) {
m := NewManager("")
out := m.Generate([]int{80, 443}, []int{22, 443}, nil, "") // 443 deduplicated
if !strings.Contains(out, "22") {
t.Errorf("expected extra port 22 in output, got:\n%s", out)
}
// 443 should appear only once
count := strings.Count(out, "443")
if count != 1 {
t.Errorf("expected 443 exactly once, got %d times in:\n%s", count, out)
}
}
func TestGenerate_NoWANInterface_NoDropRules(t *testing.T) {
m := NewManager("")
out := m.Generate([]int{80, 443}, nil, nil, "")
if strings.Contains(out, "drop") {
t.Errorf("expected no drop rules without WAN interface, got:\n%s", out)
}
}
func TestGenerate_WithWANInterface_IncludesDropRules(t *testing.T) {
m := NewManager("")
out := m.Generate([]int{80, 443}, nil, nil, "eth0")
if !strings.Contains(out, `iif "eth0"`) {
t.Errorf("expected WAN interface in rules, got:\n%s", out)
}
if !strings.Contains(out, "drop") {
t.Errorf("expected drop rule with WAN interface, got:\n%s", out)
}
if !strings.Contains(out, "@allowed_tcp_ports") {
t.Errorf("expected reference to allowed_tcp_ports set, got:\n%s", out)
}
if !strings.Contains(out, "@allowed_udp_ports") {
t.Errorf("expected reference to allowed_udp_ports set, got:\n%s", out)
}
// DNS and DHCP UDP ports should be in the allowed_udp_ports set
if !strings.Contains(out, "53, 67, 68") {
t.Errorf("expected DNS/DHCP UDP ports in allowed_udp_ports set, got:\n%s", out)
}
}
func TestGenerate_AlwaysIncludesUDPSet(t *testing.T) {
m := NewManager("")
// No WAN interface — permissive mode, but UDP set should still be visible
out := m.Generate([]int{80, 443}, nil, []int{51820}, "")
if !strings.Contains(out, "set allowed_udp_ports") {
t.Errorf("expected allowed_udp_ports set even without WAN interface, got:\n%s", out)
}
if !strings.Contains(out, "51820") {
t.Errorf("expected extra UDP port 51820 in allowed_udp_ports set, got:\n%s", out)
}
// No drop rules without WAN interface
if strings.Contains(out, "drop") {
t.Errorf("expected no drop rules without WAN interface, got:\n%s", out)
}
}
func TestGenerate_ExtraUDPPorts(t *testing.T) {
m := NewManager("")
out := m.Generate([]int{80, 443}, nil, []int{5353}, "eth0")
// User UDP port should appear in the UDP exception list
if !strings.Contains(out, "5353") {
t.Errorf("expected extra UDP port 5353 in output, got:\n%s", out)
}
// Hardcoded DNS/DHCP should still be there
if !strings.Contains(out, "53, 67, 68") {
t.Errorf("expected DNS/DHCP UDP ports still present, got:\n%s", out)
}
}