diff --git a/tsuru/client/certificate.go b/tsuru/client/certificate.go index 20cb5299..e30f6d0c 100644 --- a/tsuru/client/certificate.go +++ b/tsuru/client/certificate.go @@ -5,6 +5,8 @@ package client import ( + "crypto/ecdsa" + "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/json" @@ -14,8 +16,8 @@ import ( "net/http" "net/url" "os" - "sort" "strings" + "time" "github.com/tsuru/gnuflag" "github.com/tsuru/go-tsuruclient/pkg/config" @@ -85,7 +87,7 @@ func (c *CertificateSet) Run(context *cmd.Context) error { return err } defer response.Body.Close() - fmt.Fprintln(context.Stdout, "Successfully created the certificated.") + fmt.Fprintln(context.Stdout, "Successfully created the certificate.") return nil } @@ -167,6 +169,19 @@ func (c *CertificateList) Flags() *gnuflag.FlagSet { return c.fs } +type cnameCertificate struct { + Certificate string `json:"certificate"` + Issuer string `json:"issuer"` +} + +type routerCertificate struct { + CNameCertificates map[string]cnameCertificate `json:"cnames"` +} + +type appCertificate struct { + RouterCertificates map[string]routerCertificate `json:"routers"` +} + func (c *CertificateList) Run(context *cmd.Context) error { appName, err := c.AppNameByFlag() if err != nil { @@ -185,59 +200,46 @@ func (c *CertificateList) Run(context *cmd.Context) error { return err } defer response.Body.Close() - rawCerts := make(map[string]map[string]string) - err = json.NewDecoder(response.Body).Decode(&rawCerts) + appCerts := appCertificate{} + err = json.NewDecoder(response.Body).Decode(&appCerts) if err != nil { return err } if c.json { - return c.renderJSON(context, rawCerts) - } - - routerNames := []string{} - routerMap := make(map[string][]string) - for k := range rawCerts { - routerNames = append(routerNames, k) - for v := range rawCerts[k] { - routerMap[k] = append(routerMap[k], v) - } - } - sort.Strings(routerNames) - for k := range routerMap { - sort.Strings(routerMap[k]) + return c.renderJSON(context, appCerts) } if c.raw { - for _, r := range routerNames { - fmt.Fprintf(context.Stdout, "%s:\n", r) - for n, rawCert := range rawCerts[r] { - if rawCert == "" { - rawCert = "No certificate.\n" + for router, routerCerts := range appCerts.RouterCertificates { + fmt.Fprintf(context.Stdout, "%s:\n", router) + for cname, cnameCert := range routerCerts.CNameCertificates { + if cnameCert.Certificate == "" { + fmt.Fprintf(context.Stdout, "%s:\nNo certificate.", cname) + continue } - fmt.Fprintf(context.Stdout, "%s:\n%s", n, rawCert) + fmt.Fprintf(context.Stdout, "%s:\n%s", cname, cnameCert.Certificate) } } + return nil } + tbl := tablecli.NewTable() tbl.LineSeparator = true - tbl.Headers = tablecli.Row{"Router", "CName", "Expires", "Issuer", "Subject"} - dateFormat := "2006-01-02 15:04:05" - for r, cnames := range routerMap { - for _, n := range cnames { - rawCert := rawCerts[r][n] - if rawCert == "" { - tbl.AddRow(tablecli.Row{r, n, "-", "-", "-"}) - continue - } - cert, err := parseCert([]byte(rawCert)) + tbl.Headers = tablecli.Row{"Router", "CName", "Public Key Info", "Certificate Validity"} + for router, routerCerts := range appCerts.RouterCertificates { + for cname, cnameCert := range routerCerts.CNameCertificates { + cert, err := parseCert([]byte(cnameCert.Certificate)) if err != nil { - tbl.AddRow(tablecli.Row{r, n, err.Error(), "-", "-"}) + tbl.AddRow(tablecli.Row{router, cname, err.Error(), "-"}) continue } - tbl.AddRow(tablecli.Row{r, n, formatter.Local(cert.NotAfter).Format(dateFormat), - formatName(&cert.Issuer), formatName(&cert.Subject), + tbl.AddRow(tablecli.Row{ + router, + formatCName(cname, cnameCert.Issuer), + formatPublicKeyInfo(*cert), + formatCertificateValidity(*cert), }) } } @@ -246,7 +248,54 @@ func (c *CertificateList) Run(context *cmd.Context) error { return nil } -func (c *CertificateList) renderJSON(context *cmd.Context, rawCerts map[string]map[string]string) error { +func publicKeySize(publicKey interface{}) int { + switch pk := publicKey.(type) { + case *rsa.PublicKey: + return pk.Size() * 8 // convert bytes to bits + case *ecdsa.PublicKey: + return pk.Params().BitSize + } + return 0 +} + +func formatCName(cname string, issuer string) (cnameStr string) { + cnameStr += fmt.Sprintf("%s\n", cname) + + if issuer != "" { + cnameStr += fmt.Sprintln(" managed by: cert-manager") + cnameStr += fmt.Sprintf(" issuer: %s\n", issuer) + } + + return +} + +func formatPublicKeyInfo(cert x509.Certificate) (pkInfo string) { + publicKey := cert.PublicKeyAlgorithm.String() + if publicKey != "" { + pkInfo += fmt.Sprintf("Algorithm\n%s\n\n", publicKey) + } + + publicKeySize := publicKeySize(cert.PublicKey) + if publicKeySize > 0 { + pkInfo += fmt.Sprintf("Key size (in bits)\n%d", publicKeySize) + } + + return +} + +func formatCertificateValidity(cert x509.Certificate) string { + return fmt.Sprintf( + "Not before\n%s\n\nNot after\n%s", + formatTime(cert.NotBefore), + formatTime(cert.NotAfter), + ) +} + +func formatTime(t time.Time) string { + return t.UTC().Format(time.RFC3339) +} + +func (c *CertificateList) renderJSON(context *cmd.Context, appCerts appCertificate) error { type certificateJSONFriendly struct { Router string `json:"router"` Domain string `json:"domain"` @@ -258,19 +307,15 @@ func (c *CertificateList) renderJSON(context *cmd.Context, rawCerts map[string]m data := []certificateJSONFriendly{} - for router, domainMap := range rawCerts { - domainLoop: - for domain, raw := range domainMap { - if raw == "" { - continue domainLoop - } + for router, routerCerts := range appCerts.RouterCertificates { + for cname, cnameCert := range routerCerts.CNameCertificates { item := certificateJSONFriendly{ - Domain: domain, + Domain: cname, Router: router, - Raw: raw, + Raw: cnameCert.Certificate, } - parsedCert, err := parseCert([]byte(raw)) + parsedCert, err := parseCert([]byte(cnameCert.Certificate)) if err == nil { item.Issuer = &parsedCert.Issuer item.Subject = &parsedCert.Subject @@ -298,11 +343,130 @@ func parseCert(data []byte) (*x509.Certificate, error) { return cert, nil } -func formatName(n *pkix.Name) string { - country := strings.Join(n.Country, ",") - state := strings.Join(n.Province, ",") - locality := strings.Join(n.Locality, ",") - org := strings.Join(n.Organization, ",") - cname := n.CommonName - return fmt.Sprintf("C=%s; ST=%s; \nL=%s; O=%s;\nCN=%s", country, state, locality, org, cname) +type CertificateIssuerSet struct { + tsuruClientApp.AppNameMixIn + cname string + fs *gnuflag.FlagSet +} + +func (c *CertificateIssuerSet) Info() *cmd.Info { + return &cmd.Info{ + Name: "certificate-issuer-set", + Usage: "certificate issuer set [-a/--app appname] [-c/--cname CNAME] [issuer]", + Desc: `Creates or update a certificate issuer into the specific app.`, + MinArgs: 1, + } +} + +func (c *CertificateIssuerSet) Flags() *gnuflag.FlagSet { + if c.fs == nil { + c.fs = c.AppNameMixIn.Flags() + cname := "App CNAME" + c.fs.StringVar(&c.cname, "cname", "", cname) + c.fs.StringVar(&c.cname, "c", "", cname) + } + return c.fs +} + +func (c *CertificateIssuerSet) Run(context *cmd.Context) error { + appName, err := c.AppNameByFlag() + if err != nil { + return err + } + + if c.cname == "" { + return errors.New("You must set cname.") + } + + issuer := context.Args[0] + if issuer == "" { + return errors.New("You must set issuer.") + } + + v := url.Values{} + v.Set("cname", c.cname) + v.Set("issuer", issuer) + u, err := config.GetURLVersion("1.0", fmt.Sprintf("/apps/%s/certissuer", appName)) + if err != nil { + return err + } + + request, err := http.NewRequest(http.MethodPut, u, strings.NewReader(v.Encode())) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := tsuruHTTP.AuthenticatedClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + fmt.Fprintln(context.Stdout, "Successfully created the certificate issuer.") + return nil +} + +type CertificateIssuerUnset struct { + tsuruClientApp.AppNameMixIn + cmd.ConfirmationCommand + fs *gnuflag.FlagSet + cname string +} + +func (c *CertificateIssuerUnset) Info() *cmd.Info { + return &cmd.Info{ + Name: "certificate-issuer-unset", + Usage: "certificate issuer unset [-a/--app appname] [-c/--cname CNAME] [-y/--assume-yes]", + Desc: `Unset a certificate issuer from a specific app.`, + } +} + +func (c *CertificateIssuerUnset) Flags() *gnuflag.FlagSet { + if c.fs == nil { + c.fs = mergeFlagSet( + c.AppNameMixIn.Flags(), + c.ConfirmationCommand.Flags(), + ) + + cname := "App CNAME" + c.fs.StringVar(&c.cname, "cname", "", cname) + c.fs.StringVar(&c.cname, "c", "", cname) + } + return c.fs +} + +func (c *CertificateIssuerUnset) Run(context *cmd.Context) error { + appName, err := c.AppNameByFlag() + if err != nil { + return err + } + + if c.cname == "" { + return errors.New("You must set cname.") + } + + if !c.Confirm(context, fmt.Sprintf(`Are you sure you want to remove certificate issuer for cname: "%s"?`, c.cname)) { + return nil + } + + v := url.Values{} + v.Set("cname", c.cname) + u, err := config.GetURLVersion("1.0", fmt.Sprintf("/apps/%s/certissuer?%s", appName, v.Encode())) + if err != nil { + return err + } + + request, err := http.NewRequest(http.MethodDelete, u, nil) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := tsuruHTTP.AuthenticatedClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + fmt.Fprintln(context.Stdout, "Certificate issuer removed.") + return nil } diff --git a/tsuru/client/certificate_test.go b/tsuru/client/certificate_test.go index c8220b28..c8bb01be 100644 --- a/tsuru/client/certificate_test.go +++ b/tsuru/client/certificate_test.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/tsuru/tsuru-client/tsuru/formatter" "github.com/tsuru/tsuru/cmd" "github.com/tsuru/tsuru/cmd/cmdtest" check "gopkg.in/check.v1" @@ -49,7 +48,7 @@ func (s *S) TestCertificateSetRunSuccessfully(c *check.C) { c.Assert(command.cname, check.Equals, "app.io") err := command.Run(&context) c.Assert(err, check.IsNil) - c.Assert(stdout.String(), check.Equals, "Successfully created the certificated.\n") + c.Assert(stdout.String(), check.Equals, "Successfully created the certificate.\n") } func (s *S) TestCertificateSetRunCerticateNotFound(c *check.C) { @@ -124,33 +123,53 @@ func (s *S) TestCertificateListRunSuccessfully(c *check.C) { Stderr: &stderr, } requestCount := 0 - certMap := map[string]map[string]string{ - "ingress-router": { - "myapp.io": s.mustReadFileString(c, "./testdata/cert/server.crt"), - "myapp.other.io": "", - }, - "a-new-router": { - "myapp.io": s.mustReadFileString(c, "./testdata/cert/server.crt"), + certData := s.mustReadFileString(c, "./testdata/cert/server.crt") + appCert := appCertificate{ + RouterCertificates: map[string]routerCertificate{ + "ingress-router": { + CNameCertificates: map[string]cnameCertificate{ + "myapp.io": { + Certificate: certData, + Issuer: "lets-encrypt", + }, + "myapp.other.io": { + Certificate: "", + }, + }, + }, + "a-new-router": { + CNameCertificates: map[string]cnameCertificate{ + "myapp.io": { + Certificate: certData, + }, + }, + }, }, } - data, err := json.Marshal(certMap) + data, err := json.Marshal(appCert) c.Assert(err, check.IsNil) - expectedDate, err := time.Parse("2006-01-02 15:04:05", "2027-01-10 20:33:11") + expectedNotBefore, _ := time.Parse("2006-01-02 15:04:05", "2017-01-12 20:33:11") + expectedNotAfter, _ := time.Parse("2006-01-02 15:04:05", "2027-01-10 20:33:11") c.Assert(err, check.IsNil) - datestr := formatter.Local(expectedDate).Format("2006-01-02 15:04:05") - expected := `+----------------+----------------+---------------------+----------------------------+----------------------------+ -| Router | CName | Expires | Issuer | Subject | -+----------------+----------------+---------------------+----------------------------+----------------------------+ -| a-new-router | myapp.io | ` + datestr + ` | C=BR; ST=Rio de Janeiro; | C=BR; ST=Rio de Janeiro; | -| | | | L=Rio de Janeiro; O=Tsuru; | L=Rio de Janeiro; O=Tsuru; | -| | | | CN=app.io | CN=app.io | -+----------------+----------------+---------------------+----------------------------+----------------------------+ -| ingress-router | myapp.io | ` + datestr + ` | C=BR; ST=Rio de Janeiro; | C=BR; ST=Rio de Janeiro; | -| | | | L=Rio de Janeiro; O=Tsuru; | L=Rio de Janeiro; O=Tsuru; | -| | | | CN=app.io | CN=app.io | -+----------------+----------------+---------------------+----------------------------+----------------------------+ -| ingress-router | myapp.other.io | - | - | - | -+----------------+----------------+---------------------+----------------------------+----------------------------+ + notBeforeStr := expectedNotBefore.UTC().Format(time.RFC3339) + notAfterStr := expectedNotAfter.UTC().Format(time.RFC3339) + expected := `+----------------+----------------------------+-----------------------+----------------------+ +| Router | CName | Public Key Info | Certificate Validity | ++----------------+----------------------------+-----------------------+----------------------+ +| a-new-router | myapp.io | Algorithm | Not before | +| | | RSA | ` + notBeforeStr + ` | +| | | | | +| | | Key size (in bits) | Not after | +| | | 2048 | ` + notAfterStr + ` | ++----------------+----------------------------+-----------------------+----------------------+ +| ingress-router | myapp.io | Algorithm | Not before | +| | managed by: cert-manager | RSA | ` + notBeforeStr + ` | +| | issuer: lets-encrypt | | | +| | | Key size (in bits) | Not after | +| | | 2048 | ` + notAfterStr + ` | ++----------------+----------------------------+-----------------------+----------------------+ +| ingress-router | myapp.other.io | failed to decode data | - | ++----------------+----------------------------+-----------------------+----------------------+ ` trans := &cmdtest.ConditionalTransport{ Transport: cmdtest.Transport{ @@ -181,13 +200,22 @@ func (s *S) TestCertificateListRawRunSuccessfully(c *check.C) { } requestCount := 0 certData := s.mustReadFileString(c, "./testdata/cert/server.crt") - certMap := map[string]map[string]string{ - "ingress-router": { - "myapp.io": certData, - "myapp.other.io": "", + appCert := appCertificate{ + RouterCertificates: map[string]routerCertificate{ + "ingress-router": { + CNameCertificates: map[string]cnameCertificate{ + "myapp.io": { + Certificate: certData, + Issuer: "lets-encrypt", + }, + "myapp.other.io": { + Certificate: "", + }, + }, + }, }, } - data, err := json.Marshal(certMap) + data, err := json.Marshal(appCert) c.Assert(err, check.IsNil) trans := &cmdtest.ConditionalTransport{ Transport: cmdtest.Transport{ @@ -210,3 +238,96 @@ func (s *S) TestCertificateListRawRunSuccessfully(c *check.C) { c.Assert(strings.Contains(stdout.String(), "myapp.io:\n"+certData), check.Equals, true) c.Assert(requestCount, check.Equals, 1) } + +func (s *S) TestCertificateIssuerSetInfo(c *check.C) { + c.Assert((&CertificateIssuerSet{}).Info(), check.NotNil) +} + +func (s *S) TestCertificateIssuerSetRunSuccessfully(c *check.C) { + var stdout, stderr bytes.Buffer + context := cmd.Context{ + Stdout: &stdout, + Stderr: &stderr, + Args: []string{ + "lets-encrypt", + }, + } + trans := &cmdtest.ConditionalTransport{ + Transport: cmdtest.Transport{Status: http.StatusNoContent}, + CondFunc: func(req *http.Request) bool { + url := strings.HasSuffix(req.URL.Path, "/apps/secret/certissuer") + method := req.Method == http.MethodPut + cname := req.FormValue("cname") == "app.io" + issuer := req.FormValue("issuer") == "lets-encrypt" + return url && method && cname && issuer + }, + } + s.setupFakeTransport(trans) + command := CertificateIssuerSet{} + command.Flags().Parse(true, []string{"-a", "secret", "-c", "app.io"}) + c.Assert(command.cname, check.Equals, "app.io") + err := command.Run(&context) + c.Assert(err, check.IsNil) + c.Assert(stdout.String(), check.Equals, "Successfully created the certificate issuer.\n") +} + +func (s *S) TestCertificateIssuerUnsetInfo(c *check.C) { + c.Assert((&CertificateIssuerUnset{}).Info(), check.NotNil) +} + +func (s *S) TestCertificateIssuerUnsetRunSuccessfully(c *check.C) { + var stdout, stderr bytes.Buffer + context := cmd.Context{ + Stdout: &stdout, + Stderr: &stderr, + Stdin: strings.NewReader("y\n"), + } + requestCount := 0 + trans := &cmdtest.ConditionalTransport{ + Transport: cmdtest.Transport{Status: http.StatusNoContent}, + CondFunc: func(req *http.Request) bool { + requestCount++ + url := strings.HasSuffix(req.URL.Path, "/apps/secret/certissuer") + method := req.Method == http.MethodDelete + cname := req.FormValue("cname") == "app.io" + + return url && method && cname + }, + } + expected := `Are you sure you want to remove certificate issuer for cname: "app.io"? (y/n) ` + expected += "Certificate issuer removed.\n" + s.setupFakeTransport(trans) + command := CertificateIssuerUnset{} + command.Flags().Parse(true, []string{"-a", "secret", "-c", "app.io"}) + c.Assert(command.cname, check.Equals, "app.io") + err := command.Run(&context) + c.Assert(err, check.IsNil) + c.Assert(stdout.String(), check.Equals, expected) + c.Assert(requestCount, check.Equals, 1) +} + +func (s *S) TestCertificateIssuerUnsetRunWithoutAsking(c *check.C) { + var stdout, stderr bytes.Buffer + context := cmd.Context{ + Stdout: &stdout, + Stderr: &stderr, + } + trans := &cmdtest.ConditionalTransport{ + Transport: cmdtest.Transport{Status: http.StatusNoContent}, + CondFunc: func(req *http.Request) bool { + url := strings.HasSuffix(req.URL.Path, "/apps/secret/certissuer") + method := req.Method == http.MethodDelete + cname := req.FormValue("cname") == "app.io" + + return url && method && cname + }, + } + expected := "Certificate issuer removed.\n" + s.setupFakeTransport(trans) + command := CertificateIssuerUnset{} + command.Flags().Parse(true, []string{"-a", "secret", "-c", "app.io", "-y"}) + c.Assert(command.cname, check.Equals, "app.io") + err := command.Run(&context) + c.Assert(err, check.IsNil) + c.Assert(stdout.String(), check.Equals, expected) +} diff --git a/tsuru/main.go b/tsuru/main.go index 3b385707..63552265 100644 --- a/tsuru/main.go +++ b/tsuru/main.go @@ -131,6 +131,8 @@ func buildManagerCustom(name string, stdout, stderr io.Writer) *cmd.Manager { m.Register(&client.CertificateSet{}) m.Register(&client.CertificateUnset{}) m.Register(&client.CertificateList{}) + m.Register(&client.CertificateIssuerSet{}) + m.Register(&client.CertificateIssuerUnset{}) m.Register(&client.CnameAdd{}) m.Register(&client.CnameRemove{}) m.Register(&client.EnvGet{})