gpu-tuner

gpu-tuner

Docker app from 0sy's Repository

Overview

NVIDIA GPU monitoring, power limits, fan curves and clock caps for Unraid. Requires the NVIDIA Driver plugin and a compatible GPU. Set the WebUI URL to http://YOUR-Unraid-IP:8765 and supply an initial password of 12–1024 characters. Settings and credentials persist in appdata. GPU control requires SYS_ADMIN; privileged mode is not required. Use on a trusted LAN.

GPU Tuner for Unraid

Unraid packaging of willboyden/gpu-tuner, maintained in 0sy/gpu-tuner-unraid.

Beta release: ghcr.io/0sy/gpu-tuner-unraid:1.3.1-unraid.1-beta.1 and :latest are publicly available. Anonymous image pulls and installed-image login/reset checks passed. Submitted to Community Applications; catalog approval is pending.

Start with the Unraid installation and operation guide. The Linux/systemd instructions below are upstream documentation and are not the Unraid installation procedure.

Acknowledgments

GPU Tuner was created by willboyden. The Unraid adaptation is maintained by 0sy, with assistance from OpenAI Codex on packaging, WebUI authentication, testing and documentation.


gpu-tuner — power caps, fan curves and clock caps for NVIDIA GPUs, on one machine or several, with a GUI

A local web page (http://127.0.0.1:8765/) that shows every detected card live — temperature (plus the chip's own on a system-on-chip GPU such as GB10), fan, power, clocks, utilization, VRAM (or shared system memory), encode/decode, ECC, PCIe link width, persistence mode, what is limiting the clock, what is resident — with six hours of charts, and lets you change the three things that are safe to change on any card, whatever model or how many there are. One page can also manage other machines on your network over ssh, each one still enforcing its own limits (Multiple machines):

Knob Range
Power limit that card's own NVML min–max, narrowed by a known profile if one exists
Fan curve / fixed / driver default, over a safety floor anchored to THAT card's own slowdown threshold
Core clock cap 1,000 MHz to that card's top supported clock, or off

Plus the one thing no single card's own range tells you: a combined GPU power budget, editable from the page itself. A fresh install seeds it to whatever the caps already in force add up to; raising it later is instant. Exceeding it is a soft limit, not a wall — the page warns you with the numbers and asks you to confirm, then applies it (still bounded by each card's own hardware range, which is never overridable).

The two RTX PRO 6000 models it was built on get hand-measured profiles with informed presets (see PROFILES in gpu_tuner/safety.py); any other card is discovered generically from live NVML and can be lowered, but not raised above its factory default, until someone adds a profile for it. A control NVML doesn't offer on a card (a power limit or fans on a GB10, say) is simply not shown for it, with a line saying so.

