Skip to content

systemd and Packaging

Prakhar Yadav edited this page Sep 16, 2026 · 3 revisions

systemd and Packaging

Unit file (packaging/afanctl.service)

[Unit]
Description=afanctl - applesmc fan supervisor (pre-T2 Intel Mac)
After=multi-user.target
StartLimitIntervalSec=0

[Service]
Type=notify
ExecStart=/usr/bin/afanctl daemon --mode observe
WatchdogSec=15
Restart=always
RestartSec=1
ProtectSystem=strict
ReadWritePaths=/sys/devices/platform/applesmc.768
ProtectHome=yes
PrivateTmp=yes
NoNewPrivileges=yes
RuntimeDirectory=afanctl

[Install]
WantedBy=multi-user.target

Annotated:

Directive Why
Type=notify Daemon signals READY=1 after arming L2 + reconciling; STATUS= carries the mode.
ExecStart=… daemon --mode observe Always starts safe (writes nothing). curve at boot is opt-in by editing this line + daemon-reload.
WatchdogSec=15 Pinged twice per poll (start + end). A hang gets SIGABRT → L2 → restart. interval_s ≤ 12 keeps ≥3 s headroom (see Configuration).
Restart=always, RestartSec=1 Restart ~1 s after any death, including SIGKILL (whose reconcile restores AUTO).
StartLimitIntervalSec=0 Crash-loops restart forever rather than exhausting into a dead fans-manual state (the corrected C1 mechanism). Accepted trade: noisy-but-safe beats quiet-but-dead; journald rate-limits.
ProtectSystem=strict + ReadWritePaths=/sys/devices/platform/applesmc.768 Sandboxing: the only writable sysfs is the applesmc platform dir. Pins today's path — doctor detects a layout change and tells you to update the unit (deliberate coupling).
ProtectHome=yes, PrivateTmp=yes, NoNewPrivileges=yes Least privilege.
RuntimeDirectory=afanctl Owns /run/afanctl (cmd.json, state.json). Tmpfs: reboot always returns to observe.

Installed disabled (WantedBy only takes effect after systemctl enable).

PKGBUILD (Arch)

Two of them, both installing the five paths listed in Installation:

File Builds from Used by
packaging/PKGBUILD your working tree (--manifest-path ../Cargo.toml) development, and the release workflow's artifact
packaging/aur/PKGBUILD the source tarball attached to a tagged release the AUR recipe — complete and buildable, unpublished (AUR registration is closed to new maintainers)

The published one, annotated:

pkgname=afanctl
pkgver=0.1.0
pkgrel=1
pkgdesc="Safety-first applesmc fan supervisor for pre-T2 Intel Macs (A1708)"
arch=('x86_64')
url="https://github.com/yadav-prakhar/afanctl"
license=('GPL-3.0-only')
depends=('glibc' 'gcc-libs')      # readelf: libc.so.6 + libgcc_s.so.1
makedepends=('cargo')             # `extra/rust` provides cargo
backup=('etc/afanctl/afanctl.toml')
source=("$pkgname-$pkgver.tar.gz::$url/releases/download/v$pkgver/$pkgname-$pkgver.tar.gz")
sha256sums=('<sha256 of the release tarball>')

build() runs cargo build --release --locked; package() installs the five paths in Installation (binary 755, the rest 644; first install seeds /etc/afanctl/afanctl.toml from the default). makepkg builds without installing; makepkg -si builds + installs (requires root) and is the user-gated acceptance step in PLAN.md §9.4.

A v* tag triggers .github/workflows/release.yml, which runs the four gates in a clean Arch container, builds the package, cuts the source tarball and attaches both plus install.sh and SHA256SUMS to the GitHub release. releases/latest/download/install.sh then installs the newest release in one command, and packaging/aur/update-aur.sh <version> points the (unpublished) AUR recipe at a new tarball. The whole picture — including the Omarchy submission — is in packaging/README.md.

Polkit rule (packaging/49-afanctl.rules, annotated)

Passwordless pkexec for wheel users in a local, active session — an exact verb allowlist on the installed binary:

// allow: status [--json] | observe | curve | hold <integer-rpm>
// deny:  daemon, doctor --roundtrip, any other verb/flag/argument
polkit.addRule(function (action, subject) {
    if (action.id !== "org.freedesktop.policykit.exec") return polkit.Result.NOT_HANDLED;
    if (action.lookup("program") !== "/usr/bin/afanctl") return polkit.Result.NOT_HANDLED;
    if (!subject.isInGroup("wheel") || !subject.local || !subject.active) return polkit.Result.NOT_HANDLED;
    var argv = (action.lookup("command_line") || "").split(" ");
    if (argv.length < 2 || argv[0] !== "/usr/bin/afanctl") return polkit.Result.NOT_HANDLED;
    var verb = argv[1];
    var isU32 = function (s) {  // hold accepts only an integer rpm
        if (!/^[0-9]+$/.test(s)) return false;
        return parseInt(s, 10) <= 4294967295;
    };
    if ((verb === "observe" || verb === "curve") && argv.length === 2) return polkit.Result.YES;
    if (verb === "status" && (argv.length === 2 || (argv.length === 3 && argv[2] === "--json"))) return polkit.Result.YES;
    if (verb === "hold" && argv.length === 3 && isU32(argv[2])) return polkit.Result.YES;
    return polkit.Result.NOT_HANDLED;
});

Notes from pkexec(1): pkexec performs no argument validation, and command_line is argv joined by single spaces (no quoting) — so the rule re-checks program realpath and the argv shape, while the CLI independently parses/clamps the rpm (defence in depth). Globals (--config, --sysfs-root) are never allowed here. The rule is data: makepkg installs it; no test executes it.

afanctl.toml.default

The pristine default copy (also the first-install /etc content). Exactly the five keys in Configuration with their documenting comments.

Journal evidence to expect

  • Startup: afanctl daemon startup: mode=…, l2_armed=…, watchdog_notify=…, state_file=…, config_source=…, hw_band=…rpm (INFO).
  • Mode changes at INFO (mode change: … → … (…)), releases with their verified/FAILED state, re-arms after monitor-only.
  • Fallbacks, stall detections, pending-AUTO retries at ERROR (rate-limited); off-target dwell at WARN (every 30 polls); per-poll notes at DEBUG.
  • Observe soak record: 1 h 10 min, 1.966 s CPU over 4211 s wall (0.05 %), 2.4 MB peak RSS, zero errors apart from deliberate experiments.

Clone this wiki locally