diff --git a/cmd/revoke.go b/cmd/revoke.go index 81538826..42d24b6c 100644 --- a/cmd/revoke.go +++ b/cmd/revoke.go @@ -2,15 +2,21 @@ package cmd import ( "fmt" + "io/ioutil" + "path" "github.com/spf13/cobra" ) +func revokeHandler(cmd *cobra.Command, args []string) { + +} + // revokeCmd represents the revoke command var revokeCmd = &cobra.Command{ Use: "revoke", Short: "Revoke a certificate", - Long: ``, + Long: ``, Run: func(cmd *cobra.Command, args []string) { // TODO: Work your own magic here fmt.Println("revoke called") @@ -19,5 +25,5 @@ var revokeCmd = &cobra.Command{ func init() { RootCmd.AddCommand(revokeCmd) - + } diff --git a/cmd/run.go b/cmd/run.go index d342b154..bef2b997 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -15,20 +15,81 @@ package cmd import ( - "fmt" + "os" + "github.com/gianluca311/lego/cmd/utils" "github.com/spf13/cobra" ) +func runHandler(cmd *cobra.Command, args []string) { + conf, acc, client := utils.Setup(RootCmd) + if acc.Registration == nil { + reg, err := client.Register() + if err != nil { + logger().Fatalf("Could not complete registration\n\t%s", err.Error()) + } + + acc.Registration = reg + acc.Save() + email, err := RootCmd.PersistentFlags().GetString("email") + if err != nil { + logger().Fatalln(err.Error()) + } + logger().Print("!!!! HEADS UP !!!!") + logger().Printf(` + Your account credentials have been saved in your Let's Encrypt + configuration directory at "%s". + You should make a secure backup of this folder now. This + configuration directory will also contain certificates and + private keys obtained from Let's Encrypt so making regular + backups of this folder is ideal.`, conf.AccountPath(email)) + + } + + // If the agreement URL is empty, the account still needs to accept the LE TOS. + if acc.Registration.Body.Agreement == "" { + utils.HandleTOS(RootCmd, client, acc) + } + + domains, err := RootCmd.PersistentFlags().GetStringSlice("domains") + if err != nil { + logger().Fatalln(err.Error()) + } + + if len(domains) == 0 { + logger().Fatal("Please specify --domains or -d") + } + + nobundle, err := cmd.PersistentFlags().GetBool("no-bundle") + if err != nil { + logger().Fatalln(err.Error()) + } + cert, failures := client.ObtainCertificate(domains, !nobundle, nil) + if len(failures) > 0 { + for k, v := range failures { + logger().Printf("[%s] Could not obtain certificates\n\t%s", k, v.Error()) + } + + // Make sure to return a non-zero exit code if ObtainSANCertificate + // returned at least one error. Due to us not returning partial + // certificate we can just exit here instead of at the end. + os.Exit(1) + } + + err := utils.CheckFolder(conf.CertPath()) + if err != nil { + logger().Fatalf("Could not check/create path: %s", err.Error()) + } + + saveCertRes(cert, conf) +} + // runCmd represents the run command var runCmd = &cobra.Command{ Use: "run", Short: "Register an account, then create and install a certificate", - Long: ``, - Run: func(cmd *cobra.Command, args []string) { - // TODO: Work your own magic here - fmt.Println("run called") - }, + Long: ``, + Run: runHandler, } func init() { diff --git a/account.go b/cmd/utils/account.go similarity index 99% rename from account.go rename to cmd/utils/account.go index 85ac09f1..47127c4a 100644 --- a/account.go +++ b/cmd/utils/account.go @@ -1,4 +1,4 @@ -package main +package utils import ( "crypto" diff --git a/configuration.go b/cmd/utils/configuration.go similarity index 62% rename from configuration.go rename to cmd/utils/configuration.go index a437fd56..8df78ae0 100644 --- a/configuration.go +++ b/cmd/utils/configuration.go @@ -1,29 +1,34 @@ -package main +package utils import ( "fmt" + "log" "net/url" "os" "path" "strings" - "github.com/codegangsta/cli" + "github.com/spf13/cobra" "github.com/xenolf/lego/acme" ) // Configuration type from CLI and config files. type Configuration struct { - context *cli.Context + context *cobra.Command } // NewConfiguration creates a new configuration from CLI data. -func NewConfiguration(c *cli.Context) *Configuration { +func NewConfiguration(c *cobra.Command) *Configuration { return &Configuration{context: c} } // KeyType the type from which private keys should be generated func (c *Configuration) KeyType() (acme.KeyType, error) { - switch strings.ToUpper(c.context.GlobalString("key-type")) { + keytype, err := c.context.PersistentFlags().GetString("key-type") + if err != nil { + return "", err + } + switch strings.ToUpper(keytype) { case "RSA2048": return acme.RSA2048, nil case "RSA4096": @@ -36,12 +41,16 @@ func (c *Configuration) KeyType() (acme.KeyType, error) { return acme.EC384, nil } - return "", fmt.Errorf("Unsupported KeyType: %s", c.context.GlobalString("key-type")) + return "", fmt.Errorf("Unsupported KeyType: %s", keytype) } // ExcludedSolvers is a list of solvers that are to be excluded. func (c *Configuration) ExcludedSolvers() (cc []acme.Challenge) { - for _, s := range c.context.GlobalStringSlice("exclude") { + exclude, err := c.context.PersistentFlags().GetStringSlice("exclude") + if err != nil { + log.Fatalln(err.Error()) + } + for _, s := range exclude { cc = append(cc, acme.Challenge(s)) } return @@ -49,20 +58,32 @@ func (c *Configuration) ExcludedSolvers() (cc []acme.Challenge) { // ServerPath returns the OS dependent path to the data for a specific CA func (c *Configuration) ServerPath() string { - srv, _ := url.Parse(c.context.GlobalString("server")) + server, err := c.context.PersistentFlags().GetString("server") + if err != nil { + log.Fatalln(err.Error()) + } + srv, _ := url.Parse(server) srvStr := strings.Replace(srv.Host, ":", "_", -1) return strings.Replace(srvStr, "/", string(os.PathSeparator), -1) } // CertPath gets the path for certificates. func (c *Configuration) CertPath() string { - return path.Join(c.context.GlobalString("path"), "certificates") + pathS, err := c.context.PersistentFlags().GetString("path") + if err != nil { + log.Fatalln(err.Error()) + } + return path.Join(pathS, "certificates") } // AccountsPath returns the OS dependent path to the // local accounts for a specific CA func (c *Configuration) AccountsPath() string { - return path.Join(c.context.GlobalString("path"), "accounts", c.ServerPath()) + pathS, err := c.context.PersistentFlags().GetString("path") + if err != nil { + log.Fatalln(err.Error()) + } + return path.Join(pathS, "accounts", c.ServerPath()) } // AccountPath returns the OS dependent path to a particular account diff --git a/crypto.go b/cmd/utils/crypto.go similarity index 98% rename from crypto.go rename to cmd/utils/crypto.go index 8b23e2fc..4f86429a 100644 --- a/crypto.go +++ b/cmd/utils/crypto.go @@ -1,4 +1,4 @@ -package main +package utils import ( "crypto" diff --git a/cmd/utils/utils.go b/cmd/utils/utils.go new file mode 100644 index 00000000..3a4127c3 --- /dev/null +++ b/cmd/utils/utils.go @@ -0,0 +1,249 @@ +package utils + +import ( + "bufio" + "encoding/json" + "io/ioutil" + "log" + "os" + "path" + "strings" + + "github.com/spf13/cobra" + "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/providers/dns/cloudflare" + "github.com/xenolf/lego/providers/dns/digitalocean" + "github.com/xenolf/lego/providers/dns/dnsimple" + "github.com/xenolf/lego/providers/dns/dyn" + "github.com/xenolf/lego/providers/dns/gandi" + "github.com/xenolf/lego/providers/dns/googlecloud" + "github.com/xenolf/lego/providers/dns/namecheap" + "github.com/xenolf/lego/providers/dns/rfc2136" + "github.com/xenolf/lego/providers/dns/route53" + "github.com/xenolf/lego/providers/dns/vultr" + "github.com/xenolf/lego/providers/http/webroot" +) + +var Logger *log.Logger + +func logger() *log.Logger { + if Logger == nil { + Logger = log.New(os.Stderr, "", log.LstdFlags) + } + return Logger +} + +func CheckFolder(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return os.MkdirAll(path, 0700) + } + return nil +} + +func saveCertRes(certRes acme.CertificateResource, conf *Configuration) { + // We store the certificate, private key and metadata in different files + // as web servers would not be able to work with a combined file. + certOut := path.Join(conf.CertPath(), certRes.Domain+".crt") + privOut := path.Join(conf.CertPath(), certRes.Domain+".key") + metaOut := path.Join(conf.CertPath(), certRes.Domain+".json") + + err := ioutil.WriteFile(certOut, certRes.Certificate, 0600) + if err != nil { + logger().Fatalf("Unable to save Certificate for domain %s\n\t%s", certRes.Domain, err.Error()) + } + + err = ioutil.WriteFile(privOut, certRes.PrivateKey, 0600) + if err != nil { + logger().Fatalf("Unable to save PrivateKey for domain %s\n\t%s", certRes.Domain, err.Error()) + } + + jsonBytes, err := json.MarshalIndent(certRes, "", "\t") + if err != nil { + logger().Fatalf("Unable to marshal CertResource for domain %s\n\t%s", certRes.Domain, err.Error()) + } + + err = ioutil.WriteFile(metaOut, jsonBytes, 0600) + if err != nil { + logger().Fatalf("Unable to save CertResource for domain %s\n\t%s", certRes.Domain, err.Error()) + } +} + +func Setup(c *cobra.Command) (*Configuration, *Account, *acme.Client) { + pathS, err := c.PersistentFlags().GetString("path") + if err != nil { + logger().Fatalf(err.Error()) + } + err = CheckFolder(pathS) + if err != nil { + logger().Fatalf("Could not check/create path: %s", err.Error()) + } + + conf := NewConfiguration(c) + + email, err := c.PersistentFlags().GetString("email") + if err != nil { + logger().Fatalln(err.Error()) + } + + if len(email) == 0 { + logger().Fatal("You have to pass an account (email address) to the program using --email or -m") + } + + //TODO: move to account struct? Currently MUST pass email. + acc := NewAccount(email, conf) + + keyType, err := conf.KeyType() + if err != nil { + logger().Fatal(err.Error()) + } + + server, err := c.PersistentFlags().GetString("server") + if err != nil { + logger().Fatal(err.Error()) + } + client, err := acme.NewClient(server, acc, keyType) + if err != nil { + logger().Fatalf("Could not create client: %s", err.Error()) + } + + excludeS, err := c.PersistentFlags().GetStringSlice("exclude") + if err != nil { + logger().Fatal(err.Error()) + } + if len(excludeS) > 0 { + client.ExcludeChallenges(conf.ExcludedSolvers()) + } + + webrootS, err := c.PersistentFlags().GetString("webroot") + if err != nil { + logger().Fatal(err.Error()) + } + + if len(webrootS) > 0 { + provider, err := webroot.NewHTTPProvider(webrootS) + if err != nil { + logger().Fatal(err) + } + + client.SetChallengeProvider(acme.HTTP01, provider) + + // --webroot=foo indicates that the user specifically want to do a HTTP challenge + // infer that the user also wants to exclude all other challenges + client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01}) + } + + httpS, err := c.PersistentFlags().GetString("http") + if err != nil { + logger().Fatal(err.Error()) + } + if len(httpS) > 0 { + if strings.Index(httpS, ":") == -1 { + logger().Fatalf("The --http switch only accepts interface:port or :port for its argument.") + } + client.SetHTTPAddress(httpS) + } + + tls, err := c.PersistentFlags().GetString("tls") + if err != nil { + logger().Fatal(err.Error()) + } + + if len(tls) > 0 { + if strings.Index(tls, ":") == -1 { + logger().Fatalf("The --tls switch only accepts interface:port or :port for its argument.") + } + client.SetTLSAddress(tls) + } + + dns, err := c.PersistentFlags().GetString("dns") + if err != nil { + logger().Fatal(err.Error()) + } + + if len(dns) > 0 { + var err error + var provider acme.ChallengeProvider + switch dns { + case "cloudflare": + provider, err = cloudflare.NewDNSProvider() + case "digitalocean": + provider, err = digitalocean.NewDNSProvider() + case "dnsimple": + provider, err = dnsimple.NewDNSProvider() + case "dyn": + provider, err = dyn.NewDNSProvider() + case "gandi": + provider, err = gandi.NewDNSProvider() + case "gcloud": + provider, err = googlecloud.NewDNSProvider() + case "manual": + provider, err = acme.NewDNSProviderManual() + case "namecheap": + provider, err = namecheap.NewDNSProvider() + case "route53": + provider, err = route53.NewDNSProvider() + case "rfc2136": + provider, err = rfc2136.NewDNSProvider() + case "vultr": + provider, err = vultr.NewDNSProvider() + } + + if err != nil { + logger().Fatal(err) + } + + client.SetChallengeProvider(acme.DNS01, provider) + + // --dns=foo indicates that the user specifically want to do a DNS challenge + // infer that the user also wants to exclude all other challenges + client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSSNI01}) + } + + return conf, acc, client +} + +func HandleTOS(c *cobra.Command, client *acme.Client, acc *Account) { + // Check for a global accept override + accepttos, err := c.PersistentFlags().GetBool("accept-tos") + if err != nil { + logger().Fatalf(err.Error()) + } + + if accepttos { + err := client.AgreeToTOS() + if err != nil { + logger().Fatalf("Could not agree to TOS: %s", err.Error()) + } + + acc.Save() + return + } + + reader := bufio.NewReader(os.Stdin) + logger().Printf("Please review the TOS at %s", acc.Registration.TosURL) + + for { + logger().Println("Do you accept the TOS? Y/n") + text, err := reader.ReadString('\n') + if err != nil { + logger().Fatalf("Could not read from console: %s", err.Error()) + } + + text = strings.Trim(text, "\r\n") + + if text == "n" { + logger().Fatal("You did not accept the TOS. Unable to proceed.") + } + + if text == "Y" || text == "y" || text == "" { + err = client.AgreeToTOS() + if err != nil { + logger().Fatalf("Could not agree to TOS: %s", err.Error()) + } + acc.Save() + break + } + + logger().Println("Your input was invalid. Please answer with one of Y/y, n or by pressing enter.") + } +}