diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d4eec..014e91e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,35 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added + +- **Multi-User Authentication (dual mode)**: The server now supports optional login-protected access for shared machines (e.g. AI servers). + - **Dual mode**: with no users configured the API/web UI are open (as before); once at least one user exists, every `/api/*` and `/ws/*` endpoint requires a valid session. + - **bcrypt password hashing**: passwords are stored as bcrypt (`$2b$`) hashes in `/etc/nvcurve/users.json` (mode `0600`, root-owned). Plaintext is never persisted; login compares the plaintext against the stored hash. + - **24-hour sessions**: a successful login creates a session that lasts 24 hours (HttpOnly cookie for browsers, `Authorization: Bearer` token for CLI/scripts). Sessions are in-memory and invalidated on server restart. + - **Multi-user**: multiple named accounts are supported (no shared-password mode). + - **New CLI**: `nvcurve user add|list|remove|set-password` (root for add/remove/set-password). + - **New endpoints**: `GET /api/ping` (public), `GET /api/auth/status`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/users`. + - **Web UI**: a sign-in screen appears when authentication is enabled; the status bar shows the signed-in user with a sign-out button. Expired sessions (401) re-show the sign-in screen. + - **Brute-force lockout**: 10 failed logins from an IP within 5 minutes triggers a 15-minute lockout. + - New dependency: `bcrypt`. + ## [0.5.1] - 2026-05-09 ### Added + - **Comprehensive Documentation**: Added structured docs covering overview, installation, usage guide, and tips and tricks. README pruned to essentials with links to docs. ### Changed + - **VRAM Offset Cap Raised**: Increased the web UI VRAM slider maximum from 1000 MHz to 3000 MHz, matching the NVIDIA driver hard limit. Point 131 now allows the full range. ## [0.5.0] - 2026-03-23 ### Changed + - **CLI Architecture Simplification**: The CLI has been decoupled from the FastAPI server and now operates as a stateless direct-HAL hardware administration tool. It no longer relies on the server for data reading or offset writing. - **Consistent Privileges**: All CLI commands that interact with the hardware now explicitly require root privileges. - **Background Daemon**: Added a new lightweight Unix socket daemon (`nvcurve daemon`) to handle auto-loading profiles on boot and managing the server's lifecycle. @@ -21,6 +39,7 @@ All notable changes to this project will be documented in this file. ## [0.4.0] - 2026-03-17 ### Added + - **Multi-GPU Support** *(experimental — untested on real multi-GPU hardware)*: The server now manages all detected NVIDIA GPUs simultaneously under a single process. Each GPU gets its own isolated state (write lock, monitor clients, curve clients, active profile). REST endpoints and WebSocket subscriptions accept a `gpu_index` parameter. A new `/api/gpus` endpoint enumerates all GPUs with name, index, UUID, and PCI bus ID. - **GPU Selector in Web UI**: When multiple GPUs are present, the status bar shows a dropdown to switch the active GPU. Switching resets all pending edits, selection state, and live monitoring for the new target. - **Default Profile**: Added the ability to designate a profile as the default — it is applied automatically on server startup. @@ -32,6 +51,7 @@ All notable changes to this project will be documented in this file. - **Automated Setup Check**: `nvcurve setup` runs a consolidated 4-step hardware compatibility check: NvAPI function probe → V/F curve baseline read → non-destructive write-verify → automatic state restore. The write-verify defaults to the last GPU-domain point (safe on all GPU generations); override with `--point` and `--delta`. Pass `--full-mask` if writes fail on older GPUs such as Pascal. ### Changed + - **Profile CLI syntax**: Profile commands now take the profile name as a positional argument instead of `--name` (e.g. `nvcurve profile apply balanced` instead of `nvcurve profile apply --name balanced`). - **Improved Diagnostics**: The `read --diag` engine now reports driver version, VRAM totals, power limits, raw clock offsets, memory offset ranges, and raw boost masks in addition to the NvAPI function probe. - **Offline Snapshots**: `snapshot save`, `restore`, and `list` bypass the server and fall back to direct HAL operations when the daemon is not running. diff --git a/README.md b/README.md index b78d692..d86a349 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,18 @@ nvcurve # Launch web UI at http://localhost:8042 nvcurve read # Quick curve read from CLI ``` +## Authentication (Multi-User) + +The server runs **open by default**. On a shared machine (e.g. an AI server), add users to require a login — the web UI then shows a sign-in screen and every API/WebSocket call is protected. Passwords are stored as **bcrypt** hashes; sessions last **24 hours**. + +```bash +sudo nvcurve user add alice # add a user (prompts for password) +nvcurve user list # list users +sudo nvcurve user remove alice # remove a user +``` + +Adding the first user enables authentication immediately; removing the last user disables it. See the [Usage Guide](docs/Usage-Guide.md#authentication-multi-user) for details. + ## Systemd Service Install the daemon for automatic profile loading on boot and optional web server auto-start: @@ -131,7 +143,7 @@ The daemon reads settings from `/etc/nvcurve/config.json`: ``` | Setting | Description | -|---|---| +| --- | --- | | `host` | Web server bind address (`0.0.0.0` for network access) | | `port` | Web server port (default `8042`) | | `auto_serve` | Auto-start web server on boot | diff --git a/docs/Usage-Guide.md b/docs/Usage-Guide.md index 35d00b7..657a11b 100644 --- a/docs/Usage-Guide.md +++ b/docs/Usage-Guide.md @@ -38,7 +38,7 @@ nvcurve serve stop # Stop the server The curve editor displays your GPU's V/F curve as an interactive graph with draggable points. | Action | How | -|---|---| +| --- | --- | | Select a point | Click on it | | Multi-select | Shift+click to add/remove points | | Select all active points | Ctrl/Cmd+A | @@ -58,7 +58,7 @@ The curve editor displays your GPU's V/F curve as an interactive graph with drag The point table provides a spreadsheet-like view of all curve points with their frequency, voltage, and offset values. | Action | How | -|---|---| +| --- | --- | | Select a point | Click a row | | Toggle selection | Ctrl/Cmd+click | | Range select | Shift+click or drag across rows | @@ -88,6 +88,64 @@ Data is streamed via WebSocket from the backend at a configurable poll interval When multiple NVIDIA GPUs are detected, a GPU selector dropdown appears in the status bar. Switching GPUs resets pending edits, selection state, and monitoring for the new target. +## Authentication (Multi-User) + +By default the server runs **without** authentication — anyone who can reach the port can use it. This is fine for a single-user workstation, but on a shared AI server you will want to lock it down. nvcurve uses a **dual mode**: + +- **No users configured** → the API and web UI are open, exactly as before. +- **One or more users configured** → every API and WebSocket endpoint requires a login. The web UI shows a sign-in screen first. + +There is no "single shared password" mode — once you add a user, each person gets their own account. + +### Managing users + +Users are stored as **bcrypt** hashes in `/etc/nvcurve/users.json` (mode `0600`, root-owned). Plaintext passwords are never written to disk. Manage them with the CLI (root required to add/remove/change): + +```bash +# Add a user (prompts for the password twice). Never pass the password as an +# argument — it would be visible in the process list and recorded in sudo logs. +sudo nvcurve user add alice +sudo nvcurve user add bob + +# List users +nvcurve user list + +# Change a user's password +sudo nvcurve user set-password alice + +# Remove a user +sudo nvcurve user remove bob +``` + +Adding the first user **switches the server into authenticated mode immediately** (no restart needed). Removing the last user switches it back to open mode. + +### Signing in + +- **Web UI:** open the app as usual; if authentication is enabled you will see a sign-in screen. Enter your username and password. +- **CLI / scripts:** the Python client can authenticate and reuse the session: + + ```python + from nvcurve.client import NvCurveClient + client = NvCurveClient(base="http://127.0.0.1:8042") + client.login("alice", "S3cret!") # stores the session token + print(client.gpu()) # subsequent calls are authorized + ``` + + Or pass a token you already have: `NvCurveClient(base=..., token="...")`. + +### Sessions + +- A successful login creates a session that **lasts 24 hours**, after which a new login is required. +- Browsers receive the session as an `HttpOnly` cookie; CLI/scripts use the returned token as an `Authorization: Bearer ` header. +- Sessions are kept in server memory, so a server restart invalidates them (users must sign in again). +- A per-IP lockout (10 failed attempts within 5 minutes → 15-minute lockout) slows down brute-force guessing. + +### Security notes + +- The user store file should stay root-owned and `0600` (the CLI enforces this). +- The web UI and API are still only as safe as the network path to the server — bind to a trusted interface (`--host`) and/or firewall the port. Authentication protects against casual access, not a determined network attacker. +- The `nvcurve user` commands and the user store require root; day-to-day sign-in does not. + ## CLI Reference The CLI is designed for scripting, headless use, and quick operations. All write commands support `--dry-run` to preview changes. @@ -198,7 +256,7 @@ sudo nvcurve service configure --host 0.0.0.0 --port 8042 ## Configuration Files | File | Purpose | -|---|---| +| --- | --- | | `/etc/nvcurve/config.json` | Persistent config (host, port, auto-serve, default profiles) | | `/etc/nvcurve/profiles/*.json` | Saved profiles | | `/var/cache/nvcurve/snapshots/` | Auto-saved snapshots before writes | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59dc31c..ff00514 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,31 +1,102 @@ -import { useGpu } from './hooks/useGpu'; -import { useCurve } from './hooks/useCurve'; -import { useMonitor } from './hooks/useMonitor'; -import { StatusBar } from './components/Monitor/StatusBar'; -import { LiveMonitor } from './components/Monitor/LiveMonitor'; -import { CurveEditor } from './components/CurveEditor/CurveEditor'; -import { PointTable } from './components/PointTable/PointTable'; -import { PerformancePanel } from './components/Limits/PerformancePanel'; -import { PerformanceMonitor } from './components/Monitor/PerformanceMonitor'; -import { FanMonitor } from './components/Monitor/FanMonitor'; -import { FanCurveEditor } from './components/Fans/FanCurveEditor'; -import { ProfilePanel } from './components/Profiles/ProfilePanel'; -import { api } from './api/client'; -import { useCurveStore } from './store/curveStore'; -import { Toaster } from 'sonner'; -import { Loader, ChevronDown } from 'lucide-react'; -import { useState, useRef, useEffect } from 'react'; -import type { FanState } from './types'; +import { useGpu } from "./hooks/useGpu"; +import { useCurve } from "./hooks/useCurve"; +import { useMonitor } from "./hooks/useMonitor"; +import { StatusBar } from "./components/Monitor/StatusBar"; +import { LiveMonitor } from "./components/Monitor/LiveMonitor"; +import { CurveEditor } from "./components/CurveEditor/CurveEditor"; +import { PointTable } from "./components/PointTable/PointTable"; +import { PerformancePanel } from "./components/Limits/PerformancePanel"; +import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor"; +import { FanMonitor } from "./components/Monitor/FanMonitor"; +import { FanCurveEditor } from "./components/Fans/FanCurveEditor"; +import { ProfilePanel } from "./components/Profiles/ProfilePanel"; +import { api, onUnauthorized } from "./api/client"; +import { LoginScreen } from "./components/Auth/LoginScreen"; +import { useCurveStore } from "./store/curveStore"; +import { Toaster } from "sonner"; +import { Loader, ChevronDown } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; +import type { FanState } from "./types"; + +type AuthState = "checking" | "login" | "ok"; export default function App() { + const [authState, setAuthState] = useState("checking"); + const [authUser, setAuthUser] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .authStatus() + .then((s) => { + if (cancelled) return; + if (s.auth_required && !s.authenticated) { + setAuthState("login"); + } else { + setAuthUser(s.username); + setAuthState("ok"); + } + }) + .catch(() => { + // Server unreachable — fall through to the normal (no-auth) flow. + if (!cancelled) setAuthState("ok"); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + onUnauthorized(() => setAuthState("login")); + return () => onUnauthorized(null); + }, []); + + function handleLogout() { + api.logout().catch(() => {}); + setAuthUser(null); + setAuthState("login"); + } + + if (authState === "checking") { + return ( +
+ +
+ ); + } + + if (authState === "login") { + return ( + { + setAuthUser(username); + setAuthState("ok"); + }} + /> + ); + } + + return ; +} + +function MainApp({ + user, + onLogout, +}: { + user: string | null; + onLogout: () => void; +}) { const gpuInfo = useGpu(); const { curve, wsStatus: curveWsStatus } = useCurve(); const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor(); - const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } = useCurveStore(); + const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } = + useCurveStore(); - const [activeTab, setActiveTab] = useState<'curve' | 'performance' | 'fans'>('curve'); + const [activeTab, setActiveTab] = useState<"curve" | "performance" | "fans">( + "curve", + ); const [fanState, setFanState] = useState(null); - const [activeDomain, setActiveDomain] = useState<'gpu' | 'memory'>('gpu'); + const [activeDomain, setActiveDomain] = useState<"gpu" | "memory">("gpu"); const [isProfileOpen, setIsProfileOpen] = useState(false); const profileRef = useRef(null); @@ -37,14 +108,20 @@ export default function App() { useEffect(() => { function handleClickOutside(event: MouseEvent) { - if (profileRef.current && !profileRef.current.contains(event.target as Node)) { + if ( + profileRef.current && + !profileRef.current.contains(event.target as Node) + ) { setIsProfileOpen(false); } } document.addEventListener("mousedown", handleClickOutside); // Fetch initial active profile - api.profiles(selectedGpuIndex).then(data => setActiveProfile(data.active)).catch(console.error); + api + .profiles(selectedGpuIndex) + .then((data) => setActiveProfile(data.active)) + .catch(console.error); return () => document.removeEventListener("mousedown", handleClickOutside); }, [selectedGpuIndex, setActiveProfile]); @@ -54,28 +131,36 @@ export default function App() { } useEffect(() => { - if (activeTab === 'fans') { + if (activeTab === "fans") { refreshFans(); } }, [activeTab, selectedGpuIndex]); // Worst connection status wins const wsStatus = - monitorWsStatus === 'disconnected' || curveWsStatus === 'disconnected' - ? 'disconnected' - : monitorWsStatus === 'connecting' || curveWsStatus === 'connecting' - ? 'connecting' - : 'connected'; + monitorWsStatus === "disconnected" || curveWsStatus === "disconnected" + ? "disconnected" + : monitorWsStatus === "connecting" || curveWsStatus === "connecting" + ? "connecting" + : "connected"; return (
- + - {wsStatus !== 'connected' && ( + {wsStatus !== "connected" && (
- {wsStatus === 'connecting' ? 'Reconnecting to backend...' : 'Connection lost. Retrying...'} + {wsStatus === "connecting" + ? "Reconnecting to backend..." + : "Connection lost. Retrying..."}
)} @@ -84,20 +169,20 @@ export default function App() {
@@ -113,13 +198,23 @@ export default function App() { {activeProfile} ) : ( - 'Profiles' + "Profiles" )} - + {isProfileOpen && (
- +
)}
@@ -127,7 +222,7 @@ export default function App() { {/* Main content area */}
- {activeTab === 'curve' ? ( + {activeTab === "curve" ? ( <>
@@ -155,20 +250,27 @@ export default function App() { {curve && (
p.domain === activeDomain)} - currentVoltageMv={activeDomain === 'gpu' ? currentVoltageMv : null} - readOnly={activeDomain === 'memory'} + points={curve.points.filter( + (p) => p.domain === activeDomain, + )} + currentVoltageMv={ + activeDomain === "gpu" ? currentVoltageMv : null + } + readOnly={activeDomain === "memory"} />
)} - ) : activeTab === 'performance' ? ( + ) : activeTab === "performance" ? (
- +
) : ( diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 43c6a98..f878813 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,87 +1,174 @@ -import type { CurveState, GpuInfo, MonitoringSample, SnapshotInfo, LimitsState, ProfileData, FanState, FanPoint } from '../types'; +import type { + CurveState, + GpuInfo, + MonitoringSample, + SnapshotInfo, + LimitsState, + ProfileData, + FanState, + FanPoint, +} from "../types"; + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +// Global handler invoked when any API call returns 401 (not logged in, or the +// 24-hour session expired). The app uses it to re-show the login screen. +let unauthorizedHandler: (() => void) | null = null; +export function onUnauthorized(handler: (() => void) | null): void { + unauthorizedHandler = handler; +} + +function handleUnauthorized(res: Response): void { + if (res.status === 401 && unauthorizedHandler) { + unauthorizedHandler(); + } +} async function get(path: string, gpuIndex?: number): Promise { - const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`; + const url = + gpuIndex !== undefined + ? `/api${path}?gpu_index=${gpuIndex}` + : `/api${path}`; const res = await fetch(url); if (!res.ok) { + handleUnauthorized(res); const text = await res.text().catch(() => res.statusText); - throw new Error(`GET ${url}: ${res.status} — ${text}`); + throw new ApiError(res.status, `GET ${url}: ${res.status} — ${text}`); } return res.json() as Promise; } -async function post(path: string, body?: unknown, gpuIndex?: number): Promise { - const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`; +async function post( + path: string, + body?: unknown, + gpuIndex?: number, +): Promise { + const url = + gpuIndex !== undefined + ? `/api${path}?gpu_index=${gpuIndex}` + : `/api${path}`; const res = await fetch(url, { - method: 'POST', - headers: body != null ? { 'Content-Type': 'application/json' } : {}, + method: "POST", + headers: body != null ? { "Content-Type": "application/json" } : {}, body: body != null ? JSON.stringify(body) : undefined, }); if (!res.ok) { + handleUnauthorized(res); const text = await res.text().catch(() => res.statusText); - throw new Error(`POST ${url}: ${res.status} — ${text}`); + throw new ApiError(res.status, `POST ${url}: ${res.status} — ${text}`); } // Some endpoints return no body (204) - const ct = res.headers.get('content-type') ?? ''; - if (ct.includes('application/json')) return res.json() as Promise; + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) return res.json() as Promise; + // SAFETY: non-JSON responses (e.g. 204) carry no body; callers of these + // endpoints use T = void and ignore the result, so undefined is a valid T. return undefined as unknown as T; } async function del(path: string, gpuIndex?: number): Promise { - const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`; - const res = await fetch(url, { method: 'DELETE' }); + const url = + gpuIndex !== undefined + ? `/api${path}?gpu_index=${gpuIndex}` + : `/api${path}`; + const res = await fetch(url, { method: "DELETE" }); if (!res.ok) { + handleUnauthorized(res); const text = await res.text().catch(() => res.statusText); - throw new Error(`DELETE ${url}: ${res.status} — ${text}`); + throw new ApiError(res.status, `DELETE ${url}: ${res.status} — ${text}`); } - const ct = res.headers.get('content-type') ?? ''; - if (ct.includes('application/json')) return res.json() as Promise; + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) return res.json() as Promise; + // SAFETY: non-JSON responses (e.g. 204) carry no body; callers of these + // endpoints use T = void and ignore the result, so undefined is a valid T. return undefined as unknown as T; } +export interface AuthStatus { + auth_required: boolean; + authenticated: boolean; + username: string | null; + expires_at: number | null; +} + export const api = { - gpus: () => get('/gpus'), - gpu: (gpuIndex: number) => get('/gpu', gpuIndex), - curve: (gpuIndex: number) => get('/curve', gpuIndex), - ranges: (gpuIndex: number) => get>('/ranges', gpuIndex), - voltage: (gpuIndex: number) => get<{ voltage_uv: number; voltage_mv: number }>('/voltage', gpuIndex), - monitor: (gpuIndex: number) => get('/monitor', gpuIndex), - snapshots: (gpuIndex: number) => get('/snapshots', gpuIndex), + /** Auth */ + authStatus: () => get("/auth/status"), + login: (username: string, password: string) => + post<{ ok: boolean; username: string; expires_at: number; token: string }>( + "/auth/login", + { username, password }, + ), + logout: () => post("/auth/logout"), + + gpus: () => get("/gpus"), + gpu: (gpuIndex: number) => get("/gpu", gpuIndex), + curve: (gpuIndex: number) => get("/curve", gpuIndex), + ranges: (gpuIndex: number) => + get>( + "/ranges", + gpuIndex, + ), + voltage: (gpuIndex: number) => + get<{ voltage_uv: number; voltage_mv: number }>("/voltage", gpuIndex), + monitor: (gpuIndex: number) => get("/monitor", gpuIndex), + snapshots: (gpuIndex: number) => get("/snapshots", gpuIndex), /** Write per-point frequency deltas. deltas: { pointIndex: deltaKhz } */ writeDeltas: (deltas: Record, gpuIndex: number) => - post<{ ok: boolean; freq_warnings?: string[] }>('/curve/write', { deltas }, gpuIndex), + post<{ ok: boolean; freq_warnings?: string[] }>( + "/curve/write", + { deltas }, + gpuIndex, + ), /** Reset all frequency deltas to zero. */ - resetCurve: (gpuIndex: number) => post('/curve/reset', undefined, gpuIndex), + resetCurve: (gpuIndex: number) => post("/curve/reset", undefined, gpuIndex), /** Get performance limits mapping */ - limits: (gpuIndex: number) => get('/limits', gpuIndex), + limits: (gpuIndex: number) => get("/limits", gpuIndex), /** Set performance limits */ updateLimits: (updates: Partial, gpuIndex: number) => - post('/limits', updates, gpuIndex), + post("/limits", updates, gpuIndex), /** Reset power limit and memory offset to hardware defaults */ - resetLimits: (gpuIndex: number) => post('/limits/reset', undefined, gpuIndex), + resetLimits: (gpuIndex: number) => post("/limits/reset", undefined, gpuIndex), /** Profile Management */ - profiles: (gpuIndex: number) => get<{ profiles: ProfileData[], active: string | null, auto_load: string | null }>('/profiles', gpuIndex), - saveProfile: (name: string, gpuIndex: number) => post<{ ok: boolean; filepath: string }>('/profiles', { name }, gpuIndex), - applyProfile: (name: string, gpuIndex: number) => post(`/profiles/${encodeURIComponent(name)}/apply`, undefined, gpuIndex), + profiles: (gpuIndex: number) => + get<{ + profiles: ProfileData[]; + active: string | null; + auto_load: string | null; + }>("/profiles", gpuIndex), + saveProfile: (name: string, gpuIndex: number) => + post<{ ok: boolean; filepath: string }>("/profiles", { name }, gpuIndex), + applyProfile: (name: string, gpuIndex: number) => + post(`/profiles/${encodeURIComponent(name)}/apply`, undefined, gpuIndex), deleteProfile: (name: string) => del(`/profiles/${encodeURIComponent(name)}`), renameProfile: (oldName: string, newName: string) => - post(`/profiles/${encodeURIComponent(oldName)}/rename`, { new_name: newName }), + post(`/profiles/${encodeURIComponent(oldName)}/rename`, { + new_name: newName, + }), /** Server config */ setAutoLoadProfile: (name: string | null, gpuIndex: number) => - post<{ ok: boolean; auto_load_profile: string | null }>('/config', { auto_load_profile: name, gpu_index: gpuIndex }), + post<{ ok: boolean; auto_load_profile: string | null }>("/config", { + auto_load_profile: name, + gpu_index: gpuIndex, + }), /** Fan control */ - fans: (gpuIndex: number) => get('/fans', gpuIndex), + fans: (gpuIndex: number) => get("/fans", gpuIndex), updateFans: (curve: FanPoint[], gpuIndex: number) => - post('/fans', { curve }, gpuIndex), - resetFans: (gpuIndex: number) => post('/fans/reset', undefined, gpuIndex), + post("/fans", { curve }, gpuIndex), + resetFans: (gpuIndex: number) => post("/fans/reset", undefined, gpuIndex), setFanSpeed: (fanPct: number, gpuIndex: number) => - post('/fans/speed', { fan_pct: fanPct }, gpuIndex), + post("/fans/speed", { fan_pct: fanPct }, gpuIndex), }; diff --git a/frontend/src/components/Auth/LoginScreen.tsx b/frontend/src/components/Auth/LoginScreen.tsx new file mode 100644 index 0000000..1f2bad8 --- /dev/null +++ b/frontend/src/components/Auth/LoginScreen.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { Loader, Lock, User } from "lucide-react"; +import { api, ApiError } from "../../api/client"; + +interface Props { + onSuccess: (username: string) => void; +} + +export function LoginScreen({ onSuccess }: Props) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + try { + const res = await api.login(username, password); + onSuccess(res.username); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + setError("Invalid username or password."); + } else if (err instanceof ApiError && err.status === 429) { + setError( + "Too many failed attempts. Please wait a moment and try again.", + ); + } else { + setError(err instanceof Error ? err.message : "Login failed."); + } + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ NVCurve +

+ NVCurve +

+

Sign in to continue

+
+ +
+
+ +
+ + setUsername(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 rounded-lg pl-9 pr-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/40" + placeholder="username" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 rounded-lg pl-9 pr-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/40" + placeholder="••••••••" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Sessions last 24 hours, then you will be asked to sign in again. +

+
+
+ ); +} diff --git a/frontend/src/components/Monitor/StatusBar.tsx b/frontend/src/components/Monitor/StatusBar.tsx index bbcb2d9..f753c45 100644 --- a/frontend/src/components/Monitor/StatusBar.tsx +++ b/frontend/src/components/Monitor/StatusBar.tsx @@ -1,13 +1,24 @@ -import { Cpu, Wifi, WifiOff, Loader, Terminal, ChevronDown } from 'lucide-react'; -import type { GpuInfo, MonitoringSample } from '../../types'; -import { fmt } from '../../utils/units'; -import { useCurveStore } from '../../store/curveStore'; -import { useState, useRef, useEffect } from 'react'; +import { + Cpu, + Wifi, + WifiOff, + Loader, + Terminal, + ChevronDown, + LogOut, + User, +} from "lucide-react"; +import type { GpuInfo, MonitoringSample } from "../../types"; +import { fmt } from "../../utils/units"; +import { useCurveStore } from "../../store/curveStore"; +import { useState, useRef, useEffect } from "react"; interface Props { gpuInfo: GpuInfo | null; - wsStatus: 'connecting' | 'connected' | 'disconnected'; + wsStatus: "connecting" | "connected" | "disconnected"; monitor: MonitoringSample | null; + user?: string | null; + onLogout?: () => void; } const statusIcon = { @@ -17,19 +28,29 @@ const statusIcon = { }; const statusText = { - connected: 'Connected', - connecting: 'Connecting…', - disconnected: 'Disconnected', + connected: "Connected", + connecting: "Connecting…", + disconnected: "Disconnected", }; -export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) { - const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } = useCurveStore(); +export function StatusBar({ + gpuInfo, + wsStatus, + monitor, + user, + onLogout, +}: Props) { + const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } = + useCurveStore(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); useEffect(() => { function handleClickOutside(event: MouseEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { setIsOpen(false); } } @@ -47,20 +68,26 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) { NVCurve
- + {availableGpus.length > 1 ? (
- + {isOpen && (
{availableGpus.map((gpu) => ( @@ -71,12 +98,23 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) { setIsOpen(false); }} className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-zinc-700 transition-colors ${ - gpu.index === selectedGpuIndex ? 'text-cyan-400 font-medium bg-zinc-700/50' : 'text-zinc-300' + gpu.index === selectedGpuIndex + ? "text-cyan-400 font-medium bg-zinc-700/50" + : "text-zinc-300" }`} > - + {gpu.name} - GPU {gpu.index} + + GPU {gpu.index} + ))}
@@ -84,9 +122,12 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
) : ( <> - + - {gpuInfo?.name ?? 'No Device'} + {gpuInfo?.name ?? "No Device"} )} @@ -101,28 +142,75 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) { {/* Centre: live stats */}
- - - - - + + + + +
- {/* Right: connection status */} -
- {statusIcon[wsStatus]} - {statusText[wsStatus]} + {/* Right: user + connection status */} +
+ {user && ( +
+ + {user} + {onLogout && ( + + )} +
+ )} +
+ {statusIcon[wsStatus]} + {statusText[wsStatus]} +
); } -function StatPill({ label, value, color }: { label: string; value: string; color: string }) { +function StatPill({ + label, + value, + color, +}: { + label: string; + value: string; + color: string; +}) { return (
{label} - {value} + + {value} +
); } diff --git a/nvcurve/auth.py b/nvcurve/auth.py new file mode 100644 index 0000000..2fd69bb --- /dev/null +++ b/nvcurve/auth.py @@ -0,0 +1,289 @@ +"""Authentication: bcrypt user store, 24-hour sessions, dual-mode gating. + +Dual mode +--------- +- If the user store file exists and contains at least one user, the API + requires authentication for all ``/api/*`` and ``/ws/*`` endpoints + (except the public auth endpoints and ``/api/ping``). +- If the user store is absent or empty, the server runs with no + authentication at all — exactly like before. + +Passwords are hashed with bcrypt (``$2b$``). Plaintext passwords are never +stored. Sessions are in-memory random 256-bit tokens valid for 24 hours; +they are presented either as an HttpOnly cookie (browser) or as an +``Authorization: Bearer `` header (CLI / scripts). + +A simple per-IP lockout slows down brute-force attempts against login. +""" + +import json +import os +import secrets +import time +from typing import Any + +import bcrypt + +# ── Constants ───────────────────────────────────────────────────────────────── + +SESSION_TTL_S = 24 * 60 * 60 # sessions live for 24 hours +COOKIE_NAME = "nvcurve_session" +BCRYPT_ROUNDS = 12 +MAX_PASSWORD_BYTES = 72 # bcrypt only uses the first 72 bytes + +# Brute-force lockout: MAX_FAILURES within WINDOW_S → locked for LOCKOUT_S. +_MAX_FAILURES = 10 +_WINDOW_S = 300 +_LOCKOUT_S = 900 + +# ── User store ──────────────────────────────────────────────────────────────── +# File format (JSON): {"users": {"alice": "$2b$12$...", "bob": "$2b$12$..."}} + + +def _valid_username(name: str) -> bool: + if not name or len(name) > 32: + return False + return all(c.isalnum() or c in "_-." for c in name) + + +def load_users(path: str) -> dict[str, str]: + """Load the user store. Returns {} if the file is absent or unreadable.""" + try: + with open(path) as f: + data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + users = data.get("users", {}) + if not isinstance(users, dict): + return {} + return {k: v for k, v in users.items() if isinstance(k, str) and isinstance(v, str)} + + +def save_users(path: str, users: dict[str, str]) -> None: + """Write the user store with restrictive permissions (0600, root-owned).""" + directory = os.path.dirname(path) + if directory: + try: + os.makedirs(directory, exist_ok=True) + except OSError as exc: + raise ValueError( + f"cannot create user store directory {directory!r}: {exc}" + ) from exc + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + except OSError as exc: + raise ValueError(f"cannot write user store {path!r}: {exc}") from exc + with os.fdopen(fd, "w") as f: + json.dump({"users": users}, f, indent=2) + f.write("\n") + + +def auth_enabled(path: str) -> bool: + """True if at least one user is configured (→ authentication required).""" + return bool(load_users(path)) + + +def list_users(path: str) -> list[str]: + return sorted(load_users(path)) + + +def user_store_state(path: str) -> str: + """Report the user store state without requiring read access. + + Returns one of: + - "absent": no file (auth disabled) + - "empty": file exists but has no users (auth disabled) + - "unreadable": file exists but can't be read (e.g. non-root on a 0600 + root-owned store) — auth is almost certainly enabled + - "enabled": file readable and has at least one user (auth enabled) + """ + if not os.path.exists(path): + return "absent" + try: + with open(path) as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return "unreadable" + users = data.get("users", {}) if isinstance(data, dict) else {} + if not isinstance(users, dict) or not users: + return "empty" + return "enabled" + + +def add_user(path: str, username: str, password: str) -> None: + """Create a new user with a bcrypt-hashed password.""" + if not _valid_username(username): + raise ValueError( + f"invalid username {username!r} — use 1–32 chars: letters, digits, '_', '-', '.'" + ) + if not password: + raise ValueError("password must not be empty") + if len(password.encode("utf-8")) > MAX_PASSWORD_BYTES: + raise ValueError(f"password too long (max {MAX_PASSWORD_BYTES} bytes)") + users = load_users(path) + if username in users: + raise ValueError(f"user {username!r} already exists") + users[username] = hash_password(password) + save_users(path, users) + + +def remove_user(path: str, username: str) -> bool: + """Remove a user. Returns False if the user did not exist.""" + users = load_users(path) + if username not in users: + return False + del users[username] + save_users(path, users) + return True + + +def set_password(path: str, username: str, password: str) -> None: + """Replace the password of an existing user.""" + if not password: + raise ValueError("password must not be empty") + if len(password.encode("utf-8")) > MAX_PASSWORD_BYTES: + raise ValueError(f"password too long (max {MAX_PASSWORD_BYTES} bytes)") + users = load_users(path) + if username not in users: + raise ValueError(f"user {username!r} does not exist") + users[username] = hash_password(password) + save_users(path, users) + + +# ── Password hashing ────────────────────────────────────────────────────────── + + +def _pw_bytes(plain: str) -> bytes: + """Encode a plaintext password, truncating to bcrypt's 72-byte limit. + + bcrypt only uses the first 72 bytes and raises ValueError for longer + input; truncating here keeps the check total (no 500 on an over-long + login password) and matches what bcrypt does internally. + """ + return plain.encode("utf-8")[:MAX_PASSWORD_BYTES] + + +def hash_password(plain: str) -> str: + """Hash a plaintext password with bcrypt ($2b$).""" + return bcrypt.hashpw(_pw_bytes(plain), bcrypt.gensalt(rounds=BCRYPT_ROUNDS)).decode( + "ascii" + ) + + +def verify_password(plain: str, hashed: str) -> bool: + """Constant-time check of a plaintext password against a bcrypt hash.""" + try: + return bcrypt.checkpw(_pw_bytes(plain), hashed.encode("ascii")) + except (ValueError, TypeError): + return False + + +_dummy_hash: bytes | None = None + + +def check_credentials(users: dict[str, str], username: str, password: str) -> bool: + """Verify username+password. Unknown users burn the same bcrypt time as + known ones so response timing does not reveal which usernames exist.""" + global _dummy_hash + hashed = users.get(username) + if hashed is None: + if _dummy_hash is None: + _dummy_hash = bcrypt.hashpw( + b"nvcurve-timing-equalizer", bcrypt.gensalt(rounds=BCRYPT_ROUNDS) + ) + bcrypt.checkpw(_pw_bytes(password), _dummy_hash) + return False + return verify_password(password, hashed) + + +# ── Sessions (in-memory, 24 h) ──────────────────────────────────────────────── + +_sessions: dict[str, dict[str, Any]] = {} # token -> {"username", "expires_at"} + + +def create_session(username: str) -> tuple[str, float]: + """Create a session. Returns (token, expires_at_unix).""" + _purge_expired() + token = secrets.token_urlsafe(32) + expires_at = time.time() + SESSION_TTL_S + _sessions[token] = {"username": username, "expires_at": expires_at} + return token, expires_at + + +def get_session(token: str | None) -> str | None: + """Return the username for a valid, unexpired token — else None.""" + if not token: + return None + sess = _sessions.get(token) + if sess is None: + return None + if time.time() > sess["expires_at"]: + del _sessions[token] + return None + return sess["username"] + + +def get_session_expires_at(token: str | None) -> float | None: + if not token: + return None + sess = _sessions.get(token) + if sess is None: + return None + return sess["expires_at"] + + +def destroy_session(token: str | None) -> None: + if token: + _sessions.pop(token, None) + + +def _purge_expired() -> None: + now = time.time() + for t in [t for t, s in _sessions.items() if now > s["expires_at"]]: + del _sessions[t] + + +# ── Brute-force lockout (per client IP) ─────────────────────────────────────── + +_failures: dict[str, list[float]] = {} # ip -> recent failure timestamps +_lockouts: dict[str, float] = {} # ip -> unix time when lockout lifts + + +def is_locked_out(ip: str) -> bool: + until = _lockouts.get(ip, 0.0) + if until > time.time(): + return True + if until: + del _lockouts[ip] + return False + + +def record_failure(ip: str) -> None: + now = time.time() + recent = [t for t in _failures.get(ip, []) if now - t < _WINDOW_S] + recent.append(now) + _failures[ip] = recent + if len(recent) >= _MAX_FAILURES: + _lockouts[ip] = now + _LOCKOUT_S + del _failures[ip] + + +def clear_failures(ip: str) -> None: + _failures.pop(ip, None) + + +# ── Token extraction ────────────────────────────────────────────────────────── + + +def extract_token(request: Any) -> str | None: + """Pull the session token from an Authorization header or the cookie. + + Works with both starlette Request and WebSocket objects (both expose + ``.headers`` / ``.cookies``). + """ + auth_header = request.headers.get("authorization", "") + if auth_header.lower().startswith("bearer "): + token = auth_header[7:].strip() + if token: + return token + return request.cookies.get(COOKIE_NAME) diff --git a/nvcurve/cli.py b/nvcurve/cli.py index 9772fab..8c04d51 100644 --- a/nvcurve/cli.py +++ b/nvcurve/cli.py @@ -31,27 +31,31 @@ First-time / diagnostic commands (bypass server, escalate to root): import argparse import json +import os import struct import sys import time -import os +from .client import ApiError, NvCurveClient, ServerNotRunning from .config import Config, default_config -from .client import NvCurveClient, ServerNotRunning, ApiError from .nvapi.constants import ( - VFP_SIZE, VFP_BASE, VFP_STRIDE, - CT_SIZE, CT_BASE, CT_STRIDE, + CT_BASE, CT_POINTS, + CT_SIZE, + CT_STRIDE, + VFP_BASE, + VFP_SIZE, + VFP_STRIDE, ) - # ── Utilities ───────────────────────────────────────────────────────────────── + def hexdump(data: bytes, start: int, length: int, cols: int = 16) -> str: lines = [] end = min(start + length, len(data)) for off in range(start, end, cols): - chunk = data[off:off + cols] + chunk = data[off : off + cols] hx = " ".join(f"{b:02x}" for b in chunk) asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) lines.append(f" {off:04x}: {hx:<{cols * 3}} {asc}") @@ -76,6 +80,7 @@ def parse_range(s: str): # ── Output formatters ───────────────────────────────────────────────────────── + def print_curve(points, offsets, voltage, domains=None, full=False): """Print formatted V/F curve table.""" if voltage: @@ -106,9 +111,13 @@ def print_curve(points, offsets, voltage, domains=None, full=False): for i, (f, v) in enumerate(points): if f == 0 and v == 0: continue - if domains and i < len(domains) and domains[i] == "memory": - show.append(i) - elif f != prev_freq or i == len(points) - 1: + if ( + domains + and i < len(domains) + and domains[i] == "memory" + or f != prev_freq + or i == len(points) - 1 + ): show.append(i) prev_freq = f @@ -135,7 +144,9 @@ def print_curve(points, offsets, voltage, domains=None, full=False): elif f < 1_000_000 and v > 0 and not domain: marker = " (low-power)" if domains: - print(f"{i:3d} {freq_s:>8s} {volt_s:>8s} {offset_s:>8s} {domain}{marker}") + print( + f"{i:3d} {freq_s:>8s} {volt_s:>8s} {offset_s:>8s} {domain}{marker}" + ) else: print(f"{i:3d} {freq_s:>8s} {volt_s:>8s} {offset_s:>8s}{marker}") @@ -144,53 +155,76 @@ def print_curve(points, offsets, voltage, domains=None, full=False): gpu_idxs = [i for i, d in enumerate(domains) if d == "gpu"] mem_idxs = [i for i, d in enumerate(domains) if d == "memory"] - gpu_active = [(points[i][0], points[i][1]) for i in gpu_idxs - if i < len(points) and points[i][0] > 0] + gpu_active = [ + (points[i][0], points[i][1]) + for i in gpu_idxs + if i < len(points) and points[i][0] > 0 + ] if gpu_active: freqs = [f for f, v in gpu_active] volts = [v for f, v in gpu_active] - print(f"GPU core: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz, " - f"{min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV " - f"({len(gpu_active)} points)") + print( + f"GPU core: {min(freqs) / 1000:.0f} – {max(freqs) / 1000:.0f} MHz, " + f"{min(volts) / 1000:.0f} – {max(volts) / 1000:.0f} mV " + f"({len(gpu_active)} points)" + ) - mem_active = [(points[i][0], points[i][1]) for i in mem_idxs - if i < len(points) and points[i][0] > 0] + mem_active = [ + (points[i][0], points[i][1]) + for i in mem_idxs + if i < len(points) and points[i][0] > 0 + ] if mem_active: freqs = [f for f, v in mem_active] volts = [v for f, v in mem_active] - print(f"Memory: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz, " - f"{min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV " - f"({len(mem_active)} points)") + print( + f"Memory: {min(freqs) / 1000:.0f} – {max(freqs) / 1000:.0f} MHz, " + f"{min(volts) / 1000:.0f} – {max(volts) / 1000:.0f} mV " + f"({len(mem_active)} points)" + ) if offsets: - gpu_offsets = [offsets[i] for i in gpu_idxs - if i < len(offsets) and offsets[i] != 0] + gpu_offsets = [ + offsets[i] for i in gpu_idxs if i < len(offsets) and offsets[i] != 0 + ] if gpu_offsets: vals = set(gpu_offsets) if len(vals) == 1: - print(f"GPU offset: {next(iter(vals))/1000:+.0f} MHz " - f"(uniform across {len(gpu_offsets)} points)") + print( + f"GPU offset: {next(iter(vals)) / 1000:+.0f} MHz " + f"(uniform across {len(gpu_offsets)} points)" + ) else: - print(f"GPU offsets: {len(gpu_offsets)} points active " - f"(range: {min(vals)/1000:+.0f} to {max(vals)/1000:+.0f} MHz)") + print( + f"GPU offsets: {len(gpu_offsets)} points active " + f"(range: {min(vals) / 1000:+.0f} to {max(vals) / 1000:+.0f} MHz)" + ) else: active = [(f, v) for f, v in points if f > 0 and v > 0] if active: freqs = [f for f, v in active] volts = [v for f, v in active] - print(f"Frequency range: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz") - print(f"Voltage range: {min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV") + print( + f"Frequency range: {min(freqs) / 1000:.0f} – {max(freqs) / 1000:.0f} MHz" + ) + print( + f"Voltage range: {min(volts) / 1000:.0f} – {max(volts) / 1000:.0f} mV" + ) print(f"V/F points: {len(active)}") if offsets: nonzero = sum(1 for o in offsets if o != 0) if nonzero > 0: vals = set(o for o in offsets if o != 0) if len(vals) == 1: - print(f"Global offset: {next(iter(vals))/1000:+.0f} MHz " - f"(applied to {nonzero} points)") + print( + f"Global offset: {next(iter(vals)) / 1000:+.0f} MHz " + f"(applied to {nonzero} points)" + ) else: - print(f"Per-point offsets active on {nonzero} points " - f"(range: {min(vals)/1000:+.0f} to {max(vals)/1000:+.0f} MHz)") + print( + f"Per-point offsets active on {nonzero} points " + f"(range: {min(vals) / 1000:+.0f} to {max(vals) / 1000:+.0f} MHz)" + ) def output_json(gpu_name, points, offsets, voltage, domains=None): @@ -201,11 +235,19 @@ def output_json(gpu_name, points, offsets, voltage, domains=None): "gpu": gpu_name, "current_voltage_uV": voltage, "layout": { - "vfp_curve": {"size": VFP_SIZE, "base": VFP_BASE, - "stride": VFP_STRIDE, "max_entries": len(points)}, - "clock_table": {"size": CT_SIZE, "base": CT_BASE, - "stride": CT_STRIDE, "delta_offset": 0x14, - "max_entries": CT_POINTS}, + "vfp_curve": { + "size": VFP_SIZE, + "base": VFP_BASE, + "stride": VFP_STRIDE, + "max_entries": len(points), + }, + "clock_table": { + "size": CT_SIZE, + "base": CT_BASE, + "stride": CT_STRIDE, + "delta_offset": 0x14, + "max_entries": CT_POINTS, + }, }, "curve_info": { "gpu_points": gpu_points, @@ -228,13 +270,21 @@ def output_json(gpu_name, points, offsets, voltage, domains=None): # ── Diagnostics (direct HAL, root required) ─────────────────────────────────── + def run_diagnostics(gpu, gpu_name, gpu_index: int = 0): """Probe all known NvAPI functions and report results.""" + from .hal.limits import get_clock_offsets, get_mem_offset_range, get_power_limit + from .hal.monitoring import get_driver_version, get_vram_total, init_nvml from .hal.vfcurve import get_boost_mask - from .hal.monitoring import init_nvml, get_driver_version, get_vram_total - from .hal.limits import get_power_limit, get_clock_offsets, get_mem_offset_range from .nvapi.bootstrap import nvcall, query_interface - from .nvapi.constants import FUNC, MASK_SIZE, VOLT_SIZE, RANGES_SIZE, PERF_SIZE, VBOOST_SIZE + from .nvapi.constants import ( + FUNC, + MASK_SIZE, + PERF_SIZE, + RANGES_SIZE, + VBOOST_SIZE, + VOLT_SIZE, + ) init_nvml() # best-effort; diagnostics degrade gracefully without it @@ -246,7 +296,7 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0): print(f" GPU: {gpu_name}") print(f" Driver: {driver or '(unavailable — NVML not initialised)'}") if vram is not None: - print(f" VRAM: {vram / (1024 ** 3):.1f} GiB ({vram:,} bytes)") + print(f" VRAM: {vram / (1024**3):.1f} GiB ({vram:,} bytes)") else: print(" VRAM: (unavailable)") print() @@ -256,14 +306,14 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0): print() probes = [ - ("GetVFPCurve", FUNC["GetVFPCurve"], VFP_SIZE, 1, True), - ("GetClockBoostMask", FUNC["GetClockBoostMask"], MASK_SIZE, 1, True), - ("GetClockBoostTable", FUNC["GetClockBoostTable"], CT_SIZE, 1, True), - ("GetCurrentVoltage", FUNC["GetCurrentVoltage"], VOLT_SIZE, 1, False), + ("GetVFPCurve", FUNC["GetVFPCurve"], VFP_SIZE, 1, True), + ("GetClockBoostMask", FUNC["GetClockBoostMask"], MASK_SIZE, 1, True), + ("GetClockBoostTable", FUNC["GetClockBoostTable"], CT_SIZE, 1, True), + ("GetCurrentVoltage", FUNC["GetCurrentVoltage"], VOLT_SIZE, 1, False), ("GetClockBoostRanges", FUNC["GetClockBoostRanges"], RANGES_SIZE, 1, False), - ("GetPerfLimits", FUNC["GetPerfLimits"], PERF_SIZE, 2, False), + ("GetPerfLimits", FUNC["GetPerfLimits"], PERF_SIZE, 2, False), ("GetVoltBoostPercent", FUNC["GetVoltBoostPercent"], VBOOST_SIZE, 1, False), - ("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1, True), + ("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1, True), ] for name, fid, size, ver, needs_mask in probes: @@ -301,11 +351,15 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0): print("=== Boost mask ===") print() if mask_bytes: - active = [i for i in range(len(mask_bytes) * 8) if mask_bytes[i // 8] & (1 << (i % 8))] + active = [ + i for i in range(len(mask_bytes) * 8) if mask_bytes[i // 8] & (1 << (i % 8)) + ] print(f" Raw (hex): {mask_bytes.hex()}") if active: - print(f" Active points: {len(active)} of {len(mask_bytes) * 8}" - f" (indices {active[0]}–{active[-1]})") + print( + f" Active points: {len(active)} of {len(mask_bytes) * 8}" + f" (indices {active[0]}–{active[-1]})" + ) else: print(f" Active points: 0 of {len(mask_bytes) * 8}") else: @@ -323,8 +377,11 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0): mem_max = mem_range.get("max_mem_offset_mhz") gpc_cur_s = f"{gpc_cur:+d} MHz" if gpc_cur is not None else "unavailable" mem_cur_s = f"{mem_cur:+d} MHz" if mem_cur is not None else "unavailable" - mem_range_s = (f"{mem_min:+d} / {mem_max:+d} MHz" - if mem_min is not None and mem_max is not None else "unavailable") + mem_range_s = ( + f"{mem_min:+d} / {mem_max:+d} MHz" + if mem_min is not None and mem_max is not None + else "unavailable" + ) print(f" GPC (core) offset: {gpc_cur_s} (range: ±3000 MHz safety cap)") print(f" Memory offset: {mem_cur_s} (range: {mem_range_s})") @@ -333,11 +390,14 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0): print("=== Power limits ===") print() pwr = get_power_limit(gpu_index) - cur_w = pwr.get("power_limit_w") - def_w = pwr.get("default_power_limit_w") - min_w = pwr.get("min_power_limit_w") - max_w = pwr.get("max_power_limit_w") - def fmt_w(v): return f"{v} W" if v is not None else "unavailable" + cur_w = pwr.get("power_limit_w") + def_w = pwr.get("default_power_limit_w") + min_w = pwr.get("min_power_limit_w") + max_w = pwr.get("max_power_limit_w") + + def fmt_w(v): + return f"{v} W" if v is not None else "unavailable" + print(f" Current: {fmt_w(cur_w)}") print(f" Default: {fmt_w(def_w)}") if min_w is not None and max_w is not None: @@ -346,9 +406,11 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0): # ── Privilege / browser helpers ─────────────────────────────────────────────── + def _open_browser_as_user(url: str) -> None: """Open URL in browser, switching back to the original user if running under sudo.""" import subprocess + sudo_user = os.environ.get("SUDO_USER") if sudo_user and os.geteuid() == 0: try: @@ -361,8 +423,8 @@ def _open_browser_as_user(url: str) -> None: except Exception: pass import webbrowser - webbrowser.open(url) + webbrowser.open(url) def require_root(): @@ -372,23 +434,39 @@ def require_root(): # original user (Wayland sockets are user-owned; Firefox refuses to run as root). passthrough = [ f"{k}={v}" - for k in ("DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", - "DBUS_SESSION_BUS_ADDRESS", "XAUTHORITY") + for k in ( + "DISPLAY", + "WAYLAND_DISPLAY", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", + "XAUTHORITY", + ) if (v := os.environ.get(k)) ] try: # PYTHONDONTWRITEBYTECODE prevents root-owned __pycache__ in site-packages. - os.execvp("sudo", [ - "sudo", "env", "PYTHONDONTWRITEBYTECODE=1", *passthrough, - sys.executable, "-m", "nvcurve", *sys.argv[1:] - ]) + os.execvp( + "sudo", + [ + "sudo", + "env", + "PYTHONDONTWRITEBYTECODE=1", + *passthrough, + sys.executable, + "-m", + "nvcurve", + *sys.argv[1:], + ], + ) except Exception as e: print(f"nvcurve: sudo failed: {e}", file=sys.stderr) sys.exit(1) -_SERVER_INFO_FILE = "/run/nvcurve.json" # runtime: written by server, deleted on exit -_PERSISTENT_CONFIG_FILE = "/etc/nvcurve/config.json" # persistent: written by service install +_SERVER_INFO_FILE = "/run/nvcurve.json" # runtime: written by server, deleted on exit +_PERSISTENT_CONFIG_FILE = ( + "/etc/nvcurve/config.json" # persistent: written by service install +) _DAEMON_SOCKET_PATH = "/run/nvcurve-daemon.sock" _ALLOWED_HOSTS = {"127.0.0.1", "::1", "localhost"} @@ -400,6 +478,7 @@ def _daemon_send(cmd: dict) -> dict | None: Returns None if the daemon socket is not available (daemon not running). """ import socket as _socket + try: with _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) as sock: sock.settimeout(5.0) @@ -425,8 +504,11 @@ def _safe_host(host: str, cfg: Config) -> str: if host in ("0.0.0.0", "::"): return "127.0.0.1" if host not in _ALLOWED_HOSTS: - print(f"nvcurve: ignoring untrusted host '{host}' in server info; " - f"using {cfg.host}", file=sys.stderr) + print( + f"nvcurve: ignoring untrusted host '{host}' in server info; " + f"using {cfg.host}", + file=sys.stderr, + ) return cfg.host return host @@ -447,7 +529,13 @@ def _read_server_info() -> dict | None: # Verify the process is still alive os.kill(info["pid"], 0) return info - except (FileNotFoundError, KeyError, ProcessLookupError, OSError, json.JSONDecodeError): + except ( + FileNotFoundError, + KeyError, + ProcessLookupError, + OSError, + json.JSONDecodeError, + ): return None @@ -480,6 +568,7 @@ def _discover_server_url(cfg: Config) -> str: # ── Subcommand handlers ─────────────────────────────────────────────────────── + def _show_curve(gpu_name, points, offsets, voltage, args, domains=None) -> None: """Format and print curve data — shared by HTTP and direct-HAL paths.""" if args.json: @@ -494,6 +583,7 @@ def cmd_read(args): if args.diag: require_root() from .hal.gpu import get_gpu + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) run_diagnostics(gpu, gpu_name, gpu_index=getattr(args, "gpu_index", 0)) return @@ -501,21 +591,25 @@ def cmd_read(args): if args.raw: require_root() from .hal.gpu import get_gpu - from .hal.vfcurve import read_clock_table_raw, get_boost_mask from .hal.monitoring import read_voltage + from .hal.vfcurve import get_boost_mask, read_clock_table_raw from .nvapi.bootstrap import nvcall from .nvapi.constants import FUNC + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) print(f"GPU: {gpu_name}") mask_bytes, _ = get_boost_mask(gpu) + def fill_vfp(buf): if mask_bytes: for i in range(32): buf[4 + i] = mask_bytes[i] - vfp_raw, _ = nvcall(FUNC["GetVFPCurve"], gpu, VFP_SIZE, ver=1, pre_fill=fill_vfp) + vfp_raw, _ = nvcall( + FUNC["GetVFPCurve"], gpu, VFP_SIZE, ver=1, pre_fill=fill_vfp + ) ct_raw, _ = read_clock_table_raw(gpu) if vfp_raw: @@ -534,6 +628,7 @@ def cmd_read(args): print() from .hal.vfcurve import read_curve + curve_state, _ = read_curve(gpu, gpu_name) voltage, _ = read_voltage(gpu) if curve_state: @@ -546,8 +641,9 @@ def cmd_read(args): # ── Normal path — direct HAL (requires root) ────────────────────────────── require_root() from .hal.gpu import get_gpu - from .hal.vfcurve import read_curve from .hal.monitoring import read_voltage as _read_voltage + from .hal.vfcurve import read_curve + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) curve_state, curve_err = read_curve(gpu, gpu_name) if not curve_state: @@ -564,7 +660,7 @@ def cmd_inspect(args): """Show detailed raw ClockBoostTable fields. Requires root (direct HAL).""" require_root() from .hal.gpu import get_gpu - from .hal.vfcurve import read_clock_table_raw, read_clock_entry_full, read_curve + from .hal.vfcurve import read_clock_entry_full, read_clock_table_raw, read_curve gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) raw, err = read_clock_table_raw(gpu) @@ -604,8 +700,9 @@ def cmd_inspect(args): parts.append(f"{len(mem_indices)} memory points") parts.append(f"{len(gpu_indices) + len(mem_indices)} total") print(f"Curve: {', '.join(parts)}") - print(f"ClockBoostTable entry detail (stride=0x{CT_STRIDE:02X}, " - f"9 fields × 4 bytes)") + print( + f"ClockBoostTable entry detail (stride=0x{CT_STRIDE:02X}, 9 fields × 4 bytes)" + ) print() for p in indices: @@ -623,15 +720,17 @@ def cmd_inspect(args): freq_str = "" if p in points_data: f, v = points_data[p] - freq_str = f" (VFP: {f/1000:.0f} MHz @ {v/1000:.0f} mV)" + freq_str = f" (VFP: {f / 1000:.0f} MHz @ {v / 1000:.0f} mV)" print(f"Point {p:3d} — buffer offset 0x{off:04X}{domain_label}{freq_str}") for key, val in entry.items(): if key == "freqDelta_kHz": continue if "0x14" in key: - print(f" {key}: {val:12d} (0x{val & 0xFFFFFFFF:08X})" - f" = {val/1000:+.0f} MHz ← freqDelta") + print( + f" {key}: {val:12d} (0x{val & 0xFFFFFFFF:08X})" + f" = {val / 1000:+.0f} MHz ← freqDelta" + ) else: print(f" {key}: {val:12d} (0x{val:08X})") print() @@ -649,6 +748,7 @@ def cmd_write(args): require_root() from .hal.gpu import get_gpu from .hal.vfcurve import reset_offsets + gpu, _ = get_gpu(index=getattr(args, "gpu_index", 0)) reset_offsets(gpu) print("Reset: all offsets set to 0.") @@ -656,19 +756,22 @@ def cmd_write(args): elif args.point is not None: point_deltas[args.point] = delta_khz - print(f"Target: point {args.point}, delta {args.delta:+.0f} MHz " - f"({delta_khz:+d} kHz)") + print( + f"Target: point {args.point}, delta {args.delta:+.0f} MHz " + f"({delta_khz:+d} kHz)" + ) elif args.range: start, end = args.range for i in range(start, end + 1): point_deltas[i] = delta_khz - print(f"Target: points {start}–{end} ({len(point_deltas)} points), " - f"delta {args.delta:+.0f} MHz") + print( + f"Target: points {start}–{end} ({len(point_deltas)} points), " + f"delta {args.delta:+.0f} MHz" + ) elif args.glob: - print(f"Target: all active points (global), " - f"delta {args.delta:+.0f} MHz") + print(f"Target: all active points (global), delta {args.delta:+.0f} MHz") else: print("Error: specify --point N, --range A-B, --global, or --reset") @@ -678,7 +781,7 @@ def cmd_write(args): if args.glob: print() print("DRY RUN — would send:") - print(f" Target: Global active points") + print(" Target: Global active points") print(f" Delta: {delta_khz:+d} kHz ({args.delta:+.0f} MHz)") else: keys = sorted(point_deltas.keys()) @@ -694,8 +797,8 @@ def cmd_write(args): require_root() from .hal.gpu import get_gpu - from .hal.vfcurve import write_offsets, read_curve - from .safety import validate_write, check_negative_freq_warnings + from .hal.vfcurve import read_curve, write_offsets + from .safety import check_negative_freq_warnings, validate_write gpu_index = getattr(args, "gpu_index", 0) gpu, gpu_name = get_gpu(index=gpu_index) @@ -706,9 +809,13 @@ def cmd_write(args): if not curve_state: print(f"Failed to read curve: {curve_err}", file=sys.stderr) sys.exit(1) - point_deltas = {p.index: delta_khz for p in curve_state.points if p.domain == "gpu"} + point_deltas = { + p.index: delta_khz for p in curve_state.points if p.domain == "gpu" + } - effective_max = max_delta_khz if max_delta_khz is not None else default_config.max_delta_khz + effective_max = ( + max_delta_khz if max_delta_khz is not None else default_config.max_delta_khz + ) errors = validate_write(point_deltas, effective_max) if errors: for e in errors: @@ -717,7 +824,10 @@ def cmd_write(args): if default_config.auto_snapshot: from .hal.snapshot import save as snapshot_save - snapshot_save(gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots) + + snapshot_save( + gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots + ) ret, desc = write_offsets(gpu, point_deltas) if ret != 0: @@ -742,8 +852,8 @@ def cmd_verify(args): require_root() from .hal.gpu import get_gpu - from .hal.vfcurve import read_clock_offsets, write_offsets from .hal.snapshot import save as snapshot_save + from .hal.vfcurve import read_clock_offsets, write_offsets delta_khz = int(args.delta * 1000) @@ -755,7 +865,7 @@ def cmd_verify(args): print("Error: --point or --range required for verify mode") return - point_deltas = {p: delta_khz for p in points} + point_deltas = dict.fromkeys(points, delta_khz) gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) @@ -801,8 +911,10 @@ def cmd_verify(args): if not match: all_matched = False match_s = "OK" if match else "MISMATCH" - print(f" Point {p:3d}: expected {expected/1000:+8.0f} MHz, " - f"got {actual/1000:+8.0f} MHz [{match_s}]") + print( + f" Point {p:3d}: expected {expected / 1000:+8.0f} MHz, " + f"got {actual / 1000:+8.0f} MHz [{match_s}]" + ) collateral = [ {"point": i, "before_khz": before_offsets[i], "after_khz": after_offsets[i]} @@ -813,8 +925,10 @@ def cmd_verify(args): if collateral: print("Unintended side effects detected:") for c in collateral: - print(f" WARNING: Point {c['point']} changed: " - f"{c['before_khz']/1000:+.0f} → {c['after_khz']/1000:+.0f} MHz") + print( + f" WARNING: Point {c['point']} changed: " + f"{c['before_khz'] / 1000:+.0f} → {c['after_khz'] / 1000:+.0f} MHz" + ) else: print("No unintended side effects detected.") @@ -837,8 +951,11 @@ def cmd_snapshot(args): require_root() from .hal.gpu import get_gpu from .hal.snapshot import save as _snapshot_save + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) - path = _snapshot_save(gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots) + path = _snapshot_save( + gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots + ) if path is None: print("Failed to save snapshot.", file=sys.stderr) sys.exit(1) @@ -848,6 +965,7 @@ def cmd_snapshot(args): require_root() from .hal.gpu import get_gpu from .hal.snapshot import restore as _snapshot_restore + gpu, _ = get_gpu(index=getattr(args, "gpu_index", 0)) ok = _snapshot_restore(gpu, default_config.snapshot_dir, args.file) if not ok: @@ -857,9 +975,14 @@ def cmd_snapshot(args): elif args.action == "list": from .hal.snapshot import list_snapshots as _list_snapshots + snapshots = [ - {"filepath": s.filepath, "timestamp": s.timestamp, - "gpu": s.gpu, "nonzero_offsets": s.nonzero_offsets} + { + "filepath": s.filepath, + "timestamp": s.timestamp, + "gpu": s.gpu, + "nonzero_offsets": s.nonzero_offsets, + } for s in _list_snapshots(default_config.snapshot_dir) ] if not snapshots: @@ -874,6 +997,7 @@ def cmd_snapshot(args): def _profile_config_read() -> dict: """Read /etc/nvcurve/config.json, returning {} if absent or unreadable.""" import json as _json + try: with open(_PERSISTENT_CONFIG_FILE) as f: return _json.load(f) @@ -885,6 +1009,7 @@ def _profile_config_write(key: str, value) -> None: """Write a single key into /etc/nvcurve/config.json (creates or updates).""" import json as _json import os as _os + _os.makedirs(_os.path.dirname(_PERSISTENT_CONFIG_FILE), exist_ok=True) data = _profile_config_read() if value is None: @@ -903,6 +1028,7 @@ def _gpu_stable_key_offline(gpu_index: int) -> str | None: """ try: from .hal.gpu import discover_gpus + infos = discover_gpus() for info in infos: if info.index == gpu_index: @@ -922,11 +1048,14 @@ def _profile_config_set_default(gpu_index: int, name: str | None) -> None: """Set or clear the default profile for a specific GPU in config.json.""" import json as _json import os as _os + _os.makedirs(_os.path.dirname(_PERSISTENT_CONFIG_FILE), exist_ok=True) data = _profile_config_read() # Migrate old single-key format on write if "auto_load_profile" in data: - data.setdefault("auto_load_profiles", {})["idx:0"] = data.pop("auto_load_profile") + data.setdefault("auto_load_profiles", {})["idx:0"] = data.pop( + "auto_load_profile" + ) gpu_key = _gpu_stable_key_offline(gpu_index) if gpu_key is None: raise ValueError(f"GPU {gpu_index} not found") @@ -943,7 +1072,10 @@ def _profile_config_set_default(gpu_index: int, name: str | None) -> None: def cmd_profile(args): if args.action == "list": - import glob as _glob, json as _json, os as _os + import glob as _glob + import json as _json + import os as _os + gpu_index = getattr(args, "gpu_index", 0) profile_dir = default_config.profile_dir cfg_data = _profile_config_read() @@ -959,7 +1091,9 @@ def cmd_profile(args): with open(path) as f: p = _json.load(f) name = _os.path.splitext(_os.path.basename(path))[0] - profiles.append({"name": name, "curve_deltas": p.get("curve_deltas", {})}) + profiles.append( + {"name": name, "curve_deltas": p.get("curve_deltas", {})} + ) except Exception: pass if not profiles: @@ -968,7 +1102,8 @@ def cmd_profile(args): print("Profiles:") for p in profiles: markers = [] - if p["name"] == auto_load: markers.append("default") + if p["name"] == auto_load: + markers.append("default") marker_str = f" [{', '.join(markers)}]" if markers else "" pts = len(p["curve_deltas"]) print(f" - {p['name']} ({pts} pts){marker_str}") @@ -996,9 +1131,10 @@ def cmd_profile(args): return require_root() import os as _os + from .hal.gpu import get_gpu - from .hal.vfcurve import read_curve from .hal.limits import get_clock_offsets, get_power_limit + from .hal.vfcurve import read_curve from .profiles.native import ProfileData, save_profile gpu_index = getattr(args, "gpu_index", 0) @@ -1009,7 +1145,9 @@ def cmd_profile(args): print(f"Failed to read curve: {curve_err}", file=sys.stderr) sys.exit(1) - curve_deltas = {str(p.index): p.delta_khz for p in curve_state.points if p.delta_khz != 0} + curve_deltas = { + str(p.index): p.delta_khz for p in curve_state.points if p.delta_khz != 0 + } try: power_info = get_power_limit(gpu_index) @@ -1036,10 +1174,11 @@ def cmd_profile(args): return require_root() import os as _os + from .hal.gpu import get_gpu from .hal.limits import set_clock_offsets, set_power_limit - from .hal.vfcurve import write_offsets, reset_offsets from .hal.snapshot import save as snapshot_save + from .hal.vfcurve import reset_offsets, write_offsets from .profiles.native import load_profile from .safety import validate_write @@ -1072,7 +1211,12 @@ def cmd_profile(args): errs.append("Curve: " + "; ".join(errors)) else: if default_config.auto_snapshot: - snapshot_save(gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots) + snapshot_save( + gpu, + gpu_name, + default_config.snapshot_dir, + default_config.max_snapshots, + ) ret, desc = write_offsets(gpu, deltas) if ret != 0: errs.append(f"Curve write failed ({ret}): {desc}") @@ -1087,6 +1231,7 @@ def cmd_profile(args): def cmd_gpus(args): """List all detected NVIDIA GPUs with index, name, UUID, and PCI bus ID.""" from .hal.gpu import discover_gpus + gpus = [ {"index": i.index, "name": i.name, "uuid": i.uuid, "pci_bus_id": i.pci_bus_id} for i in discover_gpus() @@ -1101,6 +1246,84 @@ def cmd_gpus(args): print(f" [{g['index']}] {g['name']} — {uuid} — {pci_str}") +def cmd_user(args): + """Manage web UI users (authentication).""" + from . import auth + + cfg_data = _profile_config_read() + users_file = cfg_data.get("users_file", default_config.users_file) + action = args.action + + if action is None: + print("Usage: nvcurve user ...", file=sys.stderr) + print("Run 'nvcurve user --help' for details.", file=sys.stderr) + return + + if action == "list": + state = auth.user_store_state(users_file) + if state == "unreadable": + print( + "User store exists but is not readable by your user " + f"({users_file} is root-owned, mode 0600)." + ) + print("Authentication is therefore ENABLED. Run as root to list users:") + print(" sudo nvcurve user list") + return + users = auth.list_users(users_file) + if not users: + print("No users configured — the server runs without authentication.") + return + print("Users:") + for u in users: + print(f" - {u}") + return + + if action in ("add", "set-password"): + require_root() + username = args.username + # Always prompt for the password (never accept it as a CLI argument, + # which would expose it in the process list and sudo logs). + import getpass + + password = getpass.getpass(f"Password for {username}: ") + confirm = getpass.getpass("Repeat password: ") + if password != confirm: + print("Error: passwords do not match.", file=sys.stderr) + return + if not password: + print("Error: password must not be empty.", file=sys.stderr) + return + try: + if action == "add": + auth.add_user(users_file, username, password) + else: + auth.set_password(users_file, username, password) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return + if action == "add": + print(f"User '{username}' added.") + print("Authentication is now REQUIRED for the web UI and API.") + else: + print(f"Password updated for '{username}'.") + return + + if action == "remove": + require_root() + try: + ok = auth.remove_user(users_file, args.username) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return + if ok: + print(f"User '{args.username}' removed.") + if not auth.list_users(users_file): + print("No users left — the server now runs WITHOUT authentication.") + else: + print(f"User '{args.username}' not found.", file=sys.stderr) + return + + def cmd_setup(args): """One-shot hardware compatibility check: diag → read → write-verify → restore.""" explicit_point = getattr(args, "point", None) @@ -1110,9 +1333,10 @@ def cmd_setup(args): require_root() from .hal.gpu import get_gpu - from .hal.vfcurve import read_curve, read_clock_offsets, write_offsets from .hal.monitoring import read_voltage - from .hal.snapshot import save as snapshot_save, restore as snapshot_restore + from .hal.snapshot import restore as snapshot_restore + from .hal.snapshot import save as snapshot_save + from .hal.vfcurve import read_clock_offsets, read_curve, write_offsets sep = "─" * 60 @@ -1154,19 +1378,27 @@ def cmd_setup(args): # ── Step 3: write-verify cycle ───────────────────────────────────────────── if verify_point >= len(curve_state.points): - print(f"Step 3/4 Write-verify (SKIPPED — point {verify_point} not present; " - f"GPU has {len(curve_state.points)} points)") + print( + f"Step 3/4 Write-verify (SKIPPED — point {verify_point} not present; " + f"GPU has {len(curve_state.points)} points)" + ) print() print(sep) print(" RESULT: Diagnostics passed. Write-verify skipped.") - print(f" Use --point to specify a valid point index (0–{len(curve_state.points) - 1}).") + print( + f" Use --point to specify a valid point index (0–{len(curve_state.points) - 1})." + ) print(sep) return - print(f"Step 3/4 Write-verify ({verify_delta_mhz:+.0f} MHz at point {verify_point})") + print( + f"Step 3/4 Write-verify ({verify_delta_mhz:+.0f} MHz at point {verify_point})" + ) print() - snap_path = snapshot_save(gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots) + snap_path = snapshot_save( + gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots + ) if snap_path: print(f" Snapshot saved: {snap_path}") @@ -1176,7 +1408,9 @@ def cmd_setup(args): sys.exit(1) full_mask = getattr(args, "full_mask", False) - ret, desc = write_offsets(gpu, {verify_point: verify_delta_khz}, full_mask=full_mask) + ret, desc = write_offsets( + gpu, {verify_point: verify_delta_khz}, full_mask=full_mask + ) if ret != 0: print(f" Write FAILED ({ret}): {desc}") print() @@ -1195,17 +1429,22 @@ def cmd_setup(args): actual = after_offsets[verify_point] matched = actual == verify_delta_khz collateral = [ - i for i in range(min(len(before_offsets), len(after_offsets))) + i + for i in range(min(len(before_offsets), len(after_offsets))) if i != verify_point and before_offsets[i] != after_offsets[i] ] if matched: print(f" Point {verify_point}: {verify_delta_khz / 1000:+.0f} MHz OK") else: - print(f" Point {verify_point}: MISMATCH — expected {verify_delta_khz / 1000:+.0f} MHz, " - f"got {actual / 1000:+.0f} MHz") + print( + f" Point {verify_point}: MISMATCH — expected {verify_delta_khz / 1000:+.0f} MHz, " + f"got {actual / 1000:+.0f} MHz" + ) if collateral: - print(f" WARNING: {len(collateral)} collateral point(s) changed: {collateral[:5]}") + print( + f" WARNING: {len(collateral)} collateral point(s) changed: {collateral[:5]}" + ) else: print(" No collateral changes") @@ -1217,7 +1456,9 @@ def cmd_setup(args): if ok: print(" Hardware state restored to baseline.") else: - print(" WARNING: Restore failed. Run: nvcurve snapshot restore", file=sys.stderr) + print( + " WARNING: Restore failed. Run: nvcurve snapshot restore", file=sys.stderr + ) print() print(sep) @@ -1227,7 +1468,9 @@ def cmd_setup(args): print(" Next: nvcurve launch web UI") print(" nvcurve service install auto-start on boot") elif not matched: - print(" RESULT: Write verification FAILED — this configuration is not supported.") + print( + " RESULT: Write verification FAILED — this configuration is not supported." + ) else: print(" RESULT: Write applied but unexpected collateral changes detected.") print(" Review the output above before using write operations.") @@ -1288,21 +1531,28 @@ def cmd_service(args): if auto_serve: print(f" Web server will auto-start on boot at {host}:{port}") else: - print(f" Web server default: {host}:{port} (start on demand: nvcurve serve start)") + print( + f" Web server default: {host}:{port} (start on demand: nvcurve serve start)" + ) try: subprocess.run(["systemctl", "daemon-reload"], check=True) - was_active = subprocess.run( - ["systemctl", "is-active", "--quiet", "nvcurve"], - ).returncode == 0 + was_active = ( + subprocess.run( + ["systemctl", "is-active", "--quiet", "nvcurve"], + ).returncode + == 0 + ) subprocess.run(["systemctl", "enable", "--now", "nvcurve"], check=True) print("Service enabled and started.") if was_active: print() - print("Note: the service was already running and is still on the old version.") + print( + "Note: the service was already running and is still on the old version." + ) print(" Restart it to pick up the update: nvcurve service restart") print() @@ -1334,6 +1584,7 @@ def cmd_service(args): elif action == "start": require_root() import subprocess + if not os.path.exists(unit_path): print("Service is not installed. Run: nvcurve service install") return @@ -1346,6 +1597,7 @@ def cmd_service(args): elif action == "stop": require_root() import subprocess + if not os.path.exists(unit_path): print("Service is not installed.") return @@ -1358,6 +1610,7 @@ def cmd_service(args): elif action == "restart": require_root() import subprocess + if not os.path.exists(unit_path): print("Service is not installed. Run: nvcurve service install") return @@ -1373,14 +1626,16 @@ def cmd_service(args): if os.path.exists(unit_path): result = subprocess.run( ["systemctl", "is-active", "nvcurve"], - capture_output=True, text=True, + capture_output=True, + text=True, ) active = result.stdout.strip() pid_info = "" if active == "active": r2 = subprocess.run( ["systemctl", "show", "nvcurve", "--property=MainPID"], - capture_output=True, text=True, + capture_output=True, + text=True, ) pid = r2.stdout.strip().replace("MainPID=", "") if pid and pid != "0": @@ -1409,7 +1664,9 @@ def cmd_service(args): print(f"web server auto-start: {'on' if auto_serve else 'off'}") print(f"web server address: {host}:{port}") print() - print("Change with: nvcurve service configure [--auto-serve|--no-auto-serve] [--host H] [--port P]") + print( + "Change with: nvcurve service configure [--auto-serve|--no-auto-serve] [--host H] [--port P]" + ) elif action == "configure": require_root() @@ -1449,6 +1706,7 @@ def cmd_service(args): # ── Server management ───────────────────────────────────────────────────────── + def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None: """Start the web server — via daemon if available, otherwise directly (requires root).""" host = getattr(args, "host", cfg.host) @@ -1461,9 +1719,14 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None: json.dump({"pid": os.getpid(), "host": host, "port": port}, f) try: from .server import run as server_run - server_run(host=host, port=port, - gpu_index=getattr(args, "gpu_index", 0), - config=cfg, open_browser=False) + + server_run( + host=host, + port=port, + gpu_index=getattr(args, "gpu_index", 0), + config=cfg, + open_browser=False, + ) finally: if os.path.exists(_SERVER_INFO_FILE): os.remove(_SERVER_INFO_FILE) @@ -1494,8 +1757,18 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None: if getattr(args, "detach", False): import subprocess - cmd = [sys.executable, "-m", "nvcurve", "serve", "start", - "--host", host, "--port", str(port)] + + cmd = [ + sys.executable, + "-m", + "nvcurve", + "serve", + "start", + "--host", + host, + "--port", + str(port), + ] if getattr(args, "gpu_index", 0): cmd += ["--gpu", str(args.gpu_index)] log_path = _log_file() @@ -1513,6 +1786,7 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None: json.dump({"pid": os.getpid(), "host": host, "port": port}, f) try: from .server import run as server_run + server_run( host=host, port=port, @@ -1527,8 +1801,10 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None: # ── Argument parser ─────────────────────────────────────────────────────────── + def build_parser() -> argparse.ArgumentParser: from importlib.metadata import version as pkg_version + try: __version__ = pkg_version("nvcurve") except Exception: @@ -1559,13 +1835,20 @@ Examples: %(prog)s inspect --point 80 Raw buffer fields for a point (needs root) """, ) - parser.add_argument("-v", "--version", action="version", version=f"nvcurve {__version__}") parser.add_argument( - "--server", default=None, metavar="URL", + "-v", "--version", action="version", version=f"nvcurve {__version__}" + ) + parser.add_argument( + "--server", + default=None, + metavar="URL", help="Server base URL (default: http://127.0.0.1:8042)", ) parser.add_argument( - "--gpu", type=int, default=0, dest="gpu_index", + "--gpu", + type=int, + default=0, + dest="gpu_index", help="GPU index to target (default: 0)", ) sub = parser.add_subparsers(dest="command") @@ -1574,14 +1857,19 @@ Examples: p_read = sub.add_parser("read", help="Read V/F curve") p_read.add_argument("--full", action="store_true", help="Show all points") p_read.add_argument("--json", action="store_true", help="JSON output") - p_read.add_argument("--raw", action="store_true", - help="Raw hex dumps of hardware buffers (needs root)") - p_read.add_argument("--diag", action="store_true", - help="Probe all NvAPI functions (needs root)") + p_read.add_argument( + "--raw", + action="store_true", + help="Raw hex dumps of hardware buffers (needs root)", + ) + p_read.add_argument( + "--diag", action="store_true", help="Probe all NvAPI functions (needs root)" + ) # inspect - p_insp = sub.add_parser("inspect", - help="Show raw ClockBoostTable buffer fields (needs root)") + p_insp = sub.add_parser( + "inspect", help="Show raw ClockBoostTable buffer fields (needs root)" + ) p_insp.add_argument("--point", type=int, help="Single point index") p_insp.add_argument("--range", type=parse_range, help="Point range A-B") @@ -1590,37 +1878,65 @@ Examples: tgt = p_write.add_mutually_exclusive_group() tgt.add_argument("--point", type=int, help="Single point index") tgt.add_argument("--range", type=parse_range, help="Point range A-B") - tgt.add_argument("--global", dest="glob", action="store_true", - help="All points (like global NVML offset)") + tgt.add_argument( + "--global", + dest="glob", + action="store_true", + help="All points (like global NVML offset)", + ) tgt.add_argument("--reset", action="store_true", help="Reset all offsets to 0") - p_write.add_argument("--delta", type=float, default=0.0, - help="Frequency offset in MHz (e.g. 15, -30)") - p_write.add_argument("--dry-run", action="store_true", - help="Preview changes without applying") - p_write.add_argument("--max-delta", type=float, default=None, - help="Override safety limit for this write (MHz)") + p_write.add_argument( + "--delta", + type=float, + default=0.0, + help="Frequency offset in MHz (e.g. 15, -30)", + ) + p_write.add_argument( + "--dry-run", action="store_true", help="Preview changes without applying" + ) + p_write.add_argument( + "--max-delta", + type=float, + default=None, + help="Override safety limit for this write (MHz)", + ) # verify p_ver = sub.add_parser("verify", help="Write-verify-read cycle") p_ver.add_argument("--point", type=int, help="Single point index") p_ver.add_argument("--range", type=parse_range, help="Point range A-B") - p_ver.add_argument("--delta", type=float, required=True, - help="Frequency offset in MHz") + p_ver.add_argument( + "--delta", type=float, required=True, help="Frequency offset in MHz" + ) # setup - p_setup = sub.add_parser("setup", - help="Hardware compatibility check: diag → read → write-verify → restore (needs root)") - p_setup.add_argument("--point", type=int, default=None, - help="Point index to use for write-verify test (default: last GPU-domain point)") - p_setup.add_argument("--delta", type=float, default=5.0, - help="Offset in MHz to use for write-verify test (default: +5)") - p_setup.add_argument("--full-mask", action="store_true", - help="Use the full GetClockBoostMask instead of a sparse mask " - "(try this if writes fail on older GPUs such as Pascal)") + p_setup = sub.add_parser( + "setup", + help="Hardware compatibility check: diag → read → write-verify → restore (needs root)", + ) + p_setup.add_argument( + "--point", + type=int, + default=None, + help="Point index to use for write-verify test (default: last GPU-domain point)", + ) + p_setup.add_argument( + "--delta", + type=float, + default=5.0, + help="Offset in MHz to use for write-verify test (default: +5)", + ) + p_setup.add_argument( + "--full-mask", + action="store_true", + help="Use the full GetClockBoostMask instead of a sparse mask " + "(try this if writes fail on older GPUs such as Pascal)", + ) # snapshot - p_snap = sub.add_parser("snapshot", - help="Save/restore/list ClockBoostTable snapshots") + p_snap = sub.add_parser( + "snapshot", help="Save/restore/list ClockBoostTable snapshots" + ) p_snap.add_argument("action", choices=["save", "restore", "list"]) p_snap.add_argument("--file", help="Snapshot file path (for restore)") @@ -1628,30 +1944,58 @@ Examples: sub.add_parser("gpus", help="List detected NVIDIA GPUs (server-optional)") # profile - p_prof = sub.add_parser("profile", help="Manage saved clock/limit profiles (server-optional)") + p_prof = sub.add_parser( + "profile", help="Manage saved clock/limit profiles (server-optional)" + ) p_prof.add_argument("action", choices=["save", "apply", "list", "default"]) p_prof.add_argument("name", nargs="?", help="Profile name (for save/apply/default)") - p_prof.add_argument("--clear", action="store_true", help="Clear the default profile (for default action)") + p_prof.add_argument( + "--clear", + action="store_true", + help="Clear the default profile (for default action)", + ) + + # user + p_user = sub.add_parser("user", help="Manage web UI users (authentication)") + u_user = p_user.add_subparsers(dest="action") + p_user_add = u_user.add_parser("add", help="Add a user (escalates to root)") + p_user_add.add_argument("username") + u_user.add_parser("list", help="List users") + p_user_remove = u_user.add_parser( + "remove", help="Remove a user (escalates to root)" + ) + p_user_remove.add_argument("username") + p_user_setpw = u_user.add_parser( + "set-password", help="Change a user's password (escalates to root)" + ) + p_user_setpw.add_argument("username") # daemon - sub.add_parser("daemon", help="Run the nvcurve daemon (apply auto-load profiles, requires root)") + sub.add_parser( + "daemon", + help="Run the nvcurve daemon (apply auto-load profiles, requires root)", + ) # autoload - sub.add_parser("autoload", help="Apply auto-load profiles from config (requires root)") + sub.add_parser( + "autoload", help="Apply auto-load profiles from config (requires root)" + ) # serve p_srv = sub.add_parser("serve", help="Start or manage the web server") s_srv = p_srv.add_subparsers(dest="action") p_start = s_srv.add_parser("start", help="Start the server (escalates to root)") - p_start.add_argument("--host", default="127.0.0.1", - help="Bind address (default 127.0.0.1)") - p_start.add_argument("--port", type=int, default=8042, - help="Port (default 8042)") - p_start.add_argument("--detach", "-d", action="store_true", - help="Run in background") - p_start.add_argument("--direct", action="store_true", - help=argparse.SUPPRESS) # internal: skip daemon check + p_start.add_argument( + "--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)" + ) + p_start.add_argument("--port", type=int, default=8042, help="Port (default 8042)") + p_start.add_argument( + "--detach", "-d", action="store_true", help="Run in background" + ) + p_start.add_argument( + "--direct", action="store_true", help=argparse.SUPPRESS + ) # internal: skip daemon check s_srv.add_parser("stop", help="Stop the running server") s_srv.add_parser("status", help="Check server status") @@ -1660,36 +2004,50 @@ Examples: p_svc = sub.add_parser("service", help="Manage the nvcurve systemd service") s_svc = p_svc.add_subparsers(dest="action") - p_install = s_svc.add_parser("install", - help="Register as systemd service (escalates to root)") - p_install.add_argument("--auto-serve", action="store_true", dest="auto_serve", - help="Auto-start web server on boot (default: off)") - p_install.add_argument("--host", default="127.0.0.1", - help="Default web server bind address (stored in config)") - p_install.add_argument("--port", type=int, default=8042, - help="Default web server port (stored in config)") + p_install = s_svc.add_parser( + "install", help="Register as systemd service (escalates to root)" + ) + p_install.add_argument( + "--auto-serve", + action="store_true", + dest="auto_serve", + help="Auto-start web server on boot (default: off)", + ) + p_install.add_argument( + "--host", + default="127.0.0.1", + help="Default web server bind address (stored in config)", + ) + p_install.add_argument( + "--port", + type=int, + default=8042, + help="Default web server port (stored in config)", + ) - p_configure = s_svc.add_parser("configure", - help="Update config and restart daemon (escalates to root)") - p_configure.add_argument("--auto-serve", dest="auto_serve", - action="store_true", default=None, - help="Auto-start web server on boot") - p_configure.add_argument("--no-auto-serve", dest="auto_serve", - action="store_false", - help="Do not auto-start web server on boot") - p_configure.add_argument("--host", default=None, - help="Web server bind address") - p_configure.add_argument("--port", type=int, default=None, - help="Web server port") + p_configure = s_svc.add_parser( + "configure", help="Update config and restart daemon (escalates to root)" + ) + p_configure.add_argument( + "--auto-serve", + dest="auto_serve", + action="store_true", + default=None, + help="Auto-start web server on boot", + ) + p_configure.add_argument( + "--no-auto-serve", + dest="auto_serve", + action="store_false", + help="Do not auto-start web server on boot", + ) + p_configure.add_argument("--host", default=None, help="Web server bind address") + p_configure.add_argument("--port", type=int, default=None, help="Web server port") - s_svc.add_parser("uninstall", - help="Remove systemd service (escalates to root)") - s_svc.add_parser("start", - help="Start systemd service (escalates to root)") - s_svc.add_parser("stop", - help="Stop systemd service (escalates to root)") - s_svc.add_parser("restart", - help="Restart systemd service (escalates to root)") + s_svc.add_parser("uninstall", help="Remove systemd service (escalates to root)") + s_svc.add_parser("start", help="Start systemd service (escalates to root)") + s_svc.add_parser("stop", help="Stop systemd service (escalates to root)") + s_svc.add_parser("restart", help="Restart systemd service (escalates to root)") s_svc.add_parser("status", help="Check systemd service status") return parser @@ -1697,15 +2055,16 @@ Examples: def main(): import sys + base_parser = argparse.ArgumentParser(add_help=False) base_parser.add_argument("--server", default=None) base_parser.add_argument("--gpu", type=int, default=0, dest="gpu_index") - + known_args, remaining_argv = base_parser.parse_known_args(sys.argv[1:]) - + parser = build_parser() args = parser.parse_args(remaining_argv) - + if known_args.server is not None: args.server = known_args.server if known_args.gpu_index != 0: @@ -1715,8 +2074,16 @@ def main(): try: with open(_PERSISTENT_CONFIG_FILE) as f: data = json.load(f) - for key in ("max_delta_khz", "auto_snapshot", "max_snapshots", - "snapshot_dir", "profile_dir", "host", "port"): + for key in ( + "max_delta_khz", + "auto_snapshot", + "max_snapshots", + "snapshot_dir", + "profile_dir", + "users_file", + "host", + "port", + ): if key in data: setattr(cfg, key, data[key]) if "auto_load_profiles" in data: @@ -1781,7 +2148,19 @@ def main(): except OSError: pass except ApiError as e: - print(f"Shutdown failed: {e.detail}", file=sys.stderr) + if e.status_code == 401: + print( + "Server requires authentication — cannot stop it via HTTP " + "without credentials.", + file=sys.stderr, + ) + print( + "Use the daemon (nvcurve service stop) or log in via the " + "web UI.", + file=sys.stderr, + ) + else: + print(f"Shutdown failed: {e.detail}", file=sys.stderr) elif action == "status": # Try daemon socket first. @@ -1791,7 +2170,7 @@ def main(): pid = resp.get("pid") print(f"Daemon: web server running (PID {pid}) at {base_url}") else: - print(f"Daemon: web server not running") + print("Daemon: web server not running") return # Fallback: check HTTP directly. if client.ping(): @@ -1810,11 +2189,13 @@ def main(): if args.command == "daemon": from .daemon import run as daemon_run + daemon_run() return if args.command == "autoload": from .profiles.apply import run_autoload + run_autoload() return @@ -1834,5 +2215,7 @@ def main(): cmd_gpus(args) elif args.command == "profile": cmd_profile(args) + elif args.command == "user": + cmd_user(args) elif args.command == "service": cmd_service(args) diff --git a/nvcurve/client.py b/nvcurve/client.py index cd3f243..bad5574 100644 --- a/nvcurve/client.py +++ b/nvcurve/client.py @@ -1,8 +1,9 @@ """HTTP client for communicating with a running nvcurve server.""" -import httpx from typing import Any +import httpx + DEFAULT_BASE = "http://127.0.0.1:8042" _TIMEOUT = 5.0 @@ -19,14 +20,20 @@ class ApiError(Exception): class NvCurveClient: - def __init__(self, base: str = DEFAULT_BASE, gpu_index: int = 0): + def __init__( + self, base: str = DEFAULT_BASE, gpu_index: int = 0, token: str | None = None + ): self._base = base.rstrip("/") self.gpu_index = gpu_index + self.token = token def _url(self, path: str) -> str: sep = "&" if "?" in path else "?" return f"{self._base}{path}{sep}gpu_index={self.gpu_index}" + def _headers(self) -> dict: + return {"Authorization": f"Bearer {self.token}"} if self.token else {} + def _raise(self, r: httpx.Response) -> None: if r.is_error: try: @@ -37,35 +44,58 @@ class NvCurveClient: def _get(self, path: str) -> Any: try: - r = httpx.get(self._url(path), timeout=_TIMEOUT) + r = httpx.get(self._url(path), headers=self._headers(), timeout=_TIMEOUT) except httpx.ConnectError: - raise ServerNotRunning() + raise ServerNotRunning() from None self._raise(r) return r.json() def _post(self, path: str, body: Any = None) -> Any: try: - r = httpx.post(self._url(path), json=body, timeout=_TIMEOUT) + r = httpx.post( + self._url(path), json=body, headers=self._headers(), timeout=_TIMEOUT + ) except httpx.ConnectError: - raise ServerNotRunning() + raise ServerNotRunning() from None self._raise(r) return r.json() def _delete(self, path: str) -> Any: try: - r = httpx.delete(self._url(path), timeout=_TIMEOUT) + r = httpx.delete(self._url(path), headers=self._headers(), timeout=_TIMEOUT) except httpx.ConnectError: - raise ServerNotRunning() + raise ServerNotRunning() from None self._raise(r) return r.json() def ping(self) -> bool: try: - httpx.get(self._url("/api/gpu"), timeout=1.0) + httpx.get(self._base + "/api/ping", timeout=1.0) return True except Exception: return False + # ── Auth ───────────────────────────────────────────────────────────────── + + def auth_status(self) -> dict: + """Returns {auth_required, authenticated, username, expires_at}.""" + return self._get("/api/auth/status") + + def login(self, username: str, password: str) -> dict: + """Authenticate and store the session token for subsequent calls.""" + try: + r = httpx.post( + f"{self._base}/api/auth/login", + json={"username": username, "password": password}, + timeout=_TIMEOUT, + ) + except httpx.ConnectError: + raise ServerNotRunning() from None + self._raise(r) + data = r.json() + self.token = data.get("token") + return data + def gpus(self) -> list: return self._get("/api/gpus") @@ -140,7 +170,10 @@ class NvCurveClient: return self._get("/api/config") def config_update(self, auto_load_profile: str | None, gpu_index: int = 0) -> dict: - return self._post("/api/config", {"auto_load_profile": auto_load_profile, "gpu_index": gpu_index}) + return self._post( + "/api/config", + {"auto_load_profile": auto_load_profile, "gpu_index": gpu_index}, + ) # ── Server control ─────────────────────────────────────────────────────── diff --git a/nvcurve/config.py b/nvcurve/config.py index a7ec634..99baccb 100644 --- a/nvcurve/config.py +++ b/nvcurve/config.py @@ -1,18 +1,17 @@ """User-configurable settings with sensible defaults.""" -import os from dataclasses import dataclass, field @dataclass class Config: # Safety limits - max_delta_khz: int = 3000_000 # ±3000 MHz hard cap - auto_snapshot: bool = True # Save snapshot before every write - max_snapshots: int = 20 # Maximum snapshots to keep (0 = unlimited) + max_delta_khz: int = 3000_000 # ±3000 MHz hard cap + auto_snapshot: bool = True # Save snapshot before every write + max_snapshots: int = 20 # Maximum snapshots to keep (0 = unlimited) # Monitoring - poll_interval_s: float = 1.0 # WebSocket monitor poll rate + poll_interval_s: float = 1.0 # WebSocket monitor poll rate # API server host: str = "127.0.0.1" @@ -21,6 +20,11 @@ class Config: snapshot_dir: str = "/var/cache/nvcurve/snapshots" profile_dir: str = "/etc/nvcurve/profiles" + # Authentication: path to the bcrypt user store. If the file exists and + # contains at least one user, the API requires login (dual mode). If it is + # absent or empty, the server runs with no authentication. + users_file: str = "/etc/nvcurve/users.json" + # Per-GPU default profiles: applied automatically on server startup. # Key = stable GPU identifier (UUID string, "pci:{bus_id}", or "idx:{n}" fallback). # Value = profile name (str). diff --git a/nvcurve/daemon.py b/nvcurve/daemon.py index 7ca7cbd..52f0439 100644 --- a/nvcurve/daemon.py +++ b/nvcurve/daemon.py @@ -15,6 +15,7 @@ Requires root. """ import asyncio +import contextlib import json import logging import os @@ -22,6 +23,8 @@ import signal import subprocess import sys +from .config import Config + log = logging.getLogger("nvcurve.daemon") SOCKET_PATH = "/run/nvcurve-daemon.sock" @@ -29,27 +32,45 @@ _PERSISTENT_CONFIG_FILE = "/etc/nvcurve/config.json" # Global server subprocess — only touched from the asyncio event loop. _server_proc: subprocess.Popen | None = None -_cfg = None # Config instance, set in run() +_cfg: Config | None = None # Config instance, set in run() # ── Socket command handlers ──────────────────────────────────────────────────── + async def _handle_serve_start(host: str, port: int) -> dict: global _server_proc if _server_proc is not None and _server_proc.poll() is None: - return {"ok": False, "error": "web server already running", "pid": _server_proc.pid} + return { + "ok": False, + "error": "web server already running", + "pid": _server_proc.pid, + } - cmd = [sys.executable, "-m", "nvcurve", "serve", "start", - "--host", host, "--port", str(port), "--direct"] + cmd = [ + sys.executable, + "-m", + "nvcurve", + "serve", + "start", + "--host", + host, + "--port", + str(port), + "--direct", + ] log_path = "/var/log/nvcurve-server.log" log.info("Starting web server on %s:%d (log: %s)", host, port, log_path) - with open(log_path, "a") as lf: - _server_proc = subprocess.Popen( - cmd, - stdout=lf, - stderr=lf, - env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, - ) + try: + with open(log_path, "a") as lf: + _server_proc = subprocess.Popen( + cmd, + stdout=lf, + stderr=lf, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + except OSError as exc: + return {"ok": False, "error": f"cannot open log file {log_path}: {exc}"} log.info("Web server started (PID %d)", _server_proc.pid) return {"ok": True, "pid": _server_proc.pid} @@ -62,9 +83,7 @@ async def _handle_serve_stop() -> dict: log.info("Stopping web server (PID %d)…", _server_proc.pid) _server_proc.terminate() try: - await asyncio.get_running_loop().run_in_executor( - None, _server_proc.wait, 5 - ) + await asyncio.get_running_loop().run_in_executor(None, _server_proc.wait, 5) except Exception: _server_proc.kill() _server_proc = None @@ -84,6 +103,8 @@ async def _dispatch(req: dict) -> dict: if cmd == "ping": return {"ok": True} elif cmd == "serve_start": + if _cfg is None: + return {"ok": False, "error": "config not initialized"} host = req.get("host", _cfg.host) port = req.get("port", _cfg.port) return await _handle_serve_start(host, port) @@ -110,20 +131,17 @@ async def _handle_client( except Exception as exc: resp = {"ok": False, "error": str(exc)} finally: - try: + with contextlib.suppress(Exception): writer.write(json.dumps(resp).encode() + b"\n") await writer.drain() - except Exception: - pass writer.close() - try: + with contextlib.suppress(Exception): await writer.wait_closed() - except Exception: - pass # ── Entrypoint ───────────────────────────────────────────────────────────────── + def _load_persistent_config() -> dict: try: with open(_PERSISTENT_CONFIG_FILE) as f: @@ -147,10 +165,16 @@ def run() -> None: cfg_data = _load_persistent_config() - from .config import Config _cfg = Config() - for key in ("max_delta_khz", "auto_snapshot", "max_snapshots", - "snapshot_dir", "profile_dir", "host", "port"): + for key in ( + "max_delta_khz", + "auto_snapshot", + "max_snapshots", + "snapshot_dir", + "profile_dir", + "host", + "port", + ): if key in cfg_data: setattr(_cfg, key, cfg_data[key]) @@ -175,15 +199,27 @@ async def _serve_socket(auto_serve: bool = False) -> None: # Clean up stale socket from a previous (unclean) run. if os.path.exists(SOCKET_PATH): - os.unlink(SOCKET_PATH) + try: + os.unlink(SOCKET_PATH) + except OSError as exc: + log.warning("Could not remove stale socket %s: %s", SOCKET_PATH, exc) server = await asyncio.start_unix_server(_handle_client, path=SOCKET_PATH) - os.chmod(SOCKET_PATH, 0o666) # allow non-root CLI to connect + # The socket must be connectable by unprivileged users: the CLI runs as the + # regular user and talks to this root daemon over the socket. 0o666 is + # intentional (standard for /run daemon sockets). + # pi-lens-ignore: S103 + os.chmod( + SOCKET_PATH, 0o666 + ) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions log.info("Daemon listening on %s", SOCKET_PATH) if auto_serve: - log.info("auto_serve enabled — starting web server on boot") - await _handle_serve_start(_cfg.host, _cfg.port) + if _cfg is None: + log.warning("auto_serve requested but config not initialized") + else: + log.info("auto_serve enabled — starting web server on boot") + await _handle_serve_start(_cfg.host, _cfg.port) stop_event = asyncio.Event() loop = asyncio.get_running_loop() @@ -206,4 +242,7 @@ async def _serve_socket(auto_serve: bool = False) -> None: _server_proc.kill() if os.path.exists(SOCKET_PATH): - os.unlink(SOCKET_PATH) + try: + os.unlink(SOCKET_PATH) + except OSError as exc: + log.warning("Could not remove socket %s on shutdown: %s", SOCKET_PATH, exc) diff --git a/nvcurve/server.py b/nvcurve/server.py index 81edbc1..cc89730 100644 --- a/nvcurve/server.py +++ b/nvcurve/server.py @@ -7,17 +7,36 @@ Requires root (NvAPI needs it). """ import asyncio -import json import logging +import os from contextlib import asynccontextmanager +from pathlib import Path from typing import Any -from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel +from . import auth from .config import Config, default_config -from .hal.gpu import get_gpu, discover_gpus +from .hal.fans import ( + get_fan_info, + get_temp, + interpolate_fan_speed, + reset_fan, + set_fan_speed, + validate_curve, +) +from .hal.gpu import discover_gpus, get_gpu +from .hal.limits import ( + get_clock_offsets, + get_mem_offset_range, + get_power_limit, + set_clock_offsets, + set_power_limit, +) from .hal.monitoring import ( get_driver_version, get_vram_total, @@ -28,41 +47,29 @@ from .hal.monitoring import ( from .hal.ranges import get_clock_ranges from .hal.snapshot import ( list_snapshots, +) +from .hal.snapshot import ( restore as snapshot_restore, +) +from .hal.snapshot import ( save as snapshot_save, ) -from .hal.limits import ( - get_power_limit, - set_power_limit, - get_clock_offsets, - set_clock_offsets, - get_mem_offset_range, -) -from .hal.fans import ( - get_fan_info, - set_fan_speed, - reset_fan, - get_temp, - interpolate_fan_speed, - validate_curve, -) -from .profiles.native import ( - ProfileData, - save_profile, - load_profile, - list_profiles, - delete_profile, - rename_profile, -) from .hal.vfcurve import ( read_clock_offsets, read_curve, - read_vfp_curve, reset_offsets, write_global_offset, write_offsets, ) -from .safety import validate_write, check_negative_freq_warnings +from .profiles.native import ( + ProfileData, + delete_profile, + list_profiles, + load_profile, + rename_profile, + save_profile, +) +from .safety import check_negative_freq_warnings, validate_write log = logging.getLogger("nvcurve.server") @@ -71,6 +78,7 @@ def _open_browser_as_user(url: str) -> None: """Open URL as the original (non-root) user when running under sudo.""" import os import subprocess + sudo_user = os.environ.get("SUDO_USER") if sudo_user and os.geteuid() == 0: try: @@ -83,24 +91,29 @@ def _open_browser_as_user(url: str) -> None: except Exception: pass import webbrowser + webbrowser.open(url) + # ── Shared app state ────────────────────────────────────────────────────────── _state: dict[str, Any] = { - "gpus": {}, # dict[int, dict] mapping gpu_index -> gpu state + "gpus": {}, # dict[int, dict] mapping gpu_index -> gpu state "config": default_config, } + def _get_gpu_state(gpu_index: int) -> dict: if gpu_index not in _state["gpus"]: from fastapi import HTTPException + raise HTTPException(status_code=404, detail=f"GPU {gpu_index} not found") return _state["gpus"][gpu_index] # ── Serialization helpers ───────────────────────────────────────────────────── + def _vfpoint_dict(p) -> dict: return { "index": p.index, @@ -138,8 +151,12 @@ def _sample_dict(s) -> dict: "pstate_label": f"P{s.pstate}" if s.pstate is not None else None, "mem_used_bytes": s.mem_used_bytes, "mem_total_bytes": s.mem_total_bytes, - "mem_used_mib": round(s.mem_used_bytes / (1024 ** 2), 1) if s.mem_used_bytes is not None else None, - "mem_total_mib": round(s.mem_total_bytes / (1024 ** 2), 1) if s.mem_total_bytes is not None else None, + "mem_used_mib": round(s.mem_used_bytes / (1024**2), 1) + if s.mem_used_bytes is not None + else None, + "mem_total_mib": round(s.mem_total_bytes / (1024**2), 1) + if s.mem_total_bytes is not None + else None, "gpu_util_pct": s.gpu_util_pct, "mem_util_pct": s.mem_util_pct, } @@ -147,6 +164,7 @@ def _sample_dict(s) -> dict: # ── WebSocket broadcast ─────────────────────────────────────────────────────── + async def _broadcast(clients: set, payload: dict) -> None: """Send JSON payload to all connected WebSocket clients, evict dead ones.""" dead = set() @@ -160,6 +178,7 @@ async def _broadcast(clients: set, payload: dict) -> None: # ── Background monitoring poller ───────────────────────────────────────────── + async def _monitor_poller(gpu_index: int) -> None: """Continuously poll GPU state and push to connected monitor WebSocket clients.""" cfg: Config = _state["config"] @@ -198,6 +217,7 @@ async def _fan_poller(gpu_index: int) -> None: # ── Lifespan ────────────────────────────────────────────────────────────────── + @asynccontextmanager async def lifespan(app: FastAPI): loop = asyncio.get_running_loop() @@ -208,7 +228,7 @@ async def lifespan(app: FastAPI): gpu_infos = await loop.run_in_executor(None, discover_gpus) if not gpu_infos: log.warning("No GPUs discovered.") - + poller_tasks = [] for info in gpu_infos: @@ -228,18 +248,18 @@ async def lifespan(app: FastAPI): "fan_poller_task": None, } _state["gpus"][idx] = g_state - + try: gpu, name = await loop.run_in_executor(None, get_gpu, idx) g_state["gpu"] = gpu g_state["gpu_name"] = name log.info("GPU %d: %s", idx, name) - + # Read initial offsets for reconciliation baseline offsets, err = await loop.run_in_executor(None, read_clock_offsets, gpu) if offsets: g_state["last_offsets"] = offsets - + poller_tasks.append(asyncio.create_task(_monitor_poller(idx))) except Exception as exc: log.error("Failed to initialize GPU %d: %s", idx, exc) @@ -260,16 +280,28 @@ async def lifespan(app: FastAPI): if gpu_idx is None: log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key) continue - log.info("Auto-loading profile %r on GPU %d (%s) [compat path]", - profile_name, gpu_idx, gpu_key) + log.info( + "Auto-loading profile %r on GPU %d (%s) [compat path]", + profile_name, + gpu_idx, + gpu_key, + ) try: await _auto_apply_profile_with_retry(profile_name, gpu_idx) except FileNotFoundError: - log.warning("Auto-load profile %r not found in %s — skipping GPU %d", - profile_name, cfg.profile_dir, gpu_idx) + log.warning( + "Auto-load profile %r not found in %s — skipping GPU %d", + profile_name, + cfg.profile_dir, + gpu_idx, + ) except Exception as exc: - log.warning("Auto-load profile %r failed on GPU %d: %s — skipping", - profile_name, gpu_idx, exc) + log.warning( + "Auto-load profile %r failed on GPU %d: %s — skipping", + profile_name, + gpu_idx, + exc, + ) # ────────────────────────────────────────────────────────────────────────── yield # server is running @@ -294,10 +326,15 @@ async def lifespan(app: FastAPI): g_state["fan_curve"] = None try: await loop.run_in_executor(None, reset_fan, gpu_index) - log.info("GPU %d: restored automatic fan control on shutdown", gpu_index) + log.info( + "GPU %d: restored automatic fan control on shutdown", gpu_index + ) except Exception as exc: - log.warning("GPU %d: failed to restore automatic fan control on shutdown: %s", - gpu_index, exc) + log.warning( + "GPU %d: failed to restore automatic fan control on shutdown: %s", + gpu_index, + exc, + ) await loop.run_in_executor(None, shutdown_nvml) @@ -314,10 +351,55 @@ app.add_middleware( ) +# ── Authentication middleware (dual mode) ───────────────────────────────────── +# When the user store contains at least one user, every /api/* endpoint requires +# a valid session (cookie or Bearer token). With no users configured, the API is +# open — exactly like before. Static files (the SPA, including the login page) +# and the public auth/ping endpoints are always reachable. + +PUBLIC_API_PATHS = { + "/api/ping", + "/api/auth/login", + "/api/auth/status", + "/api/auth/logout", +} + + +class AuthMiddleware: + """Require a valid session for /api/* when authentication is enabled.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + path = scope["path"] + if ( + path.startswith("/api/") + and path not in PUBLIC_API_PATHS + and scope.get("method") != "OPTIONS" + ): + cfg: Config = _state["config"] + if auth.auth_enabled(cfg.users_file): + token = auth.extract_token(Request(scope)) + if auth.get_session(token) is None: + response = JSONResponse( + {"detail": "Authentication required"}, + status_code=401, + ) + await response(scope, receive, send) + return + await self.app(scope, receive, send) + + +app.add_middleware(AuthMiddleware) + + # ── Request models ──────────────────────────────────────────────────────────── + class WriteRequest(BaseModel): - deltas: dict[int, int] # {point_index: delta_kHz} + deltas: dict[int, int] # {point_index: delta_kHz} max_delta_khz: int | None = None # per-request safety limit override @@ -327,7 +409,7 @@ class GlobalOffsetRequest(BaseModel): class VerifyRequest(BaseModel): - deltas: dict[int, int] # {point_index: delta_kHz} — pre-expanded by CLI + deltas: dict[int, int] # {point_index: delta_kHz} — pre-expanded by CLI class SnapshotRestoreRequest(BaseModel): @@ -365,13 +447,105 @@ class FanSpeedRequest(BaseModel): fan_pct: int +class LoginRequest(BaseModel): + username: str + password: str + + # ── Helper: run blocking HAL call in thread pool ────────────────────────────── + async def _run(fn, *args): loop = asyncio.get_running_loop() return await loop.run_in_executor(None, fn, *args) +# ── Auth endpoints ──────────────────────────────────────────────────────────── + + +@app.get("/api/ping") +async def api_ping(): + """Public liveness probe (no auth). Used by the CLI to detect a running server.""" + return {"ok": True} + + +@app.get("/api/auth/status") +async def api_auth_status(request: Request): + """Report whether auth is required and whether this request is authenticated.""" + cfg: Config = _state["config"] + required = auth.auth_enabled(cfg.users_file) + token = auth.extract_token(request) + username = auth.get_session(token) if required else None + expires_at = auth.get_session_expires_at(token) if username else None + return { + "auth_required": required, + "authenticated": username is not None, + "username": username, + "expires_at": expires_at, + } + + +@app.post("/api/auth/login") +async def api_auth_login(req: LoginRequest, request: Request): + """Authenticate with username+password. Sets a 24-hour session cookie. + + The plaintext password is checked against the stored bcrypt hash and never + persisted. On success a random session token is returned (for Bearer use) + and set as an HttpOnly cookie (for browser use). + """ + cfg: Config = _state["config"] + users = auth.load_users(cfg.users_file) + if not users: + raise HTTPException(status_code=404, detail="Authentication is not enabled") + + client_ip = request.client.host if request.client else "unknown" + if auth.is_locked_out(client_ip): + raise HTTPException( + status_code=429, detail="Too many failed attempts. Try again later." + ) + + if auth.check_credentials(users, req.username, req.password): + auth.clear_failures(client_ip) + token, expires_at = auth.create_session(req.username) + response = JSONResponse( + { + "ok": True, + "username": req.username, + "expires_at": expires_at, + "token": token, + } + ) + response.set_cookie( + auth.COOKIE_NAME, + token, + max_age=auth.SESSION_TTL_S, + httponly=True, + samesite="lax", + path="/", + ) + return response + + auth.record_failure(client_ip) + raise HTTPException(status_code=401, detail="Invalid username or password") + + +@app.post("/api/auth/logout") +async def api_auth_logout(request: Request): + """End the current session (idempotent).""" + token = auth.extract_token(request) + auth.destroy_session(token) + response = JSONResponse({"ok": True}) + response.delete_cookie(auth.COOKIE_NAME, path="/") + return response + + +@app.get("/api/auth/users") +async def api_auth_users(): + """List configured usernames (requires auth).""" + cfg: Config = _state["config"] + return {"users": auth.list_users(cfg.users_file)} + + def _require_gpu(gpu_index: int = 0): g_state = _get_gpu_state(gpu_index) gpu = g_state["gpu"] @@ -382,10 +556,12 @@ def _require_gpu(gpu_index: int = 0): # ── REST endpoints ──────────────────────────────────────────────────────────── + @app.get("/api/gpus") async def api_gpus(): """List all discovered GPUs.""" from .hal.gpu import discover_gpus + gpu_infos = await _run(discover_gpus) return [ { @@ -397,6 +573,7 @@ async def api_gpus(): for info in gpu_infos ] + @app.get("/api/gpu") async def api_gpu(gpu_index: int = 0): """GPU info: name, driver version, VRAM.""" @@ -408,7 +585,7 @@ async def api_gpu(gpu_index: int = 0): "index": gpu_index, "driver_version": driver, "vram_bytes": vram, - "vram_gib": round(vram / (1024 ** 3), 2) if vram else None, + "vram_gib": round(vram / (1024**3), 2) if vram else None, } @@ -433,7 +610,9 @@ async def api_curve_point(point: int, gpu_index: int = 0): if state is None: raise HTTPException(status_code=500, detail=f"Failed to read curve: {err}") if point < 0 or point >= len(state.points): - raise HTTPException(status_code=400, detail=f"Point index must be 0–{len(state.points)-1}") + raise HTTPException( + status_code=400, detail=f"Point index must be 0–{len(state.points) - 1}" + ) return _vfpoint_dict(state.points[point]) @@ -451,6 +630,7 @@ async def api_ranges(gpu_index: int = 0): async def api_voltage(gpu_index: int = 0): """Current GPU core voltage.""" from .hal.monitoring import read_voltage + gpu, g_state = _require_gpu(gpu_index) voltage_uv, err = await _run(read_voltage, gpu) if voltage_uv is None: @@ -492,6 +672,7 @@ def _persist_config_field(key: str, value) -> None: """ import json as _json import os as _os + config_path = "/etc/nvcurve/config.json" if not _os.path.exists(config_path): return @@ -589,6 +770,7 @@ async def _auto_apply_profile_with_retry( Logs a warning and gives up after max_retries failed attempts. """ import os as _os + cfg: Config = _state["config"] g_state = _get_gpu_state(gpu_index) gpu = g_state["gpu"] @@ -598,43 +780,69 @@ async def _auto_apply_profile_with_retry( profile = await _run(load_profile, filepath) # raises FileNotFoundError if missing expected: dict[int, int] = ( - {int(k): v for k, v in profile.curve_deltas.items()} if profile.curve_deltas else {} + {int(k): v for k, v in profile.curve_deltas.items()} + if profile.curve_deltas + else {} ) for attempt in range(max_retries): errs = await _apply_profile(name, gpu_index) if errs: - log.warning("Auto-load attempt %d/%d had errors: %s", - attempt + 1, max_retries, "; ".join(errs)) + log.warning( + "Auto-load attempt %d/%d had errors: %s", + attempt + 1, + max_retries, + "; ".join(errs), + ) elif expected: offsets, err = await _run(read_clock_offsets, gpu) if offsets is None: - log.warning("Auto-load attempt %d/%d: read-back failed: %s", - attempt + 1, max_retries, err) + log.warning( + "Auto-load attempt %d/%d: read-back failed: %s", + attempt + 1, + max_retries, + err, + ) else: mismatches = [ - f"pt{idx}: expected {val/1000:+.0f}MHz got {offsets[idx]/1000:+.0f}MHz" + f"pt{idx}: expected {val / 1000:+.0f}MHz got {offsets[idx] / 1000:+.0f}MHz" for idx, val in expected.items() if idx < len(offsets) and offsets[idx] != val ] if not mismatches: - log.info("Auto-load profile %r verified on GPU %d (attempt %d/%d)", - name, gpu_index, attempt + 1, max_retries) + log.info( + "Auto-load profile %r verified on GPU %d (attempt %d/%d)", + name, + gpu_index, + attempt + 1, + max_retries, + ) return - log.warning("Auto-load attempt %d/%d: read-back mismatch — %s", - attempt + 1, max_retries, "; ".join(mismatches)) + log.warning( + "Auto-load attempt %d/%d: read-back mismatch — %s", + attempt + 1, + max_retries, + "; ".join(mismatches), + ) else: - log.info("Auto-load profile %r applied on GPU %d (attempt %d/%d)", - name, gpu_index, attempt + 1, max_retries) + log.info( + "Auto-load profile %r applied on GPU %d (attempt %d/%d)", + name, + gpu_index, + attempt + 1, + max_retries, + ) return if attempt < max_retries - 1: - delay = 2 ** attempt # 1 s, 2 s, 4 s + delay = 2**attempt # 1 s, 2 s, 4 s log.info("Retrying auto-load in %ds…", delay) await asyncio.sleep(delay) - log.warning("Auto-load profile %r failed after %d attempts — giving up", name, max_retries) + log.warning( + "Auto-load profile %r failed after %d attempts — giving up", name, max_retries + ) async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]: @@ -645,6 +853,7 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]: Sets g_state["active_profile"] on full success. """ import os as _os + g_state = _get_gpu_state(gpu_index) gpu = g_state["gpu"] cfg: Config = _state["config"] @@ -677,7 +886,13 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]: errs.append("Curve: " + "; ".join(errors)) else: if cfg.auto_snapshot: - await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + await _run( + snapshot_save, + gpu, + g_state["gpu_name"], + cfg.snapshot_dir, + cfg.max_snapshots, + ) ret, desc = await _run(write_offsets, gpu, deltas) if ret != 0: errs.append(f"Curve write failed ({ret}): {desc}") @@ -748,7 +963,9 @@ async def api_profile_delete(name: str): g_state["active_profile"] = None changed = any(v == name for v in cfg.auto_load_profiles.values()) if changed: - cfg.auto_load_profiles = {k: v for k, v in cfg.auto_load_profiles.items() if v != name} + cfg.auto_load_profiles = { + k: v for k, v in cfg.auto_load_profiles.items() if v != name + } _persist_auto_load_profiles(cfg.auto_load_profiles) return {"ok": True} @@ -805,8 +1022,8 @@ async def api_limits(gpu_index: int = 0): mem_off_range = await _run(get_mem_offset_range, gpu_index) return { **power, - **offsets, # gpc_offset_mhz, mem_offset_mhz - **mem_off_range, # min_mem_offset_mhz, max_mem_offset_mhz + **offsets, # gpc_offset_mhz, mem_offset_mhz + **mem_off_range, # min_mem_offset_mhz, max_mem_offset_mhz } @@ -905,6 +1122,7 @@ async def api_limits_reset(gpu_index: int = 0): # ── Fan endpoints ────────────────────────────────────────────────────────────── + @app.get("/api/fans") async def api_fans(gpu_index: int = 0): """Current fan state: fan %, curve, and whether curve control is active.""" @@ -935,12 +1153,18 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0): # set to an inappropriate speed. if curve_data: sample = await _run(poll, g_state["gpu"], gpu_index) - test_temp = sample.temp_c if sample and sample.temp_c is not None else curve_data[0]["temp_c"] + test_temp = ( + sample.temp_c + if sample and sample.temp_c is not None + else curve_data[0]["temp_c"] + ) target = interpolate_fan_speed(curve_data, test_temp) if target is not None: fan_ok, fan_msg = await _run(set_fan_speed, gpu_index, target) if not fan_ok: - raise HTTPException(status_code=500, detail=f"Fan control not available: {fan_msg}") + raise HTTPException( + status_code=500, detail=f"Fan control not available: {fan_msg}" + ) # Stop existing poller if running if g_state.get("fan_poller_task"): @@ -994,6 +1218,7 @@ async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0): # ── Write endpoints ──────────────────────────────────────────────────────────── + async def _reconcile_check(gpu_index: int) -> dict | None: """Re-read current offsets and return a warning dict if they differ from our last known state. @@ -1035,7 +1260,9 @@ async def api_curve_write(req: WriteRequest, gpu_index: int = 0): vfp_state, _ = await _run(read_curve, gpu, g_state["gpu_name"]) - effective_limit = req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz + effective_limit = ( + req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz + ) errors = validate_write(req.deltas, effective_limit) if errors: raise HTTPException(status_code=400, detail={"errors": errors}) @@ -1052,7 +1279,13 @@ async def api_curve_write(req: WriteRequest, gpu_index: int = 0): warning = await _reconcile_check(gpu_index) if cfg.auto_snapshot: - await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + await _run( + snapshot_save, + gpu, + g_state["gpu_name"], + cfg.snapshot_dir, + cfg.max_snapshots, + ) ret, desc = await _run(write_offsets, gpu, req.deltas) if ret != 0: @@ -1081,7 +1314,9 @@ async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0): raise HTTPException(status_code=500, detail="Failed to read curve") all_deltas = {p.index: req.delta_khz for p in vfp_state.points if p.domain == "gpu"} - effective_limit = req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz + effective_limit = ( + req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz + ) errors = validate_write(all_deltas, effective_limit) if errors: raise HTTPException(status_code=400, detail={"errors": errors}) @@ -1097,7 +1332,13 @@ async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0): warning = await _reconcile_check(gpu_index) if cfg.auto_snapshot: - await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + await _run( + snapshot_save, + gpu, + g_state["gpu_name"], + cfg.snapshot_dir, + cfg.max_snapshots, + ) ret, desc = await _run(write_global_offset, gpu, req.delta_khz) if ret != 0: @@ -1124,7 +1365,13 @@ async def api_curve_reset(gpu_index: int = 0): warning = await _reconcile_check(gpu_index) if cfg.auto_snapshot: - await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + await _run( + snapshot_save, + gpu, + g_state["gpu_name"], + cfg.snapshot_dir, + cfg.max_snapshots, + ) ret, desc = await _run(reset_offsets, gpu) if ret != 0: @@ -1151,10 +1398,14 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0): before_offsets, err = await _run(read_clock_offsets, gpu) if before_offsets is None: - raise HTTPException(status_code=500, detail=f"Failed to read current state: {err}") + raise HTTPException( + status_code=500, detail=f"Failed to read current state: {err}" + ) # Always snapshot before verify — it's a testing operation - await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + await _run( + snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots + ) async with g_state["write_lock"]: ret, desc = await _run(write_offsets, gpu, req.deltas) @@ -1165,7 +1416,9 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0): after_offsets, err = await _run(read_clock_offsets, gpu) if after_offsets is None: - raise HTTPException(status_code=500, detail=f"Verification read failed: {err}") + raise HTTPException( + status_code=500, detail=f"Verification read failed: {err}" + ) g_state["active_profile"] = None await _update_offsets_and_broadcast(gpu_index) @@ -1177,12 +1430,14 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0): match = actual == expected if not match: all_matched = False - points_result.append({ - "point": point, - "expected_khz": expected, - "actual_khz": actual, - "match": match, - }) + points_result.append( + { + "point": point, + "expected_khz": expected, + "actual_khz": actual, + "match": match, + } + ) collateral = [ {"point": i, "before_khz": before_offsets[i], "after_khz": after_offsets[i]} @@ -1206,6 +1461,7 @@ async def api_shutdown(): """Gracefully shut down the server process.""" import os import signal + loop = asyncio.get_running_loop() loop.call_later(0.1, lambda: os.kill(os.getpid(), signal.SIGTERM)) return {"ok": True} @@ -1216,7 +1472,9 @@ async def api_snapshot_save(gpu_index: int = 0): """Save a ClockBoostTable snapshot.""" gpu, g_state = _require_gpu(gpu_index) cfg: Config = _state["config"] - path = await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + path = await _run( + snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots + ) if path is None: raise HTTPException(status_code=500, detail="Failed to save snapshot") return {"ok": True, "filepath": path} @@ -1241,9 +1499,27 @@ async def api_snapshot_restore(req: SnapshotRestoreRequest, gpu_index: int = 0): # ── WebSocket endpoints ─────────────────────────────────────────────────────── + +def _ws_authenticated(ws: WebSocket) -> bool: + """True if the WebSocket connection is allowed (auth disabled or valid session). + + The session token is read from the Authorization header or the cookie + (browsers send the cookie automatically on the WS handshake). The token is + deliberately NOT accepted via a query string, since uvicorn's access log + records the full path including the query string. + """ + cfg: Config = _state["config"] + if not auth.auth_enabled(cfg.users_file): + return True + return auth.get_session(auth.extract_token(ws)) is not None + + @app.websocket("/ws/monitor") async def ws_monitor(ws: WebSocket): """Stream MonitoringSample at poll_interval_s. Clients receive JSON objects.""" + if not _ws_authenticated(ws): + await ws.close(code=1008) + return await ws.accept() try: data = await ws.receive_json() @@ -1274,7 +1550,7 @@ async def ws_monitor(ws: WebSocket): except WebSocketDisconnect: pass except Exception: - pass + log.debug("monitor ws client error", exc_info=True) finally: g_state["monitor_clients"].discard(ws) @@ -1282,6 +1558,9 @@ async def ws_monitor(ws: WebSocket): @app.websocket("/ws/curve") async def ws_curve(ws: WebSocket): """Push CurveState whenever the curve changes (after writes).""" + if not _ws_authenticated(ws): + await ws.close(code=1008) + return await ws.accept() try: data = await ws.receive_json() @@ -1313,47 +1592,54 @@ async def ws_curve(ws: WebSocket): except WebSocketDisconnect: pass except Exception: - pass + log.debug("curve ws client error", exc_info=True) finally: g_state["curve_clients"].discard(ws) # ── Frontend SPA ────────────────────────────────────────────────────────────── -import os -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse -from pathlib import Path # When set, suppresses the auto-open browser behaviour so the dev can open # the Vite dev server (pnpm dev) manually instead. _DEV_PORT = os.environ.get("NVCURVE_DEV_PORT") -# Robust asset resolution using importlib.resources -try: - from importlib.resources import files as _resource_files - # In a packaged installation, frontend/dist is inside the package - _dist_dir = _resource_files("nvcurve") / "frontend" / "dist" - - # Fallback for local development where frontend/dist might be at project root - if not _dist_dir.is_dir(): - _here = Path(__file__).parent - _dist_dir = _here.parent / "frontend" / "dist" -except (ImportError, TypeError): - # Legacy fallback for older Python or environments without importlib.resources.files - _here = Path(__file__).parent - _dist_dir = _here / "frontend" / "dist" - if not _dist_dir.is_dir(): - _dist_dir = _here.parent / "frontend" / "dist" -_dist_dir = str(_dist_dir) +def _resolve_dist_dir() -> str: + """Resolve the frontend dist directory to a string path. + + Prefers the packaged location (importlib.resources), then falls back to + the project-root layout used during local development. + """ + try: + from importlib.resources import files as _resource_files + + candidate = _resource_files("nvcurve") / "frontend" / "dist" + if candidate.is_dir(): + return str(candidate) + except (ImportError, TypeError): + pass + here = Path(__file__).parent + for base in (here, here.parent): + dist = base / "frontend" / "dist" + if dist.is_dir(): + return str(dist) + return str(here / "frontend" / "dist") + + +_dist_dir = _resolve_dist_dir() if os.path.isdir(os.path.join(_dist_dir, "assets")): - app.mount("/assets", StaticFiles(directory=os.path.join(_dist_dir, "assets")), name="assets") + app.mount( + "/assets", + StaticFiles(directory=os.path.join(_dist_dir, "assets")), + name="assets", + ) + @app.get("/{catchall:path}") async def serve_spa(catchall: str): - if catchall.startswith("api/") or catchall.startswith("ws/"): + if catchall.startswith(("api/", "ws/")): raise HTTPException(status_code=404, detail="Not Found") if not os.path.isdir(_dist_dir): @@ -1372,6 +1658,7 @@ async def serve_spa(catchall: str): # ── Factory for configured app ──────────────────────────────────────────────── + def create_app(config: Config = default_config) -> FastAPI: """Create a server app with a custom config (e.g. different gpu_index).""" _state["config"] = config @@ -1388,6 +1675,7 @@ def run( """Start the uvicorn server. Blocking.""" import socket import threading + import uvicorn _state["config"] = config @@ -1404,7 +1692,9 @@ def run( s.bind((host, port)) except OSError: print(f"Error: port {port} is already in use.") - print(f"Use --port N to specify a different port, or free port {port} first.") + print( + f"Use --port N to specify a different port, or free port {port} first." + ) return url = f"http://{host}:{port}" diff --git a/pyproject.toml b/pyproject.toml index 7322ece..f397960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "nvidia-ml-py>=12.0", "pydantic>=2.0", "httpx>=0.27", + "bcrypt>=4.0", ] [project.scripts] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..8fd8643 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,4 @@ +{ + "venvPath": ".", + "venv": ".venv" +} diff --git a/uv.lock b/uv.lock index de8ec54..287f06c 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -159,6 +225,7 @@ name = "nvcurve" version = "0.5.1" source = { editable = "." } dependencies = [ + { name = "bcrypt" }, { name = "fastapi" }, { name = "httpx" }, { name = "nvidia-ml-py" }, @@ -168,6 +235,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "bcrypt", specifier = ">=4.0" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, { name = "nvidia-ml-py", specifier = ">=12.0" },