Grove 1.8: the daemon stops being root
Grove 1.8 hands socket binding to launchd and systemd, so the daemon that serves your local sites has never been root for a single instruction — plus a doctor that re-checks the invariants and a regression test for the CA name constraint.
Grove has run as root since the first release, and for a reason that sounded unarguable: it serves your sites on ports 80 and 443 and answers DNS on 53, and binding a port below 1024 needs privilege. So the daemon was root. Every request that came in, every php-fpm it spawned, every certificate it signed — done by a process that could do anything on your machine.
We spent a lot of effort in 1.5.0 making that root process careful. Privilege dropping on every spawn. A CA constrained to your TLD so a leaked key couldn't sign google.com. An IPC socket locked down so a script on your laptop couldn't ask root for favours. It was good work, and it was all in service of a premise we hadn't questioned: that the daemon had to be root to begin with.
It doesn't. 1.8 is what happens when you pull that thread.
The one sentence
Binding a port below 1024 needs privilege. Serving on one does not.
A socket is a file descriptor. Whoever binds it needs root; whoever holds it afterwards needs nothing at all. And your machine already has a process that's root at boot and whose whole job is starting other processes — launchd on macOS, systemd on Linux. So let it bind 53, 80 and 443, and hand the descriptors to a daemon that has never been root for a single instruction.
That's the release. Everything else follows from it.
How it works
grove install now writes a Sockets dictionary into the launchd plist, and on Linux a companion grove.socket unit beside the service:
<key>Sockets</key>
<dict>
<key>https</key>
<dict>
<key>SockServiceName</key><string>443</string>
<key>SockNodeName</key><string>127.0.0.1</string>
</dict>
<!-- http, dns-tcp, dns-udp likewise -->
</dict>
<key>UserName</key><string>kh</string>
launchd binds those four ports while it is root, starts the daemon as kh, and the daemon asks for each descriptor before it would otherwise bind:
$ grove doctor
privileges running as kh (uid 501); sockets delivered: :53/udp :53/tcp :80 :443
grove-home ~/.grove owned by kh, mode 755
ipc-socket ~/.grove/grove.sock mode 660
...
Same accept loops as before, on a descriptor the daemon didn't create. From its first instruction, the process serving your sites is you.
What still needs root
Three things, all one-off and all visible: writing the unit file, writing /etc/resolver/<tld> so the system sends .test queries to Grove's DNS, and adding the CA to the system trust store. Each of those happens inside sudo grove install or sudo grove ca rotate, when you typed sudo and can see why. Nothing that happens while a request is being served is privileged any more.
Things that quietly got better
Some consequences you wouldn't guess from the headline.
grove restart stopped racing. It used to ask launchd to kickstart the system job — which an unprivileged command can't do. Now the daemon simply exits and KeepAlive brings it back, and because launchd keeps holding the listening sockets across the restart, the ports are never released. There was a window where a restart could leave the daemon serving nothing; that window doesn't exist any more, because there's nothing to re-bind.
Privilege dropping turned itself off. Every spawn — php-fpm, PostgreSQL, MySQL, Redis — used to go through machinery that dropped from root to you. That machinery now finds it isn't root and does nothing; the children simply inherit your identity. Less code on the hot path, and one less place for a mistake.
The CA key becomes yours. The daemon signs leaf certificates. The daemon is no longer root. So a key only root can read is HTTPS that doesn't work — the key is now 0600, owned by you. That's a trade, and we've written it down honestly in ARCHITECTURE.md under Trust boundaries: code running as you can now read the key. What bounds that is the name constraint (it can only sign under your TLD) and the fact that those names resolve to loopback. What you get in exchange is that the root-privileged IPC surface that same code could already reach is gone, entirely. We think that's a good trade. You're allowed to disagree, and the doc gives you enough to decide.
Upgrading, honestly
Nothing happens until you ask. Grove updates its binary without rewriting the unit, so a 1.8 daemon on an old install still runs as root with the old plist, and everything works exactly as it did. The daemon doesn't insist on delivered sockets — a port nobody handed over is bound the old way — because insisting would have broken every existing install the day it shipped.
One command switches a machine over:
$ sudo grove install
writing /Library/LaunchDaemons/com.elyracode.grove.plist (sockets: 53 80 443, user: kh)
chown -R kh ~/.grove (3,412 files)
restarting daemon
$ grove doctor
privileges running as kh (uid 501); sockets delivered: :53/udp :53/tcp :80 :443
That chown -R is the step to know about. On a machine in daily use, the Grove home is thousands of files — config.toml, every PHP build, the CA key — and they were root's. grove install hands them over in the same step, so it's one deliberate chown rather than a surprise permission error at nine on Monday. If you somehow end up with a user daemon and a root-owned tree, it says so at startup and grove doctor repeats it on the grove-home line.
Rolling back is safe. A root daemon reads a user-owned home without complaint, so installing an older Grove and running its sudo grove install puts everything back. We checked.
Re-checking what we claimed
1.5.0 made promises. 1.8 makes them checkable.
grove doctor re-runs the invariants every time
Four new lines. ipc-socket: the daemon's socket isn't world-accessible. grove-home: the tree it reads binaries from isn't world-writable. site-certs: how many leaves, soonest expiry, expired ones flagged as reissued on next request. And trust-store, which is the interesting one:
trust-store ⚠ two Grove CAs trusted. Current: SHA1 4A:2F:… (constrained to .test)
Stale: SHA1 9C:11:… (no name constraint)
remove: security delete-certificate -Z 9C11…
This catches something grove ca rotate without sudo used to leave behind: an old, unconstrained CA the machine still trusts, able to sign any hostname. Note the word trusts — a rotate strips the old CA's trust settings but leaves the certificate in the keychain, so the check asks the system's trust evaluator, not the keychain contents. An inert leftover raises nothing; a believed one gets named, with its own removal command.
Likewise root-ca-scope now reads the NameConstraints extension off the certificate itself instead of trusting the ca-meta.json sitting beside it — because a note left over from an earlier CA can't be allowed to vouch for one that constrains nothing.
The central claim, as a test
The most important sentence in 1.5.0 was this: a leaked Grove CA key cannot mint a certificate for anything outside your TLD that a machine trusting the CA will accept. It lived in two doc comments.
It's now a regression test through rustls + webpki — the same verifier, the same rules, browsers apply. Three parts, and the middle one is the clever bit:
A Grove-signed leaf for
google.comis refused.The same leaf, signed by a CA without the constraint, is accepted — so the refusal in (1) is the extension's doing, not an expired date or a broken chain masquerading as success.
The certificates Grove actually issues — wildcard subdomains included — are accepted.
A fourth test pins that the constraint is a DNS suffix: myapp.localtest is outside .test. And the request-path sanitizer — the one function standing between an attacker's URL and document_root.join(…) — gets property tests: for any string at all, the result is relative, has only ordinary components, stays under the root, and sanitizing twice changes nothing. Thousands of traversal-shaped inputs, generated, not hand-picked.
One honest gap: runtime-hash verification isn't among the invariants yet. There's no recorded digest to compare a PHP build against until runtimes are content-addressed. It's on the list; it isn't in this release; the doctor doesn't pretend otherwise.
Also: ElyraSQL 1.11.3
The bundled ElyraSQL moves from 1.11.2 to 1.11.3, fixing two things a Grove site could actually hit — SET @var inside a stored procedure vanishing when CALL returned, and deeply nested expressions overflowing a small stack instead of being refused cleanly. It also gains UTC_TIMESTAMP() and CONVERT_TZ(), freezes NOW() to one instant per statement, and makes || honour PIPES_AS_CONCAT. grove service install elyrasql fetches the new build into a versioned directory; your database file is untouched.
Why this matters
Local development tools ask for root casually. sudo this, sudo that, a daemon that runs as root because it always has. Each time, the justification is a port number, and each time the real cost — that every bug in that daemon is a root bug — is somewhere off the balance sheet.
Grove 1.8 moves it onto the sheet, and then pays it. The daemon that handles every request to your sites is you. The three things that need root are named and happen when you type sudo. And the promises we made about the certificate authority are no longer prose; they run in CI, through the verifier your browser uses.
Get it
Grove 1.8.0 is at elyracode.com/grove. Upgrade the binary as usual, then — when you're ready — sudo grove install once, and grove doctor to see the privileges line say your name.