Where they exist it replaces gpu-fan-curve.service and gpu-power-limit.service (the original lab's services: disabled by the installer, kept on disk, restored by --uninstall).

The page on a desktop: the machines table, the workstation's power budget and its two cards with live readings and power presets The same page at phone width

Six hours of temperature, fan, power, clock and utilization history for two cards

These screenshots come from demo mode: three simulated machines, no real GPU involved.

Try it without a GPU: demo mode

git clone https://github.com/willboyden/gpu-tuner && cd gpu-tuner
./gpu-tuner serve --demo      # the page on http://127.0.0.1:8766/
./gpu-tuner open --demo       # in a second terminal: signs your browser in

Demo mode serves the real page over three made-up machines: a workstation with two RTX PRO 6000 cards (control daemon, power budget, fan curves), a render box with an RTX 5090 (a lowered power limit and a clock cap) and a GB10 edge box (monitor-only, unified memory). Their readings move like training, inference and rendering jobs, and the charts start with six hours of history. Every change you make goes through the same daemon checks as on real hardware (a copy of the daemon logic runs in-process for each simulated machine) and then lands on a simulated card.

It needs only Python 3: no NVIDIA driver, no pynvml, no root and no install. It never loads NVML, never talks to a real gpu-tunerd or its socket, never runs ssh and opens no connection beyond its own 127.0.0.1 port; its state lives in a private temporary directory that is removed when it stops. It uses its own port (8766) and its own session cookie, so it can run beside a real page without signing you out of it. gpu-tuner open keeps its single-use sign-in links in ~/.config/gpu-tuner, as for the real page.

Install

Requirements

  • Linux with systemd, an NVIDIA driver and nvidia-smi. x86_64 and aarch64 (the monitor-only node runs on DGX Spark; the root daemon has only been run on x86_64).
  • The system python (/usr/bin/python3) able to import pynvml. On Debian/Ubuntu: sudo apt install python3-pynvml. The root daemon runs with python3 -I, so a pip install into your user or a venv is not enough.
  • sudo on every machine that gets the root daemon. Nothing else needs root.
  • A browser on the machine with the page. For other machines: OpenSSH on both ends, and an ssh login from the page's machine to each one (ssh <host> true works). The loop under "Several machines at once" also needs git and tar.

Every machine gets one of three modes:

Mode Command Installs Use it for
page + control ./install.sh root daemon, the page (user unit), app launcher the machine you sit at
control only ./install.sh --no-ui root daemon another machine whose GPUs you want to change from your page
monitor only ./install.sh --node just the code, no services another machine you only want to watch — or one whose GPUs expose nothing to set (DGX Spark)

Every mode puts the code at /usr/local/lib/gpu-tuner (root-owned); add --dry-run to any of them to print each privileged command and change nothing. Run install.sh as yourself, not with sudo: it asks for sudo itself, and on first install records your uid as the one allowed to talk to the daemon (to change it later: edit allowed_uid in /etc/gpu-tuner/config.json, then sudo systemctl restart gpu-tunerd).

1. The machine you sit at

git clone https://github.com/willboyden/gpu-tuner && cd gpu-tuner
./install.sh --dry-run   # read what it will do
./install.sh             # asks for sudo; rolls back on any failure
./gpu-tuner open         # signs your browser in (also "GPU Tuner" in the app launcher)

The page runs from this checkout (the user unit points at it), so keep the checkout where it is. That's all a single machine needs. Without the install, ./gpu-tuner serve runs the page monitor-only: every reading and chart is live, each card's envelope is shown, the controls are locked.

2. Each other machine (optional)

As the user your page will log in as (the daemon only answers that user):

git clone https://github.com/willboyden/gpu-tuner && cd gpu-tuner   # or copy this directory over
./gpu-tuner probe | less        # optional, read-only: what NVML exposes on this machine
./install.sh --no-ui            # control only — or ./install.sh --node for monitor only

--node ends by printing what the machine reports (gpu-tuner node --check); if a GPU shows no settable power limit · fans 0 · no clock cap, monitor-only is all it can do anyway.

3. Connect them

On the machine with the page:

./gpu-tuner hosts --init-key --from 10.0.0.5     # this machine's address, as the others see it

That creates the page's own ssh key (~/.config/gpu-tuner/ssh/id_ed25519, mode 0600, never printed; no passphrase so the page's service can use it unattended) and prints one line to add to ~/.ssh/authorized_keys on every other machine:

restrict,from="10.0.0.5",command="/usr/local/lib/gpu-tuner/gpu-tuner node --stdio" ssh-ed25519 AAAA… gpu-tuner-hub

That key can run the node and nothing else: no shell, no forwarding, no pty, and — with --from (comma-separate several addresses if the machines see this one on different networks) — only from here. Then:

  1. Connect to each machine once by hand (ssh <host> true), so its host key is checked and pinned: the page only reads known_hosts and refuses unknown or changed keys.

  2. List the machines in ~/.config/gpu-tuner/hosts.json and chmod 600 it (see examples/hosts.example.json and the field notes under Multiple machines):

    {"hosts": [
      {"id": "local", "label": "Workstation",
       "wall": {"non_gpu_dc_w": 630, "psu_efficiency": 0.9, "circuits": {"15 A": 1440, "20 A": 1920}}},
      {"id": "gpu-box-2", "label": "GPU box 2", "ssh": "gpu-box-2"},
      {"id": "edge-1", "label": "Edge 1", "ssh": "me@10.0.0.21", "port": 2222}
    ]}
    
  3. Check, then restart the page so it reads the list:

    ./gpu-tuner hosts --check            # connects to each machine once and says exactly what's wrong
    systemctl --user restart gpu-tuner-ui
    

Several machines at once

Steps 2 and 3 for a list of machines, from the page's git checkout. It uses your existing ssh access to copy the code (committed files only, via git archive), install (sudo asks for a password on each machine, hence ssh -t) and add the page's key line once; after that the page only ever logs in with its own restricted key. Your first ssh to each machine here also checks and pins its host key. (It assumes apt; adjust for your distro.)

PAGE_IP=10.0.0.5          # this machine's address as the others see it; comma-separate several
LINE=$(./gpu-tuner hosts --init-key --from "$PAGE_IP" | grep '^restrict,')
if [ -z "$LINE" ]; then echo "init-key printed no key line; stopping"; else
for h in gpu-box-2 gpu-box-3; do                  # your ssh aliases or user@host
  mode=--no-ui                                    # or --node for monitor only
  git archive HEAD | ssh "$h" 'mkdir -p gpu-tuner && tar -x -C gpu-tuner' &&
  ssh -t "$h" "/usr/bin/python3 -I -c 'import pynvml' 2>/dev/null || sudo apt install -y python3-pynvml; ~/gpu-tuner/install.sh $mode" &&
  printf '%s\n' "$LINE" | ssh "$h" 'umask 077; mkdir -p ~/.ssh; chmod 700 ~/.ssh; f=~/.ssh/authorized_keys; read -r l
