Compare commits
2 Commits
v0.2.0
...
21b963b8ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b963b8ec | ||
|
|
0a50e2d7f0 |
@@ -388,3 +388,63 @@ func (c *cfClient) deleteRecord(zoneID, recordID string) error {
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureCNAME creates a CNAME record (name → target) in Cloudflare if one
|
||||||
|
// doesn't already exist with the correct target. Used by the reconciler to
|
||||||
|
// set up wildcard CNAMEs (e.g., *.cloud.payne.io → cloud.payne.io) for
|
||||||
|
// public domains with subdomains enabled.
|
||||||
|
func EnsureCNAME(apiToken, name, target string) error {
|
||||||
|
cf := &cfClient{apiToken: apiToken}
|
||||||
|
|
||||||
|
parts := strings.Split(name, ".")
|
||||||
|
// Strip leading wildcard for zone extraction: *.cloud.payne.io → payne.io
|
||||||
|
nonWild := parts
|
||||||
|
if parts[0] == "*" {
|
||||||
|
nonWild = parts[1:]
|
||||||
|
}
|
||||||
|
if len(nonWild) < 2 {
|
||||||
|
return fmt.Errorf("invalid CNAME name: %s", name)
|
||||||
|
}
|
||||||
|
zoneName := strings.Join(nonWild[len(nonWild)-2:], ".")
|
||||||
|
|
||||||
|
zoneID, err := cf.getZoneID(zoneName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting zone ID for %s: %w", zoneName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing CNAME — skip if already correct
|
||||||
|
if recordID, content, err := cf.getRecordID(zoneID, name, "CNAME"); err == nil {
|
||||||
|
if content == target {
|
||||||
|
return nil // already up to date
|
||||||
|
}
|
||||||
|
// Wrong target — update it
|
||||||
|
return cf.patchCNAME(zoneID, recordID, name, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("creating wildcard CNAME", "component", "ddns", "name", name, "target", target)
|
||||||
|
return cf.createCNAME(zoneID, name, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cfClient) createCNAME(zoneID, name, target string) error {
|
||||||
|
body, _ := json.Marshal(map[string]string{"type": "CNAME", "name": name, "content": target})
|
||||||
|
resp, err := c.do(http.MethodPost,
|
||||||
|
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID),
|
||||||
|
bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating CNAME: %w", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cfClient) patchCNAME(zoneID, recordID, name, target string) error {
|
||||||
|
body, _ := json.Marshal(map[string]string{"type": "CNAME", "name": name, "content": target})
|
||||||
|
resp, err := c.do(http.MethodPatch,
|
||||||
|
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
|
||||||
|
bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating CNAME: %w", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -226,3 +226,15 @@ func TestGetStatus_ReflectsManualUpdate(t *testing.T) {
|
|||||||
t.Error("expected Enabled=true")
|
t.Error("expected Enabled=true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureCNAME_InvalidName(t *testing.T) {
|
||||||
|
// Single-label name should fail before any API call
|
||||||
|
if err := EnsureCNAME("fake-token", "localhost", "target"); err == nil {
|
||||||
|
t.Error("expected error for single-label CNAME name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildcard with single-label domain should also fail
|
||||||
|
if err := EnsureCNAME("fake-token", "*.localhost", "target"); err == nil {
|
||||||
|
t.Error("expected error for wildcard with single-label domain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/wild-cloud/wild-central/internal/authelia"
|
"github.com/wild-cloud/wild-central/internal/authelia"
|
||||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
|
"github.com/wild-cloud/wild-central/internal/ddns"
|
||||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||||
"github.com/wild-cloud/wild-central/internal/domains"
|
"github.com/wild-cloud/wild-central/internal/domains"
|
||||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||||
@@ -205,6 +206,7 @@ func (r *Reconciler) Reconcile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
r.DDNS.Trigger()
|
r.DDNS.Trigger()
|
||||||
|
r.ensureCloudflareCNAMEs(doms)
|
||||||
|
|
||||||
r.health.UpdatedAt = time.Now()
|
r.health.UpdatedAt = time.Now()
|
||||||
|
|
||||||
@@ -503,6 +505,31 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma
|
|||||||
return stillMissing
|
return stillMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureCloudflareCNAMEs creates wildcard CNAME records in Cloudflare for
|
||||||
|
// public domains with subdomains enabled (e.g., *.cloud.payne.io → cloud.payne.io).
|
||||||
|
// This makes subdomains resolvable on the public internet without individual
|
||||||
|
// DNS records per app.
|
||||||
|
func (r *Reconciler) ensureCloudflareCNAMEs(doms []domains.Domain) {
|
||||||
|
cfToken := ""
|
||||||
|
if r.GetCloudflareToken != nil {
|
||||||
|
cfToken = r.GetCloudflareToken()
|
||||||
|
}
|
||||||
|
if cfToken == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dom := range doms {
|
||||||
|
if !dom.Public || !dom.Subdomains || dom.DomainName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cname := "*." + dom.DomainName
|
||||||
|
if err := ddns.EnsureCNAME(cfToken, cname, dom.DomainName); err != nil {
|
||||||
|
slog.Warn("failed to ensure wildcard CNAME", "component", "reconcile",
|
||||||
|
"cname", cname, "target", dom.DomainName, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// hasCertForDomain checks if a valid (non-empty) cert exists for a domain —
|
// hasCertForDomain checks if a valid (non-empty) cert exists for a domain —
|
||||||
// either an individual cert (<domain>.pem) or a wildcard cert that covers it.
|
// either an individual cert (<domain>.pem) or a wildcard cert that covers it.
|
||||||
func hasCertForDomain(domain string) bool {
|
func hasCertForDomain(domain string) bool {
|
||||||
|
|||||||
@@ -243,6 +243,43 @@ func TestReconcile_DNSEntriesBuiltFromDomains(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReconcile_CNAMEsSkippedWithoutToken(t *testing.T) {
|
||||||
|
// A public wildcard domain should not panic or error when no CF token is configured.
|
||||||
|
doms := []domains.Domain{
|
||||||
|
{
|
||||||
|
DomainName: "cloud.example.com",
|
||||||
|
Backend: domains.Backend{Address: "192.168.1.10:80", Type: domains.BackendHTTP},
|
||||||
|
Subdomains: true,
|
||||||
|
Public: true,
|
||||||
|
TLS: domains.TLSTerminate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r, _, _, ddns := newTestReconciler(t, doms)
|
||||||
|
// GetCloudflareToken is nil — ensureCloudflareCNAMEs should silently return
|
||||||
|
r.Reconcile()
|
||||||
|
|
||||||
|
if !ddns.triggerCalled {
|
||||||
|
t.Error("expected DDNS.Trigger() to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureCloudflareCNAMEs_FiltersCorrectly(t *testing.T) {
|
||||||
|
// Track which domains EnsureCNAME would be called for by using a token
|
||||||
|
// that would fail at the API level. We verify the method doesn't attempt
|
||||||
|
// CNAMEs for non-qualifying domains by checking it returns cleanly
|
||||||
|
// (no token = no API calls).
|
||||||
|
doms := []domains.Domain{
|
||||||
|
{DomainName: "cloud.example.com", Subdomains: true, Public: true}, // qualifies
|
||||||
|
{DomainName: "app.example.com", Subdomains: false, Public: true}, // no subdomains
|
||||||
|
{DomainName: "internal.example.com", Subdomains: true, Public: false}, // not public
|
||||||
|
{DomainName: "", Subdomains: true, Public: true}, // empty name
|
||||||
|
}
|
||||||
|
r, _, _, _ := newTestReconciler(t, doms)
|
||||||
|
// No token — method returns immediately without attempting any CNAME operations
|
||||||
|
r.ensureCloudflareCNAMEs(doms)
|
||||||
|
// No panic, no error — success
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helper tests ---
|
// --- Helper tests ---
|
||||||
|
|
||||||
func TestIsValidCertFile_Valid(t *testing.T) {
|
func TestIsValidCertFile_Valid(t *testing.T) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
<rect width="32" height="32" rx="7" fill="#2563eb"/>
|
<rect width="32" height="32" rx="6" fill="#d97706"/>
|
||||||
<!-- Central node -->
|
<!-- Central node -->
|
||||||
<circle cx="16" cy="16" r="4" fill="white"/>
|
<circle cx="16" cy="16" r="4" fill="white"/>
|
||||||
<!-- Radiating connections -->
|
<!-- Radiating connections -->
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Reference in New Issue
Block a user