diff --git a/README.md b/README.md index ff692132..0efa7ee2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ lego supports both binary installs and install from source. To get the binary just download the latest release for your OS/Arch from [the release page](https://github.com/xenolf/lego/releases) and put the binary somewhere convenient. lego does not assume anything about the location you run it from. -To install from source, just run +To install from source, just run ``` go get -u github.com/xenolf/lego ``` @@ -45,7 +45,7 @@ Please keep in mind that CLI switches and APIs are still subject to change. When using the standard `--path` option, all certificates and account configurations are saved to a folder *.lego* in the current working directory. #### Sudo -The CLI does not require root permissions but needs to bind to port 80 and 443 for certain challenges. +The CLI does not require root permissions but needs to bind to port 80 and 443 for certain challenges. To run the CLI without sudo, you have four options: - Use setcap 'cap_net_bind_service=+ep' /path/to/program @@ -71,36 +71,49 @@ This traffic redirection is only needed as long as lego solves challenges. As so #### Usage ``` -NAME: - lego - Let's Encrypt client written in Go +Let's Encrypt client written in Go -USAGE: - lego [global options] command [command options] [arguments...] - -VERSION: - 0.3.0 - -COMMANDS: - run Register an account, then create and install a certificate - revoke Revoke a certificate - renew Renew a certificate - dnshelp Shows additional help for the --dns global option - help, h Shows a list of commands or help for one command - -GLOBAL OPTIONS: - --domains, -d [--domains option --domains option] Add domains to the process - --server, -s "https://acme-v01.api.letsencrypt.org/directory" CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. - --email, -m Email used for registration and recovery contact. - --accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service. - --key-type, -k "rsa2048" Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384 - --path "${CWD}/.lego" Directory to use for storing the data - --exclude, -x [--exclude option --exclude option] Explicitly disallow solvers by name from being used. Solvers: "http-01", "tls-sni-01". - --webroot Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge - --http Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port - --tls Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port - --dns Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage. - --help, -h show help - --version, -v print the version +Usage: + lego [command] + +Available Commands: + dnshelp Shows additional help for the --dns global option + renew Renew a certificate + revoke Revoke a certificate + run Register an account, then create and install a certificate + version Prints current version of lego + +Flags: + -a, --accept-tos By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service. + --dns string Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage. + -d, --domains value Add domains to the process (default []) + -m, --email string Email used for registration and recovery contact. + -x, --exclude value Explicitly disallow solvers by name from being used. Solvers: "http-01", "tls-sni-01". (default []) + -h, --help help for lego + --http string Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port + -k, --key-type string Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384 (default "rsa2048") + --path string Directory to use for storing the data (default "{$CWD}/.lego") + -s, --server string CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default "https://acme-v01.api.letsencrypt.org/directory") + --tls string Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port + --webroot string Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge + +Use "lego [command] --help" for more information about a command. +``` + +For further help on a command: +``` +$ lego renew --help +Renew a certificate + +Usage: + lego renew [flags] + +Flags: + --days int The number of days left on a certificate to renew it. + --no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. + --resuse-key Used to indicate you want to reuse your current private key for the new certificate. + +... ``` ##### CLI Example @@ -111,7 +124,7 @@ If your environment does not allow you to bind to these ports, please read [Port Obtain a certificate: ```bash -$ lego --email="foo@bar.com" --domains="example.com" run +$ lego run --email="foo@bar.com" --domains="example.com" ``` (Find your certificate in the `.lego` folder of current working directory.) @@ -119,13 +132,13 @@ $ lego --email="foo@bar.com" --domains="example.com" run To renew the certificate: ```bash -$ lego --email="foo@bar.com" --domains="example.com" renew +$ lego renew --email="foo@bar.com" --domains="example.com" ``` Obtain a certificate using the DNS challenge and AWS Route 53: ```bash -$ AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=my_id AWS_SECRET_ACCESS_KEY=my_key lego --email="foo@bar.com" --domains="example.com" --dns="route53" run +$ AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=my_id AWS_SECRET_ACCESS_KEY=my_key lego run --email="foo@bar.com" --domains="example.com" --dns="route53" ``` Note that `--dns=foo` implies `--exclude=http-01` and `--exclude=tls-sni-01`. lego will not attempt other challenges if you've told it to use DNS instead. @@ -210,7 +223,7 @@ if err != nil { } // We specify an http port of 5002 and an tls port of 5001 on all interfaces -// because we aren't running as root and can't bind a listener to port 80 and 443 +// because we aren't running as root and can't bind a listener to port 80 and 443 // (used later when we attempt to pass challenges). Keep in mind that we still // need to proxy challenge traffic to port 5002 and 5001. client.SetHTTPAddress(":5002") diff --git a/cli.go b/cli.go deleted file mode 100644 index 052fc763..00000000 --- a/cli.go +++ /dev/null @@ -1,178 +0,0 @@ -// Let's Encrypt client to go! -// CLI application for generating Let's Encrypt certificates using the ACME package. -package main - -import ( - "fmt" - "log" - "os" - "path" - "strings" - "text/tabwriter" - - "github.com/codegangsta/cli" - "github.com/xenolf/lego/acme" -) - -// Logger is used to log errors; if nil, the default log.Logger is used. -var Logger *log.Logger - -// logger is an helper function to retrieve the available logger -func logger() *log.Logger { - if Logger == nil { - Logger = log.New(os.Stderr, "", log.LstdFlags) - } - return Logger -} - -var gittag string - -func main() { - app := cli.NewApp() - app.Name = "lego" - app.Usage = "Let's Encrypt client written in Go" - - version := "0.3.0" - if strings.HasPrefix(gittag, "v") { - version = gittag - } - - app.Version = version - - acme.UserAgent = "lego/" + app.Version - - cwd, err := os.Getwd() - if err != nil { - logger().Fatal("Could not determine current working directory. Please pass --path.") - } - defaultPath := path.Join(cwd, ".lego") - - app.Commands = []cli.Command{ - { - Name: "run", - Usage: "Register an account, then create and install a certificate", - Action: run, - Flags: []cli.Flag{ - cli.BoolFlag{ - Name: "no-bundle", - Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.", - }, - }, - }, - { - Name: "revoke", - Usage: "Revoke a certificate", - Action: revoke, - }, - { - Name: "renew", - Usage: "Renew a certificate", - Action: renew, - Flags: []cli.Flag{ - cli.IntFlag{ - Name: "days", - Value: 0, - Usage: "The number of days left on a certificate to renew it.", - }, - cli.BoolFlag{ - Name: "reuse-key", - Usage: "Used to indicate you want to reuse your current private key for the new certificate.", - }, - cli.BoolFlag{ - Name: "no-bundle", - Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.", - }, - }, - }, - { - Name: "dnshelp", - Usage: "Shows additional help for the --dns global option", - Action: dnshelp, - }, - } - - app.Flags = []cli.Flag{ - cli.StringSliceFlag{ - Name: "domains, d", - Usage: "Add domains to the process", - }, - cli.StringFlag{ - Name: "server, s", - Value: "https://acme-v01.api.letsencrypt.org/directory", - Usage: "CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client.", - }, - cli.StringFlag{ - Name: "email, m", - Usage: "Email used for registration and recovery contact.", - }, - cli.BoolFlag{ - Name: "accept-tos, a", - Usage: "By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.", - }, - cli.StringFlag{ - Name: "key-type, k", - Value: "rsa2048", - Usage: "Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384", - }, - cli.StringFlag{ - Name: "path", - Usage: "Directory to use for storing the data", - Value: defaultPath, - }, - cli.StringSliceFlag{ - Name: "exclude, x", - Usage: "Explicitly disallow solvers by name from being used. Solvers: \"http-01\", \"tls-sni-01\".", - }, - cli.StringFlag{ - Name: "webroot", - Usage: "Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge", - }, - cli.StringFlag{ - Name: "http", - Usage: "Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port", - }, - cli.StringFlag{ - Name: "tls", - Usage: "Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port", - }, - cli.StringFlag{ - Name: "dns", - Usage: "Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.", - }, - } - - app.Run(os.Args) -} - -func dnshelp(c *cli.Context) { - fmt.Printf( - `Credentials for DNS providers must be passed through environment variables. - -Here is an example bash command using the CloudFlare DNS provider: - - $ CLOUDFLARE_EMAIL=foo@bar.com \ - CLOUDFLARE_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ - lego --dns cloudflare --domains www.example.com --email me@bar.com run - -`) - - w := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) - fmt.Fprintln(w, "Valid providers and their associated credential environment variables:") - fmt.Fprintln(w) - fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY") - fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN") - fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_API_KEY") - fmt.Fprintln(w, "\tgandi:\tGANDI_API_KEY") - fmt.Fprintln(w, "\tgcloud:\tGCE_PROJECT") - fmt.Fprintln(w, "\tmanual:\tnone") - fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY") - fmt.Fprintln(w, "\trfc2136:\tRFC2136_TSIG_KEY, RFC2136_TSIG_SECRET,\n\t\tRFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER") - fmt.Fprintln(w, "\troute53:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION") - fmt.Fprintln(w, "\tdyn:\tDYN_CUSTOMER_NAME, DYN_USER_NAME, DYN_PASSWORD") - fmt.Fprintln(w, "\tvultr:\tVULTR_API_KEY") - w.Flush() - - fmt.Println(` -For a more detailed explanation of a DNS provider's credential variables, -please consult their online documentation.`) -} diff --git a/cmd/dnshelp.go b/cmd/dnshelp.go new file mode 100644 index 00000000..8edb94df --- /dev/null +++ b/cmd/dnshelp.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +// dnshelpCmd represents the dnshelp command +var dnshelpCmd = &cobra.Command{ + Use: "dnshelp", + Short: "Shows additional help for the --dns global option", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf( + `Credentials for DNS providers must be passed through environment variables. + +Here is an example bash command using the CloudFlare DNS provider: + + $ CLOUDFLARE_EMAIL=foo@bar.com \ + CLOUDFLARE_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ + lego --dns cloudflare --domains www.example.com --email me@bar.com run + +`) + + w := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) + fmt.Fprintln(w, "Valid providers and their associated credential environment variables:") + fmt.Fprintln(w) + fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY") + fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN") + fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_API_KEY") + fmt.Fprintln(w, "\tgandi:\tGANDI_API_KEY") + fmt.Fprintln(w, "\tgcloud:\tGCE_PROJECT") + fmt.Fprintln(w, "\tmanual:\tnone") + fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY") + fmt.Fprintln(w, "\trfc2136:\tRFC2136_TSIG_KEY, RFC2136_TSIG_SECRET,\n\t\tRFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER") + fmt.Fprintln(w, "\troute53:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION") + fmt.Fprintln(w, "\tdyn:\tDYN_CUSTOMER_NAME, DYN_USER_NAME, DYN_PASSWORD") + fmt.Fprintln(w, "\tvultr:\tVULTR_API_KEY") + w.Flush() + + fmt.Println(` +For a more detailed explanation of a DNS provider's credential variables, +please consult their online documentation.`) + }, +} + +func init() { + RootCmd.AddCommand(dnshelpCmd) + + // Here you will define your flags and configuration settings. + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + // dnshelpCmd.PersistentFlags().String("foo", "", "A help for foo") + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: + // dnshelpCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + +} diff --git a/cmd/renew.go b/cmd/renew.go new file mode 100644 index 00000000..c49b2d40 --- /dev/null +++ b/cmd/renew.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "encoding/json" + "io/ioutil" + "path" + "time" + + "github.com/xenolf/lego/cmd/utils" + "github.com/spf13/cobra" + "github.com/xenolf/lego/acme" +) + +func renewHandler(cmd *cobra.Command, args []string) { + conf, _, client := utils.Setup(RootCmd) + + domains, err := RootCmd.PersistentFlags().GetStringSlice("domains") + if err != nil { + logger().Fatalln(err.Error()) + } + + if len(domains) <= 0 { + logger().Fatal("Please specify at least one domain.") + } + + domain := domains[0] + + // load the cert resource from files. + // We store the certificate, private key and metadata in different files + // as web servers would not be able to work with a combined file. + certPath := path.Join(conf.CertPath(), domain+".crt") + privPath := path.Join(conf.CertPath(), domain+".key") + metaPath := path.Join(conf.CertPath(), domain+".json") + + certBytes, err := ioutil.ReadFile(certPath) + if err != nil { + logger().Fatalf("Error while loading the certificate for domain %s\n\t%s", domain, err.Error()) + } + + days, err := cmd.PersistentFlags().GetInt("days") + if err != nil { + logger().Fatalln(err.Error()) + } + + if days > 0 { + expTime, err := acme.GetPEMCertExpiration(certBytes) + if err != nil { + logger().Printf("Could not get Certification expiration for domain %s", domain) + } + + if int(expTime.Sub(time.Now()).Hours()/24.0) > days { + return + } + } + + metaBytes, err := ioutil.ReadFile(metaPath) + if err != nil { + logger().Fatalf("Error while loading the meta data for domain %s\n\t%s", domain, err.Error()) + } + + var certRes acme.CertificateResource + err = json.Unmarshal(metaBytes, &certRes) + if err != nil { + logger().Fatalf("Error while marshalling the meta data for domain %s\n\t%s", domain, err.Error()) + } + + reusekey, err := cmd.PersistentFlags().GetBool("reuse-key") + if err != nil { + logger().Fatalln(err.Error()) + } + + if reusekey { + keyBytes, err := ioutil.ReadFile(privPath) + if err != nil { + logger().Fatalf("Error while loading the private key for domain %s\n\t%s", domain, err.Error()) + } + certRes.PrivateKey = keyBytes + } + + certRes.Certificate = certBytes + + nobundle, err := cmd.PersistentFlags().GetBool("no-bundle") + if err != nil { + logger().Fatalln(err.Error()) + } + + newCert, err := client.RenewCertificate(certRes, !nobundle) + if err != nil { + logger().Fatalf("%s", err.Error()) + } + + utils.SaveCertRes(newCert, conf) +} + +// renewCmd represents the renew command +var renewCmd = &cobra.Command{ + Use: "renew", + Short: "Renew a certificate", + Long: ``, + Run: renewHandler, +} + +func init() { + RootCmd.AddCommand(renewCmd) + + renewCmd.PersistentFlags().Int("days", 0, "The number of days left on a certificate to renew it.") + renewCmd.PersistentFlags().Bool("resuse-key", false, "Used to indicate you want to reuse your current private key for the new certificate.") + renewCmd.PersistentFlags().Bool("no-bundle", false, "Do not create a certificate bundle by adding the issuers certificate to the new certificate.") + +} diff --git a/cmd/revoke.go b/cmd/revoke.go new file mode 100644 index 00000000..8f1fe28b --- /dev/null +++ b/cmd/revoke.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "io/ioutil" + "path" + + "github.com/xenolf/lego/cmd/utils" + "github.com/spf13/cobra" +) + +func revokeHandler(cmd *cobra.Command, args []string) { + conf, _, client := utils.Setup(RootCmd) + + err := utils.CheckFolder(conf.CertPath()) + if err != nil { + logger().Fatalf("Could not check/create path: %s", err.Error()) + } + + domains, err := RootCmd.PersistentFlags().GetStringSlice("domains") + if err != nil { + logger().Fatalln(err.Error()) + } + + for _, domain := range domains { + logger().Printf("Trying to revoke certificate for domain %s", domain) + + certPath := path.Join(conf.CertPath(), domain+".crt") + certBytes, err := ioutil.ReadFile(certPath) + + err = client.RevokeCertificate(certBytes) + if err != nil { + logger().Fatalf("Error while revoking the certificate for domain %s\n\t%s", domain, err.Error()) + } else { + logger().Print("Certificate was revoked.") + } + } +} + +// revokeCmd represents the revoke command +var revokeCmd = &cobra.Command{ + Use: "revoke", + Short: "Revoke a certificate", + Long: ``, + Run: revokeHandler, +} + +func init() { + RootCmd.AddCommand(revokeCmd) + +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 00000000..e1dbea9e --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "fmt" + "log" + "os" + "path" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/xenolf/lego/acme" +) + +var gittag string +var cfgFile string +var version string +var Logger *log.Logger + +func logger() *log.Logger { + if Logger == nil { + Logger = log.New(os.Stderr, "", log.LstdFlags) + } + return Logger +} + +// This represents the base command when called without any subcommands +var RootCmd = &cobra.Command{ + Use: "lego", + Short: "Let's Encrypt client written in Go", + Long: ``, + // Uncomment the following line if your bare application + // has an action associated with it: + // Run: func(cmd *cobra.Command, args []string) { }, +} + +// Execute adds all child commands to the root command sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := RootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(-1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + + version = "0.3.0" + if strings.HasPrefix(gittag, "v") { + version = gittag + } + + acme.UserAgent = "lego/" + version + cwd, err := os.Getwd() + if err != nil { + logger().Fatal("Could not determine current working directory. Please pass --path.") + } + defaultPath := path.Join(cwd, ".lego") + + // Cobra also supports local flags, which will only run + // when this action is called directly. + RootCmd.PersistentFlags().StringSliceP("domains", "d", nil, "Add domains to the process") + RootCmd.PersistentFlags().StringP("server", "s", "https://acme-v01.api.letsencrypt.org/directory", "CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client.") + RootCmd.PersistentFlags().StringP("email", "m", "", "Email used for registration and recovery contact.") + RootCmd.PersistentFlags().BoolP("accept-tos", "a", false, "By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.") + RootCmd.PersistentFlags().StringP("key-type", "k", "rsa2048", "Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384") + RootCmd.PersistentFlags().String("path", defaultPath, "Directory to use for storing the data") + RootCmd.PersistentFlags().StringSliceP("exclude", "x", nil, "Explicitly disallow solvers by name from being used. Solvers: \"http-01\", \"tls-sni-01\".") + RootCmd.PersistentFlags().String("webroot", "", "Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge") + RootCmd.PersistentFlags().String("http", "", "Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port") + RootCmd.PersistentFlags().String("tls", "", "Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port") + RootCmd.PersistentFlags().String("dns", "", "Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.") +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if cfgFile != "" { // enable ability to specify config file via flag + viper.SetConfigFile(cfgFile) + } + + viper.AddConfigPath("$HOME") // adding home directory as first search path + viper.AutomaticEnv() // read in environment variables that match + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err == nil { + fmt.Println("Using config file:", viper.ConfigFileUsed()) + } +} diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 00000000..8a674773 --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "os" + + "github.com/xenolf/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()) + } + + utils.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: runHandler, +} + +func init() { + RootCmd.AddCommand(runCmd) + + runCmd.PersistentFlags().Bool("no-bundle", false, "Do not create a certificate bundle by adding the issuers certificate to the new certificate.") + +} diff --git a/account.go b/cmd/utils/account.go similarity index 97% rename from account.go rename to cmd/utils/account.go index 85ac09f1..547d8e94 100644 --- a/account.go +++ b/cmd/utils/account.go @@ -1,4 +1,4 @@ -package main +package utils import ( "crypto" @@ -24,7 +24,7 @@ func NewAccount(email string, conf *Configuration) *Account { accKeysPath := conf.AccountKeysPath(email) // TODO: move to function in configuration? accKeyPath := accKeysPath + string(os.PathSeparator) + email + ".key" - if err := checkFolder(accKeysPath); err != nil { + if err := CheckFolder(accKeysPath); err != nil { logger().Fatalf("Could not check/create directory for account %s: %v", email, err) } 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..e4b578f7 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") + pathStr, err := c.context.PersistentFlags().GetString("path") + if err != nil { + log.Fatalln(err.Error()) + } + return path.Join(pathStr, "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()) + pathStr, err := c.context.PersistentFlags().GetString("path") + if err != nil { + log.Fatalln(err.Error()) + } + return path.Join(pathStr, "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/cli_handlers.go b/cmd/utils/utils.go similarity index 50% rename from cli_handlers.go rename to cmd/utils/utils.go index 1601c43b..4bacbcbd 100644 --- a/cli_handlers.go +++ b/cmd/utils/utils.go @@ -1,15 +1,15 @@ -package main +package utils import ( "bufio" "encoding/json" "io/ioutil" + "log" "os" "path" "strings" - "time" - "github.com/codegangsta/cli" + "github.com/spf13/cobra" "github.com/xenolf/lego/acme" "github.com/xenolf/lego/providers/dns/cloudflare" "github.com/xenolf/lego/providers/dns/digitalocean" @@ -24,43 +24,103 @@ import ( "github.com/xenolf/lego/providers/http/webroot" ) -func checkFolder(path string) error { +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 setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { - err := checkFolder(c.GlobalString("path")) +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) { + pathStr, err := c.PersistentFlags().GetString("path") + if err != nil { + logger().Fatalf(err.Error()) + } + err = CheckFolder(pathStr) if err != nil { logger().Fatalf("Could not check/create path: %s", err.Error()) } conf := NewConfiguration(c) - if len(c.GlobalString("email")) == 0 { + + 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(c.GlobalString("email"), conf) + acc := NewAccount(email, conf) keyType, err := conf.KeyType() if err != nil { logger().Fatal(err.Error()) } - client, err := acme.NewClient(c.GlobalString("server"), acc, keyType) + 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()) } - if len(c.GlobalStringSlice("exclude")) > 0 { + excludeStr, err := c.PersistentFlags().GetStringSlice("exclude") + if err != nil { + logger().Fatal(err.Error()) + } + if len(excludeStr) > 0 { client.ExcludeChallenges(conf.ExcludedSolvers()) } - if c.GlobalIsSet("webroot") { - provider, err := webroot.NewHTTPProvider(c.GlobalString("webroot")) + webrootStr, err := c.PersistentFlags().GetString("webroot") + if err != nil { + logger().Fatal(err.Error()) + } + + if len(webrootStr) > 0 { + provider, err := webroot.NewHTTPProvider(webrootStr) if err != nil { logger().Fatal(err) } @@ -71,24 +131,39 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { // infer that the user also wants to exclude all other challenges client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01}) } - if c.GlobalIsSet("http") { - if strings.Index(c.GlobalString("http"), ":") == -1 { + + httpStr, err := c.PersistentFlags().GetString("http") + if err != nil { + logger().Fatal(err.Error()) + } + if len(httpStr) > 0 { + if strings.Index(httpStr, ":") == -1 { logger().Fatalf("The --http switch only accepts interface:port or :port for its argument.") } - client.SetHTTPAddress(c.GlobalString("http")) + client.SetHTTPAddress(httpStr) } - if c.GlobalIsSet("tls") { - if strings.Index(c.GlobalString("tls"), ":") == -1 { + 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(c.GlobalString("tls")) + client.SetTLSAddress(tls) } - if c.GlobalIsSet("dns") { + 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 c.GlobalString("dns") { + switch dns { case "cloudflare": provider, err = cloudflare.NewDNSProvider() case "digitalocean": @@ -127,37 +202,14 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { return conf, acc, client } -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 handleTOS(c *cli.Context, client *acme.Client, acc *Account) { +func HandleTOS(c *cobra.Command, client *acme.Client, acc *Account) { // Check for a global accept override - if c.GlobalBool("accept-tos") { + 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()) @@ -195,139 +247,3 @@ func handleTOS(c *cli.Context, client *acme.Client, acc *Account) { logger().Println("Your input was invalid. Please answer with one of Y/y, n or by pressing enter.") } } - -func run(c *cli.Context) { - conf, acc, client := setup(c) - 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() - - 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(c.GlobalString("email"))) - - } - - // If the agreement URL is empty, the account still needs to accept the LE TOS. - if acc.Registration.Body.Agreement == "" { - handleTOS(c, client, acc) - } - - if len(c.GlobalStringSlice("domains")) == 0 { - logger().Fatal("Please specify --domains or -d") - } - - cert, failures := client.ObtainCertificate(c.GlobalStringSlice("domains"), !c.Bool("no-bundle"), 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 := checkFolder(conf.CertPath()) - if err != nil { - logger().Fatalf("Could not check/create path: %s", err.Error()) - } - - saveCertRes(cert, conf) -} - -func revoke(c *cli.Context) { - - conf, _, client := setup(c) - - err := checkFolder(conf.CertPath()) - if err != nil { - logger().Fatalf("Could not check/create path: %s", err.Error()) - } - - for _, domain := range c.GlobalStringSlice("domains") { - logger().Printf("Trying to revoke certificate for domain %s", domain) - - certPath := path.Join(conf.CertPath(), domain+".crt") - certBytes, err := ioutil.ReadFile(certPath) - - err = client.RevokeCertificate(certBytes) - if err != nil { - logger().Fatalf("Error while revoking the certificate for domain %s\n\t%s", domain, err.Error()) - } else { - logger().Print("Certificate was revoked.") - } - } -} - -func renew(c *cli.Context) { - conf, _, client := setup(c) - - if len(c.GlobalStringSlice("domains")) <= 0 { - logger().Fatal("Please specify at least one domain.") - } - - domain := c.GlobalStringSlice("domains")[0] - - // load the cert resource from files. - // We store the certificate, private key and metadata in different files - // as web servers would not be able to work with a combined file. - certPath := path.Join(conf.CertPath(), domain+".crt") - privPath := path.Join(conf.CertPath(), domain+".key") - metaPath := path.Join(conf.CertPath(), domain+".json") - - certBytes, err := ioutil.ReadFile(certPath) - if err != nil { - logger().Fatalf("Error while loading the certificate for domain %s\n\t%s", domain, err.Error()) - } - - if c.IsSet("days") { - expTime, err := acme.GetPEMCertExpiration(certBytes) - if err != nil { - logger().Printf("Could not get Certification expiration for domain %s", domain) - } - - if int(expTime.Sub(time.Now()).Hours()/24.0) > c.Int("days") { - return - } - } - - metaBytes, err := ioutil.ReadFile(metaPath) - if err != nil { - logger().Fatalf("Error while loading the meta data for domain %s\n\t%s", domain, err.Error()) - } - - var certRes acme.CertificateResource - err = json.Unmarshal(metaBytes, &certRes) - if err != nil { - logger().Fatalf("Error while marshalling the meta data for domain %s\n\t%s", domain, err.Error()) - } - - if c.Bool("reuse-key") { - keyBytes, err := ioutil.ReadFile(privPath) - if err != nil { - logger().Fatalf("Error while loading the private key for domain %s\n\t%s", domain, err.Error()) - } - certRes.PrivateKey = keyBytes - } - - certRes.Certificate = certBytes - - newCert, err := client.RenewCertificate(certRes, !c.Bool("no-bundle")) - if err != nil { - logger().Fatalf("%s", err.Error()) - } - - saveCertRes(newCert, conf) -} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 00000000..6b966913 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// versionCmd represents the version command +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Prints current version of lego", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + // TODO: Work your own magic here + fmt.Println("lego version", version) + }, +} + +func init() { + RootCmd.AddCommand(versionCmd) +} diff --git a/main.go b/main.go new file mode 100644 index 00000000..b210b425 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/xenolf/lego/cmd" + +func main() { + cmd.Execute() +}