Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add certificate install and uninstall commands #311

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions certificates/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package certificates

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

"github.com/smallstep/certinfo"
"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(), truststore.WithDebug())

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(cert *x509.Certificate) (info string, err error) {
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
}

func Trusted(cert *x509.Certificate) bool {
chains, err := cert.Verify(x509.VerifyOptions{})
return len(chains) > 0 && err == nil
}
160 changes: 160 additions & 0 deletions cmd/certificate_install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package cmd

import (
"crypto/x509"
"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"
"go.step.sm/crypto/pemutil"
)

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

cert, err := c.fetchRootCertificate(environment)
if 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 certificates.Trusted(cert) {
c.UI.Info("Root certificate already installed and trusted")
return 0
}

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(cert); 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) (cert *x509.Certificate, err error) {
if c.path == "" {
c.path = certificates.RootCertificatePath(c.Trellis.ConfigPath())
}
siteName, _ := c.Trellis.FindSiteNameFromEnvironment(environment, "")
host := c.Trellis.SiteFromEnvironmentAndName(environment, siteName).MainHost()

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

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

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

cert, err = pemutil.ParseCertificate(certBytes)

if err != nil {
return nil, err
}

return cert, 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)
}
})
}
}
Loading