[ -n "$l" ] || { echo "empty key line: nothing added" >&2; exit 1; }
[ -s "$f" ] && [ -n "$(tail -c1 "$f")" ] && echo >> "$f"
grep -qxF "$l" "$f" 2>/dev/null || printf "%s\n" "$l" >> "$f"; chmod 600 "$f"'
done
fi

The key line reaches each machine on stdin, never as a command argument; it is added once (a second run adds nothing), on its own line even if authorized_keys doesn't end in a newline, and only after that machine's install succeeded. Then write hosts.json, ./gpu-tuner hosts --check and restart the page as in step 3.

Upgrading

Pull (or copy) the new version and run the same install.sh mode again — on this machine and on each other one. It's idempotent: config.json (your budget) and state.json (your saved power caps, fan curves and clock caps) are kept, never reseeded, and both services are restarted so the new code actually runs. Check with ./gpu-tuner hosts --check afterwards — it prints each machine's version. Machines on different versions keep working together as long as they speak the same protocol; one whose protocol is too old or too new is shown on the page as incompatible rather than half-working.

Uninstalling

  • This machine, or any other (from its checkout — ~/gpu-tuner if you used the loop above): ./install.sh --uninstall. It removes the services and the code under /usr/local/lib/gpu-tuner, hands the fans back to the driver (power caps stay as they are), and re-enables gpu-fan-curve/gpu-power-limit if this machine had them. /etc/gpu-tuner and /var/lib/gpu-tuner are kept; delete them by hand for a clean slate.
  • On each other machine, also remove the page's key: delete the line ending in gpu-tuner-hub from ~/.ssh/authorized_keys.
  • On the page's machine: rm -r ~/.config/gpu-tuner/ssh ~/.config/gpu-tuner/hosts.json.

What install.sh does on first install

