-
Notifications
You must be signed in to change notification settings - Fork 0
systemd and Packaging
[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.targetAnnotated:
| 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).
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.
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.
The pristine default copy (also the first-install /etc content). Exactly
the five keys in Configuration with their documenting comments.
- 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.
- Home
- Installation
- CLI Reference
- Configuration
- Control Policy
- Supervisor and Run Modes
- Safety Model
- Hardware Interface
- Diagnostics (doctor)
- Plugin Surface (omafan)
- JSON Schemas
- systemd and Packaging
- Development and Testing
- Architecture and Internals
- Troubleshooting and FAQ
- Background and Verification
- afanctl-vs-mbpfan