diff --git a/caddy/https/setup.go b/caddy/https/setup.go index ac275466..eebfc62d 100644 --- a/caddy/https/setup.go +++ b/caddy/https/setup.go @@ -83,10 +83,30 @@ func Setup(c *setup.Controller) (middleware.Middleware, error) { c.TLS.Ciphers = append(c.TLS.Ciphers, value) } case "clients": - c.TLS.ClientCerts = c.RemainingArgs() - if len(c.TLS.ClientCerts) == 0 { + clientCertList := c.RemainingArgs() + if len(clientCertList) == 0 { return nil, c.ArgErr() } + + listStart, mustProvideCA := 1, true + switch clientCertList[0] { + case "request": + c.TLS.ClientAuth = tls.RequestClientCert + mustProvideCA = false + case "require": + c.TLS.ClientAuth = tls.RequireAnyClientCert + mustProvideCA = false + case "verify_if_given": + c.TLS.ClientAuth = tls.VerifyClientCertIfGiven + default: + c.TLS.ClientAuth = tls.RequireAndVerifyClientCert + listStart = 0 + } + if mustProvideCA && len(clientCertList) <= listStart { + return nil, c.ArgErr() + } + + c.TLS.ClientCerts = clientCertList[listStart:] case "load": c.Args(&loadDir) c.TLS.Manual = true diff --git a/caddy/https/setup_test.go b/caddy/https/setup_test.go index 378a24f8..59a772c4 100644 --- a/caddy/https/setup_test.go +++ b/caddy/https/setup_test.go @@ -189,34 +189,69 @@ func TestSetupParseWithWrongOptionalParams(t *testing.T) { } func TestSetupParseWithClientAuth(t *testing.T) { + // Test missing client cert file params := `tls ` + certFile + ` ` + keyFile + ` { - clients client_ca.crt client2_ca.crt + clients }` c := setup.NewTestController(params) _, err := Setup(c) - if err != nil { - t.Errorf("Expected no errors, got: %v", err) - } - - if count := len(c.TLS.ClientCerts); count != 2 { - t.Fatalf("Expected two client certs, had %d", count) - } - if actual := c.TLS.ClientCerts[0]; actual != "client_ca.crt" { - t.Errorf("Expected first client cert file to be '%s', but was '%s'", "client_ca.crt", actual) - } - if actual := c.TLS.ClientCerts[1]; actual != "client2_ca.crt" { - t.Errorf("Expected second client cert file to be '%s', but was '%s'", "client2_ca.crt", actual) - } - - // Test missing client cert file - params = `tls ` + certFile + ` ` + keyFile + ` { - clients - }` - c = setup.NewTestController(params) - _, err = Setup(c) if err == nil { t.Errorf("Expected an error, but no error returned") } + + noCAs, twoCAs := []string{}, []string{"client_ca.crt", "client2_ca.crt"} + for caseNumber, caseData := range []struct { + params string + clientAuthType tls.ClientAuthType + expectedErr bool + expectedCAs []string + }{ + {"", tls.NoClientCert, false, noCAs}, + {`tls ` + certFile + ` ` + keyFile + ` { + clients client_ca.crt client2_ca.crt + }`, tls.RequireAndVerifyClientCert, false, twoCAs}, + // now come modifier + {`tls ` + certFile + ` ` + keyFile + ` { + clients request + }`, tls.RequestClientCert, false, noCAs}, + {`tls ` + certFile + ` ` + keyFile + ` { + clients require + }`, tls.RequireAnyClientCert, false, noCAs}, + {`tls ` + certFile + ` ` + keyFile + ` { + clients verify_if_given client_ca.crt client2_ca.crt + }`, tls.VerifyClientCertIfGiven, false, twoCAs}, + {`tls ` + certFile + ` ` + keyFile + ` { + clients verify_if_given + }`, tls.VerifyClientCertIfGiven, true, noCAs}, + } { + c := setup.NewTestController(caseData.params) + _, err := Setup(c) + if caseData.expectedErr { + if err == nil { + t.Errorf("In case %d: Expected an error, got: %v", caseNumber, err) + } + continue + } + if err != nil { + t.Errorf("In case %d: Expected no errors, got: %v", caseNumber, err) + } + + if caseData.clientAuthType != c.TLS.ClientAuth { + t.Errorf("In case %d: Expected TLS client auth type %v, got: %v", + caseNumber, caseData.clientAuthType, c.TLS.ClientAuth) + } + + if count := len(c.TLS.ClientCerts); count < len(caseData.expectedCAs) { + t.Fatalf("In case %d: Expected %d client certs, had %d", caseNumber, len(caseData.expectedCAs), count) + } + + for idx, expected := range caseData.expectedCAs { + if actual := c.TLS.ClientCerts[idx]; actual != expected { + t.Errorf("In case %d: Expected %dth client cert file to be '%s', but was '%s'", + caseNumber, idx, expected, actual) + } + } + } } func TestSetupParseWithKeyType(t *testing.T) { diff --git a/caddy/setup/browse.go b/caddy/setup/browse.go index 13d42e54..28cb2582 100644 --- a/caddy/setup/browse.go +++ b/caddy/setup/browse.go @@ -210,7 +210,8 @@ td:first-child svg { position: absolute; } -td .name { +td .name, +td .goup { margin-left: 1.75em; word-break: break-all; overflow-wrap: break-word; @@ -263,7 +264,6 @@ footer { - @@ -299,14 +299,14 @@ footer { - -
-

{{.LinkedPath}}

+

+ {{range $url, $name := .BreadcrumbMap}}{{$name}}{{if ne $url "/"}}/{{end}}{{end}} +

-
+
{{.NumDirs}} director{{if eq 1 .NumDirs}}y{{else}}ies{{end}} {{.NumFiles}} file{{if ne 1 .NumFiles}}s{{end}}
@@ -342,6 +342,17 @@ footer { {{end}} + {{if .CanGoUp}} + + + + Go up + + + — + — + + {{end}} {{range .Items}} diff --git a/dist/automate.go b/dist/automate.go index afe57650..594233f5 100644 --- a/dist/automate.go +++ b/dist/automate.go @@ -66,10 +66,7 @@ func main() { if p.arch == "arm" { baseFilename += p.arm } - binFilename = baseFilename - if p.os == "windows" { - binFilename += ".exe" - } + binFilename = baseFilename + p.binExt binPath := filepath.Join(buildDir, binFilename) archive := filepath.Join(releaseDir, fmt.Sprintf("%s.%s", baseFilename, p.archive)) @@ -126,7 +123,7 @@ func (p platform) String() string { func numProcs() int { n := runtime.GOMAXPROCS(0) if n == runtime.NumCPU() && n > 1 { - n -= 1 + n-- } return n } @@ -151,8 +148,8 @@ var platforms = []platform{ {os: "openbsd", arch: "386", archive: "tar.gz"}, {os: "openbsd", arch: "amd64", archive: "tar.gz"}, {os: "solaris", arch: "amd64", archive: "tar.gz"}, - {os: "windows", arch: "386", archive: "zip"}, - {os: "windows", arch: "amd64", archive: "zip"}, + {os: "windows", arch: "386", binExt: ".exe", archive: "zip"}, + {os: "windows", arch: "amd64", binExt: ".exe", archive: "zip"}, } var distContents = []string{ diff --git a/middleware/browse/browse.go b/middleware/browse/browse.go index a6e8be86..80ae8881 100644 --- a/middleware/browse/browse.go +++ b/middleware/browse/browse.go @@ -6,7 +6,6 @@ import ( "bytes" "encoding/json" "errors" - "fmt" "net/http" "net/url" "os" @@ -69,11 +68,13 @@ type Listing struct { middleware.Context } -// LinkedPath returns l.Path where every element is a clickable -// link to the path up to that point so far. -func (l Listing) LinkedPath() string { +// BreadcrumbMap returns l.Path where every element is a map +// of URLs and path segment names. +func (l Listing) BreadcrumbMap() map[string]string { + result := map[string]string{} + if len(l.Path) == 0 { - return "" + return result } // skip trailing slash @@ -83,14 +84,13 @@ func (l Listing) LinkedPath() string { } parts := strings.Split(lpath, "/") - var result string for i, part := range parts { if i == 0 && part == "" { // Leading slash (root) - result += `/` + result["/"] = "/" continue } - result += fmt.Sprintf(`%s/`, strings.Join(parts[:i+1], "/"), part) + result[strings.Join(parts[:i+1], "/")] = part } return result @@ -241,6 +241,11 @@ func (b Browse) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { if !middleware.Path(r.URL.Path).Matches(bc.PathScope) { continue } + switch r.Method { + case http.MethodGet, http.MethodHead: + default: + return http.StatusMethodNotAllowed, nil + } // Browsing navigation gets messed up if browsing a directory // that doesn't end in "/" (which it should, anyway) diff --git a/middleware/browse/browse_test.go b/middleware/browse/browse_test.go index 884d0394..a9983bd2 100644 --- a/middleware/browse/browse_test.go +++ b/middleware/browse/browse_test.go @@ -104,6 +104,51 @@ func TestSort(t *testing.T) { } } +func TestBrowseHTTPMethods(t *testing.T) { + tmpl, err := template.ParseFiles("testdata/photos.tpl") + if err != nil { + t.Fatalf("An error occured while parsing the template: %v", err) + } + + b := Browse{ + Next: middleware.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) { + t.Fatalf("Next shouldn't be called") + return 0, nil + }), + Root: "./testdata", + Configs: []Config{ + { + PathScope: "/photos", + Template: tmpl, + }, + }, + } + + rec := httptest.NewRecorder() + for method, expected := range map[string]int{ + http.MethodGet: http.StatusOK, + http.MethodHead: http.StatusOK, + http.MethodOptions: http.StatusMethodNotAllowed, + http.MethodPost: http.StatusMethodNotAllowed, + http.MethodPut: http.StatusMethodNotAllowed, + http.MethodPatch: http.StatusMethodNotAllowed, + http.MethodDelete: http.StatusMethodNotAllowed, + "COPY": http.StatusMethodNotAllowed, + "MOVE": http.StatusMethodNotAllowed, + "MKCOL": http.StatusMethodNotAllowed, + } { + req, err := http.NewRequest(method, "/photos/", nil) + if err != nil { + t.Fatalf("Test: Could not create HTTP request: %v", err) + } + + code, _ := b.ServeHTTP(rec, req) + if code != expected { + t.Errorf("Wrong status with HTTP Method %s: expected %d, got %d", method, expected, code) + } + } +} + func TestBrowseTemplate(t *testing.T) { tmpl, err := template.ParseFiles("testdata/photos.tpl") if err != nil { diff --git a/middleware/context_test.go b/middleware/context_test.go index e0c91f64..a61a3bf9 100644 --- a/middleware/context_test.go +++ b/middleware/context_test.go @@ -52,7 +52,13 @@ func TestInclude(t *testing.T) { fileContent: `str1 {{ .InvalidField }} str2`, expectedContent: "", shouldErr: true, - expectedErrorContent: `InvalidField is not a field of struct type middleware.Context`, + expectedErrorContent: `InvalidField`, + }, + { + fileContent: `str1 {{ .InvalidField }} str2`, + expectedContent: "", + shouldErr: true, + expectedErrorContent: `type middleware.Context`, }, } diff --git a/middleware/gzip/gzip.go b/middleware/gzip/gzip.go index 9d75b351..4ef65855 100644 --- a/middleware/gzip/gzip.go +++ b/middleware/gzip/gzip.go @@ -141,3 +141,22 @@ func (w *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { } return nil, nil, fmt.Errorf("not a Hijacker") } + +// Flush implements http.Flusher. It simply wraps the underlying +// ResponseWriter's Flush method if there is one, or panics. +func (w *gzipResponseWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } else { + panic("not a Flusher") // should be recovered at the beginning of middleware stack + } +} + +// CloseNotify implements http.CloseNotifier. +// It just inherits the underlying ResponseWriter's CloseNotify method. +func (w *gzipResponseWriter) CloseNotify() <-chan bool { + if cn, ok := w.ResponseWriter.(http.CloseNotifier); ok { + return cn.CloseNotify() + } + panic("not a CloseNotifier") +} diff --git a/middleware/recorder.go b/middleware/recorder.go index ab30e305..50f4811c 100644 --- a/middleware/recorder.go +++ b/middleware/recorder.go @@ -87,3 +87,12 @@ func (r *ResponseRecorder) Flush() { panic("not a Flusher") // should be recovered at the beginning of middleware stack } } + +// CloseNotify implements http.CloseNotifier. +// It just inherits the underlying ResponseWriter's CloseNotify method. +func (r *ResponseRecorder) CloseNotify() <-chan bool { + if cn, ok := r.ResponseWriter.(http.CloseNotifier); ok { + return cn.CloseNotify() + } + panic("not a CloseNotifier") +} diff --git a/server/config.go b/server/config.go index 1f4acdb6..e66ec801 100644 --- a/server/config.go +++ b/server/config.go @@ -1,6 +1,7 @@ package server import ( + "crypto/tls" "net" "github.com/mholt/caddy/middleware" @@ -75,4 +76,5 @@ type TLSConfig struct { ProtocolMaxVersion uint16 PreferServerCipherSuites bool ClientCerts []string + ClientAuth tls.ClientAuthType } diff --git a/server/server.go b/server/server.go index 7b9856ef..41d1a637 100644 --- a/server/server.go +++ b/server/server.go @@ -4,20 +4,28 @@ package server import ( + "crypto/rand" "crypto/tls" "crypto/x509" "fmt" + "io" "io/ioutil" "log" "net" "net/http" "os" + "path/filepath" "runtime" "strings" "sync" "time" ) +const ( + tlsNewTicketEvery = time.Hour * 10 // generate a new ticket for TLS PFS encryption every so often + tlsNumTickets = 4 // hold and consider that many tickets to decrypt TLS sessions +) + // Server represents an instance of a server, which serves // HTTP requests at a particular address (host and port). A // server is capable of serving numerous virtual hosts on @@ -28,6 +36,7 @@ type Server struct { HTTP2 bool // whether to enable HTTP/2 tls bool // whether this server is serving all HTTPS hosts or not OnDemandTLS bool // whether this server supports on-demand TLS (load certs at handshake-time) + tlsGovChan chan struct{} // close to stop the TLS maintenance goroutine vhosts map[string]virtualHost // virtual hosts keyed by their address listener ListenerFile // the listener which is bound to the socket listenerMu sync.Mutex // protects listener @@ -216,6 +225,11 @@ func serveTLS(s *Server, ln net.Listener, tlsConfigs []TLSConfig) error { return err } + // Setup any goroutines governing over TLS settings + s.tlsGovChan = make(chan struct{}) + timer := time.NewTicker(tlsNewTicketEvery) + go runTLSTicketKeyRotation(s.TLSConfig, timer, s.tlsGovChan) + // Create TLS listener - note that we do not replace s.listener // with this TLS listener; tls.listener is unexported and does // not implement the File() method we need for graceful restarts @@ -258,6 +272,11 @@ func (s *Server) Stop() (err error) { } s.listenerMu.Unlock() + // Closing this signals any TLS governor goroutines to exit + if s.tlsGovChan != nil { + close(s.tlsGovChan) + } + return } @@ -314,6 +333,16 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + // Use URL.RawPath If you need the original, "raw" URL.Path in your middleware. + // Collapse any ./ ../ /// madness here instead of doing that in every plugin. + if r.URL.Path != "/" { + path := filepath.Clean(r.URL.Path) + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + r.URL.Path = path + } + // Execute the optional request callback if it exists and it's not disabled if s.ReqCallback != nil && !s.vhosts[host].config.TLS.Manual && s.ReqCallback(w, r) { return @@ -350,17 +379,19 @@ func DefaultErrorFunc(w http.ResponseWriter, r *http.Request, status int) { // setupClientAuth sets up TLS client authentication only if // any of the TLS configs specified at least one cert file. func setupClientAuth(tlsConfigs []TLSConfig, config *tls.Config) error { - var clientAuth bool + whatClientAuth := tls.NoClientCert for _, cfg := range tlsConfigs { - if len(cfg.ClientCerts) > 0 { - clientAuth = true - break + if whatClientAuth < cfg.ClientAuth { // Use the most restrictive. + whatClientAuth = cfg.ClientAuth } } - if clientAuth { + if whatClientAuth != tls.NoClientCert { pool := x509.NewCertPool() for _, cfg := range tlsConfigs { + if len(cfg.ClientCerts) == 0 { + continue + } for _, caFile := range cfg.ClientCerts { caCrt, err := ioutil.ReadFile(caFile) // Anyone that gets a cert from this CA can connect if err != nil { @@ -372,12 +403,73 @@ func setupClientAuth(tlsConfigs []TLSConfig, config *tls.Config) error { } } config.ClientCAs = pool - config.ClientAuth = tls.RequireAndVerifyClientCert + config.ClientAuth = whatClientAuth } return nil } +var runTLSTicketKeyRotation = standaloneTLSTicketKeyRotation + +var setSessionTicketKeysTestHook = func(keys [][32]byte) [][32]byte { + return keys +} + +// standaloneTLSTicketKeyRotation governs over the array of TLS ticket keys used to de/crypt TLS tickets. +// It periodically sets a new ticket key as the first one, used to encrypt (and decrypt), +// pushing any old ticket keys to the back, where they are considered for decryption only. +// +// Lack of entropy for the very first ticket key results in the feature being disabled (as does Go), +// later lack of entropy temporarily disables ticket key rotation. +// Old ticket keys are still phased out, though. +// +// Stops the timer when returning. +func standaloneTLSTicketKeyRotation(c *tls.Config, timer *time.Ticker, exitChan chan struct{}) { + defer timer.Stop() + // The entire page should be marked as sticky, but Go cannot do that + // without resorting to syscall#Mlock. And, we don't have madvise (for NODUMP), too. ☹ + keys := make([][32]byte, 1, tlsNumTickets) + + rng := c.Rand + if rng == nil { + rng = rand.Reader + } + if _, err := io.ReadFull(rng, keys[0][:]); err != nil { + c.SessionTicketsDisabled = true // bail if we don't have the entropy for the first one + return + } + c.SetSessionTicketKeys(setSessionTicketKeysTestHook(keys)) + + for { + select { + case _, isOpen := <-exitChan: + if !isOpen { + return + } + case <-timer.C: + rng = c.Rand // could've changed since the start + if rng == nil { + rng = rand.Reader + } + var newTicketKey [32]byte + _, err := io.ReadFull(rng, newTicketKey[:]) + + if len(keys) < tlsNumTickets { + keys = append(keys, keys[0]) // manipulates the internal length + } + for idx := len(keys) - 1; idx >= 1; idx-- { + keys[idx] = keys[idx-1] // yes, this makes copies + } + + if err == nil { + keys[0] = newTicketKey + } + // pushes the last key out, doesn't matter that we don't have a new one + c.SetSessionTicketKeys(setSessionTicketKeysTestHook(keys)) + } + } +} + // RunFirstStartupFuncs runs all of the server's FirstStartup // callback functions unless one of them returns an error first. // It is the caller's responsibility to call this only once and diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 00000000..08f1915b --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,60 @@ +package server + +import ( + "crypto/tls" + "testing" + "time" +) + +func TestStandaloneTLSTicketKeyRotation(t *testing.T) { + tlsGovChan := make(chan struct{}) + defer close(tlsGovChan) + callSync := make(chan bool, 1) + defer close(callSync) + + oldHook := setSessionTicketKeysTestHook + defer func() { + setSessionTicketKeysTestHook = oldHook + }() + var keysInUse [][32]byte + setSessionTicketKeysTestHook = func(keys [][32]byte) [][32]byte { + keysInUse = keys + callSync <- true + return keys + } + + c := new(tls.Config) + timer := time.NewTicker(time.Millisecond * 1) + + go standaloneTLSTicketKeyRotation(c, timer, tlsGovChan) + + rounds := 0 + var lastTicketKey [32]byte + for { + select { + case <-callSync: + if lastTicketKey == keysInUse[0] { + close(tlsGovChan) + t.Errorf("The same TLS ticket key has been used again (not rotated): %x.", lastTicketKey) + return + } + lastTicketKey = keysInUse[0] + rounds++ + if rounds <= tlsNumTickets && len(keysInUse) != rounds { + close(tlsGovChan) + t.Errorf("Expected TLS ticket keys in use: %d; Got instead: %d.", rounds, len(keysInUse)) + return + } + if c.SessionTicketsDisabled == true { + t.Error("Session tickets have been disabled unexpectedly.") + return + } + if rounds >= tlsNumTickets+1 { + return + } + case <-time.After(time.Second * 1): + t.Errorf("Timeout after %d rounds.", rounds) + return + } + } +}