Compare commits
5 Commits
d5457ae2dc
...
d35d0854cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35d0854cb | ||
|
|
1176c7715a | ||
|
|
17748ce09c | ||
|
|
b45c0d6a81 | ||
|
|
c87125d003 |
22
TODO.md
22
TODO.md
@@ -1,22 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
## Response headers don't work with host ACL conditions
|
|
||||||
|
|
||||||
`http-response set-header ... if host_X` doesn't work because `hdr(host)` is a request-phase fetch that HAProxy can't evaluate in the response phase. The headers are generated but silently ignored (HAProxy logs a WARNING).
|
|
||||||
|
|
||||||
Fix: capture the host in a transaction variable during the request phase, then use it in response rules:
|
|
||||||
```haproxy
|
|
||||||
http-request set-var(txn.host) hdr(host)
|
|
||||||
acl is_keila var(txn.host) -i keila.cloud.payne.io
|
|
||||||
http-response set-header Access-Control-Allow-Origin "https://cloud.payne.io" if is_keila
|
|
||||||
```
|
|
||||||
|
|
||||||
Affects: keila (CORS headers), plausible (Private-Network-Access header).
|
|
||||||
|
|
||||||
## Input validation for header values
|
|
||||||
|
|
||||||
Header values with special characters (newlines, NULs) should be rejected at registration time. Currently values are Go-`%q`-quoted in the HAProxy output which handles spaces/commas but may produce backslash escapes that HAProxy doesn't understand for edge cases.
|
|
||||||
|
|
||||||
## APT package: Authelia bare-metal installation
|
|
||||||
|
|
||||||
When building the wild-central apt package in `dist/`, include Authelia as a dependency or document its installation. Bare-metal installation instructions: https://www.authelia.com/integration/deployment/bare-metal/
|
|
||||||
@@ -50,6 +50,20 @@ func (api *API) vpnAutoUDPPorts() []int {
|
|||||||
return []int{cfg.ListenPort}
|
return []int{cfg.ListenPort}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vpnCIDR returns the VPN subnet CIDR (e.g. "10.8.0.0/24") when VPN is enabled
|
||||||
|
// with LAN routing configured. Used to generate nftables masquerade rules.
|
||||||
|
func (api *API) vpnCIDR() string {
|
||||||
|
cfg, err := api.vpn.GetConfig()
|
||||||
|
if err != nil || !cfg.Enabled || cfg.LanCIDR == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
_, vpnNet, err := net.ParseCIDR(cfg.Address)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return vpnNet.String()
|
||||||
|
}
|
||||||
|
|
||||||
// syncNftablesOnly regenerates and applies nftables rules from the current global config
|
// syncNftablesOnly regenerates and applies nftables rules from the current global config
|
||||||
// without touching HAProxy. Called when firewall settings change independently.
|
// without touching HAProxy. Called when firewall settings change independently.
|
||||||
// Runs errors are logged only — the caller does not wait for completion.
|
// Runs errors are logged only — the caller does not wait for completion.
|
||||||
@@ -76,7 +90,8 @@ func (api *API) syncNftablesOnly(globalCfg *config.State) {
|
|||||||
extraTCP, extraUDP := config.SplitExtraPorts(nftCfg.ExtraPorts)
|
extraTCP, extraUDP := config.SplitExtraPorts(nftCfg.ExtraPorts)
|
||||||
extraUDP = append(extraUDP, api.vpnAutoUDPPorts()...)
|
extraUDP = append(extraUDP, api.vpnAutoUDPPorts()...)
|
||||||
ports := api.haproxy.GetListenPorts(customRoutes)
|
ports := api.haproxy.GetListenPorts(customRoutes)
|
||||||
nftContent := api.nftables.Generate(ports, extraTCP, extraUDP, nftCfg.WANInterface)
|
vpnCIDR := api.vpnCIDR()
|
||||||
|
nftContent := api.nftables.Generate(ports, extraTCP, extraUDP, nftCfg.WANInterface, vpnCIDR)
|
||||||
if err := api.nftables.WriteRules(nftContent); err != nil {
|
if err := api.nftables.WriteRules(nftContent); err != nil {
|
||||||
slog.Error("failed to write nftables rules", "component", "nftables-sync", "error", err)
|
slog.Error("failed to write nftables rules", "component", "nftables-sync", "error", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -303,8 +303,8 @@ log-dhcp
|
|||||||
// SafeApply validates, backs up, writes, restarts, and verifies the dnsmasq config.
|
// SafeApply validates, backs up, writes, restarts, and verifies the dnsmasq config.
|
||||||
// On restart or verification failure, rolls back to the previous config.
|
// On restart or verification failure, rolls back to the previous config.
|
||||||
func (g *Manager) SafeApply(content string) error {
|
func (g *Manager) SafeApply(content string) error {
|
||||||
// Validate via temp file
|
// Validate via temp file (use /tmp to avoid stray files in dnsmasq.d if process crashes)
|
||||||
tmpFile := g.configPath + ".validate"
|
tmpFile := "/tmp/wild-central-dnsmasq-validate.tmp"
|
||||||
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
|
||||||
return fmt.Errorf("writing validation file: %w", err)
|
return fmt.Errorf("writing validation file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -336,6 +336,10 @@ func (g *Manager) SafeApply(content string) error {
|
|||||||
return fmt.Errorf("health check failed (rolled back): %w", err)
|
return fmt.Errorf("health check failed (rolled back): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up backup — dnsmasq loads every file in its conf dir,
|
||||||
|
// so stale .bak files cause duplicate/conflicting records.
|
||||||
|
os.Remove(g.configPath + ".bak")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -576,6 +576,8 @@ func (m *Manager) SafeApply(content string) error {
|
|||||||
return fmt.Errorf("health check failed (rolled back): %w", err)
|
return fmt.Errorf("health check failed (rolled back): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
os.Remove(m.configPath + ".bak")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ func (m *Manager) GetRulesPath() string {
|
|||||||
// Policy is always "accept" — the allowed_tcp_ports set documents what is in use
|
// Policy is always "accept" — the allowed_tcp_ports set documents what is in use
|
||||||
// and enables strict WAN filtering when wanInterface is configured.
|
// and enables strict WAN filtering when wanInterface is configured.
|
||||||
// extraTCPPorts and extraUDPPorts are user-specified ports to allow beyond the HAProxy set.
|
// 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 {
|
// vpnCIDR, when non-empty (e.g. "10.8.0.0/24"), adds forward and masquerade rules for VPN traffic.
|
||||||
|
func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPorts []int, wanInterface string, vpnCIDR string) string {
|
||||||
// Merge and deduplicate TCP ports (HAProxy + extra TCP)
|
// Merge and deduplicate TCP ports (HAProxy + extra TCP)
|
||||||
seen := make(map[int]bool)
|
seen := make(map[int]bool)
|
||||||
var tcpPorts []int
|
var tcpPorts []int
|
||||||
@@ -96,7 +97,8 @@ func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPort
|
|||||||
sb.WriteString(" }\n\n")
|
sb.WriteString(" }\n\n")
|
||||||
|
|
||||||
sb.WriteString(" chain input {\n")
|
sb.WriteString(" chain input {\n")
|
||||||
sb.WriteString(" type filter hook input priority filter; policy accept;\n\n")
|
sb.WriteString(" # Priority -1 so loopback accept runs before CrowdSec's blacklist chain (priority 0)\n")
|
||||||
|
sb.WriteString(" type filter hook input priority -1; policy accept;\n\n")
|
||||||
sb.WriteString(" iif \"lo\" accept\n")
|
sb.WriteString(" iif \"lo\" accept\n")
|
||||||
sb.WriteString(" ct state { established, related } accept\n")
|
sb.WriteString(" ct state { established, related } accept\n")
|
||||||
|
|
||||||
@@ -107,6 +109,23 @@ func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPort
|
|||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString(" }\n")
|
sb.WriteString(" }\n")
|
||||||
|
|
||||||
|
// VPN forwarding and masquerade: when vpnCIDR is set, allow forwarding between
|
||||||
|
// wg0 and LAN, and masquerade VPN traffic leaving the VPN subnet.
|
||||||
|
// These rules live in the nftables table (not iptables PostUp) so they survive
|
||||||
|
// Docker restarts, which flush the iptables-nft compatibility tables.
|
||||||
|
if vpnCIDR != "" {
|
||||||
|
sb.WriteString("\n chain vpn-forward {\n")
|
||||||
|
sb.WriteString(" type filter hook forward priority filter; policy accept;\n")
|
||||||
|
sb.WriteString(" iif \"wg0\" accept\n")
|
||||||
|
sb.WriteString(" oif \"wg0\" ct state { established, related } accept\n")
|
||||||
|
sb.WriteString(" }\n")
|
||||||
|
sb.WriteString("\n chain vpn-postrouting {\n")
|
||||||
|
sb.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n")
|
||||||
|
fmt.Fprintf(&sb, " ip saddr %s ip daddr != %s masquerade\n", vpnCIDR, vpnCIDR)
|
||||||
|
sb.WriteString(" }\n")
|
||||||
|
}
|
||||||
|
|
||||||
sb.WriteString("}\n")
|
sb.WriteString("}\n")
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
@@ -180,6 +199,8 @@ func (m *Manager) SafeApply(content string) error {
|
|||||||
return fmt.Errorf("verification failed (rolled back): %w", err)
|
return fmt.Errorf("verification failed (rolled back): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
os.Remove(m.rulesPath + ".bak")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ func TestPortsToStr(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerate_AlwaysIncludesStructure(t *testing.T) {
|
func TestGenerate_AlwaysIncludesStructure(t *testing.T) {
|
||||||
m := NewManager("")
|
m := NewManager("")
|
||||||
out := m.Generate([]int{80, 443}, nil, nil, "")
|
out := m.Generate([]int{80, 443}, nil, nil, "", "")
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"table inet wild-central",
|
"table inet wild-central",
|
||||||
"set allowed_tcp_ports",
|
"set allowed_tcp_ports",
|
||||||
"chain input",
|
"chain input",
|
||||||
"type filter hook input priority filter; policy accept;",
|
"type filter hook input priority -1; policy accept;",
|
||||||
"iif \"lo\" accept",
|
"iif \"lo\" accept",
|
||||||
"ct state { established, related } accept",
|
"ct state { established, related } accept",
|
||||||
} {
|
} {
|
||||||
@@ -55,7 +55,7 @@ func TestGenerate_AlwaysIncludesStructure(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerate_WithPorts_IncludesElements(t *testing.T) {
|
func TestGenerate_WithPorts_IncludesElements(t *testing.T) {
|
||||||
m := NewManager("")
|
m := NewManager("")
|
||||||
out := m.Generate([]int{80, 443, 8404}, nil, nil, "")
|
out := m.Generate([]int{80, 443, 8404}, nil, nil, "", "")
|
||||||
|
|
||||||
if !strings.Contains(out, "elements = { 80, 443, 8404 }") {
|
if !strings.Contains(out, "elements = { 80, 443, 8404 }") {
|
||||||
t.Errorf("expected elements line with ports, got:\n%s", out)
|
t.Errorf("expected elements line with ports, got:\n%s", out)
|
||||||
@@ -64,7 +64,7 @@ func TestGenerate_WithPorts_IncludesElements(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerate_NoPorts_TCPSetHasNoElements(t *testing.T) {
|
func TestGenerate_NoPorts_TCPSetHasNoElements(t *testing.T) {
|
||||||
m := NewManager("")
|
m := NewManager("")
|
||||||
out := m.Generate([]int{}, nil, nil, "")
|
out := m.Generate([]int{}, nil, nil, "", "")
|
||||||
|
|
||||||
// TCP set should have no elements when no ports given
|
// TCP set should have no elements when no ports given
|
||||||
if strings.Contains(out, "allowed_tcp_ports") && strings.Contains(out, "elements") {
|
if strings.Contains(out, "allowed_tcp_ports") && strings.Contains(out, "elements") {
|
||||||
@@ -83,7 +83,7 @@ func TestGenerate_NoPorts_TCPSetHasNoElements(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerate_ExtraPortsMerged(t *testing.T) {
|
func TestGenerate_ExtraPortsMerged(t *testing.T) {
|
||||||
m := NewManager("")
|
m := NewManager("")
|
||||||
out := m.Generate([]int{80, 443}, []int{22, 443}, nil, "") // 443 deduplicated
|
out := m.Generate([]int{80, 443}, []int{22, 443}, nil, "", "") // 443 deduplicated
|
||||||
|
|
||||||
if !strings.Contains(out, "22") {
|
if !strings.Contains(out, "22") {
|
||||||
t.Errorf("expected extra port 22 in output, got:\n%s", out)
|
t.Errorf("expected extra port 22 in output, got:\n%s", out)
|
||||||
@@ -97,7 +97,7 @@ func TestGenerate_ExtraPortsMerged(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerate_NoWANInterface_NoDropRules(t *testing.T) {
|
func TestGenerate_NoWANInterface_NoDropRules(t *testing.T) {
|
||||||
m := NewManager("")
|
m := NewManager("")
|
||||||
out := m.Generate([]int{80, 443}, nil, nil, "")
|
out := m.Generate([]int{80, 443}, nil, nil, "", "")
|
||||||
|
|
||||||
if strings.Contains(out, "drop") {
|
if strings.Contains(out, "drop") {
|
||||||
t.Errorf("expected no drop rules without WAN interface, got:\n%s", out)
|
t.Errorf("expected no drop rules without WAN interface, got:\n%s", out)
|
||||||
@@ -106,7 +106,7 @@ func TestGenerate_NoWANInterface_NoDropRules(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerate_WithWANInterface_IncludesDropRules(t *testing.T) {
|
func TestGenerate_WithWANInterface_IncludesDropRules(t *testing.T) {
|
||||||
m := NewManager("")
|
m := NewManager("")
|
||||||
out := m.Generate([]int{80, 443}, nil, nil, "eth0")
|
out := m.Generate([]int{80, 443}, nil, nil, "eth0", "")
|
||||||
|
|
||||||
if !strings.Contains(out, `iif "eth0"`) {
|
if !strings.Contains(out, `iif "eth0"`) {
|
||||||
t.Errorf("expected WAN interface in rules, got:\n%s", out)
|
t.Errorf("expected WAN interface in rules, got:\n%s", out)
|
||||||
@@ -129,7 +129,7 @@ func TestGenerate_WithWANInterface_IncludesDropRules(t *testing.T) {
|
|||||||
func TestGenerate_AlwaysIncludesUDPSet(t *testing.T) {
|
func TestGenerate_AlwaysIncludesUDPSet(t *testing.T) {
|
||||||
m := NewManager("")
|
m := NewManager("")
|
||||||
// No WAN interface — permissive mode, but UDP set should still be visible
|
// No WAN interface — permissive mode, but UDP set should still be visible
|
||||||
out := m.Generate([]int{80, 443}, nil, []int{51820}, "")
|
out := m.Generate([]int{80, 443}, nil, []int{51820}, "", "")
|
||||||
|
|
||||||
if !strings.Contains(out, "set allowed_udp_ports") {
|
if !strings.Contains(out, "set allowed_udp_ports") {
|
||||||
t.Errorf("expected allowed_udp_ports set even without WAN interface, got:\n%s", out)
|
t.Errorf("expected allowed_udp_ports set even without WAN interface, got:\n%s", out)
|
||||||
@@ -143,9 +143,39 @@ func TestGenerate_AlwaysIncludesUDPSet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerate_WithVPNCIDR_IncludesMasquerade(t *testing.T) {
|
||||||
|
m := NewManager("")
|
||||||
|
out := m.Generate([]int{80, 443}, nil, nil, "", "10.8.0.0/24")
|
||||||
|
|
||||||
|
if !strings.Contains(out, "chain vpn-forward") {
|
||||||
|
t.Errorf("expected vpn-forward chain, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `iif "wg0" accept`) {
|
||||||
|
t.Errorf("expected wg0 accept rule, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "chain vpn-postrouting") {
|
||||||
|
t.Errorf("expected vpn-postrouting chain, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "ip saddr 10.8.0.0/24 ip daddr != 10.8.0.0/24 masquerade") {
|
||||||
|
t.Errorf("expected masquerade rule with VPN CIDR, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_NoVPNCIDR_NoMasquerade(t *testing.T) {
|
||||||
|
m := NewManager("")
|
||||||
|
out := m.Generate([]int{80, 443}, nil, nil, "", "")
|
||||||
|
|
||||||
|
if strings.Contains(out, "masquerade") {
|
||||||
|
t.Errorf("expected no masquerade without VPN CIDR, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "vpn-forward") {
|
||||||
|
t.Errorf("expected no vpn-forward chain without VPN CIDR, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerate_ExtraUDPPorts(t *testing.T) {
|
func TestGenerate_ExtraUDPPorts(t *testing.T) {
|
||||||
m := NewManager("")
|
m := NewManager("")
|
||||||
out := m.Generate([]int{80, 443}, nil, []int{5353}, "eth0")
|
out := m.Generate([]int{80, 443}, nil, []int{5353}, "eth0", "")
|
||||||
|
|
||||||
// User UDP port should appear in the UDP exception list
|
// User UDP port should appear in the UDP exception list
|
||||||
if !strings.Contains(out, "5353") {
|
if !strings.Contains(out, "5353") {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
package reconcile
|
package reconcile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -433,26 +435,31 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma
|
|||||||
gatewayDomain = parts[1]
|
gatewayDomain = parts[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect unique missing certs. Use hasCertForDomain which checks
|
// Collect certs that need provisioning: missing OR expiring within 30 days.
|
||||||
// both individual certs AND wildcard coverage (e.g., *.civilsociety.dev
|
// Uses hasCertForDomain for existence (checks individual + wildcard coverage)
|
||||||
// covers git.civilsociety.dev — no individual cert needed).
|
// and certExpiresWithin for expiry checking.
|
||||||
missing := map[string]bool{}
|
const renewThreshold = 30 * 24 * time.Hour
|
||||||
|
needsProvision := map[string]bool{}
|
||||||
for _, dom := range doms {
|
for _, dom := range doms {
|
||||||
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
|
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if hasCertForDomain(dom.DomainName) {
|
if hasCertForDomain(dom.DomainName) {
|
||||||
continue
|
// Cert exists — check if it's expiring soon
|
||||||
|
if path := certPathForDomain(dom.DomainName); path != "" && !certExpiresWithin(path, renewThreshold) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info("cert expired or expiring soon, scheduling renewal", "component", "reconcile", "domain", dom.DomainName)
|
||||||
}
|
}
|
||||||
// Determine what cert to provision: wildcard for gateway subdomains, individual otherwise
|
// Determine what cert to provision: wildcard for gateway subdomains, individual otherwise
|
||||||
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
|
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
|
||||||
missing["*."+gatewayDomain] = true
|
needsProvision["*."+gatewayDomain] = true
|
||||||
} else {
|
} else {
|
||||||
missing[dom.DomainName] = true
|
needsProvision[dom.DomainName] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(missing) == 0 {
|
if len(needsProvision) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,28 +471,28 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfToken == "" || email == "" || r.Certs == nil {
|
if cfToken == "" || email == "" || r.Certs == nil {
|
||||||
// Can't auto-provision — report what's missing
|
// Can't auto-provision — report what's needed
|
||||||
var names []string
|
var names []string
|
||||||
for domain := range missing {
|
for domain := range needsProvision {
|
||||||
names = append(names, domain)
|
names = append(names, domain)
|
||||||
slog.Warn("missing cert (auto-provision unavailable — configure Cloudflare token and operator email)",
|
slog.Warn("cert needs provisioning (auto-provision unavailable — configure Cloudflare token and operator email)",
|
||||||
"component", "reconcile", "domain", domain)
|
"component", "reconcile", "domain", domain)
|
||||||
}
|
}
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-provision missing certs
|
// Auto-provision missing/expiring certs
|
||||||
if err := r.Certs.EnsureCredentials(cfToken); err != nil {
|
if err := r.Certs.EnsureCredentials(cfToken); err != nil {
|
||||||
slog.Error("failed to write certbot credentials", "component", "reconcile", "error", err)
|
slog.Error("failed to write certbot credentials", "component", "reconcile", "error", err)
|
||||||
var names []string
|
var names []string
|
||||||
for domain := range missing {
|
for domain := range needsProvision {
|
||||||
names = append(names, domain)
|
names = append(names, domain)
|
||||||
}
|
}
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
var stillMissing []string
|
var stillMissing []string
|
||||||
for domain := range missing {
|
for domain := range needsProvision {
|
||||||
provisionDomain := domain
|
provisionDomain := domain
|
||||||
if strings.HasPrefix(domain, "*.") {
|
if strings.HasPrefix(domain, "*.") {
|
||||||
provisionDomain = domain // certbot handles wildcard with -d *.example.com
|
provisionDomain = domain // certbot handles wildcard with -d *.example.com
|
||||||
@@ -550,6 +557,38 @@ func isValidCertFile(path string) bool {
|
|||||||
return err == nil && info.Size() > 0
|
return err == nil && info.Size() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// certPathForDomain returns the HAProxy PEM path covering a domain, or "" if none exists.
|
||||||
|
// Checks individual cert first, then wildcard.
|
||||||
|
func certPathForDomain(domain string) string {
|
||||||
|
if path := certbot.HAProxyCertPath(domain); isValidCertFile(path) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if parts := strings.SplitN(domain, ".", 2); len(parts) == 2 {
|
||||||
|
if path := certbot.HAProxyCertPath(parts[1]); isValidCertFile(path) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// certExpiresWithin checks if the cert at path expires within the given threshold.
|
||||||
|
// Returns true if the cert is expired or will expire within the threshold.
|
||||||
|
func certExpiresWithin(path string, threshold time.Duration) bool {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Until(cert.NotAfter) < threshold
|
||||||
|
}
|
||||||
|
|
||||||
func extractHost(addr string) string {
|
func extractHost(addr string) string {
|
||||||
for i := len(addr) - 1; i >= 0; i-- {
|
for i := len(addr) - 1; i >= 0; i-- {
|
||||||
if addr[i] == ':' {
|
if addr[i] == ':' {
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
package reconcile
|
package reconcile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||||
@@ -310,6 +318,60 @@ func TestIsValidCertFile_Missing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeSelfSignedCert(t *testing.T, path string, notAfter time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "test"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: notAfter,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertExpiresWithin_Expired(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "expired.pem")
|
||||||
|
writeSelfSignedCert(t, path, time.Now().Add(-24*time.Hour))
|
||||||
|
if !certExpiresWithin(path, 30*24*time.Hour) {
|
||||||
|
t.Error("expected expired cert to return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertExpiresWithin_ExpiringSoon(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "expiring.pem")
|
||||||
|
writeSelfSignedCert(t, path, time.Now().Add(10*24*time.Hour))
|
||||||
|
if !certExpiresWithin(path, 30*24*time.Hour) {
|
||||||
|
t.Error("expected cert expiring in 10 days to return true for 30-day threshold")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertExpiresWithin_Valid(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "valid.pem")
|
||||||
|
writeSelfSignedCert(t, path, time.Now().Add(60*24*time.Hour))
|
||||||
|
if certExpiresWithin(path, 30*24*time.Hour) {
|
||||||
|
t.Error("expected cert with 60 days left to return false for 30-day threshold")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertExpiresWithin_Missing(t *testing.T) {
|
||||||
|
if certExpiresWithin("/nonexistent/cert.pem", 30*24*time.Hour) {
|
||||||
|
t.Error("expected missing file to return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractHost(t *testing.T) {
|
func TestExtractHost(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input, want string
|
input, want string
|
||||||
|
|||||||
@@ -460,16 +460,9 @@ func (m *Manager) renderWgConfig(cfg *Config, srv *secrets, peers []*Peer) strin
|
|||||||
// DNS is intentionally omitted from the server config — it belongs only in
|
// DNS is intentionally omitted from the server config — it belongs only in
|
||||||
// the client peer config (GeneratePeerConfigText). Setting it here would
|
// the client peer config (GeneratePeerConfigText). Setting it here would
|
||||||
// cause wg-quick to reconfigure the server's own DNS resolver.
|
// cause wg-quick to reconfigure the server's own DNS resolver.
|
||||||
// When LAN CIDR is configured: allow forwarding between wg0 and LAN, and masquerade.
|
// VPN forwarding and masquerade rules are managed in the nftables table
|
||||||
// Uses iptables (iptables-nft on modern systems) for reliable PostUp/PreDown syntax.
|
// (inet wild-central) rather than iptables PostUp/PreDown, so they survive
|
||||||
if cfg.LanCIDR != "" {
|
// Docker restarts which flush the iptables-nft compatibility tables.
|
||||||
_, vpnNet, _ := net.ParseCIDR(cfg.Address)
|
|
||||||
if vpnNet != nil {
|
|
||||||
vpnCIDR := vpnNet.String()
|
|
||||||
fmt.Fprintf(&sb, "PostUp = iptables -I FORWARD -i wg0 -j ACCEPT; iptables -I FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -s %s ! -d %s -j MASQUERADE\n", vpnCIDR, vpnCIDR)
|
|
||||||
fmt.Fprintf(&sb, "PreDown = iptables -D FORWARD -i wg0 -j ACCEPT 2>/dev/null; iptables -D FORWARD -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; iptables -t nat -D POSTROUTING -s %s ! -d %s -j MASQUERADE 2>/dev/null; true\n", vpnCIDR, vpnCIDR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, p := range peers {
|
for _, p := range peers {
|
||||||
sb.WriteString("\n[Peer]\n")
|
sb.WriteString("\n[Peer]\n")
|
||||||
fmt.Fprintf(&sb, "# %s\n", p.Name)
|
fmt.Fprintf(&sb, "# %s\n", p.Name)
|
||||||
|
|||||||
Reference in New Issue
Block a user