fix(dns): treat trailing-dot FQDN form of localhost as loopback

RFC 1034: `localhost.` and `localhost` are the same name — the trailing
dot just marks the FQDN form. Without this, an upstream answer of
127.0.0.1 for `localhost.` (or `relay.localhost.`) would get filtered as
poison, breaking user-configured local relays addressed in FQDN form.
This commit is contained in:
Claude
2026-05-16 13:37:02 +00:00
parent 440f5495a4
commit c36c9ccf43
2 changed files with 13 additions and 2 deletions
@@ -296,8 +296,10 @@ class SurgeDns(
* already lowercased.
*/
private fun isLoopbackHostname(host: String): Boolean {
if (host == "localhost" || host.endsWith(".localhost")) return true
val literal = parseIpLiteral(host) ?: return false
// RFC 1034: a trailing dot is the FQDN form (e.g. `localhost.`), still the same name.
val name = host.trimEnd('.')
if (name == "localhost" || name.endsWith(".localhost")) return true
val literal = parseIpLiteral(name) ?: return false
return literal.isLoopbackAddress || literal.isAnyLocalAddress
}
@@ -540,6 +540,15 @@ class SurgeDnsTest {
assertEquals(listOf(ip("127.0.0.1")), dns.lookup("relay.localhost"))
}
@Test
fun `trailing-dot FQDN form of localhost keeps loopback answers`() {
// RFC 1034: `localhost.` is the same name as `localhost`, just in FQDN form.
val upstream = CountingDns(mapOf("localhost." to listOf(ip("127.0.0.1"))))
val dns = SurgeDns(delegate = upstream)
assertEquals(listOf(ip("127.0.0.1")), dns.lookup("localhost."))
}
@Test
fun `ipv4 loopback literal keeps loopback answers`() {
val upstream = CountingDns(mapOf("127.0.0.1" to listOf(ip("127.0.0.1"))))