Skip to content

Commit d747b4d

Browse files
committed
Add certificate install and uninstall commands
`trellis certificate install` will install the root certificate from a Trellis development VM (by default) into your computer's system truststore. This means local development HTTPS sites will be considered secure by web browsers and won't show insecure warnings. `trellis certificate uninstall` removes the previously installed certificate from your computer's system "truststore".
1 parent c63ec05 commit d747b4d

File tree

8 files changed

+1621
-18
lines changed

8 files changed

+1621
-18
lines changed

certificates/main.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package certificates
2+
3+
import (
4+
"crypto/tls"
5+
"crypto/x509"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/smallstep/certinfo"
13+
"github.com/smallstep/truststore"
14+
)
15+
16+
func RootCertificatePath(configPath string) string {
17+
return filepath.Join(configPath, "root_certificates", "root_ca.crt")
18+
}
19+
20+
func InstallFile(path string) error {
21+
opts := []truststore.Option{}
22+
opts = append(opts, truststore.WithFirefox(), truststore.WithJava(), truststore.WithDebug())
23+
24+
if trustErr := truststore.InstallFile(path, opts...); trustErr != nil {
25+
switch err := trustErr.(type) {
26+
case *truststore.CmdError:
27+
return fmt.Errorf("failed to execute \"%s\" failed with: %v", strings.Join(err.Cmd().Args, " "), err.Error())
28+
default:
29+
return fmt.Errorf("failed to install %s: %v", path, err.Error())
30+
}
31+
}
32+
33+
return nil
34+
}
35+
36+
func UninstallFile(path string) error {
37+
opts := []truststore.Option{}
38+
opts = append(opts, truststore.WithFirefox(), truststore.WithJava())
39+
40+
if trustErr := truststore.UninstallFile(path, opts...); trustErr != nil {
41+
switch err := trustErr.(type) {
42+
case *truststore.CmdError:
43+
return fmt.Errorf("failed to execute \"%s\" failed with: %v", strings.Join(err.Cmd().Args, " "), err.Error())
44+
default:
45+
return fmt.Errorf("failed to uninstall %s: %v", path, err.Error())
46+
}
47+
}
48+
49+
return nil
50+
}
51+
52+
func ShortText(cert *x509.Certificate) (info string, err error) {
53+
if s, err := certinfo.CertificateShortText(cert); err == nil {
54+
return s, nil
55+
}
56+
57+
return "", fmt.Errorf("Error reading certificate: %v", err)
58+
}
59+
60+
func FetchRootCertificate(path string, host string) (cert []byte, err error) {
61+
tr := &http.Transport{
62+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
63+
}
64+
client := &http.Client{Transport: tr}
65+
66+
res, err := client.Get(fmt.Sprintf("https://%s:8443/roots.pem", host))
67+
if err != nil {
68+
return nil, fmt.Errorf("Could not fetch root certificate from server: %v", err)
69+
}
70+
71+
body, err := io.ReadAll(res.Body)
72+
res.Body.Close()
73+
74+
if err != nil {
75+
return nil, fmt.Errorf("Could not read response from server: %v", err)
76+
}
77+
78+
if res.StatusCode != 200 {
79+
return nil, fmt.Errorf("Could not fetch root certificate from server: %d status code received", res.StatusCode)
80+
}
81+
82+
return body, nil
83+
}
84+
85+
func Trusted(cert *x509.Certificate) bool {
86+
chains, err := cert.Verify(x509.VerifyOptions{})
87+
return len(chains) > 0 && err == nil
88+
}

