Repository: caddyserver/caddy (language: go)

Issue:
Limit `a` dynamic upstream source to specific IP versions
As follow-up to https://github.com/caddyserver/caddy/pull/4470, I would like to have an `aaaa` source.

Here is the use-case:
- Caddy runs on an IPv4-accessible server, proxying to IPv6-accessible servers
- Rather than adding entries for each domain one by one, Caddy could just resolve the incoming domain to the right server, if I could restrict it to only use IPv6-servers

Currently, if I use an `a` dynamic upstream, it sometimes resolves to the IPv4, leading to complications.


Discussion / hints:
A few notes on that:
- I think calling the current dynamic module just "`a`" is misleading
- Changing the name of that dynamic module would be a breaking change
- The current implementation's [`Resolver.LookupIPAddr`](https://pkg.go.dev/net#Resolver.LookupIPAddr) returns a slice of resolved IPv4 and IPv6 addresses, which Caddy then load balances between, explaining your observed behavior ("it sometimes resolves to the IPv4")
- In your case, you could configure a health check to fall back to the working upstream (IPv6), if the resolved IPv4 isn't reachable or whatever
- Current implementation probably fails x% of times on hostnames that resolve dual stack with Caddy being on a IPv6-only (or IPv4-only) host
- [`Resolver.LookupIP`](https://pkg.go.dev/net#Resolver.LookupIP) is like `LookupIPAddr`, but allows one to specify which family to resolve for
We could probably just add a `versions` config to `a`, defaulting to both v4 and v6, but you could configure it to only v6 to ignore any IPv4 records.

Fix patch:
diff --git a/modules/caddyhttp/reverseproxy/caddyfile.go b/modules/caddyhttp/reverseproxy/caddyfile.go
index 1211188d677..cf84ba7c640 100644
--- a/modules/caddyhttp/reverseproxy/caddyfile.go
+++ b/modules/caddyhttp/reverseproxy/caddyfile.go
@@ -1324,6 +1324,7 @@ func (u *SRVUpstreams) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 //	    resolvers           <resolvers...>
 //	    dial_timeout        <timeout>
 //	    dial_fallback_delay <timeout>
+//	    versions            ipv4|ipv6
 //	}
 func (u *AUpstreams) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 	for d.Next() {
@@ -1397,8 +1398,30 @@ func (u *AUpstreams) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 				}
 				u.FallbackDelay = caddy.Duration(dur)
 
+			case "versions":
+				args := d.RemainingArgs()
+				if len(args) == 0 {
+					return d.Errf("must specify at least one version")
+				}
+
+				if u.Versions == nil {
+					u.Versions = &ipVersions{}
+				}
+
+				trueBool := true
+				for _, arg := range args {
+					switch arg {
+					case "ipv4":
+						u.Versions.IPv4 = &trueBool
+					case "ipv6":
+						u.Versions.IPv6 = &trueBool
+					default:
+						return d.Errf("unsupported version: '%s'", arg)
+					}
+				}
+
 			default:
-				return d.Errf("unrecognized srv option '%s'", d.Val())
+				return d.Errf("unrecognized a option '%s'", d.Val())
 			}
 		}
 	}
diff --git a/modules/caddyhttp/reverseproxy/upstreams.go b/modules/caddyhttp/reverseproxy/upstreams.go
index 7a90016d029..30bd7b5753c 100644
--- a/modules/caddyhttp/reverseproxy/upstreams.go
+++ b/modules/caddyhttp/reverseproxy/upstreams.go
@@ -213,6 +213,11 @@ func (sl srvLookup) isFresh() bool {
 	return time.Since(sl.freshness) < time.Duration(sl.srvUpstreams.Refresh)
 }
 
+type ipVersions struct {
+	IPv4 *bool `json:"ipv4,omitempty"`
+	IPv6 *bool `json:"ipv6,omitempty"`
+}
+
 // AUpstreams provides upstreams from A/AAAA lookups.
 // Results are cached and refreshed at the configured
 // refresh interval.
@@ -240,6 +245,11 @@ type AUpstreams struct {
 	// A negative value disables this.
 	FallbackDelay caddy.Duration `json:"dial_fallback_delay,omitempty"`
 
+	// The IP versions to resolve for. By default, both
+	// "ipv4" and "ipv6" will be enabled, which
+	// correspond to A and AAAA records respectively.
+	Versions *ipVersions `json:"versions,omitempty"`
+
 	resolver *net.Resolver
 }
 
@@ -286,7 +296,29 @@ func (au *AUpstreams) Provision(_ caddy.Context) error {
 
 func (au AUpstreams) GetUpstreams(r *http.Request) ([]*Upstream, error) {
 	repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
-	auStr := repl.ReplaceAll(au.String(), "")
+
+	resolveIpv4 := au.Versions.IPv4 == nil || *au.Versions.IPv4
+	resolveIpv6 := au.Versions.IPv6 == nil || *au.Versions.IPv6
+
+	// Map ipVersion early, so we can use it as part of the cache-key.
+	// This should be fairly inexpensive and comes and the upside of
+	// allowing the same dynamic upstream (name + port combination)
+	// to be used multiple times with different ip versions.
+	//
+	// It also forced a cache-miss if a previously cached dynamic
+	// upstream changes its ip version, e.g. after a config reload,
+	// while keeping the cache-invalidation as simple as it currently is.
+	var ipVersion string
+	switch {
+	case resolveIpv4 && !resolveIpv6:
+		ipVersion = "ip4"
+	case !resolveIpv4 && resolveIpv6:
+		ipVersion = "ip6"
+	default:
+		ipVersion = "ip"
+	}
+
+	auStr := repl.ReplaceAll(au.String()+ipVersion, "")
 
 	// first, use a cheap read-lock to return a cached result quickly
 	aAaaaMu.RLock()
@@ -311,7 +343,7 @@ func (au AUpstreams) GetUpstreams(r *http.Request) ([]*Upstream, error) {
 	name := repl.ReplaceAll(au.Name, "")
 	port := repl.ReplaceAll(au.Port, "")
 
-	ips, err := au.resolver.LookupIPAddr(r.Context(), name)
+	ips, err := au.resolver.LookupIP(r.Context(), ipVersion, name)
 	if err != nil {
 		return nil, err
 	}


Test patch:
diff --git a/caddytest/integration/caddyfile_adapt/reverse_proxy_dynamic_upstreams.txt b/caddytest/integration/caddyfile_adapt/reverse_proxy_dynamic_upstreams.txt
index 2f2cbcd3eb1..384cc056262 100644
--- a/caddytest/integration/caddyfile_adapt/reverse_proxy_dynamic_upstreams.txt
+++ b/caddytest/integration/caddyfile_adapt/reverse_proxy_dynamic_upstreams.txt
@@ -11,6 +11,7 @@
 			resolvers 8.8.8.8 8.8.4.4
 			dial_timeout 2s
 			dial_fallback_delay 300ms
+			versions ipv6
 		}
 	}
 }
@@ -66,7 +67,10 @@
 												"8.8.4.4"
 											]
 										},
-										"source": "a"
+										"source": "a",
+										"versions": {
+											"ipv6": true
+										}
 									},
 									"handler": "reverse_proxy"
 								}
@@ -113,4 +117,4 @@
 			}
 		}
 	}
-}
\ No newline at end of file
+}