Lunatik is a framework for scripting the Linux kernel with Lua. It is composed by the Lua interpreter modified to run in the kernel; a device driver (written in Lua =)) and a command line tool to load and run scripts and manage runtime environments from the user space; a C API to load and run scripts and manage runtime environments from the kernel; and Lua APIs for binding kernel facilities to Lua scripts.
Note: Lunatik supports Linux Kernel versions 6.6 and later
Feel free to join us on Matrix.
Here is an example of a character device driver written in Lua using Lunatik to generate random ASCII printable characters:
-- /lib/modules/lua/passwd.lua
--
-- implements /dev/passwd for generate passwords
-- usage: $ sudo lunatik run passwd
-- $ head -c <width> /dev/passwd
local device = require("device")
local linux = require("linux")
local stat = require("linux.stat")
local driver = {name = "passwd", mode = stat.IRUGO}
function driver:read() -- read(2) callback
-- generate random ASCII printable characters
return string.char(linux.random(32, 126))
end
-- creates a new character device
device.new(driver)Install dependencies (here for Debian/Ubuntu, to be adapted to one's distribution):
sudo apt install git build-essential lua5.5 dwarves clang llvm libelf-dev linux-headers-$(uname -r) linux-tools-common linux-tools-$(uname -r) pkg-config libpcap-dev m4Install dependencies (here for Arch Linux):
sudo pacman -S git lua clang llvm m4 libpcap pkg-config build2 linux-tools linux-headersUbuntu packages Lua 5.5 from 26.04 and Debian from testing. Where the distribution does not carry it yet, build the interpreter from source:
curl -sSLO https://www.lua.org/ftp/lua-5.5.1.tar.gz
tar xf lua-5.5.1.tar.gz && make -C lua-5.5.1
sudo install -m 0755 lua-5.5.1/src/lua /usr/bin/lua5.5The lua-readline package is optional. When installed, the REPL gains line editing and command history:
sudo apt install lua-readline # Debian/UbuntuCompile and install lunatik:
LUNATIK_DIR=~/lunatik # to be adapted
mkdir "${LUNATIK_DIR}" ; cd "${LUNATIK_DIR}"
git clone --depth 1 --recurse-submodules https://github.com/luainkernel/lunatik.git
cd lunatik
make
sudo make installOnce done, the debian_kernel_postinst_lunatik.sh script from tools/ may be copied into
/etc/kernel/postinst.d/: this ensures lunatik (and also the xdp needed libs) will get
compiled on kernel upgrade.
Install Lunatik from our package feed.
sudo lunatik # execute Lunatik REPL
Lunatik 4.4 Copyright (C) 2023-2026 Ring Zero Desenvolvimento de Software LTDA.
> return 42 -- execute this line in the kernel
42
usage: lunatik [load|unload|reload|status|test|list] [run|spawn|stop <script>] [percpu] [compile <arguments>]load: load Lunatik kernel modulesunload: unload Lunatik kernel modulesreload: reload Lunatik kernel modulesstatus: show which Lunatik kernel modules are currently loadedtest [suite]: run installed test suites (see Testing)compile <arguments>: runlunaticwith the given arguments (see lunatic)list: show which runtime environments are currently runningrun [softirq|hardirq]: create a new runtime environment to run the script/lib/modules/lua/<script>.lua; passsoftirqfor hooks that fire in softirq context (netfilter, XDP), orhardirqfor hooks that fire in hardirq context (kprobes); optionally passpercputo create one runtime per CPU id, dispatched to the runtime of the CPU the callback runs on. The script runs once per runtime and can read its id withlunatik.cpu(); the runtimes share a netfilter hook and a kprobe, and constructors whose registration is global fail at load in a percpu runtime. A runtime is a CPU, not a connection: see percpu scriptsspawn: create a new runtime environment and spawn a thread to run the script/lib/modules/lua/<script>.luastop: stop the runtime environment created to run the script<script>default: start a REPL (Read–Eval–Print Loop)
lunatik run <script> [softirq|hardirq] percpu creates one runtime per CPU id and
dispatches a callback to the runtime of the CPU it fires on. The runtimes share the
registrations a script makes once for all of them, a netfilter hook and a kprobe, and each reads
its own id with lunatik.cpu().
A runtime is a CPU, not a connection. A netfilter hook runs in the packet's own processing path
(NF_HOOK from ip_rcv, ip_output and their peers), so the runtime is whichever CPU that path
is on, and the packets of one connection reach several. Over loopback and veth the transmit side
queues the packet to its own CPU's backlog (__netif_rx from loopback_xmit and
veth_forward_skb), which the receive softirq drains on that CPU. On a NIC it is the CPU the
queue's interrupt is bound to; with RPS, the one the queue's map picks from the flow hash, stable
while the map is; with RFS, the one where the flow's last recvmsg ran, which follows a reader
that migrates. An outbound hook reached from a sendmsg runs on the sending process's CPU, but
the same hook number also fires from the receive softirq, forwarding a packet or sending a RST for
one, and from the timer softirq on a retransmission, where it is that softirq's CPU. A kprobe
reaches the runtime of the CPU the probed call ran on.
State that must see a whole flow therefore belongs in something the runtimes share, a table
published in lunatik._ENV or the conntrack mark; the runtime holds what is per-CPU, a counter or
a cache. An rcu.table() the script body creates is not shared: the body runs once per runtime, so
each gets its own.
usage: lunatic [options] [filenames]lunatic is luac built with the host compiler from the same lua/ sources and configuration
as lunatik.ko, so its chunks match the kernel's opcode set and integer-only number format;
chunks from the distribution luac are rejected by the kernel. The options are luac's
(-l list, -o output, -p parse only, -s strip debug information, -v version) plus
-e big|little, the byte order of the target when it is not the host's, and lunatik compile runs
it with the same arguments.
A chunk is installed and run under the usual .lua name; the kernel detects it by its signature.
Several inputs make one chunk that runs them in order, as with luac, so compile one file per
call. -s drops the source name and the line numbers, so a stripped chunk reports an error as
?:?: ...; keep the full chunk while developing:
lunatik compile -o hello.luac hello.lua
sudo install -m 0644 hello.luac /lib/modules/lua/hello.lua
sudo lunatik run helloBYTECODE=1 make install installs the kernel Lua libraries and the examples as stripped chunks
instead of source, so an error raised from one of them reads ?:?:.
Install and run the test suites:
sudo make install
sudo lunatik test # run all suites
sudo lunatik test thread # run a specific suite (bpf, crypto, data, device,
# examples, fifo, fsnotify, hid, io, linux, lua, luac,
# monitor, netlink, notifier, probe, rcu, runtime,
# sched, set, skb, socket, struct, task, tc,
# thread, xdp)lunatik test reloads the modules before the run and unloads them
afterwards, so each invocation exercises the currently-installed kernel
code. See tests/README.md for the full list of suites
and individual tests.
Lunatik 4.4 is based on Lua 5.5 adapted to run in the kernel.
Lunatik does not support floating-point arithmetic,
thus it does not support __div nor __pow
metamethods
and the type number has only the subtype integer.
Lunatik does not support the os library,
floating-point arithmetic (__div, __pow), or debug.debug.
The math library is present but all floating-point functions are absent —
only integer operations are supported.
The io library is supported with the
following limitations: there are no default streams (io.stdin, io.stdout, io.stderr),
no default input/output (io.read, io.write, io.input, io.output), no process pipes
(io.popen), no temporary files (io.tmpfile), and no buffering control (file:setvbuf).
The available functions are io.open, io.lines, io.type, and the file handle methods
read, write, lines, flush, seek, and close.
On failure, error messages always read "I/O error" regardless of the underlying errno.
Lunatik modifies the following identifiers:
- _VERSION: is defined as
"Lua 5.5-kernel". - collectgarbage("count"): returns the total memory in use by Lua in bytes, instead of Kbytes.
- package.path: is defined as
"/lib/modules/lua/?.lua;/lib/modules/lua/?/init.lua". - require: only supports built-in or already linked C modules, that is, Lunatik cannot load kernel modules dynamically.
Lua APIs are documented with LDoc and can be browsed at luainkernel.github.io/lunatik.
The table below lists the available kernel Lua modules:
| Module | Description |
|---|---|
linux |
Kernel utilities: schedule, time, random, stat flags |
task |
Linux task inspection: comm, pid, tgid, prio, cpu, current |
thread |
Kernel threads: spawn, stop, shouldstop |
cpu |
CPU counts and iteration: num_online, stats, foreach_online |
socket |
Kernel sockets: TCP, UDP, AF_PACKET, AF_UNIX, in the initial network namespace or a task's |
netlink |
Netlink namespace: rtnetlink and generic-netlink sessions, in the initial network namespace or a task's; softirq-safe channel |
data |
Raw memory buffer for binary data read/write |
device |
Character device drivers |
rcu |
RCU-protected shared hash table |
set |
Compact immutable string set: exact membership, plus a suffix-matched labeled flavor |
netfilter |
Netfilter hooks: register packet processing callbacks |
skb |
Socket buffer (sk_buff): inspect and modify packets |
xdp |
XDP (eXpress Data Path) hooks |
tc |
TC (Traffic Control) hooks |
sched |
sched_ext (extensible scheduler) hooks: dispatch queue and slice from Lua; 6.12 and later, with CONFIG_SCHED_CLASS_EXT |
bpf |
Pinned eBPF map access (hash, array, LRU hash, queue, stack) |
crypto |
Kernel crypto API: hash, cipher, AEAD, RNG, compression (below 6.15) |
hid |
HID device drivers |
probe |
Kernel probes (kprobe / tracepoint) |
syscall |
System call addresses by number, for probe |
fifo |
Kernel FIFO queues |
completion |
Kernel completions: new, complete, wait |
signal |
POSIX signal management |
byteorder |
Network byte order conversions |
darken |
AES-256-CTR encrypted script execution |
lighten |
Lua interface for running encrypted scripts via darken |
notifier |
Kernel notifier chain registration |
fsnotify |
Filesystem notification: inode, mount and superblock marks, their events, and the verdict a permission event asks for |
lunatik.runner |
Run, spawn, and stop scripts from within Lua |
net |
Networking helpers |
mailbox |
Asynchronous inter-runtime messaging |
The C API allows kernel modules to create and manage Lunatik runtime environments, define new object classes, and expose kernel facilities to Lua scripts. See doc/capi.md for the full reference.
spyglass
is a kernel script that implements a keylogger inspired by the
spy kernel module.
This kernel script logs the keysym of the pressed keys in a device (/dev/spyglass).
If the keysym is a printable character, spyglass logs the keysym itself;
otherwise, it logs a mnemonic of the ASCII code, (e.g., <del> stands for 127).
The keyboard notifier fires in hardirq context, whereas the device requires
process context; spyglass splits across two runtimes
(device.lua in process and
notifier.lua in hardirq) sharing captured
chars via a fifo.
sudo make examples_install # installs examples
sudo lunatik run examples/spyglass/device # runs spyglass
sudo tail -f /dev/spyglass # prints the key log
keylocker
is a kernel script that implements
Konami Code
for locking and unlocking the console keyboard.
When the user types ↑ ↑ ↓ ↓ ← → ← → LCTRL LALT,
the keyboard will be locked; that is, the system will stop processing any key pressed
until the user types the same key sequence again.
The keyboard notifier fires in hardirq context, so keylocker must run in a
hardirq runtime (passed as the third argument to lunatik run).
sudo make examples_install # installs examples
sudo lunatik run examples/keylocker hardirq # runs keylocker
<↑> <↑> <↓> <↓> <←> <→> <←> <→> <LCTRL> <LALT> # locks keyboard
<↑> <↑> <↓> <↓> <←> <→> <←> <→> <LCTRL> <LALT> # unlocks keyboard
tap
is a kernel script that implements a sniffer using AF_PACKET socket.
It prints destination and source MAC addresses followed by Ethernet type and the frame size.
sudo make examples_install # installs examples
sudo lunatik run examples/tap # runs tap
cat /dev/tap
shared is a kernel script that implements an in-memory key-value store using rcu, data, socket and thread.
sudo make examples_install # installs examples
sudo lunatik spawn examples/shared # spawns shared
nc 127.0.0.1 90 # connects to shared
foo=bar # assigns "bar" to foo
foo # retrieves foo
bar
nokey # retrieves a key that was never assigned
# answers with an empty line
^C # finishes the connection
echod is an echo server implemented as kernel scripts.
sudo make examples_install # installs examples
sudo lunatik spawn examples/echod/daemon # runs echod
nc 127.0.0.1 1337
hello kernel!
hello kernel!
systrack
is a kernel script that uses kprobes to count every system call on the
running architecture. systrack/device
exposes the live counters as a character device readable with cat.
The device runtime creates the probe runtime via runner.run, passing the
RCU counter table via runtime:resume(). Stopping the device runtime also
stops the probe runtime.
sudo make examples_install # installs examples
sudo lunatik run examples/systrack/device # starts device and probe runtimes
cat /dev/systrack
close: 473
openat: 515
read: 1066
write: 438
sudo lunatik stop examples/systrack/device # stops device and probe runtimes
dropreason answers "why is my packet dying?":
a kprobe on the out-of-line drop path reads the drop reason off the probed
function's arguments and counts the drops that reach it by name
(linux.dropreason) in an RCU table published on the shared environment
(lunatik._ENV). That path is sk_skb_reason_drop(sk, skb, reason) from v6.11,
where kfree_skb_reason(skb, reason) became a static inline over it, so the
script probes whichever symbol linux.lookup() finds and reads the reason from
the argument that goes with it; when neither exists, it names both instead of
failing as a registration error. A drop freed from hardirq
(dev_kfree_skb_any()) or as a segment list (kfree_skb_list()) takes another
path and is not counted.
report reads those counts live from the REPL.
The script logs the symbol it settled on to dmesg as it arms, and the first
drop matching WATCH also has its registers and call trace dumped there, which
is what names the drop site.
sudo make examples_install # installs examples
sudo lunatik run examples/dropreason/monitor hardirq # arms the kprobe
echo x > /dev/udp/127.0.0.1/9999 # trigger a NO_SOCKET drop
sudo lunatik # opens the kernel REPL
> drops = require("examples.dropreason.report")
> drops.NO_SOCKET
1
> drops.report() # counts by reason
1 NO_SOCKET
6 TCP_OLD_DATA
152 NOT_SPECIFIED
sudo lunatik stop examples/dropreason/monitor
netfailover reroutes in reaction to a link: when the
watched interface (dummy0) goes down, a backup route to 192.0.2.1 is installed
in table 200 with netlink.rt, and removed when the link comes back up; each
change is announced on the netfailover family of a netlink.channel.
control owns notifier.netdevice, whose
callback runs under RTNL, where a netlink.rt request is refused, so it only
records the link state in an rcu.table; reactor,
a spawned thread, polls that table and reprograms the route. They are two
runtimes because one would deadlock: the reactor holding the runtime lock while
it waits for RTNL, as the callback holds RTNL waiting for the runtime lock.
sudo make examples_install # installs examples
sudo ip link add dummy0 type dummy && sudo ip link set dummy0 up
sudo lunatik run examples/netfailover/control # records the link state
sudo lunatik spawn examples/netfailover/reactor # reroutes on it
sudo ip link set dummy0 down # installs the backup route
ip route show table 200
192.0.2.1 dev lo proto static scope link
sudo ip link set dummy0 up # removes it
sudo lunatik stop examples/netfailover/reactor
sudo lunatik stop examples/netfailover/control
sudo ip link del dummy0
ifquarantine composes two notifier chains to build
an interface-level default-deny policy: every new network interface
(NETDEV_REGISTER) is automatically added to a shared RCU set whose contents
a netfilter hook uses to decide the verdict on each packet. An interface is
released from quarantine by writing allow=<name> to /dev/ifquarantine;
re-denied with deny=<name>; inspected with cat /dev/ifquarantine.
The control runtime (process context) owns notifier.netdevice and the
device; it runs the netfilter hook as a softirq percpu script, one runtime
per CPU sharing the hook, and hands each the quarantine set via rcu.table
through percpu:resume(). Illustrates cross-subsystem composition between
two notifier chains of different execution contexts.
sudo make examples_install # installs examples
sudo lunatik run examples/ifquarantine/control # starts control+filter
sudo cat /dev/ifquarantine # lists known interfaces and verdict
sudo sh -c "echo 'deny=eth0' > /dev/ifquarantine" # quarantine eth0
sudo sh -c "echo 'allow=eth0' > /dev/ifquarantine" # lift the quarantine
sudo lunatik stop examples/ifquarantine/control # stops both scripts
The interfaces that already exist are recorded and allowed, not quarantined:
register_netdevice_notifier synchronously replays NETDEV_REGISTER (and
NETDEV_UP) for each netdev the initial namespace already has when the notifier
block is registered, inside notifier.netdevice, so the script records what
arrives before that call returns, and a policy that denied those would take the
machine off the network, lo and the uplink included. They are listed by cat /dev/ifquarantine and can be quarantined with deny=<name>.
linkflap detects an interface that flaps: a
notifier.netdevice callback keeps each interface's UP and DOWN transitions of
the last 10 seconds, and once one interface reaches 5 it multicasts a flapping
event, the interface name and the transition count as generic netlink
attributes, on the linkflap family of a netlink.channel.
subscriber joins that family's multicast group
from userspace and prints each event. The callback runs holding RTNL, which a
multicast does not take, so one runtime does both. Registering the notifier
replays an UP for each interface that already exists, one transition each.
sudo make examples_install # installs examples
sudo lunatik run examples/linkflap/watch # arms the notifier
cc -O2 examples/linkflap/subscriber.c -o linkflap-sub # builds the subscriber
GRP=$(genl ctrl get name linkflap | grep -oiE 'ID-0x[0-9a-f]+' | sed 's/^ID-//i')
sudo ./linkflap-sub "$GRP" & # prints each event
sudo ip link add dummy0 type dummy
for i in 1 2 3; do sudo ip link set dummy0 up; sudo ip link set dummy0 down; done
linkflap: dummy0 flapping (5 transitions)
sudo ip link del dummy0
sudo lunatik stop examples/linkflap/watch
filter is a kernel extension composed by a XDP/eBPF program to filter HTTPS sessions and a Lua kernel script to filter SNI TLS extension. This kernel extension drops any HTTPS request destinated to a blacklisted server.
Usage requires libbpf and bpftool installed.
Come back to this repository, install and load the filter:
cd ${LUNATIK_DIR}/lunatik # cf. above
sudo make btf_install # needed to export the 'bpf_luaxdp_run' kfunc
sudo make examples_install # installs examples
make ebpf # builds the XDP/eBPF program
sudo make ebpf_install # installs the XDP/eBPF program
# Run the Lua kernel script, one runtime per CPU
sudo lunatik run examples/filter/sni softirq percpu
# Load the compiled XDP/eBPF program and attach to interface <ifname>
sudo bpftool prog load examples/filter/https.o /sys/fs/bpf/lunatik_filter type xdp
sudo bpftool net attach xdp pinned /sys/fs/bpf/lunatik_filter dev <ifname>For example, testing is easy thanks to docker. Assuming docker is installed and running:
- in a terminal:
sudo bpftool prog load example/filter/https.o /sys/fs/bpf/lunatik_filter type xdp
sudo bpftool net attach xdp pinned /sys/fs/bpf/lunatik_filter dev docker0
sudo journalctl -ft kernel- in another one:
docker run --rm -it alpine/curl https://ebpf.ioThe system logs (in the first terminal) should display filter_sni: ebpf.io DROP, and the
docker run… should return curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to ebpf.io:443.
This other sni filter uses netfilter api.
dnsblock is a kernel script that uses the netfilter framework (luanetfilter) to filter DNS packets.
This script drops any outbound DNS packet with question matching the blacklist provided by the user. By default, it will block DNS resolutions for the domains github.com and gitlab.com.
sudo make examples_install # installs examples
sudo lunatik run examples/dnsblock/nf_dnsblock softirq # runs the Lua kernel script
sudo lunatik run examples/dnsblock/nf_dnsblock softirq percpu # or one runtime per CPU, sharing the hook
dnsdoctor is a kernel script that uses the netfilter framework (luanetfilter) to change the DNS response
from Public IP to a Private IP if the destination IP matches the one provided by the user. For example, if the user
wants to change the DNS response from 192.168.10.1 to 10.1.2.3 for the domain lunatik.com if the query is being sent to 10.1.1.2 (a private client), this script can be used.
sudo make examples_install # installs examples
examples/dnsdoctor/setup.sh # sets up the environment
# test the setup, a response with IP 192.168.10.1 should be returned
dig lunatik.com
# run the Lua kernel script
sudo lunatik run examples/dnsdoctor/nf_dnsdoctor softirq
sudo lunatik run examples/dnsdoctor/nf_dnsdoctor softirq percpu # or one runtime per CPU, sharing the hook
# test the setup, a response with IP 10.1.2.3 should be returned
dig lunatik.com
# cleanup
sudo lunatik unload
examples/dnsdoctor/cleanup.sh
tcpreject is a kernel script that uses the netfilter framework (luanetfilter) and the socket buffer API (luaskb) to inject a TCP RST toward the origin of forwarded packets.
It intercepts packets marked by an nft rule, builds a RST+ACK by
copying the original packet, inverting IP, MAC, and port addresses,
trimming the payload, and recomputing the checksums.
It supports both IPv4 and IPv6.
By default, it rejects forwarded HTTPS (TCP/443) connections to 8.8.8.8 (IPv4)
and 2001:4860:4860::8888 (IPv6).
sudo make examples_install # installs examples
sudo examples/tcpreject/setup.sh # sets up namespace, nft mark rule, and loads the hook
# connection is reset immediately (IPv4)
ip netns exec tcpreject curl --connect-timeout 2 https://8.8.8.8
# connection is reset immediately (IPv6)
ip netns exec tcpreject curl --connect-timeout 2 https://[2001:4860:4860::8888]
# cleanup
sudo examples/tcpreject/cleanup.sh
sniclassify is a kernel extension composed by a TC/eBPF classifier program attached on egress, a Lua kernel script to classify SNI traffic. This kernel extension extracts server name and assigns traffic classes according to a Lua policy table.
Install the classifier:
sudo make btf_install # needed to export the 'bpf_luatc_run' kfunc
sudo make examples_install # installs examples
make ebpf # builds the TC/eBPF program
sudo make ebpf_install # installs the TC/eBPF programRun the classifier and set up the HTB classes on an interface:
sudo ./examples/sniclassify/setup.sh eth0Tear it down with:
sudo ./examples/sniclassify/cleanup.sh eth0The classifier inspects outbound TLS ClientHello packets, extracts the SNI field, and assigns a traffic class according to the Lua policy table.
Verify and test:
sudo tc filter show dev eth0
sudo journalctl -ft kernel
gesture is a kernel script that implements a HID driver for QEMU USB Mouse (0627:0001). It supports gestures: swiping right locks the mouse, and swiping left unlocks it.
- You need to change the display protocal into
VNCand enable USB mouse device in QEMU, the following configuration can help you disable PS2 mouse & enable USB mouse:
<features>
<!-- ... -->
<ps2 state="off"/>
<!-- ... -->
</features>
- run the gesture script:
sudo make examples_install # installs examples
sudo lunatik run examples/gesture softirq # runs gesture
# In QEMU window:
# Drag right to lock the mouse
# Drag left to unlock the mouse
xiaomi
is a kernel script that ports the Xiaomi Silent Mouse driver to Lua using luahid.
It fixes the report descriptor for the device (0x2717:0x5014).
sudo make examples_install # installs examples
sudo lunatik run examples/xiaomi softirq # runs xiaomi driver
Then insert the Xiaomi Silent Mouse with bluetooth mode on and it should work properly.
lldpd shows how to implement a simple LLDP transmitter in kernel space using Lunatik. It periodically emits LLDP frames on a given interface using an AF_PACKET socket.
sudo make examples_install # installs examples
# the LLDP daemon sends frames on a single Ethernet interface
# you may use an existing interface, or create a virtual one for testing
# create a veth pair (the example uses veth0 by default)
ip link add veth0 type veth peer name veth1
ip link set veth0 up
ip link set veth1 up
sudo lunatik spawn examples/lldpd # runs lldpd
# verify LLDP frames are being transmitted
sudo tcpdump -i veth0 -e ether proto 0x88cc -vv
cpuexporter will gather CPU usage statistics and expose using OpenMetrics text format at a UNIX socket file.
sudo make examples_install # installs examples
sudo lunatik spawn examples/cpuexporter # runs cpuexporter
sudo socat - UNIX-CONNECT:/tmp/cpuexporter.sock <<<""
# TYPE cpu_usage_system gauge
cpu_usage_system{cpu="cpu1"} 0.0000000000000000 1764094519529162
cpu_usage_system{cpu="cpu0"} 0.0000000000000000 1764094519529162
# TYPE cpu_usage_idle gauge
cpu_usage_idle{cpu="cpu1"} 100.0000000000000000 1764094519529162
cpu_usage_idle{cpu="cpu0"} 100.0000000000000000 1764094519529162
...fsmonitor uses the fsnotify module to log what changes in one directory: an
entry created or deleted, a file written or its attributes changed, each line carrying the entry name,
its inode number and the pid that did it.
The mark is an inode mark on the directory WATCHED names, carrying EVENT_ON_CHILD so that events on
the files inside it are reported too. That flag is one level deep: nothing under a subdirectory arrives.
It also reaches a file only through its parent in the directory cache, so a write to a file opened by handle
with open_by_handle_at after the cache dropped its entry, or a change to its attributes, is not reported.
sudo make examples_install # installs examples
mkdir -p /tmp/lunatik-fsmonitor # the directory it watches
sudo lunatik run examples/fsmonitor # runs fsmonitor
touch /tmp/lunatik-fsmonitor/file
echo data > /tmp/lunatik-fsmonitor/file
rm /tmp/lunatik-fsmonitor/file
sudo lunatik stop examples/fsmonitor # stops fsmonitor
sudo dmesg -t # prints what it logged
fsmonitor: created file ino 13862 pid 2222346
fsmonitor: attributes file ino 13862 pid 2222346
fsmonitor: modified file ino 13862 pid 2222341
fsmonitor: modified file ino 13862 pid 2222341
fsmonitor: deleted file ino 13862 pid 2222347
The shell's redirection truncates the file on open and then writes it, so one command logs two
modifications; an event on the directory itself, its own chmod or touch, carries no entry name and
prints ?.
execguard is an allowlist for exec over one directory: a permission event
parks the execve inside the callback, which refuses it unless the entry's name is in the set it was
built with.
A second list names who may run them: when the scope holds a file pids as the script starts, one pid per
line, the exec is refused to every pid it does not name. That pid is the one of the thread calling execve,
which after a fork is the child's: a shell the list names runs a program there only with exec, which
keeps its pid. Each refusal is logged with its reason, not in the allowlist or pid not allowed.
The mark is an inode mark on SCOPE carrying EVENT_ON_CHILD, so the only exec it can refuse is of an
entry directly inside that directory. It is never a system wide default deny: a "mount" or "sb" mark
reaches every file of a mount or of a whole filesystem, and a rule that denies there leaves the machine
unable to run the programs that would undo it. Give it a scratch mount of its own, as below, so that the
umount ends the rule even if the script cannot be stopped.
An exec opens more than the program for exec. Inside execve the kernel opens a script's interpreter, the
path on its #! line, and an ELF program's loader, the one absolute path ldd prints without =>, the
same way, and each asks the rule under its own name: a script whose interpreter lives in the scope is
refused unless that name is in the allowlist too. strace -e openat shows none of those opens; what it
shows, the loader reading its cache and the libraries and an interpreter reading its script, are reads that
never reach the rule. Landlock asks for its execute right at the same opens, so the programs, their loaders
and their interpreters are the list a Landlock ruleset grants it on as well. That is also why the rule stays
on a directory of its own: on the one holding sh or the loader, every script or every dynamically linked
program on the machine would have to pass the allowlist.
EVENT_ON_CHILD reaches the entry through its parent in the directory cache, so a program opened by handle
with open_by_handle_at after the cache dropped its entry, and run with execveat and AT_EMPTY_PATH, is
never asked about. That takes CAP_DAC_READ_SEARCH, and a filesystem that drops entries: the tmpfs below
keeps every entry it holds in the cache.
On a kernel built without CONFIG_FANOTIFY_ACCESS_PERMISSIONS, which has no permission events, the script
still loads: it says so in the log and guards nothing.
sudo make examples_install # installs examples
sudo mkdir -p -m 0755 /tmp/lunatik-execguard
sudo mount -t tmpfs -o size=1M,mode=0755 lunatik-execguard /tmp/lunatik-execguard
sudo cp /bin/true /bin/date /tmp/lunatik-execguard/
sudo lunatik run examples/execguard # runs execguard
/tmp/lunatik-execguard/true # "true" is in the allowlist: it runs
/tmp/lunatik-execguard/date # "date" is not
bash: /tmp/lunatik-execguard/date: Operation not permitted
sudo lunatik stop examples/execguard # ends the rule
sudo umount /tmp/lunatik-execguard # and takes the mark with it
sudo dmesg -t # prints what it refused
execguard: denied date to pid 2222403: not in the allowlist
With a pid list:
sudo mount -t tmpfs -o size=1M,mode=0755 lunatik-execguard /tmp/lunatik-execguard
sudo cp /bin/true /tmp/lunatik-execguard/
bash # a shell for the list to name
echo $$ | sudo tee /tmp/lunatik-execguard/pids
sudo lunatik run examples/execguard # reads the list as it starts
/tmp/lunatik-execguard/true # the child the shell forks has a pid of its own
bash: /tmp/lunatik-execguard/true: Operation not permitted
exec /tmp/lunatik-execguard/true # runs in the listed pid, and ends that shell
sudo lunatik stop examples/execguard
sudo umount /tmp/lunatik-execguard
sudo dmesg -t
execguard: denied true to pid 2222510: pid not allowed
- Scripting the Linux Routing Table with Lua — Netdev 0x17 (2023)
- Linux Network Scripting with Lua — Netdev 0x14 (2020)
- Lua no Núcleo — Lua Workshop 2023, PUC-Rio (Portuguese)
- Scriptable Operating Systems with Lua — DLS 2014
- Lua in Kernel — Opportunity Open Source Conference 2024
- From the Kernel to the Moon: A Journey into Lunatik Bindings — Medium (2025)
- Is eBPF driving you crazy? Let it run Lunatik instead! — Medium (2024)
- Lua in the kernel? — LWN.net (2020)
- LuaHID — LabLua (2025), Jieming Zhou
- Lunatik binding for Netfilter — LabLua (2024), Mohammad Shehar Yaar Tausif
- Lua hook on kTLS — LabLua (2020), Xinzhe Wang
- Lunatik States Management — LabLua (2020), Matheus Rodrigues
- XDP Lua — LabLua (2019), Victor Nogueira
- RCU binding for Lunatik — LabLua (2018), Caio Messias
- Lunatik Socket Library — LabLua (2018), Chengzhi Tan
- Port Lua Test Suite to the NetBSD Kernel — LabLua (2015), Guilherme Salazar
- Lua scripting in the NetBSD kernel — The NetBSD Foundation (2010), Lourival Vieira Neto
Conventions for contributors, and for AI coding assistants working on this repository, are in AGENTS.md: build and test loop, execution contexts, object model, code style, and commit discipline. Design notes for work in progress live under doc/design; what is being worked on, and how far along it is, lives in the project boards.
Lunatik is dual-licensed under MIT or GPL-2.0-only.
Lua submodule is licensed under MIT. For more details, see its Copyright Notice.
Klibc submodule is dual-licensed under BSD 3-Clause or GPL-2.0-only. For more details, see its LICENCE file.