Watch
1
0
Fork
You've already forked RedFlag
0

processes: attribute each pid to its unit or container

/proc/[pid]/cgroup gives the join the process list never had: systemd unit,
container id, runtime. Both cgroup generations, both cgroup drivers, docker,
podman, containerd, crio and lxc. On a 321-process desktop every userland
process attributes; the 172 that do not are kernel threads.

The kernel's id is full-length and the docker inventory reports twelve
characters, so the join is a prefix, not equality. RAF carries the rule.
This commit is contained in:
Fimeg 2026-08-31 22:40:52 -04:00
commit d1670cfd6a
5 changed files with 335 additions and 3 deletions

View file

@ -39,9 +39,9 @@ On drill-down (clicking a process row):
### List Scan (`GetFullProcessSnapshot`)
Reads `/proc/[pid]/stat`, `/proc/[pid]/status`, `/proc/[pid]/exe`, `/proc/[pid]/cmdline`, `/proc/[pid]/cwd`, `/proc/[pid]/io` for every PID. No related data collected at this stage.
Reads `/proc/[pid]/stat`, `/proc/[pid]/status`, `/proc/[pid]/exe`, `/proc/[pid]/cmdline`, `/proc/[pid]/cwd`, `/proc/[pid]/cgroup`, `/proc/[pid]/io` for every PID. No related data collected at this stage.
**Fields (25+):** PID, Name, Path, Cmdline, Cwd, State, UID, GID, EUID, EGID, User, Group, TTY, TTYName, CPUSecondsUser, CPUSecondsSystem, CPUPercent, RSSBytes, VMSBytes, MemPercent, Threads, Nice, StartTimeSeconds, ParentPID, ProcessGroupID, ElevationStatus, OnDisk, DiskBytesRead, DiskBytesWritten
**Fields (25+):** PID, Name, Path, Cmdline, Cwd, State, UID, GID, EUID, EGID, User, Group, TTY, TTYName, CPUSecondsUser, CPUSecondsSystem, CPUPercent, RSSBytes, VMSBytes, MemPercent, Threads, Nice, StartTimeSeconds, ParentPID, ProcessGroupID, ElevationStatus, OnDisk, DiskBytesRead, DiskBytesWritten, Cgroup, Unit, ContainerID, ContainerRuntime
### Drill-Down (`GetProcessDetail`)
@ -57,6 +57,42 @@ Adds related data from a single `/proc/[pid]/fd/` walk (consolidated from three
| Namespaces | `/proc/[pid]/ns/` symlinks | `max_namespaces` (default 50) |
| Listening ports | Socket inode correlation with `/proc/net/tcp` | `max_listening_ports` (default 100) |
### Key Implementation Detail: Ownership Attribution
`/proc/[pid]/cgroup` answers who owns a process, which is the join between the process
list and the service, container, and package inventories. Without it a process list is a
task manager: a name, a number, and no way to ask what put it there.
`parseCgroupOwner` produces four fields from that one file:
| Field | Meaning |
|---|---|
| `cgroup` | the path the attribution was read from, kept so a surprising answer can be checked |
| `unit` | innermost systemd unit — `nginx.service`, `app-firefox-4090.scope` |
| `container_id` | full container ID as the kernel spells it |
| `container_runtime` | `docker`, `podman`, `containerd`, `crio`, `lxc` |
Both cgroup generations are handled. v2 is the single `0::<path>` line. v1 has one line per
controller and the paths can disagree, so the `name=systemd` hierarchy wins when present —
it is the one that carries unit and container scopes.
Container detection covers both cgroup drivers, because the same runtime writes different
paths depending on how it was configured: systemd driver gives `docker-<id>.scope`,
`libpod-<id>.scope`, `cri-containerd-<id>.scope`; cgroupfs driver gives the bare ID under
`/docker/` or `/kubepods/.../pod<uid>/`. LXC carries a name rather than a hash. A scope
whose suffix is not hex of length 12 or 64 is a unit, not a container — `docker-notahash.scope`
attributes as a unit.
**The Docker join is a prefix match, not equality.** `container_id` here is the full ID
from the kernel; the Docker scanner reports `c.ID[:12]` in its inventory
(`agent/internal/orchestrator/docker_scanner.go`). A consumer joining the two compares
prefixes. Truncating the kernel's answer to match one scanner's display width would be
fabricating uniformity, which the mutation protocol forbids for the same reason.
Kernel threads have cgroup `/` and stay unattributed, correctly — they have no unit, no
container, and no package. On a 321-process Arch desktop that is 172 of them, and every
userland process attributes.
### Key Implementation Detail: Socket Inode Correlation
Listening ports are per-process, not system-wide. The scanner collects socket inodes from `/proc/[pid]/fd/` symlinks (`socket:[12345]`), then matches them against inode numbers in `/proc/net/tcp` and `/proc/net/tcp6`. Only LISTEN state (0A) entries whose inode matches a process socket are included.
@ -98,6 +134,7 @@ Data collection limits are server-controlled via `ProcessExplorerConfig` (stored
| File | Purpose |
|------|---------|
| `agent/internal/system/process_detail.go` | Types: `FullProcess`, `ProcessOpenFile`, `ProcessOpenSocket`, `ProcessOpenPipe`, `ProcessMemoryMap`, `ProcessNamespace`, `ProcessListeningPort`, `ProcessCaps`, `FullProcessSnapshot` |
| `agent/internal/system/process_owner.go` | `ProcessOwner` and `parseCgroupOwner` — cgroup attribution, platform-independent and fixture-tested |
| `agent/internal/system/process_detail_linux.go` | Linux `/proc` reader: `getFullProcessSnapshot()`, `getProcessDetail()`, `walkProcFD()`, `readListeningPorts()`, `parseHexAddr()` |
| `agent/internal/system/process_detail_other.go` | Stub for non-Linux platforms |
| `agent/internal/handlers/processes.go` | `HandleScanProcesses` — command handler |
@ -135,4 +172,4 @@ Data collection limits are server-controlled via `ProcessExplorerConfig` (stored
---
*Added: 2026-06-10*
*Added: 2026-06-10 · ownership attribution 2026-08-31*

View file

@ -47,6 +47,9 @@ type FullProcess struct {
// Elevation
ElevationStatus string `json:"elevation_status,omitempty"` // "elevated" if uid!=euid
// Ownership — cgroup, systemd unit, container. Inlined into the JSON.
ProcessOwner
// Disk I/O (from /proc/[pid]/io, may be empty for other users' processes)
DiskBytesRead uint64 `json:"disk_bytes_read,omitempty"`
DiskBytesWritten uint64 `json:"disk_bytes_written,omitempty"`

View file

@ -178,6 +178,11 @@ func readFullProc(pid int, totalCPU, memTotal uint64, passwd, group map[uint32]s
}
}
// /proc/[pid]/cgroup — the owning unit or container
if cgroupData, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid)); err == nil {
proc.ProcessOwner = parseCgroupOwner(string(cgroupData))
}
// /proc/[pid]/cmdline — full command line
cmdlineData, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err == nil {

View file

@ -0,0 +1,130 @@
package system
import "strings"
// ProcessOwner names what a process belongs to, read from its cgroup path.
// It is the join between the process list and the service, container, and
// package inventories — a PID answers "who owns me" without the Desktop
// having to guess from a name.
type ProcessOwner struct {
Cgroup string `json:"cgroup,omitempty"`
Unit string `json:"unit,omitempty"`
ContainerID string `json:"container_id,omitempty"`
ContainerRuntime string `json:"container_runtime,omitempty"`
}
// parseCgroupOwner reads the contents of /proc/[pid]/cgroup.
//
// v2 is one `0::<path>` line. v1 is one line per controller, and the paths can
// disagree — the systemd hierarchy is the one that carries unit and container
// scopes, so it wins when present.
func parseCgroupOwner(content string) ProcessOwner {
path := ""
for _, line := range strings.Split(content, "\n") {
fields := strings.SplitN(strings.TrimSpace(line), ":", 3)
if len(fields) != 3 || fields[2] == "" {
continue
}
hierarchy, controllers, candidate := fields[0], fields[1], fields[2]
if hierarchy == "0" && controllers == "" {
path = candidate // v2: unified, authoritative
break
}
if controllers == "name=systemd" {
path = candidate
continue
}
if path == "" && candidate != "/" {
path = candidate
}
}
if path == "" || path == "/" {
return ProcessOwner{}
}
owner := ProcessOwner{Cgroup: path}
segments := strings.Split(strings.Trim(path, "/"), "/")
containerIndex := -1
for i := len(segments) - 1; i >= 0; i-- {
runtime, id := containerSegment(segments, i)
if id == "" {
continue
}
owner.ContainerRuntime = runtime
owner.ContainerID = id
containerIndex = i
break
}
for i := len(segments) - 1; i >= 0; i-- {
if i == containerIndex {
continue
}
if strings.HasSuffix(segments[i], ".service") || strings.HasSuffix(segments[i], ".scope") {
owner.Unit = segments[i]
break
}
}
return owner
}
// containerSegment identifies a container from one cgroup path segment,
// returning its runtime and full ID. Empty ID means the segment names no
// container.
func containerSegment(segments []string, i int) (string, string) {
segment := segments[i]
// systemd cgroup drivers: docker-<id>.scope, libpod-<id>.scope,
// crio-<id>.scope, cri-containerd-<id>.scope.
if scope := strings.TrimSuffix(segment, ".scope"); scope != segment {
for prefix, runtime := range map[string]string{
"docker-": "docker",
"libpod-": "podman",
"crio-": "crio",
"cri-containerd-": "containerd",
"containerd-": "containerd",
} {
if id := strings.TrimPrefix(scope, prefix); id != scope && isContainerID(id) {
return runtime, id
}
}
}
// LXC keeps a name, not a hash: /lxc/<name>, /lxc.payload.<name>.
if payload := strings.TrimPrefix(segment, "lxc.payload."); payload != segment && payload != "" {
return "lxc", payload
}
if i > 0 && segments[i-1] == "lxc" && segment != "" {
return "lxc", segment
}
// cgroupfs drivers keep the bare ID: /docker/<id>, /kubepods/.../<id>.
if isContainerID(segment) {
if i > 0 {
switch {
case segments[i-1] == "docker":
return "docker", segment
case strings.HasPrefix(segments[i-1], "pod"), strings.HasPrefix(segments[i-1], "kubepods"):
return "containerd", segment
}
}
}
return "", ""
}
// isContainerID reports whether a segment is a container hash. Runtimes emit
// the full 64-hex ID; some emit the 12-hex short form, and the cgroup is not
// the place to decide which one a UI should show.
func isContainerID(segment string) bool {
if len(segment) != 64 && len(segment) != 12 {
return false
}
for i := 0; i < len(segment); i++ {
c := segment[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}

View file

@ -0,0 +1,157 @@
package system
import "testing"
func TestParseCgroupOwner(t *testing.T) {
const dockerID = "a3f1c2d4e5b60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
cases := []struct {
name string
body string
want ProcessOwner
}{
{
name: "v2 system service",
body: "0::/system.slice/nginx.service\n",
want: ProcessOwner{Cgroup: "/system.slice/nginx.service", Unit: "nginx.service"},
},
{
name: "v2 user scope keeps the innermost unit",
body: "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-4090.scope\n",
want: ProcessOwner{
Cgroup: "/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-4090.scope",
Unit: "app-firefox-4090.scope",
},
},
{
name: "docker systemd driver",
body: "0::/system.slice/docker-" + dockerID + ".scope\n",
want: ProcessOwner{
Cgroup: "/system.slice/docker-" + dockerID + ".scope",
ContainerID: dockerID,
ContainerRuntime: "docker",
},
},
{
name: "docker cgroupfs driver",
body: "0::/docker/" + dockerID + "\n",
want: ProcessOwner{
Cgroup: "/docker/" + dockerID,
ContainerID: dockerID,
ContainerRuntime: "docker",
},
},
{
name: "podman",
body: "0::/machine.slice/libpod-" + dockerID + ".scope\n",
want: ProcessOwner{
Cgroup: "/machine.slice/libpod-" + dockerID + ".scope",
ContainerID: dockerID,
ContainerRuntime: "podman",
},
},
{
name: "cri-containerd under kubepods",
body: "0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podabc.slice/cri-containerd-" + dockerID + ".scope\n",
want: ProcessOwner{
Cgroup: "/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podabc.slice/cri-containerd-" + dockerID + ".scope",
ContainerID: dockerID,
ContainerRuntime: "containerd",
},
},
{
name: "kubepods bare id",
body: "0::/kubepods/burstable/pod9c4f/" + dockerID + "\n",
want: ProcessOwner{
Cgroup: "/kubepods/burstable/pod9c4f/" + dockerID,
ContainerID: dockerID,
ContainerRuntime: "containerd",
},
},
{
name: "lxc payload",
body: "0::/lxc.payload.mail/system.slice/postfix.service\n",
want: ProcessOwner{
Cgroup: "/lxc.payload.mail/system.slice/postfix.service",
Unit: "postfix.service",
ContainerID: "mail",
ContainerRuntime: "lxc",
},
},
{
name: "lxc plain",
body: "0::/lxc/mail\n",
want: ProcessOwner{Cgroup: "/lxc/mail", ContainerID: "mail", ContainerRuntime: "lxc"},
},
{
name: "v1 prefers the systemd hierarchy",
body: "12:pids:/\n" +
"6:memory:/system.slice\n" +
"1:name=systemd:/system.slice/sshd.service\n",
want: ProcessOwner{Cgroup: "/system.slice/sshd.service", Unit: "sshd.service"},
},
{
name: "v1 without systemd falls back to a named controller",
body: "6:memory:/system.slice/cron.service\n5:cpu:/\n",
want: ProcessOwner{Cgroup: "/system.slice/cron.service", Unit: "cron.service"},
},
{
name: "kernel thread at the root has no owner",
body: "0::/\n",
want: ProcessOwner{},
},
{
name: "empty",
body: "",
want: ProcessOwner{},
},
{
name: "malformed lines are skipped",
body: "garbage\n0::/system.slice/chronyd.service\n",
want: ProcessOwner{Cgroup: "/system.slice/chronyd.service", Unit: "chronyd.service"},
},
{
name: "a scope that is not a container id stays a unit",
body: "0::/system.slice/docker-notahash.scope\n",
want: ProcessOwner{
Cgroup: "/system.slice/docker-notahash.scope",
Unit: "docker-notahash.scope",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseCgroupOwner(tc.body)
if got != tc.want {
t.Fatalf("parseCgroupOwner(%q)\n got: %+v\nwant: %+v", tc.body, got, tc.want)
}
})
}
}
func TestIsContainerID(t *testing.T) {
valid := []string{
"a3f1c2d4e5b60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
"a3f1c2d4e5b6",
}
for _, id := range valid {
if !isContainerID(id) {
t.Errorf("isContainerID(%q) = false, want true", id)
}
}
invalid := []string{
"",
"nginx.service",
"A3F1C2D4E5B6", // runtimes emit lowercase; uppercase is something else
"a3f1c2d4e5b", // 11
"a3f1c2d4e5b60", // 13
"g3f1c2d4e5b6",
}
for _, id := range invalid {
if isContainerID(id) {
t.Errorf("isContainerID(%q) = true, want false", id)
}
}
}