
TLDR: I patched i3lock
to update a file when I unlock my laptop. I wrote a tool monitoring this file. If it is not modified for a specific time, the daemon executes a kill switch command. As my system does not support hibernation, I implemented my own solution using cryptsetup luksSuspend
. The source code can be found on Github.
On my laptop I wanted to have two security features:
hibernate with linux-hardened
When you suspend your system, the RAM keeps its power and its content. When you hibernate, the content of the RAM gets dumped into your (encrypted) swap file. To resume from hibernate on a system using full disk encryption, you have to enter your full disk encryption password first. That’s a nice protection from cold boot attacks. But, for security reasons hibernation is not available on Arch Linux when used with the linux-hardened
kernel (Arch Linux Issue, blog post).
kmille@spring:~ systemctl hibernate
Call to Hibernate failed: Sleep verb 'hibernate' is not configured or configuration is not supported by kernel
Hibernate is nice because you can remove the full disk encryption key from memory and keep the state of the system. If your system does not support hibernate, you can build something similar with cryptsetup luksSuspend
and cryptsetup luksResume
. Let’s think about this simple script:
cryptsetup luksSuspend root
echo mem > /sys/power/state
cryptsetup luksResume root
cryptsetup luksSuspend
blocks all I/O operations from and to the root filesystem and removes the full disk encryption key from memory. echo mem > /sys/power/state
uses the kernel interface to hibernate the system. Later, when the laptop wakes up again, it executes cryptsetup luksResume root
. You will see your shell asking for the password. Until the root device gets unlocked, most of the system/desktop environment just hangs.
Now to be honest with you: The script above does not work. The second invocation of cryptsetup
does not work, as the access to the binary is still blocked by the first cryptsetup
call. There are some workarounds for this. For example, you can create a chroot environment in /boot
, like described in this blog post (I would not recommend). There are projects using the shutdown
initrd hook that copy the content of the extracted initramfs to /run/initramfs
. There is a bash script based implementation and one written in go. Unfortunately, these implementations are not stable and sometimes lead to kernel hangs (see my Github issue), even though they stop some systemd services before luksSuspend
ing.
I ended up solving this issue by having a system with two partitions: One for / and one for /home. This is my working solution:
loginctl lock-sessions
mount -o remount,nobarrier /home
sync
cryptsetup luksSuspend home
echo mem > /sys/power/state
cryptsetup luksResume --tries 10 home
mount -o remount,barrier /home
The mount -o remount,nobarrier /home
is needed. Without the system hangs after “echo mem”. I got it from the go implementation go-luks-suspend
. More information can be found in mount’s man page.
This is how it looks like on a dirty laptop display when called with my wrapper (more on that below, ignore the “Device is not authorized” message):

