diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 77b566fc..38e952eb 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -21,6 +21,8 @@ # GT CONSENT-CODE 1 NN type NN and Enter (the question's code) # GT CONSENT-WAIT 2 type y and Enter (not the code: refused) # GT POINTER-ZONE0, GT POINTER-UNTRUSTED move the pointer onto the newest window +# GT TOUCH-HOVER-ZONE0 move the pointer right, onto zone 0's window +# GT TOUCH-UNTRUSTED tap the screen's left half (the newest window) # GT SCREENSHOT-POPUP take a screenshot # GT KEY-MENU-POPUP press Alt+p (the chrome menu) # GT KEY-FOCUS-POPUP press Alt+j @@ -1184,6 +1186,50 @@ if since_mark cursor untrusted | grep -q 'asked for the text cursor by name' && else fail "zone-cursor-by-name" "$(since_mark cursor untrusted | tail -4 | tr '\n' ' ')" fi +# --- a touchscreen drives the pointer ------------------------------------------------------- +# dwl takes a touchscreen as the pointer, so a tap is a click: it gives a window the keyboard as +# a click does, and zones are offered no touch. The VM's touchscreen is QEMU's virtio-multitouch +# (run-ovmf.sh --touch). untrusted's window maps beside zone 0's, unasked, where dwl puts the +# newest: the screen's left half, which the host taps. +stop_zone untrusted +as_user "/usr/libexec/kryptik/wlprobe cursor 40" > "$LOG/touch-zone0.out" 2>&1 & +touch0=$! +wait_for 20 grep -q committed "$LOG/touch-zone0.out" +wait_for 10 grep -qx 'zone=0' "$RT/kryptik/focus" +mark touch untrusted +launch_plain untrusted "/usr/libexec/kryptik/wlprobe cursor 30" > "$LOG/launch-touch.out" 2>&1 +wait_for 20 probe_committed touch +sleep 2 +# The pointer rests on zone 0's window, the right half, when the tap comes: the press must +# reach the window tapped, not the one under the pointer. zone 0's probe says each entry. +entered="$(grep -c 'set a ' "$LOG/touch-zone0.out")" +zone0_entered() { [[ "$(grep -c 'set a ' "$LOG/touch-zone0.out")" -gt "$entered" ]]; } +echo "GT TOUCH-HOVER-ZONE0" +wait_for 10 zone0_entered +hovered="$(grep -c 'set a ' "$LOG/touch-zone0.out")" +touch_before="$(grep '^zone=' "$RT/kryptik/focus" 2>/dev/null)" +echo "GT TOUCH-UNTRUSTED" +if ! grep -q 'Name="QEMU Virtio MultiTouch"' /proc/bus/input/devices; then + fail "touch-picks-window" "no touchscreen in this guest: $(grep '^N: ' /proc/bus/input/devices | tr '\n' ' ')" +elif [[ "$touch_before" != "zone=0" ]]; then + fail "touch-picks-window" "zone 0's window did not have the keyboard before the tap: ${touch_before:-no focus record}" +elif wait_for 10 grep -qx 'zone=untrusted' "$RT/kryptik/focus"; then + pass "touch-picks-window" "a tap on untrusted's window gave it the keyboard, as a click does" +else + fail "touch-picks-window" "after the tap: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark touch untrusted | tail -2 | tr '\n' ' ')" +fi +touch_pressed() { since_mark touch untrusted | grep -q 'pointer button 272 released'; } +if [[ "$hovered" -le "$entered" ]]; then + fail "touch-presses-window" "the pointer never came onto zone 0's window before the tap, so nothing was shown: $(tail -3 "$LOG/touch-zone0.out" | tr '\n' ' ')" +elif ! wait_for 10 touch_pressed; then + fail "touch-presses-window" "untrusted's window was sent no press and release: $(since_mark touch untrusted | tail -3 | tr '\n' ' '); zone 0's: $(grep 'pointer button' "$LOG/touch-zone0.out" | tr '\n' ' ')" +elif grep -q 'pointer button' "$LOG/touch-zone0.out"; then + fail "touch-presses-window" "the tap's button also went to zone 0's window, where the pointer rested: $(grep 'pointer button' "$LOG/touch-zone0.out" | tr '\n' ' ')" +else + pass "touch-presses-window" "the tap's press and release went to untrusted's window, and none to zone 0's, under the pointer before" +fi +pkill -u "$USER_NAME" -f 'wlprobe cursor 40' 2>/dev/null; wait "$touch0" 2>/dev/null + # --- a zone's window hears the scale it is shown at ------------------------------------------ # wp_fractional_scale_v1 through the proxy: the compositor tells each window its output's scale, # in 120ths, so a browser draws text sharp at 1.5 instead of being scaled down from 2. This diff --git a/build/recipes/etc.sh b/build/recipes/etc.sh index 7831303b..e6e65212 100644 --- a/build/recipes/etc.sh +++ b/build/recipes/etc.sh @@ -2,6 +2,21 @@ # etc: a stage 04 recipe, sourced by build/stages/04-base-system.sh, # which runs it in the order its list gives. +# etc_login_defs FILE: lines set in shadow's login.defs, not left to the file +# a release ships. login reads the terminals root may use from CONSOLE's file. +# su gives root ENV_SUPATH's PATH with or without "-", and shadow's step strips +# /usr/sbin from it, where ip, iw and cryptsetup live. +etc_login_defs() { + local def + for def in "CONSOLE /etc/securetty" "SU_WHEEL_ONLY yes" "ENV_SUPATH PATH=/usr/sbin:/usr/bin"; do + if grep -q "^${def%% *}[[:space:]]" "$1"; then + sed -i "s|^${def%% *}[[:space:]].*|${def}|" "$1" + else + printf '%s\n' "$def" >> "$1" + fi + done +} + # /etc/os-release and friends. BUILD_ID, the commit that built the image, is # added after the steps (at the end of this file): as this step's input it # would re-fingerprint this step, and every step after it, on each commit. @@ -51,18 +66,11 @@ EOF usermod -p '*' root grep -q '^root:\*:' /etc/shadow && echo "root: no password" || { echo "FAIL: root has a password in the image"; return 1; } : > /etc/securetty - # Both set here, not left to shadow's login.defs, which a release may - # change: login reads the terminals root may use from CONSOLE's file. - local def - for def in "CONSOLE /etc/securetty" "SU_WHEEL_ONLY yes"; do - if grep -q "^${def%% *}[[:space:]]" /etc/login.defs; then - sed -i "s|^${def%% *}[[:space:]].*|${def}|" /etc/login.defs - else - printf '%s\n' "$def" >> /etc/login.defs - fi - done + etc_login_defs /etc/login.defs grep -qx 'CONSOLE /etc/securetty' /etc/login.defs && [[ ! -s /etc/securetty ]] \ || { echo "FAIL: root is not kept off the terminals"; return 1; } + grep -qx 'ENV_SUPATH PATH=/usr/sbin:/usr/bin' /etc/login.defs \ + || { echo "FAIL: su would give root a PATH without /usr/sbin"; return 1; } # Kernel interface names (eth0, wlan0), the same on every machine: eudev's # slot-naming rule is masked. diff --git a/build/service-scripts/power.sh b/build/service-scripts/power.sh index 98c8fa39..dcfb9ffe 100755 --- a/build/service-scripts/power.sh +++ b/build/service-scripts/power.sh @@ -129,6 +129,18 @@ if [ "${1:-}" = --once ]; then exit 0 fi +# The state partition waits for its passphrase, read with builtins alone: a program started now +# can wait on it. Then kryptikd keeps the 3% rule (suspend.rs), as neither the zones' stop nor +# the shutdown's sync and unmounts could finish. +sealed() { + local d n s + for d in "$SYS"/block/dm-*/dm; do + read -r n 2>/dev/null < "$d/name" && [ "$n" = kryptik-state ] || continue + read -r s 2>/dev/null < "$d/suspended" && [ "$s" = 1 ] && return 0 + done + return 1 +} + # Each running zone stopped as `kryptikd stop` does, its launcher closing its volume, then off. shut_down() { local z @@ -158,7 +170,7 @@ while :; do fi # Twice running, so one odd reading does not end the session. if [ "$LOW" = yes ]; then low=$((low + 1)); else low=0; fi - if [ "$low" -ge 2 ]; then + if [ "$low" -ge 2 ] && ! sealed; then echo "power: the battery is at ${CHARGE}%: stopping the zones and powering off before it runs out" shut_down fi diff --git a/build/service-scripts/watchdog.sh b/build/service-scripts/watchdog.sh index 5cb3d1d8..46f44f8b 100755 --- a/build/service-scripts/watchdog.sh +++ b/build/service-scripts/watchdog.sh @@ -5,14 +5,17 @@ set -u # AMD's TCO timer counts on through a sleep to idle (s2idle): its driver does not stop it for # one, as Intel's does, and the ticks that would feed it stop. Fed, it would reset a machine # that sleeps that way a minute into each sleep, so there it is left unopened, and stopped as -# its driver leaves it. In ACPI's own sleep states the firmware stops it. +# its driver leaves it. In ACPI's own sleep states the firmware stops it. The software +# watchdog likewise: its clock runs whenever the s2idle loop wakes for an event that wakes +# nothing, amd_pmc's 2.5 s pause after each deepest sleep included, so a night of an EC's +# battery and charger events would spend its minute and reboot the sleeping machine. sleeps_to_idle() { grep -q '\[s2idle\]' /sys/power/mem_sleep 2>/dev/null; } fds=() for dev in /dev/watchdog[0-9]*; do [ -c "$dev" ] || continue name="${dev##*/}" id="$(cat "/sys/class/watchdog/${name}/identity" 2>/dev/null || echo unknown)" - if [ "$id" = "SP5100 TCO timer" ] && sleeps_to_idle; then + if { [ "$id" = "SP5100 TCO timer" ] || [ "$id" = "Software Watchdog" ]; } && sleeps_to_idle; then echo "watchdog: not feeding ${dev} (${id}): it would count through this machine's sleep to idle" continue fi diff --git a/compartments/kryptikd/src/camera.rs b/compartments/kryptikd/src/camera.rs index 22d29233..fc80f44e 100644 --- a/compartments/kryptikd/src/camera.rs +++ b/compartments/kryptikd/src/camera.rs @@ -104,6 +104,14 @@ fn read_trim(p: &Path) -> Option { fs::read_to_string(p).ok().map(|s| s.trim().to_string()) } +/// Whether a program kept the camera from an earlier grant: its descriptor holds the driver in +/// `sys`, and with it the streaming interface of the device in `LET_GO`, which, probed again, +/// would come back with no capture node. +fn kept(sys: &Path, run: &Path) -> bool { + run.join(LET_GO).exists() + && read_trim(&sys.join("module").join(DRIVER).join("refcnt")).is_some_and(|n| n != "0") +} + /// The camera's node in `sys`, standing for /sys: of the video4linux devices that are the first /// node of their device (`index` 0, a UVC camera's capture node and not its metadata one), the /// lowest numbered; its name, major and minor, and what the device calls itself. @@ -240,6 +248,9 @@ pub fn grant(zone: &crate::zone::Zone) -> Result { end(g)?; } let dir = zone_dir(&zone.name).map_err(|e| format!("{RUN_DIR}/{}: {e}", zone.name))?; + if kept(Path::new("/sys"), Path::new(RUN_DIR)) { + return Err("a program still holds the camera from its last grant: turn the camera off there, then give it again".into()); + } modprobe(&[DRIVER])?; let _ = fs::remove_file(Path::new(RUN_DIR).join(STAYED)); if let Ok(text) = fs::read_to_string(Path::new(RUN_DIR).join(LET_GO)) { diff --git a/compartments/kryptikd/src/camera/tests.rs b/compartments/kryptikd/src/camera/tests.rs index 2d31a334..8a6efca0 100644 --- a/compartments/kryptikd/src/camera/tests.rs +++ b/compartments/kryptikd/src/camera/tests.rs @@ -106,3 +106,20 @@ fn names_from_sysfs_are_one_component() { assert!(!is_sys_name(bad), "{bad:?}"); } } + +#[test] +fn a_camera_still_held_is_not_taken_again() { + let s = sys("kept"); + let run = s.join("run"); + fs::create_dir_all(&run).unwrap(); + fs::create_dir_all(s.join("module").join(DRIVER)).unwrap(); + fs::write(s.join("module").join(DRIVER).join("refcnt"), "1\n").unwrap(); + assert!(!kept(&s, &run), "held, but nothing to take again"); + fs::write(run.join(LET_GO), "usb 3-6:1.0\n").unwrap(); + assert!(kept(&s, &run), "held, with an interface to take again"); + fs::write(s.join("module").join(DRIVER).join("refcnt"), "0\n").unwrap(); + assert!(!kept(&s, &run), "let go of"); + fs::remove_dir_all(s.join("module").join(DRIVER)).unwrap(); + assert!(!kept(&s, &run), "unloaded"); + let _ = fs::remove_dir_all(&s); +} diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index 721e1bfa..b6853f4f 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1923,6 +1923,8 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec, pauses: &mut /// How often the login prompt's update notice is checked. const NOTICE_EVERY: Duration = Duration::from_secs(3600); +/// How often the battery is looked at while the state partition is suspended: power.sh's round. +const SEALED_EVERY: Duration = Duration::from_secs(30); pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { // First, before anything that could read /etc or /var: a daemon started again while the state @@ -1977,7 +1979,31 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { // The login prompt carries what `kryptik update status` says of a stale statement, on the // installed system's instance: checked as it starts, then hourly, as days are what count. let mut notice_due = (!cfg.developer).then(Instant::now); + // From each wake until the passphrase is given, power.sh waits on the state partition, so + // its 3% rule is kept here: a laptop the battery's alarm woke, its lid shut, would + // otherwise run flat at the prompt. A daemon started again at the prompt looks too. + let mut sealed_due = (!cfg.developer && crate::suspend::state_suspended()).then(Instant::now); + let mut sealed_low = 0; loop { + if sealed_due.is_some_and(|due| Instant::now() >= due) { + sealed_due = None; + // A sleep under way looks again when it ends, the count carried across it: a machine + // put back to sleep within 30 s of each wake still powers off. + if !crate::suspend::state_suspended() { + sealed_low = 0; + } else if !jobs.iter().any(|j| j.what == JobKind::Sleep) { + let (low, off) = crate::suspend::sealed_look(sealed_low, crate::suspend::critical()); + sealed_low = low; + sealed_due = Some(Instant::now() + SEALED_EVERY); + if off { + eprintln!("kryptikd serve: the battery is at {}% or under, the state partition suspended: powering off", crate::suspend::CRITICAL); + let e = crate::suspend::power_off(); + // Once: the next look would be refused the same way. + eprintln!("kryptikd serve: the machine did not power off: {e}"); + sealed_due = None; + } + } + } if notice_due.is_some_and(|due| Instant::now() >= due) { // The update state is on the state partition: while that is suspended the notice // keeps, and the daemon stays free to answer the screen lock. @@ -1992,13 +2018,19 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { notice_due = Some(Instant::now() + NOTICE_EVERY); } reap(&mut pending, &mut jobs, &mut proxies); + let mut slept = false; jobs.retain_mut(|j| match j.exited { Some(st) => { + slept |= j.what == JobKind::Sleep; finish_job(j, st); false } None => true, }); + // Woken or refused, a sleep leaves the state partition suspended. + if slept && !cfg.developer { + sealed_due = Some(Instant::now()); + } // fds[0] is the listener, then one per launch awaiting readiness; timeout: nearest deadline. let mut fds: Vec = vec![libc::pollfd { fd: listener.as_raw_fd(), events: libc::POLLIN, revents: 0 }]; @@ -2013,7 +2045,7 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { let left = p.deadline().saturating_duration_since(now).as_millis() as i32; timeout = if timeout < 0 { left } else { timeout.min(left) }; } - if let Some(due) = notice_due { + for due in [notice_due, sealed_due].into_iter().flatten() { let left = due.saturating_duration_since(now).as_millis().min(i32::MAX as u128) as i32; timeout = if timeout < 0 { left } else { timeout.min(left) }; } diff --git a/compartments/kryptikd/src/suspend.rs b/compartments/kryptikd/src/suspend.rs index 9402d3d8..7a2e4f66 100644 --- a/compartments/kryptikd/src/suspend.rs +++ b/compartments/kryptikd/src/suspend.rs @@ -238,6 +238,70 @@ pub fn sleep() -> Result<(), String> { .map_err(|e| format!("/sys/power/state: {e}")) } +/// The charge, in percent, at or under which a battery running down powers the machine off +/// (power.sh's CRITICAL). +pub const CRITICAL: u64 = 3; + +fn attr(dir: &Path, name: &str) -> Option { + fs::read_to_string(dir.join(name)).ok().map(|s| s.trim().to_string()) +} + +/// A battery's charge in percent: its capacity, or the energy or the charge it holds against +/// what it holds full. +fn percent(bat: &Path) -> Option { + if let Some(c) = attr(bat, "capacity").filter(|c| !c.is_empty() && c.bytes().all(|b| b.is_ascii_digit())) { + return c.parse().ok(); + } + ["energy", "charge"].iter().find_map(|k| { + let now = attr(bat, &format!("{k}_now"))?.parse::().ok()?; + let full = attr(bat, &format!("{k}_full"))?.parse::().ok().filter(|&f| f > 0)?; + Some((now.saturating_mul(100) / full).min(100)) + }) +} + +/// Whether the battery is critical as power.sh judges it, from what `sys` says: the fullest of +/// the machine's own batteries (not a mouse's, scope Device) at or under CRITICAL, and running +/// down: one says it discharges, or none says anything and no adapter is online. power.sh waits +/// on the state partition while it is suspended, as anything started then does, so the daemon +/// keeps the rule meanwhile; the two must agree. +pub fn critical_in(sys: &Path) -> bool { + let Ok(rd) = fs::read_dir(sys.join("class/power_supply")) else { return false }; + let all: Vec<(PathBuf, String)> = rd.flatten().filter_map(|e| Some((e.path(), attr(&e.path(), "type")?))).collect(); + let mains = all.iter().any(|(d, t)| matches!(t.as_str(), "Mains" | "USB") && attr(d, "online").as_deref() == Some("1")); + let bats: Vec<&PathBuf> = all + .iter() + .filter(|(d, t)| t == "Battery" && attr(d, "scope").as_deref() != Some("Device") && attr(d, "present").is_none_or(|p| p == "1")) + .map(|(d, _)| d) + .collect(); + let Some(charge) = bats.iter().filter_map(|d| percent(d)).max() else { return false }; + let said: Vec = bats.iter().filter_map(|d| attr(d, "status")).collect(); + let draining = said.iter().any(|s| s == "Discharging") + || (!said.iter().any(|s| matches!(s.as_str(), "Charging" | "Full" | "Not charging")) && !mains); + draining && charge <= CRITICAL +} + +pub fn critical() -> bool { + critical_in(Path::new("/sys")) +} + +/// One look at the battery while the state partition is suspended, after `low` critical looks +/// in a row: the count now, and whether to power off. Twice running, as power.sh has it, so one +/// odd reading does not end the session. +pub fn sealed_look(low: u32, critical: bool) -> (u32, bool) { + let low = if critical { low + 1 } else { 0 }; + (low, low >= 2) +} + +/// Off at once, the battery running out with the state partition suspended. Everything was +/// written out before the seal, nothing can be written to the partition now, and a sync, or +/// the shutdown that would stop the zones first, would wait on it for good. Returns only with +/// why the kernel refused. +pub fn power_off() -> std::io::Error { + // SAFETY: reboot takes a command alone, and RB_POWER_OFF does not return when it works. + unsafe { libc::reboot(libc::RB_POWER_OFF) }; + std::io::Error::last_os_error() +} + #[derive(Debug)] pub enum ResumeError { /// cryptsetup exited 2: no keyslot took the passphrase. diff --git a/compartments/kryptikd/src/suspend/tests.rs b/compartments/kryptikd/src/suspend/tests.rs index 803c72e9..facf08c8 100644 --- a/compartments/kryptikd/src/suspend/tests.rs +++ b/compartments/kryptikd/src/suspend/tests.rs @@ -87,3 +87,83 @@ fn a_seal_that_left_a_zone_waiting_counts_itself() { count_left_waiting(&dir.join("absent").join("left-waiting")); let _ = fs::remove_dir_all(&dir); } + +/// A power supply of the kernel's shape under `sys`: a directory with its type and attributes. +fn supply(sys: &Path, name: &str, kind: &str, attrs: &[(&str, &str)]) { + let d = sys.join("class/power_supply").join(name); + fs::create_dir_all(&d).unwrap(); + fs::write(d.join("type"), format!("{kind}\n")).unwrap(); + for (k, v) in attrs { + fs::write(d.join(k), format!("{v}\n")).unwrap(); + } +} + +fn fresh_sys(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("kryptikd-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +// The cases of tools/tests/power.sh that decide power.sh's critical=: the two must agree. +#[test] +fn the_battery_is_critical_as_power_sh_has_it() { + let s = fresh_sys("power-none"); + assert!(!critical_in(&s), "no supply at all, as in a VM"); + assert!(!critical_in(&s.join("absent")), "no power_supply class"); + + let s = fresh_sys("power-low"); + supply(&s, "AC", "Mains", &[("online", "0")]); + supply(&s, "BAT0", "Battery", &[("capacity", "3"), ("status", "Discharging")]); + assert!(critical_in(&s), "3% on battery"); + supply(&s, "BAT1", "Battery", &[("capacity", "60"), ("status", "Discharging")]); + assert!(!critical_in(&s), "but not while a second battery is full enough"); + + let s = fresh_sys("power-four"); + supply(&s, "BAT0", "Battery", &[("capacity", "4"), ("status", "Discharging")]); + assert!(!critical_in(&s), "4% is not yet"); + + let s = fresh_sys("power-weak"); + supply(&s, "AC", "Mains", &[("online", "1")]); + supply(&s, "BAT0", "Battery", &[("capacity", "2"), ("status", "Discharging")]); + assert!(critical_in(&s), "plugged in to a charger that gives less than the machine uses, it runs down"); + supply(&s, "BAT0", "Battery", &[("status", "Charging")]); + assert!(!critical_in(&s), "2% charging on mains"); + + let s = fresh_sys("power-word"); + supply(&s, "BAT0", "Battery", &[("capacity", "2"), ("status", "Unknown")]); + assert!(critical_in(&s), "no word from the battery and no adapter: it runs down"); + supply(&s, "ucsi", "USB", &[("online", "1")]); + assert!(!critical_in(&s), "no word from the battery, a USB charger online"); + supply(&s, "BAT0", "Battery", &[("status", "Not charging")]); + supply(&s, "ucsi", "USB", &[("online", "0")]); + assert!(!critical_in(&s), "one that holds its charge"); + + let s = fresh_sys("power-other"); + supply(&s, "hidpp_battery_0", "Battery", &[("capacity", "1"), ("scope", "Device"), ("status", "Discharging")]); + assert!(!critical_in(&s), "a mouse's empty battery is not the machine's"); + supply(&s, "BAT0", "Battery", &[("capacity", "2"), ("present", "0"), ("status", "Discharging")]); + supply(&s, "BAT1", "Battery", &[("capacity", "fail"), ("status", "Discharging")]); + assert!(!critical_in(&s), "an absent battery and a bad reading count for nothing"); + + let s = fresh_sys("power-energy"); + supply(&s, "BAT0", "Battery", &[("capacity", "fail"), ("energy_now", "5130000"), ("energy_full", "57000000"), ("status", "Discharging")]); + assert!(!critical_in(&s), "with no capacity that reads, the energy against full: 9%"); + let s = fresh_sys("power-charge"); + supply(&s, "BAT0", "Battery", &[("charge_now", "120000"), ("charge_full", "4000000"), ("status", "Discharging")]); + assert!(critical_in(&s), "or the charge against full: 3%"); + supply(&s, "BAT0", "Battery", &[("charge_full", "0")]); + assert!(!critical_in(&s), "a battery full at nothing gives no charge"); + + for tag in ["none", "low", "four", "weak", "word", "other", "energy", "charge"] { + let _ = fs::remove_dir_all(std::env::temp_dir().join(format!("kryptikd-power-{tag}-{}", std::process::id()))); + } +} + +#[test] +fn critical_twice_running_powers_off() { + assert_eq!(sealed_look(0, true), (1, false), "one odd reading does not end the session"); + assert_eq!(sealed_look(1, true), (2, true), "the second, 30 s on, does"); + assert_eq!(sealed_look(1, false), (0, false), "a reading above starts the count again"); + assert_eq!(sealed_look(0, false), (0, false)); +} diff --git a/docs/design/camera.md b/docs/design/camera.md index d4c2b77d..9baa44f0 100644 --- a/docs/design/camera.md +++ b/docs/design/camera.md @@ -87,7 +87,10 @@ loaded only for a grant. (`modprobe -r`, which takes the V4L2 core and videobuf2 with it once nothing uses them). Should the module not unload, something still holding it, the grant ends all the same, the node gone and the driver - unbound; the module stays until the next grant, and the chrome says so. + unbound; the module stays, and the chrome says so. A program that keeps + its descriptor also keeps the driver's hold on the camera's streaming + interface, which the camera probed again could not take. So a grant is + refused, with the reason, until that program lets go. - **What the user sees.** While a zone holds the camera, a window of the chrome's names the zone, as the microphone's does. The camera's light, on machines that tie it to the sensor in hardware, says when it captures. diff --git a/docs/design/laptop.md b/docs/design/laptop.md index 4e382756..bda079e5 100644 --- a/docs/design/laptop.md +++ b/docs/design/laptop.md @@ -376,8 +376,18 @@ The lock's check, in `make gui-test`: next 8 s once unlocked. **The watchdog** must not reset a sleeping machine, nor one waiting at the -state prompt. The drivers stop their timers across suspend, and the feeder -starts no program, so it goes on feeding while the partition is suspended. +state prompt. Intel's timer stops across every sleep, and in ACPI's own +sleep states the firmware stops the rest. + +On a machine that sleeps to idle, two timers would go on counting through +it, so the feeder leaves both unopened: +- AMD's timer, whose driver does not stop it; +- the software watchdog, whose clock runs while the s2idle loop handles an + event that wakes nothing, amd_pmc's 2.5 s pause after each deepest sleep + included. + +The feeder starts no program, so it goes on feeding while the partition is +suspended. ### In the threat model @@ -467,6 +477,20 @@ and the power key, idle and inhibitors, suspend, and runtime directories since there is no hibernation. Each look is written to `/run/kryptik-power/state`, which the chrome reads, so the menu says what the service acts on. +- **The battery while the state partition waits.** From a wake until the + passphrase is given, the power service waits on the partition, as every + program started then does. A battery reaching its alarm level wakes an + s2idle laptop, so a laptop asleep in a bag would wake to the prompt, + lid still shut, and run flat there. kryptikd keeps the 3% rule + meanwhile, from `/sys` alone (`critical` in `suspend.rs`, held to + power.sh's cases): seen twice 30 s apart, it powers the machine off at + once (`reboot(2)`). The count carries across a sleep, so a machine put + back to sleep soon after each wake still powers off. The zones are frozen + and their volumes and the partition have no key, so they are not stopped + first: everything was written out before the seal, nothing can be + written now, and a sync would wait for good. The power service, should + its programs run then, leaves the power-off to kryptikd: its shutdown + would stop the zones and sync, and neither could finish. - **A gauge that reads too low.** A worn battery whose gauge says 3% or less while it still holds charge powers the machine off a minute after every boot on battery. Requiring a reading above 3% earlier in the boot would @@ -640,9 +664,12 @@ stops listing until it is on again, is never turned on by it. A monitor no line names gets 2 if it is a built-in panel (eDP, LVDS, DSI) of 192 dpi or more and 1200 lines or more, as GNOME decides, and 1 otherwise: a television's EDID may give its aspect ratio as its size, so only built-in -panels are measured. Zones are told the scale through `wl_output`, rounded -up to a whole number, and draw that much larger; the compositor scales them -down to a fraction such as 1.5. dwl and the proxy needed no change. +panels are measured. A zone's window is told the exact scale through +`wp_fractional_scale_v1`, which the proxy passes on (`policy.rs`), and a +client that reads only `wl_output`'s whole number draws at that, which the +compositor scales down to the fraction. havoc and kryptik-lock draw at 1 at +every scale, so the compositor enlarges them, and they look soft at any scale +but 1. Check: `make gui-test` writes the user's line for the first monitor at 1 and `* 2`, plugs a second monitor in, and finds it at scale 2 and the first at 1; diff --git a/docs/status.md b/docs/status.md index 9954d2cc..c1d3c722 100644 --- a/docs/status.md +++ b/docs/status.md @@ -85,9 +85,10 @@ media it tested. stopping the feeder). A crashed service or a frozen desktop is not detected, because a false reboot is worse than the hang. A hung kernel is reset only by a hardware timer (Intel TCO, AMD SP5100) or the lockup - detectors. AMD's timer is not fed on a machine that sleeps to idle, as it - would count through each sleep, so there the lockup detectors alone catch - a hung kernel. + detectors. On a machine that sleeps to idle, AMD's timer and the software + watchdog are not fed, as both would count through each sleep. There + Intel's timer, where there is one, still catches a stopped machine, and + on an AMD laptop the lockup detectors alone catch a hung kernel. - NVIDIA's graphics have no driver: nouveau needs tens of megabytes of GSP firmware for each generation. Beside Intel's or AMD's on a laptop, the built-in screen works through those, a port the NVIDIA chip drives stays diff --git a/docs/user-guide.md b/docs/user-guide.md index 2cb77d27..f8befcdf 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -124,6 +124,17 @@ older EPYC models: they keep the firmware's own microcode, the kernel log says `No sha256 digest for patch ID`, and every login prompt says to update the firmware. +Leave the firmware's IOMMU, its DMA protection and Pluton on, as they come. +- Without the IOMMU, every device can reach all of memory, and nothing at + the login prompt says so. +- With the IOMMU on, AMD's own sleep checker holds the Ryzen AI 300 and + Ryzen AI Max processors to two things: + - the firmware's ACPI device `MSFT0201`; + - pre-boot DMA protection, or `MSFT0201` named in the firmware's IVRS + table. + + A firmware setting can take either away. + A firmware that also keeps Microsoft's certificates in `db` boots whatever Microsoft signs, so someone with the machine in hand can start such a system and have it ask for the state passphrase as Kryptik does. Taking Microsoft's @@ -300,7 +311,10 @@ The menu shows the zone's glyph and label and the window's title, which the zone's proxy prefixes with the zone's name, as `[untrusted] ...`. A zone's window that opens while another window has the keyboard does not take it, nor does it get it when that window closes: Alt+j, Alt+k, a click or the -pointer moving onto it moves the keyboard there. Keys (Alt is the modifier): +pointer moving onto it moves the keyboard there. A touchscreen works as the +pointer, on the built-in screen when there is one: a tap is a click and a +drag drags with one finger, and zones are given pointer events only, not +touch. Keys (Alt is the modifier): | keys | what | | --- | --- | @@ -464,6 +478,26 @@ network. A new passphrase keeps it so; forget the network to stop. `kryptik wifi list` names the saved networks, and `kryptik wifi forget SSID` removes one. +**Your country's radio rules.** Nothing sets the country the radio's rules +come from, so until it joins a network that names one, the radio keeps the +world's rules: +- the 6 GHz band is off, so a network on 6 GHz alone is never seen; +- on 5 GHz it only listens, and joins a network there once it has heard it. + +The rules go back to the world's at every disconnect. For your own country's +rules until the next boot: +1. At the administration login, run `su`. +2. Run `/usr/sbin/iw reg set XX`, where `XX` is your country's two letters + (`US`, `DE`). +3. `/usr/sbin/iw reg get` shows the rules in force. + +A radio whose firmware keeps its own rules, as Intel's do, follows those +instead, and `/usr/sbin/iw reg get` names it as self-managed. + +A Wi-Fi 7 network that offers only SAE-EXT-KEY, the WPA3 mode Wi-Fi 7's +multi-link operation uses, is not joined. Have the router offer WPA3 (SAE) +or WPA2 beside it. + **The local network.** Every zone with a network reaches the internet through the net zone. The network the machine itself is on, with its router, its printers and the page a hotel's or café's Wi-Fi asks you to log in on, is @@ -475,10 +509,12 @@ SCALE` draws everything on that monitor SCALE times as large, from 0.5 to 4, 1.5 say on a laptop's high-resolution panel, and `kryptik display NAME SCALE X Y` also puts its top left corner at X,Y. The choice is kept for every login and made again whenever the monitor is plugged in; NAME `*` stands for -every monitor no other line names, and `kryptik display NAME auto` gives the -monitor back its own: 2 for a built-in panel of 192 dpi or more, 1 for the -rest. Zones are told the scale rounded up to a whole number, and draw that -much larger. +every monitor no other line names. `kryptik display NAME auto` drops that +monitor's line, so it takes the `*` line if there is one, and otherwise 2 for a +built-in panel of 192 dpi or more and 1200 lines or more, 1 for the rest. A +zone's window is told the exact scale, and the browser draws sharp at it. +Kryptik's terminals, the menu and the lock screen draw at 1 and are enlarged, +so at any scale but 1 they look soft. **USB sticks.** Zone 0 reads nothing on a USB stick or an SD card. Plugging one in opens a window of the chrome that names each partition by what the diff --git a/tools/desktop/dwl-zone-borders.py b/tools/desktop/dwl-zone-borders.py index 3ac3b4d6..0e7ce9c2 100755 --- a/tools/desktop/dwl-zone-borders.py +++ b/tools/desktop/dwl-zone-borders.py @@ -1271,6 +1271,154 @@ \tif (lidshut && undocktimer) \t\twl_event_source_timer_update(undocktimer, 5000); } +"""), + # A touchscreen drives the pointer, where dwl 0.8 drops touch devices. A finger down moves + # the pointer there and presses its left button, moving drags, and lifting releases it: a tap + # clicks through dwl's own pointer path, its focus rules and the lock's. Zones are offered no + # touch, only those pointer events, so the proxy and its policy stay as they are. + ("""#include +""", + """#include +#include +"""), + ("""static struct wl_listener cursor_motion_absolute = {.notify = motionabsolute}; +""", + """static struct wl_listener cursor_motion_absolute = {.notify = motionabsolute}; +static void touchdown(struct wl_listener *listener, void *data); +static void touchmotion(struct wl_listener *listener, void *data); +static void touchup(struct wl_listener *listener, void *data); +static void touchcancel(struct wl_listener *listener, void *data); +static struct wl_listener cursor_touch_down = {.notify = touchdown}; +static struct wl_listener cursor_touch_motion = {.notify = touchmotion}; +static struct wl_listener cursor_touch_up = {.notify = touchup}; +static struct wl_listener cursor_touch_cancel = {.notify = touchcancel}; +static struct wl_listener cursor_touch_frame = {.notify = cursorframe}; +"""), + ("""\twl_signal_add(&cursor->events.frame, &cursor_frame); +""", + """\twl_signal_add(&cursor->events.frame, &cursor_frame); +\twl_signal_add(&cursor->events.touch_down, &cursor_touch_down); +\twl_signal_add(&cursor->events.touch_motion, &cursor_touch_motion); +\twl_signal_add(&cursor->events.touch_up, &cursor_touch_up); +\twl_signal_add(&cursor->events.touch_cancel, &cursor_touch_cancel); +\twl_signal_add(&cursor->events.touch_frame, &cursor_touch_frame); +"""), + ("""\twl_list_remove(&cursor_motion_absolute.link); +""", + """\twl_list_remove(&cursor_motion_absolute.link); +\twl_list_remove(&cursor_touch_down.link); +\twl_list_remove(&cursor_touch_motion.link); +\twl_list_remove(&cursor_touch_up.link); +\twl_list_remove(&cursor_touch_cancel.link); +\twl_list_remove(&cursor_touch_frame.link); +"""), + ("""\tcase WLR_INPUT_DEVICE_SWITCH: +\t\tcreateswitch(wlr_switch_from_input_device(device)); +\t\tbreak; +""", + """\tcase WLR_INPUT_DEVICE_SWITCH: +\t\tcreateswitch(wlr_switch_from_input_device(device)); +\t\tbreak; +\tcase WLR_INPUT_DEVICE_TOUCH: +\t\twlr_cursor_attach_input_device(cursor, device); +\t\tbreak; +"""), + ("""void +cursorframe(struct wl_listener *listener, void *data) +{ +""", + """/* Kryptik: the finger that drives the pointer, -1 while none is down; a second is ignored. */ +static int32_t touchid = -1; + +/* Kryptik: the pointer to where a touch is: on the built-in panel when there is one, as a + * laptop's touchscreen is its own, and over every screen otherwise. One touchscreen is taken + * to be the laptop's, so a touch monitor on a desk drives the panel too. 0, the touch dropped, + * while the panel is there but off: dark, or shut away, a touch on it lands where no one sees. */ +static int +touchpoint(struct wlr_input_device *device, uint32_t time, double x, double y) +{ +\tstruct wlr_box box = {0}; +\tdouble lx, ly; +\tint panel = 0; +\tMonitor *m; + +\twl_list_for_each(m, &mons, link) { +\t\tconst char *n = m->wlr_output->name; +\t\tif (strncmp(n, "eDP", 3) && strncmp(n, "LVDS", 4) && strncmp(n, "DSI", 3)) +\t\t\tcontinue; +\t\tpanel = 1; +\t\tif (m->wlr_output->enabled) +\t\t\tbox = m->m; +\t} +\tif (panel && wlr_box_empty(&box)) +\t\treturn 0; +\tif (!wlr_box_empty(&box)) { +\t\tlx = box.x + x * box.width; +\t\tly = box.y + y * box.height; +\t} else { +\t\twlr_cursor_absolute_to_layout_coords(cursor, device, x, y, &lx, &ly); +\t} +\tmotionnotify(time, device, lx - cursor->x, ly - cursor->y, lx - cursor->x, ly - cursor->y); +\t/* motionnotify picks the surface before it moves the cursor, which a mouse's small steps +\t * never show. A touch jumps, so the surface is picked again where it landed, or the press +\t * would go to whatever the pointer rested on, another zone's window as likely as not. */ +\tmotionnotify(0, NULL, 0, 0, 0, 0); +\treturn 1; +} + +/* Kryptik: the left button, as buttonpress takes it from a pointer. */ +static void +touchbutton(uint32_t time, enum wl_pointer_button_state state) +{ +\tstruct wlr_pointer_button_event event = {.time_msec = time, .button = BTN_LEFT, .state = state}; +\tbuttonpress(NULL, &event); +} + +void +touchdown(struct wl_listener *listener, void *data) +{ +\tstruct wlr_touch_down_event *event = data; +\t/* Through a lid shut on a dock, a touch is no one's: it would click, and take the keyboard, +\t * on a screen no one sees, and hold off the idle lock. */ +\tif (touchid != -1 || lidshut) +\t\treturn; +\tif (!touchpoint(&event->touch->base, event->time_msec, event->x, event->y)) +\t\treturn; +\ttouchid = event->touch_id; +\ttouchbutton(event->time_msec, WL_POINTER_BUTTON_STATE_PRESSED); +} + +void +touchmotion(struct wl_listener *listener, void *data) +{ +\tstruct wlr_touch_motion_event *event = data; +\tif (event->touch_id == touchid) +\t\ttouchpoint(&event->touch->base, event->time_msec, event->x, event->y); +} + +void +touchup(struct wl_listener *listener, void *data) +{ +\tstruct wlr_touch_up_event *event = data; +\tif (event->touch_id != touchid) +\t\treturn; +\ttouchid = -1; +\ttouchbutton(event->time_msec, WL_POINTER_BUTTON_STATE_RELEASED); +} + +void +touchcancel(struct wl_listener *listener, void *data) +{ +\tstruct wlr_touch_cancel_event *event = data; +\tif (event->touch_id != touchid) +\t\treturn; +\ttouchid = -1; +\ttouchbutton(event->time_msec, WL_POINTER_BUTTON_STATE_RELEASED); +} + +void +cursorframe(struct wl_listener *listener, void *data) +{ """), # Locked, dwl heeds no key at all, so a laptop that starts to play aloud under the lock # is silenced only by unlocking. It heeds config.h's lockedkeys instead: the volume and diff --git a/tools/desktop/wlprobe.c b/tools/desktop/wlprobe.c index faa0e9c2..c850c063 100644 --- a/tools/desktop/wlprobe.c +++ b/tools/desktop/wlprobe.c @@ -35,7 +35,8 @@ * its middle: drawn, it covers the whole screen. * Says when the window, and when the cursor image, * enters an output: the second only if the - * compositor took the image. With shape, also ask + * compositor took the image, and each pointer + * button it is sent. With shape, also ask * for the text cursor by name (wp_cursor_shape_v1) * wlprobe animate SECONDS as oversize 0, and once drawn commit again on * every frame callback for SECONDS; then say how @@ -616,6 +617,10 @@ static int handle_one(void) draw_popup(); } else if (cursor && object == pointer_id && opcode == 0) { set_cursor(get32(body)); /* wl_pointer.enter(serial, surface, x, y) */ + } else if (cursor && object == pointer_id && opcode == 3) { + /* wl_pointer.button(serial, time, button, state) */ + printf("pointer button %u %s\n", get32(body + 8), get32(body + 12) ? "pressed" : "released"); + fflush(stdout); } else if (cursor && opcode == 0 && (object == SURFACE || (cursor_surf && object == cursor_surf))) { /* wl_surface.enter(output) */ printf("%s entered an output\n", object == SURFACE ? "the window" : "the cursor image"); diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index b7088c08..b6d6bd18 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -70,7 +70,7 @@ install_disk gui-install "$USB" --vars clean && green "installed" || { red "inst # ----------------------------------------------------------------- step 2 -- step "step 2: the desktop, driven" rm -f "$SHOT" "$SHOT_FS" "$SHOT_OVER" "$SHOT_HEAD" "$SHOT_POPUP" "$SHOT_LOCKED" -start_vm gui-p2 --net user --gpu --second-head --mem 3072 +start_vm gui-p2 --net user --gpu --second-head --touch --mem 3072 DRIVE_TIMEOUT=600 drive --qmp "$QMP" \ "expect:KRYPTIK_SMOKE: END" "seen:kryptik-firstboot: created user '${TUSER}'" "login:${TUSER}:${TPASS}" \ "send:su - root -c 'bash /usr/lib/kryptik/guest-tests/gui-check.sh ${TUSER} ${TPASS} 2>&1 | tee /var/log/kryptik/gui-check.log; echo GCHECK-DONE'" \ @@ -116,6 +116,8 @@ DRIVE_TIMEOUT=600 drive --qmp "$QMP" \ "expect:GT CONSENT-WAIT 2" "key:y" "key:ret" \ "expect:GT POINTER-ZONE0" "pointer:-4000,-4000" "pointer:120,120" \ "expect:GT POINTER-UNTRUSTED" "pointer:-4000,-4000" "pointer:120,120" \ + "expect:GT TOUCH-HOVER-ZONE0" "pointer:4000,0" "pointer:-80,0" "pointer:-1,0" \ + "expect:GT TOUCH-UNTRUSTED" "touch:250,500" \ "expect:GT SCREENSHOT-POPUP" "sleep:2" "screendump:${SHOT_POPUP}" \ "expect:GT KEY-MENU-POPUP" "key:alt+p" \ "expect:GT KEY-FOCUS-POPUP" "key:alt+j" \ @@ -183,7 +185,7 @@ for name in session-socket compositor-running desktop-memory-bound chrome-focus- second-head-scale-chosen zone-sees-heads-unnamed \ vt-admin-login vt-desktop-back vt-desktop-takes-keys dwl-execs-nothing dwl-without-group seat-is-dwls dwl-confined-to-its-files \ volume-key lock-passes-volume-keys lock-holds-other-keys \ - zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown zone-cursor-by-name zone-fractional-scale compositor-socket-unreached \ + zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown zone-cursor-by-name touch-picks-window touch-presses-window zone-fractional-scale compositor-socket-unreached \ zone0-frame-clock zone-window-animates \ status-network-line-user \ zone0-fullscreen-granted zone0-fullscreen-needs-focus fullscreen-by-key marks-fullscreen zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zoom-twice-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do diff --git a/tools/image/run-ovmf.sh b/tools/image/run-ovmf.sh index 263a1500..c3d54bc1 100755 --- a/tools/image/run-ovmf.sh +++ b/tools/image/run-ovmf.sh @@ -6,7 +6,7 @@ # [--disk FILE]... [--testctl FILE] [--vars clean|enrolled|ms|FILE] # [--vars-file FILE] [--mode console|smoke|serve] [--timeout N] # [--log FILE] [--net none|user] [--mem MB] [--cpus N] [--gpu] -# [--second-head] [--s3] [--sound WAV] [--usb-port] [--allow-reboot] +# [--second-head] [--touch] [--s3] [--sound WAV] [--usb-port] [--allow-reboot] # [--until REGEX] [--name TAG] # # --usb IMG the medium as a USB mass-storage device (removable), @@ -38,6 +38,8 @@ # --second-head with --gpu in serve mode: a second output on the GPU, off # until a VNC client on the printed socket asks for a size # (vm-drive.py's head: step), which plugs a monitor in +# --touch with --gpu: a touchscreen over the first output, QEMU's +# virtio-multitouch (8.1 on), which vm-drive.py's touch: step taps # --s3 offer suspend to RAM, which QEMU otherwise hides: the guest # sleeps until vm-drive.py's wakeup step # --sound WAV two HD Audio cards: the first only plays, into the file WAV @@ -54,7 +56,7 @@ SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SELF}/../../build/lib/common.sh" USB=""; ISO=""; NOMEDIA=0; DISKS=(); TESTCTL=""; VARS="clean"; VARS_FILE="" -MODE="smoke"; TIMEOUT=300; LOG=""; NET="none"; MEM=2048; CPUS=2; GPU=0; HEAD2=0; S3=0; SOUND=""; ALLOW_REBOOT=0; NAME="vm" +MODE="smoke"; TIMEOUT=300; LOG=""; NET="none"; MEM=2048; CPUS=2; GPU=0; HEAD2=0; TOUCH=0; S3=0; SOUND=""; ALLOW_REBOOT=0; NAME="vm" DISK_RO=0; DISK_4K=0; BLKDEBUG=""; UNTIL=""; USB_PORT=0; USB_RO=on while [[ "$#" -gt 0 ]]; do case "$1" in @@ -77,13 +79,14 @@ while [[ "$#" -gt 0 ]]; do --cpus) CPUS="${2:?}"; shift 2 ;; --gpu) GPU=1; shift ;; --second-head) HEAD2=1; shift ;; + --touch) TOUCH=1; shift ;; --s3) S3=1; shift ;; --sound) SOUND="${2:?}"; shift 2 ;; --usb-port) USB_PORT=1; shift ;; --allow-reboot) ALLOW_REBOOT=1; shift ;; --until) UNTIL="${2:?}"; shift 2 ;; --name) NAME="${2:?}"; shift 2 ;; - -h|--help) sed -n '2,49p' "${BASH_SOURCE[0]}"; exit 0 ;; + -h|--help) sed -n '2,51p' "${BASH_SOURCE[0]}"; exit 0 ;; *) die "unknown argument: $1" ;; esac done @@ -93,6 +96,7 @@ done [[ "$NOMEDIA" -eq 1 && "${#DISKS[@]}" -eq 0 ]] && die "--no-media needs at least one --disk" [[ -n "$UNTIL" && "$MODE" != smoke ]] && die "--until is for --mode smoke" [[ "$HEAD2" -eq 1 && ( "$GPU" -ne 1 || "$MODE" != serve ) ]] && die "--second-head is for --gpu in --mode serve" +[[ "$TOUCH" -eq 1 && "$GPU" -ne 1 ]] && die "--touch is for --gpu: the touchscreen covers its first output" [[ "$HEAD2" -eq 1 && "$S3" -eq 1 ]] && die "--second-head needs virtio-gpu, which --s3 gives up for std VGA's framebuffer" # Files only. This hands paths to a process that writes to them. @@ -171,6 +175,13 @@ elif [[ "$GPU" -eq 1 ]]; then ARGS+=( -display none -vga none -device virtio-vga,id=gpu0 ) fi ARGS+=( -device virtio-keyboard-pci -device virtio-mouse-pci ) + if [[ "$TOUCH" -eq 1 ]]; then + "$QEMU" -device help 2>/dev/null | grep -q '"virtio-multitouch-pci"' \ + || die "--touch: this QEMU has no virtio-multitouch-pci (QEMU 8.1 or later has)" + # Bound to the GPU's first head, so an input-send-event naming that head reaches it + # before the mouse, which takes button events too. + ARGS+=( -device virtio-multitouch-pci,id=touch0,display=gpu0,head=0 ) + fi else ARGS+=( -display none -vga none ) fi diff --git a/tools/image/state-test.sh b/tools/image/state-test.sh index e879895b..e3b6a09c 100755 --- a/tools/image/state-test.sh +++ b/tools/image/state-test.sh @@ -167,10 +167,17 @@ sfdisk --part-label "$DISK" 4 not-kryptik >/dev/null 2>&1 || die "relabel" degraded_boot state-p5 'no partition labelled kryptik-state on /dev/vda' sfdisk --part-label "$DISK" 4 kryptik-state >/dev/null 2>&1 || die "relabel back" normal_boot state-p5b +# QEMU without --s3 sleeps only to idle, where the software watchdog is not fed (watchdog.sh). +# Intel's timer, fed either way, shows the feeder had run when the report was written. +txt | grep -qE 'KRYPTIK_SMOKE: watchdog watchdog[0-9]+: iTCO_wdt state=active' \ + && txt | grep -qE 'KRYPTIK_SMOKE: watchdog watchdog[0-9]+: Software Watchdog state=inactive' \ + && green "state-p5b: sleeping to idle, the software watchdog is not fed" \ + || red "state-p5b: the boot report does not show Intel's timer fed and the software watchdog unfed" # ----------------------------------------------------------------- step 6 -- step "step 6: nothing feeds the watchdog: the machine resets itself and comes back with its data" -start_vm state-p6 +# With S3 offered the guest sleeps deep, so the feeder arms the software watchdog this step resets by. +start_vm state-p6 --s3 # The feeder stopped, not killed (s6 would restart it): a hung userspace only the watchdog resets. p6=( "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" "$(ROOTSH 's6-svc -p /run/service/watchdog && echo FEEDER-STOPPED')" "expect:FEEDER-STOPPED" diff --git a/tools/image/vm-drive.py b/tools/image/vm-drive.py index a199dea0..3820b2de 100755 --- a/tools/image/vm-drive.py +++ b/tools/image/vm-drive.py @@ -32,6 +32,9 @@ pointer:DX,DY move the guest's mouse by DX,DY through QMP; it is relative and accelerated, so a large move ends at the screen's edge + touch:X,Y tap the touchscreen (run-ovmf.sh --touch) at X,Y, + thousandths of the first output's width and height: + one finger down, then lifted type-from:REGEX wait for REGEX on a line that has ended (REGEX names no line end), then type what its first group matched (digits and letters) and Enter @@ -258,6 +261,15 @@ def vnc_size(s, w, h): if n: msg += struct.pack(">IHHHHI", 0, 0, 0, w, h, 0) s.sendall(msg) +def touch_events(x, y): + """A tap at X,Y (0..0x7fff) as two input-send-event batches: the finger down, then lifted.""" + def mtt(kind, tracking, axis="x", value=0): + return {"type": "mtt", "data": {"type": kind, "slot": 0, "tracking-id": tracking, "axis": axis, "value": value}} + down = [mtt("begin", 1), mtt("data", 1, "x", x), mtt("data", 1, "y", y), + {"type": "btn", "data": {"down": True, "button": "touch"}}] + up = [mtt("end", -1), {"type": "btn", "data": {"down": False, "button": "touch"}}] + return [down, up] + def qmp(path, cmd, args=None): s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect(path) @@ -352,6 +364,17 @@ def main(): r = qmp(qmpsock, "input-send-event", {"events": events}) if "error" in r: raise RuntimeError(f"input-send-event {rest}: {r['error']}") time.sleep(0.3) + elif kind == "touch": + if not qmpsock: raise RuntimeError("touch needs --qmp") + px, py = (int(v) for v in rest.split(",")) + if not (0 <= px <= 1000 and 0 <= py <= 1000): raise RuntimeError(f"touch:{rest}: not in 0..1000") + tap = touch_events(px * 0x7fff // 1000, py * 0x7fff // 1000) + for events in tap: + # The head binds the events to run-ovmf.sh's touchscreen, not the mouse. + r = qmp(qmpsock, "input-send-event", {"device": "gpu0", "head": 0, "events": events}) + if "error" in r: raise RuntimeError(f"input-send-event touch:{rest}: {r['error']}") + time.sleep(0.15) + time.sleep(0.3) elif kind == "type-from": if not qmpsock: raise RuntimeError("type-from needs --qmp") text = d.line_value(rest) diff --git a/tools/install/kryptik-hwreport.sh b/tools/install/kryptik-hwreport.sh index 492d4403..5e7912db 100755 --- a/tools/install/kryptik-hwreport.sh +++ b/tools/install/kryptik-hwreport.sh @@ -94,9 +94,16 @@ firmware() { case "$(efi_byte SecureBoot)" in 1) echo "secure boot: on" ;; 0) echo "secure boot: off" ;; *) echo "secure boot: unreadable" ;; esac printf 'setup mode: %s\n' "$(efi_byte SetupMode)" printf 'lockdown: %s\n' "$(sed -n 's/.*\[\(.*\)\].*/\1/p' "$SYS/kernel/security/lockdown" 2>/dev/null)" + # The TPM's version and the driver it came up under, and the hardware random number generator + # the kernel reads, if any: some firmware TPMs register none. tpm=none - for t in "$SYS"/class/tpm/tpm*; do [ -e "$t" ] && tpm="version $(one "$t/tpm_version_major")"; done - printf 'tpm: %s\n' "$tpm" + for t in "$SYS"/class/tpm/tpm*; do + [ -e "$t" ] || continue + drv="$(target "$t/device/driver")" + tpm="version $(one "$t/tpm_version_major"), driver ${drv:--}" + done + rng="$(one "$SYS/class/misc/hw_random/rng_current")" + printf 'tpm: %s; hwrng: %s\n' "$tpm" "${rng:-none}" # What confines a device's DMA: the IOMMUs in use, their groups by how they translate (DMA # strict, DMA-FQ lazy, identity not at all), and what Thunderbolt lets a plugged device do. units="" @@ -148,6 +155,12 @@ cpu() { }' "$R/proc/cpuinfo" 2>/dev/null printf 'present: %s, online: %s, smt: %s\n' "$(one "$SYS/devices/system/cpu/present")" \ "$(one "$SYS/devices/system/cpu/online")" "$(one "$SYS/devices/system/cpu/smt/control")" + # Which driver sets the clock, and how: power.sh's energy hints act only in a driver's active + # mode, and a firmware without CPPC leaves amd-pstate out. + pol="$SYS/devices/system/cpu/cpufreq/policy0" + fd="$(one "$pol/scaling_driver")"; fg="$(one "$pol/scaling_governor")" + fe="$(one "$pol/energy_performance_preference")"; fa="$(one "$SYS/devices/system/cpu/amd_pstate/status")" + printf 'cpufreq: %s, governor %s, epp %s, amd-pstate %s\n' "${fd:-none}" "${fg:--}" "${fe:--}" "${fa:--}" kmsg | sed -n 's/^\[[^]]*\] *\(microcode: .*\)/\1/p' | head -n 3 for f in "$SYS"/devices/system/cpu/vulnerabilities/*; do [ -r "$f" ] && printf '%s: %s\n' "${f##*/}" "$(one "$f")" @@ -409,6 +422,12 @@ laptop() { printf 'power_supply %s type %s online %s\n' "${p##*/}" "${t:--}" "${o:--}" fi done + sleeping + cameras +} + +# The sleep states the kernel offers, how the last sleeps went, and what woke the machine. +sleeping() { printf 'sleep states: %s\n' "$(one "$SYS/power/state")" printf 'sleep mem: %s\n' "$(one "$SYS/power/mem_sleep")" # The sleeps since boot, and how long the last kept the hardware in its deepest state: an @@ -427,8 +446,31 @@ laptop() { done done printf 'power controller: %s\n' "${pmc:-none}" - # Intel's image processing units by PCI ID: the IPU6 and IPU7 ones 6.18's drivers bind (ipu6.h, - # staging ipu7.h), and IPU8's, which staging ipu7 names but does not bind yet. + # What ended the last sleep: the interrupt, where the kernel keeps it (PM_SLEEP_DEBUG), and the + # wakeup sources that fired most since boot. By their events: kryptikd sleeps without the + # wakeup_count handshake, so each source's wakeup_count stays 0. + irq="$(one "$SYS/power/pm_wakeup_irq")" + printf 'woken by irq: %s\n' "${irq:--}" + for w in "$SYS"/class/wakeup/wakeup*; do + wn="$(int "$w/event_count")" + [ "${wn:-0}" -gt 0 ] && printf '%s %s\n' "$wn" "$(one "$w/name")" + done | sort -rn | head -n 5 | while read -r wn ws; do printf 'wakeup source %s events %s\n' "$ws" "$wn"; done + # With the IOMMU on, amd-debug-tools holds AMD's family 1Ah models 0x20-0x2e, 0x60-0x6e and + # 0x70-0x7e to the ACPI device MSFT0201, and to pre-boot DMA protection (IVRS IVinfo bit 1) or + # MSFT0201 named in the IVRS: a firmware setting can take either away. + if [ -r "$SYS/firmware/acpi/tables/IVRS" ]; then + msft=absent + for a in "$SYS"/bus/acpi/devices/MSFT0201:*; do [ -e "$a" ] && msft=present; done + iv="$(od -An -tu4 -j36 -N4 "$SYS/firmware/acpi/tables/IVRS" 2>/dev/null | tr -d ' ')" + case "$iv" in ''|*[!0-9]*) dp=- ;; *) dp=off; [ $(( iv & 2 )) -eq 0 ] || dp=on ;; esac + named=no; LC_ALL=C grep -aq MSFT0201 "$SYS/firmware/acpi/tables/IVRS" && named=yes + printf 'amd iommu: msft0201 %s, pre-boot dma protection %s, msft0201 in ivrs %s\n' "$msft" "$dp" "$named" + fi +} + +# Intel's image processing units by PCI ID: the IPU6 and IPU7 ones 6.18's drivers bind (ipu6.h, +# staging ipu7.h), and IPU8's, which staging ipu7 names but does not bind yet. +cameras() { for d in "$SYS"/bus/pci/devices/*; do [ "$(hex "$d/vendor")" = 8086 ] || continue case "$(hex "$d/device")" in diff --git a/tools/kryptik b/tools/kryptik index ebb7880e..79cd4f98 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -76,8 +76,9 @@ USAGE: everything half as large again), its top left corner at X,Y if given; kept for every login; NAME * is any monitor no line names - $PROG display NAME auto that monitor as it comes: 2 for a built-in - panel of 192 dpi or more, else 1 + $PROG display NAME auto drop that monitor's line: the * line then, + if any, else 2 for a built-in panel of 192 + dpi and 1200 lines or more, else 1 OPTIONS: --zones DIR override the zone directory (default: $ZONES_DIR) diff --git a/tools/tests/console-dead-port.sh b/tools/tests/console-dead-port.sh index a6ce74a2..300c3d1a 100755 --- a/tools/tests/console-dead-port.sh +++ b/tools/tests/console-dead-port.sh @@ -22,7 +22,7 @@ body "$ROOT/build/recipes/init.sh" "cat > \"\$skel/rc.shutdown\" <<'EOF'" > "$T/ [[ -s "$T/shutdown.orig" ]] && green "rc.shutdown is in init.sh" || red "no rc.shutdown in init.sh" mkdir -p "$T/bin" "$T/run" "$T/dev" -for held in sysinit firstboot; do echo done > "$T/run/kryptik-$held"; done +for held in sysinit firstboot; do echo "done" > "$T/run/kryptik-$held"; done : > "$T/dev/ttyS0"; : > "$T/dev/tty0"; : > "$T/dev/console" echo 'ro rootwait kryptik.slot=a' > "$T/cmdline" cat > "$T/bin/stty" <<'S' diff --git a/tools/tests/hwreport.sh b/tools/tests/hwreport.sh index 6e0a8d33..cf938d1c 100755 --- a/tools/tests/hwreport.sh +++ b/tools/tests/hwreport.sh @@ -40,6 +40,12 @@ printf '\006\000\000\000\001' > "$W/sys/firmware/efi/efivars/SecureBoot-8be4df61 printf '\006\000\000\000\000' > "$W/sys/firmware/efi/efivars/SetupMode-8be4df61-93ca-11d2-aa0d-00e098032b8c" put sys/kernel/security/lockdown 'none integrity [confidentiality]' put sys/class/tpm/tpm0/tpm_version_major 2 +mkdir -p "$W/sys/class/tpm/tpm0/device" "$W/sys/bus/platform/drivers/tpm_tis" +ln -s "$W/sys/bus/platform/drivers/tpm_tis" "$W/sys/class/tpm/tpm0/device/driver" +put sys/class/misc/hw_random/rng_current tpm-rng-0 +put sys/devices/system/cpu/cpufreq/policy0/scaling_driver intel_pstate +put sys/devices/system/cpu/cpufreq/policy0/scaling_governor powersave +put sys/devices/system/cpu/cpufreq/policy0/energy_performance_preference balance_power for n in 0 1; do printf 'processor\t: %s\nvendor_id\t: GenuineIntel\ncpu family\t: 6\nmodel\t\t: 140\nmodel name\t: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz\nstepping\t: 1\nmicrocode\t: 0xa4\nflags\t\t: fpu nx smep smap umip ibt rdrand aes\n\n' "$n" done > "$W/proc/cpuinfo" @@ -151,7 +157,8 @@ want "whether its state is kept" '^state: tmpfs$' want "the maker and the model" '^product_version: ThinkPad X1 Carbon Gen 9$' want "Secure Boot, read from the firmware" '^secure boot: on$' want "the lockdown in force" '^lockdown: confidentiality$' -want "the TPM" '^tpm: version 2$' +want "the TPM, its driver and the random number generator" '^tpm: version 2, driver tpm_tis; hwrng: tpm-rng-0$' +want "the clock's driver, governor and energy hint" '^cpufreq: intel_pstate, governor powersave, epp balance_power, amd-pstate -$' want "the processor" '^vendor, family/model/stepping: GenuineIntel, 6/140/1$' want "its microcode, and what loaded it" '^microcode: 0xa4$' want "the loader's own line" '^microcode: Current revision: 0x000000a4$' @@ -443,6 +450,30 @@ lap "its sensor, by its ACPI ID" '^camera acpi OVTI01A0:00 sensor$' lap "the sensor's power controller" '^camera acpi INT3472:01 sensor power$' lap "and the Visual Sensing Controller" '^camera acpi INTC1009:00 visual sensing controller$' nolap "not a sensor the firmware calls absent" 'OVTI02C1' +lap "no wakeup interrupt where the kernel keeps none" '^woken by irq: -$' +nolap "and no AMD IOMMU line without an IVRS table" '^amd iommu: ' + +echo "-- what ended a sleep, and AMD's sleep needs with the IOMMU on" +put sys/power/pm_wakeup_irq 9 +put sys/class/wakeup/wakeup0/name PNP0C0D:00 +put sys/class/wakeup/wakeup0/event_count 2 +# A wakeup_count the kernel keeps only after the handshake kryptikd does not make. +put sys/class/wakeup/wakeup0/wakeup_count 0 +put sys/class/wakeup/wakeup1/name alarmtimer.0.auto +put sys/class/wakeup/wakeup1/event_count 0 +mkdir -p "$W/sys/firmware/acpi/tables" +{ head -c 36 /dev/zero; printf '\002\000\000\000'; } > "$W/sys/firmware/acpi/tables/IVRS" +put sys/bus/acpi/devices/MSFT0201:00/status 15 +laptop_now +lap "the interrupt that ended the last sleep" '^woken by irq: 9$' +lap "a wakeup source that fired, by its events" '^wakeup source PNP0C0D:00 events 2$' +nolap "not one that never did" '^wakeup source alarmtimer' +lap "MSFT0201 and pre-boot DMA protection, read from the firmware" '^amd iommu: msft0201 present, pre-boot dma protection on, msft0201 in ivrs no$' +# The protection off, MSFT0201 gone from ACPI's devices and named in the IVRS instead. +{ head -c 36 /dev/zero; printf '\000\000\000\000'; printf 'IVHD MSFT0201'; } > "$W/sys/firmware/acpi/tables/IVRS" +rm -rf "$W/sys/bus/acpi/devices/MSFT0201:00" +laptop_now +lap "each taken away, and MSFT0201 named in the IVRS" '^amd iommu: msft0201 absent, pre-boot dma protection off, msft0201 in ivrs yes$' echo echo "${PASS} passed, ${FAIL} failed" diff --git a/tools/tests/power.sh b/tools/tests/power.sh index 49f8bf71..bfbdb944 100755 --- a/tools/tests/power.sh +++ b/tools/tests/power.sh @@ -117,5 +117,22 @@ set_attr AC online 1; echo Discharging > "$T/sys/class/power_supply/BAT0/status" once > /dev/null check "plugged in, it follows the charger even while the battery runs down" "$(pref 0)" balance_performance +echo "-- the state partition waiting for its passphrase" +# sealed() alone, from the service: it decides whether the 3% power-off is the service's or kryptikd's. +eval "$(sed -n '/^sealed() {/,/^}/p' "$P")" +dm() { # dm N NAME SUSPENDED: a device-mapper device in the stand-in /sys/block + mkdir -p "$T/sys/block/dm-$1/dm" + echo "$2" > "$T/sys/block/dm-$1/dm/name"; echo "$3" > "$T/sys/block/dm-$1/dm/suspended" +} +is_sealed() { if SYS="$T/sys" sealed; then echo yes; else echo no; fi; } +fresh +check "no device-mapper device" "$(is_sealed)" no +dm 0 kryptik-zone-work 1; dm 1 kryptik-state 0 +check "the state partition up, a zone's volume waiting" "$(is_sealed)" no +dm 1 kryptik-state 1 +check "the state partition waiting for its passphrase" "$(is_sealed)" yes +if grep -qF '[ "$low" -ge 2 ] && ! sealed; then' "$P"; then r=yes; else r=no; fi +check "then the 3% power-off is kryptikd's, not the service's" "$r" yes + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/tools/tests/su-path.sh b/tools/tests/su-path.sh new file mode 100755 index 00000000..144bf2a5 --- /dev/null +++ b/tools/tests/su-path.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# What su gives root for PATH. s_shadow edits the PATH lines of shadow 4.16.0's login.defs, then +# s_etc's etc_login_defs sets its own lines: both run here on shadow's file as the build runs +# them, and ENV_SUPATH is read back as su reads it (su.c sets it with or without "-"). Offline. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=build/recipes/etc.sh +source "$ROOT/build/recipes/etc.sh" + +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } + +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +printf 'ENV_SUPATH\tPATH=/sbin:/bin:/usr/sbin:/usr/bin\nENV_PATH\tPATH=/bin:/usr/bin\n' > "$T/login.defs" + +# s_shadow's own edit, as the recipe writes it. +edit="$(grep -o "/PATH=/{[^']*}" "$ROOT/build/recipes/shadow.sh")" +if [[ -n "$edit" ]] && sed -i -e "$edit" "$T/login.defs"; then + green "s_shadow's PATH edit applies" +else + red "no PATH edit found in build/recipes/shadow.sh" +fi + +etc_login_defs "$T/login.defs" +etc_login_defs "$T/login.defs" +su_path="$(sed -n 's/^ENV_SUPATH[[:space:]]\{1,\}//p' "$T/login.defs")" +[[ "$su_path" == PATH=* && ":${su_path#PATH=}:" == *:/usr/sbin:* && ":${su_path#PATH=}:" == *:/usr/bin:* ]] \ + && green "su gives root ${su_path}" || red "su gives root ${su_path:-no PATH}, without /usr/sbin or /usr/bin" +[[ "$(grep -c '^ENV_SUPATH[[:space:]]' "$T/login.defs")" -eq 1 ]] \ + && green "one ENV_SUPATH line, run twice" || red "ENV_SUPATH lines: $(grep -c '^ENV_SUPATH' "$T/login.defs")" +grep -qx 'CONSOLE /etc/securetty' "$T/login.defs" && grep -qx 'SU_WHEEL_ONLY yes' "$T/login.defs" \ + && green "root stays off the terminals, and su is wheel's" || red "CONSOLE or SU_WHEEL_ONLY is not set" +grep -q "$(printf '^ENV_PATH\tPATH=/bin:/usr/bin$')" "$T/login.defs" \ + && green "other users' su PATH is shadow's" || red "ENV_PATH changed: $(grep '^ENV_PATH' "$T/login.defs")" + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] diff --git a/tools/tests/vm-drive.py b/tools/tests/vm-drive.py index 110441f1..eccdce64 100755 --- a/tools/tests/vm-drive.py +++ b/tools/tests/vm-drive.py @@ -188,6 +188,30 @@ def run(self): ("device_add", {"driver": "usb-audio", "id": "usbsound", "audiodev": "snd2", "bus": "xhci.0"}), ("device_del", {"id": "usbsound"})]) +print("-- touch: taps the touchscreen on the GPU's first head: a finger down, then lifted") +sock7 = os.path.join(T, "serial7"); qmp7 = os.path.join(T, "qmp7") +Guest(sock7, {}).start() +q7 = Qmp(qmp7, busy=0); q7.start() +argv, sys.argv = sys.argv, ["vm-drive.py", "--serial", sock7, "--qmp", qmp7, "--timeout", "5", "touch:250,500"] +with contextlib.redirect_stdout(io.StringIO()): + rc = vm.main() +sys.argv = argv +check("touch: succeeds", rc, 0) +mt = lambda kind, tracking, axis="x", value=0: {"type": "mtt", "data": {"type": kind, "slot": 0, "tracking-id": tracking, "axis": axis, "value": value}} +check("the finger goes down at a quarter across and half down, then lifts, both bound to head 0", q7.asked, [ + ("input-send-event", {"device": "gpu0", "head": 0, "events": [ + mt("begin", 1), mt("data", 1, "x", 8191), mt("data", 1, "y", 16383), + {"type": "btn", "data": {"down": True, "button": "touch"}}]}), + ("input-send-event", {"device": "gpu0", "head": 0, "events": [ + mt("end", -1), {"type": "btn", "data": {"down": False, "button": "touch"}}]})]) +sock8 = os.path.join(T, "serial8") +Guest(sock8, {}).start() +argv, sys.argv = sys.argv, ["vm-drive.py", "--serial", sock8, "--qmp", qmp7, "--timeout", "5", "touch:1200,500"] +with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + rc = vm.main() +sys.argv = argv +check("a tap off the screen is refused before QEMU is asked", (rc != 0, len(q7.asked)), (True, 2)) + print() print(f"{PASS} passed, {FAIL} failed") sys.exit(0 if FAIL == 0 else 1) diff --git a/tools/tests/watchdog-feeder.sh b/tools/tests/watchdog-feeder.sh new file mode 100755 index 00000000..21a83d3f --- /dev/null +++ b/tools/tests/watchdog-feeder.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# build/service-scripts/watchdog.sh against stand-in timers: on a machine that sleeps to idle it +# leaves AMD's TCO timer and the software watchdog unfed, as both would count through a sleep, +# and feeds the rest; elsewhere it feeds every one. The script runs with its paths under a +# scratch directory, files for its devices, and one round of its loop. Offline. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="${ROOT}/build/service-scripts/watchdog.sh" +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT + +mkdir -p "$T/dev" +timer() { # timer N IDENTITY: /dev/watchdogN and its sysfs entry + : > "$T/dev/watchdog$1" + mkdir -p "$T/sys/class/watchdog/watchdog$1" + echo "$2" > "$T/sys/class/watchdog/watchdog$1/identity" + echo 60 > "$T/sys/class/watchdog/watchdog$1/timeout" +} +timer 0 "Software Watchdog" +timer 1 "SP5100 TCO timer" +timer 2 "iTCO_wdt" +sed -e "s|/dev/watchdog|$T/dev/watchdog|g" \ + -e "s|/sys/class/watchdog/|$T/sys/class/watchdog/|g" \ + -e "s|/sys/power/mem_sleep|$T/mem_sleep|g" \ + -e 's|\[ -c "\$dev" \]|[ -e "$dev" ]|' \ + -e 's|^while :; do$|for _ in 1; do|' \ + -e 's|^ sleep 10$| :|' \ + -e 's|exec sleep infinity|exit 0|' \ + "$SCRIPT" > "$T/watchdog.sh" +grep -q 'for _ in 1; do' "$T/watchdog.sh" && grep -q '\[ -e "\$dev" \]' "$T/watchdog.sh" \ + && grep -q "$T/mem_sleep" "$T/watchdog.sh" \ + || { echo "watchdog.sh changed shape: its loop, its device test or its mem_sleep read was not found"; exit 1; } +feeds() { # feeds MEM_SLEEP: what the feeder says + echo "$1" > "$T/mem_sleep" + timeout 20 bash "$T/watchdog.sh" 2>&1 +} + +said="$(feeds '[s2idle] deep')" +grep -q "not feeding $T/dev/watchdog0 (Software Watchdog)" <<<"$said" \ + && green "sleeping to idle, the software watchdog is not fed" || red "s2idle, softdog: $(tr '\n' '|' <<<"$said")" +grep -q "not feeding $T/dev/watchdog1 (SP5100 TCO timer)" <<<"$said" \ + && green "nor AMD's TCO timer" || red "s2idle, SP5100: $(tr '\n' '|' <<<"$said")" +grep -q "feeding $T/dev/watchdog2 (iTCO_wdt" <<<"$said" && ! grep -q "not feeding $T/dev/watchdog2" <<<"$said" \ + && green "and Intel's, which stops across that sleep, is" || red "s2idle, iTCO: $(tr '\n' '|' <<<"$said")" +said="$(feeds 's2idle [deep]')" +n="$(grep -c "^watchdog: feeding $T/dev/watchdog" <<<"$said")" +[[ "$n" -eq 3 ]] && ! grep -q 'not feeding' <<<"$said" \ + && green "sleeping deep, where the firmware stops them, all three are fed" || red "deep: $(tr '\n' '|' <<<"$said")" + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]]