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

+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> {
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
const url =
gpuIndex !== undefined
? `/api${path}?gpu_index=${gpuIndex}`
: `/api${path}`;
const res = await fetch(url);
if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText);
throw new Error(`GET ${url}: ${res.status} — ${text}`);
throw new ApiError(res.status, `GET ${url}: ${res.status} — ${text}`);
}
return res.json() as Promise<T>;
}
async function post<T = void>(path: string, body?: unknown, gpuIndex?: number): Promise<T> {
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
async function post<T = void>(
path: string,
body?: unknown,
gpuIndex?: number,
): Promise<T> {
const url =
gpuIndex !== undefined
? `/api${path}?gpu_index=${gpuIndex}`
: `/api${path}`;
const res = await fetch(url, {
method: 'POST',
headers: body != null ? { 'Content-Type': 'application/json' } : {},
method: "POST",
headers: body != null ? { "Content-Type": "application/json" } : {},
body: body != null ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText);
throw new Error(`POST ${url}: ${res.status} — ${text}`);
throw new ApiError(res.status, `POST ${url}: ${res.status} — ${text}`);
}
// Some endpoints return no body (204)
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('application/json')) return res.json() as Promise<T>;
const ct = res.headers.get("content-type") ?? "";
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;
}
async function del<T = void>(path: string, gpuIndex?: number): Promise<T> {
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
const res = await fetch(url, { method: 'DELETE' });
const url =
gpuIndex !== undefined
? `/api${path}?gpu_index=${gpuIndex}`
: `/api${path}`;
const res = await fetch(url, { method: "DELETE" });
if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText);
throw new Error(`DELETE ${url}: ${res.status} — ${text}`);
throw new ApiError(res.status, `DELETE ${url}: ${res.status} — ${text}`);
}
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('application/json')) return res.json() as Promise<T>;
const ct = res.headers.get("content-type") ?? "";
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;
}
export interface AuthStatus {
auth_required: boolean;
authenticated: boolean;
username: string | null;
expires_at: number | null;
}
export const api = {
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),
/** Auth */
authStatus: () => get<AuthStatus>("/auth/status"),
login: (username: string, password: string) =>
post<{ ok: boolean; username: string; expires_at: number; token: string }>(
"/auth/login",
{ username, password },
),
logout: () => post("/auth/logout"),
gpus: () => get<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 } */
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. */
resetCurve: (gpuIndex: number) => post('/curve/reset', undefined, gpuIndex),
resetCurve: (gpuIndex: number) => post("/curve/reset", undefined, gpuIndex),
/** Get performance limits mapping */
limits: (gpuIndex: number) => get<LimitsState>('/limits', gpuIndex),
limits: (gpuIndex: number) => get<LimitsState>("/limits", gpuIndex),
/** Set performance limits */
updateLimits: (updates: Partial<LimitsState>, gpuIndex: number) =>
post('/limits', updates, gpuIndex),
post("/limits", updates, gpuIndex),
/** Reset power limit and memory offset to hardware defaults */
resetLimits: (gpuIndex: number) => post('/limits/reset', undefined, gpuIndex),
resetLimits: (gpuIndex: number) => post("/limits/reset", undefined, gpuIndex),
/** Profile Management */
profiles: (gpuIndex: number) => get<{ profiles: ProfileData[], active: string | null, auto_load: string | null }>('/profiles', gpuIndex),
saveProfile: (name: string, gpuIndex: number) => post<{ ok: boolean; filepath: string }>('/profiles', { name }, gpuIndex),
applyProfile: (name: string, gpuIndex: number) => post(`/profiles/${encodeURIComponent(name)}/apply`, undefined, gpuIndex),
profiles: (gpuIndex: number) =>
get<{
profiles: ProfileData[];
active: string | null;
auto_load: string | null;
}>("/profiles", gpuIndex),
saveProfile: (name: string, gpuIndex: number) =>
post<{ ok: boolean; filepath: string }>("/profiles", { name }, gpuIndex),
applyProfile: (name: string, gpuIndex: number) =>
post(`/profiles/${encodeURIComponent(name)}/apply`, undefined, gpuIndex),
deleteProfile: (name: string) => del(`/profiles/${encodeURIComponent(name)}`),
renameProfile: (oldName: string, newName: string) =>
post(`/profiles/${encodeURIComponent(oldName)}/rename`, { new_name: newName }),
post(`/profiles/${encodeURIComponent(oldName)}/rename`, {
new_name: newName,
}),
/** Server config */
setAutoLoadProfile: (name: string | null, gpuIndex: number) =>
post<{ ok: boolean; auto_load_profile: string | null }>('/config', { auto_load_profile: name, gpu_index: gpuIndex }),
post<{ ok: boolean; auto_load_profile: string | null }>("/config", {
auto_load_profile: name,
gpu_index: gpuIndex,
}),
/** Fan control */
fans: (gpuIndex: number) => get<FanState>('/fans', gpuIndex),
fans: (gpuIndex: number) => get<FanState>("/fans", gpuIndex),
updateFans: (curve: FanPoint[], gpuIndex: number) =>
post('/fans', { curve }, gpuIndex),
resetFans: (gpuIndex: number) => post('/fans/reset', undefined, gpuIndex),
post("/fans", { curve }, gpuIndex),
resetFans: (gpuIndex: number) => post("/fans/reset", undefined, gpuIndex),
setFanSpeed: (fanPct: number, gpuIndex: number) =>
post('/fans/speed', { fan_pct: fanPct }, gpuIndex),
post("/fans/speed", { fan_pct: fanPct }, gpuIndex),
};