NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

fan.nix (6320B)


      1 # modules/home/fan.nix — `fan`: the laptop's fans from the terminal, safely.
      2 #
      3 #   fan              one line: CPU °C, fan rpm and %, clock cap, load, clamp yes/no, EC mode
      4 #   fan watch [s]    the same line every s seconds (default 2), Ctrl-C to stop
      5 #   fan max          100 % now, with the watchdog (below)        fan 60   fixed 60 % (30-100)
      6 #   fan auto         back to the EC's own curve; stops the watchdog
      7 #   fan log          what the watchdog has done
      8 #
      9 # Why a watchdog: this laptop's EC, once in manual fan mode, asserts PROCHOT
     10 # and pins every core at 399 MHz as soon as load starts (fan-throttle-guard.nix).
     11 # `fan max` therefore starts a transient user unit (fan-watchdog) that samples
     12 # /proc/stat and /proc/cpuinfo every second and, the moment CPU busy ≥ 25 %
     13 # while no core is above 600 MHz (fanfix's clamp rule), runs `fan-ec auto`,
     14 # sends a notification and exits. Root access is `sudo -n fan-ec …`
     15 # (modules/hosts/laptop/fan-cli.nix: a store script with a passwordless rule for wheel).
     16 # Reads need no root at all: k10temp and the uniwill hwmon are world-readable.
     17 { ... }:
     18 {
     19   flake.homeModules.fan =
     20   { pkgs, lib, ... }:
     21   let
     22     bin = lib.makeBinPath [ pkgs.coreutils pkgs.gawk pkgs.gnugrep pkgs.gnused pkgs.systemd pkgs.libnotify ];
     23     # NixOS's setuid sudo lives in /run/wrappers; the store copy is not setuid
     24     # and refuses to run ("must be owned by uid 0 and have the setuid bit set").
     25     sudo = "/run/wrappers/bin/sudo";
     26 
     27     # shared read-only sampler, sourced by both scripts
     28     lib-sh = pkgs.writeText "fan-lib.sh" ''
     29       TRIP_MHZ=600; BUSY_MIN=25
     30       STATE=''${XDG_RUNTIME_DIR:-/tmp}/fan.stat
     31       hw() { local n; for h in /sys/class/hwmon/hwmon*; do n=$(cat "$h/name" 2>/dev/null); [ "$n" = "$1" ] && { echo "$h"; return; }; done; }
     32       cpu_temp() { local h; h=$(hw k10temp); [ -n "$h" ] && echo $(( $(cat "$h/temp1_input") / 1000 )) || echo -; }
     33       fan_rpm()  { local h; h=$(hw uniwill); [ -n "$h" ] && cat "$h/fan1_input" || echo -; }
     34       fan_pct()  { local h; h=$(hw uniwill); [ -n "$h" ] && echo $(( $(cat "$h/pwm1") * 100 / 255 )) || echo -; }
     35       gpu_temp() { local h; h=$(hw uniwill); [ -n "$h" ] && echo $(( $(cat "$h/temp2_input") / 1000 )) || echo -; }
     36       cap_mhz()  { echo $(( $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq) / 1000 )); }
     37       clocks()   { awk '/^cpu MHz/ {s+=$4; n++; if ($4>m) m=$4} END {printf "%d %d", (n?s/n:0), m}' /proc/cpuinfo; }
     38       # CPU busy % since the previous call (snapshot in $STATE); 0 on the first call
     39       busy() {
     40         local cur prev b=0
     41         cur=$(head -1 /proc/stat)
     42         [ -r "$STATE" ] && prev=$(cat "$STATE") || prev=
     43         printf '%s' "$cur" > "$STATE"
     44         [ -n "$prev" ] && b=$(awk -v a="$prev" -v b="$cur" 'BEGIN {
     45           na=split(a,x," "); nb=split(b,y," "); ta=0; tb=0
     46           for (i=2;i<=na;i++) ta+=x[i]; for (i=2;i<=nb;i++) tb+=y[i]
     47           ia=x[5]+x[6]; ib=y[5]+y[6]; d=tb-ta; if (d<=0) {print 0; exit}
     48           printf "%d", 100*(d-(ib-ia))/d }')
     49         echo "$b"
     50       }
     51       clamped() {  # 1 when busy yet no core above TRIP_MHZ
     52         local b=$1 mx=$2; [ "$b" -ge "$BUSY_MIN" ] && [ "$mx" -gt 0 ] && [ "$mx" -lt "$TRIP_MHZ" ] && echo 1 || echo 0
     53       }
     54       ec_mode() { ${sudo} -n /run/current-system/sw/bin/fan-ec mode 2>/dev/null || echo "?"; }
     55       status_line() {
     56         local b mx avg; b=$(busy); read -r avg mx <<< "$(clocks)"
     57         local cl; cl=$(clamped "$b" "$mx")
     58         printf 'CPU %s°C · GPU %s°C · fan %s rpm %s%% · cap %s MHz · clocks avg %s / max %s · load %s%% · clamp %s · fans %s\n' \
     59           "$(cpu_temp)" "$(gpu_temp)" "$(fan_rpm)" "$(fan_pct)" "$(cap_mhz)" "$avg" "$mx" "$b" \
     60           "$([ "$cl" = 1 ] && echo YES || echo no)" "$(ec_mode)"
     61       }
     62     '';
     63 
     64     fan-watchdog = pkgs.writeShellScriptBin "fan-watchdog" ''
     65       set -uo pipefail
     66       PATH=${bin}:$PATH
     67       . ${lib-sh}
     68       LOG=''${XDG_STATE_HOME:-$HOME/.local/state}/fan-watchdog.log; mkdir -p "$(dirname "$LOG")"
     69       log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
     70       log "armed: fans manual ($1), watching for the EC clamp"
     71       busy >/dev/null; sleep 1
     72       while :; do
     73         b=$(busy); read -r _ mx <<< "$(clocks)"
     74         if [ "$(clamped "$b" "$mx")" = 1 ]; then
     75           out=$(${sudo} -n /run/current-system/sw/bin/fan-ec auto 2>&1)
     76           log "CLAMP: load $b %, max core $mx MHz -> fan-ec auto: $out"
     77           notify-send -a fan -u critical "Fans back to auto" "EC clamp hit (load $b %, cores at $mx MHz). Manual fan mode released."
     78           exit 0
     79         fi
     80         sleep 1
     81       done
     82     '';
     83 
     84     fan = pkgs.writeShellScriptBin "fan" ''
     85       set -uo pipefail
     86       PATH=${bin}:$PATH
     87       . ${lib-sh}
     88       UNIT=fan-watchdog
     89       EC=/run/current-system/sw/bin/fan-ec
     90       LOG=''${XDG_STATE_HOME:-$HOME/.local/state}/fan-watchdog.log
     91 
     92       arm() {  # start (or restart) the watchdog as a transient user unit
     93         systemctl --user stop "$UNIT" 2>/dev/null || true
     94         systemd-run --user --unit="$UNIT" --collect --quiet ${fan-watchdog}/bin/fan-watchdog "$1" \
     95           && echo "watchdog armed: back to auto the moment the EC clamps the CPU (fan log)"
     96       }
     97       disarm() { systemctl --user stop "$UNIT" 2>/dev/null || true; }
     98 
     99       if [ "$(id -u)" = 0 ]; then
    100         echo "fan: run this as yourself, not under sudo (it needs your user session for the watchdog; root access is handled inside)" >&2
    101         exit 1
    102       fi
    103 
    104       case "''${1:-}" in
    105         ""|status) status_line ;;
    106         watch)
    107           iv=''${2:-2}; busy >/dev/null; sleep "$iv"
    108           while :; do status_line; sleep "$iv"; done ;;
    109         max)   ${sudo} -n "$EC" max  && arm max ;;
    110         auto)  disarm; ${sudo} -n "$EC" auto ;;
    111         [0-9]*) p=''${1%\%}; ${sudo} -n "$EC" "$p" && arm "$p %" ;;
    112         ec)    ${sudo} -n "$EC" status ;;
    113         log)   [ -r "$LOG" ] && tail -n ''${2:-20} "$LOG" || echo "no watchdog log yet" ;;
    114         -h|--help|help)
    115           echo "usage: fan [status] | watch [sec] | max | <30-100> | auto | ec | log [n]"
    116           echo "  max / <pct> put the fans in manual mode WITH the clamp watchdog; auto releases them." ;;
    117         *) echo "fan: unknown command '$1' (try: fan help)" >&2; exit 2 ;;
    118       esac
    119     '';
    120   in
    121   {
    122     home.packages = [ fan fan-watchdog ];
    123   }
    124   ;
    125 }