Skip to content

Commit 6f27d00

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 447139d commit 6f27d00

File tree

8 files changed

+1543
-1
lines changed

8 files changed

+1543
-1
lines changed

certificates/main.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package certificates
2+
3+
import (
4+
"crypto/tls"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/smallstep/certinfo"
12+
"github.com/smallstep/cli/crypto/pemutil"
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())
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(path string) (info string, err error) {
53+
if cert, err := pemutil.ReadCertificate(path); err == nil {
54+
if s, err := certinfo.CertificateShortText(cert); err == nil {
55+
return s, nil
56+
}
57+
}
58+
59+
return "", fmt.Errorf("Error reading certificate: %v", err)
60+
}
61+
62+
func FetchRootCertificate(path string, host string) (cert []byte, err error) {
63+
tr := &http.Transport{
64+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
65+
}
66+
client := &http.Client{Transport: tr}
67+
68+
res, err := client.Get(fmt.Sprintf("https://%s:8443/roots.pem", host))
69+
if err != nil {
70+
return nil, fmt.Errorf("Could not fetch root certificate from server: %v", err)
71+
}
72+
73+
body, err := io.ReadAll(res.Body)
74+
res.Body.Close()
75+
76+
if err != nil {
77+
return nil, fmt.Errorf("Could not read response from server: %v", err)
78+
}
79+
80+
if res.StatusCode != 200 {
81+
return nil, fmt.Errorf("Could not fetch root certificate from server: %d status code received", res.StatusCode)
82+
}
83+
84+
return body, nil
85+
}

cmd/certificate_install.go

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

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+
}

cmd/certificate_uninstall.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package cmd
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/mitchellh/cli"
10+
"github.com/posener/complete"
11+
"github.com/roots/trellis-cli/certificates"
12+
"github.com/roots/trellis-cli/trellis"
13+
)
14+
15+
type CertificateUninstallCommand struct {
16+
UI cli.Ui
17+
Trellis *trellis.Trellis
18+
flags *flag.FlagSet
19+
path string
20+
}
21+
22+
func NewCertificateUninstallCommand(ui cli.Ui, trellis *trellis.Trellis) *CertificateUninstallCommand {
23+
c := &CertificateUninstallCommand{UI: ui, Trellis: trellis}
24+
c.init()
25+
return c
26+
}
27+
28+
func (c *CertificateUninstallCommand) init() {
29+
c.flags = flag.NewFlagSet("", flag.ContinueOnError)
30+
c.flags.Usage = func() { c.UI.Info(c.Help()) }
31+
c.flags.StringVar(&c.path, "path", "", "Local path to custom root certificate to uninstall")
32+
}
33+
34+
func (c *CertificateUninstallCommand) Run(args []string) int {
35+
if err := c.Trellis.LoadProject(); err != nil {
36+
c.UI.Error(err.Error())
37+
return 1
38+
}
39+
40+
c.Trellis.CheckVirtualenv(c.UI)
41+
42+
commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0}
43+
commandArgumentErr := commandArgumentValidator.validate(args)
44+
if commandArgumentErr != nil {
45+
c.UI.Error(commandArgumentErr.Error())
46+
c.UI.Output(c.Help())
47+
return 1
48+
}
49+
50+
if c.path == "" {
51+
c.path = certificates.RootCertificatePath(c.Trellis.ConfigPath())
52+
}
53+
54+
if _, err := os.Stat(c.path); os.IsNotExist(err) {
55+
c.UI.Error(fmt.Sprintf("Root certificate not found: %s", c.path))
56+
return 1
57+
}
58+
59+
if err := certificates.UninstallFile(c.path); err != nil {
60+
c.UI.Error("Error uninstalling root certificate to truststore:")
61+
c.UI.Error(err.Error())
62+
return 1
63+
}
64+
65+
c.UI.Info(fmt.Sprintf("Certificate %s has been removed.\n", c.path))
66+
c.UI.Info("Note: your web browser(s) will need to be restarted for this to take effect.")
67+
68+
return 0
69+
}
70+
71+
func (c *CertificateUninstallCommand) Synopsis() string {
72+
return "Uninstalls a root certificate in the system truststore"
73+
}
74+
75+
func (c *CertificateUninstallCommand) Help() string {
76+
helpText := `
77+
Usage: trellis certificate uninstall [options]
78+
79+
Uninstalls a root certificate in the system truststore. This will stop your computer/browser
80+
from trusting the root certificate authority.
81+
82+
Note: browsers may have to be restarted after running this command for it to take effect.
83+
84+
Uninstall a non-default root certificate via a local path:
85+
86+
$ trellis certificate uninstall --path ~/certs/root.crt
87+
88+
Options:
89+
-h, --help show this help
90+
--path local path to custom root certificate to uninstall
91+
`
92+
93+
return strings.TrimSpace(helpText)
94+
}
95+
96+
func (c *CertificateUninstallCommand) AutocompleteArgs() complete.Predictor {
97+
return c.Trellis.AutocompleteEnvironment(c.flags)
98+
}
99+
100+
func (c *CertificateUninstallCommand) AutocompleteFlags() complete.Flags {
101+
return complete.Flags{
102+
"--path": complete.PredictNothing,
103+
}
104+
}

0 commit comments

Comments
 (0)