From e5ca1a3d50997161aec0a59e1a3d7b03410f7ec8 Mon Sep 17 00:00:00 2001 From: Philipp Mieden Date: Wed, 20 Feb 2019 18:19:00 +0100 Subject: [PATCH] bootstrapped local mode, for automatically generating a trusted self signed cert via mkcert tool for local development, added example --- .gitignore | 2 +- README.md | 2 +- TODO.md | 10 ++++ config.go | 4 ++ example/main.go | 52 +++++++++++++++++ reloader.go | 1 + simplecert.go | 43 ++++++++++++++- utils.go | 144 ++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 TODO.md create mode 100644 example/main.go diff --git a/.gitignore b/.gitignore index a1338d6..cfbd4c0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ *.dll *.so *.dylib - +*.pem # Test binary, build with `go test -c` *.test diff --git a/README.md b/README.md index 1037d51..38001cc 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ if err != nil { // redirect HTTP to HTTPS // CAUTION: This has to be done AFTER simplecert setup // Otherwise Port 80 will be blocked and cert registration fails! -cLog.Info("starting HTTP Listener on Port 80") +log.Println("starting HTTP Listener on Port 80") go http.ListenAndServe(":80", http.HandlerFunc(redirect)) // init strict tlsConfig with certReloader diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..d54a217 --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +# TODO + +- allow graceful shutdown before renewal +- add a wrapper for ListenAndServeTLS +- use go modules for vendoring + +## update readme + +- local mode +- set API TOKEN for dns challenge diff --git a/config.go b/config.go index 95e83f5..d603dec 100644 --- a/config.go +++ b/config.go @@ -30,6 +30,7 @@ var Default = &Config{ Domains: []string{}, CacheDir: "", DNSProvider: "", + Local: false, } // Config allows configuration of simplecert @@ -66,6 +67,9 @@ type Config struct { // DNSProvider name for DNS challenges (optional) // see: https://godoc.org/github.com/xenolf/lego/providers/dns DNSProvider string + + // Local runmode + Local bool } // CheckConfig checks if config can be used to obtain a cert diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..603195a --- /dev/null +++ b/example/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "log" + "net/http" + + "github.com/foomo/simplecert" + "github.com/foomo/tlsconfig" +) + +type Handler struct{} + +func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("hello from simplecert")) +} + +func main() { + + // do the cert magic + cfg := simplecert.Default + cfg.Domains = []string{"yourdomain.com", "www.yourdomain.com"} + cfg.CacheDir = "letsencrypt" + cfg.SSLEmail = "you@emailprovider.com" + cfg.Local = true + certReloader, err := simplecert.Init(cfg) + if err != nil { + log.Fatal("simplecert init failed: ", err) + } + + // redirect HTTP to HTTPS + log.Println("starting HTTP Listener on Port 80") + go http.ListenAndServe(":80", http.HandlerFunc(simplecert.Redirect)) + + // init strict tlsConfig with certReloader + tlsconf := tlsconfig.NewServerTLSConfig(tlsconfig.TLSModeServerStrict) + + // now set GetCertificate to the reloaders GetCertificateFunc to enable hot reload + tlsconf.GetCertificate = certReloader.GetCertificateFunc() + + // init server + s := &http.Server{ + Addr: ":443", + TLSConfig: tlsconf, + Handler: Handler{}, + } + + log.Println("now visit: https://" + cfg.Domains[0]) + + // lets go + log.Fatal(s.ListenAndServeTLS("", "")) +} diff --git a/reloader.go b/reloader.go index 414e0f3..7f51e64 100644 --- a/reloader.go +++ b/reloader.go @@ -74,6 +74,7 @@ func NewCertReloader(certPath, keyPath string, logFile *os.File) (*CertReloader, log.Fatal("[FATAL] simplecert: failed to close logfile handle: ", err) } log.Println("[INFO] simplecert: closed logfile handle") + os.Exit(0) } } }() diff --git a/simplecert.go b/simplecert.go index 8847ecd..c3bbf73 100644 --- a/simplecert.go +++ b/simplecert.go @@ -44,6 +44,9 @@ func Init(cfg *Config) (*CertReloader, error) { // update global config c = cfg + // make sure the cacheDir exists + ensureCacheDirExists(c.CacheDir) + // open logfile handle logFile, err := os.OpenFile(filepath.Join(c.CacheDir, logFileName), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0755) if err != nil { @@ -53,6 +56,43 @@ func Init(cfg *Config) (*CertReloader, error) { // configure log pkg to log to stdout and into the logfile log.SetOutput(io.MultiWriter(os.Stdout, logFile)) + if c.Local { + + // update the cachedir path + // certs used in local mode are stored in the "local" subfolder + // to avoid overwriting a production certificate + c.CacheDir = filepath.Join(c.CacheDir, "local") + + // make sure the cacheDir/local folder exists + ensureCacheDirExists(c.CacheDir) + + var ( + certFilePath = filepath.Join(c.CacheDir, "cert.pem") + keyFilePath = filepath.Join(c.CacheDir, "key.pem") + ) + + // check if a local cert is already cached + if certCached(c.CacheDir) { + + // cert cached! Did the domains change? + // If the domains have been modified we need to generate a new certificate + if domainsChanged() { + log.Println("[INFO] cert cached but domains have changed. generating a new one...") + createLocalCert(certFilePath, keyFilePath) + } + } else { + + // nothing there yet. create a new one + createLocalCert(certFilePath, keyFilePath) + } + + // create entries in /etc/hosts if necessary + updateHosts() + + // return a cert reloader for the local cert + return NewCertReloader(certFilePath, keyFilePath, logFile) + } + var ( certFilePath = filepath.Join(c.CacheDir, "cert.pem") keyFilePath = filepath.Join(c.CacheDir, "key.pem") @@ -94,9 +134,6 @@ func Init(cfg *Config) (*CertReloader, error) { * No Cert Found. Register a new one */ - // make sure the cacheDir exists - ensureCacheDirExists(c.CacheDir) - // get ACME Client client := createClient(getUser()) diff --git a/utils.go b/utils.go index f95419b..79039e1 100644 --- a/utils.go +++ b/utils.go @@ -13,12 +13,20 @@ import ( "encoding/json" "encoding/pem" "errors" + "fmt" "io/ioutil" "log" + "net/http" "os" + "os/exec" + "path/filepath" + "strconv" + "strings" "syscall" "time" + "github.com/go-yaml/yaml" + "github.com/lextoumbourou/goodhosts" "github.com/xenolf/lego/certificate" ) @@ -26,6 +34,8 @@ import ( // even if renewal happens just before midnight and restoring afterwards var backupDate string +const localhost = "127.0.0.1" + /* * Utils */ @@ -214,3 +224,137 @@ func saveCertToDisk(cert *certificate.Resource, cacheDir string) error { return nil } + +// Redirect a request to HTTPS and strip www. subdomain +func Redirect(w http.ResponseWriter, req *http.Request) { + + // remove/add not default ports from req.Host + target := "https://" + strings.TrimPrefix(req.Host, "www.") + req.URL.Path + if len(req.URL.RawQuery) > 0 { + target += "?" + req.URL.RawQuery + } + + fmt.Println("redirect: ", target, " ("+req.Host+")", "UserAgent:", req.UserAgent()) + http.Redirect(w, req, target, http.StatusTemporaryRedirect) +} + +// updateHosts is used in local mode +// to add all host entries for the domains +func updateHosts() { + hosts, err := goodhosts.NewHosts() + if err != nil { + log.Fatal("[ERROR] could not open hostsfile: ", err) + } + + for _, d := range c.Domains { + if !hosts.Has(localhost, d) { + hosts.Add(localhost, d) + } + } + + if err := hosts.Flush(); err != nil { + log.Fatal("[ERROR] could not update /etc/hosts: ", err) + } +} + +// createLocalCert first creates a local root CA for mkcert +// and then generates a trusted certificate for the domains specified in the configuration +func createLocalCert(certFilePath, keyFilePath string) { + + log.Println("[INFO] no cached cert found. Creating a new one for local development...") + + // run mkcert to create root CA + runCommand("mkcert", "-install") + + // run mkcert to generate the certificate + runCommand("mkcert", c.Domains...) + + var ( + newCertFile string + newKeyFile string + ) + if len(c.Domains) > 1 { + newCertFile = c.Domains[0] + "+" + strconv.Itoa(len(c.Domains)-1) + ".pem" + newKeyFile = c.Domains[0] + "+" + strconv.Itoa(len(c.Domains)-1) + "-key.pem" + } else { + newCertFile = c.Domains[0] + ".pem" + newKeyFile = c.Domains[0] + "-key.pem" + } + + // rename certificate file + log.Println("[INFO] renaming", newCertFile, "to", certFilePath) + err := os.Rename(newCertFile, certFilePath) + if err != nil { + log.Fatal("[ERROR] failed to rename cert file: ", err) + } + + // rename key file + log.Println("[INFO] renaming", newKeyFile, "to", keyFilePath) + err = os.Rename(newKeyFile, keyFilePath) + if err != nil { + log.Fatal("[ERROR] failed to rename key file: ", err) + } + + // write domains to CacheDir/domains.yml + b, err := yaml.Marshal(c.Domains) + if err != nil { + log.Fatal("[ERROR] failed to marshal domain slice: ", err) + } + + // create file to persist the domains used for the cert + f, err := os.Create(filepath.Join(c.CacheDir, "domains.yml")) + if err != nil { + log.Fatal("[ERROR] failed to create domains.yml: ", err) + } + defer f.Close() + + // write to disk + _, err = f.Write(b) + if err != nil { + log.Fatal("[ERROR] failed to write domains.yml: ", err) + } +} + +// domainsChanged check the stored domains when running in local mode +// if they dont match the domains from the configuration +// this function returns true +func domainsChanged() bool { + + // read domains.yml from local cachedir + b, err := ioutil.ReadFile(filepath.Join(c.CacheDir, "domains.yml")) + if err != nil { + log.Fatal(err) + } + + // unmarshal string slice + var domains []string + err = yaml.Unmarshal(b, &domains) + if err != nil { + log.Fatal(err) + } + + // if the number of entries is not equal, bail out. + if len(domains) != len(c.Domains) { + return true + } + + // compare each entry + for i, d := range domains { + if d != c.Domains[i] { + return true + } + } + + // identical + return false +} + +// runCommand executes the named command with the supplied arguments +// and fatals on error +func runCommand(cmd string, args ...string) { + out, err := exec.Command(cmd, args...).CombinedOutput() + if err != nil { + log.Println("[ERROR] failed to run command: ", cmd+strings.Join(args, " ")) + log.Fatal("[ERROR] error: ", err, ", output: ", string(out)) + } +}