mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
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:
1 parent
a1d2fd7ed1
commit
eba3106c0e
4 files changed
+55
-10
No files matched your search
@@ -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
@@ -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
@@ -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]",
|
||||
|
||||
@@ -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)")
|
||||
|
||||
Reference in new issue
Block a user