]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
commands: Add TLS/HTTPS support to hugo server
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 5 Jun 2023 07:53:53 +0000 (09:53 +0200)
committerGitHub <noreply@github.com>
Mon, 5 Jun 2023 07:53:53 +0000 (09:53 +0200)
* commands: Add TLS/HTTPS support to hugo server

The "auto cert" handling in this PR is backed by mkcert (see link below).

To get this up and running on a new PC, you can:

```
hugo server trust
hugo server --tlsAuto
```

When `--tlsAuto` (or `--tlsCertFile` and `--tlsKeyFile`) is set and no `--baseURL` is provided as a flag, the server is
started with TLS and `https` as the protocol.

Note that you only need to run `hugo server trust` once per PC.

If you already have the key and the cert file (e.g. by using mkcert directly), you can do:

```
hugo server --tlsCertFile mycert.pem --tlsKeyFile mykey.pem
```

See https://github.com/FiloSottile/mkcert

Fixes #11064

commands/commandeer.go
commands/server.go
go.mod
go.sum
testscripts/commands/gen.txt

index 0ae6aefce05ed9ace6ece77ae6c189779612b09e..d43d06b8a757f9136bc761b34dada4b440cbee24 100644 (file)
@@ -18,6 +18,7 @@ import (
        "errors"
        "fmt"
        "io"
+       "log"
        "os"
        "os/signal"
        "path/filepath"
@@ -389,6 +390,9 @@ func (r *rootCommand) PreRun(cd, runner *simplecobra.Commandeer) error {
        if r.quiet {
                r.Out = io.Discard
        }
+       // Used by mkcert (server).
+       log.SetOutput(r.Out)
+
        r.Printf = func(format string, v ...interface{}) {
                if !r.quiet {
                        fmt.Fprintf(r.Out, format, v...)
index 048b13ba3c2abd938cd4896724063fa18c89226b..4efb47f2736f6d29df75e92c946e693902fa4164 100644 (file)
@@ -16,10 +16,14 @@ package commands
 import (
        "bytes"
        "context"
+       "crypto/tls"
+       "crypto/x509"
        "encoding/json"
+       "encoding/pem"
        "errors"
        "fmt"
        "io"
+       "io/ioutil"
        "net"
        "net/http"
        "net/url"
@@ -27,6 +31,8 @@ import (
        "sync"
        "sync/atomic"
 
+       "github.com/bep/mclib"
+
        "os/signal"
        "path"
        "path/filepath"
@@ -54,6 +60,7 @@ import (
        "github.com/gohugoio/hugo/transform"
        "github.com/gohugoio/hugo/transform/livereloadinject"
        "github.com/spf13/afero"
+       "github.com/spf13/cobra"
        "github.com/spf13/fsync"
        "golang.org/x/sync/errgroup"
        "golang.org/x/sync/semaphore"
@@ -96,13 +103,40 @@ func newHugoBuilder(r *rootCommand, s *serverCommand, onConfigLoaded ...func(rel
 }
 
 func newServerCommand() *serverCommand {
+       // Flags.
+       var uninstall bool
+
        var c *serverCommand
+
        c = &serverCommand{
                quit: make(chan bool),
+               commands: []simplecobra.Commander{
+                       &simpleCommand{
+                               name:  "trust",
+                               short: "Install the local CA in the system trust store.",
+                               run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error {
+                                       action := "-install"
+                                       if uninstall {
+                                               action = "-uninstall"
+                                       }
+                                       os.Args = []string{action}
+                                       return mclib.RunMain()
+                               },
+                               withc: func(cmd *cobra.Command, r *rootCommand) {
+                                       cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Uninstall the local CA (but do not delete it).")
+
+                               },
+                       },
+               },
        }
+
        return c
 }
 
+func (c *serverCommand) Commands() []simplecobra.Commander {
+       return c.commands
+}
+
 type countingStatFs struct {
        afero.Fs
        statCounter uint64
@@ -422,6 +456,9 @@ type serverCommand struct {
        navigateToChanged   bool
        serverAppend        bool
        serverInterface     string
+       tlsCertFile         string
+       tlsKeyFile          string
+       tlsAuto             bool
        serverPort          int
        liveReloadPort      int
        serverWatch         bool
@@ -431,10 +468,6 @@ type serverCommand struct {
        disableBrowserError bool
 }
 
-func (c *serverCommand) Commands() []simplecobra.Commander {
-       return c.commands
-}
-
 func (c *serverCommand) Name() string {
        return "server"
 }
@@ -494,6 +527,9 @@ of a second, you will be able to save and see your changes nearly instantly.`
        cmd.Flags().IntVarP(&c.serverPort, "port", "p", 1313, "port on which the server will listen")
        cmd.Flags().IntVar(&c.liveReloadPort, "liveReloadPort", -1, "port for live reloading (i.e. 443 in HTTPS proxy situations)")
        cmd.Flags().StringVarP(&c.serverInterface, "bind", "", "127.0.0.1", "interface to which the server will bind")
+       cmd.Flags().StringVarP(&c.tlsCertFile, "tlsCertFile", "", "", "path to TLS certificate file")
+       cmd.Flags().StringVarP(&c.tlsKeyFile, "tlsKeyFile", "", "", "path to TLS key file")
+       cmd.Flags().BoolVar(&c.tlsAuto, "tlsAuto", false, "generate and use locally-trusted certificates.")
        cmd.Flags().BoolVarP(&c.serverWatch, "watch", "w", true, "watch filesystem for changes and recreate as needed")
        cmd.Flags().BoolVar(&c.noHTTPCache, "noHTTPCache", false, "prevent HTTP caching")
        cmd.Flags().BoolVarP(&c.serverAppend, "appendPort", "", true, "append port to baseURL")
@@ -507,6 +543,9 @@ of a second, you will be able to save and see your changes nearly instantly.`
        cmd.Flags().String("memstats", "", "log memory usage to this file")
        cmd.Flags().String("meminterval", "100ms", "interval to poll memory usage (requires --memstats), valid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".")
 
+       cmd.Flags().SetAnnotation("tlsCertFile", cobra.BashCompSubdirsInDir, []string{})
+       cmd.Flags().SetAnnotation("tlsKeyFile", cobra.BashCompSubdirsInDir, []string{})
+
        r := cd.Root.Command.(*rootCommand)
        applyLocalFlagsBuild(cmd, r)
 
@@ -524,7 +563,14 @@ func (c *serverCommand) PreRun(cd, runner *simplecobra.Commandeer) error {
                                if err := c.createServerPorts(cd); err != nil {
                                        return err
                                }
+
+                               if (c.tlsCertFile == "" || c.tlsKeyFile == "") && c.tlsAuto {
+                                       c.withConfE(func(conf *commonConfig) error {
+                                               return c.createCertificates(conf)
+                                       })
+                               }
                        }
+
                        if err := c.setBaseURLsInConfig(); err != nil {
                                return err
                        }
@@ -619,6 +665,78 @@ func (c *serverCommand) getErrorWithContext() any {
        return m
 }
 
+func (c *serverCommand) createCertificates(conf *commonConfig) error {
+       hostname := "localhost"
+       if c.r.baseURL != "" {
+               u, err := url.Parse(c.r.baseURL)
+               if err != nil {
+                       return err
+               }
+               hostname = u.Hostname()
+       }
+
+       // For now, store these in the Hugo cache dir.
+       // Hugo should probably introduce some concept of a less temporary application directory.
+       keyDir := filepath.Join(conf.configs.LoadingInfo.BaseConfig.CacheDir, "_mkcerts")
+
+       // Create the directory if it doesn't exist.
+       if _, err := os.Stat(keyDir); os.IsNotExist(err) {
+               if err := os.MkdirAll(keyDir, 0777); err != nil {
+                       return err
+               }
+       }
+
+       c.tlsCertFile = filepath.Join(keyDir, fmt.Sprintf("%s.pem", hostname))
+       c.tlsKeyFile = filepath.Join(keyDir, fmt.Sprintf("%s-key.pem", hostname))
+
+       // Check if the certificate already exists and is valid.
+       certPEM, err := ioutil.ReadFile(c.tlsCertFile)
+       if err == nil {
+               rootPem, err := ioutil.ReadFile(filepath.Join(mclib.GetCAROOT(), "rootCA.pem"))
+               if err == nil {
+                       if err := c.verifyCert(rootPem, certPEM, hostname); err == nil {
+                               c.r.Println("Using existing", c.tlsCertFile, "and", c.tlsKeyFile)
+                               return nil
+                       }
+               }
+       }
+
+       c.r.Println("Creating TLS certificates in", keyDir)
+
+       // Yes, this is unfortunate, but it's currently the only way to use Mkcert as a library.
+       os.Args = []string{"-cert-file", c.tlsCertFile, "-key-file", c.tlsKeyFile, hostname}
+       return mclib.RunMain()
+
+}
+
+func (c *serverCommand) verifyCert(rootPEM, certPEM []byte, name string) error {
+       roots := x509.NewCertPool()
+       ok := roots.AppendCertsFromPEM(rootPEM)
+       if !ok {
+               return fmt.Errorf("failed to parse root certificate")
+       }
+
+       block, _ := pem.Decode(certPEM)
+       if block == nil {
+               return fmt.Errorf("failed to parse certificate PEM")
+       }
+       cert, err := x509.ParseCertificate(block.Bytes)
+       if err != nil {
+               return fmt.Errorf("failed to parse certificate: %v", err.Error())
+       }
+
+       opts := x509.VerifyOptions{
+               DNSName: name,
+               Roots:   roots,
+       }
+
+       if _, err := cert.Verify(opts); err != nil {
+               return fmt.Errorf("failed to verify certificate: %v", err.Error())
+       }
+
+       return nil
+}
+
 func (c *serverCommand) createServerPorts(cd *simplecobra.Commandeer) error {
        flags := cd.CobraCommand.Flags()
        var cerr error
@@ -661,36 +779,40 @@ func (c *serverCommand) createServerPorts(cd *simplecobra.Commandeer) error {
 
 // fixURL massages the baseURL into a form needed for serving
 // all pages correctly.
-func (c *serverCommand) fixURL(baseURL, s string, port int) (string, error) {
+func (c *serverCommand) fixURL(baseURLFromConfig, baseURLFromFlag string, port int) (string, error) {
+       certsSet := (c.tlsCertFile != "" && c.tlsKeyFile != "") || c.tlsAuto
        useLocalhost := false
-       if s == "" {
-               s = baseURL
+       baseURL := baseURLFromFlag
+       if baseURL == "" {
+               baseURL = baseURLFromConfig
                useLocalhost = true
        }
 
-       if !strings.HasSuffix(s, "/") {
-               s = s + "/"
+       if !strings.HasSuffix(baseURL, "/") {
+               baseURL = baseURL + "/"
        }
 
        // do an initial parse of the input string
-       u, err := url.Parse(s)
+       u, err := url.Parse(baseURL)
        if err != nil {
                return "", err
        }
 
        // if no Host is defined, then assume that no schema or double-slash were
        // present in the url.  Add a double-slash and make a best effort attempt.
-       if u.Host == "" && s != "/" {
-               s = "//" + s
+       if u.Host == "" && baseURL != "/" {
+               baseURL = "//" + baseURL
 
-               u, err = url.Parse(s)
+               u, err = url.Parse(baseURL)
                if err != nil {
                        return "", err
                }
        }
 
        if useLocalhost {
-               if u.Scheme == "https" {
+               if certsSet {
+                       u.Scheme = "https"
+               } else if u.Scheme == "https" {
                        u.Scheme = "http"
                }
                u.Host = "localhost"
@@ -807,10 +929,22 @@ func (c *serverCommand) serve() error {
 
        for i := range baseURLs {
                mu, listener, serverURL, endpoint, err := srv.createEndpoint(i)
-               srv := &http.Server{
-                       Addr:    endpoint,
-                       Handler: mu,
+               var srv *http.Server
+               if c.tlsCertFile != "" && c.tlsKeyFile != "" {
+                       srv = &http.Server{
+                               Addr:    endpoint,
+                               Handler: mu,
+                               TLSConfig: &tls.Config{
+                                       MinVersion: tls.VersionTLS12,
+                               },
+                       }
+               } else {
+                       srv = &http.Server{
+                               Addr:    endpoint,
+                               Handler: mu,
+                       }
                }
+
                servers = append(servers, srv)
 
                if doLiveReload {
@@ -824,7 +958,11 @@ func (c *serverCommand) serve() error {
                }
                c.r.Printf("Web Server is available at %s (bind address %s)\n", serverURL, c.serverInterface)
                wg1.Go(func() error {
-                       err = srv.Serve(listener)
+                       if c.tlsCertFile != "" && c.tlsKeyFile != "" {
+                               err = srv.ServeTLS(listener, c.tlsCertFile, c.tlsKeyFile)
+                       } else {
+                               err = srv.Serve(listener)
+                       }
                        if err != nil && err != http.ErrServerClosed {
                                return err
                        }
diff --git a/go.mod b/go.mod
index 18847d14271d01d137e11fc55691f8563946cb0f..d19c1b0ba146045435f6de74dd0f6398fd7c6a92 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
        github.com/bep/gowebp v0.2.0
        github.com/bep/helpers v0.4.0
        github.com/bep/lazycache v0.2.0
+       github.com/bep/mclib v1.20400.20402
        github.com/bep/overlayfs v0.6.0
        github.com/bep/simplecobra v0.3.1
        github.com/bep/tmc v0.5.1
@@ -125,7 +126,7 @@ require (
        github.com/perimeterx/marshmallow v1.1.4 // indirect
        github.com/russross/blackfriday/v2 v2.1.0 // indirect
        go.opencensus.io v0.24.0 // indirect
-       golang.org/x/crypto v0.3.0 // indirect
+       golang.org/x/crypto v0.9.0 // indirect
        golang.org/x/mod v0.10.0 // indirect
        golang.org/x/oauth2 v0.7.0 // indirect
        golang.org/x/sys v0.8.0 // indirect
@@ -135,6 +136,8 @@ require (
        google.golang.org/grpc v1.54.0 // indirect
        google.golang.org/protobuf v1.30.0 // indirect
        gopkg.in/yaml.v3 v3.0.1 // indirect
+       howett.net/plist v1.0.0 // indirect
+       software.sslmate.com/src/go-pkcs12 v0.2.0 // indirect
 )
 
 go 1.18
diff --git a/go.sum b/go.sum
index f02171e68dcb736e77530f34928f1eb3acfe673d..ec96735a68562315baf7d9bd1c2bcdc0bccb3bc7 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -178,6 +178,8 @@ github.com/bep/helpers v0.4.0 h1:ab9veaAiWY4ST48Oxp5usaqivDmYdB744fz+tcZ3Ifs=
 github.com/bep/helpers v0.4.0/go.mod h1:/QpHdmcPagDw7+RjkLFCvnlUc8lQ5kg4KDrEkb2Yyco=
 github.com/bep/lazycache v0.2.0 h1:HKrlZTrDxHIrNKqmnurH42ryxkngCMYLfBpyu40VcwY=
 github.com/bep/lazycache v0.2.0/go.mod h1:xUIsoRD824Vx0Q/n57+ZO7kmbEhMBOnTjM/iPixNGbg=
+github.com/bep/mclib v1.20400.20402 h1:olpCE2WSPpOAbFE1R4hnftSEmQ34+xzy2HRzd0m69rA=
+github.com/bep/mclib v1.20400.20402/go.mod h1:pkrk9Kyfqg34Uj6XlDq9tdEFJBiL1FvCoCgVKRzw1EY=
 github.com/bep/overlayfs v0.6.0 h1:sgLcq/qtIzbaQNl2TldGXOkHvqeZB025sPvHOQL+DYo=
 github.com/bep/overlayfs v0.6.0/go.mod h1:NFjSmn3kCqG7KX2Lmz8qT8VhPPCwZap3UNogXawoQHM=
 github.com/bep/simplecobra v0.3.1 h1:Ms9BucXcJRiGbPYpaJyxItYceQN/pvEZ0+V1+cUcsZ4=
@@ -411,6 +413,7 @@ github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc=
 github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
 github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
 github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
 github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
@@ -595,8 +598,9 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
-golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
+golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1024,6 +1028,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/neurosnap/sentences.v1 v1.0.6/go.mod h1:YlK+SN+fLQZj+kY3r8DkGDhDr91+S3JmTb5LSxFRQo0=
+gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -1042,8 +1047,12 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
+howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
 nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
 nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+software.sslmate.com/src/go-pkcs12 v0.2.0 h1:nlFkj7bTysH6VkC4fGphtjXRbezREPgrHuJG20hBGPE=
+software.sslmate.com/src/go-pkcs12 v0.2.0/go.mod h1:23rNcYsMabIc1otwLpTkCCPwUq6kQsTyowttG/as0kQ=
index 06f060b3ccc79fd65cfbd27babd1f7eec783fb1d..891079e11bc8f361bfdfa0b019f59cf2922ffe2c 100644 (file)
@@ -1,6 +1,6 @@
 # Test the gen commands.
 # Note that adding new commands will require updating the NUM_COMMANDS value.
-env NUM_COMMANDS=41
+env NUM_COMMANDS=42
 
 hugo gen -h
 stdout 'A collection of several useful generators\.'