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

> Learn how witr resolves ports to processes for PID 1 owned sockets. Discover how it finds the true owner of file descriptors in /proc for accurate port attribution.

- Repository: [Pranshu Parmar/witr](https://github.com/pranshuparmar/witr)
- Tags: internals
- Published: 2026-08-10

---

**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`](https://github.com/pranshuparmar/witr/blob/main/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](https://github.com/pranshuparmar/witr/blob/main/internal/proc/net_linux.go#L59-L104).

```go
// 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](https://github.com/pranshuparmar/witr/blob/main/internal/proc/net_linux.go#L106-L145).

## 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](https://github.com/pranshuparmar/witr/blob/main/internal/proc/net_linux.go#L147-L196).

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.

```go
// 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`](https://github.com/pranshuparmar/witr/blob/main/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`](https://github.com/pranshuparmar/witr/blob/main/pkg/model/socket.go) | Defines `model.Socket` (inode-keyed socket metadata) and `model.OpenPort` (PID-attributed port record) |
| [`internal/proc/net_freebsd.go`](https://github.com/pranshuparmar/witr/blob/main/internal/proc/net_freebsd.go), [`net_darwin.go`](https://github.com/pranshuparmar/witr/blob/main/net_darwin.go), [`net_windows.go`](https://github.com/pranshuparmar/witr/blob/main/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.