Studio: add UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK to switch off the startup public lookups (#7433)
* Studio: make the startup public-IP lookup opt-in (#7307 P8) Startup resolved the machine's external IP by asking ifconfig.me whenever Studio bound to 0.0.0.0 or ::. That tells whoever runs that service this host is running Unsloth, which the user never agreed to. The lookup is now gated behind UNSLOTH_STUDIO_PUBLIC_IP_PROBE, off by default, and logs plainly what it sends and where when enabled. Only 1/true/yes/on enable it, so a typo leaves the private default in place. The other two steps stay unconditional because neither discloses anything: the GCE metadata server is link-local, and the UDP connect only asks the kernel which local address routes to 8.8.8.8 without putting a packet on the wire. Disabling the probe therefore still yields a usable LAN address for the access banner. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: gate the check-host.net reachability probe too, and keep the cloud address Problem 8 of #7307 is the check-host.net probe in _verify_global_reachability: it hands this machine's address and port to a third party and asks its nodes to connect back. That was still unconditional, so on GCE and on any host whose routing address is already public the reported behaviour was unchanged. It is now behind the same UNSLOTH_STUDIO_PUBLIC_IP_PROBE opt-in. A private interface address is not proof the port is unreachable, since a NAT or cloud firewall can forward it. The private-address path no longer sets _public_reachable = False, so the banner keeps its warning instead of claiming local network only. To stop the privacy default from costing cloud users their shareable address, step 1 now reads AWS IMDSv2 and Azure IMDS alongside GCE on link-local 169.254.169.254. Also drops the redundant function-local import os and documents the variable in the README remote access section. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: switch to a single UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK opt-out Replaces the opt-in variable and the cloud metadata work from the previous commit. Both third-party startup lookups stay on by default, so nothing changes for existing users, and one variable turns both off for lab and privacy-sensitive deployments. That is the fallback the reporter offered in #7307 Problem 8, and it keeps the firewall diagnostic that the reachability check exists to provide. The ifconfig.me lookup and the check-host.net probe are now both guarded by public_check_disabled(). Parsing matches the nearest existing switch, _trust_forwarded_for in utils/client_ip.py. The reachability guard sits after the private-address branch, which makes no network call, so a LAN user who opts out still gets the address note. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
This commit is contained in:
parent
502730bbba
commit
cd5011f288
3 changed files with 135 additions and 10 deletions
|
|
@ -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`):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
103
studio/backend/tests/test_public_check_optout.py
Normal file
103
studio/backend/tests/test_public_check_optout.py
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue