feat: multi-user authentication (dual mode)

Add optional login protection for the web UI/API, intended for shared
machines (e.g. AI servers). Dual mode: with no users configured the API
and 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 stored as $2b$ hashes in
  /etc/nvcurve/users.json (0600, root-owned); plaintext never persisted.
- 24-hour sessions: HttpOnly cookie for browsers, Authorization: Bearer
  token for CLI/scripts; in-memory, invalidated on server restart.
- Multi-user: multiple named accounts (no shared-password mode).
- New CLI: nvcurve user add|list|remove|set-password (root for mutating
  ops; password always prompted, never a CLI argument).
- New endpoints: GET /api/ping (public), /api/auth/status|login|logout|users.
- Web UI: sign-in screen when auth is enabled; status bar shows the
  signed-in user with sign-out; expired sessions (401) re-show sign-in.
- Brute-force lockout: 10 failed logins/IP within 5 min -> 15 min lockout.
- New dependency: bcrypt.

Also: LSP config (pyrightconfig.json) pointing at the project .venv, and
small error-handling cleanups in daemon.py/server.py.
This commit is contained in:
ARIA committed 2026-09-02 15:21:35 +02:00
1 parent af23a10f25
commit bbd692ea2e
16 files changed
+2085 -483

No files matched your search

+20
View File
@@ -2,17 +2,35 @@
All notable changes to this project will be documented in this file. 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 ## [0.5.1] - 2026-05-09
### Added ### Added
- **Comprehensive Documentation**: Added structured docs covering overview, installation, usage guide, and tips and tricks. README pruned to essentials with links to docs. - **Comprehensive Documentation**: Added structured docs covering overview, installation, usage guide, and tips and tricks. README pruned to essentials with links to docs.
### Changed ### 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. - **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 ## [0.5.0] - 2026-03-23
### Changed ### 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. - **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. - **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. - **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 ## [0.4.0] - 2026-03-17
### Added ### 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. - **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. - **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. - **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. - **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 ### 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`). - **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. - **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. - **Offline Snapshots**: `snapshot save`, `restore`, and `list` bypass the server and fall back to direct HAL operations when the daemon is not running.
+13 -1
View File
@@ -53,6 +53,18 @@ nvcurve # Launch web UI at http://localhost:8042
nvcurve read # Quick curve read from CLI 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 ## Systemd Service
Install the daemon for automatic profile loading on boot and optional web server auto-start: 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 | | Setting | Description |
|---|---| | --- | --- |
| `host` | Web server bind address (`0.0.0.0` for network access) | | `host` | Web server bind address (`0.0.0.0` for network access) |
| `port` | Web server port (default `8042`) | | `port` | Web server port (default `8042`) |
| `auto_serve` | Auto-start web server on boot | | `auto_serve` | Auto-start web server on boot |
+61 -3
View File
@@ -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. The curve editor displays your GPU's V/F curve as an interactive graph with draggable points.
| Action | How | | Action | How |
|---|---| | --- | --- |
| Select a point | Click on it | | Select a point | Click on it |
| Multi-select | Shift+click to add/remove points | | Multi-select | Shift+click to add/remove points |
| Select all active points | Ctrl/Cmd+A | | 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. The point table provides a spreadsheet-like view of all curve points with their frequency, voltage, and offset values.
| Action | How | | Action | How |
|---|---| | --- | --- |
| Select a point | Click a row | | Select a point | Click a row |
| Toggle selection | Ctrl/Cmd+click | | Toggle selection | Ctrl/Cmd+click |
| Range select | Shift+click or drag across rows | | 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. 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 <token>` 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 ## CLI Reference
The CLI is designed for scripting, headless use, and quick operations. All write commands support `--dry-run` to preview changes. 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 ## Configuration Files
| File | Purpose | | File | Purpose |
|---|---| | --- | --- |
| `/etc/nvcurve/config.json` | Persistent config (host, port, auto-serve, default profiles) | | `/etc/nvcurve/config.json` | Persistent config (host, port, auto-serve, default profiles) |
| `/etc/nvcurve/profiles/*.json` | Saved profiles | | `/etc/nvcurve/profiles/*.json` | Saved profiles |
| `/var/cache/nvcurve/snapshots/` | Auto-saved snapshots before writes | | `/var/cache/nvcurve/snapshots/` | Auto-saved snapshots before writes |
+149 -47
View File
@@ -1,31 +1,102 @@
import { useGpu } from './hooks/useGpu'; import { useGpu } from "./hooks/useGpu";
import { useCurve } from './hooks/useCurve'; import { useCurve } from "./hooks/useCurve";
import { useMonitor } from './hooks/useMonitor'; import { useMonitor } from "./hooks/useMonitor";
import { StatusBar } from './components/Monitor/StatusBar'; import { StatusBar } from "./components/Monitor/StatusBar";
import { LiveMonitor } from './components/Monitor/LiveMonitor'; import { LiveMonitor } from "./components/Monitor/LiveMonitor";
import { CurveEditor } from './components/CurveEditor/CurveEditor'; import { CurveEditor } from "./components/CurveEditor/CurveEditor";
import { PointTable } from './components/PointTable/PointTable'; import { PointTable } from "./components/PointTable/PointTable";
import { PerformancePanel } from './components/Limits/PerformancePanel'; import { PerformancePanel } from "./components/Limits/PerformancePanel";
import { PerformanceMonitor } from './components/Monitor/PerformanceMonitor'; import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor";
import { FanMonitor } from './components/Monitor/FanMonitor'; import { FanMonitor } from "./components/Monitor/FanMonitor";
import { FanCurveEditor } from './components/Fans/FanCurveEditor'; import { FanCurveEditor } from "./components/Fans/FanCurveEditor";
import { ProfilePanel } from './components/Profiles/ProfilePanel'; import { ProfilePanel } from "./components/Profiles/ProfilePanel";
import { api } from './api/client'; import { api, onUnauthorized } from "./api/client";
import { useCurveStore } from './store/curveStore'; import { LoginScreen } from "./components/Auth/LoginScreen";
import { Toaster } from 'sonner'; import { useCurveStore } from "./store/curveStore";
import { Loader, ChevronDown } from 'lucide-react'; import { Toaster } from "sonner";
import { useState, useRef, useEffect } from 'react'; import { Loader, ChevronDown } from "lucide-react";
import type { FanState } from './types'; import { useState, useRef, useEffect } from "react";
import type { FanState } from "./types";
type AuthState = "checking" | "login" | "ok";
export default function App() { export default function App() {
const [authState, setAuthState] = useState<AuthState>("checking");
const [authUser, setAuthUser] = useState<string | null>(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 (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<Loader className="animate-spin text-zinc-500" size={28} />
</div>
);
}
if (authState === "login") {
return (
<LoginScreen
onSuccess={(username) => {
setAuthUser(username);
setAuthState("ok");
}}
/>
);
}
return <MainApp user={authUser} onLogout={handleLogout} />;
}
function MainApp({
user,
onLogout,
}: {
user: string | null;
onLogout: () => void;
}) {
const gpuInfo = useGpu(); const gpuInfo = useGpu();
const { curve, wsStatus: curveWsStatus } = useCurve(); const { curve, wsStatus: curveWsStatus } = useCurve();
const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor(); 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<FanState | null>(null); const [fanState, setFanState] = useState<FanState | null>(null);
const [activeDomain, setActiveDomain] = useState<'gpu' | 'memory'>('gpu'); const [activeDomain, setActiveDomain] = useState<"gpu" | "memory">("gpu");
const [isProfileOpen, setIsProfileOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null); const profileRef = useRef<HTMLDivElement>(null);
@@ -37,14 +108,20 @@ export default function App() {
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { 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); setIsProfileOpen(false);
} }
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
// Fetch initial active profile // 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); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [selectedGpuIndex, setActiveProfile]); }, [selectedGpuIndex, setActiveProfile]);
@@ -54,28 +131,36 @@ export default function App() {
} }
useEffect(() => { useEffect(() => {
if (activeTab === 'fans') { if (activeTab === "fans") {
refreshFans(); refreshFans();
} }
}, [activeTab, selectedGpuIndex]); }, [activeTab, selectedGpuIndex]);
// Worst connection status wins // Worst connection status wins
const wsStatus = const wsStatus =
monitorWsStatus === 'disconnected' || curveWsStatus === 'disconnected' monitorWsStatus === "disconnected" || curveWsStatus === "disconnected"
? 'disconnected' ? "disconnected"
: monitorWsStatus === 'connecting' || curveWsStatus === 'connecting' : monitorWsStatus === "connecting" || curveWsStatus === "connecting"
? 'connecting' ? "connecting"
: 'connected'; : "connected";
return ( return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col"> <div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col">
<Toaster theme="dark" position="top-right" /> <Toaster theme="dark" position="top-right" />
<StatusBar gpuInfo={gpuInfo} wsStatus={wsStatus} monitor={monitor} /> <StatusBar
gpuInfo={gpuInfo}
wsStatus={wsStatus}
monitor={monitor}
user={user}
onLogout={onLogout}
/>
{wsStatus !== 'connected' && ( {wsStatus !== "connected" && (
<div className="bg-amber-500/10 border-b border-amber-500/20 text-amber-500 px-4 py-2 text-center text-sm font-medium flex justify-center items-center gap-2"> <div className="bg-amber-500/10 border-b border-amber-500/20 text-amber-500 px-4 py-2 text-center text-sm font-medium flex justify-center items-center gap-2">
<Loader size={14} className="animate-spin" /> <Loader size={14} className="animate-spin" />
{wsStatus === 'connecting' ? 'Reconnecting to backend...' : 'Connection lost. Retrying...'} {wsStatus === "connecting"
? "Reconnecting to backend..."
: "Connection lost. Retrying..."}
</div> </div>
)} )}
@@ -84,20 +169,20 @@ export default function App() {
<div className="flex justify-between items-end border-b border-zinc-800 pb-2"> <div className="flex justify-between items-end border-b border-zinc-800 pb-2">
<div className="flex gap-6"> <div className="flex gap-6">
<button <button
onClick={() => setActiveTab('curve')} onClick={() => setActiveTab("curve")}
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === 'curve' ? 'border-pink-500 text-zinc-100' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`} className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === "curve" ? "border-pink-500 text-zinc-100" : "border-transparent text-zinc-500 hover:text-zinc-300"}`}
> >
Curve Curve
</button> </button>
<button <button
onClick={() => setActiveTab('performance')} onClick={() => setActiveTab("performance")}
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === 'performance' ? 'border-pink-500 text-zinc-100' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`} className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === "performance" ? "border-pink-500 text-zinc-100" : "border-transparent text-zinc-500 hover:text-zinc-300"}`}
> >
Performance Performance
</button> </button>
<button <button
onClick={() => setActiveTab('fans')} onClick={() => setActiveTab("fans")}
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === 'fans' ? 'border-pink-500 text-zinc-100' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`} className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === "fans" ? "border-pink-500 text-zinc-100" : "border-transparent text-zinc-500 hover:text-zinc-300"}`}
> >
Fans Fans
</button> </button>
@@ -113,13 +198,23 @@ export default function App() {
<span className="text-zinc-200">{activeProfile}</span> <span className="text-zinc-200">{activeProfile}</span>
</> </>
) : ( ) : (
'Profiles' "Profiles"
)} )}
<ChevronDown size={14} className={isProfileOpen ? "rotate-180 transition-transform" : "transition-transform"} /> <ChevronDown
size={14}
className={
isProfileOpen
? "rotate-180 transition-transform"
: "transition-transform"
}
/>
</button> </button>
{isProfileOpen && ( {isProfileOpen && (
<div className="absolute right-0 top-full mt-2 w-80 z-50 shadow-[0_8px_30px_rgb(0,0,0,0.8)] border border-zinc-700/50 rounded-lg overflow-hidden"> <div className="absolute right-0 top-full mt-2 w-80 z-50 shadow-[0_8px_30px_rgb(0,0,0,0.8)] border border-zinc-700/50 rounded-lg overflow-hidden">
<ProfilePanel activeProfile={activeProfile} onProfileApplied={setActiveProfile} /> <ProfilePanel
activeProfile={activeProfile}
onProfileApplied={setActiveProfile}
/>
</div> </div>
)} )}
</div> </div>
@@ -127,7 +222,7 @@ export default function App() {
{/* Main content area */} {/* Main content area */}
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
{activeTab === 'curve' ? ( {activeTab === "curve" ? (
<> <>
<div className="flex gap-4 items-stretch w-full"> <div className="flex gap-4 items-stretch w-full">
<div className="flex-1 min-w-0 flex flex-col"> <div className="flex-1 min-w-0 flex flex-col">
@@ -155,20 +250,27 @@ export default function App() {
{curve && ( {curve && (
<div className="w-full"> <div className="w-full">
<PointTable <PointTable
points={curve.points.filter(p => p.domain === activeDomain)} points={curve.points.filter(
currentVoltageMv={activeDomain === 'gpu' ? currentVoltageMv : null} (p) => p.domain === activeDomain,
readOnly={activeDomain === 'memory'} )}
currentVoltageMv={
activeDomain === "gpu" ? currentVoltageMv : null
}
readOnly={activeDomain === "memory"}
/> />
</div> </div>
)} )}
</> </>
) : activeTab === 'performance' ? ( ) : activeTab === "performance" ? (
<div className="flex gap-4 items-start w-full"> <div className="flex gap-4 items-start w-full">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<PerformancePanel /> <PerformancePanel />
</div> </div>
<div className="w-80 shrink-0 flex flex-col"> <div className="w-80 shrink-0 flex flex-col">
<PerformanceMonitor monitor={monitor} history={monitorHistory} /> <PerformanceMonitor
monitor={monitor}
history={monitorHistory}
/>
</div> </div>
</div> </div>
) : ( ) : (
+123 -36
View File
@@ -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<T>(path: string, gpuIndex?: number): Promise<T> { async function get<T>(path: string, gpuIndex?: number): Promise<T> {
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); const res = await fetch(url);
if (!res.ok) { if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText); 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<T>; return res.json() as Promise<T>;
} }
async function post<T = void>(path: string, body?: unknown, gpuIndex?: number): Promise<T> { async function post<T = void>(
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`; path: string,
body?: unknown,
gpuIndex?: number,
): Promise<T> {
const url =
gpuIndex !== undefined
? `/api${path}?gpu_index=${gpuIndex}`
: `/api${path}`;
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: "POST",
headers: body != null ? { 'Content-Type': 'application/json' } : {}, headers: body != null ? { "Content-Type": "application/json" } : {},
body: body != null ? JSON.stringify(body) : undefined, body: body != null ? JSON.stringify(body) : undefined,
}); });
if (!res.ok) { if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText); 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) // Some endpoints return no body (204)
const ct = res.headers.get('content-type') ?? ''; const ct = res.headers.get("content-type") ?? "";
if (ct.includes('application/json')) return res.json() as Promise<T>; if (ct.includes("application/json")) return res.json() as Promise<T>;
// 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; return undefined as unknown as T;
} }
async function del<T = void>(path: string, gpuIndex?: number): Promise<T> { async function del<T = void>(path: string, gpuIndex?: number): Promise<T> {
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`; const url =
const res = await fetch(url, { method: 'DELETE' }); gpuIndex !== undefined
? `/api${path}?gpu_index=${gpuIndex}`
: `/api${path}`;
const res = await fetch(url, { method: "DELETE" });
if (!res.ok) { if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText); 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') ?? ''; const ct = res.headers.get("content-type") ?? "";
if (ct.includes('application/json')) return res.json() as Promise<T>; if (ct.includes("application/json")) return res.json() as Promise<T>;
// 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; return undefined as unknown as T;
} }
export interface AuthStatus {
auth_required: boolean;
authenticated: boolean;
username: string | null;
expires_at: number | null;
}
export const api = { export const api = {
gpus: () => get<GpuInfo[]>('/gpus'), /** Auth */
gpu: (gpuIndex: number) => get<GpuInfo>('/gpu', gpuIndex), authStatus: () => get<AuthStatus>("/auth/status"),
curve: (gpuIndex: number) => get<CurveState>('/curve', gpuIndex), login: (username: string, password: string) =>
ranges: (gpuIndex: number) => get<Record<string, { min_khz: number; max_khz: number }>>('/ranges', gpuIndex), post<{ ok: boolean; username: string; expires_at: number; token: string }>(
voltage: (gpuIndex: number) => get<{ voltage_uv: number; voltage_mv: number }>('/voltage', gpuIndex), "/auth/login",
monitor: (gpuIndex: number) => get<MonitoringSample>('/monitor', gpuIndex), { username, password },
snapshots: (gpuIndex: number) => get<SnapshotInfo[]>('/snapshots', gpuIndex), ),
logout: () => post("/auth/logout"),
gpus: () => get<GpuInfo[]>("/gpus"),
gpu: (gpuIndex: number) => get<GpuInfo>("/gpu", gpuIndex),
curve: (gpuIndex: number) => get<CurveState>("/curve", gpuIndex),
ranges: (gpuIndex: number) =>
get<Record<string, { min_khz: number; max_khz: number }>>(
"/ranges",
gpuIndex,
),
voltage: (gpuIndex: number) =>
get<{ voltage_uv: number; voltage_mv: number }>("/voltage", gpuIndex),
monitor: (gpuIndex: number) => get<MonitoringSample>("/monitor", gpuIndex),
snapshots: (gpuIndex: number) => get<SnapshotInfo[]>("/snapshots", gpuIndex),
/** Write per-point frequency deltas. deltas: { pointIndex: deltaKhz } */ /** Write per-point frequency deltas. deltas: { pointIndex: deltaKhz } */
writeDeltas: (deltas: Record<number, number>, gpuIndex: number) => writeDeltas: (deltas: Record<number, number>, 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. */ /** 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 */ /** Get performance limits mapping */
limits: (gpuIndex: number) => get<LimitsState>('/limits', gpuIndex), limits: (gpuIndex: number) => get<LimitsState>("/limits", gpuIndex),
/** Set performance limits */ /** Set performance limits */
updateLimits: (updates: Partial<LimitsState>, gpuIndex: number) => updateLimits: (updates: Partial<LimitsState>, gpuIndex: number) =>
post('/limits', updates, gpuIndex), post("/limits", updates, gpuIndex),
/** Reset power limit and memory offset to hardware defaults */ /** 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 */ /** Profile Management */
profiles: (gpuIndex: number) => get<{ profiles: ProfileData[], active: string | null, auto_load: string | null }>('/profiles', gpuIndex), profiles: (gpuIndex: number) =>
saveProfile: (name: string, gpuIndex: number) => post<{ ok: boolean; filepath: string }>('/profiles', { name }, gpuIndex), get<{
applyProfile: (name: string, gpuIndex: number) => post(`/profiles/${encodeURIComponent(name)}/apply`, undefined, gpuIndex), 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)}`), deleteProfile: (name: string) => del(`/profiles/${encodeURIComponent(name)}`),
renameProfile: (oldName: string, newName: string) => renameProfile: (oldName: string, newName: string) =>
post(`/profiles/${encodeURIComponent(oldName)}/rename`, { new_name: newName }), post(`/profiles/${encodeURIComponent(oldName)}/rename`, {
new_name: newName,
}),
/** Server config */ /** Server config */
setAutoLoadProfile: (name: string | null, gpuIndex: number) => 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 */ /** Fan control */
fans: (gpuIndex: number) => get<FanState>('/fans', gpuIndex), fans: (gpuIndex: number) => get<FanState>("/fans", gpuIndex),
updateFans: (curve: FanPoint[], gpuIndex: number) => updateFans: (curve: FanPoint[], gpuIndex: number) =>
post('/fans', { curve }, gpuIndex), post("/fans", { curve }, gpuIndex),
resetFans: (gpuIndex: number) => post('/fans/reset', undefined, gpuIndex), resetFans: (gpuIndex: number) => post("/fans/reset", undefined, gpuIndex),
setFanSpeed: (fanPct: number, gpuIndex: number) => setFanSpeed: (fanPct: number, gpuIndex: number) =>
post('/fans/speed', { fan_pct: fanPct }, gpuIndex), post("/fans/speed", { fan_pct: fanPct }, gpuIndex),
}; };
@@ -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<string | null>(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 (
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<img src="/logo.svg" alt="NVCurve" className="w-12 h-12 mb-3" />
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-violet-400 to-fuchsia-400 tracking-tight">
NVCurve
</h1>
<p className="text-sm text-zinc-500 mt-1">Sign in to continue</p>
</div>
<form
onSubmit={submit}
className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 shadow-[0_8px_30px_rgb(0,0,0,0.6)] flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label
htmlFor="username"
className="text-xs font-medium text-zinc-400"
>
Username
</label>
<div className="relative">
<User
size={15}
className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
/>
<input
id="username"
type="text"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
className="text-xs font-medium text-zinc-400"
>
Password
</label>
<div className="relative">
<Lock
size={15}
className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
/>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => 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="••••••••"
/>
</div>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
<button
type="submit"
disabled={busy || !username || !password}
className="mt-1 flex items-center justify-center gap-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg py-2.5 transition-colors"
>
{busy && <Loader size={15} className="animate-spin" />}
{busy ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="text-center text-xs text-zinc-600 mt-4">
Sessions last 24 hours, then you will be asked to sign in again.
</p>
</div>
</div>
);
}
+121 -33
View File
@@ -1,13 +1,24 @@
import { Cpu, Wifi, WifiOff, Loader, Terminal, ChevronDown } from 'lucide-react'; import {
import type { GpuInfo, MonitoringSample } from '../../types'; Cpu,
import { fmt } from '../../utils/units'; Wifi,
import { useCurveStore } from '../../store/curveStore'; WifiOff,
import { useState, useRef, useEffect } from 'react'; 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 { interface Props {
gpuInfo: GpuInfo | null; gpuInfo: GpuInfo | null;
wsStatus: 'connecting' | 'connected' | 'disconnected'; wsStatus: "connecting" | "connected" | "disconnected";
monitor: MonitoringSample | null; monitor: MonitoringSample | null;
user?: string | null;
onLogout?: () => void;
} }
const statusIcon = { const statusIcon = {
@@ -17,19 +28,29 @@ const statusIcon = {
}; };
const statusText = { const statusText = {
connected: 'Connected', connected: "Connected",
connecting: 'Connecting…', connecting: "Connecting…",
disconnected: 'Disconnected', disconnected: "Disconnected",
}; };
export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) { export function StatusBar({
const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } = useCurveStore(); gpuInfo,
wsStatus,
monitor,
user,
onLogout,
}: Props) {
const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } =
useCurveStore();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { 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); setIsOpen(false);
} }
} }
@@ -47,20 +68,26 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
NVCurve NVCurve
</span> </span>
<div className="w-px h-4 bg-zinc-700 mx-1 hidden sm:block"></div> <div className="w-px h-4 bg-zinc-700 mx-1 hidden sm:block"></div>
{availableGpus.length > 1 ? ( {availableGpus.length > 1 ? (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1.5 px-2 py-1 -ml-2 rounded hover:bg-zinc-800 transition-colors group" className="flex items-center gap-1.5 px-2 py-1 -ml-2 rounded hover:bg-zinc-800 transition-colors group"
> >
<Cpu size={14} className="text-zinc-400 group-hover:text-zinc-300 transition-colors" /> <Cpu
size={14}
className="text-zinc-400 group-hover:text-zinc-300 transition-colors"
/>
<span className="font-medium text-zinc-200 truncate group-hover:text-white transition-colors"> <span className="font-medium text-zinc-200 truncate group-hover:text-white transition-colors">
{gpuInfo?.name ?? 'No Device'} {gpuInfo?.name ?? "No Device"}
</span> </span>
<ChevronDown size={14} className={`text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} /> <ChevronDown
size={14}
className={`text-zinc-500 transition-transform ${isOpen ? "rotate-180" : ""}`}
/>
</button> </button>
{isOpen && ( {isOpen && (
<div className="absolute top-full left-0 mt-1 w-64 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl overflow-hidden z-50 py-1"> <div className="absolute top-full left-0 mt-1 w-64 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl overflow-hidden z-50 py-1">
{availableGpus.map((gpu) => ( {availableGpus.map((gpu) => (
@@ -71,12 +98,23 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
setIsOpen(false); setIsOpen(false);
}} }}
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-zinc-700 transition-colors ${ 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"
}`} }`}
> >
<Cpu size={14} className={gpu.index === selectedGpuIndex ? 'text-cyan-400' : 'text-zinc-500'} /> <Cpu
size={14}
className={
gpu.index === selectedGpuIndex
? "text-cyan-400"
: "text-zinc-500"
}
/>
<span className="truncate flex-1">{gpu.name}</span> <span className="truncate flex-1">{gpu.name}</span>
<span className="text-xs text-zinc-500 font-mono">GPU {gpu.index}</span> <span className="text-xs text-zinc-500 font-mono">
GPU {gpu.index}
</span>
</button> </button>
))} ))}
</div> </div>
@@ -84,9 +122,12 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
</div> </div>
) : ( ) : (
<> <>
<Cpu size={16} className="shrink-0 text-zinc-400 ml-1 hidden sm:block" /> <Cpu
size={16}
className="shrink-0 text-zinc-400 ml-1 hidden sm:block"
/>
<span className="font-medium text-zinc-200 truncate"> <span className="font-medium text-zinc-200 truncate">
{gpuInfo?.name ?? 'No Device'} {gpuInfo?.name ?? "No Device"}
</span> </span>
</> </>
)} )}
@@ -101,28 +142,75 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
{/* Centre: live stats */} {/* Centre: live stats */}
<div className="flex-1 flex items-center justify-center gap-5 min-w-0 hidden lg:flex"> <div className="flex-1 flex items-center justify-center gap-5 min-w-0 hidden lg:flex">
<StatPill label="Temp" value={fmt.celsius(monitor?.temp_c)} color="text-orange-400" /> <StatPill
<StatPill label="Power" value={fmt.watts(monitor?.power_w)} color="text-pink-400" /> label="Temp"
<StatPill label="Fan" value={fmt.pct(monitor?.fan_pct)} color="text-blue-400" /> value={fmt.celsius(monitor?.temp_c)}
<StatPill label="GPU Util" value={fmt.pct(monitor?.gpu_util_pct)} color="text-yellow-400" /> color="text-orange-400"
<StatPill label="Mem Util" value={fmt.pct(monitor?.mem_util_pct)} color="text-sky-400" /> />
<StatPill
label="Power"
value={fmt.watts(monitor?.power_w)}
color="text-pink-400"
/>
<StatPill
label="Fan"
value={fmt.pct(monitor?.fan_pct)}
color="text-blue-400"
/>
<StatPill
label="GPU Util"
value={fmt.pct(monitor?.gpu_util_pct)}
color="text-yellow-400"
/>
<StatPill
label="Mem Util"
value={fmt.pct(monitor?.mem_util_pct)}
color="text-sky-400"
/>
</div> </div>
{/* Right: connection status */} {/* Right: user + connection status */}
<div className="flex items-center gap-1.5 text-zinc-400 shrink-0"> <div className="flex items-center gap-3 shrink-0">
{statusIcon[wsStatus]} {user && (
<span className="hidden sm:inline">{statusText[wsStatus]}</span> <div className="flex items-center gap-1.5 text-zinc-400">
<User size={14} className="text-zinc-500" />
<span className="hidden sm:inline text-zinc-300">{user}</span>
{onLogout && (
<button
onClick={onLogout}
title="Sign out"
className="p-1 rounded hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
>
<LogOut size={14} />
</button>
)}
</div>
)}
<div className="flex items-center gap-1.5 text-zinc-400">
{statusIcon[wsStatus]}
<span className="hidden sm:inline">{statusText[wsStatus]}</span>
</div>
</div> </div>
</div> </div>
</header> </header>
); );
} }
function StatPill({ label, value, color }: { label: string; value: string; color: string }) { function StatPill({
label,
value,
color,
}: {
label: string;
value: string;
color: string;
}) {
return ( return (
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-zinc-500 text-xs">{label}</span> <span className="text-zinc-500 text-xs">{label}</span>
<span className={`font-mono font-semibold text-sm ${color}`}>{value}</span> <span className={`font-mono font-semibold text-sm ${color}`}>
{value}
</span>
</div> </div>
); );
} }
+289
View File
@@ -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 <token>`` 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)
+594 -211
View File
File diff suppressed because it is too large. Load diff
+43 -10
View File
@@ -1,8 +1,9 @@
"""HTTP client for communicating with a running nvcurve server.""" """HTTP client for communicating with a running nvcurve server."""
import httpx
from typing import Any from typing import Any
import httpx
DEFAULT_BASE = "http://127.0.0.1:8042" DEFAULT_BASE = "http://127.0.0.1:8042"
_TIMEOUT = 5.0 _TIMEOUT = 5.0
@@ -19,14 +20,20 @@ class ApiError(Exception):
class NvCurveClient: 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._base = base.rstrip("/")
self.gpu_index = gpu_index self.gpu_index = gpu_index
self.token = token
def _url(self, path: str) -> str: def _url(self, path: str) -> str:
sep = "&" if "?" in path else "?" sep = "&" if "?" in path else "?"
return f"{self._base}{path}{sep}gpu_index={self.gpu_index}" 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: def _raise(self, r: httpx.Response) -> None:
if r.is_error: if r.is_error:
try: try:
@@ -37,35 +44,58 @@ class NvCurveClient:
def _get(self, path: str) -> Any: def _get(self, path: str) -> Any:
try: try:
r = httpx.get(self._url(path), timeout=_TIMEOUT) r = httpx.get(self._url(path), headers=self._headers(), timeout=_TIMEOUT)
except httpx.ConnectError: except httpx.ConnectError:
raise ServerNotRunning() raise ServerNotRunning() from None
self._raise(r) self._raise(r)
return r.json() return r.json()
def _post(self, path: str, body: Any = None) -> Any: def _post(self, path: str, body: Any = None) -> Any:
try: 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: except httpx.ConnectError:
raise ServerNotRunning() raise ServerNotRunning() from None
self._raise(r) self._raise(r)
return r.json() return r.json()
def _delete(self, path: str) -> Any: def _delete(self, path: str) -> Any:
try: try:
r = httpx.delete(self._url(path), timeout=_TIMEOUT) r = httpx.delete(self._url(path), headers=self._headers(), timeout=_TIMEOUT)
except httpx.ConnectError: except httpx.ConnectError:
raise ServerNotRunning() raise ServerNotRunning() from None
self._raise(r) self._raise(r)
return r.json() return r.json()
def ping(self) -> bool: def ping(self) -> bool:
try: try:
httpx.get(self._url("/api/gpu"), timeout=1.0) httpx.get(self._base + "/api/ping", timeout=1.0)
return True return True
except Exception: except Exception:
return False 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: def gpus(self) -> list:
return self._get("/api/gpus") return self._get("/api/gpus")
@@ -140,7 +170,10 @@ class NvCurveClient:
return self._get("/api/config") return self._get("/api/config")
def config_update(self, auto_load_profile: str | None, gpu_index: int = 0) -> dict: 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 ─────────────────────────────────────────────────────── # ── Server control ───────────────────────────────────────────────────────
+9 -5
View File
@@ -1,18 +1,17 @@
"""User-configurable settings with sensible defaults.""" """User-configurable settings with sensible defaults."""
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
@dataclass @dataclass
class Config: class Config:
# Safety limits # Safety limits
max_delta_khz: int = 3000_000 # ±3000 MHz hard cap max_delta_khz: int = 3000_000 # ±3000 MHz hard cap
auto_snapshot: bool = True # Save snapshot before every write auto_snapshot: bool = True # Save snapshot before every write
max_snapshots: int = 20 # Maximum snapshots to keep (0 = unlimited) max_snapshots: int = 20 # Maximum snapshots to keep (0 = unlimited)
# Monitoring # Monitoring
poll_interval_s: float = 1.0 # WebSocket monitor poll rate poll_interval_s: float = 1.0 # WebSocket monitor poll rate
# API server # API server
host: str = "127.0.0.1" host: str = "127.0.0.1"
@@ -21,6 +20,11 @@ class Config:
snapshot_dir: str = "/var/cache/nvcurve/snapshots" snapshot_dir: str = "/var/cache/nvcurve/snapshots"
profile_dir: str = "/etc/nvcurve/profiles" 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. # Per-GPU default profiles: applied automatically on server startup.
# Key = stable GPU identifier (UUID string, "pci:{bus_id}", or "idx:{n}" fallback). # Key = stable GPU identifier (UUID string, "pci:{bus_id}", or "idx:{n}" fallback).
# Value = profile name (str). # Value = profile name (str).
+67 -28
View File
@@ -15,6 +15,7 @@ Requires root.
""" """
import asyncio import asyncio
import contextlib
import json import json
import logging import logging
import os import os
@@ -22,6 +23,8 @@ import signal
import subprocess import subprocess
import sys import sys
from .config import Config
log = logging.getLogger("nvcurve.daemon") log = logging.getLogger("nvcurve.daemon")
SOCKET_PATH = "/run/nvcurve-daemon.sock" 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. # Global server subprocess — only touched from the asyncio event loop.
_server_proc: subprocess.Popen | None = None _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 ──────────────────────────────────────────────────── # ── Socket command handlers ────────────────────────────────────────────────────
async def _handle_serve_start(host: str, port: int) -> dict: async def _handle_serve_start(host: str, port: int) -> dict:
global _server_proc global _server_proc
if _server_proc is not None and _server_proc.poll() is None: 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", cmd = [
"--host", host, "--port", str(port), "--direct"] sys.executable,
"-m",
"nvcurve",
"serve",
"start",
"--host",
host,
"--port",
str(port),
"--direct",
]
log_path = "/var/log/nvcurve-server.log" log_path = "/var/log/nvcurve-server.log"
log.info("Starting web server on %s:%d (log: %s)", host, port, log_path) log.info("Starting web server on %s:%d (log: %s)", host, port, log_path)
with open(log_path, "a") as lf: try:
_server_proc = subprocess.Popen( with open(log_path, "a") as lf:
cmd, _server_proc = subprocess.Popen(
stdout=lf, cmd,
stderr=lf, stdout=lf,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, 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) log.info("Web server started (PID %d)", _server_proc.pid)
return {"ok": True, "pid": _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) log.info("Stopping web server (PID %d)…", _server_proc.pid)
_server_proc.terminate() _server_proc.terminate()
try: try:
await asyncio.get_running_loop().run_in_executor( await asyncio.get_running_loop().run_in_executor(None, _server_proc.wait, 5)
None, _server_proc.wait, 5
)
except Exception: except Exception:
_server_proc.kill() _server_proc.kill()
_server_proc = None _server_proc = None
@@ -84,6 +103,8 @@ async def _dispatch(req: dict) -> dict:
if cmd == "ping": if cmd == "ping":
return {"ok": True} return {"ok": True}
elif cmd == "serve_start": elif cmd == "serve_start":
if _cfg is None:
return {"ok": False, "error": "config not initialized"}
host = req.get("host", _cfg.host) host = req.get("host", _cfg.host)
port = req.get("port", _cfg.port) port = req.get("port", _cfg.port)
return await _handle_serve_start(host, port) return await _handle_serve_start(host, port)
@@ -110,20 +131,17 @@ async def _handle_client(
except Exception as exc: except Exception as exc:
resp = {"ok": False, "error": str(exc)} resp = {"ok": False, "error": str(exc)}
finally: finally:
try: with contextlib.suppress(Exception):
writer.write(json.dumps(resp).encode() + b"\n") writer.write(json.dumps(resp).encode() + b"\n")
await writer.drain() await writer.drain()
except Exception:
pass
writer.close() writer.close()
try: with contextlib.suppress(Exception):
await writer.wait_closed() await writer.wait_closed()
except Exception:
pass
# ── Entrypoint ───────────────────────────────────────────────────────────────── # ── Entrypoint ─────────────────────────────────────────────────────────────────
def _load_persistent_config() -> dict: def _load_persistent_config() -> dict:
try: try:
with open(_PERSISTENT_CONFIG_FILE) as f: with open(_PERSISTENT_CONFIG_FILE) as f:
@@ -147,10 +165,16 @@ def run() -> None:
cfg_data = _load_persistent_config() cfg_data = _load_persistent_config()
from .config import Config
_cfg = Config() _cfg = Config()
for key in ("max_delta_khz", "auto_snapshot", "max_snapshots", for key in (
"snapshot_dir", "profile_dir", "host", "port"): "max_delta_khz",
"auto_snapshot",
"max_snapshots",
"snapshot_dir",
"profile_dir",
"host",
"port",
):
if key in cfg_data: if key in cfg_data:
setattr(_cfg, key, cfg_data[key]) 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. # Clean up stale socket from a previous (unclean) run.
if os.path.exists(SOCKET_PATH): 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) 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) log.info("Daemon listening on %s", SOCKET_PATH)
if auto_serve: if auto_serve:
log.info("auto_serve enabled — starting web server on boot") if _cfg is None:
await _handle_serve_start(_cfg.host, _cfg.port) 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() stop_event = asyncio.Event()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -206,4 +242,7 @@ async def _serve_socket(auto_serve: bool = False) -> None:
_server_proc.kill() _server_proc.kill()
if os.path.exists(SOCKET_PATH): 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)
+399 -109
View File
@@ -7,17 +7,36 @@ Requires root (NvAPI needs it).
""" """
import asyncio import asyncio
import json
import logging import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any 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.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from . import auth
from .config import Config, default_config 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 ( from .hal.monitoring import (
get_driver_version, get_driver_version,
get_vram_total, get_vram_total,
@@ -28,41 +47,29 @@ from .hal.monitoring import (
from .hal.ranges import get_clock_ranges from .hal.ranges import get_clock_ranges
from .hal.snapshot import ( from .hal.snapshot import (
list_snapshots, list_snapshots,
)
from .hal.snapshot import (
restore as snapshot_restore, restore as snapshot_restore,
)
from .hal.snapshot import (
save as snapshot_save, 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 ( from .hal.vfcurve import (
read_clock_offsets, read_clock_offsets,
read_curve, read_curve,
read_vfp_curve,
reset_offsets, reset_offsets,
write_global_offset, write_global_offset,
write_offsets, 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") 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.""" """Open URL as the original (non-root) user when running under sudo."""
import os import os
import subprocess import subprocess
sudo_user = os.environ.get("SUDO_USER") sudo_user = os.environ.get("SUDO_USER")
if sudo_user and os.geteuid() == 0: if sudo_user and os.geteuid() == 0:
try: try:
@@ -83,24 +91,29 @@ def _open_browser_as_user(url: str) -> None:
except Exception: except Exception:
pass pass
import webbrowser import webbrowser
webbrowser.open(url) webbrowser.open(url)
# ── Shared app state ────────────────────────────────────────────────────────── # ── Shared app state ──────────────────────────────────────────────────────────
_state: dict[str, Any] = { _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, "config": default_config,
} }
def _get_gpu_state(gpu_index: int) -> dict: def _get_gpu_state(gpu_index: int) -> dict:
if gpu_index not in _state["gpus"]: if gpu_index not in _state["gpus"]:
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"GPU {gpu_index} not found") raise HTTPException(status_code=404, detail=f"GPU {gpu_index} not found")
return _state["gpus"][gpu_index] return _state["gpus"][gpu_index]
# ── Serialization helpers ───────────────────────────────────────────────────── # ── Serialization helpers ─────────────────────────────────────────────────────
def _vfpoint_dict(p) -> dict: def _vfpoint_dict(p) -> dict:
return { return {
"index": p.index, "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, "pstate_label": f"P{s.pstate}" if s.pstate is not None else None,
"mem_used_bytes": s.mem_used_bytes, "mem_used_bytes": s.mem_used_bytes,
"mem_total_bytes": s.mem_total_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_used_mib": round(s.mem_used_bytes / (1024**2), 1)
"mem_total_mib": round(s.mem_total_bytes / (1024 ** 2), 1) if s.mem_total_bytes is not None else None, 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, "gpu_util_pct": s.gpu_util_pct,
"mem_util_pct": s.mem_util_pct, "mem_util_pct": s.mem_util_pct,
} }
@@ -147,6 +164,7 @@ def _sample_dict(s) -> dict:
# ── WebSocket broadcast ─────────────────────────────────────────────────────── # ── WebSocket broadcast ───────────────────────────────────────────────────────
async def _broadcast(clients: set, payload: dict) -> None: async def _broadcast(clients: set, payload: dict) -> None:
"""Send JSON payload to all connected WebSocket clients, evict dead ones.""" """Send JSON payload to all connected WebSocket clients, evict dead ones."""
dead = set() dead = set()
@@ -160,6 +178,7 @@ async def _broadcast(clients: set, payload: dict) -> None:
# ── Background monitoring poller ───────────────────────────────────────────── # ── Background monitoring poller ─────────────────────────────────────────────
async def _monitor_poller(gpu_index: int) -> None: async def _monitor_poller(gpu_index: int) -> None:
"""Continuously poll GPU state and push to connected monitor WebSocket clients.""" """Continuously poll GPU state and push to connected monitor WebSocket clients."""
cfg: Config = _state["config"] cfg: Config = _state["config"]
@@ -198,6 +217,7 @@ async def _fan_poller(gpu_index: int) -> None:
# ── Lifespan ────────────────────────────────────────────────────────────────── # ── Lifespan ──────────────────────────────────────────────────────────────────
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -208,7 +228,7 @@ async def lifespan(app: FastAPI):
gpu_infos = await loop.run_in_executor(None, discover_gpus) gpu_infos = await loop.run_in_executor(None, discover_gpus)
if not gpu_infos: if not gpu_infos:
log.warning("No GPUs discovered.") log.warning("No GPUs discovered.")
poller_tasks = [] poller_tasks = []
for info in gpu_infos: for info in gpu_infos:
@@ -228,18 +248,18 @@ async def lifespan(app: FastAPI):
"fan_poller_task": None, "fan_poller_task": None,
} }
_state["gpus"][idx] = g_state _state["gpus"][idx] = g_state
try: try:
gpu, name = await loop.run_in_executor(None, get_gpu, idx) gpu, name = await loop.run_in_executor(None, get_gpu, idx)
g_state["gpu"] = gpu g_state["gpu"] = gpu
g_state["gpu_name"] = name g_state["gpu_name"] = name
log.info("GPU %d: %s", idx, name) log.info("GPU %d: %s", idx, name)
# Read initial offsets for reconciliation baseline # Read initial offsets for reconciliation baseline
offsets, err = await loop.run_in_executor(None, read_clock_offsets, gpu) offsets, err = await loop.run_in_executor(None, read_clock_offsets, gpu)
if offsets: if offsets:
g_state["last_offsets"] = offsets g_state["last_offsets"] = offsets
poller_tasks.append(asyncio.create_task(_monitor_poller(idx))) poller_tasks.append(asyncio.create_task(_monitor_poller(idx)))
except Exception as exc: except Exception as exc:
log.error("Failed to initialize GPU %d: %s", idx, 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: if gpu_idx is None:
log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key) log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key)
continue continue
log.info("Auto-loading profile %r on GPU %d (%s) [compat path]", log.info(
profile_name, gpu_idx, gpu_key) "Auto-loading profile %r on GPU %d (%s) [compat path]",
profile_name,
gpu_idx,
gpu_key,
)
try: try:
await _auto_apply_profile_with_retry(profile_name, gpu_idx) await _auto_apply_profile_with_retry(profile_name, gpu_idx)
except FileNotFoundError: except FileNotFoundError:
log.warning("Auto-load profile %r not found in %s — skipping GPU %d", log.warning(
profile_name, cfg.profile_dir, gpu_idx) "Auto-load profile %r not found in %s — skipping GPU %d",
profile_name,
cfg.profile_dir,
gpu_idx,
)
except Exception as exc: except Exception as exc:
log.warning("Auto-load profile %r failed on GPU %d: %s — skipping", log.warning(
profile_name, gpu_idx, exc) "Auto-load profile %r failed on GPU %d: %s — skipping",
profile_name,
gpu_idx,
exc,
)
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
yield # server is running yield # server is running
@@ -294,10 +326,15 @@ async def lifespan(app: FastAPI):
g_state["fan_curve"] = None g_state["fan_curve"] = None
try: try:
await loop.run_in_executor(None, reset_fan, gpu_index) 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: except Exception as exc:
log.warning("GPU %d: failed to restore automatic fan control on shutdown: %s", log.warning(
gpu_index, exc) "GPU %d: failed to restore automatic fan control on shutdown: %s",
gpu_index,
exc,
)
await loop.run_in_executor(None, shutdown_nvml) 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 ──────────────────────────────────────────────────────────── # ── Request models ────────────────────────────────────────────────────────────
class WriteRequest(BaseModel): 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 max_delta_khz: int | None = None # per-request safety limit override
@@ -327,7 +409,7 @@ class GlobalOffsetRequest(BaseModel):
class VerifyRequest(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): class SnapshotRestoreRequest(BaseModel):
@@ -365,13 +447,105 @@ class FanSpeedRequest(BaseModel):
fan_pct: int fan_pct: int
class LoginRequest(BaseModel):
username: str
password: str
# ── Helper: run blocking HAL call in thread pool ────────────────────────────── # ── Helper: run blocking HAL call in thread pool ──────────────────────────────
async def _run(fn, *args): async def _run(fn, *args):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, fn, *args) 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): def _require_gpu(gpu_index: int = 0):
g_state = _get_gpu_state(gpu_index) g_state = _get_gpu_state(gpu_index)
gpu = g_state["gpu"] gpu = g_state["gpu"]
@@ -382,10 +556,12 @@ def _require_gpu(gpu_index: int = 0):
# ── REST endpoints ──────────────────────────────────────────────────────────── # ── REST endpoints ────────────────────────────────────────────────────────────
@app.get("/api/gpus") @app.get("/api/gpus")
async def api_gpus(): async def api_gpus():
"""List all discovered GPUs.""" """List all discovered GPUs."""
from .hal.gpu import discover_gpus from .hal.gpu import discover_gpus
gpu_infos = await _run(discover_gpus) gpu_infos = await _run(discover_gpus)
return [ return [
{ {
@@ -397,6 +573,7 @@ async def api_gpus():
for info in gpu_infos for info in gpu_infos
] ]
@app.get("/api/gpu") @app.get("/api/gpu")
async def api_gpu(gpu_index: int = 0): async def api_gpu(gpu_index: int = 0):
"""GPU info: name, driver version, VRAM.""" """GPU info: name, driver version, VRAM."""
@@ -408,7 +585,7 @@ async def api_gpu(gpu_index: int = 0):
"index": gpu_index, "index": gpu_index,
"driver_version": driver, "driver_version": driver,
"vram_bytes": vram, "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: if state is None:
raise HTTPException(status_code=500, detail=f"Failed to read curve: {err}") raise HTTPException(status_code=500, detail=f"Failed to read curve: {err}")
if point < 0 or point >= len(state.points): 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]) 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): async def api_voltage(gpu_index: int = 0):
"""Current GPU core voltage.""" """Current GPU core voltage."""
from .hal.monitoring import read_voltage from .hal.monitoring import read_voltage
gpu, g_state = _require_gpu(gpu_index) gpu, g_state = _require_gpu(gpu_index)
voltage_uv, err = await _run(read_voltage, gpu) voltage_uv, err = await _run(read_voltage, gpu)
if voltage_uv is None: if voltage_uv is None:
@@ -492,6 +672,7 @@ def _persist_config_field(key: str, value) -> None:
""" """
import json as _json import json as _json
import os as _os import os as _os
config_path = "/etc/nvcurve/config.json" config_path = "/etc/nvcurve/config.json"
if not _os.path.exists(config_path): if not _os.path.exists(config_path):
return return
@@ -589,6 +770,7 @@ async def _auto_apply_profile_with_retry(
Logs a warning and gives up after max_retries failed attempts. Logs a warning and gives up after max_retries failed attempts.
""" """
import os as _os import os as _os
cfg: Config = _state["config"] cfg: Config = _state["config"]
g_state = _get_gpu_state(gpu_index) g_state = _get_gpu_state(gpu_index)
gpu = g_state["gpu"] 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 profile = await _run(load_profile, filepath) # raises FileNotFoundError if missing
expected: dict[int, int] = ( 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): for attempt in range(max_retries):
errs = await _apply_profile(name, gpu_index) errs = await _apply_profile(name, gpu_index)
if errs: if errs:
log.warning("Auto-load attempt %d/%d had errors: %s", log.warning(
attempt + 1, max_retries, "; ".join(errs)) "Auto-load attempt %d/%d had errors: %s",
attempt + 1,
max_retries,
"; ".join(errs),
)
elif expected: elif expected:
offsets, err = await _run(read_clock_offsets, gpu) offsets, err = await _run(read_clock_offsets, gpu)
if offsets is None: if offsets is None:
log.warning("Auto-load attempt %d/%d: read-back failed: %s", log.warning(
attempt + 1, max_retries, err) "Auto-load attempt %d/%d: read-back failed: %s",
attempt + 1,
max_retries,
err,
)
else: else:
mismatches = [ 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() for idx, val in expected.items()
if idx < len(offsets) and offsets[idx] != val if idx < len(offsets) and offsets[idx] != val
] ]
if not mismatches: if not mismatches:
log.info("Auto-load profile %r verified on GPU %d (attempt %d/%d)", log.info(
name, gpu_index, attempt + 1, max_retries) "Auto-load profile %r verified on GPU %d (attempt %d/%d)",
name,
gpu_index,
attempt + 1,
max_retries,
)
return return
log.warning("Auto-load attempt %d/%d: read-back mismatch — %s", log.warning(
attempt + 1, max_retries, "; ".join(mismatches)) "Auto-load attempt %d/%d: read-back mismatch — %s",
attempt + 1,
max_retries,
"; ".join(mismatches),
)
else: else:
log.info("Auto-load profile %r applied on GPU %d (attempt %d/%d)", log.info(
name, gpu_index, attempt + 1, max_retries) "Auto-load profile %r applied on GPU %d (attempt %d/%d)",
name,
gpu_index,
attempt + 1,
max_retries,
)
return return
if attempt < max_retries - 1: 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) log.info("Retrying auto-load in %ds…", delay)
await asyncio.sleep(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]: 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. Sets g_state["active_profile"] on full success.
""" """
import os as _os import os as _os
g_state = _get_gpu_state(gpu_index) g_state = _get_gpu_state(gpu_index)
gpu = g_state["gpu"] gpu = g_state["gpu"]
cfg: Config = _state["config"] 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)) errs.append("Curve: " + "; ".join(errors))
else: else:
if cfg.auto_snapshot: 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) ret, desc = await _run(write_offsets, gpu, deltas)
if ret != 0: if ret != 0:
errs.append(f"Curve write failed ({ret}): {desc}") errs.append(f"Curve write failed ({ret}): {desc}")
@@ -748,7 +963,9 @@ async def api_profile_delete(name: str):
g_state["active_profile"] = None g_state["active_profile"] = None
changed = any(v == name for v in cfg.auto_load_profiles.values()) changed = any(v == name for v in cfg.auto_load_profiles.values())
if changed: 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) _persist_auto_load_profiles(cfg.auto_load_profiles)
return {"ok": True} 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) mem_off_range = await _run(get_mem_offset_range, gpu_index)
return { return {
**power, **power,
**offsets, # gpc_offset_mhz, mem_offset_mhz **offsets, # gpc_offset_mhz, mem_offset_mhz
**mem_off_range, # min_mem_offset_mhz, max_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 ────────────────────────────────────────────────────────────── # ── Fan endpoints ──────────────────────────────────────────────────────────────
@app.get("/api/fans") @app.get("/api/fans")
async def api_fans(gpu_index: int = 0): async def api_fans(gpu_index: int = 0):
"""Current fan state: fan %, curve, and whether curve control is active.""" """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. # set to an inappropriate speed.
if curve_data: if curve_data:
sample = await _run(poll, g_state["gpu"], gpu_index) 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) target = interpolate_fan_speed(curve_data, test_temp)
if target is not None: if target is not None:
fan_ok, fan_msg = await _run(set_fan_speed, gpu_index, target) fan_ok, fan_msg = await _run(set_fan_speed, gpu_index, target)
if not fan_ok: 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 # Stop existing poller if running
if g_state.get("fan_poller_task"): if g_state.get("fan_poller_task"):
@@ -994,6 +1218,7 @@ async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
# ── Write endpoints ──────────────────────────────────────────────────────────── # ── Write endpoints ────────────────────────────────────────────────────────────
async def _reconcile_check(gpu_index: int) -> dict | None: 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. """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"]) 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) errors = validate_write(req.deltas, effective_limit)
if errors: if errors:
raise HTTPException(status_code=400, detail={"errors": 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) warning = await _reconcile_check(gpu_index)
if cfg.auto_snapshot: 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) ret, desc = await _run(write_offsets, gpu, req.deltas)
if ret != 0: 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") 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"} 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) errors = validate_write(all_deltas, effective_limit)
if errors: if errors:
raise HTTPException(status_code=400, detail={"errors": 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) warning = await _reconcile_check(gpu_index)
if cfg.auto_snapshot: 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) ret, desc = await _run(write_global_offset, gpu, req.delta_khz)
if ret != 0: if ret != 0:
@@ -1124,7 +1365,13 @@ async def api_curve_reset(gpu_index: int = 0):
warning = await _reconcile_check(gpu_index) warning = await _reconcile_check(gpu_index)
if cfg.auto_snapshot: 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) ret, desc = await _run(reset_offsets, gpu)
if ret != 0: 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) before_offsets, err = await _run(read_clock_offsets, gpu)
if before_offsets is None: 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 # 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"]: async with g_state["write_lock"]:
ret, desc = await _run(write_offsets, gpu, req.deltas) 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) after_offsets, err = await _run(read_clock_offsets, gpu)
if after_offsets is None: 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 g_state["active_profile"] = None
await _update_offsets_and_broadcast(gpu_index) 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 match = actual == expected
if not match: if not match:
all_matched = False all_matched = False
points_result.append({ points_result.append(
"point": point, {
"expected_khz": expected, "point": point,
"actual_khz": actual, "expected_khz": expected,
"match": match, "actual_khz": actual,
}) "match": match,
}
)
collateral = [ collateral = [
{"point": i, "before_khz": before_offsets[i], "after_khz": after_offsets[i]} {"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.""" """Gracefully shut down the server process."""
import os import os
import signal import signal
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
loop.call_later(0.1, lambda: os.kill(os.getpid(), signal.SIGTERM)) loop.call_later(0.1, lambda: os.kill(os.getpid(), signal.SIGTERM))
return {"ok": True} return {"ok": True}
@@ -1216,7 +1472,9 @@ async def api_snapshot_save(gpu_index: int = 0):
"""Save a ClockBoostTable snapshot.""" """Save a ClockBoostTable snapshot."""
gpu, g_state = _require_gpu(gpu_index) gpu, g_state = _require_gpu(gpu_index)
cfg: Config = _state["config"] 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: if path is None:
raise HTTPException(status_code=500, detail="Failed to save snapshot") raise HTTPException(status_code=500, detail="Failed to save snapshot")
return {"ok": True, "filepath": path} return {"ok": True, "filepath": path}
@@ -1241,9 +1499,27 @@ async def api_snapshot_restore(req: SnapshotRestoreRequest, gpu_index: int = 0):
# ── WebSocket endpoints ─────────────────────────────────────────────────────── # ── 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") @app.websocket("/ws/monitor")
async def ws_monitor(ws: WebSocket): async def ws_monitor(ws: WebSocket):
"""Stream MonitoringSample at poll_interval_s. Clients receive JSON objects.""" """Stream MonitoringSample at poll_interval_s. Clients receive JSON objects."""
if not _ws_authenticated(ws):
await ws.close(code=1008)
return
await ws.accept() await ws.accept()
try: try:
data = await ws.receive_json() data = await ws.receive_json()
@@ -1274,7 +1550,7 @@ async def ws_monitor(ws: WebSocket):
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
except Exception: except Exception:
pass log.debug("monitor ws client error", exc_info=True)
finally: finally:
g_state["monitor_clients"].discard(ws) g_state["monitor_clients"].discard(ws)
@@ -1282,6 +1558,9 @@ async def ws_monitor(ws: WebSocket):
@app.websocket("/ws/curve") @app.websocket("/ws/curve")
async def ws_curve(ws: WebSocket): async def ws_curve(ws: WebSocket):
"""Push CurveState whenever the curve changes (after writes).""" """Push CurveState whenever the curve changes (after writes)."""
if not _ws_authenticated(ws):
await ws.close(code=1008)
return
await ws.accept() await ws.accept()
try: try:
data = await ws.receive_json() data = await ws.receive_json()
@@ -1313,47 +1592,54 @@ async def ws_curve(ws: WebSocket):
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
except Exception: except Exception:
pass log.debug("curve ws client error", exc_info=True)
finally: finally:
g_state["curve_clients"].discard(ws) g_state["curve_clients"].discard(ws)
# ── Frontend SPA ────────────────────────────────────────────────────────────── # ── 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 # When set, suppresses the auto-open browser behaviour so the dev can open
# the Vite dev server (pnpm dev) manually instead. # the Vite dev server (pnpm dev) manually instead.
_DEV_PORT = os.environ.get("NVCURVE_DEV_PORT") _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")): 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}") @app.get("/{catchall:path}")
async def serve_spa(catchall: str): 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") raise HTTPException(status_code=404, detail="Not Found")
if not os.path.isdir(_dist_dir): if not os.path.isdir(_dist_dir):
@@ -1372,6 +1658,7 @@ async def serve_spa(catchall: str):
# ── Factory for configured app ──────────────────────────────────────────────── # ── Factory for configured app ────────────────────────────────────────────────
def create_app(config: Config = default_config) -> FastAPI: def create_app(config: Config = default_config) -> FastAPI:
"""Create a server app with a custom config (e.g. different gpu_index).""" """Create a server app with a custom config (e.g. different gpu_index)."""
_state["config"] = config _state["config"] = config
@@ -1388,6 +1675,7 @@ def run(
"""Start the uvicorn server. Blocking.""" """Start the uvicorn server. Blocking."""
import socket import socket
import threading import threading
import uvicorn import uvicorn
_state["config"] = config _state["config"] = config
@@ -1404,7 +1692,9 @@ def run(
s.bind((host, port)) s.bind((host, port))
except OSError: except OSError:
print(f"Error: port {port} is already in use.") 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 return
url = f"http://{host}:{port}" url = f"http://{host}:{port}"
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"nvidia-ml-py>=12.0", "nvidia-ml-py>=12.0",
"pydantic>=2.0", "pydantic>=2.0",
"httpx>=0.27", "httpx>=0.27",
"bcrypt>=4.0",
] ]
[project.scripts] [project.scripts]
+4
View File
@@ -0,0 +1,4 @@
{
"venvPath": ".",
"venv": ".venv"
}
Generated
+68
View File
@@ -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" }, { 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]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.2.25"
@@ -159,6 +225,7 @@ name = "nvcurve"
version = "0.5.1" version = "0.5.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "nvidia-ml-py" }, { name = "nvidia-ml-py" },
@@ -168,6 +235,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "bcrypt", specifier = ">=4.0" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "nvidia-ml-py", specifier = ">=12.0" }, { name = "nvidia-ml-py", specifier = ">=12.0" },