mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-24 16:23:51 +00:00
* refactor(studio): make cloudflare tunnels runtime-managed * feat(studio): add public access controls * fix(studio): preserve public stop responses * feat(studio): show public access settings * fix(studio): report settings-managed public links * fix(studio): respect launch tunnel ownership * fix(studio): apply public runtime trust policy * refactor(studio): rename public access to remote access * feat(studio): serve web ui through desktop remote access * feat(studio): set the remote password from desktop The desktop app signs in with a local secret and never sees the seeded administrator password, which is only printed to a terminal. Remote access refuses to open a tunnel while that seeded password is still in place, so a desktop user had no way forward: the change-password flow needs a current password they do not have, and the General tab hides its password row on desktop. Add a Remote password row to the Remote access section, desktop only. While the seeded password is pending it sets the first password through a new POST /api/auth/desktop-initial-password; once a password exists the same row changes it through the existing endpoint. The new route accepts only a desktop-issued JWT (web sessions and API keys are refused), applies only while the seeded credential is unchanged, and binds its write to the credential version it read so a concurrent web change or CLI reset is not overwritten. Both password routes now keep the local desktop credential valid and mint desktop-flagged tokens when the caller is the desktop app, so auto-auth stays passwordless and the session survives the change it made. Browser callers keep their existing behavior: the credential is revoked and ordinary tokens are issued. Remote access status reports password_pending on its own, independent of block precedence, so the row is correct even when another block hides the reason. Re-reading status after either operation clears the pending-password block and lets the tunnel start; remote browsers then sign in as unsloth with the new password. The action reuses the account password dialog in an initial-password mode rather than adding a component, and adds no file. * feat(studio): redesign remote access settings as a status card The remote access controls rendered as flat settings rows, so the feature read as hidden and the tunnel URL was squeezed into a description cell. Rebuild remote-access-section.tsx as a bordered card: - header with globe icon, title, live status dot plus state text and the start/stop action together - online state gets a dedicated URL panel with a copy button, a QR button that opens a scannable code for phones (react-qr-code, svg, zero deps), and a note that the URL plus remote password grants sign in - block reasons and tunnel errors render as their own strip, destructive styling on error - shine sweep on the status text while starting or stopping - password and auto start rows move below a divider inside the card Polling and start/stop/auto-start logic are unchanged. * fix(studio): stop tunnel dns wait from stalling on negative caches Starting remote access took 45-48s while a manual tunnel came online in about 8s. _wait_for_dns queried DoH immediately after the edge connection registered, before the fresh trycloudflare hostname propagated, and the resolver negative-cached the NXDOMAIN (trycloudflare.com publishes a 1800s SOA negative TTL; 1.1.1.1 was observed serving the stale negative for about 40s while the loop re-asked it every 2s). - delay the first lookup 3s so it cannot seed those negative caches - ask two independent resolvers per round (cloudflare and google DoH) - cap the dns wait at 20s so it can never starve the health probe, which is what actually gates advertising the URL Start to online now measures about 12s end to end against a live backend. The consecutive-error bailout for blocked DoH is unchanged. * fix(studio): drop stale stopping status after a finished stop A stop worker outlives its completed stop by up to about a second while draining stop responses. During that window remote_access_status kept reporting state stopping and forced can_start false, so a quick stop-then-start flashed "Stopping" in the UI and the start request could 409 as operation_in_progress. Once the tunnel controller reports off with no pending stop handle the stop is done, so a still-alive stop worker no longer masks the idle state or a newly admitted start. Unconfirmed terminations (stop_pending) keep reporting stopping so retry stays visible. * fix(studio): resolve the tunnel hostname through one resolver Asking a second DoH resolver in the same round cannot rescue the poll: both queries land milliseconds apart, so the round that seeds one resolver's negative cache seeds the other's too. It also disclosed the freshly issued hostname to a third party on nearly every start, where the provider that handed the name out already knows it. The hold-off and the wait cap stay, which is where the measured speedup comes from. The cap trades one case away: a hostname that only propagates after 20s now meets the OS resolver while it is still missing, and because the tunnel has registered by then a failed probe ends the attempt instead of retrying over http2. In exchange every start whose record exists by 20s spends the rest of the deadline on probe attempts rather than a single one at 45s. * fix(studio): report a stop only while its teardown is outstanding A stop worker outlives the teardown it performed, so reporting stopping until that thread exits hid the tunnel already being off and refused a start that would have succeeded. The worker's recorded admission sits behind the control token once its teardown advanced the generation, but a start advances the generation the same way, so only that advance together with an off tunnel and no pending stop shows the teardown actually happened. * fix(studio): keep the remote access card legible The status text goes back to full opacity. The shimmer needs partly transparent glyphs, which put the transitional states below the contrast that same text had before, and a viewer who asked for reduced motion paid that cost with no sweep to show for it. The status dot already marks the transition. The auto-start switch's accessible name matches its visible label again after the rename, and the block reason no longer borrows the error colour when a failed tunnel is not what it is reporting. Moving the URL panel and the block-reason strip out of the section body keeps the section at the complexity it had before the card, and SettingsRow drops labelAccessory along with the row that used it. * perf(studio): verify a new tunnel at the edge instead of through DNS Cloudflare routes quick tunnels by TLS SNI, so the edge serves a tunnel as soon as its connection registers, before the hostname resolves anywhere. Probing an edge address with the tunnel's name in SNI and Host takes that hostname's DNS off the startup path, along with the wait that existed only to keep an early lookup from caching the miss. Measured over four fresh tunnels each: verification after registration falls from 10.97s to 6.61s on average, and a whole start from 22.9s to 16.8s. It also removes the case behind the slowest starts, where one DoH query sent before the record propagates is negative-cached, blinding the poll until its own cap expires and pushing a start past 30s. The hostname path stays as the fallback for networks that block direct addresses or intercept TLS. It is entered as soon as the edge looks unreachable rather than merely unready: an error 1033 page is an answer, a failed connection is not. * fix(studio): bound the edge probe and dial distinct frontends Verifying a quick tunnel at Cloudflare's edge shipped with two defects. The wait checked the clock only after trying every address, so a full pass always ran: verify_public_url with a 0.05s timeout still took 12s, against 1.65s before the edge probe existed, and a pass of two real TLS handshakes overshot the 15s cap to about 19s, taken out of the hostname fallback's share of the same deadline. The clock is now read before every attempt, and no attempt is given more time than the deadline leaves. The addresses were also not distinct. macOS reports the A records again as IPv4-mapped under AF_INET6, so taking one per family dialled a single frontend twice, as 104.16.230.132 and ::ffff:104.16.230.132. That doubled the cost of a pass, halved the poll rate, and let two dependent samples of one address exhaust the unreachable counter. Deduplicating by frontend yields 104.16.230.132 and 104.16.231.132 here. An intercepting proxy answering with its own page is an answer rather than an unreachable edge, so it resets that counter exactly as error 1033 does and the wait ends at the cap instead. The comments claimed interception left through the early exit; they now describe what it does. * fix(studio): clear the python floor, read encoding and locale parity gates The tunnel test annotated a helper with `str | None`, which evaluates on the 3.9 floor that pyproject declares and pushed the studio union ratchet from 35 files to 36. Six new test reads took the platform default encoding, so they break on Windows the moment those files gain a non-ASCII byte. The remote password dialog's six new strings existed only in en, which strict locale parity rejects; they are now translated into every overlay. * Fix settings-managed tunnels dying on Linux, and stop-during-retry orphans for PR #7875 PR_SET_PDEATHSIG is a parent-THREAD death signal, not parent-process: the kernel fires it when the thread that forked the child exits. start_remote_access forks cloudflared from a short-lived worker thread, so the connector was SIGTERMed about a second after it came online, every time. Reproduced 3/3 on a real install: the URL is logged, then state goes to error with "cloudflared exited". Auto-start shares the same worker, so it was affected too. Only Linux and WSL are hit. macOS arms no per-thread signal and Windows uses a process-wide Job Object, so both were fine, as was the launch path, which forks from the main thread. Changes: - process_lifetime: spawn_on_lifetime_thread() performs the fork on one process-lifetime daemon thread, so PDEATHSIG means "die with the parent process" again. Non-Linux spawns directly. Falls back to an inline spawn when no helper thread can be obtained, so it can never block. - cloudflare_tunnel: spawn through that helper. The abort branch now tears the connector down instead of returning the URL of a process it just detached, which could leave a live public tunnel no later stop_studio_tunnel() could reach. Restore _tunnel_state to "starting" after attempt 1's teardown so the http2 retry no longer advertises "stopping", which made stop_studio_tunnel() early-return and stop nothing, and made the Settings Stop route a no-op. - run.py: an explicit --cloudflare/--no-cloudflare/--secure on this invocation now beats an inherited _UNSLOTH_CLOUDFLARE_INTENT, so a stale export, Docker ENV or systemd Environment= cannot re-enable a tunnel the user opted out of. The marker still softens the compatibility --no-cloudflare into "unset". - Add tests for remote-access-state.ts, which had no coverage. No bugs found there; the logic is correct, it simply was not exercised. Verified: 701 passed in the focused backend slice (the one failure is a pre-existing missing optional dep, and fails on main too), 347 frontend tests, typecheck, biome and 80 simulation tests including a real-child-process tunnel lifecycle and Chromium/Firefox/WebKit CORS runs. * Surface the unstoppable-connector error, bound the Stop wait, unlatch polling for PR #7875 Follow-ups from reviewing the remote access flow on Linux. The tunnel lifetime fix landed already; these are the smaller things around it. - remote_access_status collapsed "cloudflared could not be stopped" into the generic "Cloudflare tunnel failed". That is the one error the user can act on: the connector's exit was never confirmed, so it still holds the runtime slot and Start stays disabled. Pass it through like the other known messages. - The Stop worker waited on a live start worker with no deadline, polling at 100 Hz. A start that never claims settings ownership (foreign owner, or one that bailed on admission) deferred the user's Stop for the full probe deadline, up to about 170s. Bound it at 5s and poll at 50 Hz. - setPollEnabled(false) had no path back to true. It fires when you stop a tunnel from a browser connected through that tunnel, which is correct, but the section then stayed dark until it unmounted. Resume on any later action or on a password-change refresh, since both prove the origin is reachable. - test_change_password_policy called change_password positionally, so the new is_desktop parameter defaulted to the Depends object, which is truthy. Harmless today because the assertions fire earlier, but it silently exercises the preserve-desktop-secret branch. Pass False explicitly. Verified: 230 passed in the focused backend slice, 347 frontend tests, typecheck, build, locale parity, and no new biome warnings. Re-checked on a real install: auto-start brought a tunnel up at boot and it stayed online, and Stop through that tunnel returned 200 in 0.07s. The one backend failure (test_health_response_reports_desktop_capability_fields) is a pre-existing environment gap and fails on main too. * Correct streaming_supported, shorten the CORS preflight window, guard the spawner across fork for PR #7875 Findings from simulating the remote access paths across platforms, browsers and upgrade scenarios. Three small fixes, each measured rather than reasoned. - streaming_supported was a hardcoded True. Every tunnel Studio opens is a Cloudflare Quick Tunnel, and Cloudflare documents that Quick Tunnels do not support Server-Sent Events. Measured against a real tunnel: a local SSE endpoint delivers 6 events over a 5.0s spread, and the same endpoint through the tunnel answers 200 with text/event-stream and then delivers nothing at all before the read times out. Chunked non-SSE streaming was blackholed the same way, so no transport change works around it. The field now reports whether a tunnel is currently carrying the traffic, so it is true locally and false while a tunnel is live. Nothing branches on it yet, which is exactly why it should be right before something does. - The CORS middleware inherited Starlette's 600s Access-Control-Max-Age. is_allowed_origin closes the instant the tunnel URL clears, but a preflight the browser already cached does not. Measured across four engines: after remote access was stopped, WebKit reused its cached preflight and the state-changing POST still reached the server, while Chromium, Firefox and Edge re-preflighted and were refused. Pinning max_age to 60 keeps preflight caching useful and makes revocation nearly as immediate as every other trust signal here. - spawn_on_lifetime_thread keeps a module-level lock and a helper thread. A fork child inherits the lock in whatever state it was in, and a spawner whose thread does not exist in the child. Reproduced: forking while the lock is held deadlocks the child permanently. Not reachable today, since the backend only uses the spawn start method and the one fork start method lives in the training worker, which never imports the tunnel. Registering an at-fork reset is three lines and closes the class of bug rather than the instance. Also verified and unchanged: no schema change in any store, so an old studio.db with no remote_access_auto_start row reads as false and fail-closed on corrupt or non-boolean values; the argument parser is byte-identical to main, so no launch flag default moved; the PR touches no hardware, GPU or desktop files, and all twelve OS x accelerator combinations produce an identical remote-access status. Host and Origin are forbidden request headers in all four engines, so no page script can forge the Cloudflare provenance gate. Verified: 95 simulation cases, 41 adversarial cases, 109 in the directly affected backend slice, 347 frontend tests, typecheck, build and locale parity. On a real install: auto-start brought a tunnel up at boot and it stayed online, Stop through that tunnel returned 200 in 0.09s, and the live status now reports max-age 60 and streaming_supported false. * Keep the desktop backend up without a dist, and settle self-origin Stop on off for PR #7875 The desktop spawns "studio --api-only" with no --frontend, and install.sh --tauri skips the frontend build outright. The new desktop-owned branch made _serve_frontend true for that launch, so an unresolvable studio/frontend/dist raised SystemExit before TAURI_PORT was emitted and the app never got a backend. The SPA only backs the optional remote web UI there, so warn and carry on API-only; a web UI launch still aborts loudly. A Stop sent from the tunnel's own origin is answered with a terminal off, then polling restarts in perform()'s finally and the first poll can still land inside the ~50ms teardown drain, where the backend reports "stopping". That overwrote the terminal off, cloudflared then exited, and the card latched on a permanent "Stopping" for a tunnel that was already down. Teardown frames no longer overwrite it, while a poll that can still stop the connector proves teardown was abandoned and takes the card back over. --------- Co-authored-by: Maheswar Kumar <110882203+mahiatlinux@users.noreply.github.com> Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
107 lines
3.3 KiB
Python
107 lines
3.3 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Pydantic schemas for the Authentication API."""
|
|
|
|
from typing import Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from auth.storage import MIN_PASSWORD_LENGTH
|
|
|
|
|
|
class AuthLoginRequest(BaseModel):
|
|
"""Login payload: username/password to obtain a JWT."""
|
|
|
|
username: str = Field(..., description = "Username")
|
|
password: str = Field(..., description = "Password")
|
|
|
|
|
|
class DesktopLoginRequest(BaseModel):
|
|
"""Desktop-only local secret exchange payload."""
|
|
|
|
secret: str = Field(..., description = "Desktop local auth secret")
|
|
|
|
|
|
class RefreshTokenRequest(BaseModel):
|
|
"""Refresh token payload to obtain new access + refresh tokens."""
|
|
|
|
refresh_token: str = Field(..., description = "Refresh token from a previous login or refresh")
|
|
|
|
|
|
class AuthStatusResponse(BaseModel):
|
|
"""Indicate whether the seeded admin auth flow is ready."""
|
|
|
|
initialized: bool = Field(..., description = "True if the auth database contains a login user")
|
|
default_username: str = Field(
|
|
"unsloth",
|
|
description = "Default admin username for first-boot UI prefill.",
|
|
)
|
|
requires_password_change: bool = Field(
|
|
...,
|
|
description = "True if the seeded admin must still change the default password",
|
|
)
|
|
|
|
|
|
class DesktopInitialPasswordRequest(BaseModel):
|
|
"""Set the seeded admin's first real password from the desktop app."""
|
|
|
|
new_password: str = Field(
|
|
...,
|
|
min_length = MIN_PASSWORD_LENGTH,
|
|
description = f"Replacement password (minimum {MIN_PASSWORD_LENGTH} characters)",
|
|
)
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
"""Change the current user's password, typically on first login."""
|
|
|
|
current_password: str = Field(
|
|
...,
|
|
min_length = MIN_PASSWORD_LENGTH,
|
|
description = "Existing password for the authenticated user",
|
|
)
|
|
new_password: str = Field(
|
|
...,
|
|
min_length = MIN_PASSWORD_LENGTH,
|
|
description = f"Replacement password (minimum {MIN_PASSWORD_LENGTH} characters)",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API key schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class CreateApiKeyRequest(BaseModel):
|
|
"""Request body to create a new API key."""
|
|
|
|
name: str = Field(..., description = "Human-readable label for this key")
|
|
expires_in_days: Optional[int] = Field(
|
|
None, description = "Number of days until the key expires (None = never)"
|
|
)
|
|
|
|
|
|
class ApiKeyResponse(BaseModel):
|
|
"""Public representation of an API key (never contains the raw key)."""
|
|
|
|
id: int
|
|
name: str
|
|
key_prefix: str = Field(..., description = "First 8 characters after sk-unsloth- for display")
|
|
created_at: str
|
|
last_used_at: Optional[str] = None
|
|
expires_at: Optional[str] = None
|
|
is_active: bool
|
|
|
|
|
|
class CreateApiKeyResponse(BaseModel):
|
|
"""Returned once when a key is created -- ``key`` is never shown again."""
|
|
|
|
key: str = Field(..., description = "Full API key (shown once)")
|
|
api_key: ApiKeyResponse
|
|
|
|
|
|
class ApiKeyListResponse(BaseModel):
|
|
"""List of API keys for the authenticated user."""
|
|
|
|
api_keys: list[ApiKeyResponse]
|