diff --git a/README.md b/README.md index 1facb87c11..e0fc8ee44c 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,8 @@ unsloth studio -H 0.0.0.0 -p 8888 ``` The Cloudflare tunnel is **off by default**: `-H 0.0.0.0` exposes the raw port only, not a public internet URL. Pair the wildcard bind with `--cloudflare` (`unsloth studio -H 0.0.0.0 --cloudflare`) to also publish a public `https://*.trycloudflare.com` link, or prefer `--secure` (above), which keeps the raw port private. `--cloudflare` has no effect on a loopback bind. +On a wildcard bind Unsloth works out the address to share by asking `ifconfig.me` for the public IP, then asks `check-host.net` whether that port is reachable so it can tell you if a firewall is in the way. Both contact a third party. Set `UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK=1` to skip them; the banner then shows the LAN address and no reachability line. + The first time Unsloth is published on a public URL (`--secure` or `--cloudflare`) with the auto-generated admin password still in place, it asks for a new admin password in the terminal (masked input with confirmation) before the public link goes up. Without an attached terminal it warns instead and keeps the bootstrap deadline: Unsloth shuts down after `UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT` (default 1 hour) unless the password is changed in the web UI. For headless setups that cannot answer that prompt, set the initial admin password non-interactively with `--password` (only takes effect when no password is set yet; if one already exists it is a hard error, so rotate later with `unsloth studio reset-password`): diff --git a/studio/backend/run.py b/studio/backend/run.py index f1fc8c6062..38636a6fba 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -111,13 +111,26 @@ from startup_banner import print_studio_access_banner, print_studio_stop_hint logger = get_logger(__name__) +DISABLE_PUBLIC_CHECK_ENV = "UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK" + + +def public_check_disabled() -> bool: + """True when the operator has turned off the third-party startup lookups. + + On a wildcard bind Unsloth asks ifconfig.me for the public IP and check-host.net + whether the port is reachable. Both are useful for sharing a Studio but both tell + an outside service this machine is running one, which lab and privacy-sensitive + deployments do not want (#7307 Problem 8). Set the var to opt out. + """ + return os.environ.get(DISABLE_PUBLIC_CHECK_ENV, "").strip().lower() in {"1", "true", "yes"} + def _resolve_external_ip() -> str: """Resolve the machine's external IP address. Tries, in order: 1. GCE metadata server (instant on Google Cloud VMs) - 2. ifconfig.me (anywhere with internet) + 2. ifconfig.me (anywhere with internet, skipped by UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK) 3. LAN IP via UDP socket trick (fallback) """ import urllib.request @@ -136,14 +149,15 @@ def _resolve_external_ip() -> str: except Exception: pass - # 2. Public IP service. - try: - with urllib.request.urlopen("https://ifconfig.me", timeout = 3) as resp: - ip = resp.read().decode().strip() - if ip: - return ip - except Exception: - pass + # 2. Public IP service. Third-party, so skippable; the LAN address below still works. + if not public_check_disabled(): + try: + with urllib.request.urlopen("https://ifconfig.me", timeout = 3) as resp: + ip = resp.read().decode().strip() + if ip: + return ip + except Exception: + pass # 3. Fallback: LAN IP via UDP socket trick try: @@ -304,7 +318,8 @@ def _verify_global_reachability(display_host: str, port: int) -> None: """Probe check-host.net to confirm display_host:port is reachable from the public internet. Synchronous so output lands between the banner URLs and the stop hint. Bounded at ~15s; failures swallowed (verifier failing != Unsloth - failing). Only meaningful for a wildcard bind.""" + failing). Only meaningful for a wildcard bind, and skipped entirely by + UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK.""" global _public_reachable # Reset to "unknown" each run; set True/False only when the probe decides. _public_reachable = None @@ -344,6 +359,11 @@ def _verify_global_reachability(display_host: str, port: int) -> None: # Not an IP literal; probe by hostname. pass + # The probe hands display_host:port to a third party and asks it to connect. + if public_check_disabled(): + logger.debug("Skipping the check-host.net probe (%s).", DISABLE_PUBLIC_CHECK_ENV) + return + try: qs = urllib.parse.urlencode({"host": f"{display_host}:{port}", "max_nodes": 3}) req = urllib.request.Request( diff --git a/studio/backend/tests/test_public_check_optout.py b/studio/backend/tests/test_public_check_optout.py new file mode 100644 index 0000000000..8c13cb16c9 --- /dev/null +++ b/studio/backend/tests/test_public_check_optout.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Coverage for UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK (#7307 Problem 8). + +A wildcard bind asks ifconfig.me for the public IP and check-host.net whether the +port is reachable. Both stay on by default; setting the var skips both, which is +what lab and privacy-sensitive deployments asked for. +""" + +import socket +import urllib.request + +import pytest + +import run +from run import ( + DISABLE_PUBLIC_CHECK_ENV, + _resolve_external_ip, + _verify_global_reachability, + public_check_disabled, +) + +IFCONFIG = "https://ifconfig.me" +CHECK_HOST = "check-host.net" + + +class _FakeSocket: + """Stand-in for the step 3 UDP route lookup.""" + + def connect(self, addr): + pass + + def getsockname(self): + return ("192.168.1.50", 0) + + def close(self): + pass + + +@pytest.fixture +def calls(monkeypatch): + """Record every outbound URL and fail it, so resolution reaches the LAN step.""" + seen = [] + + def _urlopen(req, *args, **kwargs): + seen.append(req if isinstance(req, str) else req.full_url) + raise OSError("no network in this test") + + monkeypatch.setattr(urllib.request, "urlopen", _urlopen) + monkeypatch.setattr(socket, "socket", lambda *a, **k: _FakeSocket()) + monkeypatch.delenv(DISABLE_PUBLIC_CHECK_ENV, raising = False) + return seen + + +# ── public_check_disabled ─────────────────────────────────────────── + + +def test_enabled_by_default(monkeypatch): + monkeypatch.delenv(DISABLE_PUBLIC_CHECK_ENV, raising = False) + assert public_check_disabled() is False + + +@pytest.mark.parametrize("raw", ["1", "true", "TRUE", "Yes", " 1 "]) +def test_disabling_values(monkeypatch, raw): + monkeypatch.setenv(DISABLE_PUBLIC_CHECK_ENV, raw) + assert public_check_disabled() is True + + +@pytest.mark.parametrize("raw", ["0", "false", "no", "off", "", " ", "ture"]) +def test_anything_else_leaves_it_on(monkeypatch, raw): + monkeypatch.setenv(DISABLE_PUBLIC_CHECK_ENV, raw) + assert public_check_disabled() is False + + +# ── the two lookups ───────────────────────────────────────────────── + + +def test_public_ip_lookup_runs_by_default(calls): + assert _resolve_external_ip() == "192.168.1.50" + assert IFCONFIG in calls + + +def test_public_ip_lookup_skipped_when_disabled(monkeypatch, calls): + monkeypatch.setenv(DISABLE_PUBLIC_CHECK_ENV, "1") + + assert _resolve_external_ip() == "192.168.1.50", "the LAN address still resolves" + assert IFCONFIG not in calls + + +def test_reachability_probe_runs_by_default(calls): + _verify_global_reachability("95.216.11.2", 8888) + assert any(CHECK_HOST in url for url in calls) + + +def test_reachability_probe_skipped_when_disabled(monkeypatch, calls, capsys): + monkeypatch.setenv(DISABLE_PUBLIC_CHECK_ENV, "1") + + _verify_global_reachability("95.216.11.2", 8888) + capsys.readouterr() + + assert not any(CHECK_HOST in url for url in calls) + assert run._public_reachable is None, "skipping must not claim a reachability result"