cmd/certificate_install.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package cmd
2+
3+
import (
4+
"crypto/x509"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/mitchellh/cli"
12+
"github.com/posener/complete"
13+
"github.com/roots/trellis-cli/certificates"
14+
"github.com/roots/trellis-cli/trellis"
15+
"go.step.sm/crypto/pemutil"
16+
)
17+
18+
type CertificateInstallCommand struct {
19+
UI cli.Ui
20+
Trellis *trellis.Trellis
21+
flags *flag.FlagSet
22+
path string
23+
}
24+
25+
func NewCertificateInstallCommand(ui cli.Ui, trellis *trellis.Trellis) *CertificateInstallCommand {
26+
c := &CertificateInstallCommand{UI: ui, Trellis: trellis}
27+
c.init()
28+
return c
29+
}
30+
31+
func (c *CertificateInstallCommand) init() {
32+
c.flags = flag.NewFlagSet("", flag.ContinueOnError)
33+
c.flags.Usage = func() { c.UI.Info(c.Help()) }
34+
c.flags.StringVar(&c.path, "path", "", "Local path to custom root certificate to install")
35+
}
36+
37+
func (c *CertificateInstallCommand) Run(args []string) int {
38+
if err := c.Trellis.LoadProject(); err != nil {
39+
c.UI.Error(err.Error())
40+
return 1
41+
}
42+
43+
c.Trellis.CheckVirtualenv(c.UI)
44+
45+
commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 1}
46+
commandArgumentErr := commandArgumentValidator.validate(args)
47+
if commandArgumentErr != nil {
48+
c.UI.Error(commandArgumentErr.Error())
49+
c.UI.Output(c.Help())
50+
return 1
51+
}
52+
53+
environment := "development"
54+
55+
if len(args) == 1 {
56+
environment = args[0]
57+
environmentErr := c.Trellis.ValidateEnvironment(environment)
58+
if environmentErr != nil {
59+
c.UI.Error(environmentErr.Error())
60+
return 1
61+
}
62+
}
63+
64+
cert, err := c.fetchRootCertificate(environment)
65+
if err != nil {
66+
c.UI.Error("Error fetching root certificate from server:")
67+
c.UI.Error(err.Error())
68+
c.UI.Error("The server (likely Vagrant virtual machine) must be running and have been provisioned with an SSL enabled site.")
69+
return 1
70+
}
71+
72+
if certificates.Trusted(cert) {
73+
c.UI.Info("Root certificate already installed and trusted")
74+
return 0
75+
}
76+
77+
if err := certificates.InstallFile(c.path); err != nil {
78+
c.UI.Error("Error installing root certificate to truststore:")
79+
c.UI.Error(err.Error())
80+
return 1
81+
}
82+
83+
c.UI.Info(fmt.Sprintf("Certificate %s has been installed.\n", c.path))
84+
if text, err := certificates.ShortText(cert); err == nil {
85+
c.UI.Info(text)
86+
}
87+
88+
c.UI.Info("Note: your web browser(s) will need to be restarted for this to take effect.")
89+
90+
return 0
91+
}
92+
93+
func (c *CertificateInstallCommand) Synopsis() string {
94+
return "Installs a root certificate in the system truststore"
95+
}
96+
97+
func (c *CertificateInstallCommand) Help() string {
98+
helpText := `
99+
Usage: trellis certificate install [options] [ENVIRONMENT]
100+
101+
Installs a root certificate in the system truststore. This allows your local
102+
computer to trust the "self-signed" root CA (certificate authority) that Trellis
103+
uses in development which avoids insecure warnings in your web browsers.
104+
105+
By default this integrates with a Trellis server/VM and requires that it's running.
106+
However, the --path option can be used to specify any root certificate making this
107+
command useful for non-Trellis use cases too.
108+
109+
Note: browsers may have to be restarted after running this command for it to take effect.
110+
111+
Install a non-default root certificate via a local path:
112+
113+
$ trellis certificate install --path ~/certs/root.crt
114+
115+
Arguments:
116+
ENVIRONMENT Name of environment (default: development)
117+
118+
Options:
119+
-h, --help show this help
120+
--path local path to custom root certificate to install
121+
`
122+
123+
return strings.TrimSpace(helpText)
124+
}
125+
126+
func (c *CertificateInstallCommand) AutocompleteArgs() complete.Predictor {
127+
return c.Trellis.AutocompleteEnvironment(c.flags)
128+
}
129+
130+
func (c *CertificateInstallCommand) AutocompleteFlags() complete.Flags {
131+
return complete.Flags{
132+
"--path": complete.PredictNothing,
133+
}
134+
}
135+
136+
func (c *CertificateInstallCommand) fetchRootCertificate(environment string) (cert *x509.Certificate, err error) {
137+
if c.path == "" {
138+
c.path = certificates.RootCertificatePath(c.Trellis.ConfigPath())
139+
}
140+
siteName, _ := c.Trellis.FindSiteNameFromEnvironment(environment, "")
141+
host := c.Trellis.SiteFromEnvironmentAndName(environment, siteName).MainHost()
142+
143+
certBytes, err := certificates.FetchRootCertificate(c.path, host)
144+
145+
if err = os.MkdirAll(filepath.Dir(c.path), os.ModePerm); err != nil {
146+
return nil, err
147+
}
148+
149+
if err = os.WriteFile(c.path, certBytes, os.ModePerm); err != nil {
150+
return nil, err
151+
}
152+
153+
cert, err = pemutil.ParseCertificate(certBytes)
154+
155+
if err != nil {
156+
return nil, err
157+
}
158+
159+
return cert, nil
160+
}

cmd/certificate_install_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/mitchellh/cli"
8+
"github.com/roots/trellis-cli/trellis"
9+
)
10+
11+
func TestCertificateInstallRunValidations(t *testing.T) {
12+
cases := []struct {
13+
name string
14+
projectDetected bool
15+
args []string
16+
out string
17+
code int
18+
}{
19+
{
20+
"no_project",
21+
false,
22+
nil,
23+
"No Trellis project detected",
24+
1,
25+
},
26+
{
27+
"too_many_args",
28+
true,
29+
[]string{"foo", "bar"},
30+
"Error: too many arguments",
31+
1,
32+
},
33+
}
34+
35+
for _, tc := range cases {
36+
t.Run(tc.name, func(t *testing.T) {
37+
ui := cli.NewMockUi()
38+
trellis := trellis.NewMockTrellis(tc.projectDetected)
39+
galaxyInstallCommand := NewCertificateInstallCommand(ui, trellis)
40+
41+
code := galaxyInstallCommand.Run(tc.args)
42+
43+
if code != tc.code {
44+
t.Errorf("expected code %d to be %d", code, tc.code)
45+
}
46+
47+
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
48+
49+
if !strings.Contains(combined, tc.out) {
50+
t.Errorf("expected output %q to contain %q", combined, tc.out)
51+
}
52+
})
53+
}
54+
}

0 commit comments

Comments
 (0)