How witr Resolves Ports to Processes When the Socket Is Owned by PID 1

When a socket's owner shows as PID 1 in /proc/net/tcp, witr walks the /proc/<pid>/fd directory of every running process to find which process actually holds the file descriptor, correctly attributing the port even for containerized or parent-transferred sockets.

The witr network monitoring tool implements a two-phase resolution strategy on Linux to map network sockets to their owning processes. This approach specifically handles edge cases where the socket appears owned by PID 1 (the init process) in the kernel's network tables, but is actually held by a descendant process such as a containerized application or a service spawned by systemd.

The PID 1 Problem in Port-to-Process Mapping

Linux's /proc/net/tcp and related files expose socket information with an inode number, but the "owner" field in these files can be misleading. When a socket is created and then passed to a child process—or when network namespaces are involved—the kernel may report PID 1 as the owner. Simple parsing of /proc/net/* files fails to identify the true process holding the socket open.

witr solves this by decoupling socket discovery from process attribution, ensuring accurate mapping regardless of which PID appears in the network tables.

Phase 1: Building the Socket-Inode Cache

The first phase extracts all socket metadata from the kernel without attempting process attribution. In internal/proc/net_linux.go, the readSockets function parses four procfs files:

  • /proc/net/tcp
  • /proc/net/tcp6
  • /proc/net/udp
  • /proc/net/udp6

For each socket entry, witr extracts the inode, local address, port, protocol, and state, storing them in a map keyed by inode. The cache is valid for 2 seconds to avoid redundant parsing when examining thousands of processes net_linux.go lines 59-104.

// Build the socket table without PID information
sockets, err := proc.ReadOpenSockets()
if err != nil {
    log.Fatalf("failed to read sockets: %v", err)
}

The parseAddr function handles hexadecimal-to-human conversion of IP addresses and ports, decoding the kernel's compact representation into usable strings and integers net_linux.go lines 106-145.

Phase 2: Walking Process File Descriptors

The second phase resolves the actual process ownership. The ListOpenPorts function iterates through all numeric directories in /proc—each representing a running PID—and examines the /proc/<pid>/fd directory net_linux.go lines 147-196.

For each file descriptor symlink that points to socket:[inode], witr:

  1. Extracts the inode number from the symlink target
  2. Looks up that inode in the cached socket table from Phase 1
  3. Emits an model.OpenPort record containing the actual PID, port, address, protocol, and state

This fd-walk strategy is PID-agnostic—it does not trust any owner field from /proc/net/*. Instead, it discovers ownership by finding which process holds the file descriptor, making it immune to the PID 1 attribution problem.

// Get accurate port-to-PID mappings, even for containerized sockets
ports, err := proc.ListOpenPorts()
if err != nil {
    log.Fatalf("failed to list ports: %v", err)
}
for _, p := range ports {
    fmt.Printf("PID %d → %s:%d (%s, %s)\n",
        p.PID, p.Address, p.Port, p.Protocol, p.State)
}

Typical output showing correct attribution:


PID 2379 → 127.0.0.1:5432 (TCP, LISTEN)
PID 1124 → 0.0.0.0:22 (TCP, LISTEN)
PID 987 → [::]:80 (TCP6, LISTEN)

Key Files and Data Structures

File Purpose
internal/proc/net_linux.go Core implementation: parses /proc/net/*, implements inode caching, and walks /proc/<pid>/fd for PID resolution
pkg/model/socket.go Defines model.Socket (inode-keyed socket metadata) and model.OpenPort (PID-attributed port record)
internal/proc/net_freebsd.go, net_darwin.go, net_windows.go Platform-specific implementations with equivalent logic

Why This Approach Handles PID 1 Correctly

  • No trust in kernel owner fields — The /proc/net/tcp "owner" is ignored entirely; ownership is proven by fd possession
  • Complete process enumeration — Every PID directory is examined, so no process is overlooked due to namespace or parent-child relationships
  • Inode-based matching — The socket inode is the ground truth, linking kernel socket data to process file descriptor tables

This design also handles transferred sockets (where a service creates a socket and passes it to workers) and network namespaces (where containerized processes appear outside PID 1's hierarchy but still hold fds).

Summary

  • readSockets builds a 2-second cached map of socket inodes to addresses, ports, and protocols from /proc/net/*
  • ListOpenPorts walks every /proc/<pid>/fd directory to discover which process holds each socket's file descriptor
  • The inode-based lookup completely bypasses misleading PID fields in kernel network tables
  • This approach correctly attributes ports to their true owners even when /proc/net/tcp shows PID 1

Frequently Asked Questions

How does witr handle sockets created in one process but used in another?

witr attributes the socket to whichever process currently holds the file descriptor. Since Phase 2 examines every process's fd table, it finds the actual holder regardless of which process originally created the socket. This correctly handles common patterns like systemd socket activation or prefork server models.

Does walking every /proc/[pid]/fd directory cause performance problems?

The 2-second cache on the socket table mitigates overhead. witr parses /proc/net/* once, then performs the fd walk using that cached data. For systems with many processes, this is significantly faster than re-parsing network tables for each PID.

Can witr see sockets inside containers from the host?

From the host PID namespace, witr sees container processes as regular PIDs in /proc. The fd walk works normally. However, if the container uses a separate network namespace with its own /proc/net tables, host-side witr would need to access that namespace's procfs to see those sockets.

Why not use /proc/[pid]/net/tcp for each process instead of the global /proc/net/tcp?

Per-process /proc/[pid]/net/* files show the network namespace view, not the process's own sockets. A process in the host's network namespace sees all host sockets there, while a process in a container namespace sees only that namespace's sockets. The fd-walk method using global /proc/net/* plus per-process fd examination is more direct and doesn't require namespace switching.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →