Skip to content

Commit b304c8c

Browse files
retlehsclaudeswalkinshaw
authored
Add trellis vm trust for self-signed SSL certs (#682)
* Add `trellis vm trust` for self-signed SSL certs 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> * Simplify fingerprinting Compute macOS specific one within macOS specific code and only expose a single `Fingerprint` field. * Move per-site trust orchestration into pkg/trust 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> * Drop the trust-state flock 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> * Add unit tests for trust path helpers and ApplySite/RevokeSite 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> * Add filesystem-only tests for state, linux bundle, NSS, macOS helpers 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> * Test platform stores via injectable exec runner 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> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Scott Walkinshaw <scott.walkinshaw@gmail.com>
1 parent aaf00ca commit b304c8c

30 files changed

Lines changed: 4032 additions & 0 deletions

cmd/vm_trust.go

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package cmd
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"path/filepath"
7+
"runtime"
8+
"sort"
9+
"strings"
10+
11+
"github.com/hashicorp/cli"
12+
"github.com/roots/trellis-cli/app_paths"
13+
"github.com/roots/trellis-cli/pkg/trust"
14+
"github.com/roots/trellis-cli/trellis"
15+
)
16+
17+
type VmTrustCommand struct {
18+
UI cli.Ui
19+
Trellis *trellis.Trellis
20+
flags *flag.FlagSet
21+
noExportKey bool
22+
trustSystem bool
23+
site string
24+
}
25+
26+
func NewVmTrustCommand(ui cli.Ui, trellis *trellis.Trellis) *VmTrustCommand {
27+
c := &VmTrustCommand{UI: ui, Trellis: trellis}
28+
c.init()
29+
return c
30+
}
31+
32+
func (c *VmTrustCommand) init() {
33+
c.flags = flag.NewFlagSet("", flag.ContinueOnError)
34+
c.flags.Usage = func() { c.UI.Info(c.Help()) }
35+
c.flags.BoolVar(&c.noExportKey, "no-export-key", false, "Skip exporting the private key to the host.")
36+
c.flags.BoolVar(&c.trustSystem, "trust-system", false, "Linux only: also write to /usr/local/share/ca-certificates and run sudo update-ca-certificates.")
37+
c.flags.StringVar(&c.site, "site", "", "Trust only the named site instead of every self-signed site.")
38+
}
39+
40+
func (c *VmTrustCommand) Run(args []string) int {
41+
if err := c.Trellis.LoadProject(); err != nil {
42+
c.UI.Error(err.Error())
43+
return 1
44+
}
45+
46+
c.Trellis.CheckVirtualenv(c.UI)
47+
48+
if err := c.flags.Parse(args); err != nil {
49+
return 1
50+
}
51+
52+
if err := (&CommandArgumentValidator{required: 0, optional: 0}).validate(c.flags.Args()); err != nil {
53+
c.UI.Error(err.Error())
54+
c.UI.Output(c.Help())
55+
return 1
56+
}
57+
58+
if c.trustSystem && runtime.GOOS != "linux" {
59+
c.UI.Error("Error: --trust-system is only supported on Linux.")
60+
return 1
61+
}
62+
63+
manager, err := newVmManager(c.Trellis, c.UI)
64+
if err != nil {
65+
c.UI.Error("Error: " + err.Error())
66+
return 1
67+
}
68+
69+
sites := c.selectSites()
70+
if len(sites) == 0 {
71+
if c.site != "" {
72+
c.UI.Error(fmt.Sprintf("Error: site %q not found, or it is not configured with ssl.enabled and ssl.provider: self-signed.", c.site))
73+
return 1
74+
}
75+
c.UI.Info("No sites in the development environment have ssl.enabled with provider: self-signed. Nothing to trust.")
76+
return 0
77+
}
78+
79+
store, err := trust.Default(trust.Options{TrustSystem: c.trustSystem})
80+
if err != nil {
81+
c.UI.Error("Error: " + err.Error())
82+
return 1
83+
}
84+
85+
state, err := trust.Load()
86+
if err != nil {
87+
c.UI.Error("Error reading trust state: " + err.Error())
88+
return 1
89+
}
90+
91+
instanceName, err := c.Trellis.GetVmInstanceName()
92+
if err != nil {
93+
c.UI.Error(err.Error())
94+
return 1
95+
}
96+
97+
exitCode := 0
98+
firefoxHintShown := false
99+
100+
for _, name := range sortedSiteNames(sites) {
101+
certPEM, err := manager.ReadRootFile("/etc/nginx/ssl/" + name + ".cert")
102+
if err != nil {
103+
c.UI.Error(fmt.Sprintf("%s: failed to read cert from VM: %s", name, err))
104+
c.UI.Error(fmt.Sprintf(" (does the cert exist at /etc/nginx/ssl/%s.cert? run `trellis provision development` if SSL was just enabled)", name))
105+
exitCode = 1
106+
continue
107+
}
108+
109+
var keyPEM []byte
110+
if !c.noExportKey {
111+
keyData, keyErr := manager.ReadRootFile("/etc/nginx/ssl/" + name + ".key")
112+
if keyErr != nil {
113+
c.UI.Warn(fmt.Sprintf("%s: failed to export private key from VM: %s", name, keyErr))
114+
} else if len(keyData) == 0 {
115+
c.UI.Warn(fmt.Sprintf("%s: VM returned an empty private key; skipping key export.", name))
116+
} else {
117+
keyPEM = keyData
118+
}
119+
}
120+
121+
out := trust.ApplySite(store, state, trust.SiteInput{
122+
Project: c.Trellis.Path,
123+
Site: name,
124+
InstanceName: instanceName,
125+
BaseDir: app_paths.DataDir(),
126+
CertPEM: certPEM,
127+
KeyPEM: keyPEM,
128+
})
129+
130+
if out.Err != nil {
131+
c.UI.Error(fmt.Sprintf("%s: %s", name, out.Err))
132+
if out.ErrHint != "" {
133+
c.UI.Error(" (" + out.ErrHint + ")")
134+
}
135+
exitCode = 1
136+
continue
137+
}
138+
139+
c.UI.Info(fmt.Sprintf("%s: %s", name, out.Verb))
140+
for _, loc := range out.Locations {
141+
c.UI.Info(" - " + trust.FormatLocation(loc))
142+
}
143+
if out.NSS.FirefoxFound && out.NSS.CertutilMissing {
144+
firefoxHintShown = true
145+
}
146+
}
147+
148+
if err := state.Save(); err != nil {
149+
c.UI.Error("Error saving trust state: " + err.Error())
150+
exitCode = 1
151+
}
152+
153+
c.printSummary(trust.ExportDir(app_paths.DataDir(), instanceName, c.Trellis.Path), firefoxHintShown)
154+
155+
return exitCode
156+
}
157+
158+
func (c *VmTrustCommand) selectSites() map[string]*trellis.Site {
159+
out := map[string]*trellis.Site{}
160+
env := c.Trellis.Environments["development"]
161+
if env == nil {
162+
return out
163+
}
164+
for name, site := range env.WordPressSites {
165+
if c.site != "" && name != c.site {
166+
continue
167+
}
168+
if !site.SslEnabled() || site.SslProvider() != "self-signed" {
169+
continue
170+
}
171+
out[name] = site
172+
}
173+
return out
174+
}
175+
176+
func (c *VmTrustCommand) printSummary(exportDir string, firefoxHint bool) {
177+
c.UI.Info("")
178+
c.UI.Info(fmt.Sprintf("Exported certs and keys live under %s", exportDir))
179+
if runtime.GOOS == "linux" {
180+
c.UI.Info(fmt.Sprintf("Set NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, REQUESTS_CA_BUNDLE to %s for tools that read a single bundle.", filepath.Join(app_paths.DataDir(), "ca-bundle.pem")))
181+
c.UI.Info("Tools with statically-linked roots (some Go binaries, Java) ignore env-var trust roots; use --trust-system if you need system-wide trust.")
182+
}
183+
if firefoxHint {
184+
c.UI.Warn("")
185+
c.UI.Warn("Firefox is installed but `certutil` is not on PATH, so Firefox was not auto-trusted.")
186+
switch runtime.GOOS {
187+
case "darwin":
188+
c.UI.Warn(" Install with: brew install nss")
189+
case "linux":
190+
c.UI.Warn(" Install with: sudo apt install libnss3-tools (or your distro's equivalent)")
191+
}
192+
c.UI.Warn("Then re-run `trellis vm trust`.")
193+
c.UI.Warn("Alternative: in Firefox set `security.enterprise_roots.enabled = true` in about:config to use the system trust store.")
194+
}
195+
}
196+
197+
func sortedSiteNames(sites map[string]*trellis.Site) []string {
198+
names := make([]string, 0, len(sites))
199+
for name := range sites {
200+
names = append(names, name)
201+
}
202+
sort.Strings(names)
203+
return names
204+
}
205+
206+
func (c *VmTrustCommand) Synopsis() string {
207+
return "Trusts the VM's self-signed SSL certificates on the host."
208+
}
209+
210+
func (c *VmTrustCommand) Help() string {
211+
helpText := `
212+
Usage: trellis vm trust [options]
213+
214+
Extracts each self-signed SSL cert generated by Trellis for the development
215+
environment, exports the cert and key to ~/.local/share/trellis/ssl/<project>/,
216+
and trusts the cert in the host's platform trust stores.
217+
218+
On macOS the cert is added to the user's login keychain. On Linux the cert is
219+
written to a per-user CA dir and a combined bundle.
220+
221+
When the certutil binary is available (from nss / libnss3-tools), the cert is
222+
also added to every Firefox profile's NSS database.
223+
224+
This command requires the VM to be running.
225+
226+
Options:
227+
--site Trust only this site instead of every self-signed site.
228+
--no-export-key Skip writing the private key to the host.
229+
--trust-system Linux only: also install the cert system-wide
230+
(writes to /usr/local/share/ca-certificates and runs
231+
sudo update-ca-certificates).
232+
-h, --help Show this help.
233+
`
234+
return strings.TrimSpace(helpText)
235+
}

