Skip to content

Instantly share code, notes, and snippets.

@sneaky-potato
Last active March 19, 2026 19:22
Show Gist options
  • Select an option

  • Save sneaky-potato/e0ee79bf9c4606f0b94d2100f8ee23b3 to your computer and use it in GitHub Desktop.

Select an option

Save sneaky-potato/e0ee79bf9c4606f0b94d2100f8ee23b3 to your computer and use it in GitHub Desktop.
This is a design document for adding support for eBPF abstraction layer for Lunatik

Lunatik eBPF Abstraction Layer

Lunatik has support for XDP through its xdp module

After studying the Lunatik codebase and the existing luaxdp binding, I spent time surveying the Linux Traffic Control subsystem (here), eBPF program types, and the limitations of the BPF verifier to understand what the right problem to solve actually was.

LuaXDP uses eBPF as a layer to integrate Lua callbacks (reference here). TC would use something like that only. The reason being: XDP does not allow Linux kernel modules to be called directly. At present it is only extensible through eBPF. So an eBPF layer is essential for now.

Same story goes for TC. Since an eBPF layer cannot be eliminated completely and a lot of code from luaxdp would be used in this binding as well.

That process led me to a conclusion: the value of new bindings is combinatory, not linear. Each binding added to Lunatik multiplies the scripting surface available. luatc alone is not the point.

A generic, reusable lunatik_bpf_run() layer that makes every future binding easier will be a better value addition to Lunatik ecosystem.

This paired with an API to interact with eBPF maps will complete this project. The goal is to make Lunatik compatible with eBPF programs so we can script the kernel in more ways.


Project Description

The Problem With eBPF Alone

eBPF has become the dominant mechanism for extending the Linux kernel at runtime. It is fast, safe (verifier-enforced), and increasingly expressive. But it has a fundamental constraint: the verifier bounds what you can express. No dynamic string operations. No pattern matching. No growable data structures. No hot-swap of program logic without a full reload.

It is an architectural property of eBPF. Complex policy logic, the kind that operators actually need does not fit this model.

Enter Lua. Lua is small, embeddable, and designed for exactly this role: a scripting language that extends a host system without replacing it. Lua is often a glue language for the exisiting architecture. eBPF and Lua are not competitors, we can make them complementary tools for different parts of the same problem.

Idea: eBPF as Structure, Lua as Policy

The design philosophy of this project follows the pattern established by luaxdp:

flowchart TD

    NIC[NIC / Packet Source]

    subgraph eBPF Layer
        XDP[XDP Hook]
        BPF[eBPF Program]
        KFUNC[bpf_luaxdp_run kfunc]
    end

    subgraph Lunatik Layer
        LUNATIK[Lunatik Runtime]
        LUA[Lua Policy Handler]
    end

    NIC --> XDP
    XDP --> BPF
    BPF --> KFUNC
    KFUNC --> LUNATIK
    LUNATIK --> LUA

    LUA -->|verdict| LUNATIK
    LUNATIK -->|return| KFUNC
    KFUNC --> BPF
    BPF --> XDP
Loading
  • eBPF programs define the points of interest: fast-path, packet parsing, the conditions under which policy logic is needed.
  • bpf_luaxdp_run() is the weaving point: execution suspends, control passes to Lua, a verdict is returned, execution resumes.
  • Lua scripts define the policy: string matching, pattern tables, dynamic rules, hot-reloadable without recompiling or reloading any BPF program.

eBPF = structure + safe hooks

Lua = dynamic policy engine

Currently, luaxdp implements this pattern but it is tightly coupled to XDP. The kfunc bpf_luaxdp_run() is registered only for BPF_PROG_TYPE_XDP and receives an xdp_md context.

This project aims to generalize that design.


Core Components

Generic lunatik_bpf_run() Layer

Since eBPF programs cannot call arbitrary kernel functions, the Linux kernel provides kfuncs as a mechanism for exposing functionality to BPF programs. These functions are registered with the BPF subsystem using BTF-based kfunc registration, which allows the verifier to enforce safety constraints.

