Skip to content

feat(init): replace AWS init and the Go pid1 with eiffel-init - #6

Open
patrislav wants to merge 5 commits into
feat/rust-eiffel-buildfrom
feat/rust-eiffel-init
Open

patrislav wants to merge 5 commits into
feat/rust-eiffel-buildfrom
feat/rust-eiffel-init

Conversation

@patrislav

@patrislav patrislav commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Inside the enclave two processes do one job. AWS's init.c mounts the filesystems, answers the parent's heartbeat and switches root, then hands off to our Go pid1, which spawns and supervises the customer's programs. That split means two languages, two build stages and two sets of behaviour to reason about for something that is a few hundred lines of work in total. This replaces both with one program.

Second of three stacked pull requests; merge the builder first. chronyd is still spawned as the first supervised child here, and the next pull request removes it.

What lands

  • A new eiffel-init crate, built static for musl, that runs the whole boot sequence: block every signal, mount devtmpfs and take the console, answer the heartbeat the parent waits on, read and unlink the config files, switch root, mount the filesystem set, spawn the programs and supervise them.
  • The heartbeat retries for about five seconds while the vsock transport finishes probing, and treats anything else as fatal. It runs before any slow work, because nitro-cli blocks on it and will report the enclave as failed without it.
  • Supervision is a single reaping loop that owns every child. On the first direct child exit, whatever the status, the init signals everything to terminate, waits up to two seconds so apps can flush, then reboots.
  • Unsafe code lives in one small module holding a single pre-exec closure that unblocks signals and starts a new session in the child. The rest of the crate denies it, and that denial was checked by deliberately breaking it.
  • Every failure writes a one-line reason to the console before acting on it. In an enclave the console is the only debugging channel there is.
  • The Go supervisor directory is deleted, along with the Dockerfile stage that built AWS's init. The build asserts the new binary needs no dynamic loader.

Migration for customers

The cmd file no longer accepts a /sbin/pid1 line, and that path no longer exists in the rootfs. List only your own programs. A stale cmd is rejected at first boot with a console message naming the problem rather than failing obscurely. The README carries the note.

Behavioural differences from today

  • Child output is inherited rather than piped, so ordering between processes is now whatever reaches the console, not what the Go copy goroutines produced.
  • A single cgroup v2 mount replaces the per-controller v1 loop. Nothing in the enclave uses cgroups, and v2 is what a current userspace expects.
  • Shutdown signals children and waits briefly instead of rebooting immediately.

Verification

  • Formatting, clippy with warnings denied, and the full workspace suite pass: nine builder tests and nine init tests.
  • The binary builds static for musl and carries no interpreter segment.
  • Two cache-free builds of the init stage produce an identical binary hash.
  • No host paths leak into the binary.
  • The mount sequence is expressed as a data table and asserted entry by entry, including that every mount is nosuid and noexec, that only devpts allows device nodes, and that no v1 cgroup mount exists.

Not verified

Nothing was booted. There is no Nitro hardware, and the machine this was built on cannot run the QEMU machine type that emulates one, so the boot test, the device assertions and the heartbeat plumbing are unrun. No PCR values were computed or compared.

The QEMU harness was reworked here. The arrangement tried first does not match what vhost-device-vsock actually offers, since its socket backend cannot answer a connection the guest initiates. The harness now follows QEMU's own documented setup. That change is reasoned from the upstream documentation, not from a run.

Reading PCRs back from inside the guest was left out rather than guessed at. It needs an attestation client and a request encoding that would have been recalled rather than read.

Impact

PCR0, PCR1 and PCR2 all move again. Re-pin from the PCR output.

🤖 Generated with Claude Code

patrislav and others added 4 commits November 2, 2026 17:02
One static musl binary that does what AWS's init.c and eiffel's Go pid1
did between them: block every signal, bring up devtmpfs and the console,
answer the vsock heartbeat nitro-cli waits for, read and unlink /cmd and
/env, switch into /rootfs, mount the filesystem set, then spawn chronyd
plus the listed programs and reap them from a single waitpid(-1) loop.
The first direct child to exit takes the enclave down: SIGTERM to all,
two seconds to flush, reboot.

Two deliberate differences from init.c: a single cgroup2 mount replaces
the per-controller cgroup v1 loop, and children inherit the console fds
instead of being piped through a copy thread each. There is no nsm.ko
handling at all; the driver is built into the kernel since PR #4.

Every failure writes one line to the console before acting on it. The
console is the only debugging channel an enclave has.

unsafe lives in src/sys.rs alone, which holds the pre_exec hook; the
crate denies it everywhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The init stage is now a cargo build of eiffel-init for musl instead of a
git clone and gcc of AWS's init.c, with --remap-path-prefix so nothing
about the build machine reaches PCR1, and the same PT_INTERP assertion
the chrony stage makes. /blobs/init keeps its name and path, so the
bootstrap layout only loses its rootfs/sbin/pid1 entry.

The builder stage grows a COPY of eiffel-init/ because cargo needs every
workspace member on disk to resolve the manifest, even to build one.

Verified reproducible: two --no-cache builds of the init stage produce
the same sha256.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The example's cmd loses its /sbin/pid1 line, which the new init rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rust job gains the static musl build with the no-PT_INTERP assertion
the image makes, so a change that breaks the enclave target fails before
anything spends eight minutes on a kernel. The reproducibility job
already hashes /init, which is now eiffel-init.

The boot test gets a second EIF of its own, whose only program is a
static /app/hello that prints the time, opens /dev/nsm and exits 0, so
the run can assert the whole sequence: the heartbeat answered, the
program started, its output on the console, and the shutdown. The
example EIF could only ever prove the heartbeat.

The harness switches to vhost-device-vsock's vsock backend
(--forward-cid=1) and a host listener on the vsock loopback. The
previous version sent a "LISTEN 9000" line over the UDS backend, which
is not part of its protocol: that backend implements Firecracker's
hybrid vsock, where a host listener is a socket named <uds-path>_<port>
and CONNECT only goes host-to-guest. QEMU's own nitro-enclave
documentation describes the arrangement used here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@patrislav
patrislav force-pushed the feat/rust-eiffel-init branch from b3b1acc to 07cc646 Compare November 2, 2026 16:15
Brings up the workflow hardening. The only conflict was the boot-test step
in enclave.yml, where this branch had changed the EIF it boots and the
mount it needs: kept both, with the Fedora digest and the
vhost-device-vsock version pin from below.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@patrislav
patrislav added this pull request to stack #8 November 2, 2026 17:56
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