Auto reboot feature for Linux laptops
GrapheneOS started to implement an Auto reboot feature:
GrapheneOS provides an auto-reboot feature which reboots locked devices after a set period of time to put data at rest. A countdown timer is started each time the device is locked, and the device will reboot if a successful unlock doesn’t occur before the timer reaches zero.
Inactivity Reboot then came to iOS 18 and finally also to normal Android devices. So it’s time to bring it to Linux laptops. The logic I use is a bit different, as we use laptops in a different way compared to smartphones.
First, I patched my screen locker i3lock
: Every time I unlock my laptop, i3lock
writes the current date to /run/user/$userid/i3/unlocked.txt
.
commit 63863697eaa9c227f21c77badc7e04dc652e1224
Author: kmille <github@androidloves.me>
Date: Sat Mar 15 21:41:25 2025 +0100
Log current time to state file after successful authentication
diff --git a/i3lock.c b/i3lock.c
index bcd6976..53e712b 100644
--- a/i3lock.c
+++ b/i3lock.c
@@ -294,6 +294,21 @@ static void input_done(void) {
DEBUG("successfully authenticated\n");
clear_password_memory();
+ FILE *fp;
+ char state_file[50];
+ time_t now = time(NULL);
+
+ sprintf(state_file, "/run/user/%d/i3/unlocked.txt", getuid());
+ fp = fopen(state_file, "w");
+ if(fp == NULL) {
+ DEBUG("Could not not open file '%s'\n", state_file);
+ } else {
+ struct tm *local_time = localtime(&now);
+ fprintf(fp, "%s", asctime(local_time));
+ fclose(fp);
+ DEBUG("successfully updated state file '%s': %s\n", state_file, asctime(local_time));
+ }
+
/* PAM credentials should be refreshed, this will for example update any kerberos tickets.
* Related to credentials pam_end() needs to be called to cleanup any temporary
* credentials like kerberos /tmp/krb5cc_pam_* files which may of been left behind if the
Then I wrote a simple daemon in go. I called it inactivityd
. It checks the modified timestamp of the monitored file. Configuration is done by environment variables (file to monitor, timeout, command to execute). If it does not change for a period of time, the daemon executes the configured command. Instead of powering off the system, I like to keep my state and use my luksSuspend
implementation. In this case the timeout is set to two hours. This presumes that you take breaks or go to the toilet often and also lock your screen (both good habits 😄). After booting the laptop, the monitored file does not exist. In this case the tool uses the uptime.
This is how it looks like when you start the daemon:
DEBUG: Enabling debug log
DEBUG: env STATE_FILE="/run/user/1001/i3/unlocked.txt"
DEBUG: env TIMEOUT="2h0m0s" (7200 seconds)
DEBUG: env COMMAND="sudo /usr/local/bin/luks-suspend-openvt-wrapper"
INFO: Successfully started
DEBUG: Checking inactivity (/run/user/1001/i3/unlocked.txt)
DEBUG: State file was modified at "2025-05-05 15:08:16.408385026 +0200 CEST"
DEBUG: Triggering command after "2025-05-05 17:08:16.408385026 +0200 CEST" (timeout 2h0m0s)
DEBUG: Now we're at "2025-05-05 15:16:45.609396789 +0200 CEST m=+0.000436884"
... (sleeping again)
You can find the source code of the whole project on Github: https://github.com/kmille/auto-reboot-linux
Auto hibernate with systemd timer
A friend of mine came up with a different solution: Use a systemd timer that wakes up the laptop every morning and hibernates.
[Unit]
Description=auto hibernate at a specific time
[Timer]
OnCalendar=*-*-* 04:00:00
Unit=systemd-hibernate.service
WakeSystem=true
Persistent=false
[Install]
WantedBy=timers.target
Appendix: Putting everything together (how to use it)
Note: This project is built for Arch Linux.
1) Patching i3lock
First, clone the repository and patch i3lock
:
git clone https://github.com/kmille/auto-reboot-linux.git
cd auto-reboot-linux/i3lock
kmille@spring:i3lock make buildInstall
makepkg --syncdeps --install
==> Making package: i3lock 2.15-3 (Wed May 7 13:44:15 2025)
...
==> Finished making: i3lock 2.15-3 (Wed May 7 13:44:20 2025)
==> Installing package i3lock with pacman -U...
loading packages...
warning: i3lock-2.15-3 is up to date
resolving dependencies...
looking for conflicting packages...
Packages (1) i3lock-2.15-3
Total Installed Size: 0.05 MiB
Net Upgrade Size: 0.00 MiB
:: Proceed with installation? [Y/n]
...
kmille@spring:i3lock make clean
rm -rf src pkg *.zst keys *.xz *.asc
This patches, builds and installs i3lock
version 2.15-3 (current upstream is 2.15-2). You may need a gpg --recv-keys 424E14D703E7C6D43D9D6F364E7160ED4AC8EE1D
before. You have to update i3lock
every time an update is available. Fortunately, this is happening not super often. I monitor these updates with nvchecker. You can use this config:
kmille@spring:i3lock cat nvchecker.toml
[i3lock]
source = "github"
github = "i3/i3lock"
use_max_tag = true
Now, let’s test if the patched version works. Run i3-lock --debug
and unlock. You will see:
...
[i3lock-debug] successfully authenticated
[i3lock-debug] successfully updated state file '/run/user/1001/i3/unlocked.txt': Wed May 7 13:50:21 2025
...
kmille@spring:i3lock cat /run/user/1001/i3/unlocked.txt
Wed May 7 13:50:21 2025
2) Building the inactivity daemon
Let’s cd daemon
and use the Makefile to build and run the daemon:
export STATE_FILE := /run/user/1001/i3/unlocked.txt
export TIMEOUT := 20s
export COMMAND := date >> /tmp/inactivity.txt
export DEBUG := 1
run:
go run ./main.go
...
kmille@spring:daemon make run
go run ./main.go
2025/05/07 13:58:03 DEBUG: Enabling debug log
2025/05/07 13:58:03 DEBUG: env STATE_FILE="/run/user/1001/i3/unlocked.txt"
2025/05/07 13:58:03 DEBUG: env TIMEOUT="20s" (20 seconds)
2025/05/07 13:58:03 DEBUG: env COMMAND="date >> /tmp/inactivity.txt"
2025/05/07 13:58:03 INFO: Successfully started
2025/05/07 13:58:03 DEBUG: Checking inactivity (/run/user/1001/i3/unlocked.txt)
2025/05/07 13:58:03 DEBUG: State file was modified at "2025-05-07 13:50:21.416868945 +0200 CEST"
2025/05/07 13:58:03 DEBUG: Triggering command after "2025-05-07 13:50:41.416868945 +0200 CEST" (timeout 20s)
2025/05/07 13:58:03 DEBUG: Now we're at "2025-05-07 13:58:03.283652172 +0200 CEST m=+0.000297479"
2025/05/07 13:58:03 INFO: Running command: "date >> /tmp/inactivity.txt"
As we didn’t lock the screen for over twenty seconds, the date command is executed (every twenty seconds):
kmille@spring:daemon cat /tmp/inactivity.txt
Mon May 7 13:58:03 CEST 2025
Mon May 7 13:58:23 CEST 2025
...
The repo also contains an inactivityd.service
to use it as systemd service. There is also a PKGBUILD
to build and install inactivityd
as Arch package. Just check the Makefile.
3) Using inactivityd with lukSuspend
I will show you my current configuration. I need to test it, so I may change some parts of it in the future:
kmille@spring:daemon sudo cat /etc/inactivity.env
DEBUG="1"
STATE_FILE="/run/user/1001/i3/unlocked.txt"
TIMEOUT="2h"
COMMAND="/usr/local/bin/luks-suspend-openvt-wrapper"
In the systemd unit I use EnvironmentFile=/etc/inactivity.env
. As trigger command, I use a wrapper:
kmille@spring:daemon cat /usr/local/bin/luks-suspend-openvt-wrapper
/usr/bin/openvt --wait --switch /usr/local/bin/luks-suspend.sh
kmille@spring:daemon cat /usr/local/bin/luks-suspend.sh
#!/usr/bin/env sh
set -x
if [[ $EUID -ne 0 ]]; then
echo "$0 must be run as root"
exit 1
fi
loginctl lock-sessions
mount -o remount,nobarrier /home
sync
cryptsetup luksSuspend home
echo mem > /sys/power/state
cryptsetup luksResume --tries 10 home
mount -o remount,barrier /home
Some explanation: /usr/bin/openvt --wait --switch
executes a command in a new virtual terminal (ctrl + alt + Fx). Try the following:
sudo /usr/bin/openvt --wait --switch bash
Why do I have an additional wrapper? Because I also have a shortcut executing the wrapper (and thus luksSuspend
ing the system). But openvt
needs root permissions. That’s why I added a sudo exception, to use it without entering a password:
root@spring:~ cat /etc/sudoers.d/luks-suspend
kmille ALL = NOPASSWD: /usr/local/bin/luks-suspend-openvt-wrapper
The use of openvt
is totally optionally, but I like it in terms of system integration. By the way, I also execute the wrapper if someone is plugging in non-whitelisted USB devices, check my fork of the silk-guardian project.