The eBPF docs mention a list of kfuncs here: https://docs.ebpf.io/linux/kfuncs/, This could provide with inspiration for future kfunc support by Lunatik.

Kfunc registration

__bpf_kfunc int bpf_luaxdp_run(struct xdp_md *ctx, ...);
__bpf_kfunc int bpf_luatc_run(struct __sk_buff *skb, ...);

The functions are then registered with the BPF subsystem.

BTF_KFUNCS_START(bpf_luatc_set)
BTF_ID_FLAGS(func, bpf_luatc_run)
BTF_KFUNCS_END(bpf_luatc_set)

Finally, during module initialization, the kfunc set is registered with the kernel for the relevant BPF program type:

register_btf_kfunc_id_set(
    BPF_PROG_TYPE_SCHED_CLS,
    &bpf_luatc_kfunc_set
);

This restricts them to specific program types such as BPF_PROG_TYPE_SCHED_CLS.

Generic interface

Shared internal function that any type-specific kfunc can call:

typedef int (*lunatik_bpf_cb)(lua_State *L, void *ctx);

int lunatik_bpf_run(const char *runtime_name, lunatik_bpf_cb push_ctx, void *ctx);

This function:

  1. Looks up the named Lunatik runtime
  2. Calls push_ctx(L, ctx) to push the context onto the Lua stack as a typed userdata
  3. Invokes the registered Lua handler
  4. Reads and returns the verdict

Type-specific kfuncs then become thin wrappers:

// bpf_luaxdp_run will get refactored, behaviour unchanged
__bpf_kfunc int bpf_luaxdp_run(struct xdp_md *ctx, ...)
{
    return lunatik_bpf_run(runtime, luaxdp_push_ctx, ctx);
}

// bpf_luatc_run will be a new addition
__bpf_kfunc int bpf_luatc_run(struct __sk_buff *skb, ...)
{
    return lunatik_bpf_run(runtime, luatc_push_ctx, skb);
}

The runtime parameter allows multiple Lua runtimes to coexist, enabling different TC classifiers to delegate processing to different Lua handlers.

Each kfunc is registered for its specific bpf_prog_type via BTF_KFUNCS_START / BTF_KFUNCS_END, maintaining verifier safety.

Traffic Control Binding (luatc)

Traffic Control is the primary demonstration of the generic layer. TC operates on __sk_buff and is the place in the kernel with several capabilities:

  • tc_classid: direct assignment to HTB qdisc classes for traffic shaping
  • tstamp: packet transmit timestamp for pacing
  • priority, tc_index, mark: classification metadata

None of these are available at XDP. TC is where shaping decisions are made. Lua is where complex policy logic: string matching, pattern tables, dynamic rules, is expressed.

We can extend the recently merged luaskb module which provides an abstraction to __sk_buff data structure:

skb.mark         -- skb->mark           (r/w)
skb.priority     -- skb->priority       (r/w)
skb.tc_index     -- skb->tc_index       (r/w)
skb.tc_classid   -- skb->tc_classid     (r/w)
skb.protocol     -- skb->protocol       (r)
skb.tstamp       -- skb->tstamp         (r/w)
local tc = require("tc")

tc.action.OK          -- TC_ACT_OK         = 0
tc.action.RECLASSIFY  -- TC_ACT_RECLASSIFY = 1
tc.action.SHOT        -- TC_ACT_SHOT       = 2
tc.action.PIPE        -- TC_ACT_PIPE       = 3
tc.action.STOLEN      -- TC_ACT_STOLEN     = 4
tc.action.REDIRECT    -- TC_ACT_REDIRECT   = 7

tc.attach(handler)    -- register Lua callback
tc.detach()           -- unregister

eBPF Maps Module

To fully use the bpf ecosystem via Lunatik, we need access to shared kernel state. Hence support for eBPF maps module for Lunatik is important, this will allow Lunatik to have:

  • Stateful policies: a Lua handler writes {ip -> classid} to a map; the eBPF fast path reads it
  • Cross-binding composition: luaxdp writes to a map that luatc reads, enabling pipelines that span subsystems
  • Operator visibility: Lua scripts read kernel maps to inspect state without a separate userspace tool
