mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
fix: accept HOST=* so IPv6 users get a dual-stack bind (closes #795)
aiohttp hands HOST straight to getaddrinfo, which has no notion of a '*' wildcard: the lookup fails and MeTube dies at startup on an opaque DNS error. '*' is nevertheless what people reach for when they want to serve both IP stacks -- it is the answer given on #795 -- while the value that actually does it, an empty string, is undiscoverable. asyncio expands an empty host to one listening socket per address family, so map '*' onto it. Verified against the real stack: '' -> [('0.0.0.0', p), ('::', p, 0, 0)] '0.0.0.0' -> [('0.0.0.0', p)] '::' -> [('::', p, 0, 0)] '*' -> gaierror (before this change) The README claimed the 0.0.0.0 default was "all interfaces", which is only true of IPv4; document the three modes instead. Note that '::' is IPv6-only whatever the host's bindv6only says, because asyncio always sets IPV6_V6ONLY on the sockets it binds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+15
-1
@@ -113,6 +113,18 @@ class Config:
|
||||
sys.exit(1)
|
||||
setattr(self, k, v in ('true', 'True', 'on', '1'))
|
||||
|
||||
# aiohttp hands HOST straight to getaddrinfo, which has no notion of a
|
||||
# '*' wildcard: the lookup fails and takes the server down at startup
|
||||
# with an opaque DNS error. '*' is nevertheless what people reach for
|
||||
# when they want to serve both IP stacks, while the value that actually
|
||||
# does it -- an empty string, which asyncio expands to one listening
|
||||
# socket per address family -- is undiscoverable. Accept '*' as the
|
||||
# spelling for "every interface, both stacks". Note that '::' on its own
|
||||
# is IPv6-only regardless of the host's bindv6only setting, because
|
||||
# asyncio always sets IPV6_V6ONLY on the sockets it binds.
|
||||
if self.HOST.strip() == '*':
|
||||
self.HOST = ''
|
||||
|
||||
if not self.URL_PREFIX.endswith('/'):
|
||||
self.URL_PREFIX += '/'
|
||||
|
||||
@@ -1386,7 +1398,9 @@ def isAccessLogEnabled():
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(parseLogLevel(config.LOGLEVEL) or logging.INFO)
|
||||
log.info(f"Listening on {config.HOST}:{config.PORT}")
|
||||
# An empty HOST binds every interface on both stacks; print the '*' spelling
|
||||
# that selects it rather than a bare ':8081'.
|
||||
log.info(f"Listening on {config.HOST or '*'}:{config.PORT}")
|
||||
|
||||
|
||||
# Auto-detect cookie file on startup
|
||||
|
||||
@@ -51,6 +51,24 @@ class ConfigTests(unittest.TestCase):
|
||||
self.assertEqual(c.PUBLIC_HOST_URL, "")
|
||||
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "")
|
||||
|
||||
def test_host_wildcard_becomes_empty_for_dual_stack(self):
|
||||
# Regression: aiohttp passes HOST to getaddrinfo, which does not resolve
|
||||
# '*' -- the server died at startup on a DNS error. '*' now selects the
|
||||
# empty string, the only value asyncio expands to a listening socket per
|
||||
# address family.
|
||||
for raw in ("*", " * "):
|
||||
with self.subTest(raw=raw):
|
||||
with patch.dict(os.environ, _base_env(HOST=raw), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.HOST, "")
|
||||
|
||||
def test_host_literal_addresses_are_untouched(self):
|
||||
for raw in ("0.0.0.0", "::", "127.0.0.1", ""):
|
||||
with self.subTest(raw=raw):
|
||||
with patch.dict(os.environ, _base_env(HOST=raw), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.HOST, raw)
|
||||
|
||||
def test_blank_audio_host_falls_back_to_audio_download_route(self):
|
||||
# Regression: a present-but-blank PUBLIC_HOST_AUDIO_URL must not stay empty
|
||||
# (which produced root-relative, 404ing audio links). It falls back to the
|
||||
|
||||
Reference in New Issue
Block a user