serve: support IPv6 bind addresses (#3212)

--host :: failed before the model loaded because Server always opened an IPv4 socket. Server now selects AF_INET6 when the bind address contains a
colon; the ready line, the 'from other devices' lines and the browser address bracket an IPv6 address. The bind is still the person's choice and
no default changes. Ported from PR #3212 by purrsec with its tests (serve/test_security.py, including a real :: bind). setup.py's --host help and
docs/DETAILS.md say :: is all IPv6 interfaces; the README hunks of the PR (they name strata launch --host, not checked) are left out.
This commit is contained in:
PurrSec authored and Niko1221 committed 2026-10-26 18:50:11 +01:00
1 parent a1d2fd7ed1
commit eba3106c0e
4 files changed
+55 -10

No files matched your search

+2
View File
@@ -544,6 +544,8 @@ tables per token). The scaling is fixed for the whole run, because the KV cache
`--host 0.0.0.0 --api-key some-long-secret`, or add `"host": "0.0.0.0"` and `"api_key": "..."` to the config. A key is
recommended, not required: without one the server starts and warns that anyone who can reach the port can use the model
and its tools. `--no-api-key` (or `"api_key": "none"`) says it is on purpose. The server window prints this PC's addresses.
For IPv6 use `--host ::` (or `"host": "::"`, all IPv6 interfaces) and put the address in brackets in URLs, for example
`http://[2001:db8::7]:8080/v1`.
On Windows allow the port in the firewall for private networks:
`New-NetFirewallRule -DisplayName "Strata 8080" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Private`
(admin PowerShell).
+11 -5
View File
@@ -7445,6 +7445,11 @@ class Server(ThreadingHTTPServer):
# (STRATA_HTTP_BACKLOG overrides it)
request_queue_size = max(5, int(os.environ.get("STRATA_HTTP_BACKLOG") or 256))
def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True):
if ":" in server_address[0]:
self.address_family = socket.AF_INET6
super().__init__(server_address, RequestHandlerClass, bind_and_activate)
def handle_error(self, request, client_address):
if not isinstance(sys.exc_info()[1], ConnectionError): # a client that hangs up needs no stack trace
super().handle_error(request, client_address)
@@ -8143,7 +8148,7 @@ def main(argv=None, *, lifecycle_event=None, interrupt_exit_code: int = 0) -> in
"written by setup.py")
ap.add_argument("--host", default=None,
help="the address to listen on: 127.0.0.1 = this PC only (the default), 0.0.0.0 = also other devices "
"on your network (set an API key); also \"host\" in the config")
"on your network, :: = all IPv6 interfaces (set an API key); also \"host\" in the config")
ap.add_argument("--script-cycle", action="store_true",
help="with --script given more than once: repeat the scripts by position instead of advancing "
"to the last one and staying there, so extra requests a harness makes (a session title, "
@@ -8617,18 +8622,19 @@ def main(argv=None, *, lifecycle_event=None, interrupt_exit_code: int = 0) -> in
bound_port = httpd.server_address[1] # --port 0: report the port the OS actually assigned
svc.start_idle_unload()
svc.start_vision_idle()
here = "127.0.0.1" if a.host in ("0.0.0.0", "", "::") else a.host
here = "[::1]" if a.host == "::" else "127.0.0.1" if a.host in ("0.0.0.0", "") else f"[{a.host}]" if ":" in a.host else a.host
print(f"ready: http://{here}:{bound_port}/v1 (OpenAI: /v1/chat/completions, Anthropic: /v1/messages, "
f"context {engine.max_context} tokens{', images on' if vision else ''}"
f"{', API key required' if svc.api_key else ''})", flush=True)
print(f" open http://{here}:{bound_port}/ in a browser to chat; close this window to stop the model", flush=True)
if a.host not in ("127.0.0.1", "localhost", "::1"):
# issue #26: reachable from other devices - say at which address, and what can still block it
ips = lan_addresses()
ips = [f"[{a.host}]"] if ":" in a.host and a.host != "::" else [] if a.host == "::" else lan_addresses()
for ip in ips:
print(f" from other devices: http://{ip}:{bound_port}/ (API: http://{ip}:{bound_port}/v1)", flush=True)
if not ips:
print(" from other devices: http://<this PC's IP address>:" + str(bound_port) + "/", flush=True)
address = "[<this PC's IPv6 address>]" if a.host == "::" else "<this PC's IP address>"
print(f" from other devices: http://{address}:{bound_port}/", flush=True)
if not svc.api_key:
print(" WARNING: " + open_server_warning(bound_port, tools=svc.mcp is not None),
flush=True)
@@ -8642,7 +8648,7 @@ def main(argv=None, *, lifecycle_event=None, interrupt_exit_code: int = 0) -> in
# on its own thread: webbrowser.open waits for the browser launcher, which on Linux can run for as long as
# the browser does, and the main thread must be free to take Ctrl+C and SIGTERM (#1872)
threading.Thread(target=webbrowser.open, daemon=True, name="strata-open-browser",
args=(f"http://{'127.0.0.1' if a.host in ('0.0.0.0', '') else a.host}:{bound_port}/",)).start()
args=(f"http://{here}:{bound_port}/",)).start()
if ready_path is not None or event_sink is not None:
ready_host = f"[{here}]" if ":" in here and not here.startswith("[") else here
record = {"schema_version": 1, "instance_id": svc.instance_id, "pid": os.getpid(),
+37 -1
View File
@@ -12,12 +12,13 @@ import json
import socket
import sys
import unittest
from http.server import BaseHTTPRequestHandler
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from serve.frontend import ChatTemplate # noqa: E402
from serve.server import (ByteTokenizer, MockEngine, Service, allowed_hosts_of, host_allowed, # noqa: E402
from serve.server import (ByteTokenizer, MockEngine, Server, Service, allowed_hosts_of, host_allowed, # noqa: E402
host_name, host_names_for, origin_allowed, serve)
ROOT = Path(__file__).resolve().parents[1]
@@ -57,6 +58,41 @@ class HostNames(unittest.TestCase):
self.assertNotIn("*", names)
class ListenAddress(unittest.TestCase):
def test_ipv4_address_family(self):
with Server(("127.0.0.1", 0), BaseHTTPRequestHandler, bind_and_activate=False) as s:
self.assertEqual(s.address_family, socket.AF_INET)
@unittest.skipUnless(socket.has_ipv6, "IPv6 is not available")
def test_ipv6_loopback(self):
for host in ("::1", "2001:db8::7"):
try:
with Server((host, 0), BaseHTTPRequestHandler, bind_and_activate=False) as s:
self.assertEqual(s.address_family, socket.AF_INET6)
except OSError as e:
self.skipTest(f"IPv6 socket is not available: {e}")
tok = ByteTokenizer()
svc = Service(MockEngine(tok, "</think>\n\nok", max_context=4096), tok,
ChatTemplate(ROOT / "serve/chat_template.jinja"))
try:
httpd = serve(svc, host="::", port=0)
except OSError as e:
self.skipTest(f"IPv6 loopback is not available: {e}")
try:
self.assertEqual(httpd.address_family, socket.AF_INET6)
c = http.client.HTTPConnection("::1", httpd.server_address[1], timeout=10)
try:
c.request("GET", "/health")
r = c.getresponse()
self.assertEqual(r.status, 200)
r.read()
finally:
c.close()
finally:
httpd.shutdown()
httpd.server_close()
class HostCheck(unittest.TestCase):
def test_accepted(self):
for host in ("localhost:8095", "LOCALHOST", "127.0.0.1:1", "[::1]:8095", "192.168.1.20:8095", "[fe80::1]",
+5 -4
View File
@@ -25,7 +25,7 @@ Options: --family qwen|swift, --model Q2_0|IQ2_XS|IQ3_XXS|IQ3_S, --context 32768
at least 1 - an explicit --rope-scaling none is refused for such a context), --vision yes|no|gpu|cpu, --port
8080, --yes (recommended
answers, no questions), --setup (install another model / change settings instead of starting), --no-start,
--host 0.0.0.0 --api-key KEY (reach it from other devices on your network), --experimental-speed-projection on|off
--host 0.0.0.0 --api-key KEY (reach it over IPv4; use --host :: for IPv6), --experimental-speed-projection on|off
(EXPERIMENTAL, off by default),
--models-dir DIR, --gguf-dir DIR (use GGUF files you already have), --build (compile instead of the ready-made
engine), --check (only check this PC), --resident-budget-gib N (UD-Q4_K_XL's or UD-IQ4_XS's experts in RAM),
@@ -8685,9 +8685,10 @@ def main() -> int:
"auto, placed from each GPU's free VRAM")
ap.add_argument("--no-remote-expert-opt", action="store_true",
help="with two or more GPUs: leave out --remote-expert-opt, which setup adds there (#578)")
ap.add_argument("--host", help="where the server listens: 127.0.0.1 = this PC only (default), 0.0.0.0 = also other "
"devices on your network (issue #26; an --api-key is recommended)")
ap.add_argument("--api-key", help="require this key from clients (recommended with --host 0.0.0.0); several keys: key1,key2")
ap.add_argument("--host", help="where the server listens: 127.0.0.1 = this PC only (default), 0.0.0.0 = all IPv4 "
"interfaces, :: = all IPv6 interfaces (issue #26; an --api-key is recommended)")
ap.add_argument("--api-key", help="require this key from clients (recommended with --host 0.0.0.0 or --host ::); "
"several keys: key1,key2")
ap.add_argument("--no-browser", dest="browser", action="store_false", default=None,
help="do not open the chat page in the browser when the model is ready (for a harness or an app "
"that uses the API; remembered for this model, also in run-<model>.bat/.sh)")