local map = require("ebpf.map")

local flow_table = map.open("/sys/fs/bpf/flow_cache")
local classid = flow_table:lookup(dst_ip)
flow_table:update(src_ip, new_classid)
flow_table:delete(stale_ip)

eBPF maps are created via bpf(BPF_MAP_CREATE, ...) and accessed via bpf_map_lookup_elem, bpf_map_update_elem, and bpf_map_delete_elem helpers. We want to access these via kernel's internal map API without going through syscall interface.

The following APIs are exposed by kernel to use:

bpf_map_get will need the file descriptor which needs to be created via a process context.

Map Creation

This design requires bpf maps to be created outside Lunatik, via userspace programs like bpftool.

bpftool map create /sys/fs/bpf/flow_cache type hash key 4 value 4 entries 128 name flow_cache

Map Lookup and usage

Map resolution happens once during script initialization via open(path), which runs in process context inside driver:write(). The resolved struct bpf_map* is stored in a Lua userdata and reused by all subsequent lookup/update/delete calls. These are safe to call from softirq.

bpffs intentionally blocks filp_open on pinned objects, bpffs_obj_open unconditionally returns -EIO. Instead we use kern_path to resolve the dentry without triggering the open handler, then read inode->i_private directly where bpffs stores the struct bpf_map*. This has been verified via a kernel module POC.

static int luabpf_map_open(lua_State *L)
{
    const char *path = luaL_checkstring(L, 1);
    struct path kpath;
    struct bpf_map *map;
    struct bpf_map **udata;
    int err;

    err = kern_path(path, LOOKUP_FOLLOW, &kpath);
    if (err)
        return luaL_error(L, "kern_path failed: %d", err);

    map = d_inode(kpath.dentry)->i_private;
    path_put(&kpath);

    if (!map || IS_ERR(map))
        return luaL_error(L, "not a valid bpf map path");

    bpf_map_inc(map); // increase reference counter

    udata = lua_newuserdata(L, sizeof(struct bpf_map *));
    *udata = map;
    luaL_setmetatable(L, "bpf.map");
    return 1;
}
  • lookup(key) on the map pointer will return the value stored in the map for the provided key. We can reuse luadata for the actual types.
  • update(key, data) on the map pointer will update the map for the provided key with the given data.
  • delete(key) on the map pointer will delete the key from the map.
  • Once we have the pointer to struct bpf_map, we could call kernel ops helpers defined here to do lookup, update, delete.

Cleanup

First Packet Classification

  1. Setup TC
tc qdisc del dev eth0 root 2>/dev/null
tc qdisc add dev eth0 root handle 1: htb default 30

tc class add dev eth0 parent 1: classid 1:10 htb rate 20mbit  # realtime
tc class add dev eth0 parent 1: classid 1:20 htb rate 10mbit  # streaming
tc class add dev eth0 parent 1: classid 1:30 htb rate 5mbit   # bulk/default

tc filter add dev eth0 ingress bpf da obj tc.bpf.o sec classifier
  1. eBPF program
extern int bpf_luatc_run(char *key, size_t key__sz, struct __sk_buff *skb, void *arg, size_t arg__sz) __ksym;

SEC("tc")
int tls_classifier(struct __sk_buff *skb)
{
    struct flow_key key = extract_key(skb);
    // key could be made from src_ip, dst_ip, src_port, dst_port, ip_proto
    // We could also timestamp the cache entried to handle TCP port reuse.

    __u32 *classid = bpf_map_lookup_elem(&flow_cache, &key);
    if (classid) {
        skb->tc_classid = *classid;
        return TC_ACT_OK;
    }

    // only invoke Lua if this looks like a TLS ClientHello
    if (!is_tls_client_hello(skb))
        return TC_ACT_OK;

    return bpf_luatc_run(runtime, sizeof(runtime), skb, NULL, 0);
}
  1. Lua policy handler
