p-mark is a Go library and command for tagging Linux process lifetimes with
64-bit marks. User-space Go rules decide which processes are explicitly marked,
and eBPF programs keep those marks inherited by children of marked processes.
The root package is the process-marking engine. The fwmark package demonstrates
one practical consumer: copying process marks into Linux socket SO_MARK /
fwmark values so routing policy and firewall rules can match the process'
traffic.
Caution
p-mark is an experimental and unstable software that work under
high privileges while havent being audited yet.
Do not use in production.
Also check issues.
The daemon loads eBPF programs and pins maps in bpffs, so it normally needs root or equivalent capabilities.
Usage of pmark:
-fmark-value string
fwmark format value to derive full mark from; overwrites mark-value
-fwmark
enable Linux fwmark socket marking with fwmark eBPF hooks in daemon mode
-http-addr string
daemon HTTP control listen address (default "127.0.0.1:8050")
-mark-priority int
signed int8 priority assigned by the default check callback; higher priority wins
-mark-value uint
mark value assigned by the default check callback (default 16978289124505026561)
-pin-path string
bpffs directory for pinned maps (default "/sys/fs/bpf/pmark")
-rule-cmd string
comma-separated regexps matched against cmdline by the default check callback
-rule-comm string
comma-separated regexps matched against comm by the default check callback (default "firefox")
-rule-exe string
comma-separated regexps matched against exe and exe basename by the default check callback
-rule-ppid string
comma-separated parent process ids matched by the default check callback
-watch-interval duration
interval for watcher refreshes (default 1s)
-watcher
watch the pinned process map instead of running the daemon
Run the default daemon, which marks processes whose comm matches firefox by default:
sudo pmarkMark by other process properties:
sudo pmark \
-pin-path /sys/fs/bpf/pmark \
-rule-comm 'firefox,chromium' \
-rule-cmd 'profile-name' \
-rule-exe '/usr/bin/firefox' \
-mark-value 16978289124505026561 \
-mark-priority 10Enable fwmark integration and derive the 64-bit pmark value from a 32-bit Linux fwmark:
sudo ./pmark -fwmark -fmark-value 0xeb9f0001With -fwmark, marked processes receive the fwmark through three cgroup hooks:
sock_create handles the normal socket-creation path, and connect4 and
connect6 repair the mark immediately before a connected socket selects its
route. The userspace reconciler also attempts to update sockets that were
already open. If a connect hook finds a required process mark but cannot apply
it, the hook rejects the connection. The caller normally receives EPERM.
The daemon promotes a fully anchored, case-sensitive literal comm regexp,
such as ^curl$, to an in-kernel exec rule. When every mark-producing rule can
be promoted, the policy is authoritative: the exec tracepoint writes the
matching or non-matching result to the process map before the new program can
create its first socket. Mixed policies use positive-only mode, and policies
without a promotable rule use userspace-only mode. Startup logs and
GET /state report the active mode. Other regexp forms and cmdline, exe,
and PPID rules continue to run in userspace.
The watcher does not run the marking logic. It opens the pinned processes map
and prints the currently marked process tree:
sudo pmark -watcher -pin-path /sys/fs/bpf/pmarkProcess map watcher: /sys/fs/bpf/pmark/processes
Refreshed: 2026-06-07T15:08:12+04:00
Alive entries: 9
Tombstones: 152
Observed tombstones: 152
Collected tombstones: 0
Latest entry update: 1780830491 (857ms ago, boot_ns=15323572543606)
Process tree:
73006 .firefox-wrappe mark=16978289124505026561 priority=0 gen=1
└ 73076 forkserver mark=16978289124505026561 priority=0 gen=1
├ 73080 Socket Process mark=16978289124505026561 priority=0 gen=1
├ 73103 Privileged Cont mark=16978289124505026561 priority=0 gen=1
├ 73113 RDD Process mark=16978289124505026561 priority=0 gen=1
├ 73156 Isolated Web Co mark=16978289124505026561 priority=0 gen=1
├ 73165 Isolated Web Co mark=16978289124505026561 priority=0 gen=1
├ 73269 WebExtensions mark=16978289124505026561 priority=0 gen=1
└ 73322 Utility Process mark=16978289124505026561 priority=0 gen=1
Adjust refresh rate with -watch-interval:
sudo ./pmark -watcher -watch-interval 500msDaemon mode starts an HTTP control server on -http-addr, defaulting to
127.0.0.1:8050. Open that address in a browser to view the admin panel. The
panel exposes current state and lets the sample regexp rules and mark settings
be updated without restarting the daemon.
If you've run the daemon with -fwmark, you can use it later in
iptables/nftables rules (e.g., for routing traffic of marked apps to different
routes).
These rules will drop all packets owned by processes that have the mark attached by the daemon from the examples above, and thus block all firefox traffic:
sudo nft delete table inet ebpf_test_fwmark 2>/dev/null
sudo nft add table inet ebpf_test_fwmark
sudo nft 'add chain inet ebpf_test_fwmark output { type filter hook output priority 0; policy accept; }'
sudo nft add rule inet ebpf_test_fwmark output meta mark 0xeb9f0001 counter drop
sudo nft add rule inet ebpf_test_fwmark output socket mark 0xeb9f0001 counter dropThen you can check stats with:
sudo nft list chain inet ebpf_test_fwmark outputBuild or run directly from the flake:
nix build github:asciimoth/p-mark#pmark
nix profile add github:asciimoth/p-mark#pmarkPackages are published to my deb/rpm repository:
Setup it for your sytstem via script (or manually):
curl https://repo.moth.contact/setup.sh | bashThen install with your system package manager:
sudo apt install pmark
# or
sudo dnf install pmark
# or
sudo yum install pmarkRelease archives and package artifacts are published on the GitHub releases page.
AUR is available
Run the unit tests:
just testRun only the unprivileged tests for the embedded eBPF specifications and C ABI:
just test-ebpfCheck C formatting and static analysis, compile both eBPF byte orders with
strict warnings, and check that the committed bpf2go artifacts are current:
just lint-ebpfRun the privileged Docker end-to-end suite:
just test-e2eRun the same focused marking gate in a disposable VM:
just test-e2e-guestThe end-to-end tests use eBPF, bpffs, the host PID namespace, and cgroup socket
hooks. See e2e/README.md for the host requirements, case
layout, image cache, and coverage options.
The guest command does not share the host PID namespace, network, or BPF
objects. See e2e/guest/README.md for its QEMU and
kernel requirements and its saved test evidence.
Note: You can find more marks usage examples in ebpf-test
Use the pmark for custom Go logic to decide process marks in your program.
package main
import (
"log"
"os"
"os/signal"
"syscall"
pmark "github.com/asciimoth/p-mark"
)
func main() {
daemon, err := pmark.NewDaemon("/sys/fs/bpf/pmark", pmark.Callbacks{
Check: func(info pmark.ProcessInfo) (int8, uint64, bool) {
if info.Comm == "firefox" {
return 10, 0x0000004200000001, true
}
return 0, 0, false
},
ProcessUpdate: func(update pmark.ProcessUpdate) {
log.Printf("pid=%d mark=%#x has_mark=%v",
update.Key.Tgid, update.Value.Mark, update.Value.HasMark)
},
Logf: log.Printf,
}, 0, 0)
if err != nil {
log.Fatal(err)
}
if _, err := daemon.SetKernelPolicy(pmark.KernelPolicy{
Mode: pmark.KernelPolicyAuthoritative,
CommRules: []pmark.ExactCommRule{
{Comm: "firefox", Priority: 10, Mark: 0x0000004200000001},
},
}, nil); err != nil {
log.Fatal(err)
}
if err := daemon.Run(); err != nil {
log.Fatal(err)
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
if err := daemon.Stop(); err != nil {
log.Fatal(err)
}
}Important API points:
CheckFuncreturns(priority, mark, true)to explicitly mark a process.- Returning
ok=falseleaves the process unmarked unless a live parent mark can be inherited. SetCheckerreplaces rule logic, bumps the checker generation, and re-traverses/proc. It explicitly selectsuserspace-onlymode.SetKernelPolicyinstalls generation-qualified exactcommrules and the userspace checker in one transaction. A nil checker uses an equivalent exact matcher built from the supplied rules.KernelPolicyStatereports the active mode, generation, and rule count.ForceProcessTraversalre-checks current processes without bumping the generation.GrabProcessMapStateis useful for admin panels and watchers.
Use multirule when you need a userspace-only view of which processes match
multiple independent rules, without creating pmark marks for those matches.
tracker := multirule.New()
browserRule := tracker.RegisterRule(func(info pmark.ProcessInfo) bool {
return info.Comm == "firefox"
})
daemon, err := pmark.NewDaemon("/sys/fs/bpf/pmark", pmark.Callbacks{
Check: tracker.CheckCallback(),
ProcessEvent: tracker.ProcessEventCallback(),
Logf: log.Printf,
}, 0, 0)
if err != nil {
log.Fatal(err)
}
_ = browserRuleTracker.CheckCallback observes process information and always returns
ok=false, so the tracker does not ask pmark to write any eBPF process marks.
Each observed process is checked against registered rules and inherits matched
rule IDs from the latest known parent. RegisterRule immediately checks all
already observed processes, UnregisterRule removes the ID from every entry, and
RuleIDs, Matches, and Snapshot expose the current in-memory state.
For callers that only have a PID, RuleIDsByPID and MatchesPID look up the
latest pmark.ProcessKey observed for that PID:
if tracker.MatchesPID(pid, browserRule) {
log.Printf("pid %d is in the browser rule set", pid)
}PID-only lookups are convenient but less precise than pmark.ProcessKey
lookups. Linux can reuse PIDs, so when multiple process lifetimes have used the
same PID, multirule maps that PID to the latest observed ProcessKey. When an
exit event removes that ProcessKey, the associated PID lookup is removed too.
Use fwmark when the mark should become the Linux socket mark used by routing
policy or firewall rules. The fwmark value is stored in the high 32 bits of the
64-bit process mark.
package main
import (
"log"
"os"
"os/signal"
"syscall"
pmark "github.com/asciimoth/p-mark"
"github.com/asciimoth/p-mark/fwmark"
)
func main() {
pinPath := "/sys/fs/bpf/pmark"
mark := fwmark.ToMark(0x42)
daemon, err := pmark.NewDaemon(pinPath, pmark.Callbacks{
Check: func(info pmark.ProcessInfo) (int8, uint64, bool) {
return 0, mark, info.Comm == "firefox"
},
Logf: log.Printf,
}, 0, 0)
if err != nil {
log.Fatal(err)
}
if _, err := daemon.SetKernelPolicy(pmark.KernelPolicy{
Mode: pmark.KernelPolicyAuthoritative,
CommRules: []pmark.ExactCommRule{
{Comm: "firefox", Mark: mark},
},
}, nil); err != nil {
log.Fatal(err)
}
manager, err := fwmark.NewManager(pinPath, log.Printf)
if err != nil {
log.Fatal(err)
}
defer manager.Close()
fwmarkUpdate := manager.ProcessUpdateCallback()
daemon.UpdateHooks(pmark.Callbacks{
ProcessUpdate: fwmarkUpdate,
Logf: log.Printf,
})
if err := daemon.Run(); err != nil {
log.Fatal(err)
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
if err := daemon.Stop(); err != nil {
log.Fatal(err)
}
}The fwmark.Manager loads cgroup eBPF programs that share the same pinned
processes map as the pmark daemon. Its ProcessUpdateCallback should be
connected to the daemon so already-open sockets are reconciled after process
marks change.
p-mark splits policy from propagation:
- Userspace owns general policy. Go callbacks inspect
/procmetadata and decide which processes receive explicit marks. Exactcommpolicies can also run synchronously in the exec BPF program. - Kernel eBPF owns fast propagation. Tracepoint programs observe fork, exec, and exit transitions and maintain/emit effective mark state.
- A pinned BPF hash map named
processesis the shared state between the root daemon, custom eBPF consumers, watchers, and packages such asfwmark.
Process identity is not just PID. The key is (tgid, start_time), where
start_time is /proc/<pid>/stat field 22 in USER_HZ ticks. This avoids most
PID-reuse confusion for one boot.
NewDaemon loads the generated eBPF object and pins processes, comm_rules,
active_policy, and events under the configured bpffs directory. The daemon
removes these known pins at startup because their layouts are one versioned ABI
and stale layouts cannot be reused safely. It also prepares a userspace mirror.
Run then:
- Traverses
/procbefore attaching programs and appliesCheckFunc. - Attaches tracepoint programs for
sched_process_fork,sched_process_exec, andsched_process_exit. - Traverses
/procagain after attaching programs. This second traversal narrows the race window between the first traversal and tracepoint attach. - Consumes the ring buffer and reconciles BPF events with userspace rules.
On fork, BPF tries to copy a live parent mark to the child before sending an
event. Userspace then reconciles that result with its mirror and calls
CheckFunc for the child. If the checker returns a mark, that explicit mark can
override inheritance according to normal merge rules.
On exec, BPF reads active_policy once and looks up the current task name in
the generation-qualified comm_rules map. A match writes an explicit value to
processes. An authoritative miss writes a live no-mark value when an old or
inherited value exists. A miss without existing state stays absent because a
missing entry already means unmarked. The update occurs before ring-buffer
reservation, so a full buffer cannot delay the decision. Userspace merges the
event value with its mirror and can re-check metadata because cmdline and
exe may have changed.
Policy updates first populate a complete inactive rule generation. They then
switch the one-entry active_policy map and remove inactive rules. An exec can
therefore observe a complete old generation or a complete new generation.
Failed transactions do not reuse their generation, so a failed rollback cannot
make a partial rule set active later. Generation zero is reserved. The daemon
returns an error instead of wrapping after generation 2^64-1.
On exit, entries become tombstones instead of immediate deletes. Tombstones keep late events for the same process lifetime from reviving stale marks. The daemon periodically removes old tombstones from both the userspace mirror and kernel map.
fwmark loads cgroup sock_create, connect4, and connect6 eBPF programs
and attaches them to the root cgroup. When a process creates a socket, the
sock_create program computes the current process key, looks up processes,
and if the entry is live and marked, writes:
socket fwmark = process mark >> 32
The connect programs repeat this lookup for IPv4 and IPv6 connections and use
bpf_setsockopt(SO_MARK) before the first route lookup. They repair a socket
that was created before its process-map entry was ready or that a userspace
descriptor scan missed. If a required SO_MARK update fails, the program
rejects the connection instead of allowing an unmarked route.
Existing sockets still need userspace help. fwmark.Manager.ProcessUpdateCallback
returns a pmark.ProcessUpdate hook that walks /proc/<pid>/fd, obtains each
target fd through pidfd_getfd, and applies SO_MARK with setsockopt. This
also clears marks after a process stops matching and updates sockets before
they connect.
Process matching after exec still runs in userspace. A connection that starts
before userspace adds its process-map entry cannot be repaired by the connect
hooks. Unconnected UDP sendto() and sendmsg() traffic is also outside these
hooks.
The processes map is a pinned BPF_MAP_TYPE_HASH named processes.
Key:
struct process_key {
__u32 tgid;
__u64 start_time;
};Value:
struct process_value {
bool tombstone;
bool inheritance;
bool has_mark;
__s8 priority;
__u64 generation;
__u64 mark;
__u64 timestamp;
};Meaning:
tombstone: the process lifetime exited; treat as unmarked.inheritance: true means the mark was explicit/original from userspace; false means it was inherited.has_mark: gates whethermarkandpriorityare meaningful.priority: higher priority wins when userspace merges competing live values.generation: checker generation that last reconciled the entry.mark: the 64-bit user-defined process mark.timestamp:CLOCK_BOOTTIMEnanoseconds, also used for tombstone expiry.
Correct use:
- Treat missing entries, tombstones, and
has_mark=falseas unmarked. - Use the full
(tgid, start_time)key, not PID alone. - Keep C struct layouts exactly aligned with the generated Go layout if you add another BPF object.
- Let the daemon own tombstone collection and rule-generation updates.
- If custom userspace writes map entries, use the same merge semantics as the
daemon or route writes through daemon APIs such as
SetProcessMark.
fwmark is the reference pattern for custom consumers.
From eBPF:
- Declare a map named
processeswith the same key/value layout andLIBBPF_PIN_BY_NAME. - Load your eBPF collection with
ebpf.CollectionOptions{Maps: ebpf.MapOptions{PinPath: pinPath}}so cilium/ebpf reuses the map pinned by the root daemon. - In the hook, construct the same process key. For current tasks, use the thread
group leader and convert
start_boottimeto USER_HZ ticks. - Only act on values where
!tombstone && has_mark.
From userspace:
- Start the root
pmark.Daemonfirst so the shared map exists and tracepoint programs maintain inheritance. - Load your custom manager with the same
pinPath. - Subscribe to
ProcessUpdateto apply side effects that BPF hooks cannot apply retroactively. - After installing or replacing hooks, replay or re-traverse existing state.
Daemon.UpdateHooksreplays current live entries to the newProcessUpdatecallback.ForceProcessTraversalre-checks/procwith the current checker, andSetCheckerorForceBumpGenerationre-check with a new generation.
That replay/re-traversal step is important for race control and for processes that already existed before custom logic was attached. Without it, a consumer may only see future fork/exec transitions and miss live processes that already carry marks.
- Figure out what to do with time namespaces.
- Test on more distros arcs and configurations
- Security audit
- Flatpack app name matching in rules?
This repository is dual licensed under GPL or MIT; see LICENSE-GPL and LICENSE-MIT.
Note: the Go bindings embed precompiled eBPF object blobs generated by bpf2go
(mark_bpf*.o and fwmark/fwmark_bpf*.o). Those blobs are built from the
open-source C files in this repository (
mark.c
and
fwmark/fwmark.c
), which
declare Dual MIT/GPL for the kernel verifier.
You can regenerate them with:
go generate ./...