Skip to content

Commit

Permalink
commands: Add TLS/HTTPS support to hugo server
Browse files Browse the repository at this point in the history
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 gohugoio#11064
  • Loading branch information
bep committed Jun 4, 2023
1 parent 536bf71 commit 42c3f6e
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 20 deletions.
4 changes: 4 additions & 0 deletions commands/commandeer.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"errors"
"fmt"
"io"
"log"
"os"
"os/signal"
"path/filepath"
Expand Down Expand Up @@ -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...)
Expand Down
174 changes: 156 additions & 18 deletions commands/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,23 @@ package commands
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"sync"
"sync/atomic"

"github.com/bep/mclib"

"os/signal"
"path"
"path/filepath"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
}
Expand Down Expand Up @@ -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")
Expand All @@ -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("cert", cobra.BashCompSubdirsInDir, []string{})
cmd.Flags().SetAnnotation("key", cobra.BashCompSubdirsInDir, []string{})

r := cd.Root.Command.(*rootCommand)
applyLocalFlagsBuild(cmd, r)

Expand All @@ -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
}
Expand Down Expand Up @@ -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(string(rootPem), string(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 string, name string) error {
roots := x509.NewCertPool()
ok := roots.AppendCertsFromPEM([]byte(rootPEM))
if !ok {
return fmt.Errorf("failed to parse root certificate")
}

block, _ := pem.Decode([]byte(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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.4.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.7.0 // indirect
github.com/aws/smithy-go v1.8.0 // indirect
github.com/bep/mclib v1.20400.20400 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
Expand Down Expand Up @@ -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
Expand All @@ -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
Loading

0 comments on commit 42c3f6e

Please sign in to comment.