cmd/vm_trust_paths.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package cmd
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/hashicorp/cli"
9+
"github.com/roots/trellis-cli/pkg/trust"
10+
"github.com/roots/trellis-cli/trellis"
11+
)
12+
13+
type VmTrustPathsCommand struct {
14+
UI cli.Ui
15+
Trellis *trellis.Trellis
16+
flags *flag.FlagSet
17+
site string
18+
}
19+
20+
func NewVmTrustPathsCommand(ui cli.Ui, trellis *trellis.Trellis) *VmTrustPathsCommand {
21+
c := &VmTrustPathsCommand{UI: ui, Trellis: trellis}
22+
c.init()
23+
return c
24+
}
25+
26+
func (c *VmTrustPathsCommand) init() {
27+
c.flags = flag.NewFlagSet("", flag.ContinueOnError)
28+
c.flags.Usage = func() { c.UI.Info(c.Help()) }
29+
c.flags.StringVar(&c.site, "site", "", "Show only the named site.")
30+
}
31+
32+
func (c *VmTrustPathsCommand) Run(args []string) int {
33+
if err := c.Trellis.LoadProject(); err != nil {
34+
c.UI.Error(err.Error())
35+
return 1
36+
}
37+
38+
c.Trellis.CheckVirtualenv(c.UI)
39+
40+
if err := c.flags.Parse(args); err != nil {
41+
return 1
42+
}
43+
44+
if err := (&CommandArgumentValidator{required: 0, optional: 0}).validate(c.flags.Args()); err != nil {
45+
c.UI.Error(err.Error())
46+
c.UI.Output(c.Help())
47+
return 1
48+
}
49+
50+
state, err := trust.Load()
51+
if err != nil {
52+
c.UI.Error("Error reading trust state: " + err.Error())
53+
return 1
54+
}
55+
56+
project := c.Trellis.Path
57+
entries := state.EntriesForProject(project)
58+
if c.site != "" {
59+
filtered := entries[:0]
60+
for _, e := range entries {
61+
if e.Site == c.site {
62+
filtered = append(filtered, e)
63+
}
64+
}
65+
entries = filtered
66+
}
67+
68+
if len(entries) == 0 {
69+
c.UI.Info(fmt.Sprintf("no trust entries for %s. Run `trellis vm trust` first.", project))
70+
return 0
71+
}
72+
73+
for i, entry := range entries {
74+
if i > 0 {
75+
c.UI.Info("")
76+
}
77+
key := entry.KeyPath
78+
if key == "" {
79+
key = "<not exported>"
80+
}
81+
c.UI.Info(entry.Site)
82+
c.UI.Info(" cert: " + entry.CertPath)
83+
c.UI.Info(" key: " + key)
84+
}
85+
86+
return 0
87+
}
88+
89+
func (c *VmTrustPathsCommand) Synopsis() string {
90+
return "Prints the host paths of the exported SSL cert and key for each trusted site."
91+
}
92+
93+
func (c *VmTrustPathsCommand) Help() string {
94+
helpText := `
95+
Usage: trellis vm trust paths [options]
96+
97+
Prints one line per trusted site for the current project, showing where the
98+
exported cert and private key live on the host. Use these paths to wire up
99+
host-side tooling (Vite, Playwright, curl) against the same cert the VM
100+
serves.
101+
102+
Options:
103+
--site Show only the named site.
104+
-h, --help Show this help.
105+
`
106+
return strings.TrimSpace(helpText)
107+
}

0 commit comments

Comments
 (0)