Skip to content

Add trellis vm trust for self-signed SSL certs#682

Merged
retlehs merged 7 commits into
masterfrom
trust-cmd
May 3, 2026
Merged

Add trellis vm trust for self-signed SSL certs#682
retlehs merged 7 commits into
masterfrom
trust-cmd

Conversation

@retlehs

@retlehs retlehs commented May 2, 2026

Copy link
Copy Markdown
Member

Summary

Replaces the manual 6-step ritual for trusting the Lima VM's self-signed cert with a single trellis vm trust command. Pulls the cert and key out of the VM, exports them to ~/.local/share/trellis/ssl/<vm>-<hash>/, and trusts the cert in the macOS login keychain and every Firefox profile so browsers and host-side tooling stop showing warnings.

Commands

  • trellis vm trust — trust each ssl.enabled: true && provider: self-signed site. Flags: --site, --no-export-key, --trust-system (Linux only).
  • trellis vm untrust — reverse trust entries this project added. Flag: --site.
  • trellis vm trust paths — print the host paths of the exported cert + key per site.

Behavior

  • Re-runs are safe and quiet. If the cert is already trusted, the command prints already trusted (fingerprint match, skipped) and does nothing. If something has drifted (manual deletion, regenerated cert, etc.), it re-applies trust and tells you why.
  • Untrust only removes what vm trust added, leaving manually trusted certs in your keychain alone. Failures preserve state so a retry is safe.
  • Firefox is covered when certutil is on PATH (from brew install nss / apt install libnss3-tools); if it's missing the command prints a one-time install hint instead of silently skipping.
  • Two projects with the same site name (e.g. example.com) don't collide — entries are scoped per project.

Example output

$ trellis vm trust
roots.io: trusted
  - macOS login keychain
  - Firefox (3m3ekuyx.default-release)
  - Firefox (nkqpcx5m.default)

Exported certs and keys live under /Users/ben/.local/share/trellis/ssl/roots.dev-bee136b5

Follow up

Update the docs with the new commands after this PR lands in a new release

🤖 Generated with Claude Code

@retlehs retlehs self-assigned this May 2, 2026
@retlehs
retlehs requested a review from swalkinshaw May 2, 2026 23:34
Automates extracting the per-site self-signed cert and key from the Lima
VM and trusting the cert on the host (macOS login keychain + Firefox NSS
on macOS and Linux). Replaces the manual 6-step ritual previously
documented for Lima users.

Adds `vm trust`, `vm untrust`, and `vm trust paths` subcommands. Trust
state is recorded in ~/.local/share/trellis/state/trusted_certs.json
with a process-level flock so concurrent runs serialize. Verify checks
the live trust setting (macOS verify-cert, Linux system bundle, NSS
fingerprint) so drift triggers a re-trust instead of silently passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
swalkinshaw and others added 6 commits May 2, 2026 21:46
Compute macOS specific one within macOS specific code and only expose a
single `Fingerprint` field.
Lift the read-cert / fingerprint / decide-skip-or-retrust / untrust-old /
trust-new / record-state sequence out of cmd/vm_trust.go into
trust.ApplySite, plus the symmetric trust.RevokeSite for vm_untrust.go.
Path/label helpers (ProjectID, Label, ExportDir) move alongside since
the package owns the on-disk layout.

The cmd files now hold only CLI concerns: flag parsing, site selection,
VM cert fetching, and UI formatting. vm_trust.go Run drops from ~230
to ~100 lines; vm_untrust.go from ~135 to ~75. Trust logic, state
mutation, and path conventions are now testable without spawning
security/certutil from a cmd test.

Drive-by: the private key is now written via a temp-file + rename
helper, closing a brief window where an existing key file at 0o644
would hold the new private bytes before Chmod ran.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The lock guarded a vanishingly rare race: two concurrent `trellis vm
trust` runs against the same state file. State.Save already does
temp-write + atomic rename, so concurrent saves can't corrupt the file
— the realistic hazard is a lost update across a load/modify/save
window, which requires the user to run the command twice in parallel
by hand. The Linux user CA bundle rebuild has the same shape.

If lost updates ever show up in practice the right fix is a short-lived
flock inside State.Save, not a multi-second lock around the whole trust
loop (which on Linux can include blocking sudo prompts).

Removing it deletes lock_unix.go, the windows no-op stub, and the
AcquireLock calls in both cmd files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Covers the deterministic part of the package:

- paths_test: ProjectID stable + distinct, Label format, ExportDir
  separates forks of the same instance name
- format_test: FormatLocation across macOS / Linux / NSS / unknown
- store_test: VerifyResult.AllAccounted (Missing breaks, Unknown counts,
  lengths must match)
- linux_test: safeFilename (dot/dash/underscore preserved, slashes and
  control chars and unicode replaced)
- apply_test: ApplySite via a recordingStore Store fake — fresh trust,
  fingerprint-match skip, drift re-trust, fingerprint-changed re-trust,
  untrust-error state-preservation, trust-error partial state,
  empty-cert early exit, key file mode 0o600, key file removal when
  KeyPEM is empty, CertPath refresh on skip; RevokeSite success + error;
  writeFileAtomic mode invariant on existing 0o644 file

Coverage for pkg/trust goes from ~7% to ~27%, but the relevant number is
that ApplySite is now at 87% and RevokeSite at 100%. Remaining 0% is in
macos/linux/nss.go where exec-binding makes them Tier 4 work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Covers the platform store layer's pure-FS and pure-string parts. Uses
t.Setenv(\"XDG_DATA_HOME\") and t.Setenv(\"HOME\") to point app_paths
and Firefox profile lookup at tempdirs — no production refactor needed
since both already respect those env vars.

- state_test: Load on missing/empty/corrupt JSON, Save+Load round-trip
  preserves all fields including AddedAt, Save leaves no temp files
  behind from the atomic-rename path
- linux_test: rebuildBundle concatenates user CAs in dir order, injects
  trailing newlines so adjacent PEM blocks parse, removes the bundle
  when the dir is empty; pemFileContainsFingerprint scans all PEM
  blocks; fileFingerprintMatches handles match/mismatch/missing-file
- nss_test: isNSSNicknameNotFound covers the certutil error variants;
  isNSSProfile detects cert9.db (modern) and cert8.db (legacy);
  firefoxProfileDirs walks the platform-appropriate parent
- macos_test: isKeychainNotFound covers the security CLI error
  variants; sha1HexFromCertFile matches crypto/sha1 over DER and
  returns empty on missing/invalid input

Coverage for pkg/trust now 47.6% (was ~27% after Tier 2, ~7% baseline).
The remaining 0% surface is exec-bound: linuxStore.Trust/Untrust/
Verify, macOSStore.Trust/Untrust/Verify, nssTrust/Untrust/Verify,
keychainTrustsCertForSSL, keychainHasFingerprint. Those are Tier 4
work that needs an exec runner shim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a runner interface (Run, RunStdin, Lookup) and a default execRunner
wrapping os/exec. Each platform store and a new nssHelper take a runner
so tests can inject a recording fake instead of spawning real binaries.

The fake (runner_test.go) registers single-shot canned responses keyed
by program name + an args prefix and records every call so tests can
assert on argument shape and call sequence.

Tests cover the previously-zero-coverage branches:
- macOS: Trust calls add-trusted-cert with -p ssl / -r trustRoot;
  Untrust deletes when find-certificate confirms presence; skips
  delete when fingerprint is already gone; treats "could not be
  found" as success; surfaces other delete errors; Verify maps
  verify-cert outcomes to Present / Missing (not-trusted) / Unknown
- Linux: Trust writes user CA + bundle without sudo; --trust-system
  invokes sudo tee + sudo update-ca-certificates; system location is
  recorded even when update-ca-certificates fails so untrust can clean
  up; Untrust removes user CA file; system Untrust runs sudo rm -f and
  update-ca-certificates --fresh; Verify requires both file and bundle
- NSS: disabled is a no-op; reports CertutilMissing without erroring;
  reports FirefoxFound=false when no profiles exist; happy path runs
  -D then -A in order; -A failure surfaces; Untrust treats nickname-
  not-found as success; certutil-missing-but-records-exist returns
  error; Verify classifies present / missing / unknown

Drive-by fix: rebuildBundle would error when called with the user CA
dir absent and no bundle present (os.Remove on missing file). Now
guarded with os.IsNotExist so untrust paths that bypass the user CA
location don't panic on a fresh install.

Coverage for pkg/trust now 81.2%, up from 47.6%. Remaining 0% is
execRunner itself (the prod-only os/exec wrapper) and Default()
(GOOS dispatcher).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@retlehs
retlehs merged commit b304c8c into master May 3, 2026
8 checks passed
@retlehs
retlehs deleted the trust-cmd branch May 3, 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.

2 participants