It takes over the caps currently in force: it seeds the daemon's state from nvidia-smi and seeds gpu_budget_w to their sum (or null if no card reports a settable cap). Fans stay on the driver's own curve until you pick one — unless the machine already ran a gpu-fan-curve.service, in which case they stay on the curve it ran. Where gpu-fan-curve.service / gpu-power-limit.service exist (the original lab's services) they are disabled, kept on disk, and restored by --uninstall; one caveat, stated so nobody is surprised: stopping gpu-power-limit runs its ExecStop, which restores factory caps for the second or two before gpu-tunerd starts and re-applies the seeded ones. It finishes by asking the page itself whether it reaches the daemon, not just whether the services are "active".

Multiple machines

One page, every machine: a fleet strip (a row per GPU, with each machine's link state) and a tab per machine with that machine's cards, budget and charts. With only one machine nothing changes. Nothing new listens on the network anywhere — the page reaches the other machines over ssh, and each machine's own root daemon stays the authority over its own cards. Setup is steps 2 and 3 of Install.

this machine                                         each other machine
gpu-tuner serve (page) ── ssh, restricted key ──▶  gpu-tuner node --stdio (you) ──unix sock──▶ gpu-tunerd (root)
      └── subprocess ──▶ gpu-tuner node --stdio ──unix sock──▶ gpu-tunerd (root, here)

hosts.json, field by field:

  • ssh is what you'd type after ssh: a ~/.ssh/config alias or user@host (never an option — anything starting with - is refused). Your ssh config still applies; known_hosts is only read, so connect once by hand first to check and pin each host key.
  • The page logs in with its own key only — no agent, no passwords, no default ~/.ssh/id_*. With no page key yet, it doesn't try ssh at all and that machine's tab says to run --init-key. One caveat: an IdentityFile your ~/.ssh/config names for that host is still offered (ssh adds those up); if the page's key isn't authorized there but that one is, the login works but is unrestricted, and the machine's tab says so.
  • "local" is the machine the page runs on. Leave it out to run the page on a machine without GPUs. With no hosts.json at all, the page manages just this machine, as before.
  • wall (optional, display-only) is that machine's worst-case wall-power model: the watts its non-GPU parts draw, its PSU efficiency, and the circuits to compare against. The numbers above are the original workstation's; without wall the page simply doesn't estimate.
  • Each machine has its own power budget (they're on different circuits), set from its tab.

What to expect from a DGX Spark (GB10). Measured on four of them (aarch64, driver 580.178.04, 2026-10-03): NVML reports no power limit, no fans, no supported-clock list and no memory info — firmware manages the whole chip — so a Spark shows temperature, power draw, clocks, utilization and system memory (shared with the GPU), and offers no controls. It also shows the SoC temperature: the hottest of the kernel's thermal zones (/sys/class/thermal), which on a GB10 measure the package the GPU sits in. On three Sparks under load (GPU at 70 °C or more, the week to 2026-10-26) it averaged 11-14 °C above the GPU die NVML reports and reached 24 °C above it (2-4 °C at idle), so it is the number to watch on a Spark; it is a tile, a dotted line on the temperature chart and part of the machines table. Discrete cards never get one: their machine's thermal zones are the motherboard's. Install it with --node: there is nothing for a root daemon to do. Its thresholds come back as T.Limit 99 °C, slowdown 86 °C and shutdown 90 °C, so the page measures headroom against whichever threshold a card reports lowest, and names it. ./gpu-tuner probe (or ssh <host> python3 - < gpu_tuner/probe.py before installing anything) prints exactly what NVML exposes on a machine, read-only.

What a link failure looks like. A machine that drops off keeps its last-known cards on its tab, greyed out, with the reason ("ssh refused the login…", "host key unknown or changed…", "no route to host") and when it will retry; its controls lock, and whatever it last applied stays in force there. A change that times out says it may or may not have applied — it is never retried for you.

What is safe, and why these limits

Every write is validated by the root daemon against gpu_tuner/safety.py, never by the page. The file lives in a root-owned copy under /usr/local/lib/gpu-tuner, so nothing running as the desktop user can widen a limit.

  • Power. The card's own NVML range, narrowed by its profile if one exists, then that machine's combined budget. Lowering is always allowed (an over-budget state must be walkable back down); raising past the budget needs an explicit confirm click, which the page's set_power request carries as confirm_override. The two profiled cards' presets carry numbers measured on those cards, so a choice is informed, not a guess. A card with no profile can be lowered but never raised above its factory default. The combined budget itself is a set_budget request, persisted to config.json by the daemon — nothing enforces it beyond that one comparison, so raising it is instant and never needs a restart.
  • Fans. Any curve or fixed speed is evaluated as max(requested, floor(temp)). The floor's shape was measured on this lab's own cards (61 °C @ 43%, 78 °C @ 46%, 80 °C @ 59% — never lazier than their stock driver curve), but its "100% by" temperature is rescaled per card from THAT card's own NVML slowdown threshold (safety.safety_floor_for), not a fixed number — on this lab's cards that lands at 85 °C, 10 °C under their 95 °C slowdown (92/93 °C T.Limit, 98 °C shutdown), which is where the constant came from originally. A curve must rise monotonically and end at 100% at or below that card's own floor ceiling. Hysteresis: up at once, down only after 2 °C of cooling.
  • Clock cap. SetGpuLockedClocks(min, cap) — it can only slow the card. The value snaps down to a supported clock step. 1,000 MHz floor is a fat-finger guard, not a safety limit.
  • Failure policy: always toward the driver's own fan control. SIGTERM → fans handed back in finally. Crash or watchdog kill → the unit's ExecStopPost hands them back. A refused fan write or an unreadable temperature → that card goes to driver control and is retried in 30 s. Fan speeds are re-asserted every 30 s (suspend/resume and GPU resets silently hand the fans back). Power caps are re-checked every 10 s and re-applied if anything else changed them. Power caps are left in place on exit, on purpose: stopping a service must not uncap the Workstation card to 600 W.

Left out on purpose (also listed on the page)

Core/memory clock offsets (no range is known-stable across arbitrary hardware, and an unstable offset corrupts results before it crashes), thermal-limit / target-temperature changes (unsupported on most cards; NVML reports the acoustic thresholds unsupported), toggling ECC, MIG or compute mode (ECC's mode and error counts ARE shown, read-only, when a card supports them), turning persistence mode off (its actual on/off state is shown, read-only — the daemon holds it on), chassis fans (BMC-owned on this lab's board; the PWM registers do nothing here, though that varies by board elsewhere).

How it is built

gpu-tunerd (root, systemd)  <--unix socket, SO_PEERCRED-->  gpu-tuner node (you)  <--stdin/stdout, JSON lines-->
 owns every NVML write                                       NVML reads, forwards     (a subprocess here, ssh elsewhere)
 fan loop, validation                                        whitelisted changes
                                                                                    gpu-tuner serve (you, systemd --user)
                                                                                     history, page, /api/*  <--127.0.0.1:8765--> browser
File Role
gpu_tuner/safety.py The envelope: profiles, budget (soft, overridable), floor (per-card), curve rules. Pure functions, unit-tested.
gpu_tuner/nvml.py Thin NVML wrapper, generic over however many GPUs are present; dry_run=True records writes instead of making them.
gpu_tuner/daemon.py Root daemon: socket server, fan loop, state + config persistence, startup/exit policy.
gpu_tuner/node.py One machine's GPUs over stdin/stdout: samples, daemon status, whitelisted changes to its own daemon. Exits when the page goes away.
gpu_tuner/proto.py The wire protocol, and the sanitizers that treat every node's messages as untrusted.
gpu_tuner/hosts.py hosts.json: which machines, the exact ssh command for each, the restricted-key line.
gpu_tuner/hub.py One link per machine (reconnect, backoff, heartbeat, one change in flight), and the 6 h history on the page's clock.
gpu_tuner/server.py The page: /api/state, /api/history?host=, /api/apply (routed by machine, never by GPU alone), sign-in.
gpu_tuner/cli.py gpu-tuner serve | open | node | probe | hosts (and demo-node, which serve --demo starts).
gpu_tuner/demo.py Demo mode: a pynvml-shaped simulation of three machines, run through the real node, daemon and page code.
gpu_tuner/seed.py · probe.py Fresh-install seed from nvidia-smi · read-only NVML capability report.
web/ The page. No framework, no CDN, no inline script/style (CSP), no innerHTML.
gpu-tunerd.service Root unit: Type=notify, watchdog, hardened, ExecStopPost --restore-fans.
gpu-tuner-ui.service User unit for the page. gpu-tuner.desktop (with icon.svg) is the launcher.
install.sh Install / upgrade / --no-ui / --node / --dry-run / --uninstall, with rollback.

Not containerized, on purpose — checked, not assumed. This app's writes need root (NO_PERMISSION from NVML otherwise, verified 2026-09-07), and this lab's Docker is rootless: a container's uid 0 maps to the host's own unprivileged user, not real root. Verified directly: docker run --gpus all --privileged --cap-add SYS_ADMIN ... nvidia-smi -pl <value> still gets Insufficient Permissions, and /proc/self/uid_map inside that container shows 0 <host-uid> 1 — the container's "root" is the invoking user, as far as anything outside the container's own namespace is concerned. There is no containerization scheme that changes this on this security model, so both halves stay host-installed processes.

Why two processes. Fan and power writes are root-only on these cards (non-root returns NO_PERMISSION, verified 2026-09-07). The page should not be root. Reads need no privilege, so the UI server samples NVML itself and only forwards change requests to the daemon.

Why the page has a login at all. This box runs untrusted agents and containers, and any local process can reach 127.0.0.1. So: loopback bind only; an HttpOnly; SameSite=Strict session cookie whose secret never appears in a URL or a log (gpu-tuner open mints a single-use 60 s nonce and the server trades it for the cookie); a Host check against DNS rebinding; an Origin check on every write. The daemon socket is 0600, owned by the configured uid, and re-checks the peer uid on every connection. Process names shown on the page come from /proc/<pid>/comm, never cmdline (command lines here carry API keys).

Why a daemon and not a one-shot. NVML has no "install a curve" call: SetFanSpeed pins a fixed speed until the next write, so a curve has to be re-evaluated as temperature moves.

Security review

SoC temperature (1.3.0, 2026-10-26)

  • One new read, no new write or input. A node now reads /sys/class/thermal/thermal_zone*/temp (world-readable) for GPUs that report unified memory. The directory is fixed in code, not taken from the page, the hub or hosts.json; nothing new is written and no new request type exists.
  • The hub takes it like every other reading. soc_temp is one more number in the sample whitelist (proto.py), so NaN, huge values and wrong types are rejected as before. The protocol number is unchanged: an older node simply sends no SoC value and the page says it was not reported.
  • Discrete cards never show one, whatever the motherboard's zones read, and readings outside 1-125 °C (0, negatives, the 127.5 °C of an unconnected sensor) are dropped rather than shown.
  • This change has not had an independent review.

Demo mode (1.2.0, 2026-10-26)

  • Nothing real is read or changed. Every demo process blocks the pynvml module before anything else runs (a test checks it cannot be imported there); each simulated machine's daemon is an in-process copy of the daemon logic on a socket in a private 0700 temporary directory, never /run/gpu-tuner/control.sock; no ssh is run and no connection is opened beyond the page's own loopback port.
  • Nothing from the machine running it reaches the page. Host labels, the hostname each node reports and the process names are made up, and the demo node never reads /proc for them.
  • The same page security as the real one: loopback bind, Host check, Origin check on writes, an HttpOnly; SameSite=Strict cookie traded for a single-use nonce. It uses its own cookie name and an in-memory session secret, because cookies ignore the port: with the real page's cookie name, signing in to a demo on 8766 would sign you out of the real page on 8765.
  • Ctrl-C and SIGTERM remove the temporary directory; after a SIGKILL it stays in $TMPDIR (a few small state files and sockets).
  • This change has not had an independent review.

1.1.2 (2026-10-04)

An independent audit before publishing the install docs found:

  • A client on the daemon's socket could crash the root daemon — fixed. JSON nested about 10,000 levels deep (20 KB, under the 64 KiB request cap) makes Python 3.12/3.13's parser raise RecursionError, which serve_one didn't catch, so the daemon exited (fans went back to the driver; systemd restarted it 5 s later). Any process running as the daemon's configured user could repeat it at will. Python 3.14 parses that depth without error, which is why it went unseen. Now it's a "bad request" reply, and no exception from a request or a client can end the daemon's loop. Covered by tests that pass on 3.12 and 3.14 and fail on 3.12 without the fix.
  • Sign-in link on the command line. gpu-tuner open hands the single-use, 60 s sign-in link to xdg-open as an argument, so for that minute another local user could read it from /proc (unless /proc is mounted hidepid=2) and use it first. On a machine shared with other users, run gpu-tuner open --print-url and paste the link instead.
  • Signing everyone out. The session never expires on its own: delete ~/.config/gpu-tuner/token and restart the page (systemctl --user restart gpu-tuner-ui) to revoke every signed-in browser.
  • The multi-machine install loop now copies only committed files, checks the system python the daemon actually uses, refuses an empty key line, and appends the key safely to an authorized_keys that doesn't end in a newline. Its append step is tested against scratch files.

Multiple machines (1.1.0, 2026-10-03)

  • No new listening sockets. The page reaches other machines only by running ssh; each machine's daemon still listens only on its own 0600 Unix socket.
  • Each daemon stays the authority. The page can send a machine only the change requests that machine's own daemon accepts from its own local page; the node forwards only the whitelisted operations and fields (proto.APPLY_OPS / APPLY_KEYS), never a socket path or anything else.
  • The key is restricted on every remote (restrict,from=…,command=…): it can run the node, nothing else. The node also reports whether sshd forced its command, and the page shows a warning when it says no — a hint for catching a mis-set-up machine, not a control: a compromised node could claim anything. The page runs ssh with only its own key (IdentitiesOnly, IdentityAgent=none, a scrubbed environment, no password or keyboard-interactive auth), BatchMode, StrictHostKeyChecking=yes (known_hosts read, never written), no forwarding, no ControlMaster.
  • hosts.json decides what ssh runs against, so it must be yours and not group/world-writable, is read only at startup, is validated strictly (ids, [user@]host with no leading -, no %, no whitespace), and nothing from the browser can reach ssh's arguments.
  • Every node is untrusted input. A compromised machine can lie about its own readings, but each message is size-capped (256 KiB a line, 20 MB per 10 s before the link is dropped), rejects NaN/Infinity and out-of-range numbers (one would blank the page for every machine), is rebuilt from a whitelist with every string, list and UUID capped, and may only report the GPUs its hello announced; the page inserts text only. A malformed message ends that machine's session and it reconnects — it can't stop the link or the page.
  • Outbound connections. The page now opens ssh to the machines in hosts.json — its own traffic, as you, outside any egress proxy you run for other tools. Nothing else goes out.
  • What it means, plainly: the page's key, on the machine you sit at, can change every managed machine's GPUs within each one's own safety envelope — the same power the page already had over the local cards, extended to every machine you list.

Two existing bugs were found while building this, both fixed:

  • install.sh checked for a saved state.json as you, but its directory is root-only (0700), so the check always said "missing" and every upgrade reseeded the state, discarding saved fan curves and clock caps. Root does the check now.
  • The hardened gpu-tuner-ui.service from the first review had ProtectSystem/ProtectHome/ PrivateTmp. In a user unit those need a mount namespace inside an unprivileged user namespace, and from inside it the daemon's socket refuses the connection (EACCES, measured on Ubuntu 26.04; each of the three alone broke it). It had never actually been installed on the machine it was built on, so nobody saw the page come up monitor-only. The unit keeps only the seccomp/prctl-based hardening now, and install.sh checks that the page really reaches the daemon.

First public push (2026-09-23)

Reviewed before the first public push (2026-09-23). No way was found for an unauthenticated actor, a CSRF'd browser tab, or the non-root UI process to force a write the root daemon shouldn't allow, or to bypass a card's own hardware range. Fixed as a result:

  • save_state/save_config now chmod the file explicitly (0600/0644) instead of trusting the ambient umask — the directory's own mode already prevented this from being exploitable today, but a future change to that mode shouldn't silently make state.json world-readable.
  • validate_clock_cap's 1,000 MHz "fat-finger" floor used to make the clock-cap feature unusable on any card whose entire range sits under 1,000 MHz (an inverted "1000-800 MHz" range rejected every value) — now the floor only applies when the card's own top clock clears it; below that, the card's own minimum takes over.
  • serve_one()'s request read loop had no overall time limit, only a per-recv() one — a process sharing the daemon's configured uid (the socket's only real gate) could trickle bytes just under that timeout indefinitely and stall the fan-safety tick loop, which runs on the same thread. It now has a hard 2 s wall-clock deadline regardless of how the bytes arrive.
  • confirm_override is now checked with is True, not bool(...) — the loose form would let a client send "confirm_override": "false" (a non-empty string) and have it override anyway.
  • set_budget now rejects a value above the sum of every detected card's own hardware maximum — above that, the budget could never actually bind on anything.
  • gpu-tuner-ui.service had no sandboxing at all, unlike its root sibling, despite being the process that parses attacker-reachable HTTP input. It was given a baseline then — part of which (ProtectSystem/ProtectHome/PrivateTmp) turned out to break the page; see 1.1.0 above.

What running this means, stated plainly. gpu-tunerd runs as root with CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_SYS_ADMIN. CAP_SYS_ADMIN is required — verified directly (see "Not containerized, on purpose" above) — and a process that holds it is not meaningfully contained against its own compromise, whatever else the unit's hardening does. Read the daemon's source before you sudo this install; the sandboxing narrows the blast radius of a parsing bug, it doesn't remove the trust you're extending.

Recommended, not yet done — needs testing against real hardware before changing: CAP_DAC_OVERRIDE in that same bounding set isn't obviously justified (state/config files are already root-owned with owner-rw bits, so root can open them without a DAC override; it's most likely there for /dev/nvidia* access if those nodes are group-restricted). Removing it, or replacing it with SupplementaryGroups=video (or your distro's GPU group), would tighten this further — untested here because the daemon in question is live, root, and controlling real hardware on the machine this was built on, and a wrong guess breaks GPU control until someone's at the console to fix it. Same caution applies to further gpu-tunerd.service hardening (SystemCallFilter=@system-service, DeviceAllow=char-nvidia rw with DevicePolicy=closed, RestrictSUIDSGID, ProtectClock, ProtectHostname, ProtectKernelLogs, ProcSubset=pid, RemoveIPC) — all plausible defense-in-depth, none verified compatible with NVML's ioctls yet.

Files on disk after install

Path What
/usr/local/lib/gpu-tuner/ root-owned copy of the code; gpu-tuner node --stdio here is what other machines' pages run
/etc/gpu-tuner/config.json allowed_uid, gpu_budget_w (editable from the page — this is just where the daemon persists it; null = nothing settable), interval_s
/var/lib/gpu-tuner/state.json the applied settings; re-validated on every start
/run/gpu-tuner/control.sock daemon socket
~/.config/gpu-tuner/ session token and sign-in nonces (0600); hosts.json and ssh/id_ed25519 if you manage other machines
~/.config/systemd/user/gpu-tuner-ui.service, ~/.local/share/applications/gpu-tuner.desktop UI unit and launcher. Both point into the checkout (the page's code, the launcher's icon.svg), so keep it where it is

Operate

journalctl -u gpu-tunerd -f                       # every fan decision, every request (audit lines), every fault
journalctl --user -u gpu-tuner-ui -f              # links up/down, and every change routed: apply host=… -> ok
systemctl status gpu-tunerd; systemctl --user status gpu-tuner-ui
./gpu-tuner hosts --check                         # every listed machine, once, with a diagnosis
./gpu-tuner node --check                          # what this machine reports, and whether its daemon answers
./gpu-tuner probe                                 # read-only JSON: everything NVML exposes here
nvidia-smi --query-gpu=name,power.limit,fan.speed,temperature.gpu --format=csv
./install.sh --uninstall                          # back to gpu-fan-curve + gpu-power-limit, if they existed

To try the daemon without root: ./gpu-tunerd --dry-run --socket /run/user/$UID/gt.sock --state /tmp/gpu-tuner-tryout/state.json (the --state override matters: it defaults to the root-owned /var/lib/gpu-tuner/state.json, which a non-root run cannot create) then ./gpu-tuner serve --socket /run/user/$UID/gt.sock. Every request is validated and logged exactly as it would be; nothing is written to the cards.

Tests

bash tests/gpu-tuner-test.sh (from this directory) — offline, no GPU, no root, no ssh:

  • tests/gpu-tuner-test.py, 75 tests against fake NVML (tests/fakes.py): budget (soft override, BudgetExceeded vs a hard SafetyError, no-budget machines), ranges (including "NVML reports none" being None, never a 0-0 W range), per-card floor, curve rules, hysteresis, faults, persistence, drift re-apply, the restart-race socket handoff, default fan curves that fit each card's own thresholds, a GB10-like machine (no writes, no lasting warnings, unified memory, the SoC temperature from fake thermal zones with the junk ones ignored, none on a discrete card), an older pynvml missing bindings, the install seed ([N/A], mixed, no budget, fan default), and hostile requests on the daemon's socket (deep nesting, an unexpected error inside a request: both answered, the daemon keeps running).
  • tests/gpu-tuner-fleet-test.py, 38 tests, ~25 s: the protocol (NaN, 1e999, oversize, nesting, huge ints, whitelists), hosts.json (injection attempts, file ownership/mode/symlink, the exact ssh argv, no key means no ssh, the restricted-key line), the node (hello first, only whitelisted ops and fields reach the daemon, exits on EOF / silence / broken pipe), links to tests/fake_node.py subprocesses (reconnect, stale, timeout, busy, down, incompatible version, malformed hello, unannounced GPUs, fatal, shell noise, unrestricted key, ssh failure messages), routing (a change reaches only the named machine), history gaps (and rows narrower than the current series list), the SoC temperature end to end, and the HTTP API.
  • tests/gpu-tuner-demo-test.py, 17 tests, ~2 s: demo mode. The simulation (cards load through the real NVML wrapper, a GB10's SoC temperature a few degrees over its die, the same second gives the same reading in two processes, six hours of plausible history that joins the live readings, writes land on the simulated card), that nothing real is used (no /proc reads, no /sys/class/thermal reads, made-up hostnames and process names, no ssh or real daemon socket in a demo node's command line, pynvml cannot be imported in a demo process), and the page end to end under a scratch HOME: its own cookie, the real page's request checks, three machines up, a change applied through the daemon's checks and read back, a monitor-only machine refusing, and SIGTERM removing the temporary directory.
  • The installer's dry run in every mode, and the page's CSP invariants.

bash tests/live-ui-test.sh (opt-in, ~70 s, needs a GPU and Firefox, no root) — tests/live-ui-test.py starts a dry-run daemon and the page on a scratch socket and port, checks the auth gates (401/421/403, nonce replay, cross-origin POST) and drives the real page in headless Firefox: the over-budget warn-then-confirm flow, the budget editor, power/fan/clock apply and read-back, keyboard curve editing across a re-render, baseline reset, no JS errors, no overflow at phone width. Then tests/fake_hub.py serves the real page over four fake machines (a 2x RTX PRO 6000 box, a GB10, a 5090, an unreachable one) and the test checks the fleet strip and tabs, the GB10's monitor-only view (no PCIe riser false alarm, headroom against its lowest threshold, a SoC temperature tile), that a change on the 5090's tab reaches only that machine's daemon, and the unreachable machine's explanation. Nothing is written to the cards.

All three pass as of 2026-10-04 on driver 595.91 (the live one: 42/42). The demo suite was added and passes as of 2026-10-26. The opt-in live suite was not re-run for 1.2.0; the page's own change is the demo notice, which only appears in demo mode. For 1.3.0 it was re-run on 2026-10-26 (driver 595.91): 43/43, with the new SoC tile check, after its two power-total checks stopped assuming the Max-Q sits at 300 W. For 1.3.1 (a fanless card has no Fan tile, so a GB10's eight tiles fit one row): 45/45 on 2026-10-26.

Also run against real machines, set up with the "several machines at once" loop above: a page on the RTX PRO 6000 workstation managing an RTX 5090 box (x86_64, driver 595.91.07, --no-ui: power 400-575 W settable, three fans, clock cap up to 3,090 MHz) and four DGX Sparks (--node, read-only as above), every one reached through the restricted forced-command key (2026-10-03). On 2026-10-04 real changes were made from the page to the remote 5090 — its machine budget 425 → 450 W, then its power limit 425 → 450 W — each applied by that machine's daemon and read back by NVML on that machine, and an over-budget change was refused there as it should be. Exercised again 2026-10-23 (driver 595.99) through the same channel: that machine's budget 450 → 575 W and its power limit 450 → 575 W — the card's factory default, which is also its settable ceiling here, since a card with no profile may be raised to but not past its default — applied and read back by the daemon and by nvidia-smi on that machine. Not yet exercised on a remote machine from the page: a fan-curve or clock-cap change (same request path, covered by the dry-run and fake-machine tests).

Related apps

Explore more like this

Explore all

Details

Repository
ghcr.io/0sy/gpu-tuner-unraid:latest
Last Updated2026-10-31
First Seen2026-10-31

Runtime arguments

Web UI
http://[IP]:[PORT:8765]/
Network
bridge
Shell
sh
Privileged
false
Extra Params
--gpus all --cap-add=SYS_ADMIN --stop-timeout=45

Template configuration

AppdataPathrw

Persistent GPU settings and WebUI credentials. Use a dedicated directory; do not share with another running GPU Tuner instance.

Target
/data
Default
/mnt/user/appdata/gpu-tuner
Value
/mnt/user/appdata/gpu-tuner
WebUI PortPorttcp

WebUI port on the Unraid host. Match this port in WebUI URL.

Target
8765
Default
8765
Value
8765
WebUI URLVariable

Required: exact browser URL, e.g. http://192.168.1.10:8765. Use your Unraid IP and the host WebUI port. No path. This permits the Host and Origin used by your browser.

Target
GPU_TUNER_PUBLIC_URL
Initial WebUI PasswordVariable

First install only: 12–1024 characters. Ignored once a password hash exists. Required for installation. After successful login, enable Advanced View, remove this variable and Apply to remove the plaintext from Docker configuration and the saved template.

Target
GPU_TUNER_INITIAL_PASSWORD