Skip to content

Harden fd inheritance, socket lifecycle, and control-plane I/O - #4

Merged
jaredLunde merged 2 commits into
mainfrom
devin/1787182640-unix-fd-socket-hardening
Aug 19, 2026
Merged

Harden fd inheritance, socket lifecycle, and control-plane I/O#4
jaredLunde merged 2 commits into
mainfrom
devin/1787182640-unix-fd-socket-hardening

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the fd/socket findings from the Linux/unix scan. Four of them are the interesting ones; the rest are small and readable from the diff.

Inherited descriptors were adopted on faith. LISTEN_FDS is systemd's variable, so FDs 3..3+n can arrive from something that is not our supervisor, and FromRawFd validates nothing — a name collision meant wrapping (and, on drop, closing) an unrelated descriptor. role.rs now honors LISTEN_PID when present, fstats every slot, and family/listening-checks it at the typed accessor:

// take() / take_unix(): validate before removing from the map, so a
// rejected slot is left alone rather than wrapped-and-closed
if !fd_is_socket(fd) || socket_family(fd) != want || !fd_is_listening(fd) { return None; }

We can't set LISTEN_PID on our own children (the pid isn't known until after fork, and Command's env is materialized before pre_exec), so its absence is not treated as suspicious; successor identity is still checked via the Hello pid. Adopted FDs also get FD_CLOEXEC re-armed (the parent's dup2 cleared it — a leaked control-socket dup holds the supervisor's EOF open and hides the primitive's death) and O_NONBLOCK cleared (it lives on the open file description, so an async-runtime parent would hand the child a listener whose first accept() is EAGAIN).

The accept loop exited on transient errors. ECONNABORTED (peer went away before we accepted) or a moment of EMFILE permanently stopped the incumbent from answering the control socket — it keeps serving traffic but can never be handed off again, which is exactly the outage this crate exists to prevent. Now: retry EINTR/ECONNABORTED/EPROTO/EPERM, back off 5 ms → 1 s on EMFILE/ENFILE/ENOBUFS/ENOMEM/EAGAIN, fatal only on listener-invalidating errors.

Anyone local could drive a handoff. Control + trigger sockets got the ambient umask and no peer check, so a drain+seal was available to any uid on the box. New sock.rs binds a staging name, chmods 0600, then rename(2)s it over the path — no world-writable window, and no missing-path window either (the old unlink-then-bind gave connecting clients ENOENT and let a racer steal the name). Every accepted peer's uid comes from SO_PEERCRED/getpeereid and must be our euid, root, or configured. The reference supervisor additionally replaces "exec whatever path the client sent" with an allowed_binaries allowlist, and bounds trigger clients (10 s I/O timeout, 4 KiB cap, per-client error isolation so one stalled client can't kill the loop).

Frame reads desynchronized on timeout. Every protocol read carries SO_RCVTIMEO, and read_exact discards what it consumed on failure — so a frame straddling the timeout left the stream mid-frame and every subsequent frame misparsed. FrameAccumulator owns the partial bytes; a timeout is a suspension, not a loss:

match acc.poll_read(&mut stream)? {
    Some((v, msg)) => ...,
    None if acc.has_partial() => continue,   // peer is mid-frame: it's alive
    None => return Err(Error::Timeout(awaiting)), // peer sent nothing
}

Writes go out via MSG_NOSIGNAL/SO_NOSIGPIPE (a dead peer must yield EPIPE, not kill the process — Rust's default SIGPIPE masking is a property of the embedding binary, not of this library) and carry SO_SNDTIMEO, since a peer that stops reading was the one place in the handoff bounded by neither the liveness clock nor the deadline.

Smaller ones: kill(pid, 0) returning EPERM means the pid exists under another uid, and was being read as dead (only ESRCH counts now); pidfile temp names are writer-specific; sun_path length is validated up front instead of surfacing an opaque EINVAL mid-handoff; lock.rs documents that flock needs a local filesystem.

One portability wrinkle surfaced in CI: macOS fails getsockopt(SO_ACCEPTCONN) outright, which rejected every inherited listener there, so off-Linux fd_is_listening falls back to a listener's observable shape (a SOCK_STREAM with no peer per getpeername). Lockfile also bumps anyhow/spin to clear unrelated cargo-deny advisories.

Dropped nothing — every finding held up. ARCHITECTURE.md and the README config example are updated in the same commit.

Testing

CI is green on all 8 checks (including macOS and the FreeBSD cross-compile). Locally: cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, debug + release builds, and the full workspace suite (crash matrix, wire races, stress) are green. 15 new unit tests cover the added behavior: foreign LISTEN_PID, non-socket/wrong-family/connected-socket rejection, flag normalization idempotency, transient-accept classification, 0600 bind + rename with no missing-path window, overlong sun_path, peer-uid lookup, EPERM-is-alive, and frame resumption across timeout boundaries.

No end-to-end swap has been exercised against a real daemon yet — happy to run one.

Link to Devin session: https://app.devin.ai/sessions/288b845379894277bddeaa82ba171e95
Requested by: @jaredLunde

Validate inherited listener descriptors (LISTEN_PID, socket-ness, family,
listening state) and normalize FD_CLOEXEC/O_NONBLOCK on adoption; keep the
accept loop alive across transient accept(2) failures; bind control and
trigger sockets 0600 via bind-then-rename with peer-uid authentication;
make control reads resumable across receive timeouts and writes bounded
and SIGPIPE-safe; treat EPERM from kill(pid,0) as alive; validate sun_path
length.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@jaredLunde jaredLunde self-assigned this Aug 19, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

…upported

macOS fails getsockopt(SO_ACCEPTCONN) for these sockets, so every inherited
listener was rejected there. Also bumps anyhow/spin to clear cargo-deny
advisories.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@jaredLunde
jaredLunde merged commit deefe50 into main Aug 19, 2026
8 checks passed
@devin-ai-integration devin-ai-integration Bot mentioned this pull request Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant