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:
Alex Shnitman
2026-08-21 16:01:30 +02:00
parent f11b376ce7
commit 70d19759e8
3 changed files with 34 additions and 2 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ Enabling `writeinfojson` or `writethumbnail` in `YTDL_OPTIONS` also writes a fee
### 🌐 Web Server & URLs ### 🌐 Web Server & URLs
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0` (all interfaces). * __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0`, which is every IPv4 interface but no IPv6 one. Set it to `*` (or leave it empty) to listen on both stacks, or to `::` for IPv6 only — `::` does not also accept IPv4, whatever the host's `bindv6only` setting says.
* __PORT__: The port number the web server will listen on. Defaults to `8081`. * __PORT__: The port number the web server will listen on. Defaults to `8081`.
* __URL_PREFIX__: Base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`. * __URL_PREFIX__: Base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
* __PUBLIC_HOST_URL__: Base URL for the download links shown in the UI for completed files. By default, MeTube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it. * __PUBLIC_HOST_URL__: Base URL for the download links shown in the UI for completed files. By default, MeTube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it.
+15 -1
View File
@@ -113,6 +113,18 @@ class Config:
sys.exit(1) sys.exit(1)
setattr(self, k, v in ('true', 'True', 'on', '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('/'): if not self.URL_PREFIX.endswith('/'):
self.URL_PREFIX += '/' self.URL_PREFIX += '/'
@@ -1386,7 +1398,9 @@ def isAccessLogEnabled():
if __name__ == '__main__': if __name__ == '__main__':
logging.getLogger().setLevel(parseLogLevel(config.LOGLEVEL) or logging.INFO) 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 # Auto-detect cookie file on startup
+18
View File
@@ -51,6 +51,24 @@ class ConfigTests(unittest.TestCase):
self.assertEqual(c.PUBLIC_HOST_URL, "") self.assertEqual(c.PUBLIC_HOST_URL, "")
self.assertEqual(c.PUBLIC_HOST_AUDIO_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): 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 # 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 # (which produced root-relative, 404ing audio links). It falls back to the