logo

drewdevault.com

[mirror] blog and personal website of Drew DeVault git clone https://hacktivis.me/git/mirror/drewdevault.com.git
commit: 963f7458c1f1702d9f78ced7247e6c6e80b77814
parent 1cf5065eb43fcfe4fce266a995861de32bb290b1
Author: Drew DeVault <sir@cmpwn.com>
Date:   Sun, 28 Aug 2022 15:50:00 +0200

powerctl

Diffstat:

Acontent/blog/powerctl-a-hare-case-study.md251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 251 insertions(+), 0 deletions(-)

diff --git a/content/blog/powerctl-a-hare-case-study.md b/content/blog/powerctl-a-hare-case-study.md @@ -0,0 +1,251 @@ +--- +title: "powerctl: A small case study in Hare for systems programming" +date: 2022-08-28 +--- + +[powerctl](https://sr.ht/~sircmpwn/powerctl/) is a little weekend project I put +together to provide a simple tool for managing power states on Linux. I had +previously put my laptop into suspend with a basic "echo mem | doas tee +/sys/power/state", but this leaves a lot to be desired. I have to use doas to +become root, and it's annoying to enter my password &mdash; not to mention +difficult to use in a script or to attach to a key binding. powerctl is the +solution: a small 500-line Hare program which provides comprehensive support for +managing power states on Linux for non-privileged users. + +This little project ended up being a useful case-study in writing a tight +systems program in Hare. It has to do a few basic tasks which Hare shines in: + +- setuid binaries +- Group lookup from /etc/group +- Simple string manipulation +- Simple I/O within sysfs constraints + +Linux documents these features [here][0], so it's a simple matter of rigging it +up to a nice interface. Let's take a look at how it works. + +[0]: https://www.kernel.org/doc/html/latest/admin-guide/pm/sleep-states.html + +First, one of the base requirements for this tool is to run as a non-privileged +user. However, since writing to sysfs requires root, this program will have to +be setuid, so that it runs as root regardless of who executes it. To prevent any +user from suspending the system, I added a "power" group and only users who are +in this group are allowed to use the program. Enabling this functionality in +Hare is quite simple: + +```hare +use fmt; +use unix; +use unix::passwd; + +def POWER_GROUP: str = "power"; + +// Determines if the current user is a member of the power group. +fn checkgroup() bool = { + const uid = unix::getuid(); + const euid = unix::geteuid(); + if (uid == 0) { + return true; + } else if (euid != 0) { + fmt::fatal("Error: this program must be installed with setuid (chmod a+s)"); + }; + + const group = match (passwd::getgroup(POWER_GROUP)) { + case let grent: passwd::grent => + yield grent; + case void => + fmt::fatal("Error: {} power group missing from /etc/group", POWER_GROUP); + }; + defer passwd::grent_finish(&group); + + const gids = unix::getgroups(); + for (let i = 0z; i < len(gids); i += 1) { + if (gids[i] == group.gid) { + return true; + }; + }; + + return false; +}; +``` + +The POWER\_GROUP variable allows distributions that package powerctl to +configure exactly which group is allowed to use this tool. Following this, we +compare the uid and effective uid. If the uid is zero, we're already running +this tool as root, so we move on. Otherwise, if the euid is nonzero, we lack the +permissions to continue, so we bail out and tell the user to fix their +installation. + +Then we fetch the details for the power group from /etc/group. Hare's standard +library includes [a module](https://docs.harelang.org/unix/passwd) for working +with this file. Once we have the group ID from the string, we check the current +user's supplementary group IDs to see if they're a member of the appropriate +group. Nice and simple. This is also the only place in powerctl where dynamic +memory allocation is required, to store the group details, which are freed with +"defer passwd::grent_finish". + +The tool also requires some simple string munging to identify the supported set +of states. If we look at /sys/power/disk, we can see the kind of data we're +working with: + +``` +$ cat /sys/power/disk +[platform] shutdown reboot suspend test_resume +``` + +These files are a space-separated list of supported states, with the currently +enabled state enclosed in square brackets. Parsing these files is a simple +matter for Hare. We start with a simple utility function which reads the file +and prepares a [string tokenizer](https://docs.harelang.org/strings#tokenize) +which splits the string on spaces: + +```hare +fn read_states(path: str) (strings::tokenizer | fs::error | io::error) = { + static let buf: [512]u8 = [0...]; + + const file = os::open(path)?; + defer io::close(file)!; + + const z = match (io::read(file, buf)?) { + case let z: size => + yield z; + case => + abort("Unexpected EOF from sysfs"); + }; + const string = strings::rtrim(strings::fromutf8(buf[..z]), '\n'); + return strings::tokenize(string, " "); +}; +``` + +The error handling here warrants a brief note. This function can fail if the +file does not exist or if there is an I/O error when reading it. I don't think +that I/O errors are possible in this specific case (they can occur when +*writing* to these files, though), but we bubble it up regardless using +"io::read()?". The file might not exist if these features are not supported by +the current kernel configuration, in which case it's bubbled up as +"errors::noentry" via "os::open()?". These cases are handled further up the call +stack. The other potential error site is "io::close", which can fail but only in +certain circumstances (such as closing the same file twice), and we use the +error assertion operator ("!") to indicate that the programmer believes this +case cannot occur. The compiler will check our work and abort at runtime should +this assumption be proven wrong in practice. + +In the happy path, we read the file, trim off the newline, and return a +tokenizer which splits on spaces. The storage for this string is borrowed from +"buf", which is statically allocated. + +The usage of this function to query supported disk suspend behaviors is here: + +```hare +fn read_disk_states() ((disk_state, disk_state) | fs::error | io::error) = { + const tok = read_states("/sys/power/disk")?; + + let states: disk_state = 0, active: disk_state = 0; + for (true) { + let tok = match (strings::next_token(&tok)) { + case let s: str => + yield s; + case void => + break; + }; + const trimmed = strings::trim(tok, '[', ']'); + + const state = switch (trimmed) { + case "platform" => + yield disk_state::PLATFORM; + case "shutdown" => + yield disk_state::SHUTDOWN; + case "reboot" => + yield disk_state::REBOOT; + case "suspend" => + yield disk_state::SUSPEND; + case "test_resume" => + yield disk_state::TEST_RESUME; + case => + continue; + }; + states |= state; + if (trimmed != tok) { + active = state; + }; + }; + + return (states, active); +}; +``` + +This function returns a tuple which includes all of the supported disk states +OR'd together, and a value which indicates which state is currently enabled. The +loop iterates through each of the tokens from the tokenizer returned by +`read_states`, trims off the square brackets, and adds the appropriate state +bits. We also check the trimmed token against the original token to detect which +state is currently active. + +There's two edge cases to be taken into account here: what happens if Linux adds +more states in the future, and what happens if none of the states are active? In +the former case, we have the `continue` branch of the switch statement mid-loop. +Hare requires all switch statements to be exhaustive, so the compiler forces us +to consider this edge case. For the latter case, the return value will be zero, +simply indicating that none of these states are active. This is not actually +possible given the invariants for this kernel interface, but we could end up in +this situation if the kernel adds a new disk mode *and* that disk mode is active +when this code runs. + +When the time comes to modify these states, either to put the system to sleep or +to configure its behavior when put to sleep, we use the following function: + +```hare +fn write_state(path: str, state: str) (void | fs::error | io::error) = { + const file = os::open(path, fs::flags::WRONLY | fs::flags::TRUNC)?; + defer io::close(file)!; + let buf: [128]u8 = [0...]; + const file = &bufio::buffered(file, [], buf); + fmt::fprintln(file, state)?; +}; +``` + +This code is working within a specific constraint of sysfs: it must complete +the write operation in a single syscall. One of Hare's design goals is giving +you sufficient control over the program's behavior to plan for such concerns. +The means of opening the file &mdash; WRONLY | TRUNC &mdash; was also chosen +deliberately. The "single syscall" is achieved by using a buffered file, which +soaks up writes until the buffer is full and then flushes them out all at once. +The buffered stream flushes automatically on newlines by default, so the "ln" of +"fprintln" causes the write to complete in a single call. + +With this helper in place, we can write power states. The ones which configure +the kernel, but don't immediately sleep, are straightforward: + +```hare +// Sets the current mem state. +fn set_mem_state(state: mem_state) (void | fs::error | io::error) = { + write_state("/sys/power/mem_sleep", mem_state_unparse(state))?; +}; +``` + +The star of the show, however, has some extra concerns: + +```hare +// Sets the current sleep state, putting the system to sleep. +fn set_sleep_state(state: sleep_state) (void | fs::error | io::error) = { + // Sleep briefly so that the keyboard driver can process the key up if + // the user runs this program from the terminal. + time::sleep(250 * time::MILLISECOND); + write_state("/sys/power/state", sleep_state_unparse(state))?; +}; +``` + +If you enter sleep with a key held down, key repeat will kick in for the +duration of the sleep, so when running this from the terminal you'll resume to +find a bunch of new lines. The time::sleep call is a simple way to avoid this, +by giving the system time to process your key release event before sleeping. A +more sophisticated solution could open the uinput devices and wait for all keys +to be released, but that doesn't seem entirely necessary. + +Following this, we jump into the dark abyss of a low-power coma. + +And that's all there is to it! A few hours of work and 500 lines of code later +and we have a nice little systems program to make suspending my laptop easier. I +was pleasantly surprised to find out how well this little program plays to +Hare's strengths. I hope you found it interesting! And if you happen to need a +simple tool for suspending your Linux machines, +[powerctl](https://sr.ht/~sircmpwn/powerctl) might be the program for you.