local tc  = require("tc")
local map = require("ebpf.map")

local cache = map.open_by_id(FLOW_CACHE_ID)

local policy = {
    ["netflix%.com$"]       = 0x00010030,  -- bulk
    ["meet%.google%.com$"]  = 0x00010010,  -- realtime
    ["%.zoom%.us$"]         = 0x00010010,  -- realtime
    ["%.backup%.internal$"] = 0x00010040,  -- background
}

local function parse_sni(p)
    -- get sni from the packet
end

local function is_tls_hello(p)
    -- check if the packet is TLS hello
end

local function handler(p)
    if not is_tls_hello(p) then return tc.action.OK end

    local sni = parse_sni(p)
    if not sni then return tc.action.OK end

    for pattern, classid in pairs(policy) do
        if sni:match(pattern) then
            cache:update(p:flow_key(), classid)
            p.tc_classid = classid
            return tc.action.OK
        end
    end

    return tc.action.OK
end

tc.attach(handler)

Benchmarks and Evaluation

I have tried writing a TC implementation similar to XDP in sneaky-potato/luatc branch of the project. Link here

I tested two scenarios: pure eBPF program emitting TC_ACT_OK for each packet and one eBPF program calling bpf_tc_run which returns tc.action.OK for each packet.

I attached the following kprobe to measure latency.

sudo bpftrace -e '
kprobe:tcf_classify { @start[tid] = nsecs; }
kretprobe:tcf_classify {
    if (@start[tid]) {
        @lat = hist(nsecs - @start[tid]);
        delete(@start[tid]);
    }
}'

The results:

Metric Percentile eBPF eBPF + Lua TC
p50 ~3µs ~6µs
p90 ~6µs ~12µs
p99 ~20µs ~40µs

Pure eBPF path

@lat:
[128, 256)            36 |                                                    |
[256, 512)           125 |@@                                                  |
[512, 1K)            251 |@@@@@                                               |
[1K, 2K)             797 |@@@@@@@@@@@@@@@@                                    |
[2K, 4K)            2500 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[4K, 8K)            1737 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@                |
[8K, 16K)            255 |@@@@@                                               |
[16K, 32K)            60 |@                                                   |
[32K, 64K)            20 |                                                    |

eBPF classifier calling bpf_luatc_run

@lat:
[512, 1K)              8 |                                                    |
[1K, 2K)             118 |@@                                                  |
[2K, 4K)             270 |@@@@@@                                              |
[4K, 8K)            2216 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[8K, 16K)           1655 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@              |
[16K, 32K)           278 |@@@@@@                                              |
[32K, 64K)            13 |                                                    |
[64K, 128K)            1 |                                                    |

This hints to use Lua only when required, on slow paths.


Future Subsystems That Benefit Beyond TC

The generic lunatik_bpf_run() layer enables future bindings across the eBPF program type space. The most compelling candidates, in priority order:

BPF_PROG_TYPE_CGROUP_SKB: the strongest next candidate after TC. cgroup_skb programs receive a __sk_buff context identical to TC, operating at the cgroup boundary on ingress and egress. The use case is per-container bandwidth enforcement with Lua policy.

BPF_PROG_TYPE_KPROBE: attaches Lua callbacks to arbitrary kernel function entry/return points. This extends Lunatik from a networking scripting tool to a general kernel scripting tool. Write Lua functions that fire on scheduler events, file system calls, or memory allocation events and apply dynamic policy.

BPF_PROG_TYPE_TRACEPOINT: attaches Lua callbacks to pre-defined kernel tracepoints. Enables scriptable kernel observability: a Lua script processes tracing events dynamically, performing aggregation or filtering that would otherwise require recompiling a BPF program.

BPF_PROG_TYPE_CGROUP_SOCK_ADDR: triggered when a process calls bind or connect. With Lua: dynamic connection policy per container, with pattern matching on addresses and ports, hot-reloadable without kernel changes.

Each of these is a future binding, not in scope for this GSoC project. But the generic lunatik_bpf_run() layer makes each of them straightforward to add. This is precisely the combinatory effect that justifies the infrastructure investment.


