Skip to content

Commit

Permalink
Add certificate install and uninstall commands
Browse files Browse the repository at this point in the history
`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".
  • Loading branch information
swalkinshaw committed Oct 16, 2022
1 parent c63ec05 commit 7dd02a8
Show file tree
Hide file tree
Showing 8 changed files with 1,550 additions and 4 deletions.
85 changes: 85 additions & 0 deletions certificates/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package certificates

import (
"crypto/tls"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"

"github.com/smallstep/certinfo"
"github.com/smallstep/cli/crypto/pemutil"
"github.com/smallstep/truststore"
)

func RootCertificatePath(configPath string) string {
return filepath.Join(configPath, "root_certificates", "root_ca.crt")
}

func InstallFile(path string) error {
opts := []truststore.Option{}
opts = append(opts, truststore.WithFirefox(), truststore.WithJava())

if trustErr := truststore.InstallFile(path, opts...); trustErr != nil {
switch err := trustErr.(type) {
case *truststore.CmdError:
return fmt.Errorf("failed to execute \"%s\" failed with: %v", strings.Join(err.Cmd().Args, " "), err.Error())
default:
return fmt.Errorf("failed to install %s: %v", path, err.Error())
}
}

return nil
}

func UninstallFile(path string) error {
opts := []truststore.Option{}
opts = append(opts, truststore.WithFirefox(), truststore.WithJava())

if trustErr := truststore.UninstallFile(path, opts...); trustErr != nil {
switch err := trustErr.(type) {
case *truststore.CmdError:
return fmt.Errorf("failed to execute \"%s\" failed with: %v", strings.Join(err.Cmd().Args, " "), err.Error())
default:
return fmt.Errorf("failed to uninstall %s: %v", path, err.Error())
}
}

return nil
}

func ShortText(path string) (info string, err error) {
if cert, err := pemutil.ReadCertificate(path); err == nil {
if s, err := certinfo.CertificateShortText(cert); err == nil {
return s, nil
}
}

return "", fmt.Errorf("Error reading certificate: %v", err)
}

func FetchRootCertificate(path string, host string) (cert []byte, err error) {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}

res, err := client.Get(fmt.Sprintf("https://%s:8443/roots.pem", host))
if err != nil {
return nil, fmt.Errorf("Could not fetch root certificate from server: %v", err)
}

body, err := io.ReadAll(res.Body)
res.Body.Close()

if err != nil {
return nil, fmt.Errorf("Could not read response from server: %v", err)
}

if res.StatusCode != 200 {
return nil, fmt.Errorf("Could not fetch root certificate from server: %d status code received", res.StatusCode)
}

return body, nil
}
146 changes: 146 additions & 0 deletions cmd/certificate_install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package cmd

import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/roots/trellis-cli/certificates"
"github.com/roots/trellis-cli/trellis"
)

type CertificateInstallCommand struct {
UI cli.Ui
Trellis *trellis.Trellis
flags *flag.FlagSet
path string
}

func NewCertificateInstallCommand(ui cli.Ui, trellis *trellis.Trellis) *CertificateInstallCommand {
c := &CertificateInstallCommand{UI: ui, Trellis: trellis}
c.init()
return c
}

func (c *CertificateInstallCommand) init() {
c.flags = flag.NewFlagSet("", flag.ContinueOnError)
c.flags.Usage = func() { c.UI.Info(c.Help()) }
c.flags.StringVar(&c.path, "path", "", "Local path to custom root certificate to install")
}

func (c *CertificateInstallCommand) Run(args []string) int {
if err := c.Trellis.LoadProject(); err != nil {
c.UI.Error(err.Error())
return 1
}

c.Trellis.CheckVirtualenv(c.UI)

commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 1}
commandArgumentErr := commandArgumentValidator.validate(args)
if commandArgumentErr != nil {
c.UI.Error(commandArgumentErr.Error())
c.UI.Output(c.Help())
return 1
}

environment := "development"

if len(args) == 1 {
environment = args[0]
environmentErr := c.Trellis.ValidateEnvironment(environment)
if environmentErr != nil {
c.UI.Error(environmentErr.Error())
return 1
}
}

if err := c.fetchRootCertificate(environment); err != nil {
c.UI.Error("Error fetching root certificate from server:")
c.UI.Error(err.Error())
c.UI.Error("The server (likely Vagrant virtual machine) must be running and have been provisioned with an SSL enabled site.")
return 1
}

if err := certificates.InstallFile(c.path); err != nil {
c.UI.Error("Error installing root certificate to truststore:")
c.UI.Error(err.Error())
return 1
}

c.UI.Info(fmt.Sprintf("Certificate %s has been installed.\n", c.path))
if text, err := certificates.ShortText(c.path); err == nil {
c.UI.Info(text)
}

c.UI.Info("Note: your web browser(s) will need to be restarted for this to take effect.")

return 0
}

func (c *CertificateInstallCommand) Synopsis() string {
return "Installs a root certificate in the system truststore"
}

func (c *CertificateInstallCommand) Help() string {
helpText := `
Usage: trellis certificate install [options] [ENVIRONMENT]
Installs a root certificate in the system truststore. This allows your local
computer to trust the "self-signed" root CA (certificate authority) that Trellis
uses in development which avoids insecure warnings in your web browsers.
By default this integrates with a Trellis server/VM and requires that it's running.
However, the --path option can be used to specify any root certificate making this
command useful for non-Trellis use cases too.
Note: browsers may have to be restarted after running this command for it to take effect.
Install a non-default root certificate via a local path:
$ trellis certificate install --path ~/certs/root.crt
Arguments:
ENVIRONMENT Name of environment (default: development)
Options:
-h, --help show this help
--path local path to custom root certificate to install
`

return strings.TrimSpace(helpText)
}

func (c *CertificateInstallCommand) AutocompleteArgs() complete.Predictor {
return c.Trellis.AutocompleteEnvironment(c.flags)
}

func (c *CertificateInstallCommand) AutocompleteFlags() complete.Flags {
return complete.Flags{
"--path": complete.PredictNothing,
}
}

func (c *CertificateInstallCommand) fetchRootCertificate(environment string) error {
if c.path == "" {
c.path = certificates.RootCertificatePath(c.Trellis.ConfigPath())
}
siteName, _ := c.Trellis.FindSiteNameFromEnvironment(environment, "")
host := c.Trellis.SiteFromEnvironmentAndName(environment, siteName).MainHost()

cert, err := certificates.FetchRootCertificate(c.path, host)

if err = os.MkdirAll(filepath.Dir(c.path), os.ModePerm); err != nil {
return err
}

if err = os.WriteFile(c.path, cert, os.ModePerm); err != nil {
return err
}

return nil
}
54 changes: 54 additions & 0 deletions cmd/certificate_install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cmd

import (
"strings"
"testing"

"github.com/mitchellh/cli"
"github.com/roots/trellis-cli/trellis"
)

func TestCertificateInstallRunValidations(t *testing.T) {
cases := []struct {
name string
projectDetected bool
args []string
out string
code int
}{
{
"no_project",
false,
nil,
"No Trellis project detected",
1,
},
{
"too_many_args",
true,
[]string{"foo", "bar"},
"Error: too many arguments",
1,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ui := cli.NewMockUi()
trellis := trellis.NewMockTrellis(tc.projectDetected)
galaxyInstallCommand := NewCertificateInstallCommand(ui, trellis)

code := galaxyInstallCommand.Run(tc.args)

if code != tc.code {
t.Errorf("expected code %d to be %d", code, tc.code)
}

combined := ui.OutputWriter.String() + ui.ErrorWriter.String()

if !strings.Contains(combined, tc.out) {
t.Errorf("expected output %q to contain %q", combined, tc.out)
}
})
}
}
104 changes: 104 additions & 0 deletions cmd/certificate_uninstall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cmd

import (
"flag"
"fmt"
"os"
"strings"

"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/roots/trellis-cli/certificates"
"github.com/roots/trellis-cli/trellis"
)

type CertificateUninstallCommand struct {
UI cli.Ui
Trellis *trellis.Trellis
flags *flag.FlagSet
path string
}

func NewCertificateUninstallCommand(ui cli.Ui, trellis *trellis.Trellis) *CertificateUninstallCommand {
c := &CertificateUninstallCommand{UI: ui, Trellis: trellis}
c.init()
return c
}

func (c *CertificateUninstallCommand) init() {
c.flags = flag.NewFlagSet("", flag.ContinueOnError)
c.flags.Usage = func() { c.UI.Info(c.Help()) }
c.flags.StringVar(&c.path, "path", "", "Local path to custom root certificate to uninstall")
}

func (c *CertificateUninstallCommand) Run(args []string) int {
if err := c.Trellis.LoadProject(); err != nil {
c.UI.Error(err.Error())
return 1
}

c.Trellis.CheckVirtualenv(c.UI)

commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0}
commandArgumentErr := commandArgumentValidator.validate(args)
if commandArgumentErr != nil {
c.UI.Error(commandArgumentErr.Error())
c.UI.Output(c.Help())
return 1
}

if c.path == "" {
c.path = certificates.RootCertificatePath(c.Trellis.ConfigPath())
}

if _, err := os.Stat(c.path); os.IsNotExist(err) {
c.UI.Error(fmt.Sprintf("Root certificate not found: %s", c.path))
return 1
}

if err := certificates.UninstallFile(c.path); err != nil {
c.UI.Error("Error uninstalling root certificate to truststore:")
c.UI.Error(err.Error())
return 1
}

c.UI.Info(fmt.Sprintf("Certificate %s has been removed.\n", c.path))
c.UI.Info("Note: your web browser(s) will need to be restarted for this to take effect.")

return 0
}

func (c *CertificateUninstallCommand) Synopsis() string {
return "Uninstalls a root certificate in the system truststore"
}

func (c *CertificateUninstallCommand) Help() string {
helpText := `
Usage: trellis certificate uninstall [options]
Uninstalls a root certificate in the system truststore. This will stop your computer/browser
from trusting the root certificate authority.
Note: browsers may have to be restarted after running this command for it to take effect.
Uninstall a non-default root certificate via a local path:
$ trellis certificate uninstall --path ~/certs/root.crt
Options:
-h, --help show this help
--path local path to custom root certificate to uninstall
`

return strings.TrimSpace(helpText)
}

func (c *CertificateUninstallCommand) AutocompleteArgs() complete.Predictor {
return c.Trellis.AutocompleteEnvironment(c.flags)
}

func (c *CertificateUninstallCommand) AutocompleteFlags() complete.Flags {
return complete.Flags{
"--path": complete.PredictNothing,
}
}
Loading

0 comments on commit 7dd02a8

Please sign in to comment.