Expected Results

At the end of this project the Lunatik ecosystem will gain:

  • A reusable lunatik_bpf_run() abstraction layer
  • A new luatc binding for Traffic Control
  • A Lua module for interacting with eBPF maps
  • Documentation and working examples demonstrating the system

This will significantly expand Lunatik's ability to script kernel behavior using Lua.

Project Timeline

Period Milestones
Community Bonding Agree on the lunatik_bpf_run callback interface with mentors, set up a test VM with BTF-enabled kernel
Week 1-2 Implement core lunatik_bpf_run() infrastructure and refactor the existing bpf_luaxdp_run() implementation to use it
Week 3-5 Implement luatc binding and expose __sk_buff context to Lua
Week 6-9 Implement eBPF maps Lua module and kernel map wrappers
Week 10-11 Demo scripts and benchmarks of TC eBPF + Lua
Week 12 Polish, code cleanup, final evaluation

Midterm Deliverables (After 5 Weeks)

By the midterm evaluation:

  • Generic lunatik_bpf_run() layer implemented
  • bpf_luaxdp_run() refactored to use the new infrastructure
  • Basic luatc binding implemented
  • Lua handlers capable of interacting with TC packet context
  • Initial demonstration scripts completed

Final Deliverables

At the end of the project:

  • Fully functional luatc module
  • eBPF maps Lua API
  • Examples and documentation

Why This Project?

Lunatik helped me understand how Lua is actually a glue language for already running systems. It is lightweight, embeddable, and expressive enough to implement the dynamic logic.

Instead of building isolated bindings, the proposed infrastructure allows future Lunatik modules across multiple subsystems to reuse the same mechanism, multiplying the scripting capabilities of the kernel.

@lneto

lneto commented Feb 20, 2026

Copy link
Copy Markdown

Are there existing designing thoughts / constraints on how maps should be referenced from Lua: by BPF fs path, by map id or something else?

it's part of the project.. it's your design.. we are here only as facilitators ;-).. so, explore it.. pros, cons, feasibility, etc..

Should luamaps support map creation from Lua or is open only the preferred scope?

it's desirable, but you can limit your scope; I'd suggest that you consider all the desired features first, then you can put it on an actual schedule and fit them (or not) per benefit, complexity, time effort, etc..

When does ebpf maps get destroyed? Is it user's resposibility to make sure they are destroyed once Lua is done with its operations?

I think we should just respect their current contracts.

Pinned maps can be frozen via bpftool map freeze. What happens if Lua tries to update a frozen map?

We always need to fail safely on Lua side, so our bindings should guard this for the user scripts.

@sneaky-potato

Copy link
Copy Markdown
Author

Are there existing designing thoughts / constraints on how maps should be referenced from Lua: by BPF fs path, by map id or something else?

it's part of the project.. it's your design.. we are here only as facilitators ;-).. so, explore it.. pros, cons, feasibility, etc..

I did some technical homework, I think accessing eBPF maps via file descriptors / paths is going to be lot difficult (as an fd is required from a process context).

As a starting point, we could design the API to access maps via the map_id instead. The kernel has bpf_map_get_curr_or_next(u32 *id) which should allow this access. Let me do some more brainstorming over how to get the map id (besides bpftool), and how will the lifecycle look like for a map.

@lneto

lneto commented Mar 9, 2026

Copy link
Copy Markdown

Are there existing designing thoughts / constraints on how maps should be referenced from Lua: by BPF fs path, by map id or something else?

it's part of the project.. it's your design.. we are here only as facilitators ;-).. so, explore it.. pros, cons, feasibility, etc..

I did some technical homework, I think accessing eBPF maps via file descriptors / paths is going to be lot difficult (as an fd is required from a process context).

As a starting point, we could design the API to access maps via the map_id instead. The kernel has bpf_map_get_curr_or_next(u32 *id) which should allow this access. Let me do some more brainstorming over how to get the map id (besides bpftool), and how will the lifecycle look like for a map.

ack

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment